UNPKG

@tanstack/angular-table

Version:

Headless UI for building powerful tables & datagrids for Angular.

742 lines (730 loc) 28.7 kB
import * as i0 from '@angular/core'; import { untracked, computed, InjectionToken, inject, reflectComponentType, ViewContainerRef, Injectable, KeyValueDiffers, ChangeDetectorRef, OutputEmitterRef, TemplateRef, Type, Injector, runInInjectionContext, effect, Directive, Inject, Input, signal } from '@angular/core'; import { memo, createTable } from '@tanstack/table-core'; export * from '@tanstack/table-core'; /** * Implementation from @tanstack/angular-query * {@link https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/util/lazy-init/lazy-init.ts} */ function lazyInit(initializer) { let object = null; const initializeObject = () => { if (!object) { object = untracked(() => initializer()); } }; queueMicrotask(() => initializeObject()); const table = () => { }; return new Proxy(table, { apply(target, thisArg, argArray) { initializeObject(); if (typeof object === 'function') { return Reflect.apply(object, thisArg, argArray); } return Reflect.apply(target, thisArg, argArray); }, get(_, prop, receiver) { initializeObject(); return Reflect.get(object, prop, receiver); }, has(_, prop) { initializeObject(); return Reflect.has(object, prop); }, ownKeys() { initializeObject(); return Reflect.ownKeys(object); }, getOwnPropertyDescriptor() { return { enumerable: true, configurable: true, }; }, }); } function proxifyTable(tableSignal) { const internalState = tableSignal; return new Proxy(internalState, { apply() { return tableSignal(); }, get(target, property) { if (target[property]) { return target[property]; } const table = untracked(tableSignal); /** * Attempt to convert all accessors into computed ones, * excluding handlers as they do not retain any reactive value */ if (property.startsWith('get') && !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]; if (typeof maybeFn === 'function') { Object.defineProperty(target, property, { value: toComputed(tableSignal, maybeFn), configurable: true, enumerable: true, }); return target[property]; } } // @ts-expect-error return (target[property] = table[property]); }, has(_, prop) { return !!untracked(tableSignal)[prop]; }, ownKeys() { return Reflect.ownKeys(untracked(tableSignal)); }, getOwnPropertyDescriptor() { return { enumerable: true, configurable: true, }; }, }); } /** * Here we'll handle all type of accessors: * - 0 argument -> e.g. table.getCanNextPage()) * - 0~1 arguments -> e.g. table.getIsSomeRowsPinned(position?) * - 1 required argument -> e.g. table.getColumn(columnId) * - 1+ argument -> e.g. table.getRow(id, searchAll?) * * Since we are not able to detect automatically the accessors parameters, * we'll wrap all accessors into a cached function wrapping a computed * that return it's value based on the given parameters */ function toComputed(signal, fn) { const hasArgs = fn.length > 0; if (!hasArgs) { return computed(() => { void signal(); return fn(); }); } const computedCache = {}; return (...argsArray) => { const serializedArgs = serializeArgs(...argsArray); if (computedCache.hasOwnProperty(serializedArgs)) { return computedCache[serializedArgs]?.(); } const computedSignal = computed(() => { void signal(); return fn(...argsArray); }); computedCache[serializedArgs] = computedSignal; return computedSignal(); }; } function serializeArgs(...args) { return JSON.stringify(args); } const FlexRenderComponentProps = new InjectionToken('[@tanstack/angular-table] Flex render component context props'); function injectFlexRenderContext() { return inject(FlexRenderComponentProps); } /** * Flags used to manage and optimize the rendering lifecycle of content of the cell, * while using FlexRenderDirective. */ var FlexRenderFlags; (function (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. */ FlexRenderFlags[FlexRenderFlags["ViewFirstRender"] = 1] = "ViewFirstRender"; /** * Represents a state where the view is not dirty, meaning no changes require rendering updates. */ FlexRenderFlags[FlexRenderFlags["Pristine"] = 2] = "Pristine"; /** * 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. */ FlexRenderFlags[FlexRenderFlags["ContentChanged"] = 4] = "ContentChanged"; /** * 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 */ FlexRenderFlags[FlexRenderFlags["PropsReferenceChanged"] = 8] = "PropsReferenceChanged"; /** * Indicates that the current rendered view needs to be checked for changes. */ FlexRenderFlags[FlexRenderFlags["DirtyCheck"] = 16] = "DirtyCheck"; /** * Indicates that a signal within the `content(props)` result has changed */ FlexRenderFlags[FlexRenderFlags["DirtySignal"] = 32] = "DirtySignal"; /** * Indicates that the first render effect has been checked at least one time. */ FlexRenderFlags[FlexRenderFlags["RenderEffectChecked"] = 64] = "RenderEffectChecked"; })(FlexRenderFlags || (FlexRenderFlags = {})); /** * 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 */ function flexRenderComponent(component, ...options) { 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 */ class FlexRenderComponent { component; inputs; injector; outputs; mirror; allowedInputNames = []; allowedOutputNames = []; constructor(component, inputs, injector, outputs) { this.component = component; this.inputs = inputs; this.injector = injector; this.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); } } } class FlexRenderComponentFactory { #viewContainerRef = inject(ViewContainerRef); createComponent(flexRenderComponent, componentInjector) { 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; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.11", ngImport: i0, type: FlexRenderComponentFactory, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.11", ngImport: i0, type: FlexRenderComponentFactory }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.11", ngImport: i0, type: FlexRenderComponentFactory, decorators: [{ type: Injectable }] }); class FlexRenderComponentRef { componentRef; componentInjector; #keyValueDiffersFactory; #componentData; #inputValueDiffer; #outputRegistry; constructor(componentRef, componentData, componentInjector) { this.componentRef = componentRef; this.componentInjector = componentInjector; 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) { 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) { return compare.component === this.component; } /** * Tries to update current component refs input by the new given content component. */ update(content) { 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() { this.componentRef.injector.get(ChangeDetectorRef).markForCheck(); } setInputs(inputs) { for (const prop in inputs) { this.setInput(prop, inputs[prop]); } } setInput(key, value) { if (this.#componentData.allowedInputNames.includes(key)) { this.componentRef.setInput(key, value); } } setOutputs(outputs) { this.#outputRegistry.unsubscribeAll(); for (const prop in outputs) { this.setOutput(prop, outputs[prop]); } } setOutput(outputName, emit) { 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]; if (output && output instanceof OutputEmitterRef) { output.subscribe(value => { this.#outputRegistry.getListener(outputName)?.(value); }); } } } class FlexRenderComponentOutputManager { #outputSubscribers = {}; #outputListeners = {}; #valueDiffer; constructor(keyValueDiffers, initialOutputs) { this.#valueDiffer = keyValueDiffers.find(initialOutputs).create(); if (initialOutputs) { this.#valueDiffer.diff(initialOutputs); } } hasListener(outputName) { return outputName in this.#outputListeners; } setListener(outputName, callback) { this.#outputListeners[outputName] = callback; } getListener(outputName) { return this.#outputListeners[outputName]; } unsubscribeAll() { for (const prop in this.#outputSubscribers) { this.unsubscribe(prop); } } unsubscribe(outputName) { if (outputName in this.#outputSubscribers) { this.#outputSubscribers[outputName]?.unsubscribe(); delete this.#outputSubscribers[outputName]; delete this.#outputListeners[outputName]; } } diff(outputs) { return this.#valueDiffer.diff(outputs ?? {}); } } function mapToFlexRenderTypedContent(content) { 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 }; } } class FlexRenderView { view; #previousContent; #content; constructor(initialContent, view) { this.#content = initialContent; this.view = view; } get previousContent() { return this.#previousContent ?? { kind: 'null' }; } get content() { return this.#content; } set content(content) { this.#previousContent = this.#content; this.#content = content; } } class FlexRenderTemplateView extends FlexRenderView { constructor(initialContent, view) { super(initialContent, view); } updateProps(props) { this.view.markForCheck(); } 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() } onDestroy(callback) { this.view.onDestroy(callback); } } class FlexRenderComponentView extends FlexRenderView { constructor(initialContent, view) { super(initialContent, view); } updateProps(props) { 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; } } } 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; } } } onDestroy(callback) { this.view.componentRef.onDestroy(callback); } } class FlexRenderDirective { viewContainerRef; templateRef; #flexRenderComponentFactory = inject(FlexRenderComponentFactory); #changeDetectorRef = inject(ChangeDetectorRef); content = undefined; props = {}; injector = inject(Injector); renderFlags = FlexRenderFlags.ViewFirstRender; renderView = null; #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(viewContainerRef, templateRef) { this.viewContainerRef = viewContainerRef; this.templateRef = templateRef; } ngOnChanges(changes) { if (changes['props']) { this.renderFlags |= FlexRenderFlags.PropsReferenceChanged; } if (changes['content']) { this.renderFlags |= FlexRenderFlags.ContentChanged | FlexRenderFlags.ViewFirstRender; this.update(); } } ngDoCheck() { 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; } this.renderFlags |= FlexRenderFlags.DirtyCheck; const latestContent = this.#getContentValue(); if (latestContent.kind === 'null' || !this.renderView) { this.renderFlags |= FlexRenderFlags.ContentChanged; } else { 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); } } #currentEffectRef = 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 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 }); } } #shouldRecreateEntireView() { return (this.renderFlags & FlexRenderFlags.ContentChanged & FlexRenderFlags.ViewFirstRender); } #renderViewByContent(content) { 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; } } #renderStringContent(template) { const context = () => { return typeof this.content === 'string' || typeof this.content === 'number' ? this.content : this.content?.(this.props); }; const ref = this.viewContainerRef.createEmbeddedView(this.templateRef, { get $implicit() { return context(); }, }); return new FlexRenderTemplateView(template, ref); } #renderTemplateRefContent(template) { const latestContext = () => this.props; const view = this.viewContainerRef.createEmbeddedView(template.content, { get $implicit() { return latestContext(); }, }); return new FlexRenderTemplateView(template, view); } #renderComponent(flexRenderComponent) { const { inputs, outputs, injector } = flexRenderComponent.content; const getContext = () => this.props; const proxy = new Proxy(this.props, { get: (_, key) => getContext()[key], }); const componentInjector = Injector.create({ parent: injector ?? this.injector, providers: [{ provide: FlexRenderComponentProps, useValue: proxy }], }); const view = this.#flexRenderComponentFactory.createComponent(flexRenderComponent.content, componentInjector); return new FlexRenderComponentView(flexRenderComponent, view); } #renderCustomComponent(component) { const view = this.#flexRenderComponentFactory.createComponent(flexRenderComponent(component.content, { inputs: this.props, injector: this.injector, }), this.injector); return new FlexRenderComponentView(component, view); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.11", ngImport: i0, type: FlexRenderDirective, deps: [{ token: ViewContainerRef }, { token: TemplateRef }], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.11", type: FlexRenderDirective, isStandalone: true, selector: "[flexRender]", inputs: { content: ["flexRender", "content"], props: ["flexRenderProps", "props"], injector: ["flexRenderInjector", "injector"] }, providers: [FlexRenderComponentFactory], usesOnChanges: true, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.11", ngImport: i0, type: FlexRenderDirective, decorators: [{ type: Directive, args: [{ selector: '[flexRender]', standalone: true, providers: [FlexRenderComponentFactory], }] }], ctorParameters: () => [{ type: i0.ViewContainerRef, decorators: [{ type: Inject, args: [ViewContainerRef] }] }, { type: i0.TemplateRef, decorators: [{ type: Inject, args: [TemplateRef] }] }], propDecorators: { content: [{ type: Input, args: [{ required: true, alias: 'flexRender' }] }], props: [{ type: Input, args: [{ required: true, alias: 'flexRenderProps' }] }], injector: [{ type: Input, args: [{ required: false, alias: 'flexRenderInjector' }] }] } }); function createAngularTable(options) { return lazyInit(() => { const resolvedOptions = { state: {}, onStateChange: () => { }, renderFallbackValue: null, ...options(), }; const table = createTable(resolvedOptions); // By default, manage table state here using the table's initial state const state = signal(table.initialState); // Compose table options using computed. // This is to allow `tableSignal` to listen and set table option const updatedOptions = computed(() => { // listen to table state changed const tableState = state(); // listen to input options changed const tableOptions = options(); return { ...table.options, ...resolvedOptions, ...tableOptions, state: { ...tableState, ...tableOptions.state }, onStateChange: updater => { const value = updater instanceof Function ? updater(tableState) : updater; state.set(value); resolvedOptions.onStateChange?.(updater); }, }; }); // convert table instance to signal for proxify to listen to any table state and options changes const tableSignal = computed(() => { table.setOptions(updatedOptions()); return table; }, { equal: () => false, }); // proxify Table instance to provide ability for consumer to listen to any table state changes return proxifyTable(tableSignal); }); } /** * Generated bundle index. Do not edit. */ export { FlexRenderDirective as FlexRender, FlexRenderComponent, FlexRenderDirective, createAngularTable, flexRenderComponent, injectFlexRenderContext }; //# sourceMappingURL=tanstack-angular-table.mjs.map