Skip to content

Commit d64864e

Browse files
crisbetoAndrewKushnir
authored andcommitted
fix(elements): support input transform functions (#50713)
Fixes that `@angular/elements` didn't support input transform functions. Fixes #50708. PR Close #50713
1 parent 29340a0 commit d64864e

6 files changed

Lines changed: 59 additions & 25 deletions

File tree

goldens/public-api/elements/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export interface NgElementStrategy {
4545
// (undocumented)
4646
getInputValue(propName: string): any;
4747
// (undocumented)
48-
setInputValue(propName: string, value: string): void;
48+
setInputValue(propName: string, value: string, transform?: (value: any) => any): void;
4949
}
5050

5151
// @public

packages/elements/src/component-factory-strategy.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,12 @@ export class ComponentNgElementStrategy implements NgElementStrategy {
154154
* Sets the input value for the property. If the component has not yet been created, the value is
155155
* cached and set when the component is created.
156156
*/
157-
setInputValue(property: string, value: any): void {
157+
setInputValue(property: string, value: any, transform?: (value: any) => any): void {
158158
this.runInZone(() => {
159+
if (transform) {
160+
value = transform.call(this.componentRef?.instance, value);
161+
}
162+
159163
if (this.componentRef === null) {
160164
this.initialInputValues.set(property, value);
161165
return;
@@ -205,11 +209,11 @@ export class ComponentNgElementStrategy implements NgElementStrategy {
205209

206210
/** Set any stored initial inputs on the component's properties. */
207211
protected initializeInputs(): void {
208-
this.componentFactory.inputs.forEach(({propName}) => {
212+
this.componentFactory.inputs.forEach(({propName, transform}) => {
209213
if (this.initialInputValues.has(propName)) {
210214
// Call `setInputValue()` now that the component has been instantiated to update its
211215
// properties and fire `ngOnChanges()`.
212-
this.setInputValue(propName, this.initialInputValues.get(propName));
216+
this.setInputValue(propName, this.initialInputValues.get(propName), transform);
213217
}
214218
});
215219

packages/elements/src/create-custom-element.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export function createCustomElement<P>(
148148

149149
// Re-apply pre-existing input values (set as properties on the element) through the
150150
// strategy.
151-
inputs.forEach(({propName}) => {
151+
inputs.forEach(({propName, transform}) => {
152152
if (!this.hasOwnProperty(propName)) {
153153
// No pre-existing value for `propName`.
154154
return;
@@ -157,7 +157,7 @@ export function createCustomElement<P>(
157157
// Delete the property from the instance and re-apply it through the strategy.
158158
const value = (this as any)[propName];
159159
delete (this as any)[propName];
160-
strategy.setInputValue(propName, value);
160+
strategy.setInputValue(propName, value, transform);
161161
});
162162
}
163163

@@ -172,8 +172,8 @@ export function createCustomElement<P>(
172172

173173
override attributeChangedCallback(
174174
attrName: string, oldValue: string|null, newValue: string, namespace?: string): void {
175-
const propName = attributeToPropertyInputs[attrName]!;
176-
this.ngElementStrategy.setInputValue(propName, newValue);
175+
const [propName, transform] = attributeToPropertyInputs[attrName]!;
176+
this.ngElementStrategy.setInputValue(propName, newValue, transform);
177177
}
178178

179179
override connectedCallback(): void {
@@ -225,13 +225,13 @@ export function createCustomElement<P>(
225225
}
226226

227227
// Add getters and setters to the prototype for each property input.
228-
inputs.forEach(({propName}) => {
228+
inputs.forEach(({propName, transform}) => {
229229
Object.defineProperty(NgElementImpl.prototype, propName, {
230230
get(): any {
231231
return this.ngElementStrategy.getInputValue(propName);
232232
},
233233
set(newValue: any): void {
234-
this.ngElementStrategy.setInputValue(propName, newValue);
234+
this.ngElementStrategy.setInputValue(propName, newValue, transform);
235235
},
236236
configurable: true,
237237
enumerable: true,

packages/elements/src/element-strategy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export interface NgElementStrategy {
3030
connect(element: HTMLElement): void;
3131
disconnect(): void;
3232
getInputValue(propName: string): any;
33-
setInputValue(propName: string, value: string): void;
33+
setInputValue(propName: string, value: string, transform?: (value: any) => any): void;
3434
}
3535

3636
/**

packages/elements/src/utils.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,11 @@ export function strictEquals(value1: any, value2: any): boolean {
9898

9999
/** Gets a map of default set of attributes to observe and the properties they affect. */
100100
export function getDefaultAttributeToPropertyInputs(
101-
inputs: {propName: string, templateName: string}[]) {
102-
const attributeToPropertyInputs: {[key: string]: string} = {};
103-
inputs.forEach(({propName, templateName}) => {
104-
attributeToPropertyInputs[camelToDashCase(templateName)] = propName;
101+
inputs: {propName: string, templateName: string, transform?: (value: any) => any}[]) {
102+
const attributeToPropertyInputs:
103+
{[key: string]: [propName: string, transform: ((value: any) => any)|undefined]} = {};
104+
inputs.forEach(({propName, templateName, transform}) => {
105+
attributeToPropertyInputs[camelToDashCase(templateName)] = [propName, transform];
105106
});
106107

107108
return attributeToPropertyInputs;
@@ -111,9 +112,12 @@ export function getDefaultAttributeToPropertyInputs(
111112
* Gets a component's set of inputs. Uses the injector to get the component factory where the inputs
112113
* are defined.
113114
*/
114-
export function getComponentInputs(
115-
component: Type<any>, injector: Injector): {propName: string, templateName: string}[] {
116-
const componentFactoryResolver: ComponentFactoryResolver = injector.get(ComponentFactoryResolver);
115+
export function getComponentInputs(component: Type<any>, injector: Injector): {
116+
propName: string,
117+
templateName: string,
118+
transform?: (value: any) => any,
119+
}[] {
120+
const componentFactoryResolver = injector.get(ComponentFactoryResolver);
117121
const componentFactory = componentFactoryResolver.resolveComponentFactory(component);
118122
return componentFactory.inputs;
119123
}

packages/elements/test/create-custom-element_spec.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import {Subject} from 'rxjs';
1414
import {createCustomElement, NgElementConstructor} from '../src/create-custom-element';
1515
import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from '../src/element-strategy';
1616

17-
type WithFooBar = {
18-
fooFoo: string,
19-
barBar: string
20-
};
17+
interface WithFooBar {
18+
fooFoo: string;
19+
barBar: string;
20+
fooTransformed: unknown;
21+
}
2122

2223
describe('createCustomElement', () => {
2324
let selectorUid = 0;
@@ -52,18 +53,20 @@ describe('createCustomElement', () => {
5253
});
5354

5455
it('should use a default strategy for converting component inputs', () => {
55-
expect(NgElementCtor.observedAttributes).toEqual(['foo-foo', 'barbar']);
56+
expect(NgElementCtor.observedAttributes).toEqual(['foo-foo', 'barbar', 'foo-transformed']);
5657
});
5758

5859
it('should send input values from attributes when connected', () => {
5960
const element = new NgElementCtor(injector);
6061
element.setAttribute('foo-foo', 'value-foo-foo');
6162
element.setAttribute('barbar', 'value-barbar');
63+
element.setAttribute('foo-transformed', 'truthy');
6264
element.connectedCallback();
6365
expect(strategy.connectedElement).toBe(element);
6466

6567
expect(strategy.getInputValue('fooFoo')).toBe('value-foo-foo');
6668
expect(strategy.getInputValue('barBar')).toBe('value-barbar');
69+
expect(strategy.getInputValue('fooTransformed')).toBe(true);
6770
});
6871

6972
it('should work even if the constructor is not called (due to polyfill)', () => {
@@ -78,11 +81,13 @@ describe('createCustomElement', () => {
7881

7982
element.setAttribute('foo-foo', 'value-foo-foo');
8083
element.setAttribute('barbar', 'value-barbar');
84+
element.setAttribute('foo-transformed', 'truthy');
8185
element.connectedCallback();
8286

8387
expect(strategy.connectedElement).toBe(element);
8488
expect(strategy.getInputValue('fooFoo')).toBe('value-foo-foo');
8589
expect(strategy.getInputValue('barBar')).toBe('value-barbar');
90+
expect(strategy.getInputValue('fooTransformed')).toBe(true);
8691
});
8792

8893
it('should listen to output events after connected', () => {
@@ -154,9 +159,11 @@ describe('createCustomElement', () => {
154159
const element = new NgElementCtor(injector);
155160
element.fooFoo = 'foo-foo-value';
156161
element.barBar = 'barBar-value';
162+
element.fooTransformed = 'truthy';
157163

158164
expect(strategy.inputs.get('fooFoo')).toBe('foo-foo-value');
159165
expect(strategy.inputs.get('barBar')).toBe('barBar-value');
166+
expect(strategy.inputs.get('fooTransformed')).toBe(true);
160167
});
161168

162169
it('should properly handle getting/setting properties on the element even if the constructor is not called',
@@ -170,9 +177,11 @@ describe('createCustomElement', () => {
170177

171178
element.fooFoo = 'foo-foo-value';
172179
element.barBar = 'barBar-value';
180+
element.fooTransformed = 'truthy';
173181

174182
expect(strategy.inputs.get('fooFoo')).toBe('foo-foo-value');
175183
expect(strategy.inputs.get('barBar')).toBe('barBar-value');
184+
expect(strategy.inputs.get('fooTransformed')).toBe(true);
176185
});
177186

178187
it('should capture properties set before upgrading the element', () => {
@@ -181,18 +190,22 @@ describe('createCustomElement', () => {
181190
const element = Object.assign(document.createElement(selector), {
182191
fooFoo: 'foo-prop-value',
183192
barBar: 'bar-prop-value',
193+
fooTransformed: 'truthy' as unknown,
184194
});
185195
expect(element.fooFoo).toBe('foo-prop-value');
186196
expect(element.barBar).toBe('bar-prop-value');
197+
expect(element.fooTransformed).toBe('truthy');
187198

188199
// Upgrade the element to a Custom Element and insert it into the DOM.
189200
customElements.define(selector, ElementCtor);
190201
testContainer.appendChild(element);
191202
expect(element.fooFoo).toBe('foo-prop-value');
192203
expect(element.barBar).toBe('bar-prop-value');
204+
expect(element.fooTransformed).toBe(true);
193205

194206
expect(strategy.inputs.get('fooFoo')).toBe('foo-prop-value');
195207
expect(strategy.inputs.get('barBar')).toBe('bar-prop-value');
208+
expect(strategy.inputs.get('fooTransformed')).toBe(true);
196209
});
197210

198211
it('should capture properties set after upgrading the element but before inserting it into the DOM',
@@ -202,25 +215,31 @@ describe('createCustomElement', () => {
202215
const element = Object.assign(document.createElement(selector), {
203216
fooFoo: 'foo-prop-value',
204217
barBar: 'bar-prop-value',
218+
fooTransformed: 'truthy' as unknown,
205219
});
206220
expect(element.fooFoo).toBe('foo-prop-value');
207221
expect(element.barBar).toBe('bar-prop-value');
222+
expect(element.fooTransformed).toBe('truthy');
208223

209224
// Upgrade the element to a Custom Element (without inserting it into the DOM) and update a
210225
// property.
211226
customElements.define(selector, ElementCtor);
212227
customElements.upgrade(element);
213228
element.barBar = 'bar-prop-value-2';
229+
element.fooTransformed = '';
214230
expect(element.fooFoo).toBe('foo-prop-value');
215231
expect(element.barBar).toBe('bar-prop-value-2');
232+
expect(element.fooTransformed).toBe('');
216233

217234
// Insert the element into the DOM.
218235
testContainer.appendChild(element);
219236
expect(element.fooFoo).toBe('foo-prop-value');
220237
expect(element.barBar).toBe('bar-prop-value-2');
238+
expect(element.fooTransformed).toBe(false);
221239

222240
expect(strategy.inputs.get('fooFoo')).toBe('foo-prop-value');
223241
expect(strategy.inputs.get('barBar')).toBe('bar-prop-value-2');
242+
expect(strategy.inputs.get('fooTransformed')).toBe(false);
224243
});
225244

226245
it('should allow overwriting properties with attributes after upgrading the element but before inserting it into the DOM',
@@ -230,25 +249,31 @@ describe('createCustomElement', () => {
230249
const element = Object.assign(document.createElement(selector), {
231250
fooFoo: 'foo-prop-value',
232251
barBar: 'bar-prop-value',
252+
fooTransformed: 'truthy' as unknown,
233253
});
234254
expect(element.fooFoo).toBe('foo-prop-value');
235255
expect(element.barBar).toBe('bar-prop-value');
256+
expect(element.fooTransformed).toBe('truthy');
236257

237258
// Upgrade the element to a Custom Element (without inserting it into the DOM) and set an
238259
// attribute.
239260
customElements.define(selector, ElementCtor);
240261
customElements.upgrade(element);
241262
element.setAttribute('barbar', 'bar-attr-value');
263+
element.setAttribute('foo-transformed', '');
242264
expect(element.fooFoo).toBe('foo-prop-value');
243265
expect(element.barBar).toBe('bar-attr-value');
266+
expect(element.fooTransformed).toBe(false);
244267

245268
// Insert the element into the DOM.
246269
testContainer.appendChild(element);
247270
expect(element.fooFoo).toBe('foo-prop-value');
248271
expect(element.barBar).toBe('bar-attr-value');
272+
expect(element.fooTransformed).toBe(false);
249273

250274
expect(strategy.inputs.get('fooFoo')).toBe('foo-prop-value');
251275
expect(strategy.inputs.get('barBar')).toBe('bar-attr-value');
276+
expect(strategy.inputs.get('fooTransformed')).toBe(false);
252277
});
253278

254279
// Helpers
@@ -274,6 +299,7 @@ describe('createCustomElement', () => {
274299
class TestComponent {
275300
@Input() fooFoo: string = 'foo';
276301
@Input('barbar') barBar!: string;
302+
@Input({transform: (value: unknown) => !!value}) fooTransformed!: boolean;
277303

278304
@Output() bazBaz = new EventEmitter<boolean>();
279305
@Output('quxqux') quxQux = new EventEmitter<Object>();
@@ -306,8 +332,8 @@ describe('createCustomElement', () => {
306332
return this.inputs.get(propName);
307333
}
308334

309-
setInputValue(propName: string, value: string): void {
310-
this.inputs.set(propName, value);
335+
setInputValue(propName: string, value: string, transform?: (value: any) => any): void {
336+
this.inputs.set(propName, transform ? transform(value) : value);
311337
}
312338

313339
reset(): void {

0 commit comments

Comments
 (0)