diff --git a/packages/forms/signals/src/api/structure.ts b/packages/forms/signals/src/api/structure.ts index f0943863d09d..e9974ae3e27e 100644 --- a/packages/forms/signals/src/api/structure.ts +++ b/packages/forms/signals/src/api/structure.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {inject, Injector, runInInjectionContext, WritableSignal} from '@angular/core'; +import {inject, Injector, runInInjectionContext, untracked, WritableSignal} from '@angular/core'; import {BasicFieldAdapter, FieldAdapter} from '../field/field_adapter'; import {FormFieldManager} from '../field/manager'; @@ -369,10 +369,13 @@ export async function submit( action: (form: FieldTree) => Promise, ) { const node = form() as unknown as FieldNode; - markAllAsTouched(node); + const invalid = untracked(() => { + markAllAsTouched(node); + return node.invalid(); + }); // Fail fast if the form is already invalid. - if (node.invalid()) { + if (invalid) { return; } @@ -429,6 +432,12 @@ export function schema(fn: SchemaFn): Schema { /** Marks a {@link node} and its descendants as touched. */ function markAllAsTouched(node: FieldNode) { + // Don't mark hidden, disabled, or readonly fields as touched since they don't contribute to the + // form's validity. This also prevents errors from appearing immediately if they're later made + // interactive. + if (node.validationState.shouldSkipValidation()) { + return; + } node.markAsTouched(); for (const child of node.structure.children()) { markAllAsTouched(child); diff --git a/packages/forms/signals/test/node/submit.spec.ts b/packages/forms/signals/test/node/submit.spec.ts index 568a05ca489c..400336ffb959 100644 --- a/packages/forms/signals/test/node/submit.spec.ts +++ b/packages/forms/signals/test/node/submit.spec.ts @@ -9,12 +9,15 @@ import {Injector, resource, signal} from '@angular/core'; import {TestBed} from '@angular/core/testing'; import { + disabled, form, required, requiredError, submit, validateAsync, ValidationError, + hidden, + readonly, } from '../../public_api'; describe('submit', () => { @@ -269,6 +272,81 @@ describe('submit', () => { expect(f.first().errors()).toEqual([]); expect(f.last().errors()).toEqual([{kind: 'submit', fieldTree: f.last}]); }); + + it('does not mark disabled fields as touched', async () => { + const data = signal({first: '', last: ''}); + const f = form( + data, + (name) => { + // Disable first name when last name is empty. + disabled(name.first, ({valueOf}) => valueOf(name.last) === ''); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f.first().disabled()).toBe(true); + expect(f.first().touched()).toBe(false); + expect(f.last().touched()).toBe(false); + + await submit(f, async () => []); + expect(f.first().touched()).toBe(false); + expect(f.last().touched()).toBe(true); + + // Set last name to make first name enabled. + f.last().value.set('Doe'); + expect(f.first().disabled()).toBe(false); + expect(f.first().touched()).toBe(false); + }); + + it('does not mark hidden fields as touched', async () => { + const data = signal({first: '', last: ''}); + const f = form( + data, + (name) => { + // Hide first name when last name is empty. + hidden(name.first, ({valueOf}) => valueOf(name.last) === ''); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f.first().hidden()).toBe(true); + expect(f.first().touched()).toBe(false); + expect(f.last().touched()).toBe(false); + + await submit(f, async () => []); + expect(f.first().touched()).toBe(false); + expect(f.last().touched()).toBe(true); + + // Set last name to make first name visible. + f.last().value.set('Doe'); + expect(f.first().hidden()).toBe(false); + expect(f.first().touched()).toBe(false); + }); + + it('does not mark readonly fields as touched', async () => { + const data = signal({first: '', last: ''}); + const f = form( + data, + (name) => { + // Make first name readonly when last name is empty. + readonly(name.first, ({valueOf}) => valueOf(name.last) === ''); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f.first().readonly()).toBe(true); + expect(f.first().touched()).toBe(false); + expect(f.last().touched()).toBe(false); + + await submit(f, async () => []); + expect(f.first().touched()).toBe(false); + expect(f.last().touched()).toBe(true); + + // Set last name to make first name enabled. + f.last().value.set('Doe'); + expect(f.first().readonly()).toBe(false); + expect(f.first().touched()).toBe(false); + }); }); /**