From bb4c7ec6d0463e351cbce988ac01b176fc3b4fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=A2n?= Date: Sun, 26 Jan 2025 01:42:18 +0700 Subject: [PATCH] #98: Form error message directive --- .../modules/core/services/theme.service.ts | 53 ++-- .../components/toolbox/toolbox.component.html | 94 ++++++ .../create-user/create-user.component.html | 288 ++++++------------ .../create-user/create-user.component.ts | 39 ++- .../form/abstract-form-component.ts | 8 - .../form-field-error.component.css | 0 .../form-field-error.component.html | 1 + .../form-field-error.component.ts | 8 + .../directives/error-messages.directive.ts | 88 ++++++ .../directives/form-field-error.directive.ts | 67 ++++ .../src/app/modules/shared/shared.module.ts | 13 +- sep490-frontend/tailwind.config.js | 18 +- 12 files changed, 423 insertions(+), 254 deletions(-) create mode 100644 sep490-frontend/src/app/modules/shared/components/form/form-field-error/form-field-error.component.css create mode 100644 sep490-frontend/src/app/modules/shared/components/form/form-field-error/form-field-error.component.html create mode 100644 sep490-frontend/src/app/modules/shared/components/form/form-field-error/form-field-error.component.ts create mode 100644 sep490-frontend/src/app/modules/shared/directives/error-messages.directive.ts create mode 100644 sep490-frontend/src/app/modules/shared/directives/form-field-error.directive.ts diff --git a/sep490-frontend/src/app/modules/core/services/theme.service.ts b/sep490-frontend/src/app/modules/core/services/theme.service.ts index 2eae2785..cc85bda7 100644 --- a/sep490-frontend/src/app/modules/core/services/theme.service.ts +++ b/sep490-frontend/src/app/modules/core/services/theme.service.ts @@ -1,10 +1,25 @@ import {Injectable} from '@angular/core'; import {definePreset} from '@primeng/themes'; -import Material from '@primeng/themes/material'; +import Aura from '@primeng/themes/aura'; import {PrimeNG, ThemeType} from 'primeng/config'; import {BehaviorSubject, Observable, of} from 'rxjs'; -const MyPreset = definePreset(Material, { +const MyPreset = definePreset(Aura, { + primitive: { + red: { + 50: '#fef8f8', + 100: '#fbdfdf', + 200: '#f8c5c6', + 300: '#f5acad', + 400: '#f29293', + 500: '#ef797a', + 600: '#cb6768', + 700: '#a75555', + 800: '#834343', + 900: '#603031', + 950: '#3c1e1f' + } + }, semantic: { primary: { 50: '#f4fcfd', @@ -18,40 +33,6 @@ const MyPreset = definePreset(Material, { 800: '#107076', 900: '#0c5156', 950: '#073336' - }, - colorScheme: { - light: { - surface: { - 0: '#ffffff', - 50: '{neutral.50}', - 100: '{neutral.100}', - 200: '{neutral.200}', - 300: '{neutral.300}', - 400: '{neutral.400}', - 500: '{neutral.500}', - 600: '{neutral.600}', - 700: '{neutral.700}', - 800: '{neutral.800}', - 900: '{neutral.900}', - 950: '{neutral.950}' - } - }, - dark: { - surface: { - 0: '#ffffff', - 50: '{neutral.50}', - 100: '{neutral.100}', - 200: '{neutral.200}', - 300: '{neutral.300}', - 400: '{neutral.400}', - 500: '{neutral.500}', - 600: '{neutral.600}', - 700: '{neutral.700}', - 800: '{neutral.800}', - 900: '{neutral.900}', - 950: '{neutral.950}' - } - } } } }); diff --git a/sep490-frontend/src/app/modules/dev/components/toolbox/toolbox.component.html b/sep490-frontend/src/app/modules/dev/components/toolbox/toolbox.component.html index 491cc4ee..f40291d4 100644 --- a/sep490-frontend/src/app/modules/dev/components/toolbox/toolbox.component.html +++ b/sep490-frontend/src/app/modules/dev/components/toolbox/toolbox.component.html @@ -35,3 +35,97 @@ + +
+ + + + + + + + +
+ +
+ + + + + + + + +
+ +
+ + + + + + + + +
+ +
+ + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + +
diff --git a/sep490-frontend/src/app/modules/enterprise/components/create-user/create-user.component.html b/sep490-frontend/src/app/modules/enterprise/components/create-user/create-user.component.html index fc2a3c9d..dcc9e51e 100644 --- a/sep490-frontend/src/app/modules/enterprise/components/create-user/create-user.component.html +++ b/sep490-frontend/src/app/modules/enterprise/components/create-user/create-user.component.html @@ -8,113 +8,62 @@

-
+
- -
-
- {{ "validation.required" | translate }} -
-
- {{ "validation.email" | translate }} -
-
- {{ "validation.business.email.exist" | translate }} -
+
+ +
-
+
- -
-
- {{ "validation.required" | translate }} -
+
+ +
-
+
- -
-
- {{ "validation.required" | translate }} -
+
+ +
@@ -122,45 +71,34 @@

{{ "enterprise.Users.role" | translate }}

-
+
- - -
{{ selectedOption.label | translate }}
-
{{ item.label | translate }}
-
-
-
+ - {{ "validation.required" | translate }} -
+ +
+ {{ selectedOption.label | translate }} +
+
{{ item.label | translate }}
+ +
@@ -171,83 +109,51 @@

- - -
{{ selectedOption.label | translate }}
-
{{ item.label | translate }}
+ - - +
+ {{ selectedOption.label | translate }} +
+
{{ item.label | translate }}
+
+ +

+
-
-
-
-
- {{ "validation.required" | translate }} -
-
-
-
- {{ "validation.required" | translate }} -
-
- {{ "validation.business.buildings.invalid" | translate }} -
+ lazy="true" + formControlName="buildings" + > +
-
diff --git a/sep490-frontend/src/app/modules/enterprise/components/create-user/create-user.component.ts b/sep490-frontend/src/app/modules/enterprise/components/create-user/create-user.component.ts index 672b4ba6..52eae80b 100644 --- a/sep490-frontend/src/app/modules/enterprise/components/create-user/create-user.component.ts +++ b/sep490-frontend/src/app/modules/enterprise/components/create-user/create-user.component.ts @@ -4,12 +4,13 @@ import { AbstractControl, FormBuilder, FormControl, + ValidationErrors, + ValidatorFn, Validators } from '@angular/forms'; import {Router} from '@angular/router'; import {TranslateService} from '@ngx-translate/core'; import {MessageService} from 'primeng/api'; -import {takeUntil} from 'rxjs'; import {v4 as uuidv4} from 'uuid'; import {AppRoutingConstants} from '../../../../app-routing.constant'; import {AbstractFormComponent} from '../../../shared/components/form/abstract-form-component'; @@ -52,7 +53,10 @@ export class CreateUserComponent extends AbstractFormComponent('', [Validators.required]), permissionRole: new FormControl('', [Validators.required]), scope: new FormControl('', [Validators.required]), - buildings: new FormControl([]) + buildings: new FormControl( + [], + [this.buildingValidator().bind(this)] + ) }; protected scopeOptions: SelectableItem[] = [ @@ -92,20 +96,6 @@ export class CreateUserComponent extends AbstractFormComponent { - if (value === UserScope.BUILDING) { - this.enterpriseUserStructure.buildings.addValidators( - Validators.required - ); - } else { - this.enterpriseUserStructure.buildings.removeValidators( - Validators.required - ); - this.enterpriseUserStructure.buildings.reset(); - } - }); return this.enterpriseUserStructure; } @@ -123,4 +113,21 @@ export class CreateUserComponent extends AbstractFormComponent { + if (!this.enterpriseUserStructure) { + return null; + } + if ( + this.enterpriseUserStructure.scope.value === + UserScope[UserScope.BUILDING] + ) { + if (control.value?.length === 0) { + return {required: true}; + } + } + return null; + }; + } } diff --git a/sep490-frontend/src/app/modules/shared/components/form/abstract-form-component.ts b/sep490-frontend/src/app/modules/shared/components/form/abstract-form-component.ts index 3fcda134..d8bd5207 100644 --- a/sep490-frontend/src/app/modules/shared/components/form/abstract-form-component.ts +++ b/sep490-frontend/src/app/modules/shared/components/form/abstract-form-component.ts @@ -53,14 +53,6 @@ export abstract class AbstractFormComponent this.formControls = this.initializeFormControls(); this.formGroup = this.formBuilder.group(this.formControls); this.initializeData(); - this.updateFormControlsState(this.formGroup, [ - (ctr: AbstractControl): void => - this.registerSubscription( - ctr.valueChanges - .pipe(take(1)) - .subscribe((): void => ctr.markAsTouched()) - ) - ]); } submit(): void { diff --git a/sep490-frontend/src/app/modules/shared/components/form/form-field-error/form-field-error.component.css b/sep490-frontend/src/app/modules/shared/components/form/form-field-error/form-field-error.component.css new file mode 100644 index 00000000..e69de29b diff --git a/sep490-frontend/src/app/modules/shared/components/form/form-field-error/form-field-error.component.html b/sep490-frontend/src/app/modules/shared/components/form/form-field-error/form-field-error.component.html new file mode 100644 index 00000000..ea621cff --- /dev/null +++ b/sep490-frontend/src/app/modules/shared/components/form/form-field-error/form-field-error.component.html @@ -0,0 +1 @@ +
diff --git a/sep490-frontend/src/app/modules/shared/components/form/form-field-error/form-field-error.component.ts b/sep490-frontend/src/app/modules/shared/components/form/form-field-error/form-field-error.component.ts new file mode 100644 index 00000000..c52c3373 --- /dev/null +++ b/sep490-frontend/src/app/modules/shared/components/form/form-field-error/form-field-error.component.ts @@ -0,0 +1,8 @@ +import {Component} from '@angular/core'; + +@Component({ + selector: 'form-field-error', + templateUrl: './form-field-error.component.html', + styleUrl: './form-field-error.component.css' +}) +export class FormFieldErrorComponent {} diff --git a/sep490-frontend/src/app/modules/shared/directives/error-messages.directive.ts b/sep490-frontend/src/app/modules/shared/directives/error-messages.directive.ts new file mode 100644 index 00000000..59e6b6f9 --- /dev/null +++ b/sep490-frontend/src/app/modules/shared/directives/error-messages.directive.ts @@ -0,0 +1,88 @@ +import { + AfterViewInit, + ContentChild, + Directive, + ElementRef, + OnDestroy, + Optional +} from '@angular/core'; +import { + FormGroupDirective, + NgControl, + NgForm, + ValidationErrors +} from '@angular/forms'; +import {Observable, Subject, merge, takeUntil} from 'rxjs'; +import {SubscriptionAwareComponent} from '../../core/subscription-aware.component'; + +@Directive({ + selector: '[errorMessages]' +}) +export class ErrorMessagesDirective + extends SubscriptionAwareComponent + implements OnDestroy, AfterViewInit +{ + @ContentChild(NgControl) ngControl?: NgControl; + readonly errors$: Observable; + private readonly errors = new Subject(); + private readonly form: NgForm | FormGroupDirective; + + constructor( + @Optional() ngForm: NgForm, + @Optional() formGroupDirective: FormGroupDirective, + private readonly el: ElementRef + ) { + super(); + this.errors$ = this.errors.asObservable(); + this.form = ngForm || formGroupDirective; + + if (!this.form) { + throw new Error( + 'The ErrorMessagesDirective needs to be either within a NgForm or a FormGroupDirective!' + ); + } + } + + ngAfterViewInit(): void { + if (this.ngControl) { + this.errors.next(this.ngControl.errors); // because 1st statusChange occurs before ngAfterViewInit + merge(this.formSubmitEvent(), this.controlStatusChanges()) + .pipe(takeUntil(this.destroy$)) + .subscribe((): void => { + this.errors.next(this.ngControl?.errors ?? null); + this.updateDirtyStyle(this.ngControl?.errors); + }); + } else { + console.error('No NgControl found for ErrorMessagesDirective'); + } + } + + formSubmitEvent(): any { + return this.form.ngSubmit; + } + + controlStatusChanges(): any { + return this.ngControl?.statusChanges; + } + + override ngOnDestroy(): void { + this.errors.complete(); + super.ngOnDestroy(); + } + + private updateDirtyStyle(errors: ValidationErrors | null | undefined): void { + const element = this.findFormControlElement(); + if (element) { + if (errors && !(this.ngControl?.untouched && this.ngControl?.pristine)) { + element.classList.add('ng-dirty'); + } else { + element.classList.remove('ng-dirty'); + } + } + } + + private findFormControlElement(): HTMLElement | null { + const nativeElement = this.el.nativeElement; + return nativeElement.querySelector('[formControlName]'); + } +} diff --git a/sep490-frontend/src/app/modules/shared/directives/form-field-error.directive.ts b/sep490-frontend/src/app/modules/shared/directives/form-field-error.directive.ts new file mode 100644 index 00000000..87c297d0 --- /dev/null +++ b/sep490-frontend/src/app/modules/shared/directives/form-field-error.directive.ts @@ -0,0 +1,67 @@ +import { + Directive, + ElementRef, + OnDestroy, + OnInit, + Optional +} from '@angular/core'; +import {ValidationErrors} from '@angular/forms'; +import {TranslateService} from '@ngx-translate/core'; +import {takeUntil, tap} from 'rxjs'; +import {SubscriptionAwareComponent} from '../../core/subscription-aware.component'; +import {TranslateParamsPipe} from '../pipes/translate-params.pipe'; +import {ErrorMessagesDirective} from './error-messages.directive'; + +@Directive({ + selector: '[formFieldError]' +}) +export class FormFieldErrorDirective + extends SubscriptionAwareComponent + implements OnInit, OnDestroy +{ + private readonly pipe: TranslateParamsPipe; + private errors: ValidationErrors | null = null; + + constructor( + @Optional() private readonly control: ErrorMessagesDirective, + private readonly el: ElementRef, + translate: TranslateService + ) { + super(); + this.pipe = new TranslateParamsPipe(translate); + if (this.control) { + translate.onLangChange + .pipe(takeUntil(this.destroy$)) + .subscribe((): void => this.showErrors(this.errors || {})); + } + } + + ngOnInit(): void { + if (this.control) { + this.control.errors$ + .pipe( + tap(errors => (this.errors = errors)), + takeUntil(this.destroy$) + ) + .subscribe(errors => { + if ( + errors && + !( + this.control.ngControl?.untouched && + this.control.ngControl?.pristine + ) + ) { + this.showErrors(errors); + } else { + this.showErrors({}); + } + }); + } + } + + private showErrors(errors: ValidationErrors): void { + this.el.nativeElement.innerText = Object.keys(errors) + .map(key => this.pipe.transform(`validation.${key}`, errors[key])) + .join('\n'); + } +} diff --git a/sep490-frontend/src/app/modules/shared/shared.module.ts b/sep490-frontend/src/app/modules/shared/shared.module.ts index 0bfed3d9..26dbec78 100644 --- a/sep490-frontend/src/app/modules/shared/shared.module.ts +++ b/sep490-frontend/src/app/modules/shared/shared.module.ts @@ -29,8 +29,11 @@ import {ToastModule} from 'primeng/toast'; import {ToggleSwitch} from 'primeng/toggleswitch'; import {ConfirmDialogComponent} from './components/dialog/confirm-dialog/confirm-dialog.component'; import {TableTemplateComponent} from './components/table-template/table-template.component'; +import {ErrorMessagesDirective} from './directives/error-messages.directive'; +import {FormFieldErrorDirective} from './directives/form-field-error.directive'; import {TranslateParamsPipe} from './pipes/translate-params.pipe'; import {ModalProvider} from './services/modal-provider'; +import {FormFieldErrorComponent} from './components/form/form-field-error/form-field-error.component'; const primeNgModules = [ AutoFocusModule, @@ -72,7 +75,10 @@ const commons = [ declarations: [ ConfirmDialogComponent, TranslateParamsPipe, - TableTemplateComponent + TableTemplateComponent, + ErrorMessagesDirective, + FormFieldErrorDirective, + FormFieldErrorComponent ], imports: [...commons, ...primeNgModules], exports: [ @@ -80,7 +86,10 @@ const commons = [ ...primeNgModules, ConfirmDialogComponent, TranslateParamsPipe, - TableTemplateComponent + TableTemplateComponent, + ErrorMessagesDirective, + FormFieldErrorDirective, + FormFieldErrorComponent ], providers: [DatePipe, ModalProvider] }) diff --git a/sep490-frontend/tailwind.config.js b/sep490-frontend/tailwind.config.js index fd33fbe5..a8dc82f0 100644 --- a/sep490-frontend/tailwind.config.js +++ b/sep490-frontend/tailwind.config.js @@ -4,7 +4,23 @@ module.exports = { "./src/**/*.{html,ts}", ], theme: { - extend: {}, + extend: { + colors: { + red: { + 50: '#fef8f8', + 100: '#fbdfdf', + 200: '#f8c5c6', + 300: '#f5acad', + 400: '#f29293', + 500: '#ef797a', + 600: '#cb6768', + 700: '#a75555', + 800: '#834343', + 900: '#603031', + 950: '#3c1e1f' + } + } + }, }, plugins: [require('tailwindcss-primeui')] }