mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
refactor(compiler): introduce compiler infrastructure for input transforms (#50225)
Adds the necessary compiler changes to support input transform functions. The compiler output has changed in the following ways:
### Directive handler
The directive handler now extracts a reference to the input transform function and it resolves the type of its first parameter. It also asserts that the type can be referenced in the compiled output and that it doesn't clash with any pre-existing `ngAcceptInputType_` members.
### .d.ts
In the generated declaration files the compiler now inserts an `ngAcceptInputType_` member for each input with a `transform` function. The member's type corresponds to the type of the first parameter of the function, e.g.
```typescript
// foo.directive.ts
@Directive()
export class Foo {
@Input({transform: (incomingValue: string) => parseInt(incomingValue)}) value: number;
}
// foo.directive.d.ts
export class Foo {
value: number;
static ngAcceptInputType_value: string;
}
```
### Type check block
If an input has `transform` function, the TCB will use the type of its first parameter for the setter type. This uses the same infrastructure as the `ngAcceptInputType_` members.
### Directive declaration
The generated runtime directive declaration call now includes the `transform` function in the `inputs` map, if the input is being transformed. The function will be picked up by the runtime in the next commit to do the actual transformation.
```typescript
// foo.directive.ts
@Directive()
export class Foo {
@Input({transform: (incomingValue: string) => parseInt(incomingValue)}) value: number;
}
// foo.directive.js
export class Foo {
ɵdir = ɵɵdefineDirective({
inputs: {
value: ['value', 'value', incomingValue => parseInt(incomingValue)]
}
});
}
```
PR Close #50225
This commit is contained in:
parent
d0a5530f77
commit
f6da091228
48 changed files with 1616 additions and 118 deletions
|
|
@ -23,6 +23,7 @@ export enum ErrorCode {
|
|||
CONFIG_FLAT_MODULE_NO_INDEX = 4001,
|
||||
// (undocumented)
|
||||
CONFIG_STRICT_TEMPLATES_IMPLIES_FULL_TEMPLATE_TYPECHECK = 4002,
|
||||
CONFLICTING_INPUT_TRANSFORM = 2020,
|
||||
// (undocumented)
|
||||
DECORATOR_ARG_NOT_LITERAL = 1001,
|
||||
// (undocumented)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* 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 {compileDirectiveFromMetadata, ConstantPool, ForwardRefHandling, makeBindingParser, outputAst as o, ParseLocation, ParseSourceFile, ParseSourceSpan, R3DeclareDirectiveMetadata, R3DeclareHostDirectiveMetadata, R3DeclareQueryMetadata, R3DirectiveMetadata, R3HostDirectiveMetadata, R3HostMetadata, R3PartialDeclaration, R3QueryMetadata} from '@angular/compiler';
|
||||
import {compileDirectiveFromMetadata, ConstantPool, ForwardRefHandling, makeBindingParser, outputAst as o, ParseLocation, ParseSourceFile, ParseSourceSpan, R3DeclareDirectiveMetadata, R3DeclareHostDirectiveMetadata, R3DeclareQueryMetadata, R3DirectiveMetadata, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, R3PartialDeclaration, R3QueryMetadata} from '@angular/compiler';
|
||||
|
||||
import {AbsoluteFsPath} from '../../../../src/ngtsc/file_system';
|
||||
import {Range} from '../../ast/ast_host';
|
||||
|
|
@ -81,19 +81,29 @@ export function toR3DirectiveMeta<TExpression>(
|
|||
* Decodes the AST value for a single input to its representation as used in the metadata.
|
||||
*/
|
||||
function toInputMapping<TExpression>(
|
||||
value: AstValue<string|[string, string], TExpression>,
|
||||
key: string): {bindingPropertyName: string, classPropertyName: string, required: boolean} {
|
||||
value: AstValue<string|[string, string], TExpression>, key: string): R3InputMetadata {
|
||||
if (value.isString()) {
|
||||
return {bindingPropertyName: value.getString(), classPropertyName: key, required: false};
|
||||
return {
|
||||
bindingPropertyName: value.getString(),
|
||||
classPropertyName: key,
|
||||
required: false,
|
||||
transformFunction: null,
|
||||
};
|
||||
}
|
||||
|
||||
const values = value.getArray().map(innerValue => innerValue.getString());
|
||||
if (values.length !== 2) {
|
||||
const values = value.getArray();
|
||||
if (values.length !== 2 && values.length !== 3) {
|
||||
throw new FatalLinkerError(
|
||||
value.expression,
|
||||
'Unsupported input, expected a string or an array containing exactly two strings');
|
||||
'Unsupported input, expected a string or an array containing two strings and an optional function');
|
||||
}
|
||||
return {bindingPropertyName: values[0], classPropertyName: values[1], required: false};
|
||||
|
||||
return {
|
||||
bindingPropertyName: values[0].getString(),
|
||||
classPropertyName: values[1].getString(),
|
||||
transformFunction: values.length > 2 ? values[2].getOpaque() : null,
|
||||
required: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -16,3 +16,4 @@ export * from './src/metadata';
|
|||
export * from './src/references_registry';
|
||||
export * from './src/schema';
|
||||
export * from './src/util';
|
||||
export * from './src/input_transforms';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
/*!
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* 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 {outputAst} from '@angular/compiler';
|
||||
|
||||
import {ClassPropertyMapping, InputMapping} from '../../../metadata';
|
||||
import {CompileResult} from '../../../transform';
|
||||
|
||||
/** Generates additional fields to be added to a class that has inputs with transform functions. */
|
||||
export function compileInputTransformFields(inputs: ClassPropertyMapping<InputMapping>):
|
||||
CompileResult[] {
|
||||
const extraFields: CompileResult[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input.transform) {
|
||||
extraFields.push({
|
||||
name: `ngAcceptInputType_${input.classPropertyName}`,
|
||||
type: outputAst.transplantedType(input.transform.type),
|
||||
statements: [],
|
||||
initializer: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return extraFields;
|
||||
}
|
||||
|
|
@ -330,20 +330,28 @@ export function createSourceSpan(node: ts.Node): ParseSourceSpan {
|
|||
* Collate the factory and definition compiled results into an array of CompileResult objects.
|
||||
*/
|
||||
export function compileResults(
|
||||
fac: CompileResult, def: R3CompiledExpression, metadataStmt: Statement|null,
|
||||
propName: string): CompileResult[] {
|
||||
fac: CompileResult, def: R3CompiledExpression, metadataStmt: Statement|null, propName: string,
|
||||
additionalFields: CompileResult[]|null): CompileResult[] {
|
||||
const statements = def.statements;
|
||||
if (metadataStmt !== null) {
|
||||
statements.push(metadataStmt);
|
||||
}
|
||||
return [
|
||||
fac, {
|
||||
|
||||
const results = [
|
||||
fac,
|
||||
{
|
||||
name: propName,
|
||||
initializer: def.expression,
|
||||
statements: def.statements,
|
||||
type: def.type,
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (additionalFields !== null) {
|
||||
results.push(...additionalFields);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function toFactoryMetadata(
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {TypeCheckableDirectiveMeta, TypeCheckContext} from '../../../typecheck/a
|
|||
import {ExtendedTemplateChecker} from '../../../typecheck/extended/api';
|
||||
import {getSourceFile} from '../../../util/src/typescript';
|
||||
import {Xi18nContext} from '../../../xi18n';
|
||||
import {combineResolvers, compileDeclareFactory, compileNgFactoryDefField, compileResults, extractClassMetadata, extractSchemas, findAngularDecorator, forwardRefResolver, getDirectiveDiagnostics, getProviderDiagnostics, InjectableClassRegistry, isExpressionForwardReference, readBaseClass, ReferencesRegistry, resolveEnumValue, resolveImportedFile, resolveLiteral, resolveProvidersRequiringFactory, ResourceLoader, toFactoryMetadata, validateHostDirectives, wrapFunctionExpressionsInParens,} from '../../common';
|
||||
import {combineResolvers, compileDeclareFactory, compileInputTransformFields, compileNgFactoryDefField, compileResults, extractClassMetadata, extractSchemas, findAngularDecorator, forwardRefResolver, getDirectiveDiagnostics, getProviderDiagnostics, InjectableClassRegistry, isExpressionForwardReference, readBaseClass, ReferencesRegistry, resolveEnumValue, resolveImportedFile, resolveLiteral, resolveProvidersRequiringFactory, ResourceLoader, toFactoryMetadata, validateHostDirectives, wrapFunctionExpressionsInParens,} from '../../common';
|
||||
import {extractDirectiveMetadata, parseFieldStringArrayValue} from '../../directive';
|
||||
import {createModuleWithProvidersResolver, NgModuleSymbol} from '../../ng_module';
|
||||
|
||||
|
|
@ -940,10 +940,11 @@ export class ComponentDecoratorHandler implements
|
|||
const meta: R3ComponentMetadata<R3TemplateDependency> = {...analysis.meta, ...resolution};
|
||||
const fac = compileNgFactoryDefField(toFactoryMetadata(meta, FactoryTarget.Component));
|
||||
const def = compileComponentFromMetadata(meta, pool, makeBindingParser());
|
||||
const inputTransformFields = compileInputTransformFields(analysis.inputs);
|
||||
const classMetadata = analysis.classMetadata !== null ?
|
||||
compileClassMetadata(analysis.classMetadata).toStmt() :
|
||||
null;
|
||||
return compileResults(fac, def, classMetadata, 'ɵcmp');
|
||||
return compileResults(fac, def, classMetadata, 'ɵcmp', inputTransformFields);
|
||||
}
|
||||
|
||||
compilePartial(
|
||||
|
|
@ -963,11 +964,12 @@ export class ComponentDecoratorHandler implements
|
|||
const meta:
|
||||
R3ComponentMetadata<R3TemplateDependencyMetadata> = {...analysis.meta, ...resolution};
|
||||
const fac = compileDeclareFactory(toFactoryMetadata(meta, FactoryTarget.Component));
|
||||
const inputTransformFields = compileInputTransformFields(analysis.inputs);
|
||||
const def = compileDeclareComponentFromMetadata(meta, analysis.template, templateInfo);
|
||||
const classMetadata = analysis.classMetadata !== null ?
|
||||
compileDeclareClassMetadata(analysis.classMetadata).toStmt() :
|
||||
null;
|
||||
return compileResults(fac, def, classMetadata, 'ɵcmp');
|
||||
return compileResults(fac, def, classMetadata, 'ɵcmp', inputTransformFields);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ ts_library(
|
|||
"//packages/compiler-cli/src/ngtsc/reflection",
|
||||
"//packages/compiler-cli/src/ngtsc/scope",
|
||||
"//packages/compiler-cli/src/ngtsc/transform",
|
||||
"//packages/compiler-cli/src/ngtsc/translator",
|
||||
"@npm//@types/node",
|
||||
"@npm//typescript",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {PerfEvent, PerfRecorder} from '../../../perf';
|
|||
import {ClassDeclaration, ClassMember, ClassMemberKind, Decorator, ReflectionHost} from '../../../reflection';
|
||||
import {LocalModuleScopeRegistry} from '../../../scope';
|
||||
import {AnalysisOutput, CompileResult, DecoratorHandler, DetectResult, HandlerFlags, HandlerPrecedence, ResolveResult} from '../../../transform';
|
||||
import {compileDeclareFactory, compileNgFactoryDefField, compileResults, extractClassMetadata, findAngularDecorator, getDirectiveDiagnostics, getProviderDiagnostics, getUndecoratedClassWithAngularFeaturesDiagnostic, InjectableClassRegistry, isAngularDecorator, readBaseClass, ReferencesRegistry, resolveProvidersRequiringFactory, toFactoryMetadata, validateHostDirectives} from '../../common';
|
||||
import {compileDeclareFactory, compileInputTransformFields, compileNgFactoryDefField, compileResults, extractClassMetadata, findAngularDecorator, getDirectiveDiagnostics, getProviderDiagnostics, getUndecoratedClassWithAngularFeaturesDiagnostic, InjectableClassRegistry, isAngularDecorator, readBaseClass, ReferencesRegistry, resolveProvidersRequiringFactory, toFactoryMetadata, validateHostDirectives} from '../../common';
|
||||
|
||||
import {extractDirectiveMetadata} from './shared';
|
||||
import {DirectiveSymbol} from './symbol';
|
||||
|
|
@ -207,10 +207,11 @@ export class DirectiveDecoratorHandler implements
|
|||
resolution: Readonly<unknown>, pool: ConstantPool): CompileResult[] {
|
||||
const fac = compileNgFactoryDefField(toFactoryMetadata(analysis.meta, FactoryTarget.Directive));
|
||||
const def = compileDirectiveFromMetadata(analysis.meta, pool, makeBindingParser());
|
||||
const inputTransformFields = compileInputTransformFields(analysis.inputs);
|
||||
const classMetadata = analysis.classMetadata !== null ?
|
||||
compileClassMetadata(analysis.classMetadata).toStmt() :
|
||||
null;
|
||||
return compileResults(fac, def, classMetadata, 'ɵdir');
|
||||
return compileResults(fac, def, classMetadata, 'ɵdir', inputTransformFields);
|
||||
}
|
||||
|
||||
compilePartial(
|
||||
|
|
@ -218,10 +219,12 @@ export class DirectiveDecoratorHandler implements
|
|||
resolution: Readonly<unknown>): CompileResult[] {
|
||||
const fac = compileDeclareFactory(toFactoryMetadata(analysis.meta, FactoryTarget.Directive));
|
||||
const def = compileDeclareDirectiveFromMetadata(analysis.meta);
|
||||
const inputTransformFields = compileInputTransformFields(analysis.inputs);
|
||||
const classMetadata = analysis.classMetadata !== null ?
|
||||
compileDeclareClassMetadata(analysis.classMetadata).toStmt() :
|
||||
null;
|
||||
return compileResults(fac, def, classMetadata, 'ɵdir');
|
||||
|
||||
return compileResults(fac, def, classMetadata, 'ɵdir', inputTransformFields);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@
|
|||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {createMayBeForwardRefExpression, emitDistinctChangesOnlyDefaultValue, Expression, ExternalExpr, ForwardRefHandling, getSafePropertyAccessString, MaybeForwardRefExpression, ParsedHostBindings, ParseError, parseHostBindings, R3DirectiveMetadata, R3HostDirectiveMetadata, R3QueryMetadata, verifyHostBindings, WrappedNodeExpr,} from '@angular/compiler';
|
||||
import {createMayBeForwardRefExpression, emitDistinctChangesOnlyDefaultValue, Expression, ExternalExpr, ForwardRefHandling, getSafePropertyAccessString, MaybeForwardRefExpression, ParsedHostBindings, ParseError, parseHostBindings, R3DirectiveMetadata, R3HostDirectiveMetadata, R3InputMetadata, R3QueryMetadata, verifyHostBindings, WrappedNodeExpr} from '@angular/compiler';
|
||||
import ts from 'typescript';
|
||||
|
||||
import {ErrorCode, FatalDiagnosticError} from '../../../diagnostics';
|
||||
import {Reference, ReferenceEmitter} from '../../../imports';
|
||||
import {ClassPropertyMapping, HostDirectiveMeta, InputMapping} from '../../../metadata';
|
||||
import {ErrorCode, FatalDiagnosticError, makeRelatedInformation} from '../../../diagnostics';
|
||||
import {assertSuccessfulReferenceEmit, ImportFlags, Reference, ReferenceEmitter} from '../../../imports';
|
||||
import {ClassPropertyMapping, HostDirectiveMeta, InputMapping, InputTransform} from '../../../metadata';
|
||||
import {DynamicValue, EnumValue, PartialEvaluator, ResolvedValue} from '../../../partial_evaluator';
|
||||
import {ClassDeclaration, ClassMember, ClassMemberKind, Decorator, filterToMembersWithDecorator, isNamedClassDeclaration, ReflectionHost, reflectObjectLiteral,} from '../../../reflection';
|
||||
import {ClassDeclaration, ClassMember, ClassMemberKind, Decorator, filterToMembersWithDecorator, isNamedClassDeclaration, ReflectionHost, reflectObjectLiteral} from '../../../reflection';
|
||||
import {HandlerFlags} from '../../../transform';
|
||||
import {createSourceSpan, createValueHasWrongTypeError, forwardRefResolver, getConstructorDependencies, ReferencesRegistry, toR3Reference, tryUnwrapForwardRef, unwrapConstructorDependencies, unwrapExpression, validateConstructorDependencies, wrapFunctionExpressionsInParens, wrapTypeReference,} from '../../common';
|
||||
|
||||
|
|
@ -76,9 +76,10 @@ export function extractDirectiveMetadata(
|
|||
|
||||
// Construct the map of inputs both from the @Directive/@Component
|
||||
// decorator, and the decorated fields.
|
||||
const inputsFromMeta = parseInputsArray(directive, evaluator);
|
||||
const inputsFromMeta = parseInputsArray(clazz, directive, evaluator, reflector, refEmitter);
|
||||
const inputsFromFields = parseInputFields(
|
||||
filterToMembersWithDecorator(decoratedElements, 'Input', coreModule), evaluator);
|
||||
clazz, filterToMembersWithDecorator(decoratedElements, 'Input', coreModule), evaluator,
|
||||
reflector, refEmitter);
|
||||
const inputs = ClassPropertyMapping.fromMappedObject({...inputsFromMeta, ...inputsFromFields});
|
||||
|
||||
// And outputs.
|
||||
|
|
@ -211,7 +212,7 @@ export function extractDirectiveMetadata(
|
|||
lifecycle: {
|
||||
usesOnChanges,
|
||||
},
|
||||
inputs: inputs.toJointMappedObject(),
|
||||
inputs: inputs.toJointMappedObject(toR3InputMetadata),
|
||||
outputs: outputs.toDirectMappedObject(),
|
||||
queries,
|
||||
viewQueries,
|
||||
|
|
@ -571,8 +572,9 @@ function parseDecoratedFields(
|
|||
|
||||
/** Parses the `inputs` array of a directive/component decorator. */
|
||||
function parseInputsArray(
|
||||
decoratorMetadata: Map<string, ts.Expression>,
|
||||
evaluator: PartialEvaluator): Record<string, InputMapping> {
|
||||
clazz: ClassDeclaration, decoratorMetadata: Map<string, ts.Expression>,
|
||||
evaluator: PartialEvaluator, reflector: ReflectionHost,
|
||||
refEmitter: ReferenceEmitter): Record<string, InputMapping> {
|
||||
const inputsField = decoratorMetadata.get('inputs');
|
||||
|
||||
if (inputsField === undefined) {
|
||||
|
|
@ -593,12 +595,18 @@ function parseInputsArray(
|
|||
if (typeof value === 'string') {
|
||||
// If the value is a string, we treat it as a mapping string.
|
||||
const [bindingPropertyName, classPropertyName] = parseMappingString(value);
|
||||
inputs[classPropertyName] = {bindingPropertyName, classPropertyName, required: false};
|
||||
inputs[classPropertyName] = {
|
||||
bindingPropertyName,
|
||||
classPropertyName,
|
||||
required: false,
|
||||
transform: null,
|
||||
};
|
||||
} else if (value instanceof Map) {
|
||||
// If it's a map, we treat it as a config object.
|
||||
const name = value.get('name');
|
||||
const alias = value.get('alias');
|
||||
const required = value.get('required');
|
||||
let transform: InputTransform|null = null;
|
||||
|
||||
if (typeof name !== 'string') {
|
||||
throw createValueHasWrongTypeError(
|
||||
|
|
@ -606,10 +614,23 @@ function parseInputsArray(
|
|||
`Value at position ${i} of @Directive.inputs array must have a "name" property`);
|
||||
}
|
||||
|
||||
if (value.has('transform')) {
|
||||
const transformValue = value.get('transform');
|
||||
|
||||
if (!(transformValue instanceof DynamicValue) && !(transformValue instanceof Reference)) {
|
||||
throw createValueHasWrongTypeError(
|
||||
inputsField, transformValue,
|
||||
`Transform of value at position ${i} of @Directive.inputs array must be a function`);
|
||||
}
|
||||
|
||||
transform = parseInputTransformFunction(clazz, name, transformValue, reflector, refEmitter);
|
||||
}
|
||||
|
||||
inputs[name] = {
|
||||
classPropertyName: name,
|
||||
bindingPropertyName: typeof alias === 'string' ? alias : name,
|
||||
required: required === true
|
||||
required: required === true,
|
||||
transform,
|
||||
};
|
||||
} else {
|
||||
throw createValueHasWrongTypeError(
|
||||
|
|
@ -623,13 +644,15 @@ function parseInputsArray(
|
|||
|
||||
/** Parses the class members that are decorated as inputs. */
|
||||
function parseInputFields(
|
||||
inputMembers: {member: ClassMember, decorators: Decorator[]}[],
|
||||
evaluator: PartialEvaluator): Record<string, InputMapping> {
|
||||
clazz: ClassDeclaration, inputMembers: {member: ClassMember, decorators: Decorator[]}[],
|
||||
evaluator: PartialEvaluator, reflector: ReflectionHost,
|
||||
refEmitter: ReferenceEmitter): Record<string, InputMapping> {
|
||||
const inputs = {} as Record<string, InputMapping>;
|
||||
|
||||
parseDecoratedFields(inputMembers, evaluator, (classPropertyName, options, decorator) => {
|
||||
let bindingPropertyName: string;
|
||||
let required = false;
|
||||
let transform: InputTransform|null = null;
|
||||
|
||||
if (options === null) {
|
||||
bindingPropertyName = classPropertyName;
|
||||
|
|
@ -639,18 +662,135 @@ function parseInputFields(
|
|||
const aliasInConfig = options.get('alias');
|
||||
bindingPropertyName = typeof aliasInConfig === 'string' ? aliasInConfig : classPropertyName;
|
||||
required = options.get('required') === true;
|
||||
|
||||
if (options.has('transform')) {
|
||||
const transformValue = options.get('transform');
|
||||
|
||||
if (!(transformValue instanceof DynamicValue) && !(transformValue instanceof Reference)) {
|
||||
throw createValueHasWrongTypeError(
|
||||
decorator.node, transformValue, `Input transform must be a function`);
|
||||
}
|
||||
|
||||
transform = parseInputTransformFunction(
|
||||
clazz, classPropertyName, transformValue, reflector, refEmitter);
|
||||
}
|
||||
} else {
|
||||
throw createValueHasWrongTypeError(
|
||||
decorator.node, options,
|
||||
`@${decorator.name} decorator argument must resolve to a string or an object literal`);
|
||||
}
|
||||
|
||||
inputs[classPropertyName] = {bindingPropertyName, classPropertyName, required};
|
||||
inputs[classPropertyName] = {bindingPropertyName, classPropertyName, required, transform};
|
||||
});
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/** Parses the `transform` function and its type of a specific input. */
|
||||
function parseInputTransformFunction(
|
||||
clazz: ClassDeclaration, classPropertyName: string, value: DynamicValue|Reference,
|
||||
reflector: ReflectionHost, refEmitter: ReferenceEmitter): InputTransform {
|
||||
const definition = reflector.getDefinitionOfFunction(value.node);
|
||||
|
||||
if (definition === null) {
|
||||
throw createValueHasWrongTypeError(value.node, value, 'Input transform must be a function');
|
||||
}
|
||||
|
||||
if (definition.typeParameters !== null && definition.typeParameters.length > 0) {
|
||||
throw createValueHasWrongTypeError(
|
||||
value.node, value, 'Input transform function cannot be generic');
|
||||
}
|
||||
|
||||
if (definition.signatureCount > 1) {
|
||||
throw createValueHasWrongTypeError(
|
||||
value.node, value, 'Input transform function cannot have multiple signatures');
|
||||
}
|
||||
|
||||
const members = reflector.getMembersOfClass(clazz);
|
||||
|
||||
for (const member of members) {
|
||||
const conflictingName = `ngAcceptInputType_${classPropertyName}`;
|
||||
|
||||
if (member.name === conflictingName && member.isStatic) {
|
||||
throw new FatalDiagnosticError(
|
||||
ErrorCode.CONFLICTING_INPUT_TRANSFORM, value.node,
|
||||
`Class cannot have both a transform function on Input ${
|
||||
classPropertyName} and a static member called ${conflictingName}`);
|
||||
}
|
||||
}
|
||||
|
||||
const node = value instanceof Reference ? value.getIdentityIn(clazz.getSourceFile()) : value.node;
|
||||
|
||||
// This should never be null since we know the reference originates
|
||||
// from the same file, but we null check it just in case.
|
||||
if (node === null) {
|
||||
throw createValueHasWrongTypeError(
|
||||
value.node, value, 'Input transform function could not be referenced');
|
||||
}
|
||||
|
||||
// Skip over `this` parameters since they're typing the context, not the actual parameter.
|
||||
// `this` parameters are guaranteed to be first if they exist, and the only to distinguish them
|
||||
// is using the name, TS doesn't have a special AST for them.
|
||||
const firstParam = definition.parameters[0]?.name === 'this' ? definition.parameters[1] :
|
||||
definition.parameters[0];
|
||||
|
||||
// Treat functions with no arguments as `unknown` since returning
|
||||
// the same value from the transform function is valid.
|
||||
if (!firstParam) {
|
||||
return {node, type: ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword)};
|
||||
}
|
||||
|
||||
// This should be caught by `noImplicitAny` already, but null check it just in case.
|
||||
if (!firstParam.type) {
|
||||
throw createValueHasWrongTypeError(
|
||||
value.node, value, 'Input transform function first parameter must have a type');
|
||||
}
|
||||
|
||||
if (firstParam.node.dotDotDotToken) {
|
||||
throw createValueHasWrongTypeError(
|
||||
value.node, value, 'Input transform function first parameter cannot be a spread parameter');
|
||||
}
|
||||
|
||||
assertEmittableInputType(firstParam.type, clazz.getSourceFile(), reflector, refEmitter);
|
||||
|
||||
return {node, type: firstParam.type};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a type and all types contained within
|
||||
* it can be referenced in a specific context file.
|
||||
*/
|
||||
function assertEmittableInputType(
|
||||
type: ts.TypeNode, contextFile: ts.SourceFile, reflector: ReflectionHost,
|
||||
refEmitter: ReferenceEmitter): void {
|
||||
(function walk(node: ts.Node) {
|
||||
if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) {
|
||||
const declaration = reflector.getDeclarationOfIdentifier(node.typeName);
|
||||
|
||||
if (declaration !== null) {
|
||||
// If the type is declared in a different file, we have to check that it can be imported
|
||||
// into the context file. If they're in the same file, we need to verify that they're
|
||||
// exported, otherwise TS won't emit it to the .d.ts.
|
||||
if (declaration.node.getSourceFile() !== contextFile) {
|
||||
const emittedType = refEmitter.emit(
|
||||
new Reference(declaration.node), contextFile,
|
||||
ImportFlags.NoAliasing | ImportFlags.AllowTypeImports |
|
||||
ImportFlags.AllowRelativeDtsImports);
|
||||
|
||||
assertSuccessfulReferenceEmit(emittedType, node, 'type');
|
||||
} else if (!reflector.isStaticallyExported(declaration.node)) {
|
||||
throw new FatalDiagnosticError(
|
||||
ErrorCode.SYMBOL_NOT_EXPORTED, type,
|
||||
`Symbol must be exported in order to be used as the type of an Input transform function`,
|
||||
[makeRelatedInformation(declaration.node, `The symbol is declared here.`)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.forEachChild(walk);
|
||||
})(type);
|
||||
}
|
||||
|
||||
/** Parses the `outputs` array of a directive/component. */
|
||||
function parseOutputsArray(
|
||||
directive: Map<string, ts.Expression>, evaluator: PartialEvaluator): Record<string, string> {
|
||||
|
|
@ -792,3 +932,14 @@ function toHostDirectiveMetadata(
|
|||
outputs: hostDirective.outputs || null
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts the parsed input information into metadata. */
|
||||
function toR3InputMetadata(mapping: InputMapping): R3InputMetadata {
|
||||
return {
|
||||
classPropertyName: mapping.classPropertyName,
|
||||
bindingPropertyName: mapping.bindingPropertyName,
|
||||
required: mapping.required,
|
||||
transformFunction: mapping.transform !== null ? new WrappedNodeExpr(mapping.transform.node) :
|
||||
null
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ export class PipeDecoratorHandler implements
|
|||
const classMetadata = analysis.classMetadata !== null ?
|
||||
compileClassMetadata(analysis.classMetadata).toStmt() :
|
||||
null;
|
||||
return compileResults(fac, def, classMetadata, 'ɵpipe');
|
||||
return compileResults(fac, def, classMetadata, 'ɵpipe', null);
|
||||
}
|
||||
|
||||
compilePartial(node: ClassDeclaration, analysis: Readonly<PipeHandlerData>): CompileResult[] {
|
||||
|
|
@ -192,6 +192,6 @@ export class PipeDecoratorHandler implements
|
|||
const classMetadata = analysis.classMetadata !== null ?
|
||||
compileDeclareClassMetadata(analysis.classMetadata).toStmt() :
|
||||
null;
|
||||
return compileResults(fac, def, classMetadata, 'ɵpipe');
|
||||
return compileResults(fac, def, classMetadata, 'ɵpipe', null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,13 @@ export enum ErrorCode {
|
|||
*/
|
||||
HOST_DIRECTIVE_MISSING_REQUIRED_BINDING = 2019,
|
||||
|
||||
/**
|
||||
* Raised when a component specifies both a `transform` function on an input
|
||||
* and has a corresponding `ngAcceptInputType_` member for the same input.
|
||||
*/
|
||||
CONFLICTING_INPUT_TRANSFORM = 2020,
|
||||
|
||||
|
||||
SYMBOL_NOT_EXPORTED = 3001,
|
||||
/**
|
||||
* Raised when a relationship between directives and/or pipes would cause a cyclic import to be
|
||||
|
|
|
|||
|
|
@ -132,7 +132,16 @@ export enum MatchSource {
|
|||
}
|
||||
|
||||
/** Metadata for a single input mapping. */
|
||||
export type InputMapping = InputOrOutput&{required: boolean};
|
||||
export type InputMapping = InputOrOutput&{
|
||||
required: boolean;
|
||||
transform: InputTransform|null
|
||||
};
|
||||
|
||||
/** Metadata for an input's transform function. */
|
||||
export interface InputTransform {
|
||||
node: ts.Node;
|
||||
type: ts.TypeNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata collected for a directive within an NgModule's scope.
|
||||
|
|
|
|||
|
|
@ -187,24 +187,31 @@ function readInputsType(type: ts.TypeNode): Record<string, InputMapping> {
|
|||
}
|
||||
|
||||
const stringValue = readStringType(member.type);
|
||||
const classPropertyName = member.name.text;
|
||||
|
||||
// Before v16 the inputs map has the type of `{[field: string]: string}`.
|
||||
// After v16 it has the type of `{[field: string]: {alias: string, required: boolean}}`.
|
||||
if (stringValue != null) {
|
||||
inputsMap[member.name.text] = {
|
||||
inputsMap[classPropertyName] = {
|
||||
bindingPropertyName: stringValue,
|
||||
classPropertyName: member.name.text,
|
||||
required: false
|
||||
classPropertyName,
|
||||
required: false,
|
||||
// Input transform are only tracked for locally-compiled directives. Directives coming
|
||||
// from the .d.ts already have them included through `ngAcceptInputType` class members.
|
||||
transform: null,
|
||||
};
|
||||
} else {
|
||||
const config = readMapType(member.type, innerValue => {
|
||||
return readStringType(innerValue) ?? readBooleanType(innerValue);
|
||||
}) as {alias: string, required: boolean};
|
||||
|
||||
inputsMap[member.name.text] = {
|
||||
classPropertyName: member.name.text,
|
||||
inputsMap[classPropertyName] = {
|
||||
classPropertyName,
|
||||
bindingPropertyName: config.alias,
|
||||
required: config.required
|
||||
required: config.required,
|
||||
// Input transform are only tracked for locally-compiled directives. Directives coming
|
||||
// from the .d.ts already have them included through `ngAcceptInputType` class members.
|
||||
transform: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,8 @@ function resolveInput(bindingName: string, binding: InputMapping): InputMapping
|
|||
return {
|
||||
bindingPropertyName: bindingName,
|
||||
classPropertyName: binding.classPropertyName,
|
||||
required: binding.required
|
||||
required: binding.required,
|
||||
transform: binding.transform,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -169,11 +169,12 @@ export class ClassPropertyMapping<T extends InputOrOutput = InputOrOutput> imple
|
|||
* names if they differ.
|
||||
*
|
||||
* This object format is used when mappings are serialized (for example into .d.ts files).
|
||||
* @param transform Function used to transform the values of the generated map.
|
||||
*/
|
||||
toJointMappedObject(): {[classPropertyName: string]: T} {
|
||||
const obj: {[classPropertyName: string]: T} = {};
|
||||
toJointMappedObject<O = T>(transform: (value: T) => O): {[classPropertyName: string]: O} {
|
||||
const obj: {[classPropertyName: string]: O} = {};
|
||||
for (const [classPropertyName, inputOrOutput] of this.forwardMap) {
|
||||
obj[classPropertyName] = inputOrOutput;
|
||||
obj[classPropertyName] = transform(inputOrOutput);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export function extractDirectiveTypeCheckMeta(
|
|||
const stringLiteralInputFields = new Set<ClassPropertyName>();
|
||||
const undeclaredInputFields = new Set<ClassPropertyName>();
|
||||
|
||||
for (const classPropertyName of inputs.classPropertyNames) {
|
||||
for (const {classPropertyName, transform} of inputs) {
|
||||
const field = members.find(member => member.name === classPropertyName);
|
||||
if (field === undefined || field.node === null) {
|
||||
undeclaredInputFields.add(classPropertyName);
|
||||
|
|
@ -141,6 +141,9 @@ export function extractDirectiveTypeCheckMeta(
|
|||
if (field.nameNode !== null && ts.isStringLiteral(field.nameNode)) {
|
||||
stringLiteralInputFields.add(classPropertyName);
|
||||
}
|
||||
if (transform !== null) {
|
||||
coercedInputFields.add(classPropertyName);
|
||||
}
|
||||
}
|
||||
|
||||
const arity = reflector.getGenericArityOfClass(node);
|
||||
|
|
|
|||
|
|
@ -418,6 +418,11 @@ export interface FunctionDefinition {
|
|||
* Generic type parameters of the function.
|
||||
*/
|
||||
typeParameters: ts.TypeParameterDeclaration[]|null;
|
||||
|
||||
/**
|
||||
* Number of known signatures of the function.
|
||||
*/
|
||||
signatureCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -176,9 +176,13 @@ export class TypeScriptReflectionHost implements ReflectionHost {
|
|||
[ts.factory.createReturnStatement(node.body)];
|
||||
}
|
||||
|
||||
const type = this.checker.getTypeAtLocation(node);
|
||||
const signatures = this.checker.getSignaturesOfType(type, ts.SignatureKind.Call);
|
||||
|
||||
return {
|
||||
node,
|
||||
body,
|
||||
signatureCount: signatures.length,
|
||||
typeParameters: node.typeParameters === undefined ? null : Array.from(node.typeParameters),
|
||||
parameters: node.parameters.map(param => {
|
||||
const name = parameterName(param.name);
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ export interface AnalysisOutput<A> {
|
|||
*/
|
||||
export interface CompileResult {
|
||||
name: string;
|
||||
initializer: Expression;
|
||||
initializer: Expression|null;
|
||||
statements: Statement[];
|
||||
type: Type;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,11 @@ class IvyTransformationVisitor extends Visitor {
|
|||
const members = [...node.members];
|
||||
|
||||
for (const field of this.classCompilationMap.get(node)!) {
|
||||
// Type-only member.
|
||||
if (field.initializer === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Translate the initializer for the field into TS nodes.
|
||||
const exprNode = translateExpression(field.initializer, this.importManager, translateOptions);
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export interface TypeCtorMetadata {
|
|||
/**
|
||||
* Input, output, and query field names in the type which should be included as constructor input.
|
||||
*/
|
||||
fields: {inputs: string[]; outputs: string[]; queries: string[];};
|
||||
fields: {inputs: ClassPropertyMapping<InputMapping>; queries: string[];};
|
||||
|
||||
/**
|
||||
* `Set` of field names which have type coercion enabled.
|
||||
|
|
|
|||
|
|
@ -247,8 +247,7 @@ export class TypeCheckContextImpl implements TypeCheckContext {
|
|||
// it comes from a .d.ts file. .d.ts declarations don't have bodies.
|
||||
body: !dirNode.getSourceFile().isDeclarationFile,
|
||||
fields: {
|
||||
inputs: dir.inputs.classPropertyNames,
|
||||
outputs: dir.outputs.classPropertyNames,
|
||||
inputs: dir.inputs,
|
||||
// TODO(alxhub): support queries
|
||||
queries: dir.queries,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -77,8 +77,7 @@ export class Environment implements ReferenceEmitEnvironment {
|
|||
fnName,
|
||||
body: true,
|
||||
fields: {
|
||||
inputs: dir.inputs.classPropertyNames,
|
||||
outputs: dir.outputs.classPropertyNames,
|
||||
inputs: dir.inputs,
|
||||
// TODO: support queries
|
||||
queries: dir.queries,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {AST, BindingPipe, BindingType, BoundTarget, Call, DYNAMIC_TYPE, ImplicitReceiver, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, SafeCall, SafePropertyRead, SchemaMetadata, ThisReceiver, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstElement, TmplAstIcu, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
|
||||
import {AST, BindingPipe, BindingType, BoundTarget, Call, DYNAMIC_TYPE, ImplicitReceiver, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, SafeCall, SafePropertyRead, SchemaMetadata, ThisReceiver, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstElement, TmplAstIcu, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable, TransplantedType} from '@angular/compiler';
|
||||
import ts from 'typescript';
|
||||
|
||||
import {Reference} from '../../imports';
|
||||
|
|
@ -706,7 +706,7 @@ class TcbDirectiveInputsOp extends TcbOp {
|
|||
|
||||
let assignment: ts.Expression = wrapForDiagnostics(expr);
|
||||
|
||||
for (const {fieldName, required} of attr.inputs) {
|
||||
for (const {fieldName, required, transformType} of attr.inputs) {
|
||||
let target: ts.LeftHandSideExpression;
|
||||
|
||||
if (required) {
|
||||
|
|
@ -714,18 +714,26 @@ class TcbDirectiveInputsOp extends TcbOp {
|
|||
}
|
||||
|
||||
if (this.dir.coercedInputFields.has(fieldName)) {
|
||||
// The input has a coercion declaration which should be used instead of assigning the
|
||||
// expression into the input field directly. To achieve this, a variable is declared
|
||||
// with a type of `typeof Directive.ngAcceptInputType_fieldName` which is then used as
|
||||
// target of the assignment.
|
||||
const dirTypeRef = this.tcb.env.referenceType(this.dir.ref);
|
||||
if (!ts.isTypeReferenceNode(dirTypeRef)) {
|
||||
throw new Error(
|
||||
`Expected TypeReferenceNode from reference to ${this.dir.ref.debugName}`);
|
||||
let type: ts.TypeNode;
|
||||
|
||||
if (transformType) {
|
||||
type = this.tcb.env.referenceTransplantedType(new TransplantedType(transformType));
|
||||
} else {
|
||||
// The input has a coercion declaration which should be used instead of assigning the
|
||||
// expression into the input field directly. To achieve this, a variable is declared
|
||||
// with a type of `typeof Directive.ngAcceptInputType_fieldName` which is then used as
|
||||
// target of the assignment.
|
||||
const dirTypeRef: ts.TypeNode = this.tcb.env.referenceType(this.dir.ref);
|
||||
|
||||
if (!ts.isTypeReferenceNode(dirTypeRef)) {
|
||||
throw new Error(
|
||||
`Expected TypeReferenceNode from reference to ${this.dir.ref.debugName}`);
|
||||
}
|
||||
|
||||
type = tsCreateTypeQueryForCoercedInput(dirTypeRef.typeName, fieldName);
|
||||
}
|
||||
|
||||
const id = this.tcb.allocateId();
|
||||
const type = tsCreateTypeQueryForCoercedInput(dirTypeRef.typeName, fieldName);
|
||||
this.scope.addStatement(tsDeclareVariable(id, type));
|
||||
|
||||
target = id;
|
||||
|
|
@ -1664,7 +1672,7 @@ class Scope {
|
|||
|
||||
interface TcbBoundAttribute {
|
||||
attribute: TmplAstBoundAttribute|TmplAstTextAttribute;
|
||||
inputs: {fieldName: ClassPropertyName, required: boolean}[];
|
||||
inputs: {fieldName: ClassPropertyName, required: boolean, transformType: ts.TypeNode|null}[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1874,8 +1882,11 @@ function getBoundAttributes(
|
|||
if (inputs !== null) {
|
||||
boundInputs.push({
|
||||
attribute: attr,
|
||||
inputs:
|
||||
inputs.map(input => ({fieldName: input.classPropertyName, required: input.required}))
|
||||
inputs: inputs.map(input => ({
|
||||
fieldName: input.classPropertyName,
|
||||
required: input.required,
|
||||
transformType: input.transform?.type || null
|
||||
}))
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -135,18 +135,22 @@ function constructTypeCtorParameter(
|
|||
// In the special case there are no inputs, initType is set to {}.
|
||||
let initType: ts.TypeNode|null = null;
|
||||
|
||||
const keys: string[] = meta.fields.inputs;
|
||||
const plainKeys: ts.LiteralTypeNode[] = [];
|
||||
const coercedKeys: ts.PropertySignature[] = [];
|
||||
for (const key of keys) {
|
||||
if (!meta.coercedInputFields.has(key)) {
|
||||
plainKeys.push(ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(key)));
|
||||
|
||||
for (const {classPropertyName, transform} of meta.fields.inputs) {
|
||||
if (!meta.coercedInputFields.has(classPropertyName)) {
|
||||
plainKeys.push(
|
||||
ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(classPropertyName)));
|
||||
} else {
|
||||
coercedKeys.push(ts.factory.createPropertySignature(
|
||||
/* modifiers */ undefined,
|
||||
/* name */ key,
|
||||
/* name */ classPropertyName,
|
||||
/* questionToken */ undefined,
|
||||
/* type */ tsCreateTypeQueryForCoercedInput(rawType.typeName, key)));
|
||||
/* type */
|
||||
transform == null ?
|
||||
tsCreateTypeQueryForCoercedInput(rawType.typeName, classPropertyName) :
|
||||
transform.type));
|
||||
}
|
||||
}
|
||||
if (plainKeys.length > 0) {
|
||||
|
|
|
|||
|
|
@ -870,7 +870,12 @@ class TestComponent {
|
|||
name: 'Dir',
|
||||
selector: '[dir]',
|
||||
inputs: {
|
||||
input: {classPropertyName: 'input', bindingPropertyName: 'input', required: true},
|
||||
input: {
|
||||
classPropertyName: 'input',
|
||||
bindingPropertyName: 'input',
|
||||
required: true,
|
||||
transform: null,
|
||||
},
|
||||
},
|
||||
}]);
|
||||
|
||||
|
|
@ -897,11 +902,17 @@ class TestComponent {
|
|||
name: 'Dir',
|
||||
selector: '[dir]',
|
||||
inputs: {
|
||||
input: {classPropertyName: 'input', bindingPropertyName: 'input', required: true},
|
||||
input: {
|
||||
classPropertyName: 'input',
|
||||
bindingPropertyName: 'input',
|
||||
required: true,
|
||||
transform: null,
|
||||
},
|
||||
otherInput: {
|
||||
classPropertyName: 'otherInput',
|
||||
bindingPropertyName: 'otherInput',
|
||||
required: true
|
||||
required: true,
|
||||
transform: null,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -913,7 +924,8 @@ class TestComponent {
|
|||
otherDirInput: {
|
||||
classPropertyName: 'otherDirInput',
|
||||
bindingPropertyName: 'otherDirInput',
|
||||
required: true
|
||||
required: true,
|
||||
transform: null,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -938,7 +950,12 @@ class TestComponent {
|
|||
name: 'Dir',
|
||||
selector: '[dir]',
|
||||
inputs: {
|
||||
input: {classPropertyName: 'input', bindingPropertyName: 'inputAlias', required: true}
|
||||
input: {
|
||||
classPropertyName: 'input',
|
||||
bindingPropertyName: 'inputAlias',
|
||||
required: true,
|
||||
transform: null,
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
|
|
@ -962,7 +979,12 @@ class TestComponent {
|
|||
name: 'Dir',
|
||||
selector: '[dir]',
|
||||
inputs: {
|
||||
input: {classPropertyName: 'input', bindingPropertyName: 'input', required: true},
|
||||
input: {
|
||||
classPropertyName: 'input',
|
||||
bindingPropertyName: 'input',
|
||||
required: true,
|
||||
transform: null,
|
||||
},
|
||||
}
|
||||
}]);
|
||||
|
||||
|
|
@ -988,7 +1010,8 @@ class TestComponent {
|
|||
input: {
|
||||
classPropertyName: 'input',
|
||||
bindingPropertyName: 'inputAlias',
|
||||
required: true
|
||||
required: true,
|
||||
transform: null,
|
||||
},
|
||||
},
|
||||
}]);
|
||||
|
|
@ -1009,7 +1032,12 @@ class TestComponent {
|
|||
name: 'Dir',
|
||||
selector: '[dir]',
|
||||
inputs: {
|
||||
input: {classPropertyName: 'input', bindingPropertyName: 'input', required: true},
|
||||
input: {
|
||||
classPropertyName: 'input',
|
||||
bindingPropertyName: 'input',
|
||||
required: true,
|
||||
transform: null,
|
||||
},
|
||||
}
|
||||
}]);
|
||||
|
||||
|
|
@ -1032,7 +1060,12 @@ class TestComponent {
|
|||
name: 'Dir',
|
||||
selector: '[dir]',
|
||||
inputs: {
|
||||
input: {classPropertyName: 'input', bindingPropertyName: 'input', required: true},
|
||||
input: {
|
||||
classPropertyName: 'input',
|
||||
bindingPropertyName: 'input',
|
||||
required: true,
|
||||
transform: null,
|
||||
},
|
||||
},
|
||||
outputs: {inputChange: 'inputChange'},
|
||||
}]);
|
||||
|
|
@ -1053,7 +1086,14 @@ class TestComponent {
|
|||
type: 'directive',
|
||||
name: 'Dir',
|
||||
selector: '[dir]',
|
||||
inputs: {dir: {classPropertyName: 'dir', bindingPropertyName: 'dir', required: true}}
|
||||
inputs: {
|
||||
dir: {
|
||||
classPropertyName: 'dir',
|
||||
bindingPropertyName: 'dir',
|
||||
required: true,
|
||||
transform: null,
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
|
|
@ -1080,7 +1120,8 @@ class TestComponent {
|
|||
input: {
|
||||
classPropertyName: 'input',
|
||||
bindingPropertyName: 'hostAlias',
|
||||
required: true
|
||||
required: true,
|
||||
transform: null,
|
||||
},
|
||||
},
|
||||
isStandalone: true,
|
||||
|
|
@ -1111,7 +1152,8 @@ class TestComponent {
|
|||
maxlength: {
|
||||
classPropertyName: 'maxlength',
|
||||
bindingPropertyName: 'maxlength',
|
||||
required: true
|
||||
required: true,
|
||||
transform: null,
|
||||
},
|
||||
},
|
||||
}]);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import ts from 'typescript';
|
||||
|
||||
import {initMockFileSystem} from '../../file_system/testing';
|
||||
import {TypeCheckingConfig} from '../api';
|
||||
import {ALL_ENABLED_CONFIG, tcb, TestDeclaration, TestDirective} from '../testing';
|
||||
|
|
@ -595,6 +597,37 @@ describe('type check blocks', () => {
|
|||
'_t1 = (((this).foo));');
|
||||
});
|
||||
|
||||
it('should use transform type if an input has one', () => {
|
||||
const TEMPLATE = `<div dir [fieldA]="expr"></div>`;
|
||||
const DIRECTIVES: TestDeclaration[] = [{
|
||||
type: 'directive',
|
||||
name: 'Dir',
|
||||
selector: '[dir]',
|
||||
inputs: {
|
||||
fieldA: {
|
||||
bindingPropertyName: 'fieldA',
|
||||
classPropertyName: 'fieldA',
|
||||
required: false,
|
||||
transform: {
|
||||
node: ts.factory.createFunctionDeclaration(
|
||||
undefined, undefined, undefined, undefined, [], undefined, undefined),
|
||||
type: ts.factory.createUnionTypeNode([
|
||||
ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword),
|
||||
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
|
||||
])
|
||||
},
|
||||
},
|
||||
},
|
||||
coercedInputFields: ['fieldA'],
|
||||
}];
|
||||
|
||||
const block = tcb(TEMPLATE, DIRECTIVES);
|
||||
|
||||
expect(block).toContain(
|
||||
'var _t1: boolean | string = null!; ' +
|
||||
'_t1 = (((this).expr));');
|
||||
});
|
||||
|
||||
it('should handle $any casts', () => {
|
||||
const TEMPLATE = `{{$any(a)}}`;
|
||||
const block = tcb(TEMPLATE);
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@
|
|||
*/
|
||||
import ts from 'typescript';
|
||||
|
||||
import {absoluteFrom, AbsoluteFsPath, getFileSystem, getSourceFileOrError, LogicalFileSystem, NgtscCompilerHost} from '../../file_system';
|
||||
import {absoluteFrom, getFileSystem, getSourceFileOrError, LogicalFileSystem, NgtscCompilerHost} from '../../file_system';
|
||||
import {runInEachFileSystem, TestFile} from '../../file_system/testing';
|
||||
import {AbsoluteModuleStrategy, LocalIdentifierStrategy, LogicalProjectStrategy, ModuleResolver, Reference, ReferenceEmitter} from '../../imports';
|
||||
import {ClassPropertyMapping, InputMapping} from '../../metadata';
|
||||
import {NOOP_PERF_RECORDER} from '../../perf';
|
||||
import {TsCreateProgramDriver, UpdateMode} from '../../program_driver';
|
||||
import {isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflection';
|
||||
|
|
@ -18,14 +19,12 @@ import {getRootDirs} from '../../util/src/typescript';
|
|||
import {InliningMode, PendingFileTypeCheckingData, TypeCheckContextImpl, TypeCheckingHost} from '../src/context';
|
||||
import {TemplateSourceManager} from '../src/source';
|
||||
import {TypeCheckFile} from '../src/type_check_file';
|
||||
|
||||
import {ALL_ENABLED_CONFIG} from '../testing';
|
||||
|
||||
runInEachFileSystem(() => {
|
||||
describe('ngtsc typechecking', () => {
|
||||
let _: typeof absoluteFrom;
|
||||
let LIB_D_TS: TestFile;
|
||||
let TYPE_CHECK_TS: TestFile;
|
||||
|
||||
beforeEach(() => {
|
||||
_ = absoluteFrom;
|
||||
|
|
@ -83,8 +82,7 @@ TestClass.ngTypeCtor({value: 'test'});
|
|||
fnName: 'ngTypeCtor',
|
||||
body: true,
|
||||
fields: {
|
||||
inputs: ['value'],
|
||||
outputs: [],
|
||||
inputs: ClassPropertyMapping.fromMappedObject<InputMapping>({value: 'value'}),
|
||||
queries: [],
|
||||
},
|
||||
coercedInputFields: new Set(),
|
||||
|
|
@ -121,8 +119,7 @@ TestClass.ngTypeCtor({value: 'test'});
|
|||
fnName: 'ngTypeCtor',
|
||||
body: true,
|
||||
fields: {
|
||||
inputs: ['value'],
|
||||
outputs: [],
|
||||
inputs: ClassPropertyMapping.fromMappedObject<InputMapping>({value: 'value'}),
|
||||
queries: ['queryField'],
|
||||
},
|
||||
coercedInputFields: new Set(),
|
||||
|
|
@ -166,11 +163,26 @@ TestClass.ngTypeCtor({value: 'test'});
|
|||
fnName: 'ngTypeCtor',
|
||||
body: true,
|
||||
fields: {
|
||||
inputs: ['foo', 'bar'],
|
||||
outputs: [],
|
||||
inputs: ClassPropertyMapping.fromMappedObject<InputMapping>({
|
||||
foo: 'foo',
|
||||
bar: 'bar',
|
||||
baz: {
|
||||
classPropertyName: 'baz',
|
||||
bindingPropertyName: 'baz',
|
||||
required: false,
|
||||
transform: {
|
||||
type: ts.factory.createUnionTypeNode([
|
||||
ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword),
|
||||
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
|
||||
]),
|
||||
node: ts.factory.createFunctionDeclaration(
|
||||
undefined, undefined, undefined, undefined, [], undefined, undefined)
|
||||
}
|
||||
}
|
||||
}),
|
||||
queries: [],
|
||||
},
|
||||
coercedInputFields: new Set(['bar']),
|
||||
coercedInputFields: new Set(['bar', 'baz']),
|
||||
});
|
||||
const programStrategy = new TsCreateProgramDriver(program, host, options, []);
|
||||
programStrategy.updateFiles(ctx.finalize(), UpdateMode.Complete);
|
||||
|
|
@ -179,7 +191,7 @@ TestClass.ngTypeCtor({value: 'test'});
|
|||
const typeCtor = TestClassWithCtor.members.find(isTypeCtor)!;
|
||||
const ctorText = typeCtor.getText().replace(/[ \r\n]+/g, ' ');
|
||||
expect(ctorText).toContain(
|
||||
'init: Pick<TestClass, "foo"> & { bar: typeof TestClass.ngAcceptInputType_bar; }');
|
||||
'init: Pick<TestClass, "foo"> & { bar: typeof TestClass.ngAcceptInputType_bar; baz: boolean | string; }');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {absoluteFrom, AbsoluteFsPath, getSourceFileOrError, LogicalFileSystem} f
|
|||
import {TestFile} from '../../file_system/testing';
|
||||
import {AbsoluteModuleStrategy, LocalIdentifierStrategy, LogicalProjectStrategy, ModuleResolver, Reference, ReferenceEmitter, RelativePathStrategy} from '../../imports';
|
||||
import {NOOP_INCREMENTAL_BUILD} from '../../incremental';
|
||||
import {ClassPropertyMapping, CompoundMetadataReader, DirectiveMeta, HostDirectivesResolver, InputMapping, MatchSource, MetadataReaderWithIndex, MetaKind, NgModuleIndex} from '../../metadata';
|
||||
import {ClassPropertyMapping, CompoundMetadataReader, DirectiveMeta, HostDirectivesResolver, InputMapping, InputTransform, MatchSource, MetadataReaderWithIndex, MetaKind, NgModuleIndex} from '../../metadata';
|
||||
import {NOOP_PERF_RECORDER} from '../../perf';
|
||||
import {TsCreateProgramDriver} from '../../program_driver';
|
||||
import {ClassDeclaration, isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflection';
|
||||
|
|
@ -236,7 +236,10 @@ export interface TestDirective extends Partial<Pick<
|
|||
inputs?: {
|
||||
[fieldName: string]:
|
||||
string|{
|
||||
classPropertyName: string, bindingPropertyName: string, required: boolean
|
||||
classPropertyName: string;
|
||||
bindingPropertyName: string;
|
||||
required: boolean;
|
||||
transform: InputTransform|null;
|
||||
}
|
||||
};
|
||||
outputs?: {[fieldName: string]: string};
|
||||
|
|
|
|||
|
|
@ -104,3 +104,55 @@ export declare class MyModule {
|
|||
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
|
||||
}
|
||||
|
||||
/****************************************************************************************************
|
||||
* PARTIAL FILE: input_transform.js
|
||||
****************************************************************************************************/
|
||||
import { Directive, Input, NgModule } from '@angular/core';
|
||||
import * as i0 from "@angular/core";
|
||||
function toNumber(value) {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
class MyDirective {
|
||||
}
|
||||
MyDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
||||
MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyDirective, selector: "[my-directive]", inputs: { functionDeclarationInput: ["functionDeclarationInput", "functionDeclarationInput", toNumber], inlineFunctionInput: ["inlineFunctionInput", "inlineFunctionInput", (value, _) => value ? 1 : 0] }, ngImport: i0 });
|
||||
export { MyDirective };
|
||||
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyDirective, decorators: [{
|
||||
type: Directive,
|
||||
args: [{ selector: '[my-directive]' }]
|
||||
}], propDecorators: { functionDeclarationInput: [{
|
||||
type: Input,
|
||||
args: [{ transform: toNumber }]
|
||||
}], inlineFunctionInput: [{
|
||||
type: Input,
|
||||
args: [{ transform: (value, _) => value ? 1 : 0 }]
|
||||
}] } });
|
||||
class MyModule {
|
||||
}
|
||||
MyModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
|
||||
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyDirective] });
|
||||
MyModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule });
|
||||
export { MyModule };
|
||||
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, decorators: [{
|
||||
type: NgModule,
|
||||
args: [{ declarations: [MyDirective] }]
|
||||
}] });
|
||||
|
||||
/****************************************************************************************************
|
||||
* PARTIAL FILE: input_transform.d.ts
|
||||
****************************************************************************************************/
|
||||
import * as i0 from "@angular/core";
|
||||
export declare class MyDirective {
|
||||
functionDeclarationInput: any;
|
||||
inlineFunctionInput: any;
|
||||
static ɵfac: i0.ɵɵFactoryDeclaration<MyDirective, never>;
|
||||
static ɵdir: i0.ɵɵDirectiveDeclaration<MyDirective, "[my-directive]", never, { "functionDeclarationInput": { "alias": "functionDeclarationInput"; "required": false; }; "inlineFunctionInput": { "alias": "inlineFunctionInput"; "required": false; }; }, {}, never, never, false, never, false>;
|
||||
static ngAcceptInputType_functionDeclarationInput: number | string;
|
||||
static ngAcceptInputType_inlineFunctionInput: string | number;
|
||||
}
|
||||
export declare class MyModule {
|
||||
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
|
||||
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyDirective], never, never>;
|
||||
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,23 @@
|
|||
"failureMessage": "Incorrect directive definition"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "should declare inputs with transform functions",
|
||||
"inputFiles": [
|
||||
"input_transform.ts"
|
||||
],
|
||||
"expectations": [
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"expected": "input_transform_definition.js",
|
||||
"generated": "input_transform.js"
|
||||
}
|
||||
],
|
||||
"failureMessage": "Incorrect directive definition"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import {Directive, Input, NgModule} from '@angular/core';
|
||||
|
||||
function toNumber(value: number|string) {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
|
||||
@Directive({selector: '[my-directive]'})
|
||||
export class MyDirective {
|
||||
@Input({transform: toNumber}) functionDeclarationInput: any;
|
||||
|
||||
// There's an extra `_` parameter, because full compilation strips the parentheses around the
|
||||
// parameters while partial compilation keeps them. This ensures consistent output.
|
||||
@Input({transform: (value: string|number, _: any) => value ? 1 : 0}) inlineFunctionInput: any;
|
||||
}
|
||||
|
||||
@NgModule({declarations: [MyDirective]})
|
||||
export class MyModule {
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
MyDirective.ɵdir = /*@__PURE__*/ $r3$.ɵɵdefineDirective({
|
||||
…
|
||||
inputs: {
|
||||
functionDeclarationInput: ["functionDeclarationInput", "functionDeclarationInput", toNumber],
|
||||
inlineFunctionInput: ["inlineFunctionInput", "inlineFunctionInput", (value, _) => value ? 1 : 0]
|
||||
},
|
||||
features: [$r3$.ɵɵInputTransformsFeature]…
|
||||
});
|
||||
|
|
@ -2336,6 +2336,242 @@ function allTests(os: string) {
|
|||
verifyThrownError(
|
||||
ErrorCode.DECORATOR_ARG_NOT_LITERAL, '@Injectable argument must be an object literal');
|
||||
});
|
||||
|
||||
it('should produce a diangostic if the transform value is not a function', () => {
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
const NOT_A_FUNCTION = 1;
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: NOT_A_FUNCTION}) value!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
verifyThrownError(ErrorCode.VALUE_HAS_WRONG_TYPE, `Input transform must be a function`);
|
||||
});
|
||||
|
||||
it('should produce a diangostic if the transform value in the inputs array is not a function',
|
||||
() => {
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
const NOT_A_FUNCTION = 1;
|
||||
|
||||
@Directive({
|
||||
selector: '[dir]',
|
||||
standalone: true,
|
||||
inputs: [{
|
||||
name: 'value',
|
||||
transform: NOT_A_FUNCTION
|
||||
}]
|
||||
})
|
||||
export class Dir {
|
||||
value!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
verifyThrownError(
|
||||
ErrorCode.VALUE_HAS_WRONG_TYPE,
|
||||
`Transform of value at position 0 of @Directive.inputs array must be a function Value is of type 'number'.`);
|
||||
});
|
||||
|
||||
it('should produce a diangostic if the transform function first parameter has no arguments',
|
||||
() => {
|
||||
env.tsconfig({noImplicitAny: false});
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: (val) => 1}) value!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
verifyThrownError(
|
||||
ErrorCode.VALUE_HAS_WRONG_TYPE,
|
||||
`Input transform function first parameter must have a type`);
|
||||
});
|
||||
|
||||
it('should produce a diangostic if the transform function is generic', () => {
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: <T>(val: T) => 1}) value!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
verifyThrownError(
|
||||
ErrorCode.VALUE_HAS_WRONG_TYPE, `Input transform function cannot be generic`);
|
||||
});
|
||||
|
||||
it('should produce a diangostic if there is a conflicting coercion member', () => {
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: (val: string) => 1}) value!: number;
|
||||
|
||||
static ngAcceptInputType_value: boolean;
|
||||
}
|
||||
`);
|
||||
|
||||
verifyThrownError(
|
||||
ErrorCode.CONFLICTING_INPUT_TRANSFORM,
|
||||
`Class cannot have both a transform function on Input value and a static member called ngAcceptInputType_value`);
|
||||
});
|
||||
|
||||
it('should produce a diangostic if the transform function type cannot be referenced from the source file',
|
||||
() => {
|
||||
env.write('/util.ts', `
|
||||
interface InternalType {
|
||||
foo: boolean;
|
||||
}
|
||||
|
||||
export function toNumber(val: InternalType) { return 1; }
|
||||
`);
|
||||
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
import {toNumber} from './util';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: toNumber}) value!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
verifyThrownError(
|
||||
ErrorCode.IMPORT_GENERATION_FAILURE, 'Unable to import type InternalType.');
|
||||
});
|
||||
|
||||
it('should produce a diangostic if a sub-type of the transform function cannot be referenced from the source file',
|
||||
() => {
|
||||
env.write('/util.ts', `
|
||||
interface InternalType {
|
||||
foo: boolean;
|
||||
}
|
||||
|
||||
export function toNumber(val: {value: InternalType}) { return 1; }
|
||||
`);
|
||||
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
import {toNumber} from './util';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: toNumber}) value!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
verifyThrownError(
|
||||
ErrorCode.IMPORT_GENERATION_FAILURE, 'Unable to import type InternalType.');
|
||||
});
|
||||
|
||||
it('should produce a diangostic if a generic parameter of the transform function cannot be referenced from the source file',
|
||||
() => {
|
||||
env.write('/util.ts', `
|
||||
export interface GenericWrapper<T> {
|
||||
value: T;
|
||||
}
|
||||
|
||||
interface InternalType {
|
||||
foo: boolean;
|
||||
}
|
||||
|
||||
export function toNumber(val: GenericWrapper<InternalType>) { return 1; }
|
||||
`);
|
||||
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
import {toNumber} from './util';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: toNumber}) value!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
verifyThrownError(
|
||||
ErrorCode.IMPORT_GENERATION_FAILURE, 'Unable to import type InternalType.');
|
||||
});
|
||||
|
||||
it('should produce a diangostic if transform type is not exported', () => {
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
interface InternalType {
|
||||
foo: boolean;
|
||||
}
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: (val: InternalType) => 1}) val!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
verifyThrownError(
|
||||
ErrorCode.SYMBOL_NOT_EXPORTED,
|
||||
'Symbol must be exported in order to be used as the type of an Input transform function');
|
||||
});
|
||||
|
||||
it('should produce a diangostic if the transform value is not a function', () => {
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
function createTransform(outerValue: number) {
|
||||
return (innerValue: string) => outerValue;
|
||||
}
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: createTransform(1)}) value!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
verifyThrownError(ErrorCode.VALUE_HAS_WRONG_TYPE, `Input transform must be a function`);
|
||||
});
|
||||
|
||||
it('should produce a diangostic if the first parameter of a transform is a spread', () => {
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
function toNumber(...value: (string | boolean)[]) { return 1; }
|
||||
|
||||
@Directive({standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: toNumber}) value!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
verifyThrownError(
|
||||
ErrorCode.VALUE_HAS_WRONG_TYPE,
|
||||
`Input transform function first parameter cannot be a spread parameter`);
|
||||
});
|
||||
|
||||
it('should produce a diangostic if a transform function has multiple signatures', () => {
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
function toNumber(value: boolean): number;
|
||||
function toNumber(value: string): number;
|
||||
function toNumber(value: boolean | string) { return 1; }
|
||||
|
||||
@Directive({standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: toNumber}) value!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
verifyThrownError(
|
||||
ErrorCode.VALUE_HAS_WRONG_TYPE,
|
||||
`Input transform function cannot have multiple signatures`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple decorators on classes', () => {
|
||||
|
|
@ -8286,6 +8522,233 @@ function allTests(os: string) {
|
|||
expectedImports.replace(/\s/g, '')}]});`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input transforms', () => {
|
||||
it('should compile a directive input with a transform function', () => {
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
function toNumber(value: boolean | string) { return 1; }
|
||||
|
||||
@Directive({standalone: true})
|
||||
export class Dir {
|
||||
@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]');
|
||||
expect(dtsContents).toContain('static ngAcceptInputType_value: boolean | string;');
|
||||
});
|
||||
|
||||
it('should compile a component input with a transform function', () => {
|
||||
env.write('/test.ts', `
|
||||
import {Component, Input} from '@angular/core';
|
||||
|
||||
function toNumber(value: boolean | string) { return 1; }
|
||||
|
||||
@Component({standalone: true, template: 'hello'})
|
||||
export class Dir {
|
||||
@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.ɵɵStandaloneFeature, i0.ɵɵInputTransformsFeature]');
|
||||
expect(dtsContents).toContain('static ngAcceptInputType_value: boolean | string;');
|
||||
});
|
||||
|
||||
it('should compile an input with a transform function that contains a generic parameter',
|
||||
() => {
|
||||
env.write('/types.ts', `
|
||||
export interface GenericWrapper<T> {
|
||||
value: T;
|
||||
}
|
||||
`);
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
import {GenericWrapper} from './types';
|
||||
|
||||
function toNumber(value: boolean | string | GenericWrapper<string>) { return 1; }
|
||||
|
||||
@Directive({standalone: true})
|
||||
export class Dir {
|
||||
@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]');
|
||||
expect(dtsContents).toContain('import * as i1 from "./types"');
|
||||
expect(dtsContents)
|
||||
.toContain(
|
||||
'static ngAcceptInputType_value: boolean | string | i1.GenericWrapper<string>;');
|
||||
});
|
||||
|
||||
it('should compile an input with a transform function that contains nested generic parameters',
|
||||
() => {
|
||||
env.write('/types.ts', `
|
||||
export interface GenericWrapper<T> {
|
||||
value: T;
|
||||
}
|
||||
`);
|
||||
env.write('/other-types.ts', `
|
||||
export class GenericClass<T> {
|
||||
foo: T;
|
||||
}
|
||||
`);
|
||||
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
import {GenericWrapper} from './types';
|
||||
import {GenericClass} from './other-types';
|
||||
|
||||
function toNumber(value: boolean | string | GenericWrapper<GenericClass<string>>) { return 1; }
|
||||
|
||||
@Directive({standalone: true})
|
||||
export class Dir {
|
||||
@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]');
|
||||
expect(dtsContents).toContain('import * as i1 from "./types"');
|
||||
expect(dtsContents).toContain('import * as i2 from "./other-types"');
|
||||
expect(dtsContents)
|
||||
.toContain(
|
||||
'static ngAcceptInputType_value: boolean | string | i1.GenericWrapper<i2.GenericClass<string>>;');
|
||||
});
|
||||
|
||||
it('should compile an input with an external transform function', () => {
|
||||
env.write('node_modules/external/index.d.ts', `
|
||||
export interface ExternalObj {
|
||||
foo: boolean;
|
||||
}
|
||||
|
||||
export type ExternalToNumberType = string | boolean | ExternalObj;
|
||||
|
||||
export declare function externalToNumber(val: ExternalToNumberType): number;
|
||||
`);
|
||||
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
import {externalToNumber} from 'external';
|
||||
|
||||
@Directive({standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: externalToNumber}) value!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
env.driveMain();
|
||||
|
||||
const jsContents = env.getContents('test.js');
|
||||
const dtsContents = env.getContents('test.d.ts');
|
||||
|
||||
expect(jsContents).toContain(`import { externalToNumber } from 'external';`);
|
||||
expect(jsContents).toContain('inputs: { value: ["value", "value", externalToNumber] }');
|
||||
expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]');
|
||||
expect(dtsContents).toContain('import * as i1 from "./node_modules/external/index";');
|
||||
expect(dtsContents).toContain('static ngAcceptInputType_value: i1.ExternalToNumberType;');
|
||||
});
|
||||
|
||||
it('should compile an input with an inline transform function', () => {
|
||||
env.write('node_modules/external/index.d.ts', `
|
||||
export interface ExternalObj {
|
||||
foo: boolean;
|
||||
}
|
||||
|
||||
export type ExternalToNumberType = string | boolean | ExternalObj;
|
||||
`);
|
||||
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
import {ExternalToNumberType} from 'external';
|
||||
|
||||
@Directive({standalone: true})
|
||||
export class Dir {
|
||||
@Input({transform: (value: ExternalToNumberType) => value ? 1 : 0}) value!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
env.driveMain();
|
||||
|
||||
const jsContents = env.getContents('test.js');
|
||||
const dtsContents = env.getContents('test.d.ts');
|
||||
|
||||
expect(jsContents)
|
||||
.toContain('inputs: { value: ["value", "value", (value) => value ? 1 : 0] }');
|
||||
expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]');
|
||||
expect(dtsContents).toContain('import * as i1 from "./node_modules/external/index";');
|
||||
expect(dtsContents).toContain('static ngAcceptInputType_value: i1.ExternalToNumberType;');
|
||||
});
|
||||
|
||||
it('should compile a directive input with a transform function with a `this` typing', () => {
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
function toNumber(this: Dir, value: boolean | string) { return 1; }
|
||||
|
||||
@Directive({standalone: true})
|
||||
export class Dir {
|
||||
@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]');
|
||||
expect(dtsContents).toContain('static ngAcceptInputType_value: boolean | string;');
|
||||
});
|
||||
|
||||
it('should treat an input transform function only with a `this` parameter as unknown', () => {
|
||||
env.write('/test.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
function toNumber(this: Dir) { return 1; }
|
||||
|
||||
@Directive({standalone: true})
|
||||
export class Dir {
|
||||
@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]');
|
||||
expect(dtsContents).toContain('static ngAcceptInputType_value: unknown;');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function expectTokenAtPosition<T extends ts.Node>(
|
||||
|
|
|
|||
|
|
@ -336,7 +336,6 @@ export declare class AnimationEvent {
|
|||
export class Module {}
|
||||
`);
|
||||
const diags = env.driveDiagnostics();
|
||||
console.error(diags);
|
||||
expect(diags.length).toBe(0);
|
||||
});
|
||||
|
||||
|
|
@ -1695,7 +1694,12 @@ export declare class AnimationEvent {
|
|||
`);
|
||||
});
|
||||
|
||||
it('should coerce an input using a coercion function if provided', () => {
|
||||
function getDiagnosticLines(diag: ts.Diagnostic): string[] {
|
||||
const separator = '~~~~~';
|
||||
return ts.flattenDiagnosticMessageText(diag.messageText, separator).split(separator);
|
||||
}
|
||||
|
||||
it('should coerce an input using a transform function if provided', () => {
|
||||
env.write('test.ts', `
|
||||
import {Component, NgModule} from '@angular/core';
|
||||
import {MatInputModule} from '@angular/material';
|
||||
|
|
@ -1810,6 +1814,509 @@ export declare class AnimationEvent {
|
|||
expect(diags[0].messageText)
|
||||
.toBe(`Type 'undefined' is not assignable to type 'string'.`);
|
||||
});
|
||||
|
||||
it('should type check using the first parameter type of a simple transform function', () => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
|
||||
export function toNumber(val: boolean | string) { return 1; }
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class CoercionDir {
|
||||
@Input({transform: toNumber}) val!: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<input dir [val]="invalidType">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {
|
||||
invalidType = 1;
|
||||
}
|
||||
`);
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
expect(diags[0].messageText)
|
||||
.toBe(`Type 'number' is not assignable to type 'string | boolean'.`);
|
||||
});
|
||||
|
||||
it('should type checking using the first parameter type of a simple inline transform function',
|
||||
() => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class CoercionDir {
|
||||
@Input({transform: (val: boolean | string) => 1}) val!: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<input dir [val]="invalidType">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {
|
||||
invalidType = 1;
|
||||
}
|
||||
`);
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
expect(diags[0].messageText)
|
||||
.toBe(`Type 'number' is not assignable to type 'string | boolean'.`);
|
||||
});
|
||||
|
||||
it('should type check using the transform function specified in the `inputs` array', () => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
|
||||
export function toNumber(val: boolean | string) { return 1; }
|
||||
|
||||
@Directive({
|
||||
selector: '[dir]',
|
||||
standalone: true,
|
||||
inputs: [{
|
||||
name: 'val',
|
||||
transform: toNumber
|
||||
}]
|
||||
})
|
||||
export class CoercionDir {
|
||||
val!: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<input dir [val]="invalidType">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {
|
||||
invalidType = 1;
|
||||
}
|
||||
`);
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
expect(diags[0].messageText)
|
||||
.toBe(`Type 'number' is not assignable to type 'string | boolean'.`);
|
||||
});
|
||||
|
||||
it('should type check using the first parameter type of a built-in function', () => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class CoercionDir {
|
||||
@Input({transform: parseInt}) val!: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<input dir [val]="invalidType">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {
|
||||
invalidType = 1;
|
||||
}
|
||||
`);
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
expect(diags[0].messageText).toBe(`Type 'number' is not assignable to type 'string'.`);
|
||||
});
|
||||
|
||||
it('should type check an imported transform function with a complex type', () => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
|
||||
env.write('types.ts', `
|
||||
export class ComplexObjValue {
|
||||
foo: boolean;
|
||||
}
|
||||
|
||||
export interface ComplexObj {
|
||||
value: ComplexObjValue;
|
||||
}
|
||||
`);
|
||||
|
||||
env.write('utils.ts', `
|
||||
import {ComplexObj} from './types';
|
||||
|
||||
export type ToNumberType = string | boolean | ComplexObj;
|
||||
|
||||
export function toNumber(val: ToNumberType) { return 1; }
|
||||
`);
|
||||
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
import {toNumber} from './utils';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class CoercionDir {
|
||||
@Input({transform: toNumber}) val!: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<input dir [val]="invalidType">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {
|
||||
invalidType = {
|
||||
value: {
|
||||
foo: 'hello'
|
||||
}
|
||||
};
|
||||
}
|
||||
`);
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
|
||||
expect(getDiagnosticLines(diags[0])).toEqual([
|
||||
`Type '{ value: { foo: string; }; }' is not assignable to type 'ToNumberType'.`,
|
||||
` Type '{ value: { foo: string; }; }' is not assignable to type 'ComplexObj'.`,
|
||||
` The types of 'value.foo' are incompatible between these types.`,
|
||||
` Type 'string' is not assignable to type 'boolean'.`
|
||||
]);
|
||||
});
|
||||
|
||||
it('should type check an imported transform function with a complex type from an external library',
|
||||
() => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
|
||||
env.write('node_modules/external/index.d.ts', `
|
||||
export class ExternalComplexObjValue {
|
||||
foo: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalComplexObj {
|
||||
value: ExternalComplexObjValue;
|
||||
}
|
||||
|
||||
export type ExternalToNumberType = string | boolean | ExternalComplexObj;
|
||||
|
||||
export declare function externalToNumber(val: ExternalToNumberType): number;
|
||||
`);
|
||||
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
import {externalToNumber} from 'external';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class CoercionDir {
|
||||
@Input({transform: externalToNumber}) val!: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<input dir [val]="invalidType">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {
|
||||
invalidType = {
|
||||
value: {
|
||||
foo: 'hello'
|
||||
}
|
||||
};
|
||||
}
|
||||
`);
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
|
||||
expect(getDiagnosticLines(diags[0])).toEqual([
|
||||
`Type '{ value: { foo: string; }; }' is not assignable to type 'ExternalToNumberType'.`,
|
||||
` Type '{ value: { foo: string; }; }' is not assignable to type 'ExternalComplexObj'.`,
|
||||
` The types of 'value.foo' are incompatible between these types.`,
|
||||
` Type 'string' is not assignable to type 'boolean'.`
|
||||
]);
|
||||
});
|
||||
|
||||
it('should type check an input with a generic transform type', () => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
|
||||
env.write('generics.ts', `
|
||||
export interface GenericWrapper<T> {
|
||||
value: T;
|
||||
}
|
||||
`);
|
||||
|
||||
env.write('types.ts', `
|
||||
export class ExportedClass {
|
||||
foo: boolean;
|
||||
}
|
||||
`);
|
||||
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
import {GenericWrapper} from './generics';
|
||||
import {ExportedClass} from './types';
|
||||
|
||||
export interface LocalInterface {
|
||||
foo: string;
|
||||
}
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class CoercionDir {
|
||||
@Input({transform: (val: GenericWrapper<ExportedClass>) => 1}) importedVal!: number;
|
||||
@Input({transform: (val: GenericWrapper<LocalInterface>) => 1}) localVal!: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<input dir [importedVal]="invalidType" [localVal]="invalidType">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {
|
||||
invalidType = {
|
||||
value: {
|
||||
foo: 1
|
||||
}
|
||||
};
|
||||
}
|
||||
`);
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(2);
|
||||
|
||||
expect(getDiagnosticLines(diags[0])).toEqual([
|
||||
`Type '{ value: { foo: number; }; }' is not assignable to type 'GenericWrapper<ExportedClass>'.`,
|
||||
` The types of 'value.foo' are incompatible between these types.`,
|
||||
` Type 'number' is not assignable to type 'boolean'.`
|
||||
]);
|
||||
|
||||
expect(getDiagnosticLines(diags[1])).toEqual([
|
||||
`Type '{ value: { foo: number; }; }' is not assignable to type 'GenericWrapper<LocalInterface>'.`,
|
||||
` The types of 'value.foo' are incompatible between these types.`,
|
||||
` Type 'number' is not assignable to type 'string'.`
|
||||
]);
|
||||
});
|
||||
|
||||
it('should type check an input with a generic transform union type', () => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
env.write('types.ts', `
|
||||
interface GenericWrapper<T> {
|
||||
value: T;
|
||||
}
|
||||
|
||||
export type CoercionType<T> = boolean | string | GenericWrapper<T>;
|
||||
`);
|
||||
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
import {CoercionType} from './types';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class CoercionDir {
|
||||
@Input({transform: (val: CoercionType<string>) => 1}) val!: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<input dir [val]="invalidType">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {
|
||||
invalidType = {value: 1};
|
||||
}
|
||||
`);
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
|
||||
expect(getDiagnosticLines(diags[0])).toEqual([
|
||||
`Type '{ value: number; }' is not assignable to type 'CoercionType<string>'.`,
|
||||
` Type '{ value: number; }' is not assignable to type 'GenericWrapper<string>'.`,
|
||||
` Types of property 'value' are incompatible.`,
|
||||
` Type 'number' is not assignable to type 'string'.`
|
||||
]);
|
||||
});
|
||||
|
||||
it('should type check an input with a generic transform type from an external library', () => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
env.write('node_modules/external/index.d.ts', `
|
||||
export interface ExternalGenericWrapper<T> {
|
||||
value: T;
|
||||
}
|
||||
|
||||
export declare class ExternalClass {
|
||||
foo: boolean;
|
||||
}
|
||||
`);
|
||||
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
import {ExternalGenericWrapper, ExternalClass} from 'external';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class CoercionDir {
|
||||
@Input({transform: (val: ExternalGenericWrapper<ExternalClass>) => 1}) val!: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<input dir [val]="invalidType">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {
|
||||
invalidType = {
|
||||
value: {
|
||||
foo: 1
|
||||
}
|
||||
};
|
||||
}
|
||||
`);
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
expect(getDiagnosticLines(diags[0])).toEqual([
|
||||
`Type '{ value: { foo: number; }; }' is not assignable to type 'ExternalGenericWrapper<ExternalClass>'.`,
|
||||
` The types of 'value.foo' are incompatible between these types.`,
|
||||
` Type 'number' is not assignable to type 'boolean'.`
|
||||
]);
|
||||
});
|
||||
|
||||
it('should allow any value to be assigned if the transform function has no parameters',
|
||||
() => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class CoercionDir {
|
||||
@Input({transform: () => 1}) val!: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<input dir [val]="invalidType">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {
|
||||
invalidType = {};
|
||||
}
|
||||
`);
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should type check static inputs against the transform function type', () => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
|
||||
export function toNumber(val: number | boolean) { return 1; }
|
||||
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
export class CoercionDir {
|
||||
@Input({transform: toNumber}) val!: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: '<input dir val="test">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {}
|
||||
`);
|
||||
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
expect(diags[0].messageText)
|
||||
.toBe(`Type '"test"' is not assignable to type 'number | boolean'.`);
|
||||
});
|
||||
|
||||
it('should type check inputs with a transform function coming from a host directive', () => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
env.write('host-dir.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
export interface HostDirType {
|
||||
value: number;
|
||||
}
|
||||
|
||||
@Directive({standalone: true})
|
||||
export class HostDir {
|
||||
@Input({transform: (val: HostDirType) => 1}) val!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
import {HostDir} from './host-dir';
|
||||
|
||||
@Directive({
|
||||
selector: '[dir]',
|
||||
standalone: true,
|
||||
hostDirectives: [{
|
||||
directive: HostDir,
|
||||
inputs: ['val']
|
||||
}]
|
||||
})
|
||||
export class CoercionDir {}
|
||||
|
||||
@Component({
|
||||
template: '<input dir [val]="invalidType">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {
|
||||
invalidType = {
|
||||
value: 'hello'
|
||||
};
|
||||
}
|
||||
`);
|
||||
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
expect(getDiagnosticLines(diags[0])).toEqual([
|
||||
`Type '{ value: string; }' is not assignable to type 'HostDirType'.`,
|
||||
` Types of property 'value' are incompatible.`,
|
||||
` Type 'string' is not assignable to type 'number'.`
|
||||
]);
|
||||
});
|
||||
|
||||
it('should type check inputs with a transform inherited from a parent class', () => {
|
||||
env.tsconfig({strictTemplates: true});
|
||||
env.write('host-dir.ts', `
|
||||
import {Directive, Input} from '@angular/core';
|
||||
|
||||
export interface ParentType {
|
||||
value: number;
|
||||
}
|
||||
|
||||
@Directive({standalone: true})
|
||||
export class Parent {
|
||||
@Input({transform: (val: ParentType) => 1}) val!: number;
|
||||
}
|
||||
`);
|
||||
|
||||
env.write('test.ts', `
|
||||
import {Component, Directive, Input} from '@angular/core';
|
||||
import {Parent} from './host-dir';
|
||||
|
||||
@Directive({
|
||||
selector: '[dir]',
|
||||
standalone: true
|
||||
})
|
||||
export class CoercionDir extends Parent {}
|
||||
|
||||
@Component({
|
||||
template: '<input dir [val]="invalidType">',
|
||||
standalone: true,
|
||||
imports: [CoercionDir],
|
||||
})
|
||||
export class FooCmp {
|
||||
invalidType = {
|
||||
value: 'hello'
|
||||
};
|
||||
}
|
||||
`);
|
||||
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
expect(getDiagnosticLines(diags[0])).toEqual([
|
||||
`Type '{ value: string; }' is not assignable to type 'ParentType'.`,
|
||||
` Types of property 'value' are incompatible.`,
|
||||
` Type 'string' is not assignable to type 'number'.`
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restricted inputs', () => {
|
||||
|
|
|
|||
|
|
@ -80,12 +80,14 @@ export type InputMap = {
|
|||
bindingPropertyName: string,
|
||||
classPropertyName: string,
|
||||
required: boolean,
|
||||
transformFunction: InputTransformFunction,
|
||||
};
|
||||
};
|
||||
|
||||
export type Provider = unknown;
|
||||
export type Type = Function;
|
||||
export type OpaqueValue = unknown;
|
||||
export type InputTransformFunction = any;
|
||||
|
||||
export enum FactoryTarget {
|
||||
Directive = 0,
|
||||
|
|
@ -191,7 +193,11 @@ export interface R3ComponentMetadataFacade extends R3DirectiveMetadataFacade {
|
|||
export interface R3DeclareDirectiveFacade {
|
||||
selector?: string;
|
||||
type: Type;
|
||||
inputs?: {[classPropertyName: string]: string|[string, string]};
|
||||
inputs?: {
|
||||
[classPropertyName: string]: string|
|
||||
[bindingPropertyName: string,
|
||||
classPropertyName: string, transformFunction?: InputTransformFunction]
|
||||
};
|
||||
outputs?: {[classPropertyName: string]: string};
|
||||
host?: {
|
||||
attributes?: {[key: string]: OpaqueValue};
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export enum ChangeDetectionStrategy {
|
|||
export interface Input {
|
||||
alias?: string;
|
||||
required?: boolean;
|
||||
transform?: (value: any) => any;
|
||||
}
|
||||
|
||||
export interface Output {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
|
||||
import {CompilerFacade, CoreEnvironment, ExportedCompilerFacade, InputMap, OpaqueValue, R3ComponentMetadataFacade, R3DeclareComponentFacade, R3DeclareDependencyMetadataFacade, R3DeclareDirectiveDependencyFacade, R3DeclareDirectiveFacade, R3DeclareFactoryFacade, R3DeclareInjectableFacade, R3DeclareInjectorFacade, R3DeclareNgModuleFacade, R3DeclarePipeDependencyFacade, R3DeclarePipeFacade, R3DeclareQueryMetadataFacade, R3DependencyMetadataFacade, R3DirectiveMetadataFacade, R3FactoryDefMetadataFacade, R3InjectableMetadataFacade, R3InjectorMetadataFacade, R3NgModuleMetadataFacade, R3PipeMetadataFacade, R3QueryMetadataFacade, R3TemplateDependencyFacade} from './compiler_facade_interface';
|
||||
import {CompilerFacade, CoreEnvironment, ExportedCompilerFacade, InputMap, InputTransformFunction, OpaqueValue, R3ComponentMetadataFacade, R3DeclareComponentFacade, R3DeclareDependencyMetadataFacade, R3DeclareDirectiveDependencyFacade, R3DeclareDirectiveFacade, R3DeclareFactoryFacade, R3DeclareInjectableFacade, R3DeclareInjectorFacade, R3DeclareNgModuleFacade, R3DeclarePipeDependencyFacade, R3DeclarePipeFacade, R3DeclareQueryMetadataFacade, R3DependencyMetadataFacade, R3DirectiveMetadataFacade, R3FactoryDefMetadataFacade, R3InjectableMetadataFacade, R3InjectorMetadataFacade, R3NgModuleMetadataFacade, R3PipeMetadataFacade, R3QueryMetadataFacade, R3TemplateDependencyFacade} from './compiler_facade_interface';
|
||||
import {ConstantPool} from './constant_pool';
|
||||
import {ChangeDetectionStrategy, HostBinding, HostListener, Input, Output, ViewEncapsulation} from './core';
|
||||
import {compileInjectable} from './injectable_compiler_2';
|
||||
|
|
@ -324,7 +324,9 @@ function convertDirectiveFacadeToMetadata(facade: R3DirectiveMetadataFacade): R3
|
|||
inputsFromType[field] = {
|
||||
bindingPropertyName: ann.alias || field,
|
||||
classPropertyName: field,
|
||||
required: ann.required || false
|
||||
required: ann.required || false,
|
||||
// TODO(crisbeto): resolve transform function reference here.
|
||||
transformFunction: null,
|
||||
};
|
||||
} else if (isOutput(ann)) {
|
||||
outputsFromType[field] = ann.alias || field;
|
||||
|
|
@ -646,26 +648,45 @@ function isOutput(value: any): value is Output {
|
|||
return value.ngMetadataName === 'Output';
|
||||
}
|
||||
|
||||
function inputsMappingToInputMetadata(inputs: Record<string, string|[string, string]>) {
|
||||
function inputsMappingToInputMetadata(
|
||||
inputs: Record<string, string|[string, string, InputTransformFunction?]>) {
|
||||
return Object.keys(inputs).reduce<InputMap>((result, key) => {
|
||||
const value = inputs[key];
|
||||
result[key] = typeof value === 'string' ?
|
||||
{bindingPropertyName: value, classPropertyName: value, required: false} :
|
||||
{bindingPropertyName: value[0], classPropertyName: value[1], required: false};
|
||||
|
||||
// TODO(crisbeto): resolve transform function reference here.
|
||||
if (typeof value === 'string') {
|
||||
result[key] = {
|
||||
bindingPropertyName: value,
|
||||
classPropertyName: value,
|
||||
required: false,
|
||||
transformFunction: null
|
||||
};
|
||||
} else {
|
||||
result[key] = {
|
||||
bindingPropertyName: value[0],
|
||||
classPropertyName: value[1],
|
||||
required: false,
|
||||
transformFunction: null
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function parseInputsArray(values: (string|{name: string, alias?: string, required?: boolean})[]) {
|
||||
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};
|
||||
results[classPropertyName] =
|
||||
{bindingPropertyName, classPropertyName, required: false, transformFunction: null};
|
||||
} else {
|
||||
results[value.name] = {
|
||||
bindingPropertyName: value.alias || value.name,
|
||||
classPropertyName: value.name,
|
||||
required: value.required || false
|
||||
required: value.required || false,
|
||||
transformFunction: null
|
||||
};
|
||||
}
|
||||
return results;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@ export interface R3DeclareDirectiveMetadata extends R3PartialDeclaration {
|
|||
* A mapping of inputs from class property names to binding property names, or to a tuple of
|
||||
* binding property name and class property name if the names are different.
|
||||
*/
|
||||
inputs?: {[classPropertyName: string]: string|[string, string]};
|
||||
inputs?: {
|
||||
[classPropertyName: string]: string|
|
||||
[bindingPropertyName: string, classPropertyName: string, transformFunction?: o.Expression]
|
||||
};
|
||||
|
||||
/**
|
||||
* A mapping of outputs from class property names to binding property names.
|
||||
|
|
|
|||
|
|
@ -327,6 +327,9 @@ export class Identifiers {
|
|||
static HostDirectivesFeature:
|
||||
o.ExternalReference = {name: 'ɵɵHostDirectivesFeature', moduleName: CORE};
|
||||
|
||||
static InputTransformsFeatureFeature:
|
||||
o.ExternalReference = {name: 'ɵɵInputTransformsFeature', moduleName: CORE};
|
||||
|
||||
static listener: o.ExternalReference = {name: 'ɵɵlistener', moduleName: CORE};
|
||||
|
||||
static getInheritedFactory: o.ExternalReference = {
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ export interface R3InputMetadata {
|
|||
classPropertyName: string;
|
||||
bindingPropertyName: string;
|
||||
required: boolean;
|
||||
transformFunction: o.Expression|null;
|
||||
}
|
||||
|
||||
export enum R3TemplateDependencyKind {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,8 @@ function addFeatures(
|
|||
|
||||
const providers = meta.providers;
|
||||
const viewProviders = (meta as R3ComponentMetadata<R3TemplateDependency>).viewProviders;
|
||||
const inputKeys = Object.keys(meta.inputs);
|
||||
|
||||
if (providers || viewProviders) {
|
||||
const args = [providers || new o.LiteralArrayExpr([])];
|
||||
if (viewProviders) {
|
||||
|
|
@ -127,6 +129,12 @@ 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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ export function conditionallyCreateDirectiveBindingLiteral(
|
|||
map: Record<string, string|{
|
||||
classPropertyName: string;
|
||||
bindingPropertyName: string;
|
||||
transformFunction: o.Expression|null;
|
||||
}>, keepDeclared?: boolean): o.Expression|null {
|
||||
const keys = Object.getOwnPropertyNames(map);
|
||||
|
||||
|
|
@ -178,26 +179,37 @@ export function conditionallyCreateDirectiveBindingLiteral(
|
|||
let declaredName: string;
|
||||
let publicName: string;
|
||||
let minifiedName: string;
|
||||
let needsDeclaredName: boolean;
|
||||
let expressionValue: o.Expression;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// canonical syntax: `dirProp: publicProp`
|
||||
declaredName = key;
|
||||
minifiedName = key;
|
||||
publicName = value;
|
||||
needsDeclaredName = false;
|
||||
expressionValue = asLiteral(publicName);
|
||||
} else {
|
||||
minifiedName = key;
|
||||
declaredName = value.classPropertyName;
|
||||
publicName = value.bindingPropertyName;
|
||||
needsDeclaredName = publicName !== declaredName;
|
||||
|
||||
if (keepDeclared && (publicName !== declaredName || value.transformFunction != null)) {
|
||||
const expressionKeys = [asLiteral(publicName), asLiteral(declaredName)];
|
||||
|
||||
if (value.transformFunction != null) {
|
||||
expressionKeys.push(value.transformFunction);
|
||||
}
|
||||
|
||||
expressionValue = o.literalArr(expressionKeys);
|
||||
} else {
|
||||
expressionValue = asLiteral(publicName);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: minifiedName,
|
||||
// put quotes around keys that contain potentially unsafe characters
|
||||
quoted: UNSAFE_OBJECT_KEY_NAME_REGEXP.test(minifiedName),
|
||||
value: (keepDeclared && needsDeclaredName) ?
|
||||
o.literalArr([asLiteral(publicName), asLiteral(declaredName)]) :
|
||||
asLiteral(publicName)
|
||||
value: expressionValue,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,12 +80,14 @@ export type InputMap = {
|
|||
bindingPropertyName: string,
|
||||
classPropertyName: string,
|
||||
required: boolean,
|
||||
transformFunction: InputTransformFunction,
|
||||
};
|
||||
};
|
||||
|
||||
export type Provider = unknown;
|
||||
export type Type = Function;
|
||||
export type OpaqueValue = unknown;
|
||||
export type InputTransformFunction = any;
|
||||
|
||||
export enum FactoryTarget {
|
||||
Directive = 0,
|
||||
|
|
@ -191,7 +193,11 @@ export interface R3ComponentMetadataFacade extends R3DirectiveMetadataFacade {
|
|||
export interface R3DeclareDirectiveFacade {
|
||||
selector?: string;
|
||||
type: Type;
|
||||
inputs?: {[classPropertyName: string]: string|[string, string]};
|
||||
inputs?: {
|
||||
[classPropertyName: string]: string|
|
||||
[bindingPropertyName: string,
|
||||
classPropertyName: string, transformFunction?: InputTransformFunction]
|
||||
};
|
||||
outputs?: {[classPropertyName: string]: string};
|
||||
host?: {
|
||||
attributes?: {[key: string]: OpaqueValue};
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export {
|
|||
ɵɵi18nPostprocess,
|
||||
ɵɵi18nStart,
|
||||
ɵɵInheritDefinitionFeature,
|
||||
ɵɵInputTransformsFeature,
|
||||
ɵɵinjectAttribute,
|
||||
ɵɵInjectorDeclaration,
|
||||
ɵɵinvalidFactory,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* 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 {ComponentDef, DirectiveDef} from '../interfaces/definition';
|
||||
|
||||
// TODO(crisbeto): move input transforms runtime functionality here.
|
||||
/**
|
||||
* @codeGenApi
|
||||
*/
|
||||
export function ɵɵInputTransformsFeature(definition: DirectiveDef<any>|ComponentDef<any>): void {}
|
||||
|
|
@ -10,6 +10,7 @@ import {ɵɵdefineComponent, ɵɵdefineDirective, ɵɵdefineNgModule, ɵɵdefine
|
|||
import {ɵɵCopyDefinitionFeature} from './features/copy_definition_feature';
|
||||
import {ɵɵHostDirectivesFeature} from './features/host_directives_feature';
|
||||
import {ɵɵInheritDefinitionFeature} from './features/inherit_definition_feature';
|
||||
import {ɵɵInputTransformsFeature} from './features/input_transforms_feature';
|
||||
import {ɵɵNgOnChangesFeature} from './features/ng_onchanges_feature';
|
||||
import {ɵɵProvidersFeature} from './features/providers_feature';
|
||||
import {ɵɵStandaloneFeature} from './features/standalone_feature';
|
||||
|
|
@ -206,6 +207,7 @@ export {
|
|||
ɵɵHostDirectivesFeature,
|
||||
ɵɵInheritDefinitionFeature,
|
||||
ɵɵInjectorDeclaration,
|
||||
ɵɵInputTransformsFeature,
|
||||
ɵɵNgModuleDeclaration,
|
||||
ɵɵNgOnChangesFeature,
|
||||
ɵɵPipeDeclaration,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export const angularCoreEnv: {[name: string]: Function} =
|
|||
'ɵɵProvidersFeature': r3.ɵɵProvidersFeature,
|
||||
'ɵɵCopyDefinitionFeature': r3.ɵɵCopyDefinitionFeature,
|
||||
'ɵɵInheritDefinitionFeature': r3.ɵɵInheritDefinitionFeature,
|
||||
'ɵɵInputTransformsFeature': r3.ɵɵInputTransformsFeature,
|
||||
'ɵɵStandaloneFeature': r3.ɵɵStandaloneFeature,
|
||||
'ɵɵnextContext': r3.ɵɵnextContext,
|
||||
'ɵɵnamespaceHTML': r3.ɵɵnamespaceHTML,
|
||||
|
|
|
|||
Loading…
Reference in a new issue