diff --git a/goldens/public-api/core/index.md b/goldens/public-api/core/index.md index 253f4132f63c9a..ff8326232e0dfd 100644 --- a/goldens/public-api/core/index.md +++ b/goldens/public-api/core/index.md @@ -121,6 +121,9 @@ export interface AttributeDecorator { new (name: string): Attribute; } +// @public +export function booleanAttribute(value: unknown): boolean; + // @public export interface BootstrapOptions { ngZone?: NgZone | 'zone.js' | 'noop'; @@ -481,6 +484,7 @@ export interface Directive { name: string; alias?: string; required?: boolean; + transform?: (value: any) => any; } | string)[]; jit?: true; outputs?: string[]; @@ -822,6 +826,7 @@ export interface InjectorType extends Type { export interface Input { alias?: string; required?: boolean; + transform?: (value: any) => any; } // @public (undocumented) @@ -1059,6 +1064,9 @@ export interface NgZoneOptions { // @public export const NO_ERRORS_SCHEMA: SchemaMetadata; +// @public +export function numberAttribute(value: unknown, fallbackValue?: number): number; + // @public export interface OnChanges { ngOnChanges(changes: SimpleChanges): void; diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index 5a2f8c377da30b..af203981b9fa84 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -8565,7 +8565,7 @@ function allTests(os: string) { expect(jsContents).toContain('inputs: { value: ["value", "value", toNumber] }'); expect(jsContents) - .toContain('features: [i0.ɵɵStandaloneFeature, i0.ɵɵInputTransformsFeature]'); + .toContain('features: [i0.ɵɵInputTransformsFeature, i0.ɵɵStandaloneFeature]'); expect(dtsContents).toContain('static ngAcceptInputType_value: boolean | string;'); }); @@ -8748,6 +8748,32 @@ function allTests(os: string) { expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]'); expect(dtsContents).toContain('static ngAcceptInputType_value: unknown;'); }); + + it('should insert the InputTransformsFeature before the InheritDefinitionFeature', () => { + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + function toNumber(value: boolean | string) { return 1; } + + @Directive() + export class ParentDir {} + + @Directive() + export class Dir extends ParentDir { + @Input({transform: toNumber}) value!: number; + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + const dtsContents = env.getContents('test.d.ts'); + + expect(jsContents).toContain('inputs: { value: ["value", "value", toNumber] }'); + expect(jsContents) + .toContain('features: [i0.ɵɵInputTransformsFeature, i0.ɵɵInheritDefinitionFeature]'); + expect(dtsContents).toContain('static ngAcceptInputType_value: boolean | string;'); + }); }); }); diff --git a/packages/compiler/src/jit_compiler_facade.ts b/packages/compiler/src/jit_compiler_facade.ts index 704fd5673d8103..5b2be736d5c3ee 100644 --- a/packages/compiler/src/jit_compiler_facade.ts +++ b/packages/compiler/src/jit_compiler_facade.ts @@ -325,8 +325,7 @@ function convertDirectiveFacadeToMetadata(facade: R3DirectiveMetadataFacade): R3 bindingPropertyName: ann.alias || field, classPropertyName: field, required: ann.required || false, - // TODO(crisbeto): resolve transform function reference here. - transformFunction: null, + transformFunction: ann.transform != null ? new WrappedNodeExpr(ann.transform) : null, }; } else if (isOutput(ann)) { outputsFromType[field] = ann.alias || field; @@ -648,25 +647,23 @@ function isOutput(value: any): value is Output { return value.ngMetadataName === 'Output'; } -function inputsMappingToInputMetadata( - inputs: Record) { +function inputsMappingToInputMetadata(inputs: Record) { return Object.keys(inputs).reduce((result, key) => { const value = inputs[key]; - // TODO(crisbeto): resolve transform function reference here. if (typeof value === 'string') { result[key] = { bindingPropertyName: value, classPropertyName: value, + transformFunction: null, required: false, - transformFunction: null }; } else { result[key] = { bindingPropertyName: value[0], classPropertyName: value[1], + transformFunction: value[2] || null, required: false, - transformFunction: null }; } @@ -674,19 +671,23 @@ function inputsMappingToInputMetadata( }, {}); } -function parseInputsArray(values: (string|{name: string, alias?: string, required?: boolean})[]) { +function parseInputsArray( + values: (string|{name: string, alias?: string, required?: boolean, transform?: Function})[]) { return values.reduce((results, value) => { - // TODO(crisbeto): resolve transform function reference here. if (typeof value === 'string') { const [bindingPropertyName, classPropertyName] = parseMappingString(value); - results[classPropertyName] = - {bindingPropertyName, classPropertyName, required: false, transformFunction: null}; + results[classPropertyName] = { + bindingPropertyName, + classPropertyName, + required: false, + transformFunction: null, + }; } else { results[value.name] = { bindingPropertyName: value.alias || value.name, classPropertyName: value.name, required: value.required || false, - transformFunction: null + transformFunction: value.transform != null ? new WrappedNodeExpr(value.transform) : null, }; } return results; diff --git a/packages/compiler/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 78519f1df9bbe0..5ed0079dd7992e 100644 --- a/packages/compiler/src/render3/view/compiler.ts +++ b/packages/compiler/src/render3/view/compiler.ts @@ -111,7 +111,12 @@ function addFeatures( } features.push(o.importExpr(R3.ProvidersFeature).callFn(args)); } - + for (const key of inputKeys) { + if (meta.inputs[key].transformFunction !== null) { + features.push(o.importExpr(R3.InputTransformsFeatureFeature)); + break; + } + } if (meta.usesInheritance) { features.push(o.importExpr(R3.InheritDefinitionFeature)); } @@ -129,12 +134,6 @@ function addFeatures( features.push(o.importExpr(R3.HostDirectivesFeature).callFn([createHostDirectivesFeatureArg( meta.hostDirectives)])); } - for (const key of inputKeys) { - if (meta.inputs[key].transformFunction !== null) { - features.push(o.importExpr(R3.InputTransformsFeatureFeature)); - break; - } - } if (features.length) { definitionMap.set('features', o.literalArr(features)); } diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 89936061e36d1d..7d80a026ba4ad1 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -42,6 +42,7 @@ export {createComponent, reflectComponentType, ComponentMirror} from './render3/ export {isStandalone} from './render3/definition'; export {ApplicationConfig, mergeApplicationConfig} from './application_config'; export {makeStateKey, StateKey, TransferState} from './transfer_state'; +export {booleanAttribute, numberAttribute} from './util/coercion'; import {global} from './util/global'; if (typeof ngDevMode !== 'undefined' && ngDevMode) { diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index df17e9de870f3e..9123fce8b9ee5a 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -30,7 +30,7 @@ export {_sanitizeHtml as ɵ_sanitizeHtml} from './sanitization/html_sanitizer'; export {_sanitizeUrl as ɵ_sanitizeUrl} from './sanitization/url_sanitizer'; export {setAlternateWeakRefImpl as ɵsetAlternateWeakRefImpl} from './signals'; export {TESTABILITY as ɵTESTABILITY, TESTABILITY_GETTER as ɵTESTABILITY_GETTER} from './testability/testability'; -export {coerceToBoolean as ɵcoerceToBoolean} from './util/coercion'; +export {booleanAttribute, numberAttribute} from './util/coercion'; export {devModeEqual as ɵdevModeEqual} from './util/comparison'; export {global as ɵglobal} from './util/global'; export {isPromise as ɵisPromise, isSubscribable as ɵisSubscribable} from './util/lang'; diff --git a/packages/core/src/metadata/directives.ts b/packages/core/src/metadata/directives.ts index ec001285c4821e..1a3ec4aff9e456 100644 --- a/packages/core/src/metadata/directives.ts +++ b/packages/core/src/metadata/directives.ts @@ -182,7 +182,12 @@ export interface Directive { * ``` * */ - inputs?: ({name: string, alias?: string, required?: boolean}|string)[]; + inputs?: ({ + name: string, + alias?: string, + required?: boolean, + transform?: (value: any) => any, + }|string)[]; /** * Enumerates the set of event-bound output properties. @@ -817,6 +822,11 @@ export interface Input { * Whether the input is required for the directive to function. */ required?: boolean; + + /** + * Function with which to transform the input value before assigning it to the directive instance. + */ + transform?: (value: any) => any; } /** diff --git a/packages/core/src/render3/definition.ts b/packages/core/src/render3/definition.ts index adff59c52786c1..f16252ee9eb314 100644 --- a/packages/core/src/render3/definition.ts +++ b/packages/core/src/render3/definition.ts @@ -18,12 +18,12 @@ import {initNgDevMode} from '../util/ng_dev_mode'; import {stringify} from '../util/stringify'; import {NG_COMP_DEF, NG_DIR_DEF, NG_MOD_DEF, NG_PIPE_DEF} from './fields'; -import {ComponentDef, ComponentDefFeature, ComponentTemplate, ComponentType, ContentQueriesFunction, DependencyTypeList, DirectiveDef, DirectiveDefFeature, DirectiveDefListOrFactory, HostBindingsFunction, PipeDef, PipeDefListOrFactory, TypeOrFactory, ViewQueriesFunction} from './interfaces/definition'; +import {ComponentDef, ComponentDefFeature, ComponentTemplate, ComponentType, ContentQueriesFunction, DependencyTypeList, DirectiveDef, DirectiveDefFeature, DirectiveDefListOrFactory, HostBindingsFunction, InputTransformFunction, PipeDef, PipeDefListOrFactory, TypeOrFactory, ViewQueriesFunction} from './interfaces/definition'; import {TAttributes, TConstantsOrFactory} from './interfaces/node'; import {CssSelectorList} from './interfaces/projection'; import {stringifyCSSSelectorList} from './node_selector_matcher'; -interface DirectiveDefinition { +export interface DirectiveDefinition { /** * Directive type, needed to configure the injector. */ @@ -35,7 +35,7 @@ interface DirectiveDefinition { /** * A map of input names. * - * The format is in: `{[actualPropertyName: string]:(string|[string, string])}`. + * The format is in: `{[actualPropertyName: string]:(string|[string, string, Function])}`. * * Given: * ``` @@ -45,6 +45,9 @@ interface DirectiveDefinition { * * @Input('publicInput2') * declaredInput2: string; + * + * @Input({transform: (value: boolean) => value ? 1 : 0}) + * transformedInput3: number; * } * ``` * @@ -53,6 +56,11 @@ interface DirectiveDefinition { * { * publicInput1: 'publicInput1', * declaredInput2: ['declaredInput2', 'publicInput2'], + * transformedInput3: [ + * 'transformedInput3', + * 'transformedInput3', + * (value: boolean) => value ? 1 : 0 + * ] * } * ``` * @@ -61,6 +69,11 @@ interface DirectiveDefinition { * { * minifiedPublicInput1: 'publicInput1', * minifiedDeclaredInput2: [ 'publicInput2', 'declaredInput2'], + * minifiedTransformedInput3: [ + * 'transformedInput3', + * 'transformedInput3', + * (value: boolean) => value ? 1 : 0 + * ] * } * ``` * @@ -75,7 +88,7 @@ interface DirectiveDefinition { * this reason `NgOnChanges` will be deprecated and removed in future version and this * API will be simplified to be consistent with `output`. */ - inputs?: {[P in keyof T]?: string|[string, string]}; + inputs?: {[P in keyof T]?: string|[string, string, InputTransformFunction?]}; /** * A map of output names. @@ -170,7 +183,7 @@ interface DirectiveDefinition { signals?: boolean; } -interface ComponentDefinition extends Omit, 'features'> { +export interface ComponentDefinition extends Omit, 'features'> { /** * The number of nodes, local refs, and pipes in this component template. * @@ -319,7 +332,7 @@ export function ɵɵdefineComponent(componentDefinition: ComponentDefinition< id: '', }; - initFeatures(def); + initFeatures(def, componentDefinition); const dependencies = componentDefinition.dependencies; def.directiveDefs = extractDefListOrFactory(dependencies, /* pipeDef */ false); def.pipeDefs = extractDefListOrFactory(dependencies, /* pipeDef */ true); @@ -484,13 +497,13 @@ export function ɵɵsetNgModuleScope(type: any, scope: { */ function invertObject( - obj?: {[P in keyof T]?: string|[string, string]}, - secondary?: {[key: string]: string}): {[P in keyof T]: string} { + obj?: {[P in keyof T]?: string|[string, string, ...unknown[]]}, + secondary?: Record): {[P in keyof T]: string} { if (obj == null) return EMPTY_OBJ as any; const newLookup: any = {}; for (const minifiedKey in obj) { if (obj.hasOwnProperty(minifiedKey)) { - let publicName: string|[string, string] = obj[minifiedKey]!; + let publicName: string|[string, string, ...unknown[]] = obj[minifiedKey]!; let declaredName = publicName; if (Array.isArray(publicName)) { declaredName = publicName[1]; @@ -525,8 +538,7 @@ export function ɵɵdefineDirective(directiveDefinition: DirectiveDefinition< Mutable, keyof DirectiveDef> { return noSideEffects(() => { const def = getNgDirectiveDef(directiveDefinition); - initFeatures(def); - + initFeatures(def, directiveDefinition); return def; }); } @@ -626,6 +638,7 @@ function getNgDirectiveDef(directiveDefinition: DirectiveDefinition): hostAttrs: directiveDefinition.hostAttrs || null, contentQueries: directiveDefinition.contentQueries || null, declaredInputs, + inputTransforms: null, exportAs: directiveDefinition.exportAs || null, standalone: directiveDefinition.standalone === true, signals: directiveDefinition.signals === true, @@ -640,9 +653,11 @@ function getNgDirectiveDef(directiveDefinition: DirectiveDefinition): }; } -function initFeatures(definition:|Mutable, keyof DirectiveDef>| - Mutable, keyof ComponentDef>): void { - definition.features?.forEach((fn) => fn(definition)); +function initFeatures( + definition: Mutable, keyof DirectiveDef>| + Mutable, keyof ComponentDef>, + compilerDefinition: DirectiveDefinition|ComponentDefinition): void { + definition.features?.forEach((fn) => fn(definition, compilerDefinition)); } function extractDefListOrFactory( diff --git a/packages/core/src/render3/features/inherit_definition_feature.ts b/packages/core/src/render3/features/inherit_definition_feature.ts index 158cf18445397e..cd7e97e7a296ce 100644 --- a/packages/core/src/render3/features/inherit_definition_feature.ts +++ b/packages/core/src/render3/features/inherit_definition_feature.ts @@ -29,7 +29,8 @@ type WritableDef = Writable|ComponentDef>; * * @codeGenApi */ -export function ɵɵInheritDefinitionFeature(definition: DirectiveDef|ComponentDef): void { +export function ɵɵInheritDefinitionFeature( + definition: DirectiveDef|ComponentDef, compilerDef: unknown): void { let superType = getSuperType(definition.type); let shouldInheritFields = true; const inheritanceChain: WritableDef[] = [definition]; @@ -59,6 +60,7 @@ export function ɵɵInheritDefinitionFeature(definition: DirectiveDef|Compo // would've justified object creation. Unwrap them if necessary. const writeableDef = definition as WritableDef; writeableDef.inputs = maybeUnwrapEmpty(definition.inputs); + writeableDef.inputTransforms = maybeUnwrapEmpty(definition.inputTransforms); writeableDef.declaredInputs = maybeUnwrapEmpty(definition.declaredInputs); writeableDef.outputs = maybeUnwrapEmpty(definition.outputs); @@ -77,6 +79,13 @@ export function ɵɵInheritDefinitionFeature(definition: DirectiveDef|Compo fillProperties(definition.declaredInputs, superDef.declaredInputs); fillProperties(definition.outputs, superDef.outputs); + if (superDef.inputTransforms !== null) { + if (writeableDef.inputTransforms === null) { + writeableDef.inputTransforms = {}; + } + fillProperties(writeableDef.inputTransforms, superDef.inputTransforms); + } + // Merge animations metadata. // If `superDef` is a Component, the `data` field is present (defaults to an empty object). if (isComponentDef(superDef) && superDef.data.animation) { @@ -93,7 +102,7 @@ export function ɵɵInheritDefinitionFeature(definition: DirectiveDef|Compo for (let i = 0; i < features.length; i++) { const feature = features[i]; if (feature && feature.ngInherit) { - (feature as DirectiveDefFeature)(definition); + (feature as DirectiveDefFeature)(definition, compilerDef); } // If `InheritDefinitionFeature` is a part of the current `superDef`, it means that this // def already has all the necessary information inherited from its super class(es), so we diff --git a/packages/core/src/render3/features/input_transforms_feature.ts b/packages/core/src/render3/features/input_transforms_feature.ts index 5b2fa97125fff7..69261258c680ee 100644 --- a/packages/core/src/render3/features/input_transforms_feature.ts +++ b/packages/core/src/render3/features/input_transforms_feature.ts @@ -6,10 +6,31 @@ * found in the LICENSE file at https://angular.io/license */ -import {ComponentDef, DirectiveDef} from '../interfaces/definition'; +import {Mutable} from '../../interface/type'; +import {ComponentDef, DirectiveDef, InputTransformFunction} from '../interfaces/definition'; -// TODO(crisbeto): move input transforms runtime functionality here. /** + * Decorates the directive definition with support for input transform functions. + * + * If the directive uses inheritance, the feature should be included before the + * `InheritDefinitionFeature` to ensure that the `inputTransforms` field is populated. + * * @codeGenApi */ -export function ɵɵInputTransformsFeature(definition: DirectiveDef|ComponentDef): void {} +export function ɵɵInputTransformsFeature( + definition: Mutable|ComponentDef, 'inputTransforms'>, + compilerDef: {inputs: Record}): void { + const inputs = compilerDef.inputs; + const inputTransforms: Record = {}; + + for (const minifiedKey in inputs) { + if (inputs.hasOwnProperty(minifiedKey)) { + const value = inputs[minifiedKey]; + if (Array.isArray(value) && value[2]) { + inputTransforms[minifiedKey] = value[2]; + } + } + } + + definition.inputTransforms = inputTransforms; +} diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 021abd18499f4c..327372a6f88c0c 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -1311,6 +1311,10 @@ function writeToDirectiveInput( def: DirectiveDef, instance: T, publicName: string, privateName: string, value: string) { const prevConsumer = setActiveConsumer(null); try { + const inputTransforms = def.inputTransforms; + if (inputTransforms !== null && inputTransforms.hasOwnProperty(privateName)) { + value = inputTransforms[privateName].call(instance, value); + } if (def.setInput !== null) { def.setInput(instance, value, publicName, privateName); } else { diff --git a/packages/core/src/render3/interfaces/definition.ts b/packages/core/src/render3/interfaces/definition.ts index c1e813cbe2665d..14a2e6a60f351c 100644 --- a/packages/core/src/render3/interfaces/definition.ts +++ b/packages/core/src/render3/interfaces/definition.ts @@ -94,7 +94,7 @@ export interface PipeType extends Type { * * @param Selector type metadata specifying the selector of the directive or component * - * See: {@link defineDirective} + * See: {@link ɵɵdefineDirective} */ export interface DirectiveDef { /** @@ -104,6 +104,13 @@ export interface DirectiveDef { */ readonly inputs: {[P in keyof T]: string}; + /** + * A dictionary mapping the private names of inputs to their transformation functions. + * Note: the private names are used for the keys, rather than the public ones, because public + * names can be re-aliased in host directives which would invalidate the lookup. + */ + readonly inputTransforms: {[classPropertyName: string]: InputTransformFunction}|null; + /** * @deprecated This is only here because `NgOnChanges` incorrectly uses declared name instead of * public or minified name. @@ -407,7 +414,7 @@ export interface PipeDef { } export interface DirectiveDefFeature { - (directiveDef: DirectiveDef): void; + (directiveDef: DirectiveDef, compilerDef: unknown): void; /** * Marks a feature as something that {@link InheritDefinitionFeature} will execute * during inheritance. @@ -447,7 +454,7 @@ export type HostDirectiveBindingMap = { export type HostDirectiveDefs = Map, HostDirectiveDef>; export interface ComponentDefFeature { - (componentDef: ComponentDef): void; + (componentDef: ComponentDef, compilerDef: unknown): void; /** * Marks a feature as something that {@link InheritDefinitionFeature} will execute * during inheritance. @@ -459,6 +466,8 @@ export interface ComponentDefFeature { ngInherit?: true; } +/** Function that can be used to transform incoming input values. */ +export type InputTransformFunction = (value: any) => any; /** * Type used for directiveDefs on component definition. diff --git a/packages/core/src/util/coercion.ts b/packages/core/src/util/coercion.ts index 6ed07379251749..a7804667ec7cd5 100644 --- a/packages/core/src/util/coercion.ts +++ b/packages/core/src/util/coercion.ts @@ -6,7 +6,29 @@ * found in the LICENSE file at https://angular.io/license */ -/** Coerces a value (typically a string) to a boolean. */ -export function coerceToBoolean(value: unknown): boolean { +/** + * Transforms a value (typically a string) to a boolean. + * Intended to be used as a transform function of an input. + * @param value Value to be transformed. + * + * @publicApi + */ +export function booleanAttribute(value: unknown): boolean { return typeof value === 'boolean' ? value : (value != null && value !== 'false'); } + +/** + * Transforms a value (typically a string) to a number. + * Intended to be used as a transform function of an input. + * @param value Value to be transformed. + * @param fallbackValue Value to use if the provided value can't be parsed as a number. + * + * @publicApi + */ +export function numberAttribute(value: unknown, fallbackValue = NaN): number { + // parseFloat(value) handles most of the cases we're interested in (it treats null, empty string, + // and other non-number values as NaN, where Number just uses 0) but it considers the string + // '123hello' to be a valid number. Therefore we also check if Number(value) is NaN. + const isNumberValue = !isNaN(parseFloat(value as any)) && !isNaN(Number(value)); + return isNumberValue ? Number(value) : fallbackValue; +} diff --git a/packages/core/src/util/property.ts b/packages/core/src/util/property.ts index bdcaef72780d1c..2adb4b9d43730a 100644 --- a/packages/core/src/util/property.ts +++ b/packages/core/src/util/property.ts @@ -21,7 +21,7 @@ export function getClosureSafeProperty(objWithPropertyToExtract: T): string { * @param target The target to set properties on * @param source The source of the property keys and values to set */ -export function fillProperties(target: {[key: string]: string}, source: {[key: string]: string}) { +export function fillProperties(target: Record, source: Record) { for (const key in source) { if (source.hasOwnProperty(key) && !target.hasOwnProperty(key)) { target[key] = source[key]; diff --git a/packages/core/test/acceptance/directive_spec.ts b/packages/core/test/acceptance/directive_spec.ts index 65e01609a60ea0..a79a0c581dbba2 100644 --- a/packages/core/test/acceptance/directive_spec.ts +++ b/packages/core/test/acceptance/directive_spec.ts @@ -7,7 +7,7 @@ */ import {CommonModule} from '@angular/common'; -import {Component, Directive, ElementRef, EventEmitter, Input, NgModule, Output, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; +import {Component, Directive, ElementRef, EventEmitter, Input, NgModule, OnChanges, Output, SimpleChange, SimpleChanges, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; import {TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; @@ -556,6 +556,243 @@ describe('directives', () => { expect(dirInstance.plainInput).toBe(plainValue); expect(dirInstance.aliasedInput).toBe(aliasedValue); }); + + it('should transform incoming input values', () => { + @Directive({selector: '[dir]'}) + class Dir { + @Input({transform: (value: string) => value ? 1 : 0}) value = -1; + } + + @Component({template: '
'}) + class TestComp { + @ViewChild(Dir) dir!: Dir; + assignedValue = ''; + } + + TestBed.configureTestingModule({declarations: [TestComp, Dir]}); + const fixture = TestBed.createComponent(TestComp); + fixture.detectChanges(); + + expect(fixture.componentInstance.dir.value).toBe(0); + + fixture.componentInstance.assignedValue = 'hello'; + fixture.detectChanges(); + + expect(fixture.componentInstance.dir.value).toBe(1); + }); + + it('should transform incoming input values when declared through the `inputs` array', () => { + @Directive({ + selector: '[dir]', + inputs: [{name: 'value', transform: (value: string) => value ? 1 : 0}] + }) + class Dir { + value = -1; + } + + @Component({template: '
'}) + class TestComp { + @ViewChild(Dir) dir!: Dir; + assignedValue = ''; + } + + TestBed.configureTestingModule({declarations: [TestComp, Dir]}); + const fixture = TestBed.createComponent(TestComp); + fixture.detectChanges(); + + expect(fixture.componentInstance.dir.value).toBe(0); + + fixture.componentInstance.assignedValue = 'hello'; + fixture.detectChanges(); + + expect(fixture.componentInstance.dir.value).toBe(1); + }); + + it('should transform incoming static input values', () => { + @Directive({selector: '[dir]'}) + class Dir { + @Input({transform: (value: string) => value ? 1 : 0}) value = -1; + } + + @Component({template: '
'}) + class TestComp { + @ViewChild(Dir) dir!: Dir; + } + + TestBed.configureTestingModule({declarations: [TestComp, Dir]}); + const fixture = TestBed.createComponent(TestComp); + fixture.detectChanges(); + + expect(fixture.componentInstance.dir.value).toBe(1); + }); + + it('should transform incoming values for aliased inputs', () => { + @Directive({selector: '[dir]'}) + class Dir { + @Input({alias: 'valueAlias', transform: (value: string) => value ? 1 : 0}) value = -1; + } + + @Component({template: '
'}) + class TestComp { + @ViewChild(Dir) dir!: Dir; + assignedValue = ''; + } + + TestBed.configureTestingModule({declarations: [TestComp, Dir]}); + const fixture = TestBed.createComponent(TestComp); + fixture.detectChanges(); + + expect(fixture.componentInstance.dir.value).toBe(0); + + fixture.componentInstance.assignedValue = 'hello'; + fixture.detectChanges(); + + expect(fixture.componentInstance.dir.value).toBe(1); + }); + + it('should transform incoming inherited input values', () => { + @Directive() + class Parent { + @Input({transform: (value: string) => value ? 1 : 0}) value = -1; + } + + @Directive({selector: '[dir]'}) + class Dir extends Parent { + } + + @Component({template: '
'}) + class TestComp { + @ViewChild(Dir) dir!: Dir; + assignedValue = ''; + } + + TestBed.configureTestingModule({declarations: [TestComp, Dir]}); + const fixture = TestBed.createComponent(TestComp); + fixture.detectChanges(); + + expect(fixture.componentInstance.dir.value).toBe(0); + + fixture.componentInstance.assignedValue = 'hello'; + fixture.detectChanges(); + + expect(fixture.componentInstance.dir.value).toBe(1); + }); + + it('should transform aliased inputs coming from host directives', () => { + @Directive({standalone: true}) + class HostDir { + @Input({transform: (value: string) => value ? 1 : 0}) value = -1; + } + + @Directive({ + selector: '[dir]', + hostDirectives: [{directive: HostDir, inputs: ['value: valueAlias']}] + }) + class Dir { + } + + @Component({template: '
'}) + class TestComp { + @ViewChild(HostDir) hostDir!: HostDir; + assignedValue = ''; + } + + TestBed.configureTestingModule({declarations: [TestComp, Dir]}); + const fixture = TestBed.createComponent(TestComp); + fixture.detectChanges(); + + expect(fixture.componentInstance.hostDir.value).toBe(0); + + fixture.componentInstance.assignedValue = 'hello'; + fixture.detectChanges(); + + expect(fixture.componentInstance.hostDir.value).toBe(1); + }); + + it('should use the transformed input values in ngOnChanges', () => { + const trackedChanges: SimpleChange[] = []; + + @Directive({selector: '[dir]'}) + class Dir implements OnChanges { + @Input({transform: (value: string) => value ? 1 : 0}) value = -1; + + ngOnChanges(changes: SimpleChanges): void { + if (changes.value) { + trackedChanges.push(changes.value); + } + } + } + + @Component({template: '
'}) + class TestComp { + @ViewChild(Dir) dir!: Dir; + assignedValue = ''; + } + + TestBed.configureTestingModule({declarations: [TestComp, Dir]}); + const fixture = TestBed.createComponent(TestComp); + fixture.detectChanges(); + + expect(trackedChanges).toEqual([jasmine.objectContaining( + {previousValue: undefined, currentValue: 0})]); + + fixture.componentInstance.assignedValue = 'hello'; + fixture.detectChanges(); + + expect(trackedChanges).toEqual([ + jasmine.objectContaining({previousValue: undefined, currentValue: 0}), + jasmine.objectContaining({previousValue: 0, currentValue: 1}) + ]); + }); + + it('should invoke the transform function with the directive instance as the context', () => { + let instance: Dir|undefined; + + function transform(this: Dir, _value: string) { + instance = this; + return 0; + } + + @Directive({selector: '[dir]'}) + class Dir { + @Input({transform}) value: any; + } + + @Component({template: '
'}) + class TestComp { + @ViewChild(Dir) dir!: Dir; + } + + TestBed.configureTestingModule({declarations: [TestComp, Dir]}); + const fixture = TestBed.createComponent(TestComp); + fixture.detectChanges(); + + expect(instance).toBe(fixture.componentInstance.dir); + }); + + it('should transform value assigned using setInput', () => { + @Component({selector: 'comp', template: ''}) + class Comp { + @Input({transform: (value: string) => value ? 1 : 0}) value = -1; + } + + @Component({template: ''}) + class TestComp { + @ViewChild('location', {read: ViewContainerRef}) vcr!: ViewContainerRef; + } + + TestBed.configureTestingModule({declarations: [TestComp, Comp]}); + const fixture = TestBed.createComponent(TestComp); + fixture.detectChanges(); + + const ref = fixture.componentInstance.vcr.createComponent(Comp); + + ref.setInput('value', ''); + expect(ref.instance.value).toBe(0); + + ref.setInput('value', 'hello'); + expect(ref.instance.value).toBe(1); + }); }); describe('outputs', () => { diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index bce672adb47ddc..64c98187bafcbd 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -716,6 +716,9 @@ { "name": "bloomHasToken" }, + { + "name": "booleanAttribute" + }, { "name": "callHook" }, @@ -737,9 +740,6 @@ { "name": "clearViewRefreshFlag" }, - { - "name": "coerceToBoolean" - }, { "name": "collectNativeNodes" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 80861e66c082df..09473c0d4c5703 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -893,6 +893,9 @@ { "name": "bloomHasToken" }, + { + "name": "booleanAttribute" + }, { "name": "callHook" }, @@ -917,9 +920,6 @@ { "name": "clearViewRefreshFlag" }, - { - "name": "coerceToBoolean" - }, { "name": "collectNativeNodes" }, diff --git a/packages/core/test/util/coercion_spec.ts b/packages/core/test/util/coercion_spec.ts index 97a7a8aabb6c91..29bf6e97659e6a 100644 --- a/packages/core/test/util/coercion_spec.ts +++ b/packages/core/test/util/coercion_spec.ts @@ -5,52 +5,129 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {coerceToBoolean} from '@angular/core/src/util/coercion'; +import {booleanAttribute, numberAttribute} from '@angular/core'; -{ - describe('coerceToBoolean', () => { +describe('coercion functions', () => { + describe('booleanAttribute', () => { it('should coerce undefined to false', () => { - expect(coerceToBoolean(undefined)).toBe(false); + expect(booleanAttribute(undefined)).toBe(false); }); it('should coerce null to false', () => { - expect(coerceToBoolean(null)).toBe(false); + expect(booleanAttribute(null)).toBe(false); }); it('should coerce the empty string to true', () => { - expect(coerceToBoolean('')).toBe(true); + expect(booleanAttribute('')).toBe(true); }); it('should coerce zero to true', () => { - expect(coerceToBoolean(0)).toBe(true); + expect(booleanAttribute(0)).toBe(true); }); it('should coerce the string "false" to false', () => { - expect(coerceToBoolean('false')).toBe(false); + expect(booleanAttribute('false')).toBe(false); }); it('should coerce the boolean false to false', () => { - expect(coerceToBoolean(false)).toBe(false); + expect(booleanAttribute(false)).toBe(false); }); it('should coerce the boolean true to true', () => { - expect(coerceToBoolean(true)).toBe(true); + expect(booleanAttribute(true)).toBe(true); }); it('should coerce the string "true" to true', () => { - expect(coerceToBoolean('true')).toBe(true); + expect(booleanAttribute('true')).toBe(true); }); it('should coerce an arbitrary string to true', () => { - expect(coerceToBoolean('pink')).toBe(true); + expect(booleanAttribute('pink')).toBe(true); }); it('should coerce an object to true', () => { - expect(coerceToBoolean({})).toBe(true); + expect(booleanAttribute({})).toBe(true); }); it('should coerce an array to true', () => { - expect(coerceToBoolean([])).toBe(true); + expect(booleanAttribute([])).toBe(true); }); }); -} + + describe('numberAttribute', () => { + it('should coerce undefined to the default value', () => { + expect(numberAttribute(undefined)).toBeNaN(); + expect(numberAttribute(undefined, 111)).toBe(111); + }); + + it('should coerce null to the default value', () => { + expect(numberAttribute(null)).toBeNaN(); + expect(numberAttribute(null, 111)).toBe(111); + }); + + it('should coerce true to the default value', () => { + expect(numberAttribute(true)).toBeNaN(); + expect(numberAttribute(true, 111)).toBe(111); + }); + + it('should coerce false to the default value', () => { + expect(numberAttribute(false)).toBeNaN(); + expect(numberAttribute(false, 111)).toBe(111); + }); + + it('should coerce the empty string to the default value', () => { + expect(numberAttribute('')).toBeNaN(); + expect(numberAttribute('', 111)).toBe(111); + }); + + it('should coerce the string "1" to 1', () => { + expect(numberAttribute('1')).toBe(1); + expect(numberAttribute('1', 111)).toBe(1); + }); + + it('should coerce the string "123.456" to 123.456', () => { + expect(numberAttribute('123.456')).toBe(123.456); + expect(numberAttribute('123.456', 111)).toBe(123.456); + }); + + it('should coerce the string "-123.456" to -123.456', () => { + expect(numberAttribute('-123.456')).toBe(-123.456); + expect(numberAttribute('-123.456', 111)).toBe(-123.456); + }); + + it('should coerce an arbitrary string to the default value', () => { + expect(numberAttribute('pink')).toBeNaN(); + expect(numberAttribute('pink', 111)).toBe(111); + }); + + it('should coerce an arbitrary string prefixed with a number to the default value', () => { + expect(numberAttribute('123pink')).toBeNaN(); + expect(numberAttribute('123pink', 111)).toBe(111); + }); + + it('should coerce the number 1 to 1', () => { + expect(numberAttribute(1)).toBe(1); + expect(numberAttribute(1, 111)).toBe(1); + }); + + it('should coerce the number 123.456 to 123.456', () => { + expect(numberAttribute(123.456)).toBe(123.456); + expect(numberAttribute(123.456, 111)).toBe(123.456); + }); + + it('should coerce the number -123.456 to -123.456', () => { + expect(numberAttribute(-123.456)).toBe(-123.456); + expect(numberAttribute(-123.456, 111)).toBe(-123.456); + }); + + it('should coerce an object to the default value', () => { + expect(numberAttribute({})).toBeNaN(); + expect(numberAttribute({}, 111)).toBe(111); + }); + + it('should coerce an array to the default value', () => { + expect(numberAttribute([])).toBeNaN(); + expect(numberAttribute([], 111)).toBe(111); + }); + }); +}); diff --git a/packages/forms/src/directives/ng_model.ts b/packages/forms/src/directives/ng_model.ts index 06b4c8accf227b..45885b6226c80b 100644 --- a/packages/forms/src/directives/ng_model.ts +++ b/packages/forms/src/directives/ng_model.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectorRef, Directive, EventEmitter, forwardRef, Host, Inject, Input, OnChanges, OnDestroy, Optional, Output, Provider, Self, SimpleChanges, ɵcoerceToBoolean as coerceToBoolean} from '@angular/core'; +import {booleanAttribute, ChangeDetectorRef, Directive, EventEmitter, forwardRef, Host, Inject, Input, OnChanges, OnDestroy, Optional, Output, Provider, Self, SimpleChanges} from '@angular/core'; import {FormHooks} from '../model/abstract_model'; import {FormControl} from '../model/form_control'; @@ -336,7 +336,7 @@ export class NgModel extends NgControl implements OnChanges, OnDestroy { private _updateDisabled(changes: SimpleChanges) { const disabledValue = changes['isDisabled'].currentValue; // checking for 0 to avoid breaking change - const isDisabled = disabledValue !== 0 && coerceToBoolean(disabledValue); + const isDisabled = disabledValue !== 0 && booleanAttribute(disabledValue); resolvedPromise.then(() => { if (isDisabled && !this.control.disabled) { diff --git a/packages/forms/src/directives/validators.ts b/packages/forms/src/directives/validators.ts index b3277074763d4e..2b5866d65b7d65 100644 --- a/packages/forms/src/directives/validators.ts +++ b/packages/forms/src/directives/validators.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, forwardRef, Input, OnChanges, Provider, SimpleChanges, ɵcoerceToBoolean as coerceToBoolean} from '@angular/core'; +import {booleanAttribute, Directive, forwardRef, Input, OnChanges, Provider, SimpleChanges} from '@angular/core'; import {Observable} from 'rxjs'; import {AbstractControl} from '../model/abstract_model'; @@ -370,7 +370,7 @@ export class RequiredValidator extends AbstractValidatorDirective { override inputName = 'required'; /** @internal */ - override normalizeInput = coerceToBoolean; + override normalizeInput = booleanAttribute; /** @internal */ override createValidator = (input: boolean): ValidatorFn => requiredValidator; @@ -466,7 +466,7 @@ export class EmailValidator extends AbstractValidatorDirective { override inputName = 'email'; /** @internal */ - override normalizeInput = coerceToBoolean; + override normalizeInput = booleanAttribute; /** @internal */ override createValidator = (input: number): ValidatorFn => emailValidator; diff --git a/packages/router/src/directives/router_link.ts b/packages/router/src/directives/router_link.ts index 6f6740ab8729d8..3f7c9cccce7b30 100644 --- a/packages/router/src/directives/router_link.ts +++ b/packages/router/src/directives/router_link.ts @@ -7,7 +7,7 @@ */ import {LocationStrategy} from '@angular/common'; -import {Attribute, Directive, ElementRef, HostBinding, HostListener, Input, OnChanges, OnDestroy, Renderer2, SimpleChanges, ɵcoerceToBoolean as coerceToBoolean, ɵɵsanitizeUrlOrResourceUrl} from '@angular/core'; +import {Attribute, booleanAttribute, Directive, ElementRef, HostBinding, HostListener, Input, OnChanges, OnDestroy, Renderer2, SimpleChanges, ɵɵsanitizeUrlOrResourceUrl} from '@angular/core'; import {Subject, Subscription} from 'rxjs'; import {Event, NavigationEnd} from '../events'; @@ -212,7 +212,7 @@ export class RouterLink implements OnChanges, OnDestroy { */ @Input() set preserveFragment(preserveFragment: boolean|string|null|undefined) { - this._preserveFragment = coerceToBoolean(preserveFragment); + this._preserveFragment = booleanAttribute(preserveFragment); } get preserveFragment(): boolean { @@ -227,7 +227,7 @@ export class RouterLink implements OnChanges, OnDestroy { */ @Input() set skipLocationChange(skipLocationChange: boolean|string|null|undefined) { - this._skipLocationChange = coerceToBoolean(skipLocationChange); + this._skipLocationChange = booleanAttribute(skipLocationChange); } get skipLocationChange(): boolean { @@ -242,7 +242,7 @@ export class RouterLink implements OnChanges, OnDestroy { */ @Input() set replaceUrl(replaceUrl: boolean|string|null|undefined) { - this._replaceUrl = coerceToBoolean(replaceUrl); + this._replaceUrl = booleanAttribute(replaceUrl); } get replaceUrl(): boolean {