From c1bb7e007d1c5aa6e88f9128426711f6a7b8b1eb Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 27 Mar 2026 13:30:22 +0900 Subject: [PATCH 01/11] Add design doc --- docs/data-table-migration-plan.md | 174 ++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/data-table-migration-plan.md diff --git a/docs/data-table-migration-plan.md b/docs/data-table-migration-plan.md new file mode 100644 index 00000000..8c0a33a1 --- /dev/null +++ b/docs/data-table-migration-plan.md @@ -0,0 +1,174 @@ +# DataTable 移植計画 + +`tailor-data-viewer` の DataTable 関連コンポーネント・hooks・型を `@tailor-platform/app-shell` (`packages/core`) に移植する。 + +ソースリポジトリ: https://github.com/tailor-sandbox/tailor-data-viewer + +## スコープ + +### 移植対象 + +`packages/data-viewer/src/component/` 配下のすべてのコンポーネント、hooks、型定義。 + +### スコープ外 + +- `generator/metadata-generator.ts` — Tailor Platform SDK 依存のためapp-shellには含めない。別パッケージとして維持し、`TableMetadata` 等の型インターフェースのみ app-shell 側に定義する。 +- `styles/theme.css` — app-shell 既存のテーマ(CSS変数 + Tailwind)を使用するため不要。 + +## ファイルマッピング + +| ソース (tailor-data-viewer) | 移植先 (packages/core/src/) | 備考 | +|---|---|---| +| `component/types.ts` | `components/data-table/types.ts` | generator への import を除去し、`TableMetadata` 等の型を自己完結で定義 | +| `component/lib/utils.ts` | — | app-shell 既存の `@/lib/utils` の `cn()` を使用 | +| `component/table.tsx` | — | app-shell 既存の `Table` コンポーネントを使用 | +| `component/collection/use-collection.ts` | `hooks/use-collection-variables.ts` | DataTable 以外でも単体利用可能 | +| `component/collection/use-collection.test.ts` | `hooks/use-collection-variables.test.ts` | | +| `component/collection/use-collection.typetest.ts` | `hooks/use-collection-variables.typetest.ts` | | +| `component/collection/collection-provider.tsx` | `hooks/collection-variables-provider.tsx` | hook と同居 | +| `component/data-table/data-table.tsx` | `components/data-table/data-table.tsx` | 内部の `Table.*` 参照を app-shell の Table に差し替え | +| `component/data-table/data-table-context.tsx` | `components/data-table/data-table-context.tsx` | | +| `component/data-table/use-data-table.ts` | `components/data-table/use-data-table.ts` | | +| `component/data-table/pagination.tsx` | `components/data-table/pagination.tsx` | | +| `component/data-table/column-selector.tsx` | `components/data-table/column-selector.tsx` | | +| `component/data-table/csv-button.tsx` | `components/data-table/csv-button.tsx` | | +| `component/data-table/search-filter-form.tsx` | `components/data-table/search-filter-form.tsx` | | +| `component/data-table/i18n.ts` | `components/data-table/i18n.ts` | | +| `component/field-helpers.ts` | `components/data-table/field-helpers.ts` | | +| `component/field-helpers.test.ts` | `components/data-table/field-helpers.test.ts` | | +| `component/table.test.tsx` | `components/data-table/data-table.test.tsx` | | +| `component/data-table/column-selector.test.tsx` | `components/data-table/column-selector.test.tsx` | | +| `component/data-table/csv-button.test.tsx` | `components/data-table/csv-button.test.tsx` | | +| `component/index.ts` | `components/data-table/index.ts` | public exports のみ | + +## 最終的なディレクトリ構成 + +``` +packages/core/src/ + hooks/ + use-collection-variables.ts # useCollectionVariables hook(単体利用可能) + use-collection-variables.test.ts # useCollectionVariables テスト + use-collection-variables.typetest.ts # 型テスト + collection-variables-provider.tsx # CollectionVariablesProvider + useCollectionVariablesContext + components/ + data-table/ + index.ts # public exports + types.ts # 型定義(TableMetadata 含む) + data-table.tsx # DataTable compound component + data-table.test.tsx # DataTable テスト + data-table-context.tsx # DataTableContext + useDataTableContext + use-data-table.ts # useDataTable hook + pagination.tsx # Pagination component + column-selector.tsx # ColumnSelector component + column-selector.test.tsx # ColumnSelector テスト + csv-button.tsx # CsvButton component + csv-button.test.tsx # CsvButton テスト + search-filter-form.tsx # SearchFilterForm component + field-helpers.ts # createColumnHelper, inferColumns + field-helpers.test.ts # field-helpers テスト + i18n.ts # DataTable ロケールラベル(defineI18nLabels 使用) +``` + +## 作業ステップ + +### Phase 1: 型定義の移植と整理 + +1. `types.ts` を移植する + - `generator/metadata-generator.ts` からの import を除去 + - `FieldType`, `FieldMetadata`, `TableMetadata`, `TableMetadataMap` の型定義を `types.ts` 内に移動 + - metadata 依存型(`BuildQueryVariables`, `TableMetadataFilter`, `MetadataFilter`, `TableFieldName`, `TableOrderableFieldName` 等)もすべて `types.ts` 内で自己完結させる + - ランタイム定数 (`OPERATORS_BY_FILTER_TYPE`, `DEFAULT_OPERATOR_LABELS`, `fieldTypeToSortConfig`, `fieldTypeToFilterConfig`) もそのまま移植 + +### Phase 2: hooks の移植 + +2. `useCollectionVariables` hook を移植する + - `@/lib/utils` の `cn` を使用するよう import を変更 + - `../generator/metadata-generator` への参照を `./types` に変更 +3. `CollectionVariablesProvider` を移植する +4. `useDataTable` hook を移植する +5. `DataTableContext` を移植する + +### Phase 3: コンポーネントの移植 + +6. `DataTable` compound component を移植する + - 内部の `Table.*` 参照を app-shell 既存の `Table` コンポーネントにマッピング: + - `Table.Headers` → `Table.Header` + - `Table.HeaderRow` → `Table.Row` + - `Table.HeaderCell` → `Table.Head` + - `Table.Body`, `Table.Row`, `Table.Cell` → そのまま(名前一致) + - `Table.Root` は app-shell 版を使用(`tableLayout` prop は不要、削除) +7. `Pagination` を移植する +8. `ColumnSelector` を移植する +9. `CsvButton` を移植する +10. `SearchFilterForm` を移植する +11. `i18n.ts` を移植する +12. `field-helpers.ts` (`createColumnHelper`, `inferColumns`) を移植する + +### Phase 4: スタイリングの app-shell 規約への適合 + +13. 全 Tailwind クラスに `astw:` プレフィックスを付与する + - 例: `"w-full text-sm"` → `"astw:w-full astw:text-sm"` + - `cn()` の引数内の全クラスが対象 +14. 全コンポーネントのルート要素に `data-slot` 属性を追加する + - 例: `data-slot="data-table"`, `data-slot="pagination"`, `data-slot="column-selector"` 等 +15. 全サブコンポーネントに `displayName` を設定する + - 例: `DataTableRoot.displayName = "DataTable.Root"` + +### Phase 5: public API のエクスポート + +16. `components/data-table/index.ts` を作成する—以下をエクスポート: + - `DataTable` (compound namespace) + - `useDataTable`, `useDataTableContext` + - `useCollectionVariables`, `CollectionVariablesProvider`, `useCollectionVariablesContext` + - `createColumnHelper` + - `Pagination`, `ColumnSelector`, `CsvButton`, `SearchFilterForm` + - 必要最小限の型のみ(`Column`, `ColumnOptions`, `SortConfig`, `FilterConfig`, `CollectionResult`, `NodeType`, `UseCollectionReturn`, `UseDataTableReturn` 等) +17. `packages/core/src/index.ts` に `data-table/index.ts` からの re-export を追加する + +### Phase 6: テスト + +18. 既存テストを移植・修正する + - import パスの変更 + - `astw:` プレフィックス付きクラスに合わせた snapshot 更新 + - app-shell の vitest 設定に合わせた調整 +19. 全テストが pass することを確認する + +### Phase 7: 品質チェック + +20. `pnpm type-check` — 型エラーがないことを確認 +21. `pnpm lint` — lint エラーがないことを確認 +22. `pnpm test` — 全テスト pass を確認 +23. `pnpm fmt` — フォーマット統一 + +## 設計判断 + +| 論点 | 決定 | 理由 | +|---|---|---| +| **`generator/` の扱い** | `@tailor-platform/app-shell-sdk-plugins` として別パッケージ化 | Tailor SDK への依存があり、app-shell のスコープ外。新パッケージとしてモノレポに追加 | +| **metadata 型の扱い** | `types.ts` にインターフェースとして定義 | generator が同じ型に準拠する形にし、import の循環を避ける | +| **i18n の統合** | app-shell の `defineI18nLabels` に統合 | `useT()` で `AppShellConfig.locale` から自動解決。`locale` prop は削除。AppShell 外での standalone 利用は不要 | +| **既存 Table との関係** | DataTable 内部で app-shell の `Table` を使用 | 重複を避け、テーマ整合性を保つ | +| **ディレクトリ構造** | `components/data-table/` ディレクトリ (Pattern C) | ファイル数が多く、フラット配置は不適切 | +| **`useCollectionVariables` の配置** | `hooks/use-collection-variables.ts` に分離 | DataTable 以外の UI パターン(kanban 等)でも単体利用を想定 | + +## Table コンポーネントのサブコンポーネント名マッピング + +data-viewer の `DataTable` 内部で使用されている `Table.*` と app-shell 既存 `Table.*` の対応: + +| data-viewer (`Table.*`) | app-shell (`Table.*`) | +|---|---| +| `Table.Root` | `Table.Root` | +| `Table.Headers` | `Table.Header` | +| `Table.HeaderRow` | `Table.Row` | +| `Table.HeaderCell` | `Table.Head` | +| `Table.Body` | `Table.Body` | +| `Table.Row` | `Table.Row` | +| `Table.Cell` | `Table.Cell` | + +## 依存関係 + +追加パッケージ: なし(`cn`, `clsx`, `tailwind-merge` は既存) + +## 補足: generator パッケージとの関係 + +移植後、`@tailor-platform/app-shell` は `TableMetadata` インターフェースを export する。`@tailor-platform/app-shell-sdk-plugins` パッケージ(モノレポ内 `packages/sdk-plugins`)が generator を含み、この型に準拠するメタデータを生成する。 From 55c5555ba9d6867bd61f9a1105d253c191fa14d2 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 27 Mar 2026 15:37:14 +0900 Subject: [PATCH 02/11] Add MIGRATION.md --- docs/data-table-migration-plan.md | 174 ----------------- .../src/components/data-table/MIGRATION.md | 179 ++++++++++++++++++ 2 files changed, 179 insertions(+), 174 deletions(-) delete mode 100644 docs/data-table-migration-plan.md create mode 100644 packages/core/src/components/data-table/MIGRATION.md diff --git a/docs/data-table-migration-plan.md b/docs/data-table-migration-plan.md deleted file mode 100644 index 8c0a33a1..00000000 --- a/docs/data-table-migration-plan.md +++ /dev/null @@ -1,174 +0,0 @@ -# DataTable 移植計画 - -`tailor-data-viewer` の DataTable 関連コンポーネント・hooks・型を `@tailor-platform/app-shell` (`packages/core`) に移植する。 - -ソースリポジトリ: https://github.com/tailor-sandbox/tailor-data-viewer - -## スコープ - -### 移植対象 - -`packages/data-viewer/src/component/` 配下のすべてのコンポーネント、hooks、型定義。 - -### スコープ外 - -- `generator/metadata-generator.ts` — Tailor Platform SDK 依存のためapp-shellには含めない。別パッケージとして維持し、`TableMetadata` 等の型インターフェースのみ app-shell 側に定義する。 -- `styles/theme.css` — app-shell 既存のテーマ(CSS変数 + Tailwind)を使用するため不要。 - -## ファイルマッピング - -| ソース (tailor-data-viewer) | 移植先 (packages/core/src/) | 備考 | -|---|---|---| -| `component/types.ts` | `components/data-table/types.ts` | generator への import を除去し、`TableMetadata` 等の型を自己完結で定義 | -| `component/lib/utils.ts` | — | app-shell 既存の `@/lib/utils` の `cn()` を使用 | -| `component/table.tsx` | — | app-shell 既存の `Table` コンポーネントを使用 | -| `component/collection/use-collection.ts` | `hooks/use-collection-variables.ts` | DataTable 以外でも単体利用可能 | -| `component/collection/use-collection.test.ts` | `hooks/use-collection-variables.test.ts` | | -| `component/collection/use-collection.typetest.ts` | `hooks/use-collection-variables.typetest.ts` | | -| `component/collection/collection-provider.tsx` | `hooks/collection-variables-provider.tsx` | hook と同居 | -| `component/data-table/data-table.tsx` | `components/data-table/data-table.tsx` | 内部の `Table.*` 参照を app-shell の Table に差し替え | -| `component/data-table/data-table-context.tsx` | `components/data-table/data-table-context.tsx` | | -| `component/data-table/use-data-table.ts` | `components/data-table/use-data-table.ts` | | -| `component/data-table/pagination.tsx` | `components/data-table/pagination.tsx` | | -| `component/data-table/column-selector.tsx` | `components/data-table/column-selector.tsx` | | -| `component/data-table/csv-button.tsx` | `components/data-table/csv-button.tsx` | | -| `component/data-table/search-filter-form.tsx` | `components/data-table/search-filter-form.tsx` | | -| `component/data-table/i18n.ts` | `components/data-table/i18n.ts` | | -| `component/field-helpers.ts` | `components/data-table/field-helpers.ts` | | -| `component/field-helpers.test.ts` | `components/data-table/field-helpers.test.ts` | | -| `component/table.test.tsx` | `components/data-table/data-table.test.tsx` | | -| `component/data-table/column-selector.test.tsx` | `components/data-table/column-selector.test.tsx` | | -| `component/data-table/csv-button.test.tsx` | `components/data-table/csv-button.test.tsx` | | -| `component/index.ts` | `components/data-table/index.ts` | public exports のみ | - -## 最終的なディレクトリ構成 - -``` -packages/core/src/ - hooks/ - use-collection-variables.ts # useCollectionVariables hook(単体利用可能) - use-collection-variables.test.ts # useCollectionVariables テスト - use-collection-variables.typetest.ts # 型テスト - collection-variables-provider.tsx # CollectionVariablesProvider + useCollectionVariablesContext - components/ - data-table/ - index.ts # public exports - types.ts # 型定義(TableMetadata 含む) - data-table.tsx # DataTable compound component - data-table.test.tsx # DataTable テスト - data-table-context.tsx # DataTableContext + useDataTableContext - use-data-table.ts # useDataTable hook - pagination.tsx # Pagination component - column-selector.tsx # ColumnSelector component - column-selector.test.tsx # ColumnSelector テスト - csv-button.tsx # CsvButton component - csv-button.test.tsx # CsvButton テスト - search-filter-form.tsx # SearchFilterForm component - field-helpers.ts # createColumnHelper, inferColumns - field-helpers.test.ts # field-helpers テスト - i18n.ts # DataTable ロケールラベル(defineI18nLabels 使用) -``` - -## 作業ステップ - -### Phase 1: 型定義の移植と整理 - -1. `types.ts` を移植する - - `generator/metadata-generator.ts` からの import を除去 - - `FieldType`, `FieldMetadata`, `TableMetadata`, `TableMetadataMap` の型定義を `types.ts` 内に移動 - - metadata 依存型(`BuildQueryVariables`, `TableMetadataFilter`, `MetadataFilter`, `TableFieldName`, `TableOrderableFieldName` 等)もすべて `types.ts` 内で自己完結させる - - ランタイム定数 (`OPERATORS_BY_FILTER_TYPE`, `DEFAULT_OPERATOR_LABELS`, `fieldTypeToSortConfig`, `fieldTypeToFilterConfig`) もそのまま移植 - -### Phase 2: hooks の移植 - -2. `useCollectionVariables` hook を移植する - - `@/lib/utils` の `cn` を使用するよう import を変更 - - `../generator/metadata-generator` への参照を `./types` に変更 -3. `CollectionVariablesProvider` を移植する -4. `useDataTable` hook を移植する -5. `DataTableContext` を移植する - -### Phase 3: コンポーネントの移植 - -6. `DataTable` compound component を移植する - - 内部の `Table.*` 参照を app-shell 既存の `Table` コンポーネントにマッピング: - - `Table.Headers` → `Table.Header` - - `Table.HeaderRow` → `Table.Row` - - `Table.HeaderCell` → `Table.Head` - - `Table.Body`, `Table.Row`, `Table.Cell` → そのまま(名前一致) - - `Table.Root` は app-shell 版を使用(`tableLayout` prop は不要、削除) -7. `Pagination` を移植する -8. `ColumnSelector` を移植する -9. `CsvButton` を移植する -10. `SearchFilterForm` を移植する -11. `i18n.ts` を移植する -12. `field-helpers.ts` (`createColumnHelper`, `inferColumns`) を移植する - -### Phase 4: スタイリングの app-shell 規約への適合 - -13. 全 Tailwind クラスに `astw:` プレフィックスを付与する - - 例: `"w-full text-sm"` → `"astw:w-full astw:text-sm"` - - `cn()` の引数内の全クラスが対象 -14. 全コンポーネントのルート要素に `data-slot` 属性を追加する - - 例: `data-slot="data-table"`, `data-slot="pagination"`, `data-slot="column-selector"` 等 -15. 全サブコンポーネントに `displayName` を設定する - - 例: `DataTableRoot.displayName = "DataTable.Root"` - -### Phase 5: public API のエクスポート - -16. `components/data-table/index.ts` を作成する—以下をエクスポート: - - `DataTable` (compound namespace) - - `useDataTable`, `useDataTableContext` - - `useCollectionVariables`, `CollectionVariablesProvider`, `useCollectionVariablesContext` - - `createColumnHelper` - - `Pagination`, `ColumnSelector`, `CsvButton`, `SearchFilterForm` - - 必要最小限の型のみ(`Column`, `ColumnOptions`, `SortConfig`, `FilterConfig`, `CollectionResult`, `NodeType`, `UseCollectionReturn`, `UseDataTableReturn` 等) -17. `packages/core/src/index.ts` に `data-table/index.ts` からの re-export を追加する - -### Phase 6: テスト - -18. 既存テストを移植・修正する - - import パスの変更 - - `astw:` プレフィックス付きクラスに合わせた snapshot 更新 - - app-shell の vitest 設定に合わせた調整 -19. 全テストが pass することを確認する - -### Phase 7: 品質チェック - -20. `pnpm type-check` — 型エラーがないことを確認 -21. `pnpm lint` — lint エラーがないことを確認 -22. `pnpm test` — 全テスト pass を確認 -23. `pnpm fmt` — フォーマット統一 - -## 設計判断 - -| 論点 | 決定 | 理由 | -|---|---|---| -| **`generator/` の扱い** | `@tailor-platform/app-shell-sdk-plugins` として別パッケージ化 | Tailor SDK への依存があり、app-shell のスコープ外。新パッケージとしてモノレポに追加 | -| **metadata 型の扱い** | `types.ts` にインターフェースとして定義 | generator が同じ型に準拠する形にし、import の循環を避ける | -| **i18n の統合** | app-shell の `defineI18nLabels` に統合 | `useT()` で `AppShellConfig.locale` から自動解決。`locale` prop は削除。AppShell 外での standalone 利用は不要 | -| **既存 Table との関係** | DataTable 内部で app-shell の `Table` を使用 | 重複を避け、テーマ整合性を保つ | -| **ディレクトリ構造** | `components/data-table/` ディレクトリ (Pattern C) | ファイル数が多く、フラット配置は不適切 | -| **`useCollectionVariables` の配置** | `hooks/use-collection-variables.ts` に分離 | DataTable 以外の UI パターン(kanban 等)でも単体利用を想定 | - -## Table コンポーネントのサブコンポーネント名マッピング - -data-viewer の `DataTable` 内部で使用されている `Table.*` と app-shell 既存 `Table.*` の対応: - -| data-viewer (`Table.*`) | app-shell (`Table.*`) | -|---|---| -| `Table.Root` | `Table.Root` | -| `Table.Headers` | `Table.Header` | -| `Table.HeaderRow` | `Table.Row` | -| `Table.HeaderCell` | `Table.Head` | -| `Table.Body` | `Table.Body` | -| `Table.Row` | `Table.Row` | -| `Table.Cell` | `Table.Cell` | - -## 依存関係 - -追加パッケージ: なし(`cn`, `clsx`, `tailwind-merge` は既存) - -## 補足: generator パッケージとの関係 - -移植後、`@tailor-platform/app-shell` は `TableMetadata` インターフェースを export する。`@tailor-platform/app-shell-sdk-plugins` パッケージ(モノレポ内 `packages/sdk-plugins`)が generator を含み、この型に準拠するメタデータを生成する。 diff --git a/packages/core/src/components/data-table/MIGRATION.md b/packages/core/src/components/data-table/MIGRATION.md new file mode 100644 index 00000000..07060745 --- /dev/null +++ b/packages/core/src/components/data-table/MIGRATION.md @@ -0,0 +1,179 @@ +# DataTable Migration Design + +Migrate DataTable components, hooks, and types from `tailor-data-viewer` into `@tailor-platform/app-shell` (`packages/core`). + +Source repository: https://github.com/tailor-sandbox/tailor-data-viewer + +## Scope + +### In Scope + +All components, hooks, and type definitions under `packages/data-viewer/src/component/`. + +### Out of Scope + +- `generator/metadata-generator.ts` — Depends on Tailor Platform SDK; kept as a separate package. Only the type interfaces (`TableMetadata`, etc.) are defined in app-shell. +- `styles/theme.css` — Not needed; app-shell's existing theme (CSS variables + Tailwind) is used instead. + +## File Mapping + +| Source (tailor-data-viewer) | Destination (packages/core/src/) | Notes | +|---|---|---| +| `component/types.ts` | `components/data-table/types.ts` | Remove imports from `generator/`; define `TableMetadata` etc. self-contained | +| `component/lib/utils.ts` | — | Use existing `cn()` from `@/lib/utils` | +| `component/table.tsx` | — | Use existing `Table` component | +| `component/collection/use-collection.ts` | `hooks/use-collection-variables.ts` | Usable standalone outside DataTable | +| `component/collection/use-collection.test.ts` | `hooks/use-collection-variables.test.ts` | | +| `component/collection/use-collection.typetest.ts` | `hooks/use-collection-variables.typetest.ts` | | +| `component/collection/collection-provider.tsx` | `hooks/collection-variables-provider.tsx` | Co-located with the hook | +| `component/data-table/data-table.tsx` | `components/data-table/data-table.tsx` | Replace internal `Table.*` refs with app-shell Table | +| `component/data-table/data-table-context.tsx` | `components/data-table/data-table-context.tsx` | | +| `component/data-table/use-data-table.ts` | `components/data-table/use-data-table.ts` | | +| `component/data-table/pagination.tsx` | `components/data-table/pagination.tsx` | | +| `component/data-table/column-selector.tsx` | `components/data-table/column-selector.tsx` | | +| `component/data-table/csv-button.tsx` | `components/data-table/csv-button.tsx` | | +| `component/data-table/search-filter-form.tsx` | `components/data-table/search-filter-form.tsx` | | +| `component/data-table/i18n.ts` | `components/data-table/i18n.ts` | | +| `component/field-helpers.ts` | `components/data-table/field-helpers.ts` | | +| `component/field-helpers.test.ts` | `components/data-table/field-helpers.test.ts` | | +| `component/table.test.tsx` | `components/data-table/data-table.test.tsx` | | +| `component/data-table/column-selector.test.tsx` | `components/data-table/column-selector.test.tsx` | | +| `component/data-table/csv-button.test.tsx` | `components/data-table/csv-button.test.tsx` | | +| `component/index.ts` | `components/data-table/index.ts` | Public exports only | + +## Target Directory Structure + +``` +packages/core/src/ + hooks/ + use-collection-variables.ts # useCollectionVariables hook (standalone) + use-collection-variables.test.ts + use-collection-variables.typetest.ts + collection-variables-provider.tsx # CollectionVariablesProvider + useCollectionVariablesContext + components/ + data-table/ + DESIGN.md # This file + index.ts # Public exports + types.ts # Type definitions (incl. TableMetadata) + data-table.tsx # DataTable compound component + data-table.test.tsx # DataTable tests + data-table-context.tsx # DataTableContext + useDataTableContext + use-data-table.ts # useDataTable hook + pagination.tsx # Pagination component + column-selector.tsx # ColumnSelector component + column-selector.test.tsx + csv-button.tsx # CsvButton component + csv-button.test.tsx + search-filter-form.tsx # SearchFilterForm component + field-helpers.ts # createColumnHelper, inferColumns + field-helpers.test.ts + i18n.ts # DataTable locale labels (via defineI18nLabels) +``` + +## Implementation Steps + +### Phase 1: Types + +1. Migrate `types.ts` + - Remove imports from `generator/metadata-generator.ts` + - Move `FieldType`, `FieldMetadata`, `TableMetadata`, `TableMetadataMap` into `types.ts` + - Move metadata-dependent types (`BuildQueryVariables`, `TableMetadataFilter`, `MetadataFilter`, `TableFieldName`, `TableOrderableFieldName`, etc.) into `types.ts` + - Keep runtime constants (`OPERATORS_BY_FILTER_TYPE`, `DEFAULT_OPERATOR_LABELS`, `fieldTypeToSortConfig`, `fieldTypeToFilterConfig`) as-is + +### Phase 2: Hooks + +2. Migrate `useCollectionVariables` hook + - Update imports to use `@/lib/utils` for `cn` + - Replace `../generator/metadata-generator` references with `./types` +3. Migrate `CollectionVariablesProvider` +4. Migrate `useDataTable` hook +5. Migrate `DataTableContext` + +### Phase 3: Components + +Migrate each component **with app-shell conventions applied inline** (astw: prefix, data-slot, displayName — do not defer to a separate phase): + +6. `DataTable` compound component + - Map internal `Table.*` references to app-shell's Table: + - `Table.Headers` → `Table.Header` + - `Table.HeaderRow` → `Table.Row` + - `Table.HeaderCell` → `Table.Head` + - `Table.Body`, `Table.Row`, `Table.Cell` → unchanged (names match) + - Use app-shell's `Table.Root` (drop `tableLayout` prop) + - Replace raw ` }), +]; +``` + +## Key Types + +```ts +type SortConfig = + | { field: string; type: "string" } + | { field: string; type: "number" } + | { field: string; type: "date" } + | { field: string; type: "boolean" }; + +type FilterConfig = + | { field: string; type: "string" } + | { field: string; type: "number" } + | { field: string; type: "date" } + | { field: string; type: "enum"; options: SelectOption[] } + | { field: string; type: "boolean" } + | { field: string; type: "uuid" }; + +interface Column { + label?: string; + render: (row: TRow) => ReactNode; + id?: string; + width?: number; + accessor?: (row: TRow) => unknown; + sort?: SortConfig; + filter?: FilterConfig; +} + +interface RowAction { + id: string; + label: string; + icon?: ReactNode; + variant?: "default" | "destructive"; + isDisabled?: (row: TRow) => boolean; + onClick: (row: TRow) => void; +} + +interface CollectionResult { + edges: { node: T }[]; + pageInfo: PageInfo; + total?: number | null; +} + +interface PageInfo { + hasNextPage: boolean; + endCursor: string | null; + hasPreviousPage: boolean; + startCursor: string | null; +} + +interface SortState { + field: string; + direction: "Asc" | "Desc"; +} + +interface Filter { + field: TFieldName; + operator: FilterOperator; + value: unknown; +} +``` + +## i18n (Source → Migration) + +Source uses `getLabels(locale: "en" | "ja")` returning a `DataTableLabels` object. Migration replaces this with `defineI18nLabels` integrated into app-shell's i18n system. The `locale` prop is removed from `UseDataTableOptions`; locale is resolved from `AppShellConfig.locale` via `useT()`. diff --git a/packages/core/src/components/data-table/MIGRATION.md b/packages/core/src/components/data-table/MIGRATION.md index 669b9276..2cf0e919 100644 --- a/packages/core/src/components/data-table/MIGRATION.md +++ b/packages/core/src/components/data-table/MIGRATION.md @@ -14,6 +14,9 @@ All components, hooks, and type definitions under `packages/data-viewer/src/comp - `generator/metadata-generator.ts` — Depends on Tailor Platform SDK; kept as a separate package. Only the type interfaces (`TableMetadata`, etc.) are defined in app-shell. - `styles/theme.css` — Not needed; app-shell's existing theme (CSS variables + Tailwind) is used instead. +- `component/data-table/column-selector.tsx` — Will be rebuilt separately. Migration path for existing users is intentionally deferred; not planned in this document. +- `component/data-table/csv-button.tsx` — Will be rebuilt separately. Migration path for existing users is intentionally deferred; not planned in this document. +- `component/data-table/search-filter-form.tsx` — Will be rebuilt separately. Migration path for existing users is intentionally deferred; not planned in this document. ## File Mapping @@ -25,20 +28,20 @@ All components, hooks, and type definitions under `packages/data-viewer/src/comp | `component/collection/use-collection.ts` | `hooks/use-collection-variables.ts` | Usable standalone outside DataTable | | `component/collection/use-collection.test.ts` | `hooks/use-collection-variables.test.ts` | | | `component/collection/use-collection.typetest.ts` | `hooks/use-collection-variables.typetest.ts` | | -| `component/collection/collection-provider.tsx` | `hooks/collection-variables-provider.tsx` | Co-located with the hook | +| `component/collection/collection-provider.tsx` | `contexts/collection-variables-context.tsx` | Follow app-shell convention for Context Providers | | `component/data-table/data-table.tsx` | `components/data-table/data-table.tsx` | Replace internal `Table.*` refs with app-shell Table | | `component/data-table/data-table-context.tsx` | `components/data-table/data-table-context.tsx` | | | `component/data-table/use-data-table.ts` | `components/data-table/use-data-table.ts` | | | `component/data-table/pagination.tsx` | `components/data-table/pagination.tsx` | | -| `component/data-table/column-selector.tsx` | `components/data-table/column-selector.tsx` | | -| `component/data-table/csv-button.tsx` | `components/data-table/csv-button.tsx` | | -| `component/data-table/search-filter-form.tsx` | `components/data-table/search-filter-form.tsx` | | +| `component/data-table/column-selector.tsx` | — | Out of scope; will be rebuilt separately | +| `component/data-table/csv-button.tsx` | — | Out of scope; will be rebuilt separately | +| `component/data-table/search-filter-form.tsx` | — | Out of scope; will be rebuilt separately | | `component/data-table/i18n.ts` | `components/data-table/i18n.ts` | | | `component/field-helpers.ts` | `components/data-table/field-helpers.ts` | | | `component/field-helpers.test.ts` | `components/data-table/field-helpers.test.ts` | | | `component/table.test.tsx` | `components/data-table/data-table.test.tsx` | | -| `component/data-table/column-selector.test.tsx` | `components/data-table/column-selector.test.tsx` | | -| `component/data-table/csv-button.test.tsx` | `components/data-table/csv-button.test.tsx` | | +| `component/data-table/column-selector.test.tsx` | — | Out of scope; will be rebuilt separately | +| `component/data-table/csv-button.test.tsx` | — | Out of scope; will be rebuilt separately | | `component/index.ts` | `components/data-table/index.ts` | Public exports only | ## Target Directory Structure @@ -49,7 +52,8 @@ packages/core/src/ use-collection-variables.ts # useCollectionVariables hook (standalone) use-collection-variables.test.ts use-collection-variables.typetest.ts - collection-variables-provider.tsx # CollectionVariablesProvider + useCollectionVariablesContext + contexts/ + collection-variables-context.tsx # CollectionVariablesProvider + useCollectionVariablesContext components/ data-table/ DESIGN.md # This file @@ -60,16 +64,15 @@ packages/core/src/ data-table-context.tsx # DataTableContext + useDataTableContext use-data-table.ts # useDataTable hook pagination.tsx # Pagination component - column-selector.tsx # ColumnSelector component - column-selector.test.tsx - csv-button.tsx # CsvButton component - csv-button.test.tsx - search-filter-form.tsx # SearchFilterForm component field-helpers.ts # createColumnHelper, inferColumns field-helpers.test.ts i18n.ts # DataTable locale labels (via defineI18nLabels) ``` +## API Reference + +Source DataTable の完全な API 定義(型・コンポーネント・フック・ヘルパー)と移行時の変更点は **[API.md](./API.md)** を参照。 + ## Implementation Steps ### Phase 1: Types @@ -85,7 +88,7 @@ packages/core/src/ 2. Migrate `useCollectionVariables` hook - Update imports to use `@/lib/utils` for `cn` - Replace `../generator/metadata-generator` references with `./types` -3. Migrate `CollectionVariablesProvider` +3. Migrate `CollectionVariablesProvider` → `contexts/collection-variables-context.tsx` 4. Migrate `useDataTable` hook 5. Migrate `DataTableContext` @@ -105,38 +108,57 @@ Migrate each component **with app-shell conventions applied inline** (astw: pref - Replace `
/` dropdowns with app-shell `Menu` - Replace raw `` with app-shell `Input` - Replace raw `` with app-shell `Button` / `Select`. Labels will move to `defineI18nLabels`. diff --git a/packages/core/src/components/data-table/MIGRATION.md b/packages/core/src/components/data-table/MIGRATION.md index e842b0fe..38607399 100644 --- a/packages/core/src/components/data-table/MIGRATION.md +++ b/packages/core/src/components/data-table/MIGRATION.md @@ -28,7 +28,7 @@ All components, hooks, and type definitions under `packages/data-viewer/src/comp | `component/collection/use-collection.ts` | `hooks/use-collection-variables.ts` | Usable standalone outside DataTable | | `component/collection/use-collection.test.ts` | `hooks/use-collection-variables.test.ts` | | | `component/collection/use-collection.typetest.ts` | `hooks/use-collection-variables.typetest.ts` | | -| `component/collection/collection-provider.tsx` | `contexts/collection-variables-context.tsx` | Follow app-shell convention for Context Providers | +| `component/collection/collection-provider.tsx` | `contexts/collection-control-context.tsx` | Follow app-shell convention for Context Providers | | `component/data-table/data-table.tsx` | `components/data-table/data-table.tsx` | Replace internal `Table.*` refs with app-shell Table | | `component/data-table/data-table-context.tsx` | `components/data-table/data-table-context.tsx` | | | `component/data-table/use-data-table.ts` | `components/data-table/use-data-table.ts` | | @@ -53,7 +53,7 @@ packages/core/src/ use-collection-variables.test.ts use-collection-variables.typetest.ts contexts/ - collection-variables-context.tsx # CollectionVariablesProvider + useCollectionVariablesContext + collection-control-context.tsx # CollectionControlProvider + useCollectionControl components/ data-table/ DESIGN.md # This file @@ -88,7 +88,7 @@ Source DataTable の完全な API 定義(型・コンポーネント・フッ 2. Migrate `useCollectionVariables` hook - Update imports to use `@/lib/utils` for `cn` - Replace `../generator/metadata-generator` references with `./types` -3. Migrate `CollectionVariablesProvider` → `contexts/collection-variables-context.tsx` +3. Migrate `CollectionControlProvider` → `contexts/collection-control-context.tsx` 4. Migrate `useDataTable` hook 5. Migrate `DataTableContext` @@ -119,13 +119,13 @@ Migrate each component **with app-shell conventions applied inline** (astw: pref 10. Create `components/data-table/index.ts` exporting: - `DataTable` (compound namespace) - `useDataTable`, `useDataTableContext` - - `useCollectionVariables`, `CollectionVariablesProvider`, `useCollectionVariablesContext` + - `useCollectionVariables`, `CollectionControlProvider`, `useCollectionControl` - `createColumnHelper` - `Pagination` - Runtime constants: `OPERATORS_BY_FILTER_TYPE`, `DEFAULT_OPERATOR_LABELS`, `fieldTypeToSortConfig`, `fieldTypeToFilterConfig` - Exported types (public API): - Core: `Column`, `ColumnOptions`, `ColumnDefinition`, `SortConfig`, `FilterConfig`, `SortState`, `Filter`, `FilterOperator`, `SelectOption`, `PageInfo`, `RowAction`, `RowOperations` - - Collection: `CollectionVariables`, `CollectionResult`, `NodeType`, `QueryVariables`, `PaginationVariables`, `UseCollectionOptions`, `UseCollectionReturn` + - Collection: `CollectionVariables`, `CollectionControl`, `CollectionResult`, `NodeType`, `QueryVariables`, `PaginationVariables`, `UseCollectionOptions`, `UseCollectionReturn` - DataTable: `UseDataTableOptions`, `UseDataTableReturn` - Metadata (consumed by `@tailor-platform/app-shell-sdk-plugins`): `FieldType`, `FieldMetadata`, `TableMetadata`, `TableMetadataMap`, `BuildQueryVariables`, `TableMetadataFilter`, `MetadataFilter`, `TableFieldName`, `TableOrderableFieldName`, `OrderableFieldName`, `FieldName`, `MatchingTableName`, `MetadataFieldOptions`, `MetadataFieldsOptions` - i18n: `DataTableLocale` @@ -173,7 +173,7 @@ Migrate each component **with app-shell conventions applied inline** (astw: pref | **Internal UI elements** | Replace raw HTML with app-shell components (`Button`, `Input`, `Select`, `Menu`, `lucide-react` icons) | Maintains theme, accessibility, and style consistency | | **`@tanstack/react-table`** | Remove from `package.json` | The source does not use it. No code in `packages/core/src/` imports it. Removing it in Phase 6 reduces bundle size and avoids confusion | | **Styling adaptation timing** | Applied inline during component migration (Phase 3), not as a separate phase | Avoids touching each file twice | -| **`CollectionVariablesProvider` placement** | `contexts/collection-variables-context.tsx` | Follows the existing app-shell convention where all Context Providers live in `contexts/` (e.g., `auth-context.tsx`, `theme-context.tsx`). Discoverability > co-location for providers | +| **`CollectionControlProvider` placement** | `contexts/collection-control-context.tsx` | Follows the existing app-shell convention where all Context Providers live in `contexts/` (e.g., `auth-context.tsx`, `theme-context.tsx`). Discoverability > co-location for providers | | **Component pattern** | Pattern C (Multi-File Directory) — evaluate Pattern D after migration | Source uses `DataTable.Provider` + compound sub-components; there is no dominant "simple props" usage pattern today. Pattern D (Standalone + Parts) may be introduced later if a pre-assembled API is warranted | | **`data-slot` naming** | Prefix all slots with `data-table-` (e.g., `data-table-header`, `data-table-body`, `data-table-row`) | Avoids collision with existing `Table` slots (`table-header`, `table-body`, etc.) and provides a consistent CSS targeting surface | | **`DataTable.Root` wrapper styles** | Hardcoded inside `DataTable.Root`: `astw:border astw:rounded-md astw:bg-card` | Source includes these styles on the root; app-shell's `Table.Root` does not. Hardcoding keeps the DataTable visually consistent without requiring consumers to add wrapper styles | From d8105829bd08e00c83ef34cb9bc2b6ae687a6d60 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 30 Mar 2026 14:56:15 +0900 Subject: [PATCH 07/11] Format files --- .../src/components/data-table/MIGRATION.md | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/core/src/components/data-table/MIGRATION.md b/packages/core/src/components/data-table/MIGRATION.md index 38607399..fed6deee 100644 --- a/packages/core/src/components/data-table/MIGRATION.md +++ b/packages/core/src/components/data-table/MIGRATION.md @@ -162,23 +162,23 @@ Migrate each component **with app-shell conventions applied inline** (astw: pref ## Design Decisions -| Topic | Decision | Rationale | -| ------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`generator/` handling** | Separate package `@tailor-platform/app-shell-sdk-plugins` | Depends on Tailor SDK; out of scope for app-shell. Added as a new monorepo package | -| **Metadata types** | Defined as interfaces in `types.ts` | Generator conforms to the same types; avoids circular imports | -| **i18n integration** | Integrated with app-shell's `defineI18nLabels` | Resolved via `useT()` from `AppShellConfig.locale`. The `locale` prop is removed. Standalone usage outside AppShell is not needed | -| **Existing Table relationship** | DataTable uses app-shell's `Table` internally | Avoids duplication and maintains theme consistency | -| **Directory structure** | `components/data-table/` directory (Pattern C) | Too many files for flat placement | -| **`useCollectionVariables` placement** | Separated into `hooks/use-collection-variables.ts` | Intended for standalone use outside DataTable (e.g., kanban) | -| **Internal UI elements** | Replace raw HTML with app-shell components (`Button`, `Input`, `Select`, `Menu`, `lucide-react` icons) | Maintains theme, accessibility, and style consistency | -| **`@tanstack/react-table`** | Remove from `package.json` | The source does not use it. No code in `packages/core/src/` imports it. Removing it in Phase 6 reduces bundle size and avoids confusion | -| **Styling adaptation timing** | Applied inline during component migration (Phase 3), not as a separate phase | Avoids touching each file twice | -| **`CollectionControlProvider` placement** | `contexts/collection-control-context.tsx` | Follows the existing app-shell convention where all Context Providers live in `contexts/` (e.g., `auth-context.tsx`, `theme-context.tsx`). Discoverability > co-location for providers | -| **Component pattern** | Pattern C (Multi-File Directory) — evaluate Pattern D after migration | Source uses `DataTable.Provider` + compound sub-components; there is no dominant "simple props" usage pattern today. Pattern D (Standalone + Parts) may be introduced later if a pre-assembled API is warranted | -| **`data-slot` naming** | Prefix all slots with `data-table-` (e.g., `data-table-header`, `data-table-body`, `data-table-row`) | Avoids collision with existing `Table` slots (`table-header`, `table-body`, etc.) and provides a consistent CSS targeting surface | -| **`DataTable.Root` wrapper styles** | Hardcoded inside `DataTable.Root`: `astw:border astw:rounded-md astw:bg-card` | Source includes these styles on the root; app-shell's `Table.Root` does not. Hardcoding keeps the DataTable visually consistent without requiring consumers to add wrapper styles | -| **Test environment** | `happy-dom` (non-negotiable). Missing DOM APIs are polyfilled/mocked in test setup | Aligns with app-shell's existing vitest config. Switching to jsdom is not an option | -| **Test utilities** | Shared `renderWithAppShell` wrapper providing `AppShellProvider` context | Required because `useT()` depends on `useAppShellConfig()`. Avoids duplicating provider setup across every test file | +| Topic | Decision | Rationale | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`generator/` handling** | Separate package `@tailor-platform/app-shell-sdk-plugins` | Depends on Tailor SDK; out of scope for app-shell. Added as a new monorepo package | +| **Metadata types** | Defined as interfaces in `types.ts` | Generator conforms to the same types; avoids circular imports | +| **i18n integration** | Integrated with app-shell's `defineI18nLabels` | Resolved via `useT()` from `AppShellConfig.locale`. The `locale` prop is removed. Standalone usage outside AppShell is not needed | +| **Existing Table relationship** | DataTable uses app-shell's `Table` internally | Avoids duplication and maintains theme consistency | +| **Directory structure** | `components/data-table/` directory (Pattern C) | Too many files for flat placement | +| **`useCollectionVariables` placement** | Separated into `hooks/use-collection-variables.ts` | Intended for standalone use outside DataTable (e.g., kanban) | +| **Internal UI elements** | Replace raw HTML with app-shell components (`Button`, `Input`, `Select`, `Menu`, `lucide-react` icons) | Maintains theme, accessibility, and style consistency | +| **`@tanstack/react-table`** | Remove from `package.json` | The source does not use it. No code in `packages/core/src/` imports it. Removing it in Phase 6 reduces bundle size and avoids confusion | +| **Styling adaptation timing** | Applied inline during component migration (Phase 3), not as a separate phase | Avoids touching each file twice | +| **`CollectionControlProvider` placement** | `contexts/collection-control-context.tsx` | Follows the existing app-shell convention where all Context Providers live in `contexts/` (e.g., `auth-context.tsx`, `theme-context.tsx`). Discoverability > co-location for providers | +| **Component pattern** | Pattern C (Multi-File Directory) — evaluate Pattern D after migration | Source uses `DataTable.Provider` + compound sub-components; there is no dominant "simple props" usage pattern today. Pattern D (Standalone + Parts) may be introduced later if a pre-assembled API is warranted | +| **`data-slot` naming** | Prefix all slots with `data-table-` (e.g., `data-table-header`, `data-table-body`, `data-table-row`) | Avoids collision with existing `Table` slots (`table-header`, `table-body`, etc.) and provides a consistent CSS targeting surface | +| **`DataTable.Root` wrapper styles** | Hardcoded inside `DataTable.Root`: `astw:border astw:rounded-md astw:bg-card` | Source includes these styles on the root; app-shell's `Table.Root` does not. Hardcoding keeps the DataTable visually consistent without requiring consumers to add wrapper styles | +| **Test environment** | `happy-dom` (non-negotiable). Missing DOM APIs are polyfilled/mocked in test setup | Aligns with app-shell's existing vitest config. Switching to jsdom is not an option | +| **Test utilities** | Shared `renderWithAppShell` wrapper providing `AppShellProvider` context | Required because `useT()` depends on `useAppShellConfig()`. Avoids duplicating provider setup across every test file | ## Table Sub-component Name Mapping From 7e67b33d0e4ffc97eafde9ede348835340a2503d Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 30 Mar 2026 17:36:13 +0900 Subject: [PATCH 08/11] Add files --- packages/core/package.json | 1 - .../data-table/data-table-context.tsx | 57 + .../components/data-table/data-table.test.tsx | 110 + .../src/components/data-table/data-table.tsx | 356 ++ .../data-table/field-helpers.test.ts | 407 ++ .../components/data-table/field-helpers.ts | 152 + .../core/src/components/data-table/i18n.ts | 37 + .../core/src/components/data-table/index.ts | 68 + .../src/components/data-table/pagination.tsx | 135 + .../core/src/components/data-table/types.ts | 719 +++ .../components/data-table/use-data-table.ts | 244 + .../contexts/collection-control-context.tsx | 51 + .../hooks/use-collection-variables.test.ts | 452 ++ .../src/hooks/use-collection-variables.ts | 319 ++ .../use-collection-variables.typetest.ts | 171 + packages/core/src/index.ts | 82 +- packages/core/src/test-utils.tsx | 24 + packages/sdk-plugins/.oxlintrc.jsonc | 9 + packages/sdk-plugins/package.json | 53 + packages/sdk-plugins/src/index.ts | 4 + .../sdk-plugins/src/metadata-plugin.test.ts | 597 +++ packages/sdk-plugins/src/metadata-plugin.ts | 209 + packages/sdk-plugins/tsconfig.json | 16 + packages/sdk-plugins/tsdown.config.ts | 8 + packages/sdk-plugins/vitest.config.ts | 7 + pnpm-lock.yaml | 3987 ++++++++++++++++- 26 files changed, 8073 insertions(+), 202 deletions(-) create mode 100644 packages/core/src/components/data-table/data-table-context.tsx create mode 100644 packages/core/src/components/data-table/data-table.test.tsx create mode 100644 packages/core/src/components/data-table/data-table.tsx create mode 100644 packages/core/src/components/data-table/field-helpers.test.ts create mode 100644 packages/core/src/components/data-table/field-helpers.ts create mode 100644 packages/core/src/components/data-table/i18n.ts create mode 100644 packages/core/src/components/data-table/index.ts create mode 100644 packages/core/src/components/data-table/pagination.tsx create mode 100644 packages/core/src/components/data-table/types.ts create mode 100644 packages/core/src/components/data-table/use-data-table.ts create mode 100644 packages/core/src/contexts/collection-control-context.tsx create mode 100644 packages/core/src/hooks/use-collection-variables.test.ts create mode 100644 packages/core/src/hooks/use-collection-variables.ts create mode 100644 packages/core/src/hooks/use-collection-variables.typetest.ts create mode 100644 packages/core/src/test-utils.tsx create mode 100644 packages/sdk-plugins/.oxlintrc.jsonc create mode 100644 packages/sdk-plugins/package.json create mode 100644 packages/sdk-plugins/src/index.ts create mode 100644 packages/sdk-plugins/src/metadata-plugin.test.ts create mode 100644 packages/sdk-plugins/src/metadata-plugin.ts create mode 100644 packages/sdk-plugins/tsconfig.json create mode 100644 packages/sdk-plugins/tsdown.config.ts create mode 100644 packages/sdk-plugins/vitest.config.ts diff --git a/packages/core/package.json b/packages/core/package.json index 9044ee83..4862a17a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -51,7 +51,6 @@ "@base-ui/react": "^1.3.0", "@tailor-platform/app-shell-vite-plugin": "workspace:*", "@tailor-platform/auth-public-client": "^0.5.0", - "@tanstack/react-table": "^8.21.3", "change-case": "^5.4.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/core/src/components/data-table/data-table-context.tsx b/packages/core/src/components/data-table/data-table-context.tsx new file mode 100644 index 00000000..221d17d9 --- /dev/null +++ b/packages/core/src/components/data-table/data-table-context.tsx @@ -0,0 +1,57 @@ +import { createContext, useContext } from "react"; +import type { + Column, + PageInfo, + RowAction, + RowOperations, + SortState, +} from "./types"; + +/** + * Context value provided by `DataTable.Provider`. + */ +export interface DataTableContextValue> { + updateRow: RowOperations["updateRow"]; + deleteRow: RowOperations["deleteRow"]; + insertRow: RowOperations["insertRow"]; + + columns: Column[]; + rows: TRow[]; + loading: boolean; + error: Error | null; + sortStates: SortState[]; + onSort?: (field: string, direction?: "Asc" | "Desc") => void; + + visibleColumns: Column[]; + isColumnVisible: (fieldOrId: string) => boolean; + toggleColumn: (fieldOrId: string) => void; + showAllColumns: () => void; + hideAllColumns: () => void; + + pageInfo: PageInfo; + + onClickRow?: (row: TRow) => void; + rowActions?: RowAction[]; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const DataTableContext = createContext | null>(null); + +export { DataTableContext }; + +/** + * Hook to access row operations from the nearest `DataTable.Provider`. + * + * @throws Error if used outside of `DataTable.Provider`. + */ +export function useDataTableContext< + TRow extends Record, +>(): DataTableContextValue { + const ctx = useContext(DataTableContext); + if (!ctx) { + throw new Error( + "useDataTableContext must be used within ", + ); + } + return ctx as DataTableContextValue; +} diff --git a/packages/core/src/components/data-table/data-table.test.tsx b/packages/core/src/components/data-table/data-table.test.tsx new file mode 100644 index 00000000..93301fa7 --- /dev/null +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -0,0 +1,110 @@ +import { afterEach, describe, it, expect } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { createAppShellWrapper } from "@/test-utils"; +import { DataTable } from "./data-table"; +import { useDataTable } from "./use-data-table"; +import type { Column, CollectionResult } from "./types"; + +afterEach(() => { + cleanup(); +}); + +type TestRow = { id: string; name: string; status: string }; + +const testColumns: Column[] = [ + { label: "Name", render: (row) => row.name }, + { label: "Status", render: (row) => row.status }, +]; + +const testData: CollectionResult = { + edges: [ + { node: { id: "1", name: "Alice", status: "Active" } }, + { node: { id: "2", name: "Bob", status: "Inactive" } }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + hasPreviousPage: false, + startCursor: null, + }, +}; + +function TestDataTable(props: { + columns?: Column[]; + data?: CollectionResult | undefined; + loading?: boolean; + error?: Error | null; +}) { + const { + columns = testColumns, + data = "data" in props ? props.data : testData, + loading, + error, + } = props; + const table = useDataTable({ columns, data, loading, error }); + return ( + + + + + + + ); +} + +const wrapper = createAppShellWrapper("en"); + +describe("DataTable", () => { + it("renders a basic data table with headers and rows", () => { + render(, { wrapper }); + + expect(screen.getByText("Name")).toBeDefined(); + expect(screen.getByText("Status")).toBeDefined(); + expect(screen.getByText("Alice")).toBeDefined(); + expect(screen.getByText("Bob")).toBeDefined(); + expect(screen.getByText("Active")).toBeDefined(); + expect(screen.getByText("Inactive")).toBeDefined(); + }); + + it("renders loading state", () => { + render(, { wrapper }); + + expect(screen.getByText("Loading...")).toBeDefined(); + }); + + it("renders error state", () => { + render( + , + { wrapper }, + ); + + expect(screen.getByText(/Network error/)).toBeDefined(); + }); + + it("renders empty state", () => { + const emptyData: CollectionResult = { + edges: [], + pageInfo: { + hasNextPage: false, + endCursor: null, + hasPreviousPage: false, + startCursor: null, + }, + }; + render(, { wrapper }); + + expect(screen.getByText("No data")).toBeDefined(); + }); + + it("renders with data-slot attributes", () => { + const { container } = render(, { wrapper }); + + expect(container.querySelector('[data-slot="data-table"]')).toBeDefined(); + expect( + container.querySelector('[data-slot="data-table-header"]'), + ).toBeDefined(); + expect( + container.querySelector('[data-slot="data-table-body"]'), + ).toBeDefined(); + }); +}); diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx new file mode 100644 index 00000000..8a999fd3 --- /dev/null +++ b/packages/core/src/components/data-table/data-table.tsx @@ -0,0 +1,356 @@ +import { useContext, type ComponentProps, type ReactNode } from "react"; +import { Ellipsis } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { CollectionControlProvider } from "@/contexts/collection-control-context"; +import { Table } from "@/components/table"; +import { Button } from "@/components/button"; +import { Menu } from "@/components/menu"; +import type { RowAction, SortConfig, UseDataTableReturn } from "./types"; +import { + DataTableContext, + type DataTableContextValue, +} from "./data-table-context"; +import { useDataTableT } from "./i18n"; + +// ============================================================================= +// DataTable.Root +// ============================================================================= + +function DataTableRoot({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} +DataTableRoot.displayName = "DataTable.Root"; + +// ============================================================================= +// DataTable.Provider +// ============================================================================= + +function DataTableProviderComponent>({ + value, + children, +}: { + value: UseDataTableReturn; + children: ReactNode; +}) { + const dataTableValue: DataTableContextValue = { + columns: value.columns, + rows: value.rows, + loading: value.loading, + error: value.error, + sortStates: value.sortStates ?? [], + onSort: value.onSort, + updateRow: value.updateRow, + deleteRow: value.deleteRow, + insertRow: value.insertRow, + visibleColumns: value.visibleColumns, + isColumnVisible: value.isColumnVisible, + toggleColumn: value.toggleColumn, + showAllColumns: value.showAllColumns, + hideAllColumns: value.hideAllColumns, + pageInfo: value.pageInfo, + onClickRow: value.onClickRow, + rowActions: value.rowActions, + }; + + const controlValue = value.control ?? null; + + const inner = ( + + {children} + + ); + + if (controlValue) { + return ( + + {inner} + + ); + } + + return inner; +} +DataTableProviderComponent.displayName = "DataTable.Provider"; + +// ============================================================================= +// DataTable.Headers +// ============================================================================= + +function DataTableHeaders({ className }: { className?: string }) { + const ctx = useContext(DataTableContext); + if (!ctx) { + throw new Error( + " must be used within ", + ); + } + const { columns, sortStates, onSort, rowActions } = ctx; + const t = useDataTableT(); + + return ( + + + {columns?.map((col, colIndex) => { + const key = col.id ?? col.label ?? String(colIndex); + const label = col.label; + + const isSortable = !!col.sort; + const currentSort = col.sort + ? sortStates?.find( + (s) => s.field === (col.sort as SortConfig).field, + ) + : undefined; + + const handleClick = () => { + if (!isSortable || !onSort || !col.sort) return; + const nextDirection = + currentSort?.direction === "Asc" + ? "Desc" + : currentSort?.direction === "Desc" + ? undefined + : "Asc"; + onSort(col.sort.field, nextDirection); + }; + + return ( + + + {label} + {currentSort && ( + + )} + + + ); + })} + {rowActions && rowActions.length > 0 && ( + + {t("actionsHeader")} + + )} + + + ); +} +DataTableHeaders.displayName = "DataTable.Headers"; + +function SortIndicator({ direction }: { direction: "Asc" | "Desc" }) { + return ( + + {direction === "Asc" ? "▲" : "▼"} + + ); +} + +// ============================================================================= +// DataTable.Body +// ============================================================================= + +function DataTableBody({ + children, + className, +}: { + children?: ReactNode; + className?: string; +}) { + const ctx = useContext(DataTableContext); + if (!ctx) { + throw new Error( + " must be used within ", + ); + } + const { columns, rows, loading, error, onClickRow, rowActions } = ctx; + const t = useDataTableT(); + const hasRowActions = rowActions && rowActions.length > 0; + const totalColSpan = (columns?.length ?? 1) + (hasRowActions ? 1 : 0); + + if (children) { + return ( + + {children} + + ); + } + + return ( + + {loading && (!rows || rows.length === 0) && ( + + + + {t("loading")} + + + + )} + {error && ( + + + + {t("errorPrefix")} {error.message} + + + + )} + {!loading && !error && (!rows || rows.length === 0) && ( + + + + {t("noData")} + + + + )} + {rows?.map((row, rowIndex) => ( + onClickRow(row) : undefined} + > + {columns?.map((col, colIndex) => { + const key = col.id ?? col.label ?? String(colIndex); + return ( + + {col.render(row)} + + ); + })} + {hasRowActions && ( + e.stopPropagation()} + > + + + )} + + ))} + + ); +} +DataTableBody.displayName = "DataTable.Body"; + +// ============================================================================= +// DataTable.Row +// ============================================================================= + +function DataTableRow(props: ComponentProps<"tr">) { + return ; +} +DataTableRow.displayName = "DataTable.Row"; + +// ============================================================================= +// DataTable.Cell +// ============================================================================= + +function DataTableCell(props: ComponentProps<"td">) { + return ; +} +DataTableCell.displayName = "DataTable.Cell"; + +// ============================================================================= +// RowActionsMenu (internal — uses app-shell Menu) +// ============================================================================= + +function RowActionsMenu>({ + actions, + row, +}: { + actions: RowAction[]; + row: TRow; +}) { + const t = useDataTableT(); + + return ( +
+ + + + + + {actions.map((action) => { + const disabled = action.isDisabled?.(row) ?? false; + return ( + { + if (!disabled) { + action.onClick(row); + } + }} + className={cn( + action.variant === "destructive" && "astw:text-destructive", + )} + > + {action.icon} + {action.label} + + ); + })} + + +
+ ); +} + +// ============================================================================= +// DataTable namespace +// ============================================================================= + +export const DataTable = { + Provider: DataTableProviderComponent, + Root: DataTableRoot, + Headers: DataTableHeaders, + Body: DataTableBody, + Row: DataTableRow, + Cell: DataTableCell, +} as const; diff --git a/packages/core/src/components/data-table/field-helpers.test.ts b/packages/core/src/components/data-table/field-helpers.test.ts new file mode 100644 index 00000000..17642ebe --- /dev/null +++ b/packages/core/src/components/data-table/field-helpers.test.ts @@ -0,0 +1,407 @@ +import { describe, it, expect, expectTypeOf } from "vitest"; +import { column, inferColumns, createColumnHelper } from "./field-helpers"; +import type { TableMetadataMap } from "./types"; +import { fieldTypeToSortConfig, fieldTypeToFilterConfig } from "./types"; +import type { NodeType, TableFieldName } from "./types"; + +describe("NodeType", () => { + it("extracts node type from a collection result", () => { + type Result = { edges: { node: { id: string; name: string } }[] }; + type Row = NodeType; + expectTypeOf().toEqualTypeOf<{ id: string; name: string }>(); + }); + + it("handles nullable collection (e.g. gql-tada ResultOf)", () => { + type Result = + | { edges: { node: { id: string; amount: number } }[] } + | null + | undefined; + type Row = NodeType; + expectTypeOf().toEqualTypeOf<{ id: string; amount: number }>(); + }); +}); + +describe("column()", () => { + type TestRow = { name: string; age: number }; + + it("creates a column with render and sort/filter", () => { + const col = column({ + label: "Name", + render: (row) => row.name, + sort: { field: "name", type: "string" }, + filter: { field: "name", type: "string" }, + }); + expect(col.label).toBe("Name"); + expect(col.sort).toEqual({ field: "name", type: "string" }); + expect(col.filter).toEqual({ field: "name", type: "string" }); + expect(typeof col.render).toBe("function"); + }); + + it("creates minimal column with label and render", () => { + const col = column({ + label: "Age", + render: (row) => String(row.age), + }); + expect(col.label).toBe("Age"); + expect(col.sort).toBeUndefined(); + expect(col.filter).toBeUndefined(); + }); + + it("supports accessor and width", () => { + const col = column({ + label: "Actions", + width: 100, + render: (row) => `${row.name}: ${row.age}`, + accessor: (row) => row.name, + }); + expect(col.label).toBe("Actions"); + expect(col.width).toBe(100); + expect(typeof col.render).toBe("function"); + expect(typeof col.accessor).toBe("function"); + }); + + it("allows undefined label", () => { + const col = column({ + render: (row) => row.name, + }); + expect(col.label).toBeUndefined(); + expect(typeof col.render).toBe("function"); + }); +}); + +describe("inferColumns()", () => { + it("returns a function that produces column options from metadata", () => { + type TaskRow = { id: string; title: string; status: string }; + const metadata = { + name: "task", + pluralForm: "tasks", + fields: [ + { name: "id", type: "uuid", required: true }, + { name: "title", type: "string", required: true }, + { + name: "status", + type: "enum", + required: true, + enumValues: ["todo", "done"], + }, + ], + } as const; + + const infer = inferColumns(metadata); + + const titleOpts = infer("title"); + expect(titleOpts.label).toBe("title"); + expect(titleOpts.sort).toEqual({ field: "title", type: "string" }); + expect(typeof titleOpts.render).toBe("function"); + expect(typeof titleOpts.accessor).toBe("function"); + + const titleCol = column(infer("title")); + expect(titleCol.label).toBe("title"); + expect(titleCol.sort).toEqual({ field: "title", type: "string" }); + + const customCol = column({ + ...infer("status"), + label: "Custom Status", + }); + expect(customCol.label).toBe("Custom Status"); + }); +}); + +describe("fieldTypeToSortConfig", () => { + it("maps string to string sort", () => { + expect(fieldTypeToSortConfig("name", "string")).toEqual({ + field: "name", + type: "string", + }); + }); + + it("maps number to number sort", () => { + expect(fieldTypeToSortConfig("amount", "number")).toEqual({ + field: "amount", + type: "number", + }); + }); + + it("maps datetime to date sort", () => { + expect(fieldTypeToSortConfig("createdAt", "datetime")).toEqual({ + field: "createdAt", + type: "date", + }); + }); + + it("maps date to date sort", () => { + expect(fieldTypeToSortConfig("dueDate", "date")).toEqual({ + field: "dueDate", + type: "date", + }); + }); + + it("maps enum to string sort", () => { + expect(fieldTypeToSortConfig("status", "enum")).toEqual({ + field: "status", + type: "string", + }); + }); + + it("returns undefined for uuid", () => { + expect(fieldTypeToSortConfig("id", "uuid")).toBeUndefined(); + }); + + it("returns undefined for array", () => { + expect(fieldTypeToSortConfig("tags", "array")).toBeUndefined(); + }); + + it("returns undefined for nested", () => { + expect(fieldTypeToSortConfig("meta", "nested")).toBeUndefined(); + }); +}); + +describe("fieldTypeToFilterConfig", () => { + it("maps string to string filter", () => { + expect(fieldTypeToFilterConfig("name", "string")).toEqual({ + field: "name", + type: "string", + }); + }); + + it("maps number to number filter", () => { + expect(fieldTypeToFilterConfig("amount", "number")).toEqual({ + field: "amount", + type: "number", + }); + }); + + it("maps uuid to uuid filter", () => { + expect(fieldTypeToFilterConfig("id", "uuid")).toEqual({ + field: "id", + type: "uuid", + }); + }); + + it("maps enum with values to enum filter", () => { + expect(fieldTypeToFilterConfig("status", "enum", ["a", "b", "c"])).toEqual({ + field: "status", + type: "enum", + options: [ + { value: "a", label: "a" }, + { value: "b", label: "b" }, + { value: "c", label: "c" }, + ], + }); + }); + + it("returns undefined for array", () => { + expect(fieldTypeToFilterConfig("tags", "array")).toBeUndefined(); + }); +}); + +describe("inferColumns() with metadata", () => { + const testMetadata = { + task: { + name: "task", + pluralForm: "tasks", + fields: [ + { name: "id", type: "uuid", required: true }, + { name: "title", type: "string", required: true }, + { + name: "status", + type: "enum", + required: true, + enumValues: ["todo", "in_progress", "done"], + }, + { name: "dueDate", type: "date", required: false }, + { name: "count", type: "number", required: false }, + { name: "isActive", type: "boolean", required: false }, + { + name: "tags", + type: "array", + required: false, + arrayItemType: "string", + }, + ], + }, + } as const satisfies TableMetadataMap; + + type TaskRow = { + id: string; + title: string; + status: string; + dueDate: string; + count: number; + isActive: boolean; + tags: string[]; + }; + + it("creates column options with auto-detected sort/filter", () => { + const infer = inferColumns(testMetadata.task); + + const titleOpts = infer("title"); + expect(titleOpts.label).toBe("title"); + expect(titleOpts.sort).toEqual({ field: "title", type: "string" }); + expect(titleOpts.filter).toEqual({ field: "title", type: "string" }); + expect(typeof titleOpts.render).toBe("function"); + expect(typeof titleOpts.accessor).toBe("function"); + }); + + it("auto-detects enum options", () => { + const infer = inferColumns(testMetadata.task); + const statusOpts = infer("status"); + expect(statusOpts.filter).toEqual({ + field: "status", + type: "enum", + options: [ + { value: "todo", label: "todo" }, + { value: "in_progress", label: "in_progress" }, + { value: "done", label: "done" }, + ], + }); + expect(statusOpts.sort).toEqual({ field: "status", type: "string" }); + }); + + it("auto-detects date type", () => { + const infer = inferColumns(testMetadata.task); + const dateOpts = infer("dueDate"); + expect(dateOpts.sort).toEqual({ field: "dueDate", type: "date" }); + expect(dateOpts.filter).toEqual({ field: "dueDate", type: "date" }); + }); + + it("disables sort with sort: false", () => { + const infer = inferColumns(testMetadata.task); + const opts = infer("title", { sort: false }); + expect(opts.sort).toBeUndefined(); + expect(opts.filter).toEqual({ field: "title", type: "string" }); + }); + + it("disables filter with filter: false", () => { + const infer = inferColumns(testMetadata.task); + const opts = infer("title", { filter: false }); + expect(opts.sort).toEqual({ field: "title", type: "string" }); + expect(opts.filter).toBeUndefined(); + }); + + it("uuid has no sort, has uuid filter", () => { + const infer = inferColumns(testMetadata.task); + const opts = infer("id"); + expect(opts.sort).toBeUndefined(); + expect(opts.filter).toEqual({ field: "id", type: "uuid" }); + }); + + it("array type has no sort/filter", () => { + const infer = inferColumns(testMetadata.task); + const opts = infer("tags"); + expect(opts.sort).toBeUndefined(); + expect(opts.filter).toBeUndefined(); + }); + + it("generates default render and accessor functions", () => { + const infer = inferColumns(testMetadata.task); + + const opts = infer("title"); + const testRow = { + id: "1", + title: "Test Task", + status: "todo", + dueDate: "2024-01-01", + count: 5, + isActive: true, + tags: ["a"], + }; + + expect(opts.render(testRow)).toBe("Test Task"); + expect(opts.accessor!(testRow)).toBe("Test Task"); + }); + + it("throws for non-existent field", () => { + const infer = inferColumns(testMetadata.task); + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + infer("nonExistent" as any), + ).toThrow('Field "nonExistent" not found in table "task" metadata'); + }); + + it("infers TableFieldName type correctly", () => { + type TaskFieldNames = TableFieldName<(typeof testMetadata)["task"]>; + expectTypeOf().toEqualTypeOf< + "id" | "title" | "status" | "dueDate" | "count" | "isActive" | "tags" + >(); + }); + + it("spread override works with column()", () => { + const infer = inferColumns(testMetadata.task); + + const col = column({ + ...infer("title"), + label: "Custom Title", + width: 200, + }); + expect(col.label).toBe("Custom Title"); + expect(col.width).toBe(200); + expect(col.sort).toEqual({ field: "title", type: "string" }); + }); +}); + +describe("createColumnHelper()", () => { + type OrderRow = { id: string; name: string; amount: number }; + + it("returns column and inferColumns with TRow bound", () => { + const helper = createColumnHelper(); + expect(typeof helper.column).toBe("function"); + expect(typeof helper.inferColumns).toBe("function"); + }); + + it("column() works without type parameter", () => { + const { column: helperColumn } = createColumnHelper(); + const col = helperColumn({ + label: "Name", + render: (row) => row.name, + sort: { field: "name", type: "string" }, + }); + expect(col.label).toBe("Name"); + expect(col.sort).toEqual({ field: "name", type: "string" }); + }); + + it("inferColumns() works without type parameter", () => { + const metadata = { + name: "order", + pluralForm: "orders", + fields: [ + { name: "id", type: "uuid", required: true }, + { name: "name", type: "string", required: true }, + { name: "amount", type: "number", required: false }, + ], + } as const; + + const { column: helperColumn, inferColumns: helperInferColumns } = + createColumnHelper(); + const infer = helperInferColumns(metadata); + + const col = helperColumn(infer("name")); + expect(col.label).toBe("name"); + expect(col.sort).toEqual({ field: "name", type: "string" }); + }); + + it("column + inferColumns spread override", () => { + const metadata = { + name: "order", + pluralForm: "orders", + fields: [ + { name: "id", type: "uuid", required: true }, + { name: "name", type: "string", required: true }, + { name: "amount", type: "number", required: false }, + ], + } as const; + + const { column: helperColumn, inferColumns: helperInferColumns } = + createColumnHelper(); + const infer = helperInferColumns(metadata); + + const col = helperColumn({ + ...infer("name"), + label: "Custom Name", + render: (row) => `Name: ${row.name}`, + }); + expect(col.label).toBe("Custom Name"); + expect(col.sort).toEqual({ field: "name", type: "string" }); + expect(col.render({ id: "1", name: "Test", amount: 0 })).toBe("Name: Test"); + }); +}); diff --git a/packages/core/src/components/data-table/field-helpers.ts b/packages/core/src/components/data-table/field-helpers.ts new file mode 100644 index 00000000..626c23a4 --- /dev/null +++ b/packages/core/src/components/data-table/field-helpers.ts @@ -0,0 +1,152 @@ +import type { ReactNode } from "react"; +import type { + Column, + ColumnOptions, + FilterConfig, + MetadataFieldOptions, + SortConfig, + TableFieldName, + TableMetadata, +} from "./types"; +import { fieldTypeToFilterConfig, fieldTypeToSortConfig } from "./types"; + +// ============================================================================= +// column() helper +// ============================================================================= + +/** + * Define a column with explicit render and optional sort/filter/accessor. + */ +export function column>( + options: ColumnOptions, +): Column { + return { + label: options.label, + render: options.render, + id: options.id, + width: options.width, + accessor: options.accessor, + sort: options.sort, + filter: options.filter, + }; +} + +// ============================================================================= +// inferColumns() — metadata-driven column defaults +// ============================================================================= + +function formatValue(value: unknown): ReactNode { + if (value == null) return ""; + if (typeof value === "boolean") return value ? "✓" : "✗"; + if (value instanceof Date) return value.toLocaleDateString(); + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +/** + * Return a function that produces `ColumnOptions` from metadata field names. + * + * @example + * ```tsx + * const infer = inferColumns(tableMetadata.order); + * const columns = [ + * column(infer("title")), + * column({ ...infer("status"), render: (row) => }), + * ]; + * ``` + */ +export function inferColumns< + TRow extends Record, + const TTable extends TableMetadata = TableMetadata, +>( + tableMetadata: TTable, +): ( + dataKey: TableFieldName, + options?: MetadataFieldOptions, +) => ColumnOptions { + const fields = tableMetadata.fields; + + return ( + dataKey: TableFieldName, + columnOptions?: MetadataFieldOptions, + ): ColumnOptions => { + const fieldName = dataKey as string; + const fieldMeta = fields.find((f) => f.name === fieldName); + if (!fieldMeta) { + throw new Error( + `Field "${fieldName}" not found in table "${tableMetadata.name}" metadata`, + ); + } + + let sort: SortConfig | undefined; + if (columnOptions?.sort !== false) { + sort = fieldTypeToSortConfig(fieldName, fieldMeta.type); + } + if (columnOptions?.sort === false) { + sort = undefined; + } + + let filter: FilterConfig | undefined; + if (columnOptions?.filter !== false) { + filter = fieldTypeToFilterConfig( + fieldName, + fieldMeta.type, + fieldMeta.enumValues, + ); + } + if (columnOptions?.filter === false) { + filter = undefined; + } + + const label = + columnOptions?.label ?? fieldMeta.description ?? fieldMeta.name; + + return { + label, + render: ((row: Record) => + formatValue(row[fieldName])) as (row: TRow) => ReactNode, + accessor: ((row: Record) => row[fieldName]) as ( + row: TRow, + ) => unknown, + width: columnOptions?.width, + sort, + filter, + }; + }; +} + +// ============================================================================= +// createColumnHelper() — factory with TRow bound once +// ============================================================================= + +/** + * Factory that captures the row type once and returns `column` and `inferColumns` + * with `TRow` already bound. + * + * @example + * ```tsx + * const { column, inferColumns } = createColumnHelper(); + * + * const infer = inferColumns(tableMetadata.order); + * const columns = [ + * column(infer("title")), + * column({ label: "Actions", render: (row) => }), + * ]; + * ``` + */ +export function createColumnHelper>(): { + column: (options: ColumnOptions) => Column; + inferColumns: ( + tableMetadata: TTable, + ) => ( + dataKey: TableFieldName, + options?: MetadataFieldOptions, + ) => ColumnOptions; +} { + return { + column: (options: ColumnOptions) => column(options), + inferColumns: ( + tableMetadata: TTable, + ) => inferColumns(tableMetadata), + }; +} diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts new file mode 100644 index 00000000..1a19e90d --- /dev/null +++ b/packages/core/src/components/data-table/i18n.ts @@ -0,0 +1,37 @@ +import { defineI18nLabels } from "@/hooks/i18n"; + +export const dataTableLabels = defineI18nLabels({ + en: { + // DataTable.Body + loading: "Loading...", + noData: "No data", + errorPrefix: "Error:", + + // DataTable.Headers (sr-only for actions column) + actionsHeader: "Actions", + + // RowActionsMenu aria-label + rowActions: "Row actions", + + // Pagination + paginationFirst: "First page", + paginationPrevious: "Previous page", + paginationNext: "Next page", + paginationLast: "Last page", + paginationRowsPerPage: "Rows per page", + }, + ja: { + loading: "読み込み中...", + noData: "データがありません", + errorPrefix: "エラー:", + actionsHeader: "操作", + rowActions: "行の操作", + paginationFirst: "最初のページ", + paginationPrevious: "前のページ", + paginationNext: "次のページ", + paginationLast: "最後のページ", + paginationRowsPerPage: "表示件数", + }, +}); + +export const useDataTableT = dataTableLabels.useT; diff --git a/packages/core/src/components/data-table/index.ts b/packages/core/src/components/data-table/index.ts new file mode 100644 index 00000000..d064024d --- /dev/null +++ b/packages/core/src/components/data-table/index.ts @@ -0,0 +1,68 @@ +// DataTable compound component +export { DataTable } from "./data-table"; +export { useDataTable } from "./use-data-table"; +export { useDataTableContext } from "./data-table-context"; + +// Pagination +export { Pagination, type PaginationProps } from "./pagination"; + +// Field helpers +export { createColumnHelper } from "./field-helpers"; + +// i18n +export { dataTableLabels } from "./i18n"; + +// Types — runtime constants +export { + OPERATORS_BY_FILTER_TYPE, + DEFAULT_OPERATOR_LABELS, + fieldTypeToSortConfig, + fieldTypeToFilterConfig, +} from "./types"; + +// Types — public type exports +export type { + // Core + Column, + ColumnOptions, + ColumnDefinition, + SortConfig, + FilterConfig, + SortState, + Filter, + FilterOperator, + SelectOption, + PageInfo, + RowAction, + RowOperations, + + // Collection + CollectionVariables, + CollectionControl, + CollectionResult, + NodeType, + QueryVariables, + PaginationVariables, + UseCollectionOptions, + UseCollectionReturn, + + // DataTable + UseDataTableOptions, + UseDataTableReturn, + + // Metadata + FieldType, + FieldMetadata, + TableMetadata, + TableMetadataMap, + BuildQueryVariables, + TableMetadataFilter, + MetadataFilter, + TableFieldName, + TableOrderableFieldName, + OrderableFieldName, + FieldName, + MatchingTableName, + MetadataFieldOptions, + MetadataFieldsOptions, +} from "./types"; diff --git a/packages/core/src/components/data-table/pagination.tsx b/packages/core/src/components/data-table/pagination.tsx new file mode 100644 index 00000000..cde06e3e --- /dev/null +++ b/packages/core/src/components/data-table/pagination.tsx @@ -0,0 +1,135 @@ +import { + ChevronsLeft, + ChevronLeft, + ChevronRight, + ChevronsRight, +} from "lucide-react"; +import { Button } from "@/components/button"; +import { Select } from "@/components/select-standalone"; +import { useDataTableContext } from "./data-table-context"; +import { useCollectionControl } from "@/contexts/collection-control-context"; +import { useDataTableT } from "./i18n"; + +// ============================================================================= +// Pagination Component +// ============================================================================= + +export interface PaginationProps { + /** + * Available page-size options shown in a dropdown selector. + * When provided, a page-size switcher is rendered. + * + * @example + * ```tsx + * + * ``` + */ + pageSizeOptions?: number[]; +} + +/** + * Pagination controls with first/prev/next/last navigation and page indicator. + * + * Reads pagination state from `DataTableContext` and `CollectionControlContext`. + * Must be rendered inside `DataTable.Provider` but **outside** `DataTable.Root`. + * + * @example + * ```tsx + * + * + * + * + * + * + * + * ``` + */ +export function Pagination({ pageSizeOptions }: PaginationProps = {}) { + const { pageInfo } = useDataTableContext(); + const { + nextPage, + prevPage, + hasPrevPage, + hasNextPage, + currentPage, + totalPages, + goToFirstPage, + goToLastPage, + pageSize, + setPageSize, + } = useCollectionControl(); + + const t = useDataTableT(); + + return ( +
+ {pageSizeOptions && pageSizeOptions.length > 0 && ( +
+ + {t("paginationRowsPerPage")} + +