diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_MzA5NzUwNzMx b/.aspect/rules/external_repository_action_cache/npm_translate_lock_MzA5NzUwNzMx index 3e2aabe43ada..7ff35e246667 100755 --- a/.aspect/rules/external_repository_action_cache/npm_translate_lock_MzA5NzUwNzMx +++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_MzA5NzUwNzMx @@ -2,10 +2,10 @@ # Input hashes for repository rule npm_translate_lock(name = "npm2", pnpm_lock = "@//:pnpm-lock.yaml"). # This file should be checked into version control along with the pnpm-lock.yaml file. .npmrc=-1406867100 -package.json=1856721296 +package.json=1748911755 packages/compiler-cli/package.json=1094415146 packages/compiler/package.json=1190056499 -pnpm-lock.yaml=-1311498029 +pnpm-lock.yaml=533361322 pnpm-workspace.yaml=353334404 tools/bazel/rules_angular_store/package.json=-239561259 -yarn.lock=590377254 +yarn.lock=1672970305 diff --git a/contributing-docs/public-api-surface.md b/contributing-docs/public-api-surface.md index 1d2d4677e458..23ea6d6a6758 100644 --- a/contributing-docs/public-api-surface.md +++ b/contributing-docs/public-api-surface.md @@ -41,6 +41,7 @@ We explicitly consider the following to be _excluded_ from the public API: reference. - The contents and API surface of the code generated by Angular's compiler. - The `@angular/core/primitives` package, including its descendant entry-points. +- The `@angular/forms/experimental` package, including its descendant entry-points. Our peer dependencies (such as TypeScript, Zone.js, or RxJS) are not considered part of our API surface, but they are included in our SemVer policies. We might update the required version of these @@ -62,8 +63,8 @@ change outside major releases. ## Golden files -Angular tracks the status of the public API in a *golden file*, maintained with a tool called the -*public API guard*. +Angular tracks the status of the public API in a _golden file_, maintained with a tool called the +_public API guard_. If you modify any part of a public API in one of the supported public packages, the PR will fail a test in CI with an error message that instructs you to accept the golden file. diff --git a/integration/update-lock-files.mjs b/integration/update-lock-files.mjs index da32b4c33826..c4a5cf19fd4e 100644 --- a/integration/update-lock-files.mjs +++ b/integration/update-lock-files.mjs @@ -11,12 +11,13 @@ import childProcess from 'child_process'; import url from 'url'; import path from 'path'; -import { globSync } from 'tinyglobby'; +import {globSync} from 'tinyglobby'; import fs from 'fs'; const containingDir = path.dirname(url.fileURLToPath(import.meta.url)); -const testDirs = globSync('*/BUILD.bazel', {cwd: containingDir}) - .map((d) => path.join(containingDir, path.dirname(d))); +const testDirs = globSync('*/BUILD.bazel', {cwd: containingDir}).map((d) => + path.join(containingDir, path.dirname(d)), +); const yarnTestTmpDir = path.join(containingDir, '.tmp-yarn-cache'); diff --git a/package.json b/package.json index 302b029590b0..9fd9f8f89579 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,8 @@ "webtreemap": "^2.0.1", "ws": "^8.15.0", "xhr2": "0.2.1", - "yargs": "^17.2.1" + "yargs": "^17.2.1", + "zod": "3.24.1" }, "// 2": "devDependencies are not used under Bazel. Many can be removed after test.sh is deleted.", "devDependencies": { diff --git a/packages/forms/BUILD.bazel b/packages/forms/BUILD.bazel index 8dec2137fd2b..be6866114431 100644 --- a/packages/forms/BUILD.bazel +++ b/packages/forms/BUILD.bazel @@ -36,6 +36,7 @@ ng_package( ], deps = [ ":forms", + "//packages/forms/experimental", ], ) @@ -61,10 +62,15 @@ api_golden_test( filegroup( name = "files_for_docgen", - srcs = glob([ - "*.ts", - "src/**/*.ts", - ]) + ["PACKAGE.md"], + srcs = glob( + [ + "*.ts", + "src/**/*.ts", + ], + exclude = [ + "experimental/**/*.ts", + ], + ) + ["PACKAGE.md"], ) generate_api_docs( diff --git a/packages/forms/experimental/BUILD.bazel b/packages/forms/experimental/BUILD.bazel new file mode 100644 index 000000000000..83d9cd845c10 --- /dev/null +++ b/packages/forms/experimental/BUILD.bazel @@ -0,0 +1,22 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "experimental", + srcs = glob( + [ + "*.ts", + "src/**/*.ts", + ], + exclude = [ + # Exclude files that don't build + "src/custom-schemas/**", + "src/kirjs-idea1/**", + ], + ), + deps = [ + "//packages/core", + "//packages/forms", + ], +) diff --git a/packages/forms/experimental/README.md b/packages/forms/experimental/README.md new file mode 100644 index 000000000000..5aa2201aa152 --- /dev/null +++ b/packages/forms/experimental/README.md @@ -0,0 +1,42 @@ +# 🚧 Prototype of Signal-Based Forms 🏗️ + +This directory contains prototype code of how a future version of Angular Forms could look and function if built on top of signals. We're using this prototype to explore potential designs for such a system, to play with new ideas, identify challenges, and to demonstrate interoperability with the production version of `@angular/forms`. + +## Not yet supported +* Asynchronous validation +* Tracking items in arrays and moving items across arrays +* Recursive logic +* Validation on touch +* Advanced metadata use cases (custom/user-defined/access in validators) +* Dirty/Pristine state +* Dynamic objects/tuples +* Interop with Reactive/Template forms +* Interop with custom schema libraries +* Resetting the form +* Typed errors + +## FAQs + +### Why are you working on this? + +We're exploring ways that we can integrate signals into Angular's forms package. We're looking at all options, including integrating signals into template and reactive forms, and designing a new flavor of forms with signals at the core. Our hope is that we can leverage this work to close the gap between template and reactive forms, which often inspires debate in the Angular ecosystem. + +### What does this mean for the future of template and/or reactive forms? + +Nothing is changing yet with template and reactive forms. This exploration is early and is highly experimental, and many outcomes are possible - including that this just doesn't work. + +Even if we achieve our goals, we will roll out any changes to forms incrementally. Like with NgModules and `standalone`, we don't intend to deprecate template or reactive forms without a clear sign from our community that the ecosystem is fully on board. + +### Will I need to rewrite my application code to use the new forms system? + +No - a non-negotiable design goal of a new signal-based forms system is interoperability with existing forms code and applications. It should be possible to incrementally start using the new system in existing applications, and as always we will explore the possibility of automated migrations. + +### Will there be an RFC? + +If we decide to make any changes to forms, then yes. + +### What's the timeline of this effort? + +We don't know, it depends on what we learn through this process! + + \ No newline at end of file diff --git a/packages/forms/experimental/docs/signal-forms.md b/packages/forms/experimental/docs/signal-forms.md new file mode 100644 index 000000000000..8e84f3c44a01 --- /dev/null +++ b/packages/forms/experimental/docs/signal-forms.md @@ -0,0 +1,844 @@ +# Angular Signal Forms + +## What is a form + +A form is a UI element that allows a user to provide structured information to your application. It consists of various fields used to gather specific data, often enforcing constraints to ensure data integrity. + +In Angular Signal Forms, this concept is modeled by breaking it down into four distinct parts: + +1. **Data Model:** The structure and holds the current values of the data being collected by the form. +2. **Field State:** The metadata associated with each field, representing its state (e.g. `valid`, `touched`, `dirty`). +3. **Field Logic:** The business logic governing the form's behavior, such as validation and conditionally shown fields. +4. **UI Controls:** The actual HTML elements (e.g. ``, ` +} +``` + +##### `validateTree` + +`validateTree` works like `validate`, but it allows errors to be targeted to any sub-field of the validated field. For readability and perfromance it is generally preferable to apply individual validators to the field the pertain to. However in some complex validation scenarios, you may need to validate multiple fields together and assign errors to subfields. This can be accomplished with `validateTree`. + +```ts +const uniqueNamesSchema = schema(names => { + validateTree(names, ({value, field}) => { + const errors = []; + const map = new Map(); + for (let i = 0; i < value().length; i++) { + const name = value()[i]; + if (!map.has(name)) { + map.set(name, []); + } + map.get(name)!.push(i); + } + for (const indices of map.values()) { + if (indices.length > 1) { + for (const index of indices) { + errors.push({kind: 'duplicate-name', field: field[index]}); + } + } + } + return errors; + }); +}); +``` + +##### `metadata` and `define` + +`metadata` & `define` create logic that associates some additional data with a field. The API for these is currently still in flux. + + + +#### Static definition, reactive execution + +It's crucial to understand that the **schema function itself runs only once** when the form is created. Its purpose is to **statically define the structure of your field logic** and set up reactive computations. + +Therefore, you _should not_ place dynamic conditional logic (like `if` statements or `for` loops that conditionally call binding functions) directly within the schema function's top level. Instead, the reactive nature comes from the logic functions themselves, which often take reactive functions as arguments. The goal here is to _declare_ the rules using the provided binding functions, not to execute imperative logic during schema definition time. + +This is demonstrated in the following example: + +```typescript +const passwordSchema = schema((path) => { + // Define a reactive validation rule to check the password length. + validate(path.password, ({value}: FieldContext) => { + // Return a FormError if the password is not long enough. + if (value().length < 5) { + return {kind: 'too-short', message: 'Password is too short'}; + } + // Otherwise return undefined to indicate no error. + return undefined; + }); +}); + +@Component({...}) class MyComponent { + passwordForm = form(signal({password: '', confirm: ''}), passwordSchema); + + simulateUpdatePassword() { + // Password is currently invalid. + this.passwordForm.password().valid(); // false + this.passwordForm.password().errors(); // [{kind: 'too-short', message: 'Password is too short'}] + + // Update to a valid password. + this.passwordForm.password().value.set('password'); + + // Password is now valid. + this.passwordForm.password().valid(); // true + this.passwordForm.password().errors(); // []; + } +} +``` + +The logic function passed as the second argument to `validate` in the example above receives a `FieldContext` which contains a `Signal` of the current field value. By reading this signal it sets up a reactive binding that defines the field's errors in terms of its value. + +#### Defining cross-field logic + +Often, the logic for one part of your field structure depends on the state or value of _another_ part. Common examples of this include: + +- Ensuring a "confirm password" field matches the "password" field. +- Disabling a "shipping address" section if a "use billing address" checkbox is checked. +- Making a field required only if another field has a specific value. + +There are two primary approaches to implement this type of logic in Angular Signal Forms: + +##### Approach #1: Define the logic on a common parent path + +In some cases you may want to associate the logic with a common parent node in your data structure, you can define the logic on the `FieldPath` corresponding to that parent object. The `FieldContext`'s `value` signal will then give you the entire parent object's value, allowing you to compare its child properties. + +Looking at the previous `ConfirmedPassword` example, password matching logic can be implemented by adding validation to the root `path`: + +```typescript +const passwordSchema = schema((path) => { + // Add validation at the root level that considers both the + // `password` and `confirm` values. + validate(path, ({value}: FieldContext) => { + // Compare the password and confirm values, return an error if they don't match. + const {password, confirm} = value(); + if (password !== confirm) { + return {kind: 'non-matching', message: 'Password and confirm must match'}; + } + // Otherwise return undefined to indicate no error. + return undefined; + }); +}); + +@Component({...}) class MyComponent { + passwordForm = form(signal({password: 'first', confirm: 'second'}), passwordSchema); +} +``` + +An important thing to notice with this approach, is that the `non-matching` error is associated with the _root field_, not specifically with the `password` or `confirm` fields themselves. This might be suitable for displaying a general error message, but less ideal if you want to highlight the specific field the user needs to change. + +```typescript +this.passwordForm().errors(); // [{kind: 'non-matching', message: 'Password and confirm must match'}] +this.passwordForm.password().errors(); // [] +this.passwordForm.confirm().errors(); // [] +``` + +##### Approach #2: Use helper functions to access other fields' state or values + +If you need to access other fields' values but don't want to move the logic to a common parent node, you can define the logic on the desired field. The `FieldContext` provides helper functions to access the state or value of _other_ fields: + +- **`valueOf(otherPath: FieldPath): U`**: Directly retrieves the current value of the field at `otherPath`. +- **`stateOf(otherPath: FieldPath): FieldState`**: Retrieves the `FieldState` instance for the field at `otherPath` (e.g., `stateOf(otherPath).disabled()`). +- - **`fieldOf(otherPath: FieldPath): Field`**: Retrieves the `Field` instance for the field at `otherPath`. This is useful when you want to access its decendants or specific array items. + +Here's the same password matching validation, but associating the error with the `confirm` field instead, using `valueOf`: + +```typescript +const passwordSchema = schema((path) => { + // Add validation on the `confirm` field that considers both the + // `password` and `confirm` values. + validate(path.confirm, ({value, valueOf}: FieldContext) => { + // Get the value of `path.password`. + const password = valueOf(path.password); + // Compare the password and confirm values, return an error if they don't match. + if (password !== value()) { + return {kind: 'non-matching', message: 'Password and confirm must match'}; + } + // Otherwise return undefined to indicate no error. + return undefined; + }); +}); + +@Component({...}) class MyComponent { + passwordForm = form(signal({password: 'first', confirm: 'second'}), passwordSchema); + + checkInitialState() { + this.passwordForm().errors(); // [] + this.passwordForm.password().errors(); // [] + this.passwordForm.confirm().errors(); // [{kind: 'non-matching', message: 'Password and confirm must match'}] + } +} +``` + +Note that because `valueOf(path.password)` reads a signal internally (as does `value()` for the current field), this establishes a reactive dependency on the value of `password` as well as the value of `confirm`, ensuring that the validation is recomputed if either one changes. + +### Composing logic from multiple schemas + +As your field structures become more complex, or when you have common data structures used across multiple forms (like addresses, dates, or reusable components), you'll often want to reuse validation and logic rules without duplication. Angular Signal Forms enables this through **schema composition**. + +This is accomplished by using the `apply` function, which binds the logic from a child schema to a path in the parent schema. The `apply` function takes two arguments: + +1. **`path`**: A `FieldPath` within the parent schema where you want to apply the logic from the child schema. +2. **`childSchema`**: The child `Schema` to add logic from. The data type of the `childSchema` (e.g., `Schema
`) must match the data type of the `path` (e.g., `FieldPath
`). + +When you call `apply`, the logic rules defined within `childSchema` are effectively merged into the current schema at the specified `path`. + +```typescript +interface SimpleDate { + year: number; + month: number; + date: number; +} + +interface Trip { + destination: string; + start: SimpleDate; + end: SimpleDate; +} + +// Define a schema to validate dates. +const dateSchema = schema((datePath) => { + error(datePath.month, ({value}) => value() < 1 || value() > 12, 'Invalid month'); + error(datePath.date, ({value}) => value() < 1 || value() > 31, 'Invalid date'); +}); + +// Define a schema for the trip that includes validation for its dates. +const tripSchema = schema((tripPath: FieldPath) => { + // Define trip-specific logic. + required(tripPath.destination); + + // Add in standard date logic for start and end date. + apply(tripPath.start, dateSchema); + apply(tripPath.end, dateSchema); +}); + +const defaultDate: SimpleDate = {year: 0, month: 0, date: 0}; + +@Component({...}) class MyComponent { + tripModel = signal({ + destination: '', + start: defaultDate, + end: defaultDate, + }); + + tripForm = form(this.tripModel, tripSchema); +} +``` + +Because the logic is _merged_ rather than _overwritten_, the parent schema can set up additional logic for the path with the applied schema if necessary. + +```typescript +const tripSchema = schema((tripPath) => { + // Trip-specific date logic, will be merged with standard date logic below. + error(tripPath.start, ({value}) => compareToNow(value()) < 0, 'Trip must start in future'); + + // Add in standard date logic for start and end date. + apply(tripPath.start, dateSchema); + apply(tripPath.end, dateSchema); + + // More trip-specific date logic, will be merged with standard date logic above. + error(tripPath.end, ({value, valueOf}: FieldContext) => { + const startValue = valueOf(tripPath.start); + return compareTo(value(), startValue) < 0; + }, 'Trip must end after it starts'); +}); +``` + +#### Applying schema logic to an array + +Field structures sometimes contain arrays of items, such as line items in an order, tags, or multiple addresses. The challenge with arrays is that their length is dynamic – you don't know ahead of time how many elements there will be, and elements can be added or removed. This makes it impossible to statically bind logic to a specific index like `itemsPath[0]` within the schema definition, as that element might not exist. + +Instead of targeting a single element, use the `applyEach` function which applies a given schema to **every element** currently present in the array, and ensures that the logic is also applied to any elements added later. + +The `applyEach` function takes two arguments: + +1. **`arrayPath`**: A `FieldPath` for an array in the parent schema whose elements you want to apply logic to. +2. **`elementSchema`**: A `Schema` defining the logic for a single element of the array. The data type of the `elementSchema` (e.g., `Schema`) must match the data type of a single element of the `arrayPath` (e.g., `FieldPath`). + +When you call `applyEach`, the logic rules defined within `elementSchema` are automatically applied to every corresponding `Field` node representing an element within the target array field. + +```typescript +interface User { + username: string; + name: string; +} + +const userSchema = schema((userPath) => { + disabled(userPath.username, () => true, 'Username cannot be changed'); +}); + +@Component({...}) class MyComponent { + usersModel = signal([]); + + usersForm = form(this.usersModel, (usersPath: FieldPath) => { + applyEach(usersPath, userSchema); + }); + + simulateAddUser() { + this.usersModel.set([{username: 'newuser', name: 'John Doe'}]); + + this.usersForm[0].username().disabled(); // true + this.usersForm[0].username().disabledReasons(); // [{field: this.usersForm[0].username, reason: 'Username cannot be changed'}] + } +} +``` + +#### Conditionally applying schema logic + +We've established that the schema function runs only once to define the static structure of the logic. You cannot use dynamic `if` statements _within_ the schema function itself to conditionally call logic binding functions like `validate` or `disabled`. + +However, field structures often require logic that should only be active under certain conditions. For example, validation rules for billing details might only apply if the user hasn't selected "Same as shipping address". + +Schema composition provides a reactive solution for this using the `applyWhen` function. This function allows you to apply the logic from a child schema to a specific path, but only when a reactive condition evaluates to `true`. The `applyWhen` function takes three arguments: + +1. **`path`**: A `FieldPath` within the parent schema where the conditional logic should be applied. +2. **`condition`**: A function that receives the `FieldContext` for the `path` and must return a `boolean` indicating whether the child schema's logic is currently active. +3. **`schema`**: The child `Schema` whose logic should be applied _when_ the `condition` is `true`. The data type `T` of this schema (`Schema`) must match the data type of the `path` (`FieldPath`). + +The following example shows using `applyWhen` to conditionally apply validation based on a user's subscription tier: + +```typescript +interface Account { + premiumTier: boolean; + quality: 'SD'|'HD'|'4K'; + friendsAndFamily: string[]; +} + +const basicAccountSchema = schema((accountPath) => { + error(accountPath.quality, + ({value}) => value() === '4K', '4K not supported for basic accounts'); + error(accountPath.friendsAndFamily, + ({value}) => value().length > 1, 'Basic account allows 1 friends & family user'); +}); + +const accountSchema = schema((accountPath: FieldPath) => { + // Apply the basic account logic only if the user is not premium. + applyWhen(accountPath, ({value}) => !value().premiumTier, basicAccountSchema); +}); + +@Component({...}) class MyComponent { + accountForm = form(signal({ + premiumTier: true, + quality: '4K', + friendsAndFamily: [] + }), accountSchema); + + simulateUpdateTier() { + this.accountForm.quality().valid(); // true + + this.accountForm.premiumTier().value.set(false); + + this.accountForm.quality().valid(); // false + } +} +``` + +##### Conditionally applying logic with a narrowed type + +Sometimes, you need to apply logic only when a field's value matches a specific _type_, especially when dealing with union types or optional fields (`T | null | undefined`). For instance, you might have a schema for `Address` data, but you only want to apply it to a `shippingAddress: Address | null` field _when_ the value is not `null`. + +Angular Signal Forms provides `applyWhenValue` for this scenario. It works similarly to `applyWhen`, but its condition is a **type guard function** that operates directly on the field's _value_. The arguments to `applyWhenValue` are: + +1. **`path`**: A `FieldPath` within the parent schema where the conditional logic should be applied. +2. **`condition`**: A **type guard function** (`(value: T) => value is NarrowedType`) that receives the current _value_ `T` from the `path`. It should return `true` if the value matches the desired narrowed type. +3. **`schema`**: The child `Schema` whose logic should be applied _when_ the `condition` type guard returns `true`. The data type of this schema (`Schema`) must match the **narrowed type** specified in the type guard's return signature. + +Let's revisit the `Trip` example, making the dates optional (`null`) initially: + +```typescript +interface SimpleDate { + year: number; + month: number; + date: number; +} + +interface Trip { + destination: string; + start: SimpleDate|null; + end: SimpleDate|null; +} + +// Define a schema to validate dates. +const dateSchema = schema((datePath) => { + error(datePath.month, ({value}) => value() < 1 || value() > 12, 'Invalid month'); + error(datePath.date, ({value}) => value() < 1 || value() > 31, 'Invalid date'); +}); + +// Define a schema for the trip that includes validation for its dates. +const tripSchema = schema((tripPath) => { + // Define trip-specific logic. + required(tripPath.destination); + + // Add in standard date logic for start and end date when they are not null. + applyWhenValue(tripPath.start, (value): value is SimpleDate => value !== null, dateSchema); + applyWhenValue(tripPath.end, (value): value is SimpleDate => value !== null, dateSchema); +}); + +@Component({...}) class MyComponent { + tripModel = signal({ + destination: '', + start: null, + end: null, + }); + + tripForm = form(this.tripModel, tripSchema); +} +``` + +### Async logic + +In some cases, you may need to define validation or other logic that depends on an async operation. A common example of this is validation that can only be performed on the server and therefore must wait for the server response before showing the result of the validation. + +#### Async validation over http + +Server validation is one of the most common types of async logic that is required in forms, and as such Signal Forms has a convenient built in function to define server based validation. To define server based validation for a field, use the `validateHttp` logic function. This function takes a `request` and an `errors` function to map the response to a set of validation errors. Under the hood this creates an `HttpResource` for the field and runs the `errors` function to get the latest errors when it updates. `validateHttp` is a tree validator (like `validateTree`) that allows assigning errors to child fields. + +```ts +const userSchema = schema(userPath => { + validateHttp(userPath.username, { + request: ({value}) => `/api/check-username?${username}`, + errors: (data, ctx) => { + if (data === 'OK') { + return []; + } + return [{kind: 'server-error', message: data}]; + } + }); +}); +``` + +#### Async validation & validity + +Because async validation is asynchronous, it has a third potential state besides `valid` or `invalid`, `pending`, which needs to be considered when determining the validity of the form. Each `FieldState` has the following signals which describe the validation state of the field. + +| Name | Type | Meaning | +| --------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `pending` | `Signal` | `true` if there are any pending validators that may produce a validation error for this field or one of its children, `false` otherwise. | +| `valid` | `Signal` | `true` if neither the field nor any of its children has any errors or pending validators, `false` otherwise. | +| `invalid` | `Signal` | `true` if the field or any of its children has any errors, regardless of pending validators, `false` otherwise. | +| `errors` | `Signal` | The list of validation errors associated with the field. | + +Note that `!valid()` is not the same as `invalid()`, and `!invalid()` is not the same as `valid()`. Consider a field that has no current errors, but does have a pending validator. In this case `valid()` is `false` because of the pending validator, and `invalid()` is also false because there are no current errors. + +Also note that while the validation status is inherited from child to parent (a parent with an `invalid()` child is necissarily `invalid()`), the `errors()` for a field consists of only the errors that apply specifically to that field. + +#### Other async validation + +While async validation via `HttpResource` is the most common type of async validation, there may be situations where you need to perform other async operations as part of validation. In these cases, you can use `validateAsync`. This works similartly to `validateHttp`, but allows you to provide a factory to create any type of `Resource` you need, rather than the `HttpResource` automatically created by `validateHttp`. + +```ts +const userSchema = schema(userPath => { + validateAsync(userPath.username, { + params: ({value}) => `/api/check-username?${username}`, + factory: (params) => { + return rxResource({ + params, + stream: ({params}) => inject(HttpClient).get(params).pipe(...) + }); + } + errors: (data, ctx) => { + if (data === 'OK') { + return []; + } + return [{kind: 'server-error', message: data}]; + } + }); +}); +``` + +#### Async validator short-circuiting + +Because async validation is more typically more expensive than synchronous validation, async validators are only run when the synchronous validators report that the field is valid. This avoids sending wasteful requests to ther server. + +## Submitting a form + +Once the user has filled out the form, the typical next step is to submit the data, often involving client-side processing or sending it to a server. During this submission process, it's common to provide user feedback on the status (pending, success, failure). + +Angular Signal Forms provides a `submit()` helper function to manage this workflow. It orchestrates the asynchronous submission action and updates the form's status accordingly. The `submit` function takes two arguments: + +1. **`field`**: The `Field` instance to submit. This can be the root field or any sub-field node. +2. **`action`**: An asynchronous function that performs the submission action. It receives the `field` being submitted as an argument and returns a `Promise`. + - The returned `Promise` resolves with `void` (or `undefined`, or `[]`) if the action completes successfully without server-side validation errors. + - It resolves with an array of `ServerError` if the submission fails due to server-side validation or other issues that need to be reported back onto the form fields. The `ServerError` structure is detailed in the next section. + +All `FieldState` objects have a `submittedStatus` signal that indicates their current submit state. The status can be `'unsubmitted'`, `'submitting'`, or `'submitted'`. There is no status to indicate that the submit errored because errors are reported through the `errors()` state the same way as client validation errors. (This is discussed more in the next section). `FieldState` objects also have a `resetSubmittedStatus()` method which sets the `submittedStatus` back to `'unsubmitted'`. + +When a `Field` is submitted it updates the `submittedStatus` of the field _and_ all of its descendants in the field tree. Likewise when a field's status is reset via `resetSubmittedStatus()` it resets the status of the field _and_ all of its descendants. + +```typescript +// Create the field structure. +@Component({...}) class MyComponent { + userForm = form(signal({username: '', name: ''})); + + simulateSubmitLifecycle() { + let resolve: () => void; + + this.userForm().submittedStatus(); // 'unsubmitted' + + // Start a submit action. + const submitFinished = submit(this.userForm, () => new Promise(r => resolve = r)); + + this.userForm().submittedStatus(); // 'submitting' + + // Simulate the submit finishing. + resolve(); + await submitFinished; + + this.userForm().submittedStatus(); // 'submitted' + + // Reset to unsubmitted. + this.userForm().resetSubmittedStatus(); + + this.userForm().submittedStatus(); // 'unsubmitted' + } +} +``` + +### Adding server errors to the form + +Client-side validation defined in your `Schema` catches many errors, but some validation can only occur on the server (e.g., checking if a username is already taken, complex business rule validation). + +When the `action` function provided to `submit()` detects such server-side errors, it should communicate them back by resolving its `Promise` with an array of `ServerError` objects (`Promise`). + +A `ServerError` object links a `FormError` to a specific field within the submitted field structure. A `ServerError` is any object with the following properties: + +- `error: FormError`: The validation error to add to the form. +- `field: Field`: A reference to the specific `Field` node where this error should be displayed. + +The `submit()` function takes this array of `ServerError` objects and automatically adds the specified `error` to the `errors` state of the corresponding `field`. + +Its up to the developer to decide which field makes most sense to associate the error with. For a non-unique username error, associating the error with the `username` field makes sense. For a general server issue (e.g. "Internal error"), you might associate it with the field root instead. + +```typescript +@Component({...}) class MyComponent { + userForm = form(signal({username: '', name: ''})); + + myClient = /* ... create server client */; + + async submitForm() { + await submit(this.userForm, async (field) => { // `field` is the same as userForm here + const error = await myClient.addUser(field().value()); + if (error.code === myClient.Errors.NON_UNIQUE_USERNAME) { + return [{ + error: {kind: 'non-unique-username', message: 'That username is already taken'}, + field: field.username + }]; + } + }); + + this.userForm().submittedStatus(); // 'submitted' + this.userForm.username().errors(); // [{kind: 'non-unique-username', message: 'That username is already taken'}] + } +} +``` + +## Binding form fields to UI elements + +So far, we've defined the data model, the field structure with the reactive state for each field, and the declarative logic. The final piece is connecting this logical field representation to the actual UI elements (like ``, ` + + + + ` +}) +class UserFormComponent { + const userModel = signal({username: '', name: '', age: 0}) + readonly userForm: Field = form(userModel, (userPath: FieldPath) => { + disabled(userPath.username, () => true, 'Username cannot be changed'); + required(userPath.name); + error(userPath.age, ({value}) => value() < 18, 'Must be 18 or older'); + }); +} +``` + +### Automatic State Synchronization + +The `[control]` directive handles the two-way synchronization between the `Field` node's state and the UI control, including: + +- **Value Synchronization:** + - Reads the field's current value (`fieldNode().value()`) and sets the initial value of the UI control. + - Listens for changes from the UI control (e.g., `input` event) and updates the field's value signal (`fieldNode().value.set(...)`), which in turn updates your underlying data model signal. +- **Disabled State:** + - Reads the field's disabled status (`fieldNode().disabled()`) and sets the `disabled` attribute/property on the UI control accordingly. +- **Touched State:** + - Listens for interaction events (typically `blur`) on the UI control and updates the field's touched status (`fieldNode().touched` becomes `true` when the control is blurred for the first time). +- **(Other States):** Depending on the control type and library features, other states like validity attributes (`aria-invalid`) might also be synchronized. + +This automatic synchronization significantly reduces the boilerplate code needed to connect your field logic to your template. + +### Control compatibility + +The `[control]` directive works out-of-the-box with standard HTML form elements like ``, `