10000 feat(compiler): add support for shorthand property declarations in te… · angular/angular@9847d6b · GitHub
[go: up one dir, main page]

Skip to content

Commit 9847d6b

Browse files
committed
feat(compiler): add support for shorthand property declarations in templates
Adds support for shorthand property declarations inside Angular templates. E.g. doing `{foo, bar}` instead of `{foo: foo, bar: bar}`. Fixes #10277.
1 parent ed4919e commit 9847d6b

File tree

17 files changed

+390
-10
lines changed

17 files changed

+390
-10
lines changed

packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts

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

9-
import {AST, ASTWithSource, BindingPipe, MethodCall, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
9+
import {AST, ASTWithSource, BindingPipe, MethodCall, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
1010
import * as ts from 'typescript';
1111

1212
import {AbsoluteFsPath} from '../../file_system';
@@ -482,8 +482,20 @@ export class SymbolBuilder {
482482
expression.nameSpan :
483483
expression.sourceSpan;
484484

485-
let node = findFirstMatchingNode(
486-
this.typeCheckBlock, {withSpan, filter: (n: ts.Node): n is ts.Node => true});
485+
let node: ts.Node|null = null;
486+
487+
// Property reads in templates usually map to a `PropertyAccessExpression`
488+
// (e.g. `ctx.foo`) so try looking for one first.
489+
if (expression instanceof PropertyRead) {
490+
node = findFirstMatchingNode(
491+
this.typeCheckBlock, {withSpan, filter: ts.isPropertyAccessExpression});
492+
}
493+
494+
// Otherwise fall back to searching for any AST node.
495+
if (node === null) {
496+
node = findFirstMatchingNode(this.typeCheckBlock, {withSpan, filter: anyNodeFilter});
497+
}
498+
487499
if (node === null) {
488500
return null;
489501
}
@@ -560,3 +572,8 @@ export class SymbolBuilder {
560572
}
561573
}
562574
}
575+
576+
/** Filter predicate function that matches any AST node. */
577+
function anyNodeFilter(n: ts.Node): n is ts.Node {
578+
return true;
579+
}

packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,50 @@ class TestComponent {
462462
`TestComponent.html(4, 18): Property 'heihgt' does not exist on type 'TestComponent'. Did you mean 'height'?`,
463463
]);
464464
});
465+
466+
it('works for shorthand property declarations', () => {
467+
const messages = diagnose(
468+
`<div dir [input]="{a, b: 2}"></div>`, `
469+
class Dir {
470+
input: {a: string, b: number};
471+
}
472+
class TestComponent {
473+
a: number;
474+
}`,
475+
[{
476+
type: 'directive',
477+
name: 'Dir',
478+
selector: '[dir]',
479+
exportAs: ['dir'],
480+
inputs: {input: 'input'},
481+
}]);
482+
483+
expect(messages).toEqual(
484+
[`TestComponent.html(1, 20): Type 'number' is not assignable to type 'string'.`]);
485+
});
486+
487+
it('works for shorthand property declarations referring to template variables', () => {
488+
const messages = diagnose(
489+
`
490+
<span #span></span>
491+
<div dir [input]="{span, b: 2}"></div>
492+
`,
493+
`
494+
class Dir {
495+
input: {span: string, b: number};
496+
}
497+
class TestComponent {}`,
498+
[{
499+
type: 'directive',
500+
name: 'Dir',
501+
selector: '[dir]',
502+
exportAs: ['dir'],
503+
inputs: {input: 'input'},
504+
}]);
505+
506+
expect(messages).toEqual(
507+
[`TestComponent.html(3, 30): Type 'HTMLElement' is not assignable to type 'string'.`]);
508+
});
465509
});
466510

467511
describe('method call spans', () => {

packages/compiler-cli/src/ngtsc/typecheck/test/span_comments_spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ describe('type check blocks diagnostics', () => {
4242
'(ctx).m /*3,4*/({ "foo": ((ctx).a /*11,12*/) /*11,12*/, "bar": ((ctx).b /*19,20*/) /*19,20*/ } /*5,21*/) /*3,22*/');
4343
});
4444

45+
it('should annotate literal map expressions with shorthand declarations', () => {
46+
// The additional method call is present to avoid that the object literal is emitted as
47+
// statement, which would wrap it into parenthesis that clutter the expected output.
48+
const TEMPLATE = '{{ m({a, b}) }}';
49+
expect(tcbWithSpans(TEMPLATE))
50+
.toContain(
51+
'((ctx).m /*3,4*/({ "a": ((ctx).a /*6,7*/) /*6,7*/, "b": ((ctx).b /*9,10*/) /*9,10*/ } /*5,11*/) /*3,12*/)');
52+
});
53+
4554
it('should annotate literal array expressions', () => {
4655
const TEMPLATE = '{{ [a, b] }}';
4756
expect(tcbWithSpans(TEMPLATE))

packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -668,12 +668,16 @@ runInEachFileSystem(() => {
668668
const fileName = absoluteFrom('/main.ts');
669669
const templateString = `
670670
{{ [1, 2, 3] }}
671-
{{ { hello: "world" } }}`;
671+
{{ { hello: "world" } }}
672+
{{ { foo } }}`;
672673
const testValues = setup([
673674
{
674675
fileName,
675676
templates: {'Cmp': templateString},
676-
source: `export class Cmp {}`,
677+
source: `
678+
type Foo {name: string;}
679+
export class Cmp {foo: Foo;}
680+
`,
677681
},
678682
]);
679683
templateTypeChecker = testValues.templateTypeChecker;
@@ -701,6 +705,15 @@ runInEachFileSystem(() => {
701705
expect(program.getTypeChecker().typeToString(symbol.tsType))
702706
.toEqual('{ hello: string; }');
703707
});
708+
709+
it('literal map shorthand property', () => {
710+
const shorthandProp =
711+
(interpolation.expressions[2] as LiteralMap).values[0] as PropertyRead;
712+
const symbol = templateTypeChecker.getSymbolOfNode(shorthandProp, cmp)!;
713+
assertExpressionSymbol(symbol);
714+
expect(program.getTypeChecker().symbolToString(symbol.tsSymbol!)).toEqual('foo');
715+
expect(program.getTypeChecker().typeToString(symbol.tsType)).toEqual('Foo');
716+
});
704717
});
705718

706719
describe('pipes', () => {

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_template/GOLDEN_PARTIAL.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,3 +871,54 @@ export declare class MyModule {
871871
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
872872
}
873873

874+
/****************************************************************************************************
875+
* PARTIAL FILE: shorthand_property_declaration.js
876+
****************************************************************************************************/
877+
import { Component, NgModule } from '@angular/core';
878+
import * as i0 from "@angular/core";
879+
export class MyComponent {
880+
constructor() {
881+
this.a = 1;
882+
this.c = 3;
883+
}
884+
_handleClick(_value) { }
885+
}
886+
MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
887+
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, selector: "ng-component", ngImport: i0, template: `
888+
<div (click)="_handleClick({a, b: 2, c})"></div>
889+
`, isInline: true });
890+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
891+
type: Component,
892+
args: [{
893+
template: `
894+
<div (click)="_handleClick({a, b: 2, c})"></div>
895+
`
896+
}]
897+
}] });
898+
export class MyModule {
899+
}
900+
MyModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
901+
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyComponent] });
902+
MyModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule });
903+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, decorators: [{
904+
type: NgModule,
905+
args: [{ declarations: [MyComponent] }]
906+
}] });
907+
908+
/****************************************************************************************************
909+
* PARTIAL FILE: shorthand_property_declaration.d.ts
910+
****************************************************************************************************/
911+
import * as i0 from "@angular/core";
912+
export declare class MyComponent {
913+
a: number;
914+
c: number;
915+
_handleClick(_value: any): void;
916+
static ɵfac: i0.ɵɵFactoryDeclaration<MyComponent, never>;
917+
static ɵcmp: i0.ɵɵComponentDeclaration<MyComponent, "ng-component", never, {}, {}, never, never>;
918+
}
919+
export declare class MyModule {
920+
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
921+
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyComponent], never, never>;
922+
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
923+
}
924+

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_template/TEST_CASES.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,23 @@
269269
"failureMessage": "Incorrect template"
270270
}
271271
]
272+
},
273+
{
274+
"description": "should handle shorthand property declarations in templates",
275+
"inputFiles": [
276+
"shorthand_property_declaration.ts"
277+
],
278+
"expectations": [
279+
{
280+
"files": [
281+
{
282+
"expected": "shorthand_property_declaration_template.js",
283+
"generated": "shorthand_property_declaration.js"
284+
}
285+
],
286+
"failureMessage": "Incorrect template"
287+
}
288+
]
272289
}
273290
]
274291
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {Component, NgModule} from '@angular/core';
2+
3+
@Component({
4+
template: `
5+
<div (click)="_handleClick({a, b: 2, c})"></div>
6+
`
7+
})
8+
export class MyComponent {
9+
a = 1;
10+
c = 3;
11+
_handleClick(_value: any) {}
12+
}
13+
14+
@NgModule({declarations: [MyComponent]})
15+
export class MyModule {
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
template: function MyComponent_Template(rf, ctx) {
2+
if (rf & 1) {
3+
4+
i0.ɵɵlistener("click", function MyComponent_Template_div_click_0_listener() {
5+
return ctx._handleClick({
6+
a: ctx.a,
7+
b: 2,
8+
c: ctx.c
9+
});
10+
});
11+
12+
}
13+
}

packages/compiler/src/expression_parser/parser.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -929,11 +929,23 @@ export class _ParseAST {
929929
if (!this.consumeOptionalCharacter(chars.$RBRACE)) {
930930
this.rbracesExpected++;
931931
do {
932+
const keyStart = this.inputIndex;
932933
const quoted = this.next.isString();
933934
const key = this.expectIdentifierOrKeywordOrString();
934935
keys.push({key, quoted});
935-
this.expectCharacter(chars.$COLON);
936-
values.push(this.parsePipe());
936+
937+
// Properties with quoted keys can't use the shorthand syntax.
938+
if (quoted) {
939+
this.expectCharacter(chars.$COLON);
940+
values.push(this.parsePipe());
941+
} else if (this.consumeOptionalCharacter(chars.$COLON)) {
942+
values.push(this.parsePipe());
943+
} else {
944+
const span = this.span(keyStart);
945+
const sourceSpan = this.sourceSpan(keyStart);
946+
values.push(new PropertyRead(
947+
span, sourceSpan, sourceSpan, new ImplicitReceiver(span, sourceSpan), key));
948+
}
937949
} while (this.consumeOptionalCharacter(chars.$COMMA));
938950
this.rbracesExpected--;
939951
this.expectCharacter(chars.$RBRACE);

packages/compiler/test/expression_parser/parser_spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,23 @@ describe('parser', () => {
122122
expectActionError('{1234:0}', 'expected identifier, keyword, or string');
123123
expectActionError('{#myField:0}', 'expected identifier, keyword or string');
124124
});
125+
126+
it('should parse property shorthand declarations', () => {
127+
checkAction('{a, b, c}', '{a: a, b: b, c: c}');
128+
checkAction('{a: 1, b}', '{a: 1, b: b}');
129+
checkAction('{a, b: 1}', '{a: a, b: 1}');
130+
checkAction('{a: 1, b, c: 2}', '{a: 1, b: b, c: 2}');
131+
});
132+
133+
it('should not allow property shorthand declaration on quoted properties', () => {
134+
expectActionError('{"a-b"}', 'expected : at column 7');
135+
});
136+
137+
it('should not infer invalid identifiers as shorthand property declarations', () => {
138+
expectActionError('{a.b}', 'expected } at column 3');
139+
expectActionError('{a["b"]}', 'expected } at column 3');
140+
expectActionError('{1234}', ' expected identifier, keyword, or string at column 2');
141+
});
125142
});
126143

127144
describe('member access', () => {

packages/compiler/test/render3/r3_ast_absolute_span_spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,4 +360,15 @@ describe('expression AST absolute source spans', () => {
360360
expect(spans).toContain(['nestedPlaceholder', new AbsoluteSourceSpan(89, 106)]);
361361
});
362362
});
363+
364+
describe('object literal', () => {
365+
it('is correct for object literals with shorthand property declarations', () => {
366+
const spans =
367+
humanizeExpressionSource(parse('<div (click)="test({a: 1, b, c: 3, foo})"></div>').nodes);
368+
369+
expect(spans).toContain(['{a: 1, b: b, c: 3, foo: foo}', new AbsoluteSourceSpan(19, 39)]);
370+
expect(spans).toContain(['b', new AbsoluteSourceSpan(26, 27)]);
371+
expect(spans).toContain(['foo', new AbsoluteSourceSpan(35, 38)]);
372+
});
373+
});
363374
});

packages/core/test/acceptance/integration_spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2041,6 +2041,26 @@ describe('acceptance integration tests', () => {
20412041
expect(fixture.nativeElement.innerHTML).toContain('<text>Hello</text>');
20422042
});
20432043

2044+
it('should handle shorthand property declarations in templates', () => {
2045+
@Directive({selector: '[my-dir]'})
2046+
class Dir {
2047+
@Input('my-dir') value: any;
2048+
}
2049+
2050+
@Component({template: `<div [my-dir]="{a, b: 2, someProp}"></div>`})
2051+
class App {
2052+
@ViewChild(Dir) directive!: Dir;
2053+
a = 1;
2054+
someProp = 3;
2055+
}
2056+
2057+
TestBed.configureTestingModule({declarations: [App, Dir]});
2058+
const fixture = TestBed.createComponent(App);
2059+
fixture.detectChanges();
2060+
2061+
expect(fixture.componentInstance.directive.value).toEqual({a: 1, b: 2, someProp: 3});
2062+
});
2063+
20442064
describe('tView.firstUpdatePass', () => {
20452065
function isFirstUpdatePass() {
20462066
const lView = getLView();

packages/language-service/ivy/test/legacy/definitions_spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,32 @@ describe('definitions', () => {
458458
const definitionAndBoundSpan = ngLS.getDefinitionAndBoundSpan(APP_COMPONENT, position);
459459
expect(definitionAndBoundSpan).toBeUndefined();
460460
});
461+
462+
it('should work for object literals with shorthand declarations in an action', () => {
463+
const definitions = getDefinitionsAndAssertBoundSpan({
464+
templateOverride: `<div (click)="setHero({na¦me, id: 1})"></div>`,
465+
expectedSpanText: 'name',
466+
});
467+
expect(definitions!.length).toEqual(1);
468+
469+
const [def] = definitions;
470+
expect(def.textSpan).toEqual('name');
471+
expect(def.fileName).toContain('/app/app.component.ts');
472+
expect(def.contextSpan).toContain(`name = 'Frodo';`);
473+
});
474+
475+
it('should work for object literals with shorthand declarations in a data binding', () => {
476+
const definitions = getDefinitionsAndAssertBoundSpan({
477+
templateOverride: `{{ {na¦me} }}`,
478+
expectedSpanText: 'name',
479+
});
480+
expect(definitions!.length).toEqual(1);
481+
482+
const [def] = definitions;
483+
expect(def.textSpan).toEqual('name');
484+
expect(def.fileName).toContain('/app/app.component.ts');
485+
expect(def.contextSpan).toContain(`name = 'Frodo';`);
486+
});
461487
});
462488 3E7E

463489
describe('external resources', () => {

0 commit comments

Comments
 (0)
0