diff --git a/projects/igniteui-angular-elements/src/analyzer/component.ts b/projects/igniteui-angular-elements/src/analyzer/component.ts index 87aadf47f47..409381bcdb8 100644 --- a/projects/igniteui-angular-elements/src/analyzer/component.ts +++ b/projects/igniteui-angular-elements/src/analyzer/component.ts @@ -1,6 +1,11 @@ import * as ts from 'typescript'; import type { ComponentMetadata, ContentQuery } from './types'; -import { asString, first, getDecoratorName, getDecorators, getProvidedAs, getTypeExpressionIdentifier, isMethod, isProperty, isPublic } from './utils'; +import { asString, first, getDecoratorName, getDecorators, getProvidedAs, getTypeExpressionIdentifier, isMethod, isOverride, isProperty, isPublic, isReadOnly } from './utils'; + + +const isInput = (dec: ts.Decorator) => getDecoratorName(dec).includes('Input'); +const isOutput = (dec: ts.Decorator) => getDecoratorName(dec).includes('Output'); +const isInputOutput = (dec: ts.Decorator) => ['Input', 'Output'].includes(getDecoratorName(dec)); export class AnalyzerComponent { #checker: ts.TypeChecker; @@ -61,6 +66,7 @@ export class AnalyzerComponent { parents, contentQueries: this.#parseQueryProps(), methods: this.publicMethods.map(m => ({ name: m.name })), + additionalProperties: this.additionalProperties.map(p => ({ name: p.name, writable: !isReadOnly(p)})), booleanProperties: this.booleanProperties.map(asString), numericProperties: this.numericProperties.map(asString), templateProperties: this.templateProperties.map(asString), @@ -93,29 +99,42 @@ export class AnalyzerComponent { } /** - * Return all @Input properties of the underlying component. + * Return all `@Input` properties of the underlying component. * * @readonly * @memberof AnalyzerComponent */ get inputProperties() { - const isInput = (dec: ts.Decorator) => getDecoratorName(dec).includes('Input'); return this.publicProperties .filter(prop => getDecorators(first(prop.declarations as any))?.some(isInput)); } /** - * Return all @Output properties of the underlying component. + * Return all `@Output` properties of the underlying component. * * @readonly * @memberof AnalyzerComponent */ get outputProperties() { - const isOutput = (dec: ts.Decorator) => getDecoratorName(dec).includes('Output'); return this.publicProperties .filter(prop => getDecorators(first(prop.declarations as any))?.some(isOutput)); } + /** + * Return all leftover exposed properties (non-inputs) + */ + get additionalProperties() { + // TODO: Better handling of collisions with HTMLElement: + const forbiddenNames = ['children']; + + const additionalProperties = this.publicProperties + .filter(prop => !prop.declarations?.some(x => ts.canHaveDecorators(x) && getDecorators(x)?.some(isInputOutput))) + .filter(x => !forbiddenNames.includes(x.name)) + .filter(x => !this.isOverrideOfParentInput(x, this.#component)); + + return additionalProperties; + } + /** * Return all boolean @Input properties of the underlying component. * @@ -235,4 +254,20 @@ export class AnalyzerComponent { return queries; } + + private isOverrideOfParentInput(symbol: ts.Symbol, type: ts.InterfaceType): boolean { + if (isOverride(symbol)) { + // should resolve a single base for classes + const base = first(type.getBaseTypes() || []); + if (base?.isClass()) { + const baseProp = base.getProperty(symbol.escapedName.toString()); + if (isOverride(baseProp)) { + // also inherited + return this.isOverrideOfParentInput(baseProp, base); + } + return baseProp?.declarations?.some(x => ts.canHaveDecorators(x) && getDecorators(x)?.some(isInputOutput)); + } + } + return false; + } } diff --git a/projects/igniteui-angular-elements/src/analyzer/elements.config.ts b/projects/igniteui-angular-elements/src/analyzer/elements.config.ts index 85c3be6281d..3446202d870 100644 --- a/projects/igniteui-angular-elements/src/analyzer/elements.config.ts +++ b/projects/igniteui-angular-elements/src/analyzer/elements.config.ts @@ -48,6 +48,7 @@ export var registerConfig = [ isQueryList: true, }, ], + additionalProperties: [], methods: ["show", "hide"], boolProps: ["hidden"], }, @@ -63,6 +64,26 @@ export var registerConfig = [ IgxColumnLayoutComponent, ], contentQueries: [], + additionalProperties: [ + { name: "selected", writable: true }, + { name: "index" }, + { name: "defaultMinWidth" }, + { name: "cells" }, + { name: "visibleIndex" }, + { name: "columnGroup" }, + { name: "columnLayout" }, + { name: "columnLayoutChild" }, + { name: "level" }, + { name: "gridRowSpan" }, + { name: "gridColumnSpan" }, + { name: "filteringExpressionsTree" }, + { name: "parent", writable: true }, + { name: "grid", writable: true }, + { name: "topLevelParent" }, + { name: "headerCell" }, + { name: "filterCell" }, + { name: "headerGroup" }, + ], methods: ["pin", "unpin", "move", "autosize"], templateProps: [ "summaryTemplate", @@ -109,6 +130,26 @@ export var registerConfig = [ isQueryList: true, }, ], + additionalProperties: [ + { name: "cells" }, + { name: "selected", writable: true }, + { name: "columnGroup" }, + { name: "columnLayout" }, + { name: "index" }, + { name: "defaultMinWidth" }, + { name: "visibleIndex" }, + { name: "columnLayoutChild" }, + { name: "level" }, + { name: "gridRowSpan" }, + { name: "gridColumnSpan" }, + { name: "filteringExpressionsTree" }, + { name: "parent", writable: true }, + { name: "grid", writable: true }, + { name: "topLevelParent" }, + { name: "headerCell" }, + { name: "filterCell" }, + { name: "headerGroup" }, + ], methods: ["pin", "unpin", "move", "autosize"], templateProps: [ "collapsibleIndicatorTemplate", @@ -150,6 +191,26 @@ export var registerConfig = [ isQueryList: true, }, ], + additionalProperties: [ + { name: "columnLayout" }, + { name: "visibleIndex" }, + { name: "cells" }, + { name: "selected", writable: true }, + { name: "columnGroup" }, + { name: "index" }, + { name: "defaultMinWidth" }, + { name: "columnLayoutChild" }, + { name: "level" }, + { name: "gridRowSpan" }, + { name: "gridColumnSpan" }, + { name: "filteringExpressionsTree" }, + { name: "parent", writable: true }, + { name: "grid", writable: true }, + { name: "topLevelParent" }, + { name: "headerCell" }, + { name: "filterCell" }, + { name: "headerGroup" }, + ], methods: ["pin", "unpin", "move", "autosize"], templateProps: [ "collapsibleIndicatorTemplate", @@ -203,6 +264,31 @@ export var registerConfig = [ isQueryList: true, }, ], + additionalProperties: [ + { name: "groupsRecords" }, + { name: "selectedCells" }, + { name: "shouldGenerate", writable: true }, + { name: "rowList" }, + { name: "dataRowList" }, + { name: "hiddenColumnsCount" }, + { name: "pinnedColumnsCount" }, + { name: "transactions" }, + { name: "filteredData" }, + { name: "filteredSortedData" }, + { name: "validation" }, + { name: "gridAPI" }, + { name: "cdr" }, + { name: "navigation", writable: true }, + { name: "virtualizationState" }, + { name: "nativeElement" }, + { name: "defaultRowHeight" }, + { name: "defaultHeaderGroupMinWidth" }, + { name: "pinnedColumns" }, + { name: "pinnedRows" }, + { name: "unpinnedColumns" }, + { name: "visibleColumns" }, + { name: "dataView" }, + ], methods: [ "groupBy", "clearGrouping", @@ -326,6 +412,7 @@ export var registerConfig = [ component: IgxGridEditingActionsComponent, parents: [IgxActionStripComponent], contentQueries: [], + additionalProperties: [{ name: "hasChildren" }], methods: ["startEdit"], boolProps: ["addRow", "editRow", "deleteRow", "addChild", "asMenuItems"], provideAs: IgxGridActionsBaseDirective, @@ -334,6 +421,7 @@ export var registerConfig = [ component: IgxGridPinningActionsComponent, parents: [IgxActionStripComponent], contentQueries: [], + additionalProperties: [], methods: ["pin", "unpin", "scrollToRow"], boolProps: ["asMenuItems"], provideAs: IgxGridActionsBaseDirective, @@ -342,12 +430,14 @@ export var registerConfig = [ component: IgxGridToolbarActionsComponent, parents: [IgxGridToolbarComponent], contentQueries: [], + additionalProperties: [], methods: [], }, { component: IgxGridToolbarAdvancedFilteringComponent, parents: [IgxGridToolbarComponent], contentQueries: [], + additionalProperties: [{ name: "grid" }], methods: [], }, { @@ -361,6 +451,7 @@ export var registerConfig = [ contentQueries: [ { property: "hasActions", childType: IgxGridToolbarActionsComponent }, ], + additionalProperties: [{ name: "nativeElement" }], methods: [], boolProps: ["showProgress"], provideAs: IgxToolbarToken, @@ -369,6 +460,10 @@ export var registerConfig = [ component: IgxGridToolbarExporterComponent, parents: [IgxGridToolbarComponent], contentQueries: [], + additionalProperties: [ + { name: "isExporting", writable: true }, + { name: "grid" }, + ], methods: ["export"], boolProps: ["exportCSV", "exportExcel"], }, @@ -376,6 +471,7 @@ export var registerConfig = [ component: IgxGridToolbarHidingComponent, parents: [IgxGridToolbarComponent], contentQueries: [], + additionalProperties: [{ name: "grid" }], methods: ["checkAll", "uncheckAll"], numericProps: ["indentetion"], boolProps: ["hideFilter"], @@ -384,6 +480,7 @@ export var registerConfig = [ component: IgxGridToolbarPinningComponent, parents: [IgxGridToolbarComponent], contentQueries: [], + additionalProperties: [{ name: "grid" }], methods: ["checkAll", "uncheckAll"], numericProps: ["indentetion"], boolProps: ["hideFilter"], @@ -392,6 +489,7 @@ export var registerConfig = [ component: IgxGridToolbarTitleComponent, parents: [IgxGridToolbarComponent], contentQueries: [], + additionalProperties: [], methods: [], }, { @@ -433,6 +531,31 @@ export var registerConfig = [ isQueryList: true, }, ], + additionalProperties: [ + { name: "foreignKey" }, + { name: "selectedCells" }, + { name: "gridAPI", writable: true }, + { name: "shouldGenerate", writable: true }, + { name: "rowList" }, + { name: "dataRowList" }, + { name: "hiddenColumnsCount" }, + { name: "pinnedColumnsCount" }, + { name: "transactions" }, + { name: "filteredData" }, + { name: "filteredSortedData" }, + { name: "validation" }, + { name: "cdr" }, + { name: "navigation", writable: true }, + { name: "virtualizationState" }, + { name: "nativeElement" }, + { name: "defaultRowHeight" }, + { name: "defaultHeaderGroupMinWidth" }, + { name: "pinnedColumns" }, + { name: "pinnedRows" }, + { name: "unpinnedColumns" }, + { name: "visibleColumns" }, + { name: "dataView" }, + ], methods: [ "getRowByIndex", "getRowByKey", @@ -548,6 +671,14 @@ export var registerConfig = [ IgxPivotGridComponent, ], contentQueries: [], + additionalProperties: [ + { name: "totalPages", writable: true }, + { name: "isLastPage" }, + { name: "isFirstPage" }, + { name: "isFirstPageDisabled" }, + { name: "isLastPageDisabled" }, + { name: "nativeElement" }, + ], methods: ["nextPage", "previousPage", "paginate"], numericProps: ["page", "perPage", "totalRecords"], }, @@ -555,6 +686,7 @@ export var registerConfig = [ component: IgxPivotDataSelectorComponent, parents: [], contentQueries: [], + additionalProperties: [{ name: "animationSettings", writable: true }], methods: [], boolProps: [ "columnsExpanded", @@ -584,6 +716,24 @@ export var registerConfig = [ isQueryList: true, }, ], + additionalProperties: [ + { name: "dimensionsSortingExpressions" }, + { name: "defaultRowHeight" }, + { name: "allDimensions" }, + { name: "rowList" }, + { name: "dataRowList" }, + { name: "filteredData" }, + { name: "filteredSortedData" }, + { name: "validation" }, + { name: "gridAPI" }, + { name: "cdr" }, + { name: "navigation", writable: true }, + { name: "virtualizationState" }, + { name: "nativeElement" }, + { name: "defaultHeaderGroupMinWidth" }, + { name: "visibleColumns" }, + { name: "dataView" }, + ], methods: [ "notifyDimensionChange", "toggleColumn", @@ -685,6 +835,33 @@ export var registerConfig = [ }, { property: "actionStrip", childType: IgxActionStripComponent }, ], + additionalProperties: [ + { name: "islandToolbarTemplate", writable: true }, + { name: "islandPaginatorTemplate", writable: true }, + { name: "data" }, + { name: "rowIslandAPI", writable: true }, + { name: "gridAPI", writable: true }, + { name: "shouldGenerate", writable: true }, + { name: "rowList" }, + { name: "dataRowList" }, + { name: "hiddenColumnsCount" }, + { name: "pinnedColumnsCount" }, + { name: "transactions" }, + { name: "filteredData" }, + { name: "filteredSortedData" }, + { name: "validation" }, + { name: "cdr" }, + { name: "navigation", writable: true }, + { name: "virtualizationState" }, + { name: "nativeElement" }, + { name: "defaultRowHeight" }, + { name: "defaultHeaderGroupMinWidth" }, + { name: "pinnedColumns" }, + { name: "pinnedRows" }, + { name: "unpinnedColumns" }, + { name: "visibleColumns" }, + { name: "dataView" }, + ], methods: [ "isRecordPinnedByIndex", "toggleColumnVisibility", @@ -806,6 +983,33 @@ export var registerConfig = [ isQueryList: true, }, ], + additionalProperties: [ + { name: "rootRecords", writable: true }, + { name: "records", writable: true }, + { name: "processedRootRecords", writable: true }, + { name: "processedRecords", writable: true }, + { name: "selectedCells" }, + { name: "shouldGenerate", writable: true }, + { name: "rowList" }, + { name: "dataRowList" }, + { name: "hiddenColumnsCount" }, + { name: "pinnedColumnsCount" }, + { name: "filteredData" }, + { name: "filteredSortedData" }, + { name: "validation" }, + { name: "gridAPI" }, + { name: "cdr" }, + { name: "navigation", writable: true }, + { name: "virtualizationState" }, + { name: "nativeElement" }, + { name: "defaultRowHeight" }, + { name: "defaultHeaderGroupMinWidth" }, + { name: "pinnedColumns" }, + { name: "pinnedRows" }, + { name: "unpinnedColumns" }, + { name: "visibleColumns" }, + { name: "dataView" }, + ], methods: [ "getDefaultExpandState", "expandAll", diff --git a/projects/igniteui-angular-elements/src/analyzer/printer.ts b/projects/igniteui-angular-elements/src/analyzer/printer.ts index 8bdd85f493b..8abe191499c 100644 --- a/projects/igniteui-angular-elements/src/analyzer/printer.ts +++ b/projects/igniteui-angular-elements/src/analyzer/printer.ts @@ -2,7 +2,7 @@ import * as ts from 'typescript'; import * as path from 'node:path'; import * as fs from 'node:fs'; import { format } from 'prettier'; -import type { ComponentMetadata, ContentQuery } from './types'; +import type { ComponentMetadata, ContentQuery, PropertyInfo } from './types'; export class AnalyzerPrinter { @@ -53,11 +53,24 @@ export class AnalyzerPrinter { return ts.factory.createObjectLiteralExpression(properties); } + private createPropertyLiteral(prop: PropertyInfo) { + const properties = [ + ts.factory.createPropertyAssignment('name', ts.factory.createStringLiteral(prop.name)), + ]; + + if (prop.writable) { + properties.push(ts.factory.createPropertyAssignment('writable', ts.factory.createToken(ts.SyntaxKind.TrueKeyword))); + } + + return ts.factory.createObjectLiteralExpression(properties); + } + #createMetaLiteralObject([type, meta]: readonly [ts.InterfaceType, ComponentMetadata]) { const properties = [ ts.factory.createPropertyAssignment('component', ts.factory.createIdentifier(type.symbol.name)), ts.factory.createPropertyAssignment('parents', ts.factory.createArrayLiteralExpression(meta.parents.map(x => ts.factory.createIdentifier(x.symbol.name)))), ts.factory.createPropertyAssignment('contentQueries', ts.factory.createArrayLiteralExpression(meta.contentQueries.map(x => this.#createContentQueryLiteral(x)))), + ts.factory.createPropertyAssignment('additionalProperties', ts.factory.createArrayLiteralExpression(meta.additionalProperties.map(x => this.createPropertyLiteral(x)))), ts.factory.createPropertyAssignment('methods', ts.factory.createArrayLiteralExpression(meta.methods.map(x => ts.factory.createStringLiteral(x.name)))) ]; if (meta.templateProperties?.length) { diff --git a/projects/igniteui-angular-elements/src/analyzer/types.ts b/projects/igniteui-angular-elements/src/analyzer/types.ts index 1195ed6f483..9e04a2b6349 100644 --- a/projects/igniteui-angular-elements/src/analyzer/types.ts +++ b/projects/igniteui-angular-elements/src/analyzer/types.ts @@ -1,6 +1,7 @@ import * as ts from 'typescript'; -export type MethodInfo = { name: string } +export type MethodInfo = { name: string }; +export type PropertyInfo = { name: string, writable?: boolean }; export type ContentQuery = { property: string, @@ -13,6 +14,7 @@ export type ComponentMetadata = { parents: T[], contentQueries: ContentQuery[], methods: MethodInfo[], + additionalProperties: PropertyInfo[]; templateProperties?: string[], booleanProperties?: string[], numericProperties?: string[], diff --git a/projects/igniteui-angular-elements/src/analyzer/utils.ts b/projects/igniteui-angular-elements/src/analyzer/utils.ts index 2b25fc142f8..47402202261 100644 --- a/projects/igniteui-angular-elements/src/analyzer/utils.ts +++ b/projects/igniteui-angular-elements/src/analyzer/utils.ts @@ -17,7 +17,7 @@ export function readTSConfig() { export function first(arr?: T[]) { - return arr!.at(0) as T; + return arr!.at(0); } /** @@ -40,7 +40,7 @@ export function hasDecorators(node: ts.HasDecorators): boolean { * @param {(ts.ClassDeclaration | ts.PropertyDeclaration)} node the decorated node * @return {*} {readonly} */ -export function getDecorators(node: ts.ClassDeclaration | ts.PropertyDeclaration): readonly ts.Decorator[] { +export function getDecorators(node: ts.HasDecorators): readonly ts.Decorator[] { return hasDecorators(node) ? ts.getDecorators(node)! : []; } @@ -86,6 +86,19 @@ export function isPublic(symbol: ts.Symbol) { return false; } +/** returns if a symbol is either readonly or just a getter equivalent */ +export function isReadOnly(symbol: ts.Symbol) { + const isGetter = (symbol.flags & ts.SymbolFlags.GetAccessor) !== ts.SymbolFlags.None && + (symbol.flags & ts.SymbolFlags.SetAccessor) === ts.SymbolFlags.None; + const readonly = symbol.valueDeclaration && ts.getCombinedModifierFlags(symbol.valueDeclaration) & ts.ModifierFlags.Readonly; + return isGetter || readonly; +} + +/** returns if a symbol has an override modifier */ +export function isOverride(symbol: ts.Symbol) { + return (ts.getCombinedModifierFlags(symbol.valueDeclaration!) & ts.ModifierFlags.Override) !== ts.ModifierFlags.None; +} + export function asString(x?: ts.Symbol) { return x ? x.escapedName.toString() : ''; } diff --git a/projects/igniteui-angular-elements/src/app/component-config.ts b/projects/igniteui-angular-elements/src/app/component-config.ts index 360b549f379..ef3d02d8d39 100644 --- a/projects/igniteui-angular-elements/src/app/component-config.ts +++ b/projects/igniteui-angular-elements/src/app/component-config.ts @@ -1,4 +1,5 @@ import { AbstractType, Type } from '@angular/core'; +import { PropertyInfo } from '../analyzer/types'; export interface ContentQueryMeta { property: string; @@ -11,6 +12,7 @@ export interface ComponentConfig { selector?: string; parents: Type[], contentQueries: ContentQueryMeta[]; + additionalProperties: PropertyInfo[]; methods: string[]; templateProps?: string[]; numericProps?: string[]; diff --git a/projects/igniteui-angular-elements/src/app/create-custom-element.ts b/projects/igniteui-angular-elements/src/app/create-custom-element.ts index 22f65b0a1f3..f47465d5704 100644 --- a/projects/igniteui-angular-elements/src/app/create-custom-element.ts +++ b/projects/igniteui-angular-elements/src/app/create-custom-element.ts @@ -24,6 +24,32 @@ export function createIgxCustomElement(component: Type, config: IgxNgEle } } + // Reuse `createCustomElement`'s approach for Inputs, should work for any prop too: + componentConfig?.additionalProperties.forEach((p) => { + let set: (v: any) => void | undefined; + + + if (p.name in elementCtor.prototype) { + throw new Error(`Potentially illegal property name ${p.name} defined for ${component.name}`); + + } + + if (p.writable) { + set = function (newValue) { + this.ngElementStrategy.setInputValue(p.name, newValue); + } + } + + Object.defineProperty(elementCtor.prototype, p.name, { + get() { + return this.ngElementStrategy.getInputValue(p.name); + }, + set, + configurable: true, + enumerable: true, + }); + }); + // TODO: all 'template' props, setInput check for componentRef!, accumulated Props before init, object componentRef // let propName = 'sortHeaderIconTemplate'; // Object.defineProperty(elementCtor.prototype, propName, {