From 0491456c42cc70be6e2b3be6adf4b925906d655d Mon Sep 17 00:00:00 2001 From: Glen Staes Date: Wed, 12 Aug 2026 16:19:46 +0200 Subject: [PATCH 1/2] Change ng-forms prefix to ppw --- angular.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/angular.json b/angular.json index c82615ad..34422ddd 100644 --- a/angular.json +++ b/angular.json @@ -404,7 +404,7 @@ "projectType": "library", "root": "projects/ppwcode/ng-forms", "sourceRoot": "projects/ppwcode/ng-forms/src", - "prefix": "lib", + "prefix": "ppw", "architect": { "build": { "builder": "@angular/build:ng-packagr", From a65e6ac5ea745b8647d732f2c14ccd70ecf5b291 Mon Sep 17 00:00:00 2001 From: Glen Staes Date: Wed, 12 Aug 2026 17:09:44 +0200 Subject: [PATCH 2/2] Add ppw-field-errors component with translator provider --- .../field-errors/field-errors.component.html | 3 + .../field-errors.component.spec.ts | 86 ++++++++++++++++++ .../field-errors/field-errors.component.ts | 91 +++++++++++++++++++ .../lib/field-errors/field-errors.stories.ts | 73 +++++++++++++++ .../ng-forms/src/lib/provider/provider.ts | 16 ++++ projects/ppwcode/ng-forms/src/public-api.ts | 2 + 6 files changed, 271 insertions(+) create mode 100644 projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.html create mode 100644 projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.spec.ts create mode 100644 projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.ts create mode 100644 projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.stories.ts create mode 100644 projects/ppwcode/ng-forms/src/lib/provider/provider.ts diff --git a/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.html b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.html new file mode 100644 index 00000000..38c75501 --- /dev/null +++ b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.html @@ -0,0 +1,3 @@ +@if (showError()) { + {{ displayedMessage() }} +} diff --git a/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.spec.ts b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.spec.ts new file mode 100644 index 00000000..a14aad28 --- /dev/null +++ b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.spec.ts @@ -0,0 +1,86 @@ +import { inject, InjectionToken, signal } from '@angular/core' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { FieldTree, form, required, validate, ValidationError } from '@angular/forms/signals' +import { FieldErrorsErrorTranslator, providePpwcodeNgForms } from '../provider/provider' +import { FieldErrorsComponent } from './field-errors.component' + +describe('FieldErrorsComponent', () => { + const translationPrefix = new InjectionToken('Field error translation prefix') + + let component: FieldErrorsComponent + let fixture: ComponentFixture + let errorTranslator: ReturnType> + + beforeEach(async () => { + errorTranslator = vi.fn((error) => `${inject(translationPrefix)}${error.message ?? error.kind}`) + + await TestBed.configureTestingModule({ + imports: [FieldErrorsComponent], + providers: [ + { provide: translationPrefix, useValue: 'Translated: ' }, + providePpwcodeNgForms({ errorTranslator }) + ] + }).compileComponents() + + fixture = TestBed.createComponent(FieldErrorsComponent) + component = fixture.componentInstance + }) + + const createRequiredField = (value: string): FieldTree => + TestBed.runInInjectionContext(() => + form(signal(value), (path) => required(path, { message: 'A username is required.' })) + ) + + const render = async (field: FieldTree): Promise => { + fixture.componentRef.setInput('field', field) + fixture.detectChanges() + await fixture.whenStable() + } + + it('creates without displaying an error for a valid field', async () => { + await render(createRequiredField('Ada')) + + expect(component).toBeTruthy() + expect(component.showError()).toBe(false) + expect(component.displayedMessage()).toBeUndefined() + expect(fixture.nativeElement.querySelector('span')).toBeNull() + expect(errorTranslator).not.toHaveBeenCalled() + }) + + it('displays the translated first validation error', async () => { + const field = TestBed.runInInjectionContext(() => + form(signal('invalid'), (path) => + validate( + path, + (): Array => [ + { kind: 'first', message: 'First error' }, + { kind: 'second', message: 'Second error' } + ] + ) + ) + ) + + await render(field) + + expect(component.showError()).toBe(true) + expect(component.displayedMessage()).toBe('Translated: First error') + expect(fixture.nativeElement.querySelector('span')?.textContent).toContain('Translated: First error') + expect(errorTranslator).toHaveBeenCalledOnce() + expect(errorTranslator.mock.calls[0][0]).toMatchObject({ kind: 'first', message: 'First error' }) + }) + + it('reactively clears the error when the field becomes valid', async () => { + const field = createRequiredField('') + await render(field) + + expect(fixture.nativeElement.querySelector('span')).not.toBeNull() + + field().value.set('Ada') + fixture.detectChanges() + await fixture.whenStable() + + expect(component.showError()).toBe(false) + expect(component.displayedMessage()).toBeUndefined() + expect(fixture.nativeElement.querySelector('span')).toBeNull() + }) +}) diff --git a/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.ts b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.ts new file mode 100644 index 00000000..bd5f37d5 --- /dev/null +++ b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.component.ts @@ -0,0 +1,91 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + Injector, + input, + runInInjectionContext +} from '@angular/core' +import { FieldTree } from '@angular/forms/signals' +import { FIELD_ERRORS_ERROR_TRANSLATOR } from '../provider/provider' + +/** + * A component responsible for displaying validation error messages for a specific field. + * + * This component is designed to work with a field tree structure for form validation. It + * observes the field's validation errors and computes the first error to be displayed. + * Additionally, it provides a mechanism to translate error messages dynamically using an + * external translator function. + * + * Features: + * - Observes a field for validation errors. + * - Computes the first error to display. + * - Dynamically translates error messages using the provided translator. + * - Controls visibility of the error message based on the presence of errors. + * + * Dependencies: + * - Requires a `FieldTree` input to represent the form field and its associated errors. + * - Relies on an injected translator function to handle the translation of error messages. + * + * @example + * ```html + * + * + * + * + * + * Username + * + * + * + * ``` + * + * Relies on the presence of a provided translator function. + * ```ts + * // app.config.ts + * providePpwcodeNgForms({ + * errorTranslator: (error: ValidationError.WithFieldTree) => { + * const translate = inject(TranslateService) + * const _language = translate.currentLang() // acts as a trigger for recalculation + * const key = error.message ?? error.kind ?? null + * + * return key ? translate.instant(key) : '' + * }) + * }) + * ``` + */ +@Component({ + selector: 'ppw-field-errors', + imports: [], + templateUrl: './field-errors.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FieldErrorsComponent { + readonly #injector = inject(Injector) + readonly #translator = inject(FIELD_ERRORS_ERROR_TRANSLATOR) + + public readonly field = input.required>() + + readonly #firstError = computed(() => { + // This looks weird because of the double invocation, but it really is correct. + // - The first brackets are used to get the FieldTree from the input binding. + // - The second brackets are invoking the FieldTree itself to get access to methods for that field. + return this.field()().errors()[0] + }) + + public readonly showError = computed(() => !!this.#firstError()) + + public readonly displayedMessage = computed(() => { + const firstError = this.#firstError() + if (!firstError) { + return + } + + // Running this within the injection context allows the developer to use `inject` within its translator function. + // This is necessary, so they have access to the TranslateService of ngx-translate, for example. + // We have chosen this way of implementation so that the ng-forms package doesn't get an explicit depencency + // on translation packages like ngx-translate. + return runInInjectionContext(this.#injector, () => this.#translator(firstError)) + }) +} diff --git a/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.stories.ts b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.stories.ts new file mode 100644 index 00000000..c655faf1 --- /dev/null +++ b/projects/ppwcode/ng-forms/src/lib/field-errors/field-errors.stories.ts @@ -0,0 +1,73 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core' +import { form, FormField, minLength, required, ValidationError } from '@angular/forms/signals' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatInputModule } from '@angular/material/input' +import { applicationConfig, Meta, moduleMetadata, StoryObj } from '@storybook/angular' +import { providePpwcodeNgForms } from '../provider/provider' +import { FieldErrorsComponent } from './field-errors.component' + +@Component({ + selector: 'ppw-field-errors-story', + imports: [FieldErrorsComponent, FormField, MatFormFieldModule, MatInputModule], + template: ` + + Username + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class FieldErrorsStoryComponent { + readonly #model = signal({ username: '' }) + protected readonly form = form(this.#model, (path) => { + required(path.username, { message: () => 'This field is required' }) + minLength(path.username, 3) + }) +} + +const translateFieldError = (error: ValidationError.WithFieldTree): string => { + if (error.message) { + // When a message is explicitly set by the validator, show that message. + // required(path.username, { message: () => 'This field is required' }) + return error.message + } + + // The kind property contains the kind of error (required, maxLength, minLength, ...), you can use that + // for generic error messages using ngx-translate: + // `return inject(TranslateService).instant('validation' + error.kind)` + return `Validation failed: ${error.kind}` +} + +const meta: Meta = { + title: 'ng-forms/FieldErrors', + component: FieldErrorsStoryComponent, + subcomponents: { FieldErrorsComponent }, + decorators: [ + moduleMetadata({ + imports: [FieldErrorsStoryComponent] + }), + applicationConfig({ + providers: [ + providePpwcodeNgForms({ + errorTranslator: translateFieldError + }) + ] + }) + ], + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'Displays the first validation error for an Angular signal-forms field. The message is resolved by the error translator configured through `providePpwcodeNgForms`.' + } + } + }, + argTypes: {} +} + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/projects/ppwcode/ng-forms/src/lib/provider/provider.ts b/projects/ppwcode/ng-forms/src/lib/provider/provider.ts new file mode 100644 index 00000000..6e3d1583 --- /dev/null +++ b/projects/ppwcode/ng-forms/src/lib/provider/provider.ts @@ -0,0 +1,16 @@ +import { ValidationError } from '@angular/forms/signals' +import { InjectionToken } from '@angular/core' + +export declare type FieldErrorsErrorTranslator = (firstError: ValidationError.WithFieldTree) => string + +export interface PpwcodeNgFormsProviderOptions { + errorTranslator: FieldErrorsErrorTranslator +} + +export const FIELD_ERRORS_ERROR_TRANSLATOR = new InjectionToken( + 'Field errors error translator' +) + +export const providePpwcodeNgForms = ({ errorTranslator }: PpwcodeNgFormsProviderOptions) => { + return [{ provide: FIELD_ERRORS_ERROR_TRANSLATOR, useValue: errorTranslator }] +} diff --git a/projects/ppwcode/ng-forms/src/public-api.ts b/projects/ppwcode/ng-forms/src/public-api.ts index e1062824..09d5cdbc 100644 --- a/projects/ppwcode/ng-forms/src/public-api.ts +++ b/projects/ppwcode/ng-forms/src/public-api.ts @@ -8,3 +8,5 @@ export * from './lib/generators' export * from './lib/controls-of' export * from './lib/form-changes-detection' export * from './lib/signal-form-change-detection' +export * from './lib/field-errors/field-errors.component' +export * from './lib/provider/provider'