From 95ecce8334299defe55fb2b74264e5258ffd137c Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 20 Jan 2026 10:03:36 -0800 Subject: [PATCH] feat(forms): allow setting submit options at form-level Updates FormOptions to accept a submission configuration object. This allows defining default submit options (action, validation behavior, etc.) when creating the form, which can be overridden when calling submit(). --- .../src/login-validation-complete/app/app.ts | 20 +++- .../forms/signals/compat/index.api.md | 8 +- goldens/public-api/forms/signals/index.api.md | 12 ++- .../signals/compat/src/api/compat_form.ts | 6 +- .../signal_form_control.ts | 20 ++-- packages/forms/signals/src/api/structure.ts | 93 +++++++++++++------ packages/forms/signals/src/errors.ts | 1 + packages/forms/signals/src/field/manager.ts | 9 +- .../signals/src/util/normalize_form_args.ts | 6 +- .../forms/signals/test/node/submit.spec.ts | 37 ++++++++ 10 files changed, 154 insertions(+), 58 deletions(-) diff --git a/adev/src/content/examples/signal-forms/src/login-validation-complete/app/app.ts b/adev/src/content/examples/signal-forms/src/login-validation-complete/app/app.ts index 03317f7c562..d8599589eff 100644 --- a/adev/src/content/examples/signal-forms/src/login-validation-complete/app/app.ts +++ b/adev/src/content/examples/signal-forms/src/login-validation-complete/app/app.ts @@ -1,3 +1,11 @@ +/** + * @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 {ChangeDetectionStrategy, Component, signal} from '@angular/core'; import {email, form, FormField, required, submit} from '@angular/forms/signals'; @@ -28,11 +36,13 @@ export class App { onSubmit(event: Event) { event.preventDefault(); - submit(this.loginForm, async () => { - const credentials = this.loginModel(); - // In a real app, this would be async: - // await this.authService.login(credentials); - console.log('Logging in with:', credentials); + submit(this.loginForm, { + action: async () => { + const credentials = this.loginModel(); + // In a real app, this would be async: + // await this.authService.login(credentials); + console.log('Logging in with:', credentials); + }, }); } } diff --git a/goldens/public-api/forms/signals/compat/index.api.md b/goldens/public-api/forms/signals/compat/index.api.md index 96edbc3ca65..84c6a9bdb83 100644 --- a/goldens/public-api/forms/signals/compat/index.api.md +++ b/goldens/public-api/forms/signals/compat/index.api.md @@ -22,13 +22,13 @@ import { WritableSignal } from '@angular/core'; export function compatForm(model: WritableSignal): FieldTree; // @public -export function compatForm(model: WritableSignal, schemaOrOptions: SchemaOrSchemaFn | CompatFormOptions): FieldTree; +export function compatForm(model: WritableSignal, schemaOrOptions: SchemaOrSchemaFn | CompatFormOptions): FieldTree; // @public -export function compatForm(model: WritableSignal, schema: SchemaOrSchemaFn, options: CompatFormOptions): FieldTree; +export function compatForm(model: WritableSignal, schema: SchemaOrSchemaFn, options: CompatFormOptions): FieldTree; // @public -export type CompatFormOptions = Omit; +export type CompatFormOptions = Omit, 'adapter'>; // @public export class CompatValidationError implements ValidationError { @@ -54,7 +54,7 @@ export const NG_STATUS_CLASSES: SignalFormsConfig['classes']; // @public export class SignalFormControl extends AbstractControl { - constructor(value: T, schemaOrOptions?: SchemaFn | FormOptions, options?: FormOptions); + constructor(value: T, schemaOrOptions?: SchemaFn | FormOptions, options?: FormOptions); // (undocumented) addAsyncValidators(_validators: any): void; // (undocumented) diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 51cd5546211..0a8bb633ce5 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -169,10 +169,10 @@ export type FieldValidator = export function form(model: WritableSignal): FieldTree; // @public -export function form(model: WritableSignal, schemaOrOptions: SchemaOrSchemaFn | FormOptions): FieldTree; +export function form(model: WritableSignal, schemaOrOptions: SchemaOrSchemaFn | FormOptions): FieldTree; // @public -export function form(model: WritableSignal, schema: SchemaOrSchemaFn, options: FormOptions): FieldTree; +export function form(model: WritableSignal, schema: SchemaOrSchemaFn, options: FormOptions): FieldTree; // @public export const FORM_FIELD: InjectionToken>; @@ -208,9 +208,10 @@ export interface FormFieldBindingOptions { } // @public -export interface FormOptions { +export interface FormOptions { injector?: Injector; name?: string; + submission?: FormSubmitOptions; } // @public @@ -559,7 +560,10 @@ export type Subfields = { }; // @public -export function submit(form: FieldTree, options: FormSubmitOptions): Promise; +export function submit(form: FieldTree, options?: FormSubmitOptions): Promise; + +// @public (undocumented) +export function submit(form: FieldTree, action: FormSubmitOptions['action']): Promise; // @public export type TreeValidationResult = ValidationSuccess | OneOrMany; diff --git a/packages/forms/signals/compat/src/api/compat_form.ts b/packages/forms/signals/compat/src/api/compat_form.ts index d213fda1d42..231c7424a13 100644 --- a/packages/forms/signals/compat/src/api/compat_form.ts +++ b/packages/forms/signals/compat/src/api/compat_form.ts @@ -18,7 +18,7 @@ import {CompatFieldAdapter} from '../compat_field_adapter'; * @category interop * @experimental 21.0.0 */ -export type CompatFormOptions = Omit; +export type CompatFormOptions = Omit, 'adapter'>; /** * Creates a compatibility form wrapped around the given model data. @@ -85,7 +85,7 @@ export function compatForm(model: WritableSignal): FieldTree( model: WritableSignal, - schemaOrOptions: SchemaOrSchemaFn | CompatFormOptions, + schemaOrOptions: SchemaOrSchemaFn | CompatFormOptions, ): FieldTree; /** @@ -122,7 +122,7 @@ export function compatForm( export function compatForm( model: WritableSignal, schema: SchemaOrSchemaFn, - options: CompatFormOptions, + options: CompatFormOptions, ): FieldTree; export function compatForm(...args: any[]): FieldTree { diff --git a/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts b/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts index e0536fdcdd7..4537998a031 100644 --- a/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts +++ b/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts @@ -7,36 +7,36 @@ */ import { + effect, EventEmitter, inject, Injector, - signal, - WritableSignal, - effect, - untracked, ɵRuntimeError as RuntimeError, + signal, + untracked, + WritableSignal, } from '@angular/core'; import { AbstractControl, ControlEvent, FormArray, - FormControlStatus, FormControlState, + FormControlStatus, FormGroup, + FormResetEvent, PristineChangeEvent, StatusChangeEvent, TouchedChangeEvent, ValueChangeEvent, - FormResetEvent, } from '@angular/forms'; -import {compatForm} from '../api/compat_form'; -import {signalErrorsToValidationErrors} from '../../../src/compat/validation_errors'; import {FormOptions} from '../../../src/api/structure'; import {FieldState, FieldTree, SchemaFn} from '../../../src/api/types'; +import {signalErrorsToValidationErrors} from '../../../src/compat/validation_errors'; import {RuntimeErrorCode} from '../../../src/errors'; -import {normalizeFormArgs} from '../../../src/util/normalize_form_args'; import {FieldNode} from '../../../src/field/node'; +import {normalizeFormArgs} from '../../../src/util/normalize_form_args'; +import {compatForm} from '../api/compat_form'; /** Options used to update the control value. */ export type ValueUpdateOptions = { @@ -93,7 +93,7 @@ export class SignalFormControl extends AbstractControl { override readonly valueChanges = new EventEmitter(); override readonly statusChanges = new EventEmitter(); - constructor(value: T, schemaOrOptions?: SchemaFn | FormOptions, options?: FormOptions) { + constructor(value: T, schemaOrOptions?: SchemaFn | FormOptions, options?: FormOptions) { super(null, null); const [model, schema, opts] = normalizeFormArgs([signal(value), schemaOrOptions, options]); diff --git a/packages/forms/signals/src/api/structure.ts b/packages/forms/signals/src/api/structure.ts index 171c89b0a64..afd72707d1b 100644 --- a/packages/forms/signals/src/api/structure.ts +++ b/packages/forms/signals/src/api/structure.ts @@ -6,8 +6,15 @@ * found in the LICENSE file at https://angular.dev/license */ -import {inject, Injector, runInInjectionContext, untracked, WritableSignal} from '@angular/core'; - +import { + inject, + Injector, + runInInjectionContext, + ɵRuntimeError as RuntimeError, + untracked, + WritableSignal, +} from '@angular/core'; +import {RuntimeErrorCode} from '../errors'; import {BasicFieldAdapter, FieldAdapter} from '../field/field_adapter'; import {FormFieldManager} from '../field/manager'; import {FieldNode} from '../field/node'; @@ -56,7 +63,7 @@ export interface FormSubmitOptions { * @category structure * @experimental 21.0.0 */ -export interface FormOptions { +export interface FormOptions { /** * The injector to use for dependency injection. If this is not provided, the injector for the * current [injection context](guide/di/dependency-injection-context), will be used. @@ -64,6 +71,8 @@ export interface FormOptions { injector?: Injector; /** The name of the root form, used in generating name attributes for the fields. */ name?: string; + /** Options that define how to handle form submission. */ + submission?: FormSubmitOptions; /** * Adapter allows managing fields in a more flexible way. @@ -148,7 +157,7 @@ export function form(model: WritableSignal): FieldTree; */ export function form( model: WritableSignal, - schemaOrOptions: SchemaOrSchemaFn | FormOptions, + schemaOrOptions: SchemaOrSchemaFn | FormOptions, ): FieldTree; /** @@ -197,14 +206,18 @@ export function form( export function form( model: WritableSignal, schema: SchemaOrSchemaFn, - options: FormOptions, + options: FormOptions, ): FieldTree; export function form(...args: any[]): FieldTree { const [model, schema, options] = normalizeFormArgs(args); const injector = options?.injector ?? inject(Injector); const pathNode = runInInjectionContext(injector, () => SchemaImpl.rootCompile(schema)); - const fieldManager = new FormFieldManager(injector, options?.name); + const fieldManager = new FormFieldManager( + injector, + options?.name, + options?.submission as FormSubmitOptions | undefined, + ); const adapter = options?.adapter ?? new BasicFieldAdapter(); const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter); fieldManager.createFieldManagementEffect(fieldRoot.structure); @@ -389,38 +402,62 @@ export function applyWhenValue( */ export async function submit( form: FieldTree, - options: FormSubmitOptions, + options?: FormSubmitOptions, +): Promise; +export async function submit( + form: FieldTree, + action: FormSubmitOptions['action'], +): Promise; +export async function submit( + form: FieldTree, + options?: FormSubmitOptions | FormSubmitOptions['action'], ): Promise { - return untracked(async () => { - const {action, onInvalid} = options; - const ignoreValidators = options.ignoreValidators ?? 'pending'; - const node = form() as unknown as FieldNode; + const node = form() as unknown as FieldNode; + const opts = + typeof options === 'function' + ? {action: options} + : ({ + ...(node.structure.fieldManager.submitOptions ?? {}), + ...(options ?? {}), + } as Partial>); + const action = opts?.action; + if (!action) { + throw new RuntimeError( + RuntimeErrorCode.MISSING_SUBMIT_ACTION, + ngDevMode && + 'Cannot submit form with no submit action. Specify the action when creating the form, or as an additional argument to `submit()`.', + ); + } + const onInvalid = opts?.onInvalid; + const ignoreValidators = opts?.ignoreValidators ?? 'pending'; + + // Determine whether or not to run the action based on the current validity. + let shouldRunAction = true; + untracked(() => { markAllAsTouched(node); - // Determine whether or not to run the action based on the current validity. - let shouldRunAction = true; if (ignoreValidators === 'none') { shouldRunAction = node.valid(); } else if (ignoreValidators === 'pending') { shouldRunAction = !node.invalid(); } - - // Run the action (or alternatively the `onInvalid` callback) - try { - if (shouldRunAction) { - node.submitState.selfSubmitting.set(true); - const errors = await action(form); - errors && setSubmissionErrors(node, errors); - return !errors || (isArray(errors) && errors.length === 0); - } else if (onInvalid) { - onInvalid(form); - } - return false; - } finally { - node.submitState.selfSubmitting.set(false); - } }); + + // Run the action (or alternatively the `onInvalid` callback) + try { + if (shouldRunAction) { + node.submitState.selfSubmitting.set(true); + const errors = await untracked(() => action?.(form)); + errors && setSubmissionErrors(node, errors); + return !errors || (isArray(errors) && errors.length === 0); + } else { + untracked(() => onInvalid?.(form)); + } + return false; + } finally { + node.submitState.selfSubmitting.set(false); + } } /** diff --git a/packages/forms/signals/src/errors.ts b/packages/forms/signals/src/errors.ts index 5cb60fdc615..3159d428a46 100644 --- a/packages/forms/signals/src/errors.ts +++ b/packages/forms/signals/src/errors.ts @@ -27,5 +27,6 @@ export const enum RuntimeErrorCode { MANAGED_METADATA_LAZY_CREATION = 1912, BINDING_ALREADY_REGISTERED = 1913, INVALID_FIELD_DIRECTIVE_HOST = 1914, + MISSING_SUBMIT_ACTION = 1915, UNSUPPORTED_FEATURE = 1920, } diff --git a/packages/forms/signals/src/field/manager.ts b/packages/forms/signals/src/field/manager.ts index 2f9c8f8e0c2..638d6679b23 100644 --- a/packages/forms/signals/src/field/manager.ts +++ b/packages/forms/signals/src/field/manager.ts @@ -7,6 +7,7 @@ */ import {APP_ID, effect, Injector, untracked} from '@angular/core'; +import type {FormSubmitOptions} from '../api/structure'; import type {FieldNodeStructure} from './structure'; /** @@ -17,12 +18,18 @@ import type {FieldNodeStructure} from './structure'; * destroyed, which is the job of the `FormFieldManager`. */ export class FormFieldManager { + readonly injector: Injector; readonly rootName: string; + readonly submitOptions: FormSubmitOptions | undefined; + constructor( - readonly injector: Injector, + injector: Injector, rootName: string | undefined, + submitOptions: FormSubmitOptions | undefined, ) { + this.injector = injector; this.rootName = rootName ?? `${this.injector.get(APP_ID)}.form${nextFormId++}`; + this.submitOptions = submitOptions; } /** diff --git a/packages/forms/signals/src/util/normalize_form_args.ts b/packages/forms/signals/src/util/normalize_form_args.ts index cce22c9f26c..91f3d058573 100644 --- a/packages/forms/signals/src/util/normalize_form_args.ts +++ b/packages/forms/signals/src/util/normalize_form_args.ts @@ -7,8 +7,8 @@ */ import type {WritableSignal} from '@angular/core'; -import type {SchemaOrSchemaFn} from '../api/types'; import type {FormOptions} from '../api/structure'; +import type {SchemaOrSchemaFn} from '../api/types'; import {isSchemaOrSchemaFn} from '../schema/schema'; /** @@ -16,10 +16,10 @@ import {isSchemaOrSchemaFn} from '../schema/schema'; */ export function normalizeFormArgs( args: any[], -): [WritableSignal, SchemaOrSchemaFn | undefined, FormOptions | undefined] { +): [WritableSignal, SchemaOrSchemaFn | undefined, FormOptions | undefined] { let model: WritableSignal; let schema: SchemaOrSchemaFn | undefined; - let options: FormOptions | undefined; + let options: FormOptions | undefined; if (args.length === 3) { [model, schema, options] = args; diff --git a/packages/forms/signals/test/node/submit.spec.ts b/packages/forms/signals/test/node/submit.spec.ts index e6cbcf97b3d..ac060d26e7e 100644 --- a/packages/forms/signals/test/node/submit.spec.ts +++ b/packages/forms/signals/test/node/submit.spec.ts @@ -553,6 +553,43 @@ describe('submit', () => { // 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(); + }); }); /**