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; +};