From d29ced91a1143a822be9fa95baccc9f75388af1b Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 10 Dec 2024 01:16:22 +0000 Subject: [PATCH 01/80] refactor: add experimental forms package Co-authored-by: Alex Rickabaugh --- contributing-docs/public-api-surface.md | 5 +-- packages/forms-experimental/BUILD.bazel | 39 +++++++++++++++++++++++ packages/forms-experimental/PACKAGE.md | 31 ++++++++++++++++++ packages/forms-experimental/index.ts | 14 ++++++++ packages/forms-experimental/package.json | 26 +++++++++++++++ packages/forms-experimental/public_api.ts | 16 ++++++++++ packages/forms-experimental/src/forms.ts | 9 ++++++ 7 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 packages/forms-experimental/BUILD.bazel create mode 100644 packages/forms-experimental/PACKAGE.md create mode 100644 packages/forms-experimental/index.ts create mode 100644 packages/forms-experimental/package.json create mode 100644 packages/forms-experimental/public_api.ts create mode 100644 packages/forms-experimental/src/forms.ts diff --git a/contributing-docs/public-api-surface.md b/contributing-docs/public-api-surface.md index 1d2d4677e458..23ea6d6a6758 100644 --- a/contributing-docs/public-api-surface.md +++ b/contributing-docs/public-api-surface.md @@ -41,6 +41,7 @@ We explicitly consider the following to be _excluded_ from the public API: reference. - The contents and API surface of the code generated by Angular's compiler. - The `@angular/core/primitives` package, including its descendant entry-points. +- The `@angular/forms/experimental` package, including its descendant entry-points. Our peer dependencies (such as TypeScript, Zone.js, or RxJS) are not considered part of our API surface, but they are included in our SemVer policies. We might update the required version of these @@ -62,8 +63,8 @@ change outside major releases. ## Golden files -Angular tracks the status of the public API in a *golden file*, maintained with a tool called the -*public API guard*. +Angular tracks the status of the public API in a _golden file_, maintained with a tool called the +_public API guard_. If you modify any part of a public API in one of the supported public packages, the PR will fail a test in CI with an error message that instructs you to accept the golden file. diff --git a/packages/forms-experimental/BUILD.bazel b/packages/forms-experimental/BUILD.bazel new file mode 100644 index 000000000000..f9232a74ec32 --- /dev/null +++ b/packages/forms-experimental/BUILD.bazel @@ -0,0 +1,39 @@ +load("//tools:defaults.bzl", "ng_module", "ng_package") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "forms-experimental", + srcs = glob( + [ + "*.ts", + "src/**/*.ts", + ], + ), + deps = [ + "//packages/core", + "//packages/forms", + ], +) + +ng_package( + name = "npm_package", + package_name = "@angular/forms/experimental", + srcs = ["package.json"], + tags = [ + "release-with-framework", + ], + # Do not add more to this list. + # Dependencies on the full npm_package cause long re-builds. + visibility = [ + "//adev:__pkg__", + "//integration:__subpackages__", + "//modules/ssr-benchmarks:__pkg__", + "//packages/compiler-cli/integrationtest:__pkg__", + "//packages/compiler-cli/test/diagnostics:__pkg__", + "//packages/language-service/test:__pkg__", + ], + deps = [ + ":forms-experimental", + ], +) diff --git a/packages/forms-experimental/PACKAGE.md b/packages/forms-experimental/PACKAGE.md new file mode 100644 index 000000000000..8ff1156f7a9a --- /dev/null +++ b/packages/forms-experimental/PACKAGE.md @@ -0,0 +1,31 @@ +# 🚧 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`. + +## 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! + +### How can I follow what's happening? + +Our [Github project tracker](https://github.com/orgs/angular/projects/60) is where we track the active work. diff --git a/packages/forms-experimental/index.ts b/packages/forms-experimental/index.ts new file mode 100644 index 000000000000..ffb1b7aab415 --- /dev/null +++ b/packages/forms-experimental/index.ts @@ -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'; diff --git a/packages/forms-experimental/package.json b/packages/forms-experimental/package.json new file mode 100644 index 000000000000..26c31a14b59e --- /dev/null +++ b/packages/forms-experimental/package.json @@ -0,0 +1,26 @@ +{ + "name": "@angular/forms-experimental", + "version": "0.0.0-PLACEHOLDER", + "description": "Angular - experimental prototype of signal-based forms", + "author": "angular", + "license": "MIT", + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": "0.0.0-PLACEHOLDER", + "@angular/forms": "0.0.0-PLACEHOLDER" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular/angular.git", + "directory": "packages/forms-experimental" + }, + "ng-update": { + "packageGroup": "NG_UPDATE_PACKAGE_GROUP" + }, + "sideEffects": false +} diff --git a/packages/forms-experimental/public_api.ts b/packages/forms-experimental/public_api.ts new file mode 100644 index 000000000000..f1a5895d529d --- /dev/null +++ b/packages/forms-experimental/public_api.ts @@ -0,0 +1,16 @@ +/** + * @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/forms'; + +// This file only reexports content of the `src` folder. Keep it that way. diff --git a/packages/forms-experimental/src/forms.ts b/packages/forms-experimental/src/forms.ts new file mode 100644 index 000000000000..850a82bb35de --- /dev/null +++ b/packages/forms-experimental/src/forms.ts @@ -0,0 +1,9 @@ +/** + * @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 const unused = true; From 78505746eeebd197bd7651ea711fd02ac089feb8 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 10 Dec 2024 01:16:22 +0000 Subject: [PATCH 02/80] refactor: add experimental forms package Co-authored-by: Alex Rickabaugh --- packages/forms/experimental/BUILD.bazel | 39 +++++++++++++++++++++++ packages/forms/experimental/PACKAGE.md | 31 ++++++++++++++++++ packages/forms/experimental/index.ts | 14 ++++++++ packages/forms/experimental/public_api.ts | 16 ++++++++++ packages/forms/experimental/src/forms.ts | 9 ++++++ 5 files changed, 109 insertions(+) create mode 100644 packages/forms/experimental/BUILD.bazel create mode 100644 packages/forms/experimental/PACKAGE.md create mode 100644 packages/forms/experimental/index.ts create mode 100644 packages/forms/experimental/public_api.ts create mode 100644 packages/forms/experimental/src/forms.ts diff --git a/packages/forms/experimental/BUILD.bazel b/packages/forms/experimental/BUILD.bazel new file mode 100644 index 000000000000..14e07bbdb80e --- /dev/null +++ b/packages/forms/experimental/BUILD.bazel @@ -0,0 +1,39 @@ +load("//tools:defaults.bzl", "ng_module", "ng_package") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "experimental", + srcs = glob( + [ + "*.ts", + "src/**/*.ts", + ], + ), + deps = [ + "//packages/core", + "//packages/forms", + ], +) + +ng_package( + name = "npm_package", + package_name = "@angular/forms/experimental", + srcs = ["package.json"], + tags = [ + "release-with-framework", + ], + # Do not add more to this list. + # Dependencies on the full npm_package cause long re-builds. + visibility = [ + "//adev:__pkg__", + "//integration:__subpackages__", + "//modules/ssr-benchmarks:__pkg__", + "//packages/compiler-cli/integrationtest:__pkg__", + "//packages/compiler-cli/test/diagnostics:__pkg__", + "//packages/language-service/test:__pkg__", + ], + deps = [ + ":experimental", + ], +) diff --git a/packages/forms/experimental/PACKAGE.md b/packages/forms/experimental/PACKAGE.md new file mode 100644 index 000000000000..8ff1156f7a9a --- /dev/null +++ b/packages/forms/experimental/PACKAGE.md @@ -0,0 +1,31 @@ +# 🚧 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`. + +## 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! + +### How can I follow what's happening? + +Our [Github project tracker](https://github.com/orgs/angular/projects/60) is where we track the active work. diff --git a/packages/forms/experimental/index.ts b/packages/forms/experimental/index.ts new file mode 100644 index 000000000000..ffb1b7aab415 --- /dev/null +++ b/packages/forms/experimental/index.ts @@ -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'; diff --git a/packages/forms/experimental/public_api.ts b/packages/forms/experimental/public_api.ts new file mode 100644 index 000000000000..f1a5895d529d --- /dev/null +++ b/packages/forms/experimental/public_api.ts @@ -0,0 +1,16 @@ +/** + * @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/forms'; + +// This file only reexports content of the `src` folder. Keep it that way. diff --git a/packages/forms/experimental/src/forms.ts b/packages/forms/experimental/src/forms.ts new file mode 100644 index 000000000000..850a82bb35de --- /dev/null +++ b/packages/forms/experimental/src/forms.ts @@ -0,0 +1,9 @@ +/** + * @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 const unused = true; From 8550bd068343bc6882a56f2627a996ba13eb3eb5 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 10 Dec 2024 21:19:08 +0000 Subject: [PATCH 03/80] refactor: remove forms-experimental, moved to forms/experimental --- packages/forms-experimental/BUILD.bazel | 39 ----------------------- packages/forms-experimental/PACKAGE.md | 31 ------------------ packages/forms-experimental/index.ts | 14 -------- packages/forms-experimental/package.json | 26 --------------- packages/forms-experimental/public_api.ts | 16 ---------- packages/forms-experimental/src/forms.ts | 9 ------ 6 files changed, 135 deletions(-) delete mode 100644 packages/forms-experimental/BUILD.bazel delete mode 100644 packages/forms-experimental/PACKAGE.md delete mode 100644 packages/forms-experimental/index.ts delete mode 100644 packages/forms-experimental/package.json delete mode 100644 packages/forms-experimental/public_api.ts delete mode 100644 packages/forms-experimental/src/forms.ts diff --git a/packages/forms-experimental/BUILD.bazel b/packages/forms-experimental/BUILD.bazel deleted file mode 100644 index f9232a74ec32..000000000000 --- a/packages/forms-experimental/BUILD.bazel +++ /dev/null @@ -1,39 +0,0 @@ -load("//tools:defaults.bzl", "ng_module", "ng_package") - -package(default_visibility = ["//visibility:public"]) - -ng_module( - name = "forms-experimental", - srcs = glob( - [ - "*.ts", - "src/**/*.ts", - ], - ), - deps = [ - "//packages/core", - "//packages/forms", - ], -) - -ng_package( - name = "npm_package", - package_name = "@angular/forms/experimental", - srcs = ["package.json"], - tags = [ - "release-with-framework", - ], - # Do not add more to this list. - # Dependencies on the full npm_package cause long re-builds. - visibility = [ - "//adev:__pkg__", - "//integration:__subpackages__", - "//modules/ssr-benchmarks:__pkg__", - "//packages/compiler-cli/integrationtest:__pkg__", - "//packages/compiler-cli/test/diagnostics:__pkg__", - "//packages/language-service/test:__pkg__", - ], - deps = [ - ":forms-experimental", - ], -) diff --git a/packages/forms-experimental/PACKAGE.md b/packages/forms-experimental/PACKAGE.md deleted file mode 100644 index 8ff1156f7a9a..000000000000 --- a/packages/forms-experimental/PACKAGE.md +++ /dev/null @@ -1,31 +0,0 @@ -# 🚧 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`. - -## 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! - -### How can I follow what's happening? - -Our [Github project tracker](https://github.com/orgs/angular/projects/60) is where we track the active work. diff --git a/packages/forms-experimental/index.ts b/packages/forms-experimental/index.ts deleted file mode 100644 index ffb1b7aab415..000000000000 --- a/packages/forms-experimental/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @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'; diff --git a/packages/forms-experimental/package.json b/packages/forms-experimental/package.json deleted file mode 100644 index 26c31a14b59e..000000000000 --- a/packages/forms-experimental/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@angular/forms-experimental", - "version": "0.0.0-PLACEHOLDER", - "description": "Angular - experimental prototype of signal-based forms", - "author": "angular", - "license": "MIT", - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "dependencies": { - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/core": "0.0.0-PLACEHOLDER", - "@angular/forms": "0.0.0-PLACEHOLDER" - }, - "repository": { - "type": "git", - "url": "https://github.com/angular/angular.git", - "directory": "packages/forms-experimental" - }, - "ng-update": { - "packageGroup": "NG_UPDATE_PACKAGE_GROUP" - }, - "sideEffects": false -} diff --git a/packages/forms-experimental/public_api.ts b/packages/forms-experimental/public_api.ts deleted file mode 100644 index f1a5895d529d..000000000000 --- a/packages/forms-experimental/public_api.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @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/forms'; - -// This file only reexports content of the `src` folder. Keep it that way. diff --git a/packages/forms-experimental/src/forms.ts b/packages/forms-experimental/src/forms.ts deleted file mode 100644 index 850a82bb35de..000000000000 --- a/packages/forms-experimental/src/forms.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @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 const unused = true; From 139a4afdbde74c5a10624560c896cdecd82d9955 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Tue, 10 Dec 2024 14:18:56 -0800 Subject: [PATCH 04/80] refactor: move PACKAGE.md to README.md and include entrypoint in build --- packages/forms/BUILD.bazel | 14 ++++++++++---- .../forms/experimental/{PACKAGE.md => README.md} | 0 2 files changed, 10 insertions(+), 4 deletions(-) rename packages/forms/experimental/{PACKAGE.md => README.md} (100%) diff --git a/packages/forms/BUILD.bazel b/packages/forms/BUILD.bazel index 8dec2137fd2b..be6866114431 100644 --- a/packages/forms/BUILD.bazel +++ b/packages/forms/BUILD.bazel @@ -36,6 +36,7 @@ ng_package( ], deps = [ ":forms", + "//packages/forms/experimental", ], ) @@ -61,10 +62,15 @@ api_golden_test( filegroup( name = "files_for_docgen", - srcs = glob([ - "*.ts", - "src/**/*.ts", - ]) + ["PACKAGE.md"], + srcs = glob( + [ + "*.ts", + "src/**/*.ts", + ], + exclude = [ + "experimental/**/*.ts", + ], + ) + ["PACKAGE.md"], ) generate_api_docs( diff --git a/packages/forms/experimental/PACKAGE.md b/packages/forms/experimental/README.md similarity index 100% rename from packages/forms/experimental/PACKAGE.md rename to packages/forms/experimental/README.md From 405f2f0a410d7c4e56914c961455830ad2ff2552 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 10 Dec 2024 23:39:25 +0000 Subject: [PATCH 05/80] refactor: noop impl of initial concept --- packages/forms/experimental/public_api.ts | 2 +- packages/forms/experimental/src/forms.ts | 9 -- packages/forms/experimental/src/idea1.ts | 175 ++++++++++++++++++++++ 3 files changed, 176 insertions(+), 10 deletions(-) delete mode 100644 packages/forms/experimental/src/forms.ts create mode 100644 packages/forms/experimental/src/idea1.ts diff --git a/packages/forms/experimental/public_api.ts b/packages/forms/experimental/public_api.ts index f1a5895d529d..285a3fafbd6a 100644 --- a/packages/forms/experimental/public_api.ts +++ b/packages/forms/experimental/public_api.ts @@ -11,6 +11,6 @@ * @description * Entry point for all public APIs of this package. */ -export * from './src/forms'; +export const unused = true; // This file only reexports content of the `src` folder. Keep it that way. diff --git a/packages/forms/experimental/src/forms.ts b/packages/forms/experimental/src/forms.ts deleted file mode 100644 index 850a82bb35de..000000000000 --- a/packages/forms/experimental/src/forms.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @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 const unused = true; diff --git a/packages/forms/experimental/src/idea1.ts b/packages/forms/experimental/src/idea1.ts new file mode 100644 index 000000000000..7f5e43a67f11 --- /dev/null +++ b/packages/forms/experimental/src/idea1.ts @@ -0,0 +1,175 @@ +import {signal, Signal, WritableSignal} from '@angular/core'; + +// No-op implementation that demonstrates the API shape. + +/** + * A node in the Form where we can get the value, validity, etc. This could be: + * - The root form itself + * - A nested sub-form + * - A leaf input field + */ +interface FormNode { + $: FormField; +} + +/** + * A data object that can be wrapped in a form. + */ +type FormableObject = Record & {[K in keyof FormNode]?: never}; + +/** + * Represents a complete form with the ability to access the value, validity, etc. at any sub-field. + */ +type Form = T extends FormableObject + ? {[K in keyof Omit>]: Form & FormNode} & FormNode + : FormNode; + +/** + * Represents a field in a Form and gives access to the value, validity, etc. of that field. This + * could be a leaf-field, or some grouping field within the form. + */ +interface FormField extends WritableSignal { + valid: Signal; + errors: Signal; + required: Signal; + disabled: Signal; + readonly: Signal; + hidden: Signal; +} + +/** + * A schema used to define the logic for a Form. + */ +type FormSchema = Form> = FormFieldSchema & { + [K in keyof Omit>]: T[K] extends FormableObject + ? FormSchema + : FormFieldSchema; +}; + +/** + * A schema used to define the logic for a field in a Form. + */ +interface FormFieldSchema> { + logic: Partial>[]; +} + +/** + * Defines the logic for determing a field's value, validity, etc. + */ +interface FormLogic> { + value: (form: F) => T; + errors: (form: F) => string[] | boolean | null; + required: (form: F) => boolean; + disabled: (form: F) => string | boolean | null; + readonly: (form: F) => string | boolean | null; + hidden: (form: F) => boolean; +} + +/** + * Functions to define pieces of FormSchema. + */ +function form = Form>( + ...args: [...FormLogic[], Omit, 'logic'>] +): FormSchema { + return undefined!; +} +function field>( + ...logic: Partial>[] +): FormFieldSchema { + return undefined!; +} +function value>(v: T | ((form: F) => T)): FormLogic { + return undefined!; +} +function valid>( + v: string | boolean | ((form: F) => string | boolean | null), +): FormLogic { + return undefined!; +} +function required>( + v: string | boolean | ((form: F) => string | boolean | null), +): FormLogic { + return undefined!; +} +function hidden>( + v: boolean | ((form: F) => boolean | null), +): FormLogic { + return undefined!; +} + +/** + * Includes a separately defined FormSchema into another FormSchema. + */ +function include>( + schema: FormSchema>, +): FormSchema; +function include>( + schema: FormSchema>, + // TODO: should optionally take form-level logic & `Partial<>` should be deep. + augmentSchema: Partial, 'logic'>>, +): FormSchema; +function include(...args: any[]) { + return undefined!; +} + +/** + * Creates a Form from a FormSchema. + */ +function create(schema: FormSchema>): Form { + return undefined!; +} + +// Example usage: + +const nameSchema = form<{first: string; last: string}>({ + first: field(), + last: field(), +}); + +const dateSchema = form<{year: number; month: number; day: number}>({ + year: field(), + month: field( + valid((m) => (m.month.$() < 1 || m.month.$() > 12 ? 'Month must be between 1 and 12' : null)), + ), + day: field( + valid((m) => + m.day.$() < 1 || m.day.$() > (m.month.$() === 2 ? 29 : 31) + ? 'Date must be between 1 and 31' + : null, + ), + ), +}); + +const shouldCollectPhoneNum = signal(true); + +const userSchema = form<{ + name: {first: string; last: string}; + birthdate: {year: number; month: number; day: number}; + phone: {area: string; prefix: string; line: string}; +}>({ + name: include(nameSchema), + birthdate: include(dateSchema, { + year: field( + value(1990), + required('Year is required'), + valid((m) => + m.birthdate.year.$() > new Date().getFullYear() - 18 ? 'Must be 18 or older' : null, + ), + ), + month: field(required('Month is required')), + }), + phone: form( + hidden(() => !shouldCollectPhoneNum()), + { + area: field(), + prefix: field(), + line: field(), + }, + ), +}); + +const userForm = create(userSchema); +userForm.birthdate.year.$() === 2000; +userForm.$.valid() === true; +userForm.name.$.set({first: 'Bob', last: 'Loblaw'}); +userForm.phone.line.$.hidden() === true; From 8e830f942f12e27e0c7c165aa39a3fbc7e92ab97 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Thu, 12 Dec 2024 22:25:29 +0000 Subject: [PATCH 06/80] refactor: cleanup idea1 and add notes --- packages/forms/experimental/src/idea1.ts | 175 ------------------ .../forms/experimental/src/idea1/README.md | 47 +++++ .../experimental/src/idea1/example-use.ts | 67 +++++++ packages/forms/experimental/src/idea1/form.ts | 59 ++++++ .../forms/experimental/src/idea1/schema.ts | 96 ++++++++++ 5 files changed, 269 insertions(+), 175 deletions(-) delete mode 100644 packages/forms/experimental/src/idea1.ts create mode 100644 packages/forms/experimental/src/idea1/README.md create mode 100644 packages/forms/experimental/src/idea1/example-use.ts create mode 100644 packages/forms/experimental/src/idea1/form.ts create mode 100644 packages/forms/experimental/src/idea1/schema.ts diff --git a/packages/forms/experimental/src/idea1.ts b/packages/forms/experimental/src/idea1.ts deleted file mode 100644 index 7f5e43a67f11..000000000000 --- a/packages/forms/experimental/src/idea1.ts +++ /dev/null @@ -1,175 +0,0 @@ -import {signal, Signal, WritableSignal} from '@angular/core'; - -// No-op implementation that demonstrates the API shape. - -/** - * A node in the Form where we can get the value, validity, etc. This could be: - * - The root form itself - * - A nested sub-form - * - A leaf input field - */ -interface FormNode { - $: FormField; -} - -/** - * A data object that can be wrapped in a form. - */ -type FormableObject = Record & {[K in keyof FormNode]?: never}; - -/** - * Represents a complete form with the ability to access the value, validity, etc. at any sub-field. - */ -type Form = T extends FormableObject - ? {[K in keyof Omit>]: Form & FormNode} & FormNode - : FormNode; - -/** - * Represents a field in a Form and gives access to the value, validity, etc. of that field. This - * could be a leaf-field, or some grouping field within the form. - */ -interface FormField extends WritableSignal { - valid: Signal; - errors: Signal; - required: Signal; - disabled: Signal; - readonly: Signal; - hidden: Signal; -} - -/** - * A schema used to define the logic for a Form. - */ -type FormSchema = Form> = FormFieldSchema & { - [K in keyof Omit>]: T[K] extends FormableObject - ? FormSchema - : FormFieldSchema; -}; - -/** - * A schema used to define the logic for a field in a Form. - */ -interface FormFieldSchema> { - logic: Partial>[]; -} - -/** - * Defines the logic for determing a field's value, validity, etc. - */ -interface FormLogic> { - value: (form: F) => T; - errors: (form: F) => string[] | boolean | null; - required: (form: F) => boolean; - disabled: (form: F) => string | boolean | null; - readonly: (form: F) => string | boolean | null; - hidden: (form: F) => boolean; -} - -/** - * Functions to define pieces of FormSchema. - */ -function form = Form>( - ...args: [...FormLogic[], Omit, 'logic'>] -): FormSchema { - return undefined!; -} -function field>( - ...logic: Partial>[] -): FormFieldSchema { - return undefined!; -} -function value>(v: T | ((form: F) => T)): FormLogic { - return undefined!; -} -function valid>( - v: string | boolean | ((form: F) => string | boolean | null), -): FormLogic { - return undefined!; -} -function required>( - v: string | boolean | ((form: F) => string | boolean | null), -): FormLogic { - return undefined!; -} -function hidden>( - v: boolean | ((form: F) => boolean | null), -): FormLogic { - return undefined!; -} - -/** - * Includes a separately defined FormSchema into another FormSchema. - */ -function include>( - schema: FormSchema>, -): FormSchema; -function include>( - schema: FormSchema>, - // TODO: should optionally take form-level logic & `Partial<>` should be deep. - augmentSchema: Partial, 'logic'>>, -): FormSchema; -function include(...args: any[]) { - return undefined!; -} - -/** - * Creates a Form from a FormSchema. - */ -function create(schema: FormSchema>): Form { - return undefined!; -} - -// Example usage: - -const nameSchema = form<{first: string; last: string}>({ - first: field(), - last: field(), -}); - -const dateSchema = form<{year: number; month: number; day: number}>({ - year: field(), - month: field( - valid((m) => (m.month.$() < 1 || m.month.$() > 12 ? 'Month must be between 1 and 12' : null)), - ), - day: field( - valid((m) => - m.day.$() < 1 || m.day.$() > (m.month.$() === 2 ? 29 : 31) - ? 'Date must be between 1 and 31' - : null, - ), - ), -}); - -const shouldCollectPhoneNum = signal(true); - -const userSchema = form<{ - name: {first: string; last: string}; - birthdate: {year: number; month: number; day: number}; - phone: {area: string; prefix: string; line: string}; -}>({ - name: include(nameSchema), - birthdate: include(dateSchema, { - year: field( - value(1990), - required('Year is required'), - valid((m) => - m.birthdate.year.$() > new Date().getFullYear() - 18 ? 'Must be 18 or older' : null, - ), - ), - month: field(required('Month is required')), - }), - phone: form( - hidden(() => !shouldCollectPhoneNum()), - { - area: field(), - prefix: field(), - line: field(), - }, - ), -}); - -const userForm = create(userSchema); -userForm.birthdate.year.$() === 2000; -userForm.$.valid() === true; -userForm.name.$.set({first: 'Bob', last: 'Loblaw'}); -userForm.phone.line.$.hidden() === true; diff --git a/packages/forms/experimental/src/idea1/README.md b/packages/forms/experimental/src/idea1/README.md new file mode 100644 index 000000000000..7c5d4e94e153 --- /dev/null +++ b/packages/forms/experimental/src/idea1/README.md @@ -0,0 +1,47 @@ +## Notes + +- Main idea: schema logic functions define pieces of the computation functions used to build the + final signal graph representing the form. The input to these functions is the final form itself, + which allows a user to define any of the form properties as computations of other form properties. + - A danger with this approach is that a user could create cyclical deps in their signal graph, + would need good errors to explain what's wrong. +- Composition model: + - When nesting calls to group / field, the logic functions in the child fields receive the full + `Form` starting from the root. This allows definig logic based on other parent or sibling + fields. + - The `include` function can be used to include another fully self-contained schema by passing it + a `Form` rooted at the current field rather than the current field's root `Form`. We can also + specify an additional schema when doing this that _is_ rooted at the current field's root `Form` + and allows us to augment the schema we're including. (e.g. if we're including a generic date + group to use as a birth date in a larger form, we may want to put additional restrictions on + what constitues a valid year). + - Need to figure out exactly what it means to augment the schema. e.g. for validators maybe we + combine the lists and run all of them, but for initial value we override the original schema + with the augmented value. What rules make sense here? +- Current typing assumes something like Alex's signal proxy, though other designs could fit here as + well. +- A drawback to this approach is that the user must specify the shape of the data as a generic on + the top-level `group`/`field` calls, and must not specify it on any of the child ones + - Alternative idea from Alex: we could separate out the initial values from the rest of schema. + Would have to explore how this affects the composability / if it's worth it +- Another idea from Alex: the schema/form have a class/instance-like relationship, could explore + making the schema a class. +- What should be included in the schema? Other fws & libraries I've looked at so far seem to use the + schema for just the validation, but we could include more. Which of these would be useful? + - Validation + - Disabled/readonly + - Hidden/shown + - Initial value + - Label + - Placeholder + - Options (e.g. for select / chips) +- We need to know what kind of validators a field has, not just the errors that result from running + them. For example, we may want to show a `*` on a field with a required validator, or set the + `min` attribute for a field with a min validator. To support this we can use a base `Validator` + class and `instanceof` checks to check for specific types of interest, e.g. `RequiredValidator`. + - It's hard to `instanceof RequiredValidator` style checks in the template, so for convenience for + all of the native html validation attributes we can automatically extract them to separate + properties on the field, e.g. `required: Signal`, `min: Signal` + - Should users be able to extract thier own things onto the field? Could probably support this by + accepting a function when creating the form that maps a `FormField` to a record of additional + properties to add to it. diff --git a/packages/forms/experimental/src/idea1/example-use.ts b/packages/forms/experimental/src/idea1/example-use.ts new file mode 100644 index 000000000000..6b6a8d51dae7 --- /dev/null +++ b/packages/forms/experimental/src/idea1/example-use.ts @@ -0,0 +1,67 @@ +import {signal} from '@angular/core'; +import {field, form, group, hidden, include, required, validate, value} from './schema'; + +const nameSchema = group<{first: string; last: string}>({ + first: field(), + last: field(), +}); + +const dateSchema = group<{year: number; month: number; day: number}>({ + year: field(), + month: field( + validate((m) => + m.month.$() < 1 || m.month.$() > 12 ? 'Month must be between 1 and 12' : null, + ), + ), + day: field( + validate((m) => + m.day.$() < 1 || m.day.$() > (m.month.$() === 2 ? 29 : 31) + ? 'Date must be between 1 and 31' + : null, + ), + ), +}); + +const shouldCollectPhoneNum = signal(true); + +const userSchema = group<{ + name: {first: string; last: string}; + birthdate: {year: number; month: number; day: number}; + phone: {area: string; prefix: string; line: string}; +}>({ + name: include(nameSchema), + birthdate: include(dateSchema, { + year: field( + value(1990), + required('Year is required'), + validate((m) => + m.birthdate.year.$() > new Date().getFullYear() - 18 ? 'Must be 18 or older' : null, + ), + ), + month: field(required('Month is required')), + }), + phone: group( + hidden(() => !shouldCollectPhoneNum()), + { + area: field(), + prefix: field(), + line: field(), + }, + ), +}); + +const userForm = form(userSchema); +userForm.birthdate.year.$() === 2000; +userForm.$.valid() === true; +userForm.name.$.set({first: 'Bob', last: 'Loblaw'}); +userForm.phone.line.$.hidden() === true; + +const numForm = form(field()); +numForm.$() === 1; + +const nativeDateForm = form( + group<{x: Date}>({ + x: field(), + }), +); +nativeDateForm.x.$() === new Date(); diff --git a/packages/forms/experimental/src/idea1/form.ts b/packages/forms/experimental/src/idea1/form.ts new file mode 100644 index 000000000000..4a2aa22bbd2a --- /dev/null +++ b/packages/forms/experimental/src/idea1/form.ts @@ -0,0 +1,59 @@ +import {Signal, WritableSignal} from '@angular/core'; + +export interface ValidationError { + message: string; +} + +export interface Validator { + error(t: T): ValidationError | null; +} + +/** + * Contains all info about a field's value, status, errors, etc. + */ +export interface FormField extends WritableSignal { + valid: Signal; + validators: Signal[]>; + errors: Signal; + required: Signal; + disabled: Signal; + readonly: Signal; + hidden: Signal; + tocuhed: Signal; + dirty: Signal; +} + +/** + * A node in the Form where we can get the value, validity, etc. This could be: + * - The root form itself + * - A nested sub-form + * - A leaf input field + */ +export interface FormNode { + $: FormField; +} + +/** + * A data object that can have its properties wrapped into `FormNode`s. This avoids trying to wrap + * up properties of classes (e.g. `Date`). + */ +export type FormableObject = Record & { + [K in keyof FormNode]?: never; +}; + +/** + * Extracts the keys for properties from a `FormableObject` that can be wrapped in `FormNode`s. + */ +export type FormableKeys = Exclude>; + +/** + * A `FormNode` with child properties. + */ +export type FormGroup = FormNode & { + [K in FormableKeys]: Form; +}; + +/** + * A complete form with the ability to access the value, validity, etc. at any sub-field. + */ +export type Form = T extends FormableObject ? FormGroup : FormNode; diff --git a/packages/forms/experimental/src/idea1/schema.ts b/packages/forms/experimental/src/idea1/schema.ts new file mode 100644 index 000000000000..1bac69fb1b35 --- /dev/null +++ b/packages/forms/experimental/src/idea1/schema.ts @@ -0,0 +1,96 @@ +import {Form, FormableKeys, FormableObject, ValidationError, Validator} from './form'; + +/** + * A schema that defines the logic for a `FormNode`. + */ +export interface FormNodeSchema { + logic: FormLogic[]; +} + +/** + * A schema that defines the logic for a `FormGroup`. + */ +export interface FormGroupSchema extends FormNodeSchema { + fields: {[K in FormableKeys]: FormSchema}; +} + +/** + * A schema that defines the logic for a `Form`. + */ +export type FormSchema = T extends FormableObject + ? FormGroupSchema + : FormNodeSchema; + +export interface PartialFormGroupSchema extends FormNodeSchema { + fields: {[K in FormableKeys]?: PartialFormSchema}; +} + +export type PartialFormSchema = T extends FormableObject + ? PartialFormGroupSchema + : FormNodeSchema; + +/** + * Defines the logic for determing a field's value, validity, etc. + */ +export interface FormLogic { + value: (form: Form) => T; + validators: (form: Form) => Validator>[]; + required: (form: Form) => boolean; + disabled: (form: Form) => string | boolean | null; + readonly: (form: Form) => string | boolean | null; + hidden: (form: Form) => boolean; +} + +/** + * Functions to define pieces of FormSchema. + */ +export function group( + ...args: [...FormLogic[], {[K in FormableKeys]: FormSchema}] +): FormSchema { + return undefined!; +} +export function field(...logic: Partial>[]): FormSchema { + return undefined!; +} +export function value(v: T | ((form: Form) => T)): FormLogic { + return undefined!; +} +// Allow passed function to return: +// - null to indicate no error +// - string to indicate an error +// - ValidationError to indicate an error and allow returning custom ValidationError sub-class +// - Validator to provide a custom Validator sub-class that may contain metadata aside from the +// error it produces (e.g. required, min, etc.) +export function validate( + v: (form: Form) => Validator> | ValidationError | string | null, +): FormLogic { + return undefined!; +} +export function required(v: string | ((form: Form) => string | null)): FormLogic { + return undefined!; +} +export function hidden(v: boolean | ((form: Form) => boolean | null)): FormLogic { + return undefined!; +} + +/** + * Includes a separately defined FormSchema into another FormSchema. + */ +export function include(schema: FormSchema): FormSchema; +export function include( + ...args: [ + FormSchema, + ...FormLogic[], + ...([{[K in FormableKeys]?: PartialFormSchema}] | []), + ] +): FormSchema; +export function include(...args: any[]) { + return undefined!; +} + +/** + * Creates a Form from a FormSchema. + */ +export function form(schema: FormSchema): Form { + return undefined!; +} From fb67fc2a0eeca7a796f65b49af4166269f7ae50b Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Sun, 15 Dec 2024 19:06:51 +0000 Subject: [PATCH 07/80] add an idea for binding field to elements --- .../forms/experimental/src/idea2/README.md | 22 ++++ .../experimental/src/idea2/example-use.ts | 18 +++ .../experimental/src/idea2/native-controls.ts | 22 ++++ .../forms/experimental/src/idea2/ngfield.ts | 108 ++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 packages/forms/experimental/src/idea2/README.md create mode 100644 packages/forms/experimental/src/idea2/example-use.ts create mode 100644 packages/forms/experimental/src/idea2/native-controls.ts create mode 100644 packages/forms/experimental/src/idea2/ngfield.ts diff --git a/packages/forms/experimental/src/idea2/README.md b/packages/forms/experimental/src/idea2/README.md new file mode 100644 index 000000000000..f0d13776ca2a --- /dev/null +++ b/packages/forms/experimental/src/idea2/README.md @@ -0,0 +1,22 @@ +## Notes + +- Main idea: have a directive `ngField` that sets a field as the current field for all controls + beneath it. controls can then inject the current field and register themselves to control its + value and/or use some of the field's values in its bindings. To facilitate binding all of the + relevant properties/attributes, another directive `ngBindField` binds all applicable bindings for + common native controls. +- Directive support using it as either a normal directive or structural directive. When used as a + structural directive it can automatically hide/show its contents based on the hidden status from + the field. +- Allows creating separate pieces for things like labels, errors, etc. that don't control the value + of the field, but implement bits of reusable UI based on it. +- Big danger here seems like multiple controls fighting over the field. e.g. if you have a phone + input made up of individual text inputs, those individual inputs will try to register themsevles + to control the field. Is there a safe way to decide who gets control? +- Interesting observation: in the example, I have 2 UI inputs that share the same underlying field - + this means that they also share the touched status, so when I touch one of them they both become + touched (and potentially both start showing errors). + - There are real use cases that use such a configuration (e.g. date input that has an additional + input inside the popup dialog bound to the same field.) + - Does this imply that the touched status should be owned by the UI control, not the `FormField`? + Is there any other state this applies to besides tocuhed? dirty? diff --git a/packages/forms/experimental/src/idea2/example-use.ts b/packages/forms/experimental/src/idea2/example-use.ts new file mode 100644 index 000000000000..8f11fe25f538 --- /dev/null +++ b/packages/forms/experimental/src/idea2/example-use.ts @@ -0,0 +1,18 @@ +import {Component, signal} from '@angular/core'; +import {NativeInput} from './native-controls'; +import {FormField, NgBindField, NgField} from './ngfield'; + +@Component({ + selector: 'app-root', + template: ` +
+ : + +
+ + `, + imports: [NgField, NgBindField, NativeInput], +}) +export class App { + field = new FormField(signal('value'), signal('label')); +} diff --git a/packages/forms/experimental/src/idea2/native-controls.ts b/packages/forms/experimental/src/idea2/native-controls.ts new file mode 100644 index 000000000000..4f9cacb9e5c1 --- /dev/null +++ b/packages/forms/experimental/src/idea2/native-controls.ts @@ -0,0 +1,22 @@ +import {Directive, inject, output} from '@angular/core'; +import {FormFieldControl, NgField} from './ngfield'; + +// Example implementations of a few controls that work with the `NgField` directive. + +@Directive({ + selector: 'input', + host: { + '(input)': 'change.emit($event.target.value)', + '(blur)': 'blur.emit($event)', + }, +}) +export class NativeInput implements FormFieldControl { + ngField: NgField | null = inject(NgField, {optional: true}); + + change = output({alias: ''}); + blur = output({alias: ''}); + + constructor() { + this.ngField?.registerControl(this); + } +} diff --git a/packages/forms/experimental/src/idea2/ngfield.ts b/packages/forms/experimental/src/idea2/ngfield.ts new file mode 100644 index 000000000000..aafe5046e2d2 --- /dev/null +++ b/packages/forms/experimental/src/idea2/ngfield.ts @@ -0,0 +1,108 @@ +import { + Directive, + effect, + EmbeddedViewRef, + HOST_TAG_NAME, + inject, + input, + OutputRef, + signal, + Signal, + TemplateRef, + ViewContainerRef, + WritableSignal, +} from '@angular/core'; + +/** + * Dummy impl for example purposes. + */ +export class FormField { + hidden = signal(false); + tocuhed = signal(false); + dirty = signal(false); + + constructor( + public value: WritableSignal, + public label: Signal, + ) {} +} + +/** + * Interface implemented by controls that want to manage the `NgField`. + */ +export interface FormFieldControl { + change: OutputRef; + blur: OutputRef; +} + +/** + * Directive that provides a `FormField` to controls beneath it. + */ +@Directive({ + selector: '[ngField]', +}) +export class NgField { + field = input.required>({alias: 'ngField'}); + + template = inject(TemplateRef, {optional: true}); + viewContainer = inject(ViewContainerRef); + view: EmbeddedViewRef<{}> | null = null; + + constructor() { + effect(() => { + if (!this.field().hidden()) { + if (this.template && !this.view) { + this.viewContainer.clear(); + this.view = this.viewContainer.createEmbeddedView(this.template, {}); + } + } else { + this.viewContainer.clear(); + this.view = null; + } + }); + } + + registerControl(control: FormFieldControl) { + control.change.subscribe((e) => { + this.field().value.set(e); + this.field().dirty.set(true); + console.log('changed!'); + }); + control.blur.subscribe(() => { + this.field().tocuhed.set(true); + console.log('tocuhed!'); + }); + } +} + +const bindings: Record) => unknown>> = { + 'input': { + 'value': (f) => f.value(), + /* + 'disabled': field.disabled(), + 'required': field.required(), + ... + */ + }, + 'label': { + 'innerText': (f) => f.label(), + }, +}; + +/** + * Directive that binds relevant props/attrs on native elements. + */ +@Directive({ + selector: 'input[ngBindField],label[ngBindField]', // + whatever else makes sense + host: { + '[value]': 'getBinding("value")', + '[innerText]': 'getBinding("innerText")', + // disabled, required, etc. bindings... + }, +}) +export class NgBindField { + tagName = inject(HOST_TAG_NAME).toLowerCase(); + ngField = inject(NgField, {optional: true}); + getBinding = (attr: string) => + this.ngField ? bindings[this.tagName]?.[attr]?.(this.ngField.field()) : undefined; +} From a94d0db4db5c1052afdbef30589f8b33e7838d85 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Thu, 19 Dec 2024 01:03:33 +0000 Subject: [PATCH 08/80] refactor: update idea1 into a working minimal prototype --- packages/forms/experimental/BUILD.bazel | 24 +- .../forms/experimental/src/idea1/README.md | 47 --- .../experimental/src/idea1/example-use.ts | 67 ---- packages/forms/experimental/src/idea1/form.ts | 59 ---- .../forms/experimental/src/idea1/schema.ts | 96 ------ .../experimental/src/prototype1/README.md | 103 ++++++ .../forms/experimental/src/prototype1/form.ts | 143 ++++++++ .../experimental/src/prototype1/schema.ts | 223 ++++++++++++ packages/forms/experimental/test/BUILD.bazel | 41 +++ .../experimental/test/prototype1.spec.ts | 322 ++++++++++++++++++ 10 files changed, 833 insertions(+), 292 deletions(-) delete mode 100644 packages/forms/experimental/src/idea1/README.md delete mode 100644 packages/forms/experimental/src/idea1/example-use.ts delete mode 100644 packages/forms/experimental/src/idea1/form.ts delete mode 100644 packages/forms/experimental/src/idea1/schema.ts create mode 100644 packages/forms/experimental/src/prototype1/README.md create mode 100644 packages/forms/experimental/src/prototype1/form.ts create mode 100644 packages/forms/experimental/src/prototype1/schema.ts create mode 100644 packages/forms/experimental/test/BUILD.bazel create mode 100644 packages/forms/experimental/test/prototype1.spec.ts diff --git a/packages/forms/experimental/BUILD.bazel b/packages/forms/experimental/BUILD.bazel index 14e07bbdb80e..8b0cbd184421 100644 --- a/packages/forms/experimental/BUILD.bazel +++ b/packages/forms/experimental/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ng_module", "ng_package") +load("//tools:defaults.bzl", "ng_module") package(default_visibility = ["//visibility:public"]) @@ -15,25 +15,3 @@ ng_module( "//packages/forms", ], ) - -ng_package( - name = "npm_package", - package_name = "@angular/forms/experimental", - srcs = ["package.json"], - tags = [ - "release-with-framework", - ], - # Do not add more to this list. - # Dependencies on the full npm_package cause long re-builds. - visibility = [ - "//adev:__pkg__", - "//integration:__subpackages__", - "//modules/ssr-benchmarks:__pkg__", - "//packages/compiler-cli/integrationtest:__pkg__", - "//packages/compiler-cli/test/diagnostics:__pkg__", - "//packages/language-service/test:__pkg__", - ], - deps = [ - ":experimental", - ], -) diff --git a/packages/forms/experimental/src/idea1/README.md b/packages/forms/experimental/src/idea1/README.md deleted file mode 100644 index 7c5d4e94e153..000000000000 --- a/packages/forms/experimental/src/idea1/README.md +++ /dev/null @@ -1,47 +0,0 @@ -## Notes - -- Main idea: schema logic functions define pieces of the computation functions used to build the - final signal graph representing the form. The input to these functions is the final form itself, - which allows a user to define any of the form properties as computations of other form properties. - - A danger with this approach is that a user could create cyclical deps in their signal graph, - would need good errors to explain what's wrong. -- Composition model: - - When nesting calls to group / field, the logic functions in the child fields receive the full - `Form` starting from the root. This allows definig logic based on other parent or sibling - fields. - - The `include` function can be used to include another fully self-contained schema by passing it - a `Form` rooted at the current field rather than the current field's root `Form`. We can also - specify an additional schema when doing this that _is_ rooted at the current field's root `Form` - and allows us to augment the schema we're including. (e.g. if we're including a generic date - group to use as a birth date in a larger form, we may want to put additional restrictions on - what constitues a valid year). - - Need to figure out exactly what it means to augment the schema. e.g. for validators maybe we - combine the lists and run all of them, but for initial value we override the original schema - with the augmented value. What rules make sense here? -- Current typing assumes something like Alex's signal proxy, though other designs could fit here as - well. -- A drawback to this approach is that the user must specify the shape of the data as a generic on - the top-level `group`/`field` calls, and must not specify it on any of the child ones - - Alternative idea from Alex: we could separate out the initial values from the rest of schema. - Would have to explore how this affects the composability / if it's worth it -- Another idea from Alex: the schema/form have a class/instance-like relationship, could explore - making the schema a class. -- What should be included in the schema? Other fws & libraries I've looked at so far seem to use the - schema for just the validation, but we could include more. Which of these would be useful? - - Validation - - Disabled/readonly - - Hidden/shown - - Initial value - - Label - - Placeholder - - Options (e.g. for select / chips) -- We need to know what kind of validators a field has, not just the errors that result from running - them. For example, we may want to show a `*` on a field with a required validator, or set the - `min` attribute for a field with a min validator. To support this we can use a base `Validator` - class and `instanceof` checks to check for specific types of interest, e.g. `RequiredValidator`. - - It's hard to `instanceof RequiredValidator` style checks in the template, so for convenience for - all of the native html validation attributes we can automatically extract them to separate - properties on the field, e.g. `required: Signal`, `min: Signal` - - Should users be able to extract thier own things onto the field? Could probably support this by - accepting a function when creating the form that maps a `FormField` to a record of additional - properties to add to it. diff --git a/packages/forms/experimental/src/idea1/example-use.ts b/packages/forms/experimental/src/idea1/example-use.ts deleted file mode 100644 index 6b6a8d51dae7..000000000000 --- a/packages/forms/experimental/src/idea1/example-use.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {signal} from '@angular/core'; -import {field, form, group, hidden, include, required, validate, value} from './schema'; - -const nameSchema = group<{first: string; last: string}>({ - first: field(), - last: field(), -}); - -const dateSchema = group<{year: number; month: number; day: number}>({ - year: field(), - month: field( - validate((m) => - m.month.$() < 1 || m.month.$() > 12 ? 'Month must be between 1 and 12' : null, - ), - ), - day: field( - validate((m) => - m.day.$() < 1 || m.day.$() > (m.month.$() === 2 ? 29 : 31) - ? 'Date must be between 1 and 31' - : null, - ), - ), -}); - -const shouldCollectPhoneNum = signal(true); - -const userSchema = group<{ - name: {first: string; last: string}; - birthdate: {year: number; month: number; day: number}; - phone: {area: string; prefix: string; line: string}; -}>({ - name: include(nameSchema), - birthdate: include(dateSchema, { - year: field( - value(1990), - required('Year is required'), - validate((m) => - m.birthdate.year.$() > new Date().getFullYear() - 18 ? 'Must be 18 or older' : null, - ), - ), - month: field(required('Month is required')), - }), - phone: group( - hidden(() => !shouldCollectPhoneNum()), - { - area: field(), - prefix: field(), - line: field(), - }, - ), -}); - -const userForm = form(userSchema); -userForm.birthdate.year.$() === 2000; -userForm.$.valid() === true; -userForm.name.$.set({first: 'Bob', last: 'Loblaw'}); -userForm.phone.line.$.hidden() === true; - -const numForm = form(field()); -numForm.$() === 1; - -const nativeDateForm = form( - group<{x: Date}>({ - x: field(), - }), -); -nativeDateForm.x.$() === new Date(); diff --git a/packages/forms/experimental/src/idea1/form.ts b/packages/forms/experimental/src/idea1/form.ts deleted file mode 100644 index 4a2aa22bbd2a..000000000000 --- a/packages/forms/experimental/src/idea1/form.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {Signal, WritableSignal} from '@angular/core'; - -export interface ValidationError { - message: string; -} - -export interface Validator { - error(t: T): ValidationError | null; -} - -/** - * Contains all info about a field's value, status, errors, etc. - */ -export interface FormField extends WritableSignal { - valid: Signal; - validators: Signal[]>; - errors: Signal; - required: Signal; - disabled: Signal; - readonly: Signal; - hidden: Signal; - tocuhed: Signal; - dirty: Signal; -} - -/** - * A node in the Form where we can get the value, validity, etc. This could be: - * - The root form itself - * - A nested sub-form - * - A leaf input field - */ -export interface FormNode { - $: FormField; -} - -/** - * A data object that can have its properties wrapped into `FormNode`s. This avoids trying to wrap - * up properties of classes (e.g. `Date`). - */ -export type FormableObject = Record & { - [K in keyof FormNode]?: never; -}; - -/** - * Extracts the keys for properties from a `FormableObject` that can be wrapped in `FormNode`s. - */ -export type FormableKeys = Exclude>; - -/** - * A `FormNode` with child properties. - */ -export type FormGroup = FormNode & { - [K in FormableKeys]: Form; -}; - -/** - * A complete form with the ability to access the value, validity, etc. at any sub-field. - */ -export type Form = T extends FormableObject ? FormGroup : FormNode; diff --git a/packages/forms/experimental/src/idea1/schema.ts b/packages/forms/experimental/src/idea1/schema.ts deleted file mode 100644 index 1bac69fb1b35..000000000000 --- a/packages/forms/experimental/src/idea1/schema.ts +++ /dev/null @@ -1,96 +0,0 @@ -import {Form, FormableKeys, FormableObject, ValidationError, Validator} from './form'; - -/** - * A schema that defines the logic for a `FormNode`. - */ -export interface FormNodeSchema { - logic: FormLogic[]; -} - -/** - * A schema that defines the logic for a `FormGroup`. - */ -export interface FormGroupSchema extends FormNodeSchema { - fields: {[K in FormableKeys]: FormSchema}; -} - -/** - * A schema that defines the logic for a `Form`. - */ -export type FormSchema = T extends FormableObject - ? FormGroupSchema - : FormNodeSchema; - -export interface PartialFormGroupSchema extends FormNodeSchema { - fields: {[K in FormableKeys]?: PartialFormSchema}; -} - -export type PartialFormSchema = T extends FormableObject - ? PartialFormGroupSchema - : FormNodeSchema; - -/** - * Defines the logic for determing a field's value, validity, etc. - */ -export interface FormLogic { - value: (form: Form) => T; - validators: (form: Form) => Validator>[]; - required: (form: Form) => boolean; - disabled: (form: Form) => string | boolean | null; - readonly: (form: Form) => string | boolean | null; - hidden: (form: Form) => boolean; -} - -/** - * Functions to define pieces of FormSchema. - */ -export function group( - ...args: [...FormLogic[], {[K in FormableKeys]: FormSchema}] -): FormSchema { - return undefined!; -} -export function field(...logic: Partial>[]): FormSchema { - return undefined!; -} -export function value(v: T | ((form: Form) => T)): FormLogic { - return undefined!; -} -// Allow passed function to return: -// - null to indicate no error -// - string to indicate an error -// - ValidationError to indicate an error and allow returning custom ValidationError sub-class -// - Validator to provide a custom Validator sub-class that may contain metadata aside from the -// error it produces (e.g. required, min, etc.) -export function validate( - v: (form: Form) => Validator> | ValidationError | string | null, -): FormLogic { - return undefined!; -} -export function required(v: string | ((form: Form) => string | null)): FormLogic { - return undefined!; -} -export function hidden(v: boolean | ((form: Form) => boolean | null)): FormLogic { - return undefined!; -} - -/** - * Includes a separately defined FormSchema into another FormSchema. - */ -export function include(schema: FormSchema): FormSchema; -export function include( - ...args: [ - FormSchema, - ...FormLogic[], - ...([{[K in FormableKeys]?: PartialFormSchema}] | []), - ] -): FormSchema; -export function include(...args: any[]) { - return undefined!; -} - -/** - * Creates a Form from a FormSchema. - */ -export function form(schema: FormSchema): Form { - return undefined!; -} diff --git a/packages/forms/experimental/src/prototype1/README.md b/packages/forms/experimental/src/prototype1/README.md new file mode 100644 index 000000000000..986d9c340e1a --- /dev/null +++ b/packages/forms/experimental/src/prototype1/README.md @@ -0,0 +1,103 @@ +# Notes + +## Overview + +- Build up a schema to represent our form by calling `group()` and `field()`. For example: + + ```ts + const nameSchema = group({ + first: field(''), + last: field('') + }) + ``` + +- Each piece of the schema produced by `group()` and `field()` has methods to specify logic for that + part of the schema, e.g. `disabled()`, `validate()`, etc. These generally take the form of a + function that accepts the current value as a signal and produces a result. When the form is + created these functions are wrapped inside `computed()` calls, so it is reactive based on any + signal reads (either the passed in value signal, or external signals) + + ```ts + field(0).validate(value => value() > 9 ? 'too big' : null) + ``` + +- The schema pieces have a speical method called `xlink()` that allows defining reactive logic that + crosslinks fields in that schema. + + ```ts + const nameSchema = group({ + first: field(''), + last: field('') + }).xlink({ + last: (schema, form) => schema + .validate(value => form.first.$() === value() ? 'cannot be the same as your first name' : '') + }) + ``` + +- Pieces of pre-defined schema can be combined into a bigger schema. + + ```ts + const userSchema = group({ + name: nameSchema, + address: group({ + street: field(''), + city: field(''), + state: field(''), + zip: field('') + }) + }) + ``` + +- Create a form from a schema by calling `form()` and optionally passing values to bind in. + + ```ts + const nameForm = form(nameSchema, {first: 'John', last: 'Doe'}); + ``` + +- The form mimics the structure of the data and has a special property (`$`), to access the data at + that point in the form as a signal. + + ```ts + const nameSignal = nameForm.$; + const firstNameSignal = nameForm.first.$; + nameSignal() // => {first: 'John', last: 'Doe'} + nameSignal.set({first: 'Bob', last: 'Loblaw'}); + firstNameSignal() // => 'Bob' + ``` + +- In addition to itself being a signal to get/set the value of the field, the `$` object also has + properties representing metadata about the field as (readonly?) signals. + + ```ts + const firstNameDisabledSignal = nameForm.first.$.disabled; + firstNameDisabledSignal() // => false + ``` + +## Open questions + +- What about... + - Dynamic arrays + - Dynamic groups + - Static arrays? (aka tuples) +- What should be included in the schema? Other fws & libraries I've looked at so far seem to use the + schema for just the validation, but we could include more. Which of these would be useful? + - Validation + - Disabled/readonly + - Hidden/shown + - Initial value + - Label + - Placeholder + - Options (e.g. for select / chips) +- What is the best way to handle metadata that is associated with a validator, but needed even when + the validator is not in an error state. (e.g. if there is a required validator I may want to show + a `*` next to the field even when its valid) +- How do we handle an optional group (i.e. making the value of the group `undefined` rather than an + object full of empty string properties.) - more generally how do we handle union types +- Do we care that people could call something like `.min()` on a string field even though it really + only makes sense for a number field. +- Is `xlink` useful or just going to cause people to introduce cycles into their reactivity graph +- Is it worth it to offer zod / yup / ... as options for building the schema, and how exactly would + that work considering that our schema captures more than just the type and validity of the field? +- Probably would want a pipelined api rather than a fluent one so that people only need to load the + bits they're using. +- Lots more I'm sure diff --git a/packages/forms/experimental/src/prototype1/form.ts b/packages/forms/experimental/src/prototype1/form.ts new file mode 100644 index 000000000000..46aabfba00c8 --- /dev/null +++ b/packages/forms/experimental/src/prototype1/form.ts @@ -0,0 +1,143 @@ +import {computed, signal, type Signal, type WritableSignal} from '@angular/core'; +import { + FormFieldSchema, + FormGroupSchema, + type FormSchema, + type FormValidationError, + mergeLogic, + type UnwrapSchema, +} from './schema'; + +export type Writable = { + -readonly [P in keyof T]: T[P]; +}; + +export type FormField = WritableSignal & { + readonly valid: Signal; + readonly errors: Signal; + readonly disabled: Signal; +}; + +export type Form> = { + readonly $: FormField>; +} & (S extends FormGroupSchema ? {[K in keyof T]: Form} : {}); + +export interface FormNodeOptions { + parentDisabled?: Signal; + value?: [DeepPartial]; +} + +export type DeepPartial = + T extends Record + ? { + [K in keyof T]?: DeepPartial; + } + : T; + +export class FormFieldNode { + readonly $: FormField; + + constructor(schema: FormFieldSchema, options?: FormNodeOptions) { + // Merge the schema's logic array into a single logic object + let logic = mergeLogic(schema.logic); + // If the logic has an xlink function, run it to get the updated schema and logic function + if (logic.xlink) { + schema = logic.xlink(schema, this as any) as any; + logic = mergeLogic(schema.logic); + } + // If a value was provided when creating the form, add that to our schema and logic function. + if (options?.value) { + schema = schema.value(options.value[0] as T); + logic = mergeLogic(schema.logic); + } + // Generate the signals and computeds for this form field. + const $ = signal(logic.value ? logic.value() : schema.defaultValue) as WritableSignal & + Writable>; + $.errors = computed(() => logic.validator?.($) ?? [], {equal: errorsEquality}); + $.valid = computed(() => $.errors().length === 0); + $.disabled = computed(() => options?.parentDisabled?.() || (logic.disabled?.($) ?? false), { + equal: booleanReasonEquality, + }); + this.$ = $; + } +} + +export class FormGroupNode> { + readonly $: FormField; + + constructor( + schema: FormGroupSchema<{[K in keyof T]: FormSchema}>, + options?: FormNodeOptions, + ) { + // Merge the schema's logic array into a single logic object + let logic = mergeLogic(schema.logic); + // If the logic has an xlink function, run it to get the updated schema and logic function + if (logic.xlink) { + schema = logic.xlink(schema, this as any) as any; + logic = mergeLogic(schema.logic); + } + // Create our disabled signal first, as we need it to pass to our child fields + const disabled = computed(() => options?.parentDisabled?.() || (logic.disabled?.($) ?? false), { + equal: booleanReasonEquality, + }); + // Convert the child field schemas into form nodes. + const fields = Object.fromEntries( + Object.entries(schema.fields).map(([key, field]) => [ + key, + formInternal(field, { + parentDisabled: disabled, + value: options?.value && key in options.value[0] ? [options.value[0][key]] : undefined, + }), + ]), + ); + // Create the value signal for the form group. The value is derived from the child field values, + // and when someone sets it, it actually needs to set the values in the child fields + const $ = computed(() => + Object.fromEntries(Object.entries(fields).map(([field, node]) => [field, node.$()])), + ) as WritableSignal & Writable>; + // TODO: add `update` & whatever other WritableSignal methods. + $.set = (value) => { + for (const key of Object.keys(fields)) { + fields[key].$.set(value[key]); + } + }; + // Add on the rest of the field's metadata + $.errors = computed(() => logic.validator?.($) ?? [], {equal: errorsEquality}); + $.valid = computed( + () => $.errors().length === 0 && Object.values(fields).every((field) => field.$.valid()), + ); + $.disabled = disabled; + // Add references to the child fields as properties on the form group + Object.assign(this, fields); + this.$ = $; + } +} + +export function form>( + schema: S, + // TODO: allow passing signal wrapped values as well + value?: DeepPartial>, +): Form { + return formInternal(schema, {value: value ? [value] : undefined}); +} + +function formInternal>( + schema: S, + options?: FormNodeOptions>, +): Form { + if (schema instanceof FormFieldSchema) { + return new FormFieldNode>(schema, options) as Form; + } + if (schema instanceof FormGroupSchema) { + return new FormGroupNode>(schema, options) as Form; + } + throw new Error('Invalid schema'); +} + +function booleanReasonEquality(a: boolean | {reason: string}, b: boolean | {reason: string}) { + return a === b || (typeof a === 'object' && typeof b === 'object' && a.reason === b.reason); +} + +function errorsEquality(a: FormValidationError[], b: FormValidationError[]) { + return a.length === b.length && a.every((e, i) => e.equals(b[i])); +} diff --git a/packages/forms/experimental/src/prototype1/schema.ts b/packages/forms/experimental/src/prototype1/schema.ts new file mode 100644 index 000000000000..c19265007013 --- /dev/null +++ b/packages/forms/experimental/src/prototype1/schema.ts @@ -0,0 +1,223 @@ +import {type Signal} from '@angular/core'; +import {type Form} from './form'; + +export type UnwrapSchema> = + S extends FormGroupSchema + ? G extends Record> + ? UnwrapSchemaRecord + : never + : S extends FormFieldSchema + ? T + : never; + +export type UnwrapSchemaRecord>> = { + [K in keyof G]: UnwrapSchema; +}; + +export type FormGroupableObject = {[K in keyof Form>]?: never}; + +export interface FormLogic { + readonly value?: () => T; + readonly validator?: (value: Signal) => FormValidationError[]; + readonly disabled?: (value: Signal) => boolean | {reason: string} | undefined; + readonly xlink?: (schema: FormSchema, form: Form) => FormSchema; +} + +export type XLinkFunction, F extends FormSchema> = ( + schema: S, + form: Form, +) => S; + +export type FormGroupXLinkArgs< + S extends FormGroupSchema, + G extends Record>, +> = Exclude< + [...([XLinkFunction] | []), ...([{[K in keyof G]?: XLinkFunction}] | [])], + [] +>; + +export class FormValidationError { + constructor(readonly message: string) {} + + equals(other: FormValidationError) { + return this.constructor === other.constructor && this.message === other.message; + } +} + +export abstract class FormSchema { + constructor(readonly logic: readonly FormLogic[]) {} + + /** + * Adds a validator to the field. + * @param computation Function that computes the current validation error, may return: + * - `FormValidationError` instance to represent an error, potentially with a custom class + * - `string` to represent an error (auto-wrapped in a `FormValidationError`) + * - `null` to represent no error + */ + validate(computation: (value: Signal) => FormValidationError | string | null): this { + return this.addLogic({ + validator: (value) => { + const result = computation(value); + return result === null + ? [] + : [typeof result === 'string' ? new FormValidationError(result) : result]; + }, + }); + } + + /** + * Adds a disabled status check to the field. + * @param computation The current disabled status, may be: + * - `false` to represent unconditionally enabled + * - `true` to represent unconditionally disabled (reason unspecified) + * - `string` to represent unconditionally disabled (with reason) + * - A function to conditionally determine the disabled state. The function may return: + * - Any of the above values (same meaning as unconditional context) + * - `undefined` to pass on making a decision + * (will fall back to previously added `disabled` clauses) + * @returns + */ + disabled( + computation: boolean | string | ((value: Signal) => boolean | string | undefined), + ): this { + return this.addLogic({ + disabled: + typeof computation === 'function' + ? (value) => { + const result = computation(value); + return typeof result === 'string' ? {reason: result} : result; + } + : () => (typeof computation === 'string' ? {reason: computation} : computation), + }); + } + + protected addLogic(logic: FormLogic): this { + return this.setLogic([logic, ...this.logic]); + } + + protected abstract setLogic(logic?: FormLogic[]): this; +} + +export class FormFieldSchema extends FormSchema { + constructor( + readonly defaultValue: T, + logic: readonly FormLogic[] = [], + ) { + super(logic); + } + + /** + * Sets the value for this field. Note: this differs from the `defaultValue` because reactive + * changes in the value will overwrite the current value of the field (potentially a value + * inputted by the user). + * @param computation The value, or a function that returns the value + */ + value(computation: T | (() => T)): this { + return this.addLogic({ + value: typeof computation === 'function' ? (computation as () => T) : () => computation, + }); + } + + /** + * Crosslinks statuses within this field (e.g. to create a disabled state that depends on the + * validation state). + * @param fn A function that takes the current schema and the form (rooted at the same node as + * the schema) and returns an updated schema. + * @returns + */ + xlink(fn: XLinkFunction) { + return this.addLogic({ + xlink: (schema, form) => fn(schema as this, form as unknown as Form), + }); + } + + protected override setLogic(logic?: FormLogic[]): this { + return new FormFieldSchema(this.defaultValue, logic ?? this.logic) as this; + } +} + +export class FormGroupSchema>> extends FormSchema< + UnwrapSchemaRecord +> { + constructor( + readonly fields: G, + logic: readonly FormLogic>[] = [], + ) { + super(logic); + } + + /** + * Crosslinks statuses within this group (e.g. to create child validation state that depends on + * sibling value). + * @param args May be: + * 1. A function that takes the current schema and the form (rooted at the same node as + * the schema) and returns an updated schema. + * 2. An object mapping each child field to a function that takes the child field schema and the + * form (rooted at this group's node) and returns an updated schema. + * 3. Both of the avove + * @returns + */ + xlink(...args: FormGroupXLinkArgs): this { + let fn: XLinkFunction | undefined; + let fieldFns: {[K in keyof G]?: XLinkFunction} | undefined; + if (args.length === 2) { + fn = args[0]; + fieldFns = args[1]; + } else if (typeof args[0] === 'function') { + fn = args[0]; + } else { + fieldFns = args[0]; + } + return this.addLogic({ + xlink: (schema, form) => { + if (fieldFns) { + let fields = {...(schema as this).fields}; + for (const key of Object.keys(fieldFns)) { + const fieldFn = fieldFns[key]; + if (fieldFn) { + (fields as Record>)[key] = fieldFn( + fields[key] as G[string], + form as unknown as Form, + ); + } + } + schema = new FormGroupSchema(fields, schema.logic) as this; + } + if (fn) { + schema = fn(schema as this, form as unknown as Form); + } + return schema; + }, + }); + } + + protected override setLogic(logic: FormLogic>[]): this { + return new FormGroupSchema(this.fields, logic ?? this.logic) as this; + } +} + +export function mergeLogic(logics: readonly FormLogic[]): FormLogic { + return { + value: logics.find((l) => l.value)?.value, + validator: (value) => logics.flatMap((l) => l.validator?.(value) ?? []), + disabled: (value) => logics.find((l) => l.disabled?.(value) !== undefined)?.disabled?.(value), + xlink: logics.reduce<((schema: FormSchema, form: Form) => FormSchema) | undefined>( + (acc, l) => + acc && l.xlink ? (schema, form) => acc(l.xlink!(schema, form), form) : (l.xlink ?? acc), + undefined, + ), + }; +} + +export function field(): FormFieldSchema; +export function field(): FormFieldSchema; +export function field(defaultValue: T): FormFieldSchema; +export function field(defaultValue?: T): FormFieldSchema { + return new FormFieldSchema(defaultValue); +} + +export function group>>( + fields: G & FormGroupableObject, +): FormGroupSchema { + return new FormGroupSchema(fields); +} diff --git a/packages/forms/experimental/test/BUILD.bazel b/packages/forms/experimental/test/BUILD.bazel new file mode 100644 index 000000000000..94e4e0182eb0 --- /dev/null +++ b/packages/forms/experimental/test/BUILD.bazel @@ -0,0 +1,41 @@ +load("//tools:defaults.bzl", "karma_web_test_suite", "ts_library") +load("//tools/circular_dependency_test:index.bzl", "circular_dependency_test") + +circular_dependency_test( + name = "circular_deps_test", + entry_point = "angular/packages/forms/experimental/index.mjs", + deps = ["//packages/forms/experimental"], +) + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob(["**/*.ts"]), + # Visible to //:saucelabs_unit_tests_poc target + visibility = ["//:__pkg__"], + deps = [ + "//packages/core", + "//packages/core/testing", + "//packages/forms", + "//packages/forms/experimental", + "//packages/platform-browser", + "//packages/platform-browser/testing", + "//packages/private/testing", + ], +) + +karma_web_test_suite( + name = "test_web", + tags = [ + # disabled on 2020-04-14 due to failure on saucelabs monitor job + # https://app.circleci.com/pipelines/github/angular/angular/13320/workflows/9ca3527a-d448-4a64-880a-fb4de9d1fece/jobs/680645 + # ``` + # IE 11.0.0 (Windows 8.1.0.0) template-driven forms integration tests basic functionality should report properties which are written outside of template bindings FAILED + # InvalidStateError: InvalidStateError + # ``` + "fixme-saucelabs", + ], + deps = [ + ":test_lib", + ], +) diff --git a/packages/forms/experimental/test/prototype1.spec.ts b/packages/forms/experimental/test/prototype1.spec.ts new file mode 100644 index 000000000000..e6e38ee1f03d --- /dev/null +++ b/packages/forms/experimental/test/prototype1.spec.ts @@ -0,0 +1,322 @@ +import {form} from '../src/prototype1/form'; +import {field, FormValidationError, group} from '../src/prototype1/schema'; + +describe('FormFieldSchema', () => { + describe('create', () => { + it('no default', () => { + const schema = field(); + expect(schema.defaultValue).toBeUndefined(); + }); + + it('default value', () => { + const schema = field(0); + expect(schema.defaultValue).toBe(0); + }); + }); +}); + +describe('FormGroupSchema', () => { + it('create', () => { + const f = field(); + const schema = group({prop: f}); + expect(Object.keys(schema.fields)).toEqual(['prop']); + expect(schema.fields.prop).toBe(f); + }); +}); + +describe('FormFieldNode', () => { + it('create', () => { + const f = form(field(0)); + expect(f.$()).toBe(0); + expect(f.$.errors()).toEqual([]); + expect(f.$.valid()).toBe(true); + }); + + describe('validation', () => { + it('multiple errors', () => { + const f = form( + field(0) + .validate((n) => (n() > 9 ? 'too large' : null)) + .validate((n) => (n() > 99 ? 'much too large' : null)), + ); + expect(f.$.errors()).toEqual([]); + expect(f.$.valid()).toBe(true); + + f.$.set(10); + expect(f.$.errors()).toEqual([new FormValidationError('too large')]); + expect(f.$.valid()).toBe(false); + + f.$.set(100); + expect(f.$.errors()).toEqual([ + new FormValidationError('much too large'), + new FormValidationError('too large'), + ]); + expect(f.$.valid()).toBe(false); + }); + }); + + describe('disabled', () => { + it('default', () => { + expect(form(field()).$.disabled()).toBe(false); + }); + + it('const', () => { + expect(form(field().disabled(false)).$.disabled()).toBe(false); + expect(form(field().disabled(true)).$.disabled()).toBe(true); + expect(form(field().disabled('unavailable')).$.disabled()).toEqual({reason: 'unavailable'}); + }); + + it('dynamic', () => { + const f = form(field('test').disabled((n) => n() === 'disabled')); + expect(f.$.disabled()).toBe(false); + f.$.set('disabled'); + expect(f.$.disabled()).toBe(true); + }); + + it('multiple conditions', () => { + const f = form( + field('test') + .disabled((n) => + n().includes('first:false') + ? false + : n().includes('first:true') + ? 'disabled by first' + : undefined, + ) + .disabled((n) => + n().includes('second:false') + ? false + : n().includes('second:true') + ? 'disabled by second' + : undefined, + ), + ); + expect(f.$.disabled()).toBe(false); + f.$.set('first:true'); + expect(f.$.disabled()).toEqual({reason: 'disabled by first'}); + f.$.set('second:true'); + expect(f.$.disabled()).toEqual({reason: 'disabled by second'}); + f.$.set('first:true second:true'); + expect(f.$.disabled()).toEqual({reason: 'disabled by second'}); + f.$.set('first:true second:false'); + expect(f.$.disabled()).toEqual(false); + }); + }); + + it('corsslink', () => { + const f = form( + field(0) + .disabled((n) => n() > 9) + .xlink((s, f) => s.validate(() => (f.$.disabled() ? 'disabled' : null))) + .xlink((s, f) => s.validate(() => (!f.$.disabled() ? 'not disabled' : null))), + ); + expect(f.$.disabled()).toBe(false); + expect(f.$.errors()).toEqual([new FormValidationError('not disabled')]); + f.$.set(10); + expect(f.$.disabled()).toBe(true); + expect(f.$.errors()).toEqual([new FormValidationError('disabled')]); + }); +}); + +describe('FormGroupNode', () => { + it('create', () => { + const f = form(group({prop1: field(1), prop2: group({prop3: field(3)})})); + expect(f.$()).toEqual({prop1: 1, prop2: {prop3: 3}}); + expect(f.$.errors()).toEqual([]); + expect(f.$.valid()).toBe(true); + expect(f.prop1.$()).toBe(1); + expect(f.prop2.$()).toEqual({prop3: 3}); + expect(f.prop2.prop3.$()).toBe(3); + }); + + it('update', () => { + const f = form(group({prop1: field(1), prop2: group({prop3: field(3)})})); + expect(f.$()).toEqual({prop1: 1, prop2: {prop3: 3}}); + expect(f.prop1.$()).toBe(1); + expect(f.prop2.prop3.$()).toBe(3); + + f.$.set({prop1: 9, prop2: {prop3: 9}}); + expect(f.$()).toEqual({prop1: 9, prop2: {prop3: 9}}); + expect(f.prop1.$()).toBe(9); + expect(f.prop2.prop3.$()).toBe(9); + }); + + describe('validation', () => { + it('valid', () => { + const f = form( + group({ + prop1: field(0).validate((n) => (n() > 9 ? 'too large' : null)), + prop2: group({ + prop3: field(0).validate((n) => (n() > 9 ? 'too large' : null)), + }), + }).validate((n) => (n().prop1 + n().prop2.prop3 > 9 ? 'sum too large' : null)), + ); + expect(f.$.errors()).toEqual([]); + expect(f.$.valid()).toBe(true); + }); + + it('field-level invalid', () => { + const f = form( + group({ + prop1: field(10).validate((n) => (n() > 9 ? 'too large' : null)), + prop2: group({ + prop3: field(-10).validate((n) => (n() > 9 ? 'too large' : null)), + }), + }).validate((n) => (n().prop1 + n().prop2.prop3 > 9 ? 'sum too large' : null)), + ); + expect(f.$.errors()).toEqual([]); + expect(f.$.valid()).toBe(false); + expect(f.prop1.$.valid()).toBe(false); + }); + + it('group-level invalid', () => { + const f = form( + group({ + prop1: field(9).validate((n) => (n() > 9 ? 'too large' : null)), + prop2: group({ + prop3: field(9).validate((n) => (n() > 9 ? 'too large' : null)), + }), + }).validate((n) => (n().prop1 + n().prop2.prop3 > 9 ? 'sum too large' : null)), + ); + expect(f.$.errors()).toEqual([new FormValidationError('sum too large')]); + expect(f.$.valid()).toBe(false); + expect(f.prop1.$.valid()).toBe(true); + expect(f.prop2.$.valid()).toBe(true); + expect(f.prop2.prop3.$.valid()).toBe(true); + }); + }); + + describe('disabled', () => { + it('disables enabled children', () => { + const f = form(group({a: field(), b: group({c: field()})}).disabled(true)); + expect(f.$.disabled()).toBe(true); + expect(f.a.$.disabled()).toBe(true); + expect(f.b.$.disabled()).toBe(true); + expect(f.b.c.$.disabled()).toBe(true); + }); + + it('does not enable disabled children', () => { + const f = form( + group({ + a: field().disabled(true), + b: group({c: field()}).disabled(true), + }).disabled(false), + ); + expect(f.$.disabled()).toBe(false); + expect(f.a.$.disabled()).toBe(true); + expect(f.b.$.disabled()).toBe(true); + expect(f.b.c.$.disabled()).toBe(true); + }); + }); + + describe('xlink', () => { + it('top-level', () => { + const f = form( + group({ + prop1: field(0).disabled((n) => n() > 9), + prop2: field(0).disabled((n) => n() > 9), + }) + .xlink((s, f) => s.validate(() => (f.prop1.$.disabled() ? 'prop1 disabled' : null))) + .xlink((s, f) => s.validate(() => (f.prop2.$.disabled() ? 'prop2 disabled' : null))), + ); + expect(f.$.errors()).toEqual([]); + f.$.set({prop1: 10, prop2: 10}); + expect(f.$.errors()).toEqual([ + new FormValidationError('prop2 disabled'), + new FormValidationError('prop1 disabled'), + ]); + }); + + it('properties', () => { + const f = form( + group({ + prop1: field(0), + prop2: field(0), + }) + .disabled((n) => n().prop1 > n().prop2) + .xlink({ + prop1: (s, f) => s.validate(() => (f.$.disabled() ? 'parent disabled' : null)), + }) + .xlink({ + prop2: (s, f) => s.validate(() => (f.$.disabled() ? 'parent disabled' : null)), + }), + ); + expect(f.prop1.$.errors()).toEqual([]); + expect(f.prop2.$.errors()).toEqual([]); + f.$.set({prop1: 10, prop2: 0}); + expect(f.prop1.$.errors()).toEqual([new FormValidationError('parent disabled')]); + expect(f.prop2.$.errors()).toEqual([new FormValidationError('parent disabled')]); + }); + + it('nested', () => { + const f = form( + group({ + prop1: group({ + prop2: field(0), + }), + }) + .disabled((n) => n().prop1.prop2 > 9) + .xlink((s, f) => s.validate(() => (f.$.disabled() ? 'root disabled' : null)), { + // TODO: support updating deep fields too: + // option1: { prop1: { prop2: (s, f) => ... } } + // option2: { 'prop1.prop2': (s, f) => ... } // nicer, but not minification-safe + prop1: (s, f) => + s + .validate(() => (f.$.disabled() ? 'root disabled' : null)) + .xlink((s, f) => s.validate(() => (f.$.disabled() ? 'subgroup disabled' : null)), { + prop2: (s, f) => + s + .validate(() => (f.$.disabled() ? 'subgroup disabled' : null)) + .xlink((s, f) => + s.validate(() => (f.$.disabled() ? 'field disabled' : null)), + ), + }), + }), + ); + expect(f.$.errors()).toEqual([]); + expect(f.prop1.$.errors()).toEqual([]); + expect(f.prop1.prop2.$.errors()).toEqual([]); + f.prop1.prop2.$.set(10); + expect(f.$.errors()).toEqual([new FormValidationError('root disabled')]); + expect(f.prop1.$.errors()).toEqual([ + new FormValidationError('subgroup disabled'), + new FormValidationError('root disabled'), + ]); + expect(f.prop1.prop2.$.errors()).toEqual([ + new FormValidationError('field disabled'), + new FormValidationError('subgroup disabled'), + ]); + }); + }); +}); + +describe('form', () => { + describe('values', () => { + const userSchema = group({ + name: group({ + first: field(''), + last: field(''), + }), + address: group({ + street: field(''), + city: field(''), + state: field(''), + zip: field(''), + }), + }); + + it('default', () => { + expect(form(userSchema).$()).toEqual({ + name: {first: '', last: ''}, + address: {street: '', city: '', state: '', zip: ''}, + }); + }); + + it('explicit values', () => { + expect(form(userSchema, {address: {city: 'New York', state: 'NY'}}).$()).toEqual({ + name: {first: '', last: ''}, + address: {street: '', city: 'New York', state: 'NY', zip: ''}, + }); + }); + }); +}); From b2d971b14323e1b4c749e4c54928bd6190750a4b Mon Sep 17 00:00:00 2001 From: kirjs Date: Thu, 26 Dec 2024 14:36:54 -0500 Subject: [PATCH 09/80] Add my thoughts on potential signal forms API --- .../experimental/src/kirjs-idea1/README.md | 35 +++ .../src/kirjs-idea1/demo-but-no-comments.ts | 64 +++++ .../src/kirjs-idea1/demo-with-form-builder.ts | 56 +++++ .../experimental/src/kirjs-idea1/demo.ts | 224 ++++++++++++++++++ .../experimental/src/kirjs-idea1/forms.ts | 113 +++++++++ 5 files changed, 492 insertions(+) create mode 100644 packages/forms/experimental/src/kirjs-idea1/README.md create mode 100644 packages/forms/experimental/src/kirjs-idea1/demo-but-no-comments.ts create mode 100644 packages/forms/experimental/src/kirjs-idea1/demo-with-form-builder.ts create mode 100644 packages/forms/experimental/src/kirjs-idea1/demo.ts create mode 100644 packages/forms/experimental/src/kirjs-idea1/forms.ts diff --git a/packages/forms/experimental/src/kirjs-idea1/README.md b/packages/forms/experimental/src/kirjs-idea1/README.md new file mode 100644 index 000000000000..1e1d6ff1528e --- /dev/null +++ b/packages/forms/experimental/src/kirjs-idea1/README.md @@ -0,0 +1,35 @@ +# idea 1 + +The actual demo you can see in [demo.ts](./demo.ts), +or [demo-but-no-comments.ts](demo-but-no-comments.ts) + +My focus here is to explore a way to create a form that would be + +1. Simple, intuitive +2. Type safe +3. Composable +4. Declarative +5. Familiar to Angular forms users + +# Form + +In this file I'll try to use a simple user + password + address form with the +following twists: + +1. User form with username, password, confirm password, and two addresses: + shipping and billing. +2. Passwords must match (we'll need a validator for that) +3. There should be a checkbox, 'Billing address is the same' + +I'll focus on the dev API, and not the actual implementation. + +## Open questions here + +(And this prob won't make sense until you look at demo.ts) + +1. Are the types we infer enough for most of the cases? +2. What would it take to make controls easily extensible to support dynamic + forms +3. I don't like the typings for validator, this def needs more work. +4. Should validators have the ability to set props on input? e.g. should max + validator set a max prop for input[type=number] \ No newline at end of file diff --git a/packages/forms/experimental/src/kirjs-idea1/demo-but-no-comments.ts b/packages/forms/experimental/src/kirjs-idea1/demo-but-no-comments.ts new file mode 100644 index 000000000000..9868672c354d --- /dev/null +++ b/packages/forms/experimental/src/kirjs-idea1/demo-but-no-comments.ts @@ -0,0 +1,64 @@ +import {form, FormField, FormGroup} from './forms'; +import {validators} from './forms'; + +function password() { + return form.password('', { + validators: [validators.maxLength(20), validators.minLength(8), noKevinValidator], + }); +} + +function passwords() { + return form.group( + { + password: password(), + confirmationPassword: password(), + }, + { + validators: passwordsShouldMatchValidator, + }, + ); +} + +function address() { + return form.group({ + address1: form.text(), + address2: form.text(), + zip: form.number(), + }); +} + +function billingAddress() { + const isSameAsBilling = form.checkbox(); + + return form.group({ + isSameAsBilling, + address: address().withConfig({ + disabled: () => isSameAsBilling.value, + }), + }); +} + +const userForm = form.group({ + username: form.text(), + passwords: passwords(), + shippingAddress: address(), + billingAddress: billingAddress(), +}); + +// Validators +function noKevinValidator(password: FormField) { + return password.value === 'kevin' ? {nokevin: 'password can not be kevin'} : null; +} + +function passwordsShouldMatchValidator({ + value, +}: FormGroup<{ + password: FormField; + confirmationPassword: FormField; +}>) { + const {password, confirmationPassword} = value; + + return password.length > 1 && confirmationPassword.length > 1 && password === confirmationPassword + ? null + : {match: 'passwords do not match'}; +} diff --git a/packages/forms/experimental/src/kirjs-idea1/demo-with-form-builder.ts b/packages/forms/experimental/src/kirjs-idea1/demo-with-form-builder.ts new file mode 100644 index 000000000000..3016dcf22dff --- /dev/null +++ b/packages/forms/experimental/src/kirjs-idea1/demo-with-form-builder.ts @@ -0,0 +1,56 @@ +import {FormBuilder, Validators, AbstractControl, ValidationErrors} from '@angular/forms'; + +const fb = new FormBuilder(); + +function noKevinValidator(control: AbstractControl): ValidationErrors | null { + return control.value === 'kevin' ? {nokevin: 'password can not be kevin'} : null; +} + +function createPassword() { + return ['', [Validators.maxLength(20), Validators.minLength(8), noKevinValidator]]; +} + +function passwordsMatchValidator(group: AbstractControl): ValidationErrors | null { + const password = group.get('password')?.value; + const confirmPassword = group.get('confirmationPassword')?.value; + + return password?.length > 1 && confirmPassword?.length > 1 && password === confirmPassword + ? null + : {passwordsMatch: 'Passwords do not match'}; +} + +function createPasswordsGroup() { + return fb.group( + { + password: createPassword(), + confirmationPassword: createPassword(), + }, + {validators: passwordsMatchValidator}, + ); +} + +function createAddress() { + return fb.group({ + address1: [''], + address2: [''], + zip: [null], + }); +} + +export function createUserForm() { + return fb.group({ + username: [''], + passwords: createPasswordsGroup(), + shippingAddress: createAddress(), + billingAddress: fb.group({ + isSameAsBilling: [false], + address: createAddress(), + }), + }); +} + +// Also somewhere in constructor +userForm.get('billingAddress.isSameAsBilling')?.valueChanges.subscribe((isChecked) => { + const address = userForm.get('billingAddress.address'); + isChecked ? address?.disable() : address?.enable(); +}); diff --git a/packages/forms/experimental/src/kirjs-idea1/demo.ts b/packages/forms/experimental/src/kirjs-idea1/demo.ts new file mode 100644 index 000000000000..a1537825359d --- /dev/null +++ b/packages/forms/experimental/src/kirjs-idea1/demo.ts @@ -0,0 +1,224 @@ +import {validators, form, FormGroup, FormField} from './forms'; + +/** + * Here's a fictional form. + * + * My focus here is to explore a way to create a form that would be + * 1. Simple, intuitive + * 2. Type safe + * 3. Composable + * 4. Declarative + * 5. Familiar to Angular forms users + * + * In this file I'll try to use a simple user + password + address form with the following twists: + * 1. User form with username, password, confirm password, and two addresses: shipping and billing. + * 2. Passwords must match (we'll need a validator for that) + * 3. There should be a checkbox, 'Billing address is the same' + * + * I'll focus on the dev API, and not the actual implementation. + + */ + +// Let's start with a simple form +const userForm1 = form.group({ + // Instead of just control and group, i want to play with an idea of having typed fields + // With fields matching input types (text, password, checkbox, date, etc) + other for drowdowns, etc. + // This might be good for + // - typings + // - dynamic form generation. + // - Integrating with native form elements (e.g. allowing min and max on a number field) + username: form.text(), + password: form.password(''), + // I like the idea of taking value, and a separate config. + // It's consistent with the current forms, also less typing for simple forms. +}); + +// Since we're going to have two password fields (password + confirmationPassword) +// it's time to look at composability. +function password() { + // This is just a simple password field, with some validators. + return form.password('', { + validators: [ + // We can use some build-in validators, which are also type aware. + // e.g. you can't use maxLength on a number field. + validators.required(), + validators.maxLength(20), + validators.minLength(8), + // And here's a custom one + // I think Angular forms validators pretty much got it right. + // Ideally It should be possible to drop in existing validators. + (field) => { + return field.value === 'kevin' + ? {nokevin: 'password can not be kevin (sorry Kevin)'} + : null; + }, + ], + }); +} + +// Now let's group two password fields in one. +// The interesting part is the validator which disallows the same password. +// There are 2 approaches we could take. +function passwords() { + // We can have one password field in a var, and just access it directly. + const passwordField = password(); + + // So with a regular field, we could just pass validators as a second argument. + // But we want to use custom password field to keep all the validation + const confirmationPasswordField_ = form.password('', { + validators: (confirm) => null, + }); + + // But here we want to use custom field. + // We could just make it take the same args, as form.password, but here + // I want to play with ability to clone+extends fields, using withConfig method. + const confirmationPasswordField = password() + // Name can be better here, but basically here we can provide custom config + // which would normally be passed as a second argument to the field. + // Then the field would merge it + .withConfig({ + validators: (confirm) => + confirm.value.length > 1 && + // Here access the original password var, it's a bit awkward, but ok. + passwordField.value?.length > 1 && + confirm.value === passwordField.value + ? null + : 'passwords do not match', + }); + + // 💡 Alternatively we can go very specific here, + // And use addValidator (instead of withConfig) + const confirmationPasswordFieldAlternative = password().addValidator((confirm) => + confirm.value.length > 1 && + passwordField.value?.length > 1 && + confirm.value === passwordField.value + ? null + : 'passwords do not match', + ); + + return form.group({ + password: passwordField, + confirmationPassword: confirmationPasswordField, + }); +} + +// 💡here's an alternative way, with inline validation +// +function passwords2() { + return form.group( + { + password: password(), + confirmationPassword: password(), + }, + { + validators: (group) => { + const {password, confirmationPassword} = group.value; + + return password.length > 1 && + confirmationPassword.length > 1 && + password === confirmationPassword + ? null + : {match: 'passwords do not match'}; + /** 💡 Alternatively: we could return {confirmationPassword: { {match: 'passwords do not match'} }} + * and smartly merge is somehow. + * + */ + }, + }, + ); +} + +// Here's the new form, now it's time to add addresses +const userForm2 = form.group({ + username: form.text(), + passwords: passwords(), +}); + +// This is pretty straightforward: we'll use address in 2 places. +function address() { + return form.group({ + address1: form.text(), + address2: form.text(), + // This is an interesting case to think about + // Zip code is probably more of a string with a pattern validator, than a number + zip: form.number(), + }); +} + +// Billing address would group address with a "same as" which would disable it. +function billingAddress() { + const isSameAsBilling = form.checkbox(); + + const billingAddress = form.group({ + isSameAsBilling, + address: address().withConfig({ + // Eventually value and disabled both probably be a signal, but i'm not going there now. + disabled: () => isSameAsBilling.value, + }), + }); + + return form.group({ + isSameAsBilling, + address: billingAddress, + }); +} + +// 💡Alternatively we can have a disabled function on the group level +// Which would return matching structure, which would be cascaded up/down. +// This is a bit scary, because merging might get confusing fast. +function billingAddress2() { + const billingAddress = form.group( + { + isSameAsBilling: form.checkbox(), + address: address(), + }, + { + // Imperative isDisabeld ? c.disable() : c.enable() in ReactiveForms + // made me sad LOL + disabled: (group) => + group.value.isSameAsBilling + ? { + address: true, + } + : false, + }, + ); +} + +// We can move out the validator, it'd take some work +// For the typing to be nice. +function isAddressDisabled( + group: FormGroup<{ + // I'm not super sure if there's an easy way to create a validation + // For only part of a group. + isSameAsBilling: FormField; + address: ReturnType; + }>, +) { + return { + address: group.value.isSameAsBilling, + }; +} + +function billingAddress3() { + return form.group( + { + isSameAsBilling: form.checkbox(), + address: address(), + }, + { + disabled: isAddressDisabled, + }, + ); +} + +// Here's the final form, with all the fields. +const userForm = form.group({ + username: form.text(), + passwords: passwords(), + shippingAddress: address(), + billingAddress: billingAddress(), +}); + +// This is to hide unused var warning, without polluting the doc with tsignores +console.log(userForm, userForm1, userForm2, billingAddress2, billingAddress3); diff --git a/packages/forms/experimental/src/kirjs-idea1/forms.ts b/packages/forms/experimental/src/kirjs-idea1/forms.ts new file mode 100644 index 000000000000..57dfeaef30bb --- /dev/null +++ b/packages/forms/experimental/src/kirjs-idea1/forms.ts @@ -0,0 +1,113 @@ +/** + * This file just has some high level types needed for the demo. + * All the interesting stuff is in demo.ts + */ + +export type ValidatorResult = null | string | Record; +export type Validator = (formItem: T) => ValidatorResult; + +export type DisabledResult = + | boolean + | { + [K in keyof T]?: T[K] extends FormField + ? boolean + : T[K] extends FormGroup + ? DisabledResult + : never; + }; + +export type DisabledValidator = ( + formItem: T, +) => DisabledResult ? T['controls'] : never>; + +export interface FormFieldConfig { + initialValue?: T; + validators?: Validator> | Array>>; +} + +export interface FormField { + config: FormFieldConfig; + value: T; + dirty: boolean; + withConfig: (c: FormFieldConfig) => FormField; + addValidator: (v: Validator>) => FormField; +} + +export type FormItem = FormField | FormGroup; + +export interface FormGroup { + controls: T; + config: FormGroupConfig; + readonly dirty: boolean; + + withConfig(config: FormGroupConfig): FormGroup; + + addValidator: (v: Validator>) => FormField; + readonly value: { + [K in keyof T]: T[K] extends FormField + ? V + : T[K] extends FormGroup + ? T[K]['value'] + : never; + }; +} + +export type FormGroupControls = Record>; + +export type FormGroupFields = Record>; + +export type FormGroupConfig>> = { + validators?: Validator>[] | Validator>; + disabled?: DisabledValidator>; +}; + +export const validators = { + maxLength: (length: number, message = 'Too long!'): Validator> => { + return (field) => { + return field.value.length <= length ? null : message; + }; + }, + + required(message = 'This field is required'): Validator> { + return (field) => { + return field.value !== undefined && field.value !== undefined ? null : {required: message}; + }; + }, + + minLength: (length: number, message = 'Too short!'): Validator> => { + return (field) => { + return field.value.length >= length ? null : message; + }; + }, +}; + +function formField(initialValue: T, config: FormFieldConfig): FormField { + return { + config, + value: initialValue, + dirty: true, + withConfig: (c) => formField(initialValue, c), + addValidator(c) { + // We don't actually add a validator hehe + return formField(initialValue, config); + }, + }; +} + +export const form = { + group(controls: T, config?: FormGroupConfig): FormGroup { + throw new Error('not implemented'); + }, + number(initialValue = 0, config?: FormFieldConfig): FormField { + throw new Error('not implemented'); + }, + text(initialValue = '', config?: FormFieldConfig): FormField { + throw new Error('not implemented'); + }, + checkbox(initialValue = false, config?: FormFieldConfig): FormField { + throw new Error('not implemented'); + }, + password(initialValue = '', config: FormFieldConfig = {}): FormField { + throw new Error('not implemented'); + }, +}; From ab17a84da618b4894ac4e9f6edb4dd217bfeeb58 Mon Sep 17 00:00:00 2001 From: kirjs Date: Thu, 26 Dec 2024 14:44:39 -0500 Subject: [PATCH 10/80] Actually mention signals in readme :) --- packages/forms/experimental/src/kirjs-idea1/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/forms/experimental/src/kirjs-idea1/README.md b/packages/forms/experimental/src/kirjs-idea1/README.md index 1e1d6ff1528e..55fec7b5f841 100644 --- a/packages/forms/experimental/src/kirjs-idea1/README.md +++ b/packages/forms/experimental/src/kirjs-idea1/README.md @@ -3,7 +3,11 @@ The actual demo you can see in [demo.ts](./demo.ts), or [demo-but-no-comments.ts](demo-but-no-comments.ts) -My focus here is to explore a way to create a form that would be +Note, that there are no signals yet, I generally feel we should be able to use +signals for everything: inputs (such as values, disabled, validators), as well +as outputs (values, states, etc.), so that's coming later. + +My focus here is to explore a way to create a form that would be: 1. Simple, intuitive 2. Type safe From 68eba763fedf1a2c29431c46438c2e09fedec652 Mon Sep 17 00:00:00 2001 From: kirjs Date: Tue, 7 Jan 2025 14:12:38 -0500 Subject: [PATCH 11/80] Add custom schema experiments --- .../experimental/src/custom-schemas/README.md | 40 ++++ .../src/custom-schemas/ajv/ajv-adapter.ts | 87 ++++++++ .../src/custom-schemas/ajv/ajv-schema.ts | 116 +++++++++++ .../ajv/ajv-wrapper.component.ts | 29 +++ .../src/custom-schemas/error.component.ts | 39 ++++ .../src/custom-schemas/form-component.ts | 194 ++++++++++++++++++ .../src/custom-schemas/joi/joi-adapter.ts | 75 +++++++ .../src/custom-schemas/joi/joi-schema.ts | 65 ++++++ .../joi/joi-wrapper.component.ts | 28 +++ .../src/custom-schemas/schemas.component.ts | 28 +++ .../experimental/src/custom-schemas/types.ts | 40 ++++ .../experimental/src/custom-schemas/values.ts | 21 ++ .../src/custom-schemas/yup/yup-adapter.ts | 77 +++++++ .../src/custom-schemas/yup/yup-schema.ts | 58 ++++++ .../yup/yup-wrapper.component.ts | 29 +++ .../src/custom-schemas/zod/zod-adapter.ts | 87 ++++++++ .../src/custom-schemas/zod/zod-schema.ts | 64 ++++++ .../zod/zod-wrapper.component.ts | 28 +++ ...se-for-each-control-to-have-a-unique-id.md | 47 +++++ 19 files changed, 1152 insertions(+) create mode 100644 packages/forms/experimental/src/custom-schemas/README.md create mode 100644 packages/forms/experimental/src/custom-schemas/ajv/ajv-adapter.ts create mode 100644 packages/forms/experimental/src/custom-schemas/ajv/ajv-schema.ts create mode 100644 packages/forms/experimental/src/custom-schemas/ajv/ajv-wrapper.component.ts create mode 100644 packages/forms/experimental/src/custom-schemas/error.component.ts create mode 100644 packages/forms/experimental/src/custom-schemas/form-component.ts create mode 100644 packages/forms/experimental/src/custom-schemas/joi/joi-adapter.ts create mode 100644 packages/forms/experimental/src/custom-schemas/joi/joi-schema.ts create mode 100644 packages/forms/experimental/src/custom-schemas/joi/joi-wrapper.component.ts create mode 100644 packages/forms/experimental/src/custom-schemas/schemas.component.ts create mode 100644 packages/forms/experimental/src/custom-schemas/types.ts create mode 100644 packages/forms/experimental/src/custom-schemas/values.ts create mode 100644 packages/forms/experimental/src/custom-schemas/yup/yup-adapter.ts create mode 100644 packages/forms/experimental/src/custom-schemas/yup/yup-schema.ts create mode 100644 packages/forms/experimental/src/custom-schemas/yup/yup-wrapper.component.ts create mode 100644 packages/forms/experimental/src/custom-schemas/zod/zod-adapter.ts create mode 100644 packages/forms/experimental/src/custom-schemas/zod/zod-schema.ts create mode 100644 packages/forms/experimental/src/custom-schemas/zod/zod-wrapper.component.ts create mode 100644 packages/forms/experimental/src/form-control-id/a-case-for-each-control-to-have-a-unique-id.md diff --git a/packages/forms/experimental/src/custom-schemas/README.md b/packages/forms/experimental/src/custom-schemas/README.md new file mode 100644 index 000000000000..657879bed66e --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/README.md @@ -0,0 +1,40 @@ +Here I'm trying to see, what it would take for form to support external schema/validation libraries like zod, yup etc. + +In this specific case I'm generated the form from schemas. + +Things I tested: +✅ Object -> FormGroup +✅ Array -> FormArray +✅ String -> FormControl +✅ Types are supported +✅ Validation doesn't stop after first error +✅ Validation can be mapped to appropriate form fields +✅ Setting default value + Async validation + Skip validation for disabled fields + + + +## Positives +🥰 Those are used a lot in the industry, not just for form validation, it would be cool to support it +🥰 Zod and Yup are TypeScript friendly, so it's nice to have all the type information. +🥰fdcseds +🥰 It's possible to pick up native input-specific props, like min/max, step, etc. + +## Negatives +⛔ Validators only have access to the value, not control status, like `disabled/76ytr6ftddsc xztouched`/`dirty`, and it's not something we can fix. +⛔ It doesn't cover disabled/readonly (there's actually a readonly prop), that would have to happen in the template +⛔ Refine validators return `custom` error code, users would have to use superRefine or there would have to be a `param.code` convention +⛔ The interop would a bit awkward for Arrays and more dynamic forms. +⛔ Seems like we'd have to run whole form validati n messages that won't play with i18n. +We'd have to be presprictive into how the user would want to use their validators. +🧩 Since zod has lots of use cases outside of form validation, we'd need to upderstand how +to handle more advanced features, such as Tuples, Maps, Sets, transforms, preprocessing, +Array.nonempty, Promises,parsing, async parsing, descriptions, brands, etc. +🧩 For dropdowns seems like options would be duplicated? +🧩 We can also support other libs like lgtm, etc, but who'd actually support them? +🧩 Zod has optional, and nullable, and nullish (undefined or null) fields, also partials. +🧩 Weird wrapping/unwrapping of types, e.g. effects (.innerType()), optional (unwrap()), etc. +🧩 Recursive types? +🧩 Async pasring? +🧩 Default values diff --git a/packages/forms/experimental/src/custom-schemas/ajv/ajv-adapter.ts b/packages/forms/experimental/src/custom-schemas/ajv/ajv-adapter.ts new file mode 100644 index 000000000000..6f47eb6c8b11 --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/ajv/ajv-adapter.ts @@ -0,0 +1,87 @@ +import {FormControl, FormGroup, AbstractControl, FormArray} from '@angular/forms'; +import Ajv, {JSONSchemaType, ValidateFunction} from 'ajv'; +import addFormats from 'ajv-formats'; +import addErrors from 'ajv-errors'; +import {ToFormGroup} from '../types'; + +const ajv = new Ajv({allErrors: true}); +addFormats(ajv); +addErrors(ajv); + +type FormGroupType = {[key: string]: AbstractControl}; + +interface ObjectSchema { + type: 'object'; + properties: Record>; +} + +interface ArraySchema { + type: 'array'; + items: JSONSchemaType; +} + +export type AjvToForm = FormGroup>; + +export function convertAjvToForm( + schema: JSONSchemaType, + value: unknown = null, +): AjvToForm { + if (schema.type === 'object' && 'properties' in schema) { + const group: FormGroupType = {}; + + for (const [key, propSchema] of Object.entries(schema.properties)) { + const propValue = value && typeof value === 'object' ? (value as any)[key] : null; + group[key] = convertAjvToForm(propSchema as JSONSchemaType, propValue); + } + + return new FormGroup(group) as any; + } + + if (schema.type === 'array' && 'items' in schema) { + const formArray = new FormArray([]); + if (Array.isArray(value)) { + for (const item of value) { + formArray.push(convertAjvToForm(schema.items as JSONSchemaType, item)); + } + } + return formArray as any; + } + + return new FormControl(value ?? null) as any; +} + +export function ajvToFormGroup(schema: JSONSchemaType, value: unknown = null): AjvToForm { + const validate: ValidateFunction = ajv.compile(schema); + const result = convertAjvToForm(schema, value); + + if (!(result instanceof FormGroup)) { + throw new Error('Root schema must be an object type'); + } + + if (value && typeof value === 'object') { + (result as FormGroup).patchValue(value); + } + + result.setValidators(() => { + const valid = validate(result.value); + if (valid) { + return null; + } + + if (validate.errors) { + for (const error of validate.errors) { + const path = error.instancePath.split('/').filter(Boolean); + if (path.length > 0) { + const control = result.get(path.join('.')); + if (control) { + control.setErrors({[error.keyword]: error.message}); + } + } + } + } + + return {validation: validate.errors?.[0]?.message || 'Validation failed'}; + }); + + return result as AjvToForm; +} diff --git a/packages/forms/experimental/src/custom-schemas/ajv/ajv-schema.ts b/packages/forms/experimental/src/custom-schemas/ajv/ajv-schema.ts new file mode 100644 index 000000000000..c0e7de1fd156 --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/ajv/ajv-schema.ts @@ -0,0 +1,116 @@ +import {JSONSchemaType} from 'ajv'; + +interface Address { + address1: string; + address2?: string; + zip: number; +} + +const addressSchema: JSONSchemaType
= { + type: 'object', + properties: { + address1: {type: 'string'}, + address2: {type: 'string', nullable: true}, + zip: { + type: 'integer', + minimum: 10000, + maximum: 99999, + }, + }, + required: ['address1', 'zip'], + additionalProperties: false, +}; + +interface Passwords { + password: string; + confirmationPassword: string; +} + +const passwordsSchema: JSONSchemaType = { + type: 'object', + properties: { + password: { + type: 'string', + minLength: 8, + maxLength: 20, + not: {const: 'kevin'}, + }, + confirmationPassword: { + type: 'string', + minLength: 8, + maxLength: 20, + }, + }, + required: ['password', 'confirmationPassword'], + additionalProperties: false, + if: { + type: 'object', + properties: { + password: {type: 'string'}, + confirmationPassword: {type: 'string'}, + }, + required: ['password', 'confirmationPassword'], + }, + then: { + type: 'object', + properties: { + password: {type: 'string'}, + confirmationPassword: { + type: 'string', + const: {$data: '1/password'}, + }, + }, + }, + errorMessage: { + 'then.properties.confirmationPassword.const': 'Passwords do not match', + }, +}; + +interface BillingAddress { + isSameAsBilling: boolean; + address: Address; +} + +const billingAddressSchema: JSONSchemaType = { + type: 'object', + properties: { + isSameAsBilling: {type: 'boolean'}, + address: addressSchema, + }, + required: ['isSameAsBilling', 'address'], + additionalProperties: false, +}; + +export const languagesSchema: JSONSchemaType = { + type: 'array', + items: { + type: 'string', + enum: ['en', 'es', 'fr', 'de'], + }, + minItems: 1, +}; + +interface User { + username: string; + passwords: Passwords; + shippingAddress: Address; + billingAddress: BillingAddress; + languages: string[]; +} + +export const userSchema: JSONSchemaType = { + type: 'object', + required: ['username', 'passwords', 'shippingAddress', 'billingAddress', 'languages'], + properties: { + username: { + type: 'string', + minLength: 1, + maxLength: 50, + }, + passwords: passwordsSchema, + shippingAddress: addressSchema, + billingAddress: billingAddressSchema, + languages: languagesSchema, + }, + additionalProperties: false, +}; diff --git a/packages/forms/experimental/src/custom-schemas/ajv/ajv-wrapper.component.ts b/packages/forms/experimental/src/custom-schemas/ajv/ajv-wrapper.component.ts new file mode 100644 index 000000000000..4ee9ab7d22c4 --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/ajv/ajv-wrapper.component.ts @@ -0,0 +1,29 @@ +import {Component} from '@angular/core'; +import {userSchema} from './ajv-schema'; +import {ajvToFormGroup} from './ajv-adapter'; +import {FormComponent} from '../form-component'; +import {FormArray, FormControl} from '@angular/forms'; +import {defaultFormValues} from '../values'; +import {UserFormGroup} from '../types'; + +@Component({ + selector: 'app-ajv-wrapper', + standalone: true, + imports: [FormComponent], + template: ` + + `, +}) +export class AjvWrapperComponent { + readonly form = ajvToFormGroup(userSchema, defaultFormValues); + + addLanguage() { + const language = 'en'; + const languagesArray = this.form.get('languages') as FormArray; + const currentLanguages = languagesArray.value; + + if (!currentLanguages.includes(language)) { + languagesArray.push(new FormControl(language)); + } + } +} diff --git a/packages/forms/experimental/src/custom-schemas/error.component.ts b/packages/forms/experimental/src/custom-schemas/error.component.ts new file mode 100644 index 000000000000..9c6e15f0a4dd --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/error.component.ts @@ -0,0 +1,39 @@ +import {Component, Input} from '@angular/core'; +import {AbstractControl} from '@angular/forms'; +import {CommonModule} from '@angular/common'; + +@Component({ + selector: 'app-error', + standalone: true, + imports: [CommonModule], + template: ` + @if (control && control.errors) { +
+ {{ getFirstError() }} +
+ } + `, + styles: [ + ` + .error-message { + color: red; + font-size: 0.8em; + margin-top: 4px; + } + `, + ], +}) +export class ErrorComponent { + @Input() control!: AbstractControl; + + getFirstError(): string { + if (!this.control?.errors) { + return ''; + } + + const firstErrorKey = Object.keys(this.control.errors)[0]; + const error = this.control.errors[firstErrorKey]; + + return typeof error === 'string' ? error : firstErrorKey; + } +} diff --git a/packages/forms/experimental/src/custom-schemas/form-component.ts b/packages/forms/experimental/src/custom-schemas/form-component.ts new file mode 100644 index 000000000000..27a90f43d74e --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/form-component.ts @@ -0,0 +1,194 @@ +import {Component, input, Input, output} from '@angular/core'; +import {FormGroup, ReactiveFormsModule, FormArray, AbstractControl} from '@angular/forms'; + +import {AsyncPipe, JsonPipe} from '@angular/common'; +import {UserFormGroup} from './types'; +import {ErrorComponent} from './error.component'; + +let formInstanceCounter = 0; + +@Component({ + selector: 'app-generic-form', + standalone: true, + imports: [ReactiveFormsModule, AsyncPipe, JsonPipe, ErrorComponent], + template: ` + @let form = userForm(); +
+
+ + + +
+ +
+
+ + + +
+
+ + + + +
+
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+ + +
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ +
+ + @for (control of form.controls.languages.controls; track control; let i = $index) { +
+ + + +
+ } + +
+ + +
+ +
{{ form.valueChanges|async|json }}
+ `, + styles: [ + ` + .error-message { + color: red; + font-size: 0.8em; + margin-top: 4px; + } + + .form-level-error { + margin: 16px 0; + padding: 8px; + background-color: #fff5f5; + border: 1px solid #feb2b2; + border-radius: 4px; + } + + form { + max-width: 500px; + margin: 0 auto; + padding: 20px; + } + + div { + margin-bottom: 16px; + } + + label { + display: block; + margin-bottom: 8px; + font-weight: 500; + } + + input { + width: 100%; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + } + + input[type="checkbox"] { + width: auto; + } + + button { + background-color: #4299e1; + color: white; + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + } + + button:disabled { + background-color: #a0aec0; + cursor: not-allowed; + } + + button:hover:not(:disabled) { + background-color: #3182ce; + } + `, + ], +}) +export class FormComponent { + readonly userForm = input.required(); + readonly addLanguage = output(); + readonly formId = `form-${formInstanceCounter++}`; + + constructor() {} + + onSubmit() { + console.log(this.userForm().value); + } +} diff --git a/packages/forms/experimental/src/custom-schemas/joi/joi-adapter.ts b/packages/forms/experimental/src/custom-schemas/joi/joi-adapter.ts new file mode 100644 index 000000000000..e1cbc89dd33b --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/joi/joi-adapter.ts @@ -0,0 +1,75 @@ +import * as Joi from 'joi'; +import {FormControl, FormGroup, AbstractControl, FormArray, ValidationErrors} from '@angular/forms'; + +export function convertJoiToForm(schema: Joi.Schema, value: any = null): AbstractControl { + if (schema.type === 'object') { + const group: {[key: string]: AbstractControl} = {}; + const keys = (schema as any).$_terms.keys || []; + + for (const keyObj of keys) { + const key = keyObj.key; + const propSchema = keyObj.schema; + const propValue = value?.[key] ?? null; + group[key] = convertJoiToForm(propSchema, propValue); + } + + return new FormGroup(group); + } + + if (schema.type === 'array') { + const formArray = new FormArray([]); + if (value) { + for (const item of value) { + const itemSchema = (schema as any).$_terms.items[0]; + formArray.push(convertJoiToForm(itemSchema, item)); + } + } + return formArray; + } + + return new FormControl(value ?? null); +} + +const joiValidator = + (schema: Joi.Schema) => + (control: AbstractControl): ValidationErrors | null => { + try { + const {error} = schema.validate(control.value, {abortEarly: false}); + + if (!error) { + if (control instanceof FormGroup) { + for (const [, childControl] of Object.entries(control.controls)) { + childControl.setErrors(null); + } + } + return null; + } + + const errors: ValidationErrors = {}; + + for (const detail of error.details) { + const path = detail.path; + if (path.length > 0 && control instanceof FormGroup) { + const pathStr = path.join('.'); + const childControl = control.get(pathStr); + + if (childControl) { + childControl.setErrors({[detail.type]: detail.message}); + } + } + errors[detail.type] = detail.message; + } + + return errors; + } catch (error: any) { + return {joiError: error.message}; + } + }; + +export function joiToFormGroup(schema: Joi.Schema, value: any = null): AbstractControl { + const result = convertJoiToForm(schema, value); + result.setValue(value); + result.setValidators(joiValidator(schema)); + result.updateValueAndValidity(); + return result; +} diff --git a/packages/forms/experimental/src/custom-schemas/joi/joi-schema.ts b/packages/forms/experimental/src/custom-schemas/joi/joi-schema.ts new file mode 100644 index 000000000000..1bc2d661b04c --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/joi/joi-schema.ts @@ -0,0 +1,65 @@ +import * as Joi from 'joi'; + +const addressSchema = Joi.object({ + address1: Joi.string().required().messages({ + 'string.empty': 'Address 1 is required', + }), + address2: Joi.string().optional().allow(''), + zip: Joi.number().min(10000).max(99999).required().messages({ + 'number.base': 'Zip must be a number', + 'number.min': 'Zip must be at least 5 characters long', + 'any.required': 'Zip code is required', + }), +}); + +const passwordFieldSchema = Joi.string() + .min(8) + .max(20) + .custom((value: string, helpers: Joi.CustomHelpers) => { + if (value === 'kevin') { + return helpers.error('password.kevin'); + } + return value; + }) + .messages({ + 'string.min': 'Password must be at least 8 characters long', + 'string.max': 'Password must be at most 20 characters long', + 'password.kevin': "Password cannot be 'kevin'", + }); + +const passwordsSchema = Joi.object({ + password: passwordFieldSchema.required(), + confirmationPassword: passwordFieldSchema.required(), +}) + .custom((value: {password: string; confirmationPassword: string}, helpers: Joi.CustomHelpers) => { + if (value.password !== value.confirmationPassword) { + return helpers.error('passwords.match'); + } + return value; + }) + .messages({ + 'passwords.match': 'Passwords do not match', + }); + +const billingAddressSchema = Joi.object({ + isSameAsBilling: Joi.boolean().required(), + address: addressSchema.required(), +}); + +export const languagesSchema = Joi.array() + .items(Joi.string().valid('en', 'es', 'fr', 'de')) + .min(1) + .messages({ + 'array.min': 'Please select at least one language', + 'array.base': 'Invalid language selection', + }); + +export const userSchema = Joi.object({ + username: Joi.string().required().messages({ + 'string.empty': 'Username is required', + }), + passwords: passwordsSchema.required(), + shippingAddress: addressSchema.required(), + billingAddress: billingAddressSchema.required(), + languages: languagesSchema.required(), +}); diff --git a/packages/forms/experimental/src/custom-schemas/joi/joi-wrapper.component.ts b/packages/forms/experimental/src/custom-schemas/joi/joi-wrapper.component.ts new file mode 100644 index 000000000000..25b62b904b38 --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/joi/joi-wrapper.component.ts @@ -0,0 +1,28 @@ +import {Component} from '@angular/core'; +import {userSchema} from './joi-schema'; +import {joiToFormGroup} from './joi-adapter'; +import {FormComponent} from '../form-component'; +import {FormArray, FormControl} from '@angular/forms'; +import {defaultFormValues} from '../values'; + +@Component({ + selector: 'app-joi-wrapper', + standalone: true, + imports: [FormComponent], + template: ` + + `, +}) +export class JoiWrapperComponent { + readonly form = joiToFormGroup(userSchema, defaultFormValues) as any; + + addLanguage() { + const language = 'en'; + const languagesArray = this.form.get('languages') as FormArray; + const currentLanguages = languagesArray.value; + + if (!currentLanguages.includes(language)) { + languagesArray.push(new FormControl(language)); + } + } +} diff --git a/packages/forms/experimental/src/custom-schemas/schemas.component.ts b/packages/forms/experimental/src/custom-schemas/schemas.component.ts new file mode 100644 index 000000000000..75a61d30f7eb --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/schemas.component.ts @@ -0,0 +1,28 @@ +import {Component} from '@angular/core'; +import {form} from '../form'; +import {address} from '../address'; +import {YupWrapperComponent} from './yup/yup-wrapper.component'; +import {ZodWrapperComponent} from './zod/zod-wrapper.component'; +import {AjvWrapperComponent} from './ajv/ajv-wrapper.component'; +import {JoiWrapperComponent} from './joi/joi-wrapper.component'; + +@Component({ + selector: 'app-schemas', + standalone: true, + imports: [YupWrapperComponent, ZodWrapperComponent, AjvWrapperComponent, JoiWrapperComponent], + template: ` +

Schemas

+

Yup

+ + +

Zod

+ + +

AJV

+ + +

Joi

+ + `, +}) +export class SchemasComponent {} diff --git a/packages/forms/experimental/src/custom-schemas/types.ts b/packages/forms/experimental/src/custom-schemas/types.ts new file mode 100644 index 000000000000..29e4b0a620ae --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/types.ts @@ -0,0 +1,40 @@ +export interface Address { + address1: string; + address2?: string; + zip: number; +} + +export interface Passwords { + password: string; + confirmationPassword: string; +} + +export interface BillingAddress { + isSameAsBilling: boolean; + address: Address; +} + +export interface UserFormData { + username: string; + passwords: Passwords; + shippingAddress: Address; + billingAddress: BillingAddress; + languages: string[]; +} + +import {FormGroup, FormControl, FormArray} from '@angular/forms'; + +// Helper type to handle optional fields +type OptionalToNullable = T extends undefined ? never : T extends object ? T : T | null; + +// Recursive type to convert any type to its corresponding Form type +export type ToFormGroup = { + [K in keyof T]-?: T[K] extends Array + ? FormArray> + : T[K] extends Record + ? FormGroup> + : FormControl>; +}; + +// The final UserFormGroup type using the recursive ToFormGroup type +export interface UserFormGroup extends FormGroup> {} diff --git a/packages/forms/experimental/src/custom-schemas/values.ts b/packages/forms/experimental/src/custom-schemas/values.ts new file mode 100644 index 000000000000..39e550668c37 --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/values.ts @@ -0,0 +1,21 @@ +export const defaultFormValues = { + username: 'pirojok', + passwords: { + password: '12345678', + confirmationPassword: '12345678', + }, + shippingAddress: { + address1: '111', + address2: '11', + zip: 12310, + }, + billingAddress: { + isSameAsBilling: false, + address: { + address1: '11', + address2: '11', + zip: 0, + }, + }, + languages: ['en'], +}; diff --git a/packages/forms/experimental/src/custom-schemas/yup/yup-adapter.ts b/packages/forms/experimental/src/custom-schemas/yup/yup-adapter.ts new file mode 100644 index 000000000000..d315947331fe --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/yup/yup-adapter.ts @@ -0,0 +1,77 @@ +import {FormControl, FormGroup, AbstractControl, FormArray} from '@angular/forms'; +import * as yup from 'yup'; +import {ToFormGroup} from '../types'; + +type FormGroupType = {[key: string]: AbstractControl}; + +export type YupToForm = FormGroup>; +export function convertYupToForm>( + schema: T, + value: unknown = null, +): YupToForm { + if (isObjectSchema(schema)) { + const fields = schema.fields; + const group: FormGroupType = {}; + + for (const [key, fieldSchema] of Object.entries(fields)) { + if (fieldSchema && typeof fieldSchema === 'object') { + const propValue = value && typeof value === 'object' ? (value as any)[key] : null; + group[key] = convertYupToForm(fieldSchema as yup.Schema, propValue); + } + } + + return new FormGroup(group) as any; + } + + if (isArraySchema(schema)) { + const formArray = new FormArray([]); + if (Array.isArray(value)) { + const innerSchema = schema.innerType as yup.Schema; + for (const item of value) { + formArray.push(convertYupToForm(innerSchema, item)); + } + } + return formArray as any; + } + + return new FormControl(value ?? null) as any; +} + +export function yupToFormGroup(schema: yup.Schema, value: any): YupToForm { + const result = convertYupToForm(schema, value); + result.setValue(value); + + result.setValidators(() => { + try { + schema.validateSync(result.value, {abortEarly: false}); + return null; + } catch (error: unknown) { + if (error instanceof yup.ValidationError) { + console.log(error); + + if (result instanceof FormGroup) { + for (const err of error.inner) { + if (err.path) { + const control = result.get(err.path); + if (control) { + control.setErrors({[err.type ?? 'validation']: err.message}); + } + } + } + } + return {validation: error.message}; + } + return null; + } + }); + + return result as any; +} + +function isObjectSchema(schema: yup.Schema): schema is yup.ObjectSchema { + return schema.type === 'object'; +} + +function isArraySchema(schema: yup.Schema): schema is yup.ArraySchema { + return schema.type === 'array'; +} diff --git a/packages/forms/experimental/src/custom-schemas/yup/yup-schema.ts b/packages/forms/experimental/src/custom-schemas/yup/yup-schema.ts new file mode 100644 index 000000000000..e46171315a09 --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/yup/yup-schema.ts @@ -0,0 +1,58 @@ +import * as yup from 'yup'; + +const addressSchema = yup.object({ + address1: yup.string().required('Address 1 is required'), + address2: yup.string(), + zip: yup + .number() + .required('Zip code is required') + .typeError('Zip must be a number') + .min(10000, 'Zip must be at least 5 characters long'), +}); + +const passwordFieldSchema = yup + .string() + .required() + .min(8, 'Password must be at least 8 characters long') + .max(20, 'Password must be at most 20 characters long') + .test('no-kevin', "Password cannot be 'kevin'", function (value: string | undefined) { + return value !== 'kevin'; + }); + +const passwordsSchema = yup + .object({ + password: passwordFieldSchema, + confirmationPassword: passwordFieldSchema, + }) + .test('passwords-match', 'Passwords do not match', function (value) { + if (!value?.password || !value?.confirmationPassword) return false; + return value.password === value.confirmationPassword; + }); + +const billingAddressSchema = yup + .object({ + isSameAsBilling: yup.boolean(), + address: addressSchema, + }) + .required(); + +export const languagesSchema = yup + .array() + .of(yup.string()) + .min(1, 'Please select at least one language') + .test( + 'valid-languages', + 'Invalid language selection', + function (value: (string | undefined)[] | undefined) { + if (!value) return false; + return value.every((lang) => lang && ['en', 'es', 'fr', 'de'].includes(lang)); + }, + ); + +export const userSchema = yup.object({ + username: yup.string().required('Username is required'), + passwords: passwordsSchema, + shippingAddress: addressSchema, + billingAddress: billingAddressSchema, + languages: languagesSchema, +}); diff --git a/packages/forms/experimental/src/custom-schemas/yup/yup-wrapper.component.ts b/packages/forms/experimental/src/custom-schemas/yup/yup-wrapper.component.ts new file mode 100644 index 000000000000..2876d1c8f3bc --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/yup/yup-wrapper.component.ts @@ -0,0 +1,29 @@ +import {Component} from '@angular/core'; +import {userSchema} from './yup-schema'; +import {yupToFormGroup} from './yup-adapter'; + +import {FormArray, FormControl} from '@angular/forms'; +import {FormComponent} from '../form-component'; +import {defaultFormValues} from '../values'; + +@Component({ + selector: 'app-yup-wrapper', + standalone: true, + imports: [FormComponent], + template: ` + + `, +}) +export class YupWrapperComponent { + readonly form = yupToFormGroup(userSchema, defaultFormValues) as any; + + addLanguage() { + const language = 'en'; + const languagesArray = this.form.get('languages') as FormArray; + const currentLanguages = languagesArray.value; + + if (!currentLanguages.includes(language)) { + languagesArray.push(new FormControl(language)); + } + } +} diff --git a/packages/forms/experimental/src/custom-schemas/zod/zod-adapter.ts b/packages/forms/experimental/src/custom-schemas/zod/zod-adapter.ts new file mode 100644 index 000000000000..8ce5df518d41 --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/zod/zod-adapter.ts @@ -0,0 +1,87 @@ +import {ZodArray, ZodEffects, ZodObject, ZodOptional, ZodTypeAny} from 'zod'; +import {FormControl, FormGroup, AbstractControl, FormArray, ValidationErrors} from '@angular/forms'; + +export type ZodToForm = + T extends ZodObject + ? FormGroup<{[K in keyof Shape]: ZodToForm}> + : T extends ZodArray + ? FormArray> + : T extends ZodEffects + ? ZodToForm + : FormControl; + +export function convertZodToForm(schema: T, value: any = null): ZodToForm { + // If it's an object schema, create a FormGroup + if (schema instanceof ZodObject) { + const shape = schema.shape; + const group: { + [key: string]: FormGroup | FormControl | FormArray; + } = {}; + + for (const key of Object.keys(shape)) { + const propSchema = shape[key]; + const propValue = value?.[key] ?? null; + group[key] = convertZodToForm(propSchema, propValue); + } + + const formGroup = new FormGroup(group); + + return formGroup as ZodToForm; + } + if (schema instanceof ZodEffects) { + return convertZodToForm(schema.innerType(), value) as ZodToForm; + } + + if (schema instanceof ZodArray) { + const formArray = new FormArray([]); + if (value) { + for (const item of value) { + formArray.push(convertZodToForm(schema.element, item)); + } + } + return formArray as unknown as ZodToForm; + } + + if (schema instanceof ZodOptional) { + return convertZodToForm(schema.unwrap(), value) as ZodToForm; + } + + return new FormControl(value ?? null) as ZodToForm; +} + +const zodValidator = + (schema: ZodTypeAny) => + (control: AbstractControl): ValidationErrors | null => { + try { + schema.parse(control.value); + if (control instanceof FormGroup) { + for (const [key, childControl] of Object.entries(control.controls)) { + childControl.setErrors(null); + } + } + return null; + } catch (error: any) { + if (error.errors) { + for (const err of error.errors) { + const path = err.path; + if (path.length > 0 && control instanceof FormGroup) { + const pathStr = path.join('.'); + const childControl = control.get(pathStr); + + if (childControl) { + const code = err.code === 'custom' ? err.params?.code : err.code; + childControl.setErrors({[code]: err.message}); + } + } + } + } + return {zodError: error.errors}; + } + }; +export function zodToFormGroup(schema: T, value: any = null): ZodToForm { + const result = convertZodToForm(schema, value); + result.setValue(value); + result.setValidators(zodValidator(schema)); + result.updateValueAndValidity(); + return result; +} diff --git a/packages/forms/experimental/src/custom-schemas/zod/zod-schema.ts b/packages/forms/experimental/src/custom-schemas/zod/zod-schema.ts new file mode 100644 index 000000000000..124470590973 --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/zod/zod-schema.ts @@ -0,0 +1,64 @@ +import {z} from 'zod'; + +const addressSchema = z.object({ + address1: z.string().nonempty('Address 1 is required'), + address2: z.string().optional(), + zip: z + .number({ + required_error: 'Zip code is required', + invalid_type_error: 'Zip must be a number', + }) + .min(10000, {message: 'Zip must be at least 5 characters long'}), +}); + +const passwordFieldSchema = z + .string() + .min(8, {message: 'Password must be at least 8 characters long'}) + .max(20, {message: 'Password must be at most 20 characters long'}) + // Equivalent to noKevinValidator + .refine((val) => val !== 'kevin', { + message: "Password cannot be 'kevin'", + }); + +const passwordsSchema = z + .object({ + password: passwordFieldSchema, + confirmationPassword: passwordFieldSchema, + }) + .refine((data) => data.password === data.confirmationPassword, { + message: 'Passwords do not match', + path: ['confirmationPassword'], + params: {code: 'match'}, + }); + +const billingAddressSchema = z.object({ + isSameAsBilling: z.boolean(), + address: z.discriminatedUnion('isSameAsBilling', [ + z.object({ + isSameAsBilling: z.literal(false), + address1: z.string().nonempty('Address 1 is required'), + address2: z.string().optional(), + zip: z.number().optional(), + }), + // When isSameAsBilling is false, use the full address validation + z.object({ + isSameAsBilling: z.literal(true), + address: addressSchema, + }), + ]), +}); + +export const languagesSchema = z + .array(z.string()) + .min(1, {message: 'Please select at least one language'}) + .refine((langs) => langs.every((lang) => ['en', 'es', 'fr', 'de'].includes(lang)), { + message: 'Invalid language selection', + }); + +export const userSchema = z.object({ + username: z.string({}).nonempty('Username is required'), + passwords: passwordsSchema, + shippingAddress: addressSchema, + billingAddress: billingAddressSchema, + languages: languagesSchema, +}); diff --git a/packages/forms/experimental/src/custom-schemas/zod/zod-wrapper.component.ts b/packages/forms/experimental/src/custom-schemas/zod/zod-wrapper.component.ts new file mode 100644 index 000000000000..05bd9a315254 --- /dev/null +++ b/packages/forms/experimental/src/custom-schemas/zod/zod-wrapper.component.ts @@ -0,0 +1,28 @@ +import {Component, Output, EventEmitter} from '@angular/core'; +import {userSchema} from './zod-schema'; +import {zodToFormGroup} from './zod-adapter'; +import {FormComponent} from '../form-component'; +import {FormArray, FormControl} from '@angular/forms'; +import {defaultFormValues} from '../values'; + +@Component({ + selector: 'app-zod-wrapper', + standalone: true, + imports: [FormComponent], + template: ` + + `, +}) +export class ZodWrapperComponent { + readonly form = zodToFormGroup(userSchema, defaultFormValues); + + addLanguage() { + const language = 'en'; + const languagesArray = this.form.get('languages') as FormArray; + const currentLanguages = languagesArray.value; + + if (!currentLanguages.includes(language)) { + languagesArray.push(new FormControl(language)); + } + } +} diff --git a/packages/forms/experimental/src/form-control-id/a-case-for-each-control-to-have-a-unique-id.md b/packages/forms/experimental/src/form-control-id/a-case-for-each-control-to-have-a-unique-id.md new file mode 100644 index 000000000000..c60a3ed86854 --- /dev/null +++ b/packages/forms/experimental/src/form-control-id/a-case-for-each-control-to-have-a-unique-id.md @@ -0,0 +1,47 @@ +# A case for each group/field to have a unique id + +## For fields + label + +In an Angular template it's very common to need a unique id to match an input +and label. + +``` +