mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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
This commit is contained in:
committed by
Dylan Hunn
parent
25b6b9788d
commit
68017d4e75
@@ -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<T> extends Type<T> {
|
||||
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;
|
||||
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string|[string, string, InputTransformFunction?]>) {
|
||||
function inputsMappingToInputMetadata(inputs: Record<string, string|[string, string, InputTransformFunction?]>) {
|
||||
return Object.keys(inputs).reduce<InputMap>((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<InputMap>((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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@ 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';
|
||||
@@ -35,7 +35,7 @@ interface DirectiveDefinition<T> {
|
||||
/**
|
||||
* 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<T> {
|
||||
*
|
||||
* @Input('publicInput2')
|
||||
* declaredInput2: string;
|
||||
*
|
||||
* @Input({transform: (value: boolean) => value ? 1 : 0})
|
||||
* transformedInput3: number;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
@@ -53,6 +56,11 @@ interface DirectiveDefinition<T> {
|
||||
* {
|
||||
* publicInput1: 'publicInput1',
|
||||
* declaredInput2: ['declaredInput2', 'publicInput2'],
|
||||
* transformedInput3: [
|
||||
* 'transformedInput3',
|
||||
* 'transformedInput3',
|
||||
* (value: boolean) => value ? 1 : 0
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
@@ -61,6 +69,11 @@ interface DirectiveDefinition<T> {
|
||||
* {
|
||||
* minifiedPublicInput1: 'publicInput1',
|
||||
* minifiedDeclaredInput2: [ 'publicInput2', 'declaredInput2'],
|
||||
* minifiedTransformedInput3: [
|
||||
* 'transformedInput3',
|
||||
* 'transformedInput3',
|
||||
* (value: boolean) => value ? 1 : 0
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
@@ -75,7 +88,7 @@ interface DirectiveDefinition<T> {
|
||||
* 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.
|
||||
@@ -484,13 +497,13 @@ export function ɵɵsetNgModuleScope(type: any, scope: {
|
||||
|
||||
*/
|
||||
function invertObject<T>(
|
||||
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<string, string>): {[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];
|
||||
@@ -626,6 +639,8 @@ function getNgDirectiveDef<T>(directiveDefinition: DirectiveDefinition<T>):
|
||||
hostAttrs: directiveDefinition.hostAttrs || null,
|
||||
contentQueries: directiveDefinition.contentQueries || null,
|
||||
declaredInputs,
|
||||
inputTransforms: null,
|
||||
inputConfig: directiveDefinition.inputs || EMPTY_OBJ,
|
||||
exportAs: directiveDefinition.exportAs || null,
|
||||
standalone: directiveDefinition.standalone === true,
|
||||
signals: directiveDefinition.signals === true,
|
||||
|
||||
@@ -59,6 +59,7 @@ export function ɵɵInheritDefinitionFeature(definition: DirectiveDef<any>|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 +78,13 @@ export function ɵɵInheritDefinitionFeature(definition: DirectiveDef<any>|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) {
|
||||
|
||||
@@ -6,10 +6,32 @@
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {ComponentDef, DirectiveDef} from '../interfaces/definition';
|
||||
import {Mutable} from '../../interface/type';
|
||||
import {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<any>|ComponentDef<any>): void {}
|
||||
export function ɵɵInputTransformsFeature(definition: DirectiveDef<unknown>): void {
|
||||
const inputs = definition.inputConfig;
|
||||
const inputTransforms: Record<string, InputTransformFunction> = {};
|
||||
|
||||
for (const minifiedKey in inputs) {
|
||||
if (inputs.hasOwnProperty(minifiedKey)) {
|
||||
// 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.
|
||||
const value = inputs[minifiedKey];
|
||||
if (Array.isArray(value) && value[2]) {
|
||||
inputTransforms[minifiedKey] = value[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(definition as Mutable<DirectiveDef<unknown>, 'inputTransforms'>).inputTransforms =
|
||||
inputTransforms;
|
||||
}
|
||||
|
||||
@@ -1311,6 +1311,10 @@ function writeToDirectiveInput<T>(
|
||||
def: DirectiveDef<T>, 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 {
|
||||
|
||||
@@ -104,6 +104,20 @@ export interface DirectiveDef<T> {
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* Contains the raw input information produced by the compiler. Can be
|
||||
* used to do further processing after the `inputs` have been inverted.
|
||||
*/
|
||||
readonly inputConfig:
|
||||
{[classPropertyName: string]: string|[string, string, InputTransformFunction?]};
|
||||
|
||||
/**
|
||||
* @deprecated This is only here because `NgOnChanges` incorrectly uses declared name instead of
|
||||
* public or minified name.
|
||||
@@ -459,6 +473,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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function getClosureSafeProperty<T>(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<string, unknown>, source: Record<string, unknown>) {
|
||||
for (const key in source) {
|
||||
if (source.hasOwnProperty(key) && !target.hasOwnProperty(key)) {
|
||||
target[key] = source[key];
|
||||
|
||||
@@ -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: '<div dir [value]="assignedValue"></div>'})
|
||||
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: '<div dir [value]="assignedValue"></div>'})
|
||||
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: '<div dir value="staticValue"></div>'})
|
||||
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: '<div dir [valueAlias]="assignedValue"></div>'})
|
||||
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: '<div dir [value]="assignedValue"></div>'})
|
||||
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: '<div dir [valueAlias]="assignedValue"></div>'})
|
||||
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: '<div dir [value]="assignedValue"></div>'})
|
||||
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: '<div dir value="foo"></div>'})
|
||||
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: '<ng-container #location/>'})
|
||||
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', () => {
|
||||
|
||||
@@ -725,6 +725,9 @@
|
||||
{
|
||||
"name": "bloomHasToken"
|
||||
},
|
||||
{
|
||||
"name": "booleanAttribute"
|
||||
},
|
||||
{
|
||||
"name": "callHook"
|
||||
},
|
||||
@@ -746,9 +749,6 @@
|
||||
{
|
||||
"name": "clearViewRefreshFlag"
|
||||
},
|
||||
{
|
||||
"name": "coerceToBoolean"
|
||||
},
|
||||
{
|
||||
"name": "collectNativeNodes"
|
||||
},
|
||||
|
||||
@@ -896,6 +896,9 @@
|
||||
{
|
||||
"name": "bloomHasToken"
|
||||
},
|
||||
{
|
||||
"name": "booleanAttribute"
|
||||
},
|
||||
{
|
||||
"name": "callHook"
|
||||
},
|
||||
@@ -920,9 +923,6 @@
|
||||
{
|
||||
"name": "clearViewRefreshFlag"
|
||||
},
|
||||
{
|
||||
"name": "coerceToBoolean"
|
||||
},
|
||||
{
|
||||
"name": "collectNativeNodes"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user