Skip to content

Commit b51fed2

Browse files
committed
feat: add fieldInfo structural directive and use field meta data
1 parent 4e2c589 commit b51fed2

File tree

4 files changed

+60
-20
lines changed

4 files changed

+60
-20
lines changed

src/app/field-info.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Directive, effect, inject, input, TemplateRef, ViewContainerRef } from '@angular/core';
2+
import { FieldTree } from '@angular/forms/signals';
3+
import { FIELD_INFO } from './form-props';
4+
5+
@Directive({
6+
selector: '[fieldInfo]',
7+
standalone: true,
8+
})
9+
export class FieldInfo {
10+
readonly fieldInfo = input.required<FieldTree<unknown>>();
11+
readonly #templateRef = inject(TemplateRef<{ $implicit: string; cssClass: string }>);
12+
readonly #viewContainer = inject(ViewContainerRef);
13+
14+
constructor() {
15+
effect(() => {
16+
const field = this.fieldInfo()();
17+
this.#viewContainer.clear();
18+
19+
let messages: { info: string; cssClass: 'info' | 'invalid' }[] = [];
20+
21+
if (field.pending()) {
22+
messages = [{ info: 'Checking availability ...', cssClass: 'info' }];
23+
} else if (field.touched() && field.errors().length > 0) {
24+
messages = field.errors().map((e) => ({ info: e.message || 'Invalid', cssClass: 'info' }));
25+
} else if (field.hasMetadata(FIELD_INFO)) {
26+
messages = [{ info: field.metadata(FIELD_INFO)!, cssClass: 'info' }];
27+
}
28+
29+
messages.forEach((message) => {
30+
this.#viewContainer.createEmbeddedView(this.#templateRef, {
31+
$implicit: message.info,
32+
cssClass: message.cssClass,
33+
});
34+
});
35+
});
36+
}
37+
}

src/app/form-props.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createMetadataKey } from "@angular/forms/signals";
2+
3+
export const FIELD_INFO = createMetadataKey<string>()

src/app/registration-form-3/registration-form-3.html

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@ <h1>Version 3: Child Forms and Custom UI Controls</h1>
1414
[field]="registrationForm.username"
1515
[ariaInvalid]="ariaInvalidState(registrationForm.username)"
1616
/>
17-
@if (registrationForm.username().pending()) {
18-
<small>Checking availability ...</small>
19-
}
20-
<app-form-error [fieldRef]="registrationForm.username" />
17+
<small *fieldInfo="registrationForm.username; let message; let cssClass;" [class]="cssClass">{{ message }}</small>
2118
</label>
2219

2320
<!-- A whole child form with own model -->
@@ -32,7 +29,7 @@ <h1>Version 3: Child Forms and Custom UI Controls</h1>
3229
[field]="registrationForm.age"
3330
[ariaInvalid]="ariaInvalidState(registrationForm.age)"
3431
/>
35-
<app-form-error [fieldRef]="registrationForm.age" />
32+
<small *fieldInfo="registrationForm.age; let message; let cssClass = $implicit_class" [class]="cssClass">{{ message }}</small>
3633
</label>
3734
</div>
3835

@@ -45,7 +42,7 @@ <h1>Version 3: Child Forms and Custom UI Controls</h1>
4542
[field]="registrationForm.password.pw1"
4643
[ariaInvalid]="ariaInvalidState(registrationForm.password.pw1)"
4744
/>
48-
<app-form-error [fieldRef]="registrationForm.password.pw1" />
45+
<small *fieldInfo="registrationForm.password.pw1; let message; let cssClass = $implicit_class" [class]="cssClass">{{ message }}</small>
4946
</label>
5047
<label
5148
>Password Confirmation
@@ -55,9 +52,9 @@ <h1>Version 3: Child Forms and Custom UI Controls</h1>
5552
[field]="registrationForm.password.pw2"
5653
[ariaInvalid]="ariaInvalidState(registrationForm.password.pw2)"
5754
/>
58-
<app-form-error [fieldRef]="registrationForm.password.pw2" />
55+
<small *fieldInfo="registrationForm.password.pw2; let message; let cssClass = $implicit_class" [class]="cssClass">{{ message }}</small>
5956
</label>
60-
<app-form-error [fieldRef]="registrationForm.password" />
57+
<small *fieldInfo="registrationForm.password; let message; let cssClass = $implicit_class" [class]="cssClass">{{ message }}</small>
6158
</div>
6259
<fieldset>
6360
<legend>
@@ -76,11 +73,11 @@ <h1>Version 3: Child Forms and Custom UI Controls</h1>
7673
/>
7774
<button type="button" (click)="removeEmail($index)">-</button>
7875
</div>
79-
<app-form-error [fieldRef]="emailField" />
76+
<small *fieldInfo="emailField; let message; let cssClass = $implicit_class" [class]="cssClass">{{ message }}</small>
8077
</div>
8178
}
8279
</div>
83-
<app-form-error [fieldRef]="registrationForm.email" />
80+
<small *fieldInfo="registrationForm.email; let message; let cssClass = $implicit_class" [class]="cssClass">{{ message }}</small>
8481
</fieldset>
8582
<label
8683
>Subscribe to Newsletter?
@@ -91,7 +88,7 @@ <h1>Version 3: Child Forms and Custom UI Controls</h1>
9188
[selectOptions]="['Angular', 'React', 'Vue', 'Svelte']"
9289
label="Topics (multiple possible):"
9390
/>
94-
<app-form-error [fieldRef]="registrationForm.newsletterTopics" />
91+
<small *fieldInfo="registrationForm.newsletterTopics; let message; let cssClass = $implicit_class" [class]="cssClass">{{ message }}</small>
9592
<label
9693
>I agree to the terms and conditions
9794
<input
@@ -100,23 +97,20 @@ <h1>Version 3: Child Forms and Custom UI Controls</h1>
10097
[field]="registrationForm.agreeToTermsAndConditions"
10198
/>
10299
</label>
103-
<app-form-error [fieldRef]="registrationForm.agreeToTermsAndConditions" />
100+
<small *fieldInfo="registrationForm.agreeToTermsAndConditions; let message; let cssClass = $implicit_class" [class]="cssClass">{{ message }}</small>
104101
<hr />
105102
<app-form-error [fieldRef]="registrationForm" />
103+
<small *fieldInfo="registrationForm; let message; let cssClass = $implicit_class" [class]="cssClass">{{ message }}</small>
106104
<div role="group">
107105
<button
108106
type="submit"
109107
[disabled]="registrationForm().submitting()"
110108
[ariaBusy]="registrationForm().submitting()"
111109
>
112-
@if (registrationForm().submitting()) {
113-
Registering ...
114-
} @else {
115-
Register
116-
}
110+
@if (registrationForm().submitting()) { Registering ... } @else { Register }
117111
</button>
118112
<button type="reset" (click)="resetForm()">Reset</button>
119113
</div>
120114
</form>
121115

122-
<app-debug-output [form]="registrationForm"/>
116+
<app-debug-output [form]="registrationForm" />

src/app/registration-form-3/registration-form-3.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { Component, inject, resource, signal } from '@angular/core';
2-
import { apply, applyEach, applyWhen, Field, disabled, email, FieldTree, form, maxLength, min, minLength, pattern, required, schema, submit, validate, validateAsync, validateTree, ValidationErrorWithField } from '@angular/forms/signals';
2+
import { apply, applyEach, applyWhen, disabled, email, Field, FieldTree, form, maxLength, metadata, min, minLength, pattern, required, schema, submit, validate, validateAsync, validateTree, ValidationErrorWithField } from '@angular/forms/signals';
33

44
import { BackButton } from '../back-button/back-button';
55
import { DebugOutput } from '../debug-output/debug-output';
6+
import { FieldInfo } from '../field-info';
67
import { FormError } from '../form-error/form-error';
78
import { GenderIdentity, IdentityForm, identitySchema, initialGenderIdentityState } from '../identity-form/identity-form';
89
import { Multiselect } from '../multiselect/multiselect';
910
import { RegistrationService } from '../registration-service';
11+
import { FIELD_INFO } from '../form-props';
1012

1113
export interface RegisterFormData {
1214
username: string;
@@ -59,9 +61,11 @@ export const formSchema = schema<RegisterFormData>((fieldPath) => {
5961
},
6062
onError: () => undefined
6163
});
64+
metadata(fieldPath.username, FIELD_INFO, () => "A username must consists of 3-12 characters.")
6265

6366
// Age validation
6467
min(fieldPath.age, 18, { message: 'You must be >=18 years old.' });
68+
metadata(fieldPath.age, FIELD_INFO, () => "You must be 18 years old to register")
6569

6670
// Terms and conditions
6771
required(fieldPath.agreeToTermsAndConditions, {
@@ -80,6 +84,7 @@ export const formSchema = schema<RegisterFormData>((fieldPath) => {
8084
applyEach(fieldPath.email, (emailPath) => {
8185
email(emailPath, { message: 'E-Mail format is invalid' });
8286
});
87+
metadata(fieldPath.email, FIELD_INFO, () => "Please enter at least one valid E-Mail address")
8388

8489
// Password validation
8590
required(fieldPath.password.pw1, { message: 'A password is required' });
@@ -103,6 +108,7 @@ export const formSchema = schema<RegisterFormData>((fieldPath) => {
103108
message: 'The entered password must match with the one specified in "Password" field',
104109
};
105110
});
111+
metadata(fieldPath.password, FIELD_INFO, () => "Please enter a password with min 8 characters and a special character.")
106112

107113
// Newsletter validation
108114
applyWhen(
@@ -129,7 +135,7 @@ export const formSchema = schema<RegisterFormData>((fieldPath) => {
129135

130136
@Component({
131137
selector: 'app-registration-form-3',
132-
imports: [BackButton, Field, DebugOutput, FormError, IdentityForm, Multiselect],
138+
imports: [BackButton, Field, DebugOutput, FormError, IdentityForm, Multiselect, FieldInfo],
133139
templateUrl: './registration-form-3.html',
134140
styleUrl: './registration-form-3.scss',
135141
})

0 commit comments

Comments
 (0)