Skip to content

Commit 68017d4

Browse files
crisbetodylhunn
authored andcommitted
feat(core): add ability to transform input values (#50420)
According to the HTML specification most attributes are defined as strings, however some can be interpreted as different types like booleans or numbers. [In the HTML standard](https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes), boolean attributes are considered `true` if they are present on a DOM node and `false` if they are omitted. Common examples of boolean attributes are `disabled` on interactive elements like `<button>` or `checked` on `<input type="checkbox">`. Another example of an attribute that is defined as a string, but interpreted as a different type is the `value` attribute of `<input type="number">` which logs a warning and ignores the value if it can't be parsed as a number. Historically, authoring Angular inputs that match the native behavior in a type-safe way has been difficult for developers, because Angular interprets all static attributes as strings. While some recent TypeScript versions made this easier by allowing setters and getters to have different types, supporting this pattern still requires a lot of boilerplate and additional properties to be declared. For example, currently developers have to write something like this to have a `disabled` input that behaves like the native one: ```typescript import {Directive, Input} from '@angular/core'; @directive({selector: 'mat-checkbox'}) export class MatCheckbox { @input() get disabled() { return this._disabled; } set disabled(value: any) { this._disabled = typeof value === 'boolean' ? value : (value != null && value !== 'false'); } private _disabled = false; } ``` This feature aims to address the issue by introducing a `transform` property on inputs. If an input has a `transform` function, any values set through the template will be passed through the function before being assigned to the directive instance. The example from above can be rewritten to the following: ```typescript import {Directive, Input, booleanAttribute} from '@angular/core'; @directive({selector: 'mat-checkbox'}) export class MatCheckbox { @input({transform: booleanAttribute}) disabled: boolean = false; } ``` These changes also add the `booleanAttribute` and `numberAttribute` utilities to `@angular/core` since they're common enough to be useful for most projects. Fixes #8968. Fixes #14761. PR Close #50420
1 parent 25b6b97 commit 68017d4

File tree

21 files changed

+511
-65
lines changed

21 files changed

+511
-65
lines changed

goldens/public-api/core/index.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ export interface AttributeDecorator {
121121
new (name: string): Attribute;
122122
}
123123

124+
// @public
125+
export function booleanAttribute(value: unknown): boolean;
126+
124127
// @public
125128
export interface BootstrapOptions {
126129
ngZone?: NgZone | 'zone.js' | 'noop';
@@ -481,6 +484,7 @@ export interface Directive {
481484
name: string;
482485
alias?: string;
483486
required?: boolean;
487+
transform?: (value: any) => any;
484488
} | string)[];
485489
jit?: true;
486490
outputs?: string[];
@@ -822,6 +826,7 @@ export interface InjectorType<T> extends Type<T> {
822826
export interface Input {
823827
alias?: string;
824828
required?: boolean;
829+
transform?: (value: any) => any;
825830
}
826831

827832
// @public (undocumented)
@@ -1059,6 +1064,9 @@ export interface NgZoneOptions {
10591064
// @public
10601065
export const NO_ERRORS_SCHEMA: SchemaMetadata;
10611066

1067+
// @public
1068+
export function numberAttribute(value: unknown, fallbackValue?: number): number;
1069+
10621070
// @public
10631071
export interface OnChanges {
10641072
ngOnChanges(changes: SimpleChanges): void;

packages/compiler-cli/test/ngtsc/ngtsc_spec.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8565,7 +8565,7 @@ function allTests(os: string) {
85658565

85668566
expect(jsContents).toContain('inputs: { value: ["value", "value", toNumber] }');
85678567
expect(jsContents)
8568-
.toContain('features: [i0.ɵɵStandaloneFeature, i0.ɵɵInputTransformsFeature]');
8568+
.toContain('features: [i0.ɵɵInputTransformsFeature, i0.ɵɵStandaloneFeature]');
85698569
expect(dtsContents).toContain('static ngAcceptInputType_value: boolean | string;');
85708570
});
85718571

@@ -8748,6 +8748,32 @@ function allTests(os: string) {
87488748
expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]');
87498749
expect(dtsContents).toContain('static ngAcceptInputType_value: unknown;');
87508750
});
8751+
8752+
it('should insert the InputTransformsFeature before the InheritDefinitionFeature', () => {
8753+
env.write('/test.ts', `
8754+
import {Directive, Input} from '@angular/core';
8755+
8756+
function toNumber(value: boolean | string) { return 1; }
8757+
8758+
@Directive()
8759+
export class ParentDir {}
8760+
8761+
@Directive()
8762+
export class Dir extends ParentDir {
8763+
@Input({transform: toNumber}) value!: number;
8764+
}
8765+
`);
8766+
8767+
env.driveMain();
8768+
8769+
const jsContents = env.getContents('test.js');
8770+
const dtsContents = env.getContents('test.d.ts');
8771+
8772+
expect(jsContents).toContain('inputs: { value: ["value", "value", toNumber] }');
8773+
expect(jsContents)
8774+
.toContain('features: [i0.ɵɵInputTransformsFeature, i0.ɵɵInheritDefinitionFeature]');
8775+
expect(dtsContents).toContain('static ngAcceptInputType_value: boolean | string;');
8776+
});
87518777
});
87528778
});
87538779

packages/compiler/src/jit_compiler_facade.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,7 @@ function convertDirectiveFacadeToMetadata(facade: R3DirectiveMetadataFacade): R3
325325
bindingPropertyName: ann.alias || field,
326326
classPropertyName: field,
327327
required: ann.required || false,
328-
// TODO(crisbeto): resolve transform function reference here.
329-
transformFunction: null,
328+
transformFunction: ann.transform != null ? new WrappedNodeExpr(ann.transform) : null,
330329
};
331330
} else if (isOutput(ann)) {
332331
outputsFromType[field] = ann.alias || field;
@@ -648,45 +647,47 @@ function isOutput(value: any): value is Output {
648647
return value.ngMetadataName === 'Output';
649648
}
650649

651-
function inputsMappingToInputMetadata(
652-
inputs: Record<string, string|[string, string, InputTransformFunction?]>) {
650+
function inputsMappingToInputMetadata(inputs: Record<string, string|[string, string, InputTransformFunction?]>) {
653651
return Object.keys(inputs).reduce<InputMap>((result, key) => {
654652
const value = inputs[key];
655653

656-
// TODO(crisbeto): resolve transform function reference here.
657654
if (typeof value === 'string') {
658655
result[key] = {
659656
bindingPropertyName: value,
660657
classPropertyName: value,
658+
transformFunction: null,
661659
required: false,
662-
transformFunction: null
663660
};
664661
} else {
665662
result[key] = {
666663
bindingPropertyName: value[0],
667664
classPropertyName: value[1],
665+
transformFunction: value[2] || null,
668666
required: false,
669-
transformFunction: null
670667
};
671668
}
672669

673670
return result;
674671
}, {});
675672
}
676673

677-
function parseInputsArray(values: (string|{name: string, alias?: string, required?: boolean})[]) {
674+
function parseInputsArray(
675+
values: (string|{name: string, alias?: string, required?: boolean, transform?: Function})[]) {
678676
return values.reduce<InputMap>((results, value) => {
679-
// TODO(crisbeto): resolve transform function reference here.
680677
if (typeof value === 'string') {
681678
const [bindingPropertyName, classPropertyName] = parseMappingString(value);
682-
results[classPropertyName] =
683-
{bindingPropertyName, classPropertyName, required: false, transformFunction: null};
679+
results[classPropertyName] = {
680+
bindingPropertyName,
681+
classPropertyName,
682+
required: false,
683+
transformFunction: null,
684+
};
684685
} else {
685686
results[value.name] = {
686687
bindingPropertyName: value.alias || value.name,
687688
classPropertyName: value.name,
688689
required: value.required || false,
689-
transformFunction: null
690+
transformFunction: value.transform != null ? new WrappedNodeExpr(value.transform) : null,
690691
};
691692
}
692693
return results;

packages/compiler/src/render3/view/compiler.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,12 @@ function addFeatures(
111111
}
112112
features.push(o.importExpr(R3.ProvidersFeature).callFn(args));
113113
}
114-
114+
for (const key of inputKeys) {
115+
if (meta.inputs[key].transformFunction !== null) {
116+
features.push(o.importExpr(R3.InputTransformsFeatureFeature));
117+
break;
118+
}
119+
}
115120
if (meta.usesInheritance) {
116121
features.push(o.importExpr(R3.InheritDefinitionFeature));
117122
}
@@ -129,12 +134,6 @@ function addFeatures(
129134
features.push(o.importExpr(R3.HostDirectivesFeature).callFn([createHostDirectivesFeatureArg(
130135
meta.hostDirectives)]));
131136
}
132-
for (const key of inputKeys) {
133-
if (meta.inputs[key].transformFunction !== null) {
134-
features.push(o.importExpr(R3.InputTransformsFeatureFeature));
135-
break;
136-
}
137-
}
138137
if (features.length) {
139138
definitionMap.set('features', o.literalArr(features));
140139
}

packages/core/src/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export {createComponent, reflectComponentType, ComponentMirror} from './render3/
4242
export {isStandalone} from './render3/definition';
4343
export {ApplicationConfig, mergeApplicationConfig} from './application_config';
4444
export {makeStateKey, StateKey, TransferState} from './transfer_state';
45+
export {booleanAttribute, numberAttribute} from './util/coercion';
4546

4647
import {global} from './util/global';
4748
if (typeof ngDevMode !== 'undefined' && ngDevMode) {

packages/core/src/core_private_export.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export {_sanitizeHtml as ɵ_sanitizeHtml} from './sanitization/html_sanitizer';
3030
export {_sanitizeUrl as ɵ_sanitizeUrl} from './sanitization/url_sanitizer';
3131
export {setAlternateWeakRefImpl as ɵsetAlternateWeakRefImpl} from './signals';
3232
export {TESTABILITY as ɵTESTABILITY, TESTABILITY_GETTER as ɵTESTABILITY_GETTER} from './testability/testability';
33-
export {coerceToBoolean as ɵcoerceToBoolean} from './util/coercion';
33+
export {booleanAttribute, numberAttribute} from './util/coercion';
3434
export {devModeEqual as ɵdevModeEqual} from './util/comparison';
3535
export {global as ɵglobal} from './util/global';
3636
export {isPromise as ɵisPromise, isSubscribable as ɵisSubscribable} from './util/lang';

packages/core/src/metadata/directives.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,12 @@ export interface Directive {
182182
* ```
183183
*
184184
*/
185-
inputs?: ({name: string, alias?: string, required?: boolean}|string)[];
185+
inputs?: ({
186+
name: string,
187+
alias?: string,
188+
required?: boolean,
189+
transform?: (value: any) => any,
190+
}|string)[];
186191

187192
/**
188193
* Enumerates the set of event-bound output properties.
@@ -817,6 +822,11 @@ export interface Input {
817822
* Whether the input is required for the directive to function.
818823
*/
819824
required?: boolean;
825+
826+
/**
827+
* Function with which to transform the input value before assigning it to the directive instance.
828+
*/
829+
transform?: (value: any) => any;
820830
}
821831

822832
/**

packages/core/src/render3/definition.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {initNgDevMode} from '../util/ng_dev_mode';
1818
import {stringify} from '../util/stringify';
1919

2020
import {NG_COMP_DEF, NG_DIR_DEF, NG_MOD_DEF, NG_PIPE_DEF} from './fields';
21-
import {ComponentDef, ComponentDefFeature, ComponentTemplate, ComponentType, ContentQueriesFunction, DependencyTypeList, DirectiveDef, DirectiveDefFeature, DirectiveDefListOrFactory, HostBindingsFunction, PipeDef, PipeDefListOrFactory, TypeOrFactory, ViewQueriesFunction} from './interfaces/definition';
21+
import {ComponentDef, ComponentDefFeature, ComponentTemplate, ComponentType, ContentQueriesFunction, DependencyTypeList, DirectiveDef, DirectiveDefFeature, DirectiveDefListOrFactory, HostBindingsFunction, InputTransformFunction, PipeDef, PipeDefListOrFactory, TypeOrFactory, ViewQueriesFunction} from './interfaces/definition';
2222
import {TAttributes, TConstantsOrFactory} from './interfaces/node';
2323
import {CssSelectorList} from './interfaces/projection';
2424
import {stringifyCSSSelectorList} from './node_selector_matcher';
@@ -35,7 +35,7 @@ interface DirectiveDefinition<T> {
3535
/**
3636
* A map of input names.
3737
*
38-
* The format is in: `{[actualPropertyName: string]:(string|[string, string])}`.
38+
* The format is in: `{[actualPropertyName: string]:(string|[string, string, Function])}`.
3939
*
4040
* Given:
4141
* ```
@@ -45,6 +45,9 @@ interface DirectiveDefinition<T> {
4545
*
4646
* @Input('publicInput2')
4747
* declaredInput2: string;
48+
*
49+
* @Input({transform: (value: boolean) => value ? 1 : 0})
50+
* transformedInput3: number;
4851
* }
4952
* ```
5053
*
@@ -53,6 +56,11 @@ interface DirectiveDefinition<T> {
5356
* {
5457
* publicInput1: 'publicInput1',
5558
* declaredInput2: ['declaredInput2', 'publicInput2'],
59+
* transformedInput3: [
60+
* 'transformedInput3',
61+
* 'transformedInput3',
62+
* (value: boolean) => value ? 1 : 0
63+
* ]
5664
* }
5765
* ```
5866
*
@@ -61,6 +69,11 @@ interface DirectiveDefinition<T> {
6169
* {
6270
* minifiedPublicInput1: 'publicInput1',
6371
* minifiedDeclaredInput2: [ 'publicInput2', 'declaredInput2'],
72+
* minifiedTransformedInput3: [
73+
* 'transformedInput3',
74+
* 'transformedInput3',
75+
* (value: boolean) => value ? 1 : 0
76+
* ]
6477
* }
6578
* ```
6679
*
@@ -75,7 +88,7 @@ interface DirectiveDefinition<T> {
7588
* this reason `NgOnChanges` will be deprecated and removed in future version and this
7689
* API will be simplified to be consistent with `output`.
7790
*/
78-
inputs?: {[P in keyof T]?: string|[string, string]};
91+
inputs?: {[P in keyof T]?: string|[string, string, InputTransformFunction?]};
7992

8093
/**
8194
* A map of output names.
@@ -484,13 +497,13 @@ export function ɵɵsetNgModuleScope(type: any, scope: {
484497
485498
*/
486499
function invertObject<T>(
487-
obj?: {[P in keyof T]?: string|[string, string]},
488-
secondary?: {[key: string]: string}): {[P in keyof T]: string} {
500+
obj?: {[P in keyof T]?: string|[string, string, ...unknown[]]},
501+
secondary?: Record<string, string>): {[P in keyof T]: string} {
489502
if (obj == null) return EMPTY_OBJ as any;
490503
const newLookup: any = {};
491504
for (const minifiedKey in obj) {
492505
if (obj.hasOwnProperty(minifiedKey)) {
493-
let publicName: string|[string, string] = obj[minifiedKey]!;
506+
let publicName: string|[string, string, ...unknown[]] = obj[minifiedKey]!;
494507
let declaredName = publicName;
495508
if (Array.isArray(publicName)) {
496509
declaredName = publicName[1];
@@ -626,6 +639,8 @@ function getNgDirectiveDef<T>(directiveDefinition: DirectiveDefinition<T>):
626639
hostAttrs: directiveDefinition.hostAttrs || null,
627640
contentQueries: directiveDefinition.contentQueries || null,
628641
declaredInputs,
642+
inputTransforms: null,
643+
inputConfig: directiveDefinition.inputs || EMPTY_OBJ,
629644
exportAs: directiveDefinition.exportAs || null,
630645
standalone: directiveDefinition.standalone === true,
631646
signals: directiveDefinition.signals === true,

packages/core/src/render3/features/inherit_definition_feature.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export function ɵɵInheritDefinitionFeature(definition: DirectiveDef<any>|Compo
5959
// would've justified object creation. Unwrap them if necessary.
6060
const writeableDef = definition as WritableDef;
6161
writeableDef.inputs = maybeUnwrapEmpty(definition.inputs);
62+
writeableDef.inputTransforms = maybeUnwrapEmpty(definition.inputTransforms);
6263
writeableDef.declaredInputs = maybeUnwrapEmpty(definition.declaredInputs);
6364
writeableDef.outputs = maybeUnwrapEmpty(definition.outputs);
6465

@@ -77,6 +78,13 @@ export function ɵɵInheritDefinitionFeature(definition: DirectiveDef<any>|Compo
7778
fillProperties(definition.declaredInputs, superDef.declaredInputs);
7879
fillProperties(definition.outputs, superDef.outputs);
7980

81+
if (superDef.inputTransforms !== null) {
82+
if (writeableDef.inputTransforms === null) {
83+
writeableDef.inputTransforms = {};
84+
}
85+
fillProperties(writeableDef.inputTransforms, superDef.inputTransforms);
86+
}
87+
8088
// Merge animations metadata.
8189
// If `superDef` is a Component, the `data` field is present (defaults to an empty object).
8290
if (isComponentDef(superDef) && superDef.data.animation) {

packages/core/src/render3/features/input_transforms_feature.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,32 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {ComponentDef, DirectiveDef} from '../interfaces/definition';
9+
import {Mutable} from '../../interface/type';
10+
import {DirectiveDef, InputTransformFunction} from '../interfaces/definition';
1011

11-
// TODO(crisbeto): move input transforms runtime functionality here.
1212
/**
13+
* Decorates the directive definition with support for input transform functions.
14+
*
15+
* If the directive uses inheritance, the feature should be included before the
16+
* `InheritDefinitionFeature` to ensure that the `inputTransforms` field is populated.
17+
*
1318
* @codeGenApi
1419
*/
15-
export function ɵɵInputTransformsFeature(definition: DirectiveDef<any>|ComponentDef<any>): void {}
20+
export function ɵɵInputTransformsFeature(definition: DirectiveDef<unknown>): void {
21+
const inputs = definition.inputConfig;
22+
const inputTransforms: Record<string, InputTransformFunction> = {};
23+
24+
for (const minifiedKey in inputs) {
25+
if (inputs.hasOwnProperty(minifiedKey)) {
26+
// Note: the private names are used for the keys, rather than the public ones, because public
27+
// names can be re-aliased in host directives which would invalidate the lookup.
28+
const value = inputs[minifiedKey];
29+
if (Array.isArray(value) && value[2]) {
30+
inputTransforms[minifiedKey] = value[2];
31+
}
32+
}
33+
}
34+
35+
(definition as Mutable<DirectiveDef<unknown>, 'inputTransforms'>).inputTransforms =
36+
inputTransforms;
37+
}

0 commit comments

Comments
 (0)