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().
This commit is contained in:
Miles Malerba
2026-01-20 10:03:36 -08:00
committed by Leon Senft
parent dd208ca259
commit 95ecce8334
10 changed files with 154 additions and 58 deletions
@@ -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);
},
});
}
}
@@ -22,13 +22,13 @@ import { WritableSignal } from '@angular/core';
export function compatForm<TModel>(model: WritableSignal<TModel>): FieldTree<TModel>;
// @public
export function compatForm<TModel>(model: WritableSignal<TModel>, schemaOrOptions: SchemaOrSchemaFn<TModel> | CompatFormOptions): FieldTree<TModel>;
export function compatForm<TModel>(model: WritableSignal<TModel>, schemaOrOptions: SchemaOrSchemaFn<TModel> | CompatFormOptions<TModel>): FieldTree<TModel>;
// @public
export function compatForm<TModel>(model: WritableSignal<TModel>, schema: SchemaOrSchemaFn<TModel>, options: CompatFormOptions): FieldTree<TModel>;
export function compatForm<TModel>(model: WritableSignal<TModel>, schema: SchemaOrSchemaFn<TModel>, options: CompatFormOptions<TModel>): FieldTree<TModel>;
// @public
export type CompatFormOptions = Omit<FormOptions, 'adapter'>;
export type CompatFormOptions<TModel> = Omit<FormOptions<TModel>, 'adapter'>;
// @public
export class CompatValidationError<T = unknown> implements ValidationError {
@@ -54,7 +54,7 @@ export const NG_STATUS_CLASSES: SignalFormsConfig['classes'];
// @public
export class SignalFormControl<T> extends AbstractControl {
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions, options?: FormOptions);
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions<T>, options?: FormOptions<T>);
// (undocumented)
addAsyncValidators(_validators: any): void;
// (undocumented)
@@ -169,10 +169,10 @@ export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> =
export function form<TModel>(model: WritableSignal<TModel>): FieldTree<TModel>;
// @public
export function form<TModel>(model: WritableSignal<TModel>, schemaOrOptions: SchemaOrSchemaFn<TModel> | FormOptions): FieldTree<TModel>;
export function form<TModel>(model: WritableSignal<TModel>, schemaOrOptions: SchemaOrSchemaFn<TModel> | FormOptions<TModel>): FieldTree<TModel>;
// @public
export function form<TModel>(model: WritableSignal<TModel>, schema: SchemaOrSchemaFn<TModel>, options: FormOptions): FieldTree<TModel>;
export function form<TModel>(model: WritableSignal<TModel>, schema: SchemaOrSchemaFn<TModel>, options: FormOptions<TModel>): FieldTree<TModel>;
// @public
export const FORM_FIELD: InjectionToken<FormField<unknown>>;
@@ -208,9 +208,10 @@ export interface FormFieldBindingOptions {
}
// @public
export interface FormOptions {
export interface FormOptions<TModel> {
injector?: Injector;
name?: string;
submission?: FormSubmitOptions<TModel>;
}
// @public
@@ -559,7 +560,10 @@ export type Subfields<TModel> = {
};
// @public
export function submit<TModel>(form: FieldTree<TModel>, options: FormSubmitOptions<TModel>): Promise<boolean>;
export function submit<TModel>(form: FieldTree<TModel>, options?: FormSubmitOptions<TModel>): Promise<boolean>;
// @public (undocumented)
export function submit<TModel>(form: FieldTree<TModel>, action: FormSubmitOptions<TModel>['action']): Promise<boolean>;
// @public
export type TreeValidationResult<E extends ValidationError.WithOptionalFieldTree = ValidationError.WithOptionalFieldTree> = ValidationSuccess | OneOrMany<E>;
@@ -18,7 +18,7 @@ import {CompatFieldAdapter} from '../compat_field_adapter';
* @category interop
* @experimental 21.0.0
*/
export type CompatFormOptions = Omit<FormOptions, 'adapter'>;
export type CompatFormOptions<TModel> = Omit<FormOptions<TModel>, 'adapter'>;
/**
* Creates a compatibility form wrapped around the given model data.
@@ -85,7 +85,7 @@ export function compatForm<TModel>(model: WritableSignal<TModel>): FieldTree<TMo
*/
export function compatForm<TModel>(
model: WritableSignal<TModel>,
schemaOrOptions: SchemaOrSchemaFn<TModel> | CompatFormOptions,
schemaOrOptions: SchemaOrSchemaFn<TModel> | CompatFormOptions<TModel>,
): FieldTree<TModel>;
/**
@@ -122,7 +122,7 @@ export function compatForm<TModel>(
export function compatForm<TModel>(
model: WritableSignal<TModel>,
schema: SchemaOrSchemaFn<TModel>,
options: CompatFormOptions,
options: CompatFormOptions<TModel>,
): FieldTree<TModel>;
export function compatForm<TModel>(...args: any[]): FieldTree<TModel> {
@@ -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<T> extends AbstractControl {
override readonly valueChanges = new EventEmitter<T>();
override readonly statusChanges = new EventEmitter<FormControlStatus>();
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions, options?: FormOptions) {
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions<T>, options?: FormOptions<T>) {
super(null, null);
const [model, schema, opts] = normalizeFormArgs<T>([signal(value), schemaOrOptions, options]);
+65 -28
View File
@@ -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<TModel> {
* @category structure
* @experimental 21.0.0
*/
export interface FormOptions {
export interface FormOptions<TModel> {
/**
* 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<TModel>;
/**
* Adapter allows managing fields in a more flexible way.
@@ -148,7 +157,7 @@ export function form<TModel>(model: WritableSignal<TModel>): FieldTree<TModel>;
*/
export function form<TModel>(
model: WritableSignal<TModel>,
schemaOrOptions: SchemaOrSchemaFn<TModel> | FormOptions,
schemaOrOptions: SchemaOrSchemaFn<TModel> | FormOptions<TModel>,
): FieldTree<TModel>;
/**
@@ -197,14 +206,18 @@ export function form<TModel>(
export function form<TModel>(
model: WritableSignal<TModel>,
schema: SchemaOrSchemaFn<TModel>,
options: FormOptions,
options: FormOptions<TModel>,
): FieldTree<TModel>;
export function form<TModel>(...args: any[]): FieldTree<TModel> {
const [model, schema, options] = normalizeFormArgs<TModel>(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<unknown> | 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<TModel>(
form: FieldTree<TModel>,
options: FormSubmitOptions<TModel>,
options?: FormSubmitOptions<TModel>,
): Promise<boolean>;
export async function submit<TModel>(
form: FieldTree<TModel>,
action: FormSubmitOptions<TModel>['action'],
): Promise<boolean>;
export async function submit<TModel>(
form: FieldTree<TModel>,
options?: FormSubmitOptions<TModel> | FormSubmitOptions<TModel>['action'],
): Promise<boolean> {
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<FormSubmitOptions<TModel>>);
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);
}
}
/**
+1
View File
@@ -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,
}
+8 -1
View File
@@ -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<unknown> | undefined;
constructor(
readonly injector: Injector,
injector: Injector,
rootName: string | undefined,
submitOptions: FormSubmitOptions<unknown> | undefined,
) {
this.injector = injector;
this.rootName = rootName ?? `${this.injector.get(APP_ID)}.form${nextFormId++}`;
this.submitOptions = submitOptions;
}
/**
@@ -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<TModel>(
args: any[],
): [WritableSignal<TModel>, SchemaOrSchemaFn<TModel> | undefined, FormOptions | undefined] {
): [WritableSignal<TModel>, SchemaOrSchemaFn<TModel> | undefined, FormOptions<TModel> | undefined] {
let model: WritableSignal<TModel>;
let schema: SchemaOrSchemaFn<TModel> | undefined;
let options: FormOptions | undefined;
let options: FormOptions<TModel> | undefined;
if (args.length === 3) {
[model, schema, options] = args;
@@ -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();
});
});
/**