Merge pull request #396 from stillst/forms-control-value-accessor

feat: challenge 40 - forms control value accessor
This commit is contained in:
Laforge Thomas
2023-12-18 11:57:15 +01:00
committed by GitHub
27 changed files with 515 additions and 5 deletions

View File

@@ -24,7 +24,7 @@ If you would like to propose a challenge, this project is open source, so feel f
## Challenges
Check [all 40 challenges](https://angular-challenges.vercel.app/)
Check [all 41 challenges](https://angular-challenges.vercel.app/)
## Contributors ✨

View File

@@ -0,0 +1,36 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts"],
"extends": [
"plugin:@nx/angular",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
]
}
},
{
"files": ["*.html"],
"extends": ["plugin:@nx/angular-template"],
"rules": {}
}
]
}

View File

@@ -0,0 +1,13 @@
# Control Value Accessor
> author: stanislav-gavrilov
### Run Application
```bash
npx nx serve forms-control-value-accessor
```
### Documentation and Instruction
Challenge documentation is [here](https://angular-challenges.vercel.app/challenges/forms/41-control-value-accessor/).

View File

@@ -0,0 +1,22 @@
/* eslint-disable */
export default {
displayName: 'forms-control-value-accessor',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/apps/forms/control-value-accessor',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -0,0 +1,86 @@
{
"name": "forms-control-value-accessor",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/forms/control-value-accessor/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular-devkit/build-angular:application",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/forms/control-value-accessor",
"index": "apps/forms/control-value-accessor/src/index.html",
"browser": "apps/forms/control-value-accessor/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "apps/forms/control-value-accessor/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"apps/forms/control-value-accessor/src/favicon.ico",
"apps/forms/control-value-accessor/src/assets"
],
"styles": ["apps/forms/control-value-accessor/src/styles.scss"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"executor": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "forms-control-value-accessor:build:production"
},
"development": {
"buildTarget": "forms-control-value-accessor:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "forms-control-value-accessor:build"
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": [
"apps/forms/control-value-accessor/**/*.ts",
"apps/forms/control-value-accessor/**/*.html"
]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/forms/control-value-accessor/jest.config.ts"
}
}
}
}

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { FeedbackFormComponent } from './feedback-form/feedback-form.component';
@Component({
standalone: true,
imports: [FeedbackFormComponent],
selector: 'app-root',
template: `<app-feedback-form
(feedBackSubmit)="apiCall($event)"></app-feedback-form>`,
})
export class AppComponent {
apiCall(event: Record<string, string | null>): void {
console.log(event);
}
}

View File

@@ -0,0 +1,29 @@
<form
[formGroup]="feedbackForm"
class="feedback-form"
(ngSubmit)="submitForm()">
<legend class="feedback-form-title">Tell us what you think</legend>
<input
class="feedback-form-control"
[formControl]="feedbackForm.controls.name"
placeholder="Name"
type="text" />
<input
class="feedback-form-control"
[formControl]="feedbackForm.controls.email"
placeholder="Email"
type="email" />
<app-rating-control (ratingUpdated)="rating = $event"> </app-rating-control>
<textarea
class="feedback-form-control"
[formControl]="feedbackForm.controls.comment"
placeholder="Сomment text"></textarea>
<button
class="feedback-form-submit"
type="submit"
[disabled]="
!feedbackForm.touched || rating === null || feedbackForm.invalid
">
Submit
</button>
</form>

View File

@@ -0,0 +1,50 @@
* {
box-sizing: border-box;
}
:host {
display: block;
padding: 20px;
}
.feedback-form {
display: flex;
flex-direction: column;
width: 500px;
padding: 20px;
border: 1px solid #000000;
}
.feedback-form-title {
margin-bottom: 20px;
font-size: 24px;
}
.feedback-form-control {
max-height: 200px;
margin-bottom: 20px;
padding: 12px 12px 12px 20px;
border-radius: 0;
background-color: #fbfbfb;
color: #3c3c3c;
font-size: 18px;
&:focus {
padding: 10px 10px 10px 18px;
border: 2px solid #054ada;
outline: none;
background: #fff;
}
}
.feedback-form-submit {
padding: 10px;
background-color: #054ada;
color: #ffffff;
font-size: 18px;
&[disabled] {
background-color: #cccccc;
color: #ffffff;
}
}

View File

@@ -0,0 +1,42 @@
import { EventEmitter } from '@angular/core';
import { Output } from '@angular/core';
import { Component } from '@angular/core';
import { Validators } from '@angular/forms';
import { FormControl } from '@angular/forms';
import { FormGroup } from '@angular/forms';
import { ReactiveFormsModule } from '@angular/forms';
import { RatingControlComponent } from '../rating-control/rating-control.component';
@Component({
standalone: true,
imports: [RatingControlComponent, ReactiveFormsModule],
selector: 'app-feedback-form',
templateUrl: 'feedback-form.component.html',
styleUrls: ['feedback-form.component.scss'],
})
export class FeedbackFormComponent {
@Output()
readonly feedBackSubmit: EventEmitter<Record<string, string | null>> =
new EventEmitter<Record<string, string | null>>();
readonly feedbackForm = new FormGroup({
name: new FormControl('', {
validators: Validators.required,
}),
email: new FormControl('', {
validators: Validators.required,
}),
comment: new FormControl(),
});
rating: string | null = null;
submitForm(): void {
this.feedBackSubmit.emit({
...this.feedbackForm.value,
rating: this.rating,
});
this.feedbackForm.reset();
}
}

View File

@@ -0,0 +1,16 @@
<svg width="0" height="0" xmlns="http://www.w3.org/2000/svg">
<symbol id="star" width="50" height="50" xmlns="http://www.w3.org/2000/svg">
<path
d="M31.77 11.857H19.74L15.99.5l-3.782 11.357H0l9.885 6.903-3.692 11.21 9.736-7.05 9.796 6.962-3.722-11.18 9.766-6.845z" />
</symbol>
</svg>
<div class="rating">
@for (item of [].constructor(5); track item) {
<svg
class="star"
[class.star-active]="isStarActive($index, value)"
(click)="setRating($index)">
<use xlink:href="#star"></use>
</svg>
}
</div>

After

Width:  |  Height:  |  Size: 547 B

View File

@@ -0,0 +1,26 @@
.rating {
display: flex;
justify-content: center;
padding: 0 10px;
&:hover {
.star {
fill: #ffd055;
}
}
}
.star {
width: 50px;
height: 50px;
fill: #cccccc;
cursor: pointer;
&:hover ~ .star {
fill: #d8d8d8;
}
}
.star-active {
fill: #ffd055 !important;
}

View File

@@ -0,0 +1,25 @@
import { EventEmitter } from '@angular/core';
import { Output } from '@angular/core';
import { Component } from '@angular/core';
@Component({
standalone: true,
selector: 'app-rating-control',
templateUrl: 'rating-control.component.html',
styleUrls: ['rating-control.component.scss'],
})
export class RatingControlComponent {
@Output()
readonly ratingUpdated: EventEmitter<string> = new EventEmitter<string>();
value: number | null = null;
setRating(index: number): void {
this.value = index + 1;
this.ratingUpdated.emit(`${this.value}`);
}
isStarActive(index: number, value: number | null): boolean {
return value ? index < value : false;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>forms-control-value-accessor</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,4 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent).catch((err) => console.error(err));

View File

@@ -0,0 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* You can add global styles to this file, and also import other style files */

View File

@@ -0,0 +1,2 @@
import '@testing-library/jest-dom';
import 'jest-preset-angular/setup-jest';

View File

@@ -0,0 +1,14 @@
const { createGlobPatternsForDependencies } = require('@nx/angular/tailwind');
const { join } = require('path');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
join(__dirname, 'src/**/!(*.stories|*.spec).{ts,html}'),
...createGlobPatternsForDependencies(__dirname),
],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"],
"exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts"],
"compilerOptions": {
"types": ["jest", "node"]
}
}

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "es2022",
"useDefineForClassFields": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
},
{
"path": "./tsconfig.editor.json"
}
],
"extends": "../../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node", "@testing-library/jest-dom"]
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -1,5 +1,5 @@
{
"total": 40,
"total": 41,
"🟢": 14,
"🟠": 119,
"🔴": 207

View File

@@ -0,0 +1,5 @@
{
"name": "Stanislav Gavrilov",
"linkedin": "https://www.linkedin.com/in/stgavrilov/",
"github": "https://github.com/stillst"
}

View File

@@ -0,0 +1,42 @@
---
title: 🟠 Control Value Accessor
description: Challenge 41 is about creating a custom form control that implements Control Value Accessor interface.
author: stanislav-gavrilov
challengeNumber: 41
command: forms-control-value-accessor
sidebar:
order: 119
badge: New
---
## Information
In this challenge, the goal is to create a custom form field that is using the Form API of Angular `ControlValueAccessor`. You can find the documentation [here](https://angular.io/api/forms/ControlValueAccessor). This interface is crucial for creating custom form controls that can interact seamlessly with Angular's forms API.
## Statement
The primary goal is to use control in the `feedbackForm` to eliminate the need for using `@Output` to retrieve the value and inject it into the `FormGroup`.
Additionally, you are required to integrate validation for the new control to ensure that rating data exist. (The form submission button should be disabled if the form is invalid).
Currently, rating is coded this way:
```html
<app-rating-control (ratingUpdated)="rating = $event"> </app-rating-control>
```
```ts
rating: string | null = null;
onFormSubmit(): void {
this.feedBackSubmit.emit({
...this.feedbackForm.value,
rating: this.rating, // not inside the FormGroup and no validation
});
}
```
The goal is to include rating into the `FormGroup`
```html
<app-rating-control [formControl]="feedbackForm.controls.rating"> </app-rating-control>
```

View File

@@ -13,7 +13,7 @@ hero:
icon: right-arrow
variant: primary
- text: Go to the latest Challenge
link: /challenges/performance/40-christmas-web-worker/
link: /challenges/forms/41-control-value-accessor/
icon: rocket
- text: Give a star
link: https://github.com/tomalaforge/angular-challenges
@@ -25,8 +25,8 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
import MyIcon from '../../components/MyIcon.astro';
<CardGrid>
<Card title="40 Challenges">
This repository gathers 40 Challenges related to <b>Angular</b>, <b>Nx</b>, <b>RxJS</b>, <b>Ngrx</b> and <b>Typescript</b>.
<Card title="41 Challenges">
This repository gathers 41 Challenges related to <b>Angular</b>, <b>Nx</b>, <b>RxJS</b>, <b>Ngrx</b> and <b>Typescript</b>.
These challenges resolve around real-life issues or specific features to elevate your skills.
</Card>