angular/packages/forms/signals/test/node/submit.spec.ts
Leon Senft 01bfb83fc9 test(forms): submit behavior while validation is pending
Ensure `submit()` behaves as expected while a form is pending.

- Submission is not blocked by pending validation.
- Submission errors prevent pending validation errors from appearing
  after they resolve on the same field.
- Submission errors don't prevent pending validation errors from
  appearing after they resolve on subfields.
2026-01-28 00:15:29 +00:00

385 lines
11 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 {
form,
required,
requiredError,
submit,
validateAsync,
ValidationError,
} 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}]);
});
});
/**
* 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};
}