Files
Leon Senft 9f99b14882 fix(forms): only touch visible, interactive fields on submit
Don't touch hidden, disabled, or readonly fields on submit, since they
don't contribute to form validity. This also prevents errors from
appearing immediately if they're later made interactive.

Fix #66344

(cherry picked from commit e682e53113)
2026-01-28 18:56:06 +00:00

464 lines
13 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ApplicationRef, 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', () => {
it('fails fast on invalid form', async () => {
const data = signal({first: '', last: ''});
const f = form(
data,
(name) => {
required(name.first);
},
{injector: TestBed.inject(Injector)},
);
await submit(f, async (form) => {
fail('Submit action should run not on invalid form');
});
expect(f.first().errors()).toEqual([requiredError({fieldTree: f.first})]);
});
describe('while pending', () => {
it('should not block', async () => {
const data = signal('');
const {promise} = promiseWithResolvers();
const f = form(
data,
(p) => {
validateAsync(p, {
params: ({value}) => value(),
factory: (params) =>
resource({
params,
loader: () => promise,
}),
onSuccess: () => {},
onError: () => {},
});
},
{injector: TestBed.inject(Injector)},
);
expect(f().pending()).toBe(true);
const submitSpy = jasmine.createSpy();
await submit(f, submitSpy);
expect(f().pending()).toBe(true);
expect(submitSpy).toHaveBeenCalled();
});
it('should retain submit errors after pending validation resolves', async () => {
const appRef = TestBed.inject(ApplicationRef);
const data = signal('foo');
const {promise, resolve} = promiseWithResolvers<boolean>();
const f = form(
data,
(p) => {
validateAsync(p, {
params: ({value}) => value(),
factory: (params) =>
resource({
params,
loader: () => promise,
}),
onSuccess: () => ({kind: 'async'}),
onError: (error) => fail(error),
});
},
{injector: TestBed.inject(Injector)},
);
await submit(f, async () => ({kind: 'submit'}));
expect(f().errorSummary()).toEqual([jasmine.objectContaining({kind: 'submit'})]);
resolve(true);
await appRef.whenStable();
expect(f().errorSummary()).toEqual([jasmine.objectContaining({kind: 'submit'})]);
});
it('should resolve pending validation on subfield', async () => {
const appRef = TestBed.inject(ApplicationRef);
const data = signal({first: 'foo', last: 'bar'});
const {promise, resolve} = promiseWithResolvers<boolean>();
const f = form(
data,
(p) => {
validateAsync(p.first, {
params: ({value}) => value(),
factory: (params) =>
resource({
params,
loader: () => promise,
}),
onSuccess: () => ({kind: 'async'}),
onError: (error) => fail(error),
});
},
{injector: TestBed.inject(Injector)},
);
await submit(f, async () => ({kind: 'submit'}));
expect(f().errorSummary()).toEqual([jasmine.objectContaining({kind: 'submit'})]);
resolve(true);
await appRef.whenStable();
expect(f().errorSummary()).toEqual([
jasmine.objectContaining({kind: 'submit', fieldTree: f}),
jasmine.objectContaining({kind: 'async', fieldTree: f.first}),
]);
});
it('should resolve pending validation after successful submit', async () => {
const appRef = TestBed.inject(ApplicationRef);
const data = signal('foo');
const {promise, resolve} = promiseWithResolvers();
const f = form(
data,
(p) => {
validateAsync(p, {
params: ({value}) => value(),
factory: (params) =>
resource({
params,
loader: () => promise,
}),
onSuccess: () => ({kind: 'async'}),
onError: (error) => fail(error),
});
},
{injector: TestBed.inject(Injector)},
);
await submit(f, async () => undefined);
expect(f().errorSummary()).toEqual([]);
resolve(true);
await appRef.whenStable();
expect(f().errorSummary()).toEqual([jasmine.objectContaining({kind: 'async'})]);
});
});
it('maps error to a field', async () => {
const data = signal({first: '', last: ''});
const f = form(
data,
(name) => {
// first name required if last name specified
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
},
{injector: TestBed.inject(Injector)},
);
await submit(f, (form) => {
return Promise.resolve({
kind: 'lastName',
fieldTree: form.last,
});
});
expect(f.last().errors()).toEqual([{kind: 'lastName', fieldTree: f.last}]);
});
it('maps errors to multiple fields', async () => {
const data = signal({first: '', last: ''});
const f = form(data, {injector: TestBed.inject(Injector)});
await submit(f, (form) => {
return Promise.resolve([
{
kind: 'firstName',
fieldTree: form.first,
},
{
kind: 'lastName',
fieldTree: form.last,
},
{
kind: 'lastName2',
fieldTree: form.last,
},
]);
});
expect(f.first().errors()).toEqual([{kind: 'firstName', fieldTree: f.first}]);
expect(f.last().errors()).toEqual([
{kind: 'lastName', fieldTree: f.last},
{kind: 'lastName2', fieldTree: f.last},
]);
});
it('can read value from field state', async () => {
const initialValue = {first: 'meow', last: 'wuf'};
const data = signal(initialValue);
const f = form(
data,
(name) => {
// first name required if last name specified
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
},
{injector: TestBed.inject(Injector)},
);
const submitSpy = jasmine.createSpy('submit');
await submit(f, (form) => {
submitSpy(form().value());
return Promise.resolve();
});
expect(submitSpy).toHaveBeenCalledWith(initialValue);
});
it('maps untargeted errors to form root', async () => {
const data = signal({first: '', last: ''});
const f = form(
data,
(name) => {
// first name required if last name specified
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
},
{injector: TestBed.inject(Injector)},
);
await submit(f, () => {
return Promise.resolve({kind: 'custom'});
});
expect(f().errors()).toEqual([{kind: 'custom', fieldTree: f}]);
});
it('marks the form as submitting', async () => {
const initialValue = {first: 'meow', last: 'wuf'};
const data = signal(initialValue);
const f = form(
data,
(name) => {
// first name required if last name specified
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
},
{injector: TestBed.inject(Injector)},
);
expect(f().submitting()).toBe(false);
const {promise, resolve} = promiseWithResolvers<ValidationError[]>();
const result = submit(f, () => promise);
expect(f().submitting()).toBe(true);
resolve([]);
await result;
});
it('marks descendants as submitting', async () => {
const initialValue = {a: {b: 12}};
const data = signal(initialValue);
const f = form(data, {injector: TestBed.inject(Injector)});
expect(f.a.b().submitting()).toBe(false);
const {promise, resolve} = promiseWithResolvers<ValidationError[]>();
const result = submit(f, () => promise);
expect(f.a.b().submitting()).toBe(true);
resolve([]);
await result;
});
it('marks the form as touched', async () => {
const initialValue = {first: 'meow', last: 'wuf'};
const data = signal(initialValue);
const f = form(data, {injector: TestBed.inject(Injector)});
expect(f().touched()).toBe(false);
await submit(f, async () => []);
expect(f().touched()).toBe(true);
});
it('marks descendants as touched', async () => {
const initialValue = {a: {b: 12}};
const data = signal(initialValue);
const f = form(data, {injector: TestBed.inject(Injector)});
expect(f.a.b().touched()).toBe(false);
await submit(f, async () => []);
expect(f.a.b().touched()).toBe(true);
});
it('works on child fields', async () => {
const initialValue = {first: 'meow', last: 'wuf'};
const data = signal(initialValue);
const f = form(
data,
(name) => {
// first name required if last name specified
required(name.first, {
when: ({valueOf}) => valueOf(name.last) !== '',
});
},
{injector: TestBed.inject(Injector)},
);
const submitSpy = jasmine.createSpy('submit');
await submit(f.first, (form) => {
submitSpy(form().value());
return Promise.resolve({kind: 'lastName'});
});
expect(submitSpy).toHaveBeenCalledWith('meow');
});
it('recovers from errors thrown by submit action', async () => {
const f = form(signal(0), {injector: TestBed.inject(Injector)});
expect(f().submitting()).toBe(false);
const {promise, reject} = promiseWithResolvers<ValidationError[]>();
const submitPromise = submit(f, () => promise);
expect(f().submitting()).toBe(true);
const error = new Error('submit failed');
reject(error);
await expectAsync(submitPromise).toBeRejectedWith(error);
expect(f().submitting()).toBe(false);
});
it('errors are cleared on edit', async () => {
const data = signal({first: '', last: ''});
const f = form(data, {injector: TestBed.inject(Injector)});
await submit(f, async (form) => {
return [
{kind: 'submit', fieldTree: f.first},
{kind: 'submit', fieldTree: f.last},
];
});
expect(f.first().errors()).toEqual([{kind: 'submit', fieldTree: f.first}]);
expect(f.last().errors()).toEqual([{kind: 'submit', fieldTree: f.last}]);
f.first().value.set('Hello');
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);
});
});
/**
* Replace with `Promise.withResolvers()` once it's available.
*
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers.
*/
function promiseWithResolvers<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: any) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {promise, resolve, reject};
}