Skip to content

Commit 7d1e502

Browse files
SkyZeroZxpkozlowski-opensource
authored andcommitted
feat(forms): Allows transforms on FormUiControl signals
Extends the `FormUiControl` interface to allow `InputSignalWithTransform` in addition to `InputSignal` for its properties. Fixes angular#65756
1 parent e6d5632 commit 7d1e502

3 files changed

Lines changed: 191 additions & 33 deletions

File tree

goldens/public-api/forms/signals/index.api.md

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as i0 from '@angular/core';
1515
import { InjectionToken } from '@angular/core';
1616
import { Injector } from '@angular/core';
1717
import { InputSignal } from '@angular/core';
18+
import { InputSignalWithTransform } from '@angular/core';
1819
import { ModelSignal } from '@angular/core';
1920
import { NgControl } from '@angular/forms';
2021
import { OutputRef } from '@angular/core';
@@ -221,22 +222,22 @@ export interface FormOptions {
221222

222223
// @public
223224
export interface FormUiControl {
224-
readonly dirty?: InputSignal<boolean>;
225-
readonly disabled?: InputSignal<boolean>;
226-
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]>;
227-
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]>;
228-
readonly hidden?: InputSignal<boolean>;
229-
readonly invalid?: InputSignal<boolean>;
230-
readonly max?: InputSignal<number | undefined>;
231-
readonly maxLength?: InputSignal<number | undefined>;
232-
readonly min?: InputSignal<number | undefined>;
233-
readonly minLength?: InputSignal<number | undefined>;
234-
readonly name?: InputSignal<string>;
235-
readonly pattern?: InputSignal<readonly RegExp[]>;
236-
readonly pending?: InputSignal<boolean>;
237-
readonly readonly?: InputSignal<boolean>;
238-
readonly required?: InputSignal<boolean>;
239-
readonly touched?: ModelSignal<boolean> | InputSignal<boolean> | OutputRef<boolean>;
225+
readonly dirty?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
226+
readonly disabled?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
227+
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]> | InputSignalWithTransform<readonly WithOptionalField<DisabledReason>[], unknown>;
228+
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]> | InputSignalWithTransform<readonly WithOptionalField<ValidationError>[], unknown>;
229+
readonly hidden?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
230+
readonly invalid?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
231+
readonly max?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
232+
readonly maxLength?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
233+
readonly min?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
234+
readonly minLength?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
235+
readonly name?: InputSignal<string> | InputSignalWithTransform<string, unknown>;
236+
readonly pattern?: InputSignal<readonly RegExp[]> | InputSignalWithTransform<readonly RegExp[], unknown>;
237+
readonly pending?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
238+
readonly readonly?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
239+
readonly required?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
240+
readonly touched?: ModelSignal<boolean> | InputSignal<boolean> | InputSignalWithTransform<boolean, unknown> | OutputRef<boolean>;
240241
}
241242

242243
// @public

packages/forms/signals/src/api/control.ts

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {InputSignal, ModelSignal, OutputRef} from '@angular/core';
9+
import {InputSignal, InputSignalWithTransform, ModelSignal, OutputRef} from '@angular/core';
1010
import type {DisabledReason} from './types';
1111
import {ValidationError, type WithOptionalField} from './validation_errors';
1212

@@ -25,82 +25,100 @@ export interface FormUiControl {
2525
* An input to receive the errors for the field. If implemented, the `Field` directive will
2626
* automatically bind errors from the bound field to this input.
2727
*/
28-
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]>;
28+
readonly errors?:
29+
| InputSignal<readonly WithOptionalField<ValidationError>[]>
30+
| InputSignalWithTransform<readonly WithOptionalField<ValidationError>[], unknown>;
2931
/**
3032
* An input to receive the disabled status for the field. If implemented, the `Field` directive
3133
* will automatically bind the disabled status from the bound field to this input.
3234
*/
33-
readonly disabled?: InputSignal<boolean>;
35+
readonly disabled?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
3436
/**
3537
* An input to receive the reasons for the disablement of the field. If implemented, the `Field`
3638
* directive will automatically bind the disabled reason from the bound field to this input.
3739
*/
38-
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]>;
40+
readonly disabledReasons?:
41+
| InputSignal<readonly WithOptionalField<DisabledReason>[]>
42+
| InputSignalWithTransform<readonly WithOptionalField<DisabledReason>[], unknown>;
3943
/**
4044
* An input to receive the readonly status for the field. If implemented, the `Field` directive
4145
* will automatically bind the readonly status from the bound field to this input.
4246
*/
43-
readonly readonly?: InputSignal<boolean>;
47+
readonly readonly?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
4448
/**
4549
* An input to receive the hidden status for the field. If implemented, the `Field` directive
4650
* will automatically bind the hidden status from the bound field to this input.
4751
*/
48-
readonly hidden?: InputSignal<boolean>;
52+
readonly hidden?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
4953
/**
5054
* An input to receive the invalid status for the field. If implemented, the `Field` directive
5155
* will automatically bind the invalid status from the bound field to this input.
5256
*/
53-
readonly invalid?: InputSignal<boolean>;
57+
readonly invalid?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
5458
/**
5559
* An input to receive the pending status for the field. If implemented, the `Field` directive
5660
* will automatically bind the pending status from the bound field to this input.
5761
*/
58-
readonly pending?: InputSignal<boolean>;
62+
readonly pending?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
5963
/**
6064
* An input to receive the touched status for the field. If implemented, the `Field` directive
6165
* will automatically bind the touched status from the bound field to this input.
6266
*/
63-
readonly touched?: ModelSignal<boolean> | InputSignal<boolean> | OutputRef<boolean>;
67+
readonly touched?:
68+
| ModelSignal<boolean>
69+
| InputSignal<boolean>
70+
| InputSignalWithTransform<boolean, unknown>
71+
| OutputRef<boolean>;
6472
/**
6573
* An input to receive the dirty status for the field. If implemented, the `Field` directive
6674
* will automatically bind the dirty status from the bound field to this input.
6775
*/
68-
readonly dirty?: InputSignal<boolean>;
76+
readonly dirty?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
6977
/**
7078
* An input to receive the name for the field. If implemented, the `Field` directive will
7179
* automatically bind the name from the bound field to this input.
7280
*/
73-
readonly name?: InputSignal<string>;
81+
readonly name?: InputSignal<string> | InputSignalWithTransform<string, unknown>;
7482
/**
7583
* An input to receive the required status for the field. If implemented, the `Field` directive
7684
* will automatically bind the required status from the bound field to this input.
7785
*/
78-
readonly required?: InputSignal<boolean>;
86+
readonly required?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
7987
/**
8088
* An input to receive the min value for the field. If implemented, the `Field` directive will
8189
* automatically bind the min value from the bound field to this input.
8290
*/
83-
readonly min?: InputSignal<number | undefined>;
91+
readonly min?:
92+
| InputSignal<number | undefined>
93+
| InputSignalWithTransform<number | undefined, unknown>;
8494
/**
8595
* An input to receive the min length for the field. If implemented, the `Field` directive will
8696
* automatically bind the min length from the bound field to this input.
8797
*/
88-
readonly minLength?: InputSignal<number | undefined>;
98+
readonly minLength?:
99+
| InputSignal<number | undefined>
100+
| InputSignalWithTransform<number | undefined, unknown>;
89101
/**
90102
* An input to receive the max value for the field. If implemented, the `Field` directive will
91103
* automatically bind the max value from the bound field to this input.
92104
*/
93-
readonly max?: InputSignal<number | undefined>;
105+
readonly max?:
106+
| InputSignal<number | undefined>
107+
| InputSignalWithTransform<number | undefined, unknown>;
94108
/**
95109
* An input to receive the max length for the field. If implemented, the `Field` directive will
96110
* automatically bind the max length from the bound field to this input.
97111
*/
98-
readonly maxLength?: InputSignal<number | undefined>;
112+
readonly maxLength?:
113+
| InputSignal<number | undefined>
114+
| InputSignalWithTransform<number | undefined, unknown>;
99115
/**
100116
* An input to receive the value patterns for the field. If implemented, the `Field` directive
101117
* will automatically bind the value patterns from the bound field to this input.
102118
*/
103-
readonly pattern?: InputSignal<readonly RegExp[]>;
119+
readonly pattern?:
120+
| InputSignal<readonly RegExp[]>
121+
| InputSignalWithTransform<readonly RegExp[], unknown>;
104122
}
105123

106124
/**

packages/forms/signals/test/web/field_directive.spec.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {
10+
booleanAttribute,
1011
Component,
1112
computed,
1213
Directive,
@@ -16,6 +17,7 @@ import {
1617
input,
1718
inputBinding,
1819
model,
20+
numberAttribute,
1921
signal,
2022
viewChild,
2123
viewChildren,
@@ -1133,6 +1135,143 @@ describe('field directive', () => {
11331135
});
11341136
});
11351137

1138+
describe('input transforms', () => {
1139+
it('should accept InputSignal without transform', () => {
1140+
@Component({selector: 'custom-control', template: ``})
1141+
class CustomControl implements FormValueControl<string> {
1142+
readonly value = model('');
1143+
readonly disabled = input(false);
1144+
readonly readonly = input(false);
1145+
readonly required = input(false);
1146+
readonly hidden = input(false);
1147+
readonly invalid = input(false);
1148+
readonly pending = input(false);
1149+
readonly dirty = input(false);
1150+
readonly touched = input(false);
1151+
readonly min = input<number | undefined>(1);
1152+
readonly max = input<number | undefined>(1_0000);
1153+
readonly minLength = input<number | undefined>(1);
1154+
readonly maxLength = input<number | undefined>(5);
1155+
}
1156+
1157+
@Component({
1158+
imports: [Field, CustomControl],
1159+
template: `<custom-control [field]="f" />`,
1160+
})
1161+
class TestCmp {
1162+
readonly f = form(signal(''));
1163+
}
1164+
1165+
const fixture = act(() => TestBed.createComponent(TestCmp));
1166+
expect(fixture.componentInstance).toBeDefined();
1167+
});
1168+
1169+
it('should accept InputSignalWithTransform for boolean properties', () => {
1170+
@Component({selector: 'custom-control', template: ``})
1171+
class CustomControl implements FormValueControl<string> {
1172+
readonly value = model('');
1173+
readonly disabled = input(false, {transform: booleanAttribute});
1174+
readonly readonly = input(false, {transform: booleanAttribute});
1175+
readonly required = input(false, {transform: booleanAttribute});
1176+
readonly hidden = input(false, {transform: booleanAttribute});
1177+
readonly invalid = input(false, {transform: booleanAttribute});
1178+
readonly pending = input(false, {transform: booleanAttribute});
1179+
readonly dirty = input(false, {transform: booleanAttribute});
1180+
readonly touched = input(false, {transform: booleanAttribute});
1181+
}
1182+
1183+
@Component({
1184+
imports: [Field, CustomControl],
1185+
template: `<custom-control [field]="f" />`,
1186+
})
1187+
class TestCmp {
1188+
readonly f = form(signal(''));
1189+
}
1190+
1191+
const fixture = act(() => TestBed.createComponent(TestCmp));
1192+
expect(fixture.componentInstance).toBeDefined();
1193+
});
1194+
1195+
it('should accept InputSignalWithTransform for number properties', () => {
1196+
@Component({selector: 'custom-control', template: ``})
1197+
class CustomControl implements FormValueControl<number> {
1198+
readonly value = model(0);
1199+
readonly min = input<number | undefined, unknown>(undefined, {transform: numberAttribute});
1200+
readonly max = input<number | undefined, unknown>(undefined, {transform: numberAttribute});
1201+
readonly minLength = input<number | undefined, unknown>(undefined, {
1202+
transform: numberAttribute,
1203+
});
1204+
readonly maxLength = input<number | undefined, unknown>(undefined, {
1205+
transform: numberAttribute,
1206+
});
1207+
}
1208+
1209+
@Component({
1210+
imports: [Field, CustomControl],
1211+
template: `<custom-control [field]="f" />`,
1212+
})
1213+
class TestCmp {
1214+
readonly f = form(signal(0));
1215+
}
1216+
1217+
const fixture = act(() => TestBed.createComponent(TestCmp));
1218+
expect(fixture.componentInstance).toBeDefined();
1219+
});
1220+
1221+
it('should accept custom transform for arrays', () => {
1222+
@Component({selector: 'custom-control', template: ``})
1223+
class CustomControl implements FormValueControl<string> {
1224+
readonly value = model('');
1225+
readonly name = input('', {transform: (v: unknown) => String(v ?? '')});
1226+
readonly pattern = input<readonly RegExp[], unknown>([], {
1227+
transform: (v: unknown) => (Array.isArray(v) ? v : []),
1228+
});
1229+
readonly errors = input<readonly WithOptionalField<ValidationError>[], unknown>([], {
1230+
transform: (v: unknown) => (Array.isArray(v) ? v : []),
1231+
});
1232+
readonly disabledReasons = input<readonly WithOptionalField<DisabledReason>[], unknown>(
1233+
[],
1234+
{
1235+
transform: (v: unknown) => (Array.isArray(v) ? v : []),
1236+
},
1237+
);
1238+
}
1239+
1240+
@Component({
1241+
imports: [Field, CustomControl],
1242+
template: `<custom-control [field]="f" />`,
1243+
})
1244+
class TestCmp {
1245+
readonly f = form(signal(''));
1246+
}
1247+
1248+
const fixture = act(() => TestBed.createComponent(TestCmp));
1249+
expect(fixture.componentInstance).toBeDefined();
1250+
});
1251+
1252+
it('should accept mixed InputSignal and InputSignalWithTransform', () => {
1253+
@Component({selector: 'custom-control', template: ``})
1254+
class CustomControl implements FormValueControl<string> {
1255+
readonly value = model('');
1256+
readonly disabled = input(false);
1257+
readonly readonly = input(false, {transform: booleanAttribute});
1258+
readonly required = input(false);
1259+
readonly name = input('', {transform: (v: unknown) => String(v ?? '')});
1260+
}
1261+
1262+
@Component({
1263+
imports: [Field, CustomControl],
1264+
template: `<custom-control [field]="f" />`,
1265+
})
1266+
class TestCmp {
1267+
readonly f = form(signal(''));
1268+
}
1269+
1270+
const fixture = act(() => TestBed.createComponent(TestCmp));
1271+
expect(fixture.componentInstance).toBeDefined();
1272+
});
1273+
});
1274+
11361275
it('synchronizes with a value control', () => {
11371276
@Component({
11381277
imports: [Field],

0 commit comments

Comments
 (0)