Skip to content

Commit 022efea

Browse files
committed
feat(froms): add submit method to field state
Changes the `submit` function from a separately imported function to a method on the `FieldState`
1 parent cab5ddd commit 022efea

File tree

9 files changed

+208
-158
lines changed

9 files changed

+208
-158
lines changed

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

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

99
import {ChangeDetectionStrategy, Component, signal} from '@angular/core';
10-
import {email, form, FormField, required, submit} from '@angular/forms/signals';
10+
import {email, form, FormField, required} from '@angular/forms/signals';
1111

1212
interface LoginData {
1313
email: string;
@@ -36,7 +36,7 @@ export class App {
3636

3737
onSubmit(event: Event) {
3838
event.preventDefault();
39-
submit(this.loginForm, {
39+
this.loginForm().submit({
4040
action: async () => {
4141
const credentials = this.loginModel();
4242
// In a real app, this would be async:

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
153153
readonly readonly: Signal<boolean>;
154154
readonly required: Signal<boolean>;
155155
reset(value?: TValue): void;
156+
submit(options?: FormSubmitOptions<TValue>): Promise<boolean>;
156157
readonly submitting: Signal<boolean>;
157158
readonly touched: Signal<boolean>;
158159
readonly valid: Signal<boolean>;
@@ -559,7 +560,7 @@ export type Subfields<TModel> = {
559560
[Symbol.iterator](): Iterator<[string, MaybeFieldTree<TModel[keyof TModel], string>]>;
560561
};
561562

562-
// @public
563+
// @public @deprecated
563564
export function submit<TModel>(form: FieldTree<TModel>, options?: FormSubmitOptions<TModel>): Promise<boolean>;
564565

565566
// @public (undocumented)

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: 6 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -6,57 +6,27 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

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';
9+
import {inject, Injector, runInInjectionContext, WritableSignal} from '@angular/core';
1810
import {BasicFieldAdapter, FieldAdapter} from '../field/field_adapter';
1911
import {FormFieldManager} from '../field/manager';
2012
import {FieldNode} from '../field/node';
21-
import {addDefaultField} from '../field/validation';
2213
import {DYNAMIC} from '../schema/logic';
2314
import {FieldPathNode} from '../schema/path_node';
2415
import {assertPathIsCurrent, SchemaImpl} from '../schema/schema';
2516
import {normalizeFormArgs} from '../util/normalize_form_args';
26-
import {isArray} from '../util/type_guards';
27-
import type {ValidationError} from './rules/validation/validation_errors';
2817
import type {
18+
FieldState,
2919
FieldTree,
20+
FormSubmitOptions,
3021
ItemType,
3122
LogicFn,
32-
OneOrMany,
3323
PathKind,
3424
Schema,
3525
SchemaFn,
3626
SchemaOrSchemaFn,
3727
SchemaPath,
38-
TreeValidationResult,
3928
} from './types';
4029

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-
6030
/**
6131
* Options that may be specified when creating a form.
6232
*
@@ -399,6 +369,7 @@ export function applyWhenValue(
399369
*
400370
* @category submission
401371
* @experimental 21.0.0
372+
* @deprecated Use `form().submit(options)` instead.
402373
*/
403374
export async function submit<TModel>(
404375
form: FieldTree<TModel>,
@@ -412,81 +383,8 @@ export async function submit<TModel>(
412383
form: FieldTree<TModel>,
413384
options?: FormSubmitOptions<TModel> | FormSubmitOptions<TModel>['action'],
414385
): Promise<boolean> {
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';
434-
435-
// Determine whether or not to run the action based on the current validity.
436-
let shouldRunAction = true;
437-
untracked(() => {
438-
markAllAsTouched(node);
439-
440-
if (ignoreValidators === 'none') {
441-
shouldRunAction = node.valid();
442-
} else if (ignoreValidators === 'pending') {
443-
shouldRunAction = !node.invalid();
444-
}
445-
});
446-
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));
456-
}
457-
return false;
458-
} finally {
459-
node.submitState.selfSubmitting.set(false);
460-
}
461-
}
462-
463-
/**
464-
* Sets a list of submission errors to their individual fields.
465-
*
466-
* @param submittedField The field that was submitted, resulting in the errors.
467-
* @param errors The errors to set.
468-
*/
469-
function setSubmissionErrors(
470-
submittedField: FieldNode,
471-
errors: OneOrMany<ValidationError.WithOptionalFieldTree>,
472-
) {
473-
if (!isArray(errors)) {
474-
errors = [errors];
475-
}
476-
const errorsByField = new Map<FieldNode, ValidationError.WithFieldTree[]>();
477-
for (const error of errors) {
478-
const errorWithField = addDefaultField(error, submittedField.fieldProxy);
479-
const field = errorWithField.fieldTree() as FieldNode;
480-
let fieldErrors = errorsByField.get(field);
481-
if (!fieldErrors) {
482-
fieldErrors = [];
483-
errorsByField.set(field, fieldErrors);
484-
}
485-
fieldErrors.push(errorWithField);
486-
}
487-
for (const [field, fieldErrors] of errorsByField) {
488-
field.submitState.submissionErrors.set(fieldErrors);
489-
}
386+
const opts = typeof options === 'function' ? {action: options} : options;
387+
return (form() as FieldState<any>).submit(opts);
490388
}
491389

492390
/**
@@ -501,17 +399,3 @@ function setSubmissionErrors(
501399
export function schema<TValue>(fn: SchemaFn<TValue>): Schema<TValue> {
502400
return SchemaImpl.create(fn) as unknown as Schema<TValue>;
503401
}
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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,25 @@ 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<TModel> {
25+
/** Function to run when submitting the form data (when form is valid). */
26+
action: (form: FieldTree<TModel>) => Promise<TreeValidationResult>;
27+
/** Function to run when attempting to submit the form data but validation is failing. */
28+
onInvalid?: (form: FieldTree<TModel>) => void;
29+
/**
30+
* Whether to ignore any of the validators when submitting:
31+
* - 'pending': Will submit if there are no invalid validators, pending validators do not block submission (default)
32+
* - 'none': Will not submit unless all validators are passing, pending validators block submission
33+
* - 'ignore': Will always submit regardless of invalid or pending validators
34+
*/
35+
ignoreValidators?: 'pending' | 'none' | 'all';
36+
}
37+
1938
/**
2039
* A type that represents either a single value of type `T` or a readonly array of `T`.
2140
* @template T The type of the value(s).
@@ -395,6 +414,40 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
395414
* @param options Optional focus options to pass to the native focus() method.
396415
*/
397416
focusBoundControl(options?: FocusOptions): void;
417+
418+
/**
419+
* Submits the form using the given action function and applies any submission errors
420+
* resulting from the action to the field. Submission errors returned by the `action` will be integrated
421+
* into the field as a `ValidationError` on the sub-field indicated by the `fieldTree` property of the
422+
* submission error.
423+
*
424+
* @example
425+
* ```ts
426+
* async function registerNewUser(registrationForm: FieldTree<{username: string, password: string}>) {
427+
* const result = await myClient.registerNewUser(registrationForm().value());
428+
* if (result.errorCode === myClient.ErrorCode.USERNAME_TAKEN) {
429+
* return [{
430+
* fieldTree: registrationForm.username,
431+
* kind: 'server',
432+
* message: 'Username already taken'
433+
* }];
434+
* }
435+
* return undefined;
436+
* }
437+
*
438+
* const registrationForm = form(signal({username: 'god', password: ''}));
439+
* registrationForm().submit({
440+
* action: async (f) => {
441+
* return registerNewUser(registrationForm);
442+
* }
443+
* });
444+
* registrationForm.username().errors(); // [{kind: 'server', message: 'Username already taken'}]
445+
* ```
446+
*
447+
* @param options Options for the submission.
448+
* @returns Whether the submission was successful.
449+
*/
450+
submit(options?: FormSubmitOptions<TValue>): Promise<boolean>;
398451
}
399452

400453
/**

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

Lines changed: 1 addition & 1 deletion
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
/**

0 commit comments

Comments
 (0)