diff --git a/apps/keira/src/app/scss/_editor.scss b/apps/keira/src/app/scss/_editor.scss index a059ffd3a6..e7d6d25879 100644 --- a/apps/keira/src/app/scss/_editor.scss +++ b/apps/keira/src/app/scss/_editor.scss @@ -1,5 +1,9 @@ @import 'variables'; +input.ng-invalid.ng-touched { + border-color: red; +} + .top-bar { background-color: #000; color: $content-block-bg-color; diff --git a/libs/features/creature/src/creature-template/creature-template.component.html b/libs/features/creature/src/creature-template/creature-template.component.html index 44bd7d0869..57efd1e90f 100644 --- a/libs/features/creature/src/creature-template/creature-template.component.html +++ b/libs/features/creature/src/creature-template/creature-template.component.html @@ -44,12 +44,11 @@
- +
- - - + +
@@ -231,7 +230,8 @@ Models are now available in the Creature Template Model editor.Models are now available in the + Creature Template Model editor.
diff --git a/libs/features/creature/src/creature-template/creature-template.component.ts b/libs/features/creature/src/creature-template/creature-template.component.ts index f311c11baf..c01d76ff40 100644 --- a/libs/features/creature/src/creature-template/creature-template.component.ts +++ b/libs/features/creature/src/creature-template/creature-template.component.ts @@ -25,7 +25,6 @@ import { } from '@keira/shared/acore-world-model'; import { SingleRowEditorComponent } from '@keira/shared/base-abstract-classes'; import { QueryOutputComponent, TopBarComponent } from '@keira/shared/base-editor-components'; -import { Model3DViewerComponent } from '@keira/shared/model-3d-viewer'; import { BooleanOptionSelectorComponent, CreatureSelectorBtnComponent, @@ -41,6 +40,8 @@ import { TooltipModule } from 'ngx-bootstrap/tooltip'; import { CreatureHandlerService } from '../creature-handler.service'; import { CreatureTemplateService } from './creature-template.service'; import { RouterLink } from '@angular/router'; +import { InputValidationDirective } from '@keira/shared/directives'; +import { ValidationService } from '@keira/shared/common-services'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -59,12 +60,13 @@ import { RouterLink } from '@angular/router'; FlagsSelectorBtnComponent, SpellSelectorBtnComponent, CreatureSelectorBtnComponent, - Model3DViewerComponent, GenericOptionSelectorComponent, BooleanOptionSelectorComponent, IconSelectorComponent, RouterLink, + InputValidationDirective, ], + providers: [ValidationService], }) export class CreatureTemplateComponent extends SingleRowEditorComponent { protected readonly UNIT_FLAGS = UNIT_FLAGS; diff --git a/libs/features/quest/src/quest-preview/quest-preview.service.spec.ts b/libs/features/quest/src/quest-preview/quest-preview.service.spec.ts index 0f7581d301..9dc93a8f2a 100644 --- a/libs/features/quest/src/quest-preview/quest-preview.service.spec.ts +++ b/libs/features/quest/src/quest-preview/quest-preview.service.spec.ts @@ -169,7 +169,7 @@ describe('QuestPreviewService', () => { expect(mysqlQueryService.getItemNameByStartQuest).toHaveBeenCalledTimes(1); expect(mysqlQueryService.getItemNameByStartQuest).toHaveBeenCalledWith(mockID); expect(mysqlQueryService.getReputationRewardByFaction).toHaveBeenCalledTimes(1); - expect(mysqlQueryService.getReputationRewardByFaction).toHaveBeenCalledWith(null as any); + expect(mysqlQueryService.getReputationRewardByFaction).toHaveBeenCalledWith(0); }); it('sqliteQuery', async () => { diff --git a/libs/shared/base-abstract-classes/src/service/editors/editor.service.spec.ts b/libs/shared/base-abstract-classes/src/service/editors/editor.service.spec.ts index 803fd714a2..db7b20482b 100644 --- a/libs/shared/base-abstract-classes/src/service/editors/editor.service.spec.ts +++ b/libs/shared/base-abstract-classes/src/service/editors/editor.service.spec.ts @@ -1,13 +1,10 @@ import { TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; - import { QueryError } from 'mysql2'; import { ToastrService } from 'ngx-toastr'; import { of, throwError } from 'rxjs'; import { instance, mock } from 'ts-mockito'; import { MysqlQueryService } from '@keira/shared/db-layer'; import { EditorService } from './editor.service'; - import { mockChangeDetectorRef } from '@keira/shared/test-utils'; import { MockEntity, MockSingleRowEditorService } from '../../core.mock'; import Spy = jasmine.Spy; @@ -18,7 +15,6 @@ describe('EditorService', () => { beforeEach(() => TestBed.configureTestingModule({ - imports: [RouterTestingModule], providers: [ { provide: MysqlQueryService, useValue: instance(mock(MysqlQueryService)) }, { provide: ToastrService, useValue: instance(mock(ToastrService)) }, diff --git a/libs/shared/base-abstract-classes/src/service/editors/editor.service.ts b/libs/shared/base-abstract-classes/src/service/editors/editor.service.ts index 1ce8a18cf9..6ce1da278c 100644 --- a/libs/shared/base-abstract-classes/src/service/editors/editor.service.ts +++ b/libs/shared/base-abstract-classes/src/service/editors/editor.service.ts @@ -1,7 +1,7 @@ import { QueryError } from 'mysql2'; import { ToastrService } from 'ngx-toastr'; import { Observable } from 'rxjs'; -import { FormControl, FormGroup } from '@angular/forms'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; import { Class, StringKeys, TableRow } from '@keira/shared/constants'; import { MysqlQueryService } from '@keira/shared/db-layer'; @@ -82,10 +82,14 @@ export abstract class EditorService extends SubscriptionHand } protected initForm(): void { + const defaultValues = new this._entityClass(); + this._form = new FormGroup>({} as any); + // Loop through the fields and initialize controls with default values for (const field of this.fields) { - this._form.addControl(field, new FormControl()); + const defaultValue = defaultValues[field]; + this._form.addControl(field, new FormControl(defaultValue, [Validators.required])); } this.disableEntityIdField(); diff --git a/libs/shared/base-abstract-classes/src/service/editors/single-row-editor.service.spec.ts b/libs/shared/base-abstract-classes/src/service/editors/single-row-editor.service.spec.ts index 83b6098d79..4ba9ac30af 100644 --- a/libs/shared/base-abstract-classes/src/service/editors/single-row-editor.service.spec.ts +++ b/libs/shared/base-abstract-classes/src/service/editors/single-row-editor.service.spec.ts @@ -1,6 +1,4 @@ import { TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; - import { ToastrService } from 'ngx-toastr'; import { instance, mock } from 'ts-mockito'; import { MysqlQueryService } from '@keira/shared/db-layer'; @@ -14,7 +12,6 @@ describe('SingleRowEditorService', () => { beforeEach(() => TestBed.configureTestingModule({ - imports: [RouterTestingModule], providers: [ { provide: MysqlQueryService, useValue: instance(mock(MysqlQueryService)) }, { provide: ToastrService, useValue: instance(mock(ToastrService)) }, @@ -162,4 +159,64 @@ describe('SingleRowEditorService', () => { expect(updateFullQuerySpy).toHaveBeenCalledTimes(1); }); }); + + describe('updateFormAfterReload()', () => { + let consoleWarnSpy: Spy; + let mockForm: any; + + beforeEach(() => { + // Mock the form and its controls + mockForm = { + controls: { + id: { setValue: jasmine.createSpy('setValue') }, + name: { setValue: jasmine.createSpy('setValue') }, + guid: { setValue: jasmine.createSpy('setValue') }, // Add guid control + }, + }; + service['_form'] = mockForm; + + // Mock originalValue + service['_originalValue'] = { id: 123, name: 'Test Name', guid: 456 }; // Add guid value + + // Spy on console.warn + consoleWarnSpy = spyOn(console, 'warn'); + + // Temporarily override `fields` for testing + Object.defineProperty(service, 'fields', { + value: ['id', 'name', 'guid', 123 as any, null as any], + writable: true, + }); + }); + + it('should set values for valid fields in the form', () => { + service['updateFormAfterReload'](); + + expect(mockForm.controls.id.setValue).toHaveBeenCalledWith(123); + expect(mockForm.controls.name.setValue).toHaveBeenCalledWith('Test Name'); + expect(mockForm.controls.guid.setValue).toHaveBeenCalledWith(456); + }); + + it('should log a warning for invalid field types', () => { + service['updateFormAfterReload'](); + + expect(consoleWarnSpy).toHaveBeenCalledWith("Field '123' is not a valid string key."); + expect(consoleWarnSpy).toHaveBeenCalledWith("Field 'null' is not a valid string key."); + }); + + it('should not throw errors for valid but empty fields', () => { + Object.defineProperty(service, 'fields', { + value: [], // No fields to iterate + }); + + expect(() => service['updateFormAfterReload']()).not.toThrow(); + }); + + it('should reset loading to false after execution', () => { + service['_loading'] = true; + + service['updateFormAfterReload'](); + + expect(service['_loading']).toBe(false); // Ensure loading is reset + }); + }); }); diff --git a/libs/shared/base-abstract-classes/src/service/editors/single-row-editor.service.ts b/libs/shared/base-abstract-classes/src/service/editors/single-row-editor.service.ts index d9375a82dd..d74d73ea03 100644 --- a/libs/shared/base-abstract-classes/src/service/editors/single-row-editor.service.ts +++ b/libs/shared/base-abstract-classes/src/service/editors/single-row-editor.service.ts @@ -20,7 +20,7 @@ export abstract class SingleRowEditorService extends EditorS this.initForm(); } - protected override initForm() { + protected override initForm(): void { super.initForm(); this.subscriptions.push( @@ -57,7 +57,7 @@ export abstract class SingleRowEditorService extends EditorS /* * ****** onReloadSuccessful() and its helpers ****** */ - protected onLoadedExistingEntity(entity: T) { + protected onLoadedExistingEntity(entity: T): void { this._originalValue = entity; this._isNew = false; @@ -71,7 +71,7 @@ export abstract class SingleRowEditorService extends EditorS } } - protected onCreatingNewEntity(id: string | number) { + protected onCreatingNewEntity(id: string | number): void { this._originalValue = new this._entityClass(); // TODO: get rid of this type hack, see: https://github.com/microsoft/TypeScript/issues/32704 @@ -80,29 +80,31 @@ export abstract class SingleRowEditorService extends EditorS this._isNew = true; } - protected setLoadedEntity() { + protected setLoadedEntity(): void { this._loadedEntityId = this._originalValue[this._entityIdField]; } - protected updateFormAfterReload() { + protected updateFormAfterReload(): void { this._loading = true; + for (const field of this.fields) { - const control = this._form.controls[field]; - /* istanbul ignore else */ - if (control) { - control.setValue(this._originalValue[field]); - } else { - console.error(`Control '${field}' does not exist!`); - console.log(`----------- DEBUG CONTROL KEYS:`); - for (const k of Object.keys(this._form.controls)) { - console.log(k); + // Ensure `field` is of type `string` + if (typeof field === 'string') { + const control = this._form.controls[field]; + + if (control) { + const value = this._originalValue[field as keyof T]; // Ensure type safety here + control.setValue(value as T[typeof field]); } + } else { + console.warn(`Field '${String(field)}' is not a valid string key.`); } } + this._loading = false; } - protected onReloadSuccessful(data: T[], id: string | number) { + protected onReloadSuccessful(data: T[], id: string | number): void { if (data.length > 0) { // we are loading an existing entity this.onLoadedExistingEntity(data[0]); @@ -114,5 +116,6 @@ export abstract class SingleRowEditorService extends EditorS this.setLoadedEntity(); this.updateFullQuery(); } + /* ****** */ } diff --git a/libs/shared/base-editor-components/src/query-output/query-output.component.html b/libs/shared/base-editor-components/src/query-output/query-output.component.html index 60cc672070..dfeb627dba 100644 --- a/libs/shared/base-editor-components/src/query-output/query-output.component.html +++ b/libs/shared/base-editor-components/src/query-output/query-output.component.html @@ -24,16 +24,40 @@
- - - -
diff --git a/libs/shared/base-editor-components/src/query-output/query-output.component.ts b/libs/shared/base-editor-components/src/query-output/query-output.component.ts index c3261fdd6a..0f9302bea0 100644 --- a/libs/shared/base-editor-components/src/query-output/query-output.component.ts +++ b/libs/shared/base-editor-components/src/query-output/query-output.component.ts @@ -1,5 +1,4 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, Input, Output } from '@angular/core'; - import { FormsModule } from '@angular/forms'; import { EditorService } from '@keira/shared/base-abstract-classes'; import { TableRow } from '@keira/shared/constants'; @@ -11,6 +10,8 @@ import { filter } from 'rxjs'; import { HighlightjsWrapperComponent } from '../highlightjs-wrapper/highlightjs-wrapper.component'; import { ModalConfirmComponent } from '../modal-confirm/modal-confirm.component'; import { QueryErrorComponent } from './query-error/query-error.component'; +import { ValidationService } from '@keira/shared/common-services'; +import { AsyncPipe } from '@angular/common'; @Component({ // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -19,18 +20,18 @@ import { QueryErrorComponent } from './query-error/query-error.component'; templateUrl: './query-output.component.html', styleUrls: ['./query-output.component.scss'], standalone: true, - imports: [FormsModule, HighlightjsWrapperComponent, QueryErrorComponent, TranslateModule], + imports: [FormsModule, HighlightjsWrapperComponent, QueryErrorComponent, TranslateModule, AsyncPipe], }) export class QueryOutputComponent extends SubscriptionHandler { private readonly clipboardService = inject(ClipboardService); private readonly modalService = inject(BsModalService); + protected readonly validationService = inject(ValidationService); @Input() docUrl!: string; @Input() editorService!: EditorService; @Output() executeQuery = new EventEmitter(); selectedQuery: 'diff' | 'full' = 'diff'; private modalRef!: BsModalRef; - private readonly changeDetectorRef = inject(ChangeDetectorRef); showFullQuery(): boolean { diff --git a/libs/shared/common-services/src/index.ts b/libs/shared/common-services/src/index.ts index 51bf180fd3..6c883567d3 100644 --- a/libs/shared/common-services/src/index.ts +++ b/libs/shared/common-services/src/index.ts @@ -1,3 +1,4 @@ export * from './electron.service'; export * from './config.service'; export * from './location.service'; +export * from './validation.service'; diff --git a/libs/shared/common-services/src/validation.service.spec.ts b/libs/shared/common-services/src/validation.service.spec.ts new file mode 100644 index 0000000000..5da3d7adb7 --- /dev/null +++ b/libs/shared/common-services/src/validation.service.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { ValidationService } from './validation.service'; +import { take } from 'rxjs/operators'; + +describe('ValidationService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + providers: [ValidationService], + }), + ); + + function setup(): { service: ValidationService } { + const service = TestBed.inject(ValidationService); + return { service }; + } + + it('should be created', () => { + const { service } = setup(); + expect(service).toBeTruthy(); + }); + + it('should have a default value of true for validationPassed$', (done: DoneFn) => { + const { service } = setup(); + service.validationPassed$.pipe(take(1)).subscribe((value) => { + expect(value).toBe(true); + done(); + }); + }); + + it('should set control validity and update validationPassed$', (done: DoneFn) => { + const { service } = setup(); + service.setControlValidity('control1', false); + + service.validationPassed$.pipe(take(1)).subscribe((value) => { + expect(value).toBe(false); // Validation should be false + + // Update control to valid + service.setControlValidity('control1', true); + + service.validationPassed$.pipe(take(1)).subscribe((newValue) => { + expect(newValue).toBe(true); // Validation should be true + done(); + }); + }); + }); + + it('should handle removing non-existing controls gracefully', (done: DoneFn) => { + const { service } = setup(); + service.removeControl('nonExistentControl'); + + service.validationPassed$.pipe(take(1)).subscribe((value) => { + expect(value).toBe(true); // No change in validation state + done(); + }); + }); + + it('should correctly update validation state with multiple controls', (done: DoneFn) => { + const { service } = setup(); + service.setControlValidity('control1', true); + service.setControlValidity('control2', true); + service.setControlValidity('control3', false); + + service.validationPassed$.pipe(take(1)).subscribe((value) => { + expect(value).toBe(false); // validation should be false because control3 is invalid + + // Update control3 to valid + service.setControlValidity('control3', true); + + service.validationPassed$.pipe(take(1)).subscribe((newValue) => { + expect(newValue).toBe(true); // validation should now be true + done(); + }); + }); + }); +}); diff --git a/libs/shared/common-services/src/validation.service.ts b/libs/shared/common-services/src/validation.service.ts new file mode 100644 index 0000000000..d8fd48f347 --- /dev/null +++ b/libs/shared/common-services/src/validation.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class ValidationService { + private readonly controlsValidityMap = new Map(); + readonly validationPassed$: BehaviorSubject = new BehaviorSubject(true); + + setControlValidity(control: any, isValid: boolean): void { + this.controlsValidityMap.set(control, isValid); + this.updateValidationState(); + } + + removeControl(control: any): void { + this.controlsValidityMap.delete(control); + this.updateValidationState(); + } + + private updateValidationState(): void { + const allValid = Array.from(this.controlsValidityMap.values()).every((isValid) => isValid); + this.validationPassed$.next(allValid); + } +} diff --git a/libs/shared/directives/.eslintrc.json b/libs/shared/directives/.eslintrc.json new file mode 100644 index 0000000000..a20cf65c22 --- /dev/null +++ b/libs/shared/directives/.eslintrc.json @@ -0,0 +1,33 @@ +{ + "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": "keira", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "keira", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/shared/directives/README.md b/libs/shared/directives/README.md new file mode 100644 index 0000000000..94a2767b15 --- /dev/null +++ b/libs/shared/directives/README.md @@ -0,0 +1,3 @@ +# directives + +This library was generated with [Nx](https://nx.dev). diff --git a/libs/shared/directives/karma.conf.js b/libs/shared/directives/karma.conf.js new file mode 100644 index 0000000000..0571e426e4 --- /dev/null +++ b/libs/shared/directives/karma.conf.js @@ -0,0 +1,15 @@ +const { join } = require('path'); +const getBaseKarmaConfig = require('../../../karma.conf'); + +module.exports = function (config) { + const baseConfig = getBaseKarmaConfig(config); + config.set({ + ...baseConfig, + frameworks: [...baseConfig.frameworks], + plugins: [...baseConfig.plugins], + coverageReporter: { + ...baseConfig.coverageReporter, + dir: join(__dirname, '../../../coverage/libs/shared/directives'), + }, + }); +}; diff --git a/libs/shared/directives/project.json b/libs/shared/directives/project.json new file mode 100644 index 0000000000..fe7bf55ad9 --- /dev/null +++ b/libs/shared/directives/project.json @@ -0,0 +1,24 @@ +{ + "name": "keira-shared-directives", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/shared/directives/src", + "prefix": "keira", + "tags": ["scope:shared"], + "projectType": "library", + "targets": { + "test": { + "executor": "@angular-devkit/build-angular:karma", + "options": { + "tsConfig": "libs/shared/directives/tsconfig.spec.json", + "karmaConfig": "libs/shared/directives/karma.conf.js", + "polyfills": ["zone.js", "zone.js/testing"], + "sourceMap": true, + "codeCoverage": true, + "scripts": [] + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/shared/directives/src/index.ts b/libs/shared/directives/src/index.ts new file mode 100644 index 0000000000..78f2fd9021 --- /dev/null +++ b/libs/shared/directives/src/index.ts @@ -0,0 +1 @@ +export * from './validate-input.directive'; diff --git a/libs/shared/directives/src/validate-input.directive.spec.ts b/libs/shared/directives/src/validate-input.directive.spec.ts new file mode 100644 index 0000000000..c482728453 --- /dev/null +++ b/libs/shared/directives/src/validate-input.directive.spec.ts @@ -0,0 +1,181 @@ +import { Component } from '@angular/core'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ReactiveFormsModule, FormControl, FormsModule, NgControl } from '@angular/forms'; +import { InputValidationDirective } from './validate-input.directive'; +import { ValidationService } from '@keira/shared/common-services'; + +@Component({ + template: ` +
+
+ +
+
+ `, +}) +class TestComponent { + testControl = new FormControl(''); +} + +describe('InputValidationDirective', () => { + function setup() { + TestBed.configureTestingModule({ + declarations: [TestComponent], + imports: [ReactiveFormsModule, FormsModule, InputValidationDirective], + providers: [ValidationService], + }).compileComponents(); + + const fixture = TestBed.createComponent(TestComponent); + const debugElement = fixture.debugElement.query(By.directive(InputValidationDirective)); + fixture.detectChanges(); + + return { fixture, debugElement }; + } + + it('should display error message when control is invalid and touched', fakeAsync(() => { + const { fixture, debugElement } = setup(); + const control = debugElement.injector.get(NgControl).control; + control?.setValidators(() => ({ required: true })); + control?.markAsTouched(); + control?.updateValueAndValidity(); + + tick(500); // Simulate debounceTime + fixture.detectChanges(); + + const errorDiv = debugElement.nativeElement.parentNode.querySelector('.error-message'); + expect(errorDiv).toBeTruthy(); + expect(errorDiv.textContent).toBe('This field is required'); + })); + + it('should remove error message when control becomes valid', fakeAsync(() => { + const { fixture, debugElement } = setup(); + const control = debugElement.injector.get(NgControl).control; + control?.setValidators(() => ({ required: true })); + control?.markAsTouched(); + control?.updateValueAndValidity(); + + tick(500); // Simulate debounceTime + fixture.detectChanges(); + + let errorDiv = debugElement.nativeElement.parentNode.querySelector('.error-message'); + expect(errorDiv).toBeTruthy(); + + control?.clearValidators(); + control?.setValue('Valid value'); + control?.updateValueAndValidity(); + + tick(500); // Simulate debounceTime again + fixture.detectChanges(); + + errorDiv = debugElement.nativeElement.parentNode.querySelector('.error-message'); + expect(errorDiv).toBeNull(); + })); + + it('should handle multiple error types and display first error', fakeAsync(() => { + const { fixture, debugElement } = setup(); + const control = debugElement.injector.get(NgControl).control; + + control?.setValidators(() => ({ required: true, minlength: true })); + control?.markAsTouched(); + control?.updateValueAndValidity(); + + tick(500); // Simulate debounceTime + fixture.detectChanges(); + + const errorDiv = debugElement.nativeElement.parentNode.querySelector('.error-message'); + expect(errorDiv).toBeTruthy(); + expect(errorDiv.textContent).toBe('This field is required'); + })); + + it('should mark control as touched when invalid', fakeAsync(() => { + const { fixture, debugElement } = setup(); + const control = debugElement.injector.get(NgControl).control; + spyOn(control!, 'markAsTouched').and.callThrough(); + + control?.setValidators(() => ({ required: true })); + control?.setValue(''); + control?.updateValueAndValidity(); + + tick(500); // Simulate debounceTime + fixture.detectChanges(); + + expect(control?.markAsTouched).toHaveBeenCalled(); + })); + + it('should safely remove errorDiv if already exists', fakeAsync(() => { + const { fixture, debugElement } = setup(); + const control = debugElement.injector.get(NgControl).control; + + // Set invalid state to create an errorDiv + control?.setValidators(() => ({ required: true })); + control?.markAsTouched(); + control?.updateValueAndValidity(); + + tick(500); // Simulate debounceTime + fixture.detectChanges(); + + let errorDiv = debugElement.nativeElement.parentNode.querySelector('.error-message'); + expect(errorDiv).toBeTruthy(); + + // Re-trigger the update with no errors + control?.clearValidators(); + control?.updateValueAndValidity(); + + tick(500); // Simulate debounceTime + fixture.detectChanges(); + + errorDiv = debugElement.nativeElement.parentNode.querySelector('.error-message'); + expect(errorDiv).toBeNull(); + })); + + it('should display correct error message for "required" error', fakeAsync(() => { + const { fixture, debugElement } = setup(); + const control = debugElement.injector.get(NgControl).control; + + control?.setValidators(() => ({ required: true })); + control?.markAsTouched(); + control?.updateValueAndValidity(); + + tick(500); // Simulate debounceTime + fixture.detectChanges(); + + const errorDiv = debugElement.nativeElement.parentNode.querySelector('.error-message'); + expect(errorDiv).toBeTruthy(); + expect(errorDiv.textContent).toBe('This field is required'); + })); + + it('should display "Invalid field" for non-required errors', fakeAsync(() => { + const { fixture, debugElement } = setup(); + const control = debugElement.injector.get(NgControl).control; + + control?.setValidators(() => ({ minlength: { requiredLength: 5, actualLength: 3 } })); + control?.markAsTouched(); + control?.updateValueAndValidity(); + + tick(500); // Simulate debounceTime + fixture.detectChanges(); + + const errorDiv = debugElement.nativeElement.parentNode.querySelector('.error-message'); + expect(errorDiv).toBeTruthy(); + expect(errorDiv.textContent).toBe('Invalid field'); + })); + + it('should return early if control is null (explicit)', () => { + const { debugElement } = setup(); + const directive = debugElement.injector.get(InputValidationDirective); + const ngControl = debugElement.injector.get(NgControl); + + // Mock ngControl.control to be null + spyOnProperty(ngControl, 'control', 'get').and.returnValue(null); + + // Spy on the updateErrorMessage method to ensure it's not called + const updateErrorMessageSpy = spyOn(directive, 'updateErrorMessage'); + + // Call the method explicitly + directive.ngOnInit(); + + // Expect the method to exit early and not proceed further + expect(updateErrorMessageSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/shared/directives/src/validate-input.directive.ts b/libs/shared/directives/src/validate-input.directive.ts new file mode 100644 index 0000000000..d99c0efc4b --- /dev/null +++ b/libs/shared/directives/src/validate-input.directive.ts @@ -0,0 +1,65 @@ +import { Directive, ElementRef, inject, OnInit, OnDestroy, Renderer2 } from '@angular/core'; +import { AbstractControl, NgControl } from '@angular/forms'; +import { SubscriptionHandler } from '@keira/shared/utils'; +import { debounceTime, distinctUntilChanged } from 'rxjs'; +import { ValidationService } from '@keira/shared/common-services'; + +@Directive({ + selector: '[keiraInputValidation]', + standalone: true, +}) +export class InputValidationDirective extends SubscriptionHandler implements OnInit, OnDestroy { + private readonly el: ElementRef = inject(ElementRef); + private readonly renderer: Renderer2 = inject(Renderer2); + private readonly ngControl: NgControl = inject(NgControl); + private readonly validationService = inject(ValidationService); + + private errorDiv: HTMLElement | null = null; + + ngOnInit(): void { + const control = this.ngControl.control; + + if (!control) { + return; + } + + this.validationService.setControlValidity(this, control.valid); + + this.subscriptions.push( + control.statusChanges?.pipe(distinctUntilChanged(), debounceTime(500)).subscribe(() => { + this.updateErrorMessage(control); + this.validationService.setControlValidity(this, control.valid); + }), + ); + } + + override ngOnDestroy(): void { + this.validationService.removeControl(this); + super.ngOnDestroy(); + } + + private updateErrorMessage(control: AbstractControl | null): void { + if (this.errorDiv) { + this.renderer.removeChild(this.el.nativeElement.parentNode, this.errorDiv); + this.errorDiv = null; + } + + if (control?.invalid) { + control.markAsTouched(); + } + + if (control?.touched && control?.invalid && control.errors) { + const parent = this.el.nativeElement.parentNode; + if (parent) { + this.errorDiv = this.renderer.createElement('div'); + this.renderer.addClass(this.errorDiv, 'error-message'); + const errorMessage = control.errors?.['required'] ? 'This field is required' : 'Invalid field'; + + const text = this.renderer.createText(errorMessage); + this.renderer.appendChild(this.errorDiv, text); + + this.renderer.appendChild(parent, this.errorDiv); + } + } + } +} diff --git a/libs/shared/directives/tsconfig.json b/libs/shared/directives/tsconfig.json new file mode 100644 index 0000000000..5cf0a16564 --- /dev/null +++ b/libs/shared/directives/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/shared/directives/tsconfig.lib.json b/libs/shared/directives/tsconfig.lib.json new file mode 100644 index 0000000000..f68063a517 --- /dev/null +++ b/libs/shared/directives/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": ["src/**/*.spec.ts", "jest.config.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/libs/shared/directives/tsconfig.spec.json b/libs/shared/directives/tsconfig.spec.json new file mode 100644 index 0000000000..b864ec66ae --- /dev/null +++ b/libs/shared/directives/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/spec", + "types": ["jasmine", "node"] + }, + "include": ["**/*.spec.ts", "**/*.d.ts"], + "exclude": ["dist", "release", "node_modules"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 3a9cd54727..abd6080168 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -55,6 +55,8 @@ "@keira/shared/switch-language": ["libs/shared/switch-language/src/index.ts"], "@keira/shared/test-utils": ["libs/shared/test-utils/src/index.ts"], "@keira/shared/utils": ["libs/shared/utils/src/index.ts"], + "@keira/shared/directives": ["libs/shared/directives/src/index.ts"], + "@keira/shared/error-templates": ["libs/shared/error-templates/src/index.ts"], "texts": ["libs/features/texts/src/index.ts"] }, "rootDir": "."