Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@if (showError()) {
<span>{{ displayedMessage() }}</span>
}
Original file line number Diff line number Diff line change
@@ -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<string>('Field error translation prefix')

let component: FieldErrorsComponent
let fixture: ComponentFixture<FieldErrorsComponent>
let errorTranslator: ReturnType<typeof vi.fn<FieldErrorsErrorTranslator>>

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<string> =>
TestBed.runInInjectionContext(() =>
form(signal(value), (path) => required(path, { message: 'A username is required.' }))
)

const render = async (field: FieldTree<unknown>): Promise<void> => {
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<ValidationError> => [
{ 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()
})
})
Original file line number Diff line number Diff line change
@@ -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
* <!-- Use directly and with your own styling. -->
* <ppw-field-errors [field]="form.username" />
*
* <!-- Or within a mat-form-field, inheriting Material form field styling. -->
* <mat-form-field>
* <mat-label>Username</mat-label>
* <input [formField]="form.username" />
* <ppw-field-errors matError [field]="form.username" />
* </mat-form-field>
* ```
*
* 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<FieldTree<unknown>>()

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))
})
}
Original file line number Diff line number Diff line change
@@ -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: `
<mat-form-field>
<mat-label>Username</mat-label>
<input matInput [formField]="form.username" />
<ppw-field-errors matError [field]="form.username" />
</mat-form-field>
`,
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<FieldErrorsStoryComponent> = {
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<FieldErrorsStoryComponent>

export const Default: Story = {}
16 changes: 16 additions & 0 deletions projects/ppwcode/ng-forms/src/lib/provider/provider.ts
Original file line number Diff line number Diff line change
@@ -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<FieldErrorsErrorTranslator>(
'Field errors error translator'
)

export const providePpwcodeNgForms = ({ errorTranslator }: PpwcodeNgFormsProviderOptions) => {
return [{ provide: FIELD_ERRORS_ERROR_TRANSLATOR, useValue: errorTranslator }]
}
2 changes: 2 additions & 0 deletions projects/ppwcode/ng-forms/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Loading