mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
Leon Senft
parent
dd208ca259
commit
95ecce8334
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user