diff --git a/projects/ng-core/src/lib/forms/control-synchronizer.spec.ts b/projects/ng-core/src/lib/forms/control-synchronizer.spec.ts index 1e7a00bf..019bf427 100644 --- a/projects/ng-core/src/lib/forms/control-synchronizer.spec.ts +++ b/projects/ng-core/src/lib/forms/control-synchronizer.spec.ts @@ -11,13 +11,15 @@ import { delayWhen, Observable, Subject } from 'rxjs'; import { provideValueAccessor, WrappedControlSuperclass, - WrappedFormControlSuperclass, } from '../../public-api'; import { find, findDirective, setValue } from '../../test-helpers'; -abstract class AbstractValidatingComponent extends WrappedControlSuperclass { +abstract class AbstractValidatingComponent extends WrappedControlSuperclass< + string, + string | null +> { syncError = false; - control = new FormControl(undefined, () => + control = new FormControl(null, () => this.#makeError(this.syncError, 'Sync'), ); failOnNeedlessAsync = false; @@ -303,7 +305,9 @@ describe('ControlSynchronizer', () => { template: ``, providers: [provideValueAccessor(InnerComponent)], }) - class InnerComponent extends WrappedFormControlSuperclass { + class InnerComponent extends WrappedControlSuperclass { + control = new FormControl(''); + protected override outerToInnerErrors( errors: ValidationErrors, ): ValidationErrors { @@ -317,8 +321,9 @@ describe('ControlSynchronizer', () => { `, }) - class OuterComponent extends WrappedFormControlSuperclass { + class OuterComponent extends WrappedControlSuperclass { @Input() showInner!: boolean; + control = new FormControl(''); } const ctx = new ComponentContext(OuterComponent, { diff --git a/projects/ng-core/src/lib/forms/index.ts b/projects/ng-core/src/lib/forms/index.ts index 5777f3fa..add49f15 100644 --- a/projects/ng-core/src/lib/forms/index.ts +++ b/projects/ng-core/src/lib/forms/index.ts @@ -1,4 +1,4 @@ export { FormComponentSuperclass } from './form-component-superclass'; +export { provideValueAccessor } from './provide-value-accessor'; export { WrappedControlSuperclass } from './wrapped-control-superclass'; export { WrappedFormControlSuperclass } from './wrapped-form-control-superclass'; -export { provideValueAccessor } from './provide-value-accessor'; diff --git a/projects/ng-core/src/lib/forms/wrapped-control-superclass.spec.ts b/projects/ng-core/src/lib/forms/wrapped-control-superclass.spec.ts index 3b909ed6..a3a0c05d 100644 --- a/projects/ng-core/src/lib/forms/wrapped-control-superclass.spec.ts +++ b/projects/ng-core/src/lib/forms/wrapped-control-superclass.spec.ts @@ -10,7 +10,6 @@ import { FormGroup, FormsModule, ReactiveFormsModule, - UntypedFormGroup, ValidationErrors, Validators, } from '@angular/forms'; @@ -27,11 +26,10 @@ import { setValue, } from '../../test-helpers'; import { DirectiveSuperclass } from '../directive-superclass'; -import { FormComponentSuperclass } from './form-component-superclass'; import { InjectableSuperclass } from '../injectable-superclass'; +import { FormComponentSuperclass } from './form-component-superclass'; import { provideValueAccessor } from './provide-value-accessor'; import { WrappedControlSuperclass } from './wrapped-control-superclass'; -import { WrappedFormControlSuperclass } from './wrapped-form-control-superclass'; describe('WrappedControlSuperclass', () => { it('adds ng-touched to the inner form control at the right time', () => { @@ -52,62 +50,6 @@ describe('WrappedControlSuperclass', () => { }); }); - // https://github.com/simontonsoftware/s-libs/pull/52 - it('can wrap a form group', () => { - class FullName { - firstName = ''; - lastName = ''; - } - - @Component({ - selector: 'sl-full-name', - template: ` -
- - -
- `, - providers: [provideValueAccessor(FullNameComponent)], - }) - class FullNameComponent extends WrappedControlSuperclass { - override control = new UntypedFormGroup({ - firstName: new FormControl(), - lastName: new FormControl(), - }); - - protected override outerToInnerValue(outer: FullName | null): FullName { - // `outer` can come in as `null` during initialization when the user binds with `ngModel` - return outer ?? new FullName(); - } - } - - @Component({ - template: ` - - `, - }) - class FormComponent { - @Input() disabled = false; - fullName = { firstName: 'Krick', lastName: 'Ray' }; - } - - const ctx = new ComponentContext(FormComponent, { - imports: [FormsModule, ReactiveFormsModule], - declarations: [FullNameComponent], - }); - ctx.run(async () => { - const inputs = document.querySelectorAll('input'); - expect(inputs[0].value).toBe('Krick'); - expect(inputs[1].value).toBe('Ray'); - - expect(inputs[0].disabled).toBe(false); - expect(inputs[1].disabled).toBe(false); - ctx.assignInputs({ disabled: true }); - expect(inputs[0].disabled).toBe(true); - expect(inputs[1].disabled).toBe(true); - }); - }); - describe('translating between inner and outer formats', () => { it('allows setting up an observable to translate between inner and outer values', () => { @Component({ @@ -266,7 +208,9 @@ describe('WrappedControlSuperclass', () => { template: ``, providers: [provideValueAccessor(InnerComponent)], }) - class InnerComponent extends WrappedFormControlSuperclass { + class InnerComponent extends WrappedControlSuperclass { + control = new FormControl(''); + // this is an example in the docs protected override outerToInnerErrors( errors: ValidationErrors, @@ -284,7 +228,9 @@ describe('WrappedControlSuperclass', () => { @Component({ template: ``, }) - class OuterComponent extends WrappedFormControlSuperclass {} + class OuterComponent extends WrappedControlSuperclass { + control = new FormControl(''); + } const ctx = new ComponentContext(OuterComponent, { imports: [ReactiveFormsModule], @@ -310,7 +256,9 @@ describe('WrappedControlSuperclass', () => { template: ``, providers: [provideValueAccessor(InnerComponent)], }) - class InnerComponent extends WrappedFormControlSuperclass { + class InnerComponent extends WrappedControlSuperclass { + control = new FormControl(''); + // this is an example in the docs protected override setUpInnerToOuterErrors$(): Observable { return EMPTY; @@ -320,7 +268,9 @@ describe('WrappedControlSuperclass', () => { @Component({ template: ``, }) - class OuterComponent extends WrappedFormControlSuperclass {} + class OuterComponent extends WrappedControlSuperclass { + control = new FormControl(''); + } const ctx = new ComponentContext(OuterComponent, { imports: [ReactiveFormsModule], @@ -339,14 +289,18 @@ describe('WrappedControlSuperclass', () => { template: ``, providers: [provideValueAccessor(InnerComponent)], }) - class InnerComponent extends WrappedFormControlSuperclass {} + class InnerComponent extends WrappedControlSuperclass { + control = new FormControl(''); + } @Component({ selector: `sl-middle`, template: ``, providers: [provideValueAccessor(MiddleComponent)], }) - class MiddleComponent extends WrappedFormControlSuperclass {} + class MiddleComponent extends WrappedControlSuperclass { + control = new FormControl(''); + } @Component({ template: `` }) class OuterComponent {} @@ -369,7 +323,9 @@ describe('WrappedControlSuperclass', () => { template: ``, providers: [provideValueAccessor(InnerComponent)], }) - class InnerComponent extends WrappedFormControlSuperclass {} + class InnerComponent extends WrappedControlSuperclass { + control = new FormControl(''); + } @Component({ template: ` @@ -409,6 +365,154 @@ describe('WrappedControlSuperclass', () => { }); }); }); + + describe('doc example', () => { + it('works for the simple one', () => { + // vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv begin example + @Component({ + template: ``, + providers: [provideValueAccessor(StringComponent)], + }) + class StringComponent extends WrappedControlSuperclass { + control = new FormControl(''); + } + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ end example + + const ctx = new ComponentContext(StringComponent, { + imports: [ReactiveFormsModule], + }); + ctx.run(async () => { + const input = find(ctx.fixture, 'input'); + setValue(input, 'hi'); + expect(ctx.getComponentInstance().control.value).toBe('hi'); + }); + }); + + it('works for the multiple inner components one', () => { + // The idea for being able to wrap a form group came from github user A77AY: https://github.com/simontonsoftware/s-libs/pull/52 + + // vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv begin example + class FullName { + firstName = ''; + lastName = ''; + } + + @Component({ + selector: 'sl-full-name', + template: ` +
+ + +
+ `, + providers: [provideValueAccessor(FullNameComponent)], + }) + class FullNameComponent extends WrappedControlSuperclass< + FullName | null, + Partial + > { + control = new FormGroup({ + firstName: new FormControl('', { nonNullable: true }), + lastName: new FormControl('', { nonNullable: true }), + }); + + protected override outerToInnerValue(outer: FullName | null): FullName { + // `formControlName` binding can't handle a null value + return outer ?? new FullName(); + } + + protected override innerToOuterValue( + inner: Partial, + ): FullName { + // the values in a `FormGroup` can be `undefined` when their corresponding controls are disabled + return { + firstName: inner.firstName ?? '', + lastName: inner.lastName ?? '', + }; + } + } + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ end example + + @Component({ + template: ` + + `, + }) + class TestComponent { + @Input() disabled = false; + fullName = { firstName: 'Rinat', lastName: 'Arsaev' }; + } + + const ctx = new ComponentContext(TestComponent, { + imports: [FormsModule, ReactiveFormsModule], + declarations: [FullNameComponent], + }); + ctx.run(async () => { + const inputs = document.querySelectorAll('input'); + expect(inputs[0].value).toBe('Rinat'); + expect(inputs[1].value).toBe('Arsaev'); + + expect(inputs[0].disabled).toBe(false); + expect(inputs[1].disabled).toBe(false); + ctx.assignInputs({ disabled: true }); + expect(inputs[0].disabled).toBe(true); + expect(inputs[1].disabled).toBe(true); + }); + }); + + it('works for the one that modifies the value', () => { + // vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv begin example + @Component({ + selector: 'sl-date', + template: ``, + providers: [provideValueAccessor(DateComponent)], + }) + class DateComponent extends WrappedControlSuperclass< + Date | null, + string | null + > { + control = new FormControl(null); + + protected override innerToOuterValue( + inner: string | null, + ): Date | null { + return inner ? new Date(`${inner}Z`) : null; + } + + protected override outerToInnerValue( + outer: Date | null, + ): string | null { + return outer ? outer.toISOString().substring(0, 16) : null; + } + } + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ end example + + @Component({ template: `` }) + class TestComponent { + date = new Date(); + } + + const ctx = new ComponentContext(TestComponent, { + imports: [FormsModule, ReactiveFormsModule], + declarations: [DateComponent], + }); + ctx.run(async () => { + const input = find(ctx.fixture, 'input'); + + ctx.getComponentInstance().date = new Date('2018-09-03T21:00Z'); + ctx.tick(); + expect(input.value).toBe('2018-09-03T21:00'); + + setValue(input, '1980-11-04T10:00'); + expect(ctx.getComponentInstance().date).toEqual( + new Date('1980-11-04T10:00Z'), + ); + }); + }); + }); }); describe('WrappedControlSuperclass tests using an old style fixture', () => { @@ -422,14 +526,11 @@ describe('WrappedControlSuperclass tests using an old style fixture', () => { >
Touched!
-
- `, }) class TestComponent { emissions = 0; string = ''; - date = new Date(); shouldDisable = false; } @@ -443,33 +544,11 @@ describe('WrappedControlSuperclass tests using an old style fixture', () => { control = new FormControl(); } - @Component({ - selector: `sl-date-component`, - template: ` `, - providers: [provideValueAccessor(DateComponent)], - changeDetection: ChangeDetectionStrategy.OnPush, - }) - class DateComponent extends WrappedControlSuperclass { - control = new FormControl(); - - protected override innerToOuterValue(value: string): Date { - return new Date(`${value}Z`); - } - - protected override outerToInnerValue(value: Date): string { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- happens during initialization - if (value === null) { - return ''; - } - return value.toISOString().substr(0, 16); - } - } - class TestComponentContext extends ComponentContext { constructor() { super(TestComponent, { imports: [FormsModule, ReactiveFormsModule], - declarations: [DateComponent, StringComponent], + declarations: [StringComponent], }); } } @@ -483,10 +562,6 @@ describe('WrappedControlSuperclass tests using an old style fixture', () => { return find(ctx.fixture, 'sl-string-component input'); } - function dateInput(): HTMLInputElement { - return find(ctx.fixture, 'sl-date-component input'); - } - function toggleDisabledButton(): HTMLButtonElement { return findButton(ctx.fixture, 'Toggle Disabled'); } @@ -502,19 +577,6 @@ describe('WrappedControlSuperclass tests using an old style fixture', () => { }); }); - it('can translate between inner and outer values', () => { - ctx.run(() => { - ctx.getComponentInstance().date = new Date('2018-09-03T21:00Z'); - ctx.tick(); - expect(dateInput().value).toBe('2018-09-03T21:00'); - - setValue(dateInput(), '1980-11-04T10:00'); - expect(ctx.getComponentInstance().date).toEqual( - new Date('1980-11-04T10:00Z'), - ); - }); - }); - it('provides help for `onTouched`', () => { ctx.run(() => { expect(ctx.fixture.nativeElement.innerText).not.toContain('Touched!'); diff --git a/projects/ng-core/src/lib/forms/wrapped-control-superclass.ts b/projects/ng-core/src/lib/forms/wrapped-control-superclass.ts index ee67f1b5..3fedb2c0 100644 --- a/projects/ng-core/src/lib/forms/wrapped-control-superclass.ts +++ b/projects/ng-core/src/lib/forms/wrapped-control-superclass.ts @@ -23,13 +23,42 @@ import { FormComponentSuperclass } from './form-component-superclass'; /** * Extend this when creating a form component that simply wraps existing ones, to reduce a lot of boilerplate. * - * To wrap a single form control use the subclass {@linkcode WrappedFormControlSuperclass}: + * The most common case is to use a simple {@linkcode FormControl}: * ```ts * @Component({ - * template: ``, + * template: ``, * providers: [provideValueAccessor(StringComponent)], * }) - * class StringComponent extends WrappedFormControlSuperclass {} + * class StringComponent extends WrappedControlSuperclass { + * control = new FormControl(''); + * } + * ``` + * + * Example when you need to modify the wrapped control's value: + * ```ts + * @Component({ + * selector: 'sl-date', + * template: ``, + * providers: [provideValueAccessor(DateComponent)], + * }) + * class DateComponent extends WrappedControlSuperclass< + * Date | null, + * string | null + * > { + * control = new FormControl(null); + * + * protected override innerToOuterValue( + * inner: string | null, + * ): Date | null { + * return inner ? new Date(`${inner}Z`) : null; + * } + * + * protected override outerToInnerValue( + * outer: Date | null, + * ): string | null { + * return outer ? outer.toISOString().substring(0, 16) : null; + * } + * } * ``` * * Example of wrapping multiple inner components: @@ -40,7 +69,7 @@ import { FormComponentSuperclass } from './form-component-superclass'; * } * * @Component({ - * selector: 'app-full-name', + * selector: 'sl-full-name', * template: ` *
* @@ -49,35 +78,28 @@ import { FormComponentSuperclass } from './form-component-superclass'; * `, * providers: [provideValueAccessor(FullNameComponent)], * }) - * class FullNameComponent extends WrappedControlSuperclass { + * class FullNameComponent extends WrappedControlSuperclass< + * FullName | null, + * Partial + * > { * control = new FormGroup({ - * firstName: new FormControl(), - * lastName: new FormControl(), + * firstName: new FormControl('', { nonNullable: true }), + * lastName: new FormControl('', { nonNullable: true }), * }); * - * protected outerToInnerValue(outer: FullName | null): FullName { - * // `outer` can come in as `null` during initialization when the user binds with `ngModel` - * return outer || new FullName(); - * } - * } - * ``` - * - * Example when you need to modify the wrapped control's value: - * ```ts - * @Component({ - * template: ``, - * providers: [provideValueAccessor(DateComponent)], - * }) - * class DateComponent extends WrappedFormControlSuperclass { - * protected innerToOuterValue(inner: string): Date { - * return new Date(inner + "Z"); + * protected override outerToInnerValue(outer: FullName | null): FullName { + * // `formControlName` binding can't handle a null value + * return outer ?? new FullName(); * } * - * protected outerToInnerValue(outer: Date): string { - * if (outer === null) { - * return ""; // happens during initialization - * } - * return outer.toISOString().substr(0, 16); + * protected override innerToOuterValue( + * inner: Partial, + * ): FullName { + * // the values in a `FormGroup` can be `undefined` when their corresponding controls are disabled + * return { + * firstName: inner.firstName ?? '', + * lastName: inner.lastName ?? '', + * }; * } * } * ``` @@ -94,7 +116,7 @@ export abstract class WrappedControlSuperclass #errorHandler = inject(ErrorHandler); /** Bind this to your inner form control to make all the magic happen. */ - abstract control: AbstractControl; + abstract control: AbstractControl; constructor() { super();