Compare commits

...

161 Commits

Author SHA1 Message Date
Miles Malerba 4039f073f8 Merge remote-tracking branch 'upstream/main' into prototype/signal-forms 2025-08-26 15:14:08 -07:00
Miles Malerba 7f19f7b825 refactor(forms): rename private access utilities (#63407) 2025-08-26 14:38:59 -07:00
Miles Malerba b23c31a496 fix(forms): fix some things that g3 complains about (#63405)
* fix(forms): fix some things that g3 complains about

* fixup! fix(forms): fix some things that g3 complains about
2025-08-26 12:09:56 -07:00
Miles Malerba aef4b05c62 build: cleanup accidental changes from merging main 2025-08-25 18:53:25 -07:00
Miles Malerba 52c7bde082 build: fix outdated build rule references 2025-08-25 17:13:20 -07:00
Miles Malerba 71557463f0 Merge remote-tracking branch 'upstream/main' into prototype/signal-forms 2025-08-25 17:09:02 -07:00
Miles Malerba 447b6e958f refactor(forms): move addDefaultField out of public API (#63389) 2025-08-25 17:05:25 -07:00
Miles Malerba f90ca1cfcf Merge remote-tracking branch 'upstream/main' into prototype/signal-forms 2025-08-25 15:17:37 -07:00
Miles Malerba d416beee7b fix(form): set select value after option renders (#63340) 2025-08-25 09:24:29 -07:00
Miles Malerba 0b00039a45 fix(forms): ensure pending status propagates to parent (#63333)
* fix(forms): ensure pending status propagates to parent

* test(forms): add some additional tests for pending status

* refactor(forms): better variable naming

use more descriptive names for variables

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>

---------

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>
2025-08-22 11:09:13 -07:00
Miles Malerba 4065c5f715 build(forms): restore api golden generation (#63336) 2025-08-22 11:07:39 -07:00
Miles Malerba 37661b6ec1 Merge remote-tracking branch 'upstream/main' into prototype/signal-forms 2025-08-21 17:18:20 -07:00
Miles Malerba 170a4376ea docs(forms): address feedback on #63293 (#63322)
* docs(forms): address feedback on #63293

* another round of feedback addressed
2025-08-21 17:12:44 -07:00
Leon Senft 197b393aa6 perf(forms): enable tree shaking of unused errors and properties
The `ValidationError` and `AggregateProperty` classes used static
factory methods to create instances. This pattern created a hard
dependency from the base classes to all of their subclasses and
variations.

As a result, any application importing even a single validator (e.g.,
required) or property would inadvertently pull in the code for all other
validators and properties, preventing effective tree-shaking and
increasing bundle size.

This refactoring removes the static factory methods and replaces them
with standalone, exported functions or properties. This decouples the
individual validators and properties, allowing bundlers to properly
perform dead code elimination.
2025-08-21 16:58:59 -07:00
Miles Malerba 2bbd1c972d feat(forms): allow setting custom error message (#63314)
* feat(forms): allow setting custom error message

allows standard validator logic to specify custom error message

* address feedback

* address feedback
2025-08-21 14:54:57 -07:00
Miles Malerba 6434bcce0c fix(forms): minor cleanup for disabled API (#63318)
* fix(forms): minor cleanup for disabled API

rename DisabledReason.reason to DisabledReason.message for consistency with errors, and allow unconditional disabling with message

* address feedback
2025-08-21 14:48:40 -07:00
Miles Malerba aa51dcdcdc feat(forms): add support for binding to select (#63300)
Adds test for select, and textarea (which was untested)
2025-08-20 14:14:06 -07:00
Miles Malerba 46e3de0f0a cleanup unrelated formatting changes on prototype branch 2025-08-20 10:14:44 -07:00
Miles Malerba dc966ceb6a Merge remote-tracking branch 'upstream/main' into prototype/signal-forms 2025-08-20 10:06:52 -07:00
Leon Senft c303673a65 refactor(forms): relocate the signal based forms implementation
Move the signal based forms implementation from
`@angular/forms/experimental` to `@angular/forms/signals` in preparation
of merging into `main`.
2025-08-20 08:15:02 -07:00
Miles Malerba 9866f91ef6 Review pass & fixup of the control directive & related code (#63068)
* feat(forms): add missing builtin property hooks for custom controls

* docs(forms): address some todos

* feat(forms): add missing form control inputs & tsdoc

* fix(forms): correct some properties on InteropNgControl

Also notes a few properties in comments that we should consider supporting if possible

* docs(forms): tsdoc and minor fixup for controls/control.ts

* test: move control directive test to web test

this allows cleaning up hacky test code to run it in node

* fixup! test: move control directive test to web test

* fixup! feat(forms): add missing form control inputs & tsdoc

* fixup! docs(forms): tsdoc and minor fixup for controls/control.ts

* fixup! fixup! feat(forms): add missing form control inputs & tsdoc

* address review comments

* fix comment

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>

* test: cleanup test

* remove extraneous undefined

* resolve some TODOs

* test: move to new inputBinding syntax

* test: update goldens

* fix test

---------

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>
2025-08-19 16:09:05 -07:00
Leon Senft b86b1e8810 feat(forms): add error summaries to fields
Add an `errorSummary` signal to `FieldState` which contains all of the
validation errors of a field and its descendants. This can be used to
conveniently collect all form validation errors by reading it from the
root field.
2025-08-19 14:16:04 -07:00
Leon Senft 768f0410b9 feat(forms): add associated field to all validation errors
Previously only tree validation errors carried their associated fields.
Regular validation errors didn't carry their field because it was
assumed it could be trivially inferred from context.

Error summaries (which are forthcoming) break this assumption, as they
combine both regular and tree validation errors from an entire form into
a single list. Without explicitly carrying their associated field,
there's no way of knowing to which field a regular validation error is
associated when obtained through an error summary.

Note that while all validation errors now have a field, it's not
explicitly required to define one when returning an error from a logic
function callback in `validateTree()`, nor is it allowed in
`validate()`. These functions will automatically add the current field
by default to any errors missing a field definition. This ensures that
all validation errors returned from fields (e.g. `field.path.errors()`)
_do_ have a defined field.

This change makes testing errors by equality more tedious; however, we
only do so in our own tests to be comprehensive. In reality, it's
expected that developers would simply check for an expected `error.kind`
or similar property of a custom error.
2025-08-19 13:01:42 -07:00
Miles Malerba 9a0c96489a fix(forms): align FieldPath type with implementation (#63100)
* test(forms): rename outdated test file name

* split recursive logic tests into a separate file

* add tests to demonstrate issue

* fix(forms): align FieldPath type with implementation

In particular when dealing with model values that are potentially
undefined, the typing did not extract the `undefined` outside. With this
fix the `undefined` is extracted, preventing people form defining logic
on fields that might not actually exist.

* fix typo

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

* fix typo

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

* address feedback

* ci: update goldens

* type cleanup

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>

---------

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>
2025-08-18 17:06:26 -07:00
Leon Senft 1545ddcb4b refactor(forms): reduce FieldState API to a minimal set of properties
Remove the following properties from `FieldState`

* `syncErrors`
* `syncValid`

These properties are implementation details which can be accessed
internally through the field node's `validationState` where necessary.
2025-08-18 16:02:12 -07:00
Leon Senft fdbb09f2ec refactor(forms): fix typo in class name
Rename `NonMergableLogicNodeBuilder` to `NonMergeableLogicNodeBuilder`.
2025-08-18 09:33:59 -07:00
Kirill Cherkashin 04f225d516 Add forms adapter (#62940)
* refactor(forms): Add adapter support

Adapter allowing customization of the creation logic for a field and its associated structure and state.
2025-08-11 15:29:30 +02:00
Miles Malerba 2d03187a1d feat(forms): track bound controls as field state (#63060)
* feat(forms): track bound controls as field state

Adds a signal to the field state that tracks the instances of `Control` that are currently bound to the field. This is useful for error summaries that want to support jumping to fields with errors

* docs(forms): fix typo

fix typo blah blah this commit message is long enough

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

---------

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
2025-08-09 10:25:52 +09:00
Miles Malerba cb826866f3 refactor(forms): reorganize control directive & add disabled/readonly (#63058)
* refactor(forms): reorganize control directive & add disabled/readonly

* fix(forms): control directive propagates metadata

* feat(forms): support FieldState.name() & propagate to controls

* feat(forms): improve custom control support

Split `FormUiControl` into `FormValueControl` and `FormCheckboxControl`,
which capture the two main types of UI control:

 * those that expose a typed `.value()`
 * those that expose a boolean `.checked()`

Implement both in the `Control` directive.

* fix(forms): set required inputs with somewhat correct timing

If `[control]` is applied to a custom UI control, it wants to synchronize
state in the field w/ the inputs of that custom control. This is difficult
to do in user-land. We use `effect`, but effects don't run before the
lifecycle hooks of the component. This is usually okay, but has one
significant issue: the UI control's required inputs won't be set in time for
those lifecycle hooks to run.

Eventually we can build custom functionality for the `Control` directive
into the framework, but for now we work around this limitation with a hack.
We use an `@Input` instead of a signal-based `input()` for the `[control]`
to hook the exact moment inputs are being set, before the important
lifecycle hooks of the UI control. We can then initialize all our effects
and force them to run immediately, ensuring all required inputs have values.

* feat(forms): make `Control` a no-op for components with a control input

When a component is used which represents a subform, it can use a `control`
input to receive and pass down a `Field` to child controls. In that case,
the `Control` directive will match against the component, but should do
nothing since the component is already handling the `Field` propagation
internally.

* test(forms): switch tests to zoneless

Switch the test configuration to avoid pulling in zone.js, purely to avoid
the console warning spam.

* refactor(forms): address review feedback

address feedback on #62930 and  #62933

* docs(forms): add TODOs for open questions

adds TODOs for open questions on the original PRs, they can be settled in a followup

closes #62930
closes #62933

* build(forms): update api goldens

---------

Co-authored-by: Alex Rickabaugh <alx+alxhub@alxandria.net>
2025-08-08 10:41:25 +09:00
Miles Malerba e127c420f6 feat(forms): switch to RegExp in pattern validator (#63045)
* feat(forms): switch to RegExp in pattern validator

No other libraries use string patterns for validation

* refactor(forms): update PATTERN to refer to a RegExp

addresses feedback from the original PR

* docs(forms): update docs for pattern logic function

aligns the tsdoc with other validatior logic functions

* build: update api goldens

updates the api goldens to reflect changes in this PR

---------

Co-authored-by: Kirill Cherkashin <kirjs@google.com>
2025-08-08 09:18:15 +09:00
Leon Senft 21838501b6 feat(forms): prevent invalid form submission
Return from `submit()` before running the `action` callback if the form
is invalid. Note that `submit()` will still mark all fields as touched
before returning to ensure that all validation errors are displayed.
2025-08-05 13:55:24 -07:00
Leon Senft 6dbe4b7369 fix(forms): memoize deepSignal value
Test that submission errors are cleared from their corresponding field
when its value is changed. Submission errors should not be cleared from
fields whose values remain unchanged. This behavior relies on fields
memoizing their value, which `deepSignal()` now does.
2025-08-05 13:48:26 -07:00
Leon Senft cd8e0c3ae0 Merge remote-tracking branch 'upstream/main' into prototype/signal-forms 2025-08-05 11:53:19 -07:00
Miles Malerba 615f85f84e refactor(forms): reorganize and improve tsdoc for the schema (#62915)
* refactor(forms): move schema impl under a schema directory

* refactor(forms): separate out logic.ts and logic_node.ts

* docs: improve tsdoc for schema.ts

* docs: improve tsdoc for path_node.ts

* docs: improve tsdoc for logic.ts and logic_node.ts

* docs: fix a gazillion typos

* fixup! docs: fix a gazillion typos

* fixup! docs: fix a gazillion typos

* fixup! refactor(forms): separate out logic.ts and logic_node.ts

* fixup! refactor(forms): separate out logic.ts and logic_node.ts

* fixup! docs: improve tsdoc for path_node.ts

* fixup! docs: improve tsdoc for path_node.ts

* fixup! refactor(forms): separate out logic.ts and logic_node.ts

* fixup! 6e425b880a
2025-08-03 20:47:03 +09:00
Miles Malerba 0c82de5fe5 refactor(forms): cleanup util (#62911)
Move illegal.ts into util and consolidate multiple implementations of
isObejct
2025-08-01 16:08:54 +09:00
Miles Malerba 9349bcfee7 fix(forms): fix triage logic for server errors and improve field tsdoc (#62910)
* docs(forms): Improve tsdoc for field structure

Adds / improves tsdoc for field/structure.ts

* fix(forms): fix server error triage logic

fixes a bug where server errors could be lost if targeting multiple of
them at the same field

* docs: fix typo

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

* docs: fix typo

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

* docs: fix typo

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

* docs: address feedback

* refactor(forms): address ai review

address comments from gemini bot

* refactor(forms): clean up set server errors

Clean up the set server errors logic so it doesn't allocate unnecessary data structures.

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>

* fixup! docs(forms): Improve tsdoc for field structure

---------

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>
2025-08-01 15:25:11 +09:00
Kirill Cherkashin 3a4caec61c fix(forms): expose and test hidden (#62905)
This would allow using hidden

Co-authored-by: kirjs <kirjs@google.com>
2025-07-31 12:11:13 -04:00
Miles Malerba b24fec7215 docs and cleanup for field implementation (#62888)
* refactor(forms): improve docs and typing for field

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>

* fix typo

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

---------

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>
Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
2025-07-31 11:04:16 +09:00
Leon Senft 5747c7a48b refactor(forms): remove unsubmitted and submitted field states
* `FieldState.submittedStatus` is replaced by `FieldState.submitting`,
  which tracks only whether the form is currently being submitted. This
  state is propagated downwards to all descendants of the submitted field.

* `submit()` now marks the field and all of its descendants as touched
  to ensure that any validation errors are displayed upon submission
  attempts.
2025-07-30 11:18:13 -07:00
Leon Senft cf2be036c9 test(forms): update failing API golden test
Fix the last remaining CI failure on the prototype/signal-forms branch
for now.
2025-07-30 10:21:50 -07:00
Leon Senft b93a1b8df7 Fix CI (#62887)
* fix(forms): fix `yarn ts-circular-deps:check` errors

Remove circular dependencies.

* Use `import type` where possible instead of `import`.
* Split the contents of `resolution.ts` out from `context.ts`.
* Import symbols directly from their defining files rather than through
  `public_api.ts`.

* fix(forms): fix failing test case for async validation using a standard schema

This test case relied on Zone.js converting `async () => true` to a
`Promise`, which was necessary to make validation run asynchronously.
After merging the `main` branch, this test started failing because the
validator completed synchronously despite the use of an `async`
function. Using `Promise.resolve()` guarantees that the validation will
run asynchronously and fixes the failure.
2025-07-30 23:29:03 +09:00
Leon Senft 6dd4f7e67a fix(signal-forms): fix tslint errors
Add explicit types to fix `no-exported-inferred-call-type` violations.
2025-07-29 13:37:23 -07:00
Leon Senft e6e0c72118 Merge remote-tracking branch 'upstream/main' into prototype/signal-forms 2025-07-29 13:21:25 -07:00
Miles Malerba 32c9aadd6a docs/test for async validation running only after sync valid (#62830)
* add test for validateHttp on root path

* docs: calrify that async validation only runs when sync valid

* address ai review
2025-07-29 09:04:03 +09:00
Miles Malerba 862e7708e3 cleanup & type improvements for standard schema validator (#62845)
* move standard schema validator under validators

* make zod a dev dep and update to v4

* fixes and tests for validateStandardSchema typing

* switch node tests to zoneless and remove web tests

This resolves the issue with our node tests that forced us to split the
Zod tests out into a separate web test. With this commit the node tests
are fixed and the Zod-based tests are moved back under node.

* minor docs fixup for validation errors

* support deep instances of z.looseObject

* address ai review

* address feedback
2025-07-29 09:03:02 +09:00
Miles Malerba 88614f0a1b Code cleanup and docs for some of the field node implementation (#62847)
* cleanup for field/context

* cleanup for field/util

* cleanup for field/manager

* cleanup for field/node

* cleanup for field/property

* update wording

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>

* fix typo

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>

---------

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>
2025-07-29 08:33:15 +09:00
Miles Malerba 21ca05d5c9 refactor(signal-forms): cleanup for api/logic & api/structure (#62810)
* remove leftover todo

* docs pass for api/logic

* remove false from ValidationResult

In practice I found the ability to return false quite confusing.
It meant you could do `x && ValidationError.custom()`, but not
`x || ValidationError.custom()`. Making both of those work would mean
that returning `true` from `validate` vs returning `false` would mean
the exact same thing (no errors). That's also super odd, so I opted to
just remove `false` as a possibility.

* cleanup/docs pass for api/structure

* fix typo

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

* address feedback

---------

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
2025-07-25 13:22:34 -07:00
Leon Senft e30ff9f688 refactor(signal-forms): add dependency on @standard-schema/spec 2025-07-24 16:07:26 -07:00
Miles Malerba f1105f9bab refactor(signal-forms): some cleanup for deepSignal & types (#62753)
* cleanup/docs pass for deepSignal

* cleanup/docs for api/types

* refactor the tree & async validation result types

These types have been made more accurate, to reflect the fact that a
tree or async validator could return a mix of errors with and without
fields.

Also added our own copy of isArray since the built-in one doesn't handle
readonly arrays properly.

* fix typo

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

* address comments

* fix typo

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>

---------

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>
2025-07-24 14:06:33 -07:00
Leon Senft b0c962db4a refactor(signal-forms): remove unnecessary no-op schema
Use the function overload for `form()` without a schema.
2025-07-23 11:56:38 -07:00
Leon Senft c1085fc134 fixup! feat(signal-forms): add support for resetting forms 2025-07-23 11:56:38 -07:00
Leon Senft 124fab9c91 feat(signal-forms): add support for resetting forms
Introduce a `reset()` method that restores the touched and dirty signals
of a field and its descendants to their initial states. `reset()` may be
called on the root `form()` field to reset the entire form.
2025-07-23 11:56:38 -07:00
Miles Malerba e96d9e3e51 refactor(signal-forms): rework metadata API (#62713)
* rename metadata key types

* fix metadata reduce to not confuse item and acc types

* remove defineComputed, defineResource

* rename define and meatdata logic binding fns

* move setMetadata next to addToMetadata

* address comments

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

* pattern cleanup

* rename metadata concept to property

* consolidate field property state

* rename metadata.ts to property.ts

* add 2-param variant of property rule

* rename getDefault to getInitial for alignment with standard reduce

* address ai review

---------

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
2025-07-21 22:10:30 -07:00
Miles Malerba 04ba24eeeb fix bad rebase 2025-07-18 19:02:26 +00:00
Miles Malerba eefc3896f8 docs(signal-forms): add some tsdoc for async validation api (#62693)
* docs(signal-forms): add some tsdoc for async validation api

* address ai review

* address feedback
2025-07-18 11:20:06 -07:00
Miles Malerba 1243113feb Review of api/validators (#62659)
* Review of api/validators

* improve ergonomics of errors option on validators

Improves the ergonomics of the `errors` option offered by the standard
validators.
1. Renames it to `error` because in the majority of cases it will be
   used to return a single `ValidationError`.
2. Allows directly using a constant `ValidationError` insetad of having
   to create a function like `() => ValidationError.custom()`

* handle NaN case in min/max

* switch ValidationError to use camelCase names

* memoize result of logic functions

* allow any value with length or size for minlength/maxlength

* fix typos

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

* address feedback

---------

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
2025-07-18 11:19:19 -07:00
Miles Malerba 63b4448a05 refactor(signal-forms): unify how users access metadata added with define and metadata (#62656)
* refactor(signal-forms): Replace DataKey with StaticMetadataKey

* Remove `data` from the `FieldState`, it is now accessed via `metadata`

* fix typo

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

---------

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
2025-07-17 17:12:35 -07:00
Miles Malerba ebb678616a feat(signal-forms): allow validation logic to return null and false (#62657)
* feat(signal-forms): allow validation logic to return null and false

* address feedback

* address feedback
2025-07-16 15:05:45 -07:00
Miles Malerba 74d83f4f3d refactor(signal-forms): more ergonomic metadata key creation (#62653)
* refactor(signal-forms): more ergonomic metadata key creation

* address feedback and add doc comments

* address ai feedback
2025-07-16 09:30:25 -07:00
Miles Malerba 2648e75441 refactor(signal-forms): remove error logic function (#62634)
We have gotten feedback from multiple sources that the distinction
between `error` and `validate` is confusing. Removing `error` for now
and we will explore alternate method signatures for `validate` instead
in future PRs
2025-07-15 13:11:48 -07:00
Miles Malerba 07939d671c cleanup unnecessary cast (#62635) 2025-07-15 13:04:08 -07:00
Miles Malerba 372f6559db refactor(signal-forms): use discriminated union for errors (#62446)
* refactor(signal-forms): use discriminated union for errors

* incorporate feedback

* Update packages/forms/experimental/src/api/validation_errors.ts

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

* fix test

* refactor: base the union on real classes

* address ai review

* cleanup

* address feedback

---------

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
2025-07-15 11:26:11 -07:00
Kirill Cherkashin f8f0af5310 fix(forms): drop unused import (#62551)
This is also incorrect and breaks things

Co-authored-by: kirjs <kirjs@users.noreply.github.com>
2025-07-09 12:41:28 -04:00
DESKTOP-V2DGHM1\Icecream e539e16073 docs(signal-forms): fix typos in example code (chandlerfang@gmail.com) 2025-07-08 17:42:00 -07:00
Miles Malerba bb12572d8e fix(signal-forms): fix broken test (#62532) 2025-07-08 16:27:19 -04:00
Miles Malerba 68643d2ddc feat(signal-forms): Add key & index support in FieldContext (#62442)
* feat(signal-forms): Add key & index support in FieldContext

* WIP: add PathKind type param to FieldPath

* WIP: add PathKind type param to logic functions

* fix remaining issues

* schema must take a root path kind

* add @template doc for TPathKind

* Apply suggestion from @leonsenft

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>

* address comments

* address ai code review

* address comments

---------

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>
2025-07-08 15:11:59 -04:00
Miles Malerba fead94d4ec standard schema support (#61438)
* add standard schema support

* move node tests under test/node

* Apply suggestions from code review

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>

* Update packages/forms/experimental/test/web/standard_schema.spec.ts

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

* fix return type docs

---------

Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com>
Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
2025-07-01 16:30:23 -07:00
Leon Senft 090f7a38f1 docs(forms): fix typos (#62348) 2025-06-30 09:28:18 -07:00
Miles Malerba 92e7a45d43 Migrate to LogicNodeBuilder & LogicNode (#62220)
* migrate to logic node 2

* add some additional tests for predefined schemas

* fix resolve behavior

* tests and fixes for recursive logic

* post-rebase fixup

* add test for recursive logic w/ predicates

* fix predicates in recursive logic

* misc cleanup

* compile schemas once per form

* add test for recursive logic with arrays

* fix typo

* address review comments
2025-06-26 14:33:56 -07:00
Miles Malerba 3fa15e7bb9 refactor: add a new version of the logic node (#61079)
* refactor: add a new version of the logic node

Adds a new version of logic node that is created via builders. The
builder / actual logic node split allows the final logic for a node to
be lazily merged together.

My plan is to eventually swap this in for FieldLogicNode and have the
schema methods (`validate`, `apply`, etc.) work with the builders. The
FieldNode will then build its logic when it is first created.

This should be the other main piece aside from relative paths which is
needed to make self-referential logic work.

* refactor: maintain predicates as list

* refactor: refactor builder api

* refactor: defer predicate list expansion until merge time

* post-rebase fixup

* add support for new logic since last rebase

* add tsdoc
2025-06-23 09:08:59 -07:00
Kirill Cherkashin a833eb283f Forms: Always use provided injector on the form level (#62051)
Co-authored-by: kirjs <kirjs@users.noreply.github.com>
2025-06-18 14:23:24 -04:00
Kirill Cherkashin 54d1d3e453 Forms: Allow dynamically disabling built-in validators by returning u… (#62121)
* Forms: Allow dynamically disabling built-in validators by returning undefined.

* fixup: drop binding built-in metadata to inputs for now

---------

Co-authored-by: kirjs <kirjs@users.noreply.github.com>
2025-06-18 14:21:16 -04:00
Kirill Cherkashin acee6ce483 Forms: Minor clean up of tutorial v2 (#62049)
* Forms: Minor clean up of tutorial v2

* Forms: Minor clean up of tutorial v2

* Forms: Minor clean up of tutorial v2

* Forms: Minor clean up of tutorial v2

---------

Co-authored-by: kirjs <kirjs@users.noreply.github.com>
2025-06-13 12:19:50 -04:00
Kirill Cherkashin 0b5592dcc9 Forms: Clarify array tracking (#62035)
Co-authored-by: kirjs <kirjs@users.noreply.github.com>
2025-06-12 14:58:36 -04:00
Kirill Cherkashin d296791daa V2 tutorial (#62014)
* docs(forms): Add a tutorial for v2

* fixup: Address feedback

---------

Co-authored-by: kirjs <kirjs@users.noreply.github.com>
2025-06-11 17:15:30 -04:00
Alex Rickabaugh 19ecc2f300 fix(forms): handle undefined nodes in resolve() 2025-06-09 11:30:48 -07:00
Alex Rickabaugh eb6c2ecf2a refactor(forms): split FieldNode into structure, validation, context, etc.
This commit should make the code much easier to follow by breaking apart
`FieldNode` into various pieces, each focused on a single concern.
2025-06-09 11:10:27 -07:00
Alex Rickabaugh 14587546aa fix(forms): properly handle fields moving around within arrays
`FieldNode`s were previously constructed with a static 'key', used for a few
purposes including `.value` operations (read/write). This meant that when a
`FieldNode` moved in an array via the tracking mechanism, it would be
resolved via the new path, but still write to the old path (a major bug).

This commit fixes the issue by introducing a proper reactive concept for the
field's current property key in the parent, which changes as the field moves
around.
2025-06-09 11:10:27 -07:00
Alex Rickabaugh 5fd1b8fb77 refactor(forms): properly handle dynamic keyed FieldNodes 2025-06-09 11:10:27 -07:00
Alex Rickabaugh 3a7e531526 refactor(forms): undefined model values don't have fields
Adjust the behavior and typing of forms when a model value is `undefined`.
In such cases, the form is not considered to have a field for that key of
the model, and navigation attempts should return `undefined`. The type
should also reflect the field's potential non-existence, as well as
correctly narrow the type when the field is non-null asserted.
2025-06-09 11:10:27 -07:00
Kirill Cherkashin d9533445dd refactor(forms): Make imports consistent (#61908)
Co-authored-by: kirjs <kirjs@users.noreply.github.com>
2025-06-05 15:40:46 -04:00
michael-small 5e675b1354 docs: align jsdocs of experimental sf logic & structure functions (#61887) 2025-06-05 09:44:39 -04:00
Miles Malerba 00f9d5a321 switch from $state to () (#61834) 2025-06-04 10:46:36 -07:00
Kirill Cherkashin d2bca12049 The rest of the built-in validators (#61758)
* forms: Add remaining build-in validators

* fixup: Address feedback

* fixup: Clean up

---------

Co-authored-by: kirjs <kirjs@users.noreply.github.com>
2025-06-04 12:50:49 -04:00
Miles Malerba 7ecec96d3b refactor(forms): update status states based on prev discussion (#61852)
As discussed:
- Rename `.hasPendingValidators()` to just `.pending()`
- Eliminate `.status()` as a public API

Note:
- `.status()` retained internally as part of the implementation
- renamed the `'pending'` status option to `'unknown'` to avoid
  confusion with `.pending()`
2025-06-03 13:28:55 -07:00
Miles Malerba d19cb9e485 inherit disabled reason to children (#61829)
* inherit disabled reason to children

* update docs
2025-06-03 11:59:22 -07:00
Alex Rickabaugh 69f18f99d9 refactor(forms): rename FieldDirective to Control 2025-06-03 08:22:14 -07:00
Miles Malerba 7ebf93f9f8 update docs (#61711)
* update docs

Updates docs to:
- Show usage of the `schema()` function
- Mention need to run in injection context (& fix examples)

Also updates the `form` API to accept both `form(model, options)` and
`form(model, schema)`

* fixup! update docs

* add a brief explanation of each logic function

* add docs for async validation

* remove defaultValue signatures of validateHttp

* Update packages/forms/experimental/docs/signal-forms.md

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>

* fixup! Update packages/forms/experimental/docs/signal-forms.md

---------

Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
2025-05-30 09:59:28 -07:00
Alex Rickabaugh 7b68bf01fb feat(forms): support automatic array tracking
This feature adds a symbol tag to values in arrays, which is used to
automatically track those values as they move around in the array. It's one
way of achieving array tracking without a lot of overhead or configuration.

Primitive values can't be patched like this, and are still tracked by index.
2025-05-28 10:16:19 -07:00
Kirill Cherkashin 6410539818 Adding min and max validators. (#61474)
* feat(forms): Adding min and max validators.
Creating a separate file for built-in validators.
2025-05-28 09:30:18 -04:00
Miles Malerba e3221efa7f add shorthand to validate with http resource (#61707) 2025-05-27 13:55:45 -07:00
Kirill Cherkashin ecb8db04e4 forms: Drop data from context, as it is already on the state (#61646)
Co-authored-by: kirjs <kirjs@users.noreply.github.com>
2025-05-27 12:47:09 -04:00
Miles Malerba 37c5ff61c6 refactor validation status (#61546) 2025-05-22 15:52:46 -07:00
Kirill Cherkashin ea589628a7 Drop resolve from everywhere (#61614)
* forms: Drop resolve, and use the new API everywhere

* fixup: Minor cleanup

---------

Co-authored-by: kirjs <kirjs@users.noreply.github.com>
2025-05-22 11:27:36 -04:00
Kirill Cherkashin e221067e1c Adding state/field/valueOf/StateOf/FieldOf to field context (#61378)
* feat(forms): Adding state/field/valueOf/StateOf/FieldOf to field context
I'm currently keeping `resolve` so we don't have to do big changes until we finalize the API.
2025-05-21 10:59:33 -04:00
Kirill Cherkashin e61da79b3c feat(forms): Support marking a field as dirty (#61183)
Co-authored-by: kirjs <kirjs@users.noreply.github.com>
2025-05-20 15:52:23 -04:00
Miles Malerba aa318860db refactor(forms): add back schema function (#61379) 2025-05-20 11:58:40 -07:00
Alex Rickabaugh 359eb4df1b Merge branch 'main' of github.com:angular/angular into prototype/signal-forms 2025-05-20 09:57:25 -07:00
Miles Malerba dc404f0a8e add support for readonly (#61369)
* add support for readonly

* skip validation on readonly fields
2025-05-15 12:33:21 -07:00
Alex Rickabaugh 383543e5f9 wip: async validation! 2025-05-14 14:00:37 -07:00
Miles Malerba 0c99e56f97 add default touched event logic for ui controls (#61288) 2025-05-13 08:49:00 -07:00
Alex Rickabaugh a12a9fe4f9 wip: injection, data deps, resources 2025-05-12 11:42:15 -07:00
Miles Malerba 8b5aeebb54 test: switch tests to jasmine_node_test (#61074) 2025-04-30 16:03:48 -04:00
Miles Malerba 02f26cf56a Revert "Async WIP"
This reverts commit 94e1ee9eef.
2025-04-28 21:03:55 +00:00
kirjs 94e1ee9eef Async WIP 2025-04-28 16:57:31 -04:00
Miles Malerba 9385dc457c refactor: use relative FieldPathNodes (#60791)
* refactor: use relative FieldPathNodes

Switch `apply` behavior to create a brand new root path whenever a new
schema is applied. This effectively changes our `FieldPathNode` concept
from an absolute path to a relative path.

To record how the relative path nodes are connected, introduce a
`rootPaths` map to each logic node, and when we `apply`, copy all roots
from the sub-schema to the root of the current schema. This results in
the top-level schema having a mapping to all roots under it, allowing it
to construct the full absolute path.

Note: this breaks the current resolve logic, resolve needs to be
rewritten to understand the new path structure.

* refactor: merge logic nodes

Adds a method to merge one logic node into another. This allows them to
be built up separaterly and then joined, instead of always applying
rules to the same logic instance.

* fix: update resolve to handle relative paths

* fix: preserve predicates while merging

* refactor: cleanup code organization

* refactor: move subroots concept from logic to path

* refactor: Differentiate PathNode vs RootPathNode

* refactor: Optimize resolve logic

Optimize the resolve logic by only calling `getChild` from the common
ancestor field rather than the root field of the entire form. This
ensures we do not overreact to changes that won't affect the resolved
field.
2025-04-22 09:39:25 -07:00
kirjs 8b4d217466 tutorial: Move list of features to the doc 2025-04-16 13:19:19 -04:00
kirjs 16d637eb6a Expose metadata through public api 2025-04-16 12:35:13 -04:00
kirjs 05a4299ea6 Expose metadata through public api 2025-04-16 12:06:13 -04:00
kirjs 1b607655b6 Add hidden section to the tutorial
Improve applyWhen explanation
Move metadata stuff to public API
2025-04-16 11:53:02 -04:00
kirjs 9089293adf Add spaces to properly format code things 2025-04-11 12:45:27 -04:00
kirjs e91434fd71 Space feedback form description better, so markdown picks it up as code 2025-04-11 08:44:21 -04:00
kirjs 01e18cde8e Final tutorial polish 2025-04-10 18:17:56 -04:00
kirjs 3712624dfa Update tutorial and resolve TODOs 2025-04-10 15:50:00 -04:00
kirjs 7f341416f9 Add forms tutorial 2025-04-10 15:29:04 -04:00
kirjs d962dc9be1 Add Validator<T> type 2025-04-08 12:50:39 -04:00
kirjs 0cb0cd47af Only export public stuff 2025-04-07 13:38:41 -04:00
kirjs 6d21572825 Rexport everything through public_api.ts 2025-04-07 11:51:26 -04:00
kirjs 1dc28b05c0 Update readme with things that are not yet supported 2025-04-04 15:59:29 -04:00
Miles Malerba aee6ab93d6 Update docs to reflect renamed API concepts (#60734)
* Update docs to reflect renamed API concepts

* fixup! Update docs to reflect renamed API concepts

* Update docs to reflect removal of `schema()` function
2025-04-04 08:32:53 -07:00
Miles Malerba df80ab9d4c Simplify concept of schemas to just a type (#60732) 2025-04-03 16:49:51 -07:00
Miles Malerba 1be49f70d0 More followup renaming 2025-04-03 21:02:59 +00:00
Miles Malerba f142d48b8d Update docs and names to better align with the new API names 2025-04-03 20:31:33 +00:00
Miles Malerba 95fb3b4c14 Rename APIs as discussed last session 2025-04-03 18:24:49 +00:00
kirjs 2bf5732026 Move more changes 2025-04-03 12:38:07 -04:00
Miles Malerba 2a52ce8019 Add disabled reason support 2025-04-03 16:26:42 +00:00
kirjs 0f7c4338dc Fix imports in node spec 2025-04-03 12:16:13 -04:00
kirjs 29384b5344 Bring back node spec 2025-04-03 12:07:38 -04:00
Miles Malerba 86f3816abd Add some user docs 2025-04-03 15:52:04 +00:00
kirjs c71cc659f9 Make sure all files have licence header. 2025-04-02 13:29:12 -04:00
kirjs ee492c979b Move rule-based forms prototype into the main repo.
Note: this is a prototype, and is still work in progress

Get rid of old stuff
2025-04-02 13:21:24 -04:00
kirjs b167365bde Allow marking things as not dirty 2025-03-05 15:54:33 -05:00
Miles Malerba 069282f462 wip ui test 2025-03-03 19:42:18 +00:00
Alex Rickabaugh 51e79aaf73 impl: validation & errors
Propagation of validation and errors (untested)
2025-02-28 14:02:07 -08:00
Miles Malerba 001b0ba185 cleanup some old stuff 2025-02-28 01:06:19 +00:00
Miles Malerba 0ee9c7037b add form proxy 2025-02-28 01:00:15 +00:00
Miles Malerba 4f3ea59c1e work on schema for dynamic els 2025-02-26 22:08:27 +00:00
Alex Rickabaugh 1ff640dfb3 impl: working node, arrays, & logic 2025-02-24 13:59:42 -08:00
Miles Malerba 32cc250080 nested arrays test 2025-02-21 22:18:46 +00:00
Miles Malerba 75d394eb2e more thinking & prototyping 2025-02-21 21:59:40 +00:00
Alex Rickabaugh 794e80b057 refactor: sketch outline of FormNode (replacement for FormFieldImpl) 2025-02-19 14:03:29 -08:00
Miles Malerba 7d37cf7877 minor cleanup for engine 2025-02-04 00:14:35 +00:00
Miles Malerba 85f8db7539 directive stand-in for the field concept 2025-02-03 23:33:58 +00:00
Alex Rickabaugh 7ce2640a28 refactor: add linked signal to FormFieldImpl 2025-01-29 14:05:38 -08:00
Miles Malerba 68a4bf35e9 refactoring pass & metadata 2025-01-28 17:40:25 +00:00
Alex Rickabaugh 882c0ea8a1 refactor(forms): add engine & tests 2025-01-27 13:58:17 -08:00
Miles Malerba 1c8ad18dd6 cleanup some earlier prototyping 2025-01-24 22:43:38 +00:00
Miles Malerba afdbeae46c add some ideas about schema merging 2025-01-21 18:05:24 +00:00
Miles Malerba ebecdea206 prototype2: dynamic objects & zod 2025-01-21 18:01:21 +00:00
Miles Malerba 47333e4aa0 add another prototype idea 2025-01-12 21:25:18 +00:00
kirjs 68eba763fe Add custom schema experiments 2025-01-07 14:12:38 -05:00
kirjs ab17a84da6 Actually mention signals in readme :) 2024-12-26 14:44:39 -05:00
kirjs b2d971b143 Add my thoughts on potential signal forms API 2024-12-26 14:36:54 -05:00
Miles Malerba a94d0db4db refactor: update idea1 into a working minimal prototype 2024-12-23 23:29:42 +00:00
Miles Malerba fb67fc2a0e add an idea for binding field to elements 2024-12-15 22:17:40 +00:00
Miles Malerba 8e830f942f refactor: cleanup idea1 and add notes 2024-12-15 19:05:46 +00:00
Miles Malerba 405f2f0a41 refactor: noop impl of initial concept 2024-12-10 23:40:20 +00:00
Alex Rickabaugh 139a4afdbd refactor: move PACKAGE.md to README.md and include entrypoint in build 2024-12-10 14:18:56 -08:00
Miles Malerba 8550bd0683 refactor: remove forms-experimental, moved to forms/experimental 2024-12-10 21:19:08 +00:00
Miles Malerba 78505746ee refactor: add experimental forms package
Co-authored-by: Alex Rickabaugh <alxhub@users.noreply.github.com>
2024-12-10 19:24:08 +00:00
Miles Malerba d29ced91a1 refactor: add experimental forms package
Co-authored-by: Alex Rickabaugh <alxhub@users.noreply.github.com>
2024-12-10 01:16:54 +00:00
74 changed files with 14895 additions and 2 deletions
+1 -1
View File
@@ -547,7 +547,7 @@
"bzlTransitiveDigest": "9IJp6IlB/FMHFBJe4MX/DQM4zi3oArC8yqYE/+NyPwk=",
"usagesDigest": "1rX5zKEA5AF0V7MwOa4Uu00O9/xtmOXAgV3jUGUxeQk=",
"recordedFileInputs": {
"@@//package.json": "5a98de041b8a40a949062b59a03709726850890c86bcca142f2a47de022e3e0d",
"@@//package.json": "ec188498a5070d792486104602dac93c675ca86076b1b91bcd9927985ea3d738",
"@@devinfra~//bazel/package.json": "960bcecf963a211f96a3967c7cfb5d3e1cea08d94b27056a3e8dbf2fad1e2dd3",
"@@rules_browsers~//package.json": "45572077938c7a4916e4aaedf7db7ce8425854ab92f35348cff02a2134023bb8"
},
@@ -0,0 +1,580 @@
## API Report File for "forms_signals_api"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AbstractControl } from '@angular/forms';
import { ControlValueAccessor } from '@angular/forms';
import { DestroyableInjector } from '@angular/core';
import { ElementRef } from '@angular/core';
import { FormControlStatus } from '@angular/forms';
import { HttpResourceOptions } from '@angular/common/http';
import { HttpResourceRequest } from '@angular/common/http';
import * as i0 from '@angular/core';
import { Injector } from '@angular/core';
import { InputSignal } from '@angular/core';
import { ModelSignal } from '@angular/core';
import { NgControl } from '@angular/forms';
import { OutputRef } from '@angular/core';
import { ResourceRef } from '@angular/core';
import { Signal } from '@angular/core';
import type { StandardSchemaV1 } from '@standard-schema/spec';
import { ValidationErrors } from '@angular/forms';
import { ValidatorFn } from '@angular/forms';
import { WritableSignal } from '@angular/core';
// @public
export class AggregateProperty<TAcc, TItem> {
// (undocumented)
readonly getInitial: () => TAcc;
// (undocumented)
readonly reduce: (acc: TAcc, item: TItem) => TAcc;
}
// @public
export function aggregateProperty<TValue, TPropItem, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, prop: AggregateProperty<any, TPropItem>, logic: NoInfer<LogicFn<TValue, TPropItem, TPathKind>>): void;
// @public
export function andProperty(): AggregateProperty<boolean, boolean>;
// @public
export function apply<TValue>(path: FieldPath<TValue>, schema: NoInfer<SchemaOrSchemaFn<TValue>>): void;
// @public
export function applyEach<TValue>(path: FieldPath<TValue[]>, schema: NoInfer<SchemaOrSchemaFn<TValue, PathKind.Item>>): void;
// @public
export function applyWhen<TValue>(path: FieldPath<TValue>, logic: LogicFn<TValue, boolean>, schema: NoInfer<SchemaOrSchemaFn<TValue>>): void;
// @public
export function applyWhenValue<TValue, TNarrowed extends TValue>(path: FieldPath<TValue>, predicate: (value: TValue) => value is TNarrowed, schema: SchemaOrSchemaFn<TNarrowed>): void;
// @public
export function applyWhenValue<TValue>(path: FieldPath<TValue>, predicate: (value: TValue) => boolean, schema: NoInfer<SchemaOrSchemaFn<TValue>>): void;
// @public
export type AsyncValidationResult<E extends ValidationError = ValidationError> = ValidationResult<E> | 'pending';
// @public
export interface AsyncValidatorOptions<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root> {
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;
readonly params: (ctx: FieldContext<TValue, TPathKind>) => TParams;
}
// @public
export interface ChildFieldContext<TValue> extends RootFieldContext<TValue> {
readonly key: Signal<string>;
}
// @public
export class Control<T> {
get cva(): ControlValueAccessor | undefined;
readonly cvaArray: ControlValueAccessor[] | null;
readonly el: ElementRef<HTMLElement>;
readonly field: WritableSignal<Field<T>>;
// (undocumented)
set _field(value: Field<T>);
get ngControl(): NgControl;
readonly state: Signal<FieldState<T, string | number>>;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<Control<any>, "[control]", never, { "_field": { "alias": "control"; "required": true; }; }, {}, never, never, true, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<Control<any>, never>;
}
// @public
export function createProperty<TValue>(): Property<TValue>;
// @public
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(obj: WithField<E>): CustomValidationError;
// @public
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(obj?: E): WithoutField<CustomValidationError>;
// @public
export class CustomValidationError extends ValidationError {
[key: PropertyKey]: unknown;
}
// @public
export function disabled<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic?: string | NoInfer<LogicFn<TValue, boolean | string, TPathKind>>): void;
// @public
export interface DisabledReason {
readonly field: Field<unknown>;
readonly message?: string;
}
// @public
export function email<TPathKind extends PathKind = PathKind.Root>(path: FieldPath<string, TPathKind>, config?: BaseValidatorConfig<string, TPathKind>): void;
// @public
export function emailError(options: WithField<ValidationErrorOptions>): EmailValidationError;
// @public
export function emailError(options?: ValidationErrorOptions): WithoutField<EmailValidationError>;
// @public
export class EmailValidationError extends _NgValidationError {
// (undocumented)
readonly kind = "email";
}
// @public
export type Field<TValue, TKey extends string | number = string | number> = (() => FieldState<TValue, TKey>) & (TValue extends Array<infer U> ? ReadonlyArrayLike<MaybeField<U, number>> : TValue extends Record<string, any> ? Subfields<TValue> : unknown);
// @public
export type FieldContext<TValue, TPathKind extends PathKind = PathKind.Root> = TPathKind extends PathKind.Item ? ItemFieldContext<TValue> : TPathKind extends PathKind.Child ? ChildFieldContext<TValue> : RootFieldContext<TValue>;
// @public
export type FieldPath<TValue, TPathKind extends PathKind = PathKind.Root> = {
[ɵɵTYPE]: [TValue, TPathKind];
} & (TValue extends Array<unknown> ? unknown : TValue extends Record<string, any> ? {
[K in keyof TValue]: MaybeFieldPath<TValue[K], PathKind.Child>;
} : unknown);
// @public
export interface FieldState<TValue, TKey extends string | number = string | number> {
readonly controls: Signal<readonly Control<unknown>[]>;
readonly dirty: Signal<boolean>;
readonly disabled: Signal<boolean>;
readonly disabledReasons: Signal<readonly DisabledReason[]>;
readonly errors: Signal<ValidationError[]>;
readonly errorSummary: Signal<ValidationError[]>;
hasProperty(key: Property<any> | AggregateProperty<any, any>): boolean;
readonly hidden: Signal<boolean>;
readonly invalid: Signal<boolean>;
readonly keyInParent: Signal<TKey>;
markAsDirty(): void;
markAsTouched(): void;
readonly name: Signal<string>;
readonly pending: Signal<boolean>;
property<M>(prop: AggregateProperty<M, any>): Signal<M>;
property<M>(prop: Property<M>): M | undefined;
readonly readonly: Signal<boolean>;
reset(): void;
readonly submitting: Signal<boolean>;
readonly touched: Signal<boolean>;
readonly valid: Signal<boolean>;
readonly value: WritableSignal<TValue>;
}
// @public
export type FieldValidationResult<E extends ValidationError = ValidationError> = ValidationSuccess | OneOrMany<WithoutField<E>>;
// @public
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, FieldValidationResult, TPathKind>;
// @public
export function form<TValue>(model: WritableSignal<TValue>): Field<TValue>;
// @public
export function form<TValue>(model: WritableSignal<TValue>, schemaOrOptions: SchemaOrSchemaFn<TValue> | FormOptions): Field<TValue>;
// @public
export function form<TValue>(model: WritableSignal<TValue>, schema: SchemaOrSchemaFn<TValue>, options: FormOptions): Field<TValue>;
// @public
export interface FormCheckboxControl extends FormUiControl {
readonly checked: ModelSignal<boolean>;
readonly value?: undefined;
}
// @public
export interface FormOptions {
adapter?: FieldAdapter;
injector?: Injector;
// (undocumented)
name?: string;
}
// @public
export interface FormUiControl {
readonly dirty?: InputSignal<boolean>;
readonly disabled?: InputSignal<boolean>;
readonly disabledReasons?: InputSignal<readonly DisabledReason[]>;
readonly errors?: InputSignal<readonly ValidationError[]>;
readonly hidden?: InputSignal<boolean>;
readonly invalid?: InputSignal<boolean>;
readonly max?: InputSignal<number | undefined>;
readonly maxLength?: InputSignal<number | undefined>;
readonly min?: InputSignal<number | undefined>;
readonly minLength?: InputSignal<number | undefined>;
readonly name?: InputSignal<string>;
readonly pattern?: InputSignal<readonly RegExp[]>;
readonly pending?: InputSignal<boolean>;
readonly readonly?: InputSignal<boolean>;
readonly required?: InputSignal<boolean>;
readonly touched?: ModelSignal<boolean> | InputSignal<boolean> | OutputRef<boolean>;
}
// @public
export interface FormValueControl<TValue> extends FormUiControl {
readonly checked?: undefined;
readonly value: ModelSignal<TValue>;
}
// @public
export function hidden<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic: NoInfer<LogicFn<TValue, boolean, TPathKind>>): void;
// @public
export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKind = PathKind.Root> {
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
readonly options?: HttpResourceOptions<TResult, unknown>;
readonly request: ((ctx: FieldContext<TValue, TPathKind>) => string | undefined) | ((ctx: FieldContext<TValue, TPathKind>) => HttpResourceRequest | undefined);
}
// @public
export class InteropNgControl implements Pick<NgControl, InteropSharedKeys | 'control' | 'valueAccessor'>, Pick<AbstractControl<unknown>, InteropSharedKeys | 'hasValidator'> {
constructor(field: () => FieldState<unknown>);
// (undocumented)
readonly control: AbstractControl<any, any>;
// (undocumented)
get dirty(): boolean;
// (undocumented)
get disabled(): boolean;
// (undocumented)
get enabled(): boolean;
// (undocumented)
get errors(): ValidationErrors | null;
// (undocumented)
protected field: () => FieldState<unknown>;
// (undocumented)
hasValidator(validator: ValidatorFn): boolean;
// (undocumented)
get invalid(): boolean;
// (undocumented)
get pending(): boolean | null;
// (undocumented)
get pristine(): boolean;
// (undocumented)
get status(): FormControlStatus;
// (undocumented)
get touched(): boolean;
// (undocumented)
get untouched(): boolean;
// (undocumented)
updateValueAndValidity(): void;
// (undocumented)
get valid(): boolean;
// (undocumented)
get value(): any;
// (undocumented)
valueAccessor: ControlValueAccessor | null;
}
// @public
export type InteropSharedKeys = 'value' | 'valid' | 'invalid' | 'touched' | 'untouched' | 'disabled' | 'enabled' | 'errors' | 'pristine' | 'dirty' | 'status';
// @public
export interface ItemFieldContext<TValue> extends ChildFieldContext<TValue> {
readonly index: Signal<number>;
}
// @public
export function listProperty<TItem>(): AggregateProperty<TItem[], TItem | undefined>;
// @public
export type LogicFn<TValue, TReturn, TPathKind extends PathKind = PathKind.Root> = (ctx: FieldContext<TValue, TPathKind>) => TReturn;
// @public
export type MapToErrorsFn<TValue, TResult, TPathKind extends PathKind = PathKind.Root> = (result: TResult, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
// @public
export const MAX: AggregateProperty<number | undefined, number | undefined>;
// @public
export function max<TPathKind extends PathKind = PathKind.Root>(path: FieldPath<number, TPathKind>, maxValue: number | LogicFn<number, number | undefined, TPathKind>, config?: BaseValidatorConfig<number, TPathKind>): void;
// @public
export const MAX_LENGTH: AggregateProperty<number | undefined, number | undefined>;
// @public
export function maxError(max: number, options: WithField<ValidationErrorOptions>): MaxValidationError;
// @public
export function maxError(max: number, options?: ValidationErrorOptions): WithoutField<MaxValidationError>;
// @public
export function maxLength<TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, maxLength: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;
// @public
export function maxLengthError(maxLength: number, options: WithField<ValidationErrorOptions>): MaxLengthValidationError;
// @public
export function maxLengthError(maxLength: number, options?: ValidationErrorOptions): WithoutField<MaxLengthValidationError>;
// @public
export class MaxLengthValidationError extends _NgValidationError {
constructor(maxLength: number, options?: ValidationErrorOptions);
// (undocumented)
readonly kind = "maxLength";
// (undocumented)
readonly maxLength: number;
}
// @public
export function maxProperty(): AggregateProperty<number | undefined, number | undefined>;
// @public
export class MaxValidationError extends _NgValidationError {
constructor(max: number, options?: ValidationErrorOptions);
// (undocumented)
readonly kind = "max";
// (undocumented)
readonly max: number;
}
// @public
export type MaybeField<TValue, TKey extends string | number = string | number> = (TValue & undefined) | Field<Exclude<TValue, undefined>, TKey>;
// @public
export type MaybeFieldPath<TValue, TPathKind extends PathKind = PathKind.Root> = (TValue & undefined) | FieldPath<Exclude<TValue, undefined>, TPathKind>;
// @public
export const MIN: AggregateProperty<number | undefined, number | undefined>;
// @public
export function min<TPathKind extends PathKind = PathKind.Root>(path: FieldPath<number, TPathKind>, minValue: number | LogicFn<number, number | undefined, TPathKind>, config?: BaseValidatorConfig<number, TPathKind>): void;
// @public
export const MIN_LENGTH: AggregateProperty<number | undefined, number | undefined>;
// @public
export function minError(min: number, options: WithField<ValidationErrorOptions>): MinValidationError;
// @public
export function minError(min: number, options?: ValidationErrorOptions): WithoutField<MinValidationError>;
// @public
export function minLength<TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, minLength: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;
// @public
export function minLengthError(minLength: number, options: WithField<ValidationErrorOptions>): MinLengthValidationError;
// @public
export function minLengthError(minLength: number, options?: ValidationErrorOptions): WithoutField<MinLengthValidationError>;
// @public
export class MinLengthValidationError extends _NgValidationError {
constructor(minLength: number, options?: ValidationErrorOptions);
// (undocumented)
readonly kind = "minLength";
// (undocumented)
readonly minLength: number;
}
// @public
export function minProperty(): AggregateProperty<number | undefined, number | undefined>;
// @public
export class MinValidationError extends _NgValidationError {
constructor(min: number, options?: ValidationErrorOptions);
// (undocumented)
readonly kind = "min";
// (undocumented)
readonly min: number;
}
// @public
export type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
// @public
export const NgValidationError: abstract new () => NgValidationError;
// @public (undocumented)
export type NgValidationError = RequiredValidationError | MinValidationError | MaxValidationError | MinLengthValidationError | MaxLengthValidationError | PatternValidationError | EmailValidationError | StandardSchemaValidationError;
// @public
export type OneOrMany<T> = T | readonly T[];
// @public
export function orProperty(): AggregateProperty<boolean, boolean>;
// @public
export namespace PathKind {
export interface Child extends PathKind.Root {
// (undocumented)
[ɵɵTYPE]: 'child' | 'item';
}
export interface Item extends PathKind.Child {
// (undocumented)
[ɵɵTYPE]: 'item';
}
export interface Root {
[ɵɵTYPE]: 'root' | 'child' | 'item';
}
}
// @public (undocumented)
export type PathKind = PathKind.Root | PathKind.Child | PathKind.Item;
// @public
export const PATTERN: AggregateProperty<RegExp[], RegExp | undefined>;
// @public
export function pattern<TPathKind extends PathKind = PathKind.Root>(path: FieldPath<string, TPathKind>, pattern: RegExp | LogicFn<string | undefined, RegExp | undefined, TPathKind>, config?: BaseValidatorConfig<string, TPathKind>): void;
// @public
export function patternError(pattern: RegExp, options: WithField<ValidationErrorOptions>): PatternValidationError;
// @public
export function patternError(pattern: RegExp, options?: ValidationErrorOptions): WithoutField<PatternValidationError>;
// @public
export class PatternValidationError extends _NgValidationError {
constructor(pattern: RegExp, options?: ValidationErrorOptions);
// (undocumented)
readonly kind = "pattern";
// (undocumented)
readonly pattern: RegExp;
}
// @public
export class Property<TValue> {
}
// @public
export function property<TValue, TData, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, factory: (ctx: FieldContext<TValue, TPathKind>) => TData): Property<TData>;
// @public
export function property<TValue, TData, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, prop: Property<TData>, factory: (ctx: FieldContext<TValue, TPathKind>) => TData): Property<TData>;
// @public
export function readonly<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic?: NoInfer<LogicFn<TValue, boolean, TPathKind>>): void;
// @public
export type ReadonlyArrayLike<T> = Pick<ReadonlyArray<T>, number | 'length' | typeof Symbol.iterator>;
// @public
export function reducedProperty<TAcc, TItem>(reduce: (acc: TAcc, item: TItem) => TAcc, getInitial: () => TAcc): AggregateProperty<TAcc, TItem>;
// @public
export const REQUIRED: AggregateProperty<boolean, boolean>;
// @public
export function required<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind> & {
emptyPredicate?: (value: TValue) => boolean;
when?: NoInfer<LogicFn<TValue, boolean, TPathKind>>;
}): void;
// @public
export function requiredError(options: WithField<ValidationErrorOptions>): RequiredValidationError;
// @public
export function requiredError(options?: ValidationErrorOptions): WithoutField<RequiredValidationError>;
// @public
export class RequiredValidationError extends _NgValidationError {
// (undocumented)
readonly kind = "required";
}
// @public
export interface RootFieldContext<TValue> {
readonly field: Field<TValue>;
readonly fieldOf: <P>(p: FieldPath<P>) => Field<P>;
readonly state: FieldState<TValue>;
readonly stateOf: <P>(p: FieldPath<P>) => FieldState<P>;
readonly value: Signal<TValue>;
readonly valueOf: <P>(p: FieldPath<P>) => P;
}
// @public
export type Schema<in TValue> = {
[ɵɵTYPE]: SchemaFn<TValue, PathKind.Root>;
};
// @public
export function schema<TValue>(fn: SchemaFn<TValue>): Schema<TValue>;
// @public
export type SchemaFn<TValue, TPathKind extends PathKind = PathKind.Root> = (p: FieldPath<TValue, TPathKind>) => void;
// @public
export type SchemaOrSchemaFn<TValue, TPathKind extends PathKind = PathKind.Root> = Schema<TValue> | SchemaFn<TValue, TPathKind>;
// @public
export function standardSchemaError(issue: StandardSchemaV1.Issue, options: WithField<ValidationErrorOptions>): StandardSchemaValidationError;
// @public
export function standardSchemaError(issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions): WithoutField<StandardSchemaValidationError>;
// @public
export class StandardSchemaValidationError extends _NgValidationError {
constructor(issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions);
// (undocumented)
readonly issue: StandardSchemaV1.Issue;
// (undocumented)
readonly kind = "standardSchema";
}
// @public
export type Subfields<TValue> = {
readonly [K in keyof TValue as TValue[K] extends Function ? never : K]: MaybeField<TValue[K], string>;
};
// @public
export function submit<TValue>(form: Field<TValue>, action: (form: Field<TValue>) => Promise<TreeValidationResult>): Promise<void>;
// @public
export type SubmittedStatus = 'unsubmitted' | 'submitted' | 'submitting';
// @public
export type TreeValidationResult<E extends ValidationError = ValidationError> = ValidationSuccess | OneOrMany<WithOptionalField<E>>;
// @public
export type TreeValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, TreeValidationResult, TPathKind>;
// @public
export function validate<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic: NoInfer<FieldValidator<TValue, TPathKind>>): void;
// @public
export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, opts: AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>): void;
// @public
export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, opts: HttpValidatorOptions<TValue, TResult, TPathKind>): void;
// @public
export function validateTree<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic: NoInfer<TreeValidator<TValue, TPathKind>>): void;
// @public
export abstract class ValidationError {
[BRAND]: undefined;
constructor(options?: ValidationErrorOptions);
readonly field: Field<unknown>;
readonly kind: string;
readonly message?: string;
}
// @public
export type ValidationResult<E extends ValidationError = ValidationError> = ValidationSuccess | OneOrMany<E>;
// @public
export type ValidationSuccess = null | undefined | void;
// @public
export type Validator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, ValidationResult, TPathKind>;
// @public
export type WithField<T> = T & {
field: Field<unknown>;
};
// @public
export type WithOptionalField<T> = T & {
field?: Field<unknown>;
};
// @public
export type WithoutField<T> = T & {
field: never;
};
// (No @packageDocumentation comment for this package)
```
+3 -1
View File
@@ -81,6 +81,7 @@
"@rollup/plugin-commonjs": "^28.0.0",
"@rollup/plugin-node-resolve": "^16.0.0",
"@schematics/angular": "21.0.0-next.0",
"@standard-schema/spec": "^1.0.0",
"@types/angular": "^1.6.47",
"@types/babel__core": "7.20.5",
"@types/babel__generator": "7.27.0",
@@ -214,7 +215,8 @@
"tslint-no-toplevel-property-access": "0.0.2",
"typed-graphqlify": "^3.1.1",
"undici": "^7.0.0",
"vrsource-tslint-rules": "6.0.0"
"vrsource-tslint-rules": "6.0.0",
"zod": "^4.0.10"
},
"resolutions": {
"https-proxy-agent": "7.0.6",
+28
View File
@@ -0,0 +1,28 @@
load("//tools:defaults.bzl", "api_golden_test", "ng_project")
package(default_visibility = ["//visibility:public"])
ng_project(
name = "signals",
srcs = glob(
[
"*.ts",
"src/**/*.ts",
],
),
deps = [
"//:node_modules/@standard-schema/spec",
"//packages/core",
"//packages/forms",
],
)
api_golden_test(
name = "forms_signals_api",
data = [
"//goldens:public-api",
"//packages/forms/signals",
],
entry_point = "index.d.ts",
golden = "goldens/public-api/forms/signals/index.api.md",
)
+42
View File
@@ -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!
+844
View File
@@ -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 that 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. `<input>`, `<select>`, custom components) that present the form fields to the user and allow interaction.
## Creating the data model and binding it to a field structure
A key principle of Angular Signal Forms is that it **does not maintain its own internal data**. Instead, **you, the developer, own the data model**. The data model is represented as a `WritableSignal` which the library uses directly as the source of truth for the values of its fields.
To create a root `Field` instance representing your form, pass your model to the `form()` factory function:
```typescript
// Define the data structure.
interface User {
name: string;
username: string;
}
@Component({...}) class MyComponent {
// Create a model containing the initial form data.
userModel = signal<User>({name: '', username: ''});
// Create the form, linking it to the model.
userForm = form(this.userModel);
}
```
This establishes the model as the source of truth for the UI values, meaning:
- Any updates made to the `userModel` will update the field's value (as it reads from the same signal).
- Any changes the user makes via the `userForm` **directly modify** the model.
Note: `form()` must either be run in an injection context, or passed an injector as an additional option (e.g. `form(this.userModel, {injector: this.injector})`).
## Accessing the field state
Calling `form()` on your data model gives you a `Field` object which contains the field state for each field. Via this object, you can access the value, validation status, and other metadata for any field in the field structure.
### Navigating the field tree
To access a field's state, you first navigate to the field you're interested in. The `Field` object is structured as a tree wrapped around your data. The `Field` tree mirrors the structure of the data, allowing you to access a sub-field for part of the data by accessing the corresponding property on the `Field`, the same way you would navigate through the data itself.
Consider this example:
```typescript
interface LineItem {
description: string;
quantity: number;
}
interface Order {
orderId: string;
items: LineItem[];
}
@Component({...}) class MyComponent {
// Create the model.
orderModel = signal<Order>({
orderId: 'ORD-123',
items: [
{ description: 'Ergonomic Mouse', quantity: 1 },
{ description: 'Mechanical Keyboard', quantity: 1 }
]
});
// Create a `Field` for the order.
orderForm: Field<Order> = form(this.orderModel);
// Navigate the field structure to access sub-fields.
itemsField: Field<LineItem[]> = this.orderForm.items;
firstItemField: Field<LineItem> = this.orderForm.items[0];
firstItemQuantityField: Field<number> = this.orderForm.items[0].quantity;
}
```
As you can see, navigating the `orderForm` structure (`orderForm.items[0].quantity`) directly corresponds to how you would access the data in the `orderModel` (`orderModel().items[0].quantity`). Each step in this navigation gives you a more specific `Field` instance, typed according to the part of the data model it represents (e.g., `Field<LineItem[]>`, `Field<LineItem>`, `Field<number>`).
However, these navigated `Field` objects only represent the _structure_ and _grouping_ of the fields. To access the state for a specific field, call it as a function to get its `FieldState`.
### Getting the `FieldState` for a node
Every `Field` instance, whether it's the root field or a sub-field obtained through navigation, can be called as a function to access the underlying `FieldState` instance for that specific node in the field tree.
The `FieldState` gives you access to the reactive state of that part of the field structure, including:
- **`value`**: A `WritableSignal` representing the current value of the field.
- **`valid`**: A `Signal` indicating whether the field _and its descendants_ are currently valid.
- **`errors`**: A `Signal` containing the list of `FormError` associated with the field. (A `FormError` is any object of type `{kind: string, message?: string}`).
- **`disabled`**: A `Signal` indicating whether the field _or any of its parents_ are disabled.
- **`disabledReasons`**: A `Signal` indicating containing a list of reasons for the current field's disablement. Each reason consists of both the `field` that is the source of the disablement (the current field or one of its parents), as well as an optional `reason` string that may be shown to the user.
- **`touched`**: A `Signal` indicating whether the user has interacted with the field _or any of its descendants_.
Here's how you can access the `FieldState` in the previous example:
```typescript
// Update the quantity for the first item. (This updates `orderModel` as well!)
this.firstItemQuantityField().value.set(2);
// Get the value of the first item.
this.firstItemField().value(); // {description: 'Ergonomic Mouse', quantity: 2}
// Check whether the order is disabled.
this.orderForm().disabled(); // false
// Check the reasons why the order is disabled.
this.orderForm().disabledReasons(); // []
// Check if there are any errors on the items list.
this.itemsField().errors(); // [];
```
## Adding field logic
As previously shown, `FieldState` instances expose reactive state signals like `valid`, `errors`, and `disabled`. This state isn't set directly; instead, it's _derived_ from the **field logic** you define.
Another key principle of Angular Signal Forms is that all field logic, such as validation rules or conditions for disabling fields, is defined **declaratively using TypeScript**. This means you specify the rules and conditions that determine a field's state upfront, when the field structure is defined. You don't imperatively command a field to change state later (e.g. there is no imperative `.disable()` method on the field state). Instead, you define _when_ `someField` should be disabled based on other signals or static conditions.
The mechanism for defining this declarative logic is the `Schema`. Think of a `Schema` as a **blueprint** containing the rules (validators, disabled conditions, etc.) for a `Field` that manages a specific data type. A `Schema<T>` is simply a function that accepts a `FieldPath<T>` and defines logic for it.
### Creating and applying a schema
You define a `Schema` as a function that is generic over the data type it applies to. For example:
- A schema of type `Schema<string>` defines rules for a `Field<string>`.
- A schema of type `Schema<User>` defines rules for a `Field<User>`.
This ensures that your logic rules align with the structure of your field and your data model.
Inside the schema function, you'll define the specific validation rules, disabled conditions, and other logic for the field structure. _How_ to define these rules will be covered shortly.
A `Schema` can be created by calling `schema()` and passing it schema function, or just passing a schema function directly to `form()` or other methods that expect a `Schema`.
Once you have defined a schema, you associate it with your `Field` structure by passing it as the second argument to the `form()` function:
```typescript
// Define the data type for the form.
interface ConfirmedPassword {
password: string;
confirm: string;
}
// Define a schema for this data type.
const passwordSchema = schema<ConfirmedPassword>((path) => {
// Logic rules (like validators) will be defined inside this function.
// How to do this will be covered in the following sections.
});
@Component({...}) class MyComponent {
// Create the data model.
passwordModel = signal<ConfirmedPassword>({password: '', confirm: ''});
// Create the field structure, adding the logic from the schema.
passwordForm = form(this.passwordModel, passwordSchema);
// Now, passwordForm's state (e.g. this.passwordForm().errors)
// will be determined by the rules defined in passwordSchema.
}
```
In this example, `passwordSchema` is defined but doesn't contain any specific rules yet. The key takeaway here is the structure: you define a typed schema and then link it to your `form()` instance alongside your data model signal.
### Defining logic rules within a schema
The schema is where you bind logic to specific fields in the field structure. The schema function receives a single argument, a `FieldPath<T>`, where `T` is the data type of the schema (e.g., `FieldPath<ConfirmedPassword>` in the example above).
The `FieldPath` acts as your tool for navigating the structure of your data model within the schema definition. It allows you to precisely target _where_ specific logic rules should apply. Navigating the `FieldPath` mirrors how you access properties on your data object or `Field` object.
You use these navigated paths as the first argument to the built-in logic binding functions to specify which part of the field structure the logic applies to. For example:
```typescript
const passwordSchema = schema<ConfirmedPassword>((path: FieldPath<ConfirmedPassword>) => {
// Adds validation logic to require a value for `password`.
required(path.password);
// Adds validation logic to require a value for `confirm`.
required(path.confirm);
});
```
#### Logic binding functions
Angular Signal Forms provides several functions designed to be called **within the schema function** to bind logic rules to parts of your field structure. These functions all take a `FieldPath` as the first argument to indicate which field the logic applies to.
A brief description of the logic binding functions offered in Signal Forms is shown below. To learn more about how these functions are used in Signal Forms, see the following sections.
##### `validate`
`validate` is used to add validation logic to a field. It specifies validation errors as a reactive function of the field's `FieldContext`. Multiple `validate` bindings can be added for different validation rules, the full list of validation errors generated by the different rules is accessible from the `FieldState`.
```ts
const emailSchema = schema<string>(emailPath => {
validate(emailPath, ({value}) => /\w+@\w+\.\w+/.test(value()) ? [] : [{
kind: 'invalid-email-format',
message: 'Required format: x@y.z'
}]);
});
```
##### `error`
`error` is a simplified version of `validate` provided for convenience that returns a boolean validity status with an optional user-facing error message.
```ts
const emailSchema = schema<string>(emailPath => {
error(emailPath, ({value}) => /\w+@\w+\.\w+/.test(value()), 'Required format: x@y.z');
});
```
##### `required`
`required` adds validation that requires a field to be non-empty. In addition to validation, this functions sets the `REQUIRED` metadata to `true` for the given field. This ensures that special visual treatment for required fields (such as `*`) are shown.
```ts
const nameSchema = schema<{first: string, last: string}>(namePath => {
required(namePath.first);
});
```
##### `disabled`
`disabled` adds logic to disable the field under certain conditions. The logic is specified as a reactive function of the `FieldContext`. The field will be disabled when the logic function returns `true` or a `string` value. If the returned value is a `string` it represents the reason for the disablement.
```ts
const passwordSchema = schema<ConfirmedPassword>(path => {
disabled(path.confirm, ({valueOf}) => {
return valueOf(path.password).length === 0 ? 'Password confirmation disabled until password is entered' : false;
});
});
```
##### `readonly`
`readonly` adds logic to make the field readonly under certain conditions. The logic is specified as a reactive function of the `FieldContext`. The field will be readonly when the logic function returns `true`.
```ts
const allowEdits = signal(false);
const passwordSchema = schema<ConfirmedPassword>(path => {
readonly(path.confirm, (ctx) => !allowEdits());
});
```
##### `hidden`
`hidden` adds logic to make the field hidden under certain conditions. The logic is specified as a reactive function of the `FieldContext`. The field will be considered hidden when the logic function returns `true`. When a field is hidden it is ignored for the purposes of determining the valid, touched, and dirty states. It is recommended to use the hidden state of the field to drive the rendering of the associated control in the UI.
```ts
const isLoggedIn = signal(false);
const passwordSchema = schema<Order>(orderPath => {
// Hide the email field for logged in users, we'll just use the email already on record.
hidden(orderPath.email, isLoggedIn);
});
```
```html
@if (!order().hidden()) {
<input [control]="order.email" />
}
```
##### `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<string[]>(names => {
validateTree(names, ({value, field}) => {
const errors = [];
const map = new Map<string, number[]>();
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.
<!-- TODO: expand this section. -->
#### 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<ConfirmedPassword>((path) => {
// Define a reactive validation rule to check the password length.
validate(path.password, ({value}: FieldContext<string>) => {
// 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<ConfirmedPassword>((path) => {
// Add validation at the root level that considers both the
// `password` and `confirm` values.
validate(path, ({value}: FieldContext<ConfirmedPassword>) => {
// 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>): U`**: Directly retrieves the current value of the field at `otherPath`.
- **`stateOf(otherPath: FieldPath<U>): FieldState<U>`**: Retrieves the `FieldState` instance for the field at `otherPath` (e.g., `stateOf(otherPath).disabled()`).
- **`fieldOf(otherPath: FieldPath<U>): Field<U>`**: 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<ConfirmedPassword>((path) => {
// Add validation on the `confirm` field that considers both the
// `password` and `confirm` values.
validate(path.confirm, ({value, valueOf}: FieldContext<string>) => {
// 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<Address>`) must match the data type of the `path` (e.g., `FieldPath<Address>`).
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<SimpleDate>((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<Trip>((tripPath: FieldPath<Trip>) => {
// 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<Trip>({
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<Trip>((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<SimpleDate>) => {
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<User>`) must match the data type of a single element of the `arrayPath` (e.g., `FieldPath<User[]>`).
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<User>((userPath) => {
disabled(userPath.username, () => true, 'Username cannot be changed');
});
@Component({...}) class MyComponent {
usersModel = signal<User[]>([]);
usersForm = form(this.usersModel, (usersPath: FieldPath<User[]>) => {
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, message: '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<T>` 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<T>`) must match the data type of the `path` (`FieldPath<T>`).
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<Account>((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<Account>((accountPath: FieldPath<Account>) => {
// 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<T>` 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<NarrowedType>`) 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<SimpleDate>((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<Trip>((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<Trip>({
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<User>(userPath => {
validateHttp(userPath.username, {
request: ({value}) => `/api/check-username?${value()}`,
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<boolean>` | `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<boolean>` | `true` if neither the field nor any of its children has any errors or pending validators, `false` otherwise. |
| `invalid` | `Signal<boolean>` | `true` if the field or any of its children has any errors, regardless of pending validators, `false` otherwise. |
| `errors` | `Signal<FormError[]>` | 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 necessarily `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<User>(userPath => {
validateAsync(userPath.username, {
params: ({value}) => `/api/check-username?${value()}`,
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<void>(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<ServerError[]>`).
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<any>`: 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 `<input>`, `<select>`, custom components) that the user interacts with in the template.
Angular Signal Forms provides the `Control` directive (`[control]`) to seamlessly bridge this gap. The `[control]` directive is how you link a specific `Field` node from your component's class to a compatible form control element in your HTML template.
You apply the directive to a form control element and bind it to the corresponding `Field` instance representing that field.
```typescript
interface User {
username: string;
name: string;
age: number;
}
@Component({
selector: 'user-form',
imports: [Control],
template: `
<form>
<label>Username: <input [control]="userForm.username" /></label>
<label>Name: <input [control]="userForm.name" /></label>
<label>Age: <input type="number" [control]="userForm.age" /></label>
</form>
`
})
class UserFormComponent {
const userModel = signal({username: '', name: '', age: 0})
readonly userForm: Field<User> = form<User>(userModel, (userPath: FieldPath<User>) => {
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 `<input>`, `<select>`, and `<textarea>`.
It can also integrate with custom form components (including those from libraries like Angular Material - e.g., `<mat-select>`, `<mat-radio>`) provided they correctly implement Angular's `ControlValueAccessor` interface. This is the standard mechanism in Angular for components to participate in forms.
<!-- TODO: add a more in-depth section on how to integrate your own custom UI controls -->
+474
View File
@@ -0,0 +1,474 @@
# Signal Form Version 2 Tutorial
## 🚧🚧 This design is still work in progress
> This tutorial assumes you are familiar with the previous iteration of forms
> and takes it from there.
We're still in early stages but wanted to get feedback as early as possible.
## Changes to existing APIs
### `.$state` is now `()`
```typescript
const cat = signal({
name: 'pirojok the cat',
age: 5
})
const f = form(cat)
// Before
console.log(f.name.$state.errors())
console.log(f.$state.valid())
// Now
console.log(f.name().errors())
console.log(f().valid())
console.log(f.a.b.c.d.e().errors())
```
### `resolve()` is now `fieldOf`/`stateOf`/`valueOf`
We got rid of `resolve` and introduced new ways of accessing a field and its
state.
```typescript
const cat = signal({
name: 'pirojok the cat',
age: 5
});
// Before
validate(p.name, ({value, resolve}) => {
value(); // string, 'pirojok the cat'
resolve(p.name).$state.disabled(); // boolean
resolve(p.age); // Field<number>
resolve(p.age).$state; // FieldState<number>
resolve(p.age).$state.value(); // number, 5
});
// Now
validate(p.name, ({value, state, field, valueOf, stateOf, fieldOf}) => {
value(); // string, 'pirojok the cat'
state().disabled(); // boolean
fieldOf(p.age); // Field<number>
stateOf(p.age); // FieldState<number>
valueOf(p.age); // number, 5
});
```
### `Schema<T>` type is now `schema<T>()` function
This would allow us to have more control:
```typescript
// Before
export const newCatSchema: Schema<Cat> = (cat) => {
required(cat.name);
}
// Now
export const catSchema = schema<Cat>((cat) => {
required(cat.name);
});
```
### [field] directive is now [control]
```typescript
// Before
import {FieldDirective} from '@angular/forms/signals';
@Component({
imports: [FieldDirective],
template: `<input [field]="cat.name" matInput>`
})
class CatComponent {
}
// After
import {Control} from '@angular/forms/signals';
@Component({
imports: [Control],
template: `<input [control]="cat.name" matInput>`
})
class CatComponent {
}
```
### New validation statuses
```typescript
// Existing API. Note: its value can now be false while async validators are running.
form.name().valid();
// New: True when all sync validators produced no errors.
form.name().syncValid();
// New: True when async validation is running.
form.name().pending();
// New: True when at least one validator failed.
form.name().invalid();
```
### Form must be created in Injection Context
Creating a form now requires an injection context. This allows for injecting
services directly within the schema.
```typescript
const catSchema = schema<Cat>(c => {
const service = inject(CatService);
});
```
You can also pass injector to the form:
```typescript
const f = form(
data,
schema,
{injector: TestBed.inject(Injector)},
);
```
## Built-in validators
We added the same set of built-in validations as in current forms.
```typescript
// feedback.ts
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path) => {
/* ... */
required(path.password);
minLength(path.password, 8);
maxLength(path.password, 16);
min();
max();
pattern();
email();
});
}
```
## Dirty state
You can check dirty state for the form (`form().dirty()`) or any of its
children (`form.name().dirty()`).
> A field is considered dirty if its value has ever been changed by a user.
With this, we can make this component usable in a `canDeactivate` guard.
```typescript
export interface HasUnsavedChanges {
isDirty: () => boolean;
}
/* ... */
export class FeedbackComponent implements HasUnsavedChanges {
/* ... */
isDirty(): boolean {
return this.form().dirty();
}
}
```
We'll leave adding `canDeactivate` as an exercise to the reader.
> There is no pristine state; let us know if it might be useful.
## Async validation
There are several different options available for async validation.
### validateHttp
If we have an HTTP endpoint, we can use `validateHttp` which uses httpResource
under the hood.
```typescript
validateHttp(p.username, {
request: ({value}) => `/api/usernameTaken?${value()}`,
errors: (result) => {
return result ? [{kind: 'notUnique'}] : undefined;
},
});
```
### validateAsync
> This is a lower-level primitive for most cases `validateHttp` should be
> enough.
> We're also working on adding `validateGrpc`
Let's create an async function that validates the name asynchronously:
```typescript
function validateName(name: string) {
return new Promise<boolean>((resolve) => {
setTimeout(() => {
resolve(name === 'sundar');
}, 1000);
});
}
```
Now we can use the new `validateAsync` rule.
```typescript
validateAsync(path.name, {
// Reactive params
params: ({value}) => value(),
// Factory creating a resource
factory: (params) =>
resource({
params,
loader: async ({params}) => {
return new Promise<boolean>((resolve) => {
setTimeout(() => {
resolve(params === 'sundar');
}, 1000);
});
},
}),
// Maps resource to error
errors: (result) => {
return result ? [{kind: 'notUnique'}] : undefined;
},
});
```
### Fetching dynamic data
Sometimes we need to fetch data based on the value of a field.
In our case, we can have the user select a product, and then fetch the list of
versions
based on selected product:
[Product] [Version]
First we create and export a global `DataKey` which we can later attach to a
field.
```typescript
// product.service.ts
import {ResourceRef} from '@angular/core';
import {DataKey} from '@angular/forms/signals';
// Each product would have a list of available versions, e.g. [1, 2, 3]
export const VERSIONS_KEY = new DataKey<ResourceRef<number[]>>();
```
Then, let's create a separate schema for a product.
```typescript
// product.service.ts
import {ResourceRef} from '@angular/core';
import {schema} from '@angular/forms/signals';
export const VERSIONS_KEY = new DataKey<ResourceRef<number[]>>();
export interface Product {
name: string;
version: number;
}
export const productSchema = schema<Product>((path) => {
required(path.name);
required(path.version);
});
```
Since forms must be created in an injection context, we can now inject a
service:
```typescript
// product.service.ts
import {ResourceRef, inject} from '@angular/core';
import {schema, required} from '@angular/forms/signals';
export const VERSIONS_KEY = new DataKey<ResourceRef<number[]>>();
export const productSchema = schema<Product>((path) => {
const service = inject(ProductService); // You can create this service yourself.
required(path.name);
required(path.version);
});
```
Now we can use `define` rule to attach a resource to the `product.name` field.
```typescript
// product.service.ts
import {ResourceRef, inject, resource} from '@angular/core';
import {schema, required, define} from '@angular/forms/signals';
export const VERSIONS_KEY = new DataKey<ResourceRef<number[]>>();
export const productSchema = schema<Product>((path) => {
const service = inject(ProductService);
required(path.name);
required(path.version);
define(
// We attach the data to name
path.name,
// Here we create a regular resource
({value}) => {
return resource({
params: () => value(),
loader: ({params}) => service.getVersions(params),
});
},
// This allows us to get the data outside the schema.
{asKey: VERSIONS_KEY},
);
});
```
Now we can get this data in the component using the `data()` method.
```typescript
// feedback.ts
import {VERSIONS_KEY} from './product.service';
/* ... */
export class FeedbackComponent {
/* ... */
form = form(this.data, (path) => {
/* ... */
// We also have to wire up the schema using apply.
apply(path.product, productSchema);
})
readonly versions = this.form.product.name().data(VERSIONS_KEY); // This is ResourceRef<number[]>
}
```
Now we can use the resulting resource in the template.
```angular2html
<mat-select [control]="form.product.version">
@if (versions?.isLoading()) {
<mat-option disabled>Loading...</mat-option>
}
@if (versions?.hasValue()) {
@for (version of versions.value(); track version) {
<mat-option [value]="version">{{ version }}</mat-option>
}
}
</mat-select>
```
## Disabled reason
When we disable a field, we can now return a string explaining why the field is
disabled:
```typescript
disabled(path.feedback, ({valueOf}) => {
const rating = valueOf(path.rating);
// Instead of a boolean, you can now return a `string` reason
return rating > 4 ? false : 'Please provide a better rating to enable feedback';
});
```
Now we can access `disabledReasons` from the field using
`form.feedback().disabledReasons()`.
```angular2html
<mat-form-field appearance="outline">
<mat-label>Feedback</mat-label>
<input [control]="form.feedback" matInput>
@if( form.feedback().disabled() ) {
<mat-hint>
{{ form.feedback().disabledReasons()[0].reason }}
</mat-hint>
}
</mat-form-field>
```
> The disabled reason propagates down to its children in the same way the
> disabled state does.
## validateTree
`validateTree` allows running validation on a group field and attaching the
errors to specific child fields. Let's rewrite our matching password validation
to display the error on both fields.
```typescript
export function confirmationPasswordValidator(
path: FieldPath<{ password: string; confirmationPassword: string }>,
): TreeValidator<{ password: string; confirmationPassword: string }> {
return ({valueOf, fieldOf}) => {
return valueOf(path.confirmationPassword) === valueOf(path.password)
? []
: [
{
field: fieldOf(path.confirmationPassword),
kind: 'confirmationPassword',
},
{
field: fieldOf(path.password),
kind: 'confirmationPassword',
},
];
};
}
```
Then we can use the new `validateTree` rule:
```typescript
// Before
validate(path, confirmationPasswordValidator(path));
// Now
validateTree(path, confirmationPasswordValidator(path));
```
Now both fields will have the `confirmationPassword` error.
## Readonly
The new `readonly` rule is very similar to `disabled`:
```typescript
import {
/* ... */
readonly,
} from '@angular/forms/signals';
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path) => {
/* ... */
readonly(path.feedback, ({valueOf}) => {
return valueOf(path.rating) > 4;
});
});
}
```
> There is currently no `readonly reason`; let us know if there's a use case for
> having it.
### Tracking in array
We need to `track` items in an array to preserve `touched` status when they are
moved around.
We can't use item instance, because updating a property (using a spread
operator) would create a new instance change it.
We added basic array tracking by attaching a speaci to objects
+944
View File
@@ -0,0 +1,944 @@
# Signal Form Tutorial
contacts:
[@kirjs](https://moma.corp.google.com/person/kirjs)
[@mmalerba](https://moma.corp.google.com/person/mmalerba)
[@arick](https://moma.corp.google.com/person/arick)
## 🚧🚧 This design is still work in progress
> This tutorial assumes you are familiar with Angular and have an Angular app running. You could create a new one using [boq angular](http://go/boq-angular-new)
We're still in early stages, but wanted to get feedback as early as possible.
Consult [The testing doc](https://docs.google.com/document/d/1zxMQGCOrVr6LuDkCCUrpfDl7A6A21y9_cPtpAMyxQy4/edit?resourcekey=0-WBlxniv_JZ7B7GHDprxeGA&tab=t.0) for the list of things that are not yet implemented.
## The feedback form
We're going to build a feedback form with the following fields:
```
* text [name] required
* text [email] required, must have @
* password [password] required
* password [confirmationPassword] required, must match password
* rating [rating] 1-5, custom component
* text [feedback] disabled if rating is 5, otherwise required
* checkbox [recommendToFriends]
* array [friends] only displayed/validated when recommendToFriends is true
* text [name] required
* text [email] required, must have @
```
## Initial setup
We assume that you have an Angular app already.
You can see [the final app here](http://google3/experimental/users/kirjs/forms/app/feedback/)
### Defining data model
First let's create an interface for our form:
```typescript
// feedback.ts
interface Friend {
name: string;
email: string;
}
interface Feedback {
name: string;
email: string;
password: string;
confirmationPassword: string;
rating: number;
feedback: string;
recommendToFriends: boolean;
friends: Friend[];
}
```
### Creating a new Feedback Component
```typescript
// feedback.ts
import {ChangeDetectionStrategy, Component} from '@angular/core';
@Component({
selector: 'app-feedback',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [],
template: `<h1>Feedback</h1>
<!-- Template goes here -->`
})
export class FeedbackComponent {
// Our form will go here
}
```
> Wire up the component to your app, and run it.
### Creating a signal with data
Signal forms don't own data; they use a user-provided signal as the source of truth.
So first, we need to create a signal with initial values:
```typescript
// feedback.ts
import {ChangeDetectionStrategy, Component, signal} from '@angular/core'; // Added signal import
@Component({/*...*/})
export class FeedbackComponent {
readonly data = signal<Feedback>({
name: '',
rating: 1,
email: '',
password: '',
confirmationPassword: '',
feedback: '',
recommendToFriends: false,
friends: [],
});
}
```
## Creating a simple form
### Creating a form instance
A simple form just takes a signal with the data and produces matching field structure.
```typescript
// feedback.ts
import {
form,
} from '@angular/forms/signals';
@Component({/*...*/})
export class FeedbackComponent {
readonly data = signal<Feedback>({/*...*/});
// Yay, we can start using the form now!
readonly form = form(this.data);
}
```
### Binding form field to an input
Now in the template we can use the new `[control]` directive.
```typescript
// feedback.ts
import {
form,
Control,
} from '@angular/forms/signals';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
@Component({/*...*/
imports: [
Control,
// We'll also need those
MatFormFieldModule,
MatInputModule,
]
})
```
We will use a Material Input here, but the same would work with a standard input as well.
```html
<!-- Add this inside the template of FeedbackComponent -->
<mat-form-field>
<mat-label>Name</mat-label>
<input [control]="form.name" matInput>
</mat-form-field>
```
### Verifying it works
Now we can print the value of the `data` signal and validate that it changes when we change the name input.
```html
<!-- Add this to the template, and ensure JsonPipe is imported -->
<pre>
{{ data() | json}}
</pre>
```
## Adding Validation
Now let's make it interesting and add some validation rules.
### Making name required:
You can pass validation rules as a second argument to the `form` function.
```typescript
// feedback.ts
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path) => {
// Path is a special entity mirroring the structure of the form,
// with no acess to fields state or value.
// We can use it to specify which fields to validate.
});
}
```
Now let's use the built-in `required` validator.
```typescript
// feedback.ts
import {
form,
Control,
// Import the required validator
required,
} from '@angular/forms/signals';
/* ... */
export class FeedbackComponent {
readonly data = signal<Feedback>({/*...*/});
readonly form = form(this.data, (path) => {
// Now it's required!
required(path.name);
});
}
```
> Note: Currently, `required` is the only built-in validator.
### Displaying validation errors
Each form field can be called as a function to get its `FieldState`, an object containing its value and other state information in signal form.
```typescript
// Example accessors:
form.name().value(); // Value signal
form.name().valid(); // Boolean signal indicating validity
form.name().errors(); // Signal holding an array of validation errors
// 💰 💰 💰
```
We can use these in the template:
```html
<mat-form-field>
<mat-label>Name</mat-label>
<input [control]="form.name" matInput>
@if (!form.name().valid()) {
<mat-error>{{ form.name().errors() | json }}</mat-error>
<!-- Output might look like: [{ "kind": "required" }] after the input is touched and left empty. -->
}
</mat-form-field>
```
### Error structure
Each error has a `kind` property and an optional `message`.
```typescript
export interface FormError {
kind: string;
message?: string;
}
```
### Displaying an actual error
Throughout the tutorial, we're going to just output the error kind, but for this specific one, we'll display a nice translatable message.
```html
<mat-form-field>
<mat-label>Name</mat-label>
<input [control]="form.name" matInput>
@if (!form.name().valid()) {
<mat-error>
@if (form.name().errors()[0]?.kind === 'required') {
<ng-container i18n="Error message">
This field is required
</ng-container>
}
</mat-error>
}
</mat-form-field>
```
### Handling email
#### Making email required
```typescript
// feedback.ts
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path) => {
required(path.name);
// Email is required
required(path.email);
});
}
```
#### Creating a custom email validator
To create a custom validator, we'll import the `validate` function.
```typescript
// feedback.ts
import {
form,
Control,
required,
// Import validate
validate,
} from '@angular/forms/signals';
```
Similar to `required`, it takes a path, but also a validator function:
```typescript
// feedback.ts
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path) => {
required(path.name);
required(path.email);
validate(path.email, ({value}) => {
// Value is a signal.
return value().includes('@') ?
// Yay, no more nulls!
undefined :
{kind: 'emailFormat'};
})
});
}
```
> This email check is intentionally very permissive to focus on the validation mechanism.
#### Adding email field
```html
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input [control]="form.email" matInput>
@if (!form.email().valid()) {
<!-- You can display it in any way you want -->
<mat-error>{{ form.email().errors()[0].kind }}</mat-error>
}
</mat-form-field>
```
### Password and confirmation password
#### Validating passwords
First, let's make the passwords required:
```typescript
// feedback.ts
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path) => {
/* ... */
required(path.password);
required(path.confirmationPassword);
});
}
```
Now let's write a custom validator to ensure that the password and confirmation password match.
To do this, we will use the special `valueOf` function provided to the validator. `valueOf` takes a path segment and returns the corresponding form field's value.
> Besides `valueOf`, you can also use `stateOf` and `fieldOf` can be used for cross-field validation.
```typescript
// feedback.ts
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path) => {
/* ... */
required(path.password);
required(path.confirmationPassword);
validate(path.confirmationPassword, ({value, valueOf}) => {
return value() === valueOf(path.password)
? undefined
: {kind: 'confirmationPassword'};
});
});
}
```
#### Displaying password in the template
```html
<!-- Add these fields to the template -->
<mat-form-field appearance="outline">
<mat-label>Password</mat-label>
<input [control]="form.password" matInput type="password">
@if (!form.password().valid()) {
<mat-error>{{ form.password().errors()[0]?.kind }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Confirm Password</mat-label>
<input [control]="form.confirmationPassword" matInput type="password">
@if (!form.confirmationPassword().valid()) {
<mat-error>{{ form.confirmationPassword().errors()[0]?.kind }}: {{ form.confirmationPassword().errors()[0]?.message ?? 'Invalid' }}</mat-error>
}
</mat-form-field>
```
### Moving out the confirmation password validator
Let's also take a look, what would it take to the confirmation validator outside of the form.
To do this we'd create a constructor function, which would take a path with the password field.
```typescript
export function confirmationPasswordValidator(
path: FieldPath<{password: string}>,
): Validator<string> {
return ({value, valueOf}) => {
return value() === valueOf(path.password)
? undefined
: {kind: 'confirmationPassword'};
};
}
```
Now we can add it by passing relevant part of the path.
```typescript
// feedback.ts
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path) => {
/* ... */
validate(path.confirmationPassword, confirmationPasswordValidator(path));
});
}
```
## Break time
Ok, let's take a short break ☕🍪☕ before we dive into creating custom components.
## Custom components
Sometimes standard inputs aren't enough, so we can also use the `[control]` directive with custom components. To do this, we need to create a component that implements the `FormUiControl<T>` interface.
In our case, we'll create a `RatingComponent` which will display stars like this: `⭐⭐☆☆☆`
```typescript
// rating.component.ts
import {
FormUiControl,
FormError
} from '@angular/forms/signals';
import {input, model} from '@angular/core';
export class RatingComponent implements FormUiControl<number> {
// If you change the value it gets updated in the form.
readonly value = model<number>(0);
// We could take all field props, such as value/errors/touched.
readonly errors = input<readonly FormError[]>();
}
```
### Displaying the stars
This is unrelated to Forms.
you can see full implementation [here](http://google3/experimental/users/kirjs/forms/app/feedback/rating.ts)
### Using rating in the Feedback component template
After importing Rating component, you can use it like this:
```html
<div>
<label>Rating</label>
<custom-rating [control]="form.rating"/>
</div>
```
## Feedback text which is disabled if the rating is ⭐⭐⭐⭐⭐
We want the feedback text field to be required, but only enabled if the rating is less than 5 stars.
### Adding disabled rule
We use the `disabled` rule for this.
```typescript
// feedback.ts
import {
/* ... */
disabled,
} from '@angular/forms/signals';
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path) => {
/* ... */
disabled(path.feedback, ({valueOf}) => {
return valueOf(path.rating) > 4;
});
// When a field is disabled, validation doesn't run
// So it's safe to make it required.
required(path.feedback);
});
}
```
### Displaying the feedback in the template
This is pretty straightforward. The `[control]` directive handles passing the disabled state to standard inputs automatically.
> Note that the disabled state logic is defined in the form setup, not directly in the template binding.
```html
<mat-form-field appearance="outline">
<mat-label>Feedback</mat-label>
<input [control]="form.feedback" matInput>
@if (!form.feedback().valid()) {
<mat-error>
{{ form.feedback().errors()[0].kind }}
</mat-error>
}
</mat-form-field>
```
## Arrays and reusing parts of form (schemas)
The user should be able to provide name and email information for multiple friends if they choose to recommend the service.
We'll create a separate `FriendComponent` and define a reusable schema for the friend data structure.
### Friend interface
Most of the work in this section will happen in a new `friend.ts`.
First, let's ensure the `Friend` interface is defined:
```typescript
// friend.ts
export interface Friend {
name: string;
email: string;
}
```
### Create friend schema
Now we need to specify `friend` part of the form. For this we will use a special type called Schema.
This code should look familiar: Both fields are required, and the email has its own validator.
```typescript
// friend.ts
import {
Schema,
required,
validate,
} from '@angular/forms/signals';
export const friendSchema: Schema<Friend> = (friend) => {
required(friend.name);
required(friend.email);
validate(friend.email, ({value}) => {
return value().includes('@') ?
undefined :
{ kind: 'emailFormat' };
})
};
```
But, uh oh, the email validation code is duplicated from our main form. Let's extract it.
### Reusing email validator
We'll create a separate file for reusable validators.
```typescript
// validator.ts
import {
Validator
} from '@angular/forms/signals';
export const emailValidator: Validator<string> =
({value}) => {
return !value().includes('@') ? undefined : {kind: 'email'};
};
```
Now we can use it in the schema (don't forget to use in feedback component as well).
```typescript
// friend.ts
import {
Schema,
required,
validate,
} from '@angular/forms/signals';
import {emailValidator} from './validators';
// Schema is not used in this file.
export const friendSchema: Schema<Friend> = (friend) => {
required(friend.name);
required(friend.email);
validate(friend.email, emailValidator)
};
```
### Creating Friend Component
This component will display the form fields for a single friend and used in array.
```typescript
// friend.ts
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
import { Field } from '@angular/forms/signals';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-friend',
imports: [/* ... */],
})
class FriendComponent {
// We'll take the field as an input
friend = input.required<Field<Friend>>();
}
```
### Displaying name and email in Friend's template
```html
<!-- friend.component.html -->
@let friend = this.friend();
<div>
<mat-form-field>
<mat-label>Name</mat-label>
<input [control]="friend.name" matInput>
@if(!friend.name().valid()){
<mat-error>{{ friend.name().errors()[0].kind}}</mat-error>
}
</mat-form-field>
</div>
<div>
<mat-form-field>
<mat-label>Email</mat-label>
<input [control]="friend.email" matInput>
@if(!friend.email().valid()){
<mat-error>{{ friend.email().errors()[0].kind}}</mat-error>
}
</mat-form-field>
</div>
```
### Applying the friend schema to Array elements
We can use the `applyEach` rule within our main form definition to apply the `friendSchema` to each element of the `friends` array.
```typescript
// feedback.ts
import {
/* ... */
applyEach,
} from '@angular/forms/signals';
import { friendSchema } from './friend';
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path) => {
/* ... */
applyEach(path.friends, friendSchema);
});
}
```
### Displaying friend list in the template
First, we need to import `FriendComponent` into `FeedbackComponent`:
```typescript
// feedback.ts
import {FriendComponent} from './friend';
@Component({
/* ... */
imports: [
/* ... */
FriendComponent
],
})
export class FeedbackComponent {/* ... */}
```
Now, let's add the UI elements to the `FeedbackComponent` template. First, the `recommendToFriends` checkbox:
```html
<!-- feedback.component.html -->
<label>
<mat-checkbox [control]="form.recommendToFriends">
Recommend to friends
</mat-checkbox>
</label>
```
Then, we'll display the list of friends, but only when the checkbox is checked.
```html
<!-- feedback.component.html -->
@if (form.recommendToFriends().value()) {
@for (friend of form.friends; track friend) {
<app-friend [friend]="friend"></app-friend>
}
}
```
### Hiding
The current setup works, but there's a small issue.
If we create a friend with an error, and then hide it, the validation would still run, and the form would be marked as invalid.
We can solve it by using `hidden` with a predicate to disabled the validation.
```typescript
// feedback.ts
import {
/* ... */
applyEach,
hidden
} from '@angular/forms/signals';
import { friendSchema } from './friend';
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path) => {
/* ... */
applyEach(path.friends, friendSchema);
// Doesn't actually hide anything in the UI.
hidden(path.friends, ({valueOf}) => {
return valueOf(path.recommendToFriends) === false ;
});
});
}
```
> it's important to note, that `hidden` doesn't actually hide fields in the template, just disables validation.
### Conditionally enabling/disabling validation with applyWhen
Sometimes we want to apply multiple rules based only if certain condition is true.
For this we can use `applyWhen`.
Let's look at an unrelated example, where we want to apply different rules depending on whether a pet is a cat or a dog.
```typescript
// unrelated-form.ts
form(this.pet, (pet: FieldPath<any>) => {
// Applies for all pets
required(pet.cute);
// Rules that only apply for dogs.
applyWhen(
pet,
({value}) => value().type === 'dog',
(pathWhenTrue) => {
// Only required for dogs, but can be entered for cats
required(pathWhenTrue.walksPerDay);
// Doesn't apply for dogs
hidden(pathWhenTrue.purringIntensity);
}
);
applyWhen(
pet,
({value}) => value().type === 'cat',
(pathWhenTrue) => {
// Those rules only apply for cats.
required(pathWhenTrue.a);
validate(pathWhenTrue.b, /* validation rules */);
applyEach(pathWhenTrue, /* array rules */);
applyWhen(/* we can even have nested apply whens. */);
}
);
});
```
In our case, we could use applyWhen instead of hidden (although it might be an overkill for just one rule)
It's also important to not use closured path, but use the one provided by the function:
```typescript
// feedback.ts
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path) => {
/* ... other rules ... */
applyWhen(
path,
({value}) => value().recommendToFriends,
(pathWhenTrue) => {
applyEach(pathWhenTrue.friends, friendSchema);
// 🚨 👮 🚓 You have to use nested path
// This produces a Runtime error:
applyEach(path /*has to be pathWhenTrue*/.friends, friendSchema);
// ✅ This works
applyEach(pathWhenTrue.friends, friendSchema);
}
);
});
}
```
> `pathWhenTrue` could also just be called path, it's a stylistic chose.
Now, `friendSchema` validation rules will only apply when `recommendToFriends` is true.
### Adding items to the array
Let's allow the user to add a new friend to the list.
```typescript
// feedback.ts
export class FeedbackComponent {
/* ... */
addFriend() {
// value is a writable signal.
this.form.friends().value.update(
(f) => [...f, {name: '', email: ''}]
);
}
}
```
Now, add the button to the template inside the `@if` block:
```html
<!-- feedback.component.html -->
@if (form.recommendToFriends().value()) {
@for (friend of form.friends; track friend) {
<app-friend [friend]="friend"></app-friend>
}
<!-- This is the new button -->
<button mat-button (click)="addFriend()">
Add Friend
</button>
}
```
## Submitting the form
To handle form submission, use the `submit` function, passing it your form instance and an async submission handler.
```typescript
// feedback.ts
import {
/* ... */
submit,
} from '@angular/forms/signals';
/* ... */
export class FeedbackComponent {
/* ... */
submit() {
submit(this.form, async () => {
/* Do your async stuff here */
});
}
}
```
### Handling submission errors
You can return a list of server errors and map them to appropriate field here as well.
```typescript
// feedback.ts
/* ... */
export class FeedbackComponent {
/* ... */
submit() {
submit(this.form, async () => {
return Promise.resolve([
{
field: this.form.name,
error: {kind: 'notUnique'},
},
]);
});
}
}
```
## The end
This marks the end of the tutorial. Let's take a look at the complete form definition consolidating all the rules we've added:
```typescript
// feedback.ts
/* ... */
export class FeedbackComponent {
/* ... */
readonly form = form(this.data, (path: FieldPath<Feedback>) => {
required(path.name);
required(path.email);
validate(path.email, emailValidator);
required(path.password);
required(path.confirmationPassword);
disabled(path.feedback, ({valueOf}) => {
return valueOf(path.rating) > 4;
});
required(path.feedback);
validate(path.confirmationPassword, ({value, valueOf}) => {
return value() === valueOf(path.password)
? undefined
: {kind: 'confirmationPassword'};
})
applyWhen(
path,
({value}) => value().recommendToFriends,
(pathWhenTrue) => {
applyEach(pathWhenTrue.friends, friendSchema);
},
);
});
}
```
+14
View File
@@ -0,0 +1,14 @@
/**
* @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
*/
// This file is not used to build this module. It is only used during editing
// by the TypeScript language service and during build for verification. `ngc`
// replaces this file with production index.ts when it rewrites private symbol
// names.
export * from './public_api';
+23
View File
@@ -0,0 +1,23 @@
/**
* @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
*/
/**
* @module
* @description
* Entry point for all public APIs of this package.
*/
export * from './src/api/async';
export * from './src/api/control';
export * from './src/api/logic';
export * from './src/api/property';
export * from './src/api/structure';
export * from './src/api/types';
export * from './src/api/validators';
export * from './src/controls/control';
export * from './src/controls/interop_ng_control';
export * from './src/api/validation_errors';
+194
View File
@@ -0,0 +1,194 @@
/**
* @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 {httpResource, HttpResourceOptions, HttpResourceRequest} from '@angular/common/http';
import {computed, ResourceRef, Signal} from '@angular/core';
import {FieldNode} from '../field/node';
import {addDefaultField} from '../field/validation';
import {FieldPathNode} from '../schema/path_node';
import {assertPathIsCurrent} from '../schema/schema';
import {property} from './logic';
import {FieldContext, FieldPath, PathKind, TreeValidationResult} from './types';
/**
* A function that takes the result of an async operation and the current field context, and maps it
* to a list of validation errors.
*
* @param result The result of the async operation.
* @param ctx The context for the field the validator is attached to.
* @return A validation error, or list of validation errors to report based on the result of the async operation.
* The returned errors can optionally specify a field that the error should be targeted to.
* A targeted error will show up as an error on its target field rather than the field being validated.
* If a field is not given, the error is assumed to apply to the field being validated.
* @template TValue The type of value stored in the field being validated.
* @template TResult The type of result returned by the async operation
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
*/
export type MapToErrorsFn<TValue, TResult, TPathKind extends PathKind = PathKind.Root> = (
result: TResult,
ctx: FieldContext<TValue, TPathKind>,
) => TreeValidationResult;
/**
* Options that indicate how to create a resource for async validation for a field,
* and map its result to validation errors.
*
* @template TValue The type of value stored in the field being validated.
* @template TParams The type of parameters to the resource.
* @template TResult The type of result returned by the resource
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
*/
export interface AsyncValidatorOptions<
TValue,
TParams,
TResult,
TPathKind extends PathKind = PathKind.Root,
> {
/**
* A function that receives the field context and returns the params for the resource.
*
* @param ctx The field context for the field being validated.
* @returns The params for the resource.
*/
readonly params: (ctx: FieldContext<TValue, TPathKind>) => TParams;
/**
* A function that receives the resource params and returns a resource of the given params.
* The given params should be used as is to create the resource.
* The forms system will report the params as `undefined` when this validation doesn't need to be run.
*
* @param params The params to use for constructing the resource
* @returns A reference to the constructed resource.
*/
readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;
/**
* A function that takes the resource result, and the current field context and maps it to a list
* of validation errors.
*
* @param result The resource result.
* @param ctx The context for the field the validator is attached to.
* @return A validation error, or list of validation errors to report based on the resource result.
* The returned errors can optionally specify a field that the error should be targeted to.
* A targeted error will show up as an error on its target field rather than the field being validated.
* If a field is not given, the error is assumed to apply to the field being validated.
*/
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
}
/**
* Options that indicate how to create an httpResource for async validation for a field,
* and map its result to validation errors.
*
* @template TValue The type of value stored in the field being validated.
* @template TResult The type of result returned by the httpResource
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
*/
export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKind = PathKind.Root> {
/**
* A function that receives the field context and returns the url or request for the httpResource.
* If given a URL, the underlying httpResource will perform an HTTP GET on it.
*
* @param ctx The field context for the field being validated.
* @returns The URL or request for creating the httpResource.
*/
readonly request:
| ((ctx: FieldContext<TValue, TPathKind>) => string | undefined)
| ((ctx: FieldContext<TValue, TPathKind>) => HttpResourceRequest | undefined);
/**
* A function that takes the httpResource result, and the current field context and maps it to a
* list of validation errors.
*
* @param result The httpResource result.
* @param ctx The context for the field the validator is attached to.
* @return A validation error, or list of validation errors to report based on the httpResource result.
* The returned errors can optionally specify a field that the error should be targeted to.
* A targeted error will show up as an error on its target field rather than the field being validated.
* If a field is not given, the error is assumed to apply to the field being validated.
*/
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
/**
* The options to use when creating the httpResource.
*/
readonly options?: HttpResourceOptions<TResult, unknown>;
}
/**
* Adds async validation to the field corresponding to the given path based on a resource.
* Async validation for a field only runs once all synchronous validation is passing.
*
* @param path A path indicating the field to bind the async validation logic to.
* @param opts The async validation options.
* @template TValue The type of value stored in the field being validated.
* @template TParams The type of parameters to the resource.
* @template TResult The type of result returned by the resource
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
*/
export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
opts: AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>,
): void {
assertPathIsCurrent(path);
const pathNode = FieldPathNode.unwrapFieldPath(path);
const RESOURCE = property(path, (ctx) => {
const params = computed(() => {
const node = ctx.stateOf(path) as FieldNode;
const validationState = node.validationState;
if (validationState.shouldSkipValidation() || !validationState.syncValid()) {
return undefined;
}
return opts.params(ctx);
});
return opts.factory(params);
});
pathNode.logic.addAsyncErrorRule((ctx) => {
const res = ctx.state.property(RESOURCE)!;
switch (res.status()) {
case 'idle':
return undefined;
case 'loading':
case 'reloading':
return 'pending';
case 'resolved':
case 'local':
if (!res.hasValue()) {
return undefined;
}
const errors = opts.errors(res.value()!, ctx as FieldContext<TValue, TPathKind>);
return addDefaultField(errors, ctx.field);
case 'error':
// TODO: Design error handling for async validation. For now, just throw the error.
throw res.error();
}
});
}
/**
* Adds async validation to the field corresponding to the given path based on an httpResource.
* Async validation for a field only runs once all synchronous validation is passing.
*
* @param path A path indicating the field to bind the async validation logic to.
* @param opts The http validation options.
* @template TValue The type of value stored in the field being validated.
* @template TResult The type of result returned by the httpResource
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
*/
export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
opts: HttpValidatorOptions<TValue, TResult, TPathKind>,
) {
validateAsync(path, {
params: opts.request,
factory: (request: Signal<any>) => httpResource(request, opts.options),
errors: opts.errors,
});
}
+152
View File
@@ -0,0 +1,152 @@
/**
* @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 {InputSignal, ModelSignal, OutputRef} from '@angular/core';
import type {DisabledReason} from './types';
import {ValidationError} from './validation_errors';
/** The base set of properties shared by all form control contracts. */
export interface FormUiControl {
// TODO: `ValidationError` and `DisabledReason` are inherently tied to the signal forms system.
// They don't make sense when using a ccontrol separately from the forms system and setting the
// inputs individually. Givn that, should they still be part of this interface?
/**
* An input to receive the errors for the field. If implemented, the `Control` directive will
* automatically bind errors from the bound field to this input.
*/
readonly errors?: InputSignal<readonly ValidationError[]>;
/**
* An input to receive the disabled status for the field. If implemented, the `Control` directive
* will automatically bind the disabled status from the bound field to this input.
*/
readonly disabled?: InputSignal<boolean>;
/**
* An input to receive the reasons for the disablement of the field. If implemented, the `Control`
* directive will automatically bind the disabled reason from the bound field to this input.
*/
readonly disabledReasons?: InputSignal<readonly DisabledReason[]>;
/**
* An input to receive the readonly status for the field. If implemented, the `Control` directive
* will automatically bind the readonly status from the bound field to this input.
*/
readonly readonly?: InputSignal<boolean>;
/**
* An input to receive the hidden status for the field. If implemented, the `Control` directive
* will automatically bind the hidden status from the bound field to this input.
*/
readonly hidden?: InputSignal<boolean>;
/**
* An input to receive the invalid status for the field. If implemented, the `Control` directive
* will automatically bind the invalid status from the bound field to this input.
*/
readonly invalid?: InputSignal<boolean>;
/**
* An input to receive the pending status for the field. If implemented, the `Control` directive
* will automatically bind the pending status from the bound field to this input.
*/
readonly pending?: InputSignal<boolean>;
/**
* An input to receive the touched status for the field. If implemented, the `Control` directive
* will automatically bind the touched status from the bound field to this input.
*/
readonly touched?: ModelSignal<boolean> | InputSignal<boolean> | OutputRef<boolean>;
/**
* An input to receive the dirty status for the field. If implemented, the `Control` directive
* will automatically bind the dirty status from the bound field to this input.
*/
readonly dirty?: InputSignal<boolean>;
/**
* An input to receive the name for the field. If implemented, the `Control` directive will
* automatically bind the name from the bound field to this input.
*/
readonly name?: InputSignal<string>;
/**
* An input to receive the required status for the field. If implemented, the `Control` directive
* will automatically bind the required status from the bound field to this input.
*/
readonly required?: InputSignal<boolean>;
/**
* An input to receive the min value for the field. If implemented, the `Control` directive will
* automatically bind the min value from the bound field to this input.
*/
readonly min?: InputSignal<number | undefined>;
/**
* An input to receive the min length for the field. If implemented, the `Control` directive will
* automatically bind the min length from the bound field to this input.
*/
readonly minLength?: InputSignal<number | undefined>;
/**
* An input to receive the max value for the field. If implemented, the `Control` directive will
* automatically bind the max value from the bound field to this input.
*/
readonly max?: InputSignal<number | undefined>;
/**
* An input to receive the max length for the field. If implemented, the `Control` directive will
* automatically bind the max length from the bound field to this input.
*/
readonly maxLength?: InputSignal<number | undefined>;
/**
* An input to receive the value patterns for the field. If implemented, the `Control` directive
* will automatically bind the value patterns from the bound field to this input.
*/
readonly pattern?: InputSignal<readonly RegExp[]>;
}
/**
* A contract for a form control that edits a `Field` of type `TValue`. Any component that
* implements this contract can be used with the `Control` directive.
*
* Many of the properties declared on this contract are optional. They do not need to be
* implemented, but if they are will be kept in sync with the field state of the field bound to the
* `Control` directive.
*
* @template TValue The type of `Field` that the implementing component can edit.
*/
export interface FormValueControl<TValue> extends FormUiControl {
/**
* The value is the only required property in this contract. A component that wants to integrate
* with the `Control` directive via this contract, *must* provide a `model()` that will be kept in
* sync with the value of the bound `Field`.
*/
readonly value: ModelSignal<TValue>;
// TODO: We currently require that a `checked` input not be present, as we may want to introduce a
// third kind of form control for radio buttons that defines both a `value` and `checked` input.
// We are still evaluating whether this makes sense, but if we decide not to persue this we can
// remove this restriction.
/**
* The implementing component *must not* define a `checked` property. This is reserved for
* components that want to integrate with the `Control` directive as a checkbox.
*/
readonly checked?: undefined;
}
/**
* A contract for a form control that edits a boolean checkbox `Field`. Any component that
* implements this contract can be used with the `Control` directive.
*
* Many of the properties declared on this contract are optional. They do not need to be
* implemented, but if they are will be kept in sync with the field state of the field bound to the
* `Control` directive.
*/
export interface FormCheckboxControl extends FormUiControl {
/**
* The checked is the only required property in this contract. A component that wants to integrate
* with the `Control` directive, *must* provide a `model()` that will be kept in sync with the
* value of the bound `Field`.
*/
readonly checked: ModelSignal<boolean>;
// TODO: maybe this doesn't have to be strictly `undefined`? It just can't be a model signal.
// Typescript doesn't really have a way to do any-but, but we could maybe introduce an optional
// generic for it?
/**
* The implementing component *must not* define a `value` property. This is reserved for
* components that want to integrate with the `Control` directive as a standard input.
*/
readonly value?: undefined;
}
+209
View File
@@ -0,0 +1,209 @@
/**
* @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 {addDefaultField} from '../field/validation';
import {FieldPathNode} from '../schema/path_node';
import {assertPathIsCurrent} from '../schema/schema';
import {AggregateProperty, createProperty, Property} from './property';
import type {
FieldContext,
FieldPath,
FieldValidator,
LogicFn,
PathKind,
TreeValidator,
} from './types';
/**
* Adds logic to a field to conditionally disable it. A disabled field does not contribute to the
* validation, touched/dirty, or other state of its parent field.
*
* @param path The target path to add the disabled logic to.
* @param logic A reactive function that returns `true` (or a string reason) when the field is disabled,
* and `false` when it is not disabled.
* @template TValue The type of value stored in the field the logic is bound to.
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function disabled<TValue, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
logic?: string | NoInfer<LogicFn<TValue, boolean | string, TPathKind>>,
): void {
assertPathIsCurrent(path);
const pathNode = FieldPathNode.unwrapFieldPath(path);
pathNode.logic.addDisabledReasonRule((ctx) => {
let result: boolean | string = true;
if (typeof logic === 'string') {
result = logic;
} else if (logic) {
result = logic(ctx as FieldContext<TValue, TPathKind>);
}
if (typeof result === 'string') {
return {field: ctx.field, message: result};
}
return result ? {field: ctx.field} : undefined;
});
}
/**
* Adds logic to a field to conditionally make it readonly. A readonly field does not contribute to
* the validation, touched/dirty, or other state of its parent field.
*
* @param path The target path to make readonly.
* @param logic A reactive function that returns `true` when the field is readonly.
* @template TValue The type of value stored in the field the logic is bound to.
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function readonly<TValue, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
logic: NoInfer<LogicFn<TValue, boolean, TPathKind>> = () => true,
) {
assertPathIsCurrent(path);
const pathNode = FieldPathNode.unwrapFieldPath(path);
pathNode.logic.addReadonlyRule(logic);
}
/**
* Adds logic to a field to conditionally hide it. A hidden field does not contribute to the
* validation, touched/dirty, or other state of its parent field.
*
* If a field may be hidden it is recommended to guard it with an `@if` in the template:
* ```
* @if (!email().hidden()) {
* <label for="email">Email</label>
* <input id="email" type="email" [control]="email" />
* }
* ```
*
* @param path The target path to add the hidden logic to.
* @param logic A reactive function that returns `true` when the field is hidden.
* @template TValue The type of value stored in the field the logic is bound to.
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function hidden<TValue, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
logic: NoInfer<LogicFn<TValue, boolean, TPathKind>>,
): void {
assertPathIsCurrent(path);
const pathNode = FieldPathNode.unwrapFieldPath(path);
pathNode.logic.addHiddenRule(logic);
}
/**
* Adds logic to a field to determine if the field has validation errors.
*
* @param path The target path to add the validation logic to.
* @param logic A `Validator` that returns the current validation errors.
* @template TValue The type of value stored in the field the logic is bound to.
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function validate<TValue, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
logic: NoInfer<FieldValidator<TValue, TPathKind>>,
): void {
assertPathIsCurrent(path);
const pathNode = FieldPathNode.unwrapFieldPath(path);
pathNode.logic.addSyncErrorRule((ctx) =>
addDefaultField(logic(ctx as FieldContext<TValue, TPathKind>), ctx.field),
);
}
/**
* Adds logic to a field to determine if the field or any of its child fields has validation errors.
*
* @param path The target path to add the validation logic to.
* @param logic A `TreeValidator` that returns the current validation errors.
* Errors returned by the validator may specify a target field to indicate an error on a child field.
* @template TValue The type of value stored in the field the logic is bound to.
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function validateTree<TValue, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
logic: NoInfer<TreeValidator<TValue, TPathKind>>,
): void {
assertPathIsCurrent(path);
const pathNode = FieldPathNode.unwrapFieldPath(path);
pathNode.logic.addSyncTreeErrorRule((ctx) =>
addDefaultField(logic(ctx as FieldContext<TValue, TPathKind>), ctx.field),
);
}
/**
* Adds a value to an `AggregateProperty` of a field.
*
* @param path The target path to set the aggregate property on.
* @param prop The aggregate property
* @param logic A function that receives the `FieldContext` and returns a value to add to the aggregate property.
* @template TValue The type of value stored in the field the logic is bound to.
* @template TPropItem The type of value the property aggregates over.
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function aggregateProperty<TValue, TPropItem, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
prop: AggregateProperty<any, TPropItem>,
logic: NoInfer<LogicFn<TValue, TPropItem, TPathKind>>,
): void {
assertPathIsCurrent(path);
const pathNode = FieldPathNode.unwrapFieldPath(path);
pathNode.logic.addAggregatePropertyRule(prop, logic);
}
/**
* Creates a new `Property` and defines the value of the new property for the given field.
*
* @param path The path to define the property for.
* @param factory A factory function that creates the value for the property.
* This function is **not** reactive. It is run once when the field is created.
* @returns The newly created property
*/
export function property<TValue, TData, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
factory: (ctx: FieldContext<TValue, TPathKind>) => TData,
): Property<TData>;
/**
* Defines the value of a `Property` for a given field.
*
* @param path The path to define the property for.
* @param prop The property to define.
* @param factory A factory function that creates the value for the property.
* This function is **not** reactive. It is run once when the field is created.
* @returns The given property
*/
export function property<TValue, TData, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
prop: Property<TData>,
factory: (ctx: FieldContext<TValue, TPathKind>) => TData,
): Property<TData>;
export function property<TValue, TData, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
...rest:
| [(ctx: FieldContext<TValue, TPathKind>) => TData]
| [Property<TData>, (ctx: FieldContext<TValue, TPathKind>) => TData]
): Property<TData> {
assertPathIsCurrent(path);
let key: Property<TData>;
let factory: (ctx: FieldContext<TValue, TPathKind>) => TData;
if (rest.length === 2) {
[key, factory] = rest;
} else {
[factory] = rest;
}
key ??= createProperty();
const pathNode = FieldPathNode.unwrapFieldPath(path);
pathNode.logic.addPropertyFactory(key, factory as (ctx: FieldContext<unknown>) => unknown);
return key;
}
+151
View File
@@ -0,0 +1,151 @@
/**
* @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
*/
/**
* Represents a property that may be defined on a field when it is created using a `property` rule
* in the schema. A particular `Property` can only be defined on a particular field **once**.
*/
export class Property<TValue> {
private brand!: TValue;
/** Use {@link createProperty}. */
private constructor() {}
}
/** Creates a {@link Property}. */
export function createProperty<TValue>(): Property<TValue> {
return new (Property as new () => Property<TValue>)();
}
/**
* Represents a property that is aggregated from multiple parts according to the property's reducer
* function. A value can be contributed to the aggregated value for a field using an
* `aggregateProperty` rule in the schema. There may be multiple rules in a schema that contribute
* values to the same `AggregateProperty` of the same field.
*/
export class AggregateProperty<TAcc, TItem> {
private brand!: [TAcc, TItem];
/** Use {@link reducedProperty}. */
private constructor(
readonly reduce: (acc: TAcc, item: TItem) => TAcc,
readonly getInitial: () => TAcc,
) {}
}
/**
* Creates an aggregate property that reduces its individual values into an accumulated value using
* the given `reduce` and `getInitial` functions.
* @param reduce The reducer function.
* @param getInitial A function that gets the initial value for the reduce operation.
*/
export function reducedProperty<TAcc, TItem>(
reduce: (acc: TAcc, item: TItem) => TAcc,
getInitial: () => TAcc,
): AggregateProperty<TAcc, TItem> {
return new (AggregateProperty as new (
reduce: (acc: TAcc, item: TItem) => TAcc,
getInitial: () => TAcc,
) => AggregateProperty<TAcc, TItem>)(reduce, getInitial);
}
/**
* Creates an aggregate property that reduces its individual values into a list.
*/
export function listProperty<TItem>(): AggregateProperty<TItem[], TItem | undefined> {
return reducedProperty<TItem[], TItem | undefined>(
(acc, item) => (item === undefined ? acc : [...acc, item]),
() => [],
);
}
/**
* Creates an aggregate property that reduces its individual values by taking their min.
*/
export function minProperty(): AggregateProperty<number | undefined, number | undefined> {
return reducedProperty<number | undefined, number | undefined>(
(prev, next) => {
if (prev === undefined) {
return next;
}
if (next === undefined) {
return prev;
}
return Math.min(prev, next);
},
() => undefined,
);
}
/**
* Creates an aggregate property that reduces its individual values by taking their max.
*/
export function maxProperty(): AggregateProperty<number | undefined, number | undefined> {
return reducedProperty<number | undefined, number | undefined>(
(prev, next) => {
if (prev === undefined) {
return next;
}
if (next === undefined) {
return prev;
}
return Math.max(prev, next);
},
() => undefined,
);
}
/**
* Creates an aggregate property that reduces its individual values by logically or-ing them.
*/
export function orProperty(): AggregateProperty<boolean, boolean> {
return reducedProperty(
(prev, next) => prev || next,
() => false,
);
}
/**
* Creates an aggregate property that reduces its individual values by logically and-ing them.
*/
export function andProperty(): AggregateProperty<boolean, boolean> {
return reducedProperty(
(prev, next) => prev && next,
() => true,
);
}
/**
* An aggregate property representing whether the field is required.
*/
export const REQUIRED: AggregateProperty<boolean, boolean> = orProperty();
/**
* An aggregate property representing the min value of the field.
*/
export const MIN: AggregateProperty<number | undefined, number | undefined> = maxProperty();
/**
* An aggregate property representing the max value of the field.
*/
export const MAX: AggregateProperty<number | undefined, number | undefined> = minProperty();
/**
* An aggregate property representing the min length of the field.
*/
export const MIN_LENGTH: AggregateProperty<number | undefined, number | undefined> = maxProperty();
/**
* An aggregate property representing the max length of the field.
*/
export const MAX_LENGTH: AggregateProperty<number | undefined, number | undefined> = minProperty();
/**
* An aggregate property representing the patterns the field must match.
*/
export const PATTERN: AggregateProperty<RegExp[], RegExp | undefined> = listProperty<RegExp>();
+428
View File
@@ -0,0 +1,428 @@
/**
* @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 {inject, Injector, runInInjectionContext, WritableSignal} from '@angular/core';
import {BasicFieldAdapter, FieldAdapter} from '../field/field_adapter';
import {FormFieldManager} from '../field/manager';
import {FieldNode} from '../field/node';
import {addDefaultField} from '../field/validation';
import {FieldPathNode} from '../schema/path_node';
import {assertPathIsCurrent, isSchemaOrSchemaFn, SchemaImpl} from '../schema/schema';
import {isArray} from '../util/type_guards';
import type {
Field,
FieldPath,
LogicFn,
OneOrMany,
PathKind,
Schema,
SchemaFn,
SchemaOrSchemaFn,
TreeValidationResult,
} from './types';
import {ValidationError, WithOptionalField} from './validation_errors';
/** Options that may be specified when creating a form. */
export interface FormOptions {
/**
* 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.
*/
injector?: Injector;
name?: string;
/**
* Adapter allows managing fields in a more flexible way.
* Currently this is used to support interop with reactive forms.
*/
adapter?: FieldAdapter;
}
/** Extracts the model, schema, and options from the arguments passed to `form()`. */
function normalizeFormArgs<TValue>(
args: any[],
): [WritableSignal<TValue>, SchemaOrSchemaFn<TValue> | undefined, FormOptions | undefined] {
let model: WritableSignal<TValue>;
let schema: SchemaOrSchemaFn<TValue> | undefined;
let options: FormOptions | undefined;
if (args.length === 3) {
[model, schema, options] = args;
} else if (args.length === 2) {
if (isSchemaOrSchemaFn(args[1])) {
[model, schema] = args;
} else {
[model, options] = args;
}
} else {
[model] = args;
}
return [model, schema, options];
}
/**
* Creates a form wrapped around the given model data. A form is represented as simply a `Field` of
* the model data.
*
* `form` uses the given model as the source of truth and *does not* maintain its own copy of the
* data. This means that updating the value on a `FieldState` updates the originally passed in model
* as well.
*
* @example
* ```
* const nameModel = signal({first: '', last: ''});
* const nameForm = form(nameModel);
* nameForm.first().value.set('John');
* nameForm().value(); // {first: 'John', last: ''}
* nameModel(); // {first: 'John', last: ''}
* ```
*
* @param model A writable signal that contains the model data for the form. The resulting field
* structure will match the shape of the model and any changes to the form data will be written to
* the model.
* @return A `Field` representing a form around the data model.
* @template TValue The type of the data model.
*/
export function form<TValue>(model: WritableSignal<TValue>): Field<TValue>;
/**
* Creates a form wrapped around the given model data. A form is represented as simply a `Field` of
* the model data.
*
* `form` uses the given model as the source of truth and *does not* maintain its own copy of the
* data. This means that updating the value on a `FieldState` updates the originally passed in model
* as well.
*
* @example
* ```
* const nameModel = signal({first: '', last: ''});
* const nameForm = form(nameModel);
* nameForm.first().value.set('John');
* nameForm().value(); // {first: 'John', last: ''}
* nameModel(); // {first: 'John', last: ''}
* ```
*
* The form can also be created with a schema, which is a set of rules that define the logic for the
* form. The schema can be either a pre-defined schema created with the `schema` function, or a
* function that builds the schema by binding logic to a parts of the field structure.
*
* @example
* ```
* const nameForm = form(signal({first: '', last: ''}), (name) => {
* required(name.first);
* pattern(name.last, /^[a-z]+$/i, {message: 'Alphabet characters only'});
* });
* nameForm().valid(); // false
* nameForm().value.set({first: 'John', last: 'Doe'});
* nameForm().valid(); // true
* ```
*
* @param model A writable signal that contains the model data for the form. The resulting field
* structure will match the shape of the model and any changes to the form data will be written to
* the model.
* @param schemaOrOptions The second argument can be either
* 1. A schema or a function used to specify logic for the form (e.g. validation, disabled fields, etc.).
* When passing a schema, the form options can be passed as a third argument if needed.
* 2. The form options
* @return A `Field` representing a form around the data model
* @template TValue The type of the data model.
*/
export function form<TValue>(
model: WritableSignal<TValue>,
schemaOrOptions: SchemaOrSchemaFn<TValue> | FormOptions,
): Field<TValue>;
/**
* Creates a form wrapped around the given model data. A form is represented as simply a `Field` of
* the model data.
*
* `form` uses the given model as the source of truth and *does not* maintain its own copy of the
* data. This means that updating the value on a `FieldState` updates the originally passed in model
* as well.
*
* @example
* ```
* const nameModel = signal({first: '', last: ''});
* const nameForm = form(nameModel);
* nameForm.first().value.set('John');
* nameForm().value(); // {first: 'John', last: ''}
* nameModel(); // {first: 'John', last: ''}
* ```
*
* The form can also be created with a schema, which is a set of rules that define the logic for the
* form. The schema can be either a pre-defined schema created with the `schema` function, or a
* function that builds the schema by binding logic to a parts of the field structure.
*
* @example
* ```
* const nameForm = form(signal({first: '', last: ''}), (name) => {
* required(name.first);
* error(name.last, ({value}) => !/^[a-z]+$/i.test(value()), 'Alphabet characters only');
* });
* nameForm().valid(); // false
* nameForm().value.set({first: 'John', last: 'Doe'});
* nameForm().valid(); // true
* ```
*
* @param model A writable signal that contains the model data for the form. The resulting field
* structure will match the shape of the model and any changes to the form data will be written to
* the model.
* @param schema A schema or a function used to specify logic for the form (e.g. validation, disabled fields, etc.)
* @param options The form options
* @return A `Field` representing a form around the data model.
* @template TValue The type of the data model.
*/
export function form<TValue>(
model: WritableSignal<TValue>,
schema: SchemaOrSchemaFn<TValue>,
options: FormOptions,
): Field<TValue>;
export function form<TValue>(...args: any[]): Field<TValue> {
const [model, schema, options] = normalizeFormArgs<TValue>(args);
const injector = options?.injector ?? inject(Injector);
const pathNode = runInInjectionContext(injector, () => SchemaImpl.rootCompile(schema));
const fieldManager = new FormFieldManager(injector, options?.name);
const adapter = options?.adapter ?? new BasicFieldAdapter();
const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter);
fieldManager.createFieldManagementEffect(fieldRoot.structure);
return fieldRoot.fieldProxy as Field<TValue>;
}
/**
* Applies a schema to each item of an array.
*
* @example
* ```
* const nameSchema = schema<{first: string, last: string}>((name) => {
* required(name.first);
* required(name.last);
* });
* const namesForm = form(signal([{first: '', last: ''}]), (names) => {
* applyEach(names, nameSchema);
* });
* ```
*
* When binding logic to the array items, the `Field` for the array item is passed as an additional
* argument. This can be used to reference other properties on the item.
*
* @example
* ```
* const namesForm = form(signal([{first: '', last: ''}]), (names) => {
* applyEach(names, (name) => {
* error(
* name.last,
* (value, nameField) => value === nameField.first().value(),
* 'Last name must be different than first name',
* );
* });
* });
* ```
*
* @param path The target path for an array field whose items the schema will be applied to.
* @param schema A schema for an element of the array, or function that binds logic to an
* element of the array.
* @template TValue The data type of the item field to apply the schema to.
*/
export function applyEach<TValue>(
path: FieldPath<TValue[]>,
schema: NoInfer<SchemaOrSchemaFn<TValue, PathKind.Item>>,
): void {
assertPathIsCurrent(path);
const elementPath = FieldPathNode.unwrapFieldPath(path).element.fieldPathProxy;
apply(elementPath, schema as Schema<TValue>);
}
/**
* Applies a predefined schema to a given `FieldPath`.
*
* @example
* ```
* const nameSchema = schema<{first: string, last: string}>((name) => {
* required(name.first);
* required(name.last);
* });
* const profileForm = form(signal({name: {first: '', last: ''}, age: 0}), (profile) => {
* apply(profile.name, nameSchema);
* });
* ```
*
* @param path The target path to apply the schema to.
* @param schema The schema to apply to the property
* @template TValue The data type of the field to apply the schema to.
*/
export function apply<TValue>(
path: FieldPath<TValue>,
schema: NoInfer<SchemaOrSchemaFn<TValue>>,
): void {
assertPathIsCurrent(path);
const pathNode = FieldPathNode.unwrapFieldPath(path);
pathNode.mergeIn(SchemaImpl.create(schema));
}
/**
* Conditionally applies a predefined schema to a given `FieldPath`.
*
* @param path The target path to apply the schema to.
* @param logic A `LogicFn<T, boolean>` that returns `true` when the schema should be applied.
* @param schema The schema to apply to the field when the `logic` function returns `true`.
* @template TValue The data type of the field to apply the schema to.
*/
export function applyWhen<TValue>(
path: FieldPath<TValue>,
logic: LogicFn<TValue, boolean>,
schema: NoInfer<SchemaOrSchemaFn<TValue>>,
): void {
assertPathIsCurrent(path);
const pathNode = FieldPathNode.unwrapFieldPath(path);
pathNode.mergeIn(SchemaImpl.create(schema), {fn: logic, path});
}
/**
* Conditionally applies a predefined schema to a given `FieldPath`.
*
* @param path The target path to apply the schema to.
* @param predicate A type guard that accepts a value `T` and returns `true` if `T` is of type
* `TNarrowed`.
* @param schema The schema to apply to the field when `predicate` returns `true`.
* @template TValue The data type of the field to apply the schema to.
* @template TNarrowed The data type of the schema (a narrowed type of TValue).
*/
export function applyWhenValue<TValue, TNarrowed extends TValue>(
path: FieldPath<TValue>,
predicate: (value: TValue) => value is TNarrowed,
schema: SchemaOrSchemaFn<TNarrowed>,
): void;
/**
* Conditionally applies a predefined schema to a given `FieldPath`.
*
* @param path The target path to apply the schema to.
* @param predicate A function that accepts a value `T` and returns `true` when the schema
* should be applied.
* @param schema The schema to apply to the field when `predicate` returns `true`.
* @template TValue The data type of the field to apply the schema to.
*/
export function applyWhenValue<TValue>(
path: FieldPath<TValue>,
predicate: (value: TValue) => boolean,
schema: NoInfer<SchemaOrSchemaFn<TValue>>,
): void;
export function applyWhenValue(
path: FieldPath<unknown>,
predicate: (value: unknown) => boolean,
schema: SchemaOrSchemaFn<unknown>,
) {
applyWhen(path, ({value}) => predicate(value()), schema);
}
/**
* Submits a given `Field` using the given action function and applies any server errors resulting
* from the action to the field. Server errors returned by the `action` will be integrated into the
* field as a `ValidationError` on the sub-field indicated by the `field` property of the server
* error.
*
* @example
* ```
* async function registerNewUser(registrationForm: Field<{username: string, password: string}>) {
* const result = await myClient.registerNewUser(registrationForm().value());
* if (result.errorCode === myClient.ErrorCode.USERNAME_TAKEN) {
* return [{
* field: registrationForm.username,
* error: {kind: 'server', message: 'Username already taken'}
* }];
* }
* return undefined;
* }
*
* const registrationForm = form(signal({username: 'god', password: ''}));
* submit(registrationForm, async (f) => {
* return registerNewUser(registrationForm);
* });
* registrationForm.username().errors(); // [{kind: 'server', message: 'Username already taken'}]
* ```
*
* @param form The field to submit.
* @param action An asynchronous action used to submit the field. The action may return server
* errors.
* @template TValue The data type of the field being submitted.
*/
export async function submit<TValue>(
form: Field<TValue>,
action: (form: Field<TValue>) => Promise<TreeValidationResult>,
) {
const node = form() as FieldNode;
markAllAsTouched(node);
// Fail fast if the form is already invalid.
if (node.invalid()) {
return;
}
node.submitState.selfSubmitting.set(true);
try {
const errors = await action(form);
errors && setServerErrors(node, errors);
} finally {
node.submitState.selfSubmitting.set(false);
}
}
/**
* Sets a list of server errors to their individual fields.
*
* @param submittedField The field that was submitted, resulting in the errors.
* @param errors The errors to set.
*/
function setServerErrors(
submittedField: FieldNode,
errors: OneOrMany<WithOptionalField<ValidationError>>,
) {
if (!isArray(errors)) {
errors = [errors];
}
const errorsByField = new Map<FieldNode, ValidationError[]>();
for (const error of errors) {
const errorWithField = addDefaultField(error, submittedField.fieldProxy);
const field = errorWithField.field() as FieldNode;
let fieldErrors = errorsByField.get(field);
if (!fieldErrors) {
fieldErrors = [];
errorsByField.set(field, fieldErrors);
}
fieldErrors.push(errorWithField);
}
for (const [field, fieldErrors] of errorsByField) {
field.submitState.serverErrors.set(fieldErrors);
}
}
/**
* Creates a `Schema` that adds logic rules to a form.
* @param fn A **non-reactive** function that sets up reactive logic rules for the form.
* @returns A schema object that implements the given logic.
* @template TValue The value type of a `Field` that this schema binds to.
*/
export function schema<TValue>(fn: SchemaFn<TValue>): Schema<TValue> {
return SchemaImpl.create(fn) as unknown as Schema<TValue>;
}
/** Marks a {@link node} and its descendants as touched. */
function markAllAsTouched(node: FieldNode) {
node.markAsTouched();
for (const child of node.structure.children()) {
markAllAsTouched(child);
}
}
+486
View File
@@ -0,0 +1,486 @@
/**
* @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 {Signal, WritableSignal} from '@angular/core';
import type {Control} from '../controls/control';
import {AggregateProperty, Property} from './property';
import type {ValidationError, WithOptionalField, WithoutField} from './validation_errors';
/**
* Symbol used to retain generic type information when it would otherwise be lost.
*/
declare const ɵɵTYPE: unique symbol;
/**
* Creates a type based on the given type T, but with all readonly properties made writable.
* @template T The type to create a mutable version of.
*/
export type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
/**
* A type that represents either a single value of type `T` or a readonly array of `T`.
* @template T The type of the value(s).
*/
export type OneOrMany<T> = T | readonly T[];
/**
* The kind of `FieldPath` (`Root`, `Child` of another `FieldPath`, or `Item` in a `FieldPath` array)
*/
export declare namespace PathKind {
/**
* The `PathKind` for a `FieldPath` that is at the root of its field tree.
*/
export interface Root {
/**
* The `ɵɵTYPE` is constructed to allow the `extends` clause on `Child` and `Item` to narrow the
* type. Another way to think about this is, if we have a function that expects this kind of
* path, the `ɵɵTYPE` lists the kinds of path we are allowed to pass to it.
*/
[ɵɵTYPE]: 'root' | 'child' | 'item';
}
/**
* The `PathKind` for a `FieldPath` that is a child of another `FieldPath`.
*/
export interface Child extends PathKind.Root {
[ɵɵTYPE]: 'child' | 'item';
}
/**
* The `PathKind` for a `FieldPath` that is an item in a `FieldPath` array.
*/
export interface Item extends PathKind.Child {
[ɵɵTYPE]: 'item';
}
}
export type PathKind = PathKind.Root | PathKind.Child | PathKind.Item;
/**
* A status indicating whether a field is unsubmitted, submitted, or currently submitting.
*/
export type SubmittedStatus = 'unsubmitted' | 'submitted' | 'submitting';
/**
* A reason for a field's disablement.
*/
export interface DisabledReason {
/** The field that is disabled. */
readonly field: Field<unknown>;
/** A user-facing message describing the reason for the disablement. */
readonly message?: string;
}
/** The absence of an error which indicates a successful validation result. */
export type ValidationSuccess = null | undefined | void;
/**
* The result of running a field validation function.
*
* The result may be one of the following:
* 1. A {@link ValidationSuccess} to indicate no errors.
* 2. A {@link ValidationError} without a field to indicate an error on the field being validated.
* 3. A list of {@link ValidationError} without fields to indicate multiple errors on the field
* being validated.
*
* @template E the type of error (defaults to {@link ValidationError}).
*/
export type FieldValidationResult<E extends ValidationError = ValidationError> =
| ValidationSuccess
| OneOrMany<WithoutField<E>>;
/**
* The result of running a tree validation function.
*
* The result may be one of the following:
* 1. A {@link ValidationSuccess} to indicate no errors.
* 2. A {@link ValidationError} without a field to indicate an error on the field being validated.
* 3. A {@link ValidationError} with a field to indicate an error on the target field.
* 4. A list of {@link ValidationError} with or without fields to indicate multiple errors.
*
* @template E the type of error (defaults to {@link ValidationError}).
*/
export type TreeValidationResult<E extends ValidationError = ValidationError> =
| ValidationSuccess
| OneOrMany<WithOptionalField<E>>;
/**
* A validation result where all errors explicitly define their target field.
*
* The result may be one of the following:
* 1. A {@link ValidationSuccess} to indicate no errors.
* 2. A {@link ValidationError} with a field to indicate an error on the target field.
* 3. A list of {@link ValidationError} with fields to indicate multiple errors.
*
* @template E the type of error (defaults to {@link ValidationError}).
*/
export type ValidationResult<E extends ValidationError = ValidationError> =
| ValidationSuccess
| OneOrMany<E>;
/**
* An asynchronous validation result where all errors explicitly define their target field.
*
* The result may be one of the following:
* 1. A {@link ValidationResult} to indicate the result if resolved.
* 5. 'pending' if the validation is not yet resolved.
*
* @template E the type of error (defaults to {@link ValidationError}).
*/
export type AsyncValidationResult<E extends ValidationError = ValidationError> =
| ValidationResult<E>
| 'pending';
/**
* An object that represents a single field in a form. This includes both primitive value fields
* (e.g. fields that contain a `string` or `number`), as well as "grouping fields" that contain
* sub-fields. `Field` objects are arranged in a tree whose structure mimics the structure of the
* underlying data. For example a `Field<{x: number}>` has a property `x` which contains a
* `Field<number>`. To access the state associated with a field, call it as a function.
*
* @template TValue The type of the data which the field is wrapped around.
* @template TKey The type of the property key which this field resides under in its parent.
*/
export type Field<TValue, TKey extends string | number = string | number> = (() => FieldState<
TValue,
TKey
>) &
(TValue extends Array<infer U>
? ReadonlyArrayLike<MaybeField<U, number>>
: TValue extends Record<string, any>
? Subfields<TValue>
: unknown);
/**
* The sub-fields that a user can navigate to from a `Field<TValue>`.
*
* @template TValue The type of the data which the parent field is wrapped around.
*/
export type Subfields<TValue> = {
readonly [K in keyof TValue as TValue[K] extends Function ? never : K]: MaybeField<
TValue[K],
string
>;
};
/**
* An iterable object with the same shape as a readonly array.
*
* @template T The array item type.
*/
export type ReadonlyArrayLike<T> = Pick<
ReadonlyArray<T>,
number | 'length' | typeof Symbol.iterator
>;
/**
* Helper type for defining `Field`. Given a type `TValue` that may include `undefined`, it extracts
* the `undefined` outside the `Field` type.
*
* For example `MaybeField<{a: number} | undefined, TKey>` would be equivalent to
* `undefined | Field<{a: number}, TKey>`.
*
* @template TValue The type of the data which the field is wrapped around.
* @template TKey The type of the property key which this field resides under in its parent.
*/
export type MaybeField<TValue, TKey extends string | number = string | number> =
| (TValue & undefined)
| Field<Exclude<TValue, undefined>, TKey>;
/**
* Contains all of the state (e.g. value, statuses, etc.) associated with a `Field`, exposed as
* signals.
*/
export interface FieldState<TValue, TKey extends string | number = string | number> {
/**
* A writable signal containing the value for this field. Updating this signal will update the
* data model that the field is bound to.
*/
readonly value: WritableSignal<TValue>;
/**
* A signal indicating whether the field has been touched by the user.
*/
readonly touched: Signal<boolean>;
/**
* A signal indicating whether field value has been changed by user.
*/
readonly dirty: Signal<boolean>;
/**
* A signal indicating whether a field is hidden.
*
* When a field is hidden it is ignored when determining the valid, touched, and dirty states.
*
* Note: This doesn't hide the field in the template, that must be done manually.
* ```
* @if (!field.hidden()) {
* ...
* }
* ```
*/
readonly hidden: Signal<boolean>;
/**
* A signal indicating whether the field is currently disabled.
*/
readonly disabled: Signal<boolean>;
/**
* A signal containing the reasons why the field is currently disabled.
*/
readonly disabledReasons: Signal<readonly DisabledReason[]>;
/**
* A signal indicating whether the field is currently readonly.
*/
readonly readonly: Signal<boolean>;
/**
* A signal containing the current errors for the field.
*/
readonly errors: Signal<ValidationError[]>;
/**
* A signal containing the {@link errors} of the field and its descendants.
*/
readonly errorSummary: Signal<ValidationError[]>;
/**
* A signal indicating whether the field's value is currently valid.
*
* Note: `valid()` is not the same as `!invalid()`.
* - `valid()` is `true` when there are no validation errors *and* no pending validators.
* - `invalid()` is `true` when there are validation errors, regardless of pending validators.
*
* Ex: consider the situation where a field has 3 validators, 2 of which have no errors and 1 of
* which is still pending. In this case `valid()` is `false` because of the pending validator.
* However `invalid()` is also `false` because there are no errors.
*/
readonly valid: Signal<boolean>;
/**
* A signal indicating whether the field's value is currently invalid.
*
* Note: `invalid()` is not the same as `!valid()`.
* - `invalid()` is `true` when there are validation errors, regardless of pending validators.
* - `valid()` is `true` when there are no validation errors *and* no pending validators.
*
* Ex: consider the situation where a field has 3 validators, 2 of which have no errors and 1 of
* which is still pending. In this case `invalid()` is `false` because there are no errors.
* However `valid()` is also `false` because of the pending validator.
*/
readonly invalid: Signal<boolean>;
/**
* Whether there are any validators still pending for this field.
*/
readonly pending: Signal<boolean>;
/**
* A signal indicating whether the field is currently in the process of being submitted.
*/
readonly submitting: Signal<boolean>;
/**
* A signal of a unique name for the field, by default based on the name of its parent field.
*/
readonly name: Signal<string>;
/**
* The property key in the parent field under which this field is stored. If the parent field is
* array-valued, for example, this is the index of this field in that array.
*/
readonly keyInParent: Signal<TKey>;
/**
* A signal containing the `Control` directives this field is currently bound to.
*/
readonly controls: Signal<readonly Control<unknown>[]>;
/**
* Reads an aggregate property value from the field.
* @param prop The property to read.
*/
property<M>(prop: AggregateProperty<M, any>): Signal<M>;
/**
* Reads a property value from the field.
* @param prop The property key to read.
*/
property<M>(prop: Property<M>): M | undefined;
/**
* Checks whether the given metadata key has been defined for this field.
*/
hasProperty(key: Property<any> | AggregateProperty<any, any>): boolean;
/**
* Sets the touched status of the field to `true`.
*/
markAsTouched(): void;
/**
* Sets the dirty status of the field to `true`.
*/
markAsDirty(): void;
/**
* Resets the {@link touched} and {@link dirty} state of the field and its descendants.
*
* Note this does not change the data model, which can be reset directly if desired.
*/
reset(): void;
}
/**
* An object that represents a location in the `Field` tree structure and is used to bind logic to a
* particular part of the structure prior to the creation of the form. Because the `FieldPath`
* exists prior to the form's creation, it cannot be used to access any of the field state.
*
* @template TValue The type of the data which the form is wrapped around.
* @template TPathKind The kind of path (root field, child field, or item of an array)
*/
export type FieldPath<TValue, TPathKind extends PathKind = PathKind.Root> = {
[ɵɵTYPE]: [TValue, TPathKind];
} & (TValue extends Array<unknown>
? unknown
: TValue extends Record<string, any>
? {[K in keyof TValue]: MaybeFieldPath<TValue[K], PathKind.Child>}
: unknown);
/**
* Helper type for defining `FieldPath`. Given a type `TValue` that may include `undefined`, it
* extracts the `undefined` outside the `FieldPath` type.
*
* For example `MaybeFieldPath<{a: number} | undefined, PathKind.Child>` would be equivalent to
* `undefined | Field<{a: number}, PathKind.child>`.
*
* @template TValue The type of the data which the field is wrapped around.
* @template TPathKind The kind of path (root field, child field, or item of an array)
*/
export type MaybeFieldPath<TValue, TPathKind extends PathKind = PathKind.Root> =
| (TValue & undefined)
| FieldPath<Exclude<TValue, undefined>, TPathKind>;
/**
* Defines logic for a form.
*
* @template TValue The type of data stored in the form that this schema is attached to.
*/
export type Schema<in TValue> = {
[ɵɵTYPE]: SchemaFn<TValue, PathKind.Root>;
};
/**
* Function that defines rules for a schema.
*
* @template TValue The type of data stored in the form that this schema function is attached to.
* @template TPathKind The kind of path this schema function can be bound to.
*/
export type SchemaFn<TValue, TPathKind extends PathKind = PathKind.Root> = (
p: FieldPath<TValue, TPathKind>,
) => void;
/**
* A schema or schema definition function.
*
* @template TValue The type of data stored in the form that this schema function is attached to.
* @template TPathKind The kind of path this schema function can be bound to.
*/
export type SchemaOrSchemaFn<TValue, TPathKind extends PathKind = PathKind.Root> =
| Schema<TValue>
| SchemaFn<TValue, TPathKind>;
/**
* A function that receives the `FieldContext` for the field the logic is bound to and returns
* a specific result type.
*
* @template TValue The data type for the field the logic is bound to.
* @template TReturn The type of the result returned by the logic function.
* @template TPathKind The kind of path the logic is applied to (root field, child field, or item of an array)
*/
export type LogicFn<TValue, TReturn, TPathKind extends PathKind = PathKind.Root> = (
ctx: FieldContext<TValue, TPathKind>,
) => TReturn;
/**
* A function that takes the `FieldContext` for the field being validated and returns a
* `ValidationResult` indicating errors for the field.
*
* @template TValue The type of value stored in the field being validated
* @template TPathKind The kind of path being validated (root field, child field, or item of an array)
*/
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<
TValue,
FieldValidationResult,
TPathKind
>;
/**
* A function that takes the `FieldContext` for the field being validated and returns a
* `TreeValidationResult` indicating errors for the field and its sub-fields.
*
* @template TValue The type of value stored in the field being validated
* @template TPathKind The kind of path being validated (root field, child field, or item of an array)
*/
export type TreeValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<
TValue,
TreeValidationResult,
TPathKind
>;
/**
* A function that takes the `FieldContext` for the field being validated and returns a
* `ValidationResult` indicating errors for the field and its sub-fields. In a `Validator` all
* errors must explicitly define their target field.
*
* @template TValue The type of value stored in the field being validated
* @template TPathKind The kind of path being validated (root field, child field, or item of an array)
*/
export type Validator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<
TValue,
ValidationResult,
TPathKind
>;
/**
* Provides access to the state of the current field as well as functions that can be used to look
* up state of other fields based on a `FieldPath`.
*/
export type FieldContext<
TValue,
TPathKind extends PathKind = PathKind.Root,
> = TPathKind extends PathKind.Item
? ItemFieldContext<TValue>
: TPathKind extends PathKind.Child
? ChildFieldContext<TValue>
: RootFieldContext<TValue>;
/**
* The base field context that is available for all fields.
*/
export interface RootFieldContext<TValue> {
/** A signal containing the value of the current field. */
readonly value: Signal<TValue>;
/** The state of the current field. */
readonly state: FieldState<TValue>;
/** The current field. */
readonly field: Field<TValue>;
/** Gets the value of the field represented by the given path. */
readonly valueOf: <P>(p: FieldPath<P>) => P;
/** Gets the state of the field represented by the given path. */
readonly stateOf: <P>(p: FieldPath<P>) => FieldState<P>;
/** Gets the field represented by the given path. */
readonly fieldOf: <P>(p: FieldPath<P>) => Field<P>;
}
/**
* Field context that is available for all fields that are a child of another field.
*/
export interface ChildFieldContext<TValue> extends RootFieldContext<TValue> {
/** The key of the current field in its parent field. */
readonly key: Signal<string>;
}
/**
* Field context that is available for all fields that are an item in an array field.
*/
export interface ItemFieldContext<TValue> extends ChildFieldContext<TValue> {
/** The index of the current field in its parent field. */
readonly index: Signal<number>;
}
@@ -0,0 +1,415 @@
/**
* @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 type {StandardSchemaV1} from '@standard-schema/spec';
import {Field} from './types';
/** Internal symbol used for class branding. */
const BRAND = Symbol();
/**
* Options used to create a `ValidationError`.
*/
interface ValidationErrorOptions {
/** Human readable error message. */
message?: string;
}
/**
* A type that requires the given type `T` to have a `field` property.
* @template T The type to add a `field` to.
*/
export type WithField<T> = T & {field: Field<unknown>};
/**
* A type that allows the given type `T` to optionally have a `field` property.
* @template T The type to optionally add a `field` to.
*/
export type WithOptionalField<T> = T & {field?: Field<unknown>};
/**
* A type that ensures the given type `T` does not have a `field` property.
* @template T The type to remove the `field` from.
*/
export type WithoutField<T> = T & {field: never};
/**
* Create a required error associated with the target field
* @param options The validation error options
*/
export function requiredError(options: WithField<ValidationErrorOptions>): RequiredValidationError;
/**
* Create a required error
* @param options The optional validation error options
*/
export function requiredError(
options?: ValidationErrorOptions,
): WithoutField<RequiredValidationError>;
export function requiredError(
options?: ValidationErrorOptions,
): WithOptionalField<RequiredValidationError> {
return new RequiredValidationError(options);
}
/**
* Create a min value error associated with the target field
* @param min The min value constraint
* @param options The validation error options
*/
export function minError(
min: number,
options: WithField<ValidationErrorOptions>,
): MinValidationError;
/**
* Create a min value error
* @param min The min value constraint
* @param options The optional validation error options
*/
export function minError(
min: number,
options?: ValidationErrorOptions,
): WithoutField<MinValidationError>;
export function minError(
min: number,
options?: ValidationErrorOptions,
): WithOptionalField<MinValidationError> {
return new MinValidationError(min, options);
}
/**
* Create a max value error associated with the target field
* @param max The max value constraint
* @param options The validation error options
*/
export function maxError(
max: number,
options: WithField<ValidationErrorOptions>,
): MaxValidationError;
/**
* Create a max value error
* @param max The max value constraint
* @param options The optional validation error options
*/
export function maxError(
max: number,
options?: ValidationErrorOptions,
): WithoutField<MaxValidationError>;
export function maxError(
max: number,
options?: ValidationErrorOptions,
): WithOptionalField<MaxValidationError> {
return new MaxValidationError(max, options);
}
/**
* Create a minLength error associated with the target field
* @param minLength The minLength constraint
* @param options The validation error options
*/
export function minLengthError(
minLength: number,
options: WithField<ValidationErrorOptions>,
): MinLengthValidationError;
/**
* Create a minLength error
* @param minLength The minLength constraint
* @param options The optional validation error options
*/
export function minLengthError(
minLength: number,
options?: ValidationErrorOptions,
): WithoutField<MinLengthValidationError>;
export function minLengthError(
minLength: number,
options?: ValidationErrorOptions,
): WithOptionalField<MinLengthValidationError> {
return new MinLengthValidationError(minLength, options);
}
/**
* Create a maxLength error associated with the target field
* @param maxLength The maxLength constraint
* @param options The validation error options
*/
export function maxLengthError(
maxLength: number,
options: WithField<ValidationErrorOptions>,
): MaxLengthValidationError;
/**
* Create a maxLength error
* @param maxLength The maxLength constraint
* @param options The optional validation error options
*/
export function maxLengthError(
maxLength: number,
options?: ValidationErrorOptions,
): WithoutField<MaxLengthValidationError>;
export function maxLengthError(
maxLength: number,
options?: ValidationErrorOptions,
): WithOptionalField<MaxLengthValidationError> {
return new MaxLengthValidationError(maxLength, options);
}
/**
* Create a pattern matching error associated with the target field
* @param pattern The violated pattern
* @param options The validation error options
*/
export function patternError(
pattern: RegExp,
options: WithField<ValidationErrorOptions>,
): PatternValidationError;
/**
* Create a pattern matching error
* @param pattern The violated pattern
* @param options The optional validation error options
*/
export function patternError(
pattern: RegExp,
options?: ValidationErrorOptions,
): WithoutField<PatternValidationError>;
export function patternError(
pattern: RegExp,
options?: ValidationErrorOptions,
): WithOptionalField<PatternValidationError> {
return new PatternValidationError(pattern, options);
}
/**
* Create an email format error associated with the target field
* @param options The validation error options
*/
export function emailError(options: WithField<ValidationErrorOptions>): EmailValidationError;
/**
* Create an email format error
* @param options The optional validation error options
*/
export function emailError(options?: ValidationErrorOptions): WithoutField<EmailValidationError>;
export function emailError(
options?: ValidationErrorOptions,
): WithOptionalField<EmailValidationError> {
return new EmailValidationError(options);
}
/**
* Create a standard schema issue error associated with the target field
* @param issue The standard schema issue
* @param options The validation error options
*/
export function standardSchemaError(
issue: StandardSchemaV1.Issue,
options: WithField<ValidationErrorOptions>,
): StandardSchemaValidationError;
/**
* Create a standard schema issue error
* @param issue The standard schema issue
* @param options The optional validation error options
*/
export function standardSchemaError(
issue: StandardSchemaV1.Issue,
options?: ValidationErrorOptions,
): WithoutField<StandardSchemaValidationError>;
export function standardSchemaError(
issue: StandardSchemaV1.Issue,
options?: ValidationErrorOptions,
): WithOptionalField<StandardSchemaValidationError> {
return new StandardSchemaValidationError(issue, options);
}
/**
* Create a custom error associated with the target field
* @param obj The object to create an error from
*/
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(
obj: WithField<E>,
): CustomValidationError;
/**
* Create a custom error
* @param obj The object to create an error from
*/
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(
obj?: E,
): WithoutField<CustomValidationError>;
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(
obj?: E,
): WithOptionalField<CustomValidationError> {
return new CustomValidationError(obj);
}
/**
* Common interface for all validation errors.
*
* Use the creation functions to create an instance (e.g. `requiredError`, `minError`, etc.).
*/
export abstract class ValidationError {
/** Brand the class to avoid Typescript structural matching */
[BRAND] = undefined;
/** Identifies the kind of error. */
readonly kind: string = '';
/** The field associated with this error. */
readonly field!: Field<unknown>;
/** Human readable error message. */
readonly message?: string;
constructor(options?: ValidationErrorOptions) {
if (options) {
Object.assign(this, options);
}
}
}
/**
* A custom error that may contain additional properties
*/
export class CustomValidationError extends ValidationError {
/**
* Allow the user to attach arbitrary other properties.
*/
[key: PropertyKey]: unknown;
}
/**
* Internal version of `NgValidationError`, we create this separately so we can change its type on
* the exported version to a type union of the possible sub-classes.
*/
abstract class _NgValidationError extends ValidationError {}
/**
* An error used to indicate that a required field is empty.
*/
export class RequiredValidationError extends _NgValidationError {
override readonly kind = 'required';
}
/**
* An error used to indicate that a value is lower than the minimum allowed.
*/
export class MinValidationError extends _NgValidationError {
override readonly kind = 'min';
constructor(
readonly min: number,
options?: ValidationErrorOptions,
) {
super(options);
}
}
/**
* An error used to indicate that a value is higher than the maximum allowed.
*/
export class MaxValidationError extends _NgValidationError {
override readonly kind = 'max';
constructor(
readonly max: number,
options?: ValidationErrorOptions,
) {
super(options);
}
}
/**
* An error used to indicate that a value is shorter than the minimum allowed length.
*/
export class MinLengthValidationError extends _NgValidationError {
override readonly kind = 'minLength';
constructor(
readonly minLength: number,
options?: ValidationErrorOptions,
) {
super(options);
}
}
/**
* An error used to indicate that a value is longer than the maximum allowed length.
*/
export class MaxLengthValidationError extends _NgValidationError {
override readonly kind = 'maxLength';
constructor(
readonly maxLength: number,
options?: ValidationErrorOptions,
) {
super(options);
}
}
/**
* An error used to indicate that a value does not match the required pattern.
*/
export class PatternValidationError extends _NgValidationError {
override readonly kind = 'pattern';
constructor(
readonly pattern: RegExp,
options?: ValidationErrorOptions,
) {
super(options);
}
}
/**
* An error used to indicate that a value is not a valid email.
*/
export class EmailValidationError extends _NgValidationError {
override readonly kind = 'email';
}
/**
* An error used to indicate an issue validating against a standard schema.
*/
export class StandardSchemaValidationError extends _NgValidationError {
override readonly kind = 'standardSchema';
constructor(
readonly issue: StandardSchemaV1.Issue,
options?: ValidationErrorOptions,
) {
super(options);
}
}
/**
* The base class for all built-in, non-custom errors. This class can be used to check if an error
* is one of the standard kinds, allowing you to switch on the kind to further narrow the type.
*
* @example
* ```
* const f = form(...);
* for (const e of form().errors()) {
* if (e instanceof NgValidationError) {
* switch(e.kind) {
* case 'required':
* console.log('This is required!');
* break;
* case 'min':
* console.log(`Must be at least ${e.min}`);
* break;
* ...
* }
* }
* }
* ```
*/
export const NgValidationError: abstract new () => NgValidationError = _NgValidationError as any;
export type NgValidationError =
| RequiredValidationError
| MinValidationError
| MaxValidationError
| MinLengthValidationError
| MaxLengthValidationError
| PatternValidationError
| EmailValidationError
| StandardSchemaValidationError;
@@ -0,0 +1,72 @@
/**
* @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 {validate} from '../logic';
import {FieldPath, PathKind} from '../types';
import {emailError} from '../validation_errors';
import {BaseValidatorConfig, getOption} from './util';
/**
* A regular expression that matches valid e-mail addresses.
*
* At a high level, this regexp matches e-mail addresses of the format `local-part@tld`, where:
* - `local-part` consists of one or more of the allowed characters (alphanumeric and some
* punctuation symbols).
* - `local-part` cannot begin or end with a period (`.`).
* - `local-part` cannot be longer than 64 characters.
* - `tld` consists of one or more `labels` separated by periods (`.`). For example `localhost` or
* `foo.com`.
* - A `label` consists of one or more of the allowed characters (alphanumeric, dashes (`-`) and
* periods (`.`)).
* - A `label` cannot begin or end with a dash (`-`) or a period (`.`).
* - A `label` cannot be longer than 63 characters.
* - The whole address cannot be longer than 254 characters.
*
* ## Implementation background
*
* This regexp was ported over from AngularJS (see there for git history):
* https://github.com/angular/angular.js/blob/c133ef836/src/ng/directive/input.js#L27
* It is based on the
* [WHATWG version](https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) with
* some enhancements to incorporate more RFC rules (such as rules related to domain names and the
* lengths of different parts of the address). The main differences from the WHATWG version are:
* - Disallow `local-part` to begin or end with a period (`.`).
* - Disallow `local-part` length to exceed 64 characters.
* - Disallow total address length to exceed 254 characters.
*
* See [this commit](https://github.com/angular/angular.js/commit/f3f5cf72e) for more details.
*/
const EMAIL_REGEXP =
/^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
/**
* Binds a validator to the given path that requires the value to match the standard email format.
* This function can only be called on string paths.
*
* @param path Path of the field to validate
* @param config Optional, allows providing any of the following options:
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.email()`
* or a function that receives the `FieldContext` and returns custom validation error(s).
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function email<TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<string, TPathKind>,
config?: BaseValidatorConfig<string, TPathKind>,
) {
validate(path, (ctx) => {
if (!EMAIL_REGEXP.test(ctx.value())) {
if (config?.error) {
return getOption(config.error, ctx);
} else {
return emailError({message: getOption(config?.message, ctx)});
}
}
return undefined;
});
}
@@ -0,0 +1,15 @@
/**
* @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
*/
export {email} from './email';
export {max} from './max';
export {maxLength} from './max_length';
export {min} from './min';
export {minLength} from './min_length';
export {pattern} from './pattern';
export {required} from './required';
@@ -0,0 +1,52 @@
/**
* @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 {computed} from '@angular/core';
import {aggregateProperty, property, validate} from '../logic';
import {MAX} from '../property';
import {FieldPath, LogicFn, PathKind} from '../types';
import {maxError} from '../validation_errors';
import {BaseValidatorConfig, getOption} from './util';
/**
* Binds a validator to the given path that requires the value to be less than or equal to the
* given `maxValue`.
* This function can only be called on number paths.
* In addition to binding a validator, this function adds `MAX` property to the field.
*
* @param path Path of the field to validate
* @param maxValue The maximum value, or a LogicFn that returns the maximum value.
* @param config Optional, allows providing any of the following options:
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.max(maxValue)`
* or a function that receives the `FieldContext` and returns custom validation error(s).
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function max<TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<number, TPathKind>,
maxValue: number | LogicFn<number, number | undefined, TPathKind>,
config?: BaseValidatorConfig<number, TPathKind>,
) {
const MAX_MEMO = property(path, (ctx) =>
computed(() => (typeof maxValue === 'number' ? maxValue : maxValue(ctx))),
);
aggregateProperty(path, MAX, ({state}) => state.property(MAX_MEMO)!());
validate(path, (ctx) => {
const max = ctx.state.property(MAX_MEMO)!();
if (max === undefined || Number.isNaN(max)) {
return undefined;
}
if (ctx.value() > max) {
if (config?.error) {
return getOption(config.error, ctx);
} else {
return maxError(max, {message: getOption(config?.message, ctx)});
}
}
return undefined;
});
}
@@ -0,0 +1,56 @@
/**
* @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 {computed} from '@angular/core';
import {aggregateProperty, property, validate} from '../logic';
import {MAX_LENGTH} from '../property';
import {FieldPath, LogicFn, PathKind} from '../types';
import {maxLengthError} from '../validation_errors';
import {BaseValidatorConfig, getLengthOrSize, getOption, ValueWithLengthOrSize} from './util';
/**
* Binds a validator to the given path that requires the length of the value to be less than or
* equal to the given `maxLength`.
* This function can only be called on string or array paths.
* In addition to binding a validator, this function adds `MAX_LENGTH` property to the field.
*
* @param path Path of the field to validate
* @param maxLength The maximum length, or a LogicFn that returns the maximum length.
* @param config Optional, allows providing any of the following options:
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.maxLength(maxLength)`
* or a function that receives the `FieldContext` and returns custom validation error(s).
* @template TValue The type of value stored in the field the logic is bound to.
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function maxLength<
TValue extends ValueWithLengthOrSize,
TPathKind extends PathKind = PathKind.Root,
>(
path: FieldPath<TValue, TPathKind>,
maxLength: number | LogicFn<TValue, number | undefined, TPathKind>,
config?: BaseValidatorConfig<TValue, TPathKind>,
) {
const MAX_LENGTH_MEMO = property(path, (ctx) =>
computed(() => (typeof maxLength === 'number' ? maxLength : maxLength(ctx))),
);
aggregateProperty(path, MAX_LENGTH, ({state}) => state.property(MAX_LENGTH_MEMO)!());
validate(path, (ctx) => {
const maxLength = ctx.state.property(MAX_LENGTH_MEMO)!();
if (maxLength === undefined) {
return undefined;
}
if (getLengthOrSize(ctx.value()) > maxLength) {
if (config?.error) {
return getOption(config.error, ctx);
} else {
return maxLengthError(maxLength, {message: getOption(config?.message, ctx)});
}
}
return undefined;
});
}
@@ -0,0 +1,52 @@
/**
* @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 {computed} from '@angular/core';
import {aggregateProperty, property, validate} from '../logic';
import {MIN} from '../property';
import {FieldPath, LogicFn, PathKind} from '../types';
import {minError} from '../validation_errors';
import {BaseValidatorConfig, getOption} from './util';
/**
* Binds a validator to the given path that requires the value to be greater than or equal to
* the given `minValue`.
* This function can only be called on number paths.
* In addition to binding a validator, this function adds `MIN` property to the field.
*
* @param path Path of the field to validate
* @param minValue The minimum value, or a LogicFn that returns the minimum value.
* @param config Optional, allows providing any of the following options:
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.min(minValue)`
* or a function that receives the `FieldContext` and returns custom validation error(s).
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function min<TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<number, TPathKind>,
minValue: number | LogicFn<number, number | undefined, TPathKind>,
config?: BaseValidatorConfig<number, TPathKind>,
) {
const MIN_MEMO = property(path, (ctx) =>
computed(() => (typeof minValue === 'number' ? minValue : minValue(ctx))),
);
aggregateProperty(path, MIN, ({state}) => state.property(MIN_MEMO)!());
validate(path, (ctx) => {
const min = ctx.state.property(MIN_MEMO)!();
if (min === undefined || Number.isNaN(min)) {
return undefined;
}
if (ctx.value() < min) {
if (config?.error) {
return getOption(config.error, ctx);
} else {
return minError(min, {message: getOption(config?.message, ctx)});
}
}
return undefined;
});
}
@@ -0,0 +1,56 @@
/**
* @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 {computed} from '@angular/core';
import {aggregateProperty, property, validate} from '../logic';
import {MIN_LENGTH} from '../property';
import {FieldPath, LogicFn, PathKind} from '../types';
import {minLengthError} from '../validation_errors';
import {BaseValidatorConfig, getLengthOrSize, getOption, ValueWithLengthOrSize} from './util';
/**
* Binds a validator to the given path that requires the length of the value to be greater than or
* equal to the given `minLength`.
* This function can only be called on string or array paths.
* In addition to binding a validator, this function adds `MIN_LENGTH` property to the field.
*
* @param path Path of the field to validate
* @param minLength The minimum length, or a LogicFn that returns the minimum length.
* @param config Optional, allows providing any of the following options:
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.minLength(minLength)`
* or a function that receives the `FieldContext` and returns custom validation error(s).
* @template TValue The type of value stored in the field the logic is bound to.
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function minLength<
TValue extends ValueWithLengthOrSize,
TPathKind extends PathKind = PathKind.Root,
>(
path: FieldPath<TValue, TPathKind>,
minLength: number | LogicFn<TValue, number | undefined, TPathKind>,
config?: BaseValidatorConfig<TValue, TPathKind>,
) {
const MIN_LENGTH_MEMO = property(path, (ctx) =>
computed(() => (typeof minLength === 'number' ? minLength : minLength(ctx))),
);
aggregateProperty(path, MIN_LENGTH, ({state}) => state.property(MIN_LENGTH_MEMO)!());
validate(path, (ctx) => {
const minLength = ctx.state.property(MIN_LENGTH_MEMO)!();
if (minLength === undefined) {
return undefined;
}
if (getLengthOrSize(ctx.value()) < minLength) {
if (config?.error) {
return getOption(config.error, ctx);
} else {
return minLengthError(minLength, {message: getOption(config?.message, ctx)});
}
}
return undefined;
});
}
@@ -0,0 +1,55 @@
/**
* @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 {computed} from '@angular/core';
import {aggregateProperty, property, validate} from '../logic';
import {PATTERN} from '../property';
import {FieldPath, LogicFn, PathKind} from '../types';
import {patternError} from '../validation_errors';
import {BaseValidatorConfig, getOption} from './util';
/**
* Binds a validator to the given path that requires the value to match a specific regex pattern.
* This function can only be called on string paths.
* In addition to binding a validator, this function adds `PATTERN` property to the field.
*
* @param path Path of the field to validate
* @param pattern The RegExp pattern to match, or a LogicFn that returns the RegExp pattern.
* @param config Optional, allows providing any of the following options:
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.pattern(pattern)`
* or a function that receives the `FieldContext` and returns custom validation error(s).
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function pattern<TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<string, TPathKind>,
pattern: RegExp | LogicFn<string | undefined, RegExp | undefined, TPathKind>,
config?: BaseValidatorConfig<string, TPathKind>,
) {
const PATTERN_MEMO = property(path, (ctx) =>
computed(() => (pattern instanceof RegExp ? pattern : pattern(ctx))),
);
aggregateProperty(path, PATTERN, ({state}) => state.property(PATTERN_MEMO)!());
validate(path, (ctx) => {
const pattern = ctx.state.property(PATTERN_MEMO)!();
// A pattern validator should not fail on an empty value. This matches the behavior of HTML's
// built in `pattern` attribute.
if (pattern === undefined || ctx.value() == null || ctx.value() === '') {
return undefined;
}
if (!pattern.test(ctx.value())) {
if (config?.error) {
return getOption(config.error, ctx);
} else {
return patternError(pattern, {message: getOption(config?.message, ctx)});
}
}
return undefined;
});
}
@@ -0,0 +1,56 @@
/**
* @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 {computed} from '@angular/core';
import {aggregateProperty, property, validate} from '../logic';
import {REQUIRED} from '../property';
import {FieldPath, LogicFn, PathKind} from '../types';
import {requiredError} from '../validation_errors';
import {BaseValidatorConfig, getOption} from './util';
/**
* Binds a validator to the given path that requires the value to be non-empty.
* This function can only be called on any type of path.
* In addition to binding a validator, this function adds `REQUIRED` property to the field.
*
* @param path Path of the field to validate
* @param config Optional, allows providing any of the following options:
* - `message`: A user-facing message for the error.
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.required()`
* or a function that receives the `FieldContext` and returns custom validation error(s).
* - `emptyPredicate`: A function that receives the value, and returns `true` if it is considered empty.
* By default `false`, `''`, `null`, and `undefined` are considered empty
* - `when`: A function that receives the `FieldContext` and returns true if the field is required
* @template TValue The type of value stored in the field the logic is bound to.
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
*/
export function required<TValue, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
config?: BaseValidatorConfig<TValue, TPathKind> & {
emptyPredicate?: (value: TValue) => boolean;
when?: NoInfer<LogicFn<TValue, boolean, TPathKind>>;
},
): void {
const emptyPredicate =
config?.emptyPredicate ?? ((value) => value === false || value == null || value === '');
const REQUIRED_MEMO = property(path, (ctx) =>
computed(() => (config?.when ? config.when(ctx) : true)),
);
aggregateProperty(path, REQUIRED, ({state}) => state.property(REQUIRED_MEMO)!());
validate(path, (ctx) => {
if (ctx.state.property(REQUIRED_MEMO)!() && emptyPredicate(ctx.value())) {
if (config?.error) {
return getOption(config.error, ctx);
} else {
return requiredError({message: getOption(config?.message, ctx)});
}
}
return undefined;
});
}
@@ -0,0 +1,106 @@
/**
* @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 {computed, resource, ɵisPromise} from '@angular/core';
import type {StandardSchemaV1} from '@standard-schema/spec';
import {addDefaultField} from '../../field/validation';
import {validateAsync} from '../async';
import {property, validateTree} from '../logic';
import {Field, FieldPath} from '../types';
import {standardSchemaError, StandardSchemaValidationError} from '../validation_errors';
/**
* Utility type that removes a string index key when its value is `unknown`,
* i.e. `{[key: string]: unknown}`. It allows specific string keys to pass through, even if their
* value is `unknown`, e.g. `{key: unknown}`.
*/
export type RemoveStringIndexUnknownKey<K, V> = string extends K
? unknown extends V
? never
: K
: K;
/**
* Utility type that recursively ignores unknown string index properties on the given object.
* We use this on the `TSchema` type in `validateStandardSchema` in order to accommodate Zod's
* `looseObject` which includes `{[key: string]: unknown}` as part of the type.
*/
export type IgnoreUnknownProperties<T> =
T extends Record<PropertyKey, unknown>
? {
[K in keyof T as RemoveStringIndexUnknownKey<K, T[K]>]: IgnoreUnknownProperties<T[K]>;
}
: T;
/**
* Validates a field using a `StandardSchemaV1` compatible validator (e.g. a Zod validator).
*
* See https://github.com/standard-schema/standard-schema for more about standard schema.
*
* @param path The `FieldPath` to the field to validate.
* @param schema The standard schema compatible validator to use for validation.
* @template TSchema The type validated by the schema. This may be either the full `TValue` type,
* or a partial of it.
* @template TValue The type of value stored in the field being validated.
*/
export function validateStandardSchema<TSchema, TValue extends IgnoreUnknownProperties<TSchema>>(
path: FieldPath<TValue>,
schema: StandardSchemaV1<TSchema>,
) {
// We create both a sync and async validator because the standard schema validator can return
// either a sync result or a Promise, and we need to handle both cases. The sync validator
// handles the sync result, and the async validator handles the Promise.
// We memoize the result of the validation function here, so that it is only run once for both
// validators, it can then be passed through both sync & async validation.
const VALIDATOR_MEMO = property(path, ({value}) => {
return computed(() => schema['~standard'].validate(value()));
});
validateTree(path, ({state, fieldOf}) => {
// Skip sync validation if the result is a Promise.
const result = state.property(VALIDATOR_MEMO)!();
if (ɵisPromise(result)) {
return [];
}
return result.issues?.map((issue) => standardIssueToFormTreeError(fieldOf(path), issue)) ?? [];
});
validateAsync(path, {
params: ({state}) => {
// Skip async validation if the result is *not* a Promise.
const result = state.property(VALIDATOR_MEMO)!();
return ɵisPromise(result) ? result : undefined;
},
factory: (params) => {
return resource({
params,
loader: async ({params}) => (await params)?.issues ?? [],
});
},
errors: (issues, {fieldOf}) => {
return issues.map((issue) => standardIssueToFormTreeError(fieldOf(path), issue));
},
});
}
/**
* Converts a `StandardSchemaV1.Issue` to a `FormTreeError`.
*
* @param field The root field to which the issue's path is relative.
* @param issue The `StandardSchemaV1.Issue` to convert.
* @returns A `ValidationError` representing the issue.
*/
function standardIssueToFormTreeError(
field: Field<unknown>,
issue: StandardSchemaV1.Issue,
): StandardSchemaValidationError {
let target = field as Field<Record<PropertyKey, unknown>>;
for (const pathPart of issue.path ?? []) {
const pathKey = typeof pathPart === 'object' ? pathPart.key : pathPart;
target = target[pathKey] as Field<Record<PropertyKey, unknown>>;
}
return addDefaultField(standardSchemaError(issue), target);
}
@@ -0,0 +1,52 @@
/**
* @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 {LogicFn, OneOrMany, PathKind, type FieldContext} from '../types';
import {ValidationError, WithoutField} from '../validation_errors';
/** Represents a value that has a length or size, such as an array or string, or set. */
export type ValueWithLengthOrSize = {length: number} | {size: number};
/** Common options available on the standard validators. */
export type BaseValidatorConfig<TValue, TPathKind extends PathKind = PathKind.Root> =
| {
/** A user-facing error message to include with the error. */
message?: string | LogicFn<TValue, string, TPathKind>;
error?: never;
}
| {
/**
* Custom validation error(s) to report instead of the default,
* or a function that receives the `FieldContext` and returns custom validation error(s).
*/
error?:
| OneOrMany<WithoutField<ValidationError>>
| LogicFn<TValue, OneOrMany<WithoutField<ValidationError>>, TPathKind>;
message?: never;
};
/** Gets the length or size of the given value. */
export function getLengthOrSize(value: ValueWithLengthOrSize) {
const v = value as {length: number; size: number};
return typeof v.length === 'number' ? v.length : v.size;
}
/**
* Gets the value for an option that may be either a static value or a logic function that produces
* the option value.
*
* @param opt The option from BaseValidatorConfig.
* @param ctx The current FieldContext.
* @returns The value for the option.
*/
export function getOption<TOption, TValue, TPathKind extends PathKind = PathKind.Root>(
opt: Exclude<TOption, Function> | LogicFn<TValue, TOption, TPathKind> | undefined,
ctx: FieldContext<TValue, TPathKind>,
): TOption | undefined {
return opt instanceof Function ? opt(ctx) : opt;
}
@@ -0,0 +1,454 @@
/**
* @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 {
afterNextRender,
computed,
DestroyRef,
Directive,
effect,
ElementRef,
EventEmitter,
inject,
Injector,
Input,
InputSignal,
OutputEmitterRef,
OutputRef,
OutputRefSubscription,
reflectComponentType,
Renderer2,
signal,
Type,
untracked,
} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl} from '@angular/forms';
import {FormCheckboxControl, FormUiControl, FormValueControl} from '../api/control';
import {
AggregateProperty,
MAX,
MAX_LENGTH,
MIN,
MIN_LENGTH,
PATTERN,
REQUIRED,
} from '../api/property';
import type {Field} from '../api/types';
import type {FieldNode} from '../field/node';
import {
privateGetComponentInstance,
privateIsModelInput,
privateIsSignalInput,
privateRunEffect,
privateSetComponentInput as privateSetInputSignal,
} from '../util/private';
import {InteropNgControl} from './interop_ng_control';
/**
* Binds a form `Field` to a UI control that edits it. A UI control can be one of several things:
* 1. A native HTML input or textarea
* 2. A signal forms custom control that implements `FormValueControl` or `FormCheckboxControl`
* 3. A component that provides a ControlValueAccessor. This should only be used to backwards
* compatibility with reactive forms. Prefer options (1) and (2).
*
* This directive has several responsibilities:
* 1. Two-way binds the field's value with the UI control's value
* 2. Binds additional forms related state on the field to the UI control (disabled, required, etc.)
* 3. Relays relevant events on the control to the field (e.g. marks field touched on blur)
* 4. Provides a fake `NgControl` that implements a subset of the features available on the reactive
* forms `NgControl`. This is provided to improve interoperability with controls designed to work
* with reactive forms. It should not be used by controls written for signal forms.
*/
@Directive({
selector: '[control]',
providers: [
{
provide: NgControl,
useFactory: () => inject(Control).ngControl,
},
],
})
export class Control<T> {
/** The injector for this component. */
private readonly injector = inject(Injector);
private readonly renderer = inject(Renderer2);
/** Whether state synchronization with the field has been setup yet. */
private initialized = false;
/** The field that is bound to this control. */
readonly field = signal<Field<T>>(undefined as any);
// If `[control]` is applied to a custom UI control, it wants to synchronize state in the field w/
// the inputs of that custom control. This is difficult to do in user-land. We use `effect`, but
// effects don't run before the lifecycle hooks of the component. This is usually okay, but has
// one significant issue: the UI control's required inputs won't be set in time for those
// lifecycle hooks to run.
//
// Eventually we can build custom functionality for the `Control` directive into the framework,
// but for now we work around this limitation with a hack. We use an `@Input` instead of a
// signal-based `input()` for the `[control]` to hook the exact moment inputs are being set,
// before the important lifecycle hooks of the UI control. We can then initialize all our effects
// and force them to run immediately, ensuring all required inputs have values.
@Input({required: true, alias: 'control'})
set _field(value: Field<T>) {
this.field.set(value);
if (!this.initialized) {
this.initialize();
}
}
/** The field state of the bound field. */
readonly state = computed(() => this.field()());
/** The HTMLElement this directive is attached to. */
readonly el: ElementRef<HTMLElement> = inject(ElementRef);
/** The NG_VALUE_ACCESSOR array for the host component. */
readonly cvaArray = inject<ControlValueAccessor[]>(NG_VALUE_ACCESSOR, {optional: true});
/** The Cached value for the lazily created interop NgControl. */
private _ngControl: InteropNgControl | undefined;
/** A fake NgControl provided for better interop with reactive forms. */
get ngControl(): NgControl {
return (this._ngControl ??= new InteropNgControl(() => this.state())) as unknown as NgControl;
}
/** The ControlValueAccessor for the host component. */
get cva(): ControlValueAccessor | undefined {
return this.cvaArray?.[0] ?? this._ngControl?.valueAccessor ?? undefined;
}
/** Initializes state synchronization between the field and the host UI control. */
private initialize() {
this.initialized = true;
const injector = this.injector;
const cmp = privateGetComponentInstance(injector);
// If component has a `control` input, we assume that it will handle binding the field to the
// appropriate native/custom control in its template, so we do not attempt to bind any inputs on
// this component.
if (cmp && isShadowedControlComponent(cmp)) {
return;
}
if (cmp && isFormUiControl(cmp)) {
// If we're binding to a component that follows the standard form ui control contract,
// set up state synchronization based on the contract.
this.setupCustomUiControl(cmp);
} else if (this.cva !== undefined) {
// If we're binding to a component that doesn't follow the standard contract, but provides a
// control value accessor, set up state synchronization based on th CVA.
this.setupControlValueAccessor(this.cva);
} else if (
this.el.nativeElement instanceof HTMLInputElement ||
this.el.nativeElement instanceof HTMLTextAreaElement ||
this.el.nativeElement instanceof HTMLSelectElement
) {
// If we're binding to a native html input, set up state synchronization with its native
// properties / attributes.
this.setupNativeInput(this.el.nativeElement);
} else {
throw new Error(`Unhandled control?`);
}
// Register this control on the field it is currently bound to. We do this at the end of
// initialization so that it only runs if we are actually syncing with this control
// (as opposed to just passing the field through to its `control` input).
effect(
(onCleanup) => {
const fieldNode = this.state() as unknown as FieldNode;
fieldNode.nodeState.controls.update((controls) => [...controls, this as Control<unknown>]);
onCleanup(() => {
fieldNode.nodeState.controls.update((controls) => controls.filter((c) => c !== this));
});
},
{injector: this.injector},
);
}
/**
* Set up state synchronization between the field and a native <input>, <textarea>, or <select>.
*/
private setupNativeInput(
input: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
): void {
const inputType =
input instanceof HTMLTextAreaElement
? 'text'
: input instanceof HTMLSelectElement
? 'select'
: input.type;
input.addEventListener('input', () => {
switch (inputType) {
case 'checkbox':
this.state().value.set((input as HTMLInputElement).checked as T);
break;
case 'radio':
// The `input` event only fires when a radio button becomes selected, so write its `value`
// into the state.
this.state().value.set((input as HTMLInputElement).value as T);
break;
default:
this.state().value.set(input.value as T);
break;
}
this.state().markAsDirty();
});
input.addEventListener('blur', () => this.state().markAsTouched());
this.maybeSynchronize(
() => this.state().readonly(),
this.withBooleanAttribute(input, 'readonly'),
);
// TODO: consider making a global configuration option for using aria-disabled instead.
this.maybeSynchronize(
() => this.state().disabled(),
this.withBooleanAttribute(input, 'disabled'),
);
this.maybeSynchronize(() => this.state().name(), this.withAttribute(input, 'name'));
this.maybeSynchronize(
this.propertySource(REQUIRED),
this.withBooleanAttribute(input, 'required'),
);
this.maybeSynchronize(this.propertySource(MIN), this.withAttribute(input, 'min'));
this.maybeSynchronize(this.propertySource(MIN_LENGTH), this.withAttribute(input, 'minLength'));
this.maybeSynchronize(this.propertySource(MAX), this.withAttribute(input, 'max'));
this.maybeSynchronize(this.propertySource(MAX_LENGTH), this.withAttribute(input, 'maxLength'));
switch (inputType) {
case 'checkbox':
this.maybeSynchronize(
() => this.state().value(),
(value) => ((input as HTMLInputElement).checked = value as boolean),
);
break;
case 'radio':
this.maybeSynchronize(
() => this.state().value(),
(value) => {
// Although HTML behavior is to clear the input already, we do this just in case.
// It seems like it might be necessary in certain environments (e.g. Domino).
(input as HTMLInputElement).checked = input.value === value;
},
);
break;
case 'select':
this.maybeSynchronize(
() => this.state().value(),
(value) => {
// A select will not take a value unil the value's option has rendered.
afterNextRender(() => (input.value = value as string), {injector: this.injector});
},
);
break;
default:
this.maybeSynchronize(
() => this.state().value(),
(value) => {
input.value = value as string;
},
);
break;
}
}
/** Set up state synchronization between the field and a ControlValueAccessor. */
private setupControlValueAccessor(cva: ControlValueAccessor): void {
cva.registerOnChange((value: T) => this.state().value.set(value));
cva.registerOnTouched(() => this.state().markAsTouched());
this.maybeSynchronize(
() => this.state().value(),
(value) => cva.writeValue(value),
);
if (cva.setDisabledState) {
this.maybeSynchronize(
() => this.state().disabled(),
(value) => cva.setDisabledState!(value),
);
}
cva.writeValue(this.state().value());
cva.setDisabledState?.(this.state().disabled());
}
/** Set up state synchronization between the field and a FormUiControl. */
private setupCustomUiControl(cmp: FormUiControl) {
// Handle the property side of the model binding. How we do this depends on the shape of the
// component. There are 2 options:
// * it provides a `value` model (most controls that edit a single value)
// * it provides a `checked` model with no `value` signal (custom checkbox)
let cleanupValue: OutputRefSubscription | undefined;
if (isFormValueControl(cmp)) {
// <custom-input [(value)]="state().value">
this.maybeSynchronize(() => this.state().value(), withInput(cmp.value));
cleanupValue = cmp.value.subscribe((newValue) => this.state().value.set(newValue as T));
} else if (isFormCheckboxControl(cmp)) {
// <custom-checkbox [(checked)]="state().value" />
this.maybeSynchronize(() => this.state().value() as boolean, withInput(cmp.checked));
cleanupValue = cmp.checked.subscribe((newValue) => this.state().value.set(newValue as T));
} else {
throw new Error(`Unknown custom control subtype`);
}
this.maybeSynchronize(() => this.state().name(), withInput(cmp.name));
this.maybeSynchronize(() => this.state().disabled(), withInput(cmp.disabled));
this.maybeSynchronize(() => this.state().disabledReasons(), withInput(cmp.disabledReasons));
this.maybeSynchronize(() => this.state().readonly(), withInput(cmp.readonly));
this.maybeSynchronize(() => this.state().hidden(), withInput(cmp.hidden));
this.maybeSynchronize(() => this.state().errors(), withInput(cmp.errors));
if (privateIsModelInput(cmp.touched) || privateIsSignalInput(cmp.touched)) {
this.maybeSynchronize(() => this.state().touched(), withInput(cmp.touched));
}
this.maybeSynchronize(() => this.state().dirty(), withInput(cmp.dirty));
this.maybeSynchronize(() => this.state().invalid(), withInput(cmp.invalid));
this.maybeSynchronize(() => this.state().pending(), withInput(cmp.pending));
this.maybeSynchronize(this.propertySource(REQUIRED), withInput(cmp.required));
this.maybeSynchronize(this.propertySource(MIN), withInput(cmp.min));
this.maybeSynchronize(this.propertySource(MIN_LENGTH), withInput(cmp.minLength));
this.maybeSynchronize(this.propertySource(MAX), withInput(cmp.max));
this.maybeSynchronize(this.propertySource(MAX_LENGTH), withInput(cmp.maxLength));
this.maybeSynchronize(this.propertySource(PATTERN), withInput(cmp.pattern));
let cleanupTouch: OutputRefSubscription | undefined;
let cleanupDefaultTouch: (() => void) | undefined;
if (privateIsModelInput(cmp.touched) || isOutputRef(cmp.touched)) {
cleanupTouch = cmp.touched.subscribe(() => this.state().markAsTouched());
} else {
// If the component did not give us a touch event stream, use the standard touch logic,
// marking it touched when the focus moves from inside the host element to outside.
const listener = (event: FocusEvent) => {
const newActiveEl = event.relatedTarget;
if (!this.el.nativeElement.contains(newActiveEl as Element | null)) {
this.state().markAsTouched();
}
};
this.el.nativeElement.addEventListener('focusout', listener);
cleanupDefaultTouch = () => this.el.nativeElement.removeEventListener('focusout', listener);
}
// Cleanup for output binding subscriptions:
this.injector.get(DestroyRef).onDestroy(() => {
cleanupValue?.unsubscribe();
cleanupTouch?.unsubscribe();
cleanupDefaultTouch?.();
});
}
/** Synchronize a value from a reactive source to a given sink. */
private maybeSynchronize<T>(source: () => T, sink: ((value: T) => void) | undefined): void {
if (!sink) {
return undefined;
}
const ref = effect(
() => {
const value = source();
untracked(() => sink(value));
},
{injector: this.injector},
);
// Run the effect immediately to ensure sinks which are required inputs are set before they can
// be observed. See the note on `_field` for more details.
privateRunEffect(ref);
}
/** Creates a reactive value source by reading the given AggregateProperty from the field. */
private propertySource<T>(key: AggregateProperty<T, any>): () => T {
const metaSource = computed(() =>
this.state().hasProperty(key) ? this.state().property(key) : key.getInitial,
);
return () => metaSource()?.();
}
/** Creates a (non-boolean) value sync that writes the given attribute of the given element. */
private withAttribute(
element: HTMLElement,
attribute: string,
): (value: {toString(): string} | undefined) => void {
return (value) => {
if (value !== undefined) {
this.renderer.setAttribute(element, attribute, value.toString());
} else {
this.renderer.removeAttribute(element, attribute);
}
};
}
/** Creates a boolean value sync that writes the given attribute of the given element. */
private withBooleanAttribute(element: HTMLElement, attribute: string): (value: boolean) => void {
return (value) => {
if (value) {
this.renderer.setAttribute(element, attribute, '');
} else {
this.renderer.removeAttribute(element, attribute);
}
};
}
}
/** Creates a value sync from an input signal. */
function withInput<T>(input: InputSignal<T> | undefined): ((value: T) => void) | undefined {
return input ? (value: T) => privateSetInputSignal(input, value) : undefined;
}
/**
* Checks whether the given component matches the contract for either FormValueControl or
* FormCheckboxControl.
*/
function isFormUiControl(cmp: unknown): cmp is FormUiControl {
const castCmp = cmp as FormUiControl;
return (
(isFormValueControl(castCmp) || isFormCheckboxControl(castCmp)) &&
(castCmp.readonly === undefined || privateIsSignalInput(castCmp.readonly)) &&
(castCmp.disabled === undefined || privateIsSignalInput(castCmp.disabled)) &&
(castCmp.disabledReasons === undefined || privateIsSignalInput(castCmp.disabledReasons)) &&
(castCmp.errors === undefined || privateIsSignalInput(castCmp.errors)) &&
(castCmp.invalid === undefined || privateIsSignalInput(castCmp.invalid)) &&
(castCmp.pending === undefined || privateIsSignalInput(castCmp.pending)) &&
(castCmp.touched === undefined ||
privateIsModelInput(castCmp.touched) ||
privateIsSignalInput(castCmp.touched) ||
isOutputRef(castCmp.touched)) &&
(castCmp.dirty === undefined || privateIsSignalInput(castCmp.dirty)) &&
(castCmp.min === undefined || privateIsSignalInput(castCmp.min)) &&
(castCmp.minLength === undefined || privateIsSignalInput(castCmp.minLength)) &&
(castCmp.max === undefined || privateIsSignalInput(castCmp.max)) &&
(castCmp.maxLength === undefined || privateIsSignalInput(castCmp.maxLength))
);
}
/** Checks whether the given FormUiControl is a FormValueControl. */
function isFormValueControl(cmp: FormUiControl): cmp is FormValueControl<unknown> {
return privateIsModelInput((cmp as FormValueControl<unknown>).value);
}
/** Checks whether the given FormUiControl is a FormCheckboxControl. */
function isFormCheckboxControl(cmp: FormUiControl): cmp is FormCheckboxControl {
return (
privateIsModelInput((cmp as FormCheckboxControl).checked) &&
(cmp as FormCheckboxControl).value === undefined
);
}
/** Checks whether the given component has an input called `control`. */
function isShadowedControlComponent(cmp: unknown): boolean {
const mirror = reflectComponentType((cmp as {}).constructor as Type<unknown>);
return mirror?.inputs.some((input) => input.templateName === 'control') ?? false;
}
/** Checks whether the given object is an output ref. */
function isOutputRef(value: unknown): value is OutputRef<unknown> {
return value instanceof OutputEmitterRef || value instanceof EventEmitter;
}
@@ -0,0 +1,144 @@
/**
* @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 {
ControlValueAccessor,
Validators,
type AbstractControl,
type FormControlStatus,
type NgControl,
type ValidationErrors,
type ValidatorFn,
} from '@angular/forms';
import {REQUIRED} from '../api/property';
import type {FieldState} from '../api/types';
// TODO: Also consider supporting (if possible):
// - hasError
// - getError
// - reset
// - name
// - path
// - markAs[Touched,Dirty,etc.]
/**
* Properties of both NgControl & AbstractControl that are supported by the InteropNgControl.
*/
export type InteropSharedKeys =
| 'value'
| 'valid'
| 'invalid'
| 'touched'
| 'untouched'
| 'disabled'
| 'enabled'
| 'errors'
| 'pristine'
| 'dirty'
| 'status';
/**
* A fake version of `NgControl` provided by the `Control` directive. This allows interoperability
* with a wider range of components designed to work with reactive forms, in particular ones that
* inject the `NgControl`. The interop control does not implement *all* properties and methods of
* the real `NgControl`, but does implement some of the most commonly used ones that have a clear
* equivalent in signal forms.
*/
export class InteropNgControl
implements
Pick<NgControl, InteropSharedKeys | 'control' | 'valueAccessor'>,
Pick<AbstractControl<unknown>, InteropSharedKeys | 'hasValidator'>
{
constructor(protected field: () => FieldState<unknown>) {}
readonly control: AbstractControl<any, any> = this as unknown as AbstractControl<any, any>;
get value(): any {
return this.field().value();
}
get valid(): boolean {
return this.field().valid();
}
get invalid(): boolean {
return this.field().invalid();
}
get pending(): boolean | null {
return this.field().pending();
}
get disabled(): boolean {
return this.field().disabled();
}
get enabled(): boolean {
return !this.field().disabled();
}
get errors(): ValidationErrors | null {
const errors = this.field().errors();
if (errors.length === 0) {
return null;
}
const errObj: ValidationErrors = {};
for (const error of errors) {
errObj[error.kind] = error;
}
return errObj;
}
get pristine(): boolean {
return !this.field().dirty();
}
get dirty(): boolean {
return this.field().dirty();
}
get touched(): boolean {
return this.field().touched();
}
get untouched(): boolean {
return !this.field().touched();
}
get status(): FormControlStatus {
if (this.field().disabled()) {
return 'DISABLED';
}
if (this.field().valid()) {
return 'VALID';
}
if (this.field().invalid()) {
return 'INVALID';
}
if (this.field().pending()) {
return 'PENDING';
}
throw Error('AssertionError: unknown form control status');
}
valueAccessor: ControlValueAccessor | null = null;
hasValidator(validator: ValidatorFn): boolean {
// This addresses a common case where users look for the presence of `Validators.required` to
// determine whether or not to show a required "*" indicator in the UI.
if (validator === Validators.required) {
return this.field().property(REQUIRED)();
}
return false;
}
updateValueAndValidity() {
// No-op since value and validity are always up to date in signal forms.
// We offer this method so that reactive forms code attempting to call it doesn't error.
}
}
+113
View File
@@ -0,0 +1,113 @@
/**
* @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 {computed, Signal, untracked, WritableSignal} from '@angular/core';
import {Field, FieldContext, FieldPath, FieldState} from '../api/types';
import {FieldPathNode} from '../schema/path_node';
import {isArray} from '../util/type_guards';
import type {FieldNode} from './node';
import {getBoundPathDepth} from './resolution';
/**
* `FieldContext` implementation, backed by a `FieldNode`.
*/
export class FieldNodeContext implements FieldContext<unknown> {
/**
* Cache of paths that have been resolved for this context.
*
* For each resolved path we keep track of a signal of field that it maps to rather than a static
* field, since it theoretically could change. In practice for the current system it should not
* actually change, as they only place we currently track fields moving within the parent
* structure is for arrays, and paths do not currently support array indexing.
*/
private readonly cache = new WeakMap<FieldPath<unknown>, Signal<Field<unknown>>>();
constructor(
/** The field node this context corresponds to. */
private readonly node: FieldNode,
) {}
/**
* Resolves a target path relative to this context.
* @param target The path to resolve
* @returns The field corresponding to the target path.
*/
private resolve<U>(target: FieldPath<U>): Field<U> {
if (!this.cache.has(target)) {
const resolver = computed<Field<unknown>>(() => {
const targetPathNode = FieldPathNode.unwrapFieldPath(target);
// First, find the field where the root our target path was merged in.
// We determine this by walking up the field tree from the current field and looking for
// the place where the LogicNodeBuilder from the target path's root was merged in.
// We always make sure to walk up at least as far as the depth of the path we were bound to.
// This ensures that we do not accidentally match on the wrong application of a recursively
// applied schema.
let field: FieldNode | undefined = this.node;
let stepsRemaining = getBoundPathDepth();
while (stepsRemaining > 0 || !field.structure.logic.hasLogic(targetPathNode.root.logic)) {
stepsRemaining--;
field = field.structure.parent;
if (field === undefined) {
throw new Error('Path is not part of this field tree.');
}
}
// Now, we can navigate to the target field using the relative path in the target path node
// to traverse down from the field we just found.
for (let key of targetPathNode.keys) {
field = field.structure.getChild(key);
if (field === undefined) {
throw new Error(
`Cannot resolve path .${targetPathNode.keys.join('.')} relative to field ${[
'<root>',
...this.node.structure.pathKeys(),
].join('.')}.`,
);
}
}
return field.fieldProxy;
});
this.cache.set(target, resolver);
}
return this.cache.get(target)!() as Field<U>;
}
get field(): Field<unknown> {
return this.node.fieldProxy;
}
get state(): FieldState<unknown> {
return this.node;
}
get value(): WritableSignal<unknown> {
return this.node.structure.value;
}
get key(): Signal<string> {
return this.node.structure.keyInParent;
}
readonly index = computed(() => {
// Attempt to read the key first, this will throw an error if we're on a root field.
const key = this.key();
// Assert that the parent is actually an array.
if (!isArray(untracked(this.node.structure.parent!.value))) {
throw new Error(`RuntimeError: cannot access index, parent field is not an array`);
}
// Return the key as a number if we are indeed inside an array field.
return Number(key);
});
readonly fieldOf = <P>(p: FieldPath<P>) => this.resolve(p);
readonly stateOf = <P>(p: FieldPath<P>) => this.resolve(p)();
readonly valueOf = <P>(p: FieldPath<P>) => this.resolve(p)().value();
}
@@ -0,0 +1,124 @@
/**
* @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 {FieldPathNode} from '../schema/path_node';
import {FormFieldManager} from './manager';
import {FieldNode} from './node';
import {FieldNodeState} from './state';
import {ChildFieldNodeOptions, FieldNodeOptions, FieldNodeStructure} from './structure';
import {ValidationState, FieldValidationState} from './validation';
import {WritableSignal} from '@angular/core';
/**
* Adapter allowing customization of the creation logic for a field and its associated
* structure and state.
*/
export interface FieldAdapter {
/**
* Creates a node structure.
* @param node
* @param options
*/
createStructure(node: FieldNode, options: FieldNodeOptions): FieldNodeStructure;
/**
* Creates node validation state
* @param param
* @param options
*/
createValidationState(param: FieldNode, options: FieldNodeOptions): ValidationState;
/**
* Creates node state.
* @param param
* @param options
*/
createNodeState(param: FieldNode, options: FieldNodeOptions): FieldNodeState;
/**
* Creates a custom child node.
* @param options
*/
newChild(options: ChildFieldNodeOptions): FieldNode;
/**
* Creates a custom root node.
* @param fieldManager
* @param model
* @param pathNode
* @param adapter
*/
newRoot<TValue>(
fieldManager: FormFieldManager,
model: WritableSignal<TValue>,
pathNode: FieldPathNode,
adapter: FieldAdapter,
): FieldNode;
}
/**
* Basic adapter supporting standard form behavior.
*/
export class BasicFieldAdapter implements FieldAdapter {
/**
* Creates a new Root field node.
* @param fieldManager
* @param value
* @param pathNode
* @param adapter
*/
newRoot<TValue>(
fieldManager: FormFieldManager,
value: WritableSignal<TValue>,
pathNode: FieldPathNode,
adapter: FieldAdapter,
): FieldNode {
return new FieldNode({
kind: 'root',
fieldManager,
value,
pathNode,
logic: pathNode.logic.build(),
fieldAdapter: adapter,
});
}
/**
* Creates a new child field node.
* @param options
*/
newChild(options: ChildFieldNodeOptions): FieldNode {
return new FieldNode(options);
}
/**
* Creates a node state.
* @param node
*/
createNodeState(node: FieldNode): FieldNodeState {
return new FieldNodeState(node);
}
/**
* Creates a validation state.
* @param node
*/
createValidationState(node: FieldNode): ValidationState {
return new FieldValidationState(node);
}
/**
* Creates a node structure.
* @param node
* @param options
*/
createStructure(node: FieldNode, options: FieldNodeOptions): FieldNodeStructure {
return node.createStructure(options);
}
}
@@ -0,0 +1,83 @@
/**
* @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 {APP_ID, effect, inject, Injector, untracked} from '@angular/core';
import type {FieldNodeStructure} from './structure';
/**
* Manages the collection of fields associated with a given `form`.
*
* Fields are created implicitly, through reactivity, and may create "owned" entities like effects
* or resources. When a field is no longer connected to the form, these owned entities should be
* destroyed, which is the job of the `FormFieldManager`.
*/
export class FormFieldManager {
readonly rootName: string;
constructor(
readonly injector: Injector,
rootName: string | undefined,
) {
this.rootName = rootName ?? `${this.injector.get(APP_ID)}.form${nextFormId++}`;
}
/**
* Contains all child field structures that have been created as part of the current form.
* New child structures are automatically added when they are created.
* Structures are destroyed and removed when they are no longer reachable from the root.
*/
readonly structures = new Set<FieldNodeStructure>();
/**
* Creates an effect that runs when the form's structure changes and checks for structures that
* have become unreachable to clean up.
*
* For example, consider a form wrapped around the following model: `signal([0, 1, 2])`.
* This form would have 4 nodes as part of its structure tree.
* One structure for the root array, and one structure for each element of the array.
* Now imagine the data is updated: `model.set([0])`. In this case the structure for the first
* element can still be reached from the root, but the structures for the second and third
* elements are now orphaned and not connected to the root. Thus they will be destroyed.
*
* @param root The root field structure.
*/
createFieldManagementEffect(root: FieldNodeStructure): void {
effect(
() => {
const liveStructures = new Set<FieldNodeStructure>();
this.markStructuresLive(root, liveStructures);
// Destroy all nodes that are no longer live.
for (const structure of this.structures) {
if (!liveStructures.has(structure)) {
this.structures.delete(structure);
untracked(() => structure.destroy());
}
}
},
{injector: this.injector},
);
}
/**
* Collects all structures reachable from the given structure into the given set.
*
* @param structure The root structure
* @param liveStructures The set of reachable structures to populate
*/
private markStructuresLive(
structure: FieldNodeStructure,
liveStructures: Set<FieldNodeStructure>,
): void {
liveStructures.add(structure);
for (const child of structure.children()) {
this.markStructuresLive(child.structure, liveStructures);
}
}
}
let nextFormId = 0;
+227
View File
@@ -0,0 +1,227 @@
/**
* @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 type {Signal, WritableSignal} from '@angular/core';
import {AggregateProperty, Property} from '../api/property';
import type {DisabledReason, Field, FieldContext, FieldState} from '../api/types';
import type {ValidationError} from '../api/validation_errors';
import type {Control} from '../controls/control';
import {LogicNode} from '../schema/logic_node';
import {FieldPathNode} from '../schema/path_node';
import {FieldNodeContext} from './context';
import type {FormFieldManager} from './manager';
import {FieldPropertyState} from './property';
import {FIELD_PROXY_HANDLER} from './proxy';
import {FieldNodeState} from './state';
import {
type ChildFieldNodeOptions,
ChildFieldNodeStructure,
type FieldNodeOptions,
type FieldNodeStructure,
RootFieldNodeStructure,
} from './structure';
import {FieldSubmitState} from './submit';
import {ValidationState} from './validation';
import type {FieldAdapter} from './field_adapter';
/**
* Internal node in the form tree for a given field.
*
* Field nodes have several responsibilities:
* - They track instance state for the particular field (touched)
* - They compute signals for derived state (valid, disabled, etc) based on their associated
* `LogicNode`
* - They act as the public API for the field (they implement the `FieldState` interface)
* - They implement navigation of the form tree via `.parent` and `.getChild()`.
*
* This class is largely a wrapper that aggregates several smaller pieces that each manage a subset of
* the responsibilities.
*/
export class FieldNode implements FieldState<unknown> {
readonly structure: FieldNodeStructure;
readonly validationState: ValidationState;
readonly propertyState: FieldPropertyState;
readonly nodeState: FieldNodeState;
readonly submitState: FieldSubmitState;
private _context: FieldContext<unknown> | undefined = undefined;
readonly fieldAdapter: FieldAdapter;
get context(): FieldContext<unknown> {
return (this._context ??= new FieldNodeContext(this));
}
/**
* Proxy to this node which allows navigation of the form graph below it.
*/
readonly fieldProxy = new Proxy(() => this, FIELD_PROXY_HANDLER) as unknown as Field<any>;
constructor(options: FieldNodeOptions) {
this.fieldAdapter = options.fieldAdapter;
this.structure = this.fieldAdapter.createStructure(this, options);
this.validationState = this.fieldAdapter.createValidationState(this, options);
this.nodeState = this.fieldAdapter.createNodeState(this, options);
this.propertyState = new FieldPropertyState(this);
this.submitState = new FieldSubmitState(this);
}
get logicNode(): LogicNode {
return this.structure.logic;
}
get value(): WritableSignal<unknown> {
return this.structure.value;
}
get keyInParent(): Signal<string | number> {
return this.structure.keyInParent;
}
get errors(): Signal<ValidationError[]> {
return this.validationState.errors;
}
get errorSummary(): Signal<ValidationError[]> {
return this.validationState.errorSummary;
}
get pending(): Signal<boolean> {
return this.validationState.pending;
}
get valid(): Signal<boolean> {
return this.validationState.valid;
}
get invalid(): Signal<boolean> {
return this.validationState.invalid;
}
get dirty(): Signal<boolean> {
return this.nodeState.dirty;
}
get touched(): Signal<boolean> {
return this.nodeState.touched;
}
get disabled(): Signal<boolean> {
return this.nodeState.disabled;
}
get disabledReasons(): Signal<readonly DisabledReason[]> {
return this.nodeState.disabledReasons;
}
get hidden(): Signal<boolean> {
return this.nodeState.hidden;
}
get readonly(): Signal<boolean> {
return this.nodeState.readonly;
}
get controls(): Signal<readonly Control<unknown>[]> {
return this.nodeState.controls;
}
get submitting(): Signal<boolean> {
return this.submitState.submitting;
}
get name(): Signal<string> {
return this.nodeState.name;
}
property<M>(prop: AggregateProperty<M, any>): Signal<M>;
property<M>(prop: Property<M>): M | undefined;
property<M>(prop: Property<M> | AggregateProperty<M, any>): Signal<M> | M | undefined {
return this.propertyState.get(prop);
}
hasProperty(prop: Property<unknown> | AggregateProperty<unknown, any>): boolean {
return this.propertyState.has(prop);
}
/**
* Marks this specific field as touched.
*/
markAsTouched(): void {
this.nodeState.markAsTouched();
}
/**
* Marks this specific field as dirty.
*/
markAsDirty(): void {
this.nodeState.markAsDirty();
}
/**
* Resets the {@link touched} and {@link dirty} state of the field and its descendants.
*
* Note this does not change the data model, which can be reset directly if desired.
*/
reset(): void {
this.nodeState.markAsUntouched();
this.nodeState.markAsPristine();
for (const child of this.structure.children()) {
child.reset();
}
}
/**
* Creates a new root field node for a new form.
*/
static newRoot<T>(
fieldManager: FormFieldManager,
value: WritableSignal<T>,
pathNode: FieldPathNode,
adapter: FieldAdapter,
): FieldNode {
return adapter.newRoot(fieldManager, value, pathNode, adapter);
}
/**
* Creates a child field node based on the given options.
*/
private static newChild(options: ChildFieldNodeOptions): FieldNode {
return options.fieldAdapter.newChild(options);
}
createStructure(options: FieldNodeOptions) {
return options.kind === 'root'
? new RootFieldNodeStructure(
this,
options.pathNode,
options.logic,
options.fieldManager,
options.value,
options.fieldAdapter,
FieldNode.newChild,
)
: new ChildFieldNodeStructure(
this,
options.pathNode,
options.logic,
options.parent,
options.identityInParent,
options.initialKeyInParent,
options.fieldAdapter,
FieldNode.newChild,
);
}
}
/**
* Field node of a field that has children.
* This simplifies and makes certain types cleaner.
*/
export interface ParentFieldNode extends FieldNode {
readonly value: WritableSignal<Record<string, unknown>>;
readonly structure: FieldNodeStructure & {value: WritableSignal<Record<string, unknown>>};
}
@@ -0,0 +1,74 @@
/**
* @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 {computed, runInInjectionContext, Signal, untracked} from '@angular/core';
import {AggregateProperty, Property} from '../api/property';
import type {FieldNode} from './node';
import {cast} from './util';
/**
* Tracks custom properties associated with a `FieldNode`.
*/
export class FieldPropertyState {
/** A map of all `Property` and `AggregateProperty` that have been defined for this field. */
private readonly properties = new Map<
Property<unknown> | AggregateProperty<unknown, unknown>,
unknown
>();
constructor(private readonly node: FieldNode) {
// Field nodes (and thus their property state) are created in a linkedSignal in order to mirror
// the structure of the model data. We need to run the property factories untracked so that they
// do not cause recomputation of the linkedSignal.
untracked(() =>
// Property factories are run in the form's injection context so they can create resources
// and inject DI dependencies.
runInInjectionContext(this.node.structure.injector, () => {
for (const [key, factory] of this.node.logicNode.logic.getPropertyFactoryEntries()) {
this.properties.set(key, factory(this.node.context));
}
}),
);
}
/** Gets the value of a `Property` or `AggregateProperty` for the field. */
get<T>(prop: Property<T> | AggregateProperty<T, unknown>): T | undefined | Signal<T> {
if (prop instanceof Property) {
return this.properties.get(prop) as T | undefined;
}
// Aggregate properties come with an initial value, and are considered to exist for every field.
// If no logic explicitly contributes values for the property, it is just considered to be the
// initial value. Therefore if the user asks for an aggregate property for a field,
// we just create its computed on the fly.
cast<AggregateProperty<unknown, unknown>>(prop);
if (!this.properties.has(prop)) {
const logic = this.node.logicNode.logic.getAggregateProperty(prop);
const result = computed(() => logic.compute(this.node.context));
this.properties.set(prop, result);
}
return this.properties.get(prop)! as Signal<T>;
}
/**
* Checks whether the current property state has the given property.
* @param prop
* @returns
*/
has(prop: Property<unknown> | AggregateProperty<unknown, unknown>): boolean {
if (prop instanceof AggregateProperty) {
// For aggregate properties, they get added to the map lazily, on first access, so we can't
// rely on checking presence in the properties map. Instead we check if there is any logic for
// the given property.
return this.node.logicNode.logic.hasAggregateProperty(prop);
} else {
// Non-aggregate proeprties get added to our properties map on construction, so we can just
// refer to their presence in the map.
return this.properties.has(prop);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
/**
* @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 {untracked} from '@angular/core';
import {isArray} from '../util/type_guards';
import type {FieldNode} from './node';
/**
* Proxy handler which implements `Field<T>` on top of `FieldNode`.
*/
export const FIELD_PROXY_HANDLER: ProxyHandler<() => FieldNode> = {
get(getTgt: () => FieldNode, p: string | symbol) {
const tgt = getTgt();
// First, check whether the requested property is a defined child node of this node.
const child = tgt.structure.getChild(p);
if (child !== undefined) {
// If so, return the child node's `Field` proxy, allowing the developer to continue navigating
// the form structure.
return child.fieldProxy;
}
// Otherwise, we need to consider whether the properties they're accessing are related to array
// iteration. We're specifically interested in `length`, but we only want to pass this through
// if the value is actually an array.
//
// We untrack the value here to avoid spurious reactive notifications. In reality, we've already
// incurred a dependency on the value via `tgt.getChild()` above.
const value = untracked(tgt.value);
if (isArray(value)) {
// Allow access to the length for field arrays, it should be the same as the length of the data.
if (p === 'length') {
return (tgt.value() as Array<unknown>).length;
}
// Allow access to the iterator. This allows the user to spread the field array into a
// standard array in order to call methods like `filter`, `map`, etc.
if (p === Symbol.iterator) {
return (Array.prototype as any)[p];
}
// Note: We can consider supporting additional array methods if we want in the future,
// but they should be thoroughly tested. Just forwarding the method directly from the
// `Array` prototype results in broken behavior for some methods like `map`.
}
// Otherwise, this property doesn't exist.
return undefined;
},
};
@@ -0,0 +1,61 @@
/**
* @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
*/
let boundPathDepth = 0;
/**
* The depth of the current path when evaluating a logic function.
* Do not set this directly, it is a context variable managed by `setBoundPathDepthForResolution`.
*/
export function getBoundPathDepth() {
return boundPathDepth;
}
/**
* Sets the bound path depth for the duration of the given logic function.
* This is used to ensure that the field resolution algorithm walks far enough up the field tree to
* reach the point where the root of the path we're bound to is applied. This normally isn't a big
* concern, but matters when we're dealing with recursive structures.
*
* Consider this example:
*
* ```
* const s = schema(p => {
* disabled(p.next, ({valueOf}) => valueOf(p.data));
* apply(p.next, s);
* });
* ```
*
* Here we need to know that the `disabled` logic was bound to a path of depth 1. Otherwise we'd
* attempt to resolve `p.data` in the context of the field corresponding to `p.next`.
* The resolution algorithm would start with the field for `p.next` and see that it *does* contain
* the logic for `s` (due to the fact that its recursively applied.) It would then decide not to
* walk up the field tree at all, and to immediately start walking down the keys for the target path
* `p.data`, leading it to grab the field corresponding to `p.next.data`.
*
* We avoid the problem described above by keeping track of the depth (relative to Schema root) of
* the path we were bound to. We then require the resolution algorithm to walk at least that far up
* the tree before finding a node that contains the logic for `s`.
*
* @param fn A logic function that is bound to a particular path
* @param depth The depth in the field tree of the field the logic is bound to
* @returns A version of the logic function that is aware of its depth.
*/
export function setBoundPathDepthForResolution<A extends any[], R>(
fn: (...args: A) => R,
depth: number,
): (...args: A) => R {
return (...args: A) => {
try {
boundPathDepth = depth;
return fn(...args);
} finally {
boundPathDepth = 0;
}
};
}
+159
View File
@@ -0,0 +1,159 @@
/**
* @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 {computed, signal, Signal} from '@angular/core';
import type {DisabledReason} from '../api/types';
import type {Control} from '../controls/control';
import type {FieldNode} from './node';
import {reduceChildren, shortCircuitTrue} from './util';
/**
* The non-validation and non-submit state associated with a `FieldNode`, such as touched and dirty
* status, as well as derived logical state.
*/
export class FieldNodeState {
/**
* Indicates whether this field has been touched directly by the user (as opposed to indirectly by
* touching a child field).
*
* A field is considered directly touched when a user stops editing it for the first time (i.e. on blur)
*/
private readonly selfTouched = signal(false);
/**
* Indicates whether this field has been dirtied directly by the user (as opposed to indirectly by
* dirtying a child field).
*
* A field is considered directly dirtied if a user changed the value of the field at least once.
*/
private readonly selfDirty = signal(false);
/**
* Marks this specific field as touched.
*/
markAsTouched(): void {
// TODO: should this be noop for fields that are hidden/disabled/readonly
this.selfTouched.set(true);
}
/**
* Marks this specific field as dirty.
*/
markAsDirty(): void {
// TODO: should this be noop for fields that are hidden/disabled/readonly
this.selfDirty.set(true);
}
/**
* Marks this specific field as not dirty.
*/
markAsPristine(): void {
this.selfDirty.set(false);
}
/**
* Marks this specific field as not touched.
*/
markAsUntouched(): void {
this.selfTouched.set(false);
}
/** The UI controls the field is currently bound to. */
readonly controls = signal<readonly Control<unknown>[]>([]);
constructor(private readonly node: FieldNode) {}
/**
* Whether this field is considered dirty.
*
* A field is considered dirty if one of the following is true:
* - It was directly dirtied
* - One of its children is considered dirty
*/
readonly dirty: Signal<boolean> = computed(() => {
return reduceChildren(
this.node,
this.selfDirty(),
(child, value) => value || child.nodeState.dirty(),
shortCircuitTrue,
);
});
/**
* Whether this field is considered touched.
*
* A field is considered touched if one of the following is true:
* - It was directly touched
* - One of its children is considered touched
*/
readonly touched: Signal<boolean> = computed(() =>
reduceChildren(
this.node,
this.selfTouched(),
(child, value) => value || child.nodeState.touched(),
shortCircuitTrue,
),
);
/**
* The reasons for this field's disablement. This includes disabled reasons for any parent field
* that may have been disabled, indirectly causing this field to be disabled as well.
* The `field` property of the `DisabledReason` can be used to determine which field ultimately
* caused the disablement.
*/
readonly disabledReasons: Signal<readonly DisabledReason[]> = computed(() => [
...(this.node.structure.parent?.nodeState.disabledReasons() ?? []),
...this.node.logicNode.logic.disabledReasons.compute(this.node.context),
]);
/**
* Whether this field is considered disabled.
*
* A field is considered disabled if one of the following is true:
* - The schema contains logic that directly disabled it
* - Its parent field is considered disabled
*/
readonly disabled: Signal<boolean> = computed(() => !!this.disabledReasons().length);
/**
* Whether this field is considered readonly.
*
* A field is considered readonly if one of the following is true:
* - The schema contains logic that directly made it readonly
* - Its parent field is considered readonly
*/
readonly readonly: Signal<boolean> = computed(
() =>
(this.node.structure.parent?.nodeState.readonly() ||
this.node.logicNode.logic.readonly.compute(this.node.context)) ??
false,
);
/**
* Whether this field is considered hidden.
*
* A field is considered hidden if one of the following is true:
* - The schema contains logic that directly hides it
* - Its parent field is considered hidden
*/
readonly hidden: Signal<boolean> = computed(
() =>
(this.node.structure.parent?.nodeState.hidden() ||
this.node.logicNode.logic.hidden.compute(this.node.context)) ??
false,
);
readonly name: Signal<string> = computed(() => {
const parent = this.node.structure.parent;
if (!parent) {
return this.node.structure.fieldManager.rootName;
}
return `${parent.name()}.${this.node.structure.keyInParent()}`;
});
}
@@ -0,0 +1,460 @@
/**
* @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 {
computed,
DestroyableInjector,
Injector,
linkedSignal,
Signal,
WritableSignal,
} from '@angular/core';
import {DYNAMIC} from '../schema/logic';
import {LogicNode} from '../schema/logic_node';
import type {FieldPathNode} from '../schema/path_node';
import {deepSignal} from '../util/deep_signal';
import {isArray, isObject} from '../util/type_guards';
import type {FormFieldManager} from './manager';
import type {FieldNode, ParentFieldNode} from './node';
import type {FieldAdapter} from './field_adapter';
/**
* Key by which a parent `FieldNode` tracks its children.
*
* Often this is the actual property key of the child, but in the case of arrays it could be a
* tracking key allocated for the object.
*/
export type TrackingKey = PropertyKey & {__brand: 'FieldIdentity'};
/** Structural component of a `FieldNode` which tracks its path, parent, and children. */
export abstract class FieldNodeStructure {
/** Computed map of child fields, based on the current value of this field. */
abstract readonly childrenMap: Signal<Map<TrackingKey, FieldNode> | undefined>;
/** The field's value. */
abstract readonly value: WritableSignal<unknown>;
/**
* The key of this field in its parent field.
* Attempting to read this for the root field will result in an error being thrown.
*/
abstract readonly keyInParent: Signal<string>;
/** The field manager responsible for managing this field. */
abstract readonly fieldManager: FormFieldManager;
/** The root field that this field descends from. */
abstract readonly root: FieldNode;
/** The list of property keys to follow to get from the `root` to this field. */
abstract readonly pathKeys: Signal<readonly PropertyKey[]>;
/** The parent field of this field. */
abstract readonly parent: FieldNode | undefined;
/** Added to array elements for tracking purposes. */
// TODO: given that we don't ever let a field move between parents, is it safe to just extract
// this to a shared symbol for all fields, rather than having a separate one per parent?
readonly identitySymbol = Symbol();
/** Lazily initialized injector. Do not access directly, access via `injector` getter instead. */
private _injector: DestroyableInjector | undefined = undefined;
/** Lazily initialized injector. */
get injector(): DestroyableInjector {
this._injector ??= Injector.create({
providers: [],
parent: this.fieldManager.injector,
}) as DestroyableInjector;
return this._injector;
}
constructor(
/** The logic to apply to this field. */
readonly logic: LogicNode,
) {}
/** Gets the child fields of this field. */
children(): Iterable<FieldNode> {
return this.childrenMap()?.values() ?? [];
}
/** Retrieve a child `FieldNode` of this node by property key. */
getChild(key: PropertyKey): FieldNode | undefined {
const map = this.childrenMap();
const value = this.value();
if (!map || !isObject(value)) {
return undefined;
}
if (isArray(value)) {
const childValue = value[key];
if (isObject(childValue) && childValue.hasOwnProperty(this.identitySymbol)) {
// For arrays, we want to use the tracking identity of the value instead of the raw property
// as our index into the `childrenMap`.
key = childValue[this.identitySymbol] as PropertyKey;
}
}
return map.get((typeof key === 'number' ? key.toString() : key) as TrackingKey);
}
/** Destroys the field when it is no longer needed. */
destroy(): void {
this.injector.destroy();
}
}
/** The structural component of a `FieldNode` that is the root of its field tree. */
export class RootFieldNodeStructure extends FieldNodeStructure {
override get parent(): undefined {
return undefined;
}
override get root(): FieldNode {
return this.node;
}
override get pathKeys(): Signal<readonly PropertyKey[]> {
return ROOT_PATH_KEYS;
}
override get keyInParent(): Signal<string> {
return ROOT_KEY_IN_PARENT;
}
override readonly childrenMap: Signal<Map<TrackingKey, FieldNode> | undefined>;
/**
* Creates the structure for the root node of a field tree.
*
* @param node The full field node that this structure belongs to
* @param pathNode The path corresponding to this node in the schema
* @param logic The logic to apply to this field
* @param fieldManager The field manager for this field
* @param value The value signal for this field
* @param adapter Adapter that knows how to create new fields and appropriate state.
* @param createChildNode A factory function to create child nodes for this field.
*/
constructor(
/** The full field node that corresponds to this structure. */
private readonly node: FieldNode,
pathNode: FieldPathNode,
logic: LogicNode,
override readonly fieldManager: FormFieldManager,
override readonly value: WritableSignal<unknown>,
adapter: FieldAdapter,
createChildNode: (options: ChildFieldNodeOptions) => FieldNode,
) {
super(logic);
this.childrenMap = makeChildrenMapSignal(
node as ParentFieldNode,
value,
this.identitySymbol,
pathNode,
logic,
adapter,
createChildNode,
);
}
}
/** The structural component of a child `FieldNode` within a field tree. */
export class ChildFieldNodeStructure extends FieldNodeStructure {
override readonly root: FieldNode;
override readonly pathKeys: Signal<readonly PropertyKey[]>;
override readonly keyInParent: Signal<string>;
override readonly value: WritableSignal<unknown>;
override readonly childrenMap: Signal<Map<TrackingKey, FieldNode> | undefined>;
override get fieldManager(): FormFieldManager {
return this.root.structure.fieldManager;
}
/**
* Creates the structure for a child field node in a field tree.
*
* @param node The full field node that this structure belongs to
* @param pathNode The path corresponding to this node in the schema
* @param logic The logic to apply to this field
* @param parent The parent field node for this node
* @param identityInParent The identity used to track this field in its parent
* @param initialKeyInParent The key of this field in its parent at the time of creation
* @param adapter Adapter that knows how to create new fields and appropriate state.
* @param createChildNode A factory function to create child nodes for this field.
*/
constructor(
node: FieldNode,
pathNode: FieldPathNode,
logic: LogicNode,
override readonly parent: ParentFieldNode,
identityInParent: TrackingKey | undefined,
initialKeyInParent: string,
adapter: FieldAdapter,
createChildNode: (options: ChildFieldNodeOptions) => FieldNode,
) {
super(logic);
this.root = this.parent.structure.root;
this.pathKeys = computed(() => [...parent.structure.pathKeys(), this.keyInParent()]);
if (identityInParent === undefined) {
const key = initialKeyInParent;
this.keyInParent = computed(() => {
if (parent.structure.childrenMap()?.get(key as TrackingKey) !== node) {
throw new Error(
`RuntimeError: orphan field, looking for property '${key}' of ${getDebugName(parent)}`,
);
}
return key;
});
} else {
let lastKnownKey = initialKeyInParent;
this.keyInParent = computed(() => {
// TODO(alxhub): future perf optimization: here we depend on the parent's value, but most
// changes to the value aren't structural - they aren't moving around objects and thus
// shouldn't affect `keyInParent`. We currently mitigate this issue via `lastKnownKey`
// which avoids a search.
const parentValue = parent.structure.value();
if (!isArray(parentValue)) {
// It should not be possible to encounter this error. It would require the parent to
// change from an array field to non-array field. However, in the current implementation
// a field's parent can never change.
throw new Error(
`RuntimeError: orphan field, expected ${getDebugName(parent)} to be an array`,
);
}
// Check the parent value at the last known key to avoid a scan.
// Note: lastKnownKey is a string, but we pretend to typescript like its a number,
// since accessing someArray['1'] is the same as accessing someArray[1]
const data = parentValue[lastKnownKey as unknown as number];
if (
isObject(data) &&
data.hasOwnProperty(parent.structure.identitySymbol) &&
data[parent.structure.identitySymbol] === identityInParent
) {
return lastKnownKey;
}
// Otherwise, we need to check all the keys in the parent.
for (let i = 0; i < parentValue.length; i++) {
const data = parentValue[i];
if (
isObject(data) &&
data.hasOwnProperty(parent.structure.identitySymbol) &&
data[parent.structure.identitySymbol] === identityInParent
) {
return (lastKnownKey = i.toString());
}
}
throw new Error(
`RuntimeError: orphan field, can't find element in array ${getDebugName(parent)}`,
);
});
}
this.value = deepSignal(this.parent.structure.value, this.keyInParent);
this.childrenMap = makeChildrenMapSignal(
node as ParentFieldNode,
this.value,
this.identitySymbol,
pathNode,
logic,
adapter,
createChildNode,
);
this.fieldManager.structures.add(this);
}
}
/** Global id used for tracking keys. */
let globalId = 0;
/** Options passed when constructing a root field node. */
export interface RootFieldNodeOptions {
/** Kind of node, used to differentiate root node options from child node options. */
readonly kind: 'root';
/** The path node corresponding to this field in the schema. */
readonly pathNode: FieldPathNode;
/** The logic to apply to this field. */
readonly logic: LogicNode;
/** The value signal for this field. */
readonly value: WritableSignal<unknown>;
/** The field manager for this field. */
readonly fieldManager: FormFieldManager;
/** This allows for more granular field and state management, and is currently used for compat. */
readonly fieldAdapter: FieldAdapter;
}
/** Options passed when constructing a child field node. */
export interface ChildFieldNodeOptions {
/** Kind of node, used to differentiate root node options from child node options. */
readonly kind: 'child';
/** The parent field node of this field. */
readonly parent: ParentFieldNode;
/** The path node corresponding to this field in the schema. */
readonly pathNode: FieldPathNode;
/** The logic to apply to this field. */
readonly logic: LogicNode;
/** The key of this field in its parent at the time of creation. */
readonly initialKeyInParent: string;
/** The identity used to track this field in its parent. */
readonly identityInParent: TrackingKey | undefined;
/** This allows for more granular field and state management, and is currently used for compat. */
readonly fieldAdapter: FieldAdapter;
}
/** Options passed when constructing a field node. */
export type FieldNodeOptions = RootFieldNodeOptions | ChildFieldNodeOptions;
/** A signal representing an empty list of path keys, used for root fields. */
const ROOT_PATH_KEYS = computed<readonly PropertyKey[]>(() => []);
/**
* A signal representing a non-existent key of the field in its parent, used for root fields which
* do not have a parent. This signal will throw if it is read.
*/
const ROOT_KEY_IN_PARENT = computed(() => {
throw new Error(`RuntimeError: the top-level field in the form has no parent`);
});
/**
* Creates a linked signal map of all child fields for a field.
*
* @param node The field to create the children map signal for.
* @param valueSignal The value signal for the field.
* @param identitySymbol The key used to access the tracking id of a field.
* @param pathNode The path node corresponding to the field in the schema.
* @param logic The logic to apply to the field.
* @param adapter Adapter that knows how to create new fields and appropriate state.
* @param createChildNode A factory function to create child nodes for this field.
* @returns
*/
function makeChildrenMapSignal(
node: FieldNode,
valueSignal: WritableSignal<unknown>,
identitySymbol: symbol,
pathNode: FieldPathNode,
logic: LogicNode,
adapter: FieldAdapter,
createChildNode: (options: ChildFieldNodeOptions) => FieldNode,
): Signal<Map<TrackingKey, FieldNode> | undefined> {
// We use a `linkedSignal` to preserve the instances of `FieldNode` for each child field even if
// the value of this field changes its object identity. The computation creates or updates the map
// of child `FieldNode`s for `node` based on its current value.
return linkedSignal<unknown, Map<TrackingKey, FieldNode> | undefined>({
source: valueSignal,
computation: (value, previous): Map<TrackingKey, FieldNode> | undefined => {
// We may or may not have a previous map. If there isn't one, then `childrenMap` will be lazily
// initialized to a new map instance if needed.
let childrenMap = previous?.value;
if (!isObject(value)) {
// Non-object values have no children.
return undefined;
}
const isValueArray = isArray(value);
// Remove fields that have disappeared since the last time this map was computed.
if (childrenMap !== undefined) {
let oldKeys: Set<TrackingKey> | undefined = undefined;
if (isValueArray) {
oldKeys = new Set(childrenMap.keys());
for (let i = 0; i < value.length; i++) {
const childValue = value[i] as unknown;
if (isObject(childValue) && childValue.hasOwnProperty(identitySymbol)) {
oldKeys.delete(childValue[identitySymbol] as TrackingKey);
} else {
oldKeys.delete(i.toString() as TrackingKey);
}
}
for (const key of oldKeys) {
childrenMap.delete(key);
}
} else {
for (let key of childrenMap.keys()) {
if (!value.hasOwnProperty(key)) {
childrenMap.delete(key);
}
}
}
}
// Add fields that exist in the value but don't yet have instances in the map.
for (let key of Object.keys(value)) {
let trackingId: TrackingKey | undefined = undefined;
const childValue = value[key] as unknown;
// Fields explicitly set to `undefined` are treated as if they don't exist.
// This ensures that `{value: undefined}` and `{}` have the same behavior for their `value`
// field.
if (childValue === undefined) {
// The value might have _become_ `undefined`, so we need to delete it here.
childrenMap?.delete(key as TrackingKey);
continue;
}
if (isValueArray && isObject(childValue)) {
// For object values in arrays, assign a synthetic identity instead.
trackingId = (childValue[identitySymbol] as TrackingKey) ??= Symbol(
ngDevMode ? `id:${globalId++}` : '',
) as TrackingKey;
}
const identity = trackingId ?? (key as TrackingKey);
if (childrenMap?.has(identity)) {
continue;
}
// Determine the logic for the field that we're defining.
let childPath: FieldPathNode | undefined;
let childLogic: LogicNode;
if (isValueArray) {
// Fields for array elements have their logic defined by the `element` mechanism.
// TODO: other dynamic data
childPath = pathNode.getChild(DYNAMIC);
childLogic = logic.getChild(DYNAMIC);
} else {
// Fields for plain properties exist in our logic node's child map.
childPath = pathNode.getChild(key);
childLogic = logic.getChild(key);
}
childrenMap ??= new Map<TrackingKey, FieldNode>();
childrenMap.set(
identity,
createChildNode({
kind: 'child',
parent: node as ParentFieldNode,
pathNode: childPath,
logic: childLogic,
initialKeyInParent: key,
identityInParent: trackingId,
fieldAdapter: adapter,
}),
);
}
return childrenMap;
},
equal: () => false,
});
}
/** Gets a human readable name for a field node for use in error messages. */
function getDebugName(node: FieldNode) {
return `<root>.${node.structure.pathKeys().join('.')}`;
}
@@ -0,0 +1,40 @@
/**
* @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 {computed, linkedSignal, Signal, signal, WritableSignal} from '@angular/core';
import {ValidationError} from '../api/validation_errors';
import type {FieldNode} from './node';
/**
* State of a `FieldNode` that's associated with form submission.
*/
export class FieldSubmitState {
/**
* Whether this field was directly submitted (as opposed to indirectly by a parent field being submitted)
* and is still in the process of submitting.
*/
readonly selfSubmitting = signal<boolean>(false);
/** Server errors that are associated with this field. */
readonly serverErrors: WritableSignal<readonly ValidationError[]>;
constructor(private readonly node: FieldNode) {
this.serverErrors = linkedSignal({
source: this.node.structure.value,
computation: () => [] as readonly ValidationError[],
});
}
/**
* Whether this form is currently in the process of being submitted.
* Either because the field was submitted directly, or because a parent field was submitted.
*/
readonly submitting: Signal<boolean> = computed(() => {
return this.selfSubmitting() || (this.node.structure.parent?.submitting() ?? false);
});
}
+60
View File
@@ -0,0 +1,60 @@
/**
* @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 type {FieldNode} from './node';
import type {FieldNodeOptions} from './structure';
/**
* Perform a reduction over a field's children (if any) and return the result.
*
* Optionally, the reduction is short circuited based on the provided `shortCircuit` function.
*/
export function reduceChildren<T>(
node: FieldNode,
initialValue: T,
fn: (child: FieldNode, value: T) => T,
shortCircuit?: (value: T) => boolean,
): T {
const childrenMap = node.structure.childrenMap();
if (!childrenMap) {
return initialValue;
}
let value = initialValue;
for (const child of childrenMap.values()) {
if (shortCircuit?.(value)) {
break;
}
value = fn(child, value);
}
return value;
}
/** A shortCircuit function for reduceChildren that short-circuits if the value is false. */
export function shortCircuitFalse(value: boolean): boolean {
return !value;
}
/** A shortCircuit function for reduceChildren that short-circuits if the value is true. */
export function shortCircuitTrue(value: boolean): boolean {
return value;
}
/** Recasts the given value as a new type. */
export function cast<T>(value: unknown): asserts value is T {}
/**
* A helper method allowing to get injector regardless of the options type.
* @param options
*/
export function getInjectorFromOptions(options: FieldNodeOptions) {
if (options.kind === 'root') {
return options.fieldManager.injector;
}
return options.parent.structure.root.structure.injector;
}
@@ -0,0 +1,380 @@
/**
* @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 {computed, Signal} from '@angular/core';
import type {Field, Mutable, TreeValidationResult, ValidationResult} from '../api/types';
import type {ValidationError, WithOptionalField} from '../api/validation_errors';
import {isArray} from '../util/type_guards';
import type {FieldNode} from './node';
import {reduceChildren, shortCircuitFalse} from './util';
/**
* Helper function taking validation state, and returning own state of the node.
* @param state
*/
export function calculateValidationSelfStatus(
state: ValidationState,
): 'invalid' | 'unknown' | 'valid' {
if (state.errors().length > 0) {
return 'invalid';
}
if (state.pending()) {
return 'unknown';
}
return 'valid';
}
export interface ValidationState {
/**
* The full set of synchronous tree errors visible to this field. This includes ones that are
* targeted at a descendant field rather than at this field.
*/
rawSyncTreeErrors: Signal<ValidationError[]>;
/**
* The full set of synchronous errors for this field, including synchronous tree errors and server
* errors. Server errors are considered "synchronous" because they are imperatively added. From
* the perspective of the field state they are either there or not, they are never in a pending
* state.
*/
syncErrors: Signal<ValidationError[]>;
/**
* Whether the field is considered valid according solely to its synchronous validators.
* Errors resulting from a previous submit attempt are also considered for this state.
*/
syncValid: Signal<boolean>;
/**
* The full set of asynchronous tree errors visible to this field. This includes ones that are
* targeted at a descendant field rather than at this field, as well as sentinel 'pending' values
* indicating that the validator is still running and an error could still occur.
*/
rawAsyncErrors: Signal<(ValidationError | 'pending')[]>;
/**
* The asynchronous tree errors visible to this field that are specifically targeted at this field
* rather than a descendant. This also includes all 'pending' sentinel values, since those could
* theoretically result in errors for this field.
*/
asyncErrors: Signal<(ValidationError | 'pending')[]>;
/**
* The combined set of all errors that currently apply to this field.
*/
errors: Signal<ValidationError[]>;
/**
* The combined set of all errors that currently apply to this field and its descendants.
*/
errorSummary: Signal<ValidationError[]>;
/**
* Whether this field has any asynchronous validators still pending.
*/
pending: Signal<boolean>;
/**
* The validation status of the field.
* - The status is 'valid' if neither the field nor any of its children has any errors or pending
* validators.
* - The status is 'invalid' if the field or any of its children has an error
* (regardless of pending validators)
* - The status is 'unknown' if neither the field nor any of its children has any errors,
* but the field or any of its children does have a pending validator.
*
* A field is considered valid if *all* of the following are true:
* - It has no errors or pending validators
* - All of its children are considered valid
* A field is considered invalid if *any* of the following are true:
* - It has an error
* - Any of its children is considered invalid
* A field is considered to have unknown validity status if it is not valid or invalid.
*/
status: Signal<'valid' | 'invalid' | 'unknown'>;
/**
* Whether the field is considered valid.
*
* A field is considered valid if *all* of the following are true:
* - It has no errors or pending validators
* - All of its children are considered valid
*
* Note: `!valid()` is *not* the same as `invalid()`. Both `valid()` and `invalid()` can be false
* if there are currently no errors, but validators are still pending.
*/
valid: Signal<boolean>;
/**
* Whether the field is considered invalid.
*
* A field is considered invalid if *any* of the following are true:
* - It has an error
* - Any of its children is considered invalid
*
* Note: `!invalid()` is *not* the same as `valid()`. Both `valid()` and `invalid()` can be false
* if there are currently no errors, but validators are still pending.
*/
invalid: Signal<boolean>;
/**
* Indicates whether validation should be skipped for this field because it is hidden, disabled,
* or readonly.
*/
shouldSkipValidation: Signal<boolean>;
}
/**
* The validation state associated with a `FieldNode`.
*
* This class collects together various types of errors to represent the full validation state of
* the field. There are 4 types of errors that need to be combined to determine validity:
* 1. The synchronous errors produced by the schema logic.
* 2. The synchronous tree errors produced by the schema logic. Tree errors may apply to a different
* field than the one that the logic that produced them is bound to. They support targeting the
* error at an arbitrary descendant field.
* 3. The asynchronous tree errors produced by the schema logic. These work like synchronous tree
* errors, except the error list may also contain a special sentinel value indicating that a
* validator is still running.
* 4. Server errors are not produced by the schema logic, but instead get imperatively added when a
* form submit fails with errors.
*/
export class FieldValidationState implements ValidationState {
constructor(readonly node: FieldNode) {}
/**
* The full set of synchronous tree errors visible to this field. This includes ones that are
* targeted at a descendant field rather than at this field.
*/
readonly rawSyncTreeErrors: Signal<ValidationError[]> = computed(() => {
if (this.shouldSkipValidation()) {
return [];
}
return [
...this.node.logicNode.logic.syncTreeErrors.compute(this.node.context),
...(this.node.structure.parent?.validationState.rawSyncTreeErrors() ?? []),
];
});
/**
* The full set of synchronous errors for this field, including synchronous tree errors and server
* errors. Server errors are considered "synchronous" because they are imperatively added. From
* the perspective of the field state they are either there or not, they are never in a pending
* state.
*/
readonly syncErrors: Signal<ValidationError[]> = computed(() => {
// Short-circuit running validators if validation doesn't apply to this field.
if (this.shouldSkipValidation()) {
return [];
}
return [
...this.node.logicNode.logic.syncErrors.compute(this.node.context),
...this.syncTreeErrors(),
...normalizeErrors(this.node.submitState.serverErrors()),
];
});
/**
* Whether the field is considered valid according solely to its synchronous validators.
* Errors resulting from a previous submit attempt are also considered for this state.
*/
readonly syncValid: Signal<boolean> = computed(() => {
// Short-circuit checking children if validation doesn't apply to this field.
if (this.shouldSkipValidation()) {
return true;
}
return reduceChildren(
this.node,
this.syncErrors().length === 0,
(child, value) => value && child.validationState.syncValid(),
shortCircuitFalse,
);
});
/**
* The synchronous tree errors visible to this field that are specifically targeted at this field
* rather than a descendant.
*/
readonly syncTreeErrors: Signal<ValidationError[]> = computed(() =>
this.rawSyncTreeErrors().filter((err) => err.field === this.node.fieldProxy),
);
/**
* The full set of asynchronous tree errors visible to this field. This includes ones that are
* targeted at a descendant field rather than at this field, as well as sentinel 'pending' values
* indicating that the validator is still running and an error could still occur.
*/
readonly rawAsyncErrors: Signal<(ValidationError | 'pending')[]> = computed(() => {
// Short-circuit running validators if validation doesn't apply to this field.
if (this.shouldSkipValidation()) {
return [];
}
return [
// TODO: add field in `validateAsync` and remove this map
...this.node.logicNode.logic.asyncErrors.compute(this.node.context),
// TODO: does it make sense to filter this to errors in this subtree?
...(this.node.structure.parent?.validationState.rawAsyncErrors() ?? []),
];
});
/**
* The asynchronous tree errors visible to this field that are specifically targeted at this field
* rather than a descendant. This also includes all 'pending' sentinel values, since those could
* theoretically result in errors for this field.
*/
readonly asyncErrors: Signal<(ValidationError | 'pending')[]> = computed(() => {
if (this.shouldSkipValidation()) {
return [];
}
return this.rawAsyncErrors().filter(
(err) => err === 'pending' || err.field === this.node.fieldProxy,
);
});
/**
* The combined set of all errors that currently apply to this field.
*/
readonly errors = computed(() => [
...this.syncErrors(),
...this.asyncErrors().filter((err) => err !== 'pending'),
]);
readonly errorSummary = computed(() =>
reduceChildren(this.node, this.errors(), (child, result) => [
...result,
...child.errorSummary(),
]),
);
/**
* Whether this field has any asynchronous validators still pending.
*/
readonly pending = computed(() =>
reduceChildren(
this.node,
this.asyncErrors().includes('pending'),
(child, value) => value || child.validationState.asyncErrors().includes('pending'),
),
);
/**
* The validation status of the field.
* - The status is 'valid' if neither the field nor any of its children has any errors or pending
* validators.
* - The status is 'invalid' if the field or any of its children has an error
* (regardless of pending validators)
* - The status is 'unknown' if neither the field nor any of its children has any errors,
* but the field or any of its children does have a pending validator.
*
* A field is considered valid if *all* of the following are true:
* - It has no errors or pending validators
* - All of its children are considered valid
* A field is considered invalid if *any* of the following are true:
* - It has an error
* - Any of its children is considered invalid
* A field is considered to have unknown validity status if it is not valid or invalid.
*/
readonly status: Signal<'valid' | 'invalid' | 'unknown'> = computed(() => {
// Short-circuit checking children if validation doesn't apply to this field.
if (this.shouldSkipValidation()) {
return 'valid';
}
let ownStatus = calculateValidationSelfStatus(this);
return reduceChildren<'valid' | 'invalid' | 'unknown'>(
this.node,
ownStatus,
(child, value) => {
if (value === 'invalid' || child.validationState.status() === 'invalid') {
return 'invalid';
} else if (value === 'unknown' || child.validationState.status() === 'unknown') {
return 'unknown';
}
return 'valid';
},
(v) => v === 'invalid', // short-circuit on 'invalid'
);
});
/**
* Whether the field is considered valid.
*
* A field is considered valid if *all* of the following are true:
* - It has no errors or pending validators
* - All of its children are considered valid
*
* Note: `!valid()` is *not* the same as `invalid()`. Both `valid()` and `invalid()` can be false
* if there are currently no errors, but validators are still pending.
*/
readonly valid = computed(() => this.status() === 'valid');
/**
* Whether the field is considered invalid.
*
* A field is considered invalid if *any* of the following are true:
* - It has an error
* - Any of its children is considered invalid
*
* Note: `!invalid()` is *not* the same as `valid()`. Both `valid()` and `invalid()` can be false
* if there are currently no errors, but validators are still pending.
*/
readonly invalid = computed(() => this.status() === 'invalid');
/**
* Indicates whether validation should be skipped for this field because it is hidden, disabled,
* or readonly.
*/
readonly shouldSkipValidation = computed(
() => this.node.hidden() || this.node.disabled() || this.node.readonly(),
);
}
/** Normalizes a validation result to a list of validation errors. */
function normalizeErrors(error: ValidationResult): readonly ValidationError[] {
if (error === undefined) {
return [];
}
if (isArray(error)) {
return error;
}
return [error as ValidationError];
}
/**
* Sets the given field on the given error(s) if it does not already have a field.
* @param errors The error(s) to add the field to
* @param field The default field to add
* @returns The passed in error(s), with its field set.
*/
export function addDefaultField<E extends ValidationError>(
error: WithOptionalField<E>,
field: Field<unknown>,
): E;
export function addDefaultField<E extends ValidationError>(
errors: TreeValidationResult<E>,
field: Field<unknown>,
): ValidationResult<E>;
export function addDefaultField<E extends ValidationError>(
errors: TreeValidationResult<E>,
field: Field<unknown>,
): ValidationResult<E> {
if (isArray(errors)) {
for (const error of errors) {
(error as Mutable<ValidationError>).field ??= field;
}
} else if (errors) {
(errors as Mutable<ValidationError>).field ??= field;
}
return errors as ValidationResult<E>;
}
+356
View File
@@ -0,0 +1,356 @@
/**
* @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 {untracked} from '@angular/core';
import {AggregateProperty, Property} from '../api/property';
import {DisabledReason, type FieldContext, type FieldPath, type LogicFn} from '../api/types';
import type {ValidationError} from '../api/validation_errors';
import type {FieldNode} from '../field/node';
import {isArray} from '../util/type_guards';
/**
* Special key which is used to represent a dynamic logic property in a `FieldPathNode` path.
* This property is used to represent logic that applies to every element of some dynamic form data
* (i.e. an array).
*
* For example, a rule like `applyEach(p.myArray, () => { ... })` will add logic to the `DYNAMIC`
* property of `p.myArray`.
*/
export const DYNAMIC: unique symbol = Symbol();
/** Represents a result that should be ignored because its predicate indicates it is not active. */
const IGNORED = Symbol();
/**
* A predicate that indicates whether an `AbstractLogic` instance is currently active, or should be
* ignored.
*/
export interface Predicate {
/** A boolean logic function that returns true if the logic is considered active. */
readonly fn: LogicFn<any, boolean>;
/**
* The path which this predicate was created for. This is used to determine the correct
* `FieldContext` to pass to the predicate function.
*/
readonly path: FieldPath<any>;
}
/**
* Represents a predicate that is bound to a particular depth in the field tree. This is needed for
* recursively applied logic to ensure that the predicate is evaluated against the correct
* application of that logic.
*
* Consider the following example:
*
* ```
* const s = schema(p => {
* disabled(p.data);
* applyWhen(p.next, ({valueOf}) => valueOf(p.data) === 1, s);
* });
*
* const f = form(signal({data: 0, next: {data: 1, next: {data: 2, next: undefined}}}), s);
*
* const isDisabled = f.next.next.data().disabled();
* ```
*
* In order to determine `isDisabled` we need to evaluate the predicate from `applyWhen` *twice*.
* Once to see if the schema should be applied to `f.next` and again to see if it should be applied
* to `f.next.next`. The `depth` tells us which field we should be evaluating against each time.
*/
export interface BoundPredicate extends Predicate {
/** The depth in the field tree at which this predicate is bound. */
readonly depth: number;
}
/**
* Base class for all logic. It is responsible for combining the results from multiple individual
* logic functions registered in the schema, and using them to derive the value for some associated
* piece of field state.
*/
export abstract class AbstractLogic<TReturn, TValue = TReturn> {
/** The set of logic functions that contribute to the value of the associated state. */
protected readonly fns: Array<LogicFn<any, TValue | typeof IGNORED>> = [];
constructor(
/**
* A list of predicates that conditionally enable all logic in this logic instance.
* The logic is only enabled when *all* of the predicates evaluate to true.
*/
private predicates: ReadonlyArray<BoundPredicate>,
) {}
/**
* Computes the value of the associated field state based on the logic functions and predicates
* registered with this logic instance.
*/
abstract compute(arg: FieldContext<any>): TReturn;
/**
* The default value that the associated field state should assume if there are no logic functions
* registered by the schema (or if the logic is disabled by a predicate).
*/
abstract get defaultValue(): TReturn;
/** Registers a logic function with this logic instance. */
push(logicFn: LogicFn<any, TValue>) {
this.fns.push(wrapWithPredicates(this.predicates, logicFn));
}
/**
* Merges in the logic from another logic instance, subject to the predicates of both the other
* instance and this instance.
*/
mergeIn(other: AbstractLogic<TReturn, TValue>) {
const fns = this.predicates
? other.fns.map((fn) => wrapWithPredicates(this.predicates, fn))
: other.fns;
this.fns.push(...fns);
}
}
/** Logic that combines its individual logic function results with logical OR. */
export class BooleanOrLogic extends AbstractLogic<boolean> {
override get defaultValue() {
return false;
}
override compute(arg: FieldContext<any>): boolean {
return this.fns.some((f) => {
const result = f(arg);
return result && result !== IGNORED;
});
}
}
/**
* Logic that combines its individual logic function results by aggregating them in an array.
* Depending on its `ignore` function it may ignore certain values, omitting them from the array.
*/
export class ArrayMergeIgnoreLogic<TElement, TIgnore = never> extends AbstractLogic<
readonly TElement[],
TElement | readonly (TElement | TIgnore)[] | TIgnore | undefined | void
> {
/** Creates an instance of this class that ignores `null` values. */
static ignoreNull<TElement>(predicates: ReadonlyArray<BoundPredicate>) {
return new ArrayMergeIgnoreLogic<TElement, null>(predicates, (e: unknown) => e === null);
}
constructor(
predicates: ReadonlyArray<BoundPredicate>,
private ignore: undefined | ((e: TElement | undefined | TIgnore) => e is TIgnore),
) {
super(predicates);
}
override get defaultValue() {
return [];
}
override compute(arg: FieldContext<any>): readonly TElement[] {
return this.fns.reduce((prev, f) => {
const value = f(arg);
if (value === undefined || value === IGNORED) {
return prev;
} else if (isArray(value)) {
return [...prev, ...(this.ignore ? value.filter((e) => !this.ignore!(e)) : value)];
} else {
if (this.ignore && this.ignore(value as TElement | TIgnore | undefined)) {
return prev;
}
return [...prev, value];
}
}, [] as TElement[]);
}
}
/** Logic that combines its individual logic function results by aggregating them in an array. */
export class ArrayMergeLogic<TElement> extends ArrayMergeIgnoreLogic<TElement, never> {
constructor(predicates: ReadonlyArray<BoundPredicate>) {
super(predicates, undefined);
}
}
/** Logic that combines an AggregateProperty according to the property's own reduce function. */
export class AggregatePropertyMergeLogic<TAcc, TItem> extends AbstractLogic<TAcc, TItem> {
override get defaultValue() {
return this.key.getInitial();
}
constructor(
predicates: ReadonlyArray<BoundPredicate>,
private key: AggregateProperty<TAcc, TItem>,
) {
super(predicates);
}
override compute(ctx: FieldContext<any>): TAcc {
if (this.fns.length === 0) {
return this.key.getInitial();
}
let acc: TAcc = this.key.getInitial();
for (let i = 0; i < this.fns.length; i++) {
const item = this.fns[i](ctx);
if (item !== IGNORED) {
acc = this.key.reduce(acc, item);
}
}
return acc;
}
}
/**
* Wraps a logic function such that it returns the special `IGNORED` sentinel value if any of the
* given predicates evaluates to false.
*
* @param predicates A list of bound predicates to apply to the logic function
* @param logicFn The logic function to wrap
* @returns A wrapped version of the logic function that may return `IGNORED`.
*/
function wrapWithPredicates<TValue, TReturn>(
predicates: ReadonlyArray<BoundPredicate>,
logicFn: LogicFn<TValue, TReturn>,
): LogicFn<TValue, TReturn | typeof IGNORED> {
if (predicates.length === 0) {
return logicFn;
}
return (arg: FieldContext<any>): TReturn | typeof IGNORED => {
for (const predicate of predicates) {
let predicateField = arg.stateOf(predicate.path) as FieldNode;
// Check the depth of the current field vs the depth this predicate is supposed to be
// evaluated at. If necessary, walk up the field tree to grab the correct context field.
// We can check the pathKeys as an untracked read since we know the structure of our fields
// doesn't change.
const depthDiff = untracked(predicateField.structure.pathKeys).length - predicate.depth;
for (let i = 0; i < depthDiff; i++) {
predicateField = predicateField.structure.parent!;
}
// If any of the predicates don't match, don't actually run the logic function, just return
// the default value.
if (!predicate.fn(predicateField.context)) {
return IGNORED;
}
}
return logicFn(arg);
};
}
/**
* Container for all the different types of logic that can be applied to a field
* (disabled, hidden, errors, etc.)
*/
export class LogicContainer {
/** Logic that determines if the field is hidden. */
readonly hidden: BooleanOrLogic;
/** Logic that determines reasons for the field being disabled. */
readonly disabledReasons: ArrayMergeLogic<DisabledReason>;
/** Logic that determines if the field is read-only. */
readonly readonly: BooleanOrLogic;
/** Logic that produces synchronous validation errors for the field. */
readonly syncErrors: ArrayMergeIgnoreLogic<ValidationError, null>;
/** Logic that produces synchronous validation errors for the field's subtree. */
readonly syncTreeErrors: ArrayMergeIgnoreLogic<ValidationError, null>;
/** Logic that produces asynchronous validation results (errors or 'pending'). */
readonly asyncErrors: ArrayMergeIgnoreLogic<ValidationError | 'pending', null>;
/** A map of aggregate properties to the `AbstractLogic` instances that compute their values. */
private readonly aggregateProperties = new Map<
AggregateProperty<unknown, unknown>,
AbstractLogic<unknown>
>();
/** A map of property keys to the factory functions that create their values. */
private readonly propertyFactories = new Map<
Property<unknown>,
(ctx: FieldContext<unknown>) => unknown
>();
/**
* Constructs a new `Logic` container.
* @param predicates An array of predicates that must all be true for the logic
* functions within this container to be active.
*/
constructor(private predicates: ReadonlyArray<BoundPredicate>) {
this.hidden = new BooleanOrLogic(predicates);
this.disabledReasons = new ArrayMergeLogic(predicates);
this.readonly = new BooleanOrLogic(predicates);
this.syncErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError>(predicates);
this.syncTreeErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError>(predicates);
this.asyncErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError | 'pending'>(predicates);
}
/** Checks whether there is logic for the given aggregate property. */
hasAggregateProperty(prop: AggregateProperty<unknown, unknown>) {
return this.aggregateProperties.has(prop);
}
/**
* Gets an iterable of [aggregate property, logic function] pairs.
* @returns An iterable of aggregate property entries.
*/
getAggregatePropertyEntries() {
return this.aggregateProperties.entries();
}
/**
* Gets an iterable of [property, value factory function] pairs.
* @returns An iterable of property factory entries.
*/
getPropertyFactoryEntries() {
return this.propertyFactories.entries();
}
/**
* Retrieves or creates the `AbstractLogic` for a given aggregate property.
* @param prop The `AggregateProperty` for which to get the logic.
* @returns The `AbstractLogic` associated with the key.
*/
getAggregateProperty<T>(prop: AggregateProperty<unknown, T>): AbstractLogic<T> {
if (!this.aggregateProperties.has(prop as AggregateProperty<unknown, unknown>)) {
this.aggregateProperties.set(
prop as AggregateProperty<unknown, unknown>,
new AggregatePropertyMergeLogic(this.predicates, prop),
);
}
return this.aggregateProperties.get(
prop as AggregateProperty<unknown, unknown>,
)! as AbstractLogic<T>;
}
/**
* Adds a factory function for a given property.
* @param prop The `Property` to associate the factory with.
* @param factory The factory function.
* @throws If a factory is already defined for the given key.
*/
addPropertyFactory(prop: Property<unknown>, factory: (ctx: FieldContext<unknown>) => unknown) {
if (this.propertyFactories.has(prop)) {
// TODO: name of the property?
throw new Error(`Can't define value twice for the same Property`);
}
this.propertyFactories.set(prop, factory);
}
/**
* Merges logic from another `Logic` instance into this one.
* @param other The `Logic` instance to merge from.
*/
mergeIn(other: LogicContainer) {
this.hidden.mergeIn(other.hidden);
this.disabledReasons.mergeIn(other.disabledReasons);
this.readonly.mergeIn(other.readonly);
this.syncErrors.mergeIn(other.syncErrors);
this.syncTreeErrors.mergeIn(other.syncTreeErrors);
this.asyncErrors.mergeIn(other.asyncErrors);
for (const [prop, propertyLogic] of other.getAggregatePropertyEntries()) {
this.getAggregateProperty(prop).mergeIn(propertyLogic);
}
for (const [prop, propertyFactory] of other.getPropertyFactoryEntries()) {
this.addPropertyFactory(prop, propertyFactory);
}
}
}
@@ -0,0 +1,470 @@
/**
* @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 {AggregateProperty, Property} from '../api/property';
import type {
AsyncValidationResult,
DisabledReason,
FieldContext,
LogicFn,
ValidationResult,
} from '../api/types';
import {setBoundPathDepthForResolution} from '../field/resolution';
import {BoundPredicate, LogicContainer, Predicate} from './logic';
/**
* Abstract base class for building a `LogicNode`.
* This class defines the interface for adding various logic rules (e.g., hidden, disabled)
* and data factories to a node in the logic tree.
* LogicNodeBuilders are 1:1 with nodes in the Schema tree.
*/
export abstract class AbstractLogicNodeBuilder {
constructor(
/** The depth of this node in the schema tree. */
protected readonly depth: number,
) {}
/** Adds a rule to determine if a field should be hidden. */
abstract addHiddenRule(logic: LogicFn<any, boolean>): void;
/** Adds a rule to determine if a field should be disabled, and for what reason. */
abstract addDisabledReasonRule(logic: LogicFn<any, DisabledReason | undefined>): void;
/** Adds a rule to determine if a field should be read-only. */
abstract addReadonlyRule(logic: LogicFn<any, boolean>): void;
/** Adds a rule for synchronous validation errors for a field. */
abstract addSyncErrorRule(logic: LogicFn<any, ValidationResult>): void;
/** Adds a rule for synchronous validation errors that apply to a subtree. */
abstract addSyncTreeErrorRule(logic: LogicFn<any, ValidationResult>): void;
/** Adds a rule for asynchronous validation errors for a field. */
abstract addAsyncErrorRule(logic: LogicFn<any, AsyncValidationResult>): void;
/** Adds a rule to compute an aggregate property for a field. */
abstract addAggregatePropertyRule<M>(
key: AggregateProperty<unknown, M>,
logic: LogicFn<any, M>,
): void;
/** Adds a factory function to produce a data value associated with a field. */
abstract addPropertyFactory<D>(key: Property<D>, factory: (ctx: FieldContext<any>) => D): void;
/**
* Gets a builder for a child node associated with the given property key.
* @param key The property key of the child.
* @returns A `LogicNodeBuilder` for the child.
*/
abstract getChild(key: PropertyKey): LogicNodeBuilder;
/**
* Checks whether a particular `AbstractLogicNodeBuilder` has been merged into this one.
* @param builder The builder to check for.
* @returns True if the builder has been merged, false otherwise.
*/
abstract hasLogic(builder: AbstractLogicNodeBuilder): boolean;
/**
* Builds the `LogicNode` from the accumulated rules and child builders.
* @returns The constructed `LogicNode`.
*/
build(): LogicNode {
return new LeafLogicNode(this, [], 0);
}
}
/**
* A builder for `LogicNode`. Used to add logic to the final `LogicNode` tree.
* This builder supports merging multiple sources of logic, potentially with predicates,
* preserving the order of rule application.
*/
export class LogicNodeBuilder extends AbstractLogicNodeBuilder {
constructor(depth: number) {
super(depth);
}
/**
* The current `NonMergeableLogicNodeBuilder` being used to add rules directly to this
* `LogicNodeBuilder`. Do not use this directly, call `getCurrent()` which will create a current
* builder if there is none.
*/
private current: NonMergeableLogicNodeBuilder | undefined;
/**
* Stores all builders that contribute to this node, along with any predicates
* that gate their application.
*/
readonly all: {builder: AbstractLogicNodeBuilder; predicate?: Predicate}[] = [];
override addHiddenRule(logic: LogicFn<any, boolean>): void {
this.getCurrent().addHiddenRule(logic);
}
override addDisabledReasonRule(logic: LogicFn<any, DisabledReason | undefined>): void {
this.getCurrent().addDisabledReasonRule(logic);
}
override addReadonlyRule(logic: LogicFn<any, boolean>): void {
this.getCurrent().addReadonlyRule(logic);
}
override addSyncErrorRule(logic: LogicFn<any, ValidationResult>): void {
this.getCurrent().addSyncErrorRule(logic);
}
override addSyncTreeErrorRule(logic: LogicFn<any, ValidationResult>): void {
this.getCurrent().addSyncTreeErrorRule(logic);
}
override addAsyncErrorRule(logic: LogicFn<any, AsyncValidationResult>): void {
this.getCurrent().addAsyncErrorRule(logic);
}
override addAggregatePropertyRule<T>(
key: AggregateProperty<unknown, T>,
logic: LogicFn<any, T>,
): void {
this.getCurrent().addAggregatePropertyRule(key, logic);
}
override addPropertyFactory<D>(key: Property<D>, factory: (ctx: FieldContext<any>) => D): void {
this.getCurrent().addPropertyFactory(key, factory);
}
override getChild(key: PropertyKey): LogicNodeBuilder {
return this.getCurrent().getChild(key);
}
override hasLogic(builder: AbstractLogicNodeBuilder): boolean {
if (this === builder) {
return true;
}
return this.all.some(({builder: subBuilder}) => subBuilder.hasLogic(builder));
}
/**
* Merges logic from another `LogicNodeBuilder` into this one.
* If a `predicate` is provided, all logic from the `other` builder will only apply
* when the predicate evaluates to true.
* @param other The `LogicNodeBuilder` to merge in.
* @param predicate An optional predicate to gate the merged logic.
*/
mergeIn(other: LogicNodeBuilder, predicate?: Predicate): void {
// Add the other builder to our collection, we'll defer the actual merging of the logic until
// the logic node is requested to be created. In order to preserve the original ordering of the
// rules, we close off the current builder to any further edits. If additional logic is added,
// a new current builder will be created to capture it.
if (predicate) {
this.all.push({
builder: other,
predicate: {
fn: setBoundPathDepthForResolution(predicate.fn, this.depth),
path: predicate.path,
},
});
} else {
this.all.push({builder: other});
}
this.current = undefined;
}
/**
* Gets the current `NonMergeableLogicNodeBuilder` for adding rules directly to this
* `LogicNodeBuilder`. If no current builder exists, a new one is created.
* The current builder is cleared whenever `mergeIn` is called to preserve the order
* of rules when merging separate builder trees.
* @returns The current `NonMergeableLogicNodeBuilder`.
*/
private getCurrent(): NonMergeableLogicNodeBuilder {
if (this.current === undefined) {
this.current = new NonMergeableLogicNodeBuilder(this.depth);
this.all.push({builder: this.current});
}
return this.current;
}
/**
* Creates a new root `LogicNodeBuilder`.
* @returns A new instance of `LogicNodeBuilder`.
*/
static newRoot(): LogicNodeBuilder {
return new LogicNodeBuilder(0);
}
}
/**
* A type of `AbstractLogicNodeBuilder` used internally by the `LogicNodeBuilder` to record "pure"
* chunks of logic that do not require merging in other builders.
*/
class NonMergeableLogicNodeBuilder extends AbstractLogicNodeBuilder {
/** The collection of logic rules directly added to this builder. */
readonly logic = new LogicContainer([]);
/**
* A map of child property keys to their corresponding `LogicNodeBuilder` instances.
* This allows for building a tree of logic.
*/
readonly children = new Map<PropertyKey, LogicNodeBuilder>();
constructor(depth: number) {
super(depth);
}
override addHiddenRule(logic: LogicFn<any, boolean>): void {
this.logic.hidden.push(setBoundPathDepthForResolution(logic, this.depth));
}
override addDisabledReasonRule(logic: LogicFn<any, DisabledReason | undefined>): void {
this.logic.disabledReasons.push(setBoundPathDepthForResolution(logic, this.depth));
}
override addReadonlyRule(logic: LogicFn<any, boolean>): void {
this.logic.readonly.push(setBoundPathDepthForResolution(logic, this.depth));
}
override addSyncErrorRule(logic: LogicFn<any, ValidationResult>): void {
this.logic.syncErrors.push(setBoundPathDepthForResolution(logic, this.depth));
}
override addSyncTreeErrorRule(logic: LogicFn<any, ValidationResult>): void {
this.logic.syncTreeErrors.push(setBoundPathDepthForResolution(logic, this.depth));
}
override addAsyncErrorRule(logic: LogicFn<any, AsyncValidationResult>): void {
this.logic.asyncErrors.push(setBoundPathDepthForResolution(logic, this.depth));
}
override addAggregatePropertyRule<T>(
key: AggregateProperty<unknown, T>,
logic: LogicFn<any, T>,
): void {
this.logic.getAggregateProperty(key).push(setBoundPathDepthForResolution(logic, this.depth));
}
override addPropertyFactory<D>(key: Property<D>, factory: (ctx: FieldContext<any>) => D): void {
this.logic.addPropertyFactory(key, setBoundPathDepthForResolution(factory, this.depth));
}
override getChild(key: PropertyKey): LogicNodeBuilder {
if (!this.children.has(key)) {
this.children.set(key, new LogicNodeBuilder(this.depth + 1));
}
return this.children.get(key)!;
}
override hasLogic(builder: AbstractLogicNodeBuilder): boolean {
return this === builder;
}
}
/**
* Represents a node in the logic tree, containing all logic applicable
* to a specific field or path in the form structure.
* LogicNodes are 1:1 with nodes in the Field tree.
*/
export interface LogicNode {
/** The collection of logic rules (hidden, disabled, errors, etc.) for this node. */
readonly logic: LogicContainer;
/**
* Retrieves the `LogicNode` for a child identified by the given property key.
* @param key The property key of the child.
* @returns The `LogicNode` for the specified child.
*/
getChild(key: PropertyKey): LogicNode;
/**
* Checks whether the logic from a particular `AbstractLogicNodeBuilder` has been merged into this
* node.
* @param builder The builder to check for.
* @returns True if the builder has been merged, false otherwise.
*/
hasLogic(builder: AbstractLogicNodeBuilder): boolean;
}
/**
* A tree structure of `Logic` corresponding to a tree of fields.
* This implementation represents a leaf in the sense that its logic is derived
* from a single builder.
*/
class LeafLogicNode implements LogicNode {
/** The computed logic for this node. */
readonly logic: LogicContainer;
/**
* Constructs a `LeafLogicNode`.
* @param builder The `AbstractLogicNodeBuilder` from which to derive the logic.
* If undefined, an empty `Logic` instance is created.
* @param predicates An array of predicates that gate the logic from the builder.
*/
constructor(
private builder: AbstractLogicNodeBuilder | undefined,
private predicates: BoundPredicate[],
/** The depth of this node in the field tree. */
private depth: number,
) {
this.logic = builder ? createLogic(builder, predicates, depth) : new LogicContainer([]);
}
// TODO: cache here, or just rely on the user of this API to do caching?
/**
* Retrieves the `LogicNode` for a child identified by the given property key.
* @param key The property key of the child.
* @returns The `LogicNode` for the specified child.
*/
getChild(key: PropertyKey): LogicNode {
// The logic for a particular child may be spread across multiple builders. We lazily combine
// this logic at the time the child logic node is requested to be created.
const childBuilders = this.builder ? getAllChildBuilders(this.builder, key) : [];
if (childBuilders.length === 0) {
return new LeafLogicNode(undefined, [], this.depth + 1);
} else if (childBuilders.length === 1) {
const {builder, predicates} = childBuilders[0];
return new LeafLogicNode(
builder,
[...this.predicates, ...predicates.map((p) => bindLevel(p, this.depth))],
this.depth + 1,
);
} else {
const builtNodes = childBuilders.map(
({builder, predicates}) =>
new LeafLogicNode(
builder,
[...this.predicates, ...predicates.map((p) => bindLevel(p, this.depth))],
this.depth + 1,
),
);
return new CompositeLogicNode(builtNodes);
}
}
/**
* Checks whether the logic from a particular `AbstractLogicNodeBuilder` has been merged into this
* node.
* @param builder The builder to check for.
* @returns True if the builder has been merged, false otherwise.
*/
hasLogic(builder: AbstractLogicNodeBuilder): boolean {
return this.builder?.hasLogic(builder) ?? false;
}
}
/**
* A `LogicNode` that represents the composition of multiple `LogicNode` instances.
* This is used when logic for a particular path is contributed by several distinct
* builder branches that need to be merged.
*/
class CompositeLogicNode implements LogicNode {
/** The merged logic from all composed nodes. */
readonly logic: LogicContainer;
/**
* Constructs a `CompositeLogicNode`.
* @param all An array of `LogicNode` instances to compose.
*/
constructor(private all: LogicNode[]) {
this.logic = new LogicContainer([]);
for (const node of all) {
this.logic.mergeIn(node.logic);
}
}
/**
* Retrieves the child `LogicNode` by composing the results of `getChild` from all
* underlying `LogicNode` instances.
* @param key The property key of the child.
* @returns A `CompositeLogicNode` representing the composed child.
*/
getChild(key: PropertyKey): LogicNode {
return new CompositeLogicNode(this.all.flatMap((child) => child.getChild(key)));
}
/**
* Checks whether the logic from a particular `AbstractLogicNodeBuilder` has been merged into this
* node.
* @param builder The builder to check for.
* @returns True if the builder has been merged, false otherwise.
*/
hasLogic(builder: AbstractLogicNodeBuilder): boolean {
return this.all.some((node) => node.hasLogic(builder));
}
}
/**
* Gets all of the builders that contribute logic to the given child of the parent builder.
* This function recursively traverses the builder hierarchy.
* @param builder The parent `AbstractLogicNodeBuilder`.
* @param key The property key of the child.
* @returns An array of objects, each containing a `LogicNodeBuilder` for the child and any associated predicates.
*/
function getAllChildBuilders(
builder: AbstractLogicNodeBuilder,
key: PropertyKey,
): {builder: LogicNodeBuilder; predicates: Predicate[]}[] {
if (builder instanceof LogicNodeBuilder) {
return builder.all.flatMap(({builder, predicate}) => {
const children = getAllChildBuilders(builder, key);
if (predicate) {
return children.map(({builder, predicates}) => ({
builder,
predicates: [...predicates, predicate],
}));
}
return children;
});
} else if (builder instanceof NonMergeableLogicNodeBuilder) {
if (builder.children.has(key)) {
return [{builder: builder.children.get(key)!, predicates: []}];
}
} else {
throw new Error('Unknown LogicNodeBuilder type');
}
return [];
}
/**
* Creates the full `Logic` for a given builder.
* This function handles different types of builders (`LogicNodeBuilder`, `NonMergeableLogicNodeBuilder`)
* and applies the provided predicates.
* @param builder The `AbstractLogicNodeBuilder` to process.
* @param predicates Predicates to apply to the logic derived from the builder.
* @param depth The depth in the field tree of the field which this logic applies to.
* @returns The `Logic` instance.
*/
function createLogic(
builder: AbstractLogicNodeBuilder,
predicates: BoundPredicate[],
depth: number,
): LogicContainer {
const logic = new LogicContainer(predicates);
if (builder instanceof LogicNodeBuilder) {
const builtNodes = builder.all.map(
({builder, predicate}) =>
new LeafLogicNode(
builder,
predicate ? [...predicates, bindLevel(predicate, depth)] : predicates,
depth,
),
);
for (const node of builtNodes) {
logic.mergeIn(node.logic);
}
} else if (builder instanceof NonMergeableLogicNodeBuilder) {
logic.mergeIn(builder.logic);
} else {
throw new Error('Unknown LogicNodeBuilder type');
}
return logic;
}
/**
* Create a bound version of the given predicate to a specific depth in the field tree.
* This allows us to unambiguously know which `FieldContext` the predicate function should receive.
*
* This is of particular concern when a schema is applied recursively to itself. Since the schema is
* only compiled once, each nested application adds the same predicate instance. We differentiate
* these by recording the depth of the field they're bound to.
*
* @param predicate The unbound predicate
* @param depth The depth of the field the predicate is bound to
* @returns A bound predicate
*/
function bindLevel(predicate: Predicate, depth: number): BoundPredicate {
return {...predicate, depth: depth};
}
@@ -0,0 +1,101 @@
/**
* @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 {FieldPath} from '../api/types';
import {DYNAMIC, Predicate} from './logic';
import {LogicNodeBuilder} from './logic_node';
import type {SchemaImpl} from './schema';
/**
* Special key which is used to retrieve the `FieldPathNode` instance from its `FieldPath` proxy wrapper.
*/
const PATH = Symbol('PATH');
/**
* A path in the schema on which logic is stored so that it can be added to the corresponding field
* when the field is created.
*/
export class FieldPathNode {
/** The root path node from which this path node is descended. */
readonly root: FieldPathNode;
/**
* A map containing all child path nodes that have been created on this path.
* Child path nodes are created automatically on first access if they do not exist already.
*/
private readonly children = new Map<PropertyKey, FieldPathNode>();
/**
* A proxy that wraps the path node, allowing navigation to its child paths via property access.
*/
readonly fieldPathProxy: FieldPath<any> = new Proxy(
this,
FIELD_PATH_PROXY_HANDLER,
) as unknown as FieldPath<any>;
protected constructor(
/** The property keys used to navigate from the root path to this path. */
readonly keys: PropertyKey[],
/** The logic builder used to accumulate logic on this path node. */
readonly logic: LogicNodeBuilder,
root: FieldPathNode,
) {
this.root = root ?? this;
}
/**
* Gets the special path node containing the per-element logic that applies to *all* children paths.
*/
get element(): FieldPathNode {
return this.getChild(DYNAMIC);
}
/**
* Gets the path node for the given child property key.
* Child paths are created automatically on first access if they do not exist already.
*/
getChild(key: PropertyKey): FieldPathNode {
if (!this.children.has(key)) {
this.children.set(
key,
new FieldPathNode([...this.keys, key], this.logic.getChild(key), this.root),
);
}
return this.children.get(key)!;
}
/**
* Merges in logic from another schema to this one.
* @param other The other schema to merge in the logic from
* @param predicate A predicate indicating when the merged in logic should be active.
*/
mergeIn(other: SchemaImpl, predicate?: Predicate) {
const path = other.compile();
this.logic.mergeIn(path.logic, predicate);
}
/** Extracts the underlying path node from the given path proxy. */
static unwrapFieldPath(formPath: FieldPath<unknown>): FieldPathNode {
return (formPath as any)[PATH] as FieldPathNode;
}
/** Creates a new root path node to be passed in to a schema function. */
static newRoot() {
return new FieldPathNode([], LogicNodeBuilder.newRoot(), undefined!);
}
}
/** Proxy handler which implements `FieldPath` on top of a `FieldPathNode`. */
export const FIELD_PATH_PROXY_HANDLER: ProxyHandler<FieldPathNode> = {
get(node: FieldPathNode, property: string | symbol) {
if (property === PATH) {
return node;
}
return node.getChild(property).fieldPathProxy;
},
};
+113
View File
@@ -0,0 +1,113 @@
/**
* @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 {FieldPath, SchemaFn, SchemaOrSchemaFn} from '../api/types';
import {FieldPathNode} from './path_node';
/**
* Keeps track of the path node for the schema function that is currently being compiled. This is
* used to detect erroneous references to a path node outside of the context of its schema function.
* Do not set this directly, it is a context variable managed by `SchemaImpl.compile`.
*/
let currentCompilingNode: FieldPathNode | undefined = undefined;
/**
* A cache of all schemas compiled under the current root compilation. This is used to avoid doing
* extra work when compiling a schema that reuses references to the same sub-schema. For example:
*
* ```
* const sub = schema(p => ...);
* const s = schema(p => {
* apply(p.a, sub);
* apply(p.b, sub);
* });
* ```
*
* This also ensures that we don't go into an infinite loop when compiling a schema that references
* itself.
*
* Do not directly add or remove entries from this map, it is a context variable managed by
* `SchemaImpl.compile` and `SchemaImpl.rootCompile`.
*/
const compiledSchemas = new Map<SchemaImpl, FieldPathNode>();
/**
* Implements the `Schema` concept.
*/
export class SchemaImpl {
constructor(private schemaFn: SchemaFn<unknown>) {}
/**
* Compiles this schema within the current root compilation context. If the schema was previously
* compiled within this context, we reuse the cached FieldPathNode, otherwise we create a new one
* and cache it in the compilation context.
*/
compile(): FieldPathNode {
if (compiledSchemas.has(this)) {
return compiledSchemas.get(this)!;
}
const path = FieldPathNode.newRoot();
compiledSchemas.set(this, path);
let prevCompilingNode = currentCompilingNode;
try {
currentCompilingNode = path;
this.schemaFn(path.fieldPathProxy);
} finally {
// Use a try/finally to ensure we restore the previous root upon completion,
// even if there are errors while compiling the schema.
currentCompilingNode = prevCompilingNode;
}
return path;
}
/**
* Creates a SchemaImpl from the given SchemaOrSchemaFn.
*/
static create(schema: SchemaImpl | SchemaOrSchemaFn<any>) {
if (schema instanceof SchemaImpl) {
return schema;
}
return new SchemaImpl(schema as SchemaFn<unknown>);
}
/**
* Compiles the given schema in a fresh compilation context. This clears the cached results of any
* previous compilations.
*/
static rootCompile(schema: SchemaImpl | SchemaOrSchemaFn<any> | undefined): FieldPathNode {
try {
compiledSchemas.clear();
if (schema === undefined) {
return FieldPathNode.newRoot();
}
if (schema instanceof SchemaImpl) {
return schema.compile();
}
return new SchemaImpl(schema as SchemaFn<unknown>).compile();
} finally {
// Use a try/finally to ensure we properly reset the compilation context upon completion,
// even if there are errors while compiling the schema.
compiledSchemas.clear();
}
}
}
/** Checks if the given value is a schema or schema function. */
export function isSchemaOrSchemaFn(value: unknown): value is SchemaOrSchemaFn<unknown> {
return value instanceof SchemaImpl || typeof value === 'function';
}
/** Checks that a path node belongs to the schema function currently being compiled. */
export function assertPathIsCurrent(path: FieldPath<unknown>): void {
if (currentCompilingNode !== FieldPathNode.unwrapFieldPath(path).root) {
throw new Error(
`A FieldPath can only be used directly within the Schema that owns it,` +
` **not** outside of it or within a sub-schema.`,
);
}
}
@@ -0,0 +1,56 @@
/**
* @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 {computed, Signal, untracked, WritableSignal} from '@angular/core';
import {SIGNAL} from '@angular/core/primitives/signals';
import {isArray} from './type_guards';
/**
* Creates a writable signal for a specific property on a source writeable signal.
* @param source A writeable signal to derive from
* @param prop A signal of a property key of the source value
* @returns A writeable signal for the given property of the source value.
* @template S The source value type
* @template K The key type for S
*/
export function deepSignal<S, K extends keyof S>(
source: WritableSignal<S>,
prop: Signal<K>,
): WritableSignal<S[K]> {
// Memoize the property.
const read = computed(() => source()[prop()]) as WritableSignal<S[K]>;
read[SIGNAL] = source[SIGNAL];
read.set = (value: S[K]) => {
source.update((current) => valueForWrite(current, value, prop()) as S);
};
read.update = (fn: (current: S[K]) => S[K]) => {
read.set(fn(untracked(read)));
};
read.asReadonly = () => read;
return read;
}
/**
* Gets an updated root value to use when setting a value on a deepSignal with the given path.
* @param sourceValue The current value of the deepSignal's source.
* @param newPropValue The value being written to the deepSignal's property
* @param prop The deepSignal's property key
* @returns An updated value for the deepSignal's source
*/
function valueForWrite(sourceValue: unknown, newPropValue: unknown, prop: PropertyKey): unknown {
if (isArray(sourceValue)) {
const newValue = [...sourceValue];
newValue[prop as number] = newPropValue;
return newValue;
} else {
return {...(sourceValue as object), [prop]: newPropValue};
}
}
@@ -0,0 +1,75 @@
/**
* @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 {EffectRef, Injector, InputSignal, ModelSignal, ɵSIGNAL as SIGNAL} from '@angular/core';
import {isObject} from './type_guards';
// TODO: These utilities to be replaced with proper integration into framework.
export function privateGetComponentInstance(injector: Injector): unknown {
assertIsNodeInjector(injector);
if (injector._tNode.directiveStart === 0 || injector._tNode.componentOffset === -1) {
return undefined;
}
return injector._lView[injector._tNode.directiveStart + injector._tNode.componentOffset];
}
export function privateSetComponentInput<T>(inputSignal: InputSignal<T>, value: T): void {
inputSignal[SIGNAL].applyValueToInputSignal(inputSignal[SIGNAL], value);
}
export function privateIsSignalInput(value: unknown): value is InputSignal<unknown> {
return isInputSignal(value);
}
export function privateIsModelInput<T>(value: unknown): value is ModelSignal<T> {
return isInputSignal(value) && isObject(value) && 'subscribe' in value;
}
export function privateRunEffect(ref: EffectRef): void {
(ref as EffectRefImpl)[SIGNAL].run();
}
function assertIsNodeInjector(injector: Injector): asserts injector is NgNodeInjector {
if (!('_tNode' in injector)) {
throw new Error('Expected a Node Injector');
}
}
function isInputSignal(value: unknown): value is NgInputSignal {
if (!isObject(value) || !(SIGNAL in value)) {
return false;
}
const node = value[SIGNAL];
return isObject(node) && 'applyValueToInputSignal' in node;
}
interface NgNodeInjector extends Injector {
_tNode: TNode;
_lView: Array<unknown>;
}
interface TNode {
directiveStart: number;
componentOffset: number;
}
interface NgInputSignal {
[SIGNAL]: NgInputSignalNode;
}
interface NgInputSignalNode {
applyValueToInputSignal(node: NgInputSignalNode, value: unknown): void;
}
interface EffectRefImpl extends EffectRef {
readonly [SIGNAL]: {
run(): void;
};
}
@@ -0,0 +1,21 @@
/**
* @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
*/
/**
* A version of `Array.isArray` that handles narrowing of readonly arrays properly.
*/
export function isArray(value: unknown): value is any[] | readonly any[] {
return Array.isArray(value);
}
/**
* Checks if a value is an object.
*/
export function isObject(value: unknown): value is Record<PropertyKey, unknown> {
return (typeof value === 'object' || typeof value === 'function') && value != null;
}
@@ -0,0 +1,26 @@
load("//tools:defaults.bzl", "ts_project", "zoneless_jasmine_test")
ts_project(
name = "test_lib",
testonly = True,
srcs = glob(["**/*.spec.ts"]),
deps = [
"//:node_modules/zod",
"//packages/common/http",
"//packages/common/http/testing",
"//packages/core",
"//packages/core/testing",
"//packages/forms",
"//packages/forms/signals",
"//packages/platform-browser",
"//packages/platform-browser/testing",
"//packages/private/testing",
],
)
zoneless_jasmine_test(
name = "test",
data = [
":test_lib",
],
)
@@ -0,0 +1,118 @@
/**
* @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 {Injector, signal} from '@angular/core';
import {form, hidden, validate, customError} from '@angular/forms/signals';
import {TestBed} from '@angular/core/testing';
describe('hidden', () => {
it('should initially be false', () => {
const cat = signal({name: 'Pirojok-the-cat', age: 5});
const f = form(
cat,
(p) => {
hidden(p, ({value}) => {
return value.name === 'hidden-cat';
});
},
{injector: TestBed.inject(Injector)},
);
expect(f().hidden()).toBe(false);
expect(f.name().hidden()).toBe(false);
});
it('returns true when condition is met', () => {
const cat = signal({name: 'Pirojok-the-cat', age: 5});
const f = form(
cat,
(p) => {
hidden(p.name, ({value}) => {
return value() === 'hidden-cat';
});
},
{injector: TestBed.inject(Injector)},
);
f.name().value.set('hidden-cat');
expect(f.name().hidden()).toBe(true);
});
it('propagates the value down', () => {
const cat = signal({name: 'Pirojok-the-cat', age: 5});
const f = form(
cat,
(p) => {
hidden(p, ({value}) => {
return value().name === 'hidden-cat';
});
},
{injector: TestBed.inject(Injector)},
);
f.name().value.set('hidden-cat');
expect(f.name().hidden()).toBe(true);
expect(f().hidden()).toBe(true);
});
it('disables validation for the field', () => {
const cat = signal({name: 'Pirojok-the-cat', age: 5});
const f = form(
cat,
(p) => {
hidden(p.name, ({value}) => {
return value() === 'hidden-cat';
});
validate(p.name, () => {
return customError({kind: 'dog'});
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().valid()).withContext('Name is intially invalid').toBeFalse();
expect(f().valid()).withContext('Form is intially invalid').toBeFalse();
f.name().value.set('hidden-cat');
expect(f.name().hidden()).toBeTrue();
expect(f.name().valid()).toBeTrue();
expect(f().valid()).toBeTrue();
f.name().value.set('visible-cat');
expect(f.name().valid()).toBeFalse();
expect(f().valid()).toBeFalse();
});
xit('disables touch state propagation?', () => {
const cat = signal({name: 'Pirojok-the-cat', age: 5});
const f = form(
cat,
(p) => {
hidden(p.name, ({value}) => {
return value() === 'hidden-cat';
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().touched()).withContext('Name is intially untouched').toBeFalse();
expect(f().touched()).withContext('Form is intially intouched').toBeFalse();
f.name().markAsTouched();
expect(f.name().touched()).toBeTrue();
expect(f().touched()).toBeTrue();
f.name().value.set('hidden-cat');
expect(f.name().touched()).withContext('hidden name is not touched').toBeFalse();
expect(f().touched())
.withContext('form with a hidden touched field is not touched')
.toBeFalse();
});
});
@@ -0,0 +1,75 @@
/**
* @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 {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {email, form} from '../../../../public_api';
import {customError, emailError} from '../../../../src/api/validation_errors';
describe('email validator', () => {
it('returns requiredTrue error when the value is false', () => {
const cat = signal({name: 'pirojok-the-cat', email: 'cat@cat.meow'});
const f = form(
cat,
(p) => {
email(p.email);
},
{
injector: TestBed.inject(Injector),
},
);
expect(f.email().errors()).toEqual([]);
f.email().value.set('not-real-email');
expect(f.email().errors()).toEqual([emailError({field: f.email})]);
});
it('supports custom errors', () => {
const cat = signal({name: 'pirojok-the-cat', email: ''});
const f = form(
cat,
(p) => {
email(p.email, {
error: (ctx) => customError({kind: `special-email-${ctx.valueOf(p.name)}`}),
});
},
{
injector: TestBed.inject(Injector),
},
);
expect(f.email().errors()).toEqual([
customError({
kind: 'special-email-pirojok-the-cat',
field: f.email,
}),
]);
});
it('supports custom error message', () => {
const cat = signal({name: 'pirojok-the-cat', email: ''});
const f = form(
cat,
(p) => {
email(p.email, {
message: 'email error',
});
},
{
injector: TestBed.inject(Injector),
},
);
expect(f.email().errors()).toEqual([
emailError({
message: 'email error',
field: f.email,
}),
]);
});
});
@@ -0,0 +1,262 @@
/**
* @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 {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {MAX, form, max} from '../../../../public_api';
import {customError, maxError} from '../../../../src/api/validation_errors';
describe('max validator', () => {
it('returns max error when the value is larger', () => {
const cat = signal({name: 'pirojok-the-cat', age: 6});
const f = form(
cat,
(p) => {
max(p.age, 5);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
});
it('is inclusive', () => {
const cat = signal({name: 'pirojok-the-cat', age: 5});
const f = form(
cat,
(p) => {
max(p.age, 5);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([]);
});
it('returns no errors when the value is smaller', () => {
const cat = signal({name: 'pirojok-the-cat', age: 4});
const f = form(
cat,
(p) => {
max(p.age, 5);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([]);
});
it('returns custom errors when provided', () => {
const cat = signal({name: 'pirojok-the-cat', age: 6});
const f = form(
cat,
(p) => {
max(p.age, 5, {
error: ({value}) => {
return customError({kind: 'special-max', message: value().toString()});
},
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([
customError({
kind: 'special-max',
message: '6',
field: f.age,
}),
]);
});
it('supports custom error messgaes', () => {
const cat = signal({name: 'pirojok-the-cat', age: 6});
const f = form(
cat,
(p) => {
max(p.age, 5, {
message: 'max error',
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([
maxError(5, {
message: 'max error',
field: f.age,
}),
]);
});
it('treats NaN as no maximum', () => {
const cat = signal({name: 'pirojok-the-cat', age: 6});
const f = form(
cat,
(p) => {
max(p.age, NaN);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([]);
});
describe('custom properties', () => {
it('stores the MAX property on max', () => {
const cat = signal({name: 'pirojok-the-cat', age: 6});
const f = form(
cat,
(p) => {
max(p.age, 5, {
error: ({value}) => {
return customError({kind: 'special-max', message: value().toString()});
},
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().property(MAX)()).toBe(5);
});
it('merges two maxes preferring the smaller option', () => {
const cat = signal({name: 'pirojok-the-cat', age: 12});
const f = form(
cat,
(p) => {
max(p.age, 10);
max(p.age, 5);
},
{injector: TestBed.inject(Injector)},
);
f.age().value.set(12);
expect(f.age().errors()).toEqual([maxError(10, {field: f.age}), maxError(5, {field: f.age})]);
f.age().value.set(7);
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
f.age().value.set(3);
expect(f.age().errors()).toEqual([]);
expect(f.age().property(MAX)()).toBe(5);
});
it('merges two maxes _dynamically_ preferring the smaller option', () => {
const cat = signal({name: 'pirojok-the-cat', age: 12});
const maxSignal = signal(10);
const f = form(
cat,
(p) => {
max(p.age, maxSignal);
max(p.age, 5);
},
{injector: TestBed.inject(Injector)},
);
f.age().value.set(12);
expect(f.age().errors()).toEqual([maxError(10, {field: f.age}), maxError(5, {field: f.age})]);
f.age().value.set(7);
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
f.age().value.set(3);
expect(f.age().errors()).toEqual([]);
expect(f.age().property(MAX)()).toBe(5);
maxSignal.set(2);
f.age().value.set(3);
expect(f.age().errors()).toEqual([maxError(2, {field: f.age})]);
expect(f.age().property(MAX)()).toBe(2);
});
it('merges two maxes _dynamically_ ignores undefined', () => {
const cat = signal({name: 'pirojok-the-cat', age: 20}); // Age is higher than both maxes
const maxSignal = signal<number | undefined>(10);
const maxSignal2 = signal<number | undefined>(15);
const f = form(
cat,
(p) => {
max(p.age, maxSignal);
max(p.age, maxSignal2);
},
{injector: TestBed.inject(Injector)},
);
// Initially, age 20 is greater than both 10 and 15
expect(f.age().errors()).toEqual([
maxError(10, {field: f.age}),
maxError(15, {field: f.age}),
]);
// Set the first max threshold to undefined
maxSignal.set(undefined);
// Now, age 20 is only greater than 15
expect(f.age().errors()).toEqual([maxError(15, {field: f.age})]);
// Set the second max threshold to undefined
maxSignal2.set(undefined);
// No max constraints are active
expect(f.age().errors()).toEqual([]);
});
});
describe('dynamic values', () => {
it('handles dynamic value', () => {
const cat = signal({name: 'pirojok-the-cat', age: 6});
const maxValue = signal(5);
const f = form(
cat,
(p) => {
max(p.age, maxValue);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
maxValue.set(7);
expect(f.age().errors()).toEqual([]);
});
it('disables validation on undefined', () => {
const cat = signal({name: 'pirojok-the-cat', age: 6});
const maxValue = signal<number | undefined>(5);
const f = form(
cat,
(p) => {
max(p.age, maxValue);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
maxValue.set(undefined);
expect(f.age().errors()).toEqual([]);
maxValue.set(5);
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
});
it('handles dynamic value based on other field', () => {
const cat = signal({name: 'pirojok-the-cat', age: 6});
const f = form(
cat,
(p) => {
max(p.age, ({valueOf}) => {
return valueOf(p.name) === 'pirojok-the-cat' ? 5 : 10;
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
f.name().value.set('other cat');
expect(f.age().errors()).toEqual([]);
});
});
});
@@ -0,0 +1,254 @@
/**
* @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 {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {MAX_LENGTH, form, maxLength} from '../../../../public_api';
import {customError, maxLengthError} from '../../../../src/api/validation_errors';
describe('maxLength validator', () => {
it('returns maxLength error when the length is larger for strings', () => {
const data = signal({text: 'abcde'});
const f = form(
data,
(p) => {
maxLength(p.text, 3);
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([maxLengthError(3, {field: f.text})]);
});
it('returns maxLength error when the length is larger for arrays', () => {
const data = signal({list: [1, 2, 3, 4, 5]});
const f = form(
data,
(p) => {
maxLength(p.list, 3);
},
{injector: TestBed.inject(Injector)},
);
expect(f.list().errors()).toEqual([maxLengthError(3, {field: f.list})]);
});
it('is inclusive (no error if length equals maxLength)', () => {
const data = signal({text: 'abcd'});
const f = form(
data,
(p) => {
maxLength(p.text, 4);
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([]);
});
it('returns no errors when the length is smaller', () => {
const data = signal({text: 'abc'});
const f = form(
data,
(p) => {
maxLength(p.text, 5);
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([]);
});
it('returns custom errors when provided', () => {
const data = signal({text: 'abcdef'});
const f = form(
data,
(p) => {
maxLength(p.text, 5, {
error: ({value}) => {
return customError({
kind: 'special-maxLength',
message: `Length is ${value().length}`,
});
},
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([
customError({
kind: 'special-maxLength',
message: 'Length is 6',
field: f.text,
}),
]);
});
it('supports custom error message', () => {
const data = signal({text: 'abcdef'});
const f = form(
data,
(p) => {
maxLength(p.text, 5, {
message: ({value}) => `${value()} is an error!`,
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([
maxLengthError(5, {
message: 'abcdef is an error!',
field: f.text,
}),
]);
});
it('works with sets', () => {
const data = signal(new Set([1, 2, 3, 4]));
const f = form(
data,
(p) => {
maxLength(p, 3);
},
{injector: TestBed.inject(Injector)},
);
expect(f().errors()).toEqual([maxLengthError(3, {field: f})]);
});
describe('custom properties', () => {
it('stores the MAX_LENGTH property on maxLength', () => {
const data = signal({text: 'abcdef'});
const f = form(
data,
(p) => {
maxLength(p.text, 5, {
error: ({value}) => {
return customError({
kind: 'special-maxLength',
message: `Length is ${value().length}`,
});
},
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().property(MAX_LENGTH)()).toBe(5);
});
it('merges two maxLengths preferring the smaller option', () => {
const data = signal({text: 'abcdefghijklmno'});
const f = form(
data,
(p) => {
maxLength(p.text, 10);
maxLength(p.text, 5);
},
{injector: TestBed.inject(Injector)},
);
f.text().value.set('abcdefghijklmno');
expect(f.text().errors()).toEqual([
maxLengthError(10, {field: f.text}),
maxLengthError(5, {field: f.text}),
]);
f.text().value.set('abcdefg');
expect(f.text().errors()).toEqual([maxLengthError(5, {field: f.text})]);
f.text().value.set('abc');
expect(f.text().errors()).toEqual([]);
expect(f.text().property(MAX_LENGTH)()).toBe(5);
});
it('merges two maxLengths _dynamically_ preferring the smaller option', () => {
const data = signal({text: 'abcdefghijklmno'});
const maxLengthSignal = signal(10);
const f = form(
data,
(p) => {
maxLength(p.text, maxLengthSignal);
maxLength(p.text, 5);
},
{injector: TestBed.inject(Injector)},
);
f.text().value.set('abcdefghijklmno');
expect(f.text().errors()).toEqual([
maxLengthError(10, {field: f.text}),
maxLengthError(5, {field: f.text}),
]);
f.text().value.set('abcdefg');
expect(f.text().errors()).toEqual([maxLengthError(5, {field: f.text})]);
f.text().value.set('abc');
expect(f.text().errors()).toEqual([]);
maxLengthSignal.set(2);
expect(f.text().errors()).toEqual([maxLengthError(2, {field: f.text})]);
expect(f.text().property(MAX_LENGTH)()).toBe(2);
});
});
describe('dynamic values', () => {
it('handles dynamic maxLength value', () => {
const data = signal({text: 'abcdef'});
const dynamicMaxLength = signal(5);
const f = form(
data,
(p) => {
maxLength(p.text, dynamicMaxLength);
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([maxLengthError(5, {field: f.text})]);
dynamicMaxLength.set(7);
expect(f.text().errors()).toEqual([]);
});
it('disables validation on undefined value', () => {
const data = signal({text: 'abcdef'});
const dynamicMaxLength = signal<number | undefined>(5);
const f = form(
data,
(p) => {
maxLength(p.text, dynamicMaxLength);
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([maxLengthError(5, {field: f.text})]);
dynamicMaxLength.set(undefined);
expect(f.text().errors()).toEqual([]);
});
it('handles dynamic maxLength value based on other field', () => {
const data = signal({text: 'longtextvalue', category: 'A'});
const f = form(
data,
(p) => {
maxLength(p.text, ({valueOf}) => {
return valueOf(p.category) === 'A' ? 8 : 15;
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([maxLengthError(8, {field: f.text})]);
f.category().value.set('B');
expect(f.text().errors()).toEqual([]);
});
});
});
@@ -0,0 +1,249 @@
/**
* @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 {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {MIN, form, min} from '../../../../public_api';
import {customError, minError} from '../../../../src/api/validation_errors';
describe('min validator', () => {
it('returns min error when the value is smaller', () => {
const cat = signal({name: 'pirojok-the-cat', age: 4});
const f = form(
cat,
(p) => {
min(p.age, 5);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([minError(5, {field: f.age})]);
});
it('is inclusive', () => {
const cat = signal({name: 'pirojok-the-cat', age: 4});
const f = form(
cat,
(p) => {
min(p.age, 4);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([]);
});
it('returns no errors when the value is larger', () => {
const cat = signal({name: 'pirojok-the-cat', age: 10});
const f = form(
cat,
(p) => {
min(p.age, 5);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([]);
});
it('returns custom errors when provided', () => {
const cat = signal({name: 'pirojok-the-cat', age: 3});
const f = form(
cat,
(p) => {
min(p.age, 5, {
error: ({value}) => {
return customError({kind: 'special-min', message: value().toString()});
},
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([
customError({
kind: 'special-min',
message: '3',
field: f.age,
}),
]);
});
it('supports custom error messages', () => {
const cat = signal({name: 'pirojok-the-cat', age: 3});
const f = form(
cat,
(p) => {
min(p.age, 5, {
message: 'min error!!',
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([
minError(5, {
message: 'min error!!',
field: f.age,
}),
]);
});
it('treats NaN as no minimum', () => {
const cat = signal({name: 'pirojok-the-cat', age: 4});
const f = form(
cat,
(p) => {
min(p.age, NaN);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([]);
});
describe('custom properties', () => {
it('stores the MIN property on min', () => {
const cat = signal({name: 'pirojok-the-cat', age: 3});
const f = form(
cat,
(p) => {
min(p.age, 5, {
error: ({value}) => {
return customError({kind: 'special-min', message: value().toString()});
},
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().property(MIN)()).toBe(5);
});
it('merges two mins preferring the larger option', () => {
const cat = signal({name: 'pirojok-the-cat', age: 3});
const f = form(
cat,
(p) => {
min(p.age, 5);
min(p.age, 10);
},
{injector: TestBed.inject(Injector)},
);
f.age().value.set(3);
expect(f.age().errors()).toEqual([minError(5, {field: f.age}), minError(10, {field: f.age})]);
f.age().value.set(7);
expect(f.age().errors()).toEqual([minError(10, {field: f.age})]);
f.age().value.set(15);
expect(f.age().errors()).toEqual([]);
expect(f.age().property(MIN)()).toBe(10);
});
it('merges two mins _dynamically_ preferring the larger option', () => {
const cat = signal({name: 'pirojok-the-cat', age: 3});
const minSignal = signal(5);
const f = form(
cat,
(p) => {
min(p.age, minSignal);
min(p.age, 10);
},
{injector: TestBed.inject(Injector)},
);
f.age().value.set(3);
expect(f.age().errors()).toEqual([minError(5, {field: f.age}), minError(10, {field: f.age})]);
f.age().value.set(7);
expect(f.age().errors()).toEqual([minError(10, {field: f.age})]);
f.age().value.set(15);
expect(f.age().errors()).toEqual([]);
minSignal.set(30);
expect(f.age().errors()).toEqual([minError(30, {field: f.age})]);
expect(f.age().property(MIN)()).toBe(30);
});
it('merges two mins _dynamically_ ignores undefined', () => {
const cat = signal({name: 'pirojok-the-cat', age: 3});
const minSignal = signal<number | undefined>(15);
const minSignal2 = signal<number | undefined>(10);
const f = form(
cat,
(p) => {
min(p.age, minSignal);
min(p.age, minSignal2);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([
minError(15, {field: f.age}),
minError(10, {field: f.age}),
]);
minSignal.set(undefined);
expect(f.age().errors()).toEqual([minError(10, {field: f.age})]);
minSignal2.set(undefined);
expect(f.age().errors()).toEqual([]);
});
});
describe('dynamic values', () => {
it('disables validation on undefined', () => {
const cat = signal({name: 'pirojok-the-cat', age: 4});
const minValue = signal<number | undefined>(5);
const f = form(
cat,
(p) => {
min(p.age, minValue);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([minError(5, {field: f.age})]);
minValue.set(undefined);
expect(f.age().errors()).toEqual([]);
minValue.set(5);
expect(f.age().errors()).toEqual([minError(5, {field: f.age})]);
});
it('handles dynamic value', () => {
const cat = signal({name: 'pirojok-the-cat', age: 4});
const minValue = signal(5);
const f = form(
cat,
(p) => {
min(p.age, minValue);
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([minError(5, {field: f.age})]);
minValue.set(2);
expect(f.age().errors()).toEqual([]);
});
it('handles dynamic value based on other field', () => {
const cat = signal({name: 'pirojok-the-cat', age: 4});
const f = form(
cat,
(p) => {
min(p.age, ({valueOf}) => {
return valueOf(p.name) === 'pirojok-the-cat' ? 5 : 0;
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.age().errors()).toEqual([minError(5, {field: f.age})]);
f.name().value.set('other cat');
expect(f.age().errors()).toEqual([]);
});
});
});
@@ -0,0 +1,255 @@
/**
* @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 {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {MIN_LENGTH, form, minLength} from '../../../../public_api';
import {customError, minLengthError} from '../../../../src/api/validation_errors';
describe('minLength validator', () => {
it('returns minLength error when the length is smaller for strings', () => {
const data = signal({text: 'abc'});
const f = form(
data,
(p) => {
minLength(p.text, 5);
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([minLengthError(5, {field: f.text})]);
});
it('returns minLength error when the length is smaller for arrays', () => {
const data = signal({list: [1, 2, 3]});
const f = form(
data,
(p) => {
minLength(p.list, 5);
},
{injector: TestBed.inject(Injector)},
);
expect(f.list().errors()).toEqual([minLengthError(5, {field: f.list})]);
});
it('is inclusive (no error if length equals minLength)', () => {
const data = signal({text: 'abcd'});
const f = form(
data,
(p) => {
minLength(p.text, 4);
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([]);
});
it('returns no errors when the length is larger', () => {
const data = signal({text: 'abcdefghij'});
const f = form(
data,
(p) => {
minLength(p.text, 5);
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([]);
});
it('returns custom errors when provided', () => {
const data = signal({text: 'ab'});
const f = form(
data,
(p) => {
minLength(p.text, 5, {
error: ({value}) => {
return customError({
kind: 'special-minLength',
message: `Length is ${value().length}`,
});
},
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([
customError({
kind: 'special-minLength',
message: 'Length is 2',
field: f.text,
}),
]);
});
it('supports custom error messages', () => {
const data = signal({text: 'ab'});
const f = form(
data,
(p) => {
minLength(p.text, 5, {
message: ({value}) => `${value()} is error!`,
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([
minLengthError(5, {
message: 'ab is error!',
field: f.text,
}),
]);
});
it('works with sets', () => {
const data = signal(new Set([1, 2, 3, 4]));
const f = form(
data,
(p) => {
minLength(p, 5);
},
{injector: TestBed.inject(Injector)},
);
expect(f().errors()).toEqual([minLengthError(5, {field: f})]);
});
describe('custom properties', () => {
it('stores the MIN_LENGTH property on minLength', () => {
const data = signal({text: 'ab'});
const f = form(
data,
(p) => {
minLength(p.text, 5, {
error: ({value}) => {
return customError({
kind: 'special-minLength',
message: `Length is ${value().length}`,
});
},
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().property(MIN_LENGTH)()).toBe(5);
});
it('merges two minLengths preferring the larger option', () => {
const data = signal({text: 'ab'});
const f = form(
data,
(p) => {
minLength(p.text, 5);
minLength(p.text, 10);
},
{injector: TestBed.inject(Injector)},
);
f.text().value.set('ab');
expect(f.text().errors()).toEqual([
minLengthError(5, {field: f.text}),
minLengthError(10, {field: f.text}),
]);
f.text().value.set('abcdefg');
expect(f.text().errors()).toEqual([minLengthError(10, {field: f.text})]);
f.text().value.set('abcdefghijklmno');
expect(f.text().errors()).toEqual([]);
expect(f.text().property(MIN_LENGTH)()).toBe(10);
});
it('merges two minLengths _dynamically_ preferring the larger option', () => {
const data = signal({text: 'ab'});
const minLengthSignal = signal(5);
const f = form(
data,
(p) => {
minLength(p.text, minLengthSignal);
minLength(p.text, 10);
},
{injector: TestBed.inject(Injector)},
);
f.text().value.set('ab');
expect(f.text().errors()).toEqual([
minLengthError(5, {field: f.text}),
minLengthError(10, {field: f.text}),
]);
f.text().value.set('abcdefg');
expect(f.text().errors()).toEqual([minLengthError(10, {field: f.text})]);
f.text().value.set('abcdefghijklmno');
expect(f.text().errors()).toEqual([]);
minLengthSignal.set(20);
expect(f.text().errors()).toEqual([minLengthError(20, {field: f.text})]);
expect(f.text().property(MIN_LENGTH)()).toBe(20);
});
});
describe('dynamic values', () => {
it('handles dynamic minLength value', () => {
const data = signal({text: 'abcd'});
const dynamicMinLength = signal(5);
const f = form(
data,
(p) => {
minLength(p.text, dynamicMinLength);
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([minLengthError(5, {field: f.text})]);
dynamicMinLength.set(3);
expect(f.text().errors()).toEqual([]);
});
it('disables validation on undefined value', () => {
const data = signal({text: 'abcd'});
const dynamicMinLength = signal<number | undefined>(5);
const f = form(
data,
(p) => {
minLength(p.text, dynamicMinLength);
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([minLengthError(5, {field: f.text})]);
dynamicMinLength.set(undefined);
expect(f.text().errors()).toEqual([]);
});
it('handles dynamic minLength value based on other field', () => {
const data = signal({text: 'short', category: 'A'});
const f = form(
data,
(p) => {
minLength(p.text, ({valueOf}) => {
return valueOf(p.category) === 'A' ? 8 : 3;
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.text().errors()).toEqual([minLengthError(8, {field: f.text})]);
f.category().value.set('B');
expect(f.text().errors()).toEqual([]);
});
});
});
@@ -0,0 +1,121 @@
/**
* @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 {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {PATTERN, form, pattern} from '../../../../public_api';
import {customError, patternError} from '../../../../src/api/validation_errors';
describe('pattern validator', () => {
it('validates whether a value matches the pattern', () => {
const cat = signal({name: 'pelmeni-the-cat'});
const f = form(
cat,
(p) => {
pattern(p.name, /pir.*jok/);
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().errors()).toEqual([patternError(/pir.*jok/, {field: f.name})]);
});
it('supports custom error', () => {
const cat = signal({name: 'pelmeni-the-cat'});
const f = form(
cat,
(p) => {
pattern(p.name, /pir.*jok/, {error: customError()});
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().errors()).toEqual([customError({field: f.name})]);
});
it('supports custom error message', () => {
const cat = signal({name: 'pelmeni-the-cat'});
const f = form(
cat,
(p) => {
pattern(p.name, /pir.*jok/, {message: 'pattern error'});
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().errors()).toEqual([
patternError(/pir.*jok/, {message: 'pattern error', field: f.name}),
]);
});
describe('custom properties', () => {
it('sets the PATTERN property', () => {
const cat = signal({name: 'pelmeni-the-cat'});
const f = form(
cat,
(p) => {
pattern(p.name, /pir.*jok/);
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().property(PATTERN)()).toEqual([/pir.*jok/]);
});
it('merges the PATTERN property in an array', () => {
const cat = signal({name: 'pelmeni-the-cat'});
const f = form(
cat,
(p) => {
pattern(p.name, /pir.*jok/);
pattern(p.name, /pelmeni/);
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().property(PATTERN)()).toEqual([/pir.*jok/, /pelmeni/]);
});
it('PATTERN property defaults to empty list', () => {
const cat = signal({name: 'pelmeni-the-cat'});
const f = form(
cat,
(p) => {
pattern(p.name, () => undefined);
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().property(PATTERN)()).toEqual([]);
});
});
describe('dynamic values', () => {
it('updates validation result as the pattern changes', () => {
const patternSignal = signal<RegExp | undefined>(/pir.*jok/);
const cat = signal({name: 'pelmeni-the-cat'});
const f = form(
cat,
(p) => {
pattern(p.name, () => patternSignal());
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().errors()).toEqual([patternError(/pir.*jok/, {field: f.name})]);
patternSignal.set(/p.*/);
expect(f.name().errors()).toEqual([]);
patternSignal.set(/meow/);
expect(f.name().errors()).toEqual([patternError(/meow/, {field: f.name})]);
patternSignal.set(undefined);
expect(f.name().errors()).toEqual([]);
});
});
});
@@ -0,0 +1,111 @@
/**
* @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 {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {form, required} from '../../../../public_api';
import {customError, requiredError} from '../../../../src/api/validation_errors';
describe('required validator', () => {
it('returns required Error when the value is not present', () => {
const cat = signal({name: ''});
const f = form(
cat,
(p) => {
required(p.name);
},
{
injector: TestBed.inject(Injector),
},
);
expect(f.name().errors()).toEqual([requiredError({field: f.name})]);
f.name().value.set('pirojok-the-cat');
expect(f.name().errors()).toEqual([]);
});
it('supports custom errors', () => {
const cat = signal({name: '', age: 5});
const f = form(
cat,
(p) => {
required(p.name, {
error: (ctx) => customError({kind: `required-${ctx.valueOf(p.age)}`}),
});
},
{
injector: TestBed.inject(Injector),
},
);
expect(f.name().errors()).toEqual([customError({kind: 'required-5', field: f.name})]);
f.name().value.set('pirojok-the-cat');
expect(f.name().errors()).toEqual([]);
});
it('supports custom error messages', () => {
const cat = signal({name: '', age: 5});
const f = form(
cat,
(p) => {
required(p.name, {
message: 'required error',
});
},
{
injector: TestBed.inject(Injector),
},
);
expect(f.name().errors()).toEqual([requiredError({message: 'required error', field: f.name})]);
f.name().value.set('pirojok-the-cat');
expect(f.name().errors()).toEqual([]);
});
it('supports custom emptyPredicate', () => {
const cat = signal({name: ''});
const f = form(
cat,
(p) => {
required(p.name, {
emptyPredicate(value) {
return value === 'empty';
},
});
},
{
injector: TestBed.inject(Injector),
},
);
expect(f.name().errors()).toEqual([]);
f.name().value.set('empty');
expect(f.name().errors()).toEqual([requiredError({field: f.name})]);
});
it('supports custom condition', () => {
const cat = signal({name: '', age: 5});
const f = form(
cat,
(p) => {
required(p.name, {
when({valueOf}) {
return valueOf(p.age) > 10;
},
});
},
{
injector: TestBed.inject(Injector),
},
);
expect(f.name().errors()).toEqual([]);
f.age().value.set(15);
expect(f.name().errors()).toEqual([requiredError({field: f.name})]);
});
});
@@ -0,0 +1,183 @@
/**
* @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 {ApplicationRef, Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import * as z from 'zod';
import {form, schema} from '../../../../public_api';
import {validateStandardSchema} from '../../../../src/api/validators/standard_schema';
interface Flight {
id: number;
from: string;
to: string;
date: string;
delayed: boolean;
delay: number;
delayReason: string;
}
interface Trip {
departure: Flight;
return: Flight;
}
describe('standard schema integration', () => {
it('should perform sync validation using a standard schema', async () => {
const injector = TestBed.inject(Injector);
const zodName = z.object({
first: z.string().min(2),
last: z.string().min(3),
});
const nameForm = form(
signal({first: '', last: ''}),
(p) => {
validateStandardSchema(p, zodName);
},
{injector},
);
expect(nameForm.first().errors()).toEqual([
jasmine.objectContaining({
kind: 'standardSchema',
issue: jasmine.objectContaining({
message: 'Too small: expected string to have >=2 characters',
}),
}),
]);
expect(nameForm.last().errors()).toEqual([
jasmine.objectContaining({
kind: 'standardSchema',
issue: jasmine.objectContaining({
message: 'Too small: expected string to have >=3 characters',
}),
}),
]);
});
it('should perform async validation using a standard schema', async () => {
const injector = TestBed.inject(Injector);
const zodNameAsync = z
.object({
first: z.string().min(2),
last: z.string().min(3),
})
.refine(() => Promise.resolve());
const nameForm = form(
signal({first: '', last: ''}),
(p) => {
validateStandardSchema(p, zodNameAsync);
},
{injector},
);
expect(nameForm.first().errors()).toEqual([]);
expect(nameForm.last().errors()).toEqual([]);
await TestBed.inject(ApplicationRef).whenStable();
expect(nameForm.first().errors()).toEqual([
jasmine.objectContaining({
kind: 'standardSchema',
issue: jasmine.objectContaining({
message: 'Too small: expected string to have >=2 characters',
}),
}),
]);
expect(nameForm.last().errors()).toEqual([
jasmine.objectContaining({
kind: 'standardSchema',
issue: jasmine.objectContaining({
message: 'Too small: expected string to have >=3 characters',
}),
}),
]);
});
it('should support a partial schema', () => {
const zodFlight = z.object({
from: z.string().min(3),
to: z.string().min(3),
});
const s = schema<Flight>((p) => {
validateStandardSchema(p, zodFlight);
});
// Just expect schema to be defined, really just interested in testing the typing.
expect(s).toBeDefined();
});
it('should support zod looseObject', () => {
const zodFlight = z.looseObject({
from: z.string().min(3),
to: z.string().min(3),
});
const s = schema<Flight>((p) => {
validateStandardSchema(p, zodFlight);
});
// Just expect schema to be defined, really just interested in testing the typing.
expect(s).toBeDefined();
});
it('should support zod looseObject on child', () => {
const zodFlight = z.looseObject({
from: z.string().min(3),
to: z.string().min(3),
});
const zodTrip = z.object({
departure: zodFlight,
return: zodFlight,
});
const s = schema<Trip>((p) => {
validateStandardSchema(p, zodTrip);
});
// Just expect schema to be defined, really just interested in testing the typing.
expect(s).toBeDefined();
});
it('should type error on incompatible zod schema', () => {
const zodFlight = z.looseObject({
from: z.string().min(3),
to: z.string().min(3),
invalid: z.string(),
});
const s = schema<Flight>((p) => {
// @ts-expect-error
validateStandardSchema(p, zodFlight);
});
// Just expect schema to be defined, really just interested in testing the typing.
expect(s).toBeDefined();
});
it('should not allow unknown path', () => {
const zodName = z.object({
first: z.string().min(2),
last: z.string().min(3),
});
const s = schema((p) => {
//@ts-expect-error
validateStandardSchema(p, zodName);
});
// Just expect schema to be defined, really just interested in testing the typing.
expect(s).toBeDefined();
});
});
@@ -0,0 +1,224 @@
/**
* @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 {Injector, Signal, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {
SchemaOrSchemaFn,
applyEach,
applyWhen,
applyWhenValue,
form,
validate,
} from '../../../public_api';
import {customError, requiredError} from '../../../src/api/validation_errors';
export interface User {
first: string;
last: string;
}
const needsLastNamePredicate = ({value}: {value: Signal<{needLastName: boolean}>}) =>
value().needLastName;
describe('when', () => {
it('validates child field according to condition', () => {
const data = signal({first: '', needLastName: false, last: ''});
const f = form(
data,
(path) => {
applyWhen(path, needsLastNamePredicate, (namePath) => {
validate(namePath.last, ({value}) => (value().length > 0 ? undefined : requiredError()));
});
},
{injector: TestBed.inject(Injector)},
);
f().value.set({first: 'meow', needLastName: false, last: ''});
expect(f.last().errors()).toEqual([]);
f().value.set({first: 'meow', needLastName: true, last: ''});
expect(f.last().errors()).toEqual([requiredError({field: f.last})]);
});
it('Disallows using non-local paths', () => {
const data = signal({first: '', needLastName: false, last: ''});
const f = form(
data,
(path) => {
applyWhen(path, needsLastNamePredicate, (/* UNUSED */) => {
expect(() => {
validate(path.last, ({value}) => (value().length > 0 ? undefined : requiredError()));
}).toThrowError();
});
},
{injector: TestBed.inject(Injector)},
);
});
it('supports merging two array schemas', () => {
const data = signal({needLastName: true, items: [{first: '', last: ''}]});
const s: SchemaOrSchemaFn<User> = (namePath) => {
validate(namePath.last, ({value}) => {
return value().length > 0 ? undefined : customError({kind: 'required1'});
});
};
const s2: SchemaOrSchemaFn<User> = (namePath) => {
validate(namePath.last, ({value}) => {
return value.length > 0 ? undefined : customError({kind: 'required2'});
});
};
const f = form(
data,
(path) => {
applyEach(path.items, s);
applyWhen(path, needsLastNamePredicate, (names) => {
applyEach(names.items, s2);
});
},
{injector: TestBed.inject(Injector)},
);
f.needLastName().value.set(true);
expect(f.items[0].last().errors()).toEqual([
customError({kind: 'required1', field: f.items[0].last}),
customError({kind: 'required2', field: f.items[0].last}),
]);
f.needLastName().value.set(false);
expect(f.items[0].last().errors()).toEqual([
customError({kind: 'required1', field: f.items[0].last}),
]);
});
it('accepts a schema', () => {
const data = signal({first: '', needLastName: false, last: ''});
const s: SchemaOrSchemaFn<User> = (namePath) => {
validate(namePath.last, ({value}) => (value().length > 0 ? undefined : requiredError()));
};
const f = form(
data,
(path) => {
applyWhen(path, needsLastNamePredicate, s);
},
{injector: TestBed.inject(Injector)},
);
f().value.set({first: 'meow', needLastName: false, last: ''});
expect(f.last().errors()).toEqual([]);
f().value.set({first: 'meow', needLastName: true, last: ''});
expect(f.last().errors()).toEqual([requiredError({field: f.last})]);
});
it('supports mix of conditional and non conditional validators', () => {
const data = signal({first: '', needLastName: false, last: ''});
const f = form(
data,
(path) => {
validate(path.last, ({value}) =>
value().length > 4 ? undefined : customError({kind: 'short'}),
);
applyWhen(path, needsLastNamePredicate, (namePath /* Path */) => {
validate(namePath.last, ({value}) => (value().length > 0 ? undefined : requiredError()));
});
},
{injector: TestBed.inject(Injector)},
);
f().value.set({first: 'meow', needLastName: false, last: ''});
expect(f.last().errors()).toEqual([customError({kind: 'short', field: f.last})]);
f().value.set({first: 'meow', needLastName: true, last: ''});
expect(f.last().errors()).toEqual([
customError({kind: 'short', field: f.last}),
requiredError({field: f.last}),
]);
});
it('supports array schema', () => {
const data = signal({needLastName: true, items: [{first: '', last: ''}]});
const s: SchemaOrSchemaFn<User> = (i) => {
validate(i.last, ({value}) => {
return value().length > 0 ? undefined : requiredError();
});
};
const f = form(
data,
(path) => {
applyWhen(path, needsLastNamePredicate, (names /* Path */) => {
applyEach(names.items, s);
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.items[0].last().errors()).toEqual([requiredError({field: f.items[0].last})]);
f.needLastName().value.set(false);
expect(f.items[0].last().errors()).toEqual([]);
});
});
describe('applyWhenValue', () => {
it('accepts non-narrowing predicate', () => {
const data = signal<{numOrNull: number | null}>({numOrNull: null});
const f = form(
data,
(path) => {
applyWhenValue(
path.numOrNull,
(value) => value === null || value > 0,
(num) => {
validate(num, ({value}) =>
(value() ?? 0) < 10 ? customError({kind: 'too-small'}) : undefined,
);
},
);
},
{injector: TestBed.inject(Injector)},
);
expect(f.numOrNull().errors()).toEqual([customError({kind: 'too-small', field: f.numOrNull})]);
f.numOrNull().value.set(5);
expect(f.numOrNull().errors()).toEqual([customError({kind: 'too-small', field: f.numOrNull})]);
f.numOrNull().value.set(null);
expect(f.numOrNull().errors()).toEqual([customError({kind: 'too-small', field: f.numOrNull})]);
f.numOrNull().value.set(15);
expect(f.numOrNull().errors()).toEqual([]);
});
it('accepts narrowing-predicate and schema for narrowed type', () => {
const data = signal<{numOrNull: number | null}>({numOrNull: null});
const f = form(
data,
(path) => {
applyWhenValue(
path.numOrNull,
(value) => value !== null,
(num) => {
validate(num, ({value}) =>
value() < 10 ? customError({kind: 'too-small'}) : undefined,
);
},
);
},
{injector: TestBed.inject(Injector)},
);
expect(f.numOrNull().errors()).toEqual([]);
f.numOrNull().value.set(5);
expect(f.numOrNull().errors()).toEqual([customError({kind: 'too-small', field: f.numOrNull})]);
f.numOrNull().value.set(null);
expect(f.numOrNull().errors()).toEqual([]);
f.numOrNull().value.set(15);
expect(f.numOrNull().errors()).toEqual([]);
});
});
@@ -0,0 +1,54 @@
/**
* @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 {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {form} from '../../public_api';
const noop = () => {};
describe('dynamic data patterns', () => {
it('returns `undefined` for declared fields with an undefined value', () => {
const model = signal({data: undefined as string | undefined});
const f = form(model, noop, {injector: TestBed.inject(Injector)});
expect(f.data).toBe(undefined);
// @ts-expect-error: 2722
expect(() => f.data()).toThrow();
});
it('supports non-null assertions for declared fields with a potentially undefined value', () => {
const model = signal({data: 'test' as string | undefined});
const f = form(model, noop, {injector: TestBed.inject(Injector)});
expect(f.data).not.toBeUndefined();
expect(f.data!().value()).toBe('test');
// Asserts that the type of `value()` is indeed `string` and excludes `undefined`.
let value: string = f.data!().value();
});
describe('tracking', () => {
it('should write to the right key after a move', () => {
const data = signal([
{name: 'Alex', counter: 0},
{name: 'Miles', counter: 0},
]);
const f = form(data, {injector: TestBed.inject(Injector)});
const c0 = f[0].counter();
// Swap
data.update(([v0, v1]) => [v1, v0]);
c0.value.set(1);
expect(data()[0].name).toBe('Miles');
expect(data()[0].counter).toBe(0);
expect(data()[1].name).toBe('Alex');
expect(data()[1].counter).toBe(1);
});
});
});
@@ -0,0 +1,148 @@
/**
* @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 {Injector, signal, WritableSignal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {applyEach, FieldContext, FieldPath, form, PathKind, validate} from '../../public_api';
function testContext<T>(
s: WritableSignal<T>,
callback: (ctx: FieldContext<T>, p: FieldPath<T>) => void,
) {
const isCalled = jasmine.createSpy();
TestBed.runInInjectionContext(() => {
const f = form(s, (p) => {
validate(p, (ctx) => {
callback(ctx, p);
isCalled();
return undefined;
});
});
f().errors();
});
expect(isCalled).toHaveBeenCalled();
}
describe('Field Context', () => {
it('value', () => {
const cat = signal({name: 'pirojok-the-cat', age: 5});
testContext(cat, (ctx) => {
expect(ctx.value().name).toEqual('pirojok-the-cat');
});
});
it('state', () => {
const cat = signal({name: 'pirojok-the-cat', age: 5});
testContext(cat, (ctx) => {
expect(ctx.state.value().name).toEqual('pirojok-the-cat');
});
});
it('field', () => {
const cat = signal({name: 'pirojok-the-cat', age: 5});
testContext(cat, (ctx) => {
expect(ctx.field.name().value()).toEqual('pirojok-the-cat');
expect(ctx.field.age().value()).toEqual(5);
});
});
it('key', () => {
const keys: string[] = [];
const recordKey = ({key}: FieldContext<unknown, PathKind.Child>) => {
try {
keys.push(key());
} catch (e) {
keys.push((e as Error).message);
}
return undefined;
};
const cat = signal({name: 'pirojok-the-cat', age: 5});
const f = form(
cat,
(p) => {
// @ts-expect-error
validate(p, recordKey);
validate(p.name, recordKey);
validate(p.age, recordKey);
},
{injector: TestBed.inject(Injector)},
);
f().valid();
expect(keys).toEqual([
'RuntimeError: the top-level field in the form has no parent',
'name',
'age',
]);
});
it('index', () => {
const indices: (string | number)[] = [];
const recordIndex = ({index}: FieldContext<unknown, PathKind.Item>) => {
try {
indices.push(index());
} catch (e) {
indices.push((e as Error).message);
}
return undefined;
};
const pets = signal({
cats: [
{name: 'pirojok-the-cat', age: 5},
{name: 'mielo', age: 10},
],
owner: 'joe',
});
const f = form(
pets,
(p) => {
// @ts-expect-error
validate(p, recordIndex);
applyEach(p.cats, (cat) => {
validate(cat, recordIndex);
});
// @ts-expect-error
validate(p.owner, recordIndex);
},
{injector: TestBed.inject(Injector)},
);
f().valid();
expect(indices).toEqual([
'RuntimeError: the top-level field in the form has no parent',
0,
1,
'RuntimeError: cannot access index, parent field is not an array',
]);
});
it('valueOf', () => {
const cat = signal({name: 'pirojok-the-cat', age: 5});
testContext(cat, (ctx, p) => {
expect(ctx.valueOf(p.name)).toEqual('pirojok-the-cat');
expect(ctx.valueOf(p.age)).toEqual(5);
});
});
it('stateOf', () => {
const cat = signal({name: 'pirojok-the-cat', age: 5});
testContext(cat, (ctx, p) => {
expect(ctx.stateOf(p.name).value()).toEqual('pirojok-the-cat');
expect(ctx.stateOf(p.age).value()).toEqual(5);
});
});
it('fieldOf', () => {
const cat = signal({name: 'pirojok-the-cat', age: 5});
testContext(cat, (ctx, p) => {
expect(ctx.fieldOf(p.name)().value()).toEqual('pirojok-the-cat');
expect(ctx.fieldOf(p.age)().value()).toEqual(5);
});
});
});
@@ -0,0 +1,996 @@
/**
* @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 {computed, Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {
apply,
applyEach,
customError,
disabled,
FieldPath,
form,
readonly,
required,
REQUIRED,
requiredError,
Schema,
schema,
SchemaOrSchemaFn,
validate,
validateTree,
ValidationError,
} from '../../public_api';
import {SchemaImpl} from '../../src/schema/schema';
describe('FieldNode', () => {
it('can get a child of a key that exists', () => {
const f = form(
signal({
a: 1,
b: 2,
}),
{injector: TestBed.inject(Injector)},
);
expect(f.a).toBeDefined();
expect(f.a().value()).toBe(1);
});
describe('instances', () => {
it('should get the same instance when asking for a child multiple times', () => {
const f = form(
signal({
a: 1,
b: 2,
}),
{injector: TestBed.inject(Injector)},
);
const child = f.a;
expect(f.a).toBe(child);
});
it('should get the same instance when asking for a child multiple times', () => {
const value = signal<{a: number; b?: number}>({a: 1, b: 2});
const f = form(value, {injector: TestBed.inject(Injector)});
const child = f.a;
value.set({a: 3});
expect(f.a).toBe(child);
});
});
it('cannot get a child of a key that does not exist', () => {
const f = form(
signal<{a: number; b: number; c?: number}>({
a: 1,
b: 2,
}),
{
injector: TestBed.inject(Injector),
},
);
expect(f.c).toBeUndefined();
});
it('can get a child inside of a computed', () => {
const f = form(
signal({
a: 1,
b: 2,
}),
{injector: TestBed.inject(Injector)},
);
const childA = computed(() => f.a);
expect(childA()).toBeDefined();
});
it('can get a child inside of a computed', () => {
const f = form(
signal({
a: 1,
b: 2,
}),
{injector: TestBed.inject(Injector)},
);
const childA = computed(() => f.a);
expect(childA()).toBeDefined();
});
describe('dirty', () => {
it('is not dirty initially', () => {
const f = form(
signal({
a: 1,
b: 2,
}),
{injector: TestBed.inject(Injector)},
);
expect(f().dirty()).toBe(false);
expect(f.a().dirty()).toBe(false);
});
it('can be marked as dirty', () => {
const f = form(
signal({
a: 1,
b: 2,
}),
{injector: TestBed.inject(Injector)},
);
expect(f().dirty()).toBe(false);
f().markAsDirty();
expect(f().dirty()).toBe(true);
});
it('can be reset', () => {
const model = signal({a: 1, b: 2});
const f = form(model, {injector: TestBed.inject(Injector)});
f().markAsDirty();
expect(f().dirty()).toBe(true);
f().reset();
expect(f().dirty()).toBe(false);
});
it('propagates from the children', () => {
const f = form(
signal({
a: 1,
b: 2,
}),
{injector: TestBed.inject(Injector)},
);
expect(f().dirty()).toBe(false);
f.a().markAsDirty();
expect(f().dirty()).toBe(true);
});
it('does not propagate down', () => {
const f = form(
signal({
a: 1,
b: 2,
}),
{injector: TestBed.inject(Injector)},
);
expect(f.a().dirty()).toBe(false);
f().markAsDirty();
expect(f.a().dirty()).toBe(false);
});
it('does not consider children that get removed', () => {
const value = signal<{a: number; b?: number}>({a: 1, b: 2});
const f = form(value, {injector: TestBed.inject(Injector)});
expect(f().dirty()).toBe(false);
f.b!().markAsDirty();
expect(f().dirty()).toBe(true);
value.set({a: 2});
expect(f().dirty()).toBe(false);
expect(f.b).toBeUndefined();
});
});
describe('touched', () => {
it('is untouched initially', () => {
const f = form(
signal({
a: 1,
b: 2,
}),
{injector: TestBed.inject(Injector)},
);
expect(f().touched()).toBe(false);
});
it('can be marked as touched', () => {
const f = form(
signal({
a: 1,
b: 2,
}),
{injector: TestBed.inject(Injector)},
);
expect(f().touched()).toBe(false);
f().markAsTouched();
expect(f().touched()).toBe(true);
});
it('propagates from the children', () => {
const f = form(
signal({
a: 1,
b: 2,
}),
{injector: TestBed.inject(Injector)},
);
expect(f().touched()).toBe(false);
f.a().markAsTouched();
expect(f().touched()).toBe(true);
});
it('does not propagate down', () => {
const f = form(
signal({
a: 1,
b: 2,
}),
{injector: TestBed.inject(Injector)},
);
expect(f.a().touched()).toBe(false);
f().markAsTouched();
expect(f.a().touched()).toBe(false);
});
it('does not consider children that get removed', () => {
const value = signal<{a: number; b?: number}>({a: 1, b: 2});
const f = form(value, {injector: TestBed.inject(Injector)});
expect(f().touched()).toBe(false);
f.b!().markAsTouched();
expect(f().touched()).toBe(true);
value.set({a: 2});
expect(f().touched()).toBe(false);
expect(f.b).toBeUndefined();
});
it('can be reset', () => {
const model = signal({a: 1, b: 2});
const f = form(model, {injector: TestBed.inject(Injector)});
f().markAsTouched();
expect(f().touched()).toBe(true);
f().reset();
expect(f().touched()).toBe(false);
});
});
describe('arrays', () => {
it('should only have child nodes for elements that exist', () => {
const f = form(signal([1, 2]), {injector: TestBed.inject(Injector)});
expect(f[0]).toBeDefined();
expect(f[1]).toBeDefined();
expect(f[2]).not.toBeDefined();
expect(f['length']).toBe(2);
});
it('should get the element node', () => {
const f = form(
signal({names: [{name: 'Alex'}, {name: 'Miles'}]}),
(p) => {
applyEach(p.names, (a) => {
disabled(a.name, ({value, fieldOf}) => {
const el = fieldOf(a);
expect(el().value().name).toBe(value());
expect([...fieldOf(p).names].findIndex((e: any) => e === el)).not.toBe(-1);
return true;
});
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.names[0].name().disabled()).toBe(true);
expect(f.names[1].name().disabled()).toBe(true);
});
it('should support element-level logic', () => {
const f = form(
signal([1, 2, 3]),
(p) => {
applyEach(p, (a) => {
a;
disabled(a, ({value}) => value() % 2 === 0);
});
},
{injector: TestBed.inject(Injector)},
);
expect(f[0]().disabled()).toBe(false);
expect(f[1]().disabled()).toBe(true);
expect(f[2]().disabled()).toBe(false);
});
it('should support dynamic elements', () => {
const model = signal([1, 2, 3]);
const f = form(
model,
(p) => {
applyEach(p, (el) => {
// Disabled if even.
disabled(el, ({value}) => value() % 2 === 0);
});
},
{injector: TestBed.inject(Injector)},
);
model.update((v) => [...v, 4]);
expect(f[3]().disabled()).toBe(true);
});
it('should support removing elements', () => {
const value = signal([1, 2, 3]);
const f = form(value, {injector: TestBed.inject(Injector)});
f[2]().markAsTouched();
expect(f().touched()).toBe(true);
value.set([1, 2]);
expect(f().touched()).toBe(false);
});
describe('tracking', () => {
it('maintains identity across value moves', () => {
const value = signal([{name: 'Alex'}, {name: 'Kirill'}]);
const f = form(value, {injector: TestBed.inject(Injector)});
const alex = f[0];
const kirill = f[1];
value.update((old) => [old[1], old[0]]);
expect(f[0] === kirill).toBeTrue();
expect(f[1] === alex).toBeTrue();
});
it('maintains identity across value update', () => {
const value = signal([{name: 'Alex'}, {name: 'Kirill'}]);
const f = form(value, {injector: TestBed.inject(Injector)});
const alex = f[0];
const kirill = f[1];
value.update((old) => [old[1], {...old[0], name: 'Pawel'}]);
expect(f[0] === kirill).toBeTrue();
expect(f[1] === alex).toBeTrue();
});
});
});
describe('names', () => {
it('auto-generates a name for the form', () => {
const f = form(signal({}), {injector: TestBed.inject(Injector)});
expect(f().name()).toMatch(/^a.form\d+$/);
});
it('uses a specific name for the form when given', () => {
const f = form(signal({}), {injector: TestBed.inject(Injector), name: 'test'});
expect(f().name()).toBe('test');
});
it('derives child field names from parents', () => {
const f = form(signal({user: {firstName: 'Alex'}}), {
injector: TestBed.inject(Injector),
name: 'test',
});
expect(f.user().name()).toBe('test.user');
expect(f.user.firstName().name()).toBe('test.user.firstName');
});
});
describe('disabled', () => {
it('should allow logic to make a node disabled', () => {
const f = form(
signal({a: 1, b: 2}),
(p) => {
disabled(p.a, ({value}) => value() !== 2);
},
{injector: TestBed.inject(Injector)},
);
const a = f.a;
expect(f().disabled()).toBe(false);
expect(a().disabled()).toBe(true);
expect(a().disabledReasons()).toEqual([{field: f.a}]);
a().value.set(2);
expect(f().disabled()).toBe(false);
expect(a().disabled()).toBe(false);
});
it('should disable with reason', () => {
const f = form(
signal({a: 1, b: 2}),
(p) => {
disabled(p.a, () => 'a cannot be changed');
},
{injector: TestBed.inject(Injector)},
);
expect(f.a().disabled()).toBe(true);
expect(f.a().disabledReasons()).toEqual([
{
field: f.a,
message: 'a cannot be changed',
},
]);
});
it('should not have disabled reason if not disabled', () => {
const f = form(
signal({a: 1, b: 2}),
(p) => {
disabled(p.a, ({value}) => (value() > 5 ? 'a cannot be changed' : false));
},
{injector: TestBed.inject(Injector)},
);
expect(f.a().disabled()).toBe(false);
expect(f.a().disabledReasons()).toEqual([]);
f.a().value.set(6);
expect(f.a().disabled()).toBe(true);
expect(f.a().disabledReasons()).toEqual([
{
field: f.a,
message: 'a cannot be changed',
},
]);
});
it('disabled reason should propagate to children', () => {
const f = form(
signal({a: 1, b: 2}),
(p) => {
disabled(p, () => 'form unavailable');
},
{injector: TestBed.inject(Injector)},
);
expect(f().disabled()).toBe(true);
expect(f().disabledReasons()).toEqual([
{
field: f,
message: 'form unavailable',
},
]);
expect(f.a().disabled()).toBe(true);
expect(f.a().disabledReasons()).toEqual([
{
field: f,
message: 'form unavailable',
},
]);
});
it('should disable unconditionally', () => {
const f = form(
signal({a: '', b: ''}),
(p) => {
disabled(p.a);
disabled(p.b, 'disabled!');
},
{injector: TestBed.inject(Injector)},
);
expect(f.a().disabledReasons()).toEqual([
{
field: f.a,
},
]);
expect(f.b().disabledReasons()).toEqual([
{
field: f.b,
message: 'disabled!',
},
]);
});
});
describe('readonly', () => {
it('should allow logic to make a field readonly', () => {
const f = form(
signal({a: 1, b: 2}),
(p) => {
readonly(p.a);
},
{injector: TestBed.inject(Injector)},
);
expect(f().readonly()).toBe(false);
expect(f.a().readonly()).toBe(true);
expect(f.b().readonly()).toBe(false);
});
it('should allow logic to make a field conditionally readonly', () => {
const f = form(
signal({a: 1, b: 2}),
(p) => {
readonly(p.a, ({value}) => value() > 10);
},
{injector: TestBed.inject(Injector)},
);
expect(f.a().readonly()).toBe(false);
f.a().value.set(11);
expect(f.a().readonly()).toBe(true);
});
it('should make children of readonly parent readonly', () => {
const f = form(
signal({a: 1, b: 2}),
(p) => {
readonly(p);
},
{injector: TestBed.inject(Injector)},
);
expect(f().readonly()).toBe(true);
expect(f.a().readonly()).toBe(true);
expect(f.b().readonly()).toBe(true);
});
it('should not validate readonly fields', () => {
const isReadonly = signal(false);
const f = form(
signal(''),
(p) => {
readonly(p, isReadonly);
required(p);
},
{injector: TestBed.inject(Injector)},
);
expect(f().property(REQUIRED)()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().readonly()).toBe(false);
isReadonly.set(true);
expect(f().property(REQUIRED)()).toBe(true);
expect(f().valid()).toBe(true);
expect(f().readonly()).toBe(true);
});
});
describe('validation', () => {
it('should validate field', () => {
const f = form(
signal({a: 1, b: 2}),
(p) => {
validate(p.a, ({value}) => {
if (value() > 10) {
return customError({kind: 'too-damn-high'});
}
return undefined;
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.a().errors()).toEqual([]);
expect(f.a().valid()).toBe(true);
expect(f.a().errors()).toEqual([]);
expect(f().valid()).toBe(true);
f.a().value.set(11);
expect(f.a().errors()).toEqual([customError({kind: 'too-damn-high', field: f.a})]);
expect(f.a().valid()).toBe(false);
expect(f().errors()).toEqual([]);
expect(f().valid()).toBe(false);
});
it('should validate with multiple errors', () => {
const f = form(
signal({a: 1, b: 2}),
(p) => {
validate(p.a, ({value}) => {
if (value() > 10) {
return [customError({kind: 'too-damn-high'}), customError({kind: 'bad'})];
}
return undefined;
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.a().errors()).toEqual([]);
expect(f.a().valid()).toBe(true);
f.a().value.set(11);
expect(f.a().errors()).toEqual([
customError({kind: 'too-damn-high', field: f.a}),
customError({kind: 'bad', field: f.a}),
]);
expect(f.a().valid()).toBe(false);
});
it('should validate required field', () => {
const data = signal({first: '', last: ''});
const f = form(
data,
(name) => {
required(name.first);
},
{injector: TestBed.inject(Injector)},
);
expect(f.first().errors()).toEqual([requiredError({field: f.first})]);
expect(f.first().valid()).toBe(false);
expect(f.first().property(REQUIRED)()).toBe(true);
f.first().value.set('Bob');
expect(f.first().errors()).toEqual([]);
expect(f.first().valid()).toBe(true);
expect(f.first().property(REQUIRED)()).toBe(true);
});
it('should validate conditionally required field', () => {
const data = signal({first: '', last: ''});
const f = form(
data,
(name) => {
// first name required if last name specified
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
},
{injector: TestBed.inject(Injector)},
);
expect(f.first().errors()).toEqual([]);
expect(f.first().valid()).toBe(true);
expect(f.first().property(REQUIRED)()).toBe(false);
f.last().value.set('Loblaw');
expect(f.first().errors()).toEqual([requiredError({field: f.first})]);
expect(f.first().valid()).toBe(false);
expect(f.first().property(REQUIRED)()).toBe(true);
f.first().value.set('Bob');
expect(f.first().errors()).toEqual([]);
expect(f.first().valid()).toBe(true);
expect(f.first().property(REQUIRED)()).toBe(true);
});
it('should support custom empty predicate', () => {
const data = signal({name: '', quantity: 0});
const f = form(
data,
(item) => {
required(item.quantity, {emptyPredicate: (value) => value === 0});
},
{injector: TestBed.inject(Injector)},
);
expect(f.quantity().property(REQUIRED)()).toBe(true);
expect(f.quantity().errors()).toEqual([requiredError({field: f.quantity})]);
f.quantity().value.set(1);
expect(f.quantity().property(REQUIRED)()).toBe(true);
expect(f.quantity().errors()).toEqual([]);
});
it('should link required error messages to their predicate', () => {
const data = signal({country: '', amount: 0, name: ''});
const f = form(
data,
(tx) => {
required(tx.name, {
when: ({valueOf}) => valueOf(tx.country) === 'USA',
error: requiredError({message: 'Name is required in your country'}),
});
required(tx.name, {
when: ({valueOf}) => valueOf(tx.amount) >= 1000,
error: requiredError({
message: 'Name is required for large transactions',
}),
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().errors()).toEqual([]);
f.country().value.set('USA');
expect(f.name().errors()).toEqual([
requiredError({
message: 'Name is required in your country',
field: f.name,
}),
]);
f.amount().value.set(1000);
expect(f.name().errors()).toEqual([
requiredError({
message: 'Name is required in your country',
field: f.name,
}),
requiredError({
message: 'Name is required for large transactions',
field: f.name,
}),
]);
f.country().value.set('Canada');
expect(f.name().errors()).toEqual([
requiredError({
message: 'Name is required for large transactions',
field: f.name,
}),
]);
f.amount().value.set(100);
expect(f.name().errors()).toEqual([]);
});
it('should allow validate logic to return null to indicate no error', () => {
const f = form(
signal({a: 1, b: 2}),
(p) => {
validate(p.a, ({value}) => (value() > 1 ? customError() : null));
},
{injector: TestBed.inject(Injector)},
);
expect(f.a().errors()).toEqual([]);
expect(f.a().valid()).toBe(true);
f.a().value.set(2);
expect(f.a().errors()).toEqual([customError({field: f.a})]);
expect(f.a().valid()).toBe(false);
});
describe('tree validation', () => {
it('should push errors to children', () => {
const cat = signal({name: 'Fluffy', age: 5});
const f = form(
cat,
(p) => {
validateTree(p, ({value, fieldOf}) => {
const errors: ValidationError[] = [];
if (value().name.length > 8) {
errors.push(customError({kind: 'long_name', field: fieldOf(p.name)}));
}
if (value().age < 0) {
errors.push(customError({kind: 'temporal_anomaly', field: fieldOf(p.age)}));
}
return errors;
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().errors()).toEqual([]);
expect(f.age().errors()).toEqual([]);
f.age().value.set(-10);
expect(f.name().errors()).toEqual([]);
expect(f.age().errors()).toEqual([customError({kind: 'temporal_anomaly', field: f.age})]);
cat.set({name: 'Fluffy McFluffington', age: 10});
expect(f.name().errors()).toEqual([customError({kind: 'long_name', field: f.name})]);
expect(f.age().errors()).toEqual([]);
});
it('should push errors to children async', () => {
const cat = signal({name: 'Fluffy', age: 5});
const f = form(
cat,
(p) => {
validateTree(p, ({value, fieldOf}) => {
const errors: ValidationError[] = [];
if (value().name.length > 8) {
errors.push(customError({kind: 'long_name', field: fieldOf(p.name)}));
}
if (value().age < 0) {
errors.push(customError({kind: 'temporal_anomaly', field: fieldOf(p.age)}));
}
return errors;
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.name().errors()).toEqual([]);
expect(f.age().errors()).toEqual([]);
f.age().value.set(-10);
expect(f.name().errors()).toEqual([]);
expect(f.age().errors()).toEqual([customError({kind: 'temporal_anomaly', field: f.age})]);
cat.set({name: 'Fluffy McFluffington', age: 10});
expect(f.name().errors()).toEqual([customError({kind: 'long_name', field: f.name})]);
expect(f.age().errors()).toEqual([]);
});
});
});
describe('errorSummary', () => {
it('should be empty', () => {
const data = signal({});
const f = form(data, {injector: TestBed.inject(Injector)});
expect(f().errorSummary()).toEqual([]);
});
it('should contain errors from current field', () => {
const data = signal('');
const f = form(
data,
(p) => {
required(p);
},
{injector: TestBed.inject(Injector)},
);
expect(f().errorSummary()).toEqual([requiredError({field: f})]);
});
it('should contain errors from child fields', () => {
const name = signal({first: '', last: ''});
const f = form(
name,
(p) => {
required(p.first);
required(p.last);
},
{injector: TestBed.inject(Injector)},
);
expect(f().errorSummary()).toEqual([
requiredError({field: f.first}),
requiredError({field: f.last}),
]);
});
it('should accumulate errors of all descendants', () => {
const data = signal({
child: {
child: {},
},
});
const f = form(
data,
(p) => {
validate(p, () => customError({kind: 'root'}));
validate(p.child, () => customError({kind: 'child'}));
validate(p.child.child, () => customError({kind: 'grandchild'}));
},
{injector: TestBed.inject(Injector)},
);
expect(f.child.child().errorSummary()).toEqual([
customError({kind: 'grandchild', field: f.child.child}),
]);
expect(f.child().errorSummary()).toEqual([
customError({kind: 'child', field: f.child}),
customError({kind: 'grandchild', field: f.child.child}),
]);
expect(f().errorSummary()).toEqual([
customError({kind: 'root', field: f}),
customError({kind: 'child', field: f.child}),
customError({kind: 'grandchild', field: f.child.child}),
]);
});
});
describe('composition', () => {
it('should apply schema to field', () => {
interface Address {
street: string;
city: string;
}
const addressSchema: SchemaOrSchemaFn<Address> = (p) => {
disabled(p.street, () => true);
};
const data = signal<{name: string; address: Address}>({
name: '',
address: {street: '', city: ''},
});
const f = form(
data,
(p) => {
apply(p.address, addressSchema);
},
{injector: TestBed.inject(Injector)},
);
expect(f.address.street().disabled()).toBe(true);
});
});
describe('predefined schema', () => {
it('should compile schema once per form', () => {
const opts = {injector: TestBed.inject(Injector)};
const subFn = jasmine.createSpy('schemaFn');
const sub: Schema<string> = schema(subFn);
const s = schema((p: FieldPath<{a: string; b: string}>) => {
apply(p.a, sub);
apply(p.b, sub);
});
expect(subFn).toHaveBeenCalledTimes(0);
form(signal({a: '', b: ''}), s, opts);
expect(subFn).toHaveBeenCalledTimes(1);
form(signal({a: '', b: ''}), s, opts);
expect(subFn).toHaveBeenCalledTimes(2);
});
it('should resolve predefined schema paths within the local context', () => {
const s = schema<{a: string; b: string}>((p) => {
disabled(p.b, ({valueOf}) => valueOf(p.a) === 'disable-b');
});
const f = form(
signal({first: {a: '', b: ''}, second: {a: 'disable-b', b: ''}}),
(p) => {
apply(p.first, s);
apply(p.second, s);
},
{injector: TestBed.inject(Injector)},
);
expect(f.first.b().disabled()).toBe(false);
expect(f.second.b().disabled()).toBe(true);
});
it('should resolve predefined schema paths deeply nested within the schema', () => {
const s = schema<{a: string; b: string}>((p) => {
disabled(p.b, ({valueOf}) => valueOf(p.a) === 'disable-b');
});
const f = form(
signal({first: {second: {a: 'disable-b', b: ''}}}),
(p) => {
apply(p.first, (p) => {
apply(p.second, s);
});
},
{injector: TestBed.inject(Injector)},
);
expect(f.first.second.b().disabled()).toBe(true);
});
it('should error on resolving predefined schema path that is not part of the form', () => {
let otherP: FieldPath<any>;
const s = schema<string>((p) => (otherP = p));
SchemaImpl.rootCompile(s);
const f = form(
signal(''),
(p) => {
disabled(p, ({fieldOf}) => {
fieldOf(otherP);
return true;
});
},
{injector: TestBed.inject(Injector)},
);
expect(() => f().disabled()).toThrowError('Path is not part of this field tree.');
});
});
describe('reset', () => {
it('should propagate to descendants', () => {
const model = signal({a: {b: 2}});
const f = form(model, {injector: TestBed.inject(Injector)});
f.a.b().markAsDirty();
expect(f().dirty()).toBe(true);
expect(f.a().dirty()).toBe(true);
expect(f.a.b().dirty()).toBe(true);
f().reset();
expect(f().dirty()).toBe(false);
expect(f.a().dirty()).toBe(false);
expect(f.a.b().dirty()).toBe(false);
});
});
});
@@ -0,0 +1,34 @@
/**
* @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 {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {form} from '../../public_api';
describe('Field proxy', () => {
it('should not forward methods through the proxy', () => {
const f = form(signal(new Date()), {injector: TestBed.inject(Injector)});
// @ts-expect-error
expect(f.getDate).toBe(undefined as any);
});
it('should allow spreading field arrays', () => {
const f = form(signal([0, 1, 2]), {injector: TestBed.inject(Injector)});
expect([...f].map((i) => i().value())).toEqual([0, 1, 2]);
});
it('should not allow mutation of the field structure', () => {
const f = form(signal({arr: [0, 1]}), {injector: TestBed.inject(Injector)});
// Just to have an expectation, really this test is just to check the typings below.
expect(f).toBeDefined();
// @ts-expect-error
f.arr = f.arr;
// @ts-expect-error
f.arr[0] = f.arr[0];
});
});
@@ -0,0 +1,85 @@
/**
* @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 {inject, Injector, runInInjectionContext, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {form, required, schema, validate} from '../../public_api';
describe('form', () => {
describe('injection context', () => {
it('throws when there is no injection context', () => {
const model = signal(123);
expect(() => form(model)).toThrowError();
});
it('is not present in rules', () => {
const injector = TestBed.inject(Injector);
const model = signal(123);
const f = form(
model,
(p) => {
validate(p, () => {
expect(() => {
inject(Injector);
}).toThrow();
return undefined;
});
},
{injector},
);
// Make sure the validation runs
f().valid();
});
it('uses provided provided injection context to run the form', () => {
const injector = TestBed.inject(Injector);
const model = signal(123);
form(
model,
() => {
expect(inject(Injector)).toBe(injector);
},
{injector},
);
});
it('uses provided provided injection context over the one it is run in', () => {
const injector = TestBed.inject(Injector);
const injector2 = Injector.create({providers: [], parent: injector});
const model = signal(123);
runInInjectionContext(injector2, () => {
form(
model,
() => {
expect(inject(Injector)).toBe(injector);
},
{injector: injector},
);
});
});
});
it('should infer schema type', () => {
runInInjectionContext(TestBed.inject(Injector), () => {
const f = form(
signal<{x: string}>({x: ''}),
schema((p) => {
// Note: the primary purpose of this test is to verify that the line below does not have
// a type error due to `p` being of type `unknown`.
required(p.x);
}),
);
expect(f.x().valid()).toBe(false);
});
});
});
@@ -0,0 +1,422 @@
/**
* @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 {signal} from '@angular/core';
import {FieldContext, FieldState, customError} from '../../public_api';
import {DYNAMIC} from '../../src/schema/logic';
import {LogicNodeBuilder} from '../../src/schema/logic_node';
const fakeFieldContext: FieldContext<unknown> = {
fieldOf: () => undefined!,
stateOf: <P>() =>
({
context: undefined,
structure: {pathKeys: () => [], parent: undefined},
}) as unknown as FieldState<P>,
valueOf: () => undefined!,
field: undefined!,
state: undefined!,
value: undefined!,
};
describe('LogicNodeBuilder', () => {
it('should build logic', () => {
// (p) => {
// validate(p, () => ({kind: 'root-err'}));
// };
const builder = LogicNodeBuilder.newRoot();
builder.addSyncErrorRule(() => [customError({kind: 'root-err'})]);
const logicNode = builder.build();
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'root-err'}),
]);
});
it('should build child logic', () => {
// (p) => {
// validate(p.a, () => ({kind: 'child-err'}));
// };
const builder = LogicNodeBuilder.newRoot();
builder.getChild('a').addSyncErrorRule(() => [customError({kind: 'root-err'})]);
const logicNode = builder.build();
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'root-err'}),
]);
});
it('should build merged logic', () => {
// (p) => {
// validate(p, () => ({kind: 'err-1'}));
// validate(p, () => ({kind: 'err-2'}));
// };
const builder = LogicNodeBuilder.newRoot();
builder.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
const builder2 = LogicNodeBuilder.newRoot();
builder2.addSyncErrorRule(() => [customError({kind: 'err-2'})]);
builder.mergeIn(builder2);
const logicNode = builder.build();
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
customError({kind: 'err-2'}),
]);
});
it('should build merged child logic', () => {
// (p) => {
// validate(p.a, () => ({kind: 'err-1'}));
// validate(p.a, () => ({kind: 'err-2'}));
// };
const builder = LogicNodeBuilder.newRoot();
builder.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-1'})]);
const builder2 = LogicNodeBuilder.newRoot();
builder2.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-2'})]);
builder.mergeIn(builder2);
const logicNode = builder.build();
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
customError({kind: 'err-2'}),
]);
});
it('should build logic with predicate', () => {
// (p) => {
// applyWhen(p, pred, (p) => {
// validate(p, () => ({kind: 'err-1'}));
// });
// }
const builder = LogicNodeBuilder.newRoot();
const pred = signal(true);
const builder2 = LogicNodeBuilder.newRoot();
builder2.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
const logicNode = builder.build();
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
]);
pred.set(false);
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
});
it('should apply predicate to merged in logic', () => {
// (p) => {
// applyWhen(p, pred, (p) => {
// apply(p, (p) => {
// validate(p, () => ({kind: 'err-1'}));
// });
// });
// }
const builder = LogicNodeBuilder.newRoot();
const pred = signal(true);
const builder2 = LogicNodeBuilder.newRoot();
const builder3 = LogicNodeBuilder.newRoot();
builder3.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
builder2.mergeIn(builder3);
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
const logicNode = builder.build();
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
]);
pred.set(false);
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
});
it('should apply predicate to merged in child logic', () => {
// (p) => {
// applyWhen(p, pred, (p) => {
// apply(p, (p) => {
// validate(p.a, () => ({kind: 'err-1'}));
// });
// });
// }
const builder = LogicNodeBuilder.newRoot();
const pred = signal(true);
const builder2 = LogicNodeBuilder.newRoot();
const builder3 = LogicNodeBuilder.newRoot();
builder3.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-1'})]);
builder2.mergeIn(builder3);
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
const logicNode = builder.build();
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
]);
pred.set(false);
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
});
it('should combine predicates', () => {
// (p) => {
// applyWhen(p, pred, (p) => {
// applyWhen(p.a, pred2, (a) => {
// validate(a, () => ({kind: 'err-1'}));
// });
// });
// }
const builder = LogicNodeBuilder.newRoot();
const pred = signal(true);
const builder2 = LogicNodeBuilder.newRoot();
const pred2 = signal(true);
const builder3 = LogicNodeBuilder.newRoot();
builder3.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
builder2.getChild('a').mergeIn(builder3, {fn: () => pred2(), path: undefined!});
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
const logicNode = builder.build();
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
]);
pred.set(false);
pred2.set(true);
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
pred.set(true);
pred2.set(false);
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
});
it('should propagate predicates through deep application', () => {
// (p) => {
// applyWhen(p, pred, (p) => {
// validate(p.a.b, () => ({kind: 'err-1'}));
// applyWhen(p.a, pred2, (a) => {
// validate(a.b, () => ({kind: 'err-2'}));
// applyWhen(a.b, pred3, (b) => {
// validate(b, () => ({kind: 'err-3'}));
// });
// });
// });
// }
const builder = LogicNodeBuilder.newRoot();
const pred = signal(true);
const builder2 = LogicNodeBuilder.newRoot();
builder2
.getChild('a')
.getChild('b')
.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
const pred2 = signal(true);
const builder3 = LogicNodeBuilder.newRoot();
builder3.getChild('b').addSyncErrorRule(() => [customError({kind: 'err-2'})]);
const pred3 = signal(true);
const builder4 = LogicNodeBuilder.newRoot();
builder4.addSyncErrorRule(() => [customError({kind: 'err-3'})]);
builder3.getChild('b').mergeIn(builder4, {fn: () => pred3(), path: undefined!});
builder2.getChild('a').mergeIn(builder3, {fn: () => pred2(), path: undefined!});
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
const logicNode = builder.build();
expect(
logicNode.getChild('a').getChild('b').logic.syncErrors.compute(fakeFieldContext),
).toEqual([
customError({kind: 'err-1'}),
customError({kind: 'err-2'}),
customError({kind: 'err-3'}),
]);
pred.set(true);
pred2.set(true);
pred3.set(false);
expect(
logicNode.getChild('a').getChild('b').logic.syncErrors.compute(fakeFieldContext),
).toEqual([customError({kind: 'err-1'}), customError({kind: 'err-2'})]);
pred.set(true);
pred2.set(false);
pred3.set(true);
expect(
logicNode.getChild('a').getChild('b').logic.syncErrors.compute(fakeFieldContext),
).toEqual([customError({kind: 'err-1'})]);
pred.set(false);
pred2.set(true);
pred3.set(true);
expect(
logicNode.getChild('a').getChild('b').logic.syncErrors.compute(fakeFieldContext),
).toEqual([]);
});
it('should propagate predicates through deep child access', () => {
// (p) => {
// applyWhen(p, pred, (p) => {
// applyEach(p.items, (i) => {
// validate(i.last, () => ({kind: 'err-1'}));
// });
// });
// };
const builder = LogicNodeBuilder.newRoot();
const pred = signal(true);
const builder2 = LogicNodeBuilder.newRoot();
const builder3 = LogicNodeBuilder.newRoot();
builder3.getChild('last').addSyncErrorRule(() => [customError({kind: 'err-1'})]);
builder2.getChild('items').getChild(DYNAMIC).mergeIn(builder3);
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
const logicNode = builder.build();
expect(
logicNode
.getChild('items')
.getChild(DYNAMIC)
.getChild('last')
.logic.syncErrors.compute(fakeFieldContext),
).toEqual([customError({kind: 'err-1'})]);
pred.set(false);
expect(
logicNode
.getChild('items')
.getChild(DYNAMIC)
.getChild('last')
.logic.syncErrors.compute(fakeFieldContext),
).toEqual([]);
});
it('should preserve ordering across merges', () => {
// (p) => {
// validate(p, () => ({kind: 'err-1'}));
// apply(p, (p) => {
// validate(p, () => ({kind: 'err-2'}));
// })
// validate(p, () => ({kind: 'err-3'}));
// };
const builder = LogicNodeBuilder.newRoot();
builder.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
const builder2 = LogicNodeBuilder.newRoot();
builder2.addSyncErrorRule(() => [customError({kind: 'err-2'})]);
builder.mergeIn(builder2);
builder.addSyncErrorRule(() => [customError({kind: 'err-3'})]);
const logicNode = builder.build();
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
customError({kind: 'err-2'}),
customError({kind: 'err-3'}),
]);
});
it('should preserve child ordering across merges', () => {
// (p) => {
// validate(p.a, () => ({kind: 'err-1'}));
// apply(p, (p) => {
// validate(p.a, () => ({kind: 'err-2'}));
// })
// validate(p.a, () => ({kind: 'err-3'}));
// };
const builder = LogicNodeBuilder.newRoot();
builder.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-1'})]);
const builder2 = LogicNodeBuilder.newRoot();
builder2.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-2'})]);
builder.mergeIn(builder2);
builder.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-3'})]);
const logicNode = builder.build();
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
customError({kind: 'err-2'}),
customError({kind: 'err-3'}),
]);
});
it('should support circular logic structures', () => {
// const s = schema((p) => {
// validate(p, () => ({kind: 'err-1'})),
// apply(p.next, s);
// }));
const builder = LogicNodeBuilder.newRoot();
builder.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
builder.getChild('next').mergeIn(builder);
const logicNode = builder.build();
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
]);
expect(logicNode.getChild('next').logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
]);
expect(
logicNode.getChild('next').getChild('next').logic.syncErrors.compute(fakeFieldContext),
).toEqual([customError({kind: 'err-1'})]);
});
it('should support circular logic structures with predicate', () => {
// const s = schema((p) => {
// validate(p, () => ({kind: 'err-1'})),
// applyWhen(p.next, pred, s);
// }));
const pred = signal(true);
const builder = LogicNodeBuilder.newRoot();
builder.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
builder.getChild('next').mergeIn(builder, {fn: () => pred(), path: undefined!});
const logicNode = builder.build();
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
]);
expect(logicNode.getChild('next').logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
]);
expect(
logicNode.getChild('next').getChild('next').logic.syncErrors.compute(fakeFieldContext),
).toEqual([customError({kind: 'err-1'})]);
// TODO: test that verifies that the same predicate can resolve with a different field context
// on `.next` vs on `.next.next`
pred.set(false);
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
customError({kind: 'err-1'}),
]);
expect(logicNode.getChild('next').logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
expect(
logicNode.getChild('next').getChild('next').logic.syncErrors.compute(fakeFieldContext),
).toEqual([]);
});
});
@@ -0,0 +1,102 @@
/**
* @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 {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {
apply,
applyEach,
applyWhen,
customError,
form,
requiredError,
validate,
} from '../../public_api';
describe('path', () => {
describe('Active path', () => {
it('Disallows using parent paths for applyWhen', () => {
const data = signal({first: '', needLastName: false, last: ''});
form(
data,
(path) => {
applyWhen(
path,
({value}) => value().needLastName,
(/* UNUSED */) => {
expect(() => {
validate(path.last, ({value}) =>
value().length > 0 ? undefined : requiredError(),
);
}).toThrowError();
},
);
},
{injector: TestBed.inject(Injector)},
);
});
it('Disallows using parent paths for apply', () => {
const data = signal({first: '', needLastName: false, last: ''});
form(
data,
(path) => {
apply(path, () => {
expect(() => {
validate(path.last, () => {
return customError();
});
}).toThrowError();
});
},
{injector: TestBed.inject(Injector)},
);
});
it('Disallows using the same path', () => {
const data = signal({first: '', needLastName: false, last: ''});
form(
data,
(path) => {
apply(path, () => {
expect(() => {
validate(path, () => {
return customError();
});
}).toThrowError();
});
},
{injector: TestBed.inject(Injector)},
);
});
it('Disallows using parent paths for apply', () => {
const data = signal({
needLastName: false,
items: [{first: '', last: ''}],
});
form(
data,
(path) => {
applyEach(path.items, () => {
expect(() => {
validate(path.needLastName, () => {
return customError();
});
}).toThrowError();
});
},
{injector: TestBed.inject(Injector)},
);
});
});
});
@@ -0,0 +1,133 @@
/**
* @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 {computed, Injector, signal, type Signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {customError} from '../../public_api';
import {disabled, validate} from '../../src/api/logic';
import {applyEach, applyWhen, applyWhenValue, form, schema} from '../../src/api/structure';
import type {Field, Schema} from '../../src/api/types';
interface TreeData {
level: number;
next: TreeData | null;
}
function narrowed<TValue, TNarrowed extends TValue>(
field: Field<TValue> | undefined,
guard: (value: TValue) => value is TNarrowed,
): Signal<Field<TNarrowed> | undefined> {
return computed(
() => field && (guard(field().value()) ? (field as Field<TNarrowed>) : undefined),
);
}
function isNonNull<T>(t: T): t is NonNullable<T> {
return t !== null;
}
describe('recursive schema logic', () => {
it('should support recursive logic', () => {
const s = schema<TreeData>((p) => {
disabled(p.level, ({valueOf}) => {
return valueOf(p.level) % 2 === 0;
});
applyWhenValue(p.next, isNonNull, s);
});
const f = form<TreeData>(
signal({level: 0, next: {level: 1, next: {level: 2, next: {level: 3, next: null}}}}),
s,
{injector: TestBed.inject(Injector)},
);
expect(f.level().disabled()).toBe(true);
expect(narrowed(f.next, isNonNull)()?.level().disabled()).toBe(false);
expect(narrowed(narrowed(f.next, isNonNull)()?.next, isNonNull)()?.level().disabled()).toBe(
true,
);
expect(
narrowed(narrowed(narrowed(f.next, isNonNull)()?.next, isNonNull)()?.next, isNonNull)()
?.level()
.disabled(),
).toBe(false);
});
it('should support co-recursive logic', () => {
const s1: Schema<TreeData> = schema((p) => {
disabled(p.level, ({valueOf}) => valueOf(p.level) % 2 === 0);
applyWhenValue(p.next, isNonNull, s2);
});
const s2: Schema<TreeData> = schema((p) => {
disabled(p.level, ({valueOf}) => valueOf(p.level) % 2 === 0);
applyWhenValue(p.next, isNonNull, s1);
});
const f = form<TreeData>(
signal({
level: 0,
next: {level: 1, next: {level: 2, next: {level: 3, next: null!}}},
}),
s1,
{injector: TestBed.inject(Injector)},
);
expect(f.level().disabled()).toBe(true);
expect(narrowed(f.next, isNonNull)()?.level().disabled()).toBe(false);
expect(narrowed(narrowed(f.next, isNonNull)()?.next, isNonNull)()?.level().disabled()).toBe(
true,
);
expect(
narrowed(narrowed(narrowed(f.next, isNonNull)()?.next, isNonNull)()?.next, isNonNull)()
?.level()
.disabled(),
).toBe(false);
});
it('should support recursive logic with arrays', () => {
interface Dom {
tag: string;
children: Dom[];
}
const domSchema = schema<Dom>((p) => {
applyEach(p.children, domSchema);
applyWhen(
p.children,
({valueOf}) => valueOf(p.tag) === 'table',
(children) => {
applyEach(children, (c) => {
validate(c.tag, ({value}) =>
value() !== 'tr' ? customError({kind: 'invalid-child'}) : undefined,
);
});
},
);
applyWhen(
p.children,
({valueOf}) => valueOf(p.tag) === 'tr',
(children) => {
applyEach(children, (c) => {
validate(c.tag, ({value}) =>
value() !== 'td' ? customError({kind: 'invalid-child'}) : undefined,
);
});
},
);
});
const data = signal<Dom>({tag: 'div', children: [{tag: 'span', children: []}]});
const f = form(data, domSchema, {injector: TestBed.inject(Injector)});
expect(f().valid()).toBe(true);
data.set({tag: 'table', children: [{tag: 'span', children: []}]});
expect(f().valid()).toBe(false);
data.set({tag: 'table', children: [{tag: 'tr', children: [{tag: 'span', children: []}]}]});
expect(f().valid()).toBe(false);
data.set({tag: 'table', children: [{tag: 'tr', children: [{tag: 'td', children: []}]}]});
expect(f().valid()).toBe(true);
});
});
@@ -0,0 +1,289 @@
/**
* @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 {provideHttpClient} from '@angular/common/http';
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
import {ApplicationRef, Injector, resource, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {isNode} from '@angular/private/testing';
import {
applyEach,
customError,
form,
property,
required,
schema,
SchemaOrSchemaFn,
validate,
validateAsync,
validateHttp,
} from '../../public_api';
interface Cat {
name: string;
}
interface Address {
street: string;
city: string;
zip: string;
}
describe('resources', () => {
let appRef: ApplicationRef;
let backend: HttpTestingController;
let injector: Injector;
beforeEach(() => {
globalThis['ngServerMode'] = isNode;
});
afterEach(() => {
globalThis['ngServerMode'] = undefined;
});
beforeEach(() => {
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting()]});
appRef = TestBed.inject(ApplicationRef);
backend = TestBed.inject(HttpTestingController);
injector = TestBed.inject(Injector);
});
it('Takes a simple resource which reacts to data changes', async () => {
const s: SchemaOrSchemaFn<Cat> = function (p) {
const RES = property(p.name, ({value}) => {
return resource({
params: () => ({x: value()}),
loader: async ({params}) => `got: ${params.x}`,
});
});
validate(p.name, ({state}) => {
const remote = state.property(RES)!;
if (remote.hasValue()) {
return customError({message: remote.value()});
} else {
return undefined;
}
});
};
const cat = signal({name: 'cat'});
const f = form(cat, s, {injector});
await appRef.whenStable();
expect(f.name().errors()).toEqual([
customError({
message: 'got: cat',
field: f.name,
}),
]);
f.name().value.set('dog');
await appRef.whenStable();
expect(f.name().errors()).toEqual([
customError({
message: 'got: dog',
field: f.name,
}),
]);
});
it('should create a resource per entry in an array', async () => {
const s: SchemaOrSchemaFn<Cat[]> = function (p) {
applyEach(p, (p) => {
const RES = property(p.name, ({value}) => {
return resource({
params: () => ({x: value()}),
loader: async ({params}) => `got: ${params.x}`,
});
});
validate(p.name, ({state}) => {
const remote = state.property(RES)!;
if (remote.hasValue()) {
return customError({message: remote.value()});
} else {
return undefined;
}
});
});
};
const cat = signal([{name: 'cat'}, {name: 'dog'}]);
const f = form(cat, s, {injector});
await appRef.whenStable();
expect(f[0].name().errors()).toEqual([
customError({
message: 'got: cat',
field: f[0].name,
}),
]);
expect(f[1].name().errors()).toEqual([
customError({
message: 'got: dog',
field: f[1].name,
}),
]);
f[0].name().value.set('bunny');
await appRef.whenStable();
expect(f[0].name().errors()).toEqual([
customError({
message: 'got: bunny',
field: f[0].name,
}),
]);
expect(f[1].name().errors()).toEqual([
customError({
message: 'got: dog',
field: f[1].name,
}),
]);
});
it('should support tree validation for resources', async () => {
const s: SchemaOrSchemaFn<Cat[]> = function (p) {
validateAsync(p, {
params: ({value}) => value(),
factory: (params) =>
resource({
params,
loader: async ({params}) => {
return params as Cat[];
},
}),
errors: (cats, {fieldOf}) => {
return cats.map((cat, index) =>
customError({
kind: 'meows_too_much',
name: cat.name,
field: fieldOf(p)[index],
}),
);
},
});
};
const cats = signal([{name: 'Fluffy'}, {name: 'Ziggy'}]);
const f = form(cats, s, {injector});
await appRef.whenStable();
expect(f[0]().errors()).toEqual([
customError({kind: 'meows_too_much', name: 'Fluffy', field: f[0]}),
]);
expect(f[1]().errors()).toEqual([
customError({kind: 'meows_too_much', name: 'Ziggy', field: f[1]}),
]);
});
it('should support tree validation for resources', async () => {
const s: SchemaOrSchemaFn<Cat[]> = function (p) {
validateAsync(p, {
params: ({value}) => value(),
factory: (params) =>
resource({
params,
loader: async ({params}) => {
return params as Cat[];
},
}),
errors: (cats, {fieldOf}) => {
return customError({
kind: 'meows_too_much',
name: cats[0].name,
field: fieldOf(p)[0],
});
},
});
};
const cats = signal([{name: 'Fluffy'}, {name: 'Ziggy'}]);
const f = form(cats, s, {injector});
await appRef.whenStable();
expect(f[0]().errors()).toEqual([
customError({kind: 'meows_too_much', name: 'Fluffy', field: f[0]}),
]);
expect(f[1]().errors()).toEqual([]);
});
it('should support shorthand http validation', async () => {
const usernameForm = form(
signal('unique-user'),
(p) => {
validateHttp(p, {
request: ({value}) => `/api/check?username=${value()}`,
errors: (available: boolean) =>
available ? undefined : customError({kind: 'username-taken'}),
});
},
{injector},
);
TestBed.tick();
const req1 = backend.expectOne('/api/check?username=unique-user');
expect(usernameForm().valid()).toBe(false);
expect(usernameForm().invalid()).toBe(false);
expect(usernameForm().pending()).toBe(true);
req1.flush(true);
await appRef.whenStable();
expect(usernameForm().valid()).toBe(true);
expect(usernameForm().invalid()).toBe(false);
expect(usernameForm().pending()).toBe(false);
expect(true).toBe(true);
usernameForm().value.set('taken-user');
TestBed.tick();
const req2 = backend.expectOne('/api/check?username=taken-user');
expect(usernameForm().valid()).toBe(false);
expect(usernameForm().invalid()).toBe(false);
expect(usernameForm().pending()).toBe(true);
req2.flush(false);
await appRef.whenStable();
expect(usernameForm().valid()).toBe(false);
expect(usernameForm().invalid()).toBe(true);
expect(usernameForm().pending()).toBe(false);
});
it('should only run async validation when synchronously valid', async () => {
const addressModel = signal<Address>({street: '', city: '', zip: ''});
const addressSchema = schema<Address>((address) => {
required(address.street);
validateHttp(address, {
request: ({value}) => ({url: '/checkaddress', params: {...value()}}),
errors: (message: string, {fieldOf}) =>
customError({message, field: fieldOf(address.street)}),
});
});
const addressForm = form(addressModel, addressSchema, {injector});
TestBed.tick();
backend.expectNone(() => true);
addressForm.street().value.set('123 Main St');
TestBed.tick();
const req = backend.expectOne('/checkaddress?street=123%20Main%20St&city=&zip=');
req.flush('Invalid!');
await appRef.whenStable();
expect(addressForm.street().errors()).toEqual([
customError({message: 'Invalid!', field: addressForm.street}),
]);
});
});
@@ -0,0 +1,295 @@
/**
* @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 {Injector, resource, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {
customError,
form,
required,
requiredError,
submit,
validateAsync,
ValidationError,
} from '../../public_api';
describe('submit', () => {
it('fails fast on invalid form', async () => {
const data = signal({first: '', last: ''});
const f = form(
data,
(name) => {
required(name.first);
},
{injector: TestBed.inject(Injector)},
);
await submit(f, async (form) => {
fail('Submit action should run not on invalid form');
});
expect(f.first().errors()).toEqual([requiredError({field: f.first})]);
});
it('should not block on pending async validators', async () => {
const data = signal('');
const resolvers = promiseWithResolvers();
const f = form(
data,
(p) => {
validateAsync(p, {
params: ({value}) => value(),
factory: (params) =>
resource({
params,
loader: () => resolvers.promise,
}),
errors: () => {},
});
},
{injector: TestBed.inject(Injector)},
);
expect(f().pending()).toBe(true);
const submitSpy = jasmine.createSpy();
await submit(f, submitSpy);
expect(f().pending()).toBe(true);
expect(submitSpy).toHaveBeenCalled();
});
it('maps error to a field', async () => {
const data = signal({first: '', last: ''});
const f = form(
data,
(name) => {
// first name required if last name specified
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
},
{injector: TestBed.inject(Injector)},
);
await submit(f, (form) => {
return Promise.resolve(
customError({
kind: 'lastName',
field: form.last,
}),
);
});
expect(f.last().errors()).toEqual([customError({kind: 'lastName', field: f.last})]);
});
it('maps errors to multiple fields', async () => {
const data = signal({first: '', last: ''});
const f = form(data, {injector: TestBed.inject(Injector)});
await submit(f, (form) => {
return Promise.resolve([
customError({
kind: 'firstName',
field: form.first,
}),
customError({
kind: 'lastName',
field: form.last,
}),
customError({
kind: 'lastName2',
field: form.last,
}),
]);
});
expect(f.first().errors()).toEqual([customError({kind: 'firstName', field: f.first})]);
expect(f.last().errors()).toEqual([
customError({kind: 'lastName', field: f.last}),
customError({kind: 'lastName2', field: f.last}),
]);
});
it('can read value from field state', async () => {
const initialValue = {first: 'meow', last: 'wuf'};
const data = signal(initialValue);
const f = form(
data,
(name) => {
// first name required if last name specified
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
},
{injector: TestBed.inject(Injector)},
);
const submitSpy = jasmine.createSpy('submit');
await submit(f, (form) => {
submitSpy(form().value());
return Promise.resolve();
});
expect(submitSpy).toHaveBeenCalledWith(initialValue);
});
it('maps untargeted errors to form root', async () => {
const data = signal({first: '', last: ''});
const f = form(
data,
(name) => {
// first name required if last name specified
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
},
{injector: TestBed.inject(Injector)},
);
await submit(f, () => {
return Promise.resolve(customError());
});
expect(f().errors()).toEqual([customError({field: f})]);
});
it('marks the form as submitting', async () => {
const initialValue = {first: 'meow', last: 'wuf'};
const data = signal(initialValue);
const f = form(
data,
(name) => {
// first name required if last name specified
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
},
{injector: TestBed.inject(Injector)},
);
expect(f().submitting()).toBe(false);
const {promise, resolve} = promiseWithResolvers<ValidationError[]>();
const result = submit(f, () => promise);
expect(f().submitting()).toBe(true);
resolve([]);
await result;
});
it('marks descendants as submitting', async () => {
const initialValue = {a: {b: 12}};
const data = signal(initialValue);
const f = form(data, {injector: TestBed.inject(Injector)});
expect(f.a.b().submitting()).toBe(false);
const {promise, resolve} = promiseWithResolvers<ValidationError[]>();
const result = submit(f, () => promise);
expect(f.a.b().submitting()).toBe(true);
resolve([]);
await result;
});
it('marks the form as touched', async () => {
const initialValue = {first: 'meow', last: 'wuf'};
const data = signal(initialValue);
const f = form(data, {injector: TestBed.inject(Injector)});
expect(f().touched()).toBe(false);
await submit(f, async () => []);
expect(f().touched()).toBe(true);
});
it('marks descendants as touched', async () => {
const initialValue = {a: {b: 12}};
const data = signal(initialValue);
const f = form(data, {injector: TestBed.inject(Injector)});
expect(f.a.b().touched()).toBe(false);
await submit(f, async () => []);
expect(f.a.b().touched()).toBe(true);
});
it('works on child fields', async () => {
const initialValue = {first: 'meow', last: 'wuf'};
const data = signal(initialValue);
const f = form(
data,
(name) => {
// first name required if last name specified
required(name.first, {
when: ({valueOf}) => valueOf(name.last) !== '',
});
},
{injector: TestBed.inject(Injector)},
);
const submitSpy = jasmine.createSpy('submit');
await submit(f.first, (form) => {
submitSpy(form().value());
return Promise.resolve(customError({kind: 'lastName'}));
});
expect(submitSpy).toHaveBeenCalledWith('meow');
});
it('recovers from errors thrown by submit action', async () => {
const f = form(signal(0), {injector: TestBed.inject(Injector)});
expect(f().submitting()).toBe(false);
const {promise, reject} = promiseWithResolvers<ValidationError[]>();
const submitPromise = submit(f, () => promise);
expect(f().submitting()).toBe(true);
const error = new Error('submit failed');
reject(error);
await expectAsync(submitPromise).toBeRejectedWith(error);
expect(f().submitting()).toBe(false);
});
it('errors are cleared on edit', async () => {
const data = signal({first: '', last: ''});
const f = form(data, {injector: TestBed.inject(Injector)});
await submit(f, async (form) => {
return [
customError({kind: 'submit', field: f.first}),
customError({kind: 'submit', field: f.last}),
];
});
expect(f.first().errors()).toEqual([customError({kind: 'submit', field: f.first})]);
expect(f.last().errors()).toEqual([customError({kind: 'submit', field: f.last})]);
f.first().value.set('Hello');
expect(f.first().errors()).toEqual([]);
expect(f.last().errors()).toEqual([customError({kind: 'submit', field: f.last})]);
});
});
/**
* Replace with `Promise.withResolvers()` once it's available.
*
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers.
*/
function promiseWithResolvers<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: any) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {promise, resolve, reject};
}
@@ -0,0 +1,86 @@
/**
* @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 {WritableSignal} from '@angular/core';
import {form, required, schema, SchemaFn} from '../../public_api';
interface Order {
id: string;
details: {
total: number;
};
}
interface PhoneOrder {
id: string;
details: {
total: number;
model: string;
color: string;
};
}
interface PizzaOrder {
id: string;
details: {
total: number;
toppings: string;
};
}
function typeVerificationOnlyDoNotRunMe() {
describe('types', () => {
it('should apply schema function of exact type', () => {
const pizzaOrder: WritableSignal<PizzaOrder> = null!;
const pizzaOrderSchema: SchemaFn<PizzaOrder> = null!;
form(pizzaOrder, pizzaOrderSchema);
});
it('should apply schema function of partial type', () => {
const pizzaOrder: WritableSignal<PizzaOrder> = null!;
const orderSchema: SchemaFn<Order> = null!;
form(pizzaOrder, orderSchema);
});
it('should not apply schema function of different type', () => {
const pizzaOrder: WritableSignal<PizzaOrder> = null!;
const phoneOrderSchema: SchemaFn<PhoneOrder> = null!;
// @ts-expect-error
form(pizzaOrder, phoneOrderSchema);
});
it('should apply schema of exact type', () => {
const pizzaOrder: WritableSignal<PizzaOrder> = null!;
const pizzaOrderSchema = schema<PizzaOrder>(null!);
form(pizzaOrder, pizzaOrderSchema);
});
it('should apply schema of partial type', () => {
const pizzaOrder: WritableSignal<PizzaOrder> = null!;
const orderSchema = schema<Order>(null!);
form(pizzaOrder, orderSchema);
});
it('should not apply schema of different type', () => {
const pizzaOrder: WritableSignal<PizzaOrder> = null!;
const phoneOrderSchema = schema<PhoneOrder>(null!);
// @ts-expect-error
form(pizzaOrder, phoneOrderSchema);
});
it('should not allow binding logic to a potentially undefined field', () => {
schema<{a: number; b: number | undefined; c?: number}>((p) => {
required(p.a);
// @ts-expect-error
required(p.b);
// @ts-expect-error
required(p.c);
});
});
});
}
@@ -0,0 +1,587 @@
/**
* @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 {ApplicationRef, Injector, Resource, resource, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {
customError,
Field,
form,
NgValidationError,
patternError,
requiredError,
validate,
validateAsync,
validateTree,
ValidationError,
type WithoutField,
} from '../../public_api';
function validateValue(value: string): WithoutField<ValidationError>[] {
return value === 'INVALID' ? [customError()] : [];
}
function validateValueForChild(
value: string,
field: Field<unknown> | undefined,
): ValidationError[] {
return value === 'INVALID' ? [customError({field})] : [];
}
async function waitFor(fn: () => boolean, count = 100): Promise<void> {
while (!fn()) {
await new Promise((resolve) => setTimeout(resolve, 1));
if (--count === 0) {
throw Error('waitFor timeout');
}
}
}
describe('validation status', () => {
let injector: Injector;
let appRef: ApplicationRef;
beforeEach(() => {
injector = TestBed.inject(Injector);
appRef = TestBed.inject(ApplicationRef);
});
describe('single-field validator', () => {
it('should affect field validity', () => {
const f = form(
signal('VALID'),
(p) => {
validate(p, ({value}) => validateValue(value()));
},
{injector},
);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f().value.set('INVALID');
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('validity should flow from child to parent', () => {
const f = form(
signal({child: 'VALID'}),
(p) => {
validate(p.child, ({value}) => validateValue(value()));
},
{injector},
);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f.child().value.set('INVALID');
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('validity should not flow from parent to child', () => {
const f = form(
signal({child: 'VALID'}),
(p) => {
validate(p, ({value}) => validateValue(value().child));
},
{injector},
);
expect(f.child().valid()).toBe(true);
expect(f.child().invalid()).toBe(false);
f.child().value.set('INVALID');
expect(f.child().valid()).toBe(true);
expect(f.child().invalid()).toBe(false);
});
});
describe('tree validator', () => {
it('should affect validity of host field if no target specified', () => {
const f = form(
signal('VALID'),
(p) => {
validateTree(p, ({value}) => validateValueForChild(value(), undefined));
},
{injector},
);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f().value.set('INVALID');
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('should affect validity of targeted field', () => {
const f = form(
signal({child: 'VALID'}),
(p) => {
validateTree(p, ({value, fieldOf}) =>
validateValueForChild(value().child, fieldOf(p.child)),
);
},
{injector},
);
expect(f.child().valid()).toBe(true);
expect(f.child().invalid()).toBe(false);
f.child().value.set('INVALID');
expect(f.child().valid()).toBe(false);
expect(f.child().invalid()).toBe(true);
});
it('validity should flow from child to parent', () => {
const f = form(
signal({child: 'VALID'}),
(p) => {
validateTree(p, ({value, fieldOf}) =>
validateValueForChild(value().child, fieldOf(p.child)),
);
},
{injector},
);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f.child().value.set('INVALID');
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('should not affect sibling validity', () => {
const f = form(
signal({child: 'VALID', sibling: ''}),
(p) => {
validateTree(p, ({value, fieldOf}) =>
validateValueForChild(value().child, fieldOf(p.child)),
);
},
{injector},
);
expect(f.sibling().valid()).toBe(true);
expect(f.sibling().invalid()).toBe(false);
f.child().value.set('INVALID');
expect(f.sibling().valid()).toBe(true);
expect(f.sibling().invalid()).toBe(false);
});
});
describe('async validator', () => {
it('should affect validity of host field if no target specified', async () => {
let res: Resource<unknown>;
const f = form(
signal('VALID'),
(p) => {
validateAsync(p, {
params: ({value}) => value(),
factory: (params) =>
(res = resource({
params,
loader: ({params}) =>
new Promise<ValidationError[]>((r) =>
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs) => errs,
});
},
{injector},
);
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
await appRef.whenStable();
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f().value.set('INVALID');
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
await appRef.whenStable();
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('should affect validity of targeted field', async () => {
let res: Resource<unknown>;
const f = form(
signal({child: 'VALID'}),
(p) => {
validateAsync(p, {
params: ({value}) => value().child,
factory: (params) =>
(res = resource({
params,
loader: ({params}) =>
new Promise<ValidationError[]>((r) =>
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs, {fieldOf}) =>
errs.map((e) => ({
...e,
field: fieldOf(p.child),
})),
});
},
{injector},
);
await waitFor(() => res?.isLoading());
expect(f.child().pending()).toBe(true);
expect(f.child().valid()).toBe(false);
expect(f.child().invalid()).toBe(false);
await appRef.whenStable();
expect(f.child().pending()).toBe(false);
expect(f.child().valid()).toBe(true);
expect(f.child().invalid()).toBe(false);
f.child().value.set('INVALID');
await waitFor(() => res?.isLoading());
expect(f.child().pending()).toBe(true);
expect(f.child().valid()).toBe(false);
expect(f.child().invalid()).toBe(false);
await appRef.whenStable();
expect(f.child().pending()).toBe(false);
expect(f.child().valid()).toBe(false);
expect(f.child().invalid()).toBe(true);
});
it('validity should flow from child to parent', async () => {
let res: Resource<unknown>;
const f = form(
signal({child: 'VALID'}),
(p) => {
validateAsync(p, {
params: ({value}) => value().child,
factory: (params) =>
(res = resource({
params,
loader: ({params}) =>
new Promise<ValidationError[]>((r) =>
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs, {fieldOf}) =>
errs.map((e) => ({
...e,
field: fieldOf(p.child),
})),
});
},
{injector},
);
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
await appRef.whenStable();
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f.child().value.set('INVALID');
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
await appRef.whenStable();
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('pending should flow from parent to child', async () => {
// We can't guarantee the parent won't assign a tree error to the sibling field, so the
// sibling must inherit the pending state from the parent.
let res: Resource<unknown>;
const f = form(
signal({child: 'VALID', sibling: ''}),
(p) => {
validateAsync(p, {
params: ({value}) => value().child,
factory: (params) =>
(res = resource({
params,
loader: ({params}) =>
new Promise<ValidationError[]>((r) =>
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs, {fieldOf}) =>
errs.map((e) => ({
...e,
field: fieldOf(p.child),
})),
});
},
{injector},
);
await waitFor(() => res?.isLoading());
expect(f.sibling().pending()).toBe(true);
expect(f.sibling().valid()).toBe(false);
expect(f.sibling().invalid()).toBe(false);
await appRef.whenStable();
expect(f.sibling().pending()).toBe(false);
expect(f.sibling().valid()).toBe(true);
expect(f.sibling().invalid()).toBe(false);
f.child().value.set('INVALID');
await waitFor(() => res?.isLoading());
expect(f.sibling().pending()).toBe(true);
expect(f.sibling().valid()).toBe(false);
expect(f.sibling().invalid()).toBe(false);
await appRef.whenStable();
expect(f.sibling().pending()).toBe(false);
expect(f.sibling().valid()).toBe(true);
expect(f.sibling().invalid()).toBe(false);
});
it('parent should be pending/invalid while child is pending/invalid', async () => {
let res: Resource<unknown>;
const f = form(
signal({child: 'VALID'}),
(p) => {
validateAsync(p.child, {
params: ({value}) => value(),
factory: (params) =>
(res = resource({
params,
loader: ({params}) =>
new Promise<ValidationError[]>((r) =>
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs) => errs,
});
},
{injector},
);
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
await appRef.whenStable();
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(true);
expect(f().invalid()).toBe(false);
f.child().value.set('INVALID');
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
await appRef.whenStable();
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
});
describe('multiple validators', () => {
it('should be invalid status when validators are mix of valid and invalid', () => {
const f = form(
signal('MIXED'),
(p) => {
validate(p, () => []);
validate(p, () => [customError()]);
},
{injector},
);
expect(f().pending()).toBe(false);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('should be pending status when validators are mix of valid and pending', async () => {
let res: Resource<unknown>;
const f = form(
signal('MIXED'),
(p) => {
validate(p, () => []);
validateAsync(p, {
params: () => [],
factory: (params) =>
(res = resource({
params,
loader: () => new Promise<ValidationError[]>((r) => setTimeout(() => r([]))),
})),
errors: (errs) => errs,
});
},
{injector},
);
await waitFor(() => res?.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(false);
});
it('should be invalid status when validators are mix of invalid and pending', async () => {
let res: Resource<unknown>;
let res2: Resource<unknown>;
const f = form(
signal('MIXED'),
(p) => {
validateAsync(p, {
params: () => [],
factory: (params) =>
(res = resource({
params,
loader: () =>
new Promise<ValidationError[]>((r) => setTimeout(() => r([customError()]))),
})),
errors: (errs) => errs,
});
validateAsync(p, {
params: () => [],
factory: (params) =>
(res2 = resource({
params,
loader: () => new Promise<ValidationError[]>((r) => setTimeout(() => r([]), 10)),
})),
errors: (errs) => errs,
});
},
{injector},
);
await waitFor(() => !res?.isLoading() && res2.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
it('should be invalid status when validators are mix of valid, invalid, and pending', async () => {
let res: Resource<unknown>;
let res2: Resource<unknown>;
const f = form(
signal('MIXED'),
(p) => {
validate(p, () => []);
validateAsync(p, {
params: () => [],
factory: (params) =>
(res = resource({
params,
loader: () =>
new Promise<ValidationError[]>((r) => setTimeout(() => r([customError()]))),
})),
errors: (errs) => errs,
});
validateAsync(p, {
params: () => [],
factory: (params) =>
(res2 = resource({
params,
loader: () => new Promise<ValidationError[]>((r) => setTimeout(() => r([]), 10)),
})),
errors: (errs) => errs,
});
},
{injector},
);
await waitFor(() => !res?.isLoading() && res2.isLoading());
expect(f().pending()).toBe(true);
expect(f().valid()).toBe(false);
expect(f().invalid()).toBe(true);
});
});
describe('NgValidationError', () => {
it('instanceof should check if structure matches a standard error type', () => {
const e1 = requiredError();
expect(e1 instanceof NgValidationError).toBe(true);
const e2 = customError({kind: 'min', min: 'two'});
expect(e2 instanceof NgValidationError).toBe(false);
const e3 = patternError(/.*@.*\.com/);
expect(e3 instanceof NgValidationError).toBe(true);
});
it('instanceof should narrow the type to a discriminated union', () => {
const e: unknown = undefined;
if (e instanceof NgValidationError) {
e.message;
switch (e.kind) {
case 'min':
e.min;
break;
case 'standardSchema':
e.issue;
break;
// @ts-expect-error
case 'fakekind':
break;
}
}
// Just so we have an expectation in the test,
// the real goal is to test the type narrowing above.
expect(true).toBe(true);
});
});
});
@@ -0,0 +1,24 @@
load("//tools:defaults.bzl", "ng_project", "zoneless_web_test_suite")
ng_project(
name = "test_lib",
testonly = True,
srcs = glob(["**/*.spec.ts"]),
deps = [
"//:node_modules/zod",
"//packages/core",
"//packages/core/testing",
"//packages/forms",
"//packages/forms/signals",
"//packages/platform-browser",
"//packages/platform-browser/testing",
"//packages/private/testing",
],
)
zoneless_web_test_suite(
name = "test",
deps = [
":test_lib",
],
)
@@ -0,0 +1,561 @@
/**
* @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 {
Component,
ElementRef,
Injector,
input,
inputBinding,
model,
provideZonelessChangeDetection,
signal,
viewChild,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {
Control,
disabled,
form,
hidden,
max,
MAX,
maxLength,
min,
minLength,
readonly,
required,
type DisabledReason,
type Field,
type FormCheckboxControl,
type FormValueControl,
} from '../../public_api';
@Component({
selector: 'string-control',
template: `<input [control]="control()"/>`,
imports: [Control],
})
class TestStringControl {
readonly control = input.required<Field<string>>();
readonly controlDirective = viewChild.required(Control);
}
describe('control directive', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
});
it('synchronizes a basic form with a custom control', () => {
@Component({
imports: [Control],
template: `
<input [control]="f">
`,
})
class TestCmp {
f = form(signal('test'));
}
const fix = act(() => TestBed.createComponent(TestCmp));
const input = fix.nativeElement.firstChild as HTMLInputElement;
const cmp = fix.componentInstance as TestCmp;
// Initial state
expect(input.value).toBe('test');
// Model -> View
act(() => cmp.f().value.set('testing'));
expect(input.value).toBe('testing');
// View -> Model
act(() => {
input.value = 'typing';
input.dispatchEvent(new Event('input'));
});
expect(cmp.f().value()).toBe('typing');
});
it('synchronizes with a checkbox control', () => {
@Component({
imports: [Control],
template: `<input type="checkbox" [control]="f">`,
})
class TestCmp {
f = form(signal(false));
}
const fix = act(() => TestBed.createComponent(TestCmp));
const input = fix.nativeElement.firstChild as HTMLInputElement;
const cmp = fix.componentInstance as TestCmp;
// Initial state
expect(input.checked).toBe(false);
// Model -> View
act(() => cmp.f().value.set(true));
expect(input.checked).toBe(true);
// View -> Model
act(() => {
input.checked = false;
input.dispatchEvent(new Event('input'));
});
expect(cmp.f().value()).toBe(false);
});
it('synchronizes with a radio group', () => {
const {cmp, inputA, inputB, inputC} = setupRadioGroup();
// All the inputs should have the same name.
expect(inputA.name).toBe('test');
expect(inputB.name).toBe('test');
expect(inputC.name).toBe('test');
// Model -> View
act(() => cmp.f().value.set('c'));
expect(inputA.checked).toBe(false);
expect(inputB.checked).toBe(false);
expect(inputC.checked).toBe(true);
// View -> Model
act(() => {
inputB.click();
});
expect(inputA.checked).toBe(false);
expect(inputB.checked).toBe(true);
expect(inputC.checked).toBe(false);
expect(cmp.f().value()).toBe('b');
});
it('synchronizes with a textarea', () => {
@Component({
imports: [Control],
template: `<textarea #textarea [control]="f"></textarea>`,
})
class TestCmp {
f = form(signal(''));
textarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('textarea');
}
const fix = act(() => TestBed.createComponent(TestCmp));
const textarea = fix.componentInstance.textarea().nativeElement;
const cmp = fix.componentInstance as TestCmp;
expect(textarea.value).toEqual('');
// Model -> View
act(() => cmp.f().value.set('hello'));
expect(textarea.value).toEqual('hello');
// View -> Model
act(() => {
textarea.value = 'hi';
textarea.dispatchEvent(new Event('input'));
});
expect(cmp.f().value()).toBe('hi');
});
it('synchronizes with a select', () => {
@Component({
imports: [Control],
template: `
<select #select [control]="f">
<option value="one">One</option>
<option value="two">Two</option>
<option value="three">Three</option>
</select>
`,
})
class TestCmp {
f = form(signal('invalid'));
select = viewChild.required<ElementRef<HTMLSelectElement>>('select');
}
const fix = act(() => TestBed.createComponent(TestCmp));
const select = fix.componentInstance.select().nativeElement;
const cmp = fix.componentInstance as TestCmp;
expect(select.value).toEqual('');
// Model -> View
act(() => cmp.f().value.set('one'));
expect(select.value).toEqual('one');
// View -> Model
act(() => {
select.value = 'two';
select.dispatchEvent(new Event('input'));
});
expect(cmp.f().value()).toBe('two');
});
it('should assign correct value when unhiding select', () => {
@Component({
imports: [Control],
template: `
@if (!f().hidden()) {
<select #select [control]="f">
@for(opt of options; track opt) {
<option [value]="opt">{{opt}}</option>
}
</select>
}
`,
})
class TestCmp {
f = form(signal(''), (p) => hidden(p, ({value}) => value() === ''));
select = viewChild<ElementRef<HTMLSelectElement>>('select');
options = ['one', 'two', 'three'];
}
const fix = act(() => TestBed.createComponent(TestCmp));
const cmp = fix.componentInstance as TestCmp;
expect(fix.componentInstance.select()).toBeUndefined();
act(() => cmp.f().value.set('two'));
expect(fix.componentInstance.select()).not.toBeUndefined();
expect(fix.componentInstance.select()!.nativeElement.value).toEqual('two');
});
it('synchronizes with a custom value control', () => {
@Component({
selector: 'my-input',
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
})
class CustomInput implements FormValueControl<string> {
value = model('');
}
@Component({
imports: [Control, CustomInput],
template: `<my-input [control]="f" />`,
})
class TestCmp {
f = form<string>(signal('test'));
}
const fix = act(() => TestBed.createComponent(TestCmp));
const input = fix.nativeElement.firstChild.firstChild as HTMLInputElement;
const cmp = fix.componentInstance as TestCmp;
// Initial state
expect(input.value).toBe('test');
// Model -> View
act(() => cmp.f().value.set('testing'));
expect(input.value).toBe('testing');
// View -> Model
act(() => {
input.value = 'typing';
input.dispatchEvent(new Event('input'));
});
expect(cmp.f().value()).toBe('typing');
});
it('initializes a required value input before the component lifecycle runs', () => {
let initialValue: string | undefined = undefined;
@Component({
selector: 'my-input',
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
})
class CustomInput implements FormValueControl<string> {
value = model.required<string>();
ngOnInit(): void {
initialValue = this.value();
}
}
@Component({
imports: [Control, CustomInput],
template: `<my-input [control]="f" value />`,
})
class TestCmp {
f = form<string>(signal('test'));
}
const fix = act(() => TestBed.createComponent(TestCmp));
expect(initialValue as string | undefined).toBe('test');
});
it('synchronizes with a custom checkbox control', () => {
@Component({
selector: 'my-input',
template:
'<input #i type="checkbox" [checked]="checked()" (input)="checked.set(i.checked)" />',
})
class CustomInput implements FormCheckboxControl {
checked = model(false);
}
@Component({
imports: [Control, CustomInput],
template: `<my-input [control]="f" />`,
})
class TestCmp {
f = form(signal(false));
}
const fix = act(() => TestBed.createComponent(TestCmp));
const input = fix.nativeElement.firstChild.firstChild as HTMLInputElement;
const cmp = fix.componentInstance as TestCmp;
// Initial state
expect(input.checked).toBe(false);
// Model -> View
act(() => cmp.f().value.set(true));
expect(input.checked).toBe(true);
// View -> Model
act(() => {
input.checked = false;
input.dispatchEvent(new Event('input'));
});
expect(cmp.f().value()).toBe(false);
});
it('does not interfere with a component which accepts a control input directly', () => {
@Component({
selector: 'my-wrapper',
template: `{{ control()().value() }}`,
})
class WrapperCmp {
readonly control = input.required<Field<string>>();
}
@Component({
template: `<my-wrapper [control]="f" />`,
imports: [WrapperCmp, Control],
})
class TestCmp {
f = form(signal('test'));
}
const el = act(() => TestBed.createComponent(TestCmp)).nativeElement;
expect(el.textContent).toBe('test');
});
it('should update bound controls on the field when it is bound and unbound', async () => {
const f = form(signal(''), {injector: TestBed.inject(Injector)});
expect(f().controls()).toEqual([]);
const fixture = act(() =>
TestBed.createComponent(TestStringControl, {
bindings: [inputBinding('control', () => f)],
}),
);
expect(f().controls()).toEqual([fixture.componentInstance.controlDirective()]);
act(() => fixture.destroy());
expect(f().controls()).toEqual([]);
});
it('should track multiple bound controls per field', async () => {
const f = form(signal(''), {injector: TestBed.inject(Injector)});
const fixture1 = act(() =>
TestBed.createComponent(TestStringControl, {
bindings: [inputBinding('control', () => f)],
}),
);
const fixture2 = act(() =>
TestBed.createComponent(TestStringControl, {
bindings: [inputBinding('control', () => f)],
}),
);
expect(f().controls()).toEqual([
fixture1.componentInstance.controlDirective(),
fixture2.componentInstance.controlDirective(),
]);
});
it('should update bound controls on both fields when field binding changes', async () => {
const f1 = form(signal(''), {injector: TestBed.inject(Injector)});
const f2 = form(signal(''), {injector: TestBed.inject(Injector)});
const control = signal(f1);
const fixture = act(() =>
TestBed.createComponent(TestStringControl, {
bindings: [inputBinding('control', control)],
}),
);
expect(f1().controls()).toEqual([fixture.componentInstance.controlDirective()]);
expect(f2().controls()).toEqual([]);
act(() => control.set(f2));
expect(f1().controls()).toEqual([]);
expect(f2().controls()).toEqual([fixture.componentInstance.controlDirective()]);
});
it('should synchronize custom properties', () => {
@Component({
template: `
<input #text type="text" [control]="f.text">
<input #number type="number" [control]="f.number">
`,
imports: [Control],
})
class CustomPropsTestCmp {
textInput = viewChild.required<ElementRef<HTMLInputElement>>('text');
numberInput = viewChild.required<ElementRef<HTMLInputElement>>('number');
data = signal({
number: 0,
text: '',
});
f = form(this.data, (p) => {
required(p.text);
minLength(p.text, 0);
maxLength(p.text, 100);
min(p.number, 0);
max(p.number, 100);
});
}
const comp = act(() => TestBed.createComponent(CustomPropsTestCmp)).componentInstance;
expect(comp.f.number().property(MAX)()).toBe(100);
expect(comp.textInput().nativeElement.required).toBe(true);
expect(comp.textInput().nativeElement.minLength).toBe(0);
expect(comp.textInput().nativeElement.maxLength).toBe(100);
expect(comp.numberInput().nativeElement.required).toBe(false);
expect(comp.numberInput().nativeElement.min).toBe('0');
expect(comp.numberInput().nativeElement.max).toBe('100');
});
it('should synchronize readonly', () => {
@Component({
template: `
<input #text type="text" [control]="f">
`,
imports: [Control],
})
class ReadonlyTestCmp {
textInput = viewChild.required<ElementRef<HTMLInputElement>>('text');
data = signal('');
f = form(this.data, (p) => {
readonly(p);
});
}
const comp = act(() => TestBed.createComponent(ReadonlyTestCmp)).componentInstance;
expect(comp.textInput().nativeElement.readOnly).toBe(true);
});
it('should synchronize disabled reasons', () => {
@Component({
selector: 'my-input',
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
})
class CustomInput implements FormValueControl<string> {
value = model('');
disabledReasons = input<readonly DisabledReason[]>([]);
}
@Component({
template: `
<my-input [control]="f" />
`,
imports: [CustomInput, Control],
})
class ReadonlyTestCmp {
myInput = viewChild.required<CustomInput>(CustomInput);
data = signal('');
f = form(this.data, (p) => {
disabled(p, () => 'Currently unavailable');
});
}
const comp = act(() => TestBed.createComponent(ReadonlyTestCmp)).componentInstance;
expect(comp.myInput().disabledReasons()).toEqual([
{message: 'Currently unavailable', field: comp.f},
]);
});
it('should synchronize with custom control touched status', () => {
@Component({
selector: 'my-input',
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
})
class CustomInput implements FormValueControl<string> {
value = model('');
touched = model(false);
touchIt() {
this.touched.set(true);
}
}
@Component({
imports: [Control, CustomInput],
template: `<my-input [control]="f" />`,
})
class TestCmp {
f = form<string>(signal('test'));
myInput = viewChild.required(CustomInput);
}
const fix = act(() => TestBed.createComponent(TestCmp));
const field = fix.componentInstance.f;
const myInput = fix.componentInstance.myInput();
// Initial state
expect(field().touched()).toBe(false);
// View -> Model
act(() => {
myInput.touchIt();
});
expect(field().touched()).toBe(true);
// Model -> View
act(() => field().reset());
expect(myInput.touched()).toBe(false);
});
});
function setupRadioGroup() {
@Component({
imports: [Control],
template: `
<form>
<input type="radio" value="a" [control]="f">
<input type="radio" value="b" [control]="f">
<input type="radio" value="c" [control]="f">
</form>
`,
})
class TestCmp {
f = form(signal('a'), {
name: 'test',
});
}
const fix = act(() => TestBed.createComponent(TestCmp));
const formEl = (fix.nativeElement as HTMLElement).firstChild as HTMLFormElement;
const inputs = Array.from(formEl.children) as HTMLInputElement[];
const [inputA, inputB, inputC] = inputs;
const cmp = fix.componentInstance as TestCmp;
return {cmp, inputA, inputB, inputC};
}
function act<T>(fn: () => T): T {
try {
return fn();
} finally {
TestBed.tick();
}
}
+16
View File
@@ -123,6 +123,9 @@ importers:
'@schematics/angular':
specifier: 21.0.0-next.0
version: 21.0.0-next.0(chokidar@4.0.3)
'@standard-schema/spec':
specifier: ^1.0.0
version: 1.0.0
'@types/angular':
specifier: ^1.6.47
version: 1.8.9
@@ -517,6 +520,9 @@ importers:
vrsource-tslint-rules:
specifier: 6.0.0
version: 6.0.0(tslint@6.1.3(typescript@5.9.2))(typescript@5.9.2)
zod:
specifier: ^4.0.10
version: 4.1.3
adev:
dependencies:
@@ -3804,6 +3810,9 @@ packages:
'@stackblitz/sdk@1.11.0':
resolution: {integrity: sha512-DFQGANNkEZRzFk1/rDP6TcFdM82ycHE+zfl9C/M/jXlH68jiqHWHFMQURLELoD8koxvu/eW5uhg94NSAZlYrUQ==}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@szmarczak/http-timer@4.0.6':
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
@@ -11346,6 +11355,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.1.3:
resolution: {integrity: sha512-1neef4bMce1hNTrxvHVKxWjKfGDn0oAli3Wy1Uwb7TRO1+wEwoZUZNP1NXIEESybOBiFnBOhI6a4m6tCLE8dog==}
zone.js@0.15.1:
resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==}
@@ -15010,6 +15022,8 @@ snapshots:
'@stackblitz/sdk@1.11.0': {}
'@standard-schema/spec@1.0.0': {}
'@szmarczak/http-timer@4.0.6':
dependencies:
defer-to-connect: 2.0.1
@@ -24031,6 +24045,8 @@ snapshots:
zod@3.25.76: {}
zod@4.1.3: {}
zone.js@0.15.1: {}
zwitch@2.0.4: {}