Skip to content

Commit 72e5ae4

Browse files
committed
feat(forms): support FieldState.name() & propagate to controls
Automatically generate a `name` for `Field`s, and bind that on an HTML control when available.
1 parent ad0f141 commit 72e5ae4

File tree

8 files changed

+60
-4
lines changed

8 files changed

+60
-4
lines changed

packages/forms/experimental/src/api/control.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface FormUiControl<TValue> {
1616
readonly readonly?: InputSignal<boolean | undefined>;
1717
readonly valid?: InputSignal<boolean | undefined>;
1818
readonly touched?: InputSignal<boolean | undefined>;
19+
readonly name?: InputSignal<string>;
1920

2021
readonly touch?: OutputRef<void>;
2122

packages/forms/experimental/src/api/structure.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface FormOptions {
3030
* current [injection context](guide/di/dependency-injection-context), will be used.
3131
*/
3232
injector?: Injector;
33+
name?: string;
3334
}
3435

3536
/** Extracts the model, schema, and options from the arguments passed to `form()`. */
@@ -172,7 +173,7 @@ export function form<TValue>(...args: any[]): Field<TValue> {
172173
const [model, schema, options] = normalizeFormArgs<TValue>(args);
173174
const injector = options?.injector ?? inject(Injector);
174175
const pathNode = runInInjectionContext(injector, () => SchemaImpl.rootCompile(schema));
175-
const fieldManager = new FormFieldManager(injector);
176+
const fieldManager = new FormFieldManager(injector, options?.name);
176177
const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode);
177178
fieldManager.createFieldManagementEffect(fieldRoot.structure);
178179

@@ -371,3 +372,9 @@ function markAllAsTouched(node: FieldNode) {
371372
markAllAsTouched(child);
372373
}
373374
}
375+
376+
let nextFormId = 0;
377+
function nextFormName(): string {
378+
// TODO: include APP_ID?
379+
return `form${nextFormId++}`;
380+
}

packages/forms/experimental/src/api/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,11 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
278278
* A signal indicating whether the field is currently in the process of being submitted.
279279
*/
280280
readonly submitting: Signal<boolean>;
281+
/**
282+
* A signal of a unique name for the field, by default based on the name of its parent field.
283+
*/
284+
readonly name: Signal<string>;
285+
281286
/**
282287
* The property key in the parent field under which this field is stored. If the parent field is
283288
* array-valued, for example, this is the index of this field in that array.

packages/forms/experimental/src/controls/control.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export class Control<T> {
9494

9595
this.maybeSynchronize(() => this.state().readonly(), withBooleanAttribute(input, 'readonly'));
9696
this.maybeSynchronize(() => this.state().disabled(), withBooleanAttribute(input, 'disabled'));
97+
this.maybeSynchronize(() => this.state().name(), withAttribute(input, 'name'));
9798

9899
this.maybeSynchronize(this.propertySource(MIN), withAttribute(input, 'min'));
99100
this.maybeSynchronize(this.propertySource(MIN_LENGTH), withAttribute(input, 'minLength'));
@@ -135,6 +136,7 @@ export class Control<T> {
135136
private setupCustomUiControl(cmp: FormUiControl<T>) {
136137
// Input bindings:
137138
this.maybeSynchronize(() => this.state().value(), withInput(cmp.value));
139+
this.maybeSynchronize(() => this.state().name(), withInput(cmp.name));
138140
this.maybeSynchronize(() => this.state().disabled(), withInput(cmp.disabled));
139141
this.maybeSynchronize(() => this.state().readonly(), withInput(cmp.readonly));
140142
this.maybeSynchronize(() => this.state().errors(), withInput(cmp.errors));

packages/forms/experimental/src/field/manager.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {effect, Injector, untracked} from '@angular/core';
9+
import {APP_ID, effect, inject, Injector, untracked} from '@angular/core';
1010
import type {FieldNodeStructure} from './structure';
1111

1212
/**
@@ -17,7 +17,13 @@ import type {FieldNodeStructure} from './structure';
1717
* destroyed, which is the job of the `FormFieldManager`.
1818
*/
1919
export class FormFieldManager {
20-
constructor(readonly injector: Injector) {}
20+
readonly rootName: string;
21+
constructor(
22+
readonly injector: Injector,
23+
rootName: string | undefined,
24+
) {
25+
this.rootName = rootName ?? `${this.injector.get(APP_ID)}.form${nextFormId++}`;
26+
}
2127

2228
/**
2329
* Contains all child field structures that have been created as part of the current form.
@@ -73,3 +79,5 @@ export class FormFieldManager {
7379
}
7480
}
7581
}
82+
83+
let nextFormId = 0;

packages/forms/experimental/src/field/node.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,15 @@ export class FieldNode implements FieldState<unknown> {
149149
return this.submitState.submitting;
150150
}
151151

152+
get name(): Signal<string> {
153+
return this.nodeState.name;
154+
}
155+
152156
property<M>(prop: AggregateProperty<M, any>): Signal<M>;
153157
property<M>(prop: Property<M>): M | undefined;
154158
property<M>(prop: Property<M> | AggregateProperty<M, any>): Signal<M> | M | undefined {
155159
return this.propertyState.get(prop);
156160
}
157-
158161
hasProperty(prop: Property<unknown> | AggregateProperty<unknown, any>): boolean {
159162
return this.propertyState.has(prop);
160163
}

packages/forms/experimental/src/field/state.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,13 @@ export class FieldNodeState {
113113
this.node.logicNode.logic.hidden.compute(this.node.context)) ??
114114
false,
115115
);
116+
117+
readonly name: Signal<string> = computed(() => {
118+
const parent = this.node.structure.parent;
119+
if (!parent) {
120+
return this.node.structure.fieldManager.rootName;
121+
}
122+
123+
return `${parent.name()}.${this.node.structure.keyInParent()}`;
124+
});
116125
}

packages/forms/experimental/test/node/field_node.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,27 @@ describe('FieldNode', () => {
361361
});
362362
});
363363

364+
describe('names', () => {
365+
it('auto-generates a name for the form', () => {
366+
const f = form(signal({}), {injector: TestBed.inject(Injector)});
367+
expect(f().name()).toMatch(/^a.form\d+$/);
368+
});
369+
370+
it('uses a specific name for the form when given', () => {
371+
const f = form(signal({}), {injector: TestBed.inject(Injector), name: 'test'});
372+
expect(f().name()).toBe('test');
373+
});
374+
375+
it('derives child field names from parents', () => {
376+
const f = form(signal({user: {firstName: 'Alex'}}), {
377+
injector: TestBed.inject(Injector),
378+
name: 'test',
379+
});
380+
expect(f.user().name()).toBe('test.user');
381+
expect(f.user.firstName().name()).toBe('test.user.firstName');
382+
});
383+
});
384+
364385
describe('disabled', () => {
365386
it('should allow logic to make a node disabled', () => {
366387
const f = form(

0 commit comments

Comments
 (0)