Skip to content

Commit b649cce

Browse files
committed
refactor(forms): make markAsTouched() touch all descendants by default
`markAsTouched()` now marks all descendants as touched. In general this method is called when controls update the model. Most controls update leaf nodes, in which case this change has no effect. Marking all descendants allows triggering validation for subsections of a form, independently from having to call `submit()` on the entire form. `markAsTouched()` now accepts a `MarkAsTouchedOptions` parameter, which includes a `self` property. This can be used mark only the receiving field as touched: `node.markAsTouched({self: true})`.
1 parent d9e40cb commit b649cce

7 files changed

Lines changed: 93 additions & 39 deletions

File tree

goldens/public-api/forms/signals/index.api.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
143143
readonly invalid: Signal<boolean>;
144144
readonly keyInParent: Signal<TKey>;
145145
markAsDirty(): void;
146-
markAsTouched(): void;
146+
markAsTouched(options?: MarkAsTouchedOptions): void;
147147
readonly max?: Signal<number | undefined>;
148148
readonly maxLength?: Signal<number | undefined>;
149149
metadata<M>(key: MetadataKey<M, any, any>): M | undefined;
@@ -298,6 +298,11 @@ export type LogicFn<TValue, TReturn, TPathKind extends PathKind = PathKind.Root>
298298
// @public
299299
export type MapToErrorsFn<TValue, TResult, TPathKind extends PathKind = PathKind.Root> = (result: TResult, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
300300

301+
// @public
302+
export interface MarkAsTouchedOptions {
303+
self?: boolean;
304+
}
305+
301306
// @public
302307
export const MAX: MetadataKey<Signal<number | undefined>, number | undefined, number | undefined>;
303308

packages/forms/signals/compat/src/compat_field_adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export class CompatFieldAdapter implements FieldAdapter {
9494
if (!options.control) {
9595
return this.basicAdapter.createValidationState(node);
9696
}
97-
return new CompatValidationState(options);
97+
return new CompatValidationState(node, options);
9898
}
9999

100100
/**

packages/forms/signals/compat/src/compat_validation_state.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import {
1616
} from '../../src/compat/validation_errors';
1717
import {getControlStatusSignal} from './compat_field_node';
1818
import {CompatFieldNodeOptions} from './compat_structure';
19+
import {CompatFieldNode} from './compat_field_node';
1920

2021
// Readonly signal containing an empty array, used for optimization.
2122
const EMPTY_ARRAY_SIGNAL = computed(() => []);
22-
const TRUE_SIGNAL = computed(() => true);
2323

2424
/**
2525
* Compat version of a validation state that wraps a FormControl, and proxies it's validation state.
@@ -36,7 +36,10 @@ export class CompatValidationState implements ValidationState {
3636

3737
readonly parseErrors: Signal<ValidationError.WithFormField[]> = computed(() => []);
3838

39-
constructor(options: CompatFieldNodeOptions) {
39+
constructor(
40+
private readonly node: CompatFieldNode,
41+
options: CompatFieldNodeOptions,
42+
) {
4043
this.syncValid = getControlStatusSignal(options, (c: AbstractControl) => c.status === 'VALID');
4144
this.errors = getControlStatusSignal(options, extractNestedReactiveErrors);
4245
this.pending = getControlStatusSignal(options, (c) => c.pending);
@@ -57,7 +60,12 @@ export class CompatValidationState implements ValidationState {
5760
rawSyncTreeErrors = EMPTY_ARRAY_SIGNAL;
5861
syncErrors = EMPTY_ARRAY_SIGNAL;
5962
rawAsyncErrors = EMPTY_ARRAY_SIGNAL;
60-
shouldSkipValidation = TRUE_SIGNAL;
63+
64+
// Compat fields can't have validation rules applied to them; however, there are other
65+
// features that depend on this property, such as `markAsTouched()`.
66+
readonly shouldSkipValidation = computed(
67+
() => this.node.hidden() || this.node.disabled() || this.node.readonly(),
68+
);
6169

6270
/**
6371
* Computes status based on whether the field is valid/invalid/pending.

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

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -408,24 +408,14 @@ export async function submit<TModel>(
408408
);
409409
}
410410

411-
const onInvalid = options?.onInvalid as FormSubmitOptions<unknown, unknown>['onInvalid'];
412-
const ignoreValidators = options?.ignoreValidators ?? 'pending';
413-
414-
// Determine whether or not to run the action based on the current validity.
415-
let shouldRunAction = true;
416-
untracked(() => {
417-
markAllAsTouched(node);
411+
node.markAsTouched();
418412

419-
if (ignoreValidators === 'none') {
420-
shouldRunAction = node.valid();
421-
} else if (ignoreValidators === 'pending') {
422-
shouldRunAction = !node.invalid();
423-
}
424-
});
413+
const onInvalid = options?.onInvalid as FormSubmitOptions<unknown, unknown>['onInvalid'];
414+
const shouldRun = shouldRunAction(node, options?.ignoreValidators);
425415

426416
// Run the action (or alternatively the `onInvalid` callback)
427417
try {
428-
if (shouldRunAction) {
418+
if (shouldRun) {
429419
node.submitState.selfSubmitting.set(true);
430420
const errors = await untracked(() => action?.(field, detail));
431421
errors && setSubmissionErrors(node, errors);
@@ -452,17 +442,17 @@ export function schema<TValue>(fn: SchemaFn<TValue>): Schema<TValue> {
452442
return SchemaImpl.create(fn) as unknown as Schema<TValue>;
453443
}
454444

455-
/** Marks a {@link node} and its descendants as touched. */
456-
function markAllAsTouched(node: FieldNode) {
457-
// Don't mark hidden, disabled, or readonly fields as touched since they don't contribute to the
458-
// form's validity. This also prevents errors from appearing immediately if they're later made
459-
// interactive.
460-
if (node.validationState.shouldSkipValidation()) {
461-
return;
462-
}
463-
node.markAsTouched();
464-
for (const child of node.structure.children()) {
465-
markAllAsTouched(child);
445+
function shouldRunAction(
446+
node: FieldNode,
447+
ignoreValidators?: FormSubmitOptions<unknown, unknown>['ignoreValidators'],
448+
) {
449+
switch (ignoreValidators) {
450+
case 'all':
451+
return true;
452+
case 'none':
453+
return untracked(node.valid);
454+
default: // Ignore pending validators by default (or specified 'pending').
455+
return !untracked(node.invalid);
466456
}
467457
}
468458

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ export interface FormSubmitOptions<TRootModel, TSubmittedModel> {
5757
ignoreValidators?: 'pending' | 'none' | 'all';
5858
}
5959

60+
/**
61+
* Options for the `markAsTouched` method.
62+
*
63+
* @experimental 21.2.2
64+
*/
65+
export interface MarkAsTouchedOptions {
66+
/**
67+
* If `true`, only marks the current field as touched.
68+
* If `false` or not provided, marks the field and all its descendants as touched.
69+
*/
70+
self?: boolean;
71+
}
72+
6073
/**
6174
* A type that represents either a single value of type `T` or a readonly array of `T`.
6275
* @template T The type of the value(s).
@@ -430,9 +443,11 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
430443
markAsDirty(): void;
431444

432445
/**
433-
* Sets the touched status of the field to `true`.
446+
* Sets the touched status of the field and its descendants to `true`.
447+
*
448+
* @param options Options for marking the field as touched.
434449
*/
435-
markAsTouched(): void;
450+
markAsTouched(options?: MarkAsTouchedOptions): void;
436451

437452
/**
438453
* Reads a metadata value from the field.

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ import {
1717
REQUIRED,
1818
} from '../api/rules/metadata';
1919
import type {ValidationError} from '../api/rules/validation/validation_errors';
20-
import type {DisabledReason, FieldContext, FieldState, FieldTree} from '../api/types';
20+
import type {
21+
DisabledReason,
22+
FieldContext,
23+
FieldState,
24+
FieldTree,
25+
MarkAsTouchedOptions,
26+
} from '../api/types';
2127
import type {FormField} from '../directive/form_field_directive';
2228
import {DYNAMIC} from '../schema/logic';
2329
import {LogicNode} from '../schema/logic_node';
@@ -238,16 +244,26 @@ export class FieldNode implements FieldState<unknown> {
238244
return this.metadataState.has(key);
239245
}
240246

241-
/**
242-
* Marks this specific field as touched.
243-
*/
244-
markAsTouched(): void {
247+
markAsTouched(options?: MarkAsTouchedOptions): void {
245248
untracked(() => {
246-
this.nodeState.markAsTouched();
249+
this.markAsTouchedInternal(options);
247250
this.flushSync();
248251
});
249252
}
250253

254+
markAsTouchedInternal(options?: MarkAsTouchedOptions): void {
255+
if (this.validationState.shouldSkipValidation()) {
256+
return;
257+
}
258+
this.nodeState.markAsTouched();
259+
if (options?.self) {
260+
return;
261+
}
262+
for (const child of this.structure.children()) {
263+
child.markAsTouchedInternal();
264+
}
265+
}
266+
251267
/**
252268
* Marks this specific field as dirty.
253269
*/

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ describe('FieldNode', () => {
417417
expect(f().touched()).toBe(true);
418418
});
419419

420-
it('does not propagate down', () => {
420+
it('propagates down by default', () => {
421421
const f = form(
422422
signal({
423423
a: 1,
@@ -426,8 +426,28 @@ describe('FieldNode', () => {
426426
{injector: TestBed.inject(Injector)},
427427
);
428428

429+
expect(f().touched()).toBe(false);
429430
expect(f.a().touched()).toBe(false);
431+
expect(f.b().touched()).toBe(false);
430432
f().markAsTouched();
433+
expect(f().touched()).toBe(true);
434+
expect(f.a().touched()).toBe(true);
435+
expect(f.b().touched()).toBe(true);
436+
});
437+
438+
it('does not propagate down when self is true', () => {
439+
const f = form(
440+
signal({
441+
a: 1,
442+
b: 2,
443+
}),
444+
{injector: TestBed.inject(Injector)},
445+
);
446+
447+
expect(f().touched()).toBe(false);
448+
expect(f.a().touched()).toBe(false);
449+
f().markAsTouched({self: true});
450+
expect(f().touched()).toBe(true);
431451
expect(f.a().touched()).toBe(false);
432452
});
433453

0 commit comments

Comments
 (0)