diff --git a/docs/framework/angular/angular-table.md b/docs/framework/angular/angular-table.md index c3e74071d2..d992a7ecea 100644 --- a/docs/framework/angular/angular-table.md +++ b/docs/framework/angular/angular-table.md @@ -40,41 +40,181 @@ FlexRender supports any type of content supported by Angular: - A [TemplateRef](https://angular.dev/api/core/TemplateRef) - A [Component](https://angular.dev/api/core/Component) wrapped into `FlexRenderComponent` -Example: +You can just use the `cell.renderValue` or `cell.getValue` APIs to render the cells of your table. However, +these APIs will only spit out the raw cell values (from accessor functions). +If you are using the `cell: () => any` column definition options, you will want to use the `FlexRenderDirective` from the adapter. + +Cell column definition is **reactive** and runs into an **injection context**, then you can inject services or make use of signals to automatically modify the rendered content. + +#### Example ```ts @Component({ imports: [FlexRenderDirective], //... }) +class YourComponent {} ``` ```angular-html @for (row of table.getRowModel().rows; track row.id) { - - @for (cell of row.getVisibleCells(); track cell.id) { - - - - {{ cell }} - -
-
- -} - + > + + {{ cell }} + +
+ + + } + } ``` +#### Rendering a Component + +To render a Component into a specific column header/cell/footer, you can pass a `FlexRenderComponent` instantiated with +your `ComponentType, with the ability to include parameters such as inputs, outputs and a custom injector. + +```ts +import {flexRenderComponent} from "./flex-render-component"; +import {ChangeDetectionStrategy, input, output} from "@angular/core"; + +@Component({ + template: ` + ... + `, + standalone: true, + changeDetectionStrategy: ChangeDetectionStrategy.OnPush, + host: { + '(click)': 'clickEvent.emit($event)' + } +}) +class CustomCell { + readonly content = input.required(); + readonly cellType = input(); + + // An output that will emit for every cell click + readonly clickEvent = output(); +} + +class AppComponent { + columns: ColumnDef[] = [ + { + id: 'custom-cell', + header: () => { + const translateService = inject(TranslateService); + return translateService.translate('...'); + }, + cell: (context) => { + return flexRenderComponent( + MyCustomComponent, + { + injector, // Optional injector + inputs: { + // Mandatory input since we are using `input.required() + content: context.row.original.rowProperty, + // cellType? - Optional input + }, + outputs: { + clickEvent: () => { + // Do something + } + } + } + ) + }, + }, + ] +} +``` + +Underneath, this utilizes +the [ViewContainerRef#createComponent](https://angular.dev/api/core/ViewContainerRef#createComponent) api. +Therefore, you should declare your custom inputs using the @Input decorator or input/model signals. + +You can still access the table cell context through the `injectFlexRenderContext` function, which returns the context +value based on the props you pass to the `FlexRenderDirective`. + +```ts + +@Component({ + // ... +}) +class CustomCellComponent { + // context of a cell component + readonly context = injectFlexRenderContext>(); + // context of a header/footer component + readonly context = injectFlexRenderContext>(); +} +``` + +Alternatively, you can render a component into a specific column header, cell, or footer by passing the component type +to the corresponding column definitions. These column definitions will be provided to the `flexRender` directive along +with the `context`. + +```ts +class AppComponent { + columns: ColumnDef[] = [ + { + id: 'select', + header: () => TableHeadSelectionComponent, + cell: () => TableRowSelectionComponent, + }, + ] +} +``` + +```angular-html + + {{ headerCell }} + +``` + +Properties of `context` provided in the `flexRender` directive will be accessible to your component. +You can explicitly define the context properties required by your component. +In this example, the context provided to flexRender is of type HeaderContext. +Input signal `table`, which is a property of HeaderContext together with `column` and `header` properties, +is then defined to be used in the component. If any of the context properties are +needed in your component, feel free to use them. Please take note that only input signal is supported, +when defining access to context properties, using this approach. + +```angular-ts +@Component({ + template: ` + + `, + // ... +}) +export class TableHeadSelectionComponent { + //column = input.required>() + //header = input.required>() + table = input.required>() +} +``` + #### Rendering a TemplateRef In order to render a TemplateRef into a specific column header/cell/footer, you can pass the TemplateRef into the column @@ -171,101 +311,3 @@ class AppComponent { ] } ``` - -#### Rendering a Component - -To render a Component into a specific column header/cell/footer, you can pass a `FlexRenderComponent instantiated with -your `ComponentType, with the ability to include optional parameters such as inputs and an injector. - -```ts -import {FlexRenderComponent} from "@tanstack/angular-table"; - -class AppComponent { - columns: ColumnDef[] = [ - { - id: 'customCell', - header: () => new FlexRenderComponent( - CustomCellComponent, - {}, // optional inputs - injector // optional injector - ), - cell: () => this.customCell(), - }, - ] -} -``` - -Underneath, this utilizes -the [ViewContainerRef#createComponent](https://angular.dev/api/core/ViewContainerRef#createComponent) api. -Therefore, you should declare your custom inputs using the @Input decorator or input/model signals. - -You can still access the table cell context through the `injectFlexRenderContext` function, which returns the context -value based on the props you pass to the `FlexRenderDirective`. - -```ts -@Component({ - // ... -}) -class CustomCellComponent { - // context of a cell component - readonly context = injectFlexRenderContext>(); - // context of a header/footer component - readonly context = injectFlexRenderContext>(); -} -``` - -Alternatively, you can render a component into a specific column header, cell, or footer by passing the component type -to the corresponding column definitions. These column definitions will be provided to the `flexRender` directive along with the `context`. - -```ts -import {FlexRenderComponent} from "@tanstack/angular-table"; - -class AppComponent { - columns: ColumnDef[] = [ - { - id: 'select', - header: () => TableHeadSelectionComponent, - cell: () => TableRowSelectionComponent, - }, - ] -} -``` - -```angular2html - - {{ headerCell }} - -``` - -Properties of `context` provided in the `flexRender` directive will be accessible to your component. -You can explicitly define the context properties required by your component. -In this example, the context provided to flexRender is of type HeaderContext. -Input signal `table`, which is a property of HeaderContext together with `column` and `header` properties, -is then defined to be used in the component. If any of the context properties are -needed in your component, feel free to use them. Please take note that only input signal is supported, -when defining access to context properties, using this approach. - -```angular-ts -@Component({ - template: ` - - `, - // ... -}) -export class TableHeadSelectionComponent { - //column = input.required>() - //header = input.required>() - table = input.required>() -} -``` \ No newline at end of file diff --git a/examples/angular/row-selection/src/app/app.component.ts b/examples/angular/row-selection/src/app/app.component.ts index 8711fd3959..da7ce36ef5 100644 --- a/examples/angular/row-selection/src/app/app.component.ts +++ b/examples/angular/row-selection/src/app/app.component.ts @@ -9,7 +9,7 @@ import { import { ColumnDef, createAngularTable, - FlexRenderComponent, + flexRenderComponent, FlexRenderDirective, getCoreRowModel, getFilteredRowModel, @@ -43,10 +43,10 @@ export class AppComponent { { id: 'select', header: () => { - return new FlexRenderComponent(TableHeadSelectionComponent) + return flexRenderComponent(TableHeadSelectionComponent) }, cell: () => { - return new FlexRenderComponent(TableRowSelectionComponent) + return flexRenderComponent(TableRowSelectionComponent) }, }, { diff --git a/packages/angular-table/package.json b/packages/angular-table/package.json index 55aae671f5..e457315244 100644 --- a/packages/angular-table/package.json +++ b/packages/angular-table/package.json @@ -43,7 +43,7 @@ ], "scripts": { "clean": "rimraf ./build", - "test:types": "tsc --noEmit", + "test:types": "tsc --noEmit && vitest --typecheck", "test:lib": "vitest", "test:lib:dev": "vitest --watch", "build": "ng-packagr -p ng-package.json -c tsconfig.build.json && rimraf ./build/lib/package.json" @@ -53,11 +53,14 @@ "tslib": "^2.6.2" }, "devDependencies": { - "@analogjs/vite-plugin-angular": "^1.3.1", + "@analogjs/vite-plugin-angular": "^1.11.0", + "@analogjs/vitest-angular": "^1.11.0", "@angular/core": "^17.3.9", "@angular/platform-browser": "^17.3.9", "@angular/platform-browser-dynamic": "^17.3.9", - "ng-packagr": "^17.3.0" + "ng-packagr": "^17.3.0", + "typescript": "5.4.5", + "vitest": "^1.6.0" }, "peerDependencies": { "@angular/core": ">=17" diff --git a/packages/angular-table/src/flex-render.ts b/packages/angular-table/src/flex-render.ts index 86f97ae6cb..6a072f242e 100644 --- a/packages/angular-table/src/flex-render.ts +++ b/packages/angular-table/src/flex-render.ts @@ -1,20 +1,40 @@ import { ChangeDetectorRef, - ComponentRef, Directive, - EmbeddedViewRef, + DoCheck, + effect, + type EffectRef, Inject, inject, - InjectionToken, Injector, Input, - isSignal, - type OnChanges, - type SimpleChanges, + OnChanges, + runInInjectionContext, + SimpleChanges, TemplateRef, Type, ViewContainerRef, } from '@angular/core' +import { FlexRenderComponentProps } from './flex-render/context' +import { FlexRenderFlags } from './flex-render/flags' +import { + flexRenderComponent, + FlexRenderComponent, +} from './flex-render/flex-render-component' +import { FlexRenderComponentFactory } from './flex-render/flex-render-component-ref' +import { + FlexRenderComponentView, + FlexRenderTemplateView, + type FlexRenderTypedContent, + FlexRenderView, + mapToFlexRenderTypedContent, +} from './flex-render/view' +import { memo } from '@tanstack/table-core' + +export { + injectFlexRenderContext, + type FlexRenderComponentProps, +} from './flex-render/context' export type FlexRenderContent> = | string @@ -23,15 +43,20 @@ export type FlexRenderContent> = | FlexRenderComponent | TemplateRef<{ $implicit: TProps }> | null + | Record | undefined @Directive({ selector: '[flexRender]', standalone: true, + providers: [FlexRenderComponentFactory], }) export class FlexRenderDirective> - implements OnChanges + implements OnChanges, DoCheck { + readonly #flexRenderComponentFactory = inject(FlexRenderComponentFactory) + readonly #changeDetectorRef = inject(ChangeDetectorRef) + @Input({ required: true, alias: 'flexRender' }) content: | number @@ -46,6 +71,24 @@ export class FlexRenderDirective> @Input({ required: false, alias: 'flexRenderInjector' }) injector: Injector = inject(Injector) + renderFlags = FlexRenderFlags.ViewFirstRender + renderView: FlexRenderView | null = null + + readonly #latestContent = () => { + const { content, props } = this + return typeof content !== 'function' + ? content + : runInInjectionContext(this.injector, () => content(props)) + } + + #getContentValue = memo( + () => [this.#latestContent(), this.props, this.content], + latestContent => { + return mapToFlexRenderTypedContent(latestContent) + }, + { key: 'flexRenderContentValue', debug: () => false } + ) + constructor( @Inject(ViewContainerRef) private readonly viewContainerRef: ViewContainerRef, @@ -53,132 +96,191 @@ export class FlexRenderDirective> private readonly templateRef: TemplateRef ) {} - ref?: ComponentRef | EmbeddedViewRef | null = null - ngOnChanges(changes: SimpleChanges) { - if (this.ref instanceof ComponentRef) { - this.ref.injector.get(ChangeDetectorRef).markForCheck() + if (changes['props']) { + this.renderFlags |= FlexRenderFlags.PropsReferenceChanged } - if (!changes['content']) { - return + if (changes['content']) { + this.renderFlags |= + FlexRenderFlags.ContentChanged | FlexRenderFlags.ViewFirstRender + this.update() } - this.render() } - render() { - this.viewContainerRef.clear() - const { content, props } = this - if (content === null || content === undefined) { - this.ref = null + ngDoCheck(): void { + if (this.renderFlags & FlexRenderFlags.ViewFirstRender) { + // On the initial render, the view is created during the `ngOnChanges` hook. + // Since `ngDoCheck` is called immediately afterward, there's no need to check for changes in this phase. + this.renderFlags &= ~FlexRenderFlags.ViewFirstRender return } - if (typeof content === 'function') { - return this.renderContent(content(props)) + + this.renderFlags |= FlexRenderFlags.DirtyCheck + + const latestContent = this.#getContentValue() + if (latestContent.kind === 'null' || !this.renderView) { + this.renderFlags |= FlexRenderFlags.ContentChanged } else { - return this.renderContent(content) + this.renderView.content = latestContent + const { kind: previousKind } = this.renderView.previousContent + if (latestContent.kind !== previousKind) { + this.renderFlags |= FlexRenderFlags.ContentChanged + } + } + this.update() + } + + update() { + if ( + this.renderFlags & + (FlexRenderFlags.ContentChanged | FlexRenderFlags.ViewFirstRender) + ) { + this.render() + return + } + if (this.renderFlags & FlexRenderFlags.PropsReferenceChanged) { + if (this.renderView) this.renderView.updateProps(this.props) + this.renderFlags &= ~FlexRenderFlags.PropsReferenceChanged + } + if ( + this.renderFlags & + (FlexRenderFlags.DirtyCheck | FlexRenderFlags.DirtySignal) + ) { + if (this.renderView) this.renderView.dirtyCheck() + this.renderFlags &= ~( + FlexRenderFlags.DirtyCheck | FlexRenderFlags.DirtySignal + ) } } - private renderContent(content: FlexRenderContent) { - if (typeof content === 'string' || typeof content === 'number') { - return this.renderStringContent() + #currentEffectRef: EffectRef | null = null + + render() { + if (this.#shouldRecreateEntireView() && this.#currentEffectRef) { + this.#currentEffectRef.destroy() + this.#currentEffectRef = null + this.renderFlags &= ~FlexRenderFlags.RenderEffectChecked + } + + this.viewContainerRef.clear() + this.renderFlags = + FlexRenderFlags.Pristine | + (this.renderFlags & FlexRenderFlags.ViewFirstRender) | + (this.renderFlags & FlexRenderFlags.RenderEffectChecked) + + const resolvedContent = this.#getContentValue() + if (resolvedContent.kind === 'null') { + this.renderView = null + } else { + this.renderView = this.#renderViewByContent(resolvedContent) } - if (content instanceof TemplateRef) { - return this.viewContainerRef.createEmbeddedView( - content, - this.getTemplateRefContext() + + // If the content is a function `content(props)`, we initialize an effect + // in order to react to changes if the given definition use signals. + if (!this.#currentEffectRef && typeof this.content === 'function') { + this.#currentEffectRef = effect( + () => { + this.#latestContent() + if (!(this.renderFlags & FlexRenderFlags.RenderEffectChecked)) { + this.renderFlags |= FlexRenderFlags.RenderEffectChecked + return + } + this.renderFlags |= FlexRenderFlags.DirtySignal + // This will mark the view as changed, + // so we'll try to check for updates into ngDoCheck + this.#changeDetectorRef.markForCheck() + }, + { injector: this.viewContainerRef.injector } ) - } else if (content instanceof FlexRenderComponent) { - return this.renderComponent(content) - } else if (content instanceof Type) { - return this.renderCustomComponent(content) + } + } + + #shouldRecreateEntireView() { + return ( + this.renderFlags & + FlexRenderFlags.ContentChanged & + FlexRenderFlags.ViewFirstRender + ) + } + + #renderViewByContent( + content: FlexRenderTypedContent + ): FlexRenderView | null { + if (content.kind === 'primitive') { + return this.#renderStringContent(content) + } else if (content.kind === 'templateRef') { + return this.#renderTemplateRefContent(content) + } else if (content.kind === 'flexRenderComponent') { + return this.#renderComponent(content) + } else if (content.kind === 'component') { + return this.#renderCustomComponent(content) } else { return null } } - private renderStringContent(): EmbeddedViewRef { + #renderStringContent( + template: Extract + ): FlexRenderTemplateView { const context = () => { return typeof this.content === 'string' || typeof this.content === 'number' ? this.content : this.content?.(this.props) } - return this.viewContainerRef.createEmbeddedView(this.templateRef, { + const ref = this.viewContainerRef.createEmbeddedView(this.templateRef, { get $implicit() { return context() }, }) + return new FlexRenderTemplateView(template, ref) + } + + #renderTemplateRefContent( + template: Extract + ): FlexRenderTemplateView { + const latestContext = () => this.props + const view = this.viewContainerRef.createEmbeddedView(template.content, { + get $implicit() { + return latestContext() + }, + }) + return new FlexRenderTemplateView(template, view) } - private renderComponent( - flexRenderComponent: FlexRenderComponent - ): ComponentRef { - const { component, inputs, injector } = flexRenderComponent + #renderComponent( + flexRenderComponent: Extract< + FlexRenderTypedContent, + { kind: 'flexRenderComponent' } + > + ): FlexRenderComponentView { + const { inputs, outputs, injector } = flexRenderComponent.content const getContext = () => this.props - const proxy = new Proxy(this.props, { - get: (_, key) => getContext()?.[key as keyof typeof _], + get: (_, key) => getContext()[key as keyof typeof _], }) - const componentInjector = Injector.create({ parent: injector ?? this.injector, providers: [{ provide: FlexRenderComponentProps, useValue: proxy }], }) - - const componentRef = this.viewContainerRef.createComponent(component, { - injector: componentInjector, - }) - for (const prop in inputs) { - if (componentRef.instance?.hasOwnProperty(prop)) { - componentRef.setInput(prop, inputs[prop]) - } - } - return componentRef - } - - private renderCustomComponent( - component: Type - ): ComponentRef { - const componentRef = this.viewContainerRef.createComponent(component, { - injector: this.injector, - }) - for (const prop in this.props) { - // Only signal based input can be added here - if ( - componentRef.instance?.hasOwnProperty(prop) && - // @ts-ignore - isSignal(componentRef.instance[prop]) - ) { - componentRef.setInput(prop, this.props[prop]) - } - } - return componentRef + const view = this.#flexRenderComponentFactory.createComponent( + flexRenderComponent.content, + componentInjector + ) + return new FlexRenderComponentView(flexRenderComponent, view) } - private getTemplateRefContext() { - const getContext = () => this.props - return { - get $implicit() { - return getContext() - }, - } + #renderCustomComponent( + component: Extract + ): FlexRenderComponentView { + const view = this.#flexRenderComponentFactory.createComponent( + flexRenderComponent(component.content, { + inputs: this.props, + injector: this.injector, + }), + this.injector + ) + return new FlexRenderComponentView(component, view) } } - -export class FlexRenderComponent> { - constructor( - readonly component: Type, - readonly inputs: T = {} as T, - readonly injector?: Injector - ) {} -} - -const FlexRenderComponentProps = new InjectionToken>( - '[@tanstack/angular-table] Flex render component context props' -) - -export function injectFlexRenderContext>(): T { - return inject(FlexRenderComponentProps) -} diff --git a/packages/angular-table/src/flex-render/context.ts b/packages/angular-table/src/flex-render/context.ts new file mode 100644 index 0000000000..b9eacac73c --- /dev/null +++ b/packages/angular-table/src/flex-render/context.ts @@ -0,0 +1,9 @@ +import { inject, InjectionToken } from '@angular/core' + +export const FlexRenderComponentProps = new InjectionToken< + NonNullable +>('[@tanstack/angular-table] Flex render component context props') + +export function injectFlexRenderContext>(): T { + return inject(FlexRenderComponentProps) +} diff --git a/packages/angular-table/src/flex-render/flags.ts b/packages/angular-table/src/flex-render/flags.ts new file mode 100644 index 0000000000..7458f71143 --- /dev/null +++ b/packages/angular-table/src/flex-render/flags.ts @@ -0,0 +1,40 @@ +/** + * Flags used to manage and optimize the rendering lifecycle of content of the cell, + * while using FlexRenderDirective. + */ +export enum FlexRenderFlags { + /** + * Indicates that the view is being created for the first time or will be cleared during the next update phase. + * This is the initial state and will transition after the first ngDoCheck. + */ + ViewFirstRender = 1 << 0, + /** + * Represents a state where the view is not dirty, meaning no changes require rendering updates. + */ + Pristine = 1 << 1, + /** + * Indicates the `content` property has been modified or the view requires a complete re-render. + * When this flag is enabled, the view will be cleared and recreated from scratch. + */ + ContentChanged = 1 << 2, + /** + * Indicates that the `props` property reference has changed. + * When this flag is enabled, the view context is updated based on the type of the content. + * + * For Component view, inputs will be updated and view will be marked as dirty. + * For TemplateRef and primitive values, view will be marked as dirty + */ + PropsReferenceChanged = 1 << 3, + /** + * Indicates that the current rendered view needs to be checked for changes. + */ + DirtyCheck = 1 << 4, + /** + * Indicates that a signal within the `content(props)` result has changed + */ + DirtySignal = 1 << 5, + /** + * Indicates that the first render effect has been checked at least one time. + */ + RenderEffectChecked = 1 << 6, +} diff --git a/packages/angular-table/src/flex-render/flex-render-component-ref.ts b/packages/angular-table/src/flex-render/flex-render-component-ref.ts new file mode 100644 index 0000000000..71ab7999a4 --- /dev/null +++ b/packages/angular-table/src/flex-render/flex-render-component-ref.ts @@ -0,0 +1,236 @@ +import { + ChangeDetectorRef, + ComponentRef, + inject, + Injectable, + Injector, + KeyValueDiffer, + KeyValueDiffers, + OutputEmitterRef, + OutputRefSubscription, + ViewContainerRef, +} from '@angular/core' +import { FlexRenderComponent } from './flex-render-component' + +@Injectable() +export class FlexRenderComponentFactory { + #viewContainerRef = inject(ViewContainerRef) + + createComponent( + flexRenderComponent: FlexRenderComponent, + componentInjector: Injector + ): FlexRenderComponentRef { + const componentRef = this.#viewContainerRef.createComponent( + flexRenderComponent.component, + { + injector: componentInjector, + } + ) + const view = new FlexRenderComponentRef( + componentRef, + flexRenderComponent, + componentInjector + ) + + const { inputs, outputs } = flexRenderComponent + + if (inputs) view.setInputs(inputs) + if (outputs) view.setOutputs(outputs) + + return view + } +} + +export class FlexRenderComponentRef { + readonly #keyValueDiffersFactory: KeyValueDiffers + #componentData: FlexRenderComponent + #inputValueDiffer: KeyValueDiffer + + readonly #outputRegistry: FlexRenderComponentOutputManager + + constructor( + readonly componentRef: ComponentRef, + componentData: FlexRenderComponent, + readonly componentInjector: Injector + ) { + this.#componentData = componentData + this.#keyValueDiffersFactory = componentInjector.get(KeyValueDiffers) + + this.#outputRegistry = new FlexRenderComponentOutputManager( + this.#keyValueDiffersFactory, + this.outputs + ) + + this.#inputValueDiffer = this.#keyValueDiffersFactory + .find(this.inputs) + .create() + this.#inputValueDiffer.diff(this.inputs) + + this.componentRef.onDestroy(() => this.#outputRegistry.unsubscribeAll()) + } + + get component() { + return this.#componentData.component + } + + get inputs() { + return this.#componentData.inputs ?? {} + } + + get outputs() { + return this.#componentData.outputs ?? {} + } + + /** + * Get component input and output diff by the given item + */ + diff(item: FlexRenderComponent) { + return { + inputDiff: this.#inputValueDiffer.diff(item.inputs ?? {}), + outputDiff: this.#outputRegistry.diff(item.outputs ?? {}), + } + } + /** + * + * @param compare Whether the current ref component instance is the same as the given one + */ + eqType(compare: FlexRenderComponent): boolean { + return compare.component === this.component + } + + /** + * Tries to update current component refs input by the new given content component. + */ + update(content: FlexRenderComponent) { + const eq = this.eqType(content) + if (!eq) return + const { inputDiff, outputDiff } = this.diff(content) + if (inputDiff) { + inputDiff.forEachAddedItem(item => + this.setInput(item.key, item.currentValue) + ) + inputDiff.forEachChangedItem(item => + this.setInput(item.key, item.currentValue) + ) + inputDiff.forEachRemovedItem(item => this.setInput(item.key, undefined)) + } + if (outputDiff) { + outputDiff.forEachAddedItem(item => { + this.setOutput(item.key, item.currentValue) + }) + outputDiff.forEachChangedItem(item => { + if (item.currentValue) { + this.#outputRegistry.setListener(item.key, item.currentValue) + } else { + this.#outputRegistry.unsubscribe(item.key) + } + }) + outputDiff.forEachRemovedItem(item => { + this.#outputRegistry.unsubscribe(item.key) + }) + } + + this.#componentData = content + } + + markAsDirty(): void { + this.componentRef.injector.get(ChangeDetectorRef).markForCheck() + } + + setInputs(inputs: Record) { + for (const prop in inputs) { + this.setInput(prop, inputs[prop]) + } + } + + setInput(key: string, value: unknown) { + if (this.#componentData.allowedInputNames.includes(key)) { + this.componentRef.setInput(key, value) + } + } + + setOutputs( + outputs: Record< + string, + OutputEmitterRef['emit'] | null | undefined + > + ) { + this.#outputRegistry.unsubscribeAll() + for (const prop in outputs) { + this.setOutput(prop, outputs[prop]) + } + } + + setOutput( + outputName: string, + emit: OutputEmitterRef['emit'] | undefined | null + ): void { + if (!this.#componentData.allowedOutputNames.includes(outputName)) return + if (!emit) { + this.#outputRegistry.unsubscribe(outputName) + return + } + + const hasListener = this.#outputRegistry.hasListener(outputName) + this.#outputRegistry.setListener(outputName, emit) + + if (hasListener) { + return + } + + const instance = this.componentRef.instance + const output = instance[outputName as keyof typeof instance] + if (output && output instanceof OutputEmitterRef) { + output.subscribe(value => { + this.#outputRegistry.getListener(outputName)?.(value) + }) + } + } +} + +class FlexRenderComponentOutputManager { + readonly #outputSubscribers: Record = {} + readonly #outputListeners: Record void> = {} + + readonly #valueDiffer: KeyValueDiffer< + string, + undefined | null | OutputEmitterRef['emit'] + > + + constructor(keyValueDiffers: KeyValueDiffers, initialOutputs: any) { + this.#valueDiffer = keyValueDiffers.find(initialOutputs).create() + if (initialOutputs) { + this.#valueDiffer.diff(initialOutputs) + } + } + + hasListener(outputName: string) { + return outputName in this.#outputListeners + } + + setListener(outputName: string, callback: (...args: any[]) => void) { + this.#outputListeners[outputName] = callback + } + + getListener(outputName: string) { + return this.#outputListeners[outputName] + } + + unsubscribeAll(): void { + for (const prop in this.#outputSubscribers) { + this.unsubscribe(prop) + } + } + + unsubscribe(outputName: string) { + if (outputName in this.#outputSubscribers) { + this.#outputSubscribers[outputName]?.unsubscribe() + delete this.#outputSubscribers[outputName] + delete this.#outputListeners[outputName] + } + } + + diff(outputs: Record['emit'] | undefined>) { + return this.#valueDiffer.diff(outputs ?? {}) + } +} diff --git a/packages/angular-table/src/flex-render/flex-render-component.ts b/packages/angular-table/src/flex-render/flex-render-component.ts new file mode 100644 index 0000000000..edf539cd35 --- /dev/null +++ b/packages/angular-table/src/flex-render/flex-render-component.ts @@ -0,0 +1,119 @@ +import { + ComponentMirror, + Injector, + InputSignal, + OutputEmitterRef, + reflectComponentType, + Type, +} from '@angular/core' + +type Inputs = { + [K in keyof T as T[K] extends InputSignal + ? K + : never]?: T[K] extends InputSignal ? R : never +} + +type Outputs = { + [K in keyof T as T[K] extends OutputEmitterRef + ? K + : never]?: T[K] extends OutputEmitterRef + ? OutputEmitterRef['emit'] + : never +} + +type OptionalKeys = K extends keyof T + ? T[K] extends Required[K] + ? undefined extends T[K] + ? K + : never + : K + : never + +interface FlexRenderRequiredOptions< + TInputs extends Record, + TOutputs extends Record, +> { + /** + * Component instance inputs. They will be set via [componentRef.setInput API](https://angular.dev/api/core/ComponentRef#setInput) + */ + inputs: TInputs + /** + * Component instance outputs. + */ + outputs?: TOutputs + /** + * Optional {@link Injector} that will be used when rendering the component + */ + injector?: Injector +} + +interface FlexRenderOptions< + TInputs extends Record, + TOutputs extends Record, +> { + /** + * Component instance inputs. They will be set via [componentRef.setInput API](https://angular.dev/api/core/ComponentRef#setInput) + */ + inputs?: TInputs + /** + * Component instance outputs. + */ + outputs?: TOutputs + /** + * Optional {@link Injector} that will be used when rendering the component + */ + injector?: Injector +} + +/** + * Helper function to create a [@link FlexRenderComponent] instance, with better type-safety. + * + * - options object must be passed when the given component instance contains at least one required signal input. + * - options/inputs is typed with the given component inputs + * - options/outputs is typed with the given component outputs + */ +export function flexRenderComponent< + TComponent = any, + TInputs extends Inputs = Inputs, + TOutputs extends Outputs = Outputs, +>( + component: Type, + ...options: OptionalKeys extends never + ? [FlexRenderOptions?] + : [FlexRenderRequiredOptions] +) { + const { inputs, injector, outputs } = options?.[0] ?? {} + return new FlexRenderComponent(component, inputs, injector, outputs) +} + +/** + * Wrapper class for a component that will be used as content for {@link FlexRenderDirective} + * + * Prefer {@link flexRenderComponent} helper for better type-safety + */ +export class FlexRenderComponent { + readonly mirror: ComponentMirror + readonly allowedInputNames: string[] = [] + readonly allowedOutputNames: string[] = [] + + constructor( + readonly component: Type, + readonly inputs?: Inputs, + readonly injector?: Injector, + readonly outputs?: Outputs + ) { + const mirror = reflectComponentType(component) + if (!mirror) { + throw new Error( + `[@tanstack-table/angular] The provided symbol is not a component` + ) + } + this.mirror = mirror + for (const input of this.mirror.inputs) { + this.allowedInputNames.push(input.propName) + } + for (const output of this.mirror.outputs) { + this.allowedOutputNames.push(output.propName) + } + } +} diff --git a/packages/angular-table/src/flex-render/view.ts b/packages/angular-table/src/flex-render/view.ts new file mode 100644 index 0000000000..9044b45abc --- /dev/null +++ b/packages/angular-table/src/flex-render/view.ts @@ -0,0 +1,153 @@ +import { FlexRenderComponentRef } from './flex-render-component-ref' +import { EmbeddedViewRef, TemplateRef, Type } from '@angular/core' +import type { FlexRenderContent } from '../flex-render' +import { FlexRenderComponent } from './flex-render-component' + +export type FlexRenderTypedContent = + | { kind: 'null' } + | { + kind: 'primitive' + content: string | number | Record + } + | { kind: 'flexRenderComponent'; content: FlexRenderComponent } + | { kind: 'templateRef'; content: TemplateRef } + | { kind: 'component'; content: Type } + +export function mapToFlexRenderTypedContent( + content: FlexRenderContent +): FlexRenderTypedContent { + if (content === null || content === undefined) { + return { kind: 'null' } + } + if (typeof content === 'string' || typeof content === 'number') { + return { kind: 'primitive', content } + } + if (content instanceof FlexRenderComponent) { + return { kind: 'flexRenderComponent', content } + } else if (content instanceof TemplateRef) { + return { kind: 'templateRef', content } + } else if (content instanceof Type) { + return { kind: 'component', content } + } else { + return { kind: 'primitive', content } + } +} + +export abstract class FlexRenderView< + TView extends FlexRenderComponentRef | EmbeddedViewRef | null, +> { + readonly view: TView + #previousContent: FlexRenderTypedContent | undefined + #content: FlexRenderTypedContent + + protected constructor( + initialContent: Exclude, + view: TView + ) { + this.#content = initialContent + this.view = view + } + + get previousContent(): FlexRenderTypedContent { + return this.#previousContent ?? { kind: 'null' } + } + + get content() { + return this.#content + } + + set content(content: FlexRenderTypedContent) { + this.#previousContent = this.#content + this.#content = content + } + + abstract updateProps(props: Record): void + + abstract dirtyCheck(): void + + abstract onDestroy(callback: Function): void +} + +export class FlexRenderTemplateView extends FlexRenderView< + EmbeddedViewRef +> { + constructor( + initialContent: Extract< + FlexRenderTypedContent, + { kind: 'primitive' | 'templateRef' } + >, + view: EmbeddedViewRef + ) { + super(initialContent, view) + } + + override updateProps(props: Record) { + this.view.markForCheck() + } + + override dirtyCheck() { + // Basically a no-op. When the view is created via EmbeddedViewRef, we don't need to do any manual update + // since this type of content has a proxy as a context, then every time the root component is checked for changes, + // the property getter will be re-evaluated. + // + // If in a future we need to manually mark the view as dirty, just uncomment next line + // this.view.markForCheck() + } + + override onDestroy(callback: Function) { + this.view.onDestroy(callback) + } +} + +export class FlexRenderComponentView extends FlexRenderView< + FlexRenderComponentRef +> { + constructor( + initialContent: Extract< + FlexRenderTypedContent, + { kind: 'component' | 'flexRenderComponent' } + >, + view: FlexRenderComponentRef + ) { + super(initialContent, view) + } + + override updateProps(props: Record) { + switch (this.content.kind) { + case 'component': { + this.view.setInputs(props) + break + } + case 'flexRenderComponent': { + // No-op. When FlexRenderFlags.PropsReferenceChanged is set, + // FlexRenderComponent will be updated into `dirtyCheck`. + break + } + } + } + + override dirtyCheck() { + switch (this.content.kind) { + case 'component': { + // Component context is currently valuated with the cell context. Since it's reference + // shouldn't change, we force mark the component as dirty in order to re-evaluate function invocation in view. + // NOTE: this should behave like having a component with ChangeDetectionStrategy.Default + this.view.markAsDirty() + break + } + case 'flexRenderComponent': { + // Given context instance will always have a different reference than the previous one, + // so instead of recreating the entire view, we will only update the current view + if (this.view.eqType(this.content.content)) { + this.view.update(this.content.content) + } + this.view.markAsDirty() + break + } + } + } + + override onDestroy(callback: Function) { + this.view.componentRef.onDestroy(callback) + } +} diff --git a/packages/angular-table/src/index.ts b/packages/angular-table/src/index.ts index 086671e0aa..278239217c 100644 --- a/packages/angular-table/src/index.ts +++ b/packages/angular-table/src/index.ts @@ -14,11 +14,17 @@ export * from '@tanstack/table-core' export { type FlexRenderContent, - FlexRenderComponent, FlexRenderDirective, + FlexRenderDirective as FlexRender, injectFlexRenderContext, + type FlexRenderComponentProps, } from './flex-render' +export { + FlexRenderComponent, + flexRenderComponent, +} from './flex-render/flex-render-component' + export function createAngularTable( options: () => TableOptions ): Table & Signal> { diff --git a/packages/angular-table/src/proxy.ts b/packages/angular-table/src/proxy.ts index 660bb6bc40..80e0fa191a 100644 --- a/packages/angular-table/src/proxy.ts +++ b/packages/angular-table/src/proxy.ts @@ -23,8 +23,11 @@ export function proxifyTable( */ if ( property.startsWith('get') && - !property.endsWith('Handler') && - !property.endsWith('Model') + !property.endsWith('Handler') + // e.g. getCoreRowModel, getSelectedRowModel etc. + // We need that after a signal change even `rowModel` may mark the view as dirty. + // This allows to always get the latest `getContext` value while using flexRender + // && !property.endsWith('Model') ) { const maybeFn = table[property] as Function | never if (typeof maybeFn === 'function') { diff --git a/packages/angular-table/tests/createAngularTable.test.ts b/packages/angular-table/tests/createAngularTable.test.ts index 344cfe2630..9101270afa 100644 --- a/packages/angular-table/tests/createAngularTable.test.ts +++ b/packages/angular-table/tests/createAngularTable.test.ts @@ -1,13 +1,24 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { type ColumnDef, createAngularTable, getCoreRowModel, + type RowSelectionState, type Table, + RowModel, + type PaginationState, + getPaginationRowModel, } from '../src/index' -import { Component, input, isSignal, signal, untracked } from '@angular/core' +import { + Component, + effect, + input, + isSignal, + signal, + untracked, +} from '@angular/core' import { TestBed } from '@angular/core/testing' -import { setSignalInputs } from './test-utils' +import { setFixtureSignalInputs } from './test-utils' describe('createAngularTable', () => { test('should render with required signal inputs', () => { @@ -27,7 +38,7 @@ describe('createAngularTable', () => { } const fixture = TestBed.createComponent(FakeComponent) - setSignalInputs(fixture.componentInstance, { + setFixtureSignalInputs(fixture, { data: [], }) @@ -73,6 +84,53 @@ describe('createAngularTable', () => { const tableProperty = table[name as keyof typeof table] expect(isSignal(tableProperty)).toEqual(expected) }) + + test('Row model is reactive', () => { + const coreRowModelFn = vi.fn<[RowModel]>() + const rowModelFn = vi.fn<[RowModel]>() + const pagination = signal({ + pageSize: 5, + pageIndex: 0, + }) + const data = Array.from({ length: 10 }, (_, i) => ({ + id: String(i), + title: `Title ${i}`, + })) + + TestBed.runInInjectionContext(() => { + const table = createAngularTable(() => ({ + data, + columns: columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getRowId: row => row.id, + state: { + pagination: pagination(), + }, + onPaginationChange: updater => { + typeof updater === 'function' + ? pagination.update(updater) + : pagination.set(updater) + }, + })) + + effect(() => coreRowModelFn(table.getCoreRowModel())) + effect(() => rowModelFn(table.getRowModel())) + + TestBed.flushEffects() + + pagination.set({ pageIndex: 0, pageSize: 3 }) + + TestBed.flushEffects() + }) + + expect(coreRowModelFn).toHaveBeenCalledOnce() + expect(coreRowModelFn.mock.calls[0]![0].rows.length).toEqual(10) + + expect(rowModelFn).toHaveBeenCalledTimes(2) + expect(rowModelFn.mock.calls[0]![0].rows.length).toEqual(5) + expect(rowModelFn.mock.calls[1]![0].rows.length).toEqual(3) + }) }) }) @@ -80,7 +138,8 @@ const testShouldBeComputedProperty = ( table: Table, propertyName: string ) => { - if (propertyName.endsWith('Handler') || propertyName.endsWith('Model')) { + if (propertyName.endsWith('Handler')) { + // || propertyName.endsWith('Model')) { return false } diff --git a/packages/angular-table/tests/flex-render-component.test-d.ts b/packages/angular-table/tests/flex-render-component.test-d.ts new file mode 100644 index 0000000000..dca2c7b989 --- /dev/null +++ b/packages/angular-table/tests/flex-render-component.test-d.ts @@ -0,0 +1,27 @@ +import { input } from '@angular/core' +import { test } from 'vitest' +import { flexRenderComponent } from '../src' + +test('Infer component inputs', () => { + class Test { + readonly input1 = input() + } + + // @ts-expect-error Must pass right type as a value + flexRenderComponent(Test, { inputs: { input1: 1 } }) + + // Input is optional so we can skip passing the property + flexRenderComponent(Test, { inputs: {} }) +}) + +test('Options are mandatory when given component has required inputs', () => { + class Test { + readonly input1 = input() + readonly requiredInput1 = input.required() + } + + // @ts-expect-error Required input + flexRenderComponent(Test) + + flexRenderComponent(Test, { inputs: { requiredInput1: 'My value' } }) +}) diff --git a/packages/angular-table/tests/flex-render-table.test.ts b/packages/angular-table/tests/flex-render-table.test.ts new file mode 100644 index 0000000000..6894a86b9f --- /dev/null +++ b/packages/angular-table/tests/flex-render-table.test.ts @@ -0,0 +1,449 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + signal, + type TemplateRef, + ViewChild, +} from '@angular/core' +import { + type CellContext, + ColumnDef, + type ExpandedState, + getCoreRowModel, + type TableOptions, + type TableState, +} from '@tanstack/table-core' +import { + createAngularTable, + FlexRender, + flexRenderComponent, + type FlexRenderContent, + injectFlexRenderContext, +} from '../src' +import { TestBed } from '@angular/core/testing' +import { describe, expect, test, vi } from 'vitest' +import { By } from '@angular/platform-browser' + +const defaultData: TestData[] = [{ id: '1', title: 'My title' }] as TestData[] + +const defaultColumns: ColumnDef[] = [ + { + id: 'title', + accessorKey: 'title', + header: 'Title', + cell: props => props.renderValue(), + }, +] + +describe('FlexRenderDirective', () => { + test.each([null, undefined])('Render %s as empty', value => { + const { fixture, dom } = createTestTable(defaultData, [ + { id: 'first_cell', header: 'header', cell: () => value }, + ]) + const row = dom.getBodyRow(0)! + const firstCell = row.querySelector('td') + + expect(firstCell!.matches(':empty')).toBe(true) + }) + + test.each([ + ['String column via function', () => 'My string'], + ['String column', () => 'My string'], + ['Number column via function', () => 0], + ['Number column', 0], + ])('Render primitive (%s)', (columnName, columnValue) => { + const { fixture, dom } = createTestTable(defaultData, [ + { id: 'first_cell', header: columnName, cell: columnValue as any }, + ]) + const row = dom.getBodyRow(0)! + const firstCell = row.querySelector('td') + + expectPrimitiveValueIs( + firstCell, + String(typeof columnValue === 'function' ? columnValue() : columnValue) + ) + }) + + test('Render TemplateRef', () => { + @Component({ + template: ` + Cell id: {{ context.cell.id }} + `, + standalone: true, + }) + class FakeTemplateRefComponent { + @ViewChild('template', { static: true }) + templateRef!: TemplateRef + } + + const templateRef = TestBed.createComponent(FakeTemplateRefComponent) + .componentInstance.templateRef + + const { dom } = createTestTable(defaultData, [ + { id: 'first_cell', header: 'Header', cell: () => templateRef }, + ]) + + const row = dom.getBodyRow(0)! + const firstCell = row.querySelector('td') + expect(firstCell!.textContent).toEqual('Cell id: 0_first_cell') + }) + + test('Render component with FlexRenderComponent', async () => { + const status = signal('Initial status') + + const { dom } = createTestTable(defaultData, [ + { + id: 'first_cell', + header: 'Status', + cell: () => { + return flexRenderComponent(TestBadgeComponent, { + inputs: { + status: status(), + }, + }) + }, + }, + ]) + + let row = dom.getBodyRow(0)! + let firstCell = row.querySelector('td') + expect(firstCell!.textContent).toEqual('Initial status') + + status.set('Updated status') + dom.clickTriggerCdButton() + + expect(firstCell!.textContent).toEqual('Updated status') + }) + + test('Render content reactively based on signal value', async () => { + const statusComponent = signal>('Initial status') + + const { dom, fixture } = createTestTable(defaultData, [ + { + id: 'first_cell', + header: 'Status', + cell: () => { + return statusComponent() + }, + }, + ]) + + let row = dom.getBodyRow(0)! + let firstCell = row.querySelector('td') + + expect(firstCell!.textContent).toEqual('Initial status') + + statusComponent.set(null) + fixture.detectChanges() + expect(firstCell!.matches(':empty')).toBe(true) + + statusComponent.set( + flexRenderComponent(TestBadgeComponent, { + inputs: { status: 'Updated status' }, + }) + ) + fixture.detectChanges() + const el = firstCell!.firstElementChild as HTMLElement + expect(el!.tagName).toEqual('APP-TEST-BADGE') + expect(el.textContent).toEqual('Updated status') + }) + + test('Cell content always get the latest context value', async () => { + const contextCaptor = vi.fn() + + const tableState = signal>({ + rowSelection: {}, + }) + + @Component({ + template: ``, + }) + class EmptyCell {} + + const { dom, fixture } = createTestTable( + defaultData, + [ + { + id: 'cell', + header: 'Header', + cell: context => { + contextCaptor(context) + return flexRenderComponent(EmptyCell) + }, + }, + ], + () => ({ + state: tableState(), + onStateChange: updater => { + return typeof updater === 'function' + ? tableState.update(updater as any) + : tableState.set(updater) + }, + }) + ) + + const latestCall = () => + contextCaptor.mock.lastCall[0] as CellContext + // TODO: As a perf improvement, check in a future if we can avoid evaluating the cell twice during the first render. + // This is caused due to the registration of the initial effect and the first #getContentValue() to detect the + // type of content to render. + expect(contextCaptor).toHaveBeenCalledTimes(2) + + expect(latestCall().row.getIsExpanded()).toEqual(false) + expect(latestCall().row.getIsSelected()).toEqual(false) + + fixture.componentInstance.table.getRow('0').toggleSelected(true) + dom.clickTriggerCdButton2() + expect(contextCaptor).toHaveBeenCalledTimes(3) + expect(latestCall().row.getIsSelected()).toEqual(true) + + fixture.componentInstance.table.getRow('0').toggleSelected(false) + fixture.componentInstance.table.getRow('0').toggleExpanded(true) + dom.clickTriggerCdButton2() + expect(contextCaptor).toHaveBeenCalledTimes(4) + expect(latestCall().row.getIsSelected()).toEqual(false) + expect(latestCall().row.getIsExpanded()).toEqual(true) + }) + + test('Support cell with component output', async () => { + const columns = [ + { + id: 'expand', + header: 'Expand', + cell: ({ row }) => { + return flexRenderComponent(ExpandCell, { + inputs: { expanded: row.getIsExpanded() }, + outputs: { toggleExpand: () => row.toggleExpanded() }, + }) + }, + }, + ] satisfies ColumnDef[] + + @Component({ + selector: 'expand-cell', + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + }) + class ExpandCell { + readonly expanded = input(false) + readonly toggleExpand = output() + } + + @Component({ + template: ` + + + @for (row of table.getRowModel().rows; track row.id) { + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + +
+ + + +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + selector: 'app-table-test', + imports: [FlexRender], + }) + class TestComponent { + readonly expandState = signal({}) + + readonly table = createAngularTable(() => { + return { + columns: columns, + data: defaultData, + getCoreRowModel: getCoreRowModel(), + state: { expanded: this.expandState() }, + onExpandedChange: updaterOrValue => { + typeof updaterOrValue === 'function' + ? this.expandState.update(updaterOrValue) + : this.expandState.set(updaterOrValue) + }, + } + }) + } + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + + const expandCell = fixture.debugElement.query(By.directive(ExpandCell)) + expect(fixture.componentInstance.expandState()).toEqual({}) + expect(expandCell.componentInstance.expanded()).toEqual(false) + + const buttonEl = expandCell.query(By.css('button')) + expect(buttonEl.nativeElement.innerHTML).toEqual(' Not expanded ') + buttonEl.triggerEventHandler('click') + + expect(fixture.componentInstance.expandState()).toEqual({ + '0': true, + }) + fixture.detectChanges() + expect(buttonEl.nativeElement.innerHTML).toEqual(' Expanded ') + }) +}) + +function expectPrimitiveValueIs( + cell: HTMLTableCellElement | null, + value: unknown +) { + expect(cell).not.toBeNull() + expect(cell!.matches(':empty')).toBe(false) + const span = cell!.querySelector('span')! + expect(span).toBeDefined() + expect(span.innerHTML).toEqual(value) +} + +type TestData = { id: string; title: string } + +export function createTestTable( + data: TestData[], + columns: ColumnDef[], + optionsFn?: () => Partial> +) { + @Component({ + template: ` + + + @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { + + @for (header of headerGroup.headers; track header.id) { + @if (!header.isPlaceholder) { + + } + } + + } + + + @for (row of table.getRowModel().rows; track row.id) { + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + +
+ + + +
+ + + +
+ + + + + + {{ count() }} + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + selector: 'app-table-test', + imports: [FlexRender], + }) + class TestComponent { + readonly columns = input[]>(columns) + readonly data = input(data) + + readonly count = signal(0) + + readonly table = createAngularTable(() => { + return { + ...(optionsFn?.() ?? {}), + columns: this.columns(), + data: this.data(), + getCoreRowModel: getCoreRowModel(), + } + }) + } + + const fixture = TestBed.createComponent(TestComponent) + + fixture.detectChanges() + + return { + fixture, + dom: { + clickTriggerCdButton() { + const btn = fixture.debugElement.query(By.css('button')) + btn.triggerEventHandler('click', null) + fixture.detectChanges() + }, + clickTriggerCdButton2() { + const btn = fixture.debugElement.queryAll(By.css('button'))[1]! + btn.triggerEventHandler('click', null) + fixture.detectChanges() + }, + getTable() { + return fixture.nativeElement.querySelector('table') as HTMLTableElement + }, + getHeader() { + return this.getTable().querySelector('thead') as HTMLTableSectionElement + }, + getHeaderRow() { + return this.getHeader().querySelector('tr') as HTMLTableRowElement + }, + getBody() { + return this.getTable().querySelector('tbody') as HTMLTableSectionElement + }, + getBodyRow(index: number) { + return this.getBody().rows.item(index) + }, + }, + } +} + +@Component({ + selector: 'app-test-badge', + template: `{{ status() }} `, + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class TestBadgeComponent { + readonly context = injectFlexRenderContext>() + + readonly status = input.required() +} diff --git a/packages/angular-table/tests/flex-render.test.ts b/packages/angular-table/tests/flex-render.test.ts index 2cb4ecde82..e5f49ecae6 100644 --- a/packages/angular-table/tests/flex-render.test.ts +++ b/packages/angular-table/tests/flex-render.test.ts @@ -1,13 +1,22 @@ -import { Component, ViewChild, input, type TemplateRef } from '@angular/core' +import { + Component, + ViewChild, + input, + type TemplateRef, + effect, +} from '@angular/core' import { TestBed, type ComponentFixture } from '@angular/core/testing' import { createColumnHelper } from '@tanstack/table-core' import { describe, expect, test } from 'vitest' import { - FlexRenderComponent, FlexRenderDirective, injectFlexRenderContext, } from '../src/flex-render' import { setFixtureSignalInput, setFixtureSignalInputs } from './test-utils' +import { + flexRenderComponent, + FlexRenderComponent, +} from '../src/flex-render/flex-render-component' interface Data { id: string @@ -108,7 +117,7 @@ describe('FlexRenderDirective', () => { const fixture = TestBed.createComponent(TestRenderComponent) setFixtureSignalInputs(fixture, { - content: () => new FlexRenderComponent(FakeComponent), + content: () => flexRenderComponent(FakeComponent), context: { property: 'Context value', }, @@ -124,13 +133,15 @@ describe('FlexRenderDirective', () => { // Skip for now, test framework (using ComponentRef.setInput) cannot recognize signal inputs // as component inputs - test.skip('should render custom components', () => { + test('should render custom components', () => { @Component({ template: `{{ row().property }}`, standalone: true, }) class FakeComponent { row = input.required<{ property: string }>() + + constructor() {} } const fixture = TestBed.createComponent(TestRenderComponent) diff --git a/packages/angular-table/tests/test-setup.ts b/packages/angular-table/tests/test-setup.ts index 8bd07572e8..79d7ec4fa9 100644 --- a/packages/angular-table/tests/test-setup.ts +++ b/packages/angular-table/tests/test-setup.ts @@ -1,4 +1,4 @@ -import '@analogjs/vite-plugin-angular/setup-vitest' +import '@analogjs/vitest-angular/setup-zone' import '@testing-library/jest-dom/vitest' import { diff --git a/packages/angular-table/tests/test-utils.ts b/packages/angular-table/tests/test-utils.ts index cfff0e7e69..11bb5fd858 100644 --- a/packages/angular-table/tests/test-utils.ts +++ b/packages/angular-table/tests/test-utils.ts @@ -8,27 +8,14 @@ type ToSignalInputUpdatableMap = { : never]: T[K] extends InputSignal ? Value : never } -/** - * Set required signal input value to component fixture - * @see https://github.com/angular/angular/issues/54013 - */ -export function setSignalInputs>( - component: T, - inputs: ToSignalInputUpdatableMap -) { - for (const inputKey in inputs) { - if (componentHasSignalInputProperty(component, inputKey)) { - signalSetFn(component[inputKey][SIGNAL], inputs[inputKey]) - } - } -} - export function setFixtureSignalInputs>( componentFixture: ComponentFixture, inputs: ToSignalInputUpdatableMap, options: { detectChanges: boolean } = { detectChanges: true } ) { - setSignalInputs(componentFixture.componentInstance, inputs) + for (const inputKey in inputs) { + componentFixture.componentRef.setInput(inputKey, inputs[inputKey]) + } if (options.detectChanges) { componentFixture.detectChanges() } @@ -43,7 +30,7 @@ export function setFixtureSignalInput< inputName: InputName, value: InputMaps[InputName] ) { - setSignalInputs(componentFixture.componentInstance, { + setFixtureSignalInputs(componentFixture, { [inputName]: value, } as ToSignalInputUpdatableMap) } diff --git a/packages/angular-table/tsconfig.test.json b/packages/angular-table/tsconfig.test.json new file mode 100644 index 0000000000..caf761f734 --- /dev/null +++ b/packages/angular-table/tsconfig.test.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "downlevelIteration": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "ES2015", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": ["ES2022", "dom"], + "types": ["vitest/globals"] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "include": ["src", "tests"] +} diff --git a/packages/angular-table/vitest.config.ts b/packages/angular-table/vitest.config.ts index 523b22e583..67bd4e6c7f 100644 --- a/packages/angular-table/vitest.config.ts +++ b/packages/angular-table/vitest.config.ts @@ -1,13 +1,23 @@ import { defineConfig } from 'vitest/config' import packageJson from './package.json' +import angular from '@analogjs/vite-plugin-angular' +import path from 'node:path' + +const angularPlugin = angular({ tsconfig: 'tsconfig.test.json', jit: true }) export default defineConfig({ + plugins: [ + // @ts-expect-error Fix types + angularPlugin, + ], test: { name: packageJson.name, - dir: './tests', watch: false, + pool: 'threads', environment: 'jsdom', setupFiles: ['./tests/test-setup.ts'], globals: true, + reporters: 'default', + disableConsoleIntercept: true, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf00fbd5ab..1bc8e40335 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2975,8 +2975,11 @@ importers: version: 2.6.3 devDependencies: '@analogjs/vite-plugin-angular': - specifier: ^1.3.1 - version: 1.5.0(@angular-devkit/build-angular@17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(@types/express@4.17.21)(@types/node@20.14.9)(chokidar@3.6.0)(karma@6.4.3)(ng-packagr@17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(tslib@2.6.3)(typescript@5.6.2))(typescript@5.6.2))(@ngtools/webpack@17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(typescript@5.6.2)(webpack@5.90.3(esbuild@0.21.5))) + specifier: ^1.11.0 + version: 1.11.0(@angular-devkit/build-angular@17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(@types/node@20.14.9)(ng-packagr@17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(tslib@2.6.3)(typescript@5.4.5))(typescript@5.4.5)) + '@analogjs/vitest-angular': + specifier: ^1.11.0 + version: 1.11.0(@analogjs/vite-plugin-angular@1.11.0(@angular-devkit/build-angular@17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(@types/node@20.14.9)(ng-packagr@17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(tslib@2.6.3)(typescript@5.4.5))(typescript@5.4.5)))(vitest@1.6.0(@types/node@20.14.9)(jsdom@24.1.0)(less@4.2.0)(sass@1.77.6)(terser@5.31.1)) '@angular/core': specifier: ^17.3.9 version: 17.3.11(rxjs@7.8.1)(zone.js@0.14.7) @@ -2988,7 +2991,13 @@ importers: version: 17.3.11(@angular/common@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7))(rxjs@7.8.1))(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7))(@angular/platform-browser@17.3.11(@angular/animations@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(@angular/common@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7))(rxjs@7.8.1))(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7))) ng-packagr: specifier: ^17.3.0 - version: 17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(tslib@2.6.3)(typescript@5.6.2) + version: 17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(tslib@2.6.3)(typescript@5.4.5) + typescript: + specifier: 5.4.5 + version: 5.4.5 + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@20.14.9)(jsdom@24.1.0)(less@4.2.0)(sass@1.77.6)(terser@5.31.1) packages/lit-table: dependencies: @@ -3078,7 +3087,7 @@ importers: devDependencies: vue: specifier: ^3.4.31 - version: 3.4.31(typescript@5.6.2) + version: 3.4.31(typescript@5.4.5) packages: @@ -3089,11 +3098,23 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@analogjs/vite-plugin-angular@1.5.0': - resolution: {integrity: sha512-e88LyREExpKc6KgpRUcKu8JJO0k6ZVDHYzsKEzRVxbz34e8D3LTGJxYAoiW6ucxW4irKbhHZlySll5+cdtvfXA==} + '@analogjs/vite-plugin-angular@1.11.0': + resolution: {integrity: sha512-18HSwOAVFQjwwRQPq9+duOoubuiut0INo55h0gV3v2TWS+tAP2wXP8SPyAG99P3ySNQB7zMUYE8mVsqLM+8bDA==} peerDependencies: - '@angular-devkit/build-angular': ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - '@ngtools/webpack': ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + '@angular-devkit/build-angular': ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@angular/build': ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@angular-devkit/build-angular': + optional: true + '@angular/build': + optional: true + + '@analogjs/vitest-angular@1.11.0': + resolution: {integrity: sha512-ZY0AOJfTV/eIOkx3QLCF0iOUnGfYNGpf45kVVrOcuam2II6w6YcgsqpRxDIR66wXi3gbvIxJzwB05v0Hc4+Gkw==} + peerDependencies: + '@analogjs/vite-plugin-angular': '*' + '@angular-devkit/architect': ^0.1500.0 || ^0.1600.0 || ^0.1700.0 || ^0.1800.0 || ^0.1900.0 || next + vitest: ^1.3.1 || ^2.0.0 '@angular-devkit/architect@0.1703.8': resolution: {integrity: sha512-lKxwG4/QABXZvJpqeSIn/kAwnY6MM9HdHZUV+o5o3UiTi+vO8rZApG4CCaITH3Bxebm7Nam7Xbk8RuukC5rq6g==} @@ -9835,11 +9856,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.6.2: - resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} - engines: {node: '>=14.17'} - hasBin: true - ua-parser-js@0.7.38: resolution: {integrity: sha512-fYmIy7fKTSFAhG3fuPlubeGaMoAd6r0rSnfEsO5nEY55i26KSLt9EH7PLQiiqPUhNqYIJvSkTy1oArIcXAbPbA==} @@ -9901,6 +9917,9 @@ packages: resolution: {integrity: sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -9970,6 +9989,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vfile-message@4.0.2: + resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@1.6.0: resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -10401,11 +10426,17 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - '@analogjs/vite-plugin-angular@1.5.0(@angular-devkit/build-angular@17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(@types/express@4.17.21)(@types/node@20.14.9)(chokidar@3.6.0)(karma@6.4.3)(ng-packagr@17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(tslib@2.6.3)(typescript@5.6.2))(typescript@5.6.2))(@ngtools/webpack@17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(typescript@5.6.2)(webpack@5.90.3(esbuild@0.21.5)))': + '@analogjs/vite-plugin-angular@1.11.0(@angular-devkit/build-angular@17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(@types/node@20.14.9)(ng-packagr@17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(tslib@2.6.3)(typescript@5.4.5))(typescript@5.4.5))': dependencies: - '@angular-devkit/build-angular': 17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(@types/express@4.17.21)(@types/node@20.14.9)(chokidar@3.6.0)(karma@6.4.3)(ng-packagr@17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(tslib@2.6.3)(typescript@5.6.2))(typescript@5.6.2) - '@ngtools/webpack': 17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(typescript@5.6.2)(webpack@5.90.3(esbuild@0.21.5)) ts-morph: 21.0.1 + vfile: 6.0.3 + optionalDependencies: + '@angular-devkit/build-angular': 17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(@types/express@4.17.21)(@types/node@20.14.9)(chokidar@3.6.0)(karma@6.4.3)(ng-packagr@17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(tslib@2.6.3)(typescript@5.4.5))(typescript@5.4.5) + + '@analogjs/vitest-angular@1.11.0(@analogjs/vite-plugin-angular@1.11.0(@angular-devkit/build-angular@17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(@types/node@20.14.9)(ng-packagr@17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(tslib@2.6.3)(typescript@5.4.5))(typescript@5.4.5)))(vitest@1.6.0(@types/node@20.14.9)(jsdom@24.1.0)(less@4.2.0)(sass@1.77.6)(terser@5.31.1))': + dependencies: + '@analogjs/vite-plugin-angular': 1.11.0(@angular-devkit/build-angular@17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(@types/node@20.14.9)(ng-packagr@17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5))(tslib@2.6.3)(typescript@5.4.5))(typescript@5.4.5)) + vitest: 1.6.0(@types/node@20.14.9)(jsdom@24.1.0)(less@4.2.0)(sass@1.77.6)(terser@5.31.1) '@angular-devkit/architect@0.1703.8(chokidar@3.6.0)': dependencies: @@ -10418,7 +10449,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.1703.8(chokidar@3.6.0) - '@angular-devkit/build-webpack': 0.1703.8(chokidar@3.6.0)(webpack-dev-server@4.15.1(webpack@5.90.3(esbuild@0.21.5)))(webpack@5.90.3(esbuild@0.21.5)) + '@angular-devkit/build-webpack': 0.1703.8(chokidar@3.6.0)(webpack-dev-server@4.15.1(webpack@5.90.3(esbuild@0.20.1)))(webpack@5.90.3(esbuild@0.20.1)) '@angular-devkit/core': 17.3.8(chokidar@3.6.0) '@angular/compiler-cli': 17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.4.5) '@babel/core': 7.24.0 @@ -10435,12 +10466,12 @@ snapshots: '@vitejs/plugin-basic-ssl': 1.1.0(vite@5.1.7(@types/node@20.14.9)(less@4.2.0)(sass@1.71.1)(terser@5.29.1)) ansi-colors: 4.1.3 autoprefixer: 10.4.18(postcss@8.4.35) - babel-loader: 9.1.3(@babel/core@7.24.0)(webpack@5.90.3(esbuild@0.21.5)) + babel-loader: 9.1.3(@babel/core@7.24.0)(webpack@5.90.3(esbuild@0.20.1)) babel-plugin-istanbul: 6.1.1 browserslist: 4.23.1 - copy-webpack-plugin: 11.0.0(webpack@5.90.3(esbuild@0.21.5)) + copy-webpack-plugin: 11.0.0(webpack@5.90.3(esbuild@0.20.1)) critters: 0.0.22 - css-loader: 6.10.0(webpack@5.90.3(esbuild@0.21.5)) + css-loader: 6.10.0(webpack@5.90.3(esbuild@0.20.1)) esbuild-wasm: 0.20.1 fast-glob: 3.3.2 http-proxy-middleware: 2.0.6(@types/express@4.17.21) @@ -10449,11 +10480,11 @@ snapshots: jsonc-parser: 3.2.1 karma-source-map-support: 1.4.0 less: 4.2.0 - less-loader: 11.1.0(less@4.2.0)(webpack@5.90.3(esbuild@0.21.5)) - license-webpack-plugin: 4.0.2(webpack@5.90.3(esbuild@0.21.5)) + less-loader: 11.1.0(less@4.2.0)(webpack@5.90.3(esbuild@0.20.1)) + license-webpack-plugin: 4.0.2(webpack@5.90.3(esbuild@0.20.1)) loader-utils: 3.2.1 magic-string: 0.30.8 - mini-css-extract-plugin: 2.8.1(webpack@5.90.3(esbuild@0.21.5)) + mini-css-extract-plugin: 2.8.1(webpack@5.90.3(esbuild@0.20.1)) mrmime: 2.0.0 open: 8.4.2 ora: 5.4.1 @@ -10465,9 +10496,9 @@ snapshots: resolve-url-loader: 5.0.0 rxjs: 7.8.1 sass: 1.71.1 - sass-loader: 14.1.1(sass@1.71.1)(webpack@5.90.3(esbuild@0.21.5)) + sass-loader: 14.1.1(sass@1.71.1)(webpack@5.90.3(esbuild@0.20.1)) semver: 7.6.0 - source-map-loader: 5.0.0(webpack@5.90.3(esbuild@0.21.5)) + source-map-loader: 5.0.0(webpack@5.90.3(esbuild@0.20.1)) source-map-support: 0.5.21 terser: 5.29.1 tree-kill: 1.2.2 @@ -10477,10 +10508,10 @@ snapshots: vite: 5.1.7(@types/node@20.14.9)(less@4.2.0)(sass@1.71.1)(terser@5.29.1) watchpack: 2.4.0 webpack: 5.90.3(esbuild@0.21.5) - webpack-dev-middleware: 6.1.2(webpack@5.90.3(esbuild@0.21.5)) - webpack-dev-server: 4.15.1(webpack@5.90.3(esbuild@0.21.5)) + webpack-dev-middleware: 6.1.2(webpack@5.90.3(esbuild@0.20.1)) + webpack-dev-server: 4.15.1(webpack@5.90.3(esbuild@0.20.1)) webpack-merge: 5.10.0 - webpack-subresource-integrity: 5.1.0(webpack@5.90.3(esbuild@0.21.5)) + webpack-subresource-integrity: 5.1.0(webpack@5.90.3(esbuild@0.20.1)) optionalDependencies: esbuild: 0.20.1 karma: 6.4.3 @@ -10504,102 +10535,12 @@ snapshots: - utf-8-validate - webpack-cli - '@angular-devkit/build-angular@17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(@types/express@4.17.21)(@types/node@20.14.9)(chokidar@3.6.0)(karma@6.4.3)(ng-packagr@17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(tslib@2.6.3)(typescript@5.6.2))(typescript@5.6.2)': + '@angular-devkit/build-webpack@0.1703.8(chokidar@3.6.0)(webpack-dev-server@4.15.1(webpack@5.90.3(esbuild@0.20.1)))(webpack@5.90.3(esbuild@0.20.1))': dependencies: - '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.1703.8(chokidar@3.6.0) - '@angular-devkit/build-webpack': 0.1703.8(chokidar@3.6.0)(webpack-dev-server@4.15.1(webpack@5.90.3(esbuild@0.21.5)))(webpack@5.90.3(esbuild@0.21.5)) - '@angular-devkit/core': 17.3.8(chokidar@3.6.0) - '@angular/compiler-cli': 17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2) - '@babel/core': 7.24.0 - '@babel/generator': 7.23.6 - '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-split-export-declaration': 7.22.6 - '@babel/plugin-transform-async-generator-functions': 7.23.9(@babel/core@7.24.0) - '@babel/plugin-transform-async-to-generator': 7.23.3(@babel/core@7.24.0) - '@babel/plugin-transform-runtime': 7.24.0(@babel/core@7.24.0) - '@babel/preset-env': 7.24.0(@babel/core@7.24.0) - '@babel/runtime': 7.24.0 - '@discoveryjs/json-ext': 0.5.7 - '@ngtools/webpack': 17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(typescript@5.6.2)(webpack@5.90.3(esbuild@0.21.5)) - '@vitejs/plugin-basic-ssl': 1.1.0(vite@5.1.7(@types/node@20.14.9)(less@4.2.0)(sass@1.71.1)(terser@5.29.1)) - ansi-colors: 4.1.3 - autoprefixer: 10.4.18(postcss@8.4.35) - babel-loader: 9.1.3(@babel/core@7.24.0)(webpack@5.90.3(esbuild@0.21.5)) - babel-plugin-istanbul: 6.1.1 - browserslist: 4.23.1 - copy-webpack-plugin: 11.0.0(webpack@5.90.3(esbuild@0.21.5)) - critters: 0.0.22 - css-loader: 6.10.0(webpack@5.90.3(esbuild@0.21.5)) - esbuild-wasm: 0.20.1 - fast-glob: 3.3.2 - http-proxy-middleware: 2.0.6(@types/express@4.17.21) - https-proxy-agent: 7.0.4 - inquirer: 9.2.15 - jsonc-parser: 3.2.1 - karma-source-map-support: 1.4.0 - less: 4.2.0 - less-loader: 11.1.0(less@4.2.0)(webpack@5.90.3(esbuild@0.21.5)) - license-webpack-plugin: 4.0.2(webpack@5.90.3(esbuild@0.21.5)) - loader-utils: 3.2.1 - magic-string: 0.30.8 - mini-css-extract-plugin: 2.8.1(webpack@5.90.3(esbuild@0.21.5)) - mrmime: 2.0.0 - open: 8.4.2 - ora: 5.4.1 - parse5-html-rewriting-stream: 7.0.0 - picomatch: 4.0.1 - piscina: 4.4.0 - postcss: 8.4.35 - postcss-loader: 8.1.1(postcss@8.4.35)(typescript@5.6.2)(webpack@5.90.3(esbuild@0.21.5)) - resolve-url-loader: 5.0.0 rxjs: 7.8.1 - sass: 1.71.1 - sass-loader: 14.1.1(sass@1.71.1)(webpack@5.90.3(esbuild@0.21.5)) - semver: 7.6.0 - source-map-loader: 5.0.0(webpack@5.90.3(esbuild@0.21.5)) - source-map-support: 0.5.21 - terser: 5.29.1 - tree-kill: 1.2.2 - tslib: 2.6.2 - typescript: 5.6.2 - undici: 6.11.1 - vite: 5.1.7(@types/node@20.14.9)(less@4.2.0)(sass@1.71.1)(terser@5.29.1) - watchpack: 2.4.0 webpack: 5.90.3(esbuild@0.21.5) - webpack-dev-middleware: 6.1.2(webpack@5.90.3(esbuild@0.21.5)) - webpack-dev-server: 4.15.1(webpack@5.90.3(esbuild@0.21.5)) - webpack-merge: 5.10.0 - webpack-subresource-integrity: 5.1.0(webpack@5.90.3(esbuild@0.21.5)) - optionalDependencies: - esbuild: 0.20.1 - karma: 6.4.3 - ng-packagr: 17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(tslib@2.6.3)(typescript@5.6.2) - transitivePeerDependencies: - - '@rspack/core' - - '@swc/core' - - '@types/express' - - '@types/node' - - bufferutil - - chokidar - - debug - - html-webpack-plugin - - lightningcss - - node-sass - - sass-embedded - - stylus - - sugarss - - supports-color - - uglify-js - - utf-8-validate - - webpack-cli - - '@angular-devkit/build-webpack@0.1703.8(chokidar@3.6.0)(webpack-dev-server@4.15.1(webpack@5.90.3(esbuild@0.21.5)))(webpack@5.90.3(esbuild@0.21.5))': - dependencies: - '@angular-devkit/architect': 0.1703.8(chokidar@3.6.0) - rxjs: 7.8.1 - webpack: 5.90.3(esbuild@0.21.5) - webpack-dev-server: 4.15.1(webpack@5.90.3(esbuild@0.21.5)) + webpack-dev-server: 4.15.1(webpack@5.90.3(esbuild@0.20.1)) transitivePeerDependencies: - chokidar @@ -10675,21 +10616,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2)': - dependencies: - '@angular/compiler': 17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)) - '@babel/core': 7.23.9 - '@jridgewell/sourcemap-codec': 1.5.0 - chokidar: 3.6.0 - convert-source-map: 1.9.0 - reflect-metadata: 0.2.2 - semver: 7.6.3 - tslib: 2.6.3 - typescript: 5.6.2 - yargs: 17.7.2 - transitivePeerDependencies: - - supports-color - '@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7))': dependencies: tslib: 2.6.3 @@ -13012,12 +12938,6 @@ snapshots: typescript: 5.4.5 webpack: 5.90.3(esbuild@0.21.5) - '@ngtools/webpack@17.3.8(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(typescript@5.6.2)(webpack@5.90.3(esbuild@0.21.5))': - dependencies: - '@angular/compiler-cli': 17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2) - typescript: 5.6.2 - webpack: 5.90.3(esbuild@0.21.5) - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -14205,12 +14125,6 @@ snapshots: '@vue/shared': 3.4.31 vue: 3.4.31(typescript@5.4.5) - '@vue/server-renderer@3.4.31(vue@3.4.31(typescript@5.6.2))': - dependencies: - '@vue/compiler-ssr': 3.4.31 - '@vue/shared': 3.4.31 - vue: 3.4.31(typescript@5.6.2) - '@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.4.5))': dependencies: '@vue/compiler-ssr': 3.5.13 @@ -14507,7 +14421,7 @@ snapshots: transitivePeerDependencies: - supports-color - babel-loader@9.1.3(@babel/core@7.24.0)(webpack@5.90.3(esbuild@0.21.5)): + babel-loader@9.1.3(@babel/core@7.24.0)(webpack@5.90.3(esbuild@0.20.1)): dependencies: '@babel/core': 7.24.0 find-cache-dir: 4.0.0 @@ -14942,7 +14856,7 @@ snapshots: dependencies: is-what: 3.14.1 - copy-webpack-plugin@11.0.0(webpack@5.90.3(esbuild@0.21.5)): + copy-webpack-plugin@11.0.0(webpack@5.90.3(esbuild@0.20.1)): dependencies: fast-glob: 3.3.2 glob-parent: 6.0.2 @@ -14980,15 +14894,6 @@ snapshots: optionalDependencies: typescript: 5.4.5 - cosmiconfig@9.0.0(typescript@5.6.2): - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - parse-json: 5.2.0 - optionalDependencies: - typescript: 5.6.2 - critters@0.0.22: dependencies: chalk: 4.1.2 @@ -15011,7 +14916,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-loader@6.10.0(webpack@5.90.3(esbuild@0.21.5)): + css-loader@6.10.0(webpack@5.90.3(esbuild@0.20.1)): dependencies: icss-utils: 5.1.0(postcss@8.4.39) postcss: 8.4.39 @@ -16491,7 +16396,7 @@ snapshots: picocolors: 1.0.1 shell-quote: 1.8.1 - less-loader@11.1.0(less@4.2.0)(webpack@5.90.3(esbuild@0.21.5)): + less-loader@11.1.0(less@4.2.0)(webpack@5.90.3(esbuild@0.20.1)): dependencies: klona: 2.0.6 less: 4.2.0 @@ -16516,7 +16421,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - license-webpack-plugin@4.0.2(webpack@5.90.3(esbuild@0.21.5)): + license-webpack-plugin@4.0.2(webpack@5.90.3(esbuild@0.20.1)): dependencies: webpack-sources: 3.2.3 optionalDependencies: @@ -16744,7 +16649,7 @@ snapshots: min-indent@1.0.1: {} - mini-css-extract-plugin@2.8.1(webpack@5.90.3(esbuild@0.21.5)): + mini-css-extract-plugin@2.8.1(webpack@5.90.3(esbuild@0.20.1)): dependencies: schema-utils: 4.2.0 tapable: 2.2.1 @@ -16902,38 +16807,6 @@ snapshots: optionalDependencies: esbuild: 0.20.2 rollup: 4.18.0 - optional: true - - ng-packagr@17.3.0(@angular/compiler-cli@17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2))(tslib@2.6.3)(typescript@5.6.2): - dependencies: - '@angular/compiler-cli': 17.3.11(@angular/compiler@17.3.11(@angular/core@17.3.11(rxjs@7.8.1)(zone.js@0.14.7)))(typescript@5.6.2) - '@rollup/plugin-json': 6.1.0(rollup@4.18.0) - '@rollup/plugin-node-resolve': 15.2.3(rollup@4.18.0) - '@rollup/wasm-node': 4.18.0 - ajv: 8.16.0 - ansi-colors: 4.1.3 - browserslist: 4.23.1 - cacache: 18.0.3 - chokidar: 3.6.0 - commander: 12.1.0 - convert-source-map: 2.0.0 - dependency-graph: 1.0.0 - esbuild-wasm: 0.20.2 - fast-glob: 3.3.2 - find-cache-dir: 3.3.2 - injection-js: 2.4.0 - jsonc-parser: 3.3.1 - less: 4.2.0 - ora: 5.4.1 - piscina: 4.6.1 - postcss: 8.4.39 - rxjs: 7.8.1 - sass: 1.77.6 - tslib: 2.6.3 - typescript: 5.6.2 - optionalDependencies: - esbuild: 0.20.2 - rollup: 4.18.0 nice-napi@1.0.2: dependencies: @@ -17373,17 +17246,6 @@ snapshots: transitivePeerDependencies: - typescript - postcss-loader@8.1.1(postcss@8.4.35)(typescript@5.6.2)(webpack@5.90.3(esbuild@0.21.5)): - dependencies: - cosmiconfig: 9.0.0(typescript@5.6.2) - jiti: 1.21.6 - postcss: 8.4.35 - semver: 7.6.3 - optionalDependencies: - webpack: 5.90.3(esbuild@0.21.5) - transitivePeerDependencies: - - typescript - postcss-media-query-parser@0.2.3: {} postcss-modules-extract-imports@3.1.0(postcss@8.4.39): @@ -17842,7 +17704,7 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.7.1 - sass-loader@14.1.1(sass@1.71.1)(webpack@5.90.3(esbuild@0.21.5)): + sass-loader@14.1.1(sass@1.71.1)(webpack@5.90.3(esbuild@0.20.1)): dependencies: neo-async: 2.6.2 optionalDependencies: @@ -18193,7 +18055,7 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.90.3(esbuild@0.21.5)): + source-map-loader@5.0.0(webpack@5.90.3(esbuild@0.20.1)): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.0 @@ -18605,8 +18467,6 @@ snapshots: typescript@5.4.5: {} - typescript@5.6.2: {} - ua-parser-js@0.7.38: {} uc.micro@2.1.0: {} @@ -18656,6 +18516,10 @@ snapshots: dependencies: imurmurhash: 0.1.4 + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.2 + universalify@0.1.2: {} universalify@0.2.0: {} @@ -18714,6 +18578,16 @@ snapshots: vary@1.1.2: {} + vfile-message@4.0.2: + dependencies: + '@types/unist': 3.0.2 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.2 + vfile-message: 4.0.2 + vite-node@1.6.0(@types/node@20.14.9)(less@4.2.0)(sass@1.77.6)(terser@5.31.1): dependencies: cac: 6.7.14 @@ -18886,16 +18760,6 @@ snapshots: optionalDependencies: typescript: 5.4.5 - vue@3.4.31(typescript@5.6.2): - dependencies: - '@vue/compiler-dom': 3.4.31 - '@vue/compiler-sfc': 3.4.31 - '@vue/runtime-dom': 3.4.31 - '@vue/server-renderer': 3.4.31(vue@3.4.31(typescript@5.6.2)) - '@vue/shared': 3.4.31 - optionalDependencies: - typescript: 5.6.2 - vue@3.5.13(typescript@5.4.5): dependencies: '@vue/compiler-dom': 3.5.13 @@ -18929,7 +18793,7 @@ snapshots: webidl-conversions@7.0.0: {} - webpack-dev-middleware@5.3.4(webpack@5.90.3(esbuild@0.21.5)): + webpack-dev-middleware@5.3.4(webpack@5.90.3(esbuild@0.20.1)): dependencies: colorette: 2.0.20 memfs: 3.5.3 @@ -18938,7 +18802,7 @@ snapshots: schema-utils: 4.2.0 webpack: 5.90.3(esbuild@0.21.5) - webpack-dev-middleware@6.1.2(webpack@5.90.3(esbuild@0.21.5)): + webpack-dev-middleware@6.1.2(webpack@5.90.3(esbuild@0.20.1)): dependencies: colorette: 2.0.20 memfs: 3.5.3 @@ -18948,7 +18812,7 @@ snapshots: optionalDependencies: webpack: 5.90.3(esbuild@0.21.5) - webpack-dev-server@4.15.1(webpack@5.90.3(esbuild@0.21.5)): + webpack-dev-server@4.15.1(webpack@5.90.3(esbuild@0.20.1)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -18978,7 +18842,7 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 5.3.4(webpack@5.90.3(esbuild@0.21.5)) + webpack-dev-middleware: 5.3.4(webpack@5.90.3(esbuild@0.20.1)) ws: 8.17.1 optionalDependencies: webpack: 5.90.3(esbuild@0.21.5) @@ -18996,7 +18860,7 @@ snapshots: webpack-sources@3.2.3: {} - webpack-subresource-integrity@5.1.0(webpack@5.90.3(esbuild@0.21.5)): + webpack-subresource-integrity@5.1.0(webpack@5.90.3(esbuild@0.20.1)): dependencies: typed-assert: 1.0.9 webpack: 5.90.3(esbuild@0.21.5)