From 72a4b3403b9c9f183275b34d073413c6830c67fb Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 30 Jun 2026 14:48:24 +0700 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D1=80=D0=B0=D0=B2=D0=BD=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20UI=20=D1=81=20vps-tracker:=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=D0=B0=20b2fA,=20ReUI=20DataGrid=20=D0=B8=20s?= =?UTF-8?q?hell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Синхронизированы тема и layout (ModeToggle, sticky header), списки переведены на DataGridCard, дашборд на компактные SectionCards. Co-authored-by: Cursor --- .agents/skills/reui/SKILL.md | 61 + .cursor/rules/frontend-shadcn.mdc | 5 +- .cursor/rules/frontend-ui-patterns.mdc | 7 +- .cursor/rules/reui-mcp.mdc | 109 + .cursor/rules/shadcn-ui-production.mdc | 2 +- apps/web/components.json | 3 + apps/web/eslint.config.js | 21 + apps/web/package.json | 7 +- apps/web/src/components/app-sidebar.tsx | 13 +- apps/web/src/components/data-grid-card.tsx | 434 ++++ apps/web/src/components/data-grid-cells.tsx | 53 + apps/web/src/components/data-grid-types.ts | 25 + apps/web/src/components/data-table-card.tsx | 57 - apps/web/src/components/data-table-density.ts | 15 - .../src/components/data-table-pagination.tsx | 119 - .../web/src/components/data-table-toolbar.tsx | 110 - apps/web/src/components/data-table-view.tsx | 121 -- .../src/components/dns-records-data-grid.tsx | 188 ++ apps/web/src/components/dns-records-table.tsx | 242 --- ...s-data-table.tsx => domains-data-grid.tsx} | 216 +- .../components/domains-filters-toolbar.tsx | 85 + apps/web/src/components/kpi-card.tsx | 99 - apps/web/src/components/layout/app-shell.tsx | 11 +- .../web/src/components/layout/site-header.tsx | 17 +- apps/web/src/components/list-filters-bar.tsx | 171 ++ apps/web/src/components/mode-toggle.tsx | 29 + apps/web/src/components/page-shell.tsx | 11 +- apps/web/src/components/reui/autocomplete.tsx | 343 +++ apps/web/src/components/reui/badge.tsx | 98 + apps/web/src/components/reui/color-picker.tsx | 220 ++ .../data-grid/data-grid-column-filter.tsx | 165 ++ .../data-grid/data-grid-column-header.tsx | 345 +++ .../data-grid/data-grid-column-visibility.tsx | 51 + .../reui/data-grid/data-grid-pagination.tsx | 226 ++ .../reui/data-grid/data-grid-scroll-area.tsx | 419 ++++ .../data-grid/data-grid-table-dnd-rows.tsx | 309 +++ .../reui/data-grid/data-grid-table-dnd.tsx | 312 +++ .../data-grid/data-grid-table-virtual.tsx | 492 +++++ .../reui/data-grid/data-grid-table.tsx | 1431 ++++++++++++ .../components/reui/data-grid/data-grid.tsx | 270 +++ .../web/src/components/reui/date-selector.tsx | 1330 ++++++++++++ apps/web/src/components/reui/filters.tsx | 1924 +++++++++++++++++ apps/web/src/components/reui/number-field.tsx | 258 +++ .../src/components/section-cards-skeleton.tsx | 53 - apps/web/src/components/section-cards.tsx | 228 +- apps/web/src/components/skeletons.tsx | 38 + apps/web/src/components/status-badge.tsx | 33 +- .../src/components/subdomains-data-grid.tsx | 197 ++ apps/web/src/components/subdomains-table.tsx | 208 -- apps/web/src/components/table-skeleton.tsx | 55 - apps/web/src/components/theme-provider.tsx | 17 +- apps/web/src/components/truncated-text.tsx | 36 + apps/web/src/main.tsx | 4 +- apps/web/src/routes/_auth/certificates.tsx | 145 +- .../routes/_auth/domains/$domainId/dns.tsx | 109 +- .../routes/_auth/domains/$domainId/index.tsx | 65 +- apps/web/src/routes/_auth/domains/index.tsx | 74 +- apps/web/src/routes/_auth/groups/$groupId.tsx | 220 +- apps/web/src/routes/_auth/index.tsx | 86 +- apps/web/tsconfig.app.json | 2 +- packages/ui/package.json | 2 + packages/ui/src/components/button-group.tsx | 87 + packages/ui/src/components/calendar.tsx | 221 ++ packages/ui/src/components/dropdown-menu.tsx | 2 + packages/ui/src/components/input-group.tsx | 2 + packages/ui/src/components/kbd.tsx | 26 + packages/ui/src/components/scroll-area.tsx | 2 + packages/ui/src/components/select.tsx | 6 +- packages/ui/src/components/spinner.tsx | 2 +- packages/ui/src/styles/globals.css | 168 +- packages/ui/tsconfig.json | 2 +- pnpm-lock.yaml | 87 +- 72 files changed, 10700 insertions(+), 1901 deletions(-) create mode 100644 .agents/skills/reui/SKILL.md create mode 100644 .cursor/rules/reui-mcp.mdc create mode 100644 apps/web/src/components/data-grid-card.tsx create mode 100644 apps/web/src/components/data-grid-cells.tsx create mode 100644 apps/web/src/components/data-grid-types.ts delete mode 100644 apps/web/src/components/data-table-card.tsx delete mode 100644 apps/web/src/components/data-table-density.ts delete mode 100644 apps/web/src/components/data-table-pagination.tsx delete mode 100644 apps/web/src/components/data-table-toolbar.tsx delete mode 100644 apps/web/src/components/data-table-view.tsx create mode 100644 apps/web/src/components/dns-records-data-grid.tsx delete mode 100644 apps/web/src/components/dns-records-table.tsx rename apps/web/src/components/{domains-data-table.tsx => domains-data-grid.tsx} (53%) create mode 100644 apps/web/src/components/domains-filters-toolbar.tsx delete mode 100644 apps/web/src/components/kpi-card.tsx create mode 100644 apps/web/src/components/list-filters-bar.tsx create mode 100644 apps/web/src/components/mode-toggle.tsx create mode 100644 apps/web/src/components/reui/autocomplete.tsx create mode 100644 apps/web/src/components/reui/badge.tsx create mode 100644 apps/web/src/components/reui/color-picker.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-column-header.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-pagination.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-table.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid.tsx create mode 100644 apps/web/src/components/reui/date-selector.tsx create mode 100644 apps/web/src/components/reui/filters.tsx create mode 100644 apps/web/src/components/reui/number-field.tsx delete mode 100644 apps/web/src/components/section-cards-skeleton.tsx create mode 100644 apps/web/src/components/skeletons.tsx create mode 100644 apps/web/src/components/subdomains-data-grid.tsx delete mode 100644 apps/web/src/components/subdomains-table.tsx delete mode 100644 apps/web/src/components/table-skeleton.tsx create mode 100644 apps/web/src/components/truncated-text.tsx create mode 100644 packages/ui/src/components/button-group.tsx create mode 100644 packages/ui/src/components/calendar.tsx create mode 100644 packages/ui/src/components/kbd.tsx diff --git a/.agents/skills/reui/SKILL.md b/.agents/skills/reui/SKILL.md new file mode 100644 index 0000000..28ba04d --- /dev/null +++ b/.agents/skills/reui/SKILL.md @@ -0,0 +1,61 @@ +--- +name: reui +description: ReUI enterprise-компоненты в cloudflare-domain-manager — registry @reui, CLI, импорты, матрица выбора. Использовать при Data Grid, Filters и других @reui/* задачах. +--- + +# ReUI (cloudflare-domain-manager) + +## Источники + +1. [llms.txt](https://reui.io/llms.txt) — каталог для ИИ +2. MCP `plugin-shadcn-shadcn` с `registries: ["@reui"]` +3. Docs: `https://reui.io/docs/components/base/` (Copy Markdown) +4. Правила: `.cursor/rules/reui-mcp.mdc` + +## CLI (только из apps/web) + +```bash +cd apps/web +pnpm dlx shadcn@latest add @reui/data-grid --dry-run +pnpm dlx shadcn@latest add @reui/filters +``` + +## Размещение + +| Registry | Путь | Импорт | +|----------|------|--------| +| `@shadcn` | `packages/ui/src/components/` | `@cfdm/ui/components/*` | +| `@reui` | `apps/web/src/components/reui/` | `@/components/reui/*` | + +Только **Base UI** (`base-nova`). Radix-варианты не использовать. + +## Уже установлено + +`badge`, `data-grid/*`, `filters` (+ `autocomplete`, `date-selector`, `number-field`, `color-picker` при необходимости) + +## Shared-обёртки проекта + +| ReUI | Обёртка | +|------|---------| +| data-grid | `DataGridCard` | +| filters | `DomainsFiltersToolbar` | +| badge | `StatusBadge` | + +## Матрица выбора + +- Простая таблица → shadcn `Table` +- Списки с sort/pagination/virtual/columns → `DataGridCard` (`@reui/data-grid`) +- Мультифильтры → `@reui/filters` +- Semantic status → `StatusBadge` или `@reui/badge` variant + +## Зависимости (apps/web only) + +`@tanstack/react-table`, `@tanstack/react-virtual`, `@dnd-kit/*`, `date-fns`, `react-day-picker` + +После add — `pnpm install` + `pnpm --filter web build`. + +## Post-add + +- `@/components/ui/*` → `@cfdm/ui/components/*` +- Не класть ReUI в `packages/ui` +- Semantic colors: `variant="success"` — не `bg-emerald-*` diff --git a/.cursor/rules/frontend-shadcn.mdc b/.cursor/rules/frontend-shadcn.mdc index b44dae5..1825101 100644 --- a/.cursor/rules/frontend-shadcn.mdc +++ b/.cursor/rules/frontend-shadcn.mdc @@ -47,15 +47,14 @@ Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workfl | `EmptyState` | `empty-state.tsx` | | `QueryState` | `query-state.tsx` | | `ConfirmDialog` | `confirm-dialog.tsx` | -| `DataTableCard` | `data-table-card.tsx` | +| `DataGridCard` | `data-grid-card.tsx` | | `SectionCards` | `section-cards.tsx` | | `StatusBadge` | `status-badge.tsx` | | `FormSheet` | `form-sheet.tsx` | | `FormField` | `form-field.tsx` | | `TableCard` | `table-card.tsx` | | `LoadingButton` | `loading-button.tsx` | -| `SectionCardsSkeleton` | `section-cards-skeleton.tsx` | -| `TableSkeleton` | `table-skeleton.tsx` | +| `SectionCardsSkeleton` / `TableSkeleton` | `skeletons.tsx` | **Overlay:** Sheet — forms; AlertDialog — destructive confirm. diff --git a/.cursor/rules/frontend-ui-patterns.mdc b/.cursor/rules/frontend-ui-patterns.mdc index d259ada..bd07566 100644 --- a/.cursor/rules/frontend-ui-patterns.mdc +++ b/.cursor/rules/frontend-ui-patterns.mdc @@ -29,15 +29,14 @@ apps/web/src/components/ ← shared + domain + layout empty-state.tsx query-state.tsx confirm-dialog.tsx - data-table-card.tsx + data-grid-card.tsx section-cards.tsx status-badge.tsx form-sheet.tsx ← Sheet + RHF FormProvider form-field.tsx ← Field + Controller + aria-invalid table-card.tsx ← Card + Table wrapper loading-button.tsx ← Button + Spinner + label swap - section-cards-skeleton.tsx - table-skeleton.tsx + skeletons.tsx ← SectionCardsSkeleton, TableSkeleton layout/ ← app-shell, site-header domain-* ← бизнес-компоненты ``` @@ -49,7 +48,7 @@ apps/web/src/components/ ← shared + domain + layout | Page wrapper | `PageShell` | — | | Page title | `PageHeader` | — | | Stat metrics | `SectionCards` | `Card` | -| Data list | `DataTableCard` | `Table`, `InputGroup` | +| Data list | `DataGridCard` | ReUI `data-grid` | | Empty | `EmptyState` | `Empty` | | Loading / Error | `QueryState` | `Skeleton`, `Alert` | | Status | `StatusBadge` | `Badge` | diff --git a/.cursor/rules/reui-mcp.mdc b/.cursor/rules/reui-mcp.mdc new file mode 100644 index 0000000..06a8cd8 --- /dev/null +++ b/.cursor/rules/reui-mcp.mdc @@ -0,0 +1,109 @@ +--- +description: ReUI registry (@reui) — enterprise-компоненты, MCP workflow, зависимости, матрица выбора shadcn vs ReUI +alwaysApply: true +--- + +# ReUI MCP — обязательно для enterprise-компонентов + +ReUI — first-class shadcn registry с enterprise-компонентами (Data Grid, Filters, Stepper и др.). Проект использует **Base UI** (`style: base-nova`), не Radix. + +Связанные правила: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc). + +| Документ | URL | +|----------|-----| +| **llms.txt (карта для ИИ)** | https://reui.io/llms.txt | +| **Get Started** | https://reui.io/docs/get-started | +| **Styling (semantic tokens)** | https://reui.io/docs/styling | +| **MCP** | https://reui.io/docs/mcp | +| **Base UI components** | https://reui.io/docs/components/base/ | + +## Когда использовать ReUI vs shadcn + +| Задача | Registry | Импорт | +|--------|----------|--------| +| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@cfdm/ui/components/*` | +| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` | +| Data Grid (sort, pagination, virtual, columns) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` | +| Мультифильтры | `@reui` | `@/components/reui/filters` → `DomainsFiltersToolbar` | +| Number field со stepper | `@reui` | `@/components/reui/number-field` | +| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` | +| Date selector / range | `@reui` | `@/components/reui/date-selector` | +| Color picker (формы) | `@reui` | `@/components/reui/color-picker` — hex только в swatch data | +| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` | + +**Простые списки** — shadcn `Table`. **Сложные data-списки** — `DataGridCard` (ReUI data-grid), не shadcn Data Table. + +## Источники для ИИ (порядок) + +1. [ReUI llms.txt](https://reui.io/llms.txt) — полный каталог компонентов +2. MCP `plugin-shadcn-shadcn` с `registries: ["@reui"]` +3. Docs с **Copy Markdown**: `https://reui.io/docs/components/base/` +4. CLI dry-run: `cd apps/web && pnpm dlx shadcn@latest add @reui/ --dry-run` + +## MCP workflow (ветка ReUI) + +0. **Codegraph** `codegraph_explore` — найти `DataGridCard`, `DomainsFiltersToolbar` и существующие `@/components/reui/*` +1. **`get_project_registries`** — должен содержать `@reui` +2. **`search_items_in_registries`** с `registries: ["@reui"]`, `query`: `"data grid"`, `"filters"`, `"stepper"` и т.д. +3. **`get_item_examples_from_registries`** — полный код перед JSX +4. **`get_add_command_for_items`** — точная CLI-команда +5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc)) +6. **Docs:** ReUI `https://reui.io/docs/components/base/` + llms.txt — **не** ui.shadcn.com для `@reui/*` +7. Post-add: импорты shadcn-примитивов → `@cfdm/ui/components/*` +8. `pnpm install` + `pnpm --filter web build` + +## Размещение и импорты + +| Слой | Путь | Импорт | +|------|------|--------| +| shadcn primitives | `packages/ui/src/components/` | `@cfdm/ui/components/*` | +| ReUI enterprise | `apps/web/src/components/reui/` | `@/components/reui/*` | +| Проектные обёртки | `apps/web/src/components/` | `@/components/data-grid-card` и т.д. | + +```bash +cd apps/web +pnpm dlx shadcn@latest add @reui/data-grid +pnpm dlx shadcn@latest add @reui/filters +``` + +## Уже установленные ReUI-компоненты + +`badge`, `data-grid/*`, `filters` — в [`apps/web/src/components/reui/`](apps/web/src/components/reui/). + +Перед добавлением дубликата — проверить Codegraph и существующие обёртки. + +## Зависимости (только `apps/web`, не `packages/ui`) + +| npm-пакет | ReUI-компоненты | +|-----------|-----------------| +| `@tanstack/react-table` | data-grid | +| `@tanstack/react-virtual` | data-grid (virtual) | +| `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/modifiers`, `@dnd-kit/utilities` | data-grid dnd, sortable, kanban | +| `date-fns`, `react-day-picker` | date-selector | + +После `shadcn add @reui/...` — проверить, что CLI добавил недостающие deps в [`apps/web/package.json`](apps/web/package.json). + +## Semantic tokens (Styling) + +ReUI расширяет тему shadcn токенами `--success`, `--info`, `--warning`, `--destructive-foreground`, `--invert` — уже в [`packages/ui/src/styles/globals.css`](packages/ui/src/styles/globals.css). + +- Badge/Alert: `variant="success"` / `"info"` / `"warning"` — не `bg-emerald-*` +- Базовая тема: `pnpm dlx shadcn@latest apply b2fA --only theme` +- ReUI-токены: по [Styling guide](https://reui.io/docs/styling); не править `globals.css` вручную без сверки с docs + +## Запрещено + +- Копипаст с reui.io без CLI `add @reui/...` +- Класть ReUI в `packages/ui` или импортировать как `@cfdm/ui` +- Radix-варианты (`/docs/components/radix/...`) — только Base UI +- Raw Tailwind-цвета вместо ReUI semantic `variant` +- Писать data-grid/filters с нуля, если есть `@reui/*` и shared-обёртки + +## Чеклист + +- [ ] Задача требует ReUI? (см. матрицу выше) +- [ ] MCP search с `registries: ["@reui"]` + examples +- [ ] `shadcn add @reui/` из `apps/web` +- [ ] Импорты: `@cfdm/ui` для примитивов, `@/components/reui` для ReUI +- [ ] Зависимости в `apps/web/package.json` +- [ ] `pnpm --filter web build` без ошибок diff --git a/.cursor/rules/shadcn-ui-production.mdc b/.cursor/rules/shadcn-ui-production.mdc index dbe36f3..279c56b 100644 --- a/.cursor/rules/shadcn-ui-production.mdc +++ b/.cursor/rules/shadcn-ui-production.mdc @@ -20,7 +20,7 @@ UI строится **исключительно** по [shadcn/ui](https://ui.s - Monorepo: `apps/web` + `packages/ui` (`@cfdm/ui`), pnpm workspaces - Vite + TanStack Router/Query + shadcn **base-nova** - Конфиг: [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json) -- Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y` — единственный способ менять `packages/ui/src/styles/globals.css` +- Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y` — единственный способ менять базовую тему `globals.css`; ReUI semantic tokens — [Styling](https://reui.io/docs/styling) ## Backend → UI diff --git a/apps/web/components.json b/apps/web/components.json index 83a5742..f6ce184 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -12,6 +12,9 @@ }, "iconLibrary": "lucide", "rtl": false, + "registries": { + "@reui": "https://reui.io/r/{style}/{name}.json" + }, "aliases": { "components": "@/components", "utils": "@cfdm/ui/lib/utils", diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index 96da70d..e88a119 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -80,4 +80,25 @@ export default defineConfig([ 'no-restricted-syntax': 'off', }, }, + { + files: [ + 'src/components/reui/**/*.{ts,tsx}', + 'src/components/data-grid-card.tsx', + 'src/components/data-grid-types.ts', + 'src/components/data-grid-cells.tsx', + 'src/components/domains-filters-toolbar.tsx', + 'src/components/list-filters-bar.tsx', + ], + rules: { + 'react-refresh/only-export-components': 'off', + 'react-hooks/incompatible-library': 'off', + 'react-hooks/use-memo': 'off', + 'react-hooks/immutability': 'off', + 'react-hooks/exhaustive-deps': 'off', + 'no-restricted-syntax': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + 'prefer-const': 'off', + }, + }, ]) diff --git a/apps/web/package.json b/apps/web/package.json index 5d21dea..d27becd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@cfdm/shared": "workspace:*", "@cfdm/ui": "workspace:*", "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", @@ -22,12 +23,15 @@ "@tanstack/react-query": "^5.101.0", "@tanstack/react-router": "^1.170.15", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.4", "@tanstack/router-vite-plugin": "^1.167.18", "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", + "date-fns": "^4.4.0", "lucide-react": "^1.18.0", "next-themes": "^0.4.6", "react": "^19.2.6", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.6", "react-hook-form": "^7.79.0", "recharts": "^3.8.0", @@ -48,6 +52,7 @@ "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12", - "vitest": "^4.1.8" + "vitest": "^4.1.8", + "tw-animate-css": "^1.0.0" } } diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index d00f703..d118c5c 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -76,12 +76,15 @@ export function AppSidebar() { }> -
- +
+
-
- CF Domain Manager - Управление доменами +
+ CF Domain Manager + Управление доменами
diff --git a/apps/web/src/components/data-grid-card.tsx b/apps/web/src/components/data-grid-card.tsx new file mode 100644 index 0000000..5d4132d --- /dev/null +++ b/apps/web/src/components/data-grid-card.tsx @@ -0,0 +1,434 @@ +import { useState, useEffect, type ReactNode } from 'react' +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getPaginationRowModel, + flexRender, + type ColumnDef, + type SortingState, + type RowSelectionState, + type VisibilityState, + type OnChangeFn, +} from '@tanstack/react-table' +import { InboxIcon, Columns3Icon } from 'lucide-react' + +import { Checkbox } from '@cfdm/ui/components/checkbox' +import { Button } from '@cfdm/ui/components/button' +import { cn } from '@cfdm/ui/lib/utils' +import { + DataGrid, + DataGridContainer, +} from '@/components/reui/data-grid/data-grid' +import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual' +import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' +import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility' +import { EmptyState } from './empty-state' +import type { DataGridColumn } from './data-grid-types' + +const PAGINATION_LABELS = { + rowsPerPageLabel: 'Строк на странице', + info: '{from}–{to} из {count}', + previousPageLabel: 'Предыдущая страница', + nextPageLabel: 'Следующая страница', + pageLabel: 'Страница {page}', + previousPagesLabel: 'Предыдущие страницы', + nextPagesLabel: 'Следующие страницы', +} as const + +function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string { + if (headerTitle) return headerTitle + if (typeof header === 'string') return header + return '' +} + +function loadStoredColumnVisibility(key: string): VisibilityState | undefined { + try { + const raw = localStorage.getItem(key) + if (!raw) return undefined + return JSON.parse(raw) as VisibilityState + } catch { + return undefined + } +} + +export { loadStoredColumnVisibility } + +export interface DataGridColumnVisibilityOption { + id: string + label: string +} + +export function dataGridColumnVisibilityOptions( + cols: DataGridColumn[], +): DataGridColumnVisibilityOption[] { + return cols + .filter((c) => c.enableHiding !== false) + .map((c) => ({ + id: c.key, + label: c.headerTitle ?? (typeof c.header === 'string' ? c.header : c.key), + })) +} + +export interface DataGridCardProps { + title?: ReactNode + description?: ReactNode + actions?: ReactNode + columns: ColumnDef[] + data: TData[] + /** Ключ строки — функция, возвращающая уникальный id. */ + rowId?: (row: TData, index: number) => string + emptyTitle?: string + emptyDescription?: string + emptyAction?: ReactNode + onRowClick?: (row: TData) => void + /** Включить пагинацию. По умолчанию true. */ + pagination?: boolean + /** Размер страницы. По умолчанию 10. */ + pageSize?: number + /** Footer-контент (итоги). */ + footerContent?: ReactNode + /** Плотный layout. */ + dense?: boolean + /** Колонка действий закреплена справа. */ + pinLastColumn?: boolean + /** Сортировка по умолчанию. */ + initialSorting?: SortingState + /** Включить виртуализацию строк (для тяжёлых таблиц). Требует height. */ + virtualization?: boolean + /** Высота viewport для виртуализации (px). По умолчанию 480. */ + height?: number + /** Включить выбор строк (чекбоксы). */ + enableRowSelection?: boolean + /** Callback при изменении выбора. */ + onRowSelectionChange?: (selectedIds: string[]) => void + /** Показать picker видимости колонок. */ + enableColumnVisibility?: boolean + /** Управляемая видимость колонок (для внешнего UI, напр. тулбар «Вид»). */ + columnVisibility?: VisibilityState + onColumnVisibilityChange?: OnChangeFn + /** Показать встроенную кнопку «Колонки». По умолчанию true при enableColumnVisibility. */ + columnVisibilityTrigger?: boolean + /** Ключ localStorage для сохранения видимости колонок. */ + columnVisibilityStorageKey?: string + /** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */ + initialColumnVisibility?: VisibilityState + className?: string +} + +function DataGridSectionHeader({ + title, + description, + actions, +}: { + title?: ReactNode + description?: ReactNode + actions?: ReactNode +}) { + if (!title && !description && !actions) return null + + return ( +
+
+ {title ?

{title}

: null} + {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ) +} + +function DataGridPaginationBar() { + return ( +
+ +
+ ) +} + +function DataGridCardBody({ + table, + data, + emptyTitle, + onRowClick, + dense, + virtualization, + height, + footerContent, + showPagination, + enableColumnVisibility, +}: { + table: ReturnType> + data: TData[] + emptyTitle: string + onRowClick?: (row: TData) => void + dense: boolean + virtualization: boolean + height: number + footerContent?: ReactNode + showPagination: boolean + enableColumnVisibility: boolean +}) { + return ( + + + {virtualization ? ( + + + + ) : ( + + )} + + {showPagination ? : null} + + ) +} + +export function DataGridCard({ + title, + description, + actions, + columns, + data, + rowId, + emptyTitle = 'Нет записей', + emptyDescription, + emptyAction, + onRowClick, + pagination, + pageSize = 10, + footerContent, + dense = true, + pinLastColumn = false, + initialSorting, + virtualization = false, + height = 480, + enableRowSelection = false, + onRowSelectionChange, + enableColumnVisibility = false, + columnVisibility: columnVisibilityProp, + onColumnVisibilityChange, + columnVisibilityTrigger, + columnVisibilityStorageKey, + initialColumnVisibility, + className, +}: DataGridCardProps) { + const [sorting, setSorting] = useState(initialSorting ?? []) + const [rowSelection, setRowSelection] = useState({}) + const [internalColumnVisibility, setInternalColumnVisibility] = useState(() => { + const stored = columnVisibilityStorageKey + ? loadStoredColumnVisibility(columnVisibilityStorageKey) + : undefined + return { ...initialColumnVisibility, ...stored } + }) + + const isColumnVisibilityControlled = columnVisibilityProp !== undefined + const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility + const setColumnVisibility: OnChangeFn = isColumnVisibilityControlled + ? (onColumnVisibilityChange ?? (() => undefined)) + : setInternalColumnVisibility + + useEffect(() => { + if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return + localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility)) + }, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled]) + + const selectColumn: ColumnDef = { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Выбрать все" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Выбрать строку" + onClick={(e) => e.stopPropagation()} + /> + ), + enableSorting: false, + enableHiding: false, + meta: { cellClassName: 'w-10' }, + } + + const tableColumns = enableRowSelection ? [selectColumn, ...columns] : columns + + const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : '' + + const showPagination = pagination ?? true + + const table = useReactTable({ + data, + columns: tableColumns, + state: { + sorting, + columnVisibility, + ...(enableRowSelection ? { rowSelection } : {}), + }, + onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: enableRowSelection + ? (updater) => { + setRowSelection((prev) => { + const next = typeof updater === 'function' ? updater(prev) : updater + if (onRowSelectionChange && rowId) { + const ids = Object.keys(next).filter((k) => next[k]) + onRowSelectionChange(ids) + } + return next + }) + } + : undefined, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined, + initialState: { + ...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}), + ...(pinLastColumn && lastColId ? { columnPinning: { right: [lastColId] } } : {}), + }, + getRowId: rowId + ? (row, index) => rowId(row, index) + : undefined, + enableColumnPinning: pinLastColumn, + enableRowSelection, + enableHiding: enableColumnVisibility, + }) + + const showColumnVisibilityTrigger = + enableColumnVisibility && (columnVisibilityTrigger ?? true) + + const columnVisibilityAction = showColumnVisibilityTrigger ? ( + + + Колонки + + } + /> + ) : null + + const headerActions = actions ? ( +
+ {columnVisibilityAction} + {actions} +
+ ) : ( + columnVisibilityAction + ) + + const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger) + + if (data.length === 0) { + return ( +
+ {hasHeader ? ( + + ) : null} + +
+ ) + } + + const gridBody = ( + + ) + + return ( +
+ {hasHeader ? ( + + ) : null} + {gridBody} +
+ ) +} + +/** Хелпер для конвертации DataGridColumn → ColumnDef с DataGridColumnHeader. */ +export function columnDefFromDataGrid( + cols: DataGridColumn[], +): ColumnDef[] { + return cols.map((c) => { + const title = resolveHeaderTitle(c.header, c.headerTitle) + const Icon = c.icon + const sortable = c.sortable ?? Boolean(Icon) + + return { + id: c.key, + ...(sortable + ? { + accessorFn: c.sortValue + ? (row: T) => c.sortValue!(row) + : (row: T) => (row as Record)[c.key] as string | number, + } + : {}), + header: Icon + ? ({ column }) => ( + } + /> + ) + : () => c.header, + cell: ({ row }) => c.cell(row.original, row.index), + enableSorting: sortable, + enableHiding: c.enableHiding ?? true, + meta: { + headerTitle: title || undefined, + cellClassName: c.className, + headerClassName: c.headerClassName, + }, + } + }) +} + +/** @deprecated Используйте columnDefFromDataGrid */ +export const columnDefFromDataTable = columnDefFromDataGrid + +/** re-export flexRender для удобства использования в колонках. */ +export { flexRender } diff --git a/apps/web/src/components/data-grid-cells.tsx b/apps/web/src/components/data-grid-cells.tsx new file mode 100644 index 0000000..8870f85 --- /dev/null +++ b/apps/web/src/components/data-grid-cells.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react' + +import { cn } from '@cfdm/ui/lib/utils' +import { TruncatedText } from '@/components/truncated-text' + +export function dataGridCellStack( + primary: ReactNode, + secondary?: ReactNode, + className?: string, +) { + return ( +
+ {typeof primary === 'string' || typeof primary === 'number' ? ( + {primary} + ) : ( + {primary} + )} + {secondary ? ( + typeof secondary === 'string' || typeof secondary === 'number' ? ( + {secondary} + ) : ( + {secondary} + ) + ) : null} +
+ ) +} + +export function dataGridCellWithIcon( + icon: ReactNode, + children: ReactNode, + className?: string, +) { + return ( +
+ {icon} + {children} +
+ ) +} + +export function dataGridCellWithFlag( + flag: ReactNode, + primary: ReactNode, + secondary?: ReactNode, +) { + return ( +
+ {flag} + {secondary ? dataGridCellStack(primary, secondary) : {primary}} +
+ ) +} diff --git a/apps/web/src/components/data-grid-types.ts b/apps/web/src/components/data-grid-types.ts new file mode 100644 index 0000000..f1a98a4 --- /dev/null +++ b/apps/web/src/components/data-grid-types.ts @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' +import type { LucideIcon } from 'lucide-react' + +export interface DataGridColumn { + key: string + header: ReactNode + cell: (row: T, index: number) => ReactNode + icon?: LucideIcon + sortable?: boolean + sortValue?: (row: T) => string | number + headerTitle?: string + className?: string + headerClassName?: string + enableHiding?: boolean +} + +/** @deprecated Используйте DataGridColumn */ +export type DataTableColumn = DataGridColumn + +/** Унифицированные классы колонок для DataGridCard. */ +export const COL = { + num: 'w-28 text-right tabular-nums', + date: 'w-32 text-right tabular-nums text-muted-foreground', + actions: 'w-24 text-right', +} as const diff --git a/apps/web/src/components/data-table-card.tsx b/apps/web/src/components/data-table-card.tsx deleted file mode 100644 index 1413f48..0000000 --- a/apps/web/src/components/data-table-card.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { InboxIcon, type LucideIcon } from 'lucide-react' -import { EmptyState } from '@/components/empty-state' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@cfdm/ui/components/card' - -interface DataTableCardProps { - title: string - description?: string - children: React.ReactNode - toolbar?: React.ReactNode - emptyTitle?: string - emptyDescription?: string - emptyIcon?: LucideIcon - emptyAction?: React.ReactNode - isEmpty?: boolean -} - -export function DataTableCard({ - title, - description, - children, - toolbar, - emptyTitle, - emptyDescription, - emptyIcon: EmptyIcon = InboxIcon, - emptyAction, - isEmpty, -}: DataTableCardProps) { - return ( - - - {title} - {description && {description}} - - {toolbar && !isEmpty && ( -
{toolbar}
- )} - - {isEmpty && emptyTitle ? ( - - ) : ( - children - )} - -
- ) -} diff --git a/apps/web/src/components/data-table-density.ts b/apps/web/src/components/data-table-density.ts deleted file mode 100644 index 0acf344..0000000 --- a/apps/web/src/components/data-table-density.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type Density = 'compact' | 'comfortable' | 'spacious' - -export const densityRowClass: Record = { - compact: '[&_tr]:h-9 [&_tr_td]:py-1 [&_tr_th]:py-1 text-xs', - comfortable: '[&_tr]:h-10 [&_tr_td]:py-2 [&_tr_th]:py-2 text-sm', - spacious: '[&_tr]:h-12 [&_tr_td]:py-3 [&_tr_th]:py-3 text-sm', -} - -export const densityLabel: Record = { - compact: 'Компактная', - comfortable: 'Обычная', - spacious: 'Просторная', -} - -export const pageSizeOptions = [10, 20, 50] as const diff --git a/apps/web/src/components/data-table-pagination.tsx b/apps/web/src/components/data-table-pagination.tsx deleted file mode 100644 index 0d072f2..0000000 --- a/apps/web/src/components/data-table-pagination.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import type { Table } from '@tanstack/react-table' -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from '@cfdm/ui/components/pagination' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@cfdm/ui/components/select' -import { pageSizeOptions } from '@/components/data-table-density' - -interface DataTablePaginationProps { - table: Table - selectedCount?: number - pageSizeLabel?: string -} - -function buildPageRange(current: number, total: number): (number | 'ellipsis')[] { - if (total <= 7) { - return Array.from({ length: total }, (_, i) => i + 1) - } - const pages: (number | 'ellipsis')[] = [1] - const start = Math.max(2, current - 1) - const end = Math.min(total - 1, current + 1) - if (start > 2) pages.push('ellipsis') - for (let i = start; i <= end; i++) pages.push(i) - if (end < total - 1) pages.push('ellipsis') - pages.push(total) - return pages -} - -export function DataTablePagination({ - table, - selectedCount, - pageSizeLabel = 'Строк', -}: DataTablePaginationProps) { - const { pageIndex, pageSize } = table.getState().pagination - const pageCount = table.getPageCount() - - const total = table.getFilteredRowModel().rows.length - const from = total === 0 ? 0 : pageIndex * pageSize + 1 - const to = Math.min((pageIndex + 1) * pageSize, total) - - return ( -
-
- {selectedCount != null && selectedCount > 0 - ? `Выбрано: ${selectedCount} из ${total}` - : `${from}–${to} из ${total}`} -
-
-
- {pageSizeLabel} - -
- {pageCount > 1 && ( - - - - table.previousPage()} - aria-disabled={!table.getCanPreviousPage()} - className={!table.getCanPreviousPage() ? 'pointer-events-none opacity-50' : undefined} - /> - - {buildPageRange(pageIndex + 1, pageCount).map((page, idx) => - page === 'ellipsis' ? ( - - - - ) : ( - - table.setPageIndex(page - 1)} - > - {page} - - - ), - )} - - table.nextPage()} - aria-disabled={!table.getCanNextPage()} - className={!table.getCanNextPage() ? 'pointer-events-none opacity-50' : undefined} - /> - - - - )} -
-
- ) -} diff --git a/apps/web/src/components/data-table-toolbar.tsx b/apps/web/src/components/data-table-toolbar.tsx deleted file mode 100644 index 918ed51..0000000 --- a/apps/web/src/components/data-table-toolbar.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import type { ReactNode } from 'react' -import type { Table } from '@tanstack/react-table' -import { ColumnsIcon, SearchIcon } from 'lucide-react' -import { - InputGroup, - InputGroupAddon, - InputGroupInput, -} from '@cfdm/ui/components/input-group' -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from '@cfdm/ui/components/dropdown-menu' -import { AppButton } from '@/components/app-button' -import { - densityLabel, - type Density, -} from '@/components/data-table-density' - -interface DataTableToolbarProps { - table: Table - searchPlaceholder?: string - searchValue?: string - onSearchChange?: (value: string) => void - filters?: ReactNode - density: Density - onDensityChange: (density: Density) => void -} - -export function DataTableToolbar({ - table, - searchPlaceholder = 'Поиск…', - searchValue, - onSearchChange, - filters, - density, - onDensityChange, -}: DataTableToolbarProps) { - const columns = table - .getAllColumns() - .filter((col) => typeof col.getCanHide === 'function' && col.getCanHide()) - - return ( -
-
- {onSearchChange && ( - - - - - onSearchChange(event.target.value)} - /> - - )} - {filters} -
-
- - } - > - - Колонки - - - Видимость - {columns.map((column) => ( - column.toggleVisibility(!!value)} - > - {typeof column.columnDef.header === 'string' - ? column.columnDef.header - : column.id} - - ))} - - - - } - > - Плотность - - - Плотность строк - onDensityChange(val as Density)} - > - {(Object.keys(densityLabel) as Density[]).map((d) => ( - - {densityLabel[d]} - - ))} - - - -
-
- ) -} diff --git a/apps/web/src/components/data-table-view.tsx b/apps/web/src/components/data-table-view.tsx deleted file mode 100644 index 02d2081..0000000 --- a/apps/web/src/components/data-table-view.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import type { ReactNode } from 'react' -import { flexRender } from '@tanstack/react-table' -import type { Table as TableType } from '@tanstack/react-table' -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyTitle, -} from '@cfdm/ui/components/empty' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@cfdm/ui/components/table' -import { cn } from '@cfdm/ui/lib/utils' -import { DataTablePagination } from '@/components/data-table-pagination' -import { DataTableToolbar } from '@/components/data-table-toolbar' -import { densityRowClass, type Density } from '@/components/data-table-density' - -interface DataTableViewProps { - table: TableType - density: Density - onDensityChange: (density: Density) => void - searchPlaceholder?: string - searchValue?: string - onSearchChange?: (value: string) => void - filters?: ReactNode - emptyTitle?: string - emptyDescription?: string - selectedCount?: number - className?: string -} - -export function DataTableView({ - table, - density, - onDensityChange, - searchPlaceholder, - searchValue, - onSearchChange, - filters, - emptyTitle = 'Ничего не найдено', - emptyDescription = 'Измените фильтры или поисковый запрос', - selectedCount, - className, -}: DataTableViewProps) { - const columns = table.getAllColumns() - const showToolbar = onSearchChange || filters || columns.some((c) => c.getCanHide?.()) - - return ( -
- {showToolbar && ( - - )} -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - - - {emptyTitle} - {emptyDescription} - - - - - )} - -
-
-
- -
- ) -} diff --git a/apps/web/src/components/dns-records-data-grid.tsx b/apps/web/src/components/dns-records-data-grid.tsx new file mode 100644 index 0000000..808ab37 --- /dev/null +++ b/apps/web/src/components/dns-records-data-grid.tsx @@ -0,0 +1,188 @@ +import { useMemo, useState } from 'react' +import { cn } from '@cfdm/ui/lib/utils' +import { Button } from '@cfdm/ui/components/button' +import { Badge } from '@cfdm/ui/components/badge' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@cfdm/ui/components/tooltip' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { + DataGridCard, + columnDefFromDataGrid, +} from '@/components/data-grid-card' +import type { DataGridColumn } from '@/components/data-grid-types' +import { ListFiltersBar } from '@/components/list-filters-bar' +import { StatusBadge } from '@/components/status-badge' +import type { DnsRecord, IpHealthStatus } from '@/lib/schemas' +import { formatDate } from '@/lib/format' + +interface DnsRecordsDataGridProps { + records: DnsRecord[] + onDelete: (recordId: number) => void + isDeleting?: boolean + healthByIp?: Record + search?: string + onSearchChange?: (value: string) => void + hideSearch?: boolean +} + +const healthDotClass: Record = { + up: 'bg-success', + degraded: 'bg-warning', + down: 'bg-destructive', + unknown: 'bg-muted-foreground/40', +} + +const healthLabel: Record = { + up: 'OK', + degraded: 'Деград.', + down: 'Down', + unknown: '—', +} + +function IpHealthDot({ health }: { health: IpHealthStatus }) { + const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`] + if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`) + if (health.last_checked_at) tooltipParts.push(`Проверка: ${formatDate(health.last_checked_at)}`) + if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`) + return ( + + + + } + /> + {tooltipParts.join('\n')} + + + ) +} + +export function DnsRecordsDataGrid({ + records, + onDelete, + isDeleting = false, + healthByIp, + search: controlledSearch, + onSearchChange, + hideSearch = false, +}: DnsRecordsDataGridProps) { + const [internalSearch, setInternalSearch] = useState('') + const search = controlledSearch ?? internalSearch + const setSearch = onSearchChange ?? setInternalSearch + + const filteredRecords = useMemo(() => { + const query = search.trim().toLowerCase() + if (!query) return records + return records.filter( + (r) => + r.name.toLowerCase().includes(query) || + r.content.toLowerCase().includes(query) || + r.record_type.toLowerCase().includes(query), + ) + }, [records, search]) + + const columns = useMemo[]>( + () => [ + { + key: 'record_type', + header: 'Тип', + sortable: true, + sortValue: (row) => row.record_type, + cell: (row) => {row.record_type}, + }, + { + key: 'name', + header: 'Имя', + sortable: true, + sortValue: (row) => row.name, + cell: (row) => {row.name}, + }, + { + key: 'content', + header: 'Значение', + sortable: true, + sortValue: (row) => row.content, + cell: (row) => { + const health = healthByIp?.[row.content] + return ( +
+ {health ? : null} + {row.content} +
+ ) + }, + }, + { + key: 'ttl', + header: 'TTL', + sortable: true, + sortValue: (row) => row.ttl, + className: 'tabular-nums', + cell: (row) => row.ttl, + }, + { + key: 'sync_status', + header: 'Синхронизация', + cell: (row) => , + }, + { + key: 'actions', + header: '', + enableHiding: false, + className: 'text-right', + cell: (row) => ( + + Удалить + + } + title="Удалить DNS-запись?" + description={`Запись ${row.name} (${row.record_type}) будет удалена из зоны.`} + onConfirm={() => onDelete(row.id)} + /> + ), + }, + ], + [healthByIp, isDeleting, onDelete], + ) + + return ( + String(row.id)} + emptyTitle="DNS-записи не найдены" + emptyDescription="Создайте запись или измените фильтры" + pinLastColumn + actions={ + hideSearch ? undefined : ( + setSearch('')} + /> + ) + } + /> + ) +} diff --git a/apps/web/src/components/dns-records-table.tsx b/apps/web/src/components/dns-records-table.tsx deleted file mode 100644 index 437588c..0000000 --- a/apps/web/src/components/dns-records-table.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { StatusBadge } from '@/components/status-badge' -import { ConfirmDialog } from '@/components/confirm-dialog' -import { TableCard } from '@/components/table-card' -import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping' -import type { DnsRecord, IpHealthStatus } from '@/lib/schemas' -import { formatDate } from '@/lib/format' -import { AppBadge } from '@/components/app-badge' -import { AppButton } from '@/components/app-button' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@cfdm/ui/components/tooltip' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@cfdm/ui/components/table' -import { cn } from '@cfdm/ui/lib/utils' - -interface DnsRecordsTableProps { - records: DnsRecord[] - onDelete: (recordId: number) => void - isDeleting?: boolean - healthByIp?: Record -} - -const healthDotClass: Record = { - up: 'bg-emerald-500', - degraded: 'bg-amber-500', - down: 'bg-rose-500', - unknown: 'bg-muted-foreground/40', -} - -const healthLabel: Record = { - up: 'OK', - degraded: 'Деград.', - down: 'Down', - unknown: '—', -} - -function IpHealthDot({ health }: { health: IpHealthStatus }) { - const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`] - if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`) - if (health.last_checked_at) tooltipParts.push(`Проверка: ${formatDate(health.last_checked_at)}`) - if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`) - return ( - - - - } - /> - {tooltipParts.join('\n')} - - - ) -} - -function DnsRecordCells({ - record, - onDelete, - isDeleting, - healthByIp, -}: { - record: DnsRecord - onDelete: (recordId: number) => void - isDeleting?: boolean - healthByIp?: Record -}) { - const health = healthByIp?.[record.content] - return ( - <> - -
- {health ? : null} - {record.content} -
-
- {record.ttl} - - - - - - Удалить - - } - title="Удалить DNS-запись?" - description={`Запись ${record.name} (${record.record_type}) будет удалена из зоны.`} - onConfirm={() => onDelete(record.id)} - /> - - - ) -} - -function SingleRecordRow({ - record, - onDelete, - isDeleting, - isFirstGroup, - healthByIp, -}: { - record: DnsRecord - onDelete: (recordId: number) => void - isDeleting?: boolean - isFirstGroup: boolean - healthByIp?: Record -}) { - return ( - - {record.record_type} - {record.name} - - - ) -} - -function MultiValueGroupRows({ - group, - onDelete, - isDeleting, - isFirstGroup, - healthByIp, -}: { - group: DnsRecordGroup - onDelete: (recordId: number) => void - isDeleting?: boolean - isFirstGroup: boolean - healthByIp?: Record -}) { - const [first, ...rest] = group.records - - return ( - <> - - - {group.recordType} - - -
- {group.name} - - {group.records.length} адресов - -
-
- -
- {rest.map((record) => ( - - - - ))} - - ) -} - -export function DnsRecordsTable({ - records, - onDelete, - isDeleting, - healthByIp, -}: DnsRecordsTableProps) { - const groups = groupDnsRecords(records) - - return ( - -
- - - - Тип - Имя - Значение - TTL - Синхронизация - - - - - {groups.map((group, index) => - group.isMultiValue ? ( - - ) : ( - - ), - )} - -
-
-
- ) -} diff --git a/apps/web/src/components/domains-data-table.tsx b/apps/web/src/components/domains-data-grid.tsx similarity index 53% rename from apps/web/src/components/domains-data-table.tsx rename to apps/web/src/components/domains-data-grid.tsx index 8f0cd8e..1a6b407 100644 --- a/apps/web/src/components/domains-data-table.tsx +++ b/apps/web/src/components/domains-data-grid.tsx @@ -1,22 +1,9 @@ -import { useMemo, useState } from 'react' +import { useMemo } from 'react' import { Link } from '@tanstack/react-router' -import { - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, - type ColumnDef, - type SortingState, -} from '@tanstack/react-table' -import { ArrowUpDownIcon, MoreHorizontalIcon } from 'lucide-react' -import { ConfirmDialog } from '@/components/confirm-dialog' -import { StatusBadge } from '@/components/status-badge' -import type { DomainListItem } from '@/lib/schemas' -import { AppBadge } from '@/components/app-badge' -import { AppButton } from '@/components/app-button' -import { DataTableView } from '@/components/data-table-view' -import type { Density } from '@/components/data-table-density' +import { GlobeIcon, MoreHorizontalIcon } from 'lucide-react' + +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' import { DropdownMenu, DropdownMenuContent, @@ -30,6 +17,16 @@ import { SelectTrigger, SelectValue, } from '@cfdm/ui/components/select' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { + DataGridCard, + columnDefFromDataGrid, +} from '@/components/data-grid-card' +import type { DataGridColumn } from '@/components/data-grid-types' +import { DomainsFiltersToolbar } from '@/components/domains-filters-toolbar' +import { StatusBadge } from '@/components/status-badge' +import type { DomainListItem } from '@/lib/schemas' +import { useState } from 'react' export type DomainTableRow = DomainListItem @@ -38,123 +35,130 @@ interface GroupFilterItem { value: string } -interface DomainsDataTableProps { +export interface DomainsDataGridProps { data: DomainTableRow[] + totalCount: number groupFilterItems: GroupFilterItem[] groupFilterValue: string onGroupFilterChange: (value: string | null) => void + search: string + onSearchChange: (value: string) => void onDelete: (domain: DomainTableRow) => void isDeleting?: boolean + emptyAction?: React.ReactNode } -export function DomainsDataTable({ +export function DomainsDataGrid({ data, + totalCount, groupFilterItems, groupFilterValue, onGroupFilterChange, + search, + onSearchChange, onDelete, isDeleting = false, -}: DomainsDataTableProps) { - const [sorting, setSorting] = useState([]) - const [globalFilter, setGlobalFilter] = useState('') - const [density, setDensity] = useState('comfortable') + emptyAction, +}: DomainsDataGridProps) { const [deleteTarget, setDeleteTarget] = useState(null) - const columns = useMemo[]>( + const filteredData = useMemo(() => { + const query = search.trim().toLowerCase() + if (!query) return data + return data.filter( + (d) => + d.zone_name.toLowerCase().includes(query) || + (d.group_name ?? '').toLowerCase().includes(query), + ) + }, [data, search]) + + const columns = useMemo[]>( () => [ { - accessorKey: 'zone_name', - header: ({ column }) => ( - column.toggleSorting(column.getIsSorted() === 'asc')} - > - Зона - - - ), - cell: ({ row }) => ( - row.zone_name, + cell: (row) => ( + ), }, { - id: 'group', - accessorFn: (row) => row.group_name ?? 'Без группы', + key: 'group', header: 'Группа', - cell: ({ row }) => { - const domain = row.original - if (domain.group_id && domain.group_name) { + sortable: true, + sortValue: (row) => row.group_name ?? 'Без группы', + cell: (row) => { + if (row.group_id && row.group_name) { return ( - } > - {domain.group_name} - + {row.group_name} + ) } - return Без группы + return Без группы }, }, { - accessorKey: 'service_count', + key: 'service_count', header: 'Сервисы', - cell: ({ row }) => ( - row.service_count, + className: 'tabular-nums', + cell: (row) => ( + ), }, { - accessorKey: 'status', + key: 'status', header: 'Статус', - cell: ({ row }) => , + cell: (row) => , }, { - accessorKey: 'last_synced_at', + key: 'last_synced_at', header: 'Синхронизация', - cell: ({ row }) => ( - - {row.original.last_synced_at ?? '—'} - - ), + sortable: true, + sortValue: (row) => row.last_synced_at ?? '', + className: 'text-muted-foreground tabular-nums', + cell: (row) => row.last_synced_at ?? '—', }, { - id: 'actions', + key: 'actions', + header: '', enableHiding: false, - header: () => Действия, - cell: ({ row }) => ( + className: 'text-right', + cell: (row) => (
- } + render={ + + ))} +
+ ) +} + +export function FilterResultsCount({ + shown, + total, + suffix, +}: { + shown: number + total: number + suffix?: ReactNode +}) { + return ( +
+ + Показано {shown} из {total} + + {suffix ? {suffix} : null} +
+ ) +} + +export function FilterResetButton({ onClick, visible }: { onClick: () => void; visible: boolean }) { + if (!visible) return null + return ( + + ) +} + +interface FilterToggleChipProps { + label: string + active: boolean + onClick: () => void +} + +export function FilterToggleChip({ label, active, onClick }: FilterToggleChipProps) { + return ( + + ) +} + +interface ListFiltersBarProps { + search?: ListFiltersSearchProps + controls?: ReactNode + chips?: FilterChip[] + shown?: number + total?: number + resultsSuffix?: ReactNode + onReset?: () => void + showReset?: boolean + toggles?: ReactNode +} + +export function ListFiltersBar({ + search, + controls, + chips, + shown, + total, + resultsSuffix, + onReset, + showReset, + toggles, +}: ListFiltersBarProps) { + const hasMeta = + chips?.length || + (shown != null && total != null) || + resultsSuffix || + (showReset && onReset) + + return ( +
+ {search ? : null} + {controls || toggles ? ( +
+ {controls} + {toggles} + {onReset ? : null} +
+ ) : null} + {hasMeta ? ( +
+ {chips?.length ? : null} + {shown != null && total != null ? ( + + ) : resultsSuffix ? ( +
{resultsSuffix}
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/apps/web/src/components/mode-toggle.tsx b/apps/web/src/components/mode-toggle.tsx new file mode 100644 index 0000000..b7059fc --- /dev/null +++ b/apps/web/src/components/mode-toggle.tsx @@ -0,0 +1,29 @@ +import { Moon, Sun } from 'lucide-react' +import { useTheme } from 'next-themes' + +import { Button } from '@cfdm/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@cfdm/ui/components/dropdown-menu' + +export function ModeToggle() { + const { setTheme } = useTheme() + + return ( + + }> + + + Сменить тему + + + setTheme('light')}>Светлая + setTheme('dark')}>Тёмная + setTheme('system')}>Системная + + + ) +} diff --git a/apps/web/src/components/page-shell.tsx b/apps/web/src/components/page-shell.tsx index 97297c6..da746c7 100644 --- a/apps/web/src/components/page-shell.tsx +++ b/apps/web/src/components/page-shell.tsx @@ -7,14 +7,5 @@ interface PageShellProps { } export function PageShell({ children, className }: PageShellProps) { - return ( -
- {children} -
- ) + return
{children}
} diff --git a/apps/web/src/components/reui/autocomplete.tsx b/apps/web/src/components/reui/autocomplete.tsx new file mode 100644 index 0000000..76710af --- /dev/null +++ b/apps/web/src/components/reui/autocomplete.tsx @@ -0,0 +1,343 @@ +"use client" + +import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@cfdm/ui/lib/utils" +import { ScrollArea } from "@cfdm/ui/components/scroll-area" +import { XIcon, ChevronsUpDownIcon } from "lucide-react" + +const inputVariants = cva( + "outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3", + { + variants: { + size: { + sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5", + default: + "h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75", + lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2", + }, + }, + defaultVariants: { + size: "default", + }, + } +) + +const Autocomplete = AutocompletePrimitive.Root + +function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) { + return ( + + ) +} + +function AutocompleteInput({ + className, + size = "default", + showClear = false, + showTrigger = false, + ...props +}: Omit & + VariantProps & { + showClear?: boolean + showTrigger?: boolean + }) { + return ( +
+ + {showTrigger && } + {showClear && } +
+ ) +} + +function AutocompleteStatus({ + className, + ...props +}: AutocompletePrimitive.Status.Props) { + return ( + + ) +} + +function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) { + return ( + + ) +} + +function AutocompleteBackdrop({ + ...props +}: AutocompletePrimitive.Backdrop.Props) { + return ( + + ) +} + +function AutocompletePositioner({ + className, + ...props +}: AutocompletePrimitive.Positioner.Props) { + return ( + + ) +} + +function AutocompleteList({ + className, + scrollAreaClassName, + ...props +}: AutocompletePrimitive.List.Props & { + scrollAreaClassName?: string + scrollFade?: boolean + scrollbarGutter?: boolean +}) { + return ( + + + + ) +} + +function AutocompleteCollection({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteRow({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export interface AutocompleteContentProps extends React.ComponentProps< + typeof AutocompletePrimitive.Popup +> { + align?: AutocompletePrimitive.Positioner.Props["align"] + sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"] + alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"] + side?: AutocompletePrimitive.Positioner.Props["side"] + anchor?: AutocompletePrimitive.Positioner.Props["anchor"] + showBackdrop?: boolean +} + +function AutocompleteContent({ + className, + children, + showBackdrop = false, + align = "start", + sideOffset = 4, + alignOffset = 0, + side = "bottom", + anchor, + ...props +}: AutocompleteContentProps) { + return ( + + {showBackdrop && } + +
+ + {children} + +
+
+
+ ) +} + +function AutocompleteGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteGroupLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteEmpty({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteClear({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function AutocompleteTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function AutocompleteArrow({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Autocomplete, + AutocompleteValue, + AutocompleteTrigger, + AutocompleteInput, + AutocompleteStatus, + AutocompletePortal, + AutocompleteBackdrop, + AutocompletePositioner, + AutocompleteContent, + AutocompleteList, + AutocompleteCollection, + AutocompleteRow, + AutocompleteItem, + AutocompleteGroup, + AutocompleteGroupLabel, + AutocompleteEmpty, + AutocompleteClear, + AutocompleteArrow, + AutocompleteSeparator, +} \ No newline at end of file diff --git a/apps/web/src/components/reui/badge.tsx b/apps/web/src/components/reui/badge.tsx new file mode 100644 index 0000000..670bcf6 --- /dev/null +++ b/apps/web/src/components/reui/badge.tsx @@ -0,0 +1,98 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@cfdm/ui/lib/utils" + +const badgeVariants = cva( + "relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground", + outline: "border-border bg-transparent dark:bg-input/32", + secondary: "bg-secondary text-secondary-foreground", + info: "bg-info text-white", + success: "bg-success text-white", + warning: "bg-warning text-white", + destructive: "bg-destructive text-white", + focus: "bg-focus text-focus-foreground", + invert: "bg-invert text-invert-foreground", + "primary-light": + "bg-primary/10 border-none text-primary dark:bg-primary/20", + "warning-light": + "bg-warning/10 border-none text-warning-foreground dark:bg-warning/20", + "success-light": + "bg-success/10 border-none text-success-foreground dark:bg-success/20", + "info-light": + "bg-info/10 border-none text-info-foreground dark:bg-info/20", + "destructive-light": + "bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20", + "invert-light": + "bg-invert/10 border-none text-foreground dark:bg-invert/20", + "focus-light": + "bg-focus/10 border-none text-focus-foreground dark:bg-focus/20", + "primary-outline": + "bg-background border-border text-primary dark:bg-input/30", + "warning-outline": + "bg-background border-border text-warning-foreground dark:bg-input/30", + "success-outline": + "bg-background border-border text-success-foreground dark:bg-input/30", + "info-outline": + "bg-background border-border text-info-foreground dark:bg-input/30", + "destructive-outline": + "bg-background border-border text-destructive-foreground dark:bg-input/30", + "invert-outline": + "bg-background border-border text-invert-foreground dark:bg-input/30", + "focus-outline": + "bg-background border-border text-focus-foreground dark:bg-input/30", + }, + size: { + xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1", + sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1", + default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1", + lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1", + xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5", + }, + /** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */ + radius: { + default: + "rounded-sm", + full: "rounded-full", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + radius: "default", + }, + } +) + +interface BadgeProps extends useRender.ComponentProps<"span"> { + variant?: VariantProps["variant"] + size?: VariantProps["size"] + radius?: VariantProps["radius"] +} + +function Badge({ + className, + variant, + size, + radius, + render, + ...props +}: BadgeProps) { + const defaultProps = { + "data-slot": "badge", + className: cn(badgeVariants({ variant, size, radius, className })), + } + + return useRender({ + defaultTagName: "span", + render, + props: mergeProps<"span">(defaultProps, props), + }) +} + +export { Badge, badgeVariants, type BadgeProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/color-picker.tsx b/apps/web/src/components/reui/color-picker.tsx new file mode 100644 index 0000000..6adb0ce --- /dev/null +++ b/apps/web/src/components/reui/color-picker.tsx @@ -0,0 +1,220 @@ +import { useId, useMemo, useState } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@cfdm/ui/lib/utils' +import { Input } from '@cfdm/ui/components/input' +import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover' + +const PROJECT_COLOR_PRESETS = [ + '#3b82f6', + '#6366f1', + '#8b5cf6', + '#ec4899', + '#ef4444', + '#f97316', + '#eab308', + '#22c55e', + '#14b8a6', + '#06b6d4', + '#64748b', + '#18181b', +] as const + +const colorPickerGroupVariants = cva( + 'relative flex w-full items-stretch overflow-hidden rounded-lg border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40', + { + variants: { + size: { + sm: 'h-7 text-sm', + default: 'h-8 text-sm', + lg: 'h-9 text-sm', + }, + }, + defaultVariants: { + size: 'default', + }, + }, +) + +const colorPickerSwatchVariants = cva( + 'flex shrink-0 cursor-pointer items-center justify-center border-e border-input transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-50', + { + variants: { + size: { + sm: 'w-9', + default: 'w-10', + lg: 'w-11', + }, + }, + defaultVariants: { + size: 'default', + }, + }, +) + +const colorPickerInputVariants = cva( + 'min-w-0 flex-1 border-0 bg-transparent shadow-none focus-visible:border-0 focus-visible:ring-0 dark:bg-transparent', + { + variants: { + size: { + sm: 'h-7 rounded-e-lg px-2', + default: 'h-8 rounded-e-lg px-2.5', + lg: 'h-9 rounded-e-lg px-2.5', + }, + }, + defaultVariants: { + size: 'default', + }, + }, +) + +function isValidHex(value: string): boolean { + return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(value.trim()) +} + +function normalizeHex(value: string): string { + const trimmed = value.trim() + if (!trimmed) return '' + const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}` + if (/^#[0-9A-Fa-f]{3}$/.test(withHash)) { + const [, r, g, b] = withHash + return `#${r}${r}${g}${g}${b}${b}`.toLowerCase() + } + if (/^#[0-9A-Fa-f]{6}$/.test(withHash)) { + return withHash.toLowerCase() + } + return withHash +} + +function toPickerValue(value: string): string { + const normalized = normalizeHex(value) + return isValidHex(normalized) ? normalized : '#3b82f6' +} + +interface ColorPickerProps extends VariantProps { + id?: string + value?: string + onChange?: (value: string) => void + onBlur?: () => void + disabled?: boolean + placeholder?: string + 'aria-invalid'?: boolean + className?: string +} + +function ColorPicker({ + id, + value = '', + onChange, + onBlur, + disabled, + placeholder = '#3b82f6', + size = 'default', + className, + 'aria-invalid': ariaInvalid, +}: ColorPickerProps) { + const generatedId = useId() + const inputId = id ?? generatedId + const [open, setOpen] = useState(false) + const sizeValue = size ?? 'default' + + const displayValue = value ?? '' + const swatchColor = useMemo(() => { + const normalized = normalizeHex(displayValue) + return isValidHex(normalized) ? normalized : undefined + }, [displayValue]) + + const handleHexChange = (next: string) => { + onChange?.(next) + } + + const handleHexBlur = () => { + const normalized = normalizeHex(displayValue) + if (normalized !== displayValue) { + onChange?.(normalized) + } + onBlur?.() + } + + const applyColor = (next: string) => { + onChange?.(normalizeHex(next)) + } + + return ( +
+ + + + + } + /> + + +
+ Быстрый выбор +
+ {PROJECT_COLOR_PRESETS.map((preset) => ( +
+
+
+
+ handleHexChange(e.target.value)} + onBlur={handleHexBlur} + /> +
+ ) +} + +export { ColorPicker, normalizeHex, isValidHex, PROJECT_COLOR_PRESETS } diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx new file mode 100644 index 0000000..f516d6c --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx @@ -0,0 +1,165 @@ +import { useMemo, useState } from "react" +import { Badge } from "@/components/reui/badge" +import { Column } from "@tanstack/react-table" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { Input } from "@cfdm/ui/components/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@cfdm/ui/components/popover" +import { Separator } from "@cfdm/ui/components/separator" +import { CirclePlusIcon, CheckIcon } from "lucide-react" + +interface DataGridColumnFilterProps { + column?: Column + title?: string + options: { + label: string + value: string + icon?: React.ComponentType<{ className?: string }> + }[] +} + +function DataGridColumnFilter({ + column, + title, + options, +}: DataGridColumnFilterProps) { + const facets = column?.getFacetedUniqueValues() + const selectedValues = new Set(column?.getFilterValue() as string[]) + const [searchQuery, setSearchQuery] = useState("") + + const filteredOptions = useMemo(() => { + if (!searchQuery) return options + return options.filter((option) => + option.label.toLowerCase().includes(searchQuery.toLowerCase()) + ) + }, [options, searchQuery]) + + return ( + + + + {title} + {selectedValues?.size > 0 && ( + <> + + + {selectedValues.size} + +
+ {selectedValues.size > 2 ? ( + + {selectedValues.size} selected + + ) : ( + options + .filter((option) => selectedValues.has(option.value)) + .map((option) => ( + + {option.label} + + )) + )} +
+ + )} + + } + /> + +
+ setSearchQuery(e.target.value)} + className="h-8" + /> +
+
+ {filteredOptions.length === 0 ? ( +
+ No results found. +
+ ) : ( +
+ {filteredOptions.map((option) => { + const isSelected = selectedValues.has(option.value) + return ( +
{ + if (isSelected) { + selectedValues.delete(option.value) + } else { + selectedValues.add(option.value) + } + const filterValues = Array.from(selectedValues) + column?.setFilterValue( + filterValues.length ? filterValues : undefined + ) + }} + className={cn( + "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none", + "hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground" + )} + > +
+ +
+ {option.icon && ( + + )} + {option.label} + {facets?.get(option.value) && ( + + {facets.get(option.value)} + + )} +
+ ) + })} +
+ )} + {selectedValues.size > 0 && ( + <> +
+
+
column?.setFilterValue(undefined)} + className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none" + > + Clear filters +
+
+ + )} +
+ + + ) +} + +export { DataGridColumnFilter, type DataGridColumnFilterProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx new file mode 100644 index 0000000..1f1c685 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx @@ -0,0 +1,345 @@ +"use client" + +import { HTMLAttributes, memo, ReactNode, useMemo } from "react" +import { + getColumnHeaderLabel, + useDataGrid, +} from "@/components/reui/data-grid/data-grid" +import { Column } from "@tanstack/react-table" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@cfdm/ui/components/dropdown-menu" +import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react" + +interface DataGridColumnHeaderProps< + TData, + TValue, +> extends HTMLAttributes { + column: Column + /** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */ + title?: string + icon?: ReactNode + pinnable?: boolean + filter?: ReactNode + visibility?: boolean +} + +function DataGridColumnHeaderInner({ + column, + title, + icon, + className, + filter, + visibility = false, +}: DataGridColumnHeaderProps) { + const { isLoading, table, props, recordCount } = useDataGrid() + const resolvedTitle = title ?? getColumnHeaderLabel(column) + + const columnOrder = table.getState().columnOrder + const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility) + const isSorted = column.getIsSorted() + const isPinned = column.getIsPinned() + const canSort = column.getCanSort() + const canPin = column.getCanPin() + const canResize = column.getCanResize() + + const columnIndex = columnOrder.indexOf(column.id) + const canMoveLeft = columnIndex > 0 + const canMoveRight = columnIndex < columnOrder.length - 1 + + const handleSort = () => { + if (isSorted === "asc") { + column.toggleSorting(true) + } else if (isSorted === "desc") { + column.clearSorting() + } else { + column.toggleSorting(false) + } + } + + const headerLabelClassName = cn( + "text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5", + className + ) + + const headerButtonClassName = cn( + "text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg", + className + ) + + const sortIcon = + canSort && + (isSorted === "desc" ? ( + + ) : isSorted === "asc" ? ( + + ) : ( + + )) + + const hasControls = + props.tableLayout?.columnsMovable || + (props.tableLayout?.columnsVisibility && visibility) || + (props.tableLayout?.columnsPinnable && canPin) || + filter + + const menuItems = useMemo(() => { + const items: ReactNode[] = [] + let hasPreviousSection = false + + // Filter section + if (filter) { + items.push( + + {filter} + + ) + hasPreviousSection = true + } + + // Sort section + if (canSort) { + if (hasPreviousSection) { + items.push() + } + items.push( + { + if (isSorted === "asc") { + column.clearSorting() + } else { + column.toggleSorting(false) + } + }} + disabled={!canSort} + > + + Asc + {isSorted === "asc" && ( + + )} + , + { + if (isSorted === "desc") { + column.clearSorting() + } else { + column.toggleSorting(true) + } + }} + disabled={!canSort} + > + + Desc + {isSorted === "desc" && ( + + )} + + ) + hasPreviousSection = true + } + + // Pin section + if (props.tableLayout?.columnsPinnable && canPin) { + if (hasPreviousSection) { + items.push() + } + items.push( + column.pin(isPinned === "left" ? false : "left")} + > + , + column.pin(isPinned === "right" ? false : "right")} + > + + ) + hasPreviousSection = true + } + + // Move section + if (props.tableLayout?.columnsMovable) { + if (hasPreviousSection) { + items.push() + } + items.push( + { + if (columnIndex > 0) { + const newOrder = [...columnOrder] + const [movedColumn] = newOrder.splice(columnIndex, 1) + newOrder.splice(columnIndex - 1, 0, movedColumn) + table.setColumnOrder(newOrder) + } + }} + disabled={!canMoveLeft || isPinned !== false} + > + , + { + if (columnIndex < columnOrder.length - 1) { + const newOrder = [...columnOrder] + const [movedColumn] = newOrder.splice(columnIndex, 1) + newOrder.splice(columnIndex + 1, 0, movedColumn) + table.setColumnOrder(newOrder) + } + }} + disabled={!canMoveRight || isPinned !== false} + > + + ) + hasPreviousSection = true + } + + // Visibility section + if (props.tableLayout?.columnsVisibility && visibility) { + if (hasPreviousSection) { + items.push() + } + items.push( + + + + Columns + + + {table + .getAllColumns() + .filter((col) => col.getCanHide()) + .map((col) => ( + event.preventDefault()} + onCheckedChange={(value) => col.toggleVisibility(!!value)} + className="capitalize" + > + {getColumnHeaderLabel(col)} + + ))} + + + ) + } + + return items + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + filter, + canSort, + isSorted, + column, + props.tableLayout?.columnsPinnable, + props.tableLayout?.columnsMovable, + props.tableLayout?.columnsVisibility, + canPin, + isPinned, + canMoveLeft, + canMoveRight, + visibility, + table, + columnIndex, + columnOrder, + columnVisibilityKey, // Needed to update checkbox states when visibility changes + ]) + + if (hasControls) { + return ( +
+ + + {icon && icon} + {resolvedTitle} + {sortIcon} + + } + /> + + {menuItems} + + + {props.tableLayout?.columnsPinnable && canPin && isPinned && ( + + )} +
+ ) + } + + if (canSort || (props.tableLayout?.columnsResizable && canResize)) { + return ( +
+ +
+ ) + } + + return ( +
+ {icon && icon} + {resolvedTitle} +
+ ) +} + +const DataGridColumnHeader = memo( + DataGridColumnHeaderInner +) as typeof DataGridColumnHeaderInner + +export { DataGridColumnHeader, type DataGridColumnHeaderProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx new file mode 100644 index 0000000..b1e272d --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx @@ -0,0 +1,51 @@ +import { ReactElement } from "react" +import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid" +import { Table } from "@tanstack/react-table" + +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@cfdm/ui/components/dropdown-menu" + +function DataGridColumnVisibility({ + table, + trigger, +}: { + table: Table + trigger: ReactElement> +}) { + return ( + + + + + + Toggle Columns + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + event.preventDefault()} + onCheckedChange={(value) => column.toggleVisibility(!!value)} + > + {getColumnHeaderLabel(column)} + + ) + })} + + + + ) +} + +export { DataGridColumnVisibility } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx b/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx new file mode 100644 index 0000000..b849436 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx @@ -0,0 +1,226 @@ +"use client" + +import React, { ReactNode } from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@cfdm/ui/components/select" +import { Skeleton } from "@cfdm/ui/components/skeleton" +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react" + +interface DataGridPaginationProps { + sizes?: number[] + sizesInfo?: string + sizesLabel?: string + sizesDescription?: string + sizesSkeleton?: ReactNode + more?: boolean + moreLimit?: number + info?: string + infoSkeleton?: ReactNode + className?: string + rowsPerPageLabel?: string + previousPageLabel?: string + nextPageLabel?: string + ellipsisText?: string +} + +function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element { + const { table, recordCount, isLoading } = useDataGrid() + + const defaultProps: Partial = { + sizes: [5, 10, 25, 50, 100], + sizesLabel: "Show", + sizesDescription: "per page", + sizesSkeleton: , + moreLimit: 5, + more: false, + info: "{from} - {to} of {count}", + infoSkeleton: , + rowsPerPageLabel: "Rows per page", + previousPageLabel: "Go to previous page", + nextPageLabel: "Go to next page", + ellipsisText: "...", + } + + const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props } + + const btnBaseClasses = "size-7 p-0 text-sm" + const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180" + const pageIndex = table.getState().pagination.pageIndex + const pageSize = table.getState().pagination.pageSize + const from = pageIndex * pageSize + 1 + const to = Math.min((pageIndex + 1) * pageSize, recordCount) + const pageCount = table.getPageCount() + + // Replace placeholders in paginationInfo + const paginationInfo = mergedProps?.info + ? mergedProps.info + .replace("{from}", from.toString()) + .replace("{to}", to.toString()) + .replace("{count}", recordCount.toString()) + : `${from} - ${to} of ${recordCount}` + + // Pagination limit logic + const paginationMoreLimit = mergedProps?.moreLimit || 5 + + // Determine the start and end of the pagination group + const currentGroupStart = + Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit + const currentGroupEnd = Math.min( + currentGroupStart + paginationMoreLimit, + pageCount + ) + + // Render page buttons based on the current group + const renderPageButtons = () => { + const buttons = [] + for (let i = currentGroupStart; i < currentGroupEnd; i++) { + buttons.push( + + ) + } + return buttons + } + + // Render a "previous" ellipsis button if there are previous pages to show + const renderEllipsisPrevButton = () => { + if (currentGroupStart > 0) { + return ( + + ) + } + return null + } + + // Render a "next" ellipsis button if there are more pages to show after the current group + const renderEllipsisNextButton = () => { + if (currentGroupEnd < pageCount) { + return ( + + ) + } + return null + } + + return ( +
+
+ {isLoading ? ( + mergedProps?.sizesSkeleton + ) : ( + <> +
+ {mergedProps.rowsPerPageLabel} +
+ + + )} +
+
+ {isLoading ? ( + mergedProps?.infoSkeleton + ) : ( + <> +
+ {paginationInfo} +
+ {pageCount > 1 && ( +
+ + + {renderEllipsisPrevButton()} + + {renderPageButtons()} + + {renderEllipsisNextButton()} + + +
+ )} + + )} +
+
+ ) +} + +export { DataGridPagination, type DataGridPaginationProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx b/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx new file mode 100644 index 0000000..44c0452 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx @@ -0,0 +1,419 @@ +import { + PointerEvent, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" +import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area" + +import { cn } from "@cfdm/ui/lib/utils" + +const MIN_THUMB_SIZE = 24 +const FALLBACK_SCROLLBAR_SIZE = 12 + +const INITIAL_METRICS = { + hasVerticalOverflow: false, + headerHeight: 0, + horizontalScrollbarSize: 0, + thumbHeight: 0, + thumbTop: 0, + trackHeight: 0, +} as const + +type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both" + +type ScrollbarMetrics = { + hasVerticalOverflow: boolean + headerHeight: number + horizontalScrollbarSize: number + thumbHeight: number + thumbTop: number + trackHeight: number +} + +type ObservedElements = { + header: HTMLElement | null + horizontalScrollbar: HTMLElement | null + table: HTMLElement | null + tableViewport: HTMLElement | null +} + +type DataGridScrollAreaProps = Omit< + ScrollAreaPrimitive.Root.Props, + "children" +> & { + children: ReactNode + orientation?: DataGridScrollAreaOrientation +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) { + return ( + next.hasVerticalOverflow === prev.hasVerticalOverflow && + next.headerHeight === prev.headerHeight && + next.horizontalScrollbarSize === prev.horizontalScrollbarSize && + next.thumbHeight === prev.thumbHeight && + next.thumbTop === prev.thumbTop && + next.trackHeight === prev.trackHeight + ) +} + +function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) { + element.style.setProperty( + "--data-grid-scrollbar-header-height", + `${metrics.headerHeight}px` + ) + element.style.setProperty( + "--data-grid-scrollbar-thumb-height", + `${metrics.thumbHeight}px` + ) + element.style.setProperty( + "--data-grid-scrollbar-thumb-top", + `${metrics.thumbTop}px` + ) + element.style.setProperty( + "--data-grid-scrollbar-track-height", + `${metrics.trackHeight}px` + ) +} + +function DataGridScrollArea({ + children, + className, + orientation = "both", + ...props +}: DataGridScrollAreaProps) { + const { props: dataGridProps } = useDataGrid() + const containerRef = useRef(null) + const viewportRef = useRef(null) + const dragRef = useRef<{ + pointerId: number + startScrollTop: number + startY: number + } | null>(null) + const metricsRef = useRef(INITIAL_METRICS) + const observedElementsRef = useRef({ + header: null, + horizontalScrollbar: null, + table: null, + tableViewport: null, + }) + + const showHorizontal = orientation !== "vertical" + const showVertical = orientation !== "horizontal" + const usesCustomVerticalScrollbar = + showVertical && !!dataGridProps.tableLayout?.headerSticky + const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] = + useState(false) + + const clearDragState = useCallback(() => { + dragRef.current = null + document.body.style.userSelect = "" + document.body.style.webkitUserSelect = "" + }, []) + + const resetMetrics = useCallback(() => { + const container = containerRef.current + + if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) { + applyMetrics(container, INITIAL_METRICS) + metricsRef.current = INITIAL_METRICS + } + + setHasCustomVerticalOverflow((prev) => (prev ? false : prev)) + }, []) + + const syncCustomVerticalScrollbar = useCallback(() => { + const container = containerRef.current + const viewport = viewportRef.current + + if (!container || !viewport || !usesCustomVerticalScrollbar) { + resetMetrics() + return + } + + const { header, horizontalScrollbar } = observedElementsRef.current + const headerHeight = header?.getBoundingClientRect().height ?? 0 + const viewportHeight = viewport.clientHeight + const viewportWidth = viewport.clientWidth + const scrollHeight = viewport.scrollHeight + const scrollWidth = viewport.scrollWidth + const hasHorizontalOverflow = + showHorizontal && scrollWidth > viewportWidth + 0.5 + const horizontalScrollbarSize = hasHorizontalOverflow + ? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE + : 0 + const trackHeight = Math.max( + 0, + viewportHeight - headerHeight - horizontalScrollbarSize + ) + const maxScroll = Math.max(0, scrollHeight - viewportHeight) + + let nextMetrics: ScrollbarMetrics + + if (trackHeight === 0 || maxScroll === 0) { + nextMetrics = { + hasVerticalOverflow: false, + headerHeight, + horizontalScrollbarSize, + thumbHeight: trackHeight, + thumbTop: 0, + trackHeight, + } + } else { + const bodyContentHeight = Math.max( + trackHeight, + scrollHeight - headerHeight + ) + const thumbHeight = clamp( + trackHeight * (trackHeight / bodyContentHeight), + MIN_THUMB_SIZE, + trackHeight + ) + const maxThumbTop = Math.max(0, trackHeight - thumbHeight) + const thumbTop = + maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0 + + nextMetrics = { + hasVerticalOverflow: true, + headerHeight, + horizontalScrollbarSize, + thumbHeight, + thumbTop, + trackHeight, + } + } + + if (!areMetricsEqual(nextMetrics, metricsRef.current)) { + applyMetrics(container, nextMetrics) + metricsRef.current = nextMetrics + } + + setHasCustomVerticalOverflow((prev) => + prev === nextMetrics.hasVerticalOverflow + ? prev + : nextMetrics.hasVerticalOverflow + ) + }, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar]) + + useEffect(() => { + const container = containerRef.current + const viewport = viewportRef.current + + if (!container || !viewport) return + + if (!usesCustomVerticalScrollbar) { + resetMetrics() + return + } + + observedElementsRef.current = { + header: container.querySelector( + '[data-slot="data-grid-table"] thead' + ) as HTMLElement | null, + horizontalScrollbar: container.querySelector( + '[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]' + ) as HTMLElement | null, + table: container.querySelector( + '[data-slot="data-grid-table"]' + ) as HTMLElement | null, + tableViewport: container.querySelector( + '[data-slot="data-grid-table-viewport"]' + ) as HTMLElement | null, + } + + let frame = 0 + + const scheduleSync = () => { + cancelAnimationFrame(frame) + frame = window.requestAnimationFrame(syncCustomVerticalScrollbar) + } + + scheduleSync() + viewport.addEventListener("scroll", scheduleSync, { passive: true }) + + const observer = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(scheduleSync) + + observer?.observe(viewport) + observedElementsRef.current.header && + observer?.observe(observedElementsRef.current.header) + observedElementsRef.current.table && + observer?.observe(observedElementsRef.current.table) + observedElementsRef.current.tableViewport && + observer?.observe(observedElementsRef.current.tableViewport) + + return () => { + cancelAnimationFrame(frame) + observer?.disconnect() + viewport.removeEventListener("scroll", scheduleSync) + clearDragState() + } + }, [ + clearDragState, + resetMetrics, + syncCustomVerticalScrollbar, + usesCustomVerticalScrollbar, + ]) + + const scrollToThumbOffset = (nextThumbTop: number) => { + const viewport = viewportRef.current + const { thumbHeight, trackHeight } = metricsRef.current + + if (!viewport) return + + const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight) + const maxThumbTop = Math.max(0, trackHeight - thumbHeight) + + if (maxScroll === 0 || maxThumbTop === 0) { + viewport.scrollTop = 0 + return + } + + const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop + viewport.scrollTop = ratio * maxScroll + } + + const handleThumbPointerDown = (event: PointerEvent) => { + const viewport = viewportRef.current + + if (!viewport) return + + event.preventDefault() + event.stopPropagation() + event.currentTarget.setPointerCapture(event.pointerId) + + dragRef.current = { + pointerId: event.pointerId, + startScrollTop: viewport.scrollTop, + startY: event.clientY, + } + + document.body.style.userSelect = "none" + document.body.style.webkitUserSelect = "none" + } + + const handleThumbPointerMove = (event: PointerEvent) => { + const viewport = viewportRef.current + const dragState = dragRef.current + const { thumbHeight, trackHeight } = metricsRef.current + + if (!viewport || !dragState || dragState.pointerId !== event.pointerId) { + return + } + + const maxThumbTop = Math.max(0, trackHeight - thumbHeight) + const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight) + + if (maxThumbTop === 0 || maxScroll === 0) return + + const deltaY = event.clientY - dragState.startY + const nextScrollTop = + dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll + + viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll) + } + + const handleThumbPointerUp = (event: PointerEvent) => { + if (dragRef.current?.pointerId !== event.pointerId) return + clearDragState() + } + + const handleTrackPointerDown = (event: PointerEvent) => { + const { thumbHeight } = metricsRef.current + + if (event.target !== event.currentTarget) return + + event.preventDefault() + event.stopPropagation() + + const rect = event.currentTarget.getBoundingClientRect() + const offsetY = event.clientY - rect.top - thumbHeight / 2 + + scrollToThumbOffset(offsetY) + } + + return ( +
+ + + + {children} + + + + {showHorizontal && ( + + + + )} + + {showVertical && !usesCustomVerticalScrollbar && ( + + + + )} + + + {usesCustomVerticalScrollbar && hasCustomVerticalOverflow && ( + + ) +} + +export { DataGridScrollArea } +export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx new file mode 100644 index 0000000..28b02d0 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx @@ -0,0 +1,309 @@ +"use client" + +import { + createContext, + CSSProperties, + ReactNode, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" +import { + DataGridTableBase, + DataGridTableBody, + DataGridTableBodyRow, + DataGridTableBodyRowCell, + DataGridTableBodyRowSkeleton, + DataGridTableBodyRowSkeletonCell, + DataGridTableEmpty, + DataGridTableFoot, + DataGridTableHead, + DataGridTableHeadRow, + DataGridTableHeadRowCell, + DataGridTableHeadRowCellResize, + DataGridTableRowSpacer, + DataGridTableViewport, +} from "@/components/reui/data-grid/data-grid-table" +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + UniqueIdentifier, + useSensor, + useSensors, + type DragEndEvent, + type Modifier, +} from "@dnd-kit/core" +import { restrictToVerticalAxis } from "@dnd-kit/modifiers" +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { GripHorizontalIcon } from "lucide-react" + +// Context to share sortable listeners from row to handle +type SortableContextValue = ReturnType +const SortableRowContext = createContext | null>(null) + +function DataGridTableDndRowHandle({ className }: { className?: string }) { + const context = useContext(SortableRowContext) + + if (!context) { + // Fallback if context is not available (shouldn't happen in normal usage) + return ( + + ) + } + + return ( + + ) +} + +function DataGridTableDndRow({ row }: { row: Row }) { + const { + transform, + transition, + setNodeRef, + isDragging, + attributes, + listeners, + } = useSortable({ + id: row.id, + }) + + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: transition, + opacity: isDragging ? 0.8 : 1, + zIndex: isDragging ? 1 : 0, + position: "relative", + cursor: isDragging ? "grabbing" : undefined, + } + + return ( + + + {row.getVisibleCells().map((cell: Cell, colIndex) => { + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + + ) +} + +function DataGridTableDndRows({ + handleDragEnd, + dataIds, + footerContent, +}: { + handleDragEnd: (event: DragEndEvent) => void + dataIds: UniqueIdentifier[] + footerContent?: ReactNode +}) { + const { table, isLoading, props } = useDataGrid() + const pagination = table.getState().pagination + const tableContainerRef = useRef(null) + const [isDraggingRow, setIsDraggingRow] = useState(false) + + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ) + + useEffect(() => { + if (!isDraggingRow) return + + const { body, documentElement } = document + const previousBodyCursor = body.style.cursor + const previousDocumentCursor = documentElement.style.cursor + + body.style.cursor = "grabbing" + documentElement.style.cursor = "grabbing" + + return () => { + body.style.cursor = previousBodyCursor + documentElement.style.cursor = previousDocumentCursor + } + }, [isDraggingRow]) + + const modifiers = useMemo(() => { + const restrictToTableContainer: Modifier = ({ + transform, + draggingNodeRect, + }) => { + if (!tableContainerRef.current || !draggingNodeRect) { + return transform + } + + const containerRect = tableContainerRef.current.getBoundingClientRect() + const { x, y } = transform + + const minX = containerRect.left - draggingNodeRect.left + const maxX = containerRect.right - draggingNodeRect.right + const minY = containerRect.top - draggingNodeRect.top + const maxY = containerRect.bottom - draggingNodeRect.bottom + + return { + ...transform, + x: Math.max(minX, Math.min(maxX, x)), + y: Math.max(minY, Math.min(maxY, y)), + } + } + + return [restrictToVerticalAxis, restrictToTableContainer] + }, []) + + return ( + setIsDraggingRow(false)} + onDragEnd={(event) => { + setIsDraggingRow(false) + handleDragEnd(event) + }} + onDragStart={() => setIsDraggingRow(true)} + sensors={sensors} + > + + + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => { + return ( + + {headerGroup.headers.map((header, index) => { + const { column } = header + + return ( + + {header.isPlaceholder ? null : props.tableLayout + ?.columnsResizable && column.getCanResize() ? ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {props.tableLayout?.columnsResizable && + column.getCanResize() && ( + + )} +
+ ) + })} +
+ ) + })} +
+ + {(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( + + )} + + + {props.loadingMode === "skeleton" && + isLoading && + pagination?.pageSize ? ( + Array.from({ length: pagination.pageSize }).map((_, rowIndex) => ( + + {table.getVisibleFlatColumns().map((column, colIndex) => { + return ( + + {column.columnDef.meta?.skeleton} + + ) + })} + + )) + ) : table.getRowModel().rows.length ? ( + + {table.getRowModel().rows.map((row: Row) => { + return + })} + + ) : ( + + )} + + + {footerContent && ( + {footerContent} + )} +
+
+
+ ) +} + +export { DataGridTableDndRowHandle, DataGridTableDndRows } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx new file mode 100644 index 0000000..b9c0c29 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx @@ -0,0 +1,312 @@ +import { + CSSProperties, + Fragment, + ReactNode, + useEffect, + useId, + useRef, + useState, +} from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" +import { + DataGridTableBase, + DataGridTableBody, + DataGridTableBodyRow, + DataGridTableBodyRowCell, + DataGridTableBodyRowExpandded, + DataGridTableBodyRowSkeleton, + DataGridTableBodyRowSkeletonCell, + DataGridTableEmpty, + DataGridTableFoot, + DataGridTableHead, + DataGridTableHeadRow, + DataGridTableHeadRowCell, + DataGridTableHeadRowCellResize, + DataGridTableRowSpacer, + DataGridTableViewport, +} from "@/components/reui/data-grid/data-grid-table" +import { + closestCenter, + DndContext, + KeyboardSensor, + Modifier, + MouseSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core" +import { + horizontalListSortingStrategy, + SortableContext, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { + Cell, + flexRender, + Header, + HeaderGroup, + Row, +} from "@tanstack/react-table" + +import { Button } from "@cfdm/ui/components/button" +import { GripVerticalIcon } from "lucide-react" + +function DataGridTableDndHeader({ + header, +}: { + header: Header +}) { + const { props } = useDataGrid() + const { column } = header + + // Check if column ordering is enabled for this column + const canOrder = + (column.columnDef as { enableColumnOrdering?: boolean }) + .enableColumnOrdering !== false + + const { + attributes, + isDragging, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ + id: header.column.id, + }) + + const style: CSSProperties = { + opacity: isDragging ? 0.8 : 1, + position: "relative", + transform: CSS.Translate.toString(transform), + transition, + cursor: isDragging ? "grabbing" : undefined, + whiteSpace: "nowrap", + width: props.tableLayout?.columnsResizable + ? `calc(var(--header-${header.id}-size) * 1px)` + : header.column.getSize(), + zIndex: isDragging ? 1 : 0, + } + + return ( + +
+ {canOrder && ( + + )} + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + {props.tableLayout?.columnsResizable && column.getCanResize() && ( + + )} +
+
+ ) +} + +function DataGridTableDndCell({ cell }: { cell: Cell }) { + const { props } = useDataGrid() + const { isDragging, setNodeRef, transform, transition } = useSortable({ + id: cell.column.id, + }) + + const style: CSSProperties = { + opacity: isDragging ? 0.8 : 1, + position: "relative", + transform: CSS.Translate.toString(transform), + transition, + cursor: isDragging ? "grabbing" : undefined, + width: props.tableLayout?.columnsResizable + ? `calc(var(--col-${cell.column.id}-size) * 1px)` + : cell.column.getSize(), + zIndex: isDragging ? 1 : 0, + } + + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) +} + +function DataGridTableDnd({ + handleDragEnd, + footerContent, +}: { + handleDragEnd: (event: DragEndEvent) => void + footerContent?: ReactNode +}) { + const { table, isLoading, props } = useDataGrid() + const pagination = table.getState().pagination + const containerRef = useRef(null) + const [isDraggingColumn, setIsDraggingColumn] = useState(false) + + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ) + + useEffect(() => { + if (!isDraggingColumn) return + + const { body, documentElement } = document + const previousBodyCursor = body.style.cursor + const previousDocumentCursor = documentElement.style.cursor + + body.style.cursor = "grabbing" + documentElement.style.cursor = "grabbing" + + return () => { + body.style.cursor = previousBodyCursor + documentElement.style.cursor = previousDocumentCursor + } + }, [isDraggingColumn]) + + // Custom modifier to restrict dragging within table bounds with edge offset + const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => { + if (!draggingNodeRect || !containerRef.current) { + return { ...transform, y: 0 } + } + + const containerRect = containerRef.current.getBoundingClientRect() + const edgeOffset = 0 + + const minX = containerRect.left - draggingNodeRect.left - edgeOffset + const maxX = + containerRect.right - + draggingNodeRect.left - + draggingNodeRect.width + + edgeOffset + + return { + ...transform, + x: Math.min(Math.max(transform.x, minX), maxX), + y: 0, // Lock vertical movement + } + } + + return ( + setIsDraggingColumn(false)} + onDragEnd={(event) => { + setIsDraggingColumn(false) + handleDragEnd(event) + }} + onDragStart={() => setIsDraggingColumn(true)} + sensors={sensors} + > + + + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => { + return ( + + + {headerGroup.headers.map((header) => ( + + ))} + + + ) + })} + + + {(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( + + )} + + + {props.loadingMode === "skeleton" && + isLoading && + pagination?.pageSize ? ( + Array.from({ length: pagination.pageSize }).map((_, rowIndex) => ( + + {table.getVisibleFlatColumns().map((column, colIndex) => { + return ( + + {column.columnDef.meta?.skeleton} + + ) + })} + + )) + ) : table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row: Row) => { + return ( + + + {row + .getVisibleCells() + .map((cell: Cell) => { + return ( + + + + ) + })} + + {row.getIsExpanded() && ( + + )} + + ) + }) + ) : ( + + )} + + + {footerContent && ( + {footerContent} + )} + + + + ) +} + +export { DataGridTableDnd } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx new file mode 100644 index 0000000..2e42934 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx @@ -0,0 +1,492 @@ +"use client" + +import { + memo, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" +import { + DataGridTableBase, + DataGridTableBody, + DataGridTableEmpty, + DataGridTableFoot, + DataGridTableHead, + DataGridTableHeadRow, + DataGridTableHeadRowCell, + DataGridTableHeadRowCellResize, + DataGridTableRenderedRow, + DataGridTableRowSpacer, + DataGridTableViewport, + getDataGridTableRowSections, +} from "@/components/reui/data-grid/data-grid-table" +import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table" +import { + useVirtualizer, + VirtualItem, + Virtualizer, + VirtualizerOptions, +} from "@tanstack/react-virtual" + +import { cn } from "@cfdm/ui/lib/utils" +import { Spinner } from "@cfdm/ui/components/spinner" + +type DataGridTableVirtualScrollElements = { + containerElement: HTMLDivElement | null + scrollElement: HTMLElement | null +} + +type DataGridTableVirtualizerInstance = Virtualizer< + HTMLElement, + HTMLTableRowElement +> + +type DataGridTableVirtualizerOptions = Omit< + VirtualizerOptions, + "count" | "estimateSize" | "getItemKey" | "getScrollElement" +> & { + estimateSize?: (index: number, row: Row) => number + getItemKey?: (index: number, row: Row) => string | number + getScrollElement?: ( + elements: DataGridTableVirtualScrollElements + ) => HTMLElement | null +} + +interface DataGridTableVirtualProps { + height?: number | string + estimateSize?: number + overscan?: number + footerContent?: ReactNode + renderHeader?: boolean + onFetchMore?: () => void + isFetchingMore?: boolean + hasMore?: boolean + fetchMoreOffset?: number + virtualizerOptions?: DataGridTableVirtualizerOptions +} + +interface VirtualBodyProps { + table: Table + columnCount: number + topRows: Row[] + centerRows: Row[] + bottomRows: Row[] + virtualItems: VirtualItem[] + totalSize: number + isVirtualizationEnabled: boolean + isInfiniteMode: boolean + isFetchingMore: boolean + hasMore?: boolean + loadingMoreMessage: ReactNode + allRowsLoadedMessage: ReactNode + measureRowRef?: (element: HTMLTableRowElement | null) => void +} + +function DataGridTableVirtualSpacer({ + columnCount, + height, +}: { + columnCount: number + height: number +}) { + if (height <= 0) return null + + return ( + + + + ) +} + +function DataGridTableVirtualStatusRow({ + children, + className, + columnCount, +}: { + children: ReactNode + className?: string + columnCount: number +}) { + return ( + + + {children} + + + ) +} + +function DataGridTableVirtualBody({ + table: _table, + columnCount, + topRows, + centerRows, + bottomRows, + virtualItems, + totalSize, + isVirtualizationEnabled, + isInfiniteMode, + isFetchingMore, + hasMore, + loadingMoreMessage, + allRowsLoadedMessage, + measureRowRef, +}: VirtualBodyProps) { + const totalRows = topRows.length + centerRows.length + bottomRows.length + + if (!totalRows) return + + const hasCenterRows = centerRows.length > 0 + const showFetchingRow = isInfiniteMode && isFetchingMore + const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0 + const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow + const leadingSpacerHeight = + isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0 + ? (virtualItems[0]?.start ?? 0) + : 0 + const trailingSpacerHeight = + isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0 + ? Math.max( + 0, + totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0) + ) + : 0 + + const renderedRows: ReactNode[] = [] + + topRows.forEach((row, index) => { + renderedRows.push( + + ) + }) + + if (isVirtualizationEnabled) { + if (leadingSpacerHeight > 0) { + renderedRows.push( + + ) + } + + virtualItems.forEach((virtualRow) => { + const row = centerRows[virtualRow.index] + + if (!row) return + + renderedRows.push( + + ) + }) + + if (trailingSpacerHeight > 0) { + renderedRows.push( + + ) + } + } else { + centerRows.forEach((row) => { + renderedRows.push() + }) + } + + if (showFetchingRow) { + renderedRows.push( + +
+ + {loadingMoreMessage} +
+
+ ) + } + + if (showCompleteRow) { + renderedRows.push( + + {allRowsLoadedMessage} + + ) + } + + bottomRows.forEach((row, index) => { + renderedRows.push( + 0 || hasMiddleSection) + ? "bottom" + : undefined + } + /> + ) + }) + + return <>{renderedRows} +} + +/** + * Memoized virtual body: skip re-renders during active column resize. + * Column widths update via CSS variables on the element, + * so the browser handles width changes without React re-renders. + */ +const MemoizedVirtualBody = memo( + DataGridTableVirtualBody, + (_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn +) as typeof DataGridTableVirtualBody + +function DataGridTableVirtual({ + height, + estimateSize = 48, + overscan = 10, + footerContent, + renderHeader = true, + onFetchMore, + isFetchingMore = false, + hasMore, + fetchMoreOffset = 0, + virtualizerOptions, +}: DataGridTableVirtualProps) { + const { table, props } = useDataGrid() + const { topRows, centerRows, bottomRows } = getDataGridTableRowSections( + table, + props.tableLayout?.rowsPinnable + ) + const columnCount = + table.getVisibleFlatColumns().length + + (props.tableLayout?.columnsResizable ? 1 : 0) + const isInfiniteMode = typeof onFetchMore === "function" + const [viewportElements, setViewportElements] = + useState({ + containerElement: null, + scrollElement: null, + }) + + const { + estimateSize: customEstimateSize, + getItemKey: customGetItemKey, + getScrollElement: customGetScrollElement, + measureElement: customMeasureElement, + overscan: customOverscan, + ...virtualizerOptionsRest + } = virtualizerOptions ?? {} + + const isVirtualizationEnabled = virtualizerOptions?.enabled !== false + const loadingMoreMessage = + props.fetchingMoreMessage || props.loadingMessage || "Loading..." + const allRowsLoadedMessage = + props.allRowsLoadedMessage || "All records loaded" + + const handleViewportRef = useCallback((node: HTMLDivElement | null) => { + setViewportElements({ + containerElement: node, + scrollElement: + (node?.closest( + '[data-slot="scroll-area-viewport"]' + ) as HTMLElement | null) ?? node, + }) + }, []) + + const usesExternalScrollArea = + viewportElements.scrollElement !== null && + viewportElements.scrollElement !== viewportElements.containerElement + + const resolveScrollElement = useCallback(() => { + if (customGetScrollElement) { + return customGetScrollElement(viewportElements) + } + + return viewportElements.scrollElement + }, [customGetScrollElement, viewportElements]) + + const resolveItemKey = useCallback( + (index: number) => { + const row = centerRows[index] + + if (!row) return index + + return customGetItemKey?.(index, row) ?? row.id ?? index + }, + [centerRows, customGetItemKey] + ) + + const resolveEstimateSize = useCallback( + (index: number) => { + const row = centerRows[index] + + return row + ? (customEstimateSize?.(index, row) ?? estimateSize) + : estimateSize + }, + [centerRows, customEstimateSize, estimateSize] + ) + + const virtualizer = useVirtualizer({ + count: centerRows.length, + getScrollElement: resolveScrollElement, + getItemKey: resolveItemKey, + estimateSize: resolveEstimateSize, + overscan: customOverscan ?? overscan, + measureElement: customMeasureElement, + ...virtualizerOptionsRest, + }) as DataGridTableVirtualizerInstance + + const virtualItems = isVirtualizationEnabled + ? virtualizer.getVirtualItems() + : [] + const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0 + const measureRowRef = + isVirtualizationEnabled && customMeasureElement + ? virtualizer.measureElement + : undefined + const resolvedFetchMoreOffset = useMemo( + () => Math.max(0, fetchMoreOffset), + [fetchMoreOffset] + ) + + useEffect(() => { + if ( + !isVirtualizationEnabled || + !isInfiniteMode || + hasMore === false || + isFetchingMore + ) { + return + } + + const lastItem = virtualItems[virtualItems.length - 1] + if (!lastItem) return + + if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) { + onFetchMore?.() + } + }, [ + centerRows.length, + hasMore, + isFetchingMore, + isInfiniteMode, + isVirtualizationEnabled, + onFetchMore, + resolvedFetchMoreOffset, + virtualItems, + ]) + + return ( + + + {renderHeader && ( + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => ( + + {headerGroup.headers.map((header, hIndex) => { + const { column } = header + + return ( + + {header.isPlaceholder ? null : props.tableLayout + ?.columnsResizable && column.getCanResize() ? ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {props.tableLayout?.columnsResizable && + column.getCanResize() && ( + + )} +
+ ) + })} +
+ ))} +
+ )} + + {renderHeader && + (props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( + + )} + + + + + + {footerContent && ( + {footerContent} + )} +
+
+ ) +} + +export { DataGridTableVirtual } +export type { + DataGridTableVirtualProps, + DataGridTableVirtualScrollElements, + DataGridTableVirtualizerOptions, +} \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table.tsx b/apps/web/src/components/reui/data-grid/data-grid-table.tsx new file mode 100644 index 0000000..a1be5dd --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-table.tsx @@ -0,0 +1,1431 @@ +import { + CSSProperties, + Fragment, + memo, + MouseEvent as ReactMouseEvent, + ReactNode, + TouchEvent as ReactTouchEvent, + Ref, + useCallback, + useEffect, + useMemo, + useState, +} from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" +import { + Cell, + Column, + flexRender, + Header, + HeaderGroup, + Row, + Table, +} from "@tanstack/react-table" +import { cva } from "class-variance-authority" + +import { cn } from "@cfdm/ui/lib/utils" +import { Checkbox } from "@cfdm/ui/components/checkbox" +import { Spinner } from "@cfdm/ui/components/spinner" + +const headerCellSpacingVariants = cva("", { + variants: { + size: { + dense: + "px-2 h-8", + default: + "px-3", + }, + }, + defaultVariants: { + size: "default", + }, +}) + +const bodyCellSpacingVariants = cva("", { + variants: { + size: { + dense: + "px-2 py-1.5", + default: + "px-3 py-2", + }, + }, + defaultVariants: { + size: "default", + }, +}) + +const footerCellSpacingVariants = cva("", { + variants: { + size: { + dense: + "px-2 py-1.5", + default: + "px-3 py-2", + }, + }, + defaultVariants: { + size: "default", + }, +}) + +function getPinningStyles(column: Column): CSSProperties { + const isPinned = column.getIsPinned() + + return { + left: isPinned === "left" ? `${column.getStart("left")}px` : undefined, + right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, + position: isPinned ? "sticky" : "relative", + width: column.getSize(), + zIndex: isPinned ? 1 : 0, + } +} + +function assignRef(ref: Ref | undefined, value: T | null) { + if (!ref) return + + if (typeof ref === "function") { + ref(value) + return + } + + ;(ref as { current: T | null }).current = value +} + +type DataGridResizeStartEvent = + | ReactMouseEvent + | ReactTouchEvent + +type DataGridResizeDocumentEvent = globalThis.MouseEvent | globalThis.TouchEvent + +function isDataGridTouchEvent( + event: DataGridResizeStartEvent | DataGridResizeDocumentEvent +): event is ReactTouchEvent | globalThis.TouchEvent { + return "touches" in event +} + +function getDataGridResizeEventClientX( + event: DataGridResizeStartEvent | DataGridResizeDocumentEvent +) { + if (isDataGridTouchEvent(event)) { + return event.touches[0]?.clientX ?? event.changedTouches[0]?.clientX + } + + return event.clientX +} + +function startDataGridColumnResizeOnEnd( + event: DataGridResizeStartEvent, + header: Header, + table: Table +) { + const column = table.getColumn(header.column.id) + + if (!column || !column.getCanResize()) return + if (isDataGridTouchEvent(event) && event.touches.length > 1) return + + event.persist?.() + + const ownerDocument = event.currentTarget.ownerDocument + const previousBodyCursor = ownerDocument.body.style.cursor + const previousDocumentCursor = ownerDocument.documentElement.style.cursor + const startSize = header.getSize() + const dragStartClientX = getDataGridResizeEventClientX(event) + const headerCell = event.currentTarget.closest("th") + const headerRect = headerCell?.getBoundingClientRect() + const startOffset = + headerRect && + Number.isFinite( + table.options.columnResizeDirection === "rtl" + ? headerRect.left + : headerRect.right + ) + ? table.options.columnResizeDirection === "rtl" + ? headerRect.left + : headerRect.right + : dragStartClientX + + if (typeof dragStartClientX !== "number" || typeof startOffset !== "number") { + return + } + + ownerDocument.body.style.cursor = "col-resize" + ownerDocument.documentElement.style.cursor = "col-resize" + + const columnSizingStart = header + .getLeafHeaders() + .map( + (leafHeader) => + [leafHeader.column.id, leafHeader.column.getSize()] as [string, number] + ) + const directionMultiplier = + table.options.columnResizeDirection === "rtl" ? -1 : 1 + + const updateOffset = (clientXPos?: number, commit = false) => { + if (typeof clientXPos !== "number") return + + let nextColumnSizing: Record = {} + const deltaOffset = (clientXPos - dragStartClientX) * directionMultiplier + const deltaPercentage = Math.max(deltaOffset / startSize, -0.999999) + + columnSizingStart.forEach(([columnId, headerSize]) => { + nextColumnSizing[columnId] = + Math.round( + Math.max(headerSize + headerSize * deltaPercentage, 0) * 100 + ) / 100 + }) + + table.setColumnSizingInfo((old) => ({ + ...old, + startOffset, + startSize, + deltaOffset, + deltaPercentage, + columnSizingStart, + isResizingColumn: column.id, + })) + + if (commit) { + table.setColumnSizing((old) => ({ + ...old, + ...nextColumnSizing, + })) + } + } + + const endResize = (clientXPos?: number) => { + updateOffset(clientXPos, true) + table.setColumnSizingInfo((old) => ({ + ...old, + isResizingColumn: false, + startOffset: null, + startSize: null, + deltaOffset: null, + deltaPercentage: null, + columnSizingStart: [], + })) + ownerDocument.body.style.cursor = previousBodyCursor + ownerDocument.documentElement.style.cursor = previousDocumentCursor + } + + const mouseMoveHandler = (moveEvent: globalThis.MouseEvent) => { + updateOffset(moveEvent.clientX) + } + const mouseUpHandler = (upEvent: globalThis.MouseEvent) => { + ownerDocument.removeEventListener("mousemove", mouseMoveHandler) + ownerDocument.removeEventListener("mouseup", mouseUpHandler) + endResize(upEvent.clientX) + } + const touchMoveHandler = (moveEvent: globalThis.TouchEvent) => { + if (moveEvent.cancelable) { + moveEvent.preventDefault() + moveEvent.stopPropagation() + } + + updateOffset(getDataGridResizeEventClientX(moveEvent)) + } + const touchEndHandler = (endEvent: globalThis.TouchEvent) => { + ownerDocument.removeEventListener("touchmove", touchMoveHandler) + ownerDocument.removeEventListener("touchend", touchEndHandler) + + if (endEvent.cancelable) { + endEvent.preventDefault() + endEvent.stopPropagation() + } + + endResize(getDataGridResizeEventClientX(endEvent)) + } + + const passiveIfSupported = { passive: false } as const + + if (isDataGridTouchEvent(event)) { + ownerDocument.addEventListener( + "touchmove", + touchMoveHandler, + passiveIfSupported + ) + ownerDocument.addEventListener( + "touchend", + touchEndHandler, + passiveIfSupported + ) + } else { + ownerDocument.addEventListener( + "mousemove", + mouseMoveHandler, + passiveIfSupported + ) + ownerDocument.addEventListener( + "mouseup", + mouseUpHandler, + passiveIfSupported + ) + } + + table.setColumnSizingInfo((old) => ({ + ...old, + startOffset, + startSize, + deltaOffset: 0, + deltaPercentage: 0, + columnSizingStart, + isResizingColumn: column.id, + })) +} + +type DataGridTablePinnedBoundary = "top" | "bottom" + +function getDataGridTableRowSections( + table: Table, + rowsPinnable?: boolean +) { + if (!rowsPinnable) { + return { + topRows: [] as Row[], + centerRows: table.getRowModel().rows as Row[], + bottomRows: [] as Row[], + } + } + + return { + topRows: table.getTopRows() as Row[], + centerRows: table.getCenterRows() as Row[], + bottomRows: table.getBottomRows() as Row[], + } +} + +function getDataGridTableResolvedRows( + table: Table, + rowsPinnable?: boolean +) { + const { topRows, centerRows, bottomRows } = getDataGridTableRowSections( + table, + rowsPinnable + ) + const resolvedRows: Array<{ + row: Row + pinnedBoundary?: DataGridTablePinnedBoundary + }> = [] + + topRows.forEach((row, index) => { + resolvedRows.push({ + row, + pinnedBoundary: + index === topRows.length - 1 && + (centerRows.length > 0 || bottomRows.length > 0) + ? "top" + : undefined, + }) + }) + + centerRows.forEach((row) => { + resolvedRows.push({ row }) + }) + + bottomRows.forEach((row, index) => { + resolvedRows.push({ + row, + pinnedBoundary: + index === 0 && (centerRows.length > 0 || topRows.length > 0) + ? "bottom" + : undefined, + }) + }) + + return resolvedRows +} + +function DataGridTableFillCol() { + const { props } = useDataGrid() + + if (!props.tableLayout?.columnsResizable) return null + + return ( +
+ ) +} + +function DataGridTableFillHeadCell() { + const { props } = useDataGrid() + + if (!props.tableLayout?.columnsResizable) return null + + return ( + + {children} + + ) +} + +function DataGridTableHeadRow({ + children, + headerGroup, +}: { + children: ReactNode + headerGroup: HeaderGroup +}) { + const { props } = useDataGrid() + + return ( + th]:border-b", + props.tableLayout?.cellBorder && "*:last:border-e-0", + props.tableLayout?.stripped && "bg-transparent", + props.tableLayout?.headerBackground === false && "bg-transparent", + props.tableClassNames?.headerRow + )} + > + {children} + + + ) +} + +function DataGridTableHeadRowCell({ + children, + header, + dndRef, + dndStyle, +}: { + children: ReactNode + header: Header + dndRef?: React.Ref + dndStyle?: CSSProperties +}) { + const { props } = useDataGrid() + + const { column } = header + const isPinned = column.getIsPinned() + const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left") + const isFirstRightPinned = + isPinned === "right" && column.getIsFirstColumn("right") + const isLastVisibleColumn = + column.getIndex() === + header.getContext().table.getVisibleLeafColumns().length - 1 + const headerCellSpacing = headerCellSpacingVariants({ + size: props.tableLayout?.dense ? "dense" : "default", + }) + + return ( + + ) +} + +function DataGridTableHeadRowCellResize({ + header, +}: { + header: Header +}) { + const { props, table } = useDataGrid() + const { column } = header + const isLastVisibleColumn = + column.getIndex() === + header.getContext().table.getVisibleLeafColumns().length - 1 + const isResizeModeOnEnd = + (props.tableLayout?.columnsResizeMode ?? table.options.columnResizeMode) === + "onEnd" + + const handleMouseDown = (event: ReactMouseEvent) => { + event.preventDefault() + event.stopPropagation() + + if (isResizeModeOnEnd) { + startDataGridColumnResizeOnEnd(event, header, table) + return + } + + header.getResizeHandler()(event) + } + + const handleTouchStart = (event: ReactTouchEvent) => { + event.preventDefault() + event.stopPropagation() + + if (isResizeModeOnEnd) { + startDataGridColumnResizeOnEnd(event, header, table) + return + } + + header.getResizeHandler()(event) + } + + return ( +
column.resetSize(), + onMouseDown: handleMouseDown, + onTouchStart: handleTouchStart, + className: cn( + "absolute top-0 h-full cursor-col-resize user-select-none touch-none z-10 flex", + isLastVisibleColumn + ? "end-0 w-5 justify-end before:hidden" + : "-end-2 w-5 justify-center before:absolute before:inset-y-0 before:w-px before:-translate-x-px before:bg-border", + column.getIsResizing() && + (isResizeModeOnEnd + ? "opacity-100" + : isLastVisibleColumn + ? "before:absolute before:end-0 before:block before:inset-y-0 before:w-0.5 before:bg-primary opacity-100" + : "before:block before:bg-primary before:w-0.5 opacity-100") + ), + }} + /> + ) +} + +function DataGridTableResizeIndicator({ + viewportElement, +}: { + viewportElement: HTMLDivElement | null +}) { + const { props, table } = useDataGrid() + const columnSizingInfo = table.getState().columnSizingInfo + const resizingColumnId = columnSizingInfo.isResizingColumn + const resizeMode = + props.tableLayout?.columnsResizeMode ?? table.options.columnResizeMode + + if ( + !props.tableLayout?.columnsResizable || + resizeMode !== "onEnd" || + !resizingColumnId + ) { + return null + } + + const resizingHeader = table + .getFlatHeaders() + .find( + (header) => + header.column.id === resizingColumnId || header.id === resizingColumnId + ) + + if (!resizingHeader) return null + + const deltaOffset = columnSizingInfo.deltaOffset ?? 0 + const headerHeight = + viewportElement + ?.querySelector('[data-slot="data-grid-table"] thead') + ?.getBoundingClientRect().height ?? 0 + const indicatorLeft = + typeof columnSizingInfo.startOffset === "number" && viewportElement + ? columnSizingInfo.startOffset - + viewportElement.getBoundingClientRect().left + : resizingHeader.getStart() + resizingHeader.getSize() + + return ( +
+} + +function DataGridTableBody({ children }: { children: ReactNode }) { + const { props } = useDataGrid() + + return ( + + {children} + + ) +} + +function DataGridTableFoot({ children }: { children: ReactNode }) { + const { props } = useDataGrid() + return ( + + {children} + + ) +} + +function DataGridTableFootRow({ children }: { children: ReactNode }) { + const { props } = useDataGrid() + return ( + + {children} + + + ) +} + +function DataGridTableFootRowCell({ + children, + colSpan, + className, +}: { + children?: ReactNode + colSpan?: number + className?: string +}) { + const { props } = useDataGrid() + const spacing = footerCellSpacingVariants({ + size: props.tableLayout?.dense ? "dense" : "default", + }) + return ( + + ) +} + +function DataGridTableBodyRowSkeleton({ children }: { children: ReactNode }) { + const { table, props } = useDataGrid() + + return ( + td]:border-b", + props.tableLayout?.cellBorder && "*:last:border-e-0", + props.tableLayout?.stripped && + "odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent", + table.options.enableRowSelection && "*:first:relative", + props.tableClassNames?.bodyRow + )} + > + {children} + + + ) +} + +function DataGridTableBodyRowSkeletonCell({ + children, + column, +}: { + children: ReactNode + column: Column +}) { + const { props, table } = useDataGrid() + const bodyCellSpacing = bodyCellSpacingVariants({ + size: props.tableLayout?.dense ? "dense" : "default", + }) + + return ( + + ) +} + +function DataGridTableBodyRow({ + children, + row, + pinnedBoundary, + rowRef, + dndRef, + dndStyle, +}: { + children: ReactNode + row: Row + pinnedBoundary?: DataGridTablePinnedBoundary + rowRef?: React.Ref + dndRef?: React.Ref + dndStyle?: CSSProperties +}) { + const { props, table } = useDataGrid() + const isRowPinned = row.getIsPinned() + + return ( + { + assignRef(rowRef, node) + assignRef(dndRef, node) + }} + style={{ ...(dndStyle ? dndStyle : null) }} + data-state={ + table.options.enableRowSelection && row.getIsSelected() + ? "selected" + : undefined + } + data-row-pinned={isRowPinned || undefined} + data-row-pinned-boundary={pinnedBoundary} + onClick={() => props.onRowClick && props.onRowClick(row.original)} + className={cn( + "hover:bg-muted/40 data-[state=selected]:bg-muted/50", + props.onRowClick && "cursor-pointer", + !props.tableLayout?.stripped && + props.tableLayout?.rowBorder && + "border-border border-b [&:not(:last-child)>td]:border-b", + props.tableLayout?.cellBorder && "*:last:border-e-0", + props.tableLayout?.stripped && + "odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent", + table.options.enableRowSelection && "*:first:relative", + props.tableLayout?.rowsPinnable && + isRowPinned && + "bg-muted/30 hover:bg-muted/50", + pinnedBoundary === "top" && "[&>td]:shadow-[0_2px_0_rgba(0,0,0,0.03)]", + pinnedBoundary === "bottom" && + "[&>td]:shadow-[0_2px_0_rgba(0,0,0,0.03)]", + props.tableClassNames?.bodyRow + )} + > + {children} + + + ) +} + +function DataGridTableBodyRowExpandded({ row }: { row: Row }) { + const { props, table } = useDataGrid() + + return ( + td]:border-b" + )} + > + + + ) +} + +function DataGridTableBodyRowCell({ + children, + cell, + dndRef, + dndStyle, +}: { + children: ReactNode + cell: Cell + dndRef?: React.Ref + dndStyle?: CSSProperties +}) { + const { props } = useDataGrid() + + const { column, row } = cell + const isPinned = column.getIsPinned() + const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left") + const isFirstRightPinned = + isPinned === "right" && column.getIsFirstColumn("right") + const bodyCellSpacing = bodyCellSpacingVariants({ + size: props.tableLayout?.dense ? "dense" : "default", + }) + + return ( + + ) +} + +function DataGridTableRenderedRow({ + row, + pinnedBoundary, + rowRef, +}: { + row: Row + pinnedBoundary?: DataGridTablePinnedBoundary + rowRef?: React.Ref +}) { + return ( + + + {row.getVisibleCells().map((cell: Cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + {row.getIsExpanded() && } + + ) +} + +function DataGridTableEmpty() { + const { table, props } = useDataGrid() + const visibleColumnCount = + table.getVisibleLeafColumns().length + + (props.tableLayout?.columnsResizable ? 1 : 0) + + return ( + + + + ) +} + +function DataGridTableLoader() { + const { props } = useDataGrid() + + return ( +
+
+ + {props.loadingMessage || "Loading..."} +
+
+ ) +} + +function DataGridTableRowPin({ row }: { row: Row }) { + const isPinned = row.getIsPinned() + + return ( + + ) +} + +function DataGridTableRowSelect({ row }: { row: Row }) { + return ( + <> + + row.toggleSelected(!!value)} + aria-label="Select row" + className="align-[inherit]" + /> + + ) +} + +function DataGridTableRowSelectAll() { + const { table, recordCount, isLoading } = useDataGrid() + + const isAllSelected = table.getIsAllPageRowsSelected() + const isSomeSelected = table.getIsSomePageRowsSelected() + + return ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="align-[inherit]" + /> + ) +} + +function DataGridTableBodyRows({ table }: { table: Table }) { + const { isLoading, props } = useDataGrid() + const pagination = table.getState().pagination + + if (isLoading && props.loadingMode === "skeleton" && pagination?.pageSize) { + return ( + <> + {Array.from({ length: pagination.pageSize }).map((_, rowIndex) => ( + + {table.getVisibleFlatColumns().map((column, colIndex) => ( + + {column.columnDef.meta?.skeleton} + + ))} + + ))} + + ) + } + + if (isLoading && props.loadingMode === "spinner") { + return ( + + + + ) + } + + const resolvedRows = getDataGridTableResolvedRows( + table, + props.tableLayout?.rowsPinnable + ) + + if (!resolvedRows.length) return + + return ( + <> + {resolvedRows.map(({ row, pinnedBoundary }) => ( + + ))} + + ) +} + +/** + * Memoized body rows: skip re-renders during active column resize. + * Column widths update via CSS variables on the
+ {children} +
+ {children} +
+ {children} +
+ {table + .getAllColumns() + .find((column) => column.columnDef.meta?.expandedContent) + ?.columnDef.meta?.expandedContent?.(row.original)} +
+ {children} +
+ {props.emptyMessage || "No data available"} +
+
+ + + + + {props.loadingMessage || "Loading..."} +
+
element, + * so the browser handles width changes without React re-renders. + */ +const MemoizedDataGridTableBodyRows = memo( + DataGridTableBodyRows, + (_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn +) as typeof DataGridTableBodyRows + +function DataGridTableHeader() { + const { table, props } = useDataGrid() + + return ( + + + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => { + return ( + + {headerGroup.headers.map((header, index) => { + const { column } = header + + return ( + + {header.isPlaceholder ? null : props.tableLayout + ?.columnsResizable && column.getCanResize() ? ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {props.tableLayout?.columnsResizable && + column.getCanResize() && ( + + )} +
+ ) + })} +
+ ) + })} +
+
+
+ ) +} + +function DataGridTable({ + footerContent, + renderHeader = true, +}: { + footerContent?: ReactNode + renderHeader?: boolean +}) { + const { table, props } = useDataGrid() + + return ( + + + {renderHeader && ( + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => { + return ( + + {headerGroup.headers.map((header, index) => { + const { column } = header + + return ( + + {header.isPlaceholder ? null : props.tableLayout + ?.columnsResizable && column.getCanResize() ? ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {props.tableLayout?.columnsResizable && + column.getCanResize() && ( + + )} +
+ ) + })} +
+ ) + })} +
+ )} + + {renderHeader && + (props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( + + )} + + + + + + {footerContent && ( + {footerContent} + )} +
+
+ ) +} + +export { + DataGridTable, + DataGridTableBase, + DataGridTableBody, + DataGridTableBodyRow, + DataGridTableBodyRowCell, + DataGridTableBodyRowExpandded, + DataGridTableRenderedRow, + DataGridTableBodyRowSkeleton, + DataGridTableBodyRowSkeletonCell, + DataGridTableEmpty, + DataGridTableFoot, + DataGridTableFootRow, + DataGridTableFootRowCell, + DataGridTableHeader, + DataGridTableHead, + DataGridTableHeadRow, + DataGridTableHeadRowCell, + DataGridTableHeadRowCellResize, + DataGridTableLoader, + DataGridTableRowPin, + DataGridTableRowSelect, + DataGridTableRowSelectAll, + DataGridTableRowSpacer, + DataGridTableViewport, + getDataGridTableResolvedRows, + getDataGridTableRowSections, +} + +export type { DataGridTablePinnedBoundary } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid.tsx b/apps/web/src/components/reui/data-grid/data-grid.tsx new file mode 100644 index 0000000..7842ed5 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid.tsx @@ -0,0 +1,270 @@ +"use client" + +import { createContext, ReactNode, useContext, useMemo } from "react" +import { + Column, + ColumnFiltersState, + RowData, + SortingState, + Table, +} from "@tanstack/react-table" + +import { cn } from "@cfdm/ui/lib/utils" + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + headerTitle?: string + headerClassName?: string + cellClassName?: string + skeleton?: ReactNode + expandedContent?: (row: TData) => ReactNode + } +} + +/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */ +export function getColumnHeaderLabel( + column: Column +): string { + const meta = column.columnDef.meta as { headerTitle?: string } | undefined + if (typeof meta?.headerTitle === "string") return meta.headerTitle + const defHeader = column.columnDef.header + if (typeof defHeader === "string") return defHeader + return String(column.id) +} + +export type DataGridApiFetchParams = { + pageIndex: number + pageSize: number + sorting?: SortingState + filters?: ColumnFiltersState + searchQuery?: string +} + +export type DataGridApiResponse = { + data: T[] + empty: boolean + pagination: { + total: number + page: number + } +} + +export interface DataGridContextProps { + props: DataGridProps + table: Table + recordCount: number + isLoading: boolean +} + +export type DataGridRequestParams = { + pageIndex: number + pageSize: number + sorting?: SortingState + columnFilters?: ColumnFiltersState +} + +export interface DataGridProps { + className?: string + table?: Table + recordCount: number + children?: ReactNode + onRowClick?: (row: TData) => void + isLoading?: boolean + loadingMode?: "skeleton" | "spinner" + loadingMessage?: ReactNode | string + fetchingMoreMessage?: ReactNode | string + allRowsLoadedMessage?: ReactNode | string + emptyMessage?: ReactNode | string + tableLayout?: { + dense?: boolean + cellBorder?: boolean + rowBorder?: boolean + rowRounded?: boolean + stripped?: boolean + headerBackground?: boolean + headerBorder?: boolean + headerSticky?: boolean + width?: "auto" | "fixed" + columnsVisibility?: boolean + columnsResizable?: boolean + columnsResizeMode?: "onChange" | "onEnd" + columnsPinnable?: boolean + columnsMovable?: boolean + columnsDraggable?: boolean + rowsDraggable?: boolean + rowsPinnable?: boolean + } + tableClassNames?: { + base?: string + header?: string + headerRow?: string + headerSticky?: string + body?: string + bodyRow?: string + footer?: string + edgeCell?: string + } +} + +const DataGridContext = createContext< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + DataGridContextProps | undefined +>(undefined) + +function useDataGrid() { + const context = useContext(DataGridContext) + if (!context) { + throw new Error("useDataGrid must be used within a DataGridProvider") + } + return context +} + +function DataGridProvider({ + children, + table, + ...props +}: DataGridProps & { table: Table }) { + const tableState = table.getState() + const resolvedColumnsResizeMode = + props.tableLayout?.columnsResizeMode ?? "onEnd" + + // Keep resize mode aligned with the DataGrid contract every render so + // consumer-level useReactTable options cannot flip it back between drags. + if (props.tableLayout?.columnsResizable) { + table.options.columnResizeMode = resolvedColumnsResizeMode + } + + // Memoize context value so consumers don't re-render during column resize. + // Column sizing state is intentionally excluded from deps -- CSS variables + // on the
element handle width updates without React re-renders. + const value = useMemo( + () => ({ + props, + table, + recordCount: props.recordCount, + isLoading: props.isLoading || false, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + table, + props.recordCount, + props.isLoading, + props.loadingMode, + props.loadingMessage, + props.fetchingMoreMessage, + props.allRowsLoadedMessage, + props.emptyMessage, + props.onRowClick, + props.className, + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(props.tableLayout), + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(props.tableClassNames), + tableState.sorting, + tableState.pagination, + tableState.columnFilters, + tableState.rowSelection, + tableState.expanded, + tableState.columnVisibility, + tableState.columnOrder, + tableState.columnPinning, + tableState.globalFilter, + ] + ) + + return ( + + {children} + + ) +} + +function DataGrid({ + children, + table, + ...props +}: DataGridProps) { + const defaultProps: Partial> = { + loadingMode: "skeleton", + tableLayout: { + dense: false, + cellBorder: false, + rowBorder: true, + rowRounded: false, + stripped: false, + headerSticky: false, + headerBackground: true, + headerBorder: true, + width: "fixed", + columnsVisibility: false, + columnsResizable: false, + columnsResizeMode: "onEnd", + columnsPinnable: false, + columnsMovable: false, + columnsDraggable: false, + rowsDraggable: false, + rowsPinnable: false, + }, + tableClassNames: { + base: "", + header: "", + headerRow: "", + headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs", + body: "", + bodyRow: "", + footer: "", + edgeCell: "", + }, + } + + const mergedProps: DataGridProps = { + ...defaultProps, + ...props, + tableLayout: { + ...defaultProps.tableLayout, + ...(props.tableLayout || {}), + }, + tableClassNames: { + ...defaultProps.tableClassNames, + ...(props.tableClassNames || {}), + }, + } + + // Ensure table is provided + if (!table) { + throw new Error('DataGrid requires a "table" prop') + } + + return ( + + {children} + + ) +} + +function DataGridContainer({ + children, + className, + border = true, +}: { + children: ReactNode + className?: string + border?: boolean +}) { + return ( +
+ {children} +
+ ) +} + +export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer } \ No newline at end of file diff --git a/apps/web/src/components/reui/date-selector.tsx b/apps/web/src/components/reui/date-selector.tsx new file mode 100644 index 0000000..ad70a8a --- /dev/null +++ b/apps/web/src/components/reui/date-selector.tsx @@ -0,0 +1,1330 @@ +import { + type ChangeEvent, + type ComponentProps, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react" +import { + addMonths, + format, + isBefore, + isSameMonth, + parse, + subMonths, +} from "date-fns" +import { DayButton } from "react-day-picker" +import type { DateRange } from "react-day-picker" + +import { useIsMobile } from "@cfdm/ui/hooks/use-mobile" +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { Calendar, CalendarDayButton } from "@cfdm/ui/components/calendar" +import { Input } from "@cfdm/ui/components/input" +import { ScrollArea } from "@cfdm/ui/components/scroll-area" +import { Tabs, TabsList, TabsTrigger } from "@cfdm/ui/components/tabs" +import { CornerUpLeftIcon, CornerUpRightIcon, ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react" + +export interface DateSelectorI18nConfig { + // Labels + selectDate: string + apply: string + cancel: string + clear: string + today: string + // Filter types + filterTypes: { + is: string + before: string + after: string + between: string + } + // Period types + periodTypes: { + day: string + month: string + quarter: string + halfYear: string + year: string + } + // Months + months: string[] + monthsShort: string[] + // Quarters + quarters: string[] + // Half years + halfYears: string[] + // Weekdays + weekdays: string[] + weekdaysShort: string[] + // Placeholders + placeholder: string + rangePlaceholder: string +} + +export const DEFAULT_DATE_SELECTOR_I18N: DateSelectorI18nConfig = { + selectDate: "Select date", + apply: "Apply", + cancel: "Cancel", + clear: "Clear", + today: "Today", + filterTypes: { + is: "is", + before: "before", + after: "after", + between: "between", + }, + periodTypes: { + day: "Day", + month: "Month", + quarter: "Quarter", + halfYear: "Half-year", + year: "Year", + }, + months: [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ], + monthsShort: [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ], + quarters: ["Q1", "Q2", "Q3", "Q4"], + halfYears: ["H1", "H2"], + weekdays: [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ], + weekdaysShort: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], + placeholder: "Select date...", + rangePlaceholder: "Select date range...", +} + +export type DateSelectorPeriodType = + | "day" + | "month" + | "quarter" + | "half-year" + | "year" +export type DateSelectorFilterType = "is" | "before" | "after" | "between" + +export interface DateSelectorValue { + period: DateSelectorPeriodType + operator: DateSelectorFilterType + startDate?: Date + endDate?: Date + year?: number + month?: number + quarter?: number + halfYear?: number + rangeStart?: { year: number; value: number } + rangeEnd?: { year: number; value: number } +} + +export interface DateSelectorContextValue { + i18n: DateSelectorI18nConfig + variant: "outline" | "default" + size: "sm" | "default" | "lg" +} + +const DateSelectorContext = createContext({ + i18n: DEFAULT_DATE_SELECTOR_I18N, + variant: "outline", + size: "default", +}) + +export const useDateSelectorContext = () => useContext(DateSelectorContext) + +export function formatDateValue( + value: DateSelectorValue, + i18n: DateSelectorI18nConfig = DEFAULT_DATE_SELECTOR_I18N, + dayDateFormat: string = "MM/dd/yyyy" +): string { + const { + period, + startDate, + endDate, + year, + month, + quarter, + halfYear, + rangeStart, + rangeEnd, + } = value + + if (period === "day") { + if (startDate && endDate) { + return `${format(startDate, dayDateFormat)} - ${format(endDate, dayDateFormat)}` + } + if (startDate) { + return format(startDate, dayDateFormat) + } + return "" + } + + if (period === "month") { + if (rangeStart && rangeEnd) { + return `${i18n.monthsShort[rangeStart.value]} ${rangeStart.year} - ${i18n.monthsShort[rangeEnd.value]} ${rangeEnd.year}` + } + if (year !== undefined && month !== undefined) { + return `${i18n.monthsShort[month]} ${year}` + } + return "" + } + + if (period === "quarter") { + if (rangeStart && rangeEnd) { + return `${i18n.quarters[rangeStart.value]} ${rangeStart.year} - ${i18n.quarters[rangeEnd.value]} ${rangeEnd.year}` + } + if (year !== undefined && quarter !== undefined) { + return `${i18n.quarters[quarter]} ${year}` + } + return "" + } + + if (period === "half-year") { + if (rangeStart && rangeEnd) { + return `${i18n.halfYears[rangeStart.value]} ${rangeStart.year} - ${i18n.halfYears[rangeEnd.value]} ${rangeEnd.year}` + } + if (year !== undefined && halfYear !== undefined) { + return `${i18n.halfYears[halfYear]} ${year}` + } + return "" + } + + if (period === "year") { + if (rangeStart && rangeEnd) { + return `${rangeStart.year} - ${rangeEnd.year}` + } + if (year !== undefined) { + return `${year}` + } + return "" + } + + return "" +} + +interface UseDateSelectorOptions { + value?: DateSelectorValue + onChange?: (value: DateSelectorValue) => void + defaultPeriodType?: DateSelectorPeriodType + defaultFilterType?: DateSelectorFilterType + presetMode?: DateSelectorFilterType + allowRange?: boolean + yearRange?: number + baseYear?: number + minYear?: number + maxYear?: number + periodTypes?: DateSelectorPeriodType[] +} + +export function useDateSelector({ + value, + onChange, + defaultPeriodType = "day", + defaultFilterType = "is", + presetMode, + allowRange = true, + yearRange = 11, + baseYear, + minYear, + maxYear, + periodTypes, +}: UseDateSelectorOptions) { + const currentYear = baseYear ?? new Date().getFullYear() + + const validDefaultPeriodType = useMemo(() => { + if (!periodTypes || periodTypes.length === 0) return defaultPeriodType + if (periodTypes.includes(defaultPeriodType)) return defaultPeriodType + return periodTypes[0] + }, [periodTypes, defaultPeriodType]) + + // Use presetMode if provided, otherwise use value or default + const effectiveFilterType = presetMode ?? value?.operator ?? defaultFilterType + + const [periodType, setPeriodType] = useState( + value?.period || validDefaultPeriodType + ) + const [filterType, setFilterType] = + useState(effectiveFilterType) + const [selectedDate, setSelectedDate] = useState( + value?.startDate + ) + const [selectedEndDate, setSelectedEndDate] = useState( + value?.endDate + ) + const [calendarMonth, setCalendarMonth] = useState( + value?.startDate || new Date() + ) + const [selectedYear, setSelectedYear] = useState( + value?.year + ) + const [selectedMonth, setSelectedMonth] = useState( + value?.month + ) + const [selectedQuarter, setSelectedQuarter] = useState( + value?.quarter + ) + const [selectedHalfYear, setSelectedHalfYear] = useState( + value?.halfYear + ) + const [rangeStart, setRangeStart] = useState< + { year: number; value: number } | undefined + >(value?.rangeStart) + const [rangeEnd, setRangeEnd] = useState< + { year: number; value: number } | undefined + >(value?.rangeEnd) + const [hoverDate, setHoverDate] = useState() + + const years = useMemo(() => { + if (minYear !== undefined && maxYear !== undefined) { + return Array.from( + { length: maxYear - minYear + 1 }, + (_, i) => minYear + i + ) + } + return Array.from( + { length: yearRange }, + (_, i) => currentYear - Math.floor(yearRange / 2) + i + ) + }, [currentYear, yearRange, minYear, maxYear]) + + const currentValue = useMemo( + () => ({ + period: periodType, + operator: presetMode ?? filterType, + startDate: selectedDate, + endDate: selectedEndDate, + year: selectedYear, + month: selectedMonth, + quarter: selectedQuarter, + halfYear: selectedHalfYear, + rangeStart, + rangeEnd, + }), + [ + periodType, + presetMode, + filterType, + selectedDate, + selectedEndDate, + selectedYear, + selectedMonth, + selectedQuarter, + selectedHalfYear, + rangeStart, + rangeEnd, + ] + ) + + const clearSelection = useCallback(() => { + setSelectedDate(undefined) + setSelectedEndDate(undefined) + setSelectedYear(undefined) + setSelectedMonth(undefined) + setSelectedQuarter(undefined) + setSelectedHalfYear(undefined) + setRangeStart(undefined) + setRangeEnd(undefined) + }, []) + + const handleDayClick = useCallback( + (day: Date) => { + if (filterType === "between" && allowRange) { + if (!selectedDate || (selectedDate && selectedEndDate)) { + setSelectedDate(day) + setSelectedEndDate(undefined) + } else { + if (isBefore(day, selectedDate)) { + setSelectedEndDate(selectedDate) + setSelectedDate(day) + } else { + setSelectedEndDate(day) + } + } + } else { + setSelectedDate(day) + setSelectedEndDate(undefined) + } + }, + [filterType, allowRange, selectedDate, selectedEndDate] + ) + + const handlePeriodSelect = useCallback( + (year: number, value: number) => { + if (filterType === "between" && allowRange) { + if (!rangeStart || (rangeStart && rangeEnd)) { + setRangeStart({ year, value }) + setRangeEnd(undefined) + setSelectedYear(year) + if (periodType === "month") setSelectedMonth(value) + if (periodType === "quarter") setSelectedQuarter(value) + if (periodType === "half-year") setSelectedHalfYear(value) + } else { + const startKey = rangeStart.year * 100 + rangeStart.value + const endKey = year * 100 + value + if (endKey < startKey) { + setRangeEnd(rangeStart) + setRangeStart({ year, value }) + } else { + setRangeEnd({ year, value }) + } + } + } else { + setSelectedYear(year) + if (periodType === "month") setSelectedMonth(value) + if (periodType === "quarter") setSelectedQuarter(value) + if (periodType === "half-year") setSelectedHalfYear(value) + setRangeStart(undefined) + setRangeEnd(undefined) + } + }, + [filterType, allowRange, rangeStart, rangeEnd, periodType] + ) + + const handleYearSelect = useCallback( + (year: number) => { + if (filterType === "between" && allowRange) { + if (!rangeStart || (rangeStart && rangeEnd)) { + setRangeStart({ year, value: 0 }) + setRangeEnd(undefined) + setSelectedYear(year) + } else { + if (year < rangeStart.year) { + setRangeEnd(rangeStart) + setRangeStart({ year, value: 0 }) + } else { + setRangeEnd({ year, value: 0 }) + } + } + } else { + setSelectedYear(year) + setRangeStart(undefined) + setRangeEnd(undefined) + } + }, + [filterType, allowRange, rangeStart, rangeEnd] + ) + + const handlePeriodTypeChange = useCallback( + (type: DateSelectorPeriodType) => { + setPeriodType(type) + clearSelection() + }, + [clearSelection] + ) + + const handleFilterTypeChange = useCallback( + (type: DateSelectorFilterType) => { + // Don't allow changes if presetMode is set + if (presetMode !== undefined) return + setFilterType(type) + clearSelection() + }, + [clearSelection, presetMode] + ) + + const isInRange = useCallback( + (year: number, value: number) => { + if (!rangeStart || !rangeEnd) return false + const key = year * 100 + value + const startKey = rangeStart.year * 100 + rangeStart.value + const endKey = rangeEnd.year * 100 + rangeEnd.value + return key >= startKey && key <= endKey + }, + [rangeStart, rangeEnd] + ) + + const isYearInRange = useCallback( + (year: number) => { + if (!rangeStart || !rangeEnd) return false + return year >= rangeStart.year && year <= rangeEnd.year + }, + [rangeStart, rangeEnd] + ) + + useEffect(() => { + if (value) { + setPeriodType(value.period || validDefaultPeriodType) + // Use presetMode if provided, otherwise use value's operator or default + const newFilterType = presetMode ?? value.operator ?? defaultFilterType + setFilterType(newFilterType) + setSelectedDate(value.startDate) + setSelectedEndDate(value.endDate) + setSelectedYear(value.year) + setSelectedMonth(value.month) + setSelectedQuarter(value.quarter) + setSelectedHalfYear(value.halfYear) + setRangeStart(value.rangeStart) + setRangeEnd(value.rangeEnd) + } + }, [value, validDefaultPeriodType, defaultFilterType, presetMode]) + + // Sync filterType when presetMode changes + useEffect(() => { + if (presetMode !== undefined) { + setFilterType(presetMode) + } + }, [presetMode]) + + useEffect(() => { + onChange?.(currentValue) + }, [currentValue, onChange]) + + return { + // State + periodType, + filterType, + selectedDate, + selectedEndDate, + calendarMonth, + selectedYear, + selectedMonth, + selectedQuarter, + selectedHalfYear, + rangeStart, + rangeEnd, + hoverDate, + years, + currentValue, + allowRange, + + // Setters + setPeriodType: handlePeriodTypeChange, + setFilterType: handleFilterTypeChange, + setSelectedDate, + setSelectedEndDate, + setCalendarMonth, + setHoverDate, + + // Actions + clearSelection, + handleDayClick, + handlePeriodSelect, + handleYearSelect, + isInRange, + isYearInRange, + } +} + +interface DateSelectorFilterToggleProps { + value: DateSelectorFilterType + onChange: (value: DateSelectorFilterType) => void + showBetween?: boolean + showIs?: boolean + presetMode?: DateSelectorFilterType + className?: string +} + +function DateSelectorFilterToggle({ + value, + onChange, + showBetween = true, + showIs = true, + presetMode, + className, +}: DateSelectorFilterToggleProps) { + const { i18n } = useDateSelectorContext() + const isDisabled = presetMode !== undefined + + return ( + { + if (!isDisabled && newValue) { + onChange(newValue as DateSelectorFilterType) + } + }} + className={className} + > + + {showIs && ( + + {i18n.filterTypes.is} + + )} + + {i18n.filterTypes.before} + + + {i18n.filterTypes.after} + + {showBetween && ( + + {i18n.filterTypes.between} + + )} + + + ) +} + +interface DateSelectorDateSelectorPeriodTabsProps { + value: DateSelectorPeriodType + onChange: (value: DateSelectorPeriodType) => void + periodTypes?: DateSelectorPeriodType[] + className?: string + calendarMonth?: Date + onMonthChange?: (date: Date) => void + showNavigationButtons?: boolean +} + +function DateSelectorPeriodTabs({ + value, + onChange, + periodTypes, + className, + calendarMonth, + onMonthChange, + showNavigationButtons = false, +}: DateSelectorDateSelectorPeriodTabsProps) { + const { i18n } = useDateSelectorContext() + + const tabs: { value: DateSelectorPeriodType; label: string }[] = [ + { value: "day", label: i18n.periodTypes.day }, + { value: "month", label: i18n.periodTypes.month }, + { value: "quarter", label: i18n.periodTypes.quarter }, + { value: "half-year", label: i18n.periodTypes.halfYear }, + { value: "year", label: i18n.periodTypes.year }, + ] + + const filteredTabs = periodTypes + ? tabs.filter((tab) => periodTypes.includes(tab.value)) + : tabs + + return ( +
+ { + if (newValue) { + onChange(newValue as DateSelectorPeriodType) + } + }} + > + + {filteredTabs.map((tab) => ( + + {tab.label} + + ))} + + + {showNavigationButtons && + value === "day" && + calendarMonth && + onMonthChange && ( +
+ {(() => { + const today = new Date() + const isCurrentMonth = isSameMonth(calendarMonth, today) + + // Only show today button if not on current month + if (isCurrentMonth) { + return null + } + + // Determine direction based on whether calendarMonth is in future or past + const isFuture = calendarMonth > today + + return ( + + ) + })()} + + +
+ )} +
+ ) +} + +interface DateSelectorDayPickerProps { + currentMonth: Date + selectedDate?: Date + selectedEndDate?: Date + onDayClick: (day: Date) => void + isRange: boolean + onDayHover?: (day: Date | undefined) => void + hoverDate?: Date + showTwoMonths?: boolean + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 + className?: string +} + +function DateSelectorDayPicker({ + currentMonth, + selectedDate, + selectedEndDate, + onDayClick, + isRange, + onDayHover, + hoverDate, + showTwoMonths = true, + weekStartsOn, + className, +}: DateSelectorDayPickerProps) { + const { i18n } = useDateSelectorContext() + const isMobile = useIsMobile() + + // Convert to react-day-picker format + const selected: Date | DateRange | undefined = isRange + ? selectedDate && selectedEndDate + ? { from: selectedDate, to: selectedEndDate } + : selectedDate + ? { from: selectedDate, to: hoverDate || selectedDate } + : undefined + : selectedDate + + const handleSelect = (date: Date | DateRange | undefined) => { + if (!date) { + return + } + + if (isRange && "from" in date) { + // For range mode + if (date.from && !date.to) { + // First click - set start date + onDayClick(date.from) + } else if (date.from && date.to) { + // Range selected - set end date + onDayClick(date.to) + } + } else if (!isRange && date instanceof Date) { + onDayClick(date) + } + } + + // Create custom DayButton component with hover support + const CustomDayButton = useCallback( + (props: ComponentProps) => { + return ( + { + if (isRange && onDayHover && props.day) { + onDayHover(props.day.date) + } + }} + onMouseLeave={() => { + if (isRange && onDayHover) { + onDayHover(undefined) + } + }} + /> + ) + }, + [isRange, onDayHover] + ) + + // Create custom formatters for i18n + const formatters = { + formatWeekdayName: (date: Date) => { + const dayIndex = date.getDay() + return i18n.weekdaysShort[dayIndex] || i18n.weekdays[dayIndex] + }, + formatMonthCaption: (date: Date) => { + const monthIndex = date.getMonth() + const year = date.getFullYear() + return `${i18n.months[monthIndex]} ${year}` + }, + } + + return ( +
+ {isRange ? ( + void} + numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1} + showOutsideDays={true} + weekStartsOn={weekStartsOn} + formatters={formatters} + className="w-full shrink-0 p-0" + classNames={{ + months: "flex flex-wrap items-start justify-between gap-5 w-full", + month: "flex flex-col items-center min-w-0 flex-1", + nav: "hidden", + }} + components={{ + DayButton: CustomDayButton, + }} + /> + ) : ( + void} + numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1} + showOutsideDays={true} + weekStartsOn={weekStartsOn} + formatters={formatters} + className="w-full shrink-0 p-0" + classNames={{ + months: "flex flex-wrap items-start justify-between gap-5 w-full", + month: "flex flex-col items-center min-w-0 flex-1", + nav: "hidden", + }} + components={{ + DayButton: CustomDayButton, + }} + /> + )} +
+ ) +} + +interface DateSelectorDateSelectorPeriodGridProps { + years: number[] + items: string[] + selectedYear?: number + selectedValue?: number + rangeStart?: { year: number; value: number } + rangeEnd?: { year: number; value: number } + isInRange: (year: number, value: number) => boolean + onSelect: (year: number, value: number) => void + columns: number + className?: string +} + +function DateSelectorPeriodGrid({ + years, + items, + selectedYear, + selectedValue, + rangeStart, + rangeEnd, + isInRange, + onSelect, + columns, + className, +}: DateSelectorDateSelectorPeriodGridProps) { + return ( +
+ {years.map((year) => ( +
+
+ {year} +
+
+ {items.map((item, index) => { + const isSelected = + selectedYear === year && selectedValue === index + const isRangeStart = + rangeStart?.year === year && rangeStart?.value === index + const isRangeEnd = + rangeEnd?.year === year && rangeEnd?.value === index + const inRange = isInRange(year, index) + + return ( + + ) + })} +
+
+ ))} +
+ ) +} + +interface DateSelectorYearListProps { + years: number[] + selectedYear?: number + rangeStart?: { year: number; value: number } + rangeEnd?: { year: number; value: number } + isYearInRange: (year: number) => boolean + onSelect: (year: number) => void + className?: string +} + +function DateSelectorYearList({ + years, + selectedYear, + rangeStart, + rangeEnd, + isYearInRange, + onSelect, + className, +}: DateSelectorYearListProps) { + return ( +
+ {years.map((year) => { + const isSelected = selectedYear === year && !rangeStart && !rangeEnd + const isRangeStart = rangeStart?.year === year + const isRangeEnd = rangeEnd?.year === year + const inRange = isYearInRange(year) + + return ( + + ) + })} +
+ ) +} + +export interface DateSelectorProps { + value?: DateSelectorValue + onChange?: (value: DateSelectorValue) => void + allowRange?: boolean + periodTypes?: DateSelectorPeriodType[] + defaultPeriodType?: DateSelectorPeriodType + defaultFilterType?: DateSelectorFilterType + presetMode?: DateSelectorFilterType + showInput?: boolean + showTwoMonths?: boolean + label?: string + className?: string + yearRange?: number + baseYear?: number + minYear?: number + maxYear?: number + i18n?: Partial + inputHint?: string + dayDateFormat?: string + dayDateFormats?: string[] + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 +} + +export function DateSelector({ + value, + onChange, + allowRange = true, + periodTypes, + defaultPeriodType = "day", + defaultFilterType = "is", + presetMode, + showInput = true, + showTwoMonths = true, + label, + className, + yearRange = 10, + baseYear, + minYear = 2015, + maxYear = 2026, + i18n: i18nOverride, + inputHint, + dayDateFormat = "MM/dd/yyyy", + dayDateFormats, + weekStartsOn, +}: DateSelectorProps) { + const mergedI18n = useMemo( + () => ({ ...DEFAULT_DATE_SELECTOR_I18N, ...i18nOverride }), + [i18nOverride] + ) + + const selector = useDateSelector({ + value, + onChange, + defaultPeriodType, + defaultFilterType, + presetMode, + allowRange, + yearRange, + baseYear, + minYear, + maxYear, + periodTypes, + }) + + const { + periodType, + filterType, + selectedDate, + selectedEndDate, + calendarMonth, + selectedYear, + selectedMonth, + selectedQuarter, + selectedHalfYear, + rangeStart, + rangeEnd, + hoverDate, + years, + currentValue, + setPeriodType, + setFilterType, + setCalendarMonth, + setHoverDate, + clearSelection, + handleDayClick, + handlePeriodSelect, + handleYearSelect, + isInRange, + isYearInRange, + } = selector + + const displayValue = formatDateValue(currentValue, mergedI18n, dayDateFormat) + const [inputValue, setInputValue] = useState(displayValue) + const [isInputFocused, setIsInputFocused] = useState(false) + + // Sync input value when displayValue changes (but not when user is typing) + useEffect(() => { + if (!isInputFocused) { + setInputValue(displayValue) + } + }, [displayValue, isInputFocused]) + + // Compute date formats for parsing + const dateFormats = useMemo(() => { + if (dayDateFormats && dayDateFormats.length > 0) { + // Use provided formats, with dayDateFormat first if not already included + const formats = [...dayDateFormats] + if (!formats.includes(dayDateFormat)) { + formats.unshift(dayDateFormat) + } + return formats + } + // Default formats: use dayDateFormat first, then common alternatives + const defaultFormats = [ + dayDateFormat, + "dd/MM/yyyy", + "yyyy-MM-dd", + "MM-dd-yyyy", + "dd-MM-yyyy", + ] + // Remove duplicates while preserving order + return Array.from(new Set(defaultFormats)) + }, [dayDateFormat, dayDateFormats]) + + // Parse input text to DateSelectorValue + const parseInputValue = useCallback( + (text: string): DateSelectorValue | null => { + if (!text.trim()) return null + + const trimmed = text.trim() + + // Try parsing as year (e.g., "2025") + const yearMatch = trimmed.match(/^\d{4}$/) + if (yearMatch) { + const year = parseInt(yearMatch[0]) + if (year >= 1900 && year <= 2100) { + return { + period: "year", + operator: presetMode ?? filterType, + year, + } + } + } + + // Try parsing as quarter (e.g., "Q4", "Q1 2025") + const quarterMatch = trimmed.match(/^Q([1-4])(?:\s+(\d{4}))?$/i) + if (quarterMatch) { + const quarter = parseInt(quarterMatch[1]) - 1 + const year = quarterMatch[2] + ? parseInt(quarterMatch[2]) + : new Date().getFullYear() + if (year >= 1900 && year <= 2100) { + return { + period: "quarter", + operator: presetMode ?? filterType, + year, + quarter, + } + } + } + + // Try parsing as date using computed formats + for (const dateFormat of dateFormats) { + try { + const parsed = parse(trimmed, dateFormat, new Date()) + if (!isNaN(parsed.getTime())) { + return { + period: "day", + operator: presetMode ?? filterType, + startDate: parsed, + } + } + } catch { + // Continue to next format + } + } + + return null + }, + [filterType, presetMode, dateFormats] + ) + + const handleInputChange = useCallback( + (e: ChangeEvent) => { + const newValue = e.target.value + setInputValue(newValue) + + // Try to parse the input + const parsed = parseInputValue(newValue) + if (parsed) { + onChange?.(parsed) + } + }, + [onChange, parseInputValue] + ) + + const handleInputBlur = useCallback(() => { + setIsInputFocused(false) + // Reset to display value if parsing failed + if (!parseInputValue(inputValue)) { + setInputValue(displayValue) + } + }, [inputValue, displayValue, parseInputValue]) + + return ( + +
+
+ {label && ( +

+ {label} +

+ )} + +
+ {showInput && ( +
+ setIsInputFocused(true)} + onBlur={handleInputBlur} + onChange={handleInputChange} + /> + {(inputHint ? inputValue : displayValue) && ( + + )} +
+ )} + + + {periodType === "day" ? ( +
+ +
+ ) : ( +
+ + {periodType === "month" && ( + + )} + + {periodType === "quarter" && ( + + )} + + {periodType === "half-year" && ( + + )} + + {periodType === "year" && ( + + )} + +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/reui/filters.tsx b/apps/web/src/components/reui/filters.tsx new file mode 100644 index 0000000..e19c946 --- /dev/null +++ b/apps/web/src/components/reui/filters.tsx @@ -0,0 +1,1924 @@ +import type React from "react" +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react" +import { useRender } from "@base-ui/react/use-render" +import { cva } from "class-variance-authority" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { + ButtonGroup, + ButtonGroupText, +} from "@cfdm/ui/components/button-group" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@cfdm/ui/components/dropdown-menu" +import { Input } from "@cfdm/ui/components/input" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + InputGroupText, +} from "@cfdm/ui/components/input-group" +import { Kbd } from "@cfdm/ui/components/kbd" +import { ScrollArea } from "@cfdm/ui/components/scroll-area" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@cfdm/ui/components/tooltip" +import { AlertCircleIcon, XIcon, CheckIcon } from "lucide-react" + +// i18n Configuration Interface +export interface FilterI18nConfig { + // UI Labels + addFilter: string + searchFields: string + noFieldsFound: string + noResultsFound: string + select: string + true: string + false: string + min: string + max: string + to: string + typeAndPressEnter: string + selected: string + selectedCount: string + percent: string + defaultCurrency: string + defaultColor: string + addFilterTitle: string + + // Operators + operators: { + is: string + isNot: string + isAnyOf: string + isNotAnyOf: string + includesAll: string + excludesAll: string + before: string + after: string + between: string + notBetween: string + contains: string + notContains: string + startsWith: string + endsWith: string + isExactly: string + equals: string + notEquals: string + greaterThan: string + lessThan: string + overlaps: string + includes: string + excludes: string + includesAllOf: string + includesAnyOf: string + empty: string + notEmpty: string + } + + // Placeholders + placeholders: { + enterField: (fieldType: string) => string + selectField: string + searchField: (fieldName: string) => string + enterKey: string + enterValue: string + } + + // Helper functions + helpers: { + formatOperator: (operator: string) => string + } + + // Validation + validation: { + invalidEmail: string + invalidUrl: string + invalidTel: string + invalid: string + } +} + +// Default English i18n configuration +export const DEFAULT_I18N: FilterI18nConfig = { + // UI Labels + addFilter: "Filter", + searchFields: "Filter...", + noFieldsFound: "No filters found.", + noResultsFound: "No results found.", + select: "Select...", + true: "True", + false: "False", + min: "Min", + max: "Max", + to: "to", + typeAndPressEnter: "Type and press Enter to add tag", + selected: "selected", + selectedCount: "selected", + percent: "%", + defaultCurrency: "$", + defaultColor: "#000000", + addFilterTitle: "Add filter", + + // Operators + operators: { + is: "is", + isNot: "is not", + isAnyOf: "is any of", + isNotAnyOf: "is not any of", + includesAll: "includes all", + excludesAll: "excludes all", + before: "before", + after: "after", + between: "between", + notBetween: "not between", + contains: "contains", + notContains: "does not contain", + startsWith: "starts with", + endsWith: "ends with", + isExactly: "is exactly", + equals: "equals", + notEquals: "not equals", + greaterThan: "greater than", + lessThan: "less than", + overlaps: "overlaps", + includes: "includes", + excludes: "excludes", + includesAllOf: "includes all of", + includesAnyOf: "includes any of", + empty: "is empty", + notEmpty: "is not empty", + }, + + // Placeholders + placeholders: { + enterField: (fieldType: string) => `Enter ${fieldType}...`, + selectField: "Select...", + searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`, + enterKey: "Enter key...", + enterValue: "Enter value...", + }, + + // Helper functions + helpers: { + formatOperator: (operator: string) => operator.replace(/_/g, " "), + }, + + // Validation + validation: { + invalidEmail: "Invalid email format", + invalidUrl: "Invalid URL format", + invalidTel: "Invalid phone format", + invalid: "Invalid input format", + }, +} + +// Context for all Filter component props +interface FilterContextValue { + variant: "solid" | "default" + size: "sm" | "default" | "lg" + radius: "default" | "full" + i18n: FilterI18nConfig + className?: string + showSearchInput?: boolean + trigger?: React.ReactNode + allowMultiple?: boolean +} + +const FilterContext = createContext({ + variant: "default", + size: "default", + radius: "default", + i18n: DEFAULT_I18N, + className: undefined, + showSearchInput: true, + trigger: undefined, + allowMultiple: true, +}) + +const useFilterContext = () => useContext(FilterContext) + +// Container variant for filters wrapper +const filtersContainerVariants = cva("flex flex-wrap items-center", { + variants: { + variant: { + solid: "gap-2", + default: "", + }, + size: { + sm: "gap-1.5", + default: "gap-2.5", + lg: "gap-3.5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, +}) + +function FilterInput({ + field, + onBlur, + onKeyDown, + className, + ...props +}: React.InputHTMLAttributes & { + className?: string + field?: FilterFieldConfig +}) { + const context = useFilterContext() + const [isValid, setIsValid] = useState(true) + const [validationMessage, setValidationMessage] = useState("") + const inputRef = useRef(null) + + useEffect(() => { + if (props.autoFocus) { + const timer = setTimeout(() => { + inputRef.current?.focus() + }, 300) + return () => clearTimeout(timer) + } + }, [props.autoFocus]) + + // Validation function to check if input matches pattern + const validateInput = (value: string, pattern?: string): boolean => { + if (!pattern || !value) return true + const regex = new RegExp(pattern) + return regex.test(value) + } + + // Get validation message for field type + const getValidationMessage = (): string => { + return context.i18n.validation.invalid + } + + // Handle blur event - validate when user leaves input + const handleBlur = (e: React.FocusEvent) => { + const value = e.target.value + const pattern = field?.pattern || props.pattern + + // Only validate if there's a value and (pattern or validation function) + if (value && (pattern || field?.validation)) { + let valid = true + let customMessage = "" + + // If there's a custom validation function, use it + if (field?.validation) { + const result = field.validation(value) + // Handle both boolean and object return types + if (typeof result === "boolean") { + valid = result + } else { + valid = result.valid + customMessage = result.message || "" + } + } else if (pattern) { + // Use pattern validation + valid = validateInput(value, pattern) + } + + setIsValid(valid) + setValidationMessage(valid ? "" : customMessage || getValidationMessage()) + } else { + // Reset validation state for empty values or no validation + setIsValid(true) + setValidationMessage("") + } + + // Call the original onBlur if provided + onBlur?.(e) + } + + // Handle keydown event - hide validation error when user starts typing + const handleKeyDown = (e: React.KeyboardEvent) => { + // Hide validation error when user starts typing (any key except special keys) + if ( + !isValid && + ![ + "Tab", + "Escape", + "Enter", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + ].includes(e.key) + ) { + setIsValid(true) + setValidationMessage("") + } + + // Call the original onKeyDown if provided + onKeyDown?.(e) + } + + return ( + + {field?.prefix && ( + + {field.prefix} + + )} + + {!isValid && validationMessage && ( + + + }> + + + +

{validationMessage}

+
+
+
+ )} + + {field?.suffix && ( + + {field.suffix} + + )} +
+ ) +} + +interface FilterRemoveButtonProps extends React.ButtonHTMLAttributes { + icon?: React.ReactNode +} + +function FilterRemoveButton({ + className, + icon = ( + + ), + ...props +}: FilterRemoveButtonProps) { + const context = useFilterContext() + + return ( + + ) +} + +// Generic types for flexible filter system +export interface FilterOption { + value: T + label: string + icon?: React.ReactNode + metadata?: Record + className?: string +} + +export interface FilterOperator { + value: string + label: string + supportsMultiple?: boolean +} + +// Custom renderer props interface +export interface CustomRendererProps { + field: FilterFieldConfig + values: T[] + onChange: (values: T[]) => void + operator: string +} + +// Grouped field configuration interface +export interface FilterFieldGroup { + group?: string + fields: FilterFieldConfig[] +} + +// Union type for both flat and grouped field configurations +export type FilterFieldsConfig = + | FilterFieldConfig[] + | FilterFieldGroup[] + +export interface FilterFieldConfig { + key?: string + label?: string + icon?: React.ReactNode + type?: "select" | "multiselect" | "text" | "custom" | "separator" + // Group-level configuration + group?: string + fields?: FilterFieldConfig[] + // Field-specific options + options?: FilterOption[] + operators?: FilterOperator[] + customRenderer?: (props: CustomRendererProps) => React.ReactNode + customValueRenderer?: ( + values: T[], + options: FilterOption[] + ) => React.ReactNode + placeholder?: string + searchable?: boolean + maxSelections?: number + min?: number + max?: number + step?: number + prefix?: string | React.ReactNode + suffix?: string | React.ReactNode + pattern?: string + validation?: ( + value: unknown + ) => boolean | { valid: boolean; message?: string } + allowCustomValues?: boolean + className?: string + menuPopupClassName?: string + // Grouping options (legacy support) + groupLabel?: string + // Boolean field options + onLabel?: string + offLabel?: string + // Input event handlers + onInputChange?: (e: React.ChangeEvent) => void + // Default operator to use when creating a filter for this field + defaultOperator?: string + // Controlled values support for this field + value?: T[] + onValueChange?: (values: T[]) => void +} + +// Helper functions to handle both flat and grouped field configurations +const isFieldGroup = ( + item: FilterFieldConfig | FilterFieldGroup +): item is FilterFieldGroup => { + return "fields" in item && Array.isArray(item.fields) +} + +// Helper function to check if a FilterFieldConfig is a group-level configuration +const isGroupLevelField = ( + field: FilterFieldConfig +): boolean => { + return Boolean(field.group && field.fields) +} + +const flattenFields = ( + fields: FilterFieldsConfig +): FilterFieldConfig[] => { + return fields.reduce[]>((acc, item) => { + if (isFieldGroup(item)) { + return [...acc, ...item.fields] + } + // Handle group-level fields (new structure) + if (isGroupLevelField(item)) { + return [...acc, ...item.fields!] + } + return [...acc, item] + }, []) +} + +const getFieldsMap = ( + fields: FilterFieldsConfig +): Record> => { + const flatFields = flattenFields(fields) + return flatFields.reduce( + (acc, field) => { + // Only add fields that have a key (skip group-level configurations) + if (field.key) { + acc[field.key] = field + } + return acc + }, + {} as Record> + ) +} + +// Helper function to create operators from i18n config +const createOperatorsFromI18n = ( + i18n: FilterI18nConfig +): Record => ({ + select: [ + { value: "is", label: i18n.operators.is }, + { value: "is_not", label: i18n.operators.isNot }, + { value: "empty", label: i18n.operators.empty }, + { value: "not_empty", label: i18n.operators.notEmpty }, + ], + multiselect: [ + { value: "is_any_of", label: i18n.operators.isAnyOf }, + { value: "is_not_any_of", label: i18n.operators.isNotAnyOf }, + { value: "includes_all", label: i18n.operators.includesAll }, + { value: "excludes_all", label: i18n.operators.excludesAll }, + { value: "empty", label: i18n.operators.empty }, + { value: "not_empty", label: i18n.operators.notEmpty }, + ], + text: [ + { value: "contains", label: i18n.operators.contains }, + { value: "not_contains", label: i18n.operators.notContains }, + { value: "starts_with", label: i18n.operators.startsWith }, + { value: "ends_with", label: i18n.operators.endsWith }, + { value: "is", label: i18n.operators.isExactly }, + { value: "empty", label: i18n.operators.empty }, + { value: "not_empty", label: i18n.operators.notEmpty }, + ], + custom: [ + { value: "is", label: i18n.operators.is }, + { value: "after", label: i18n.operators.after }, + { value: "is", label: i18n.operators.is }, + { value: "between", label: i18n.operators.between }, + { value: "empty", label: i18n.operators.empty }, + { value: "not_empty", label: i18n.operators.notEmpty }, + ], +}) + +// Default operators for different field types (using default i18n) +export const DEFAULT_OPERATORS: Record = + createOperatorsFromI18n(DEFAULT_I18N) + +// Helper function to get operators for a field +const getOperatorsForField = ( + field: FilterFieldConfig, + values: T[], + i18n: FilterI18nConfig +): FilterOperator[] => { + if (field.operators) return field.operators + + const operators = createOperatorsFromI18n(i18n) + + // Determine field type for operator selection + let fieldType = field.type || "select" + + // If it's a select field but has multiple values, treat as multiselect + if (fieldType === "select" && values.length > 1) { + fieldType = "multiselect" + } + + // If it's a multiselect field or has multiselect operators, use multiselect operators + if (fieldType === "multiselect" || field.type === "multiselect") { + return operators.multiselect + } + + return operators[fieldType] || operators.select +} + +interface FilterOperatorDropdownProps { + field: FilterFieldConfig + operator: string + values: T[] + onChange: (operator: string) => void +} + +function FilterOperatorDropdown({ + field, + operator, + values, + onChange, +}: FilterOperatorDropdownProps) { + const context = useFilterContext() + const operators = getOperatorsForField(field, values, context.i18n) + + // Find the operator label, with fallback to formatted operator name + const operatorLabel = + operators.find((op) => op.value === operator)?.label || + context.i18n.helpers.formatOperator(operator) + + return ( + + + {operatorLabel} + + } + /> + + {operators.map((op) => ( + onChange(op.value)} + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground flex items-center justify-between" + )} + > + {op.label} + + + ))} + + + ) +} + +interface FilterValueSelectorProps { + field: FilterFieldConfig + values: T[] + onChange: (values: T[]) => void + operator: string + autoFocus?: boolean +} + +interface SelectOptionsPopoverProps { + field: FilterFieldConfig + values: T[] + onChange: (values: T[]) => void + onClose?: () => void + inline?: boolean +} + +function SelectOptionsPopover({ + field, + values, + onChange, + onClose, + inline = false, +}: SelectOptionsPopoverProps) { + const [open, setOpen] = useState(false) + const [searchInput, setSearchInput] = useState("") + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const inputRef = useRef(null) + const context = useFilterContext() + const baseId = useId() + + useEffect(() => { + if (open) { + inputRef.current?.focus() + } + }, [open]) + + useEffect(() => { + setHighlightedIndex(-1) + }, [searchInput, open]) + + useEffect(() => { + if (highlightedIndex >= 0 && open) { + const element = document.getElementById( + `${baseId}-item-${highlightedIndex}` + ) + element?.scrollIntoView({ block: "nearest" }) + } + }, [highlightedIndex, open, baseId]) + + const isMultiSelect = field.type === "multiselect" || values.length > 1 + const effectiveValues = + (field.value !== undefined ? (field.value as T[]) : values) || [] + + const selectedOptions = + field.options?.filter((opt) => effectiveValues.includes(opt.value)) || [] + const unselectedOptions = + field.options?.filter((opt) => !effectiveValues.includes(opt.value)) || [] + + // Filter options based on search input + const filteredSelectedOptions = selectedOptions // Keep all selected visible + const filteredUnselectedOptions = unselectedOptions.filter((opt) => + opt.label.toLowerCase().includes(searchInput.toLowerCase()) + ) + + const allFilteredOptions = useMemo( + () => [...filteredSelectedOptions, ...filteredUnselectedOptions], + [filteredSelectedOptions, filteredUnselectedOptions] + ) + + const handleClose = () => { + setOpen(false) + onClose?.() + } + + const renderMenuContent = () => ( + <> + {field.searchable !== false && ( + <> + = 0 + ? `${baseId}-item-${highlightedIndex}` + : undefined + } + placeholder={context.i18n.placeholders.searchField( + field.label || "" + )} + className={cn( + "border-input h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none", + "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0", + open && "placeholder:text-foreground" + )} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} + onBlur={() => open && inputRef.current?.focus()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault() + if (allFilteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev < allFilteredOptions.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (allFilteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : allFilteredOptions.length - 1 + ) + } + } else if (e.key === "ArrowLeft") { + e.preventDefault() + setOpen(false) + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault() + const option = allFilteredOptions[highlightedIndex] + if (option) { + const isSelected = effectiveValues.includes(option.value as T) + const next = isSelected + ? (effectiveValues.filter((v) => v !== option.value) as T[]) + : isMultiSelect + ? ([...effectiveValues, option.value] as T[]) + : ([option.value] as T[]) + + if ( + !isSelected && + isMultiSelect && + field.maxSelections && + next.length > field.maxSelections + ) { + return + } + + if (field.onValueChange) { + field.onValueChange(next) + } else { + onChange(next) + } + if (!isMultiSelect) handleClose() + } + } + e.stopPropagation() + }} + /> + + + )} +
+
+ + {allFilteredOptions.length === 0 && ( +
+ {context.i18n.noResultsFound} +
+ )} + + {/* Selected items */} + {filteredSelectedOptions.length > 0 && ( + + {filteredSelectedOptions.map((option, index) => { + const isHighlighted = highlightedIndex === index + const itemId = `${baseId}-item-${index}` + + return ( + setHighlightedIndex(index)} + checked={true} + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground", + option.className + )} + onSelect={(e) => { + if (isMultiSelect) e.preventDefault() + }} + onCheckedChange={() => { + const next = effectiveValues.filter( + (v) => v !== option.value + ) as T[] + if (field.onValueChange) { + field.onValueChange(next) + } else { + onChange(next) + } + if (!isMultiSelect) handleClose() + }} + > + {option.icon && option.icon} + {option.label} + + ) + })} + + )} + + {/* Separator */} + {filteredSelectedOptions.length > 0 && + filteredUnselectedOptions.length > 0 && ( + + )} + + {/* Available items */} + {filteredUnselectedOptions.length > 0 && ( + + {filteredUnselectedOptions.map((option, index) => { + const overallIndex = index + filteredSelectedOptions.length + const isHighlighted = highlightedIndex === overallIndex + const itemId = `${baseId}-item-${overallIndex}` + + return ( + setHighlightedIndex(overallIndex)} + checked={false} + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground", + option.className + )} + onSelect={(e) => { + if (isMultiSelect) e.preventDefault() + }} + onCheckedChange={() => { + const next = isMultiSelect + ? ([...effectiveValues, option.value] as T[]) + : ([option.value] as T[]) + + if ( + isMultiSelect && + field.maxSelections && + next.length > field.maxSelections + ) { + return + } + + if (field.onValueChange) { + field.onValueChange(next) + } else { + onChange(next) + } + if (!isMultiSelect) handleClose() + }} + > + {option.icon && option.icon} + {option.label} + + ) + })} + + )} +
+
+
+ + ) + + if (inline) { + return
{renderMenuContent()}
+ } + + return ( + { + setOpen(open) + if (!open) { + setTimeout(() => setSearchInput(""), 200) + } + }} + > + +
+ {field.customValueRenderer ? ( + field.customValueRenderer(values, field.options || []) + ) : ( + <> + {selectedOptions.length > 0 && ( +
+ {selectedOptions.slice(0, 3).map((option) => ( +
{option.icon}
+ ))} +
+ )} + {selectedOptions.length === 1 + ? selectedOptions[0].label + : selectedOptions.length > 1 + ? `${selectedOptions.length} ${context.i18n.selectedCount}` + : context.i18n.select} + + )} +
+ + } + /> + + {renderMenuContent()} + +
+ ) +} + +function FilterValueSelector({ + field, + values, + onChange, + operator, + autoFocus, +}: FilterValueSelectorProps) { + if (operator === "empty" || operator === "not_empty") { + return null + } + + if (field.customRenderer) { + return ( + + {field.customRenderer({ field, values, onChange, operator })} + + ) + } + + if (field.type === "text") { + return ( + onChange([e.target.value] as T[])} + placeholder={field.placeholder} + pattern={field.pattern} + field={field} + className={cn("w-36", field.className)} + autoFocus={autoFocus} + /> + ) + } + + if (field.type === "select" || field.type === "multiselect") { + return ( + + ) + } + + return ( + + ) +} +export interface Filter { + id: string + field: string + operator: string + values: T[] +} + +export interface FilterGroup { + id: string + label?: string + filters: Filter[] + fields: FilterFieldConfig[] +} + +interface FiltersContentProps { + filters: Filter[] + fields: FilterFieldsConfig + onChange: (filters: Filter[]) => void +} + +export const FiltersContent = ({ + filters, + fields, + onChange, +}: FiltersContentProps) => { + const context = useFilterContext() + const fieldsMap = useMemo(() => getFieldsMap(fields), [fields]) + + const updateFilter = useCallback( + (filterId: string, updates: Partial>) => { + onChange( + filters.map((filter) => { + if (filter.id === filterId) { + const updatedFilter = { ...filter, ...updates } + if ( + updates.operator === "empty" || + updates.operator === "not_empty" + ) { + updatedFilter.values = [] as T[] + } + return updatedFilter + } + return filter + }) + ) + }, + [filters, onChange] + ) + + const removeFilter = useCallback( + (filterId: string) => { + onChange(filters.filter((filter) => filter.id !== filterId)) + }, + [filters, onChange] + ) + + return ( +
+ {filters.map((filter) => { + const field = fieldsMap[filter.field] + if (!field) return null + + return ( + + + {field.icon && field.icon} + {field.label} + + + + field={field} + operator={filter.operator} + values={filter.values} + onChange={(operator) => updateFilter(filter.id, { operator })} + /> + + + field={field} + values={filter.values} + onChange={(values) => updateFilter(filter.id, { values })} + operator={filter.operator} + autoFocus={false} + /> + + removeFilter(filter.id)} /> + + ) + })} +
+ ) +} + +interface FiltersProps { + filters: Filter[] + fields: FilterFieldsConfig + onChange: (filters: Filter[]) => void + className?: string + variant?: "solid" | "default" + size?: "sm" | "default" | "lg" + radius?: "default" | "full" + i18n?: Partial + showSearchInput?: boolean + trigger?: React.ReactNode + allowMultiple?: boolean + menuPopupClassName?: string + collapseAddButton?: boolean + enableShortcut?: boolean + shortcutKey?: string + shortcutLabel?: string +} + +interface FilterSubmenuContentProps { + field: FilterFieldConfig + currentValues: T[] + isMultiSelect: boolean + onToggle: (value: T, isSelected: boolean) => void + i18n: FilterI18nConfig + isActive?: boolean + onActive?: () => void + onBack?: () => void + onClose?: () => void +} + +function FilterSubmenuContent({ + field, + currentValues, + isMultiSelect, + onToggle, + i18n, + isActive, + onActive, + onBack, + onClose, +}: FilterSubmenuContentProps) { + const [searchInput, setSearchInput] = useState("") + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const inputRef = useRef(null) + const baseId = useId() + + useEffect(() => { + if (isActive) { + if (field.searchable !== false) { + inputRef.current?.focus() + } else { + const listbox = document.getElementById(`${baseId}-listbox`) + listbox?.focus() + } + } + }, [isActive, field.searchable, baseId]) + + useEffect(() => { + setHighlightedIndex(-1) + }, [searchInput]) + + useEffect(() => { + if (highlightedIndex >= 0 && isActive) { + const element = document.getElementById( + `${baseId}-item-${highlightedIndex}` + ) + element?.scrollIntoView({ block: "nearest" }) + } + }, [highlightedIndex, isActive, baseId]) + + const filteredOptions = useMemo(() => { + return ( + field.options?.filter((option) => { + const isSelected = currentValues.includes(option.value) + if (isSelected) return true + if (!searchInput) return true + return option.label.toLowerCase().includes(searchInput.toLowerCase()) + }) || [] + ) + }, [field.options, searchInput, currentValues]) + + useEffect(() => { + if (isActive && filteredOptions.length > 0) { + setHighlightedIndex(0) + } + }, [isActive, filteredOptions.length]) + + return ( +
+ {field.searchable !== false && ( + <> + = 0 + ? `${baseId}-item-${highlightedIndex}` + : undefined + } + placeholder={i18n.placeholders.searchField(field.label || "")} + className={cn( + "h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none", + "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0", + isActive && "placeholder:text-foreground" + )} + value={searchInput} + onBlur={() => isActive && inputRef.current?.focus()} + onChange={(e) => setSearchInput(e.target.value)} + onFocus={() => onActive?.()} + onMouseEnter={(e) => { + onActive?.() + e.stopPropagation() + }} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault() + if (filteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev < filteredOptions.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (filteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredOptions.length - 1 + ) + } + } else if (e.key === "ArrowLeft") { + e.preventDefault() + onBack?.() + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault() + const option = filteredOptions[highlightedIndex] + if (option) { + onToggle( + option.value as T, + currentValues.includes(option.value) + ) + if (!isMultiSelect) { + onBack?.() + } + } + } else if (e.key === "Escape") { + e.preventDefault() + onClose?.() + } + e.stopPropagation() + }} + /> + + + )} +
+
{ + if (field.searchable === false) { + if (e.key === "ArrowDown") { + e.preventDefault() + if (filteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev < filteredOptions.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (filteredOptions.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredOptions.length - 1 + ) + } + } else if (e.key === "ArrowLeft") { + e.preventDefault() + onBack?.() + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault() + const option = filteredOptions[highlightedIndex] + if (option) { + onToggle( + option.value as T, + currentValues.includes(option.value) + ) + if (!isMultiSelect) { + onBack?.() + } + } + } else if (e.key === "Escape") { + e.preventDefault() + onClose?.() + } + e.stopPropagation() + } + }} + > + + {filteredOptions.length === 0 ? ( +
+ {i18n.noResultsFound} +
+ ) : ( + + {filteredOptions.map((option, index) => { + const isSelected = currentValues.includes(option.value) + const isHighlighted = highlightedIndex === index + const itemId = `${baseId}-item-${index}` + + return ( + setHighlightedIndex(index)} + checked={isSelected} + className={cn( + "data-highlighted:bg-accent data-highlighted:text-accent-foreground", + option.className + )} + onSelect={(e) => { + if (isMultiSelect) e.preventDefault() + }} + onCheckedChange={() => + onToggle(option.value as T, isSelected) + } + > + {option.icon && option.icon} + {option.label} + + ) + })} + + )} +
+
+
+
+ ) +} + +export function Filters({ + filters, + fields, + onChange, + className, + variant = "default", + size = "default", + radius = "default", + i18n, + showSearchInput = true, + trigger, + allowMultiple = true, + menuPopupClassName, + enableShortcut = false, + shortcutKey = "f", + shortcutLabel = "F", +}: FiltersProps) { + const [addFilterOpen, setAddFilterOpen] = useState(false) + const [menuSearchInput, setMenuSearchInput] = useState("") + const [activeMenu, setActiveMenu] = useState("root") + const [openSubMenu, setOpenSubMenu] = useState(null) + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const [lastAddedFilterId, setLastAddedFilterId] = useState( + null + ) + const rootInputRef = useRef(null) + const rootId = useId() + + useEffect(() => { + if (!enableShortcut) return + + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.key.toLowerCase() === shortcutKey.toLowerCase() && + !addFilterOpen && + !( + document.activeElement instanceof HTMLInputElement || + document.activeElement instanceof HTMLTextAreaElement + ) + ) { + e.preventDefault() + setAddFilterOpen(true) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [enableShortcut, shortcutKey, addFilterOpen]) + + useEffect(() => { + if (addFilterOpen && activeMenu === "root") { + rootInputRef.current?.focus() + } + }, [addFilterOpen, activeMenu]) + + useEffect(() => { + setHighlightedIndex(-1) + }, [menuSearchInput]) + + useEffect(() => { + if (highlightedIndex >= 0 && addFilterOpen) { + const element = document.getElementById( + `${rootId}-item-${highlightedIndex}` + ) + element?.scrollIntoView({ block: "nearest" }) + } + }, [highlightedIndex, addFilterOpen, rootId]) + + useEffect(() => { + if (!addFilterOpen) { + setOpenSubMenu(null) + } + }, [addFilterOpen]) + + // Track which filter instance is being built in the current Add Filter menu session + // Maps fieldKey -> unique filterId created during this open session + const [sessionFilterIds, setSessionFilterIds] = useState< + Record + >({}) + + useEffect(() => { + if (lastAddedFilterId) { + const timer = setTimeout(() => { + setLastAddedFilterId(null) + }, 1000) + return () => clearTimeout(timer) + } + }, [lastAddedFilterId]) + + const mergedI18n: FilterI18nConfig = { + ...DEFAULT_I18N, + ...i18n, + operators: { ...DEFAULT_I18N.operators, ...i18n?.operators }, + placeholders: { ...DEFAULT_I18N.placeholders, ...i18n?.placeholders }, + validation: { ...DEFAULT_I18N.validation, ...i18n?.validation }, + } + + const fieldsMap = useMemo(() => getFieldsMap(fields), [fields]) + + const updateFilter = useCallback( + (filterId: string, updates: Partial>) => { + onChange( + filters.map((filter) => { + if (filter.id === filterId) { + const updatedFilter = { ...filter, ...updates } + if ( + updates.operator === "empty" || + updates.operator === "not_empty" + ) { + updatedFilter.values = [] as T[] + } + return updatedFilter + } + return filter + }) + ) + }, + [filters, onChange] + ) + + const removeFilter = useCallback( + (filterId: string) => { + onChange(filters.filter((filter) => filter.id !== filterId)) + }, + [filters, onChange] + ) + + const addFilter = useCallback( + (fieldKey: string) => { + const field = fieldsMap[fieldKey] + if (field && field.key) { + const defaultOperator = + field.defaultOperator || + (field.type === "multiselect" ? "is_any_of" : "is") + const defaultValues: unknown[] = field.type === "text" ? [""] : [] + const newFilter = createFilter( + fieldKey, + defaultOperator, + defaultValues as T[] + ) + setLastAddedFilterId(newFilter.id) + onChange([...filters, newFilter]) + setAddFilterOpen(false) + setMenuSearchInput("") + } + }, + [fieldsMap, filters, onChange] + ) + + useEffect(() => { + if (addFilterOpen && activeMenu === "root") { + rootInputRef.current?.focus() + } + }, [addFilterOpen, activeMenu]) + + const selectableFields = useMemo(() => { + const flatFields = flattenFields(fields) + return flatFields.filter((field) => { + if (!field.key || field.type === "separator") return false + if (allowMultiple) return true + return !filters.some((filter) => filter.field === field.key) + }) + }, [fields, filters, allowMultiple]) + + const filteredFields = useMemo(() => { + return selectableFields.filter( + (f) => + !menuSearchInput || + f.label?.toLowerCase().includes(menuSearchInput.toLowerCase()) + ) + }, [selectableFields, menuSearchInput]) + + useEffect(() => { + if (addFilterOpen && filteredFields.length > 0) { + setHighlightedIndex(0) + } + }, [addFilterOpen, filteredFields.length]) + + const triggerButton = useRender({ + render: trigger as React.ReactElement, + defaultTagName: "button", + }) + + return ( + +
+ {selectableFields.length > 0 && ( + { + setAddFilterOpen(open) + if (!open) { + setMenuSearchInput("") + setSessionFilterIds({}) + } else { + setActiveMenu("root") + } + }} + > + + + {showSearchInput && ( + <> +
+ = 0 + ? `${rootId}-item-${highlightedIndex}` + : undefined + } + placeholder={mergedI18n.searchFields} + className={cn( + "h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none", + "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0", + activeMenu === "root" && "placeholder:text-foreground" + )} + value={menuSearchInput} + onFocus={() => setActiveMenu("root")} + onMouseEnter={() => setActiveMenu("root")} + onBlur={() => + activeMenu === "root" && rootInputRef.current?.focus() + } + onChange={(e) => setMenuSearchInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault() + if (filteredFields.length > 0) { + setHighlightedIndex((prev) => + prev < filteredFields.length - 1 ? prev + 1 : 0 + ) + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (filteredFields.length > 0) { + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredFields.length - 1 + ) + } + } else if ( + (e.key === "ArrowRight" || e.key === "ArrowLeft") && + highlightedIndex >= 0 + ) { + const field = filteredFields[highlightedIndex] + const hasSubMenu = + field && + (field.type === "select" || + field.type === "multiselect") && + field.options?.length + + if (e.key === "ArrowRight" && hasSubMenu) { + e.preventDefault() + setOpenSubMenu(field.key || null) + setActiveMenu(field.key || "root") + } else if (e.key === "ArrowLeft") { + e.preventDefault() + if (openSubMenu) { + setOpenSubMenu(null) + setActiveMenu("root") + } + } + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault() + const field = filteredFields[highlightedIndex] + if (field.key) { + const hasSubMenu = + (field.type === "select" || + field.type === "multiselect") && + field.options?.length + if (!hasSubMenu) { + addFilter(field.key) + } else { + if (openSubMenu === field.key) { + setOpenSubMenu(null) + setActiveMenu("root") + } else { + setOpenSubMenu(field.key) + setActiveMenu(field.key) + } + } + } + } else if (e.key === "Escape") { + setAddFilterOpen(false) + } + e.stopPropagation() + }} + /> + {enableShortcut && shortcutLabel && ( + + {shortcutLabel} + + )} +
+ + + )} + +
+
setActiveMenu("root")} + > + + {(() => { + if (filteredFields.length === 0) { + return ( +
+ {mergedI18n.noFieldsFound} +
+ ) + } + + return filteredFields.map((field, index) => { + const isHighlighted = highlightedIndex === index + const itemId = `${rootId}-item-${index}` + const hasSubMenu = + (field.type === "select" || + field.type === "multiselect") && + field.options?.length + + if (hasSubMenu) { + const isMultiSelect = field.type === "multiselect" + const fieldKey = field.key as string + const sessionFilterId = sessionFilterIds[fieldKey] + const sessionFilter = sessionFilterId + ? filters.find((f) => f.id === sessionFilterId) + : null + const currentValues = sessionFilter?.values || [] + + return ( + { + if (open) { + setOpenSubMenu(fieldKey) + } else { + if (openSubMenu === fieldKey) { + setOpenSubMenu(null) + setActiveMenu("root") + } + } + }} + > + { + setHighlightedIndex(index) + setActiveMenu("root") + }} + className="data-popup-open:bg-accent data-popup-open:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground" + > + {field.icon} + {field.label} + + + { + if (field.searchable !== false) { + setActiveMenu(fieldKey) + } + }} + onBack={() => { + setOpenSubMenu(null) + setActiveMenu("root") + }} + onClose={() => setAddFilterOpen(false)} + onToggle={(value, isSelected) => { + if (isMultiSelect) { + const nextValues = isSelected + ? (currentValues.filter( + (v) => v !== value + ) as T[]) + : ([...currentValues, value] as T[]) + + if (sessionFilter) { + if (nextValues.length === 0) { + onChange( + filters.filter( + (f) => f.id !== sessionFilter.id + ) + ) + setSessionFilterIds((prev) => ({ + ...prev, + [fieldKey]: "", + })) + } else { + onChange( + filters.map((f) => + f.id === sessionFilter.id + ? { ...f, values: nextValues } + : f + ) + ) + } + } else { + const newFilter = createFilter( + fieldKey, + field.defaultOperator || "is_any_of", + nextValues + ) + onChange([...filters, newFilter]) + setSessionFilterIds((prev) => ({ + ...prev, + [fieldKey]: newFilter.id, + })) + } + } else { + const newFilter = createFilter( + fieldKey, + field.defaultOperator || "is", + [value] as T[] + ) + setLastAddedFilterId(newFilter.id) + onChange([...filters, newFilter]) + setAddFilterOpen(false) + } + }} + /> + + + ) + } + + return ( + setHighlightedIndex(index)} + onClick={() => field.key && addFilter(field.key)} + className="data-highlighted:bg-accent data-highlighted:text-accent-foreground" + > + {field.icon} + {field.label} + + ) + }) + })()} +
+
+
+
+
+ )} + + {filters.map((filter) => { + const field = fieldsMap[filter.field] + if (!field) return null + return ( + + + {field.icon && field.icon} + {field.label} + + + field={field} + operator={filter.operator} + values={filter.values} + onChange={(operator) => updateFilter(filter.id, { operator })} + /> + + field={field} + values={filter.values} + operator={filter.operator} + onChange={(values) => updateFilter(filter.id, { values })} + autoFocus={filter.id === lastAddedFilterId} + /> + removeFilter(filter.id)} /> + + ) + })} +
+
+ ) +} + +export const createFilter = ( + field: string, + operator?: string, + values: T[] = [] +): Filter => ({ + id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, + field, + operator: operator || "is", + values, +}) + +export const createFilterGroup = ( + id: string, + label: string, + fields: FilterFieldConfig[], + initialFilters: Filter[] = [] +): FilterGroup => ({ + id, + label, + filters: initialFilters, + fields, +}) \ No newline at end of file diff --git a/apps/web/src/components/reui/number-field.tsx b/apps/web/src/components/reui/number-field.tsx new file mode 100644 index 0000000..e12c156 --- /dev/null +++ b/apps/web/src/components/reui/number-field.tsx @@ -0,0 +1,258 @@ +import { createContext, type ReactNode, useContext, useId } from "react" +import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@cfdm/ui/lib/utils" +import { Label } from "@cfdm/ui/components/label" +import { MinusIcon, PlusIcon } from "lucide-react" + +const NumberFieldContext = createContext<{ + fieldId: string + size: "sm" | "default" | "lg" +} | null>(null) + +const numberFieldGroupVariants = cva( + "relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3", + { + variants: { + size: { + sm: "h-7 text-sm", + default: + "h-8 text-sm", + lg: "h-9 text-sm", + }, + }, + defaultVariants: { + size: "default", + }, + } +) + +const numberFieldButtonVariants = cva( + "relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent", + { + variants: { + size: { + sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5", + default: + "px-2 [&_svg:not([class*='size-'])]:size-4", + lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4", + }, + }, + defaultVariants: { + size: "default", + }, + } +) + +const numberFieldInputVariants = cva( + "w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none", + { + variants: { + size: { + sm: "px-2 py-0.5", + default: + "px-2.5 py-1", + lg: "px-2.5 py-1.5", + }, + }, + defaultVariants: { + size: "default", + }, + } +) + +function NumberField({ + id, + className, + size = "default", + ...props +}: NumberFieldPrimitive.Root.Props & + VariantProps) { + const generatedId = useId() + const fieldId = id ?? generatedId + const sizeValue = size ?? "default" + + return ( + + + + ) +} + +function NumberFieldGroup({ + className, + size: sizeProp, + ...props +}: NumberFieldPrimitive.Group.Props & + Partial>) { + const context = useContext(NumberFieldContext) + if (!context) { + throw new Error( + "NumberFieldGroup must be used within a NumberField component." + ) + } + const size = sizeProp ?? context.size + + return ( + + ) +} + +function NumberFieldDecrement({ + className, + size: sizeProp, + children, + ...props +}: NumberFieldPrimitive.Decrement.Props & + Partial> & { + children?: React.ReactNode + }) { + const context = useContext(NumberFieldContext) + if (!context) { + throw new Error( + "NumberFieldDecrement must be used within a NumberField component." + ) + } + const size = sizeProp ?? context.size + + return ( + + {children ?? ( + + )} + + ) +} + +function NumberFieldIncrement({ + className, + size: sizeProp, + children, + ...props +}: NumberFieldPrimitive.Increment.Props & + Partial> & { + children?: ReactNode + }) { + const context = useContext(NumberFieldContext) + if (!context) { + throw new Error( + "NumberFieldIncrement must be used within a NumberField component." + ) + } + const size = sizeProp ?? context.size + + return ( + + {children ?? ( + + )} + + ) +} + +function NumberFieldInput({ + className, + size: sizeProp, + ...props +}: NumberFieldPrimitive.Input.Props & + Partial>) { + const context = useContext(NumberFieldContext) + if (!context) { + throw new Error( + "NumberFieldInput must be used within a NumberField component." + ) + } + const size = sizeProp ?? context.size + + return ( + + ) +} + +function NumberFieldScrubArea({ + className, + label, + ...props +}: NumberFieldPrimitive.ScrubArea.Props & { + label: string +}) { + const context = useContext(NumberFieldContext) + if (!context) { + throw new Error( + "NumberFieldScrubArea must be used within a NumberField component for accessibility." + ) + } + + return ( + + + + + + + ) +} + +function CursorGrowIcon(props: React.ComponentProps<"svg">) { + return ( + + + + ) +} + +export { + NumberField, + NumberFieldScrubArea, + NumberFieldDecrement, + NumberFieldIncrement, + NumberFieldGroup, + NumberFieldInput, +} \ No newline at end of file diff --git a/apps/web/src/components/section-cards-skeleton.tsx b/apps/web/src/components/section-cards-skeleton.tsx deleted file mode 100644 index 353ee9d..0000000 --- a/apps/web/src/components/section-cards-skeleton.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { - Card, - CardContent, - CardFooter, - CardHeader, -} from '@cfdm/ui/components/card' -import { Skeleton } from '@cfdm/ui/components/skeleton' - -export function SectionCardsSkeleton() { - return ( -
-
- {Array.from({ length: 4 }).map((_, index) => ( - - - - - - - - - - ))} -
-
- {Array.from({ length: 2 }).map((_, index) => ( - - - - - - - - - - ))} -
- - - - - - -
- {Array.from({ length: 4 }).map((_, index) => ( - - ))} -
-
-
-
- ) -} diff --git a/apps/web/src/components/section-cards.tsx b/apps/web/src/components/section-cards.tsx index 0cce32e..291a029 100644 --- a/apps/web/src/components/section-cards.tsx +++ b/apps/web/src/components/section-cards.tsx @@ -1,147 +1,95 @@ -import { Link } from '@tanstack/react-router' -import { - FolderTreeIcon, - GlobeIcon, - ServerIcon, - ShieldCheckIcon, -} from 'lucide-react' -import { StatusBadge } from '@/components/status-badge' -import { AppButton } from '@/components/app-button' -import { - AppCard, - AppCardAction, - AppCardContent, - AppCardDescription, - AppCardFooter, - AppCardHeader, - AppCardTitle, -} from '@/components/app-card' -import { - AppItem, - AppItemContent, - AppItemGroup, - AppItemTitle, -} from '@/components/app-item' +import type { ReactElement, ReactNode } from 'react' +import { Card, CardContent } from '@cfdm/ui/components/card' +import { cn } from '@cfdm/ui/lib/utils' +import { TruncatedText } from '@/components/truncated-text' -interface SectionCardsProps { - domainCount: number - certCount: number - groupCount: number - serviceCount: number - certSummary?: [string, number][] +export interface SectionCardItem { + label: ReactNode + value: string | number | ReactElement + hint?: ReactNode + icon?: ReactNode + badge?: ReactNode + variant?: 'default' | 'warning' | 'destructive' + active?: boolean + onClick?: () => void } -export function SectionCards({ - domainCount, - certCount, - groupCount, - serviceCount, - certSummary, -}: SectionCardsProps) { +const VARIANT_CLASS: Record, string> = { + default: '', + warning: 'border-warning/50', + destructive: 'border-destructive/50', +} + +function sectionGridClass(count: number): string { + if (count <= 1) return 'grid-cols-1' + if (count === 2) return 'sm:grid-cols-2' + if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3' + if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4' + if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5' + return 'sm:grid-cols-2 lg:grid-cols-3' +} + +export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) { return ( -
-
- - - Домены - - {domainCount} - - - - - - - } - > - Управление доменами - - - - - - Группы доменов - - {groupCount} - - - - - - - } - > - Канбан групп - - - - - - Сервисы - - {serviceCount} - - - - - - - } - > - Управление сервисами - - - - - - Сертификаты - - {certCount} - - - - - - - } - > - Мониторинг сертификатов - - - -
- {certSummary && certSummary.length > 0 ? ( - - - Статусы сертификатов - Сводка по последней проверке - - - - {certSummary.map(([status, count]) => ( - - - - {count} - - - ))} - - - - ) : null} +
+ {items.map((item, idx) => { + const clickable = Boolean(item.onClick) + const content = ( + + {item.icon ? ( + + {item.icon} + + ) : null} +
+
+ {typeof item.label === 'string' ? ( + {item.label} + ) : ( + {item.label} + )} + {item.badge ? {item.badge} : null} +
+
+ {item.value} + {item.hint ? ( + typeof item.hint === 'string' ? ( + · {item.hint} + ) : ( + · {item.hint} + ) + ) : null} +
+
+
+ ) + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + item.onClick?.() + } + } + : undefined + } + > + {content} + + ) + })}
) } diff --git a/apps/web/src/components/skeletons.tsx b/apps/web/src/components/skeletons.tsx new file mode 100644 index 0000000..0660532 --- /dev/null +++ b/apps/web/src/components/skeletons.tsx @@ -0,0 +1,38 @@ +import { SectionCards } from './section-cards' +import { Skeleton } from '@cfdm/ui/components/skeleton' +import { Card, CardContent } from '@cfdm/ui/components/card' + +export function SectionCardsSkeleton({ count = 4 }: { count?: number }) { + return ( + ({ + icon: , + label: , + value: , + }))} + /> + ) +} + +export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) { + return ( + + +
+
+ {Array.from({ length: cols }).map((_, i) => ( + + ))} +
+ {Array.from({ length: rows }).map((_, r) => ( +
+ {Array.from({ length: cols }).map((_, c) => ( + + ))} +
+ ))} +
+
+
+ ) +} diff --git a/apps/web/src/components/status-badge.tsx b/apps/web/src/components/status-badge.tsx index 93836ce..a976bea 100644 --- a/apps/web/src/components/status-badge.tsx +++ b/apps/web/src/components/status-badge.tsx @@ -1,22 +1,22 @@ -import { Badge, badgeVariants } from '@cfdm/ui/components/badge' -import type { VariantProps } from 'class-variance-authority' -import { cn } from '@cfdm/ui/lib/utils' +import type { ComponentProps } from 'react' -type BadgeVariant = NonNullable['variant']> +import { Badge } from '@/components/reui/badge' -const statusVariants: Record = { +type BadgeVariant = NonNullable['variant']> + +const STATUS_VARIANT: Record = { active: 'success', synced: 'success', ok: 'success', pending_push: 'secondary', - warning: 'secondary', + warning: 'warning', conflict: 'destructive', error: 'destructive', expired: 'destructive', unknown: 'outline', } -const labels: Record = { +const STATUS_LABELS: Record = { active: 'Активен', synced: 'Синхронизировано', pending_push: 'Ожидает отправки', @@ -28,20 +28,7 @@ const labels: Record = { unknown: 'Неизвестно', } -interface StatusBadgeProps { - status: string - className?: string -} - -export function StatusBadge({ status, className }: StatusBadgeProps) { - const variant = statusVariants[status] ?? 'outline' - return ( - - - {labels[status] ?? status} - - ) +export function StatusBadge({ status, label }: { status: string; label?: string }) { + const variant = STATUS_VARIANT[status] ?? 'outline' + return {label ?? STATUS_LABELS[status] ?? status} } diff --git a/apps/web/src/components/subdomains-data-grid.tsx b/apps/web/src/components/subdomains-data-grid.tsx new file mode 100644 index 0000000..f26443a --- /dev/null +++ b/apps/web/src/components/subdomains-data-grid.tsx @@ -0,0 +1,197 @@ +import { useMemo, useState } from 'react' +import { Link } from '@tanstack/react-router' +import { MoreHorizontalIcon } from 'lucide-react' + +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@cfdm/ui/components/dropdown-menu' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { + DataGridCard, + columnDefFromDataGrid, +} from '@/components/data-grid-card' +import type { DataGridColumn } from '@/components/data-grid-types' +import { ListFiltersBar } from '@/components/list-filters-bar' +import type { SubdomainTableRow } from '@/hooks/use-domain-page' +import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page' +import { formatDate } from '@/lib/format' + +interface SubdomainsDataGridProps { + domainId: string + rows: SubdomainTableRow[] + isDeleting?: boolean + isToggling?: boolean + onEdit: (row: SubdomainTableRow) => void + onDelete: (row: SubdomainTableRow) => void + onToggleEnabled: (row: SubdomainTableRow) => void + toolbar?: React.ReactNode +} + +export function SubdomainsDataGrid({ + domainId, + rows, + isDeleting = false, + isToggling = false, + onEdit, + onDelete, + onToggleEnabled, + toolbar, +}: SubdomainsDataGridProps) { + const [deleteTarget, setDeleteTarget] = useState(null) + const [search, setSearch] = useState('') + + const filteredRows = useMemo(() => { + const query = search.trim().toLowerCase() + if (!query) return rows + return rows.filter( + (row) => + row.subdomain.fqdn.toLowerCase().includes(query) || + formatSubdomainServiceLinks(row.serviceLinks).toLowerCase().includes(query), + ) + }, [rows, search]) + + const columns = useMemo[]>( + () => [ + { + key: 'fqdn', + header: 'Поддомен', + sortable: true, + sortValue: (row) => row.subdomain.fqdn, + cell: (row) => ( + {row.subdomain.fqdn} + ), + }, + { + key: 'status', + header: 'Статус', + cell: (row) => ( + + {row.subdomain.enabled ? 'Активен' : 'Неактивен'} + + ), + }, + { + key: 'service', + header: 'Группа / Сервис', + sortable: true, + sortValue: (row) => formatSubdomainServiceLinks(row.serviceLinks), + cell: (row) => ( + + {formatSubdomainServiceLinks(row.serviceLinks)} + + ), + }, + { + key: 'created_at', + header: 'Создан', + sortable: true, + sortValue: (row) => row.subdomain.created_at, + className: 'tabular-nums text-muted-foreground', + cell: (row) => formatDate(row.subdomain.created_at), + }, + { + key: 'actions', + header: '', + enableHiding: false, + className: 'text-right', + cell: (row) => ( +
+ + } + > + + Действия + + + onEdit(row)}>Редактировать + onToggleEnabled(row)} + > + {row.subdomain.enabled ? 'Деактивировать' : 'Активировать'} + + + } + > + DNS-записи + + + setDeleteTarget(row)} + > + Удалить + + + +
+ ), + }, + ], + [domainId, isToggling, onEdit, onToggleEnabled], + ) + + return ( + <> + String(row.subdomain.id)} + emptyTitle="Поддомены не найдены" + emptyDescription="Создайте поддомен или измените запрос" + pinLastColumn + actions={ +
+ setSearch('')} + /> + {toolbar} +
+ } + /> + { + if (!open) setDeleteTarget(null) + }} + title="Удалить поддомен?" + description={ + deleteTarget + ? `Поддомен ${deleteTarget.subdomain.fqdn} будет удалён из менеджера.` + : '' + } + confirmLabel="Удалить" + onConfirm={() => { + if (deleteTarget) { + onDelete(deleteTarget) + setDeleteTarget(null) + } + }} + disabled={isDeleting} + /> + + ) +} diff --git a/apps/web/src/components/subdomains-table.tsx b/apps/web/src/components/subdomains-table.tsx deleted file mode 100644 index 1670f70..0000000 --- a/apps/web/src/components/subdomains-table.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { useMemo, useState } from 'react' -import { Link } from '@tanstack/react-router' -import { - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, - type ColumnDef, - type SortingState, -} from '@tanstack/react-table' -import { MoreHorizontalIcon } from 'lucide-react' -import { ConfirmDialog } from '@/components/confirm-dialog' -import { DataTableView } from '@/components/data-table-view' -import type { Density } from '@/components/data-table-density' -import type { SubdomainTableRow } from '@/hooks/use-domain-page' -import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page' -import { formatDate } from '@/lib/format' -import { AppBadge } from '@/components/app-badge' -import { AppButton } from '@/components/app-button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@cfdm/ui/components/dropdown-menu' - -interface SubdomainsTableProps { - domainId: string - rows: SubdomainTableRow[] - isDeleting?: boolean - isToggling?: boolean - onEdit: (row: SubdomainTableRow) => void - onDelete: (row: SubdomainTableRow) => void - onToggleEnabled: (row: SubdomainTableRow) => void -} - -export function SubdomainsTable({ - domainId, - rows, - isDeleting = false, - isToggling = false, - onEdit, - onDelete, - onToggleEnabled, -}: SubdomainsTableProps) { - const [deleteTarget, setDeleteTarget] = useState(null) - const [sorting, setSorting] = useState([]) - const [globalFilter, setGlobalFilter] = useState('') - const [density, setDensity] = useState('comfortable') - - const columns = useMemo[]>( - () => [ - { - accessorKey: 'fqdn', - header: ({ column }) => ( - column.toggleSorting(column.getIsSorted() === 'asc')} - > - Поддомен - - ), - accessorFn: (row) => row.subdomain.fqdn, - cell: ({ row }) => ( - {row.original.subdomain.fqdn} - ), - }, - { - id: 'status', - header: 'Статус', - enableHiding: true, - cell: ({ row }) => ( - - {row.original.subdomain.enabled ? 'Активен' : 'Неактивен'} - - ), - }, - { - id: 'service', - header: 'Группа / Сервис', - accessorFn: (row) => formatSubdomainServiceLinks(row.serviceLinks), - cell: ({ row }) => ( - - {formatSubdomainServiceLinks(row.original.serviceLinks)} - - ), - }, - { - accessorKey: 'created_at', - header: 'Создан', - cell: ({ row }) => ( - - {formatDate(row.original.subdomain.created_at)} - - ), - }, - { - id: 'actions', - enableHiding: false, - header: () => Действия, - cell: ({ row }) => ( -
- - - } - > - - Действия - - - onEdit(row.original)}> - Редактировать - - onToggleEnabled(row.original)} - > - {row.original.subdomain.enabled ? 'Деактивировать' : 'Активировать'} - - - } - > - DNS-записи - - - setDeleteTarget(row.original)} - > - Удалить - - - -
- ), - }, - ], - [domainId, isToggling, onEdit, onToggleEnabled], - ) - - const table = useReactTable({ - data: rows, - columns, - state: { sorting, globalFilter }, - onSortingChange: setSorting, - onGlobalFilterChange: setGlobalFilter, - globalFilterFn: (row, _columnId, filterValue: string) => { - const value = filterValue.toLowerCase() - return ( - row.original.subdomain.fqdn.toLowerCase().includes(value) || - formatSubdomainServiceLinks(row.original.serviceLinks) - .toLowerCase() - .includes(value) - ) - }, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - initialState: { pagination: { pageSize: 10 } }, - }) - - return ( - <> - - { - if (!open) setDeleteTarget(null) - }} - title="Удалить поддомен?" - description={ - deleteTarget - ? `Поддомен ${deleteTarget.subdomain.fqdn} будет удалён из менеджера.` - : '' - } - confirmLabel="Удалить" - onConfirm={() => { - if (deleteTarget) { - onDelete(deleteTarget) - setDeleteTarget(null) - } - }} - disabled={isDeleting} - /> - - ) -} diff --git a/apps/web/src/components/table-skeleton.tsx b/apps/web/src/components/table-skeleton.tsx deleted file mode 100644 index d226213..0000000 --- a/apps/web/src/components/table-skeleton.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { - Card, - CardContent, - CardHeader, -} from '@cfdm/ui/components/card' -import { Skeleton } from '@cfdm/ui/components/skeleton' - -interface TableSkeletonProps { - rows?: number - columns?: number - withCard?: boolean -} - -function TableRowsSkeleton({ rows, columns }: { rows: number; columns: number }) { - return ( -
-
- {Array.from({ length: columns }).map((_, index) => ( - - ))} -
- {Array.from({ length: rows }).map((_, rowIndex) => ( -
- {Array.from({ length: columns }).map((__, colIndex) => ( - - ))} -
- ))} -
- ) -} - -export function TableSkeleton({ - rows = 5, - columns = 4, - withCard = true, -}: TableSkeletonProps) { - const table = - - if (!withCard) { - return ( -
{table}
- ) - } - - return ( - - - - - - {table} - - ) -} diff --git a/apps/web/src/components/theme-provider.tsx b/apps/web/src/components/theme-provider.tsx index 12b8979..702c7e0 100644 --- a/apps/web/src/components/theme-provider.tsx +++ b/apps/web/src/components/theme-provider.tsx @@ -1,8 +1,15 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes' +import type { ReactNode } from 'react' -export function ThemeProvider({ - children, - ...props -}: React.ComponentProps) { - return {children} +export function ThemeProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ) } diff --git a/apps/web/src/components/truncated-text.tsx b/apps/web/src/components/truncated-text.tsx new file mode 100644 index 0000000..3a9f74a --- /dev/null +++ b/apps/web/src/components/truncated-text.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react' + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@cfdm/ui/components/tooltip' +import { cn } from '@cfdm/ui/lib/utils' + +interface TruncatedTextProps { + children: ReactNode + className?: string + as?: 'span' | 'p' | 'div' + /** Явный текст подсказки, если children — не строка. */ + tooltip?: string +} + +export function TruncatedText({ children, className, as: Tag = 'span', tooltip }: TruncatedTextProps) { + const tip = + tooltip ?? + (typeof children === 'string' || typeof children === 'number' ? String(children) : null) + + if (!tip) { + return {children} + } + + return ( + + + }>{children} + {tip} + + + ) +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4e291d6..a6d17d0 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -24,10 +24,10 @@ declare module '@tanstack/react-router' { createRoot(document.getElementById('root')!).render( - + - + diff --git a/apps/web/src/routes/_auth/certificates.tsx b/apps/web/src/routes/_auth/certificates.tsx index 2e7ac55..682ef5a 100644 --- a/apps/web/src/routes/_auth/certificates.tsx +++ b/apps/web/src/routes/_auth/certificates.tsx @@ -1,17 +1,8 @@ import { createFileRoute } from '@tanstack/react-router' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useMemo, useState } from 'react' -import { - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, - type ColumnDef, - type SortingState, -} from '@tanstack/react-table' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts' -import { ShieldCheckIcon } from 'lucide-react' +import { GlobeIcon, ShieldCheckIcon } from 'lucide-react' import { toast } from 'sonner' import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries' import { api } from '@/lib/api-client' @@ -22,11 +13,13 @@ import { PageShell } from '@/components/page-shell' import { QueryState } from '@/components/query-state' import { EmptyState } from '@/components/empty-state' import { StatusBadge } from '@/components/status-badge' -import { DataTableCard } from '@/components/data-table-card' -import { DataTableView } from '@/components/data-table-view' -import type { Density } from '@/components/data-table-density' +import { + DataGridCard, + columnDefFromDataGrid, +} from '@/components/data-grid-card' +import type { DataGridColumn } from '@/components/data-grid-types' +import { ListFiltersBar } from '@/components/list-filters-bar' import { ChartCard } from '@/components/chart-card' -import { AppButton } from '@/components/app-button' import { AppItem, AppItemContent, @@ -40,7 +33,7 @@ import { type ChartConfig, } from '@cfdm/ui/components/chart' import { LoadingButton } from '@/components/loading-button' -import { TableSkeleton } from '@/components/table-skeleton' +import { TableSkeleton } from '@/components/skeletons' const chartConfig = { count: { @@ -60,8 +53,6 @@ export const Route = createFileRoute('/_auth/certificates')({ function CertificatesPage() { const [searchQuery, setSearchQuery] = useState('') - const [sorting, setSorting] = useState([{ id: 'expires_at', desc: false }]) - const [density, setDensity] = useState('comfortable') const queryClient = useQueryClient() const { data: certs, @@ -97,82 +88,52 @@ function CertificatesPage() { ) }, [certs, searchQuery]) - const isFilteredEmpty = (certs?.length ?? 0) > 0 && filteredCerts.length === 0 - - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { - accessorKey: 'hostname', - header: ({ column }) => ( - column.toggleSorting(column.getIsSorted() === 'asc')} - > - Хост - - ), - cell: ({ row }) => ( - {row.original.hostname} - ), + key: 'hostname', + header: 'Хост', + icon: GlobeIcon, + sortable: true, + sortValue: (row) => row.hostname, + cell: (row) => {row.hostname}, }, { - accessorKey: 'status', + key: 'status', header: 'Статус', - cell: ({ row }) => , + cell: (row) => , }, { - accessorKey: 'expires_at', + key: 'expires_at', header: 'Истекает', - cell: ({ row }) => ( - - {formatDate(row.original.expires_at)} - - ), + sortable: true, + sortValue: (row) => row.expires_at ?? '', + className: 'tabular-nums text-muted-foreground', + cell: (row) => formatDate(row.expires_at), }, { - id: 'relative', + key: 'relative', header: 'Срок', - cell: ({ row }) => ( + enableHiding: true, + cell: (row) => ( - {formatRelative(row.original.expires_at)} + {formatRelative(row.expires_at)} ), - enableHiding: true, }, { - accessorKey: 'last_checked_at', + key: 'last_checked_at', header: 'Проверка', - cell: ({ row }) => ( - - {formatDate(row.original.last_checked_at)} - - ), enableHiding: true, + sortable: true, + sortValue: (row) => row.last_checked_at ?? '', + className: 'tabular-nums text-muted-foreground', + cell: (row) => formatDate(row.last_checked_at), }, ], [], ) - const table = useReactTable({ - data: filteredCerts, - columns, - state: { sorting, globalFilter: searchQuery }, - onSortingChange: setSorting, - globalFilterFn: (row, _columnId, value: string) => { - const q = value.toLowerCase() - return ( - row.original.hostname.toLowerCase().includes(q) || - row.original.status.toLowerCase().includes(q) - ) - }, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - initialState: { pagination: { pageSize: 10 } }, - }) - return ( } + skeleton={} > } /> - - - + columns={columnDefFromDataGrid(columns)} + data={filteredCerts} + rowId={(row) => String(row.id)} + emptyTitle="Сертификаты не найдены" + emptyDescription="Сертификаты появятся после проверки доменов" + initialSorting={[{ id: 'expires_at', desc: false }]} + enableColumnVisibility + actions={ + setSearchQuery('')} + /> + } + /> ) diff --git a/apps/web/src/routes/_auth/domains/$domainId/dns.tsx b/apps/web/src/routes/_auth/domains/$domainId/dns.tsx index 66f4a7e..3e7cf75 100644 --- a/apps/web/src/routes/_auth/domains/$domainId/dns.tsx +++ b/apps/web/src/routes/_auth/domains/$domainId/dns.tsx @@ -3,21 +3,18 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { NetworkIcon } from 'lucide-react' import { toast } from 'sonner' -import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys } from '@/queries' import { api } from '@/lib/api-client' import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas' +import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys } from '@/queries' import { PageHeader } from '@/components/page-header' import { PageShell } from '@/components/page-shell' import { QueryState } from '@/components/query-state' -import { DataTableCard } from '@/components/data-table-card' +import { DnsRecordsDataGrid } from '@/components/dns-records-data-grid' import { FormSheet } from '@/components/form-sheet' import { FormFieldSimple } from '@/components/form-field' import { LoadingButton } from '@/components/loading-button' -import { TableSkeleton } from '@/components/table-skeleton' -import { TableToolbar } from '@/components/table-toolbar' -import { DnsRecordsTable } from '@/components/dns-records-table' +import { TableSkeleton } from '@/components/skeletons' import { AppButton } from '@/components/app-button' import { AppInput } from '@/components/app-input' import { AppField, AppFieldGroup, AppFieldLabel } from '@/components/app-field' @@ -144,17 +141,12 @@ function DnsPage() { ) }, [records, host, searchQuery]) - const hasRecords = filteredRecords.length > 0 - const isFilteredEmpty = (records?.length ?? 0) > 0 && filteredRecords.length === 0 - return ( @@ -180,50 +172,16 @@ function DnsPage() { void refetchDomain() void refetchRecords() }} - skeleton={} + skeleton={} > - setSearchQuery('')}> - Сбросить фильтр - - ) : ( - setSheetOpen(true)}>Новая запись - ) - } - toolbar={ - !host ? ( - - ) : undefined - } - > - {hasRecords && ( -
- deleteMutation.mutate(recordId)} - isDeleting={deleteMutation.isPending} - /> -
- )} -
+ deleteMutation.mutate(recordId)} + isDeleting={deleteMutation.isPending} + search={host ?? searchQuery} + onSearchChange={setSearchQuery} + hideSearch={Boolean(host)} + /> - {dnsTypeItems.map((item) => ( - - {item.label} + {DNS_TYPES.map((type) => ( + + {type} ))} @@ -269,53 +227,30 @@ function DnsPage() { )} /> - - + + - + - + - + Прокси Cloudflare ( - + )} /> diff --git a/apps/web/src/routes/_auth/domains/$domainId/index.tsx b/apps/web/src/routes/_auth/domains/$domainId/index.tsx index ce0abad..3667b18 100644 --- a/apps/web/src/routes/_auth/domains/$domainId/index.tsx +++ b/apps/web/src/routes/_auth/domains/$domainId/index.tsx @@ -1,6 +1,5 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useState } from 'react' -import { GlobeIcon } from 'lucide-react' import { domainDetailQueryOptions, domainServiceBindingsQueryOptions, @@ -16,8 +15,7 @@ import { QueryState } from '@/components/query-state' import { DomainHeader } from '@/components/domain-header' import { DomainActionsBar } from '@/components/domain-actions-bar' import { DomainBindingsCard } from '@/components/domain-bindings-card' -import { DataTableCard } from '@/components/data-table-card' -import { SubdomainsTable } from '@/components/subdomains-table' +import { SubdomainsDataGrid } from '@/components/subdomains-data-grid' import { SubdomainEditSheet, type SubdomainEditValues, @@ -34,7 +32,7 @@ import { AppCardTitle, } from '@/components/app-card' import { LoadingButton } from '@/components/loading-button' -import { TableSkeleton } from '@/components/table-skeleton' +import { TableSkeleton } from '@/components/skeletons' export const Route = createFileRoute('/_auth/domains/$domainId/')({ loader: async ({ context: { queryClient }, params }) => { @@ -54,12 +52,12 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({ function DomainPageSkeleton() { return (
- +
- - + +
- +
) } @@ -252,44 +250,23 @@ function DomainOverviewPage() {
- - Создать поддомен - syncMutation.mutate()} - disabled={syncMutation.isPending} - > - Синхронизировать - - + + deleteSubdomainMutation.mutate(row.subdomain.id) + } + onToggleEnabled={(row) => + updateSubdomainMutation.mutate({ + id: row.subdomain.id, + enabled: !row.subdomain.enabled, + }) } toolbar={} - > - - deleteSubdomainMutation.mutate(row.subdomain.id) - } - onToggleEnabled={(row) => - updateSubdomainMutation.mutate({ - id: row.subdomain.id, - enabled: !row.subdomain.enabled, - }) - } - /> - + /> 0 const isTrulyEmpty = tableData.length === 0 && !hasActiveFilter + const emptyAction = isZeroResults ? ( + setFilterGroupId('')}> + Сбросить фильтр + + ) : ( + setSheetOpen(true)}>Импортировать домен + ) + return ( } > - setFilterGroupId('')}> - Сбросить фильтр - - ) : ( - setSheetOpen(true)}>Импортировать домен - ) - } - > - {!isTrulyEmpty && !isZeroResults && ( - - )} - + {isTrulyEmpty || isZeroResults ? ( + + ) : ( + + )} { @@ -64,7 +62,71 @@ function GroupDetailPage() { }, [domains, searchQuery]) const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0 - const hasRecords = filteredDomains.length > 0 + const isTrulyEmpty = filteredDomains.length === 0 && !searchQuery.trim() + + const columns = useMemo[]>( + () => [ + { + key: 'zone_name', + header: 'Зона', + icon: GlobeIcon, + sortable: true, + sortValue: (row) => row.zone_name, + cell: (row) => {row.zone_name}, + }, + { + key: 'status', + header: 'Статус', + cell: (row) => , + }, + { + key: 'service_count', + header: 'Сервисы', + sortable: true, + sortValue: (row) => row.service_count, + className: 'tabular-nums', + cell: (row) => ( + + ), + }, + { + key: 'actions', + header: '', + enableHiding: false, + className: 'text-right', + cell: (row) => ( + + ), + }, + ], + [], + ) + + const emptyAction = isFilteredEmpty ? ( + + ) : ( + + ) return ( @@ -76,13 +138,9 @@ function GroupDetailPage() { : 'Домены в группе' } actions={ - } - > + } /> } + skeleton={} > - setSearchQuery('')}> - Сбросить фильтр - - ) : ( - } - > - Открыть канбан групп - - ) - } - toolbar={ - - } - > - {hasRecords && ( -
- - - Зона - Статус - Сервисы - Действия - - - - {filteredDomains.map((domain) => ( - - {domain.zone_name} - - - - - - } - > - {domain.service_count} - - - - - } - > - Обзор - - - - ))} - -
- )} - + {isTrulyEmpty || isFilteredEmpty ? ( + + ) : ( + String(row.id)} + emptyTitle="В группе нет доменов" + pinLastColumn + actions={ + setSearchQuery('')} + /> + } + /> + )} ) diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx index a9b912c..666e405 100644 --- a/apps/web/src/routes/_auth/index.tsx +++ b/apps/web/src/routes/_auth/index.tsx @@ -27,8 +27,8 @@ import { import { PageHeader } from '@/components/page-header' import { PageShell } from '@/components/page-shell' import { QueryState } from '@/components/query-state' -import { SectionCardsSkeleton } from '@/components/section-cards-skeleton' -import { KpiCard } from '@/components/kpi-card' +import { SectionCardsSkeleton } from '@/components/skeletons' +import { SectionCards } from '@/components/section-cards' import { ChartCard } from '@/components/chart-card' import { ExpiringCertsCard } from '@/components/expiring-certs-card' import { StatusBadge } from '@/components/status-badge' @@ -63,15 +63,15 @@ const statusChartConfig = { count: { label: 'Сертификаты' }, active: { label: 'Активен', color: 'var(--success)' }, ok: { label: 'OK', color: 'var(--success)' }, - warning: { label: 'Предупреждение', color: 'var(--chart-3)' }, - pending_push: { label: 'Ожидает', color: 'var(--chart-2)' }, + warning: { label: 'Предупреждение', color: 'var(--warning)' }, + pending_push: { label: 'Ожидает', color: 'var(--info)' }, expired: { label: 'Истёк', color: 'var(--destructive)' }, error: { label: 'Ошибка', color: 'var(--destructive)' }, unknown: { label: 'Неизвестно', color: 'var(--muted-foreground)' }, } satisfies ChartConfig const groupChartConfig = { - count: { label: 'Домены', color: 'var(--chart-2)' }, + count: { label: 'Домены', color: 'var(--chart-1)' }, } satisfies ChartConfig function DashboardPage() { @@ -135,38 +135,50 @@ function DashboardPage() { }} >
-
- } - /> - } - /> - - } - /> - } - /> -
+ , + hint: ( + + Управление + + ), + }, + { + label: 'Группы доменов', + value: groups?.length ?? 0, + icon: , + hint: ( + + Канбан + + ), + }, + { + label: 'Сервисы', + value: serviceCount, + icon: , + hint: ( + + Управление + + ), + }, + { + label: 'Сертификаты', + value: certs?.length ?? 0, + icon: , + hint: ( + + Мониторинг + + ), + }, + ]} + />
[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0", + vertical: + "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + } +) + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function ButtonGroupText({ + className, + render, + ...props +}: useRender.ComponentProps<"div">) { + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">( + { + className: cn( + "flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", + className + ), + }, + props + ), + render, + state: { + slot: "button-group-text", + }, + }) +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} diff --git a/packages/ui/src/components/calendar.tsx b/packages/ui/src/components/calendar.tsx new file mode 100644 index 0000000..26cb1ec --- /dev/null +++ b/packages/ui/src/components/calendar.tsx @@ -0,0 +1,221 @@ +"use client" + +import * as React from "react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, + type Locale, +} from "react-day-picker" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button, buttonVariants } from "@cfdm/ui/components/button" +import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + locale, + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString(locale?.code, { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative rounded-(--cell-radius)", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label + ), + month_grid: cn("w-full border-collapse", defaultClassNames.month_grid), + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", + defaultClassNames.day + ), + range_start: cn( + "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn( + "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted", + defaultClassNames.range_end + ), + today: cn( + "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: ({ ...props }) => ( + + ), + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( +