Skip to content

Commit 95ecce8

Browse files
mmalerbaleonsenft
authored andcommitted
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().
1 parent dd208ca commit 95ecce8

File tree

10 files changed

+152
-56
lines changed

10 files changed

+152
-56
lines changed

adev/src/content/examples/signal-forms/src/login-validation-complete/app/app.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
19
import {ChangeDetectionStrategy, Component, signal} from '@angular/core';
210
import {email, form, FormField, required, submit} from '@angular/forms/signals';
311

@@ -28,11 +36,13 @@ export class App {
2836

2937
onSubmit(event: Event) {
3038
event.preventDefault();
31-
submit(this.loginForm, async () => {
32-
const credentials = this.loginModel();
33-
// In a real app, this would be async:
34-
// await this.authService.login(credentials);
35-
console.log('Logging in with:', credentials);
39+
submit(this.loginForm, {
40+
action: async () => {
41+
const credentials = this.loginModel();
42+
// In a real app, this would be async:
43+
// await this.authService.login(credentials);
44+
console.log('Logging in with:', credentials);
45+
},
3646
});
3747
}
3848
}

goldens/public-api/forms/signals/compat/index.api.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ import { WritableSignal } from '@angular/core';
2222
export function compatForm<TModel>(model: WritableSignal<TModel>): FieldTree<TModel>;
2323

2424
// @public
25-
export function compatForm<TModel>(model: WritableSignal<TModel>, schemaOrOptions: SchemaOrSchemaFn<TModel> | CompatFormOptions): FieldTree<TModel>;
25+
export function compatForm<TModel>(model: WritableSignal<TModel>, schemaOrOptions: SchemaOrSchemaFn<TModel> | CompatFormOptions<TModel>): FieldTree<TModel>;
2626

2727
// @public
28-
export function compatForm<TModel>(model: WritableSignal<TModel>, schema: SchemaOrSchemaFn<TModel>, options: CompatFormOptions): FieldTree<TModel>;
28+
export function compatForm<TModel>(model: WritableSignal<TModel>, schema: SchemaOrSchemaFn<TModel>, options: CompatFormOptions<TModel>): FieldTree<TModel>;
2929

3030
// @public
31-
export type CompatFormOptions = Omit<FormOptions, 'adapter'>;
31+
export type CompatFormOptions<TModel> = Omit<FormOptions<TModel>, 'adapter'>;
3232

3333
// @public
3434
export class CompatValidationError<T = unknown> implements ValidationError {
@@ -54,7 +54,7 @@ export const NG_STATUS_CLASSES: SignalFormsConfig['classes'];
5454

5555
// @public
5656
export class SignalFormControl<T> extends AbstractControl {
57-
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions, options?: FormOptions);
57+
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions<T>, options?: FormOptions<T>);
5858
// (undocumented)
5959
addAsyncValidators(_validators: any): void;
6060
// (undocumented)

goldens/public-api/forms/signals/index.api.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,10 @@ export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> =
169169
export function form<TModel>(model: WritableSignal<TModel>): FieldTree<TModel>;
170170

171171
// @public
172-
export function form<TModel>(model: WritableSignal<TModel>, schemaOrOptions: SchemaOrSchemaFn<TModel> | FormOptions): FieldTree<TModel>;
172+
export function form<TModel>(model: WritableSignal<TModel>, schemaOrOptions: SchemaOrSchemaFn<TModel> | FormOptions<TModel>): FieldTree<TModel>;
173173

174174
// @public
175-
export function form<TModel>(model: WritableSignal<TModel>, schema: SchemaOrSchemaFn<TModel>, options: FormOptions): FieldTree<TModel>;
175+
export function form<TModel>(model: WritableSignal<TModel>, schema: SchemaOrSchemaFn<TModel>, options: FormOptions<TModel>): FieldTree<TModel>;
176176

177177
// @public
178178
export const FORM_FIELD: InjectionToken<FormField<unknown>>;
@@ -208,9 +208,10 @@ export interface FormFieldBindingOptions {
208208
}
209209

210210
// @public
211-
export interface FormOptions {
211+
export interface FormOptions<TModel> {
212212
injector?: Injector;
213213
name?: string;
214+
submission?: FormSubmitOptions<TModel>;
214215
}
215216

216217
// @public
@@ -559,7 +560,10 @@ export type Subfields<TModel> = {
559560
};
560561

561562
// @public
562-
export function submit<TModel>(form: FieldTree<TModel>, options: FormSubmitOptions<TModel>): Promise<boolean>;
563+
export function submit<TModel>(form: FieldTree<TModel>, options?: FormSubmitOptions<TModel>): Promise<boolean>;
564+
565+
// @public (undocumented)
566+
export function submit<TModel>(form: FieldTree<TModel>, action: FormSubmitOptions<TModel>['action']): Promise<boolean>;
563567

564568
// @public
565569
export type TreeValidationResult<E extends ValidationError.WithOptionalFieldTree = ValidationError.WithOptionalFieldTree> = ValidationSuccess | OneOrMany<E>;

packages/forms/signals/compat/src/api/compat_form.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {CompatFieldAdapter} from '../compat_field_adapter';
1818
* @category interop
1919
* @experimental 21.0.0
2020
*/
21-
export type CompatFormOptions = Omit<FormOptions, 'adapter'>;
21+
export type CompatFormOptions<TModel> = Omit<FormOptions<TModel>, 'adapter'>;
2222

2323
/**
2424
* Creates a compatibility form wrapped around the given model data.
@@ -85,7 +85,7 @@ export function compatForm<TModel>(model: WritableSignal<TModel>): FieldTree<TMo
8585
*/
8686
export function compatForm<TModel>(
8787
model: WritableSignal<TModel>,
88-
schemaOrOptions: SchemaOrSchemaFn<TModel> | CompatFormOptions,
88+
schemaOrOptions: SchemaOrSchemaFn<TModel> | CompatFormOptions<TModel>,
8989
): FieldTree<TModel>;
9090

9191
/**
@@ -122,7 +122,7 @@ export function compatForm<TModel>(
122122
export function compatForm<TModel>(
123123
model: WritableSignal<TModel>,
124124
schema: SchemaOrSchemaFn<TModel>,
125-
options: CompatFormOptions,
125+
options: CompatFormOptions<TModel>,
126126
): FieldTree<TModel>;
127127

128128
export function compatForm<TModel>(...args: any[]): FieldTree<TModel> {

packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,36 @@
77
*/
88

99
import {
10+
effect,
1011
EventEmitter,
1112
inject,
1213
Injector,
14+
ɵRuntimeError as RuntimeError,
1315
signal,
14-
WritableSignal,
15-
effect,
1616
untracked,
17-
ɵRuntimeError as RuntimeError,
17+
WritableSignal,
1818
} from '@angular/core';
1919
import {
2020
AbstractControl,
2121
ControlEvent,
2222
FormArray,
23-
FormControlStatus,
2423
FormControlState,
24+
FormControlStatus,
2525
FormGroup,
26+
FormResetEvent,
2627
PristineChangeEvent,
2728
StatusChangeEvent,
2829
TouchedChangeEvent,
2930
ValueChangeEvent,
30-
FormResetEvent,
3131
} from '@angular/forms';
3232

33-
import {compatForm} from '../api/compat_form';
34-
import {signalErrorsToValidationErrors} from '../../../src/compat/validation_errors';
3533
import {FormOptions} from '../../../src/api/structure';
3634
import {FieldState, FieldTree, SchemaFn} from '../../../src/api/types';
35+
import {signalErrorsToValidationErrors} from '../../../src/compat/validation_errors';
3736
import {RuntimeErrorCode} from '../../../src/errors';
38-
import {normalizeFormArgs} from '../../../src/util/normalize_form_args';
3937
import {FieldNode} from '../../../src/field/node';
38+
import {normalizeFormArgs} from '../../../src/util/normalize_form_args';
39+
import {compatForm} from '../api/compat_form';
4040

4141
/** Options used to update the control value. */
4242
export type ValueUpdateOptions = {
@@ -93,7 +93,7 @@ export class SignalFormControl<T> extends AbstractControl {
9393
override readonly valueChanges = new EventEmitter<T>();
9494
override readonly statusChanges = new EventEmitter<FormControlStatus>();
9595

96-
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions, options?: FormOptions) {
96+
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions<T>, options?: FormOptions<T>) {
9797
super(null, null);
9898

9999
const [model, schema, opts] = normalizeFormArgs<T>([signal(value), schemaOrOptions, options]);

packages/forms/signals/src/api/structure.ts

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {inject, Injector, runInInjectionContext, untracked, WritableSignal} from '@angular/core';
10-
9+
import {
10+
inject,
11+
Injector,
12+
runInInjectionContext,
13+
ɵRuntimeError as RuntimeError,
14+
untracked,
15+
WritableSignal,
16+
} from '@angular/core';
17+
import {RuntimeErrorCode} from '../errors';
1118
import {BasicFieldAdapter, FieldAdapter} from '../field/field_adapter';
1219
import {FormFieldManager} from '../field/manager';
1320
import {FieldNode} from '../field/node';
@@ -56,14 +63,16 @@ export interface FormSubmitOptions<TModel> {
5663
* @category structure
5764
* @experimental 21.0.0
5865
*/
59-
export interface FormOptions {
66+
export interface FormOptions<TModel> {
6067
/**
6168
* The injector to use for dependency injection. If this is not provided, the injector for the
6269
* current [injection context](guide/di/dependency-injection-context), will be used.
6370
*/
6471
injector?: Injector;
6572
/** The name of the root form, used in generating name attributes for the fields. */
6673
name?: string;
74+
/** Options that define how to handle form submission. */
75+
submission?: FormSubmitOptions<TModel>;
6776

6877
/**
6978
* Adapter allows managing fields in a more flexible way.
@@ -148,7 +157,7 @@ export function form<TModel>(model: WritableSignal<TModel>): FieldTree<TModel>;
148157
*/
149158
export function form<TModel>(
150159
model: WritableSignal<TModel>,
151-
schemaOrOptions: SchemaOrSchemaFn<TModel> | FormOptions,
160+
schemaOrOptions: SchemaOrSchemaFn<TModel> | FormOptions<TModel>,
152161
): FieldTree<TModel>;
153162

154163
/**
@@ -197,14 +206,18 @@ export function form<TModel>(
197206
export function form<TModel>(
198207
model: WritableSignal<TModel>,
199208
schema: SchemaOrSchemaFn<TModel>,
200-
options: FormOptions,
209+
options: FormOptions<TModel>,
201210
): FieldTree<TModel>;
202211

203212
export function form<TModel>(...args: any[]): FieldTree<TModel> {
204213
const [model, schema, options] = normalizeFormArgs<TModel>(args);
205214
const injector = options?.injector ?? inject(Injector);
206215
const pathNode = runInInjectionContext(injector, () => SchemaImpl.rootCompile(schema));
207-
const fieldManager = new FormFieldManager(injector, options?.name);
216+
const fieldManager = new FormFieldManager(
217+
injector,
218+
options?.name,
219+
options?.submission as FormSubmitOptions<unknown> | undefined,
220+
);
208221
const adapter = options?.adapter ?? new BasicFieldAdapter();
209222
const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter);
210223
fieldManager.createFieldManagementEffect(fieldRoot.structure);
@@ -389,38 +402,62 @@ export function applyWhenValue(
389402
*/
390403
export async function submit<TModel>(
391404
form: FieldTree<TModel>,
392-
options: FormSubmitOptions<TModel>,
405+
options?: FormSubmitOptions<TModel>,
406+
): Promise<boolean>;
407+
export async function submit<TModel>(
408+
form: FieldTree<TModel>,
409+
action: FormSubmitOptions<TModel>['action'],
410+
): Promise<boolean>;
411+
export async function submit<TModel>(
412+
form: FieldTree<TModel>,
413+
options?: FormSubmitOptions<TModel> | FormSubmitOptions<TModel>['action'],
393414
): Promise<boolean> {
394-
return untracked(async () => {
395-
const {action, onInvalid} = options;
396-
const ignoreValidators = options.ignoreValidators ?? 'pending';
397-
const node = form() as unknown as FieldNode;
415+
const node = form() as unknown as FieldNode;
416+
const opts =
417+
typeof options === 'function'
418+
? {action: options}
419+
: ({
420+
...(node.structure.fieldManager.submitOptions ?? {}),
421+
...(options ?? {}),
422+
} as Partial<FormSubmitOptions<TModel>>);
423+
const action = opts?.action;
424+
if (!action) {
425+
throw new RuntimeError(
426+
RuntimeErrorCode.MISSING_SUBMIT_ACTION,
427+
ngDevMode &&
428+
'Cannot submit form with no submit action. Specify the action when creating the form, or as an additional argument to `submit()`.',
429+
);
430+
}
431+
432+
const onInvalid = opts?.onInvalid;
433+
const ignoreValidators = opts?.ignoreValidators ?? 'pending';
398434

435+
// Determine whether or not to run the action based on the current validity.
436+
let shouldRunAction = true;
437+
untracked(() => {
399438
markAllAsTouched(node);
400439

401-
// Determine whether or not to run the action based on the current validity.
402-
let shouldRunAction = true;
403440
if (ignoreValidators === 'none') {
404441
shouldRunAction = node.valid();
405442
} else if (ignoreValidators === 'pending') {
406443
shouldRunAction = !node.invalid();
407444
}
445+
});
408446

409-
// Run the action (or alternatively the `onInvalid` callback)
410-
try {
411-
if (shouldRunAction) {
412-
node.submitState.selfSubmitting.set(true);
413-
const errors = await action(form);
414-
errors && setSubmissionErrors(node, errors);
415-
return !errors || (isArray(errors) && errors.length === 0);
416-
} else if (onInvalid) {
417-
onInvalid(form);
418-
}
419-
return false;
420-
} finally {
421-
node.submitState.selfSubmitting.set(false);
447+
// Run the action (or alternatively the `onInvalid` callback)
448+
try {
449+
if (shouldRunAction) {
450+
node.submitState.selfSubmitting.set(true);
451+
const errors = await untracked(() => action?.(form));
452+
errors && setSubmissionErrors(node, errors);
453+
return !errors || (isArray(errors) && errors.length === 0);
454+
} else {
455+
untracked(() => onInvalid?.(form));
422456
}
423-
});
457+
return false;
458+
} finally {
459+
node.submitState.selfSubmitting.set(false);
460+
}
424461
}
425462

426463
/**

packages/forms/signals/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@ export const enum RuntimeErrorCode {
2727
MANAGED_METADATA_LAZY_CREATION = 1912,
2828
BINDING_ALREADY_REGISTERED = 1913,
2929
INVALID_FIELD_DIRECTIVE_HOST = 1914,
30+
MISSING_SUBMIT_ACTION = 1915,
3031
UNSUPPORTED_FEATURE = 1920,
3132
}

packages/forms/signals/src/field/manager.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {APP_ID, effect, Injector, untracked} from '@angular/core';
10+
import type {FormSubmitOptions} from '../api/structure';
1011
import type {FieldNodeStructure} from './structure';
1112

1213
/**
@@ -17,12 +18,18 @@ import type {FieldNodeStructure} from './structure';
1718
* destroyed, which is the job of the `FormFieldManager`.
1819
*/
1920
export class FormFieldManager {
21+
readonly injector: Injector;
2022
readonly rootName: string;
23+
readonly submitOptions: FormSubmitOptions<unknown> | undefined;
24+
2125
constructor(
22-
readonly injector: Injector,
26+
injector: Injector,
2327
rootName: string | undefined,
28+
submitOptions: FormSubmitOptions<unknown> | undefined,
2429
) {
30+
this.injector = injector;
2531
this.rootName = rootName ?? `${this.injector.get(APP_ID)}.form${nextFormId++}`;
32+
this.submitOptions = submitOptions;
2633
}
2734

2835
/**

packages/forms/signals/src/util/normalize_form_args.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,19 @@
77
*/
88

99
import type {WritableSignal} from '@angular/core';
10-
import type {SchemaOrSchemaFn} from '../api/types';
1110
import type {FormOptions} from '../api/structure';
11+
import type {SchemaOrSchemaFn} from '../api/types';
1212
import {isSchemaOrSchemaFn} from '../schema/schema';
1313

1414
/**
1515
* Extracts the model, schema, and options from the arguments passed to `form()`.
1616
*/
1717
export function normalizeFormArgs<TModel>(
1818
args: any[],
19-
): [WritableSignal<TModel>, SchemaOrSchemaFn<TModel> | undefined, FormOptions | undefined] {
19+
): [WritableSignal<TModel>, SchemaOrSchemaFn<TModel> | undefined, FormOptions<TModel> | undefined] {
2020
let model: WritableSignal<TModel>;
2121
let schema: SchemaOrSchemaFn<TModel> | undefined;
22-
let options: FormOptions | undefined;
22+
let options: FormOptions<TModel> | undefined;
2323

2424
if (args.length === 3) {
2525
[model, schema, options] = args;

0 commit comments

Comments
 (0)