/** * @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, hidden, readonly, required, requiredError, submit, validateAsync, ValidationError, } from '../../public_api'; describe('submit', () => { let injector: Injector; beforeEach(() => { injector = TestBed.inject(Injector); }); it('fails fast on invalid form', async () => { const data = signal({first: '', last: ''}); const f = form( data, (name) => { required(name.first); }, {injector}, ); expect( await submit(f, { action: async (form) => { fail('Submit action should run not on invalid form'); }, }), ).toBe(false); 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}, ); expect(f().pending()).toBe(true); const submitSpy = jasmine.createSpy(); expect(await submit(f, {action: submitSpy})).toBe(true); 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(); 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, {action: 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(); 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, {action: 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, {action: 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}, ); expect( await submit(f, { action: (field) => { return Promise.resolve({ kind: 'lastName', fieldTree: field.last, }); }, }), ).toBe(false); 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}); expect( await submit(f, { action: (field) => { return Promise.resolve([ { kind: 'firstName', fieldTree: field.first, }, { kind: 'lastName', fieldTree: field.last, }, { kind: 'lastName2', fieldTree: field.last, }, ]); }, }), ).toBe(false); 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}, ); const submitSpy = jasmine.createSpy('submit'); expect( await submit(f, { action: (form) => { submitSpy(form().value()); return Promise.resolve(); }, }), ).toBe(true); 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}, ); expect( await submit(f, { action: () => { return Promise.resolve({kind: 'custom'}); }, }), ).toBe(false); 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}, ); expect(f().submitting()).toBe(false); const {promise, resolve} = promiseWithResolvers(); const result = submit(f, {action: () => promise}); expect(f().submitting()).toBe(true); resolve([]); expect(await result).toBe(true); }); it('marks descendants as submitting', async () => { const initialValue = {a: {b: 12}}; const data = signal(initialValue); const f = form(data, {injector}); expect(f.a.b().submitting()).toBe(false); const {promise, resolve} = promiseWithResolvers(); const result = submit(f, {action: () => promise}); expect(f.a.b().submitting()).toBe(true); resolve([]); expect(await result).toBe(true); }); it('marks the form as touched', async () => { const initialValue = {first: 'meow', last: 'wuf'}; const data = signal(initialValue); const f = form(data, {injector}); expect(f().touched()).toBe(false); expect(await submit(f, {action: async () => []})).toBe(true); 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}); expect(f.a.b().touched()).toBe(false); expect(await submit(f, {action: async () => []})).toBe(true); 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}, ); const submitSpy = jasmine.createSpy('submit'); expect( await submit(f.first, { action: (_, {root, submitted}) => { submitSpy(root().value(), submitted().value()); return Promise.resolve({kind: 'lastName'}); }, }), ).toBe(false); expect(submitSpy).toHaveBeenCalledWith({first: 'meow', last: 'wuf'}, 'meow'); }); it('recovers from errors thrown by submit action', async () => { const f = form(signal(0), {injector}); expect(f().submitting()).toBe(false); const {promise, reject} = promiseWithResolvers(); const submitPromise = submit(f, {action: () => 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}); expect( await submit(f, { action: async (form) => { return [ {kind: 'submit', fieldTree: f.first}, {kind: 'submit', fieldTree: f.last}, ]; }, }), ).toBe(false); 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, {action: 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, {action: 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, {action: 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); }); it('calls onInvalid when form is invalid', async () => { const data = signal({first: '', last: ''}); const f = form( data, (name) => { required(name.first); }, {injector}, ); const onInvalidSpy = jasmine.createSpy('onInvalid'); expect( await submit(f, { action: async () => { fail('Submit action should run not on invalid form'); }, onInvalid: onInvalidSpy, }), ).toBe(false); expect(onInvalidSpy).toHaveBeenCalledWith(f, {root: f, submitted: f}); }); it('runs action on invalid form with ignoreValidators: all', async () => { const data = signal({first: '', last: ''}); const f = form( data, (name) => { required(name.first); }, {injector}, ); const submitSpy = jasmine.createSpy('submit'); expect( await submit(f, { action: submitSpy, ignoreValidators: 'all', }), ).toBe(true); expect(submitSpy).toHaveBeenCalled(); expect(f.first().errors()).toEqual([requiredError({fieldTree: f.first})]); }); it('fails with pending validators with ignoreValidators: none', async () => { const data = signal(''); const resolvers = promiseWithResolvers(); const f = form( data, (p) => { validateAsync(p, { params: ({value}) => value(), factory: (params) => resource({ params, loader: () => resolvers.promise, }), onSuccess: () => {}, onError: () => {}, }); }, {injector}, ); const submitSpy = jasmine.createSpy('submit'); const submitPromise = submit(f, { action: submitSpy, ignoreValidators: 'none', }); expect(f().submitting()).toBe(false); expect(submitSpy).not.toHaveBeenCalled(); expect(await submitPromise).toBe(false); // Resolve as valid resolvers.resolve(undefined); }); it('falls back to form-level submit options', async () => { const data = signal({first: '', last: ''}); const submitSpy = jasmine.createSpy('submit'); const f = form( data, (name) => { required(name.first); }, {injector, submission: {action: submitSpy}}, ); f.first().value.set('John'); expect(await submit(f)).toBe(true); expect(submitSpy).toHaveBeenCalled(); }); it('throws when no submit options are provided', async () => { const data = signal({first: ''}); const f = form(data, {injector}); await expectAsync(submit(f)).toBeRejectedWithError( /Cannot submit form with no submit action\. Specify the action when creating the form, or as an additional argument to `submit\(\)`\./, ); }); it('overrides form-level submit options', async () => { const data = signal({first: ''}); const defaultSpy = jasmine.createSpy('defaultSpy'); const overrideSpy = jasmine.createSpy('overrideSpy'); const f = form(data, {injector, submission: {action: defaultSpy}}); expect(await submit(f, {action: overrideSpy})).toBe(true); expect(defaultSpy).not.toHaveBeenCalled(); expect(overrideSpy).toHaveBeenCalled(); }); it('uses the submitted field as the contextual field for options specified at submit time', async () => { const data = signal({name: 'Alice'}); const submitSpy = jasmine.createSpy('submit'); const f = form(data, {injector}); expect( await submit(f.name, { action: async (field, {root, submitted}) => { submitSpy(field().value(), root().value(), submitted().value()); return undefined; }, }), ).toBe(true); expect(submitSpy).toHaveBeenCalledWith('Alice', {name: 'Alice'}, 'Alice'); }); it('uses the root field as the contextual field for options specified at form creation time', async () => { const data = signal({name: 'Alice'}); const submitSpy = jasmine.createSpy('submit'); const f = form(data, { injector, submission: { action: async (field, {root, submitted}) => { submitSpy(field().value(), root().value(), submitted().value()); return undefined; }, }, }); expect(await submit(f.name)).toBe(true); expect(submitSpy).toHaveBeenCalledWith({name: 'Alice'}, {name: 'Alice'}, 'Alice'); }); }); /** * 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(): { promise: Promise; resolve: (value: T | PromiseLike) => void; reject: (reason?: any) => void; } { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: any) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return {promise, resolve, reject}; }