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 @@
-
+
-
-
-
+
+
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": "."