Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 17 additions & 16 deletions goldens/public-api/forms/signals/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as i0 from '@angular/core';
import { InjectionToken } from '@angular/core';
import { Injector } from '@angular/core';
import { InputSignal } from '@angular/core';
import { InputSignalWithTransform } from '@angular/core';
import { ModelSignal } from '@angular/core';
import { NgControl } from '@angular/forms';
import { OutputRef } from '@angular/core';
Expand Down Expand Up @@ -216,22 +217,22 @@ export interface FormOptions {

// @public
export interface FormUiControl {
readonly dirty?: InputSignal<boolean>;
readonly disabled?: InputSignal<boolean>;
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]>;
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]>;
readonly hidden?: InputSignal<boolean>;
readonly invalid?: InputSignal<boolean>;
readonly max?: InputSignal<number | undefined>;
readonly maxLength?: InputSignal<number | undefined>;
readonly min?: InputSignal<number | undefined>;
readonly minLength?: InputSignal<number | undefined>;
readonly name?: InputSignal<string>;
readonly pattern?: InputSignal<readonly RegExp[]>;
readonly pending?: InputSignal<boolean>;
readonly readonly?: InputSignal<boolean>;
readonly required?: InputSignal<boolean>;
readonly touched?: ModelSignal<boolean> | InputSignal<boolean> | OutputRef<boolean>;
readonly dirty?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly disabled?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]> | InputSignalWithTransform<readonly WithOptionalField<DisabledReason>[], unknown>;
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]> | InputSignalWithTransform<readonly WithOptionalField<ValidationError>[], unknown>;
readonly hidden?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly invalid?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly max?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
readonly maxLength?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
readonly min?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
readonly minLength?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
readonly name?: InputSignal<string> | InputSignalWithTransform<string, unknown>;
readonly pattern?: InputSignal<readonly RegExp[]> | InputSignalWithTransform<readonly RegExp[], unknown>;
readonly pending?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly readonly?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly required?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly touched?: ModelSignal<boolean> | InputSignal<boolean> | InputSignalWithTransform<boolean, unknown> | OutputRef<boolean>;
}

// @public
Expand Down
52 changes: 35 additions & 17 deletions packages/forms/signals/src/api/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {InputSignal, ModelSignal, OutputRef} from '@angular/core';
import {InputSignal, InputSignalWithTransform, ModelSignal, OutputRef} from '@angular/core';
import type {DisabledReason} from './types';
import {ValidationError, type WithOptionalField} from './validation_errors';

Expand All @@ -25,82 +25,100 @@ export interface FormUiControl {
* An input to receive the errors for the field. If implemented, the `Field` directive will
* automatically bind errors from the bound field to this input.
*/
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]>;
readonly errors?:
| InputSignal<readonly WithOptionalField<ValidationError>[]>
| InputSignalWithTransform<readonly WithOptionalField<ValidationError>[], unknown>;
/**
* An input to receive the disabled status for the field. If implemented, the `Field` directive
* will automatically bind the disabled status from the bound field to this input.
*/
readonly disabled?: InputSignal<boolean>;
readonly disabled?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
/**
* An input to receive the reasons for the disablement of the field. If implemented, the `Field`
* directive will automatically bind the disabled reason from the bound field to this input.
*/
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]>;
readonly disabledReasons?:
| InputSignal<readonly WithOptionalField<DisabledReason>[]>
| InputSignalWithTransform<readonly WithOptionalField<DisabledReason>[], unknown>;
/**
* An input to receive the readonly status for the field. If implemented, the `Field` directive
* will automatically bind the readonly status from the bound field to this input.
*/
readonly readonly?: InputSignal<boolean>;
readonly readonly?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
/**
* An input to receive the hidden status for the field. If implemented, the `Field` directive
* will automatically bind the hidden status from the bound field to this input.
*/
readonly hidden?: InputSignal<boolean>;
readonly hidden?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
/**
* An input to receive the invalid status for the field. If implemented, the `Field` directive
* will automatically bind the invalid status from the bound field to this input.
*/
readonly invalid?: InputSignal<boolean>;
readonly invalid?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
/**
* An input to receive the pending status for the field. If implemented, the `Field` directive
* will automatically bind the pending status from the bound field to this input.
*/
readonly pending?: InputSignal<boolean>;
readonly pending?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
/**
* An input to receive the touched status for the field. If implemented, the `Field` directive
* will automatically bind the touched status from the bound field to this input.
*/
readonly touched?: ModelSignal<boolean> | InputSignal<boolean> | OutputRef<boolean>;
readonly touched?:
| ModelSignal<boolean>
| InputSignal<boolean>
| InputSignalWithTransform<boolean, unknown>
| OutputRef<boolean>;
/**
* An input to receive the dirty status for the field. If implemented, the `Field` directive
* will automatically bind the dirty status from the bound field to this input.
*/
readonly dirty?: InputSignal<boolean>;
readonly dirty?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
/**
* An input to receive the name for the field. If implemented, the `Field` directive will
* automatically bind the name from the bound field to this input.
*/
readonly name?: InputSignal<string>;
readonly name?: InputSignal<string> | InputSignalWithTransform<string, unknown>;
/**
* An input to receive the required status for the field. If implemented, the `Field` directive
* will automatically bind the required status from the bound field to this input.
*/
readonly required?: InputSignal<boolean>;
readonly required?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
/**
* An input to receive the min value for the field. If implemented, the `Field` directive will
* automatically bind the min value from the bound field to this input.
*/
readonly min?: InputSignal<number | undefined>;
readonly min?:
| InputSignal<number | undefined>
| InputSignalWithTransform<number | undefined, unknown>;
/**
* An input to receive the min length for the field. If implemented, the `Field` directive will
* automatically bind the min length from the bound field to this input.
*/
readonly minLength?: InputSignal<number | undefined>;
readonly minLength?:
| InputSignal<number | undefined>
| InputSignalWithTransform<number | undefined, unknown>;
/**
* An input to receive the max value for the field. If implemented, the `Field` directive will
* automatically bind the max value from the bound field to this input.
*/
readonly max?: InputSignal<number | undefined>;
readonly max?:
| InputSignal<number | undefined>
| InputSignalWithTransform<number | undefined, unknown>;
/**
* An input to receive the max length for the field. If implemented, the `Field` directive will
* automatically bind the max length from the bound field to this input.
*/
readonly maxLength?: InputSignal<number | undefined>;
readonly maxLength?:
| InputSignal<number | undefined>
| InputSignalWithTransform<number | undefined, unknown>;
/**
* An input to receive the value patterns for the field. If implemented, the `Field` directive
* will automatically bind the value patterns from the bound field to this input.
*/
readonly pattern?: InputSignal<readonly RegExp[]>;
readonly pattern?:
| InputSignal<readonly RegExp[]>
| InputSignalWithTransform<readonly RegExp[], unknown>;
}

/**
Expand Down
139 changes: 139 additions & 0 deletions packages/forms/signals/test/web/field_directive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import {
booleanAttribute,
Component,
computed,
Directive,
Expand All @@ -16,6 +17,7 @@ import {
input,
inputBinding,
model,
numberAttribute,
signal,
viewChild,
viewChildren,
Expand Down Expand Up @@ -1133,6 +1135,143 @@ describe('field directive', () => {
});
});

describe('input transforms', () => {
it('should accept InputSignal without transform', () => {
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model('');
readonly disabled = input(false);
readonly readonly = input(false);
readonly required = input(false);
readonly hidden = input(false);
readonly invalid = input(false);
readonly pending = input(false);
readonly dirty = input(false);
readonly touched = input(false);
readonly min = input<number | undefined>(1);
readonly max = input<number | undefined>(1_0000);
readonly minLength = input<number | undefined>(1);
readonly maxLength = input<number | undefined>(5);
}

@Component({
imports: [Field, CustomControl],
template: `<custom-control [field]="f" />`,
})
class TestCmp {
readonly f = form(signal(''));
}

const fixture = act(() => TestBed.createComponent(TestCmp));
expect(fixture.componentInstance).toBeDefined();
});

it('should accept InputSignalWithTransform for boolean properties', () => {
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model('');
readonly disabled = input(false, {transform: booleanAttribute});
readonly readonly = input(false, {transform: booleanAttribute});
readonly required = input(false, {transform: booleanAttribute});
readonly hidden = input(false, {transform: booleanAttribute});
readonly invalid = input(false, {transform: booleanAttribute});
readonly pending = input(false, {transform: booleanAttribute});
readonly dirty = input(false, {transform: booleanAttribute});
readonly touched = input(false, {transform: booleanAttribute});
}

@Component({
imports: [Field, CustomControl],
template: `<custom-control [field]="f" />`,
})
class TestCmp {
readonly f = form(signal(''));
}

const fixture = act(() => TestBed.createComponent(TestCmp));
expect(fixture.componentInstance).toBeDefined();
});

it('should accept InputSignalWithTransform for number properties', () => {
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<number> {
readonly value = model(0);
readonly min = input<number | undefined, unknown>(undefined, {transform: numberAttribute});
readonly max = input<number | undefined, unknown>(undefined, {transform: numberAttribute});
readonly minLength = input<number | undefined, unknown>(undefined, {
transform: numberAttribute,
});
readonly maxLength = input<number | undefined, unknown>(undefined, {
transform: numberAttribute,
});
}

@Component({
imports: [Field, CustomControl],
template: `<custom-control [field]="f" />`,
})
class TestCmp {
readonly f = form(signal(0));
}

const fixture = act(() => TestBed.createComponent(TestCmp));
expect(fixture.componentInstance).toBeDefined();
});

it('should accept custom transform for arrays', () => {
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model('');
readonly name = input('', {transform: (v: unknown) => String(v ?? '')});
readonly pattern = input<readonly RegExp[], unknown>([], {
transform: (v: unknown) => (Array.isArray(v) ? v : []),
});
readonly errors = input<readonly WithOptionalField<ValidationError>[], unknown>([], {
transform: (v: unknown) => (Array.isArray(v) ? v : []),
});
readonly disabledReasons = input<readonly WithOptionalField<DisabledReason>[], unknown>(
[],
{
transform: (v: unknown) => (Array.isArray(v) ? v : []),
},
);
}

@Component({
imports: [Field, CustomControl],
template: `<custom-control [field]="f" />`,
})
class TestCmp {
readonly f = form(signal(''));
}

const fixture = act(() => TestBed.createComponent(TestCmp));
expect(fixture.componentInstance).toBeDefined();
});

it('should accept mixed InputSignal and InputSignalWithTransform', () => {
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model('');
readonly disabled = input(false);
readonly readonly = input(false, {transform: booleanAttribute});
readonly required = input(false);
readonly name = input('', {transform: (v: unknown) => String(v ?? '')});
}

@Component({
imports: [Field, CustomControl],
template: `<custom-control [field]="f" />`,
})
class TestCmp {
readonly f = form(signal(''));
}

const fixture = act(() => TestBed.createComponent(TestCmp));
expect(fixture.componentInstance).toBeDefined();
});
});

it('synchronizes with a value control', () => {
@Component({
imports: [Field],
Expand Down