Skip to content

Commit f56bb07

Browse files
mmalerbaAndrewKushnir
authored andcommitted
feat(forms): add field param to submit action and onInvalid
The `action` and `onInvalid` handlers now recevie two pieces of information: 1. The form that is being submitted 2. The specific field that the submit was triggered on Remove the `submit()` method on field state - supporting this is complex from a typing perspective, since the `FieldState` only knows its `TValue` type, not the `TModel` type of its owning `FieldTree`. Rather than try to pack additional generics on to `FieldState`, we'll just leave the `submit` function as a standalone importable function.
1 parent c6d7500 commit f56bb07

File tree

8 files changed

+156
-88
lines changed

8 files changed

+156
-88
lines changed

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -210,14 +210,20 @@ export interface FormFieldBindingOptions {
210210
export interface FormOptions<TModel> {
211211
injector?: Injector;
212212
name?: string;
213-
submission?: FormSubmitOptions<TModel>;
213+
submission?: FormSubmitOptions<TModel, unknown>;
214214
}
215215

216216
// @public
217-
export interface FormSubmitOptions<TModel> {
218-
action: (form: FieldTree<TModel>) => Promise<TreeValidationResult>;
217+
export interface FormSubmitOptions<TRootModel, TSubmittedModel> {
218+
action: (field: FieldTree<TRootModel & TSubmittedModel>, detail: {
219+
root: FieldTree<TRootModel>;
220+
submitted: FieldTree<TSubmittedModel>;
221+
}) => Promise<TreeValidationResult>;
219222
ignoreValidators?: 'pending' | 'none' | 'all';
220-
onInvalid?: (form: FieldTree<TModel>) => void;
223+
onInvalid?: (field: FieldTree<TRootModel & TSubmittedModel>, detail: {
224+
root: FieldTree<TRootModel>;
225+
submitted: FieldTree<TSubmittedModel>;
226+
}) => void;
221227
}
222228

223229
// @public
@@ -558,10 +564,10 @@ export type Subfields<TModel> = {
558564
};
559565

560566
// @public
561-
export function submit<TModel>(form: FieldTree<TModel>, options?: FormSubmitOptions<TModel>): Promise<boolean>;
567+
export function submit<TModel>(form: FieldTree<TModel>, options?: NoInfer<FormSubmitOptions<unknown, TModel>>): Promise<boolean>;
562568

563569
// @public (undocumented)
564-
export function submit<TModel>(form: FieldTree<TModel>, action: FormSubmitOptions<TModel>['action']): Promise<boolean>;
570+
export function submit<TModel>(form: FieldTree<TModel>, action: NoInfer<FormSubmitOptions<unknown, TModel>['action']>): Promise<boolean>;
565571

566572
// @public
567573
export function transformedValue<TValue, TRaw>(value: ModelSignal<TValue>, options: TransformedValueOptions<TValue, TRaw>): TransformedValueSignal<TRaw>;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export class SignalFormControl<T> extends AbstractControl {
108108
this.fieldTree = wrapFieldTreeForSyncUpdates(rawTree, () =>
109109
this.parent?.updateValueAndValidity({sourceControl: this} as any),
110110
);
111-
this.fieldState = this.fieldTree();
111+
this.fieldState = this.fieldTree() as FieldState<T>;
112112

113113
this.defineCompatProperties();
114114

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

Lines changed: 51 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ import {FieldPathNode} from '../schema/path_node';
2424
import {assertPathIsCurrent, SchemaImpl} from '../schema/schema';
2525
import {normalizeFormArgs} from '../util/normalize_form_args';
2626
import {isArray} from '../util/type_guards';
27-
import type {ValidationError} from './rules/validation/validation_errors';
27+
import type {ValidationError} from './rules';
2828
import type {
29+
FieldState,
2930
FieldTree,
31+
FormSubmitOptions,
3032
ItemType,
3133
LogicFn,
3234
OneOrMany,
@@ -35,28 +37,8 @@ import type {
3537
SchemaFn,
3638
SchemaOrSchemaFn,
3739
SchemaPath,
38-
TreeValidationResult,
3940
} from './types';
4041

41-
/**
42-
* Options that can be specified when submitting a form.
43-
*
44-
* @experimental 21.2.0
45-
*/
46-
export interface FormSubmitOptions<TModel> {
47-
/** Function to run when submitting the form data (when form is valid). */
48-
action: (form: FieldTree<TModel>) => Promise<TreeValidationResult>;
49-
/** Function to run when attempting to submit the form data but validation is failing. */
50-
onInvalid?: (form: FieldTree<TModel>) => void;
51-
/**
52-
* Whether to ignore any of the validators when submitting:
53-
* - 'pending': Will submit if there are no invalid validators, pending validators do not block submission (default)
54-
* - 'none': Will not submit unless all validators are passing, pending validators block submission
55-
* - 'ignore': Will always submit regardless of invalid or pending validators
56-
*/
57-
ignoreValidators?: 'pending' | 'none' | 'all';
58-
}
59-
6042
/**
6143
* Options that may be specified when creating a form.
6244
*
@@ -72,7 +54,7 @@ export interface FormOptions<TModel> {
7254
/** The name of the root form, used in generating name attributes for the fields. */
7355
name?: string;
7456
/** Options that define how to handle form submission. */
75-
submission?: FormSubmitOptions<TModel>;
57+
submission?: FormSubmitOptions<TModel, unknown>;
7658

7759
/**
7860
* Adapter allows managing fields in a more flexible way.
@@ -216,7 +198,7 @@ export function form<TModel>(...args: any[]): FieldTree<TModel> {
216198
const fieldManager = new FormFieldManager(
217199
injector,
218200
options?.name,
219-
options?.submission as FormSubmitOptions<unknown> | undefined,
201+
options?.submission as FormSubmitOptions<unknown, unknown> | undefined,
220202
);
221203
const adapter = options?.adapter ?? new BasicFieldAdapter();
222204
const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter);
@@ -402,35 +384,39 @@ export function applyWhenValue(
402384
*/
403385
export async function submit<TModel>(
404386
form: FieldTree<TModel>,
405-
options?: FormSubmitOptions<TModel>,
387+
options?: NoInfer<FormSubmitOptions<unknown, TModel>>,
406388
): Promise<boolean>;
407389
export async function submit<TModel>(
408390
form: FieldTree<TModel>,
409-
action: FormSubmitOptions<TModel>['action'],
391+
action: NoInfer<FormSubmitOptions<unknown, TModel>['action']>,
410392
): Promise<boolean>;
411393
export async function submit<TModel>(
412394
form: FieldTree<TModel>,
413-
options?: FormSubmitOptions<TModel> | FormSubmitOptions<TModel>['action'],
395+
options?: FormSubmitOptions<unknown, TModel> | FormSubmitOptions<unknown, TModel>['action'],
414396
): Promise<boolean> {
415-
const node = form() as unknown as FieldNode;
416-
const opts =
397+
const node = untracked(form) as FieldState<unknown> as FieldNode;
398+
399+
const field = options === undefined ? node.structure.root.fieldProxy : form;
400+
const detail = {root: node.structure.root.fieldProxy, submitted: form};
401+
402+
// Normalize options.
403+
options =
417404
typeof options === 'function'
418405
? {action: options}
419-
: ({
420-
...(node.structure.fieldManager.submitOptions ?? {}),
421-
...(options ?? {}),
422-
} as Partial<FormSubmitOptions<TModel>>);
423-
const action = opts?.action;
406+
: (options ?? node.structure.fieldManager.submitOptions);
407+
408+
// Verify that an action was provided.
409+
const action = options?.action as FormSubmitOptions<unknown, unknown>['action'];
424410
if (!action) {
425411
throw new RuntimeError(
426412
RuntimeErrorCode.MISSING_SUBMIT_ACTION,
427-
ngDevMode &&
413+
(typeof ngDevMode === 'undefined' || ngDevMode) &&
428414
'Cannot submit form with no submit action. Specify the action when creating the form, or as an additional argument to `submit()`.',
429415
);
430416
}
431417

432-
const onInvalid = opts?.onInvalid;
433-
const ignoreValidators = opts?.ignoreValidators ?? 'pending';
418+
const onInvalid = options?.onInvalid as FormSubmitOptions<unknown, unknown>['onInvalid'];
419+
const ignoreValidators = options?.ignoreValidators ?? 'pending';
434420

435421
// Determine whether or not to run the action based on the current validity.
436422
let shouldRunAction = true;
@@ -448,18 +434,45 @@ export async function submit<TModel>(
448434
try {
449435
if (shouldRunAction) {
450436
node.submitState.selfSubmitting.set(true);
451-
const errors = await untracked(() => action?.(form));
437+
const errors = await untracked(() => action?.(field, detail));
452438
errors && setSubmissionErrors(node, errors);
453439
return !errors || (isArray(errors) && errors.length === 0);
454440
} else {
455-
untracked(() => onInvalid?.(form));
441+
untracked(() => onInvalid?.(field, detail));
456442
}
457443
return false;
458444
} finally {
459445
node.submitState.selfSubmitting.set(false);
460446
}
461447
}
462448

449+
/**
450+
* Creates a `Schema` that adds logic rules to a form.
451+
* @param fn A **non-reactive** function that sets up reactive logic rules for the form.
452+
* @returns A schema object that implements the given logic.
453+
* @template TValue The value type of a `FieldTree` that this schema binds to.
454+
*
455+
* @category structure
456+
* @experimental 21.0.0
457+
*/
458+
export function schema<TValue>(fn: SchemaFn<TValue>): Schema<TValue> {
459+
return SchemaImpl.create(fn) as unknown as Schema<TValue>;
460+
}
461+
462+
/** Marks a {@link node} and its descendants as touched. */
463+
function markAllAsTouched(node: FieldNode) {
464+
// Don't mark hidden, disabled, or readonly fields as touched since they don't contribute to the
465+
// form's validity. This also prevents errors from appearing immediately if they're later made
466+
// interactive.
467+
if (node.validationState.shouldSkipValidation()) {
468+
return;
469+
}
470+
node.markAsTouched();
471+
for (const child of node.structure.children()) {
472+
markAllAsTouched(child);
473+
}
474+
}
475+
463476
/**
464477
* Sets a list of submission errors to their individual fields.
465478
*
@@ -488,30 +501,3 @@ function setSubmissionErrors(
488501
field.submitState.submissionErrors.set(fieldErrors);
489502
}
490503
}
491-
492-
/**
493-
* Creates a `Schema` that adds logic rules to a form.
494-
* @param fn A **non-reactive** function that sets up reactive logic rules for the form.
495-
* @returns A schema object that implements the given logic.
496-
* @template TValue The value type of a `FieldTree` that this schema binds to.
497-
*
498-
* @category structure
499-
* @experimental 21.0.0
500-
*/
501-
export function schema<TValue>(fn: SchemaFn<TValue>): Schema<TValue> {
502-
return SchemaImpl.create(fn) as unknown as Schema<TValue>;
503-
}
504-
505-
/** Marks a {@link node} and its descendants as touched. */
506-
function markAllAsTouched(node: FieldNode) {
507-
// Don't mark hidden, disabled, or readonly fields as touched since they don't contribute to the
508-
// form's validity. This also prevents errors from appearing immediately if they're later made
509-
// interactive.
510-
if (node.validationState.shouldSkipValidation()) {
511-
return;
512-
}
513-
node.markAsTouched();
514-
for (const child of node.structure.children()) {
515-
markAllAsTouched(child);
516-
}
517-
}

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,47 @@ import type {MetadataKey, ValidationError} from './rules';
1616
*/
1717
declare const ɵɵTYPE: unique symbol;
1818

19+
/**
20+
* Options that can be specified when submitting a form.
21+
*
22+
* @experimental 21.2.0
23+
*/
24+
export interface FormSubmitOptions<TRootModel, TSubmittedModel> {
25+
/**
26+
* Function to run when submitting the form data (when form is valid).
27+
*
28+
* @param field The contextually relevant field for this action function (the root field when
29+
* specified during form creation, and the submitted field when specified as part of the
30+
* `submit()` call)
31+
* @param detail An object containing the root field of the submitted form as well as the
32+
* submitted field itself
33+
*/
34+
action: (
35+
field: FieldTree<TRootModel & TSubmittedModel>,
36+
detail: {root: FieldTree<TRootModel>; submitted: FieldTree<TSubmittedModel>},
37+
) => Promise<TreeValidationResult>;
38+
/**
39+
* Function to run when attempting to submit the form data but validation is failing.
40+
*
41+
* @param field The contextually relevant field for this onInvalid function (the root field when
42+
* specified during form creation, and the submitted field when specified as part of the
43+
* `submit()` call)
44+
* @param detail An object containing the root field of the submitted form as well as the
45+
* submitted field itself
46+
*/
47+
onInvalid?: (
48+
field: FieldTree<TRootModel & TSubmittedModel>,
49+
detail: {root: FieldTree<TRootModel>; submitted: FieldTree<TSubmittedModel>},
50+
) => void;
51+
/**
52+
* Whether to ignore any of the validators when submitting:
53+
* - 'pending': Will submit if there are no invalid validators, pending validators do not block submission (default)
54+
* - 'none': Will not submit unless all validators are passing, pending validators block submission
55+
* - 'ignore': Will always submit regardless of invalid or pending validators
56+
*/
57+
ignoreValidators?: 'pending' | 'none' | 'all';
58+
}
59+
1960
/**
2061
* A type that represents either a single value of type `T` or a readonly array of `T`.
2162
* @template T The type of the value(s).

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

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

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

1313
/**
@@ -20,12 +20,12 @@ import type {FieldNodeStructure} from './structure';
2020
export class FormFieldManager {
2121
readonly injector: Injector;
2222
readonly rootName: string;
23-
readonly submitOptions: FormSubmitOptions<unknown> | undefined;
23+
readonly submitOptions: FormSubmitOptions<unknown, unknown> | undefined;
2424

2525
constructor(
2626
injector: Injector,
2727
rootName: string | undefined,
28-
submitOptions: FormSubmitOptions<unknown> | undefined,
28+
submitOptions: FormSubmitOptions<unknown, unknown> | undefined,
2929
) {
3030
this.injector = injector;
3131
this.rootName = rootName ?? `${this.injector.get(APP_ID)}.form${nextFormId++}`;

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

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

99
import {computed, linkedSignal, type Signal, untracked, type WritableSignal} from '@angular/core';
10-
import type {FormField} from '../directive/form_field_directive';
1110
import {
1211
MAX,
1312
MAX_LENGTH,
@@ -19,6 +18,7 @@ import {
1918
} from '../api/rules/metadata';
2019
import type {ValidationError} from '../api/rules/validation/validation_errors';
2120
import type {DisabledReason, FieldContext, FieldState, FieldTree} from '../api/types';
21+
import type {FormField} from '../directive/form_field_directive';
2222
import {DYNAMIC} from '../schema/logic';
2323
import {LogicNode} from '../schema/logic_node';
2424
import {FieldPathNode} from '../schema/path_node';

packages/forms/signals/test/node/compat/compat.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,10 @@ describe('Forms compat', () => {
347347

348348
const {promise, resolve} = promiseWithResolvers<TreeValidationResult>();
349349

350-
const result = submit(f as unknown as FieldTree<void>, {
351-
action: () => {
350+
const result = submit(f, {
351+
action: (field) => {
352+
expect(field.name().value()).toBe('pirojok-the-cat');
353+
expect(field.age().control()).toBe(control);
352354
return promise;
353355
},
354356
});

0 commit comments

Comments
 (0)