diff --git a/examples/angular/basic-app-table/.devcontainer/devcontainer.json b/examples/angular/basic-app-table/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..36f47d8762 --- /dev/null +++ b/examples/angular/basic-app-table/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Node.js", + "image": "mcr.microsoft.com/devcontainers/javascript-node:18" +} diff --git a/examples/angular/basic-app-table/.editorconfig b/examples/angular/basic-app-table/.editorconfig new file mode 100644 index 0000000000..59d9a3a3e7 --- /dev/null +++ b/examples/angular/basic-app-table/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/examples/angular/basic-app-table/.gitignore b/examples/angular/basic-app-table/.gitignore new file mode 100644 index 0000000000..0711527ef9 --- /dev/null +++ b/examples/angular/basic-app-table/.gitignore @@ -0,0 +1,42 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/examples/angular/basic-app-table/README.md b/examples/angular/basic-app-table/README.md new file mode 100644 index 0000000000..5da97a87d1 --- /dev/null +++ b/examples/angular/basic-app-table/README.md @@ -0,0 +1,27 @@ +# Basic + +This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.1.2. + +## Development server + +Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. + +## Code scaffolding + +Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. + +## Build + +Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. + +## Running unit tests + +Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). + +## Running end-to-end tests + +Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. + +## Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. diff --git a/examples/angular/basic-app-table/angular.json b/examples/angular/basic-app-table/angular.json new file mode 100644 index 0000000000..b121ae5757 --- /dev/null +++ b/examples/angular/basic-app-table/angular.json @@ -0,0 +1,107 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "basic-app-table": { + "cli": { + "cache": { + "enabled": false + } + }, + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "inlineTemplate": true, + "inlineStyle": true, + "skipTests": true, + "style": "scss" + }, + "@schematics/angular:class": { + "skipTests": true + }, + "@schematics/angular:directive": { + "skipTests": true + }, + "@schematics/angular:guard": { + "skipTests": true + }, + "@schematics/angular:interceptor": { + "skipTests": true + }, + "@schematics/angular:pipe": { + "skipTests": true + }, + "@schematics/angular:resolver": { + "skipTests": true + }, + "@schematics/angular:service": { + "skipTests": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "outputPath": "dist/basic-app-table", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": ["src/favicon.ico", "src/assets"], + "styles": ["src/styles.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "basic-app-table:build:production" + }, + "development": { + "buildTarget": "basic-app-table:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular/build:extract-i18n", + "options": { + "buildTarget": "basic-app-table:build" + } + } + } + } + }, + "cli": { + "analytics": false + } +} diff --git a/examples/angular/basic-app-table/package.json b/examples/angular/basic-app-table/package.json new file mode 100644 index 0000000000..7ab7b072af --- /dev/null +++ b/examples/angular/basic-app-table/package.json @@ -0,0 +1,34 @@ +{ + "name": "tanstack-table-example-angular-basic", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test", + "lint": "eslint ./src" + }, + "private": true, + "dependencies": { + "@angular/common": "^21.0.6", + "@angular/compiler": "^21.0.6", + "@angular/core": "^21.0.6", + "@angular/forms": "^21.0.6", + "@angular/platform-browser": "^21.0.6", + "@angular/platform-browser-dynamic": "^21.0.6", + "@angular/router": "^21.0.6", + "@tanstack/angular-table": "^9.0.0-alpha.10", + "rxjs": "~7.8.2", + "zone.js": "~0.16.0" + }, + "devDependencies": { + "@angular/build": "^21.0.4", + "@angular/cli": "^21.0.4", + "@angular/compiler-cli": "^21.0.6", + "@types/jasmine": "~5.1.13", + "jasmine-core": "~5.13.0", + "tslib": "^2.8.1", + "typescript": "5.9.3" + } +} diff --git a/examples/angular/basic-app-table/src/app/app.component.html b/examples/angular/basic-app-table/src/app/app.component.html new file mode 100644 index 0000000000..5a7105c76b --- /dev/null +++ b/examples/angular/basic-app-table/src/app/app.component.html @@ -0,0 +1,42 @@ +
+ + + @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.getAllCells(); track cell.id) { + + } + + } + + + @for (footerGroup of table.getFooterGroups(); track footerGroup.id) { + + @for (footer of footerGroup.headers; track footer.id) { + + } + + } + +
+
+
+
+
+ {{ footer }} +
+ +
+ +
diff --git a/examples/angular/basic-app-table/src/app/app.component.ts b/examples/angular/basic-app-table/src/app/app.component.ts new file mode 100644 index 0000000000..b1af1512db --- /dev/null +++ b/examples/angular/basic-app-table/src/app/app.component.ts @@ -0,0 +1,119 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core' +import { FlexRender, createTableHook } from '@tanstack/angular-table' + +// This example uses the new `createTableHook` method to create a re-usable table hook factory instead of independently +// using the standalone `useTable` hook and `createColumnHelper` method. You can choose to use either way. + +// 1. Define what the shape of your data will be for each row +type Person = { + firstName: string + lastName: string + age: number + visits: number + status: string + progress: number +} + +// 2. Create some dummy data with a stable reference (this could be an API response stored in useState or similar) +const defaultData: Array = [ + { + firstName: 'tanner', + lastName: 'linsley', + age: 24, + visits: 100, + status: 'In Relationship', + progress: 50, + }, + { + firstName: 'tandy', + lastName: 'miller', + age: 40, + visits: 40, + status: 'Single', + progress: 80, + }, + { + firstName: 'joe', + lastName: 'dirte', + age: 45, + visits: 20, + status: 'Complicated', + progress: 10, + }, + { + firstName: 'kevin', + lastName: 'vandy', + age: 28, + visits: 100, + status: 'Single', + progress: 70, + }, +] + +// 3. New in V9! Tell the table which features and row models we want to use. +// In this case, this will be a basic table with no additional features +const { injectAppTable, createAppColumnHelper } = createTableHook({ + _features: {}, + _rowModels: {}, // client-side row models. `Core` row model is now included by default, but you can still override it here + debugTable: true, +}) + +// 4. Create a helper object to help define our columns +const columnHelper = createAppColumnHelper() + +// 5. Define the columns for your table with a stable reference (in this case, defined statically outside of a react component) +const columns = columnHelper.columns([ + // accessorKey method (most common for simple use-cases) + columnHelper.accessor('firstName', { + cell: (info) => info.getValue(), + footer: (info) => info.column.id, + }), + // accessorFn used (alternative) along with a custom id + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + cell: (info) => `${info.getValue()}`, + header: () => `Last Name`, + footer: (info) => info.column.id, + }), + // accessorFn used to transform the data + columnHelper.accessor((row) => Number(row.age), { + id: 'age', + header: () => 'Age', + cell: (info) => info.renderValue(), + footer: (info) => info.column.id, + }), + columnHelper.accessor('visits', { + header: () => `Visits`, + footer: (info) => info.column.id, + }), + columnHelper.accessor('status', { + header: 'Status', + footer: (info) => info.column.id, + }), + columnHelper.accessor('progress', { + header: 'Profile Progress', + footer: (info) => info.column.id, + }), +]) + +@Component({ + selector: 'app-root', + imports: [FlexRender], + templateUrl: './app.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppComponent { + readonly data = signal>(defaultData) + + // 6. Create the table instance with the required columns and data. + // Features and row models are already defined in the createTableHook call above + readonly table = injectAppTable(() => ({ + columns, + data: this.data(), + // add additional table options here or in the createTableHook call above + })) + + rerender() { + this.data.set([...defaultData.sort(() => -1)]) + } +} diff --git a/examples/angular/basic-app-table/src/app/app.config.ts b/examples/angular/basic-app-table/src/app/app.config.ts new file mode 100644 index 0000000000..f997e614ac --- /dev/null +++ b/examples/angular/basic-app-table/src/app/app.config.ts @@ -0,0 +1,5 @@ +import type { ApplicationConfig } from '@angular/core' + +export const appConfig: ApplicationConfig = { + providers: [], +} diff --git a/examples/angular/basic-app-table/src/assets/.gitkeep b/examples/angular/basic-app-table/src/assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/angular/basic-app-table/src/favicon.ico b/examples/angular/basic-app-table/src/favicon.ico new file mode 100644 index 0000000000..57614f9c96 Binary files /dev/null and b/examples/angular/basic-app-table/src/favicon.ico differ diff --git a/examples/angular/basic-app-table/src/index.html b/examples/angular/basic-app-table/src/index.html new file mode 100644 index 0000000000..e4955ab6cb --- /dev/null +++ b/examples/angular/basic-app-table/src/index.html @@ -0,0 +1,14 @@ + + + + + Basic App Table + + + + + + + + + diff --git a/examples/angular/basic-app-table/src/main.ts b/examples/angular/basic-app-table/src/main.ts new file mode 100644 index 0000000000..c3d8f9af99 --- /dev/null +++ b/examples/angular/basic-app-table/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser' +import { appConfig } from './app/app.config' +import { AppComponent } from './app/app.component' + +bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)) diff --git a/examples/angular/basic-app-table/src/styles.scss b/examples/angular/basic-app-table/src/styles.scss new file mode 100644 index 0000000000..cda3113f7d --- /dev/null +++ b/examples/angular/basic-app-table/src/styles.scss @@ -0,0 +1,32 @@ +html { + font-family: sans-serif; + font-size: 14px; +} + +table { + border: 1px solid lightgray; +} + +tbody { + border-bottom: 1px solid lightgray; +} + +th { + border-bottom: 1px solid lightgray; + border-right: 1px solid lightgray; + padding: 2px 4px; +} + +tfoot { + color: gray; +} + +tfoot th { + font-weight: normal; +} + +.pagination-actions { + margin: 10px; + display: flex; + gap: 10px; +} diff --git a/examples/angular/basic-app-table/tsconfig.app.json b/examples/angular/basic-app-table/tsconfig.app.json new file mode 100644 index 0000000000..84f1f992d2 --- /dev/null +++ b/examples/angular/basic-app-table/tsconfig.app.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"] +} diff --git a/examples/angular/basic-app-table/tsconfig.json b/examples/angular/basic-app-table/tsconfig.json new file mode 100644 index 0000000000..b58d3efc71 --- /dev/null +++ b/examples/angular/basic-app-table/tsconfig.json @@ -0,0 +1,31 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "src", + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": ["ES2022", "dom"] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/examples/angular/basic-app-table/tsconfig.spec.json b/examples/angular/basic-app-table/tsconfig.spec.json new file mode 100644 index 0000000000..47e3dd7551 --- /dev/null +++ b/examples/angular/basic-app-table/tsconfig.spec.json @@ -0,0 +1,9 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": ["jasmine"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/examples/angular/basic/src/app/app.component.html b/examples/angular/basic/src/app/app.component.html index 8f105b6da5..5a7105c76b 100644 --- a/examples/angular/basic/src/app/app.component.html +++ b/examples/angular/basic/src/app/app.component.html @@ -5,16 +5,8 @@ @for (header of headerGroup.headers; track header.id) { @if (!header.isPlaceholder) { - - -
-
+ +
} } @@ -25,16 +17,8 @@ @for (row of table.getRowModel().rows; track row.id) { @for (cell of row.getAllCells(); track cell.id) { - - -
-
+ +
} @@ -44,16 +28,8 @@ @for (footerGroup of table.getFooterGroups(); track footerGroup.id) { @for (footer of footerGroup.headers; track footer.id) { - - - {{ footer }} - + + {{ footer }} } diff --git a/examples/angular/basic/src/app/app.component.ts b/examples/angular/basic/src/app/app.component.ts index 0150b025ec..eddc314cfb 100644 --- a/examples/angular/basic/src/app/app.component.ts +++ b/examples/angular/basic/src/app/app.component.ts @@ -2,6 +2,9 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core' import { FlexRender, injectTable, tableFeatures } from '@tanstack/angular-table' import type { ColumnDef } from '@tanstack/angular-table' +// This example uses the classic standalone `useTable` hook to create a table without the new `createTableHelper` util. + +// 1. Define what the shape of your data will be for each row type Person = { firstName: string lastName: string @@ -11,6 +14,7 @@ type Person = { progress: number } +// 2. Create some dummy data const defaultData: Array = [ { firstName: 'tanner', @@ -38,8 +42,12 @@ const defaultData: Array = [ }, ] -const _features = tableFeatures({}) +// 3. New in V9! Tell the table which features and row models we want to use. +// In this case, this will be a basic table with no additional features +const _features = tableFeatures({}) // util method to create sharable TFeatures object/type +// 4. Define the columns for your table. This uses the new `ColumnDef` type to define columns. +// Alternatively, check out the createTableHelper/createColumnHelper util for an even more type-safe way to define columns. const defaultColumns: Array> = [ { accessorKey: 'firstName', @@ -84,13 +92,13 @@ const defaultColumns: Array> = [ export class AppComponent { readonly data = signal>(defaultData) + // 5. Create the table instance with required _features, columns, and data table = injectTable(() => ({ _features, // new required option in V9. Tell the table which features you are importing and using (better tree-shaking) _rowModels: {}, // `Core` row model is now included by default, but you can still override it here data: this.data(), columns: defaultColumns, - debugTable: true, - // other options here + // ...other options here })) rerender() { diff --git a/examples/angular/column-ordering/src/app/app.component.ts b/examples/angular/column-ordering/src/app/app.component.ts index 09918b53e8..66220503fa 100644 --- a/examples/angular/column-ordering/src/app/app.component.ts +++ b/examples/angular/column-ordering/src/app/app.component.ts @@ -96,7 +96,6 @@ export class AppComponent { columnOrder: this.columnOrder(), columnVisibility: this.columnVisibility(), }, - enableExperimentalReactivity: true, onColumnVisibilityChange: (updaterOrValue) => { typeof updaterOrValue === 'function' ? this.columnVisibility.update(updaterOrValue) @@ -113,7 +112,7 @@ export class AppComponent { })) readonly stringifiedColumnOrdering = computed(() => { - return JSON.stringify(this.table.store.state.columnOrder) + return JSON.stringify(this.table.state().columnOrder) }) randomizeColumns() { diff --git a/examples/angular/column-pinning-sticky/src/app/app.component.ts b/examples/angular/column-pinning-sticky/src/app/app.component.ts index f3a6a4cb58..e92c532dec 100644 --- a/examples/angular/column-pinning-sticky/src/app/app.component.ts +++ b/examples/angular/column-pinning-sticky/src/app/app.component.ts @@ -104,7 +104,6 @@ export class AppComponent { _features, columns: this.columns(), data: this.data(), - enableExperimentalReactivity: true, debugTable: true, debugHeaders: true, debugColumns: true, @@ -112,7 +111,7 @@ export class AppComponent { })) stringifiedColumnPinning = computed(() => { - return JSON.stringify(this.table.store.state.columnPinning) + return JSON.stringify(this.table.state().columnPinning) }) readonly getCommonPinningStyles = ( diff --git a/examples/angular/column-pinning/src/app/app.component.ts b/examples/angular/column-pinning/src/app/app.component.ts index 83cebb4cb2..c01ed66eeb 100644 --- a/examples/angular/column-pinning/src/app/app.component.ts +++ b/examples/angular/column-pinning/src/app/app.component.ts @@ -114,7 +114,6 @@ export class AppComponent { columnOrder: this.columnOrder(), columnPinning: this.columnPinning(), }, - enableExperimentalReactivity: true, onColumnVisibilityChange: (updaterOrValue) => { typeof updaterOrValue === 'function' ? this.columnVisibility.update(updaterOrValue) @@ -136,7 +135,7 @@ export class AppComponent { })) stringifiedColumnPinning = computed(() => { - return JSON.stringify(this.table.store.state.columnPinning) + return JSON.stringify(this.table.state().columnPinning) }) randomizeColumns() { diff --git a/examples/angular/column-resizing-performant/src/app/app.component.ts b/examples/angular/column-resizing-performant/src/app/app.component.ts index 90ecac95bd..0c59cc3888 100644 --- a/examples/angular/column-resizing-performant/src/app/app.component.ts +++ b/examples/angular/column-resizing-performant/src/app/app.component.ts @@ -5,17 +5,17 @@ import { signal, untracked, } from '@angular/core' -import type { ColumnDef, ColumnResizeMode } from '@tanstack/angular-table' import { + FlexRenderDirective, columnResizingFeature, columnSizingFeature, - FlexRenderDirective, injectTable, tableFeatures, } from '@tanstack/angular-table' -import type { Person } from './makeData' import { makeData } from './makeData' import { TableResizableCell, TableResizableHeader } from './resizable-cell' +import type { Person } from './makeData' +import type { ColumnDef, ColumnResizeMode } from '@tanstack/angular-table' const _features = tableFeatures({ columnSizingFeature, @@ -78,7 +78,23 @@ const defaultColumns: Array> = [ export class AppComponent { readonly data = signal>(makeData(200)) - readonly columnSizing = computed(() => this.table.store.state.columnSizing) + readonly table = injectTable(() => ({ + data: this.data(), + _features, + columns: defaultColumns, + columnResizeMode: 'onChange' as ColumnResizeMode, + defaultColumn: { + minSize: 60, + maxSize: 800, + }, + debugTable: true, + debugHeaders: true, + debugColumns: true, + })) + + readonly columnSizing = this.table.Subscribe({ + selector: (state) => state.columnSizing, + }) /** * Instead of calling `column.getSize()` on every render for every header @@ -99,24 +115,10 @@ export class AppComponent { return colSizes }) - readonly table = injectTable(() => ({ - data: this.data(), - _features, - columns: defaultColumns, - columnResizeMode: 'onChange' as ColumnResizeMode, - defaultColumn: { - minSize: 60, - maxSize: 800, - }, - debugTable: true, - debugHeaders: true, - debugColumns: true, - })) - readonly columnSizingDebugInfo = computed(() => JSON.stringify( { - columnSizing: this.table.store.state.columnSizing, + columnSizing: this.columnSizing(), }, null, 2, diff --git a/examples/angular/column-visibility/src/app/app.component.ts b/examples/angular/column-visibility/src/app/app.component.ts index 74e2168642..999ac11538 100644 --- a/examples/angular/column-visibility/src/app/app.component.ts +++ b/examples/angular/column-visibility/src/app/app.component.ts @@ -135,7 +135,7 @@ export class AppComponent implements OnInit { })) stringifiedColumnVisibility = computed(() => { - return JSON.stringify(this.table.store.state.columnVisibility) + return JSON.stringify(this.table.state().columnVisibility) }) ngOnInit() { diff --git a/examples/angular/composable-tables/.devcontainer/devcontainer.json b/examples/angular/composable-tables/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..36f47d8762 --- /dev/null +++ b/examples/angular/composable-tables/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Node.js", + "image": "mcr.microsoft.com/devcontainers/javascript-node:18" +} diff --git a/examples/angular/composable-tables/.editorconfig b/examples/angular/composable-tables/.editorconfig new file mode 100644 index 0000000000..59d9a3a3e7 --- /dev/null +++ b/examples/angular/composable-tables/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/examples/angular/composable-tables/.gitignore b/examples/angular/composable-tables/.gitignore new file mode 100644 index 0000000000..0711527ef9 --- /dev/null +++ b/examples/angular/composable-tables/.gitignore @@ -0,0 +1,42 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/examples/angular/composable-tables/README.md b/examples/angular/composable-tables/README.md new file mode 100644 index 0000000000..5da97a87d1 --- /dev/null +++ b/examples/angular/composable-tables/README.md @@ -0,0 +1,27 @@ +# Basic + +This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.1.2. + +## Development server + +Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. + +## Code scaffolding + +Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. + +## Build + +Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. + +## Running unit tests + +Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). + +## Running end-to-end tests + +Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. + +## Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. diff --git a/examples/angular/composable-tables/angular.json b/examples/angular/composable-tables/angular.json new file mode 100644 index 0000000000..7d88b1324b --- /dev/null +++ b/examples/angular/composable-tables/angular.json @@ -0,0 +1,107 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "composable": { + "cli": { + "cache": { + "enabled": false + } + }, + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "inlineTemplate": true, + "inlineStyle": true, + "skipTests": true, + "style": "scss" + }, + "@schematics/angular:class": { + "skipTests": true + }, + "@schematics/angular:directive": { + "skipTests": true + }, + "@schematics/angular:guard": { + "skipTests": true + }, + "@schematics/angular:interceptor": { + "skipTests": true + }, + "@schematics/angular:pipe": { + "skipTests": true + }, + "@schematics/angular:resolver": { + "skipTests": true + }, + "@schematics/angular:service": { + "skipTests": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "outputPath": "dist/composable", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": ["src/favicon.ico", "src/assets"], + "styles": ["src/styles.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "composable:build:production" + }, + "development": { + "buildTarget": "composable:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular/build:extract-i18n", + "options": { + "buildTarget": "composable:build" + } + } + } + } + }, + "cli": { + "analytics": false + } +} diff --git a/examples/angular/composable-tables/package.json b/examples/angular/composable-tables/package.json new file mode 100644 index 0000000000..3bc4d07a85 --- /dev/null +++ b/examples/angular/composable-tables/package.json @@ -0,0 +1,34 @@ +{ + "name": "tanstack-table-example-angular-composable-tables", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test", + "lint": "eslint ./src" + }, + "private": true, + "dependencies": { + "@angular/common": "^21.0.6", + "@angular/compiler": "^21.0.6", + "@angular/core": "^21.0.6", + "@angular/forms": "^21.0.6", + "@angular/platform-browser": "^21.0.6", + "@angular/platform-browser-dynamic": "^21.0.6", + "@angular/router": "^21.0.6", + "@tanstack/angular-table": "^9.0.0-alpha.10", + "rxjs": "~7.8.2", + "zone.js": "~0.16.0" + }, + "devDependencies": { + "@angular/build": "^21.0.4", + "@angular/cli": "^21.0.4", + "@angular/compiler-cli": "^21.0.6", + "@types/jasmine": "~5.1.13", + "jasmine-core": "~5.13.0", + "tslib": "^2.8.1", + "typescript": "5.9.3" + } +} diff --git a/examples/angular/composable-tables/src/app/app.component.html b/examples/angular/composable-tables/src/app/app.component.html new file mode 100644 index 0000000000..331ef7b47c --- /dev/null +++ b/examples/angular/composable-tables/src/app/app.component.html @@ -0,0 +1,59 @@ +
+
+ + + + + @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.getAllCells(); track cell.id) { + + } + + } + + + + @for (footerGroup of table.getFooterGroups(); track footerGroup.id) { + + @for (footer of footerGroup.headers; track footer.id) { + + } + + } + +
+ + {{ header }} + +
+ + {{ cell }} + +
+ @if (!footer.isPlaceholder) { + + {{ footer }} + + } +
+
+ +
+
diff --git a/examples/angular/composable-tables/src/app/app.component.ts b/examples/angular/composable-tables/src/app/app.component.ts new file mode 100644 index 0000000000..47dfa1cd5d --- /dev/null +++ b/examples/angular/composable-tables/src/app/app.component.ts @@ -0,0 +1,80 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core' +import { + FlexRender, + TanStackTable, + TanStackTableCell, + TanStackTableHeader, + flexRenderComponent, +} from '@tanstack/angular-table' +import { NgComponentOutlet } from '@angular/common' +import { createAppColumnHelper, injectAppTable } from './table' +import { makeData } from './makeData' +import type { Person } from './makeData' + +// Create column helpers with TFeatures already bound - only need TData! +const personColumnHelper = createAppColumnHelper() + +@Component({ + selector: 'app-root', + imports: [ + FlexRender, + TanStackTableHeader, + TanStackTableCell, + NgComponentOutlet, + TanStackTable, + ], + templateUrl: './app.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppComponent { + readonly data = signal(makeData(5000)) + + readonly columns = personColumnHelper.columns([ + personColumnHelper.accessor('firstName', { + header: 'First Name', + footer: ({ header }) => flexRenderComponent(header.FooterColumnId), + cell: ({ cell }) => flexRenderComponent(cell.TextCell), + }), + personColumnHelper.accessor('lastName', { + header: 'Last Name', + footer: ({ header }) => flexRenderComponent(header.FooterColumnId), + cell: ({ cell }) => flexRenderComponent(cell.TextCell), + }), + personColumnHelper.accessor('age', { + header: 'Age', + footer: ({ header }) => flexRenderComponent(header.FooterSum), + cell: ({ cell }) => flexRenderComponent(cell.NumberCell), + }), + personColumnHelper.accessor('visits', { + header: 'Visits', + footer: ({ header }) => flexRenderComponent(header.FooterSum), + cell: ({ cell }) => flexRenderComponent(cell.NumberCell), + }), + personColumnHelper.accessor('status', { + header: 'Status', + footer: ({ header }) => flexRenderComponent(header.FooterColumnId), + cell: ({ cell }) => flexRenderComponent(cell.StatusCell), + }), + personColumnHelper.accessor('progress', { + header: 'Progress', + footer: ({ header }) => flexRenderComponent(header.FooterSum), + cell: ({ cell }) => flexRenderComponent(cell.ProgressCell), + }), + personColumnHelper.display({ + id: 'actions', + header: 'Actions', + cell: ({ cell }) => flexRenderComponent(cell.RowActionsCell), + }), + ]) + + table = injectAppTable(() => ({ + columns: this.columns, + data: this.data(), + debugTable: true, + // more table options + })) + + onRefresh = () => { + this.data.set([...makeData(5000)]) + } +} diff --git a/examples/angular/composable-tables/src/app/app.config.ts b/examples/angular/composable-tables/src/app/app.config.ts new file mode 100644 index 0000000000..828af23571 --- /dev/null +++ b/examples/angular/composable-tables/src/app/app.config.ts @@ -0,0 +1,7 @@ +import { provideRouter } from '@angular/router' +import { routes } from './app.routes' +import type { ApplicationConfig } from '@angular/core' + +export const appConfig: ApplicationConfig = { + providers: [provideRouter(routes)], +} diff --git a/examples/angular/composable-tables/src/app/app.routes.ts b/examples/angular/composable-tables/src/app/app.routes.ts new file mode 100644 index 0000000000..9a884b1131 --- /dev/null +++ b/examples/angular/composable-tables/src/app/app.routes.ts @@ -0,0 +1,3 @@ +import type { Routes } from '@angular/router' + +export const routes: Routes = [] diff --git a/examples/angular/composable-tables/src/app/components/cell-components.ts b/examples/angular/composable-tables/src/app/components/cell-components.ts new file mode 100644 index 0000000000..a6c20ad3a6 --- /dev/null +++ b/examples/angular/composable-tables/src/app/components/cell-components.ts @@ -0,0 +1,113 @@ +import { Component, computed } from '@angular/core' +import { injectFlexRenderContext } from '@tanstack/angular-table' +import { CurrencyPipe } from '@angular/common' +import { injectTableCellContext } from '../table' +import type { CellContext, TableFeatures } from '@tanstack/angular-table' + +@Component({ + selector: 'span', + host: { + 'tanstack-table-text-cell': '', + }, + template: ` {{ cell.getValue() }} `, +}) +export class TextCell { + readonly cell = + injectFlexRenderContext>() +} + +@Component({ + selector: 'span', + host: { + 'tanstack-table-number-cell': '', + }, + template: ` {{ cell.getValue().toLocaleString() }} `, +}) +export class NumberCell { + readonly cell = + injectFlexRenderContext>() +} + +@Component({ + selector: 'span', + host: { + 'tanstack-table-status-cell': '', + '[class.status-badge]': 'true', + '[class]': 'cell().getValue()', + }, + template: ` {{ cell().getValue() }} `, +}) +export class StatusCell { + readonly cell = injectTableCellContext< + 'relationship' | 'complicated' | 'single' + >() +} + +@Component({ + selector: 'table-progress-cell', + template: ` +
+
+ `, +}) +export class ProgressCell { + readonly cell = injectTableCellContext() + + readonly progress = computed(() => this.cell().getValue()) +} + +@Component({ + selector: 'table-row-actions', + template: ` +
+ + + +
+ `, +}) +export class RowActionsCell { + readonly cell = injectTableCellContext() + + view() { + alert( + `View: ${this.cell().row.original.firstName} ${this.cell().row.original.lastName}`, + ) + } + + edit() { + alert( + `Edit: ${this.cell().row.original.firstName} ${this.cell().row.original.lastName}`, + ) + } + + delete() { + alert( + `Delete: ${this.cell().row.original.firstName} ${this.cell().row.original.lastName}`, + ) + } +} + +@Component({ + selector: 'table-price-cell', + template: ` {{ cell().getValue() | currency }} `, + imports: [CurrencyPipe], +}) +export class PriceCell { + readonly cell = injectTableCellContext() +} + +@Component({ + selector: 'span', + host: { + 'tanstack-table-category-cell': '', + '[class.category-badge]': 'true', + '[class]': 'cell().getValue()', + }, + template: ` {{ cell().getValue() }} `, +}) +export class CategoryCell { + readonly cell = injectTableCellContext< + 'electronics' | 'clothing' | 'food' | 'books' + >() +} diff --git a/examples/angular/composable-tables/src/app/components/header-components.ts b/examples/angular/composable-tables/src/app/components/header-components.ts new file mode 100644 index 0000000000..2d23c41b53 --- /dev/null +++ b/examples/angular/composable-tables/src/app/components/header-components.ts @@ -0,0 +1,58 @@ +// export function SortIndicator() { +// const header = useHeaderContext() +// const sorted = header.column.getIsSorted() +// +// if (!sorted) return null +// +// return ( +// {sorted === 'asc' ? '🔼' : '🔽'} +// ) +// } + +import { Component, computed } from '@angular/core' +import { injectTableHeaderContext } from '../table' + +// @Component({ +// selector: 'app-sort-indicator', +// host: { +// class: 'sort-indicator', +// }, +// template: ` {{ sorted === 'asc' ? '🔼' : '🔽' }} `, +// }) +// export class SortIndicator { +// readonly context = injectTableHeaderContext() +// } + +@Component({ + selector: 'span', + host: { + 'tanstack-footer-column-id': '', + class: 'footer-column-id', + }, + template: `{{ header().column.id }}`, +}) +export class FooterColumnId { + readonly header = injectTableHeaderContext() +} + +@Component({ + selector: 'span', + host: { + 'tanstack-footer-sum': '', + class: 'footer-sum', + }, + template: `{{ sum() > 0 ? sum().toLocaleString() : '—' }}`, +}) +export class FooterSum { + readonly header = injectTableHeaderContext() + + readonly table = computed(() => this.header().getContext().table) + readonly rows = computed(() => this.table().getFilteredRowModel().rows) + + readonly sum = computed(() => + this.rows().reduce((acc, row) => { + const value = row.getValue(this.header().column.id) + return acc + (typeof value === 'number' ? value : 0) + }, 0), + ) +} diff --git a/examples/angular/composable-tables/src/app/components/table-components.ts b/examples/angular/composable-tables/src/app/components/table-components.ts new file mode 100644 index 0000000000..fd9734be2f --- /dev/null +++ b/examples/angular/composable-tables/src/app/components/table-components.ts @@ -0,0 +1,27 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core' +import { injectTableContext } from '../table' + +@Component({ + template: ` +
+

{{ title() }}

+ + + + @if (onRefresh(); as onRefresh) { + + } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TableToolbar { + readonly title = input.required() + readonly onRefresh = input<() => void>() + + readonly table = injectTableContext() + + constructor() { + this.table().resetColumnFilters() + } +} diff --git a/examples/angular/composable-tables/src/app/makeData.ts b/examples/angular/composable-tables/src/app/makeData.ts new file mode 100644 index 0000000000..17dec1c6de --- /dev/null +++ b/examples/angular/composable-tables/src/app/makeData.ts @@ -0,0 +1,77 @@ +import { faker } from '@faker-js/faker' + +export type Person = { + firstName: string + lastName: string + age: number + visits: number + progress: number + status: 'relationship' | 'complicated' | 'single' + subRows?: Array +} + +export type Product = { + id: string + name: string + category: 'electronics' | 'clothing' | 'food' | 'books' + price: number + stock: number + rating: number +} + +const range = (len: number) => { + const arr: Array = [] + for (let i = 0; i < len; i++) { + arr.push(i) + } + return arr +} + +const newPerson = (): Person => { + return { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + age: faker.number.int(40), + visits: faker.number.int(1000), + progress: faker.number.int(100), + status: faker.helpers.shuffle([ + 'relationship', + 'complicated', + 'single', + ])[0], + } +} + +const newProduct = (): Product => { + return { + id: faker.string.uuid(), + name: faker.commerce.productName(), + category: faker.helpers.shuffle([ + 'electronics', + 'clothing', + 'food', + 'books', + ])[0], + price: parseFloat(faker.commerce.price({ min: 5, max: 500 })), + stock: faker.number.int({ min: 0, max: 200 }), + rating: faker.number.int({ min: 0, max: 100 }), + } +} + +export function makeData(...lens: Array) { + const makeDataLevel = (depth = 0): Array => { + const len = lens[depth] + return range(len).map((): Person => { + return { + ...newPerson(), + subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined, + } + }) + } + + return makeDataLevel() +} + +export function makeProductData(count: number): Array { + return range(count).map(() => newProduct()) +} diff --git a/examples/angular/composable-tables/src/app/table.ts b/examples/angular/composable-tables/src/app/table.ts new file mode 100644 index 0000000000..a4a6ef2bb4 --- /dev/null +++ b/examples/angular/composable-tables/src/app/table.ts @@ -0,0 +1,106 @@ +/** + * Custom table hook setup using createTableHook + * + * This file creates a custom useAppTable hook with pre-bound components. + * Features, row models, and default options are defined once here and shared across all tables. + * Context hooks and a pre-bound createAppColumnHelper are also exported. + */ +import { + columnFilteringFeature, + createFilteredRowModel, + createPaginatedRowModel, + createSortedRowModel, + filterFns, + rowPaginationFeature, + rowSortingFeature, + sortFns, + tableFeatures, +} from '@tanstack/angular-table' +// Import table-level components +import { createTableHook } from '@tanstack/angular-table' +import { TableToolbar } from './components/table-components' +import { + CategoryCell, + NumberCell, + PriceCell, + ProgressCell, + RowActionsCell, + StatusCell, + TextCell, +} from './components/cell-components' +import { FooterColumnId, FooterSum } from './components/header-components' + +// Import table-level components +// import { +// PaginationControls, +// RowCount, +// TableToolbar, +// } from '../components/table-components' + +// Import cell-level components + +// Import header/footer-level components (both use useHeaderContext) + +/** + * Create the custom table hook with all pre-bound components. + * This exports: + * - createAppColumnHelper: Create column definitions with TFeatures already bound + * - useAppTable: Hook for creating tables with TFeatures baked in + * - useTableContext: Access table instance in tableComponents + * - useCellContext: Access cell instance in cellComponents + * - useHeaderContext: Access header instance in headerComponents + */ +export const { + createAppColumnHelper, + injectAppTable, + injectTableContext, + injectTableCellContext, + injectTableHeaderContext, + // useAppTable, + // useTableContext, + // useCellContext, + // useHeaderContext, +} = createTableHook({ + // Features are set once here and shared across all tables + _features: tableFeatures({ + columnFilteringFeature, + rowPaginationFeature, + rowSortingFeature, + }), + + // Row models are set once here + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + }, + + // set any default table options here too + getRowId: (row) => row.id, + + // Register table-level components (accessible via table.ComponentName) + tableComponents: { + // PaginationControls, + // RowCount, + TableToolbar, + }, + + // Register cell-level components (accessible via cell.ComponentName in AppCell) + cellComponents: { + TextCell, + NumberCell, + ProgressCell, + StatusCell, + CategoryCell, + PriceCell, + RowActionsCell, + }, + + // Register header/footer-level components (accessible via header.ComponentName in AppHeader/AppFooter) + headerComponents: { + // SortIndicator, + // ColumnFilter, + FooterColumnId, + FooterSum, + }, +}) diff --git a/examples/angular/composable-tables/src/assets/.gitkeep b/examples/angular/composable-tables/src/assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/angular/composable-tables/src/favicon.ico b/examples/angular/composable-tables/src/favicon.ico new file mode 100644 index 0000000000..57614f9c96 Binary files /dev/null and b/examples/angular/composable-tables/src/favicon.ico differ diff --git a/examples/angular/composable-tables/src/index.html b/examples/angular/composable-tables/src/index.html new file mode 100644 index 0000000000..9a37746785 --- /dev/null +++ b/examples/angular/composable-tables/src/index.html @@ -0,0 +1,14 @@ + + + + + Composable tables + + + + + + + + + diff --git a/examples/angular/composable-tables/src/main.ts b/examples/angular/composable-tables/src/main.ts new file mode 100644 index 0000000000..c3d8f9af99 --- /dev/null +++ b/examples/angular/composable-tables/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser' +import { appConfig } from './app/app.config' +import { AppComponent } from './app/app.component' + +bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)) diff --git a/examples/angular/composable-tables/src/styles.scss b/examples/angular/composable-tables/src/styles.scss new file mode 100644 index 0000000000..d7a5ed6c49 --- /dev/null +++ b/examples/angular/composable-tables/src/styles.scss @@ -0,0 +1,249 @@ +html { + font-family: sans-serif; + font-size: 14px; +} + +table { + border: 1px solid lightgray; + border-collapse: collapse; + width: 100%; +} + +tbody { + border-bottom: 1px solid lightgray; +} + +th, +td { + border-bottom: 1px solid lightgray; + border-right: 1px solid lightgray; + padding: 8px 12px; + text-align: left; +} + +th { + background-color: #f5f5f5; + font-weight: 600; +} + +tfoot { + color: gray; +} + +tfoot th { + font-weight: normal; +} + +.table-container { + padding: 16px; + max-width: 1200px; + margin: 0 auto; +} + +.pagination { + display: flex; + align-items: center; + gap: 8px; + margin-top: 16px; + flex-wrap: wrap; +} + +.pagination button { + border: 1px solid #ccc; + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + background: white; +} + +.pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pagination input { + border: 1px solid #ccc; + border-radius: 4px; + padding: 4px; + width: 64px; +} + +.pagination select { + border: 1px solid #ccc; + border-radius: 4px; + padding: 4px; +} + +.sort-indicator { + margin-left: 4px; +} + +.column-filter { + margin-top: 4px; +} + +.column-filter input { + border: 1px solid #ccc; + border-radius: 4px; + padding: 4px; + width: 100%; + font-size: 12px; +} + +.sortable-header { + cursor: pointer; + user-select: none; +} + +.sortable-header:hover { + background-color: #e8e8e8; +} + +.row-actions { + display: flex; + gap: 4px; +} + +.row-actions button { + border: 1px solid #ccc; + border-radius: 4px; + padding: 2px 8px; + cursor: pointer; + background: white; + font-size: 12px; +} + +.row-actions button:hover { + background-color: #f0f0f0; +} + +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.status-badge.relationship { + background-color: #d4edda; + color: #155724; +} + +.status-badge.complicated { + background-color: #fff3cd; + color: #856404; +} + +.status-badge.single { + background-color: #cce5ff; + color: #004085; +} + +.progress-bar { + width: 100%; + height: 8px; + background-color: #e9ecef; + border-radius: 4px; + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + background-color: #007bff; + transition: width 0.2s; +} + +.table-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 8px; +} + +.table-toolbar h2 { + margin: 0; +} + +.table-toolbar button { + border: 1px solid #ccc; + border-radius: 4px; + padding: 8px 16px; + cursor: pointer; + background: white; +} + +.table-toolbar button:hover { + background-color: #f0f0f0; +} + +.row-count { + color: #666; + font-size: 14px; + margin-top: 8px; +} + +.app { + padding: 16px; +} + +.app h1 { + text-align: center; + margin-bottom: 8px; +} + +.description { + text-align: center; + color: #666; + margin-bottom: 32px; +} + +.description code { + background-color: #f5f5f5; + padding: 2px 6px; + border-radius: 4px; + font-size: 13px; +} + +.table-divider { + height: 48px; + border-bottom: 1px solid #e0e0e0; + margin: 32px auto; + max-width: 1200px; +} + +.category-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + text-transform: capitalize; +} + +.category-badge.electronics { + background-color: #e3f2fd; + color: #1565c0; +} + +.category-badge.clothing { + background-color: #fce4ec; + color: #c2185b; +} + +.category-badge.food { + background-color: #e8f5e9; + color: #2e7d32; +} + +.category-badge.books { + background-color: #fff8e1; + color: #f57c00; +} + +.price { + font-weight: 600; + color: #2e7d32; +} diff --git a/examples/angular/composable-tables/tsconfig.app.json b/examples/angular/composable-tables/tsconfig.app.json new file mode 100644 index 0000000000..84f1f992d2 --- /dev/null +++ b/examples/angular/composable-tables/tsconfig.app.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"] +} diff --git a/examples/angular/composable-tables/tsconfig.json b/examples/angular/composable-tables/tsconfig.json new file mode 100644 index 0000000000..b58d3efc71 --- /dev/null +++ b/examples/angular/composable-tables/tsconfig.json @@ -0,0 +1,31 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "src", + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": ["ES2022", "dom"] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/examples/angular/composable-tables/tsconfig.spec.json b/examples/angular/composable-tables/tsconfig.spec.json new file mode 100644 index 0000000000..47e3dd7551 --- /dev/null +++ b/examples/angular/composable-tables/tsconfig.spec.json @@ -0,0 +1,9 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": ["jasmine"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/examples/angular/editable/src/app/app.component.html b/examples/angular/editable/src/app/app.component.html index 37420e6395..d8cff86033 100644 --- a/examples/angular/editable/src/app/app.component.html +++ b/examples/angular/editable/src/app/app.component.html @@ -94,7 +94,7 @@
Page
- {{ table.store.state.pagination.pageIndex + 1 }} of + {{ table.state().pagination.pageIndex + 1 }} of {{ table.getPageCount() }}
diff --git a/examples/angular/expanding/src/app/app.component.ts b/examples/angular/expanding/src/app/app.component.ts index e8026047b2..059a55f965 100644 --- a/examples/angular/expanding/src/app/app.component.ts +++ b/examples/angular/expanding/src/app/app.component.ts @@ -105,8 +105,11 @@ export class AppComponent { JSON.stringify(this.expanded(), undefined, 2), ) + readonly rowSelectionState = this.table.Subscribe({ + selector: (state) => state.rowSelection, + }) readonly rawRowSelectionState = computed(() => - JSON.stringify(this.table.store.state.rowSelection, undefined, 2), + JSON.stringify(this.rowSelectionState(), undefined, 2), ) onPageInputChange(event: Event): void { diff --git a/examples/angular/filters/src/app/app.component.ts b/examples/angular/filters/src/app/app.component.ts index 1696f292af..23af7fdfe4 100644 --- a/examples/angular/filters/src/app/app.component.ts +++ b/examples/angular/filters/src/app/app.component.ts @@ -101,7 +101,6 @@ export class AppComponent { paginatedRowModel: createPaginatedRowModel(), sortedRowModel: createSortedRowModel(sortFns), }, - // enableExperimentalReactivity: true, columns: this.columns, data: this.data(), state: { diff --git a/examples/angular/grouping/src/app/app.component.ts b/examples/angular/grouping/src/app/app.component.ts index eedc6b5692..c04dc60a9e 100644 --- a/examples/angular/grouping/src/app/app.component.ts +++ b/examples/angular/grouping/src/app/app.component.ts @@ -5,42 +5,11 @@ import { computed, signal, } from '@angular/core' -import { - FlexRenderDirective, - aggregationFns, - columnFilteringFeature, - columnGroupingFeature, - createExpandedRowModel, - createFilteredRowModel, - createGroupedRowModel, - createPaginatedRowModel, - createTableHelper, - filterFns, - isFunction, - rowExpandingFeature, - rowPaginationFeature, -} from '@tanstack/angular-table' -import { columns } from './columns' +import { FlexRenderDirective, isFunction } from '@tanstack/angular-table' +import { columns, tableHelper } from './columns' import { makeData } from './makeData' -import type { Person } from './makeData' import type { GroupingState, Updater } from '@tanstack/angular-table' -export const tableHelper = createTableHelper({ - _features: { - columnGroupingFeature, - rowPaginationFeature, - columnFilteringFeature, - rowExpandingFeature, - }, - _rowModels: { - groupedRowModel: createGroupedRowModel(aggregationFns), - expandedRowModel: createExpandedRowModel(), - paginatedRowModel: createPaginatedRowModel(), - filteredRowModel: createFilteredRowModel(filterFns), - }, - TData: {} as Person, -}) - @Component({ selector: 'app-root', standalone: true, @@ -58,7 +27,6 @@ export class AppComponent { ) readonly table = tableHelper.injectTable(() => ({ - enableExperimentalReactivity: true, data: this.data(), columns: columns, initialState: { diff --git a/examples/angular/grouping/src/app/columns.ts b/examples/angular/grouping/src/app/columns.ts index ae74a4c5bb..4bae2676d7 100644 --- a/examples/angular/grouping/src/app/columns.ts +++ b/examples/angular/grouping/src/app/columns.ts @@ -1,6 +1,37 @@ -import { tableHelper } from './app.component' +import { + aggregationFns, + columnFilteringFeature, + columnGroupingFeature, + createExpandedRowModel, + createFilteredRowModel, + createGroupedRowModel, + createPaginatedRowModel, + createTableHelper, + filterFns, + rowExpandingFeature, + rowPaginationFeature, +} from '@tanstack/angular-table' +import type { Person } from './makeData' -const { columnHelper } = tableHelper +export const tableHelper = createTableHelper({ + _features: { + columnGroupingFeature, + rowPaginationFeature, + columnFilteringFeature, + rowExpandingFeature, + }, + _rowModels: { + groupedRowModel: createGroupedRowModel(aggregationFns), + expandedRowModel: createExpandedRowModel(), + paginatedRowModel: createPaginatedRowModel(), + filteredRowModel: createFilteredRowModel(filterFns), + }, + TData: {} as Person, +}) +export default tableHelper + +const { createColumnHelper } = tableHelper +const columnHelper = createColumnHelper() export const columns = columnHelper.columns([ columnHelper.group({ diff --git a/examples/angular/remote-data/src/app/app.component.ts b/examples/angular/remote-data/src/app/app.component.ts index 2cb507fcd1..8de00c8c8e 100644 --- a/examples/angular/remote-data/src/app/app.component.ts +++ b/examples/angular/remote-data/src/app/app.component.ts @@ -2,7 +2,6 @@ import { HttpParams } from '@angular/common/http' import { ChangeDetectionStrategy, Component, - ResourceStatus, linkedSignal, resource, signal, @@ -60,12 +59,12 @@ export class AppComponent { readonly sorting = signal([{ id: 'id', desc: false }]) readonly globalFilter = signal(null) readonly data = resource({ - request: () => ({ + params: () => ({ page: this.pagination(), globalFilter: this.globalFilter(), sorting: this.sorting(), }), - loader: ({ request: { page, globalFilter, sorting }, abortSignal }) => { + loader: ({ params: { page, globalFilter, sorting }, abortSignal }) => { let httpParams = new HttpParams({ fromObject: { _page: page.pageIndex + 1, @@ -127,8 +126,7 @@ export class AppComponent { status: this.data.status(), }), computation: (source, previous) => { - if (previous && source.status === ResourceStatus.Loading) - return previous.value + if (previous && source.status === 'loading') return previous.value return source.value ?? { items: [], totalCount: 0 } }, }) diff --git a/examples/angular/remote-data/src/app/app.config.server.ts b/examples/angular/remote-data/src/app/app.config.server.ts index 0893630247..0b2c82509c 100644 --- a/examples/angular/remote-data/src/app/app.config.server.ts +++ b/examples/angular/remote-data/src/app/app.config.server.ts @@ -1,12 +1,11 @@ import { mergeApplicationConfig } from '@angular/core' -import { provideServerRendering } from '@angular/platform-server' -import { provideServerRouting } from '@angular/ssr' +import { provideServerRendering, withRoutes } from '@angular/ssr' import { appConfig } from './app.config' import { serverRoutes } from './app.routes.server' import type { ApplicationConfig } from '@angular/core' const serverConfig: ApplicationConfig = { - providers: [provideServerRendering(), provideServerRouting(serverRoutes)], + providers: [provideServerRendering(withRoutes(serverRoutes))], } export const config = mergeApplicationConfig(appConfig, serverConfig) diff --git a/examples/angular/remote-data/src/app/app.routes.server.ts b/examples/angular/remote-data/src/app/app.routes.server.ts index dddd01e7df..09fcfa3cc9 100644 --- a/examples/angular/remote-data/src/app/app.routes.server.ts +++ b/examples/angular/remote-data/src/app/app.routes.server.ts @@ -1,4 +1,5 @@ -import { RenderMode, type ServerRoute } from '@angular/ssr' +import { RenderMode } from '@angular/ssr' +import type { ServerRoute } from '@angular/ssr' export const serverRoutes: Array = [ { diff --git a/examples/angular/remote-data/src/server.ts b/examples/angular/remote-data/src/server.ts index fac11ed3aa..b669f5c878 100644 --- a/examples/angular/remote-data/src/server.ts +++ b/examples/angular/remote-data/src/server.ts @@ -1,3 +1,5 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { AngularNodeAppEngine, createNodeRequestHandler, @@ -5,8 +7,6 @@ import { writeResponseToNodeResponse, } from '@angular/ssr/node' import express from 'express' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' const serverDistFolder = dirname(fileURLToPath(import.meta.url)) const browserDistFolder = resolve(serverDistFolder, '../browser') @@ -40,7 +40,7 @@ app.use( /** * Handle all other requests by rendering the Angular application. */ -app.use('/**', (req, res, next) => { +app.use('*', (req, res, next) => { angularApp .handle(req) .then((response) => diff --git a/examples/angular/remote-data/tsconfig.json b/examples/angular/remote-data/tsconfig.json index b58d3efc71..eb0dd3b6d5 100644 --- a/examples/angular/remote-data/tsconfig.json +++ b/examples/angular/remote-data/tsconfig.json @@ -15,7 +15,7 @@ "sourceMap": true, "declaration": false, "experimentalDecorators": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "importHelpers": true, "target": "ES2022", "module": "ES2022", diff --git a/examples/angular/row-selection-signal/src/app/app.component.ts b/examples/angular/row-selection-signal/src/app/app.component.ts index 18c7af0700..8791072cf2 100644 --- a/examples/angular/row-selection-signal/src/app/app.component.ts +++ b/examples/angular/row-selection-signal/src/app/app.component.ts @@ -115,7 +115,6 @@ export class AppComponent { }, columns: this.columns, data: this.data(), - enableExperimentalReactivity: true, state: { rowSelection: this.rowSelection(), }, diff --git a/examples/angular/row-selection/src/app/app.component.html b/examples/angular/row-selection/src/app/app.component.html index 2d003e6809..b2929372c8 100644 --- a/examples/angular/row-selection/src/app/app.component.html +++ b/examples/angular/row-selection/src/app/app.component.html @@ -8,13 +8,7 @@ @for (header of headerGroup.headers; track header.id) { @if (!header.isPlaceholder) { - + {{ headerCell }} @@ -38,13 +32,7 @@ @for (cell of row.getAllCells(); track cell.id) { - + {{ renderCell }} diff --git a/examples/angular/row-selection/src/app/app.component.ts b/examples/angular/row-selection/src/app/app.component.ts index bfcc77b615..f2a28d09ba 100644 --- a/examples/angular/row-selection/src/app/app.component.ts +++ b/examples/angular/row-selection/src/app/app.component.ts @@ -6,7 +6,7 @@ import { viewChild, } from '@angular/core' import { - FlexRenderDirective, + FlexRender, columnFilteringFeature, createFilteredRowModel, createPaginatedRowModel, @@ -23,9 +23,9 @@ import { TableHeadSelectionComponent, TableRowSelectionComponent, } from './selection-column.component' +import type { TemplateRef } from '@angular/core' import type { Person } from './makeData' import type { ColumnDef, RowSelectionState } from '@tanstack/angular-table' -import type { TemplateRef } from '@angular/core' const tableHelper = createTableHelper({ _features: { @@ -43,7 +43,7 @@ const tableHelper = createTableHelper({ @Component({ selector: 'app-root', standalone: true, - imports: [FilterComponent, FlexRenderDirective, FormsModule], + imports: [FilterComponent, FlexRender, FormsModule], templateUrl: './app.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -58,12 +58,8 @@ export class AppComponent { readonly columns: Array> = [ { id: 'select', - header: () => { - return flexRenderComponent(TableHeadSelectionComponent) - }, - cell: () => { - return flexRenderComponent(TableRowSelectionComponent) - }, + header: () => flexRenderComponent(TableHeadSelectionComponent), + cell: () => flexRenderComponent(TableRowSelectionComponent), }, { header: 'Name', @@ -73,7 +69,7 @@ export class AppComponent { accessorKey: 'firstName', cell: (info) => info.getValue(), footer: (props) => props.column.id, - header: 'First name', + header: (props) => `First name`, }, { accessorFn: (row) => row.lastName, @@ -123,7 +119,6 @@ export class AppComponent { state: { rowSelection: this.rowSelection(), }, - enableExperimentalReactivity: true, enableRowSelection: true, // enable row selection for all rows // enableRowSelection: row => row.original.age > 18, // or enable row selection conditionally per row onRowSelectionChange: (updaterOrValue) => { diff --git a/examples/angular/row-selection/src/app/selection-column.component.ts b/examples/angular/row-selection/src/app/selection-column.component.ts index 1ed10d8f1c..d3ac18aeaa 100644 --- a/examples/angular/row-selection/src/app/selection-column.component.ts +++ b/examples/angular/row-selection/src/app/selection-column.component.ts @@ -1,6 +1,13 @@ -import { injectFlexRenderContext } from '@tanstack/angular-table' -import { ChangeDetectionStrategy, Component } from '@angular/core' -import type { CellContext, HeaderContext } from '@tanstack/angular-table' +import { + injectFlexRenderContext, + injectTableCellContext, +} from '@tanstack/angular-table' +import { ChangeDetectionStrategy, Component, computed } from '@angular/core' +import type { + CellContext, + HeaderContext, + RowData, +} from '@tanstack/angular-table' @Component({ template: ` @@ -17,11 +24,11 @@ import type { CellContext, HeaderContext } from '@tanstack/angular-table' standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TableHeadSelectionComponent { - context = injectFlexRenderContext< - // @ts-expect-error TODO: Should fix types - HeaderContext<{ rowSelectionFeature: {} }, T, unknown> - >() +export class TableHeadSelectionComponent { + context = + injectFlexRenderContext< + HeaderContext<{ rowSelectionFeature: {} }, T, unknown> + >() } @Component({ @@ -39,6 +46,8 @@ export class TableHeadSelectionComponent { changeDetection: ChangeDetectionStrategy.OnPush, }) export class TableRowSelectionComponent { + readonly cell = injectTableCellContext() + readonly row = computed(() => this.cell().row) context = // @ts-expect-error TODO: Should fix types injectFlexRenderContext>() diff --git a/examples/angular/signal-input/src/app/person-table/person-table.component.ts b/examples/angular/signal-input/src/app/person-table/person-table.component.ts index e06d8a00d9..8fb41632af 100644 --- a/examples/angular/signal-input/src/app/person-table/person-table.component.ts +++ b/examples/angular/signal-input/src/app/person-table/person-table.component.ts @@ -51,7 +51,6 @@ export class PersonTableComponent { state: { pagination: this.pagination(), }, - enableExperimentalReactivity: true, onPaginationChange: (updaterOrValue) => { typeof updaterOrValue === 'function' ? this.pagination.update(updaterOrValue) diff --git a/packages/angular-table/package.json b/packages/angular-table/package.json index 97d583fea8..e4220ec4a5 100644 --- a/packages/angular-table/package.json +++ b/packages/angular-table/package.json @@ -33,14 +33,14 @@ } }, "engines": { - "node": ">=16" + "node": ">=18" }, "files": [ "dist", "src" ], "scripts": { - "build": "ng-packagr -p ng-package.json -c tsconfig.build.json && rimraf ./build/lib/package.json", + "build": "ng-packagr -p ng-package.json -c tsconfig.build.json && rimraf ./dist/package.json", "build:types": "tsc --emitDeclarationOnly", "clean": "rimraf ./build && rimraf ./dist", "test:build": "publint --strict", @@ -58,8 +58,8 @@ "devDependencies": { "@analogjs/vite-plugin-angular": "^2.2.1", "@analogjs/vitest-angular": "^2.2.1", - "@angular/core": "^21.0.6", - "@angular/platform-browser": "^21.0.6", + "@angular/core": "^21.0.0", + "@angular/platform-browser": "^21.0.0", "ng-packagr": "^21.0.1", "typescript": "5.9.3" }, diff --git a/packages/angular-table/src/angularReactivityFeature.ts b/packages/angular-table/src/angularReactivityFeature.ts index ffbbd4e54f..8e5453fd9f 100644 --- a/packages/angular-table/src/angularReactivityFeature.ts +++ b/packages/angular-table/src/angularReactivityFeature.ts @@ -30,7 +30,6 @@ export interface AngularReactivityFlags { } interface TableOptions_AngularReactivity { - enableExperimentalReactivity?: boolean reactivity?: Partial } @@ -67,7 +66,7 @@ const getUserSkipPropertyFn = ( return value ?? defaultPropertyFn } -export function constructAngularReactivityFeature< +function constructAngularReactivityFeature< TFeatures extends TableFeatures, TData extends RowData, >(): TableFeature> { diff --git a/packages/angular-table/src/flex-render/flags.ts b/packages/angular-table/src/flex-render/flags.ts index 7458f71143..34f82c6537 100644 --- a/packages/angular-table/src/flex-render/flags.ts +++ b/packages/angular-table/src/flex-render/flags.ts @@ -8,15 +8,11 @@ export enum FlexRenderFlags { * 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, + ContentChanged = 1 << 1, /** * 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. @@ -24,17 +20,15 @@ export enum FlexRenderFlags { * 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, + PropsReferenceChanged = 1 << 2, /** * Indicates that the current rendered view needs to be checked for changes. + * This will be set to true when `content(props)` result has changed or during + * forced update */ - DirtyCheck = 1 << 4, - /** - * Indicates that a signal within the `content(props)` result has changed - */ - DirtySignal = 1 << 5, + Dirty = 1 << 3, /** * Indicates that the first render effect has been checked at least one time. */ - RenderEffectChecked = 1 << 6, + RenderEffectChecked = 1 << 4, } 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 index 8d115dfcba..0486766ae0 100644 --- a/packages/angular-table/src/flex-render/flex-render-component-ref.ts +++ b/packages/angular-table/src/flex-render/flex-render-component-ref.ts @@ -8,13 +8,16 @@ import { OutputEmitterRef, OutputRefSubscription, ViewContainerRef, - inject, } from '@angular/core' import { FlexRenderComponent } from './flex-render-component' @Injectable() export class FlexRenderComponentFactory { - #viewContainerRef = inject(ViewContainerRef) + readonly #viewContainerRef: ViewContainerRef + + constructor(viewContainerRef: ViewContainerRef) { + this.#viewContainerRef = viewContainerRef + } createComponent( flexRenderComponent: FlexRenderComponent, @@ -22,9 +25,7 @@ export class FlexRenderComponentFactory { ): FlexRenderComponentRef { const componentRef = this.#viewContainerRef.createComponent( flexRenderComponent.component, - { - injector: componentInjector, - }, + { injector: componentInjector }, ) const view = new FlexRenderComponentRef( componentRef, diff --git a/packages/angular-table/src/flex-render/view.ts b/packages/angular-table/src/flex-render/view.ts index fbdda58851..45310aec19 100644 --- a/packages/angular-table/src/flex-render/view.ts +++ b/packages/angular-table/src/flex-render/view.ts @@ -2,7 +2,7 @@ import { TemplateRef, Type } from '@angular/core' import { FlexRenderComponent } from './flex-render-component' import type { EmbeddedViewRef } from '@angular/core' import type { FlexRenderComponentRef } from './flex-render-component-ref' -import type { FlexRenderContent } from '../flex-render' +import type { FlexRenderContent } from '../flexRender' export type FlexRenderTypedContent = | { kind: 'null' } @@ -67,6 +67,8 @@ export abstract class FlexRenderView< abstract dirtyCheck(): void abstract onDestroy(callback: Function): void + + abstract unmount(): void } export class FlexRenderTemplateView extends FlexRenderView< @@ -95,6 +97,10 @@ export class FlexRenderTemplateView extends FlexRenderView< // this.view.markForCheck() } + override unmount() { + this.view.destroy() + } + override onDestroy(callback: Function) { this.view.onDestroy(callback) } @@ -148,6 +154,10 @@ export class FlexRenderComponentView extends FlexRenderView< } } + override unmount() { + this.view.componentRef.destroy() + } + override onDestroy(callback: Function) { this.view.componentRef.onDestroy(callback) } diff --git a/packages/angular-table/src/flex-render.ts b/packages/angular-table/src/flexRender.ts similarity index 57% rename from packages/angular-table/src/flex-render.ts rename to packages/angular-table/src/flexRender.ts index e699a81210..197857e5ee 100644 --- a/packages/angular-table/src/flex-render.ts +++ b/packages/angular-table/src/flexRender.ts @@ -1,10 +1,9 @@ import { - ChangeDetectorRef, + DestroyRef, Directive, - DoCheck, + EffectRef, Injector, - OnChanges, - SimpleChanges, + InputSignal, TemplateRef, Type, ViewContainerRef, @@ -12,7 +11,9 @@ import { effect, inject, input, + linkedSignal, runInInjectionContext, + untracked, } from '@angular/core' import { FlexRenderComponentProps } from './flex-render/context' import { FlexRenderFlags } from './flex-render/flags' @@ -27,14 +28,17 @@ import { FlexRenderView, mapToFlexRenderTypedContent, } from './flex-render/view' +import { TanStackTableCellToken } from './helpers/cell' +import { TanStackTableHeaderToken } from './helpers/header' +import { TanStackTableToken } from './helpers/table' import type { FlexRenderTypedContent } from './flex-render/view' import type { CellContext, + CellData, HeaderContext, - Table, + RowData, TableFeatures, } from '@tanstack/table-core' -import type { EffectRef } from '@angular/core' export { injectFlexRenderContext, @@ -51,49 +55,49 @@ export type FlexRenderContent> = | Record | undefined +export type FlexRenderInputContent> = + | number + | string + | ((props: TProps) => FlexRenderContent) + | null + | undefined + @Directive({ selector: 'ng-template[flexRender]', standalone: true, - providers: [FlexRenderComponentFactory], }) -export class FlexRender< +export class FlexRenderDirective< + TFeatures extends TableFeatures, + TRowData extends RowData, + TValue extends CellData, TProps extends | NonNullable - | CellContext - | HeaderContext, -> - implements OnChanges, DoCheck -{ - readonly #flexRenderComponentFactory = inject(FlexRenderComponentFactory) - readonly #changeDetectorRef = inject(ChangeDetectorRef) - - readonly content = input.required< - | number - | string - | ((props: TProps) => FlexRenderContent) - | null - | undefined - >({ - alias: 'flexRender', - }) - - readonly props = input.required({ + | CellContext + | HeaderContext, +> { + readonly #flexRenderComponentFactory = new FlexRenderComponentFactory( + inject(ViewContainerRef), + ) + + readonly inputContent: InputSignal> = input( + undefined as FlexRenderInputContent, + { alias: 'flexRender' }, + ) + readonly content = linkedSignal(() => this.inputContent()) + + readonly inputProps = input({} as TProps, { alias: 'flexRenderProps', }) + readonly props = linkedSignal(() => this.inputProps()) - readonly notifier = input<'doCheck' | 'tableChange'>('tableChange', { - alias: 'flexRenderNotifier', - }) - - readonly injector = input(inject(Injector), { + readonly inputInjector = input(inject(Injector), { alias: 'flexRenderInjector', }) + readonly injector = linkedSignal(() => this.inputInjector()) - readonly viewContainerRef = inject(ViewContainerRef) - - readonly templateRef = inject(TemplateRef) + readonly #viewContainerRef = inject(ViewContainerRef) + readonly #templateRef = inject(TemplateRef) - table: Table renderFlags = FlexRenderFlags.ViewFirstRender renderView: FlexRenderView | null = null @@ -112,32 +116,44 @@ export class FlexRender< return mapToFlexRenderTypedContent(latestContent) }) - ngOnChanges(changes: SimpleChanges>) { - if (changes['props']) { - const props = changes.props.currentValue - this.table = 'table' in props ? props.table : null - this.renderFlags |= FlexRenderFlags.PropsReferenceChanged - this.bindTableDirtyCheck() - } - if (changes['content']) { - this.renderFlags |= - FlexRenderFlags.ContentChanged | FlexRenderFlags.ViewFirstRender - this.update() - } - } + constructor() { + const destroyRef = inject(DestroyRef) + destroyRef.onDestroy(() => { + if (this.#currentEffectRef) { + this.#currentEffectRef.destroy() + this.#currentEffectRef = null + } + if (this.renderView) { + this.renderView.unmount() + this.renderView = 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 - } + let previousContent: FlexRenderInputContent + let previousProps: TProps - if (this.notifier() === 'doCheck') { - this.renderFlags |= FlexRenderFlags.DirtyCheck - this.doCheck() - } + effect(() => { + const props = this.props() + const content = this.content() + + if (!(this.renderFlags & FlexRenderFlags.ViewFirstRender)) { + if (previousContent !== content) { + this.renderFlags |= FlexRenderFlags.ContentChanged + } + if (previousProps !== props) { + this.renderFlags |= FlexRenderFlags.PropsReferenceChanged + } + } + + untracked(() => this.update()) + + if (FlexRenderFlags.ViewFirstRender & this.renderFlags) { + this.renderFlags &= ~FlexRenderFlags.ViewFirstRender + } + + previousContent = content + previousProps = props + }) } private doCheck() { @@ -154,28 +170,6 @@ export class FlexRender< this.update() } - #tableChangeEffect: EffectRef | null = null - - private bindTableDirtyCheck() { - this.#tableChangeEffect?.destroy() - this.#tableChangeEffect = null - let firstCheck = !!(this.renderFlags & FlexRenderFlags.ViewFirstRender) - if (this.table && this.notifier() === 'tableChange') { - this.#tableChangeEffect = effect( - () => { - this.table.get() - if (firstCheck) { - firstCheck = false - return - } - this.renderFlags |= FlexRenderFlags.DirtyCheck - this.doCheck() - }, - { injector: this.injector() }, - ) - } - } - update() { if ( this.renderFlags & @@ -184,46 +178,49 @@ export class FlexRender< 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.renderFlags & FlexRenderFlags.Dirty) { if (this.renderView) this.renderView.dirtyCheck() - this.renderFlags &= ~( - FlexRenderFlags.DirtyCheck | FlexRenderFlags.DirtySignal - ) + this.renderFlags &= ~FlexRenderFlags.Dirty } } #currentEffectRef: EffectRef | null = null render() { + // When the view is recreated from scratch (content change or first render), + // we have to destroy the current effect listener since it will be recreated + // skipping the first call (FlexRenderFlags.RenderEffectChecked) if (this.#shouldRecreateEntireView() && this.#currentEffectRef) { this.#currentEffectRef.destroy() this.#currentEffectRef = null this.renderFlags &= ~FlexRenderFlags.RenderEffectChecked } - this.viewContainerRef.clear() + this.#viewContainerRef.clear() + if (this.renderView) { + this.renderView.unmount() + this.renderView = null + } + 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) - } - + 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') { + // to react to changes. If the current fn uses signals, we will set the DirtySignal flag + // to re-schedule the component updates + if ( + !this.#currentEffectRef && + typeof untracked(this.content) === 'function' + ) { this.#currentEffectRef = effect( () => { this.#latestContent() @@ -231,12 +228,10 @@ export class FlexRender< 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() + this.renderFlags |= FlexRenderFlags.Dirty + this.doCheck() }, - { injector: this.viewContainerRef.injector }, + { injector: this.#viewContainerRef.injector }, ) } } @@ -274,7 +269,7 @@ export class FlexRender< ? content : content?.(this.props()) } - const ref = this.viewContainerRef.createEmbeddedView(this.templateRef, { + const ref = this.#viewContainerRef.createEmbeddedView(this.#templateRef, { get $implicit() { return context() }, @@ -286,11 +281,15 @@ export class FlexRender< template: Extract, ): FlexRenderTemplateView { const latestContext = () => this.props() - const view = this.viewContainerRef.createEmbeddedView(template.content, { - get $implicit() { - return latestContext() + const view = this.#viewContainerRef.createEmbeddedView( + template.content, + { + get $implicit() { + return latestContext() + }, }, - }) + { injector: this.#getInjector() }, + ) return new FlexRenderTemplateView(template, view) } @@ -300,16 +299,8 @@ export class FlexRender< { 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 _], - }) - const componentInjector = Injector.create({ - parent: injector ?? this.injector(), - providers: [{ provide: FlexRenderComponentProps, useValue: proxy }], - }) + const { injector } = flexRenderComponent.content + const componentInjector = this.#getInjector(injector) const view = this.#flexRenderComponentFactory.createComponent( flexRenderComponent.content, componentInjector, @@ -320,19 +311,49 @@ export class FlexRender< #renderCustomComponent( component: Extract, ): FlexRenderComponentView { + const instance = flexRenderComponent(component.content, { + inputs: this.props(), + injector: this.#getInjector(this.injector()), + }) const view = this.#flexRenderComponentFactory.createComponent( - flexRenderComponent(component.content, { - inputs: this.props(), - injector: this.injector(), - }), + instance, this.injector(), ) return new FlexRenderComponentView(component, view) } -} -/** - * @deprecated Use `FlexRender` import instead. - * @alias FlexRender - */ -export const FlexRenderDirective = FlexRender + #getInjector(parentInjector?: Injector) { + const getContext = () => this.props() + const proxy = new Proxy(this.props(), { + get: (_, key) => getContext()[key as keyof typeof _], + }) + + const staticProviders = [] + if ('table' in proxy) { + staticProviders.push({ + provide: TanStackTableToken, + useValue: () => proxy.table, + }) + } + if ('cell' in proxy) { + staticProviders.push({ + provide: TanStackTableCellToken, + useValue: () => proxy.cell, + }) + } + if ('header' in proxy) { + staticProviders.push({ + provide: TanStackTableHeaderToken, + useValue: () => proxy.header, + }) + } + + return Injector.create({ + parent: parentInjector ?? this.injector(), + providers: [ + ...staticProviders, + { provide: FlexRenderComponentProps, useValue: proxy }, + ], + }) + } +} diff --git a/packages/angular-table/src/helpers/cell.ts b/packages/angular-table/src/helpers/cell.ts new file mode 100644 index 0000000000..b54c98da67 --- /dev/null +++ b/packages/angular-table/src/helpers/cell.ts @@ -0,0 +1,43 @@ +import { Directive, InjectionToken, inject, input } from '@angular/core' +import { Cell, CellData, RowData, TableFeatures } from '@tanstack/table-core' +import type { Signal } from '@angular/core' + +export interface TanStackTableCellContext< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, +> { + cell: Signal> +} + +export const TanStackTableCellToken = new InjectionToken< + TanStackTableCellContext['cell'] +>('[TanStack Table] CellContext') + +@Directive({ + selector: '[tanStackTableCell]', + exportAs: 'cell', + providers: [ + { + provide: TanStackTableCellToken, + useFactory: () => inject(TanStackTableCell).cell, + }, + ], +}) +export class TanStackTableCell< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, +> implements TanStackTableCellContext { + readonly cell = input.required>({ + alias: 'tanStackTableCell', + }) +} + +export function injectTableCellContext< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, +>(): TanStackTableCellContext['cell'] { + return inject(TanStackTableCellToken) +} diff --git a/packages/angular-table/src/helpers/createTableHook.ts b/packages/angular-table/src/helpers/createTableHook.ts new file mode 100644 index 0000000000..55a2442b39 --- /dev/null +++ b/packages/angular-table/src/helpers/createTableHook.ts @@ -0,0 +1,395 @@ +import { createColumnHelper as coreCreateColumnHelper } from '@tanstack/table-core' +import { injectTable } from '../injectTable' +import { injectFlexRenderContext } from '../flexRender' +import { injectTableHeaderContext as _injectTableHeaderContext } from './header' +import { injectTableContext as _injectTableContext } from './table' +import { injectTableCellContext as _injectTableCellContext } from './cell' +import type { FlexRenderContent } from '../flexRender' +import type { AngularTable } from '../injectTable' +import type { + AccessorFn, + AccessorFnColumnDef, + AccessorKeyColumnDef, + Cell, + CellContext, + CellData, + Column, + ColumnDef, + DeepKeys, + DeepValue, + DisplayColumnDef, + GroupColumnDef, + Header, + HeaderContext, + IdentifiedColumnDef, + Row, + RowData, + Table, + TableFeature, + TableFeatures, + TableOptions, + TableState, +} from '@tanstack/table-core' +import type { Type } from '@angular/core' + +type RenderableComponent = + | Type + | (>(props: T) => FlexRenderContent) + +// ============================================================================= +// Enhanced Context Types with Pre-bound Components +// ============================================================================= + +/** + * Enhanced CellContext with pre-bound cell components. + * The `cell` property includes the registered cellComponents. + */ +export type AppCellContext< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, + TCellComponents extends Record, +> = { + cell: Cell & + TCellComponents & { FlexRender: () => unknown } + column: Column + getValue: CellContext['getValue'] + renderValue: CellContext['renderValue'] + row: Row + table: Table +} + +/** + * Enhanced HeaderContext with pre-bound header components. + * The `header` property includes the registered headerComponents. + */ +export type AppHeaderContext< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, + THeaderComponents extends Record, +> = { + column: Column + header: Header & + THeaderComponents & { FlexRender: () => unknown } + table: Table +} + +// ============================================================================= +// Enhanced Column Definition Types +// ============================================================================= + +/** + * Template type for column definitions that can be a string or a function. + */ +type AppColumnDefTemplate = + | string + | ((props: TProps) => any) + +/** + * Enhanced column definition base with pre-bound components in cell/header/footer contexts. + */ +type AppColumnDefBase< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, + TCellComponents extends Record, + THeaderComponents extends Record, +> = Omit< + IdentifiedColumnDef, + 'cell' | 'header' | 'footer' +> & { + cell?: AppColumnDefTemplate< + AppCellContext + > + header?: AppColumnDefTemplate< + AppHeaderContext + > + footer?: AppColumnDefTemplate< + AppHeaderContext + > +} + +/** + * Enhanced display column definition with pre-bound components. + */ +type AppDisplayColumnDef< + TFeatures extends TableFeatures, + TData extends RowData, + TCellComponents extends Record, + THeaderComponents extends Record, +> = Omit< + DisplayColumnDef, + 'cell' | 'header' | 'footer' +> & { + cell?: AppColumnDefTemplate< + AppCellContext + > + header?: AppColumnDefTemplate< + AppHeaderContext + > + footer?: AppColumnDefTemplate< + AppHeaderContext + > +} + +/** + * Enhanced group column definition with pre-bound components. + */ +type AppGroupColumnDef< + TFeatures extends TableFeatures, + TData extends RowData, + TCellComponents extends Record, + THeaderComponents extends Record, +> = Omit< + GroupColumnDef, + 'cell' | 'header' | 'footer' | 'columns' +> & { + cell?: AppColumnDefTemplate< + AppCellContext + > + header?: AppColumnDefTemplate< + AppHeaderContext + > + footer?: AppColumnDefTemplate< + AppHeaderContext + > + columns?: Array> +} + +// ============================================================================= +// Enhanced Column Helper Type +// ============================================================================= + +/** + * Enhanced column helper with pre-bound components in cell/header/footer contexts. + * This enables TypeScript to know about the registered components when defining columns. + */ +export type AppColumnHelper< + TFeatures extends TableFeatures, + TData extends RowData, + TCellComponents extends Record, + THeaderComponents extends Record, +> = { + /** + * Creates a data column definition with an accessor key or function. + * The cell, header, and footer contexts include pre-bound components. + */ + accessor: < + TAccessor extends AccessorFn | DeepKeys, + TValue extends TAccessor extends AccessorFn + ? TReturn + : TAccessor extends DeepKeys + ? DeepValue + : never, + >( + accessor: TAccessor, + column: TAccessor extends AccessorFn + ? AppColumnDefBase< + TFeatures, + TData, + TValue, + TCellComponents, + THeaderComponents + > & { id: string } + : AppColumnDefBase< + TFeatures, + TData, + TValue, + TCellComponents, + THeaderComponents + >, + ) => TAccessor extends AccessorFn + ? AccessorFnColumnDef + : AccessorKeyColumnDef + + /** + * Wraps an array of column definitions to preserve each column's individual TValue type. + */ + columns: >>( + columns: [...TColumns], + ) => Array> & [...TColumns] + + /** + * Creates a display column definition for non-data columns. + * The cell, header, and footer contexts include pre-bound components. + */ + display: ( + column: AppDisplayColumnDef< + TFeatures, + TData, + TCellComponents, + THeaderComponents + >, + ) => DisplayColumnDef + + /** + * Creates a group column definition with nested child columns. + * The cell, header, and footer contexts include pre-bound components. + */ + group: ( + column: AppGroupColumnDef< + TFeatures, + TData, + TCellComponents, + THeaderComponents + >, + ) => GroupColumnDef +} + +/** + * Extended table API returned by useAppTable with all App wrapper components + */ +export type AppAngularTable< + TFeatures extends TableFeatures, + TData extends RowData, + TSelected, + TTableComponents extends Record, + TCellComponents extends Record, + THeaderComponents extends Record, +> = AngularTable & NoInfer + +// ============================================================================= +// CreateTableHook Options and Props +// ============================================================================= + +/** + * Options for creating a table hook with pre-bound components and default table options. + * Extends all TableOptions except 'columns' | 'data' | 'store' | 'state' | 'initialState'. + */ +export type CreateTableContextOptions< + TFeatures extends TableFeatures, + TTableComponents extends Record, + TCellComponents extends Record, + THeaderComponents extends Record, +> = Omit< + TableOptions, + 'columns' | 'data' | 'store' | 'state' | 'initialState' +> & { + /** + * Table-level components that need access to the table instance. + * These are available directly on the table object returned by useAppTable. + * Use `useTableContext()` inside these components. + * @example { PaginationControls, GlobalFilter, RowCount } + */ + tableComponents?: TTableComponents + /** + * Cell-level components that need access to the cell instance. + * These are available on the cell object passed to AppCell's children. + * Use `useCellContext()` inside these components. + * @example { TextCell, NumberCell, DateCell, CurrencyCell } + */ + cellComponents?: TCellComponents + /** + * Header-level components that need access to the header instance. + * These are available on the header object passed to AppHeader/AppFooter's children. + * Use `useHeaderContext()` inside these components. + * @example { SortIndicator, ColumnFilter, ResizeHandle } + */ + headerComponents?: THeaderComponents +} + +export function createTableHook< + TFeatures extends TableFeatures, + const TTableComponents extends Record, + const TCellComponents extends Record, + const THeaderComponents extends Record, +>({ + tableComponents, + cellComponents, + headerComponents, + ...defaultTableOptions +}: CreateTableContextOptions< + TFeatures, + TTableComponents, + TCellComponents, + THeaderComponents +>) { + function injectTableContext() { + return _injectTableContext() + } + + function injectTableHeaderContext() { + return _injectTableHeaderContext() + } + + function injectTableCellContext() { + return _injectTableCellContext() + } + + function injectFlexRenderHeaderContext< + TData extends RowData, + TValue extends CellData, + >() { + return injectFlexRenderContext>() + } + + function injectFlexRenderCellContext< + TData extends RowData, + TValue extends CellData, + >() { + return injectFlexRenderContext>() + } + + function injectAppTable( + tableOptions: () => Omit< + TableOptions, + '_features' | '_rowModels' + >, + selector?: (state: TableState) => TSelected, + ): AppAngularTable< + TFeatures, + TData, + TSelected, + TTableComponents, + TCellComponents, + THeaderComponents + > { + const appTableFeatures: TableFeature<{}> = { + constructTableAPIs: (table) => { + Object.assign(table, tableComponents) + }, + assignCellPrototype(prototype) { + Object.assign(prototype, cellComponents) + }, + assignHeaderPrototype(prototype) { + Object.assign(prototype, headerComponents) + }, + } + + return injectTable(() => { + const options = { + ...defaultTableOptions, + ...tableOptions(), + } as TableOptions + options._features = { ...options._features, appTableFeatures } + return options + }, selector) as AngularTable + } + + function createAppColumnHelper(): AppColumnHelper< + TFeatures, + TData, + TCellComponents, + THeaderComponents + > { + // The runtime implementation is the same - components are attached at render time + // This cast provides the enhanced types for column definitions + return coreCreateColumnHelper() as AppColumnHelper< + TFeatures, + TData, + TCellComponents, + THeaderComponents + > + } + + return { + createAppColumnHelper, + injectTableContext, + injectTableHeaderContext, + injectTableCellContext, + injectFlexRenderHeaderContext, + injectFlexRenderCellContext, + injectAppTable, + } +} diff --git a/packages/angular-table/src/helpers/flexRenderCell.ts b/packages/angular-table/src/helpers/flexRenderCell.ts new file mode 100644 index 0000000000..be575621ae --- /dev/null +++ b/packages/angular-table/src/helpers/flexRenderCell.ts @@ -0,0 +1,83 @@ +import { Directive, effect, inject, input } from '@angular/core' +import { + Cell, + CellData, + Header, + RowData, + TableFeatures, +} from '@tanstack/table-core' +import { FlexRenderDirective } from '../flexRender' + +/** + * Simplified directive wrapper of `*flexRender`. + * + * Use this utility component to render headers, cells, or footers with custom markup. + * + * Only one prop (`cell`, `header`, or `footer`) may be passed based on the used selector. + * + * @example + * ```html + * {{cell}} + * {{header}} + * {{footer}} + * ``` + * + * This replaces calling `*flexRender` directly like this: + * ```html + * {{cell}} + * {{header}} + * {{footer}} + * ``` + * + * @see {FlexRender} + */ +@Directive({ + selector: + 'ng-template[flexRenderCell], ng-template[flexRenderFooter], ng-template[flexRenderHeader]', + hostDirectives: [{ directive: FlexRenderDirective }], +}) +export class FlexRenderCell< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, +> { + readonly #flexRender = inject( + FlexRenderDirective, + ) + + readonly cell = input>(undefined, { + alias: 'flexRenderCell', + }) + + readonly header = input>(undefined, { + alias: 'flexRenderHeader', + }) + + readonly footer = input>(undefined, { + alias: 'flexRenderFooter', + }) + + constructor() { + effect(() => { + const cell = this.cell() + const header = this.header() + const footer = this.footer() + const { content, props } = this.#flexRender + + if (cell) { + content.set(cell.column.columnDef.cell) + props.set(cell.getContext()) + } + + if (header) { + content.set(header.column.columnDef.header) + props.set(header.getContext()) + } + + if (footer) { + content.set(footer.column.columnDef.footer) + props.set(footer.getContext()) + } + }) + } +} diff --git a/packages/angular-table/src/helpers/header.ts b/packages/angular-table/src/helpers/header.ts new file mode 100644 index 0000000000..d80e302ad9 --- /dev/null +++ b/packages/angular-table/src/helpers/header.ts @@ -0,0 +1,43 @@ +import { Directive, InjectionToken, inject, input } from '@angular/core' +import { CellData, Header, RowData, TableFeatures } from '@tanstack/table-core' +import type { Signal } from '@angular/core' + +export const TanStackTableHeaderToken = new InjectionToken< + TanStackTableHeaderContext['header'] +>('[TanStack Table] HeaderContext') + +export interface TanStackTableHeaderContext< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, +> { + header: Signal> +} + +@Directive({ + selector: '[tanStackTableHeader]', + exportAs: 'header', + providers: [ + { + provide: TanStackTableHeaderToken, + useFactory: () => inject(TanStackTableHeader).header, + }, + ], +}) +export class TanStackTableHeader< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, +> implements TanStackTableHeaderContext { + readonly header = input.required>({ + alias: 'tanStackTableHeader', + }) +} + +export function injectTableHeaderContext< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData, +>(): TanStackTableHeaderContext['header'] { + return inject(TanStackTableHeaderToken) +} diff --git a/packages/angular-table/src/helpers/table.ts b/packages/angular-table/src/helpers/table.ts new file mode 100644 index 0000000000..4baec5264f --- /dev/null +++ b/packages/angular-table/src/helpers/table.ts @@ -0,0 +1,40 @@ +import { Directive, InjectionToken, inject, input } from '@angular/core' +import { RowData, Table, TableFeatures } from '@tanstack/table-core' +import type { Signal } from '@angular/core' + +export const TanStackTableToken = new InjectionToken< + TanStackTableContext['table'] +>('[TanStack Table] Table Context') + +export interface TanStackTableContext< + TFeatures extends TableFeatures, + TData extends RowData, +> { + table: Signal> +} + +@Directive({ + selector: '[tanStackTable]', + exportAs: 'table', + providers: [ + { + provide: TanStackTableToken, + useFactory: () => inject(TanStackTable).table, + }, + ], +}) +export class TanStackTable< + TFeatures extends TableFeatures, + TData extends RowData, +> implements TanStackTableContext { + readonly table = input.required>({ + alias: 'tanStackTable', + }) +} + +export function injectTableContext< + TFeatures extends TableFeatures, + TData extends RowData, +>(): TanStackTableContext['table'] { + return inject(TanStackTableToken) +} diff --git a/packages/angular-table/src/index.ts b/packages/angular-table/src/index.ts index 30cd0760fc..7045d58ba8 100644 --- a/packages/angular-table/src/index.ts +++ b/packages/angular-table/src/index.ts @@ -1,9 +1,20 @@ +import { FlexRenderCell } from './helpers/flexRenderCell' +import { FlexRenderDirective } from './flexRender' + export * from '@tanstack/table-core' -export * from './angularReactivityFeature' +// export * from './angularReactivityFeature' export * from './createTableHelper' -export * from './flex-render' +export * from './flexRender' export * from './injectTable' -export * from './lazySignalInitializer' -export * from './reactivityUtils' +// export * from './lazySignalInitializer' +// export * from './reactivityUtils' export * from './flex-render/flex-render-component' + +export * from './helpers/cell' +export * from './helpers/header' +export * from './helpers/table' +export * from './helpers/createTableHook' +export * from './helpers/flexRenderCell' + +export const FlexRender = [FlexRenderDirective, FlexRenderCell] as const diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index 9c3bf7e99f..d99eb1a646 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -10,7 +10,6 @@ import { constructTable } from '@tanstack/table-core' import { injectStore } from '@tanstack/angular-store' import { lazyInit } from './lazySignalInitializer' import { angularReactivityFeature } from './angularReactivityFeature' -import type { Signal } from '@angular/core' import type { RowData, Table, @@ -18,6 +17,7 @@ import type { TableOptions, TableState, } from '@tanstack/table-core' +import type { Signal, ValueEqualityFn } from '@angular/core' export type AngularTable< TFeatures extends TableFeatures, @@ -33,18 +33,18 @@ export type AngularTable< */ Subscribe: (props: { selector: (state: TableState) => TSubSelected - children: ((state: Signal>) => any) | any - }) => any + equal?: ValueEqualityFn + }) => Signal> } export function injectTable< TFeatures extends TableFeatures, TData extends RowData, - TSelected = {}, + TSelected = TableState, >( options: () => TableOptions, - selector: (state: TableState) => TSelected = () => - ({}) as TSelected, + selector: (state: TableState) => TSelected = (state) => + state as TSelected, ): AngularTable { assertInInjectionContext(injectTable) const injector = inject(Injector) @@ -58,11 +58,9 @@ export function injectTable< }, } as TableOptions - const table = constructTable(resolvedOptions) as AngularTable< - TFeatures, - TData, - TSelected - > + const table: AngularTable = constructTable( + resolvedOptions, + ) as AngularTable const updatedOptions = computed>(() => { const tableOptionsValue = options() @@ -96,12 +94,9 @@ export function injectTable< const tableSignalNotifier = computed( () => { - // TODO: replace computed just using effects could be better? tableState() table.setOptions(updatedOptions()) - untracked(() => { - table.baseStore.setState((prev) => ({ ...prev })) - }) + untracked(() => table.baseStore.setState((prev) => ({ ...prev }))) return table }, { equal: () => false }, @@ -111,18 +106,17 @@ export function injectTable< table.Subscribe = function Subscribe(props: { selector: (state: TableState) => TSubSelected - children: ((state: Signal>) => any) | any + equal?: ValueEqualityFn }) { - const selected = injectStore(table.store, props.selector, { injector }) - if (typeof props.children === 'function') { - return props.children(selected) - } - return props.children + return injectStore(table.store, props.selector, { + injector, + equal: props.equal, + }) } - const stateStore = injectStore(table.store, selector, { injector }) - - Reflect.set(table, 'state', stateStore) + Object.defineProperty(table, 'state', { + value: injectStore(table.store, selector, { injector }), + }) return table }) diff --git a/packages/angular-table/src/reactivityUtils.ts b/packages/angular-table/src/reactivityUtils.ts index eb02ce9906..9545c4afc7 100644 --- a/packages/angular-table/src/reactivityUtils.ts +++ b/packages/angular-table/src/reactivityUtils.ts @@ -1,4 +1,6 @@ import { computed } from '@angular/core' +import { $internalMemoFnMeta, getMemoFnMeta } from '@tanstack/table-core' +import type { MemoFnMeta } from '@tanstack/table-core' import type { Signal } from '@angular/core' export const $TABLE_REACTIVE = Symbol('reactive') @@ -7,10 +9,8 @@ export function markReactive(obj: T): void { Object.defineProperty(obj, $TABLE_REACTIVE, { value: true }) } -export function isReactive( - obj: T, -): obj is T & { [$TABLE_REACTIVE]: true } { - return Reflect.get(obj, $TABLE_REACTIVE) === true +export function isReactive(obj: T): boolean { + return Reflect.get(obj as {}, $TABLE_REACTIVE) === true } /** @@ -39,12 +39,12 @@ export function defineLazyComputedProperty( Object.defineProperty(originalObject, property, { enumerable: true, configurable: true, - get(this) { + get() { const computedValue = toComputed(notifier, valueFn, property) markReactive(computedValue) // Once the property is set the first time, we don't need a getter anymore // since we have a computed / cached fn value - Object.defineProperty(this, property, { + Object.defineProperty(originalObject, property, { value: computedValue, configurable: true, enumerable: true, @@ -58,14 +58,12 @@ export function defineLazyComputedProperty( /** * @internal should be used only internally */ -type ComputedFunction = - // 0 args - T extends (...args: []) => infer TReturn - ? Signal - : // 1+ args - T extends (arg0?: any, ...args: Array) => any - ? T - : never +type ComputedFunction = T extends () => infer TReturn + ? Signal + : // 1+ args + T extends (arg0?: any, ...args: Array) => any + ? T + : never /** * @description Transform a function into a computed that react to given notifier re-computations @@ -91,7 +89,7 @@ export function toComputed< fn: TFunction, debugName: string, ): ComputedFunction { - const hasArgs = fn.length > 0 + const hasArgs = getFnArgsLength(fn) > 0 if (!hasArgs) { const computedFn = computed( () => { @@ -105,23 +103,37 @@ export function toComputed< return computedFn as ComputedFunction } - const computedCache: Record> = {} - - const computedFn = (arg0: any, ...otherArgs: Array) => { - const argsArray = [arg0, ...otherArgs] + const computedFn: ((this: unknown, ...argsArray: Array) => unknown) & { + _reactiveCache?: Record> + } = function (this: unknown, ...argsArray: Array) { + const cacheable = + argsArray.length === 0 || + argsArray.every((arg) => { + return ( + arg === null || + arg === undefined || + typeof arg === 'string' || + typeof arg === 'number' || + typeof arg === 'boolean' || + typeof arg === 'symbol' + ) + }) + if (!cacheable) { + return fn.apply(this, argsArray) + } const serializedArgs = serializeArgs(...argsArray) - if (computedCache.hasOwnProperty(serializedArgs)) { - return computedCache[serializedArgs]?.() + if ((computedFn._reactiveCache ??= {})[serializedArgs]) { + return computedFn._reactiveCache[serializedArgs]() } const computedSignal = computed( () => { void notifier() - return fn(...argsArray) + return fn.apply(this, argsArray) }, { debugName }, ) - computedCache[serializedArgs] = computedSignal + computedFn._reactiveCache[serializedArgs] = computedSignal return computedSignal() } @@ -136,28 +148,43 @@ function serializeArgs(...args: Array) { return JSON.stringify(args) } +function getFnArgsLength( + fn: ((...args: any) => any) & { originalArgsLength?: number }, +): number { + return Math.max(0, getMemoFnMeta(fn)?.originalArgsLength ?? fn.length) +} + export function assignReactivePrototypeAPI( notifier: Signal, prototype: Record, fnName: string, ) { + if (isReactive(prototype[fnName])) return + const fn = prototype[fnName] - const originalArgsLength = Math.max( - 0, - Reflect.get(fn, 'originalArgsLength') ?? 0, - ) + const originalArgsLength = getFnArgsLength(fn) if (originalArgsLength <= 1) { - const cached = {} as Record> Object.defineProperty(prototype, fnName, { enumerable: true, configurable: true, get(this) { const self = this - return (cached[`${self.id}_${fnName}`] ??= computed(() => { - notifier() - return fn.call(self) - })) + // Create a cache in the current prototype to allow the signals + // to be garbage collected. Shorthand for a WeakMap implementation + self._reactiveCache ??= {} + const cached = (self._reactiveCache[`${self.id}${fnName}`] ??= computed( + () => { + notifier() + return fn.apply(self) + }, + {}, + )) + markReactive(cached) + cached[$internalMemoFnMeta] = { + originalArgsLength, + } satisfies MemoFnMeta + return cached }, }) } else { @@ -165,5 +192,9 @@ export function assignReactivePrototypeAPI( notifier() return fn.apply(this, args) } + markReactive(prototype[fnName]) + prototype[fnName][$internalMemoFnMeta] = { + originalArgsLength, + } satisfies MemoFnMeta } } diff --git a/packages/angular-table/tests/angularReactivityFeature.test.ts b/packages/angular-table/tests/angularReactivityFeature.test.ts new file mode 100644 index 0000000000..f2de587be1 --- /dev/null +++ b/packages/angular-table/tests/angularReactivityFeature.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test, vi } from 'vitest' +import { computed, effect, isSignal, signal } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { injectTable, stockFeatures } from '../src' +import { getFnReactiveCache, testShouldBeComputedProperty } from './test-utils' +import type { WritableSignal } from '@angular/core' +import type { ColumnDef } from '../src' + +describe('angularReactivityFeature', () => { + type Data = { id: string; title: string } + const data = signal>([{ id: '1', title: 'Title' }]) + const columns: Array> = [ + { + id: 'id', + header: 'Id', + accessorKey: 'id', + cell: (context) => context.getValue(), + }, + { + id: 'title', + header: 'Title', + accessorKey: 'title', + cell: (context) => context.getValue(), + }, + ] + + function createTestTable(_data: WritableSignal> = data) { + return TestBed.runInInjectionContext(() => + injectTable(() => ({ + data: _data(), + _features: { ...stockFeatures }, + columns: columns, + getRowId: (row) => row.id, + reactivity: { + column: true, + cell: true, + row: true, + header: true, + }, + })), + ) + } + + const table = createTestTable() + const tablePropertyKeys = Object.keys(table) + + describe('Table property reactivity', () => { + test.each( + tablePropertyKeys.map((property) => [ + property, + testShouldBeComputedProperty(table, property), + ]), + )('property (%s) is computed -> (%s)', (name, expected) => { + const tableProperty = table[name as keyof typeof table] + expect(isSignal(tableProperty)).toEqual(expected) + }) + + describe('will create a computed for non detectable computed properties', () => { + test('getIsSomeRowsPinned', () => { + table.getIsSomeRowsPinned('top') + table.getIsSomeRowsPinned('bottom') + table.getIsSomeRowsPinned() + + expect(getFnReactiveCache(table.getIsSomeRowsPinned)).toHaveProperty( + '["top"]', + ) + expect(getFnReactiveCache(table.getIsSomeRowsPinned)).toHaveProperty( + '["bottom"]', + ) + expect(getFnReactiveCache(table.getIsSomeRowsPinned)).toHaveProperty( + '[]', + ) + }) + }) + }) + + describe('Header property reactivity', () => { + const headers = table.getHeaderGroups() + headers.forEach((headerGroup, index) => { + const headerPropertyKeys = Object.keys(headerGroup) + test.each( + headerPropertyKeys.map((property) => [ + property, + testShouldBeComputedProperty(headerGroup, property), + ]), + )( + `HeaderGroup ${headerGroup.id} (${index}) - property (%s) is computed -> (%s)`, + (name, expected) => { + const tableProperty = headerGroup[name as keyof typeof headerGroup] + expect(isSignal(tableProperty)).toEqual(expected) + }, + ) + + const headers = headerGroup.headers + headers.forEach((header, cellIndex) => { + const headerPropertyKeys = Object.keys(header).concat( + Object.getOwnPropertyNames(Object.getPrototypeOf(header)), + ) + test.each( + headerPropertyKeys.map((property) => [ + property, + testShouldBeComputedProperty(header, property), + ]), + )( + `HeaderGroup ${headerGroup.id} (${index}) / Header ${header.id} - property (%s) is computed -> (%s)`, + (name, expected) => { + const tableProperty = header[name as keyof typeof header] + expect(isSignal(tableProperty)).toEqual(expected) + }, + ) + }) + }) + }) + + describe('Column property reactivity', () => { + const columns = table.getAllColumns() + columns.forEach((column, index) => { + const columnPropertyKeys = Object.keys(column).concat( + Object.getOwnPropertyNames(Object.getPrototypeOf(column)), + ) + test.each( + columnPropertyKeys.map((property) => [ + property, + testShouldBeComputedProperty(column, property), + ]), + )( + `Column ${column.id} (${index}) - property (%s) is computed -> (%s)`, + (name, expected) => { + const tableProperty = column[name as keyof typeof column] + expect(isSignal(tableProperty)).toEqual(expected) + }, + ) + }) + }) + + describe('Row and cells property reactivity', () => { + const flatRows = table.getRowModel().flatRows + flatRows.forEach((row, index) => { + const rowsPropertyKeys = Object.keys(row).concat( + Object.getOwnPropertyNames(Object.getPrototypeOf(row)), + ) + test.each( + rowsPropertyKeys.map((property) => [ + property, + testShouldBeComputedProperty(row, property), + ]), + )( + `Row ${row.id} (${index}) - property (%s) is computed -> (%s)`, + (name, expected) => { + const tableProperty = row[name as keyof typeof row] + expect(isSignal(tableProperty)).toEqual(expected) + }, + ) + + const cells = row.getAllCells() + cells.forEach((cell, cellIndex) => { + const cellPropertyKeys = Object.keys(cell).concat( + Object.getOwnPropertyNames(Object.getPrototypeOf(cell)), + ) + test.each( + cellPropertyKeys.map((property) => [ + property, + testShouldBeComputedProperty(cell, property), + ]), + )( + `Row ${row.id} (${index}) / Cell ${cell.id} - property (%s) is computed -> (%s)`, + (name, expected) => { + const tableProperty = cell[name as keyof typeof cell] + expect(isSignal(tableProperty)).toEqual(expected) + }, + ) + }) + }) + }) + + describe('Integration', () => { + test('methods works will be reactive effects', () => { + const data = signal>([{ id: '1', title: 'Title' }]) + const table = createTestTable(data) + const isSelectedRow1Captor = vi.fn<(val: boolean) => void>() + const cellGetValueCaptor = vi.fn<(val: unknown) => void>() + const columnIsVisibleCaptor = vi.fn<(val: boolean) => void>() + + // This will test a case where you put in the effect a single cell property method + // which will trigger effect reschedule only when the value changes, acting like + // its a computed value + const cell = computed( + () => table.getRowModel().rows[0]!.getAllCells()[0]!, + ) + + TestBed.runInInjectionContext(() => { + effect(() => { + isSelectedRow1Captor(cell().row.getIsSelected()) + }) + effect(() => { + cellGetValueCaptor(cell().getValue()) + }) + effect(() => { + columnIsVisibleCaptor(cell().column.getIsVisible()) + }) + }) + + TestBed.tick() + expect(isSelectedRow1Captor).toHaveBeenCalledTimes(1) + expect(cellGetValueCaptor).toHaveBeenCalledTimes(1) + expect(columnIsVisibleCaptor).toHaveBeenCalledTimes(1) + + cell().row.toggleSelected(true) + TestBed.tick() + expect(isSelectedRow1Captor).toHaveBeenCalledTimes(2) + expect(cellGetValueCaptor).toHaveBeenCalledTimes(1) + expect(columnIsVisibleCaptor).toHaveBeenCalledTimes(1) + + data.set([{ id: '1', title: 'Title 3' }]) + TestBed.tick() + // In this case it will be called twice since `data` will change and + // the cell instance will be recreated + expect(isSelectedRow1Captor).toHaveBeenCalledTimes(3) + expect(cellGetValueCaptor).toHaveBeenCalledTimes(2) + expect(columnIsVisibleCaptor).toHaveBeenCalledTimes(2) + + cell().column.toggleVisibility(false) + TestBed.tick() + expect(isSelectedRow1Captor).toHaveBeenCalledTimes(3) + expect(cellGetValueCaptor).toHaveBeenCalledTimes(2) + expect(columnIsVisibleCaptor).toHaveBeenCalledTimes(3) + + expect(isSelectedRow1Captor.mock.calls).toEqual([[false], [true], [true]]) + expect(cellGetValueCaptor.mock.calls).toEqual([['1'], ['1']]) + expect(columnIsVisibleCaptor.mock.calls).toEqual([ + [true], + [true], + [false], + ]) + }) + }) +}) diff --git a/packages/angular-table/tests/flex-render/flex-render.unit.test.ts b/packages/angular-table/tests/flex-render/flex-render.unit.test.ts index c366867354..96ce5241aa 100644 --- a/packages/angular-table/tests/flex-render/flex-render.unit.test.ts +++ b/packages/angular-table/tests/flex-render/flex-render.unit.test.ts @@ -5,10 +5,10 @@ import { describe, expect, test } from 'vitest' import { FlexRender, FlexRenderDirective, + flexRenderComponent, injectFlexRenderContext, -} from '../../src/flex-render' +} from '../../src' import { setFixtureSignalInput, setFixtureSignalInputs } from '../test-utils' -import { flexRenderComponent } from '../../src/flex-render/flex-render-component' import type { TemplateRef } from '@angular/core' import type { ComponentFixture } from '@angular/core/testing' @@ -167,28 +167,26 @@ describe('FlexRenderDirective', () => { @Component({ selector: 'app-test-render', template: ` - + `, standalone: true, - imports: [FlexRenderDirective], + imports: [FlexRender], }) class TestRenderComponent { - readonly content = input.required() + readonly content = input.required() readonly context = input.required>() } -type FlexRenderDirectiveAllowedContent = ReturnType< - FlexRender>['content'] +type FlexRenderAllowedContent = ReturnType< + FlexRenderDirective< + any, + any, + NonNullable, + NonNullable + >['content'] > function expectPrimitiveValueIs( diff --git a/packages/angular-table/tests/injectTable.test.ts b/packages/angular-table/tests/injectTable.test.ts index abc4995b34..1f74844c66 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -1,6 +1,12 @@ import { isProxy } from 'node:util/types' import { describe, expect, test, vi } from 'vitest' -import { Component, effect, input, isSignal, signal } from '@angular/core' +import { + ChangeDetectionStrategy, + Component, + effect, + input, + signal, +} from '@angular/core' import { TestBed } from '@angular/core/testing' import { ColumnDef, @@ -8,20 +14,17 @@ import { stockFeatures, } from '@tanstack/table-core' import { RowModel, injectTable } from '../src' -import { - setFixtureSignalInputs, - testShouldBeComputedProperty, -} from './test-utils' import type { PaginationState } from '../src' describe('injectTable', () => { - test('should render with required signal inputs', () => { + test('should support required signal inputs', () => { @Component({ - selector: 'app-fake', + selector: 'app-table', template: ``, standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) - class FakeComponent { + class TableComponent { data = input.required>() table = injectTable(() => ({ @@ -31,12 +34,18 @@ describe('injectTable', () => { })) } - const fixture = TestBed.createComponent(FakeComponent) - setFixtureSignalInputs(fixture, { - data: [], + @Component({ + selector: 'app-root', + imports: [TableComponent], + template: ` `, + changeDetection: ChangeDetectionStrategy.OnPush, }) + class RootComponent {} + const fixture = TestBed.createComponent(RootComponent) fixture.detectChanges() + + fixture.whenRenderingDone() }) describe('Proxy table', () => { @@ -55,8 +64,8 @@ describe('injectTable', () => { })), ) - test('table must be a signal', () => { - expect(isSignal(table.get)).toEqual(true) + test('table is proxy', () => { + expect(isProxy(table)).toBe(true) }) test('supports "in" operator', () => { @@ -66,7 +75,7 @@ describe('injectTable', () => { }) test('supports "Object.keys"', () => { - const keys = Object.keys(table.get()) + const keys = Object.keys(table.get()).concat('state') expect(Object.keys(table)).toEqual(keys) }) @@ -122,152 +131,3 @@ describe('injectTable', () => { }) }) }) - -describe('injectTable - Experimental reactivity', () => { - type Data = { id: string; title: string } - const data = signal>([{ id: '1', title: 'Title' }]) - const columns: Array> = [ - { id: 'id', header: 'Id', cell: (context) => context.getValue() }, - { id: 'title', header: 'Title', cell: (context) => context.getValue() }, - ] - const table = TestBed.runInInjectionContext(() => - injectTable(() => ({ - data: data(), - _features: { ...stockFeatures }, - columns: columns, - getRowId: (row) => row.id, - reactivity: { - column: true, - cell: true, - row: true, - header: true, - }, - })), - ) - const tablePropertyKeys = Object.keys(table) - - describe('Proxy', () => { - test('table is proxy', () => { - expect(isProxy(table)).toBe(true) - }) - - test('supports "in" operator', () => { - expect('_features' in table).toBe(true) - expect('options' in table).toBe(true) - expect('notFound' in table).toBe(false) - }) - - test('supports "Object.keys"', () => { - const keys = Object.keys(table) - expect(Object.keys(table)).toEqual(keys) - }) - - test('supports "Object.has"', () => { - const keys = Object.keys(table) - expect(Object.keys(table)).toEqual(keys) - }) - }) - - describe('Table property reactivity', () => { - test.each( - tablePropertyKeys.map((property) => [ - property, - testShouldBeComputedProperty(table, property), - ]), - )('property (%s) is computed -> (%s)', (name, expected) => { - const tableProperty = table[name as keyof typeof table] - expect(isSignal(tableProperty)).toEqual(expected) - }) - }) - - describe('Header property reactivity', () => { - const headers = table.getHeaderGroups() - headers.forEach((headerGroup, index) => { - const headerPropertyKeys = Object.keys(headerGroup) - test.each( - headerPropertyKeys.map((property) => [ - property, - testShouldBeComputedProperty(headerGroup, property), - ]), - )( - `HeaderGroup ${headerGroup.id} (${index}) - property (%s) is computed -> (%s)`, - (name, expected) => { - const tableProperty = headerGroup[name as keyof typeof headerGroup] - expect(isSignal(tableProperty)).toEqual(expected) - }, - ) - - const headers = headerGroup.headers - headers.forEach((header, cellIndex) => { - const headerPropertyKeys = Object.keys(header) - test.each( - headerPropertyKeys.map((property) => [ - property, - testShouldBeComputedProperty(header, property), - ]), - )( - `HeaderGroup ${headerGroup.id} (${index}) / Header ${header.id} - property (%s) is computed -> (%s)`, - (name, expected) => { - const tableProperty = header[name as keyof typeof header] - expect(isSignal(tableProperty)).toEqual(expected) - }, - ) - }) - }) - }) - - describe('Column property reactivity', () => { - const columns = table.getAllColumns() - columns.forEach((column, index) => { - const columnPropertyKeys = Object.keys(column) - test.each( - columnPropertyKeys.map((property) => [ - property, - testShouldBeComputedProperty(column, property), - ]), - )( - `Column ${column.id} (${index}) - property (%s) is computed -> (%s)`, - (name, expected) => { - const tableProperty = column[name as keyof typeof column] - expect(isSignal(tableProperty)).toEqual(expected) - }, - ) - }) - }) - - describe('Row property reactivity', () => { - const flatRows = table.getRowModel().flatRows - flatRows.forEach((row, index) => { - const rowsPropertyKeys = Object.keys(row) - test.each( - rowsPropertyKeys.map((property) => [ - property, - testShouldBeComputedProperty(row, property), - ]), - )( - `Row ${row.id} (${index}) - property (%s) is computed -> (%s)`, - (name, expected) => { - const tableProperty = row[name as keyof typeof row] - expect(isSignal(tableProperty)).toEqual(expected) - }, - ) - - const cells = row.getAllCells() - cells.forEach((cell, cellIndex) => { - const cellPropertyKeys = Object.keys(cell) - test.each( - cellPropertyKeys.map((property) => [ - property, - testShouldBeComputedProperty(cell, property), - ]), - )( - `Row ${row.id} (${index}) / Cell ${cell.id} - property (%s) is computed -> (%s)`, - (name, expected) => { - const tableProperty = cell[name as keyof typeof cell] - expect(isSignal(tableProperty)).toEqual(expected) - }, - ) - }) - }) - }) -}) diff --git a/packages/angular-table/tests/reactivityUtils.test.ts b/packages/angular-table/tests/reactivityUtils.test.ts index 72c3a5110b..5aff11bf23 100644 --- a/packages/angular-table/tests/reactivityUtils.test.ts +++ b/packages/angular-table/tests/reactivityUtils.test.ts @@ -26,15 +26,15 @@ describe('toComputed', () => { mockFn(result()) }) - TestBed.flushEffects() + TestBed.tick() expect(mockFn).toHaveBeenLastCalledWith(2) notifier.set(3) - TestBed.flushEffects() + TestBed.tick() expect(mockFn).toHaveBeenLastCalledWith(6) notifier.set(2) - TestBed.flushEffects() + TestBed.tick() expect(mockFn).toHaveBeenLastCalledWith(4) expect(mockFn.mock.calls.length).toEqual(3) @@ -53,7 +53,7 @@ describe('toComputed', () => { }, '3args', ) - expect(fn1.length).toEqual(1) + expect(fn1.length).toEqual(0) // currently full rest parameters is not supported const fn2 = toComputed( @@ -143,10 +143,12 @@ describe('toComputed', () => { describe('args 0~1', () => { test('creates a fn an explicit first argument and allows other args', () => { const notifier = signal(1) + const captor = vi.fn<(arg0?: number) => void>() + const captor2 = vi.fn<(arg0?: number) => void>() const fn1 = toComputed( notifier, - (arg0?: number) => { + (arg0: number | undefined) => { if (arg0 === undefined) { return 5 * notifier() } @@ -154,9 +156,26 @@ describe('toComputed', () => { }, 'optionalArgs', ) - expect(fn1.length).toEqual(1) - fn1() + expect(isSignal(fn1)).toEqual(false) + + TestBed.runInInjectionContext(() => { + effect(() => { + captor(fn1(0)) + }) + effect(() => { + captor2(fn1(1)) + }) + }) + + TestBed.tick() + notifier.set(2) + TestBed.tick() + notifier.set(3) + expect(captor.mock.calls).toHaveLength(1) + expect(captor2.mock.calls).toHaveLength(2) + expect(captor2).toHaveBeenNthCalledWith(1, 1) + expect(captor2).toHaveBeenNthCalledWith(2, 2) }) }) }) diff --git a/packages/angular-table/tests/test-utils.ts b/packages/angular-table/tests/test-utils.ts index 920220e3f2..7681a4d4a7 100644 --- a/packages/angular-table/tests/test-utils.ts +++ b/packages/angular-table/tests/test-utils.ts @@ -1,4 +1,5 @@ import { SIGNAL } from '@angular/core/primitives/signals' +import { getMemoFnMeta } from '@tanstack/table-core' import type { InputSignal } from '@angular/core' import type { ComponentFixture } from '@angular/core/testing' @@ -53,10 +54,34 @@ export async function flushQueue() { } const staticComputedProperties = ['get', 'state'] +const staticNonComputedProperties = [ + 'getIsSomeRowsPinned', + 'getColumn', + 'getRowId', + 'getRow', + 'getIsSomeColumnsPinned', + 'getContext', +] + +function getFnArgsLength( + fn: ((...args: any) => any) & { originalArgsLength?: number }, +): number { + return Math.max(0, getMemoFnMeta(fn)?.originalArgsLength ?? fn.length) +} + export const testShouldBeComputedProperty = ( testObj: any, propertyName: string, + excludeComputed: Array = [], ) => { + if (excludeComputed.includes(propertyName)) { + return false + } + + if (staticNonComputedProperties.some((prop) => propertyName === prop)) { + return false + } + if (staticComputedProperties.some((prop) => propertyName === prop)) { return true } @@ -67,7 +92,12 @@ export const testShouldBeComputedProperty = ( // Only properties with no arguments are computed const fn = testObj[propertyName] // Cannot test if is lazy computed since we return the unwrapped value - return fn instanceof Function && fn.length === 0 + const args = Math.max(0, getFnArgsLength(fn) - 1) + return fn instanceof Function && args === 0 } return false } + +export function getFnReactiveCache(fn: any): any { + return fn._reactiveCache +} diff --git a/packages/table-core/src/utils.ts b/packages/table-core/src/utils.ts index 5734ec3e8a..6d6f56be41 100755 --- a/packages/table-core/src/utils.ts +++ b/packages/table-core/src/utils.ts @@ -60,6 +60,23 @@ export function flattenBy( return flat } +export const $internalMemoFnMeta = Symbol('memoFnMeta') +export type MemoFnMeta = { originalArgsLength?: number } + +/** + * @internal + */ +function setMemoFnMeta(fn: Function, meta: MemoFnMeta) { + Object.defineProperty(fn, $internalMemoFnMeta, { value: meta }) +} + +/** + * @internal + */ +export function getMemoFnMeta(fn: any): MemoFnMeta | null { + return (typeof fn === 'function' && fn[$internalMemoFnMeta]) ?? null +} + interface MemoOptions, TDepArgs, TResult> { fn: (...args: NoInfer) => TResult memoDeps?: (depArgs?: TDepArgs) => [...TDeps] | undefined @@ -82,7 +99,7 @@ export const memo = , TDepArgs, TResult>({ let deps: Array | undefined = [] let result: TResult | undefined - return (depArgs): TResult => { + const memoizedFn = (depArgs?: TDepArgs): TResult => { onBeforeCompare?.() const newDeps = memoDeps?.(depArgs) const depsChanged = @@ -103,6 +120,10 @@ export const memo = , TDepArgs, TResult>({ return result } + + setMemoFnMeta(memoizedFn, { originalArgsLength: fn.length }) + + return memoizedFn } interface TableMemoOptions< @@ -356,9 +377,8 @@ export function assignPrototypeAPIs< return fn(this, ...args) } } - Object.defineProperties(prototype[fnKey], { - originalArgsLength: { value: fn.length }, - }) + + setMemoFnMeta(prototype[fnKey], { originalArgsLength: fn.length }) } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0721ca44a1..c008c3a462 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,61 @@ importers: specifier: 5.9.3 version: 5.9.3 + examples/angular/basic-app-table: + dependencies: + '@angular/common': + specifier: ^21.0.6 + version: 21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2) + '@angular/compiler': + specifier: ^21.0.6 + version: 21.0.6 + '@angular/core': + specifier: ^21.0.6 + version: 21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0) + '@angular/forms': + specifier: ^21.0.6 + version: 21.0.6(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(@angular/platform-browser@21.0.6(@angular/animations@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(rxjs@7.8.2) + '@angular/platform-browser': + specifier: ^21.0.6 + version: 21.0.6(@angular/animations@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)) + '@angular/platform-browser-dynamic': + specifier: ^21.0.6 + version: 21.0.6(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/compiler@21.0.6)(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(@angular/platform-browser@21.0.6(@angular/animations@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))) + '@angular/router': + specifier: ^21.0.6 + version: 21.0.6(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(@angular/platform-browser@21.0.6(@angular/animations@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(rxjs@7.8.2) + '@tanstack/angular-table': + specifier: ^9.0.0-alpha.10 + version: link:../../../packages/angular-table + rxjs: + specifier: ~7.8.2 + version: 7.8.2 + zone.js: + specifier: ~0.16.0 + version: 0.16.0 + devDependencies: + '@angular/build': + specifier: ^21.0.4 + version: 21.0.4(kc35yzw5n5t7efydd2g6bmpsfy) + '@angular/cli': + specifier: ^21.0.4 + version: 21.0.4(@types/node@25.0.3)(chokidar@4.0.3) + '@angular/compiler-cli': + specifier: ^21.0.6 + version: 21.0.6(@angular/compiler@21.0.6)(typescript@5.9.3) + '@types/jasmine': + specifier: ~5.1.13 + version: 5.1.13 + jasmine-core: + specifier: ~5.13.0 + version: 5.13.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + examples/angular/column-ordering: dependencies: '@angular/common': @@ -390,6 +445,61 @@ importers: specifier: 5.9.3 version: 5.9.3 + examples/angular/composable-tables: + dependencies: + '@angular/common': + specifier: ^21.0.6 + version: 21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2) + '@angular/compiler': + specifier: ^21.0.6 + version: 21.0.6 + '@angular/core': + specifier: ^21.0.6 + version: 21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0) + '@angular/forms': + specifier: ^21.0.6 + version: 21.0.6(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(@angular/platform-browser@21.0.6(@angular/animations@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(rxjs@7.8.2) + '@angular/platform-browser': + specifier: ^21.0.6 + version: 21.0.6(@angular/animations@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)) + '@angular/platform-browser-dynamic': + specifier: ^21.0.6 + version: 21.0.6(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/compiler@21.0.6)(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(@angular/platform-browser@21.0.6(@angular/animations@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))) + '@angular/router': + specifier: ^21.0.6 + version: 21.0.6(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(@angular/platform-browser@21.0.6(@angular/animations@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(rxjs@7.8.2) + '@tanstack/angular-table': + specifier: ^9.0.0-alpha.10 + version: link:../../../packages/angular-table + rxjs: + specifier: ~7.8.2 + version: 7.8.2 + zone.js: + specifier: ~0.16.0 + version: 0.16.0 + devDependencies: + '@angular/build': + specifier: ^21.0.4 + version: 21.0.4(kc35yzw5n5t7efydd2g6bmpsfy) + '@angular/cli': + specifier: ^21.0.4 + version: 21.0.4(@types/node@25.0.3)(chokidar@4.0.3) + '@angular/compiler-cli': + specifier: ^21.0.6 + version: 21.0.6(@angular/compiler@21.0.6)(typescript@5.9.3) + '@types/jasmine': + specifier: ~5.1.13 + version: 5.1.13 + jasmine-core: + specifier: ~5.13.0 + version: 5.13.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + examples/angular/editable: dependencies: '@angular/animations': @@ -3439,10 +3549,10 @@ importers: specifier: ^2.2.1 version: 2.2.1(@analogjs/vite-plugin-angular@2.2.1(@angular-devkit/build-angular@21.0.4(u5fs7psindzucntcdedavjbwtm))(@angular/build@21.0.4(kc35yzw5n5t7efydd2g6bmpsfy)))(@angular-devkit/architect@0.2100.4(chokidar@4.0.3))(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0(postcss@8.5.6))(less@4.4.2)(lightningcss@1.30.2)(sass@1.93.2)(terser@5.44.0)(tsx@4.19.2)(yaml@2.6.1)) '@angular/core': - specifier: ^21.0.6 + specifier: ^21.0.0 version: 21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0) '@angular/platform-browser': - specifier: ^21.0.6 + specifier: ^21.0.0 version: 21.0.6(@angular/animations@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@21.0.6(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@21.0.6(@angular/compiler@21.0.6)(rxjs@7.8.2)(zone.js@0.16.0)) ng-packagr: specifier: ^21.0.1