From 5760647b11d6975a93dc07112b603ba7c0608a27 Mon Sep 17 00:00:00 2001 From: mattlocker Date: Thu, 15 Feb 2024 20:44:39 +0100 Subject: [PATCH 1/2] feat: form with CVA --- .../src/app/address/address.component.ts | 99 +++++++++++++++++ apps/example/src/app/address/address.ts | 6 ++ apps/example/src/app/app.component.ts | 3 + apps/example/src/app/app.routes.ts | 4 + .../form-with-cva/form-with-cva.component.ts | 102 ++++++++++++++++++ 5 files changed, 214 insertions(+) create mode 100644 apps/example/src/app/address/address.component.ts create mode 100644 apps/example/src/app/address/address.ts create mode 100644 apps/example/src/app/form-with-cva/form-with-cva.component.ts diff --git a/apps/example/src/app/address/address.component.ts b/apps/example/src/app/address/address.component.ts new file mode 100644 index 0000000..7244d0a --- /dev/null +++ b/apps/example/src/app/address/address.component.ts @@ -0,0 +1,99 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Inject, + OnDestroy, + OnInit, + forwardRef, +} from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { Address } from './address'; +import { Subscription, tap } from 'rxjs'; + +@Component({ + selector: 'app-address', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AddressComponent), + multi: true, + }, + ], + template: `
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
`, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AddressComponent + implements OnInit, OnDestroy, ControlValueAccessor +{ + onChange: any; + onTouched: any; + disabled = false; + obj!: Address; + formGroup!: FormGroup; + sub?: Subscription; + + ngOnInit(): void { + const fb = new FormBuilder(); + this.formGroup = fb.group({ + street: new FormControl(null, [Validators.minLength(3)]), + zip: new FormControl(null, [Validators.minLength(3)]), + city: new FormControl(null, [Validators.minLength(3)]), + country: new FormControl(null, [Validators.minLength(3)]), + }); + } + + ngOnDestroy(): void { + if (this.sub) { + this.sub.unsubscribe(); + } + } + + registerOnChange(onChange: any): void { + this.sub = this.formGroup.valueChanges + .pipe(tap((value) => onChange(value))) + .subscribe(); + } + registerOnTouched(onTouched: any): void { + this.onTouched = onTouched; + } + + writeValue(obj: Address): void { + if (obj) { + console.log('writeValue'); + this.formGroup.patchValue(obj, { emitEvent: false }); + this.obj = obj; + } + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + } +} diff --git a/apps/example/src/app/address/address.ts b/apps/example/src/app/address/address.ts new file mode 100644 index 0000000..091faec --- /dev/null +++ b/apps/example/src/app/address/address.ts @@ -0,0 +1,6 @@ +export interface Address { + street?: string; + zip?: string; + city?: string; + country?: string; +} diff --git a/apps/example/src/app/app.component.ts b/apps/example/src/app/app.component.ts index 37a726b..4edcf1f 100644 --- a/apps/example/src/app/app.component.ts +++ b/apps/example/src/app/app.component.ts @@ -16,6 +16,9 @@ import { RouterLink, RouterOutlet } from '@angular/router';
  • Multipage form
  • +
  • + Form with CVA +
  • diff --git a/apps/example/src/app/app.routes.ts b/apps/example/src/app/app.routes.ts index fd753da..95b975c 100644 --- a/apps/example/src/app/app.routes.ts +++ b/apps/example/src/app/app.routes.ts @@ -18,4 +18,8 @@ export const routes: Routes = [ path: 'multi-page-form', loadChildren: () => import('./multi-page-form/multi-page-form.routes'), }, + { + path: 'form-with-cva', + loadComponent: () => import('./form-with-cva/form-with-cva.component'), + }, ]; diff --git a/apps/example/src/app/form-with-cva/form-with-cva.component.ts b/apps/example/src/app/form-with-cva/form-with-cva.component.ts new file mode 100644 index 0000000..5234073 --- /dev/null +++ b/apps/example/src/app/form-with-cva/form-with-cva.component.ts @@ -0,0 +1,102 @@ +import { JsonPipe, NgFor, NgIf } from '@angular/common'; +import { Component, effect, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + SignalFormBuilder, + SignalInputDebounceDirective, + SignalInputDirective, + SignalInputErrorDirective, + withErrorComponent, +} from '@ng-signal-forms'; +import { CustomErrorComponent } from '../custom-input-error.component'; +import { AddressComponent } from './../address/address.component'; +import { Address } from '../address/address'; +@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, + AddressComponent, + ], + providers: [withErrorComponent(CustomErrorComponent)], +}) +export default class FormWithCvaComponent { + private sfb = inject(SignalFormBuilder); + + // TODO: type of address should be Address | null + form = this.sfb.createFormGroup<{ name: string; age: number | null; address: any | null; }>({ + name: 'Alice', + age: null, + address: { city: 'Vienna'} + }); + + 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(); + } + + prefill() { + // TODO: improve this API to set form groups + this.form.controls.age.value.set(42); + this.form.controls.name.value.set('Bob'); + } +} From 20e32c5074ef23dc03a4a098cadb16bf02368a53 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Fri, 16 Feb 2024 17:05:14 +0100 Subject: [PATCH 2/2] use signals for CVA implementation --- .../src/app/address/address.component.ts | 99 ------------------- .../address/address.component.ts | 79 +++++++++++++++ .../{ => form-with-cva}/address/address.ts | 0 .../form-with-cva/form-with-cva.component.ts | 28 ++---- .../src/lib/signal-input.directive.ts | 8 +- 5 files changed, 93 insertions(+), 121 deletions(-) delete mode 100644 apps/example/src/app/address/address.component.ts create mode 100644 apps/example/src/app/form-with-cva/address/address.component.ts rename apps/example/src/app/{ => form-with-cva}/address/address.ts (100%) diff --git a/apps/example/src/app/address/address.component.ts b/apps/example/src/app/address/address.component.ts deleted file mode 100644 index 7244d0a..0000000 --- a/apps/example/src/app/address/address.component.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - Inject, - OnDestroy, - OnInit, - forwardRef, -} from '@angular/core'; -import { - ControlValueAccessor, - FormBuilder, - FormControl, - FormGroup, - NG_VALUE_ACCESSOR, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; -import { Address } from './address'; -import { Subscription, tap } from 'rxjs'; - -@Component({ - selector: 'app-address', - standalone: true, - imports: [CommonModule, ReactiveFormsModule], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AddressComponent), - multi: true, - }, - ], - template: `
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class AddressComponent - implements OnInit, OnDestroy, ControlValueAccessor -{ - onChange: any; - onTouched: any; - disabled = false; - obj!: Address; - formGroup!: FormGroup; - sub?: Subscription; - - ngOnInit(): void { - const fb = new FormBuilder(); - this.formGroup = fb.group({ - street: new FormControl(null, [Validators.minLength(3)]), - zip: new FormControl(null, [Validators.minLength(3)]), - city: new FormControl(null, [Validators.minLength(3)]), - country: new FormControl(null, [Validators.minLength(3)]), - }); - } - - ngOnDestroy(): void { - if (this.sub) { - this.sub.unsubscribe(); - } - } - - registerOnChange(onChange: any): void { - this.sub = this.formGroup.valueChanges - .pipe(tap((value) => onChange(value))) - .subscribe(); - } - registerOnTouched(onTouched: any): void { - this.onTouched = onTouched; - } - - writeValue(obj: Address): void { - if (obj) { - console.log('writeValue'); - this.formGroup.patchValue(obj, { emitEvent: false }); - this.obj = obj; - } - } - - setDisabledState?(isDisabled: boolean): void { - this.disabled = isDisabled; - } -} diff --git a/apps/example/src/app/form-with-cva/address/address.component.ts b/apps/example/src/app/form-with-cva/address/address.component.ts new file mode 100644 index 0000000..3d7e69f --- /dev/null +++ b/apps/example/src/app/form-with-cva/address/address.component.ts @@ -0,0 +1,79 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + forwardRef, effect +} from '@angular/core'; +import { + ControlValueAccessor, + FormsModule, + NG_VALUE_ACCESSOR +} from '@angular/forms'; +import { createFormField, createFormGroup, SignalInputDirective, V } from '@ng-signal-forms'; +import { Address } from './address'; + +@Component({ + selector: 'app-address', + standalone: true, + imports: [CommonModule, SignalInputDirective, FormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AddressComponent), + multi: true + } + ], + template: ` +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AddressComponent + implements ControlValueAccessor { + onChange: any; + onTouched: any; + formGroup = createFormGroup({ + street: createFormField(undefined as undefined | string, { validators: [V.minLength(3)] }), + zip: createFormField(undefined as undefined | string, { validators: [V.minLength(3)] }), + city: createFormField(undefined as undefined | string, { validators: [V.minLength(3)] }), + country: createFormField(undefined as undefined | string, { validators: [V.minLength(3)] }) + }); + + change = effect(() => { + this.onChange(this.formGroup.value()); + }, { allowSignalWrites: true }); + + registerOnChange(onChange: any): void { + this.onChange = onChange; + } + + registerOnTouched(onTouched: any): void { + this.onTouched = onTouched; + } + + writeValue(obj: Address): void { + // TODO: obj should receive values from parent + if (obj) { + this.formGroup.controls.street.value.set(obj.street); + this.formGroup.controls.zip.value.set(obj.zip); + this.formGroup.controls.city.value.set(obj.city); + this.formGroup.controls.country.value.set(obj.country); + } + } +} diff --git a/apps/example/src/app/address/address.ts b/apps/example/src/app/form-with-cva/address/address.ts similarity index 100% rename from apps/example/src/app/address/address.ts rename to apps/example/src/app/form-with-cva/address/address.ts diff --git a/apps/example/src/app/form-with-cva/form-with-cva.component.ts b/apps/example/src/app/form-with-cva/form-with-cva.component.ts index 5234073..da6ae5e 100644 --- a/apps/example/src/app/form-with-cva/form-with-cva.component.ts +++ b/apps/example/src/app/form-with-cva/form-with-cva.component.ts @@ -1,16 +1,16 @@ import { JsonPipe, NgFor, NgIf } from '@angular/common'; -import { Component, effect, inject } from '@angular/core'; +import { Component, effect } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { - SignalFormBuilder, + createFormGroup, SignalInputDebounceDirective, SignalInputDirective, SignalInputErrorDirective, - withErrorComponent, + withErrorComponent } from '@ng-signal-forms'; import { CustomErrorComponent } from '../custom-input-error.component'; -import { AddressComponent } from './../address/address.component'; -import { Address } from '../address/address'; +import { AddressComponent } from './address/address.component'; + @Component({ selector: 'app-basic-form', template: ` @@ -69,27 +69,19 @@ import { Address } from '../address/address'; providers: [withErrorComponent(CustomErrorComponent)], }) export default class FormWithCvaComponent { - private sfb = inject(SignalFormBuilder); - // TODO: type of address should be Address | null - form = this.sfb.createFormGroup<{ name: string; age: number | null; address: any | null; }>({ + form = createFormGroup({ name: 'Alice', - age: null, - address: { city: 'Vienna'} + age: null as number | null, + // TODO: this should a form group so the initial value will be set in the form + // but if we make this a group now, then the user input is not emitted back to the form + address: { city: 'Vienna'} as any }); 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(); } diff --git a/packages/platform/src/lib/signal-input.directive.ts b/packages/platform/src/lib/signal-input.directive.ts index 344f428..db9f320 100644 --- a/packages/platform/src/lib/signal-input.directive.ts +++ b/packages/platform/src/lib/signal-input.directive.ts @@ -16,8 +16,8 @@ import { SIGNAL_INPUT_MODIFIER, SignalInputModifier } from "./signal-input-modif '[class.ng-dirty]': 'this.formField?.dirtyState() === "DIRTY"', '[class.ng-touched]': 'this.formField?.touchedState() === "TOUCHED"', '[class.ng-untouched]': 'this.formField?.touchedState() === "UNTOUCHED"', - '[attr.disabled]': '!propagateState ? undefined : this.formField?.disabled() ? true : undefined', - '[attr.readonly]': '!propagateState ? undefined : this.formField?.readOnly() ? true : undefined', + '[attr.disabled]': '!propagateState ? undefined : this.formField?.disabled?.() ? true : undefined', + '[attr.readonly]': '!propagateState ? undefined : this.formField?.readOnly?.() ? true : undefined', }, }) export class SignalInputDirective implements OnInit { @@ -30,7 +30,7 @@ export class SignalInputDirective implements OnInit { onModelChange(value: unknown) { if (this.modifiers && this.modifiers.length === 1) { this.modifiers[0].onModelChange(value); - } else if (this.formField) { + } else if (this.formField && this.formField.value.set) { this.formField.value.set(value); } } @@ -67,6 +67,6 @@ export class SignalInputDirective implements OnInit { emitModelToViewChange: true, })); - this.formField?.registerOnReset(value => this.model.control.setValue(value)) + this.formField?.registerOnReset?.(value => this.model.control.setValue(value)) } }