From d03d8754adb96554c1596e056a4f236b415cb70e Mon Sep 17 00:00:00 2001
From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com>
Date: Sat, 9 Dec 2023 12:07:56 +0100
Subject: [PATCH] feat: add API to create basic forms (#24)
---
.devcontainer/devcontainer.json | 7 +-
.eslintrc.json | 5 +-
apps/example/src/app/app.component.ts | 14 +-
apps/example/src/app/app.routes.ts | 16 +-
.../app/basic-form/basic-form.component.ts | 91 ++++++++++
.../simple-form.component.ts | 21 +--
package.json | 2 +-
packages/platform/src/lib/form-builder.ts | 31 ++--
packages/platform/src/lib/form-field.ts | 73 +++++---
packages/platform/src/lib/form-group.ts | 170 ++++++++++--------
packages/platform/src/lib/models.ts | 32 ++++
11 files changed, 319 insertions(+), 143 deletions(-)
create mode 100644 apps/example/src/app/basic-form/basic-form.component.ts
rename apps/example/src/app/{ => simple-form}/simple-form.component.ts (93%)
create mode 100644 packages/platform/src/lib/models.ts
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 1cb4805..ef4ac72 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -37,7 +37,12 @@
}
},
// Add the IDs of extensions you want installed when the container is created.
- "extensions": ["Angular.ng-template", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
+ "extensions": [
+ "Angular.ng-template",
+ "esbenp.prettier-vscode",
+ "dbaeumer.vscode-eslint",
+ "eamodio.gitlens"
+ ]
}
}
}
diff --git a/.eslintrc.json b/.eslintrc.json
index 0be733b..ad3ffbf 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -24,7 +24,10 @@
{
"files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nx/typescript"],
- "rules": {}
+ "rules": {
+ "@typescript-eslint/ban-types": "off",
+ "@typescript-eslint/no-explicit-any": "off"
+ }
},
{
"files": ["*.js", "*.jsx"],
diff --git a/apps/example/src/app/app.component.ts b/apps/example/src/app/app.component.ts
index 56b75e1..37a726b 100644
--- a/apps/example/src/app/app.component.ts
+++ b/apps/example/src/app/app.component.ts
@@ -1,15 +1,15 @@
-import {Component} from '@angular/core';
-import {RouterLink, RouterOutlet} from "@angular/router";
+import { Component } from '@angular/core';
+import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
- imports: [
- RouterLink,
- RouterOutlet
- ],
+ imports: [RouterLink, RouterOutlet],
template: `
-
+
`,
standalone: true,
})
diff --git a/apps/example/src/app/app.routes.ts b/apps/example/src/app/app.routes.ts
index d5371c4..fd753da 100644
--- a/apps/example/src/app/app.routes.ts
+++ b/apps/example/src/app/app.routes.ts
@@ -1,17 +1,21 @@
-import {Routes} from "@angular/router";
+import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
- redirectTo: 'simple-form',
- pathMatch: 'full'
+ redirectTo: 'basic-form',
+ pathMatch: 'full',
+ },
+ {
+ path: 'basic-form',
+ loadComponent: () => import('./basic-form/basic-form.component'),
},
{
path: 'simple-form',
- loadComponent: () => import('./simple-form.component')
+ loadComponent: () => import('./simple-form/simple-form.component'),
},
{
path: 'multi-page-form',
- loadChildren: () => import('./multi-page-form/multi-page-form.routes')
- }
+ loadChildren: () => import('./multi-page-form/multi-page-form.routes'),
+ },
];
diff --git a/apps/example/src/app/basic-form/basic-form.component.ts b/apps/example/src/app/basic-form/basic-form.component.ts
new file mode 100644
index 0000000..139ee7f
--- /dev/null
+++ b/apps/example/src/app/basic-form/basic-form.component.ts
@@ -0,0 +1,91 @@
+import { Component, effect, inject } from '@angular/core';
+import {
+ SignalFormBuilder,
+ SignalInputDebounceDirective,
+ SignalInputDirective,
+ SignalInputErrorDirective,
+ withErrorComponent,
+} from '@ng-signal-forms';
+import { JsonPipe, NgFor, NgIf } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { CustomErrorComponent } from '../custom-input-error.component';
+
+@Component({
+ selector: 'app-basic-form',
+ template: `
+
+
+
+
+
+
+
States
+
{{
+ {
+ state: form.state(),
+ dirtyState: form.dirtyState(),
+ touchedState: form.touchedState(),
+ valid: form.valid()
+ } | json
+ }}
+
+
+
Value
+
{{ form.value() | json }}
+
+
Errors
+
{{ form.errorsArray() | json }}
+
+
+ `,
+ standalone: true,
+ imports: [
+ JsonPipe,
+ FormsModule,
+ SignalInputDirective,
+ SignalInputErrorDirective,
+ NgIf,
+ NgFor,
+ SignalInputDebounceDirective,
+ ],
+ providers: [withErrorComponent(CustomErrorComponent)],
+})
+export default class BasicFormComponent {
+ private sfb = inject(SignalFormBuilder);
+
+ form = this.sfb.createFormGroup<{ name: string; age: number | null }>({
+ name: 'Alice',
+ age: null,
+ });
+
+ formChanged = effect(() => {
+ console.log('form changed:', this.form.value());
+ });
+
+ nameChanged = effect(() => {
+ console.log('name changed:', this.form.controls.name.value());
+ });
+
+ ageChanged = effect(() => {
+ console.log('age changed:', this.form.controls.age.value());
+ });
+
+ reset() {
+ this.form.reset();
+ }
+
+ setForm() {
+ // TODO: allow form values to be set
+ }
+}
diff --git a/apps/example/src/app/simple-form.component.ts b/apps/example/src/app/simple-form/simple-form.component.ts
similarity index 93%
rename from apps/example/src/app/simple-form.component.ts
rename to apps/example/src/app/simple-form/simple-form.component.ts
index 4ad4248..2b9252d 100644
--- a/apps/example/src/app/simple-form.component.ts
+++ b/apps/example/src/app/simple-form/simple-form.component.ts
@@ -1,13 +1,6 @@
-import {
- Component,
- inject,
- Signal,
- signal,
- WritableSignal,
-} from '@angular/core';
+import { Component, inject, Signal } from '@angular/core';
import {
FormField,
- FormGroup,
SetValidationState,
SignalFormBuilder,
SignalInputDebounceDirective,
@@ -19,7 +12,7 @@ import {
} from '@ng-signal-forms';
import { JsonPipe, NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
-import { CustomErrorComponent } from './custom-input-error.component';
+import { CustomErrorComponent } from '../custom-input-error.component';
@Component({
selector: 'app-simple-form',
@@ -158,9 +151,9 @@ export default class SimpleFormComponent {
),
};
}),
- todos: this.sfb.createFormGroup[]>>(
+ todos: this.sfb.createFormGroup(
() => {
- return signal([]);
+ return [];
},
{
validators: [V.minLength(1)],
@@ -187,8 +180,10 @@ export default class SimpleFormComponent {
};
addTodo() {
- this.form.controls.todos
- .controls.update(todos => [...todos, this.createTodo()]);
+ this.form.controls.todos.controls.update((todos) => [
+ ...todos,
+ this.createTodo(),
+ ]);
}
reset() {
diff --git a/package.json b/package.json
index c5632b0..d1f8a9f 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"ng": "ng",
- "start": "nx serve example",
+ "start": "nx serve example --host 0.0.0.0",
"build": "nx run-many --target=build --all",
"watch": "nx build --watch --configuration development",
"test": "nx test",
diff --git a/packages/platform/src/lib/form-builder.ts b/packages/platform/src/lib/form-builder.ts
index 73b449c..7e648eb 100644
--- a/packages/platform/src/lib/form-builder.ts
+++ b/packages/platform/src/lib/form-builder.ts
@@ -1,27 +1,34 @@
-import {inject, Injectable, Injector, WritableSignal} from "@angular/core";
-import {FormField, createFormField, FormFieldOptions, FormFieldOptionsCreator} from "./form-field";
-import {FormGroup, createFormGroup,FormGroupOptions} from "./form-group";
+import { inject, Injectable, Injector, WritableSignal } from '@angular/core';
+import {
+ FormField,
+ createFormField,
+ FormFieldOptions,
+ FormFieldOptionsCreator,
+} from './form-field';
+import { FormGroup, createFormGroup, FormGroupOptions } from './form-group';
+import { FormGroupCreator } from './models';
@Injectable({
- providedIn: 'root'
+ providedIn: 'root',
})
export class SignalFormBuilder {
private injector = inject(Injector);
public createFormField(
value: Value | WritableSignal,
- options?: FormFieldOptions | FormFieldOptionsCreator,
+ options?: FormFieldOptions | FormFieldOptionsCreator
): FormField {
return createFormField(value, options, this.injector);
}
- public createFormGroup<
- Controls extends | { [p: string]: FormField | FormGroup }
- | WritableSignal
- >(
- formGroupCreator: () => Controls,
+ public createFormGroup(
+ formGroupCreator: FormFields | (() => FormFields),
options?: FormGroupOptions
- ): FormGroup {
- return createFormGroup(formGroupCreator, options, this.injector);
+ ): FormGroup {
+ return createFormGroup(
+ formGroupCreator,
+ options,
+ this.injector
+ );
}
}
diff --git a/packages/platform/src/lib/form-field.ts b/packages/platform/src/lib/form-field.ts
index 5ee21b7..15c2ae0 100644
--- a/packages/platform/src/lib/form-field.ts
+++ b/packages/platform/src/lib/form-field.ts
@@ -1,4 +1,12 @@
-import {computed, effect, Injector, isSignal, signal, Signal, WritableSignal} from '@angular/core';
+import {
+ computed,
+ effect,
+ Injector,
+ isSignal,
+ signal,
+ Signal,
+ WritableSignal,
+} from '@angular/core';
import {
computeErrors,
computeErrorsArray,
@@ -15,6 +23,7 @@ export type DirtyState = 'PRISTINE' | 'DIRTY';
export type TouchedState = 'TOUCHED' | 'UNTOUCHED';
export type FormField = {
+ __type: 'FormField';
value: WritableSignal;
errors: Signal;
errorsArray: Signal;
@@ -27,7 +36,7 @@ export type FormField = {
markAsTouched: () => void;
markAsDirty: () => void;
reset: () => void;
- registerOnReset: (fn: (value: Value) => void) => void
+ registerOnReset: (fn: (value: Value) => void) => void;
};
export type FormFieldOptions = {
@@ -35,7 +44,7 @@ export type FormFieldOptions = {
hidden?: () => boolean;
disabled?: () => boolean;
};
-export type FormFieldOptionsCreator = (value: Signal) => FormFieldOptions
+export type FormFieldOptionsCreator = (value: Signal) => FormFieldOptions;
export function createFormField(
value: Value | WritableSignal,
@@ -44,56 +53,72 @@ export function createFormField(
): FormField {
const valueSignal =
// needed until types for writable signal are fixed
- (typeof value === 'function' && isSignal(value) ? value : signal(value)) as WritableSignal;
- const finalOptions = options && typeof options === 'function' ? options(valueSignal) : options;
+ (
+ typeof value === 'function' && isSignal(value) ? value : signal(value)
+ ) as WritableSignal;
+ const finalOptions =
+ options && typeof options === 'function' ? options(valueSignal) : options;
- const validatorsSignal = computeValidators(valueSignal, finalOptions?.validators, injector);
+ const validatorsSignal = computeValidators(
+ valueSignal,
+ finalOptions?.validators,
+ injector
+ );
const validateStateSignal = computeValidateState(validatorsSignal);
const errorsSignal = computeErrors(validateStateSignal);
const errorsArraySignal = computeErrorsArray(validateStateSignal);
const stateSignal = computeState(validateStateSignal);
- const validSignal = computed(() => stateSignal() === 'VALID')
+ const validSignal = computed(() => stateSignal() === 'VALID');
const touchedSignal = signal('UNTOUCHED');
const dirtySignal = signal('PRISTINE');
const hiddenSignal = signal(false);
const disabledSignal = signal(false);
- effect(() => {
- if (valueSignal()) {
- dirtySignal.set('DIRTY');
+ effect(
+ () => {
+ if (valueSignal()) {
+ dirtySignal.set('DIRTY');
+ }
+ },
+ {
+ allowSignalWrites: true,
+ injector: injector,
}
- }, {
- allowSignalWrites: true,
- injector: injector
- });
+ );
if (finalOptions?.hidden) {
- effect(() => {
+ effect(
+ () => {
hiddenSignal.set(finalOptions!.hidden!());
},
{
allowSignalWrites: true,
- injector: injector
- });
+ injector: injector,
+ }
+ );
}
if (finalOptions?.disabled) {
- effect(() => {
+ effect(
+ () => {
disabledSignal.set(finalOptions!.disabled!());
},
{
allowSignalWrites: true,
- injector: injector
- });
+ injector: injector,
+ }
+ );
}
- const defaultValue = typeof value === 'function' && isSignal(value) ? value() :value;
- let onReset = (value: Value) => {}
+ const defaultValue =
+ typeof value === 'function' && isSignal(value) ? value() : value;
+ let onReset = (_value: Value) => {};
return {
+ __type: 'FormField',
value: valueSignal,
errors: errorsSignal,
errorsArray: errorsArraySignal,
@@ -105,12 +130,12 @@ export function createFormField(
disabled: disabledSignal,
markAsTouched: () => touchedSignal.set('TOUCHED'),
markAsDirty: () => dirtySignal.set('DIRTY'),
- registerOnReset: (fn: (value: Value) => void) => onReset = fn,
+ registerOnReset: (fn: (value: Value) => void) => (onReset = fn),
reset: () => {
valueSignal.set(defaultValue);
touchedSignal.set('UNTOUCHED');
dirtySignal.set('PRISTINE');
onReset(defaultValue);
- }
+ },
};
}
diff --git a/packages/platform/src/lib/form-group.ts b/packages/platform/src/lib/form-group.ts
index 5e3fc54..0c2f7d9 100644
--- a/packages/platform/src/lib/form-group.ts
+++ b/packages/platform/src/lib/form-group.ts
@@ -1,5 +1,12 @@
-import {computed, Injector, isSignal, Signal, WritableSignal} from '@angular/core';
-import {DirtyState, FormField, TouchedState} from './form-field';
+import {
+ computed,
+ Injector,
+ isSignal,
+ signal,
+ Signal,
+ WritableSignal,
+} from '@angular/core';
+import { createFormField, DirtyState, TouchedState } from './form-field';
import {
computeErrors,
computeErrorsArray,
@@ -10,21 +17,17 @@ import {
ValidationState,
Validator,
} from './validation';
-
-export type UnwrappedFormGroup = {
- [K in keyof Controls]: Controls[K] extends FormField
- ? V
- : Controls[K] extends FormGroup
- ? UnwrappedFormGroup
- : never;
-};
-
-export type FormGroup<
- Controls extends | { [p: string]: FormField | FormGroup }
- | WritableSignal = {}
-> = {
- value: Signal>;
- controls: { [K in keyof Controls]: Controls[K] };
+import {
+ FormGroupCreator,
+ UnwrappedFormGroup,
+ FormGroupFields,
+ FormGroupCreatorOrSignal,
+} from './models';
+
+export type FormGroup = {
+ __type: 'FormGroup';
+ value: Signal>;
+ controls: FormGroupFields;
valid: Signal;
state: Signal;
dirtyState: Signal;
@@ -42,30 +45,41 @@ export type FormGroupOptions = {
};
const markFormControlAsTouched = (f: any) => {
- if (typeof f.markAsTouched === "function") {
+ if (typeof f.markAsTouched === 'function') {
f.markAsTouched();
}
- if (typeof f.markAllAsTouched === "function") {
+ if (typeof f.markAllAsTouched === 'function') {
f.markAllAsTouched();
}
-}
-export function createFormGroup<
- Controls extends | { [p: string]: FormField | FormGroup }
- | WritableSignal
->(
- formGroupCreator: () => Controls,
+};
+export function createFormGroup(
+ formGroupCreator: FormFields | (() => FormFields),
options?: FormGroupOptions,
injector?: Injector
-): FormGroup {
- const formGroup = formGroupCreator();
- const initialArrayControls =
- typeof formGroup === 'function' && isSignal(formGroup) ? [...formGroup()] : [];
+): FormGroup {
+ const formGroup: FormFields =
+ typeof formGroupCreator === 'function'
+ ? formGroupCreator()
+ : formGroupCreator;
+
+ const formFieldsMapOrSignal = Array.isArray(formGroup)
+ ? signal(formGroup as any[])
+ : Object.entries(formGroup).reduce((acc, [key, value]: [string, any]) => {
+ (acc as any)[key] =
+ value?.__type === 'FormGroup' || value?.__type === 'FormField'
+ ? value
+ : createFormField(value);
+ return acc;
+ }, {} as FormGroupFields);
+
+ const initialArrayControls = isSignal(formFieldsMapOrSignal)
+ ? [...formFieldsMapOrSignal()]
+ : [];
const valueSignal = computed(() => {
- const fg =
- typeof formGroup === 'function' && isSignal(formGroup)
- ? formGroup()
- : formGroup;
+ const fg = isSignal(formFieldsMapOrSignal)
+ ? formFieldsMapOrSignal()
+ : formFieldsMapOrSignal;
if (Array.isArray(fg)) {
return fg.map((f) => f.value());
@@ -75,8 +89,11 @@ export function createFormGroup<
return acc;
}, {} as any);
});
-
- const validatorsSignal = computeValidators(valueSignal, options?.validators, injector);
+ const validatorsSignal = computeValidators(
+ valueSignal,
+ options?.validators,
+ injector
+ );
const validateStateSignal = computeValidateState(validatorsSignal);
const errorsSignal = computeErrors(validateStateSignal);
@@ -85,25 +102,25 @@ export function createFormGroup<
const stateSignal = computeState(validateStateSignal);
const fgStateSignal = computed(() => {
- const fg =
- typeof formGroup === 'function' && isSignal(formGroup)
- ? formGroup()
- : formGroup;
- const states = Object.values(fg)
- .map((field) => field.state())
- .concat(stateSignal());
- if (states.some((state) => state === 'INVALID')) {
- return 'INVALID';
- }
- if (states.some((state) => state === 'PENDING')) {
- return 'PENDING';
- }
- return 'VALID';
- });
+ const fg = isSignal(formFieldsMapOrSignal)
+ ? formFieldsMapOrSignal()
+ : formFieldsMapOrSignal;
+ const states = Object.values(fg)
+ .map((field) => field.state())
+ .concat(stateSignal());
+ if (states.some((state) => state === 'INVALID')) {
+ return 'INVALID';
+ }
+ if (states.some((state) => state === 'PENDING')) {
+ return 'PENDING';
+ }
+ return 'VALID';
+ });
return {
+ __type: 'FormGroup',
value: valueSignal,
- controls: formGroup,
+ controls: formFieldsMapOrSignal as any,
state: fgStateSignal,
valid: computed(() => fgStateSignal() === 'VALID'),
errors: computed(() => {
@@ -111,22 +128,20 @@ export function createFormGroup<
}),
errorsArray: computed(() => {
const myErrors = errorsArraySignal();
- const fg =
- typeof formGroup === 'function' && isSignal(formGroup)
- ? formGroup()
- : formGroup;
+ const fg = isSignal(formFieldsMapOrSignal)
+ ? formFieldsMapOrSignal()
+ : formFieldsMapOrSignal;
const childErrors = Object.entries(fg).map(([key, f]) => {
return (f as any)
.errorsArray()
- .map((e: any) => ({...e, path: e.path ? key + '.' + e.path : key}));
+ .map((e: any) => ({ ...e, path: e.path ? key + '.' + e.path : key }));
});
return myErrors.concat(...childErrors);
}),
dirtyState: computed(() => {
- const fg =
- typeof formGroup === 'function' && isSignal(formGroup)
- ? formGroup()
- : formGroup;
+ const fg = isSignal(formFieldsMapOrSignal)
+ ? formFieldsMapOrSignal()
+ : formFieldsMapOrSignal;
const states = Object.values(fg).map((f) => f.dirtyState());
@@ -138,10 +153,9 @@ export function createFormGroup<
return 'PRISTINE';
}),
touchedState: computed(() => {
- const fg =
- typeof formGroup === 'function' && isSignal(formGroup)
- ? formGroup()
- : formGroup;
+ const fg = isSignal(formFieldsMapOrSignal)
+ ? formFieldsMapOrSignal()
+ : formFieldsMapOrSignal;
const states = Object.values(fg).map((f) => f.touchedState());
@@ -153,31 +167,31 @@ export function createFormGroup<
return 'UNTOUCHED';
}),
markAllAsTouched: () => {
- const fg =
- typeof formGroup === 'function' && isSignal(formGroup)
- ? formGroup()
- : formGroup;
+ const fg = isSignal(formFieldsMapOrSignal)
+ ? formFieldsMapOrSignal()
+ : formFieldsMapOrSignal;
if (Array.isArray(fg)) {
- fg.forEach(f => markFormControlAsTouched(f))
+ fg.forEach((f) => markFormControlAsTouched(f));
return;
}
- Object.values(fg).forEach(f => markFormControlAsTouched(f))
+ Object.values(fg).forEach((f) => markFormControlAsTouched(f));
},
reset: () => {
- const fg =
- typeof formGroup === 'function' && isSignal(formGroup)
- ? formGroup()
- : formGroup;
+ const fg = isSignal(formFieldsMapOrSignal)
+ ? formFieldsMapOrSignal()
+ : formFieldsMapOrSignal;
if (Array.isArray(fg)) {
// need to create new array to set so change is not swallowed by equality of objects
- (formGroup as WritableSignal).set([...initialArrayControls]);
+ (formFieldsMapOrSignal as WritableSignal).set([
+ ...initialArrayControls,
+ ]);
return;
}
- return Object.values(fg).forEach(f => {
- f.reset()
- })
+ return Object.values(fg).forEach((f) => {
+ f.reset();
+ });
},
};
}
diff --git a/packages/platform/src/lib/models.ts b/packages/platform/src/lib/models.ts
new file mode 100644
index 0000000..bfe2f64
--- /dev/null
+++ b/packages/platform/src/lib/models.ts
@@ -0,0 +1,32 @@
+import { WritableSignal } from '@angular/core';
+import { FormField } from './form-field';
+import { FormGroup } from './form-group';
+
+export type Primitives = string | number | boolean | Date | null | undefined;
+
+export type FormFields = FormField | FormGroup;
+
+export type FormFieldInputs = FormFields | Primitives;
+
+export type FormGroupCreator = Record | T[];
+
+export type FormGroupCreatorOrSignal =
+ FormGroupCreator | WritableSignal[]>;
+
+export type FormGroupFields = {
+ [K in keyof Fields]: Fields[K] extends Primitives
+ ? FormField
+ : Fields[K] extends FormGroup
+ ? G extends unknown[]
+ ? FormGroup[]>>
+ : Fields[K]
+ : Fields[K];
+};
+
+export type UnwrappedFormGroup = {
+ [K in keyof Fields]: Fields[K] extends FormField
+ ? V
+ : Fields[K] extends FormGroup
+ ? UnwrappedFormGroup
+ : never;
+};