Compare commits

...
12 Commits
Author SHA1 Message Date
Denozordec f861d0fa8c fix(ci): restore changed-path detection on multi-commit pushes
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 1m6s
quality / go (push) Successful in 55s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 2m59s
CD / publish (push) Successful in 5m17s
Push-чекаут в quality-воркфлоу делался с fetch-depth: 2, поэтому
BEFORE_SHA (github.event.before) при пуше нескольких коммитов
отсутствовал в чекауте, и git diff падал с "fatal: bad object"
(exit 128). Чекаут переведён на fetch-depth: 0; перед диффом
добавлен guard git cat-file -e: если before недоступен
(force-push), шаг не падает, а уходит в полный прогон через
empty-diff fallback.
2026-09-25 23:54:48 +07:00
Denozordec 2f914bcc65 fix(web): add horizontal inset to frame section content
quality / commitlint (push) Skipped
quality / changes (push) Failing after 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / go (push) Skipped
CD / quality (push) Failing after 11s
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
CD / publish (push) Skipped
Контент карточки «Сравнение ревизий» и шела аналитических карточек
прилипал к рамке FramePanel: в contentClassName передавался только
вертикальный py-, а FrameSection рендерит панель с p-0. Добавлен
px-5 — горизонтальная вставка по константе кита panelCardInsetClassName,
на одной оси с остальными карточками (мониторинг, дашборд).
2026-09-25 22:54:47 +07:00
Denozordec 7a10138990 fix(web): open select popups below trigger instead of over it
Селекты приложения использовали alignItemWithTrigger по умолчанию:
Base UI накладывает выбранный пункт списка на триггер, а у селектов с
плейсхолдером (без значения, например «Ревизия A/B» на странице
операций) попап целиком накрывал триггер — выглядел как сломанный
список вместо выпадающего меню. В обёртке SelectMenu/SelectField
выставлен alignItemWithTrigger=false — список раскрывается под
триггером у всех селектов приложения, как это делает и вендорский
data-grid-pagination.
2026-09-25 22:34:04 +07:00
Denozordec 391f34a53d chore: keep staged-context.ps1 array on single staged file
Пайплайн Sort-Object разворачивал одноэлементный массив в скаляр, из-за
чего под StrictMode падал доступ к .Count и скрипт возвращал exit 1 при
ровно одном застейдженном файле. Обёртка @() сохраняет массив; сообщения
коммитов по одному файлу снова проходят обязательную проверку.
2026-09-25 22:33:32 +07:00
Denozordec ecd48642de fix(web): align grid columns with reui reference and clean labels
Гриды операций и расписания выровнены по эталону data-grid-filtering-2:
явные размеры колонок (size в columnDef) вместо равномерного деления
при table-layout: fixed — колонка названия доминирует, второстепенные
сжаты по содержимому, пропали обрезанные имена («partner-dom…»,
«Обновлен...»). Убраны фейковые многоточия из JS-срезов: ID ревизии и
варианты выбора в сравнении теперь показываются целиком, а усечение
делает CSS-truncate. Подписи «Ревизия A/B» переведены на Field/FieldLabel
вместо самодельных span, шапка блока diff дополнена заголовком
«ревизия A → ревизия B» (CodeBlockTitle) слева от кнопок.
2026-09-25 22:14:02 +07:00
Denozordec 09580bbcab feat(web): add job run timeline and code-block revision diff
Раскрытие строки задачи показывает вертикальный ReUI Timeline:
создана (с триггером) -> взята в работу -> результат с текстом
ошибки. Diff ревизий переведён с самодельных <pre> на официальный
@reui/code-block: единый блок с разметкой добавленных и удалённых
строк (знаки +/- и тонировка), нумерацией, копированием и разворотом
длинных списков. ResourcePage (filtered-вариант) получил поддержку
expandedContent/getRowCanExpand — колонка раскрытия строится так же,
как во FrameDataGrid. Точечные упрощения: прогресс BGP-сессий в
мониторинге переведён на Progress из кита вместо самодельного бара;
удалены @deprecated-константы ui-surface.ts, мёртвый
data-grid-defaults.ts и неиспользуемый sparkline-ряд дашборда.
2026-09-25 21:46:57 +07:00
Denozordec 4d54dacd05 fix(web): localize reui grids and filters, unify ru terminology
Русификация переведена на официальные i18n-слои ReUI: полный русский
словарь DataGrid (lib/reui-i18n-ru.ts) подключён через data-grid-i18n
во FrameDataGrid и ResourcePage — меню сортировки, «Загрузка…»,
«Нет данных» и пагинация больше не откатываются к английским дефолтам
компонентов. Словарь фильтров дополнен ~30 недостающими ключами,
каскадер в мастере проверки переведён целиком. Исправлены
некорректные и разъехавшиеся лейблы: accept -> «Принять»,
API-триггер -> «через API», «арендатор» вместо «Тенант»,
«Наблюдатель» вместо «Просмотр», «Основной» вместо
«Мастер (плоскость)», «AS-префиксы», «DoH-профили», колонка канбана
«В очереди».
2026-09-25 21:46:24 +07:00
Denozordec a3562cc11e chore(web): update vendored ReUI components to latest registry
Обновлены вендоренные компоненты ReUI (base-nova) до актуальных версий
реестра: семейство data-grid (плюс новые data-grid-i18n и
data-grid-cell-selection), filters, cascader, frame, kanban, stepper,
timeline, badge, alert, icon-tile, icon-stack и новый code-block
(подсветка на shiki). Shadcn-примитивы в packages/ui обновлены тем же
запуском CLI. Импорты "cn" из новых файлов переведены на алиас
@evobgp/ui/lib/utils, пакет cn из dependencies удалён, локальные
eslint-disable в вендоренных файлах поправлены.
2026-09-25 21:45:56 +07:00
Denozordec 95d4305241 feat(web): show job trigger and module details in jobs list
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 1m3s
quality / web (push) Successful in 1m58s
quality / go (push) Successful in 3m21s
quality / bird2 (push) Successful in 19s
CD / quality (push) Successful in 7m6s
CD / publish (push) Successful in 9m18s
Колонка «Вид» в журнале задач (Операции → Задачи и Расписание) стала информативнее: подзаголовок с именами обновляемых модулей для tenant_refresh (из meta.module_ids), сообщение apply_summary для deploy_apply и бейдж триггера («по расписанию», «вручную», «после правки данных»). Поиск по журналу учитывает новые поля.

Бэкенд: meta.trigger теперь записывается для module_refresh и deploy_apply при ручных запусках (POST /v1/modules/{id}/refresh, /v1/apply, /v1/speakers/{id}/apply), а deploy_apply из цепочки обновления наследует триггер родительской задачи.
2026-09-25 16:16:53 +07:00
Denozordec dc803bcb34 refactor(web): remove deprecated dashboard components and enhance KPI grid
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 46s
quality / web (push) Successful in 1m16s
quality / go (push) Successful in 2m42s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 5m19s
CD / publish (push) Successful in 7m19s
- Deleted unused components: `DashboardActivityTimeline`, `DashboardFramePanel`, `DashboardModulesGrid`, `DashboardRecentJobsGrid`, and `DashboardRecentRevisionsGrid` to streamline the dashboard.
- Updated `DashboardKpiGrid` to improve KPI display logic, including progress indicators and enhanced badge functionality.
- Refactored `DashboardNetworkHealth` to provide better status representation based on loading states and network conditions.
- Introduced new properties for KPI cards to support progress tracking and improved visual feedback.

This cleanup aims to enhance performance and maintainability of the dashboard while providing a better user experience.
2026-08-31 10:15:59 +07:00
Denozordec e469c421ca feat(web): enhance status toggle group and filter utilities
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 24s
quality / web (push) Successful in 1m1s
CD / quality (push) Successful in 1m37s
CD / publish (push) Successful in 2m53s
- Introduced new properties for status toggle items, including `icon` and `tone`, to improve visual representation.
- Updated the `StatusToggleGroup` component to utilize these new properties for better user feedback.
- Enhanced filter utilities with additional functions for managing filter queries, including `getPrimaryTextField`, `getExtraFilterFields`, and `setFilterTextValue`.
- Improved the `ResourcePageFiltered` component to integrate search functionality and manage filter states more effectively.

This update aims to streamline the user experience in managing statuses and filters across the application.
2026-08-23 13:16:00 +07:00
DenozordecandCursor 094f75913b fix(web): unify ReUI chrome and stop tab underline overlap
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 22s
quality / web (push) Successful in 1m0s
CD / quality (push) Successful in 1m35s
CD / publish (push) Successful in 2m59s
Секции страниц переведены на segmented Tabs (c-tabs-9), статус грида — ToggleGroup в toolbar. Убраны календарь задач, пустые вкладки мониторинга и demo-деревья ReUI. KPI overflow как в CFDM, DetailPanel на карточке модуля, один FrameSection.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 12:46:20 +07:00
229 changed files with 11640 additions and 18081 deletions

No files matched your search

+11 -8
View File
@@ -46,28 +46,31 @@ pnpm dlx shadcn@latest add @reui/data-grid # ReUI enterprise → apps/web/src/co
|--------|----------|--------|
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` |
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → `reui-kit/ResourcePage` |
| Мультифильтры | `@reui` | `@/components/reui/filters` |
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid (через `DataGridCard`), не shadcn Data Table.
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid через `ResourcePage`, не shadcn Data Table.
## Уже установленные shared-обёртки
В `apps/web/src/components/`:
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
- `QueryState` — обёртка loading/error/empty для TanStack Query
- `EmptyState` — пустые списки
- `EmptyState` — пустые списки (empty-state-14 DNA)
- `ConfirmDialog` — подтверждения (не `window.confirm`)
- `LoadingButton` — кнопка с loading-состоянием
- `StatusBadge` — статусные бейджи
- `SectionCards` — сетка KPI-карточек
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
- `LoadingButton` — SoT кнопка с loading (`Button` + `Spinner`)
- `StatusBadge` / `CategoryBadge` / `ModeBadge` — semantic ReUI Badge
- `SegmentedTabs` — секции страницы (c-tabs-9)
- `StatusToggleGroup` — статус грида (c-toggle-group-5)
- `FormDrawer` — Sheet overlay (sheet-8 / form-7)
- `Skeletons` (`TableSkeleton`, `KpiStatGridSkeleton`) — скелетоны
- `TruncatedText` — текст с тултипом
- `ModeToggle` — переключатель темы
Kit: `reui-kit/ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`, `FrameSection`.
Перед созданием новой обёртки — проверить существующие через Codegraph.
+2 -2
View File
@@ -70,14 +70,14 @@ alwaysApply: true
|------|------|--------|
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
| PRO blocks (reference) | — | CLI → adapt into kit; не держать demo-деревья в `src/components/blocks/` |
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`, `FrameSection`
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
+10 -3
View File
@@ -23,10 +23,10 @@ alwaysApply: false
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell, DetailPanel, FrameSection |
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Quick Actions: `QuickActionGrid`.
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Page tabs: [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9). Grid status: [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5). Quick Actions: `QuickActionGrid`.
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
@@ -48,9 +48,16 @@ alwaysApply: false
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
*Проверка:* https://ui.shadcn.com/docs/components/form
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
**WEB-06a** | MUST | Переключатели по роли UX:
- секции страницы (Сеть / Операции / Справочники) — native `Tabs` default list (`SegmentedTabs`, [c-tabs-9](https://reui.io/preview/base/components/c-tabs-9));
- статус грида (Все/Вкл) — `ToggleGroup` в toolbar (`StatusToggleGroup`, [c-toggle-group-5](https://reui.io/preview/base/components/c-toggle-group-5));
- настройки — вертикальный Tabs rail ([settings-3](https://reui.io/preview/base/settings-3)).
Не `variant="line"` для page/in-grid tabs.
*Проверка:* `@/components/segmented-tabs`, `@/components/status-toggle-group`.
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
*Проверка:* https://ui.shadcn.com/docs/components/sonner
+11 -2
View File
@@ -52,8 +52,10 @@ jobs:
fetch-depth: 0
- if: ${{ inputs.is_pull_request == false }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Полная история: BEFORE_SHA = github.event.before при multi-commit push
# лежит глубже shallow-среза, и git diff падает с "bad object".
with:
fetch-depth: 2
fetch-depth: 0
- id: detect
name: Detect changed paths per module
env:
@@ -94,7 +96,14 @@ jobs:
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
before="$BEFORE_SHA"
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
FILES="$(git diff --name-only "$before" "$after")"
# Force-push мог отбросить before; отсутствие объекта — не ошибка,
# а сигнал уйти в полный прогон через пустой diff.
if git cat-file -e "$before^{commit}" 2>/dev/null; then
FILES="$(git diff --name-only "$before" "$after")"
else
echo "BEFORE_SHA $before not found in checkout — full pipeline fallback"
FILES=""
fi
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
else
+1 -1
View File
@@ -23,7 +23,7 @@
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
| Точки входа процессов | `cmd/*/` |
| Веб (SvelteKit) | `web/` |
| Веб (React + ReUI) | `apps/web/` |
| Compose, деплой | `deploy/compose/` |
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
+1 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'
export default tseslint.config(
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] },
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
+1
View File
@@ -41,6 +41,7 @@
"react-hook-form": "^7.60.0",
"recharts": "3.8.0",
"shadcn": "^4.19.0",
"shiki": "^4.4.3",
"sonner": "^1.7.0",
"zod": "^3.25.0"
},
@@ -8,7 +8,7 @@ import {
TooltipTrigger,
} from '@evobgp/ui/components/tooltip'
import { PanelCard } from '@/components/panel-card'
import { FrameSection } from '@/components/reui-kit'
/** Chart Frame shell — chart-1 spacing, not Card.
* @see https://reui.io/preview/base/chart-1
@@ -51,16 +51,16 @@ export function AnalyticsCardShell({
)
return (
<PanelCard
<FrameSection
title={titleNode}
description={description}
actions={actions}
footer={footer}
className={cn('overflow-hidden', className)}
contentClassName="flex flex-col gap-5 py-5"
contentClassName="flex flex-col gap-5 px-5 py-5"
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
>
{children}
</PanelCard>
</FrameSection>
)
}
@@ -1,4 +1,4 @@
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
@@ -114,7 +114,7 @@ export function DashboardPlatformCard({
</Button>
<Button
className="flex-1"
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
onClick={() => navigate({ to: '/monitoring' })}
>
Мониторинг
</Button>
@@ -1,324 +0,0 @@
import { type ComponentProps } from "react"
import { Badge } from "@/components/reui/badge"
import { type DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { type ColumnDef } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { type ModuleRecord, type ModuleStatus } from "./data"
import { CalendarDaysIcon, FileTextIcon, StarIcon, MoreHorizontalIcon, CopyIcon, ArchiveIcon } from "lucide-react"
export type ModuleRowAction = "open" | "favorite" | "duplicate" | "archive"
const moduleStatusVariant: Record<
ModuleStatus,
ComponentProps<typeof Badge>["variant"]
> = {
Planned: "info-outline",
Backlog: "outline",
"In Progress": "warning-outline",
}
const moduleStatusDotClass: Record<ModuleStatus, string> = {
Planned: "bg-sky-500 dark:bg-sky-400",
Backlog: "bg-muted-foreground/50",
"In Progress": "bg-amber-500 dark:bg-amber-400",
}
function DotSeparator() {
return (
<span
className="bg-muted-foreground/45 size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
)
}
function getProgressToneClass(value: number) {
if (value >= 75) return "text-emerald-500 dark:text-emerald-400"
if (value >= 40) return "text-amber-500 dark:text-amber-400"
if (value > 0) return "text-sky-500 dark:text-sky-400"
return "text-muted-foreground/35"
}
function getWindowDurationLabel(module: ModuleRecord) {
const start = new Date(module.dateStart).getTime()
const end = new Date(module.dateEnd).getTime()
const dayMs = 24 * 60 * 60 * 1000
const days = Math.max(1, Math.round((end - start) / dayMs))
return `${days}-day window`
}
function getCompactDateRange(module: ModuleRecord) {
return module.dateRange.replace(/, 2026/g, "")
}
function ModuleProgress({ module }: { module: ModuleRecord }) {
const value = module.progress
const radius = 18
const circumference = 2 * Math.PI * radius
const dashOffset = circumference - (value / 100) * circumference
const progressClassName = getProgressToneClass(value)
return (
<div className="flex min-w-0 items-center gap-2.5">
<div className="relative size-10 shrink-0">
<svg
viewBox="0 0 44 44"
className="absolute inset-0 size-10 -rotate-90"
aria-hidden="true"
>
<circle
cx="22"
cy="22"
r={radius}
fill="none"
className="stroke-muted-foreground/20"
strokeWidth="3.25"
/>
<circle
cx="22"
cy="22"
r={radius}
fill="none"
className={cn("stroke-current", progressClassName)}
strokeWidth="3.25"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</svg>
<span className="text-muted-foreground absolute inset-0 flex items-center justify-center text-[9px] leading-none font-medium tabular-nums">
{value}%
</span>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground text-sm font-medium tabular-nums">
{value}% ready
</span>
<span className="text-muted-foreground truncate text-xs tabular-nums">
{module.tasksCompleted}/{module.tasksTotal} tasks
</span>
</div>
</div>
)
}
function ModuleNameCell({ module }: { module: ModuleRecord }) {
return (
<div className="flex min-w-0 flex-col gap-1">
<span className="text-foreground truncate text-sm leading-5 font-medium">
{module.name}
</span>
<div className="text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
<span className="shrink-0">{module.kind}</span>
<DotSeparator />
<span className="truncate">{module.owner.name}</span>
<DotSeparator />
<span className="truncate font-mono tracking-wide uppercase">
{module.id}
</span>
</div>
</div>
)
}
function ModuleDateCell({ module }: { module: ModuleRecord }) {
return (
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate text-sm font-medium tabular-nums">
{getCompactDateRange(module)}
</span>
<span className="text-muted-foreground inline-flex min-w-0 items-center gap-1.5 truncate text-xs">
<CalendarDaysIcon className="size-3.5 shrink-0" aria-hidden="true" />
{getWindowDurationLabel(module)}
</span>
</div>
)
}
function ModuleStatusCell({ module }: { module: ModuleRecord }) {
return (
<Badge variant={moduleStatusVariant[module.status]}>
<span
className={cn(
"size-1.5 shrink-0 rounded-full!",
moduleStatusDotClass[module.status]
)}
aria-hidden="true"
/>
{module.status}
</Badge>
)
}
function ModuleActions({
module,
onAction,
}: {
module: ModuleRecord
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
}) {
return (
<div className="flex items-center justify-end gap-1">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Open ${module.name}`}
onClick={(event) => {
event.stopPropagation()
onAction("open", module)
}}
>
<FileTextIcon aria-hidden="true" />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`${module.favorite ? "Unfavorite" : "Favorite"} ${
module.name
}`}
className={cn(module.favorite && "text-amber-500")}
onClick={(event) => {
event.stopPropagation()
onAction("favorite", module)
}}
>
<StarIcon aria-hidden="true" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`More actions for ${module.name}`}
onClick={(event) => event.stopPropagation()}
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onAction("duplicate", module)}>
<CopyIcon aria-hidden="true" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onAction("archive", module)}
>
<ArchiveIcon aria-hidden="true" />
Archive
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export function createModuleGridColumns({
onAction,
}: {
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
}): ColumnDef<DataGridFeatures, ModuleRecord>[] {
return [
{
accessorKey: "progress",
id: "progress",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ModuleProgress module={row.original} />,
size: 210,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Progress",
},
},
{
accessorKey: "name",
id: "name",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ModuleNameCell module={row.original} />,
minSize: 300,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
autoSize: true,
headerTitle: "Module",
},
},
{
accessorKey: "dateStart",
id: "dateStart",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ModuleDateCell module={row.original} />,
sortFn: (rowA, rowB) =>
new Date(rowA.original.dateStart).getTime() -
new Date(rowB.original.dateStart).getTime(),
size: 180,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Window",
},
},
{
accessorKey: "status",
id: "status",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ModuleStatusCell module={row.original} />,
size: 126,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Status",
},
},
{
id: "actions",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<ModuleActions module={row.original} onAction={onAction} />
),
size: 104,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Actions",
},
},
]
}
@@ -1,355 +0,0 @@
import { useCallback, useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
DataGrid,
dataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
useTable,
type PaginationState,
type SortingState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { createModuleGridColumns, type ModuleRowAction } from "./columns"
import {
MODULE_RECORDS,
MODULE_STATUS_OPTIONS,
type ModuleRecord,
type ModuleStatus,
} from "./data"
import { FlagIcon, ChevronRightIcon, PackageIcon, SearchIcon, XIcon, ArrowUpDownIcon, ChevronDownIcon, FilterIcon } from "lucide-react"
type ModuleSort = "name" | "dateStart" | "progress" | "status"
const sortLabels: Record<ModuleSort, string> = {
name: "Name",
dateStart: "Window",
progress: "Progress",
status: "Status",
}
function buildSorting(sortBy: ModuleSort): SortingState {
return [{ id: sortBy, desc: false }]
}
function getModuleSearchBlob(module: ModuleRecord) {
return [
module.name,
module.id,
module.kind,
module.owner.name,
module.owner.role,
module.health,
module.status,
module.dateRange,
]
.join(" ")
.toLowerCase()
}
export function ModulesDataGridView() {
const [modules, setModules] = useState<ModuleRecord[]>(MODULE_RECORDS)
const [searchQuery, setSearchQuery] = useState("")
const [selectedStatuses, setSelectedStatuses] = useState<ModuleStatus[]>([])
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sortBy, setSortBy] = useState<ModuleSort>("name")
const [sorting, setSorting] = useState<SortingState>(() =>
buildSorting("name")
)
const filteredModules = useMemo(() => {
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
return modules.filter((module) => {
const matchesSearch =
normalizedSearchQuery.length === 0 ||
getModuleSearchBlob(module).includes(normalizedSearchQuery)
const matchesStatus =
selectedStatuses.length === 0 ||
selectedStatuses.includes(module.status)
return matchesSearch && matchesStatus
})
}, [modules, searchQuery, selectedStatuses])
const activeFilterCount = selectedStatuses.length
const resetPagination = useCallback(() => {
setPagination((current) => ({
...current,
pageIndex: 0,
}))
}, [])
const handleSearchChange = useCallback(
(value: string) => {
setSearchQuery(value)
resetPagination()
},
[resetPagination]
)
const handleStatusToggle = useCallback(
(status: ModuleStatus, checked: boolean) => {
setSelectedStatuses((current) => {
if (checked) {
return current.includes(status) ? current : [...current, status]
}
return current.filter((item) => item !== status)
})
resetPagination()
},
[resetPagination]
)
const handleSortChange = useCallback(
(value: string) => {
const nextSort = value as ModuleSort
setSortBy(nextSort)
setSorting(buildSorting(nextSort))
resetPagination()
},
[resetPagination]
)
const handleModuleAction = useCallback(
(action: ModuleRowAction, module: ModuleRecord) => {
if (action === "favorite") {
setModules((current) =>
current.map((item) =>
item.id === module.id
? {
...item,
favorite: !item.favorite,
}
: item
)
)
toast.success(module.favorite ? "Removed favorite" : "Module starred", {
description: module.name,
})
return
}
if (action === "open") {
toast.info("Open module", {
description: `${module.name} (${module.kind})`,
})
return
}
toast.message(
action === "duplicate" ? "Duplicate module" : "Archive module",
{
description: `Connect this action to your ${module.name} module flow.`,
}
)
},
[]
)
const handleAddModule = () => {
toast.success("Add module", {
description: "Open your module creation dialog from this primary action.",
})
}
const columns = useMemo(
() => createModuleGridColumns({ onAction: handleModuleAction }),
[handleModuleAction]
)
// eslint-disable-next-line react-hooks/incompatible-library
const table = useTable({
features: dataGridFeatures,
data: filteredModules,
columns,
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
getRowId: (row) => row.id,
})
return (
<DataGrid
table={table}
recordCount={filteredModules.length}
emptyMessage="No modules match the selected filters."
tableLayout={{
dense: true,
rowBorder: true,
headerSticky: false,
columnsVisibility: false,
columnsResizable: false,
columnsMovable: false,
width: "fixed",
}}
tableClassNames={{
bodyRow: "group/module-row [&>td]:h-16",
}}
>
<div className="flex w-full max-w-6xl flex-col">
<div className="flex flex-col gap-3 border-b px-0 py-3 lg:min-h-14 lg:flex-row lg:items-center lg:gap-4 lg:py-0">
<div className="flex min-w-0 items-center gap-2">
<FlagIcon className="size-3.5 shrink-0 fill-amber-400 text-amber-400" aria-hidden="true" />
<span className="text-muted-foreground truncate text-sm">
Keenthemes
</span>
<ChevronRightIcon className="text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
<PackageIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
<h2 className="text-foreground truncate text-sm font-medium">
Modules
</h2>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2 lg:ml-auto lg:flex-nowrap">
<InputGroup className="w-full min-w-40 sm:w-48">
<InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchQuery}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder="Search..."
aria-label="Search modules"
/>
{searchQuery.length > 0 ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label="Clear search"
onClick={() => handleSearchChange("")}
>
<XIcon className="size-4" aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline">
<ArrowUpDownIcon data-icon="inline-start" aria-hidden="true" />
{sortLabels[sortBy]}
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuGroup>
{(["name", "dateStart", "progress", "status"] as const).map(
(value) => (
<DropdownMenuItem
key={value}
onClick={() => handleSortChange(value)}
>
{sortLabels[value]}
</DropdownMenuItem>
)
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline">
<FilterIcon data-icon="inline-start" aria-hidden="true" />
Filters
{activeFilterCount > 0 ? (
<Badge variant="outline" radius="full">
{activeFilterCount}
</Badge>
) : null}
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Status</DropdownMenuLabel>
{MODULE_STATUS_OPTIONS.map((status) => (
<DropdownMenuCheckboxItem
key={status}
checked={selectedStatuses.includes(status)}
closeOnClick={false}
onCheckedChange={(checked) =>
handleStatusToggle(status, checked === true)
}
>
{status}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{activeFilterCount > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
closeOnClick={false}
onClick={() => {
setSelectedStatuses([])
resetPagination()
}}
>
Reset filters
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<Button type="button" onClick={handleAddModule}>
Add Module
</Button>
</div>
</div>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<div className="border-t px-0 py-3">
{filteredModules.length > 0 ? (
<DataGridPagination
sizes={[5, 10, 15]}
info="{from} - {to} of {count} modules"
className="py-0"
/>
) : (
<p className="text-muted-foreground text-sm">0 modules</p>
)}
</div>
</div>
</DataGrid>
)
}
@@ -1,394 +0,0 @@
export type ModuleStatus = "Planned" | "Backlog" | "In Progress"
export type ModuleKind = "System" | "Feature" | "Area"
export type ModuleHealth = "On Track" | "Watch" | "Blocked"
export interface ModuleOwner {
name: string
initials: string
role: string
avatarSrc: string
}
export interface ModuleRecord {
id: string
name: string
kind: ModuleKind
owner: ModuleOwner
progress: number
tasksCompleted: number
tasksTotal: number
contributors: number
blockers: number
health: ModuleHealth
dateStart: string
dateEnd: string
dateRange: string
status: ModuleStatus
favorite: boolean
}
export const MODULE_STATUS_OPTIONS: ModuleStatus[] = [
"Planned",
"Backlog",
"In Progress",
]
const moduleOwners = {
maya: {
name: "Maya Patel",
initials: "MP",
role: "Release lead",
avatarSrc:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&h=80&dpr=2&q=80",
},
jonah: {
name: "Jonah Lee",
initials: "JL",
role: "Product ops",
avatarSrc:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=80&h=80&dpr=2&q=80",
},
nina: {
name: "Nina Santos",
initials: "NS",
role: "Workspace admin",
avatarSrc:
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=80&h=80&dpr=2&q=80",
},
elijah: {
name: "Elijah Morgan",
initials: "EM",
role: "Billing lead",
avatarSrc:
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=80&h=80&dpr=2&q=80",
},
priya: {
name: "Priya Shah",
initials: "PS",
role: "Lifecycle PM",
avatarSrc:
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=80&h=80&dpr=2&q=80",
},
omar: {
name: "Omar Haddad",
initials: "OH",
role: "Security owner",
avatarSrc:
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=80&h=80&dpr=2&q=80",
},
sofia: {
name: "Sofia Romero",
initials: "SR",
role: "Content lead",
avatarSrc:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=80&h=80&dpr=2&q=80",
},
kenji: {
name: "Kenji Tan",
initials: "KT",
role: "Platform lead",
avatarSrc:
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=80&h=80&dpr=2&q=80",
},
lena: {
name: "Lena Brooks",
initials: "LB",
role: "Developer tools",
avatarSrc:
"https://images.unsplash.com/photo-1551836022-d5d88e9218df?w=80&h=80&dpr=2&q=80",
},
} satisfies Record<string, ModuleOwner>
export const MODULE_RECORDS: ModuleRecord[] = [
{
id: "core-workflow",
name: "Core Workflow",
kind: "System",
owner: moduleOwners.maya,
progress: 25,
tasksCompleted: 8,
tasksTotal: 32,
contributors: 6,
blockers: 0,
health: "On Track",
dateStart: "2026-04-17",
dateEnd: "2026-05-01",
dateRange: "Apr 17 - May 01, 2026",
status: "Planned",
favorite: false,
},
{
id: "onboarding-flow",
name: "Onboarding Flow",
kind: "Feature",
owner: moduleOwners.jonah,
progress: 0,
tasksCompleted: 0,
tasksTotal: 18,
contributors: 4,
blockers: 0,
health: "Watch",
dateStart: "2026-04-19",
dateEnd: "2026-05-03",
dateRange: "Apr 19 - May 03, 2026",
status: "Backlog",
favorite: false,
},
{
id: "workspace-setup",
name: "Workspace Setup",
kind: "Area",
owner: moduleOwners.nina,
progress: 0,
tasksCompleted: 2,
tasksTotal: 14,
contributors: 3,
blockers: 1,
health: "Blocked",
dateStart: "2026-04-21",
dateEnd: "2026-05-05",
dateRange: "Apr 21 - May 05, 2026",
status: "In Progress",
favorite: false,
},
{
id: "permission-matrix",
name: "Permission Matrix",
kind: "System",
owner: moduleOwners.maya,
progress: 42,
tasksCompleted: 11,
tasksTotal: 26,
contributors: 5,
blockers: 0,
health: "Watch",
dateStart: "2026-04-22",
dateEnd: "2026-05-06",
dateRange: "Apr 22 - May 06, 2026",
status: "In Progress",
favorite: true,
},
{
id: "billing-rules",
name: "Billing Rules",
kind: "Feature",
owner: moduleOwners.elijah,
progress: 64,
tasksCompleted: 21,
tasksTotal: 33,
contributors: 7,
blockers: 0,
health: "On Track",
dateStart: "2026-04-18",
dateEnd: "2026-05-02",
dateRange: "Apr 18 - May 02, 2026",
status: "In Progress",
favorite: false,
},
{
id: "notification-center",
name: "Notification Center",
kind: "Area",
owner: moduleOwners.priya,
progress: 18,
tasksCompleted: 5,
tasksTotal: 28,
contributors: 4,
blockers: 2,
health: "Blocked",
dateStart: "2026-04-23",
dateEnd: "2026-05-09",
dateRange: "Apr 23 - May 09, 2026",
status: "Backlog",
favorite: false,
},
{
id: "audit-trail",
name: "Audit Trail",
kind: "System",
owner: moduleOwners.omar,
progress: 76,
tasksCompleted: 19,
tasksTotal: 25,
contributors: 5,
blockers: 0,
health: "On Track",
dateStart: "2026-04-15",
dateEnd: "2026-04-30",
dateRange: "Apr 15 - Apr 30, 2026",
status: "In Progress",
favorite: true,
},
{
id: "template-library",
name: "Template Library",
kind: "Feature",
owner: moduleOwners.sofia,
progress: 33,
tasksCompleted: 10,
tasksTotal: 30,
contributors: 6,
blockers: 0,
health: "Watch",
dateStart: "2026-04-24",
dateEnd: "2026-05-10",
dateRange: "Apr 24 - May 10, 2026",
status: "Planned",
favorite: false,
},
{
id: "integration-hub",
name: "Integration Hub",
kind: "Area",
owner: moduleOwners.kenji,
progress: 58,
tasksCompleted: 14,
tasksTotal: 24,
contributors: 8,
blockers: 1,
health: "Watch",
dateStart: "2026-04-20",
dateEnd: "2026-05-04",
dateRange: "Apr 20 - May 04, 2026",
status: "In Progress",
favorite: false,
},
{
id: "api-console",
name: "API Console",
kind: "Feature",
owner: moduleOwners.lena,
progress: 91,
tasksCompleted: 29,
tasksTotal: 32,
contributors: 4,
blockers: 0,
health: "On Track",
dateStart: "2026-04-12",
dateEnd: "2026-04-26",
dateRange: "Apr 12 - Apr 26, 2026",
status: "In Progress",
favorite: true,
},
{
id: "role-automation",
name: "Role Automation",
kind: "System",
owner: moduleOwners.maya,
progress: 12,
tasksCompleted: 3,
tasksTotal: 25,
contributors: 3,
blockers: 1,
health: "Blocked",
dateStart: "2026-04-25",
dateEnd: "2026-05-12",
dateRange: "Apr 25 - May 12, 2026",
status: "Backlog",
favorite: false,
},
{
id: "workspace-invites",
name: "Workspace Invites",
kind: "Feature",
owner: moduleOwners.jonah,
progress: 47,
tasksCompleted: 15,
tasksTotal: 32,
contributors: 5,
blockers: 0,
health: "Watch",
dateStart: "2026-04-19",
dateEnd: "2026-05-06",
dateRange: "Apr 19 - May 06, 2026",
status: "Planned",
favorite: false,
},
{
id: "release-checklist",
name: "Release Checklist",
kind: "Area",
owner: moduleOwners.nina,
progress: 84,
tasksCompleted: 26,
tasksTotal: 31,
contributors: 7,
blockers: 0,
health: "On Track",
dateStart: "2026-04-16",
dateEnd: "2026-05-01",
dateRange: "Apr 16 - May 01, 2026",
status: "In Progress",
favorite: false,
},
{
id: "reporting-digest",
name: "Reporting Digest",
kind: "Feature",
owner: moduleOwners.priya,
progress: 5,
tasksCompleted: 2,
tasksTotal: 38,
contributors: 3,
blockers: 0,
health: "Watch",
dateStart: "2026-04-28",
dateEnd: "2026-05-16",
dateRange: "Apr 28 - May 16, 2026",
status: "Backlog",
favorite: false,
},
{
id: "security-review",
name: "Security Review",
kind: "System",
owner: moduleOwners.omar,
progress: 69,
tasksCompleted: 18,
tasksTotal: 26,
contributors: 6,
blockers: 2,
health: "Blocked",
dateStart: "2026-04-18",
dateEnd: "2026-05-07",
dateRange: "Apr 18 - May 07, 2026",
status: "In Progress",
favorite: false,
},
{
id: "help-center",
name: "Help Center",
kind: "Area",
owner: moduleOwners.sofia,
progress: 39,
tasksCompleted: 9,
tasksTotal: 23,
contributors: 4,
blockers: 0,
health: "On Track",
dateStart: "2026-04-23",
dateEnd: "2026-05-11",
dateRange: "Apr 23 - May 11, 2026",
status: "Planned",
favorite: false,
},
{
id: "data-retention",
name: "Data Retention",
kind: "System",
owner: moduleOwners.kenji,
progress: 22,
tasksCompleted: 7,
tasksTotal: 32,
contributors: 5,
blockers: 1,
health: "Watch",
dateStart: "2026-04-27",
dateEnd: "2026-05-14",
dateRange: "Apr 27 - May 14, 2026",
status: "Backlog",
favorite: false,
},
]
@@ -1,15 +0,0 @@
import { ModulesDataGridView } from "./components/data-grid-view"
export function Page() {
return (
<main
className="mx-auto flex min-h-svh w-full max-w-6xl items-start justify-center p-4 pt-8 sm:p-8 sm:pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Modules data grid
</h1>
<ModulesDataGridView />
</main>
)
}
@@ -1,394 +0,0 @@
"use client"
import { type ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import { type DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from "@/components/reui/data-grid/data-grid-table"
import { Rating } from "@/components/reui/rating"
import { type ColumnDef } from "@tanstack/react-table"
import { format, parseISO } from "date-fns"
import { cn } from "@evobgp/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import { Switch } from "@evobgp/ui/components/switch"
import {
type AutomationKind,
type AutomationOwnerAvailability,
type AutomationState,
type IAutomationRecord,
} from "./data"
import { GitBranchIcon, RouteIcon, SparklesIcon, MailIcon, BellRingIcon, EllipsisVerticalIcon, PencilIcon, EyeIcon, CircleCheckIcon, ArchiveIcon } from "lucide-react"
export type AutomationAction = "edit" | "open" | "archive"
const automationKindStyles: Record<
AutomationKind,
{ chipClassName: string; icon: ReactNode }
> = {
sequence: {
chipClassName: "text-sky-600 dark:text-sky-300",
icon: (
<GitBranchIcon className="size-4" aria-hidden="true" />
),
},
routing: {
chipClassName: "text-violet-600 dark:text-violet-300",
icon: (
<RouteIcon className="size-4" aria-hidden="true" />
),
},
enrichment: {
chipClassName: "text-emerald-600 dark:text-emerald-300",
icon: (
<SparklesIcon className="size-4" aria-hidden="true" />
),
},
digest: {
chipClassName: "text-amber-600 dark:text-amber-300",
icon: (
<MailIcon className="size-4" aria-hidden="true" />
),
},
escalation: {
chipClassName: "text-rose-600 dark:text-rose-300",
icon: (
<BellRingIcon className="size-4" aria-hidden="true" />
),
},
}
const stateBadgeStyles: Record<
AutomationState,
{ label: string; dotClassName?: string }
> = {
live: {
label: "Live",
dotClassName: "bg-emerald-500",
},
review: {
label: "Needs approval",
dotClassName: "bg-amber-500",
},
drafts: {
label: "Draft",
dotClassName: "bg-slate-400 dark:bg-slate-300",
},
paused: {
label: "Paused",
dotClassName: "bg-zinc-400 dark:bg-zinc-300",
},
}
const updatedBucketLabel: Record<IAutomationRecord["updatedBucket"], string> = {
today: "Today",
"this-week": "This week",
older: "Older",
}
const availabilityColor: Record<AutomationOwnerAvailability, string> = {
online: "bg-green-500",
away: "bg-yellow-400",
busy: "bg-red-500",
offline: "bg-gray-500",
}
function AutomationKindChip({ kind }: { kind: AutomationKind }) {
const style = automationKindStyles[kind]
return (
<Item
render={<span />}
className={cn(
"p-0",
"border-background bg-muted flex size-9 items-center justify-center border-2 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4",
style.chipClassName
)}
aria-hidden="true"
>
<ItemMedia variant="icon" className="size-auto">
{style.icon}
</ItemMedia>
</Item>
)
}
function AutomationNameCell({ automation }: { automation: IAutomationRecord }) {
return (
<div className="flex min-w-0 items-center gap-3">
<AutomationKindChip kind={automation.kind} />
<div className="flex min-w-0 flex-col gap-px">
<span className="text-foreground truncate text-sm font-medium">
{automation.title}
</span>
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
<span className="truncate">{automation.runWindowLabel}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="truncate">{automation.audienceLabel}</span>
</div>
</div>
</div>
)
}
function OwnerCell({ automation }: { automation: IAutomationRecord }) {
return (
<div className="flex items-center gap-2">
<div className="relative shrink-0">
<Avatar className="size-8">
{automation.owner.avatar ? (
<AvatarImage
src={automation.owner.avatar}
alt={automation.owner.name}
/>
) : null}
<AvatarFallback>{automation.owner.initials}</AvatarFallback>
</Avatar>
<span
className={cn(
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
availabilityColor[automation.owner.availability]
)}
aria-hidden
/>
</div>
<div className="min-w-0">
<div className="text-foreground line-clamp-1 font-medium">
{automation.owner.name}
</div>
<div
className="text-muted-foreground line-clamp-1 text-xs"
title={automation.owner.email}
>
{automation.owner.email}
</div>
</div>
</div>
)
}
function StateCell({ automation }: { automation: IAutomationRecord }) {
const stateStyle = stateBadgeStyles[automation.state]
return (
<div className="flex min-w-0 items-center">
<Badge variant="outline" className="gap-1.5">
{stateStyle.dotClassName ? (
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
stateStyle.dotClassName
)}
aria-hidden="true"
/>
) : null}
{stateStyle.label}
</Badge>
</div>
)
}
function UpdatedCell({ automation }: { automation: IAutomationRecord }) {
return (
<div className="flex min-w-0 flex-col gap-px">
<span className="text-foreground text-sm">
{format(parseISO(automation.updatedAt), "MMM d, yyyy")}
</span>
<span className="text-muted-foreground text-xs">
{updatedBucketLabel[automation.updatedBucket]}
</span>
</div>
)
}
function RatingCell({ automation }: { automation: IAutomationRecord }) {
return <Rating rating={automation.rating} size="sm" showValue={true} />
}
function AutomationActionsCell({
automation,
onAction,
onToggleEnabled,
}: {
automation: IAutomationRecord
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${automation.title}`}
>
<EllipsisVerticalIcon className="size-4" aria-hidden="true" />
</Button>
}
/>
{/* Content */}
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onAction("edit", automation)}>
<PencilIcon className="size-4" aria-hidden="true" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction("open", automation)}>
<EyeIcon className="size-4" aria-hidden="true" />
View Details
</DropdownMenuItem>
<DropdownMenuItem
closeOnClick={false}
onClick={(event) => {
// The Switch toggles itself and its click bubbles here; skip it
// so item-level activation (row click, Enter/Space) toggles once.
if (
event.target instanceof Element &&
event.target.closest('[data-slot="switch"]')
) {
return
}
onToggleEnabled(automation, !automation.enabled)
}}
className="justify-between gap-4"
>
<span className="flex items-center gap-2">
<CircleCheckIcon className="size-4" aria-hidden="true" />
Enabled
</span>
<Switch
size="sm"
aria-label={`Toggle ${automation.title}`}
checked={automation.enabled}
onCheckedChange={(checked) =>
onToggleEnabled(automation, checked)
}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onAction("archive", automation)}
>
<ArchiveIcon className="size-4" aria-hidden="true" />
Archive
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function createAutomationColumns({
onAction,
onToggleEnabled,
}: {
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
}): ColumnDef<DataGridFeatures, IAutomationRecord>[] {
return [
{
id: "select",
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
size: 30,
enableSorting: false,
enableResizing: false,
enableHiding: false,
},
{
accessorFn: (row) => row.title,
id: "workflow",
header: ({ column }) => (
<DataGridColumnHeader title="Workflow" column={column} />
),
cell: ({ row }) => <AutomationNameCell automation={row.original} />,
size: 340,
minSize: 200,
enableSorting: true,
enableHiding: false,
meta: {
autoSize: true,
headerClassName: "pl-3!",
cellClassName: "pl-3!",
},
},
{
accessorFn: (row) => row.owner.name,
id: "owner",
header: ({ column }) => (
<DataGridColumnHeader title="Owner" column={column} />
),
cell: ({ row }) => <OwnerCell automation={row.original} />,
size: 175,
enableSorting: true,
},
{
accessorFn: (row) => row.rating,
id: "rating",
header: ({ column }) => (
<DataGridColumnHeader title="Score" column={column} />
),
cell: ({ row }) => <RatingCell automation={row.original} />,
size: 150,
enableSorting: true,
},
{
accessorFn: (row) => row.state,
id: "state",
header: ({ column }) => (
<DataGridColumnHeader title="State" column={column} />
),
cell: ({ row }) => <StateCell automation={row.original} />,
size: 150,
enableSorting: true,
},
{
accessorFn: (row) => parseISO(row.updatedAt).getTime(),
id: "updatedAt",
header: ({ column }) => (
<DataGridColumnHeader title="Last updated" column={column} />
),
cell: ({ row }) => <UpdatedCell automation={row.original} />,
size: 125,
enableSorting: true,
},
{
id: "actions",
header: () => null,
cell: ({ row }) => (
<div className="flex items-center justify-end">
<AutomationActionsCell
automation={row.original}
onAction={onAction}
onToggleEnabled={onToggleEnabled}
/>
</div>
),
size: 56,
enableSorting: false,
},
]
}
@@ -1,679 +0,0 @@
import { useCallback, useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
DataGrid,
dataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import { Filters } from "@/components/reui/filters/filters"
import {
createFilterQuery,
createFilterRule,
flattenFilterConditions,
} from "@/components/reui/filters/filters-query"
import type { FilterCondition } from "@/components/reui/filters/filters-query"
import type {
FilterField,
FilterQuery,
} from "@/components/reui/filters/filters-types"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
useTable,
type PaginationState,
type RowSelectionState,
type SortingState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@evobgp/ui/lib/utils"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@evobgp/ui/components/alert-dialog"
import { Button } from "@evobgp/ui/components/button"
import { Separator } from "@evobgp/ui/components/separator"
import { Tabs, TabsList, TabsTrigger } from "@evobgp/ui/components/tabs"
import { createAutomationColumns } from "./columns"
import {
AUTOMATION_TABS,
AUTOMATIONS,
DELIVERY_FILTER_OPTIONS,
getAutomationTabFromState,
OWNER_FILTER_OPTIONS,
UPDATED_FILTER_OPTIONS,
type AutomationState,
type AutomationTab,
type IAutomationRecord,
} from "./data"
import { SearchIcon, UsersIcon, RouteIcon, ClockIcon, PlusIcon, FilterIcon, FunnelXIcon } from "lucide-react"
type ToastTone = "success" | "neutral" | "destructive"
const toneStyles: Record<ToastTone, { dot: string }> = {
success: { dot: "bg-emerald-500" },
neutral: { dot: "bg-sky-500" },
destructive: { dot: "bg-rose-500" },
}
function showAutomationToast({
tone,
title,
description,
}: {
tone: ToastTone
title: string
description: string
}) {
toast.custom((id) => (
<div className="bg-popover text-popover-foreground border-border flex w-[356px] flex-col gap-3 rounded-md border p-4 shadow-lg">
<div className="flex items-start gap-2">
<span
className={cn(
"mt-1 flex size-2 shrink-0 rounded-full",
toneStyles[tone].dot
)}
aria-hidden="true"
/>
<div className="flex flex-1 flex-col gap-1">
<p className="text-sm font-semibold">{title}</p>
<p className="text-muted-foreground text-sm leading-relaxed text-pretty">
{description}
</p>
</div>
</div>
<div className="flex gap-2">
<Button size="xs" variant="outline" onClick={() => toast.dismiss(id)}>
Done
</Button>
</div>
</div>
))
}
function getAutomationSearchBlob(automation: IAutomationRecord) {
return [
automation.title,
automation.kind,
automation.state,
automation.deliveryMode,
automation.audienceLabel,
automation.runWindowLabel,
automation.owner.name,
automation.owner.email,
automation.owner.teamLabel,
automation.approvalRequired ? "approval required" : "auto-approved",
automation.enabled ? "enabled" : "disabled",
]
.join(" ")
.toLowerCase()
}
/** Operators that take no value, so an empty `values` list is expected. */
const VALUELESS_OPERATORS = new Set(["empty", "not_empty"])
function getActiveFilters(filters: FilterCondition[]) {
return filters.filter((filter) => {
const { operator, values } = filter
if (VALUELESS_OPERATORS.has(operator)) return true
if (!values || values.length === 0) return false
if (
values.every((value) => typeof value === "string" && value.trim() === "")
) {
return false
}
if (values.every((value) => value === null || value === undefined)) {
return false
}
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
return false
}
return true
})
}
function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return "Select..."
if (values.length > 1) return `${values.length} selected`
return null
}
function renderSingleSelectedLabel(
values: unknown[],
options: { value: string; label: string }[]
) {
const state = renderSelectedCount(values)
if (state) return state
const option = options.find((item) => item.value === values[0])
return option?.label ?? String(values[0])
}
function filterFieldValue(
automation: IAutomationRecord,
field: string
): unknown {
switch (field) {
case "workflow":
return getAutomationSearchBlob(automation)
case "ownerTeam":
return automation.owner.team
case "deliveryMode":
return automation.deliveryMode
case "updatedBucket":
return automation.updatedBucket
default:
return ""
}
}
function matchesFilterCondition(
fieldValue: unknown,
operator: string,
values: unknown[]
): boolean {
switch (operator) {
case "is":
return values.includes(fieldValue)
case "is_not":
return !values.includes(fieldValue)
case "is_any_of":
return values.some((value) => fieldValue === value)
case "is_none_of":
return !values.some((value) => fieldValue === value)
case "contains": {
const tokens = values.map((value) => String(value).trim()).filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase())
)
}
case "not_contains":
return !values.some((value) =>
String(fieldValue).toLowerCase().includes(String(value).toLowerCase())
)
case "starts_with":
return values.some((value) =>
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase())
)
case "ends_with":
return values.some((value) =>
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase())
)
case "empty":
return fieldValue === "" || fieldValue == null
case "not_empty":
return fieldValue !== "" && fieldValue != null
default:
return true
}
}
function applyFiltersToData(
data: IAutomationRecord[],
filters: FilterCondition[]
): IAutomationRecord[] {
const active = getActiveFilters(filters)
let result = [...data]
active.forEach((filter) => {
const { field, operator, values, negated } = filter
result = result.filter((item) => {
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? raw : ""
const matches = matchesFilterCondition(fieldValue, operator, values)
// A chip negated from its menu keeps its operator and flips its meaning.
return negated ? !matches : matches
})
})
return result
}
const OWNER_TEAM_FILTER_OPTIONS = OWNER_FILTER_OPTIONS.filter(
(option) => option.value !== "everyone"
).map((option) => ({
value: option.value,
label: option.label,
}))
const DELIVERY_MODE_FILTER_OPTIONS = DELIVERY_FILTER_OPTIONS.filter(
(option) => option.value !== "any"
).map((option) => ({
value: option.value,
label: option.label,
}))
const UPDATED_BUCKET_FILTER_OPTIONS = UPDATED_FILTER_OPTIONS.filter(
(option) => option.value !== "any"
).map((option) => ({
value: option.value,
label: option.label,
}))
const filterFields: FilterField[] = [
{
id: "workflow",
label: "Workflow",
icon: (
<SearchIcon className="size-3.5" aria-hidden />
),
type: "text",
placeholder: "Search...",
},
{
id: "ownerTeam",
label: "Owner team",
icon: (
<UsersIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
options: OWNER_TEAM_FILTER_OPTIONS,
renderValue: ({ values }) =>
renderSingleSelectedLabel(values, OWNER_TEAM_FILTER_OPTIONS),
},
{
id: "deliveryMode",
label: "Delivery mode",
icon: (
<RouteIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
options: DELIVERY_MODE_FILTER_OPTIONS,
renderValue: ({ values }) =>
renderSingleSelectedLabel(values, DELIVERY_MODE_FILTER_OPTIONS),
},
{
id: "updatedBucket",
label: "Last updated",
icon: (
<ClockIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
options: UPDATED_BUCKET_FILTER_OPTIONS,
renderValue: ({ values }) =>
renderSingleSelectedLabel(values, UPDATED_BUCKET_FILTER_OPTIONS),
},
]
function createDefaultAutomationFilters(): FilterQuery {
return createFilterQuery([
createFilterRule({
id: "workflow-1",
path: ["workflow"],
operator: "contains",
value: "",
}),
])
}
function getTabCounts(records: IAutomationRecord[]) {
return {
all: records.length,
live: records.filter((record) => record.state === "live").length,
review: records.filter((record) => record.state === "review").length,
drafts: records.filter((record) => record.state === "drafts").length,
paused: records.filter((record) => record.state === "paused").length,
} satisfies Record<AutomationTab, number>
}
export function AutomationLibraryGridView() {
const [automations, setAutomations] =
useState<IAutomationRecord[]>(AUTOMATIONS)
const [activeTab, setActiveTab] = useState<AutomationTab>("all")
const [filterQuery, setFilterQuery] = useState<FilterQuery>(
createDefaultAutomationFilters
)
const filters = useMemo(
() => flattenFilterConditions(filterQuery),
[filterQuery]
)
const [sorting, setSorting] = useState<SortingState>([
{ id: "updatedAt", desc: true },
])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [automationPendingArchive, setAutomationPendingArchive] =
useState<IAutomationRecord | null>(null)
const resetPagination = useCallback(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
)
}, [])
const filteredBaseAutomations = useMemo(() => {
return applyFiltersToData(automations, filters)
}, [automations, filters])
const filteredAutomations = useMemo(
() =>
filteredBaseAutomations.filter((automation) =>
activeTab === "all"
? true
: getAutomationTabFromState(automation.state) === activeTab
),
[activeTab, filteredBaseAutomations]
)
const tabCounts = useMemo(
() => getTabCounts(filteredBaseAutomations),
[filteredBaseAutomations]
)
const filteredLiveCount = useMemo(
() =>
filteredAutomations.filter((automation) => automation.state === "live")
.length,
[filteredAutomations]
)
const filteredReviewCount = useMemo(
() =>
filteredAutomations.filter((automation) => automation.state === "review")
.length,
[filteredAutomations]
)
const selectedCount = useMemo(
() => Object.keys(rowSelection).length,
[rowSelection]
)
const columns = useMemo(
() =>
createAutomationColumns({
onAction: (action, automation) => {
if (action === "archive") {
setAutomationPendingArchive(automation)
return
}
if (action === "edit") {
showAutomationToast({
tone: "neutral",
title: "Workflow editor",
description: `Connect "${automation.title}" to your builder, side panel, or automation step editor.`,
})
return
}
showAutomationToast({
tone: "success",
title: "Workflow details",
description: `"${automation.title}" is ready for a detail route, run history drawer, or audit panel.`,
})
},
onToggleEnabled: (automation, nextValue) => {
const nextState: AutomationState =
nextValue && automation.state === "paused"
? "live"
: nextValue && automation.state === "drafts"
? "review"
: !nextValue && automation.state === "live"
? "paused"
: automation.state
setAutomations((current) =>
current.map((item) =>
item.id === automation.id
? {
...item,
enabled: nextValue,
state: nextState,
}
: item
)
)
showAutomationToast({
tone: nextValue ? "success" : "neutral",
title: nextValue ? "Workflow enabled" : "Workflow paused",
description: nextValue
? `"${automation.title}" is ready to run in the ${nextState === "review" ? "review" : "live"} queue.`
: `"${automation.title}" will stay available but will not continue running until resumed.`,
})
},
}),
[]
)
const table = useTable({
features: dataGridFeatures,
data: filteredAutomations,
columns,
getRowId: (row) => row.id,
state: {
sorting,
rowSelection,
pagination,
},
enableRowSelection: true,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
})
const handleClearControls = useCallback(() => {
setFilterQuery(createDefaultAutomationFilters())
resetPagination()
}, [resetPagination])
const handleFiltersChange = useCallback(
(nextQuery: FilterQuery) => {
setFilterQuery(nextQuery)
resetPagination()
},
[resetPagination]
)
const handleArchiveAutomation = useCallback(() => {
if (!automationPendingArchive) return
const automationToArchive = automationPendingArchive
setAutomations((current) =>
current.filter(
(automation) => automation.id !== automationPendingArchive.id
)
)
setRowSelection((current) => {
const next = { ...current }
delete next[automationPendingArchive.id]
return next
})
setAutomationPendingArchive(null)
resetPagination()
showAutomationToast({
tone: "destructive",
title: "Workflow archived",
description: `"${automationToArchive.title}" was removed from this automation library.`,
})
}, [automationPendingArchive, resetPagination])
const emptyMessage =
"No workflows match this automation slice. Switch tabs or clear the filters."
return (
<>
{/* Table */}
<DataGrid
table={table}
recordCount={filteredAutomations.length}
emptyMessage={emptyMessage}
tableLayout={{
dense: true,
}}
tableClassNames={{
edgeCell: "first:ps-3 last:pe-3",
}}
>
<Frame dense variant="default" spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-px">
<FrameTitle className="text-balance">
Automation Library
</FrameTitle>
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>
{filteredAutomations.length} workflow
{filteredAutomations.length === 1 ? "" : "s"}
</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{filteredLiveCount} live</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{filteredReviewCount} review</span>
{selectedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} selected</span>
</>
) : null}
</FrameDescription>
</div>
<Button type="button" className="shrink-0">
<PlusIcon className="size-4" aria-hidden="true" />
New workflow
</Button>
</FrameHeader>
<FramePanel className="p-0 shadow-none!">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<Tabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value as AutomationTab)
resetPagination()
}}
>
<TabsList variant="line" className="gap-5">
{AUTOMATION_TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="gap-2 px-0 pt-0 pb-(--frame-panel-header-py) text-sm"
>
<span>{tab.label}</span>
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tabCounts[tab.value]}
</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<Separator />
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
<Filters
query={filterQuery}
fields={filterFields}
onQueryChange={handleFiltersChange}
size="default"
trigger={
<Button variant="outline" aria-label="Filters">
<FilterIcon className="size-4" aria-hidden="true" />
Filters
</Button>
}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} selected
</Badge>
) : null}
<Button
type="button"
variant="outline"
onClick={handleClearControls}
>
<FunnelXIcon className="size-4" aria-hidden="true" />
Clear
</Button>
</div>
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
<AlertDialog
open={automationPendingArchive != null}
onOpenChange={(open) => {
if (!open) {
setAutomationPendingArchive(null)
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive workflow?</AlertDialogTitle>
<AlertDialogDescription>
{automationPendingArchive
? `Archive "${automationPendingArchive.title}" from this automation library. Run history and ownership context can stay available in your backend, but this row will disappear from the grid preview.`
: "Archive this workflow from the automation library."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleArchiveAutomation}
render={
<Button type="button" variant="destructive">
Archive
</Button>
}
/>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -1,384 +0,0 @@
export type AutomationTab = "all" | "live" | "review" | "drafts" | "paused"
export type AutomationKind =
| "sequence"
| "routing"
| "enrichment"
| "digest"
| "escalation"
export type OwnerFilter =
| "everyone"
| "product"
| "engineering"
| "operations"
| "revenue"
| "support"
export type DeliveryFilter =
| "any"
| "scheduled"
| "event-driven"
| "manual"
| "hybrid"
export type UpdatedFilter = "any" | "today" | "this-week" | "older"
export type AutomationState = Exclude<AutomationTab, "all">
export type AutomationOwnerAvailability = "online" | "away" | "busy" | "offline"
export interface IAutomationOwner {
id: string
name: string
email: string
initials: string
avatar?: string
availability: AutomationOwnerAvailability
team: Exclude<OwnerFilter, "everyone">
teamLabel: string
}
export interface IAutomationRecord {
id: string
title: string
kind: AutomationKind
state: AutomationState
rating: number
deliveryMode: Exclude<DeliveryFilter, "any">
owner: IAutomationOwner
updatedAt: string
updatedBucket: Exclude<UpdatedFilter, "any">
enabled: boolean
approvalRequired: boolean
audienceLabel: string
runWindowLabel: string
}
const OWNERS: Record<string, IAutomationOwner> = {
maya: {
id: "maya-patel",
name: "Maya Patel",
email: "maya@reui.io",
initials: "MP",
avatar:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
availability: "online",
team: "product",
teamLabel: "Product",
},
jonas: {
id: "jonas-reed",
name: "Jonas Reed",
email: "jonas@reui.io",
initials: "JR",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
availability: "busy",
team: "engineering",
teamLabel: "Engineering",
},
priya: {
id: "priya-nair",
name: "Priya Nair",
email: "priya@reui.io",
initials: "PN",
avatar:
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
availability: "away",
team: "operations",
teamLabel: "Operations",
},
emil: {
id: "emil-novak",
name: "Emil Novak",
email: "emil@reui.io",
initials: "EN",
avatar:
"https://images.unsplash.com/photo-1560250097-0b93528c311a?w=96&h=96&dpr=2&q=80",
availability: "offline",
team: "revenue",
teamLabel: "Revenue",
},
nora: {
id: "nora-ibrahim",
name: "Nora Ibrahim",
email: "nora@reui.io",
initials: "NI",
avatar:
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
availability: "online",
team: "support",
teamLabel: "Support",
},
}
function automation(
input: Omit<IAutomationRecord, "owner"> & {
owner: keyof typeof OWNERS
}
): IAutomationRecord {
return {
...input,
owner: OWNERS[input.owner],
}
}
export const AUTOMATION_TABS: { value: AutomationTab; label: string }[] = [
{ value: "all", label: "All" },
{ value: "live", label: "Live" },
{ value: "review", label: "Needs Review" },
{ value: "drafts", label: "Drafts" },
{ value: "paused", label: "Paused" },
]
export const OWNER_FILTER_OPTIONS: {
value: OwnerFilter
label: string
}[] = [
{ value: "everyone", label: "Owner team" },
{ value: "product", label: "Product" },
{ value: "engineering", label: "Engineering" },
{ value: "operations", label: "Operations" },
{ value: "revenue", label: "Revenue" },
{ value: "support", label: "Support" },
]
export const DELIVERY_FILTER_OPTIONS: {
value: DeliveryFilter
label: string
}[] = [
{ value: "any", label: "Delivery mode" },
{ value: "scheduled", label: "Scheduled" },
{ value: "event-driven", label: "Event-driven" },
{ value: "manual", label: "Manual" },
{ value: "hybrid", label: "Hybrid" },
]
export const UPDATED_FILTER_OPTIONS: {
value: UpdatedFilter
label: string
}[] = [
{ value: "any", label: "Last updated" },
{ value: "today", label: "Today" },
{ value: "this-week", label: "This week" },
{ value: "older", label: "Older" },
]
export function getAutomationTabFromState(
state: AutomationState
): Exclude<AutomationTab, "all"> {
return state
}
export const AUTOMATIONS: IAutomationRecord[] = [
automation({
id: "renewal-touchpoint-orchestration",
title: "Renewal touchpoint orchestration",
kind: "sequence",
state: "live",
rating: 4.8,
deliveryMode: "scheduled",
owner: "emil",
updatedAt: "2026-04-11",
updatedBucket: "today",
enabled: true,
approvalRequired: false,
audienceLabel: "Renewal accounts",
runWindowLabel: "Weekdays 09:00",
}),
automation({
id: "delegated-sender-review-route",
title: "Delegated sender review route",
kind: "routing",
state: "review",
rating: 4.2,
deliveryMode: "manual",
owner: "priya",
updatedAt: "2026-04-11",
updatedBucket: "today",
enabled: false,
approvalRequired: true,
audienceLabel: "Delegated senders",
runWindowLabel: "Queue-based release",
}),
automation({
id: "launch-handoff-digest",
title: "Launch handoff digest",
kind: "digest",
state: "live",
rating: 4.7,
deliveryMode: "scheduled",
owner: "maya",
updatedAt: "2026-04-10",
updatedBucket: "this-week",
enabled: true,
approvalRequired: false,
audienceLabel: "Launch squad",
runWindowLabel: "Daily 08:30",
}),
automation({
id: "sla-escalation-watch",
title: "SLA escalation watch",
kind: "escalation",
state: "live",
rating: 4.9,
deliveryMode: "event-driven",
owner: "nora",
updatedAt: "2026-04-10",
updatedBucket: "this-week",
enabled: true,
approvalRequired: false,
audienceLabel: "Priority tickets",
runWindowLabel: "On trigger",
}),
automation({
id: "lead-enrichment-pass",
title: "Lead enrichment pass",
kind: "enrichment",
state: "paused",
rating: 3.9,
deliveryMode: "hybrid",
owner: "jonas",
updatedAt: "2026-04-09",
updatedBucket: "this-week",
enabled: false,
approvalRequired: false,
audienceLabel: "Inbound pipeline",
runWindowLabel: "Hourly batch",
}),
automation({
id: "sandbox-onboarding-sequence",
title: "Sandbox onboarding sequence",
kind: "sequence",
state: "drafts",
rating: 4.1,
deliveryMode: "scheduled",
owner: "priya",
updatedAt: "2026-04-08",
updatedBucket: "this-week",
enabled: false,
approvalRequired: false,
audienceLabel: "Trial workspaces",
runWindowLabel: "Pending QA",
}),
automation({
id: "partner-routing-fallback",
title: "Partner routing fallback",
kind: "routing",
state: "live",
rating: 4.4,
deliveryMode: "hybrid",
owner: "emil",
updatedAt: "2026-04-07",
updatedBucket: "this-week",
enabled: true,
approvalRequired: false,
audienceLabel: "Partner renewals",
runWindowLabel: "Live + nightly",
}),
automation({
id: "weekly-adoption-digest",
title: "Weekly adoption digest",
kind: "digest",
state: "paused",
rating: 3.8,
deliveryMode: "scheduled",
owner: "maya",
updatedAt: "2026-04-06",
updatedBucket: "this-week",
enabled: false,
approvalRequired: false,
audienceLabel: "Workspace champions",
runWindowLabel: "Fridays 16:00",
}),
automation({
id: "enterprise-risk-escalation",
title: "Enterprise risk escalation",
kind: "escalation",
state: "review",
rating: 4.3,
deliveryMode: "manual",
owner: "nora",
updatedAt: "2026-04-05",
updatedBucket: "older",
enabled: false,
approvalRequired: true,
audienceLabel: "Enterprise accounts",
runWindowLabel: "Manual release",
}),
automation({
id: "crm-enrichment-backfill",
title: "CRM enrichment backfill",
kind: "enrichment",
state: "live",
rating: 4.6,
deliveryMode: "scheduled",
owner: "jonas",
updatedAt: "2026-04-04",
updatedBucket: "older",
enabled: true,
approvalRequired: false,
audienceLabel: "Open opportunities",
runWindowLabel: "Nightly 01:00",
}),
automation({
id: "trial-conversion-follow-up",
title: "Trial conversion follow-up",
kind: "sequence",
state: "drafts",
rating: 4.0,
deliveryMode: "scheduled",
owner: "emil",
updatedAt: "2026-04-03",
updatedBucket: "older",
enabled: false,
approvalRequired: false,
audienceLabel: "Product-led signups",
runWindowLabel: "Awaiting copy",
}),
automation({
id: "owner-assignment-router",
title: "Owner assignment router",
kind: "routing",
state: "live",
rating: 4.5,
deliveryMode: "event-driven",
owner: "priya",
updatedAt: "2026-04-02",
updatedBucket: "older",
enabled: true,
approvalRequired: false,
audienceLabel: "Workspace requests",
runWindowLabel: "Immediate",
}),
automation({
id: "ops-exception-digest",
title: "Ops exception digest",
kind: "digest",
state: "review",
rating: 4.1,
deliveryMode: "scheduled",
owner: "priya",
updatedAt: "2026-04-11",
updatedBucket: "today",
enabled: false,
approvalRequired: true,
audienceLabel: "Ops leadership",
runWindowLabel: "Daily 18:00",
}),
automation({
id: "billing-retry-escalation",
title: "Billing retry escalation",
kind: "escalation",
state: "live",
rating: 4.7,
deliveryMode: "event-driven",
owner: "nora",
updatedAt: "2026-04-01",
updatedBucket: "older",
enabled: true,
approvalRequired: false,
audienceLabel: "Recovery queue",
runWindowLabel: "On failure",
}),
]
@@ -1,15 +0,0 @@
import { AutomationLibraryGridView } from "./components/data-grid-view"
export function Page() {
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Automation library data grid
</h1>
<AutomationLibraryGridView />
</main>
)
}
@@ -1,106 +0,0 @@
import { type CSSProperties } from "react"
import {
Frame,
FrameFooter,
FramePanel,
} from "@/components/reui/frame"
import { IconStack } from "@/components/reui/icon-stack"
import { Button } from "@evobgp/ui/components/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@evobgp/ui/components/empty"
import { Separator } from "@evobgp/ui/components/separator"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@evobgp/ui/components/tooltip"
import { Building2Icon, InfoIcon } from "lucide-react"
export function EmptyState() {
return (
<section
className="flex w-full max-w-5xl flex-col gap-8"
aria-labelledby="organizations-heading"
>
<div className="flex flex-col gap-6">
<h2
id="organizations-heading"
className="text-2xl font-semibold tracking-tight"
>
Organizations
</h2>
<Separator />
</div>
<TooltipProvider delay={200}>
<Frame spacing="xs" className="w-full" aria-label="Organizations setup">
<FramePanel className="flex min-h-[274px] items-center justify-center px-6 py-12">
<Empty className="max-w-md flex-none bg-transparent p-0">
<EmptyHeader className="gap-5">
<EmptyMedia className="mb-0">
<IconStack
aria-hidden="true"
className="h-20 w-18"
style={
{
"--icon-stack-content-x": "70%",
"--icon-stack-content-y": "57%",
} as CSSProperties
}
>
<Building2Icon className="size-4.5" strokeWidth="1.8" aria-hidden="true" />
</IconStack>
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">
Enable organizations
</EmptyTitle>
<EmptyDescription className="max-w-sm text-sm/relaxed">
Enable your users to create organizations, manage team
members, and control access with role-based permissions.
</EmptyDescription>
</div>
</EmptyHeader>
<EmptyContent className="mt-5 max-w-none gap-0">
<Button type="button">Enable organizations</Button>
</EmptyContent>
</Empty>
</FramePanel>
<FrameFooter className="flex-row items-center justify-start">
<Tooltip>
<TooltipTrigger
render={
<a
href="#organizations-docs"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-sm text-xs font-medium transition-colors outline-none focus-visible:ring-[3px]"
/>
}
>
<InfoIcon className="size-3.5" aria-hidden="true" />
<span>
Learn more about{" "}
<span className="text-primary">Organizations</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64">
Organizations group users, teams, roles, and permissions under
one account boundary.
</TooltipContent>
</Tooltip>
</FrameFooter>
</Frame>
</TooltipProvider>
</section>
)
}
@@ -1,12 +0,0 @@
import { EmptyState } from "./components/empty-state"
export function Page() {
return (
<main
className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-10"
aria-labelledby="organizations-heading"
>
<EmptyState />
</main>
)
}
@@ -1,147 +0,0 @@
import type { ReactNode } from "react"
import type { BadgeProps } from "@/components/reui/badge"
import { ClaudeAiIcon } from "@evobgp/ui/components/svgs/claudeAiIcon"
import { CursorDark } from "@evobgp/ui/components/svgs/cursorDark"
import { CursorLight } from "@evobgp/ui/components/svgs/cursorLight"
import { GoogleCalendar } from "@evobgp/ui/components/svgs/googleCalendar"
import { ResendIconBlack } from "@evobgp/ui/components/svgs/resendIconBlack"
import { ResendIconWhite } from "@evobgp/ui/components/svgs/resendIconWhite"
import { Slack } from "@evobgp/ui/components/svgs/slack"
import { Stripe } from "@evobgp/ui/components/svgs/stripe"
import { Supabase } from "@evobgp/ui/components/svgs/supabase"
export type IntegrationStatus = "connected" | "available" | "preview"
export interface IntegrationStatusMeta {
label: string
variant: BadgeProps["variant"]
}
export interface IntegrationApp {
id: string
name: string
description: string
logo: ReactNode
status: IntegrationStatus
actionLabel: string
}
export const INTEGRATION_STATUS_META: Record<
IntegrationStatus,
IntegrationStatusMeta
> = {
connected: {
label: "Connected",
variant: "success-light",
},
available: {
label: "Available",
variant: "secondary",
},
preview: {
label: "Preview",
variant: "info-light",
},
}
const logoClassName = "block size-5"
function ThemeLogo({ light, dark }: { light: ReactNode; dark: ReactNode }) {
return (
<>
<span
aria-hidden="true"
className="flex size-5 items-center justify-center leading-none dark:hidden"
>
{light}
</span>
<span
aria-hidden="true"
className="hidden size-5 items-center justify-center leading-none dark:flex"
>
{dark}
</span>
</>
)
}
const CURSOR_LOGO = (
<ThemeLogo
light={<CursorLight className={logoClassName} />}
dark={<CursorDark className={logoClassName} />}
/>
)
const RESEND_LOGO = (
<ThemeLogo
light={<ResendIconBlack className={logoClassName} />}
dark={<ResendIconWhite className={logoClassName} />}
/>
)
export const INTEGRATION_APPS: IntegrationApp[] = [
{
id: "supabase",
name: "Supabase",
description:
"Sync workspace records, owners, and lifecycle events from your product database.",
logo: <Supabase className={logoClassName} aria-hidden="true" />,
status: "connected",
actionLabel: "Configure",
},
{
id: "slack",
name: "Slack",
description:
"Route approvals, launch notes, and incident updates into selected team channels.",
logo: <Slack className={logoClassName} aria-hidden="true" />,
status: "connected",
actionLabel: "Configure",
},
{
id: "stripe",
name: "Stripe",
description:
"Mirror subscription, invoice, and renewal signals into account reviews.",
logo: <Stripe className={logoClassName} aria-hidden="true" />,
status: "connected",
actionLabel: "Configure",
},
{
id: "google-calendar",
name: "Google Calendar",
description:
"Create planning reviews from milestones, customer dates, and release windows.",
logo: <GoogleCalendar className={logoClassName} aria-hidden="true" />,
status: "available",
actionLabel: "Install",
},
{
id: "resend",
name: "Resend",
description:
"Send branded transactional updates when workflows change status or ownership.",
logo: RESEND_LOGO,
status: "available",
actionLabel: "Install",
},
{
id: "cursor",
name: "Cursor",
description:
"Attach implementation context and AI coding notes to active product tasks.",
logo: CURSOR_LOGO,
status: "preview",
actionLabel: "Configure",
},
{
id: "claude",
name: "Claude",
description:
"Draft summaries, support notes, and rollout briefs from approved workspace data.",
logo: <ClaudeAiIcon className={logoClassName} aria-hidden="true" />,
status: "preview",
actionLabel: "Install",
},
]
@@ -1,109 +0,0 @@
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { Button } from "@evobgp/ui/components/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@evobgp/ui/components/item"
import {
INTEGRATION_APPS,
INTEGRATION_STATUS_META,
type IntegrationApp,
} from "./data"
function IntegrationLogo({ app }: { app: IntegrationApp }) {
return (
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:block [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto translate-y-0! self-center!">
{app.logo}
</ItemMedia>
</Item>
)
}
function IntegrationRow({ app }: { app: IntegrationApp }) {
const status = INTEGRATION_STATUS_META[app.status]
return (
<Item role="listitem" className="items-center px-3.5 py-2.5 sm:px-4">
{/* Media */}
<ItemMedia className="translate-y-0! self-center!">
<IntegrationLogo app={app} />
</ItemMedia>
{/* Content */}
<ItemContent className="min-w-0 gap-0">
<ItemTitle className="w-full min-w-0 gap-2">
<span className="truncate">{app.name}</span>
<Badge variant={status.variant}>{status.label}</Badge>
</ItemTitle>
<ItemDescription className="line-clamp-1">
{app.description}
</ItemDescription>
</ItemContent>
{/* Actions */}
<ItemActions className="ml-auto shrink-0 justify-end gap-2">
<Button type="button" variant="outline" size="sm">
{app.actionLabel}
</Button>
</ItemActions>
</Item>
)
}
export function IntegrationSettings() {
return (
<div className="flex w-full max-w-4xl flex-col gap-5">
{/* Section */}
<section className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-semibold">Connected Apps</h3>
<p className="text-muted-foreground text-sm">
Manage source systems for automation, scheduling, support, and AI
workflows.
</p>
</div>
<Button type="button" size="sm" className="w-full sm:w-auto">
Build Connector
</Button>
</div>
<Frame>
<FrameHeader className="sr-only">
<FrameTitle>Integration Apps</FrameTitle>
<FrameDescription>
Available workspace apps and configuration actions.
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0!">
<ItemGroup className="gap-0">
{INTEGRATION_APPS.map((app, index) => (
<div key={app.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null}
<IntegrationRow app={app} />
</div>
))}
</ItemGroup>
</FramePanel>
</Frame>
</section>
</div>
)
}
@@ -1,15 +0,0 @@
import { IntegrationSettings } from "./components/integration-settings"
export function Page() {
return (
<main
className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Integration settings
</h1>
<IntegrationSettings />
</main>
)
}
@@ -1,40 +0,0 @@
import { useState } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import { FieldLegend, FieldSet } from "@evobgp/ui/components/field"
import { ACCENT_COLORS } from "./data"
import { CheckIcon } from "lucide-react"
// ── Color Picker ──
export function ColorPicker() {
const [selected, setSelected] = useState(ACCENT_COLORS[0].value)
return (
<FieldSet className="gap-0">
<FieldLegend className="sr-only">Accent color</FieldLegend>
{/* Actions */}
<div className="flex items-center gap-3">
{ACCENT_COLORS.map((color) => (
<button
key={color.value}
type="button"
aria-label={color.name}
onClick={() => setSelected(color.value)}
className={cn(
"flex size-7 items-center justify-center rounded-full transition-shadow",
selected === color.value &&
"ring-ring ring-offset-background ring-2 ring-offset-2"
)}
style={{ backgroundColor: color.value }}
>
{selected === color.value ? (
<CheckIcon className="size-3 text-white" aria-hidden="true" />
) : null}
</button>
))}
</div>
</FieldSet>
)
}
@@ -1,109 +0,0 @@
"use client"
import { type ReactNode } from "react"
// ── Types ──
export type SelectOption = {
value: string
label: string
}
export type Preference = {
id: string
label: string
description: string
defaultChecked: boolean
}
export type AccentColorOption = {
name: string
value: string
}
export type SettingBadgeVariant =
| "primary-light"
| "destructive-light"
| "info-light"
export type SettingFieldProps = {
title: string
description: string
badge?: { label: string; variant: SettingBadgeVariant }
children: ReactNode
last?: boolean
}
export type DateFormatOption = {
value: string
label: string
}
// ── Data ──
export const DAYS: SelectOption[] = [
{ value: "monday", label: "Monday" },
{ value: "tuesday", label: "Tuesday" },
{ value: "wednesday", label: "Wednesday" },
{ value: "thursday", label: "Thursday" },
{ value: "friday", label: "Friday" },
{ value: "saturday", label: "Saturday" },
{ value: "sunday", label: "Sunday" },
]
export const CURRENCIES: SelectOption[] = [
{ value: "usd", label: "USD · US Dollar" },
{ value: "eur", label: "EUR · Euro" },
{ value: "gbp", label: "GBP · British Pound" },
{ value: "jpy", label: "JPY · Japanese Yen" },
]
export const REGIONS: SelectOption[] = [
{ value: "us", label: "United States" },
{ value: "eu", label: "European Union" },
{ value: "uk", label: "United Kingdom" },
{ value: "jp", label: "Japan" },
{ value: "au", label: "Australia" },
]
export const ACCENT_COLORS: AccentColorOption[] = [
{ name: "Slate", value: "#64748b" },
{ name: "Rose", value: "#f43f5e" },
{ name: "Orange", value: "#f97316" },
{ name: "Violet", value: "#8b5cf6" },
{ name: "Emerald", value: "#10b981" },
{ name: "Sky", value: "#0ea5e9" },
]
export const PREFERENCES: Preference[] = [
{
id: "keyboard-shortcuts",
label: "Enable keyboard shortcuts.",
description: "Use shortcuts to speed up your workflow.",
defaultChecked: true,
},
{
id: "spellcheck",
label: "Auto spell-check.",
description: "Highlight spelling errors in text fields.",
defaultChecked: true,
},
{
id: "compact-mode",
label: "Compact Mode.",
description: "Reduce spacing for a denser interface layout.",
defaultChecked: false,
},
{
id: "animations",
label: "Reduce animations.",
description: "Minimize motion effects throughout the UI.",
defaultChecked: false,
},
]
export const DATE_FORMAT_OPTIONS: DateFormatOption[] = [
{ value: "mdy", label: "MM/DD/YYYY" },
{ value: "dmy", label: "DD/MM/YYYY" },
{ value: "ymd", label: "YYYY/MM/DD" },
]
@@ -1,57 +0,0 @@
import { useState } from "react"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Switch } from "@evobgp/ui/components/switch"
import { PREFERENCES } from "./data"
// ── Editor Preferences ──
export function EditorPreferences() {
const [values, setValues] = useState<Record<string, boolean>>(() =>
Object.fromEntries(PREFERENCES.map((p) => [p.id, p.defaultChecked]))
)
return (
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Editor preferences</FieldLegend>
{/* Description */}
<FieldDescription className="sr-only">
Configure editing behavior and interface density.
</FieldDescription>
{/* List */}
<FieldGroup className="gap-3">
{PREFERENCES.map((pref) => (
<Field key={pref.id} orientation="horizontal" className="gap-3">
<Switch
id={`settings-3-${pref.id}`}
checked={values[pref.id]}
onCheckedChange={(value) =>
setValues((prev) => ({ ...prev, [pref.id]: value }))
}
className="mt-0.5"
/>
<FieldContent className="gap-0.5">
<FieldLabel htmlFor={`settings-3-${pref.id}`}>
{pref.label}
</FieldLabel>
<FieldDescription className="text-xs">
{pref.description}
</FieldDescription>
</FieldContent>
</Field>
))}
</FieldGroup>
</FieldSet>
)
}
@@ -1,267 +0,0 @@
"use client"
import { useState } from "react"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evobgp/ui/components/input-group"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import {
ToggleGroup,
ToggleGroupItem,
} from "@evobgp/ui/components/toggle-group"
import { ColorPicker } from "./color-picker"
import { CURRENCIES, DAYS, REGIONS } from "./data"
import { EditorPreferences } from "./editor-preferences"
import { SettingField } from "./setting-field"
import { PlusIcon, UploadIcon, GlobeIcon, CircleDollarSignIcon } from "lucide-react"
export function GeneralSettings() {
const [dateFormat, setDateFormat] = useState(["mdy"])
const [startOfWeek, setStartOfWeek] = useState("monday")
const [region, setRegion] = useState("us")
const [currency, setCurrency] = useState("usd")
const handleStartOfWeekChange = (value: string | null) => {
if (value !== null) {
setStartOfWeek(value)
}
}
const handleRegionChange = (value: string | null) => {
if (value !== null) {
setRegion(value)
}
}
const handleCurrencyChange = (value: string | null) => {
if (value !== null) {
setCurrency(value)
}
}
return (
<Frame className="w-full max-w-3xl">
<FrameHeader className="px-2! py-2.5!">
<FrameTitle>General Settings</FrameTitle>
<FrameDescription>Core app preferences.</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<FieldGroup className="gap-0">
{/* ── Project name ── */}
<SettingField
title="Project name"
description="The display name for your project across the platform."
labelFor="settings-3-project-name"
>
<Input id="settings-3-project-name" defaultValue="Acme Dashboard" />
</SettingField>
{/* ── API endpoint ── */}
<SettingField
title="API endpoint"
description="Set the base URL for your project API."
badge={{ label: "Required", variant: "destructive-light" }}
labelFor="settings-3-api-endpoint"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-3-api-endpoint"
defaultValue="api.acme.io"
/>
</InputGroup>
</SettingField>
{/* ── Start of week ── */}
<SettingField
title="Start of week"
description="Choose which day marks the start of your week."
labelFor="settings-3-start-of-week"
>
<Select value={startOfWeek} onValueChange={handleStartOfWeekChange}>
<SelectTrigger id="settings-3-start-of-week" className="w-full">
<SelectValue>{getOptionLabel(DAYS, startOfWeek)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{DAYS.map((day) => (
<SelectItem key={day.value} value={day.value}>
{day.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingField>
{/* ── Allowed origins ── */}
<SettingField
title="Allowed origins"
description="Define the trusted domains for CORS requests."
>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button variant="outline">
<PlusIcon aria-hidden="true" />
Add origin
</Button>
<Button variant="outline">
<UploadIcon aria-hidden="true" />
Import from file
</Button>
</div>
</SettingField>
{/* ── Accent color ── */}
<SettingField
title="Accent color"
description="Select a color to represent your brand."
badge={{ label: "Upgrade to Pro", variant: "primary-light" }}
>
<ColorPicker />
</SettingField>
{/* ── Region & currency ── */}
<SettingField
title="Region & currency"
description="Adjust your regional preferences and currency."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Region and currency</FieldLegend>
<FieldDescription className="sr-only">
Regional and financial formatting preferences.
</FieldDescription>
<FieldGroup className="gap-3">
<Field>
<FieldLabel htmlFor="settings-3-region">Region</FieldLabel>
<Select value={region} onValueChange={handleRegionChange}>
<SelectTrigger id="settings-3-region" className="w-full">
<GlobeIcon aria-hidden="true" />
<SelectValue>
{getOptionLabel(REGIONS, region)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{REGIONS.map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-3-currency">
Currency
</FieldLabel>
<Select value={currency} onValueChange={handleCurrencyChange}>
<SelectTrigger id="settings-3-currency" className="w-full">
<CircleDollarSignIcon aria-hidden="true" />
<SelectValue>
{getOptionLabel(CURRENCIES, currency)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{CURRENCIES.map((currency) => (
<SelectItem
key={currency.value}
value={currency.value}
>
{currency.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
</SettingField>
{/* ── Date display format ── */}
<SettingField
title="Date display format"
description="Choose your preferred format for dates."
>
<ToggleGroup
multiple={false}
value={dateFormat}
onValueChange={(value) => {
if (value.length > 0) setDateFormat(value)
}}
variant="outline"
size="sm"
aria-label="Date display format"
>
<ToggleGroupItem value="mdy">MM/DD/YYYY</ToggleGroupItem>
<ToggleGroupItem value="dmy">DD/MM/YYYY</ToggleGroupItem>
<ToggleGroupItem value="ymd">YYYY/MM/DD</ToggleGroupItem>
</ToggleGroup>
</SettingField>
{/* ── Editor preferences ── */}
<SettingField
title="Editor preferences"
description="Adjust your editing environment and display options."
last
contentClassName="@md/field-group:w-[22rem]"
>
<EditorPreferences />
</SettingField>
</FieldGroup>
</FramePanel>
<FrameFooter className="flex flex-row justify-end gap-3">
<Button variant="outline">Reset</Button>
<Button>Save changes</Button>
</FrameFooter>
</Frame>
)
}
// ── Helper functions ──
function getOptionLabel(
options: Array<{ value: string; label: string }>,
value: string
) {
return options.find((option) => option.value === value)?.label ?? value
}
@@ -1,69 +0,0 @@
import { type ComponentProps, type ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import { cn } from "@evobgp/ui/lib/utils"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from "@evobgp/ui/components/field"
interface SettingFieldProps {
title: string
description: string
badge?: {
label: string
variant: ComponentProps<typeof Badge>["variant"]
}
children: ReactNode
last?: boolean
labelFor?: string
contentClassName?: string
}
// ── Setting Field ──
export function SettingField({
title,
description,
badge,
children,
last,
labelFor,
contentClassName,
}: SettingFieldProps) {
return (
<>
<Field orientation="responsive" className="gap-4 px-4 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{badge ? (
<Badge variant={badge.variant} size="sm">
{badge.label}
</Badge>
) : null}
</div>
<FieldDescription className="text-xs">{description}</FieldDescription>
</div>
<FieldContent
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
>
{children}
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}
@@ -1,9 +0,0 @@
import { GeneralSettings } from "./components/general-settings"
export function Page() {
return (
<div className="flex min-h-svh w-full items-start justify-center p-8 md:p-16">
<GeneralSettings />
</div>
)
}
@@ -1,194 +0,0 @@
export type WizardStepId = "company" | "ownership" | "documents"
export type RegistrationStatus = "registered" | "invited" | "new"
export type VendorCategory = "software" | "services" | "data" | "hardware"
export type VendorCountry = "us" | "gb" | "de" | "uz" | "sg"
export type DocumentId =
| "certificate"
| "tax-letter"
| "ownership-chart"
| "bank-letter"
export interface WizardStep {
id: WizardStepId
step: number
title: string
description: string
}
export interface WizardOption<TValue extends string = string> {
value: TValue
label: string
description?: string
}
export interface OwnerRecord {
id: string
name: string
address: string
share: string
}
export interface DocumentRequirement {
id: DocumentId
title: string
description: string
status: "Ready" | "Needs upload" | "Review"
}
export interface WizardValues {
companyName: string
registrationStatus: RegistrationStatus
portalId: string
category: VendorCategory
country: VendorCountry
contactEmail: string
website: string
owners: OwnerRecord[]
taxId: string
incorporationDate: string
ownershipAttested: boolean
documentIds: DocumentId[]
submissionNotes: string
}
export const WIZARD_STEPS: WizardStep[] = [
{
id: "company",
step: 1,
title: "Company Details",
description: "Vendor identity and procurement profile.",
},
{
id: "ownership",
step: 2,
title: "Compliance",
description: "Owners and tax IDs.",
},
{
id: "documents",
step: 3,
title: "Submit",
description: "Review and submit.",
},
]
export const REGISTRATION_OPTIONS: WizardOption<RegistrationStatus>[] = [
{
value: "registered",
label: "Yes",
description: "Vendor record already exists in the portal.",
},
{
value: "invited",
label: "Invited",
description: "Invitation sent, registration is pending.",
},
{
value: "new",
label: "No",
description: "Create a fresh procurement record.",
},
]
export const CATEGORY_OPTIONS: WizardOption<VendorCategory>[] = [
{
value: "software",
label: "Software subscription",
description: "SaaS, licenses, and usage-based software.",
},
{
value: "services",
label: "Professional services",
description: "Implementation, advisory, and support work.",
},
{
value: "data",
label: "Data provider",
description: "Market data, enrichment, and data feeds.",
},
{
value: "hardware",
label: "Hardware supplier",
description: "Devices, equipment, and physical goods.",
},
]
export const COUNTRY_OPTIONS: WizardOption<VendorCountry>[] = [
{
value: "us",
label: "United States",
},
{
value: "gb",
label: "United Kingdom",
},
{
value: "de",
label: "Germany",
},
{
value: "uz",
label: "Uzbekistan",
},
{
value: "sg",
label: "Singapore",
},
]
export const DOCUMENT_REQUIREMENTS: DocumentRequirement[] = [
{
id: "certificate",
title: "Certificate of incorporation",
description: "Legal entity proof.",
status: "Ready",
},
{
id: "tax-letter",
title: "Tax residency letter",
description: "Needed for payment release.",
status: "Needs upload",
},
{
id: "ownership-chart",
title: "Ownership chart",
description: "Confirms ownership threshold.",
status: "Review",
},
{
id: "bank-letter",
title: "Bank verification letter",
description: "Verifies payee details.",
status: "Ready",
},
]
export const DEFAULT_WIZARD_VALUES: WizardValues = {
companyName: "ReUI Labs",
registrationStatus: "registered",
portalId: "VEN-2048",
category: "software",
country: "us",
contactEmail: "procurement@reui.dev",
website: "https://reui.dev",
owners: [
{
id: "owner-1",
name: "Maya Chen",
address: "214 Mission Street, San Francisco, CA",
share: "52%",
},
{
id: "owner-2",
name: "Kai Morgan",
address: "18 King Street, Seattle, WA",
share: "28%",
},
],
taxId: "US-82-4917721",
incorporationDate: "08/08/2025",
ownershipAttested: true,
documentIds: ["certificate", "bank-letter"],
submissionNotes:
"Route final review to finance once tax residency is uploaded.",
}
@@ -1,646 +0,0 @@
import type { ReactNode } from "react"
import { Button } from "@evobgp/ui/components/button"
import { Checkbox } from "@evobgp/ui/components/checkbox"
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Textarea } from "@evobgp/ui/components/textarea"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@evobgp/ui/components/tooltip"
import {
CATEGORY_OPTIONS,
COUNTRY_OPTIONS,
DOCUMENT_REQUIREMENTS,
REGISTRATION_OPTIONS,
type DocumentId,
type OwnerRecord,
type VendorCategory,
type VendorCountry,
type WizardOption,
type WizardValues,
} from "./data"
import { InfoIcon, Trash2Icon, PlusIcon } from "lucide-react"
export type WizardErrors = Partial<Record<keyof WizardValues, string>>
type WizardValueChange = (
field: keyof WizardValues,
value: WizardValues[keyof WizardValues]
) => void
interface WizardSummary {
categoryLabel: string
countryLabel: string
registrationLabel: string
}
function RequiredMark() {
return <span className="text-destructive">*</span>
}
function FieldLabelHint({
htmlFor,
label,
hint,
children,
}: {
htmlFor?: string
label: string
hint: string
children: ReactNode
}) {
return (
<div className="flex w-fit items-center gap-1.5 leading-snug">
<FieldLabel htmlFor={htmlFor}>{children}</FieldLabel>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex size-4 shrink-0 items-center justify-center rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
aria-label={`${label} hint`}
/>
}
>
<InfoIcon className="size-3.5" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-56 text-xs text-balance">
{hint}
</TooltipContent>
</Tooltip>
</div>
)
}
function WizardSelectField<TValue extends string>({
id,
value,
options,
placeholder,
onValueChange,
}: {
id: string
value: TValue
options: WizardOption<TValue>[]
placeholder: string
onValueChange: (value: TValue) => void
}) {
const selectedOption =
options.find((option) => option.value === value) ?? null
return (
<Select
value={selectedOption}
items={options}
onValueChange={(nextValue) => {
if (nextValue) {
onValueChange(nextValue.value)
}
}}
>
<SelectTrigger id={id} className="w-full [&_small]:hidden">
<SelectValue>
{(item: WizardOption<TValue> | null) => item?.label ?? placeholder}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{options.map((option) => (
<SelectItem key={option.value} value={option}>
{option.description ? (
<span className="flex min-w-0 flex-col items-start gap-px">
<span className="truncate font-medium">{option.label}</span>
<small className="text-muted-foreground truncate text-xs">
{option.description}
</small>
</span>
) : (
option.label
)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
function SummaryLine({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-4 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-foreground min-w-0 text-right font-medium break-words">
{value}
</dd>
</div>
)
}
function SummarySection({
title,
children,
className,
}: {
title: string
children: ReactNode
className?: string
}) {
return (
<section
className={[
"border-border/70 flex min-w-0 flex-col gap-3 rounded-md border p-4",
className,
]
.filter(Boolean)
.join(" ")}
>
<h3 className="text-sm font-semibold">{title}</h3>
{children}
</section>
)
}
function SectionHeading({
id,
title,
description,
}: {
id: string
title: string
description: string
}) {
return (
<div className="flex flex-col gap-1">
<h2 id={id} className="text-base font-semibold">
{title}
</h2>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
)
}
function RemoveRowButton({
label,
onClick,
}: {
label: string
onClick: () => void
}) {
return (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive justify-self-start sm:justify-self-end"
aria-label={label}
onClick={onClick}
>
<Trash2Icon className="size-4" aria-hidden="true" />
</Button>
)
}
function OwnerRows({
owners,
error,
onOwnerChange,
onAddOwner,
onRemoveOwner,
}: {
owners: OwnerRecord[]
error?: string
onOwnerChange: (id: string, field: keyof OwnerRecord, value: string) => void
onAddOwner: () => void
onRemoveOwner: (id: string) => void
}) {
return (
<Field data-invalid={Boolean(error)} className="gap-3 sm:col-span-2">
<div className="flex flex-col gap-2">
{owners.map((owner, index) => (
<div
key={owner.id}
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)_5rem_auto]"
>
<Field className="gap-1.5">
<FieldLabel htmlFor={`${owner.id}-name`} className="sr-only">
Owner {index + 1} name
</FieldLabel>
<Input
id={`${owner.id}-name`}
value={owner.name}
onChange={(event) =>
onOwnerChange(owner.id, "name", event.target.value)
}
placeholder="Owner full name"
autoComplete="name"
/>
</Field>
<Field className="gap-1.5">
<FieldLabel htmlFor={`${owner.id}-address`} className="sr-only">
Owner {index + 1} address
</FieldLabel>
<Input
id={`${owner.id}-address`}
value={owner.address}
onChange={(event) =>
onOwnerChange(owner.id, "address", event.target.value)
}
placeholder="Registered address"
autoComplete="street-address"
/>
</Field>
<Field className="gap-1.5">
<FieldLabel htmlFor={`${owner.id}-share`} className="sr-only">
Owner {index + 1} share
</FieldLabel>
<Input
id={`${owner.id}-share`}
value={owner.share}
onChange={(event) =>
onOwnerChange(owner.id, "share", event.target.value)
}
placeholder="Share"
inputMode="numeric"
/>
</Field>
<RemoveRowButton
label={`Remove owner ${index + 1}`}
onClick={() => onRemoveOwner(owner.id)}
/>
</div>
))}
</div>
{error ? <FieldError>{error}</FieldError> : null}
<div className="flex justify-end">
<Button
type="button"
variant="outline"
className="w-auto"
onClick={onAddOwner}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Add owner
</Button>
</div>
</Field>
)
}
function AttestationCheckbox({
id,
checked,
label,
description,
error,
onCheckedChange,
}: {
id: string
checked: boolean
label: string
description: string
error?: string
onCheckedChange: (checked: boolean) => void
}) {
return (
<Field
data-invalid={Boolean(error)}
orientation="horizontal"
className="gap-3"
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={(nextChecked) => onCheckedChange(nextChecked === true)}
aria-invalid={Boolean(error)}
/>
<FieldContent>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<FieldDescription>{description}</FieldDescription>
</FieldContent>
{error ? <FieldError>{error}</FieldError> : null}
</Field>
)
}
export function CompanyStepFields({
values,
errors,
summary,
onValueChange,
}: {
values: WizardValues
errors: WizardErrors
summary: WizardSummary
onValueChange: WizardValueChange
}) {
return (
<section
className="flex flex-col gap-6"
aria-labelledby="wizard-2-company-title"
>
<SectionHeading
id="wizard-2-company-title"
title="Company Details"
description="Identify the vendor record before compliance review."
/>
<FieldGroup className="grid gap-5 sm:grid-cols-2">
<Field data-invalid={Boolean(errors.companyName)} className="gap-2">
<FieldLabelHint
htmlFor="wizard-2-company"
label="Legal Company Name"
hint="Match the registered legal entity name."
>
Legal Company Name <RequiredMark />
</FieldLabelHint>
<Input
id="wizard-2-company"
value={values.companyName}
onChange={(event) =>
onValueChange("companyName", event.target.value)
}
aria-invalid={Boolean(errors.companyName)}
autoComplete="organization"
/>
{errors.companyName ? (
<FieldError>{errors.companyName}</FieldError>
) : null}
</Field>
<Field className="gap-2">
<FieldLabelHint
htmlFor="wizard-2-registration"
label="Portal Registration"
hint="Use the status from the vendor portal."
>
Portal Registration
</FieldLabelHint>
<WizardSelectField
id="wizard-2-registration"
value={values.registrationStatus}
options={REGISTRATION_OPTIONS}
placeholder="Select status"
onValueChange={(value) =>
onValueChange("registrationStatus", value)
}
/>
<FieldDescription>{summary.registrationLabel}</FieldDescription>
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="wizard-2-category">Category</FieldLabel>
<WizardSelectField<VendorCategory>
id="wizard-2-category"
value={values.category}
options={CATEGORY_OPTIONS}
placeholder="Select category"
onValueChange={(value) => onValueChange("category", value)}
/>
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="wizard-2-country">Country</FieldLabel>
<WizardSelectField<VendorCountry>
id="wizard-2-country"
value={values.country}
options={COUNTRY_OPTIONS}
placeholder="Select country"
onValueChange={(value) => onValueChange("country", value)}
/>
</Field>
<Field data-invalid={Boolean(errors.contactEmail)} className="gap-2">
<FieldLabel htmlFor="wizard-2-email">
Vendor Email <RequiredMark />
</FieldLabel>
<Input
id="wizard-2-email"
value={values.contactEmail}
onChange={(event) =>
onValueChange("contactEmail", event.target.value)
}
aria-invalid={Boolean(errors.contactEmail)}
type="email"
autoComplete="email"
/>
{errors.contactEmail ? (
<FieldError>{errors.contactEmail}</FieldError>
) : null}
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="wizard-2-website">Website</FieldLabel>
<Input
id="wizard-2-website"
value={values.website}
onChange={(event) => onValueChange("website", event.target.value)}
autoComplete="url"
/>
</Field>
</FieldGroup>
</section>
)
}
export function OwnershipStepFields({
values,
errors,
onValueChange,
onOwnerChange,
onAddOwner,
onRemoveOwner,
}: {
values: WizardValues
errors: WizardErrors
onValueChange: WizardValueChange
onOwnerChange: (id: string, field: keyof OwnerRecord, value: string) => void
onRemoveOwner: (id: string) => void
onAddOwner: () => void
}) {
return (
<section
className="flex flex-col gap-6"
aria-labelledby="wizard-2-ownership-title"
>
<SectionHeading
id="wizard-2-ownership-title"
title="Compliance"
description="Confirm ownership and tax records."
/>
<FieldGroup className="grid gap-5 sm:grid-cols-2">
<OwnerRows
owners={values.owners}
error={errors.owners}
onOwnerChange={onOwnerChange}
onAddOwner={onAddOwner}
onRemoveOwner={onRemoveOwner}
/>
<Field data-invalid={Boolean(errors.taxId)} className="gap-2">
<FieldLabelHint
htmlFor="wizard-2-tax"
label="Tax/VAT ID"
hint="Enter the identifier finance will validate."
>
Tax/VAT ID <RequiredMark />
</FieldLabelHint>
<Input
id="wizard-2-tax"
value={values.taxId}
onChange={(event) => onValueChange("taxId", event.target.value)}
aria-invalid={Boolean(errors.taxId)}
placeholder="Enter tax number"
/>
{errors.taxId ? <FieldError>{errors.taxId}</FieldError> : null}
</Field>
<Field
data-invalid={Boolean(errors.incorporationDate)}
className="gap-2"
>
<FieldLabelHint
htmlFor="wizard-2-incorporated"
label="Date Incorporated"
hint="Use the date from the registration record."
>
Date Incorporated <RequiredMark />
</FieldLabelHint>
<Input
id="wizard-2-incorporated"
value={values.incorporationDate}
onChange={(event) =>
onValueChange("incorporationDate", event.target.value)
}
aria-invalid={Boolean(errors.incorporationDate)}
placeholder="MM/DD/YYYY"
/>
{errors.incorporationDate ? (
<FieldError>{errors.incorporationDate}</FieldError>
) : null}
</Field>
<AttestationCheckbox
id="wizard-2-ownership-attested"
checked={values.ownershipAttested}
label="Ownership reviewed"
description="Records match company documents."
error={errors.ownershipAttested}
onCheckedChange={(checked) =>
onValueChange("ownershipAttested", checked)
}
/>
</FieldGroup>
</section>
)
}
export function DocumentsStepFields({
values,
errors,
summary,
onValueChange,
onDocumentToggle,
}: {
values: WizardValues
errors: WizardErrors
summary: WizardSummary
onValueChange: WizardValueChange
onDocumentToggle: (documentId: DocumentId, checked: boolean) => void
}) {
return (
<section
className="flex flex-col gap-6"
aria-labelledby="wizard-2-documents-title"
>
<SectionHeading
id="wizard-2-documents-title"
title="Submit"
description="Review files and confirm."
/>
<FieldGroup className="grid gap-6">
<Field data-invalid={Boolean(errors.documentIds)} className="gap-3">
<FieldLabelHint
label="Required Documents"
hint="Select files already received from the vendor."
>
Required Documents
</FieldLabelHint>
<FieldGroup className="grid gap-2 sm:grid-cols-2">
{DOCUMENT_REQUIREMENTS.map((document) => (
<FieldLabel key={document.id}>
<Field orientation="horizontal">
<Checkbox
checked={values.documentIds.includes(document.id)}
onCheckedChange={(checked) =>
onDocumentToggle(document.id, checked === true)
}
/>
<FieldContent>
<FieldTitle>{document.title}</FieldTitle>
<FieldDescription>{document.description}</FieldDescription>
</FieldContent>
</Field>
</FieldLabel>
))}
</FieldGroup>
{errors.documentIds ? (
<FieldError>{errors.documentIds}</FieldError>
) : null}
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="wizard-2-notes">Reviewer Notes</FieldLabel>
<Textarea
id="wizard-2-notes"
value={values.submissionNotes}
onChange={(event) =>
onValueChange("submissionNotes", event.target.value)
}
className="min-h-24 resize-none"
/>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<SummarySection title="Vendor">
<dl className="flex flex-col gap-2.5">
<SummaryLine label="Company" value={values.companyName} />
<SummaryLine label="Category" value={summary.categoryLabel} />
<SummaryLine label="Country" value={summary.countryLabel} />
</dl>
</SummarySection>
<SummarySection title="Compliance">
<dl className="flex flex-col gap-2.5">
<SummaryLine label="Tax/VAT" value={values.taxId} />
<SummaryLine label="Owners" value={values.owners.length} />
<SummaryLine label="Status" value="Reviewed" />
</dl>
</SummarySection>
</div>
</FieldGroup>
</section>
)
}
@@ -1,426 +0,0 @@
"use client"
import { useCallback, useMemo, useState, type FormEvent } from "react"
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import { toast } from "sonner"
import { Button } from "@evobgp/ui/components/button"
import { Spinner } from "@evobgp/ui/components/spinner"
import {
CATEGORY_OPTIONS,
COUNTRY_OPTIONS,
DEFAULT_WIZARD_VALUES,
REGISTRATION_OPTIONS,
WIZARD_STEPS,
type DocumentId,
type OwnerRecord,
type WizardOption,
type WizardStepId,
type WizardValues,
} from "./data"
import {
CompanyStepFields,
DocumentsStepFields,
OwnershipStepFields,
type WizardErrors,
} from "./procurement-vendor-steps"
import { CheckIcon, FlagIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
function createDefaultValues(): WizardValues {
return {
...DEFAULT_WIZARD_VALUES,
owners: DEFAULT_WIZARD_VALUES.owners.map((owner) => ({ ...owner })),
documentIds: [...DEFAULT_WIZARD_VALUES.documentIds],
}
}
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
}
function getOptionLabel<TValue extends string>(
options: WizardOption<TValue>[],
value: TValue
) {
return options.find((option) => option.value === value)?.label ?? "Not set"
}
function getStepErrors(stepId: WizardStepId, values: WizardValues) {
const nextErrors: WizardErrors = {}
if (stepId === "company") {
if (!values.companyName.trim()) {
nextErrors.companyName = "Enter the legal company name."
}
if (!isValidEmail(values.contactEmail)) {
nextErrors.contactEmail = "Enter a valid procurement email."
}
}
if (stepId === "ownership") {
if (!values.taxId.trim()) {
nextErrors.taxId = "Enter the Tax/VAT ID."
}
if (!values.incorporationDate.trim()) {
nextErrors.incorporationDate = "Enter the incorporation date."
}
if (!values.owners.some((owner) => owner.name.trim())) {
nextErrors.owners = "Add at least one beneficial owner."
}
if (!values.ownershipAttested) {
nextErrors.ownershipAttested = "Confirm beneficial ownership review."
}
}
if (stepId === "documents" && values.documentIds.length < 2) {
nextErrors.documentIds = "Select at least two received documents."
}
return nextErrors
}
function getAllRequiredErrors(values: WizardValues) {
return {
...getStepErrors("company", values),
...getStepErrors("ownership", values),
...getStepErrors("documents", values),
}
}
function getFirstInvalidStep(values: WizardValues) {
if (Object.keys(getStepErrors("company", values)).length > 0) return 1
if (Object.keys(getStepErrors("ownership", values)).length > 0) return 2
if (Object.keys(getStepErrors("documents", values)).length > 0) return 3
return null
}
function DotSeparator() {
return (
<span
className="size-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500"
aria-hidden="true"
/>
)
}
export function ProcurementVendorWizard() {
const [currentStep, setCurrentStep] = useState(2)
const [isSubmitting, setIsSubmitting] = useState(false)
const [values, setValues] = useState<WizardValues>(createDefaultValues)
const [errors, setErrors] = useState<WizardErrors>({})
const currentStepConfig = useMemo(
() =>
WIZARD_STEPS.find((step) => step.step === currentStep) ?? WIZARD_STEPS[0],
[currentStep]
)
const summary = useMemo(
() => ({
categoryLabel: getOptionLabel(CATEGORY_OPTIONS, values.category),
countryLabel: getOptionLabel(COUNTRY_OPTIONS, values.country),
registrationLabel: getOptionLabel(
REGISTRATION_OPTIONS,
values.registrationStatus
),
}),
[values.category, values.country, values.registrationStatus]
)
const handleValueChange = useCallback(
(field: keyof WizardValues, value: WizardValues[keyof WizardValues]) => {
setValues((current) => ({
...current,
[field]: value,
}))
setErrors((current) => {
if (!current[field]) {
return current
}
const nextErrors = { ...current }
delete nextErrors[field]
return nextErrors
})
},
[]
)
const handleOwnerChange = useCallback(
(id: string, field: keyof OwnerRecord, value: string) => {
setValues((current) => ({
...current,
owners: current.owners.map((owner) =>
owner.id === id ? { ...owner, [field]: value } : owner
),
}))
setErrors((current) => {
if (!current.owners) return current
const nextErrors = { ...current }
delete nextErrors.owners
return nextErrors
})
},
[]
)
const handleAddOwner = useCallback(() => {
setValues((current) => ({
...current,
owners: [
...current.owners,
{
id: `owner-${Date.now()}`,
name: "",
address: "",
share: "",
},
],
}))
}, [])
const handleRemoveOwner = useCallback((id: string) => {
setValues((current) => ({
...current,
owners: current.owners.filter((owner) => owner.id !== id),
}))
setErrors((current) => {
if (!current.owners) return current
const nextErrors = { ...current }
delete nextErrors.owners
return nextErrors
})
}, [])
const handleDocumentToggle = useCallback(
(documentId: DocumentId, checked: boolean) => {
setValues((current) => ({
...current,
documentIds: checked
? Array.from(new Set([...current.documentIds, documentId]))
: current.documentIds.filter((id) => id !== documentId),
}))
setErrors((current) => {
if (!current.documentIds) return current
const nextErrors = { ...current }
delete nextErrors.documentIds
return nextErrors
})
},
[]
)
const handleBack = useCallback(() => {
setCurrentStep((step) => Math.max(1, step - 1))
}, [])
const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (isSubmitting) {
return
}
if (currentStepConfig.id !== "documents") {
const stepErrors = getStepErrors(currentStepConfig.id, values)
if (Object.keys(stepErrors).length > 0) {
setErrors((current) => ({ ...current, ...stepErrors }))
return
}
const nextStep = Math.min(currentStep + 1, WIZARD_STEPS.length)
setCurrentStep(nextStep)
return
}
const requiredErrors = getAllRequiredErrors(values)
const invalidStep = getFirstInvalidStep(values)
if (Object.keys(requiredErrors).length > 0 && invalidStep !== null) {
setErrors(requiredErrors)
setCurrentStep(invalidStep)
return
}
setIsSubmitting(true)
window.setTimeout(() => {
setIsSubmitting(false)
toast.success("Vendor file submitted", {
description: `${values.companyName} is ready for procurement review.`,
})
}, 900)
},
[currentStep, currentStepConfig.id, isSubmitting, values]
)
const isLastStep = currentStep === WIZARD_STEPS.length
const canGoBack = currentStep > 1 && !isSubmitting
return (
<form
onSubmit={handleSubmit}
className="flex w-full max-w-2xl flex-col"
aria-label="Procurement vendor onboarding wizard"
>
<Stepper
value={currentStep}
onValueChange={setCurrentStep}
indicators={{
completed: (
<CheckIcon className="size-3.5" aria-hidden="true" />
),
loading: <Spinner className="size-3.5" />,
}}
className="w-full"
>
<Frame stacked={true} className="w-full">
<FrameHeader>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle>Procurement Vendor Setup</FrameTitle>
<FrameDescription className="inline-flex min-w-0 items-center gap-1.5 text-sm">
<span className="shrink-0">{values.portalId}</span>
<DotSeparator />
<span className="truncate">{summary.categoryLabel}</span>
</FrameDescription>
</div>
<Badge variant="warning-light">Review file</Badge>
</div>
</FrameHeader>
<FramePanel className="flex items-center overflow-x-auto">
<StepperNav
aria-label="Wizard progress"
className="min-w-full items-center"
>
{WIZARD_STEPS.map((step, index) => (
<StepperItem
key={step.id}
step={step.step}
loading={isSubmitting && step.step === currentStep}
className="relative min-w-0"
>
<StepperTrigger
type="button"
className="min-w-0 justify-start gap-2 disabled:opacity-100"
>
<StepperIndicator className="data-[state=active]:bg-primary/10 data-[state=active]:text-primary data-[state=completed]:bg-primary data-[state=inactive]:border-border data-[state=inactive]:text-foreground data-[state=active]:before:border-primary relative isolate size-6 overflow-visible rounded-full border before:pointer-events-none before:absolute before:inset-0 before:z-10 before:rounded-full before:border before:border-dashed before:border-transparent before:content-[''] data-[state=active]:border-0 data-[state=active]:before:animate-[spin_8s_linear_infinite] motion-reduce:data-[state=active]:before:animate-none">
{index + 1}
</StepperIndicator>
<StepperTitle className="text-foreground hidden truncate text-sm capitalize sm:block">
{step.title}
</StepperTitle>
</StepperTrigger>
{WIZARD_STEPS.length > index + 1 ? (
<StepperSeparator className="bg-border group-data-[state=completed]/step:bg-primary mx-2" />
) : null}
</StepperItem>
))}
</StepperNav>
</FramePanel>
<FramePanel>
<StepperPanel className="text-sm">
{WIZARD_STEPS.map((step) => (
<StepperContent key={step.id} value={step.step}>
{step.id === "company" ? (
<CompanyStepFields
values={values}
errors={errors}
summary={summary}
onValueChange={handleValueChange}
/>
) : null}
{step.id === "ownership" ? (
<OwnershipStepFields
values={values}
errors={errors}
onValueChange={handleValueChange}
onOwnerChange={handleOwnerChange}
onAddOwner={handleAddOwner}
onRemoveOwner={handleRemoveOwner}
/>
) : null}
{step.id === "documents" ? (
<DocumentsStepFields
values={values}
errors={errors}
summary={summary}
onValueChange={handleValueChange}
onDocumentToggle={handleDocumentToggle}
/>
) : null}
</StepperContent>
))}
</StepperPanel>
</FramePanel>
<FrameFooter className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<FrameDescription className="hidden min-w-0 items-center gap-1.5 text-sm sm:flex">
<FlagIcon className="size-4 shrink-0" aria-hidden="true" />
{currentStepConfig.description}
</FrameDescription>
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
{canGoBack ? (
<Button
type="button"
variant="outline"
onClick={handleBack}
className="shrink-0"
>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Previous
</Button>
) : null}
<Button
type="submit"
disabled={isSubmitting}
className="shrink-0"
>
{isSubmitting ? <Spinner data-icon="inline-start" /> : null}
{isLastStep ? "Submit" : "Next Step"}
{!isLastStep && !isSubmitting ? (
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
) : null}
</Button>
</div>
</FrameFooter>
</Frame>
</Stepper>
</form>
)
}
@@ -1,9 +0,0 @@
import { ProcurementVendorWizard } from "./components/procurement-vendor-wizard"
export function Page() {
return (
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 lg:p-10">
<ProcurementVendorWizard />
</div>
)
}
+1 -50
View File
@@ -1,50 +1 @@
import type { ComponentProps, ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const TONE_VARIANT: Record<string, BadgeVariant> = {
neutral: 'outline',
info: 'info-light',
warning: 'warning-light',
success: 'success-light',
}
export function ModeBadge({
enabled,
onLabel = 'включён',
offLabel = 'выключен',
className,
}: {
enabled: boolean
onLabel?: string
offLabel?: string
className?: string
}) {
return enabled ? (
<Badge variant="success-light" size="sm" radius="full" className={className}>
{onLabel}
</Badge>
) : (
<Badge variant="secondary" size="sm" radius="full" className={className}>
{offLabel}
</Badge>
)
}
export function CategoryBadge({
children,
tone = 'neutral',
className,
}: {
children: ReactNode
tone?: keyof typeof TONE_VARIANT
className?: string
}) {
return (
<Badge variant={TONE_VARIANT[tone]} size="sm" radius="full" className={className}>
{children}
</Badge>
)
}
export { CategoryBadge, ModeBadge } from '@/components/status-badge'
@@ -1,50 +0,0 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
export interface CountedLineTab {
id: string
label: string
count?: number
}
interface CountedLineTabsProps {
tabs: CountedLineTab[]
value: string
onValueChange: (value: string) => void
className?: string
listClassName?: string
children?: ReactNode
}
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
export function CountedLineTabs({
tabs,
value,
onValueChange,
className,
listClassName,
children,
}: CountedLineTabsProps) {
return (
<Tabs value={value} onValueChange={onValueChange} className={className}>
<TabsList variant="line" className={cn('gap-5', listClassName)}>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>{tab.label}</span>
{tab.count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tab.count}
</span>
) : null}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
)
}
@@ -1,102 +0,0 @@
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { Badge } from '@/components/reui/badge'
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { cn } from '@evobgp/ui/lib/utils'
import { recentPlatformActivity } from '@/lib/metrics'
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
const KIND_META = {
job: { icon: Info, className: 'text-info' },
revision: { icon: CheckCircle, className: 'text-success' },
network: { icon: AlertTriangle, className: 'text-warning' },
} as const
function statusBadgeVariant(status: string) {
const s = status.toLowerCase()
if (['ok', 'success', 'completed', 'done'].includes(s)) return 'success-light' as const
if (['running', 'queued', 'pending'].includes(s)) return 'info-light' as const
if (['warning', 'mismatch'].includes(s)) return 'warning-light' as const
if (['failed', 'error', 'cancelled'].includes(s)) return 'destructive-light' as const
return 'outline' as const
}
export function DashboardActivityTimeline({
jobs,
revisions,
peers,
speakers,
loading,
}: {
jobs: JobRow[]
revisions: RevisionRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
loading?: boolean
}) {
const items = recentPlatformActivity(jobs, revisions, peers, speakers, 6)
return (
<DashboardFramePanel
title="Недавняя активность"
description="Задачи, ревизии и сетевые события"
className="h-full min-w-0"
>
{loading ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
) : items.length === 0 ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Нет недавних событий</p>
) : (
<div className="px-4 py-4">
<Timeline defaultValue={items.length}>
{items.map((item, index) => {
const meta = KIND_META[item.kind]
const Icon = meta.icon
return (
<TimelineItem
key={item.id}
step={index + 1}
className="group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-4"
>
<TimelineHeader>
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem)] group-data-[orientation=vertical]/timeline:translate-y-5" />
<TimelineIndicator className="border-none bg-transparent group-data-[orientation=vertical]/timeline:-left-6">
<span
className={cn(
'bg-muted/70 flex size-7 items-center justify-center rounded-full',
meta.className,
)}
>
<Icon className="size-3.5" aria-hidden />
</span>
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="min-w-0 pb-1 text-foreground">
<TimelineTitle className="text-sm leading-snug font-normal break-words">
{item.message}
</TimelineTitle>
<div className="mt-2">
<Badge variant={statusBadgeVariant(item.status)} size="sm">
{item.statusLabel ?? item.status}
</Badge>
</div>
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
</div>
)}
</DashboardFramePanel>
)
}
@@ -1,36 +0,0 @@
import type { ReactNode } from 'react'
import {
PanelCard,
panelCardContentFlushClassName,
} from '@/components/panel-card'
import { cn } from '@evobgp/ui/lib/utils'
/** Card-surface panel for dashboard sections (replaces legacy Frame shell). */
export function DashboardFramePanel({
title,
description,
actions,
children,
className,
contentClassName,
}: {
title?: string
description?: string
actions?: ReactNode
children: ReactNode
className?: string
contentClassName?: string
}) {
return (
<PanelCard
title={title}
description={description}
actions={actions}
className={cn('h-full', className)}
contentClassName={cn(panelCardContentFlushClassName, contentClassName)}
>
{children}
</PanelCard>
)
}
@@ -3,12 +3,10 @@ import {
Boxes,
ListChecks,
Network,
ServerCog,
Share2,
} from 'lucide-react'
import type { ReactNode } from 'react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
@@ -16,6 +14,11 @@ import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
type KpiCard = KpiStatItem & { icon: ReactNode }
function ratioPercent(part: number, total: number): number | undefined {
if (total <= 0) return undefined
return Math.round((part / total) * 100)
}
function buildKpis({
modules,
peers,
@@ -40,17 +43,34 @@ function buildKpis({
).length
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
const disabledModules = Math.max(0, modules.length - enabledModules)
return [
{
id: 'modules',
icon: <Boxes aria-hidden />,
iconClassName: 'text-primary',
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
value: loading ? '—' : enabledModules,
label: 'Модули активны',
progress: loading ? undefined : ratioPercent(enabledModules, modules.length),
footer: (
<Badge variant="primary-light" size="sm">
{loading ? '…' : `${modules.length} всего`}
<Badge
variant={
loading || modules.length === 0
? 'outline'
: disabledModules === 0
? 'success-light'
: 'warning-light'
}
size="sm"
>
{loading
? '…'
: modules.length === 0
? 'нет модулей'
: disabledModules === 0
? 'все активны'
: `${disabledModules} выкл`}
</Badge>
),
},
@@ -60,6 +80,7 @@ function buildKpis({
iconClassName: 'text-info',
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
label: 'BGP готовность',
progress: loading || bgpPct === null ? undefined : bgpPct,
footer: (
<Badge
variant={
@@ -73,34 +94,9 @@ function buildKpis({
>
{loading || bgpPct === null
? 'нет включённых пиров'
: `${network.peersEstablished} установлено`}
</Badge>
),
},
{
id: 'peers',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
label: 'Пиры установлены',
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${network.peersTotal} в каталоге`}
</Badge>
),
},
{
id: 'speakers',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-warning',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
label: 'Спикеры в сети',
footer: (
<Badge
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm"
>
{loading ? '…' : 'в сети'}
: bgpPct >= 90
? 'сессии в норме'
: `${network.peersEstablished} установлено`}
</Badge>
),
},
@@ -112,7 +108,7 @@ function buildKpis({
label: 'Активные задачи',
footer: (
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
{loading ? '…' : `${jobs.length} в выборке`}
{loading ? '…' : running > 0 ? 'выполняются' : 'очередь пуста'}
</Badge>
),
},
@@ -1,118 +0,0 @@
import { useMemo } from 'react'
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
import { kpiGridClassName } from '@/lib/ui-surface'
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
function syntheticSparkline(seed: number, points = 9): number[] {
const base = Math.max(4, seed)
return Array.from({ length: points }, (_, i) =>
Math.round(base * (0.82 + (i / points) * 0.18 + Math.sin(i + seed) * 0.04)),
)
}
function buildMetrics({
modules,
peers,
speakers,
jobs,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}): KpiSparklineMetric[] {
const enabledModules = modules.filter((m) => m.enabled !== false).length
const network = aggregateNetworkMetrics(peers, speakers)
const bgpPct =
network.peersEnabled > 0
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
: 0
const running = runningJobCount(jobs)
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
return [
{
id: 'bgp',
title: 'BGP готовность',
label: 'Установлено / включено',
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
detail: loading ? '' : `${network.peersEstablished} сессий`,
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'danger',
sparkline: syntheticSparkline(bgpPct || 40),
},
{
id: 'modules',
title: 'Модули',
label: 'Активные списки',
value: loading ? '—' : `${enabledModules}`,
delta: loading ? '…' : `${modules.length} всего`,
deltaVariant: 'primary-light',
detail: loading ? '' : 'маршрутизация',
tone: 'info',
sparkline: syntheticSparkline(enabledModules || 3),
},
{
id: 'speakers',
title: 'Спикеры',
label: 'В сети / всего',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
delta:
loading || network.speakersTotal === 0
? '…'
: network.speakersOnline === network.speakersTotal
? 'все в сети'
: 'частично',
deltaVariant:
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
detail: loading ? '' : 'live-снимок',
tone: network.speakersOnline === network.speakersTotal ? 'success' : 'warning',
sparkline: syntheticSparkline(network.speakersOnline || 2),
},
{
id: 'jobs',
title: 'Задачи',
label: 'Активные / ошибки',
value: loading ? '—' : String(running),
delta: loading ? '…' : failedJobs > 0 ? `${failedJobs} ошибок` : 'без сбоев',
deltaVariant: failedJobs > 0 ? 'destructive-light' : 'success-light',
detail: loading ? '' : `${jobs.length} в выборке`,
tone: failedJobs > 0 ? 'danger' : running > 0 ? 'info' : 'success',
sparkline: syntheticSparkline(running + failedJobs || 1),
},
]
}
export function DashboardKpiSparklineRow({
modules,
peers,
speakers,
jobs,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}) {
const metrics = useMemo(
() => buildMetrics({ modules, peers, speakers, jobs, loading }),
[modules, peers, speakers, jobs, loading],
)
return (
<section aria-label="KPI обзора" className={kpiGridClassName}>
{metrics.map((metric) => (
<KpiSparklineCard key={metric.id} metric={metric} />
))}
</section>
)
}
@@ -1,117 +0,0 @@
import { useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import { BoxesIcon, PlusIcon, SearchIcon } from 'lucide-react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridNameCell } from '@/components/data-grid-cell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
ResourcePage,
createTextFilterQuery,
type DataGridColumnDef,
} from '@/components/reui-kit'
import { Button } from '@evobgp/ui/components/button'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api'
const MODULE_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'enabled', label: 'Вкл' },
{ id: 'disabled', label: 'Выкл' },
]
const filterFields: FilterField[] = [
{
id: 'search',
label: 'Поиск',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Поиск модулей…',
},
]
function getFilterFieldValue(item: ModuleRow, field: string): unknown {
if (field === 'search') {
return `${item.name} ${item.type} ${moduleTypeRu(item.type)}`
}
return undefined
}
function tabFilter(item: ModuleRow, tabId: string): boolean {
if (tabId === 'enabled') return item.enabled !== false
if (tabId === 'disabled') return item.enabled === false
return true
}
export function DashboardModulesGrid({
modules,
isLoading = false,
}: {
modules: ModuleRow[]
isLoading?: boolean
}) {
const navigate = useNavigate()
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
createTextFilterQuery('search'),
)
const columns = useMemo<DataGridColumnDef<ModuleRow>[]>(
() => [
{
accessorKey: 'name',
id: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => <DataGridNameCell icon={BoxesIcon} title={row.original.name} />,
minSize: 180,
meta: { headerTitle: 'Модуль' },
},
{
accessorKey: 'type',
id: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
meta: { headerTitle: 'Тип' },
},
{
accessorKey: 'priority',
id: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
),
size: 88,
meta: { headerTitle: 'Приоритет' },
},
],
[],
)
return (
<ResourcePage
title="Модули"
description="Поиск и быстрый переход к настройке"
tabs={MODULE_TABS}
tabFilter={tabFilter}
filterFields={filterFields}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={getFilterFieldValue}
columns={columns}
data={modules}
getRowId={(row) => row.id}
isLoading={isLoading}
primaryAction={
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
<PlusIcon />
Создать
</Button>
}
onRowClick={(row) =>
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })
}
emptyState={{ title: 'Нет модулей по выбранным фильтрам.' }}
/>
)
}
@@ -60,8 +60,28 @@ export function DashboardNetworkHealth({
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
percent: loading ? 0 : utilization,
badge: (
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
{loading ? '…' : `${established}/${total}`}
<Badge
variant={
loading || total === 0
? 'outline'
: offline === 0
? 'success-light'
: 'warning-light'
}
size="sm"
radius="full"
>
{loading
? '…'
: total === 0
? 'нет данных'
: offline === 0
? mode === 'peers'
? 'все установлены'
: 'все в сети'
: mode === 'peers'
? `${offline} не установлены`
: `${offline} офлайн`}
</Badge>
),
}}
@@ -61,7 +61,6 @@ const ACTIONS: QuickActionItem[] = [
title: 'Мониторинг',
description: 'Состояние системы, BIRD и PostgreSQL.',
to: '/monitoring',
search: { tab: 'system' },
icon: <Gauge aria-hidden />,
iconClassName: 'text-destructive',
},
@@ -1,62 +0,0 @@
import { useMemo } from 'react'
import { ListTodo } from 'lucide-react'
import { DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
export function DashboardRecentJobsGrid({
jobs,
nameById,
isLoading = false,
}: {
jobs: JobRow[]
nameById: Map<string, string>
isLoading?: boolean
}) {
const data = useMemo(() => jobs.slice(0, 8), [jobs])
const columns = useMemo<DataGridColumnDef<JobRow>[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => (
<DataGridNameCell
icon={ListTodo}
title={jobKindRu(row.original.kind)}
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
: undefined
}
/>
),
meta: { headerTitle: 'Вид' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
],
[nameById],
)
return (
<FrameDataGrid
title="Недавние задачи"
description="Последние фоновые операции"
columns={columns}
data={data}
rowId={(row) => row.job_id}
emptyTitle="Нет задач"
pagination={false}
isLoading={isLoading}
/>
)
}
@@ -1,55 +0,0 @@
import { useMemo } from 'react'
import { GitCommitHorizontal } from 'lucide-react'
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
import type { RevisionRow } from '@/types/api'
export function DashboardRecentRevisionsGrid({
revisions,
isLoading = false,
}: {
revisions: RevisionRow[]
isLoading?: boolean
}) {
const data = useMemo(() => revisions.slice(0, 8), [revisions])
const columns = useMemo<DataGridColumnDef<RevisionRow>[]>(
() => [
{
id: 'id',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 10)}…`} />
),
meta: { headerTitle: 'ID' },
},
{
accessorKey: 'created_at',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<DataGridMutedCell>
{new Date(row.original.created_at).toLocaleString('ru-RU')}
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
},
],
[],
)
return (
<FrameDataGrid
title="Последние ревизии"
description="История конфигураций"
columns={columns}
data={data}
rowId={(row) => row.id}
emptyTitle="Нет ревизий"
pagination={false}
isLoading={isLoading}
/>
)
}
@@ -15,7 +15,7 @@ import {
import type { DohProfile } from '@/types/api'
const filterFields: FilterField[] = [
createSearchFilterField('search', 'Поиск', 'Поиск DoH профилей…'),
createSearchFilterField('search', 'Поиск', 'Поиск DoH-профилей…'),
]
export function DirectoriesDohGrid({
@@ -81,7 +81,7 @@ export function DirectoriesDohGrid({
return (
<ResourcePage
title="DoH профили"
title="DoH-профили"
description="Резолверы DNS-over-HTTPS для доменных модулей"
filterFields={filterFields}
filterQuery={filterQuery}
@@ -94,7 +94,7 @@ export function DirectoriesDohGrid({
isLoading={isLoading}
primaryAction={actions}
pinLastColumn={Boolean(canWrite && onEdit)}
emptyState={{ title: 'Нет DoH профилей', action: actions }}
emptyState={{ title: 'Нет DoH-профилей', action: actions }}
/>
)
}
+19 -2
View File
@@ -7,6 +7,10 @@ import { InboxIcon, type LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import {
Empty,
EmptyContent,
@@ -25,6 +29,8 @@ interface EmptyStateProps {
className?: string
stackedIcon?: boolean
centered?: boolean
/** Wrap in Frame (empty-state-14). Nested grids should leave this false. */
framed?: boolean
}
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
@@ -44,15 +50,16 @@ export function EmptyState({
className,
stackedIcon = true,
centered = true,
framed = false,
}: EmptyStateProps) {
const Icon = isLucideIcon(icon) ? icon : InboxIcon
const customIcon = icon && !isLucideIcon(icon) ? icon : null
const body = (
const empty = (
<Empty
className={cn(
'max-w-md flex-none border-0 bg-transparent p-0',
!centered && className,
!centered && !framed && className,
)}
>
<EmptyHeader className="gap-5 text-center">
@@ -88,6 +95,16 @@ export function EmptyState({
</Empty>
)
const body = framed ? (
<Frame dense spacing="sm" className={cn('w-full', !centered && className)}>
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
{empty}
</FramePanel>
</Frame>
) : (
empty
)
if (!centered) return body
return (
@@ -1,107 +0,0 @@
"use client"
import { useState } from "react"
import { Checkbox } from "@evobgp/ui/components/checkbox"
import { Field } from "@evobgp/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { Label } from "@evobgp/ui/components/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
const statuses = ["Pending", "Shipped", "Cancelled"] as const
type Status = (typeof statuses)[number]
function toggleStatus(values: Status[], value: Status) {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value]
}
export function Pattern() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
return (
<Field className="max-w-sm">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search orders..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchQuery.length > 0 ? (
<InputGroupButton
aria-label="Clear search"
size="icon-xs"
variant="ghost"
onClick={() => setSearchQuery("")}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
<Popover>
<PopoverTrigger
render={
<InputGroupButton
variant="ghost"
size="xs"
className="gap-1.5"
aria-label="Filter order status"
/>
}
>
<ListFilterIcon className="size-3.5" aria-hidden="true" />
Status
{selectedStatuses.length > 0 ? (
<span className="text-muted-foreground tabular-nums">
{selectedStatuses.length}
</span>
) : null}
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-3">
<div className="flex flex-col gap-2">
{statuses.map((status) => (
<div key={status} className="flex items-center gap-2.5">
<Checkbox
id={`order-status-${status}`}
checked={selectedStatuses.includes(status)}
onCheckedChange={() =>
setSelectedStatuses((previous) =>
toggleStatus(previous, status)
)
}
/>
<Label
htmlFor={`order-status-${status}`}
className="text-sm font-normal"
>
{status}
</Label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</InputGroupAddon>
</InputGroup>
</Field>
)
}
@@ -1,19 +0,0 @@
import { Field } from '@evobgp/ui/components/field'
import { SelectMenu } from '@/components/select-field'
const items = [
{ label: 'Select an item', value: 'placeholder' as const },
...Array.from({ length: 100 }).map((_, i) => ({
label: `Item ${i}`,
value: `item-${i}` as const,
})),
]
export function Pattern() {
return (
<Field className="max-w-xs">
<SelectMenu items={items} placeholder="Select an item" />
</Field>
)
}
@@ -1,129 +0,0 @@
import { Button } from "@evobgp/ui/components/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@evobgp/ui/components/card"
import { Input } from "@evobgp/ui/components/input"
import { Label } from "@evobgp/ui/components/label"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-xs flex-col gap-6">
<Tabs defaultValue="account">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Account</CardTitle>
<CardDescription className="text-sm">
Update your account information.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-name" className="text-sm">
Name
</Label>
<Input
id="underline-name"
defaultValue="Alex Chen"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label htmlFor="underline-email" className="text-sm">
Email
</Label>
<Input
id="underline-email"
type="email"
defaultValue="alex.chen@example.com"
className="h-9"
/>
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Save changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Password</CardTitle>
<CardDescription className="text-sm">
Change your password here.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-current" className="text-sm">
Current password
</Label>
<Input id="underline-current" type="password" className="h-9" />
</div>
<div className="space-y-2">
<Label htmlFor="underline-new" className="text-sm">
New password
</Label>
<Input id="underline-new" type="password" className="h-9" />
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Update password</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Settings</CardTitle>
<CardDescription className="text-sm">
Manage your preferences.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-theme" className="text-sm">
Theme
</Label>
<Input
id="underline-theme"
defaultValue="Light"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label htmlFor="underline-language" className="text-sm">
Language
</Label>
<Input
id="underline-language"
defaultValue="English"
className="h-9"
/>
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Save settings</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,46 +0,0 @@
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="overview">
<TabsList className="w-full">
<TabsTrigger value="overview">
<LayoutDashboardIcon className="size-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics">
<BarChart3Icon className="size-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="settings">
<SettingsIcon className="size-4" />
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Card>
<CardContent>Overview dashboard content goes here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics">
<Card>
<CardContent>Analytics charts and metrics.</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardContent>Application settings and preferences.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,61 +0,0 @@
import { Badge } from "@/components/reui/badge"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="inbox">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="inbox" className="gap-2">
Inbox
<Badge variant="primary-light" size="sm">
12
</Badge>
</TabsTrigger>
<TabsTrigger value="drafts" className="gap-2">
Drafts
<Badge variant="info-light" size="sm">
3
</Badge>
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
Sent
</TabsTrigger>
<TabsTrigger value="spam" className="gap-2">
Spam
<Badge variant="destructive-light" size="sm">
24
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="inbox">
<Card>
<CardContent>12 unread messages in your inbox.</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts">
<Card>
<CardContent>3 drafts waiting to be sent.</CardContent>
</Card>
</TabsContent>
<TabsContent value="sent">
<Card>
<CardContent>All sent messages appear here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="spam">
<Card>
<CardContent>24 spam messages detected.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+13 -3
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import { FieldGroup } from '@evobgp/ui/components/field'
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
import {
Sheet,
SheetContent,
@@ -20,7 +22,11 @@ interface FormDrawerProps {
className?: string
}
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
/**
* Create/edit overlay — Sheet DNA (sheet-8) + FieldGroup (form-7).
* @see https://reui.io/preview/base/sheet-8
* @see https://reui.io/preview/base/form-7
*/
export function FormDrawer({
open,
onOpenChange,
@@ -34,13 +40,17 @@ export function FormDrawer({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
className={cn('flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
>
<SheetHeader className="shrink-0 border-b border-border/50">
<SheetTitle>{title}</SheetTitle>
{description ? <SheetDescription>{description}</SheetDescription> : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
<div className="min-h-0 flex-1 overflow-hidden">
<ScrollArea className="h-full">
<FieldGroup className="p-4">{children}</FieldGroup>
</ScrollArea>
</div>
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{footer}
-14
View File
@@ -1,14 +0,0 @@
/**
* Re-export from reui-kit — keep `@/components/kpi-stat-grid` imports stable.
* @see https://reui.io/preview/base/stats-12
*/
export {
KpiStatGrid,
KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatVariant,
type OpsKpiCard,
} from '@/components/reui-kit/kpi-stat-grid'
+1 -1
View File
@@ -89,7 +89,7 @@ const NAV_GROUPS: NavGroup[] = [
items: [
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD' },
],
},
{
+5 -6
View File
@@ -1,18 +1,17 @@
import { Button } from '@evobgp/ui/components/button'
import { Loader2Icon } from 'lucide-react'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { Spinner } from '@evobgp/ui/components/spinner'
import type { ComponentProps, ReactNode } from 'react'
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
type LoadingButtonProps = ComponentProps<typeof Button> & {
loading?: boolean
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
children: ReactNode
}
/** SoT for async actions — Button + Spinner. */
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
return (
<Button disabled={disabled || loading} {...props}>
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
{loading ? <Spinner data-icon="inline-start" /> : null}
{children}
</Button>
)
@@ -20,6 +20,7 @@ import {
import { CascaderInput, CascaderValue } from '@/components/reui/cascader/cascader-nav'
import type { CascaderNode } from '@/components/reui/cascader/cascader-types'
import { ApiError, apiMutate } from '@/lib/api-client'
import { CASCADER_LABELS_RU } from '@/lib/reui-i18n-ru'
import type {
BgpCommunity,
LookupQueryKind,
@@ -202,9 +203,7 @@ export function LookupAddStep({
selectable={allowEmptyCommunity ? 'any' : 'leaf'}
mode="columns"
labels={{
search: 'Поиск',
empty: 'Ничего не найдено',
back: 'Назад',
...CASCADER_LABELS_RU,
rootLevel: 'Модули',
}}
>
@@ -3,8 +3,8 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Field, FieldDescription, FieldLabel } from '@evobgp/ui/components/field'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
@@ -171,42 +171,42 @@ export function ModuleCreateDialog({
}}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-name">Название</Label>
<Field>
<FieldLabel htmlFor="mod-create-name">Название</FieldLabel>
<Input
id="mod-create-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Имя модуля"
/>
</div>
</Field>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
<Field orientation="horizontal">
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="mod-create-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
<FieldLabel htmlFor="mod-create-enabled">Включён</FieldLabel>
<FieldDescription>
Выключенный модуль не участвует в обновлении и применении.
</p>
</FieldDescription>
</div>
<Checkbox
id="mod-create-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
</Field>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-priority">Приоритет</Label>
<Field>
<FieldLabel htmlFor="mod-create-priority">Приоритет</FieldLabel>
<Input
id="mod-create-priority"
type="number"
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
</Field>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
<Field>
<FieldLabel htmlFor="mod-create-interval">Интервал обновления (сек)</FieldLabel>
<Input
id="mod-create-interval"
type="number"
@@ -215,17 +215,17 @@ export function ModuleCreateDialog({
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(e.target.value)}
/>
</div>
</Field>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
<Field>
<FieldLabel htmlFor="mod-create-cron">Cron (опционально)</FieldLabel>
<Input
id="mod-create-cron"
placeholder="0 * * * *"
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
/>
</div>
</Field>
<CommunitySelect
id="mod-create-community"
@@ -248,8 +248,8 @@ export function ModuleCreateDialog({
}}
/>
<div className="flex flex-col gap-2">
<Label>DoH профили</Label>
<Field>
<FieldLabel>DoH профили</FieldLabel>
{dohProfiles.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
) : (
@@ -281,7 +281,7 @@ export function ModuleCreateDialog({
})}
</div>
)}
</div>
</Field>
</>
) : null}
</FormDrawer>
@@ -1,6 +1,6 @@
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import { KpiStatGridSkeleton } from '@/components/skeletons'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
@@ -57,7 +57,7 @@ export const peerColumns: DataGridColumnDef<PeerRow>[] = [
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
switch (field) {
case 'name':
return item.name ?? item.neighbor
return `${item.name ?? ''} ${item.neighbor} ${item.remote_asn ?? ''}`
case 'neighbor':
return item.neighbor
case 'session_state':
@@ -68,7 +68,7 @@ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
switch (field) {
case 'endpoint':
return `${item.endpoint} ${item.agent_domain ?? ''}`
return `${item.endpoint} ${item.agent_domain ?? ''} ${item.id}`
case 'role':
return item.role
default:
@@ -185,7 +185,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
label="Роль"
items={[
{ value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер (плоскость)' },
{ value: 'master', label: 'Основной' },
]}
value={role}
onValueChange={(v) => setRole(v ?? 'replica')}
@@ -0,0 +1,73 @@
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { jobStatusRu, jobTriggerRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
/**
* ReUI Timeline в раскрытии строки задач: создана → взята в работу → результат.
* Активный шаг выводится из фактических отметок времени задачи.
* @see https://reui.io/docs/components/base/timeline
*/
const ruDateTime = (value: string | null | undefined) =>
value ? new Date(value).toLocaleString('ru-RU') : undefined
function resultSubtitle(job: JobRow): string | undefined {
if (job.error) return job.error
const summary = job.meta?.apply_summary
if (summary && typeof summary === 'object') {
const message = (summary as Record<string, unknown>).message
if (typeof message === 'string' && message !== '') return message
}
return undefined
}
export function JobTimeline({ job }: { job: JobRow }) {
const trigger = jobTriggerRu(job.meta?.trigger)
const completed = job.finished_at ? 3 : job.started_at ? 2 : 1
const failed = ['failed', 'error'].includes(job.status.toLowerCase())
const subtitle = resultSubtitle(job)
return (
<div className="py-4 ps-3 pe-2">
<Timeline defaultValue={completed}>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>{ruDateTime(job.created_at)}</TimelineDate>
<TimelineTitle>Создана{trigger ? ` (${trigger})` : ''}</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Идентификатор: {job.job_id}</TimelineContent>
</TimelineItem>
<TimelineItem step={2}>
<TimelineHeader>
<TimelineDate>{ruDateTime(job.started_at)}</TimelineDate>
<TimelineTitle>Взята в работу</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
</TimelineItem>
<TimelineItem step={3}>
<TimelineHeader>
<TimelineDate>{ruDateTime(job.finished_at)}</TimelineDate>
<TimelineTitle className={failed ? 'text-destructive' : undefined}>
{job.finished_at ? jobStatusRu(job.status) : 'Ещё выполняется'}
</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
{subtitle ? <TimelineContent>{subtitle}</TimelineContent> : null}
</TimelineItem>
</Timeline>
</div>
)
}
@@ -4,8 +4,10 @@ import { ListTodo, SearchIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Badge } from '@/components/reui/badge'
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { JobTimeline } from '@/components/operations/job-timeline'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
@@ -14,7 +16,7 @@ import {
type DataGridColumnDef,
} from '@/components/reui-kit'
import { apiMutate } from '@/lib/api-client'
import { jobKindRu } from '@/lib/ui-labels'
import { jobKindRu, jobSubtitleRu, jobTriggerRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
@@ -71,19 +73,27 @@ export function OperationsJobsGrid({
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => (
<DataGridNameCell
icon={ListTodo}
title={jobKindRu(row.original.kind)}
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ??
String(row.original.meta.module_id))
: undefined
}
/>
),
cell: ({ row }) => {
const trigger = jobTriggerRu(row.original.meta?.trigger)
return (
<DataGridNameCell
icon={ListTodo}
title={
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{jobKindRu(row.original.kind)}</span>
{trigger ? (
<Badge variant="outline" size="sm" className="shrink-0 font-normal">
{trigger}
</Badge>
) : null}
</span>
}
subtitle={jobSubtitleRu(row.original.kind, row.original.meta, nameById)}
/>
)
},
meta: { headerTitle: 'Вид' },
size: 320,
},
{
accessorKey: 'status',
@@ -96,6 +106,7 @@ export function OperationsJobsGrid({
return <StatusBadge status={row.original.status} hint={hint} />
},
meta: { headerTitle: 'Статус' },
size: 150,
},
{
id: 'created_at',
@@ -109,6 +120,7 @@ export function OperationsJobsGrid({
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
size: 190,
},
{
id: 'finished_at',
@@ -122,6 +134,7 @@ export function OperationsJobsGrid({
</DataGridMutedCell>
),
meta: { headerTitle: 'Завершена' },
size: 190,
},
{
id: 'actions',
@@ -156,10 +169,9 @@ export function OperationsJobsGrid({
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
getFilterFieldValue={(item, field) => {
if (field !== 'search') return undefined
const moduleName = item.meta?.module_id
? (nameById.get(String(item.meta.module_id)) ?? String(item.meta.module_id))
: ''
return `${jobKindRu(item.kind)} ${item.status} ${item.job_id} ${moduleName}`
const subtitle = jobSubtitleRu(item.kind, item.meta, nameById)
const trigger = jobTriggerRu(item.meta?.trigger)
return `${jobKindRu(item.kind)} ${item.status} ${item.job_id} ${subtitle ?? ''} ${trigger ?? ''}`
}}
columns={columns}
data={items}
@@ -168,6 +180,8 @@ export function OperationsJobsGrid({
pinLastColumn
virtualization={items.length > 80}
emptyState={{ title: 'Нет задач' }}
getRowCanExpand={() => true}
expandedContent={(row) => <JobTimeline job={row} />}
/>
)
}
@@ -8,13 +8,22 @@ import {
KanbanColumn,
KanbanColumnContent,
KanbanItem,
KanbanOverlay,
} from '@/components/reui/kanban'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import {
Item,
ItemContent,
ItemDescription,
ItemTitle,
} from '@evobgp/ui/components/item'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
/**
* Operations jobs board view — ReUI Kanban (read-only columns).
* Operations jobs board — ReUI Kanban (read-only columns).
* @see https://reui.io/preview/base/kanban-board-8
* @see https://reui.io/docs/components/base/kanban
* @see https://reui.io/docs/components/base/frame
*/
@@ -22,7 +31,7 @@ import type { JobRow } from '@/types/api'
const COLUMN_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const
const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
queued: 'Очередь',
queued: 'В очереди',
running: 'Выполняются',
succeeded: 'Успешные',
failed: 'Ошибки',
@@ -78,9 +87,9 @@ export function OperationsJobsKanban({
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">{COLUMN_LABELS[columnId]}</p>
<span className="text-muted-foreground text-xs tabular-nums">
<Badge variant="outline" size="sm">
{columns[columnId]?.length ?? 0}
</span>
</Badge>
</div>
<KanbanColumnContent value={columnId}>
{(columns[columnId] ?? []).map((job) => (
@@ -88,24 +97,35 @@ export function OperationsJobsKanban({
key={job.job_id}
value={job.job_id}
disabled
className="bg-background flex flex-col gap-1 rounded-md border p-3"
className="p-0"
>
<p className="text-sm leading-tight font-medium">
{jobKindRu(job.kind)}
</p>
<StatusBadge status={job.status} />
{job.meta?.module_id ? (
<DataGridMutedCell>
{nameById.get(String(job.meta.module_id)) ??
String(job.meta.module_id)}
</DataGridMutedCell>
) : null}
<Item variant="outline" size="sm">
<ItemContent>
<ItemTitle>{jobKindRu(job.kind)}</ItemTitle>
<StatusBadge status={job.status} />
{job.meta?.module_id ? (
<ItemDescription>
<DataGridMutedCell>
{nameById.get(String(job.meta.module_id)) ??
String(job.meta.module_id)}
</DataGridMutedCell>
</ItemDescription>
) : null}
</ItemContent>
</Item>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay>
<Item variant="outline" size="sm">
<ItemContent>
<ItemTitle>Задача</ItemTitle>
</ItemContent>
</Item>
</KanbanOverlay>
</Kanban>
</FramePanel>
</Frame>
@@ -56,9 +56,10 @@ export function OperationsRevisionsGrid({
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 12)}…`} />
<DataGridNameCell icon={GitCommitHorizontal} title={row.original.id} />
),
meta: { headerTitle: 'ID' },
size: 340,
},
{
accessorKey: 'created_at',
@@ -69,6 +70,7 @@ export function OperationsRevisionsGrid({
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
size: 210,
},
{
accessorKey: 'materialized_prefix_count',
@@ -79,6 +81,7 @@ export function OperationsRevisionsGrid({
</span>
),
meta: { headerTitle: 'Префиксов' },
size: 150,
},
{
id: 'actions',
@@ -91,7 +94,7 @@ export function OperationsRevisionsGrid({
<RefreshCw className="size-3.5" />
</Button>
}
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
title={`Откатиться к ревизии ${row.original.id}?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
confirmLabel="Откатить"
destructive
+11 -93
View File
@@ -1,93 +1,11 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
/**
* Frame shell for analytics / comparison panels (not list grids).
* @see https://reui.io/docs/components/base/frame
* @see https://reui.io/preview/base/chart-1
*/
export const panelCardClassName = 'w-full gap-0 p-0'
export const panelCardHeaderClassName = 'border-b'
/** Flush body inside FramePanel (toolbar / data-grid). */
export const panelCardContentFlushClassName = 'p-0'
/** Horizontal inset for toolbar / pagination rows. */
export const panelCardInsetClassName = 'px-5 py-3'
export const panelCardFooterClassName = 'border-t bg-transparent'
interface PanelCardProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
footer?: ReactNode
children?: ReactNode
className?: string
headerClassName?: string
contentClassName?: string
footerClassName?: string
size?: 'default' | 'sm'
}
export function PanelCard({
title,
description,
actions,
footer,
children,
className,
headerClassName,
contentClassName,
footerClassName,
size = 'default',
}: PanelCardProps) {
const hasHeader = Boolean(title || description || actions)
const spacing = size === 'sm' ? 'sm' : 'default'
return (
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, 'min-w-0', className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader
className={cn(
panelCardHeaderClassName,
'flex-row items-start justify-between gap-3',
headerClassName,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
) : null}
{children != null && children !== false ? (
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
) : null}
{footer ? (
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
export {
FrameSection,
FrameSection as PanelCard,
panelCardClassName,
panelCardHeaderClassName,
panelCardContentFlushClassName,
panelCardInsetClassName,
panelCardFooterClassName,
type FrameSectionProps,
type FrameSectionProps as PanelCardProps,
} from '@/components/reui-kit/frame-section'
@@ -1,24 +1,13 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@evobgp/ui/components/empty'
import { EmptyState } from '@/components/empty-state'
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
export function IllustratedEmptyState({
title,
description,
icon: Icon,
icon,
action,
}: {
title: string
@@ -27,23 +16,13 @@ export function IllustratedEmptyState({
action?: ReactNode
}) {
return (
<Frame dense spacing="sm" className="w-full">
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
{action ? <div className="w-full self-end">{action}</div> : null}
<Empty className="max-w-md gap-5 bg-transparent p-0">
<EmptyHeader className="items-center gap-5 text-center">
<EmptyMedia className="mb-0">
<IconStack aria-hidden>
<Icon strokeWidth={1.9} aria-hidden />
</IconStack>
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
</div>
</EmptyHeader>
</Empty>
</FramePanel>
</Frame>
<EmptyState
framed
centered={false}
icon={icon}
title={title}
description={description}
action={action}
/>
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
export { DonutBreakdownCard } from './donut-breakdown-card'
export { IllustratedEmptyState } from './illustrated-empty-state'
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid'
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/reui-kit'
export { PanelCorners } from './panel-corners'
export { ProjectsEmptyState } from './projects-empty-state'
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
@@ -1,9 +1,9 @@
import { Boxes, Plus } from 'lucide-react'
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
import { EmptyState } from '@/components/empty-state'
import { Button } from '@evobgp/ui/components/button'
/** empty-state-3 pattern for first module. */
/** empty-state-14 for first module. Preview: https://reui.io/preview/base/empty-state-14 */
export function ProjectsEmptyState({
canCreate = true,
onCreate,
@@ -12,7 +12,9 @@ export function ProjectsEmptyState({
onCreate?: () => void
}) {
return (
<IllustratedEmptyState
<EmptyState
framed
centered={false}
icon={Boxes}
title="Создайте первый модуль"
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
@@ -2,9 +2,17 @@ import {
createFilterQuery,
createFilterRule,
flattenFilterConditions,
flattenFilterRules,
isFilterRule,
updateFilterRule,
type FilterCondition,
} from '@/components/reui/filters/filters-query'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import type {
FilterField,
FilterGroupNode,
FilterNode,
FilterQuery,
} from '@/components/reui/filters/filters-types'
/** Operators that take no value, so an empty `values` list is expected. */
const VALUELESS_OPERATORS = new Set(['empty', 'not_empty'])
@@ -131,6 +139,95 @@ export function createSearchFilterField(
}
}
export function getPrimaryTextField(fields: FilterField[]): FilterField | undefined {
return fields.find((field) => field.type === 'text' && !field.fields)
}
export function getExtraFilterFields(
fields: FilterField[],
primaryId?: string,
): FilterField[] {
if (!primaryId) return fields
return fields.filter((field) => field.id !== primaryId)
}
function ruleFieldId(path: string[]): string | undefined {
return path[0]
}
export function getFilterTextValue(query: FilterQuery, fieldId: string): string {
const rule = flattenFilterRules(query).find((item) => ruleFieldId(item.path) === fieldId)
if (rule?.value == null) return ''
if (Array.isArray(rule.value)) return rule.value.map(String).join(' ')
return String(rule.value)
}
export function setFilterTextValue(
query: FilterQuery,
fieldId: string,
text: string,
): FilterQuery {
const existing = flattenFilterRules(query).find((item) => ruleFieldId(item.path) === fieldId)
if (existing) {
return updateFilterRule(query, existing.id, {
operator: 'contains',
value: text,
})
}
return {
...query,
rules: [
createFilterRule({
id: `${fieldId}-1`,
path: [fieldId],
operator: 'contains',
value: text,
}),
...query.rules,
],
}
}
function mapGroupRules<V>(
group: FilterGroupNode<V>,
map: (node: FilterNode<V>) => FilterNode<V> | null,
): FilterGroupNode<V> {
const rules: FilterNode<V>[] = []
for (const child of group.rules) {
if (isFilterRule(child)) {
const next = map(child)
if (next) rules.push(next)
continue
}
const nested = mapGroupRules(child, map)
if (nested.rules.length > 0) rules.push(nested)
}
return { ...group, rules }
}
export function stripFieldFromQuery(query: FilterQuery, fieldId: string): FilterQuery {
return mapGroupRules(query, (node) => {
if (isFilterRule(node) && ruleFieldId(node.path) === fieldId) return null
return node
})
}
export function mergeFieldRules(
extraQuery: FilterQuery,
sourceQuery: FilterQuery,
fieldId: string,
): FilterQuery {
const searchRules = flattenFilterRules(sourceQuery).filter(
(rule) => ruleFieldId(rule.path) === fieldId,
)
const strippedExtra = stripFieldFromQuery(extraQuery, fieldId)
if (searchRules.length === 0) return strippedExtra
return {
...strippedExtra,
rules: [...searchRules, ...strippedExtra.rules],
}
}
export function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано`
@@ -47,7 +47,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { EmptyState } from '@/components/empty-state'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
export type DataGridColumnDef<TData extends object> = ColumnDef<DataGridFeatures, TData>
@@ -298,6 +298,7 @@ function FrameDataGridBody<TData extends object>({
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyTitle}
i18n={DATA_GRID_I18N_RU}
tableLayout={kitDataGridTableLayout({
dense,
width: tableWidth,
@@ -317,7 +318,7 @@ function FrameDataGridBody<TData extends object>({
<>
<Separator />
<FrameFooter>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
<DataGridPagination sizes={[10, 25, 50]} />
</FrameFooter>
</>
) : null}
@@ -0,0 +1,93 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
/**
* Frame shell for analytics / comparison / detail panels (not list grids).
* @see https://reui.io/docs/components/base/frame
* @see https://reui.io/preview/base/chart-1
*/
export const panelCardClassName = 'w-full gap-0 p-0'
export const panelCardHeaderClassName = 'border-b'
/** Flush body inside FramePanel (toolbar / data-grid). */
export const panelCardContentFlushClassName = 'p-0'
/** Horizontal inset for toolbar / pagination rows. */
export const panelCardInsetClassName = 'px-5 py-3'
export const panelCardFooterClassName = 'border-t bg-transparent'
export interface FrameSectionProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
footer?: ReactNode
children?: ReactNode
className?: string
headerClassName?: string
contentClassName?: string
footerClassName?: string
size?: 'default' | 'sm'
}
export function FrameSection({
title,
description,
actions,
footer,
children,
className,
headerClassName,
contentClassName,
footerClassName,
size = 'default',
}: FrameSectionProps) {
const hasHeader = Boolean(title || description || actions)
const spacing = size === 'sm' ? 'sm' : 'default'
return (
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, 'min-w-0', className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader
className={cn(
panelCardHeaderClassName,
'flex-row items-start justify-between gap-3',
headerClassName,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
) : null}
{children != null && children !== false ? (
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
) : null}
{footer ? (
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
@@ -36,3 +36,12 @@ export {
export { ExpandableResourceGrid } from './expandable-resource-grid'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell } from './settings-shell'
export {
FrameSection,
panelCardClassName,
panelCardHeaderClassName,
panelCardContentFlushClassName,
panelCardInsetClassName,
panelCardFooterClassName,
type FrameSectionProps,
} from './frame-section'
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Progress } from '@evobgp/ui/components/progress'
import { cn } from '@evobgp/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { IconTile } from '@/components/reui/icon-tile'
@@ -29,6 +30,8 @@ export type KpiStatItem = {
iconClassName?: string
variant?: KpiStatVariant
footer?: ReactNode
/** 0–100 completion bar under the value (stats-4). Omit to hide. */
progress?: number
}
/** CFDM-compatible card shape (id required). */
@@ -48,6 +51,17 @@ const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
destructive: 'text-destructive',
}
const PROGRESS_TONE_CLASS: Record<KpiStatVariant, string> = {
default: '',
warning: '[&_[data-slot=progress-indicator]]:bg-warning',
destructive: '[&_[data-slot=progress-indicator]]:bg-destructive',
}
function clampProgress(value: number): number {
if (Number.isNaN(value)) return 0
return Math.min(100, Math.max(0, value))
}
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
@@ -67,7 +81,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
{item.hint}
</Badge>
)
@@ -81,30 +95,53 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const valueVariant = item.variant ?? 'default'
return (
<div className="relative z-10 flex h-full items-start gap-3">
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
{item.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div>
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
{item.progress !== undefined ? (
<Progress
value={clampProgress(item.progress)}
className={cn(
'mt-1.5 w-full gap-0 **:data-[slot=progress-track]:h-1.5',
PROGRESS_TONE_CLASS[valueVariant],
)}
aria-label={
typeof item.label === 'string'
? `${item.label}: ${clampProgress(item.progress)}%`
: undefined
}
/>
) : null}
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
</div>
</div>
)
@@ -116,7 +153,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
const selected = isSelected(item)
return cn(
'relative isolate flex h-full flex-col',
'relative isolate flex h-full min-w-0 flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
@@ -12,7 +12,7 @@ interface OpsDashboardProps {
/** Slot after KPI (QuickActionGrid). Preview: stats-12 · card-12 */
afterKpi?: ReactNode
charts: ReactNode
queue: ReactNode
queue?: ReactNode
queueTitle?: string
queueDescription?: string
headerActions?: ReactNode
@@ -31,10 +31,12 @@ function OpsDashboardSkeleton() {
</header>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="flex min-w-0 flex-col gap-4">
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<div className="grid min-w-0 grid-cols-1 gap-4 @5xl:grid-cols-2">
<Skeleton className="h-56 w-full rounded-xl" />
<Skeleton className="h-56 w-full rounded-xl" />
</div>
</div>
<Skeleton className="h-48 w-full rounded-xl" />
</div>
)
}
@@ -76,15 +78,17 @@ export function OpsDashboard({
{charts}
</section>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
{queueDescription ? (
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
) : null}
</div>
{queue}
</section>
{queue ? (
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
{queueDescription ? (
<p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
) : null}
</div>
{queue}
</section>
) : null}
</div>
)
}
@@ -1,14 +1,19 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import {
useTable,
type ExpandedState,
type PaginationState,
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table'
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state'
import {
StatusToggleGroup,
type StatusToggleTone,
} from '@/components/status-toggle-group'
import { Badge } from '@/components/reui/badge'
import {
DataGrid,
@@ -17,7 +22,9 @@ import {
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { DataGridTableRowExpand } from '@/components/reui/data-grid/data-grid-table'
import { Filters } from '@/components/reui/filters/filters'
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
Frame,
@@ -29,11 +36,27 @@ import {
} from '@/components/reui/frame'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Button } from '@evobgp/ui/components/button'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { Separator } from '@evobgp/ui/components/separator'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
import { applyFiltersToData, createEmptyFilterQuery } from './filter-utils'
import {
applyFiltersToData,
createEmptyFilterQuery,
createTextFilterQuery,
getActiveFilters,
getExtraFilterFields,
getFilterTextValue,
getPrimaryTextField,
mergeFieldRules,
setFilterTextValue,
stripFieldFromQuery,
} from './filter-utils'
import {
FrameDataGrid,
applyKitActionColumn,
@@ -48,6 +71,8 @@ export interface ResourcePageTab {
id: string
label: string
count?: number
icon?: LucideIcon
tone?: StatusToggleTone
}
type SimpleGridPassthrough<T extends object> = Pick<
@@ -71,6 +96,8 @@ type SimpleGridPassthrough<T extends object> = Pick<
| 'horizontalScroll'
| 'pinLeftColumnIds'
| 'columnPinControls'
| 'expandedContent'
| 'getRowCanExpand'
>
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
@@ -195,6 +222,8 @@ function ResourcePageSimple<T extends object>({
horizontalScroll,
pinLeftColumnIds,
columnPinControls,
expandedContent,
getRowCanExpand,
}: ResourcePageProps<T>) {
if (isLoading) return <ResourcePageSkeleton />
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
@@ -233,6 +262,8 @@ function ResourcePageSimple<T extends object>({
height={height}
onRowSelectionChange={onRowSelectionChange}
enableColumnVisibility={enableColumnVisibility}
expandedContent={expandedContent}
getRowCanExpand={getRowCanExpand}
columnVisibility={columnVisibility}
onColumnVisibilityChange={onColumnVisibilityChange}
columnVisibilityTrigger={columnVisibilityTrigger}
@@ -281,19 +312,44 @@ function ResourcePageFiltered<T extends object>({
virtualization = false,
height = 480,
horizontalScroll = false,
expandedContent,
getRowCanExpand,
}: ResourcePageProps<T>) {
const headerActions = primaryAction ?? actions
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab
const showFilters = filterFields.length > 0
const primaryTextField = useMemo(() => getPrimaryTextField(filterFields), [filterFields])
const extraFilterFields = useMemo(
() => getExtraFilterFields(filterFields, primaryTextField?.id),
[filterFields, primaryTextField],
)
const showSearch = Boolean(primaryTextField)
const showFiltersPopover = extraFilterFields.length > 0
const [internalQuery, setInternalQuery] = useState<FilterQuery>(createEmptyFilterQuery)
const isQueryControlled = controlledQuery !== undefined
const filterQuery = isQueryControlled ? controlledQuery : internalQuery
const setFilterQuery = isQueryControlled ? onFilterQueryChange : setInternalQuery
const searchText = primaryTextField
? getFilterTextValue(filterQuery, primaryTextField.id)
: ''
const extraQuery = useMemo(
() =>
primaryTextField
? stripFieldFromQuery(filterQuery, primaryTextField.id)
: filterQuery,
[filterQuery, primaryTextField],
)
const extraActiveCount = useMemo(
() => getActiveFilters(flattenFilterConditions(extraQuery)).length,
[extraQuery],
)
const hasDirtyFilters = searchText.trim() !== '' || extraActiveCount > 0
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [expanded, setExpanded] = useState<ExpandedState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize,
@@ -331,9 +387,28 @@ function ResourcePageFiltered<T extends object>({
const selectedCount = selectedIds.length
// Колонка раскрытия — тот же приём, что во FrameDataGrid: рендерер
// подстроки живёт в meta колонки, DataGridTable читает его оттуда.
const tableColumns = useMemo(
() => applyKitActionColumn(columns, { pinLastColumn }),
[columns, pinLastColumn],
() =>
applyKitActionColumn(
expandedContent
? [
{
id: 'expand',
header: () => null,
cell: ({ row }) => <DataGridTableRowExpand row={row} />,
enableSorting: false,
enableHiding: false,
size: 40,
meta: { cellClassName: 'w-10', expandedContent },
},
...columns,
]
: columns,
{ pinLastColumn },
),
[columns, pinLastColumn, expandedContent],
)
const { enablePinning, columnPinning } = kitColumnPinning({
pinLastColumn,
@@ -354,6 +429,7 @@ function ResourcePageFiltered<T extends object>({
sorting,
rowSelection,
pagination,
expanded,
...(enablePinning ? { columnPinning } : {}),
},
initialState: enablePinning ? { columnPinning } : undefined,
@@ -361,6 +437,10 @@ function ResourcePageFiltered<T extends object>({
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
onExpandedChange: setExpanded,
getRowCanExpand: expandedContent
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
: undefined,
})
const handleTabChange = useCallback(
@@ -372,19 +452,38 @@ function ResourcePageFiltered<T extends object>({
[onTabChange, resetPagination],
)
const handleFiltersChange = useCallback(
(next: FilterQuery) => {
setFilterQuery(next)
const handleSearchChange = useCallback(
(value: string) => {
if (!primaryTextField) return
setFilterQuery(setFilterTextValue(filterQuery, primaryTextField.id, value))
resetPagination()
},
[setFilterQuery, resetPagination],
[filterQuery, primaryTextField, setFilterQuery, resetPagination],
)
const handleFiltersChange = useCallback(
(next: FilterQuery) => {
setFilterQuery(
primaryTextField
? mergeFieldRules(next, filterQuery, primaryTextField.id)
: next,
)
resetPagination()
},
[filterQuery, primaryTextField, setFilterQuery, resetPagination],
)
const handleClear = useCallback(() => {
onClearFilters?.()
if (!isQueryControlled) setInternalQuery(createEmptyFilterQuery())
if (!isQueryControlled) {
setInternalQuery(
primaryTextField
? createTextFilterQuery(primaryTextField.id)
: createEmptyFilterQuery(),
)
}
resetPagination()
}, [onClearFilters, isQueryControlled, resetPagination])
}, [onClearFilters, isQueryControlled, primaryTextField, resetPagination])
const countedTabs = useMemo(
() =>
@@ -392,6 +491,8 @@ function ResourcePageFiltered<T extends object>({
id: tab.id,
label: tab.label,
count: tabCounts[tab.id] ?? tab.count ?? 0,
icon: tab.icon,
tone: tab.tone,
})),
[tabs, tabCounts],
)
@@ -429,6 +530,7 @@ function ResourcePageFiltered<T extends object>({
recordCount={filteredData.length}
emptyMessage="Нет записей по выбранным фильтрам."
onRowClick={onRowClick}
i18n={DATA_GRID_I18N_RU}
tableLayout={kitDataGridTableLayout({
dense: true,
width: 'fixed',
@@ -465,46 +567,64 @@ function ResourcePageFiltered<T extends object>({
) : null}
<FramePanel className="p-0 shadow-none!">
{countedTabs.length > 0 ? (
<>
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={countedTabs}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{showSearch && primaryTextField ? (
<InputGroup className="h-8 w-full min-w-[12rem] max-w-sm">
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchText}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder={primaryTextField.placeholder ?? 'Поиск…'}
aria-label={primaryTextField.label || 'Поиск'}
/>
</InputGroup>
) : null}
{showFiltersPopover ? (
<Filters
query={extraQuery}
fields={extraFilterFields}
onQueryChange={handleFiltersChange}
variant="advanced"
advancedMode="popover"
size="default"
labels={FILTERS_LABELS_RU}
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
{extraActiveCount > 0 ? (
<Badge size="xs" variant="secondary" radius="full">
{extraActiveCount}
</Badge>
) : null}
</Button>
}
/>
) : null}
{!showSearch && !showFiltersPopover && countedTabs.length === 0 ? (
<div />
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{countedTabs.length > 0 ? (
<StatusToggleGroup
items={countedTabs}
value={activeTab}
onValueChange={handleTabChange}
aria-label="Фильтр по статусу"
/>
</div>
<Separator />
</>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
{showFilters ? (
<Filters
query={filterQuery}
fields={filterFields}
onQueryChange={handleFiltersChange}
size="default"
labels={FILTERS_LABELS_RU}
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
) : (
<div />
)}
<div className="flex flex-wrap items-center justify-end gap-2">
) : null}
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{showFilters ? (
{hasDirtyFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
@@ -526,7 +646,7 @@ function ResourcePageFiltered<T extends object>({
<Separator />
<FrameFooter>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} sizes={[5, 10, 20, 50]} />
<DataGridPagination sizes={[5, 10, 20, 50]} />
</FrameFooter>
</FramePanel>
</Frame>
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import {
useCascaderActions,
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
import {
@@ -81,7 +79,8 @@ export function useCascaderVirtualizer({
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
// React Compiler bails on `useVirtualizer`; harmless, rows memoise one by one.
// React Compiler bails on `useVirtualizer` HERE, and only here - the bail does not
// propagate to the components that call this hook. They opt out themselves.
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
count,
getScrollElement,
@@ -225,6 +224,11 @@ function CascaderVirtualRows({
estimateSize,
overscan,
}: CascaderVirtualItemsProps) {
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
window freezes on scroll. Inert where no compiler runs. */
"use no memo"
const {
estimateRowSize,
overscan: rootOverscan,
@@ -375,6 +379,11 @@ function CascaderVirtualColumnRows({
overscan,
activeIndex,
}: CascaderVirtualColumnProps & { activeIndex: number }) {
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
window freezes on scroll. Inert where no compiler runs. */
"use no memo"
const {
estimateRowSize,
overscan: rootOverscan,
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import {
useCascaderLoader,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Column } from "@tanstack/react-table"
@@ -31,6 +32,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const { i18n } = useDataGrid()
const facets = column?.getFacetedUniqueValues()
const filterValue = column?.getFilterValue()
const selectedValues = new Set(
@@ -64,7 +66,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} selected
{i18n.labels.filterSelectedCount(selectedValues.size)}
</Badge>
) : (
options
@@ -97,7 +99,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
No results found.
{i18n.labels.filterNoResults}
</div>
) : (
<div className="p-1">
@@ -173,7 +175,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
}}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
{i18n.labels.filterClear}
</div>
</div>
</>
@@ -47,16 +47,22 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props } = useDataGrid()
const { i18n, isLoading, table, props } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
// TanStack's columnOrder defaults to [] until a consumer seeds it; fall
// back to the definition order so Move Left/Right work out of the box.
// The order a move rewrites: the consumer's columnOrder (TanStack defaults it
// to []), then every leaf it leaves out, in definition order - the same
// completion TanStack applies when rendering, so a rendered neighbour is
// always present to re-seat beside, even after columns are added later.
const columnOrderState = table.state.columnOrder
const columnOrder =
columnOrderState.length > 0
? columnOrderState
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
const definitionOrder = table
.getAllColumns()
.flatMap((topColumn) => topColumn.getLeafColumns())
.map((leafColumn) => leafColumn.id)
const columnOrder = [
...columnOrderState,
...definitionOrder.filter((id) => !columnOrderState.includes(id)),
]
const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility
? JSON.stringify(table.state.columnVisibility)
@@ -67,9 +73,46 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
const canPin = column.getCanPin()
const canResize = column.getCanResize()
const columnIndex = columnOrder.indexOf(column.id)
const canMoveLeft = columnIndex > 0
const canMoveRight = columnIndex < columnOrder.length - 1
// Move neighbours come from what is RENDERED: the column's own pin bucket,
// visible columns only. Stepping through the raw columnOrder would trade
// places with a hidden or pinned column - an enabled click that moves nothing.
// With grouping in TanStack's default "reorder" mode, grouped columns render
// first whatever columnOrder says: they neither move nor serve as a target.
const groupedColumnMode = (
table.options as { groupedColumnMode?: false | "reorder" | "remove" }
).groupedColumnMode
const isHoistedByGrouping = (target: object) =>
groupedColumnMode !== false &&
typeof (target as { getIsGrouped?: unknown }).getIsGrouped === "function" &&
(target as { getIsGrouped: () => boolean }).getIsGrouped()
const renderedPeers = (
isPinned === "start"
? table.getStartVisibleLeafColumns()
: isPinned === "end"
? table.getEndVisibleLeafColumns()
: table.getCenterVisibleLeafColumns()
)
.filter((leafColumn) => !isHoistedByGrouping(leafColumn))
.map((leafColumn) => leafColumn.id)
const renderedIndex = renderedPeers.indexOf(column.id)
const leftNeighbour =
renderedIndex > 0 ? renderedPeers[renderedIndex - 1] : undefined
const rightNeighbour =
renderedIndex !== -1 && renderedIndex < renderedPeers.length - 1
? renderedPeers[renderedIndex + 1]
: undefined
const canMoveLeft = leftNeighbour !== undefined
const canMoveRight = rightNeighbour !== undefined
/** Re-seats this column beside a rendered neighbour; every other column,
* hidden or pinned ones included, keeps its place in the full order. */
const moveBeside = (neighbourId: string, side: "before" | "after") => {
const newOrder = columnOrder.filter((id) => id !== column.id)
const at = newOrder.indexOf(neighbourId)
if (at === -1) return
newOrder.splice(side === "before" ? at : at + 1, 0, column.id)
table.setColumnOrder(newOrder)
}
const handleSort = () => {
if (isSorted === "asc") {
@@ -139,7 +182,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">Asc</span>
<span className="grow">{i18n.labels.sortAscending}</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
@@ -156,7 +199,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">Desc</span>
<span className="grow">{i18n.labels.sortDescending}</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
@@ -176,7 +219,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
onClick={() => column.pin(isPinned === "start" ? false : "start")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span>
<span className="grow">{i18n.labels.pinColumnStart}</span>
{isPinned === "start" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
@@ -186,7 +229,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
onClick={() => column.pin(isPinned === "end" ? false : "end")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span>
<span className="grow">{i18n.labels.pinColumnEnd}</span>
{isPinned === "end" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
@@ -204,32 +247,22 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
<DropdownMenuItem
key="move-left"
onClick={() => {
if (columnIndex > 0) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex - 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
if (leftNeighbour) moveBeside(leftNeighbour, "before")
}}
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Left</span>
<span>{i18n.labels.moveColumnStart}</span>
</DropdownMenuItem>,
<DropdownMenuItem
key="move-right"
onClick={() => {
if (columnIndex < columnOrder.length - 1) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex + 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
if (rightNeighbour) moveBeside(rightNeighbour, "after")
}}
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Right</span>
<span>{i18n.labels.moveColumnEnd}</span>
</DropdownMenuItem>
)
hasPreviousSection = true
@@ -244,7 +277,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>Columns</span>
<span>{i18n.labels.columnsMenu}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
@@ -282,7 +315,8 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
canMoveRight,
visibility,
table,
columnIndex,
leftNeighbour,
rightNeighbour,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
@@ -314,8 +348,8 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
variant="ghost"
className="rounded-lg -me-1 size-7"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
aria-label={i18n.labels.unpinColumn(resolvedTitle)}
title={i18n.labels.unpinColumn(resolvedTitle)}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
@@ -1,7 +1,10 @@
"use client"
import type { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Table } from "@tanstack/react-table"
@@ -21,13 +24,15 @@ function DataGridColumnVisibility<TData extends object>({
table: Table<DataGridFeatures, TData>
trigger: ReactElement<Record<string, unknown>>
}) {
const { i18n } = useDataGrid()
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
{i18n.labels.toggleColumns}
</DropdownMenuLabel>
{table
.getAllColumns()
@@ -0,0 +1,99 @@
export interface DataGridI18nLabels {
/* The column header menu. */
sortAscending: string
sortDescending: string
pinColumnStart: string
pinColumnEnd: string
moveColumnStart: string
moveColumnEnd: string
columnsMenu: string
unpinColumn: (title: string) => string
toggleColumns: string
/* Row and cell affordances. */
rowCreate: string
pinRow: string
unpinRow: string
selectRow: string
selectAll: string
expandRow: string
collapseRow: string
dragToReorder: string
dragToReorderRow: string
reorderingUnavailable: string
/* Grid states. */
loading: string
empty: string
allRowsLoaded: string
/* Pagination. */
rowsPerPage: string
paginationInfo: (info: { from: number; to: number; count: number }) => string
previousPage: string
nextPage: string
goToPage: (page: number) => string
paginationEllipsis: string
/* The faceted column filter. */
filterSelectedCount: (count: number) => string
filterNoResults: string
filterClear: string
}
export interface DataGridI18nConfig {
labels: DataGridI18nLabels
}
export type DataGridI18nOverrides = {
labels?: Partial<DataGridI18nLabels>
}
const DEFAULT_DATA_GRID_LABELS: DataGridI18nLabels = {
sortAscending: "Asc",
sortDescending: "Desc",
pinColumnStart: "Pin to left",
pinColumnEnd: "Pin to right",
moveColumnStart: "Move to left",
moveColumnEnd: "Move to right",
columnsMenu: "Columns",
unpinColumn: (title) => `Unpin ${title} column`,
toggleColumns: "Toggle Columns",
rowCreate: "Add row",
pinRow: "Pin row",
unpinRow: "Unpin row",
selectRow: "Select row",
selectAll: "Select all",
expandRow: "Expand row",
collapseRow: "Collapse row",
dragToReorder: "Drag to reorder",
dragToReorderRow: "Drag to reorder row",
reorderingUnavailable: "Reordering unavailable",
loading: "Loading...",
empty: "No data available",
allRowsLoaded: "All records loaded",
rowsPerPage: "Rows per page",
paginationInfo: ({ from, to, count }) => `${from} - ${to} of ${count}`,
previousPage: "Go to previous page",
nextPage: "Go to next page",
goToPage: (page) => `Go to page ${page}`,
paginationEllipsis: "...",
filterSelectedCount: (count) => `${count} selected`,
filterNoResults: "No results found.",
filterClear: "Clear filters",
}
const DEFAULT_DATA_GRID_I18N: DataGridI18nConfig = Object.freeze({
labels: Object.freeze(DEFAULT_DATA_GRID_LABELS),
})
/**
* A shallow merge per section, deliberately: a deep merge would leak a
* default back into a function-valued label the consumer replaced. With no
* overrides the frozen default is returned as-is, so the merge is free to
* run on every render without producing a new identity.
*/
export function mergeDataGridI18n(
overrides?: DataGridI18nOverrides
): DataGridI18nConfig {
if (!overrides?.labels) return DEFAULT_DATA_GRID_I18N
return {
labels: { ...DEFAULT_DATA_GRID_LABELS, ...overrides.labels },
}
}
@@ -20,6 +20,8 @@ interface DataGridPaginationProps {
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
/** Page buttons the truncated middle shows; the row adds first, last and
* an ellipsis for each hidden stretch. Clamped to a minimum of 3. */
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
@@ -30,19 +32,113 @@ interface DataGridPaginationProps {
ellipsisText?: string
}
/**
* One slot in the page row: a page button, or an ellipsis that jumps over the
* pages it stands for.
*/
type DataGridPaginationItem =
| { type: "page"; index: number }
| { type: "ellipsis"; direction: "previous"; target: number }
| { type: "ellipsis"; direction: "next"; target: number }
/**
* The adaptive window: first page, the run around the current one, last page,
* with an ellipsis standing in for each hidden stretch. It replaced fixed
* BLOCKS (1-5, then 6-10) because a block never shows the last page, so on a
* 50-page grid there was no way to reach the end - the primitive wires only
* `previousPage()` / `nextPage()`, so there is no last-page arrow either.
*
* `limit` is `moreLimit`: how many page buttons the truncated middle shows.
* At the default of 5 this emits exactly the same rows as the block version
* did at `moreLimit: 5`, so the prop keeps both its meaning and its default.
* An ellipsis never stands for a single page, because the early return covers
* every count a full row could hold.
*/
function getDataGridPaginationItems(
pageIndex: number,
pageCount: number,
limit: number
): DataGridPaginationItem[] {
// Pages flanking the current one, per side. The run is rebuilt from it so
// the count is always odd: an even `limit` would make the head and tail
// rows one slot wider than the middle, and the footer would twitch as the
// user pages through.
const sibling = Math.max(0, Math.floor(((Math.floor(limit) || 3) - 3) / 2))
const pages = sibling * 2 + 3
// Two ellipses cost the width of two pages, so below this there is nothing
// to gain by hiding any.
if (pageCount <= pages + 2) {
return Array.from({ length: pageCount }, (_, index) => ({
type: "page" as const,
index,
}))
}
if (pageIndex <= sibling + 2) {
return [
...Array.from({ length: pages }, (_, index) => ({
type: "page" as const,
index,
})),
{ type: "ellipsis", direction: "next", target: pages },
{ type: "page", index: pageCount - 1 },
]
}
if (pageIndex >= pageCount - sibling - 3) {
return [
{ type: "page", index: 0 },
{
type: "ellipsis",
direction: "previous",
target: pageCount - pages - 1,
},
...Array.from({ length: pages }, (_, offset) => ({
type: "page" as const,
index: pageCount - pages + offset,
})),
]
}
return [
{ type: "page", index: 0 },
{
type: "ellipsis",
direction: "previous",
target: pageIndex - sibling - 1,
},
...Array.from({ length: sibling * 2 + 1 }, (_, offset) => ({
type: "page" as const,
index: pageIndex - sibling + offset,
})),
{ type: "ellipsis", direction: "next", target: pageIndex + sibling + 1 },
{ type: "page", index: pageCount - 1 },
]
}
/**
* A page button is square at one digit and grows from there, so the row does
* not reflow when the count crosses 10 or 100. The floor is each style's own
* `icon-sm` square, which is also its `sm` HEIGHT, so the numbers stay square
* and keep the arrows' height. Hardcoding one value would be right in three
* styles and wrong in five.
*/
const PAGE_BUTTON_WIDTH_CLASS =
"min-w-7"
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
const { i18n, table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
ellipsisText: "...",
rowsPerPageLabel: i18n.labels.rowsPerPage,
previousPageLabel: i18n.labels.previousPage,
nextPageLabel: i18n.labels.nextPage,
ellipsisText: i18n.labels.paginationEllipsis,
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
@@ -55,84 +151,21 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
// A supplied `info` keeps its placeholder-template contract; the default
// routes through the i18n label function, where word order is free.
const paginationInfo = mergedProps.info
? mergedProps.info
.replaceAll("{from}", from.toString())
.replaceAll("{to}", to.toString())
.replaceAll("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
: i18n.labels.paginationInfo({ from, to, count: 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
const paginationItems = getDataGridPaginationItems(
pageIndex,
pageCount,
mergedProps.moreLimit ?? 5
)
// Render page buttons based on the current group
const renderPageButtons = () => {
const buttons = []
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
buttons.push(
<Button
key={i}
size="icon-sm"
variant="ghost"
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
onClick={() => {
if (pageIndex !== i) {
table.setPageIndex(i)
}
}}
>
{i + 1}
</Button>
)
}
return buttons
}
// Render a "previous" ellipsis button if there are previous pages to show
const renderEllipsisPrevButton = () => {
if (currentGroupStart > 0) {
return (
<Button
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
// Render a "next" ellipsis button if there are more pages to show after the current group
const renderEllipsisNextButton = () => {
if (currentGroupEnd < pageCount) {
return (
<Button
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
return (
<div
data-slot="data-grid-pagination"
@@ -156,7 +189,15 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="w-16" size="sm">
{/* w-fit with a min, never a fixed width: a fixed w-16 clipped
the value "100" by 1px at nova's paddings, while fit-content
grows the trigger for 3-digit sizes and the min keeps the
1-2 digit ones from collapsing narrower than 64px. */}
<SelectTrigger
aria-label={mergedProps.rowsPerPageLabel}
className="w-fit min-w-16"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent
@@ -183,7 +224,7 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1">
<div className="order-1 flex flex-wrap items-center justify-center gap-1 sm:flex-nowrap">
<Button
size="icon-sm"
variant="ghost"
@@ -197,11 +238,49 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
<ChevronLeftIcon className="size-4" />
</Button>
{renderEllipsisPrevButton()}
{renderPageButtons()}
{renderEllipsisNextButton()}
{paginationItems.map((item) =>
item.type === "page" ? (
<Button
key={`page-${item.index}`}
size="sm"
variant="ghost"
aria-label={i18n.labels.goToPage(item.index + 1)}
aria-current={
pageIndex === item.index ? "page" : undefined
}
className={cn(
PAGE_BUTTON_WIDTH_CLASS,
"px-1.5 text-sm",
"text-muted-foreground",
{
"bg-accent text-accent-foreground":
pageIndex === item.index,
}
)}
onClick={() => {
if (pageIndex !== item.index) {
table.setPageIndex(item.index)
}
}}
>
{item.index + 1}
</Button>
) : (
/* Clickable, unlike the shadcn PaginationEllipsis, which is
an aria-hidden span. This one MOVES the user, so it needs
a name saying where. */
<Button
key={`ellipsis-${item.direction}`}
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
aria-label={i18n.labels.goToPage(item.target + 1)}
onClick={() => table.setPageIndex(item.target)}
>
{mergedProps.ellipsisText}
</Button>
)
)}
<Button
size="icon-sm"
@@ -19,8 +19,11 @@ const INITIAL_METRICS = {
trackHeight: 0,
} as const
// Track footprint, measured: horizontal is 8px tall and vertical 6px wide, and
// each loses 1px to its transparent border plus 2px to p-px, so the thumbs land
// at 5px and 3px. Shrink these further and the thumb stops being a grab target.
const SCROLLBAR_CLASSNAME =
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
@@ -454,12 +457,12 @@ function DataGridScrollArea({
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
className="pointer-events-auto relative h-full w-2 touch-none p-px"
className="pointer-events-auto relative h-full w-1.5 touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-2",
"bg-border absolute end-px w-1.5",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full"
)}
@@ -1,3 +1,5 @@
"use client"
import {
createContext,
memo,
@@ -105,7 +107,7 @@ type DataGridTableDndRowDecoration<TData extends object> = (context: {
function DataGridTableDndRowHandle({
className,
disabled,
disabledLabel = "Reordering unavailable",
disabledLabel,
}: {
className?: string
/**
@@ -118,7 +120,10 @@ function DataGridTableDndRowHandle({
/** Announced and shown on hover in place of the drag affordance. */
disabledLabel?: string
}) {
const { i18n } = useDataGrid()
const context = useContext(SortableRowContext)
const resolvedDisabledLabel =
disabledLabel ?? i18n.labels.reorderingUnavailable
if (!context || disabled) {
return (
@@ -133,8 +138,8 @@ function DataGridTableDndRowHandle({
disabled && "cursor-not-allowed",
className
)}
aria-label={disabled ? disabledLabel : "Drag to reorder row"}
title={disabled ? disabledLabel : undefined}
aria-label={disabled ? resolvedDisabledLabel : i18n.labels.dragToReorderRow}
title={disabled ? resolvedDisabledLabel : undefined}
disabled
>
<GripHorizontalIcon aria-hidden="true" />
@@ -150,7 +155,7 @@ function DataGridTableDndRowHandle({
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
aria-label={i18n.labels.dragToReorderRow}
{...context.attributes}
{...context.listeners}
>
@@ -1,5 +1,3 @@
"use client"
import {
Fragment,
memo,
@@ -69,7 +67,7 @@ function DataGridTableDndHeader<TData extends object>({
}: {
header: Header<DataGridFeatures, TData, unknown>
}) {
const { props } = useDataGrid()
const { i18n, props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
@@ -115,7 +113,7 @@ function DataGridTableDndHeader<TData extends object>({
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
aria-label={i18n.labels.dragToReorder}
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useRef, useState } from "react"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type {
@@ -6,6 +6,7 @@ import type {
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import {
DataGridTableAddRow,
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
@@ -49,6 +50,33 @@ type DataGridTableVirtualizerInstance = Virtualizer<
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
interface DataGridTableColumnVirtualizerOptions {
/** Off unless explicitly true; a column window is opt-in. */
enabled?: boolean
overscan?: number
}
type DataGridTableColumnScrollRequest = {
align: DataGridTableVirtualScrollAlignment
behavior: ScrollBehavior
columnId: string | undefined
columnIndex: number
scrollElement: HTMLElement
}
function isSameDataGridTableColumnScrollRequest(
previous: DataGridTableColumnScrollRequest | null,
next: DataGridTableColumnScrollRequest
) {
return (
previous?.align === next.align &&
previous.behavior === next.behavior &&
previous.columnId === next.columnId &&
previous.columnIndex === next.columnIndex &&
previous.scrollElement === next.scrollElement
)
}
type DataGridTableVirtualScrollRequest = {
align: DataGridTableVirtualScrollAlignment
behavior: ScrollBehavior
@@ -267,6 +295,17 @@ interface DataGridTableVirtualProps<TData extends object> {
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
/** Index within the center (non-pinned) row section to reveal. */
scrollToRowIndex?: number
/**
* Opt-in horizontal virtualization of the CENTER columns. Activates only
* under a fixed table layout with a single ungrouped header row; pinned
* columns stay mounted, and anything else falls back to full-column
* rendering. `scrollBehavior` is shared with controlled row scrolling.
*/
columnVirtualizerOptions?: DataGridTableColumnVirtualizerOptions
/** Alignment used when revealing a controlled target column. */
scrollToColumnAlign?: DataGridTableVirtualScrollAlignment
/** Index within the center (non-pinned) visible leaf columns to reveal. */
scrollToColumnIndex?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
@@ -290,6 +329,7 @@ interface VirtualBodyProps<TData extends object> {
loadingMoreMessage: ReactNode
allRowsLoadedMessage: ReactNode
measureRowRef?: (element: HTMLTableRowElement | null) => void
centerColumnWindow?: { start: number; end: number }
}
function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
@@ -424,6 +464,24 @@ function DataGridTableVirtualStatusRow<TData extends object>({
)
}
/**
* A scroll frame only shifts the window, so every surviving row's inputs are
* identical and its last render is reused; the per-frame cost is the rows
* entering the window, not all mounted rows. Cell-level state (selection,
* focus, row checks) repaints through each cell's own Subscribe, and any real
* data change rebuilds the row wrappers, so identity comparison is safe.
*/
const MemoizedRenderedRow = memo(
DataGridTableRenderedRow,
(prev, next) =>
prev.row === next.row &&
prev.rowIndex === next.rowIndex &&
prev.rowRef === next.rowRef &&
prev.pinnedBoundary === next.pinnedBoundary &&
prev.centerWindow?.start === next.centerWindow?.start &&
prev.centerWindow?.end === next.centerWindow?.end
) as typeof DataGridTableRenderedRow
function DataGridTableVirtualBody<TData extends object>({
table,
topRows,
@@ -438,6 +496,7 @@ function DataGridTableVirtualBody<TData extends object>({
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
centerColumnWindow,
}: VirtualBodyProps<TData>) {
const { isLoading } = useDataGrid()
const totalRows = topRows.length + centerRows.length + bottomRows.length
@@ -479,9 +538,10 @@ function DataGridTableVirtualBody<TData extends object>({
topRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
<MemoizedRenderedRow
key={row.id}
row={row}
centerWindow={centerColumnWindow}
pinnedBoundary={
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
}
@@ -506,11 +566,12 @@ function DataGridTableVirtualBody<TData extends object>({
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
<MemoizedRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
rowIndex={virtualRow.index}
centerWindow={centerColumnWindow}
/>
)
})
@@ -527,7 +588,12 @@ function DataGridTableVirtualBody<TData extends object>({
} else {
centerRows.forEach((row, rowIndex) => {
renderedRows.push(
<DataGridTableRenderedRow key={row.id} row={row} rowIndex={rowIndex} />
<MemoizedRenderedRow
key={row.id}
row={row}
rowIndex={rowIndex}
centerWindow={centerColumnWindow}
/>
)
})
}
@@ -557,9 +623,10 @@ function DataGridTableVirtualBody<TData extends object>({
bottomRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
<MemoizedRenderedRow
key={row.id}
row={row}
centerWindow={centerColumnWindow}
pinnedBoundary={
index === 0 && (topRows.length > 0 || hasMiddleSection)
? "bottom"
@@ -576,10 +643,15 @@ function DataGridTableVirtualBody<TData extends object>({
* Memoized virtual body: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
* A cell-selection drag gets the same treatment: painting goes through each
* cell's own Subscribe, and the virtualizer re-renders itself from inside the
* memo boundary, so parent-driven reconciliation during the drag is waste.
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
(_prev, next) =>
!!next.table.state.columnResizing.isResizingColumn ||
next.table._isSelectingCells === true
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData extends object>({
@@ -589,6 +661,9 @@ function DataGridTableVirtual<TData extends object>({
scrollBehavior = "auto",
scrollToRowAlign = "auto",
scrollToRowIndex,
columnVirtualizerOptions,
scrollToColumnAlign = "auto",
scrollToColumnIndex,
footerContent,
renderHeader = true,
onFetchMore,
@@ -597,9 +672,22 @@ function DataGridTableVirtual<TData extends object>({
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid<TData>()
const { i18n, table, props } = useDataGrid<TData>()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
// Column windows only where the geometry is provable: a fixed table
// layout (undefined defaults to fixed; the colgroup then owns every
// width, so colSpan spacers cannot drift) and a single ungrouped header
// row (a colSpan group cannot be windowed). Anything else falls back to
// full-column rendering silently, the same posture as row virtualization
// toward unsupported shapes.
const columnVirtualizationActive =
columnVirtualizerOptions?.enabled === true &&
props.tableLayout?.width !== "auto" &&
mergedHeaderGroups.length === 1 &&
(mergedHeaderGroups[0]?.headers.every((header) => header.colSpan <= 1) ??
false)
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
@@ -622,9 +710,9 @@ function DataGridTableVirtual<TData extends object>({
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
props.fetchingMoreMessage || props.loadingMessage || i18n.labels.loading
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
props.allRowsLoadedMessage || i18n.labels.allRowsLoaded
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
@@ -679,6 +767,69 @@ function DataGridTableVirtual<TData extends object>({
...virtualizerOptionsRest,
}) as DataGridTableVirtualizerInstance
// Horizontal offsets invert in RTL; the virtualizer must be told.
const isRtl = useMemo(
() =>
viewportElements.containerElement
? getComputedStyle(viewportElements.containerElement).direction ===
"rtl"
: false,
[viewportElements.containerElement]
)
const resolveColumnKey = useCallback(
(index: number) => centerVisibleColumns[index]?.id ?? index,
[centerVisibleColumns]
)
const resolveColumnEstimateSize = useCallback(
(index: number) => centerVisibleColumns[index]?.getSize() ?? 0,
[centerVisibleColumns]
)
const columnVirtualizer = useVirtualizer({
horizontal: true,
isRtl,
count: columnVirtualizationActive ? centerVisibleColumns.length : 0,
getScrollElement: resolveScrollElement,
getItemKey: resolveColumnKey,
estimateSize: resolveColumnEstimateSize,
overscan: columnVirtualizerOptions?.overscan ?? 3,
})
// Column sizes are exact reads of getSize(), never DOM-measured, so a
// resize commit or a visibility/order/pinning change must resync the
// virtualizer's cached sizes by hand.
useEffect(() => {
if (columnVirtualizationActive) columnVirtualizer.measure()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
columnVirtualizationActive,
columnVirtualizer,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnSizing,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnVisibility,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnOrder,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnPinning,
])
const virtualColumns = columnVirtualizationActive
? columnVirtualizer.getVirtualItems()
: []
// The window is a contiguous inclusive [start, end] over the center
// columns; before the scroll element resolves the item list is empty and
// the grid renders full-width for that first frame.
const centerColumnWindow =
columnVirtualizationActive && virtualColumns.length > 0
? {
start: virtualColumns[0]!.index,
end: virtualColumns[virtualColumns.length - 1]!.index,
}
: undefined
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
@@ -705,6 +856,51 @@ function DataGridTableVirtual<TData extends object>({
// before the consumer flips isFetchingMore, and loops at end-of-data when
// hasMore is never set.
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
const lastColumnScrollRequestRef =
useRef<DataGridTableColumnScrollRequest | null>(null)
// Controlled column reveal: same dedupe posture as the row request - the
// signature keeps ordinary renders from re-scrolling, and exact column
// sizes mean no post-measure follow-up pass is needed.
useEffect(() => {
if (
!columnVirtualizationActive ||
scrollToColumnIndex === undefined ||
scrollToColumnIndex < 0 ||
scrollToColumnIndex >= centerVisibleColumns.length ||
!viewportElements.scrollElement
) {
return
}
const request: DataGridTableColumnScrollRequest = {
align: scrollToColumnAlign,
behavior: scrollBehavior,
columnId: centerVisibleColumns[scrollToColumnIndex]?.id,
columnIndex: scrollToColumnIndex,
scrollElement: viewportElements.scrollElement,
}
if (
isSameDataGridTableColumnScrollRequest(
lastColumnScrollRequestRef.current,
request
)
) {
return
}
lastColumnScrollRequestRef.current = request
columnVirtualizer.scrollToIndex(scrollToColumnIndex, {
align: scrollToColumnAlign,
behavior: scrollBehavior === "smooth" ? "smooth" : "auto",
})
}, [
columnVirtualizationActive,
columnVirtualizer,
centerVisibleColumns,
scrollToColumnAlign,
scrollToColumnIndex,
scrollBehavior,
viewportElements.scrollElement,
])
// Resolve after every commit so a stable getter can expose a replaced ref;
// the request signature prevents duplicate scrolling on ordinary renders.
@@ -847,30 +1043,79 @@ function DataGridTableVirtual<TData extends object>({
virtualItems,
])
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: {
height,
overflow: "auto",
position: "relative",
// Standalone mode: this node IS the scroll container, so it
// must stay at its parent's width (not the resizable table
// width) or horizontal scrolling becomes impossible.
width: "auto",
}
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
// The header re-renders only when its real inputs move: the table
// wrapper (any table state change recreates it), the layout props, and
// the column window. A scroll frame changes none of them, so the whole
// sortable-header subtree is reused instead of rebuilt per frame.
// eslint-disable-next-line react-hooks/exhaustive-deps
const headerNode = useMemo(
() =>
renderHeader && (
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{/* Under an active column window the single ungrouped header
row is bucketed start / windowed center / end, with each
off-window flank one colSpan spacer sized by the intact
colgroup - the same shape the body rows take. */}
{centerColumnWindow
? headerGroup.headers
.filter((header) => header.column.getIsPinned() === "start")
.map((header) => (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
header.column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
))
: null}
{centerColumnWindow && centerColumnWindow.start > 0 ? (
<th
aria-hidden="true"
data-slot="data-grid-table-virtual-col-spacer"
colSpan={centerColumnWindow.start}
className="p-0"
/>
) : null}
{centerColumnWindow
? headerGroup.headers
.filter((header) => !header.column.getIsPinned())
.slice(centerColumnWindow.start, centerColumnWindow.end + 1)
.map((header) => (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
header.column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
))
: null}
{centerColumnWindow &&
centerVisibleColumns.length - 1 - centerColumnWindow.end > 0 ? (
<th
aria-hidden="true"
data-slot="data-grid-table-virtual-col-spacer"
colSpan={
centerVisibleColumns.length - 1 - centerColumnWindow.end
}
className="p-0"
/>
) : null}
{!centerColumnWindow &&
headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "end")
.map((header) => {
const { column } = header
@@ -890,38 +1135,66 @@ function DataGridTableVirtual<TData extends object>({
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "end")
.map((header) => {
const { column } = header
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "end")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
renderHeader,
table,
props.tableLayout,
centerColumnWindow,
hasRightPinnedColumns,
]
)
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: {
height,
overflow: "auto",
position: "relative",
// Standalone mode: this node IS the scroll container, so it
// must stay at its parent's width (not the resizable table
// width) or horizontal scrolling becomes impossible.
width: "auto",
}
}
>
<DataGridTableBase>
{headerNode}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
@@ -943,7 +1216,12 @@ function DataGridTableVirtual<TData extends object>({
loadingMoreMessage={loadingMoreMessage}
allRowsLoadedMessage={allRowsLoadedMessage}
measureRowRef={measureRowRef}
centerColumnWindow={centerColumnWindow}
/>
{/* Same appended region as the standard body, so onRowCreate and
a consumer draft work in the virtual layout too. */}
{props.appendRow}
<DataGridTableAddRow />
</DataGridTableBody>
{footerContent && (
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,19 @@
import { createContext, useContext, useEffect, useMemo, useRef } from "react"
import {
createContext,
useContext,
useEffect,
useId,
useMemo,
useRef,
} from "react"
import type { ReactNode } from "react"
import {
mergeDataGridI18n,
type DataGridI18nConfig,
type DataGridI18nOverrides,
} from "@/components/reui/data-grid/data-grid-i18n"
import {
cellSelectionFeature,
columnFacetingFeature,
columnFilteringFeature,
columnOrderingFeature,
@@ -30,6 +43,7 @@ import {
tableFeatures,
} from "@tanstack/react-table"
import type {
Cell,
Column,
ColumnFiltersState,
ReactTable,
@@ -49,6 +63,39 @@ import { cn } from "@evobgp/ui/lib/utils"
* installing the data grid no longer widens `ColumnMeta` for every other
* table in the consuming app.
*/
/**
* Per-column write contract for the spreadsheet operations (paste, fill,
* clear, cut). Presence marks the column writable; every write still flows
* through `onCellsChange`, so the consumer's state stays the only data owner.
*/
export interface DataGridColumnCellEdit<TData> {
/**
* Set false for a column that formats clipboard output but is never
* written; a function decides per row, so locked rows (archived, another
* user's, a totals row) reject like any read-only cell. Defaults to true.
*/
editable?: boolean | ((row: TData) => boolean)
/**
* Clipboard or fill string to typed value. Return undefined to reject the
* cell into `rejected`. Without it the raw string passes through unchanged -
* a deliberate non-coercion, so a number column receives strings only when
* the consumer chose not to parse.
*/
parse?: (raw: string, row: TData) => unknown
/** Typed value to clipboard string. Fallback: String(value ?? ""). */
format?: (value: unknown, row: TData) => string
/** Value dispatched by Delete/Backspace and cut. Defaults to null. */
clearValue?: unknown
/**
* Opt into the grid's built-in free-text editor: an overlay input (or
* auto-growing textarea) flush over the focused cell, opened by Enter, F2,
* typing, or double-click, committing through `onCellsChange` as source
* `"edit"` with `parse` applied. Columns without it stay consumer-edited
* via `onCellEditRequest` or their own cell renderers.
*/
control?: "text" | "textarea"
}
export interface DataGridColumnMeta<TData> {
headerTitle?: string
headerClassName?: string
@@ -56,6 +103,15 @@ export interface DataGridColumnMeta<TData> {
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
autoSize?: boolean
cellEdit?: DataGridColumnCellEdit<TData>
/**
* Under `columnsResizable`, this column absorbs the free space the filler
* strip would otherwise hold, so the grid always reads full-width and the
* built-in editor covers the whole cell. One column per grid; while free
* space remains, manual resizing of this column is visually a no-op (the
* absorbed space compensates), the flex-column trade-off.
*/
fillWidth?: boolean
}
/**
@@ -105,6 +161,10 @@ export const dataGridFeatures = tableFeatures({
rowSelectionFeature,
rowExpandingFeature,
rowPinningFeature,
// Registration alone is inert: it seeds a `cellSelection: []` slice and
// prototype methods but binds no DOM handlers, so grids without
// `tableLayout.cellSelection` render and behave exactly as before.
cellSelectionFeature,
sortedRowModel: createSortedRowModel(),
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
@@ -156,6 +216,131 @@ export function getColumnHeaderLabel<TData extends RowData, TValue>(
return String(column.id)
}
/**
* The td contract for spreadsheet selection. Call inside a
* `Subscribe source={table.atoms.cellSelection}` render only: the reads are
* builder calls whose state dependency React Compiler cannot see, exactly the
* trap the row-selection checkbox documents.
*/
export function getDataGridCellSelectionCellAttrs<TData extends object>(
cell: Cell<DataGridFeatures, TData, unknown>
): {
"aria-selected": boolean
"data-col-id": string
"data-cell-selected": true | undefined
"data-cell-focused": true | undefined
"data-cell-edge-top": true | undefined
"data-cell-edge-right": true | undefined
"data-cell-edge-bottom": true | undefined
"data-cell-edge-left": true | undefined
} {
const selected = cell.getIsSelected()
const edges = cell.getSelectionEdges()
return {
"aria-selected": selected,
"data-col-id": cell.column.id,
"data-cell-selected": selected || undefined,
"data-cell-focused": cell.getIsFocused() || undefined,
"data-cell-edge-top": edges.top || undefined,
"data-cell-edge-right": edges.right || undefined,
"data-cell-edge-bottom": edges.bottom || undefined,
"data-cell-edge-left": edges.left || undefined,
}
}
/**
* Selection chrome for a body td, activated by the data attributes above plus
* the imperatively toggled `data-cell-fill-target` (fill-drag preview, written
* outside React like the resize indicator). A ::before overlay, not outline
* or box-shadow: an outline ring hugs the cell box and cannot line up with
* the gridlines the perimeter paints on, and the pinned-column dividers
* already own the cell's shadow slot (tailwind-merge would collapse a second
* shadow-[...] into it).
*/
export const dataGridCellSelectionCellClasses = cn(
"relative select-none",
// A light tint: the selection must read as a range without drowning the
// gridlines under a heavy fill. The focused cell stays unfilled. Pinned
// cells must stay OPAQUE (they hide scrolled content), so they get the
// tint pre-mixed over the background, and the focused pinned cell keeps
// a solid background instead of turning transparent.
"data-[cell-selected]:bg-primary/4",
"data-[cell-selected]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]",
"data-[cell-focused]:bg-transparent!",
"data-[cell-focused]:data-pinned:bg-background!",
// Every selection line is one layout-free ::before overlay per cell. It
// reaches 1px BEYOND the cell so each line paints exactly ON the shared
// gridline it replaces: the range perimeter and the interior dividers
// share this geometry, so where they meet they coincide instead of
// stacking into a wider edge, every side stays exactly 1px, and
// selecting never adds real borders that shift layout. At the table's
// own boundary there is no shared gridline and the overshoot leaves the
// scrollable content box, where it is CLIPPED - the perimeter simply
// vanished on the first column, last column and last row. So every
// overshoot that can hit a boundary goes through a variable the
// boundary cells zero out. Longhand inset utilities only, logical
// start/end
// so RTL mirrors for free; a separate override rule cannot do this,
// equal-specificity variants leave shorthand-vs-longhand to sort order.
"data-[cell-selected]:before:pointer-events-none data-[cell-selected]:before:absolute data-[cell-selected]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-selected]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-selected]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-selected]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-selected]:before:border-primary data-[cell-selected]:before:content-['']",
"data-[cell-focused]:before:pointer-events-none data-[cell-focused]:before:absolute data-[cell-focused]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-focused]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-focused]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-focused]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-focused]:before:border-primary data-[cell-focused]:before:content-['']",
// Interior dividers: selected cells repaint their own end and bottom
// gridlines on the overlay, so gridline-less grids still divide a range.
// The gray color is guarded by not- variants (higher specificity), so a
// side that belongs to the range perimeter deterministically stays
// primary.
"data-[cell-selected]:before:border-e data-[cell-selected]:before:border-b",
"data-[cell-selected]:not-data-[cell-edge-right]:before:border-e-border",
"data-[cell-selected]:not-data-[cell-edge-bottom]:before:border-b-border",
// The Sheets model: a single selected cell IS its own range, so the four
// edge attributes below already draw its full primary box; inside a
// larger range the anchor reads by its unfilled background alone. The
// explicit ring only covers a focused cell with no selection at all.
// Edge sides are logical (border-e/border-s): the feature derives edges
// from display-order column indexes, which mirror in RTL.
"data-[cell-focused]:not-data-[cell-selected]:before:border",
"data-[cell-edge-top]:before:border-t data-[cell-edge-right]:before:border-e data-[cell-edge-bottom]:before:border-b data-[cell-edge-left]:before:border-s",
// While a fill drag is live the preview border is the ONE painter for
// the whole pending region, so the source cells' own chrome rests -
// otherwise its lines double against the region border at every shared
// edge. display is the no-cascade-fight switch (see below).
"in-data-[cell-filling]:data-[cell-selected]:before:hidden",
"in-data-[cell-filling]:data-[cell-focused]:before:hidden",
// While the built-in editor is open its outline is the one border; the
// edited cell's own overlay chrome hides so nothing peeks around the
// flush editor. display is the switch because no other before: rule
// sets it, so there is no cascade order to lose.
"in-data-[cell-editing]:data-[cell-focused]:before:hidden",
// Clamped to the cell edge at the grid's own boundary, the overlay's
// lines sit flush against the container border instead of being clipped
// away outside the scrollable content. A custom property has no cascade
// fight to lose. First/last-child cover the boundary columns: when a
// filler td follows the last data column, that cell's overshoot lands
// INSIDE the table and needs no clamp, and the td:first/last-child of a
// row are its logical start/end in RTL too.
"in-[tr:last-child]:[--data-grid-overlay-bottom:0px]",
// The top overshoot lands under a sticky header, which is positioned and
// paints over it - so the first row's perimeter lost its top line. Clamp
// inside the cell there, exactly like the other three boundaries.
"in-[tr:first-child]:[--data-grid-overlay-top:0px]",
"first:[--data-grid-overlay-start:0px]",
"last:[--data-grid-overlay-end:0px]",
// A pinned column is a boundary like the grid's own edges: the sticky
// neighbor would cover an on-gridline overlay line, and re-painting it
// from the pinned side doubles into a 2px blur whenever subpixel
// positions disagree (browser zoom, fractional fill widths). One
// painter instead: the overlay clamps inside its own cell there, the
// same custom-property clamp the first and last cells use.
"[&:has(+td[data-pinned])]:[--data-grid-overlay-end:0px]",
"[&:has(+[data-slot=data-grid-table-fill-body-cell]:last-child)]:[--data-grid-overlay-end:0px]",
"[&:has(+[data-slot=data-grid-table-fill-body-cell]+td[data-pinned])]:[--data-grid-overlay-end:0px]",
"[td[data-pinned]+&]:[--data-grid-overlay-start:0px]",
// Tint only: the dashed region outline is one overlay element drawn by
// the fill session, so neighboring target cells never double their edges.
"data-[cell-fill-target]:bg-primary/4",
"data-[cell-fill-target]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]"
)
export type DataGridApiFetchParams = {
pageIndex: number
pageSize: number
@@ -188,6 +373,10 @@ export interface DataGridContextProps<TData extends object> {
table: DataGridTableInstance<TData>
recordCount: number
isLoading: boolean
/** Stable per-grid prefix for the DOM ids ARIA relations point at. */
gridId: string
/** The merged label set; components read `i18n.labels.<key>`. */
i18n: DataGridI18nConfig
/**
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
* so every table variant and viewport instance shares one application state.
@@ -195,13 +384,66 @@ export interface DataGridContextProps<TData extends object> {
autoSize?: DataGridAutoSizeController
}
/**
* Row and column ids are consumer strings; DOM ids cannot carry spaces or
* quotes. One shared sanitizer so `aria-activedescendant` writers and the id
* renderers always agree on the same encoding.
*/
export function toDataGridDomId(value: string): string {
return value.replace(/[^\w-]/g, "_")
}
export type DataGridAutoSizeController = {
/**
* Grows the first visible `meta.autoSize` column by the given free space.
* Applies at most once per column id; safe to call from every viewport
* measurement. Returns true when a sizing update was dispatched.
* Feeds the controller one viewport measurement: the SIGNED free space
* between the scroll container and the table, negative while the table
* overflows. The first sight of a `meta.autoSize` column absorbs the
* free space into its width; after that, every measurement REFLOWS the
* same growth - a wider window widens the column, a narrower one gives
* the space back down to the column's `minSize` (or its starting width
* when none is set) - so the grid tracks its container live. Commits
* are coalesced for performance: sub-pixel deltas are dropped, a
* streaming resize gets one leading commit plus one trailing commit at
* settle, and a width the user dragged is never touched. Safe to call
* from every viewport measurement; returns true when a sizing update
* was dispatched.
*/
apply: (fillWidth: number) => boolean
apply: (freeSpace: number) => boolean
}
type DataGridAutoSizeReflowState = {
applied: { columnId: string; base: number; grown: number } | null
settleTimer: ReturnType<typeof setTimeout> | null
pendingFreeSpace: number
lastCommitAt: number
}
/**
* Bookkeeping keyed on the TABLE STORE, never on a React instance: dev
* StrictMode double-mounts the tree, so two controller instances serve one
* page and only the first would remember what it absorbed - the second
* reads the grown width as a user drag and freezes the column. The store
* object is the one identity that provably survives that dance (the
* absorbed sizing rides inside it), and the WeakMap lets a discarded
* table take its bookkeeping with it.
*/
const dataGridAutoSizeStates = new WeakMap<
object,
DataGridAutoSizeReflowState
>()
function getDataGridAutoSizeState(store: object): DataGridAutoSizeReflowState {
let state = dataGridAutoSizeStates.get(store)
if (!state) {
state = {
applied: null,
settleTimer: null,
pendingFreeSpace: 0,
lastCommitAt: 0,
}
dataGridAutoSizeStates.set(store, state)
}
return state
}
function createDataGridAutoSizeController<TData extends object>(
@@ -217,29 +459,95 @@ function createDataGridAutoSizeController<TData extends object>(
*/
getTable: () => DataGridTableInstance<TData>
): DataGridAutoSizeController {
let applied: { columnId: string; base: number; grown: number } | null = null
// Reflow coalescing: a live window drag streams a measurement per frame,
// and every accepted commit re-renders the table. One leading commit
// keeps single snaps (a maximize) instant; the rest of the stream parks
// its latest value behind one trailing timer, so a continuous drag costs
// two commits instead of sixty a second. The trailing timer may fire
// after the grid unmounts; it re-reads the table then, and a sizing
// write to the consumer's store is inert once nothing renders it.
const SETTLE_MS = 150
const reflow = (freeSpace: number): boolean => {
const table = getTable()
const state = getDataGridAutoSizeState(table.store)
// LIVE state, never the render snapshot: a measurement can run between
// our own sizing commit and React's re-render, and the stale snapshot
// would hide the width this controller just wrote.
const columnSizing =
table.atoms.columnSizing?.get() ?? table.state.columnSizing
if (!state.applied) return false
const autoSizeColumn = table
.getVisibleLeafColumns()
.find(
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
)
if (!autoSizeColumn || autoSizeColumn.id !== state.applied.columnId)
return false
// A width this coordinator did not write belongs to the user's drag;
// reflow stands down until a reset re-arms the column.
const currentSize = columnSizing[state.applied.columnId]
if (currentSize !== undefined && currentSize !== state.applied.grown) {
return false
}
// The free space as it would measure with our growth removed; the
// target re-absorbs exactly that, floored at minSize so a narrow
// window hands space back without ever crushing the column (no
// explicit minSize floors at the starting width instead).
const freeAtBase = freeSpace + (state.applied.grown - state.applied.base)
const floor = autoSizeColumn.columnDef.minSize ?? state.applied.base
const target = Math.max(floor, state.applied.base + freeAtBase)
if (Math.abs(target - state.applied.grown) < 1) return false
state.applied = { ...state.applied, grown: target }
table.setColumnSizing((old) => ({
...old,
[state.applied!.columnId]: target,
}))
return true
}
return {
apply(fillWidth: number) {
apply(freeSpace: number) {
const table = getTable()
const columnSizing = table.state.columnSizing
const state = getDataGridAutoSizeState(table.store)
// Same live read as `reflow`; the snapshot race is what made the
// re-arm check clear the bookkeeping right after the first absorb.
const columnSizing =
table.atoms.columnSizing?.get() ?? table.state.columnSizing
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
// controlled state replacement) so the column re-fills instead of
// leaving a dead blank strip.
if (applied && columnSizing[applied.columnId] === undefined) {
applied = null
if (state.applied && columnSizing[state.applied.columnId] === undefined) {
state.applied = null
}
if (fillWidth <= 0) return false
const autoSizeColumn = table
.getVisibleLeafColumns()
.find(
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
)
if (!autoSizeColumn || applied?.columnId === autoSizeColumn.id) {
if (autoSizeColumn && state.applied?.columnId === autoSizeColumn.id) {
state.pendingFreeSpace = freeSpace
const now = Date.now()
if (now - state.lastCommitAt > SETTLE_MS) {
state.lastCommitAt = now
return reflow(freeSpace)
}
if (state.settleTimer !== null) clearTimeout(state.settleTimer)
state.settleTimer = setTimeout(() => {
state.settleTimer = null
state.lastCommitAt = Date.now()
reflow(state.pendingFreeSpace)
}, SETTLE_MS)
return false
}
const fillWidth = Math.max(0, freeSpace)
if (fillWidth <= 0) return false
if (!autoSizeColumn) {
return false
}
@@ -249,13 +557,13 @@ function createDataGridAutoSizeController<TData extends object>(
// un-resizable: the drag committed, the next viewport measurement
// stamped the fill back on top, and the column snapped to its old width.
//
// Deliberately keyed on observed state rather than on `applied`, which
// Deliberately keyed on observed state rather than on `state.applied`, which
// is per-coordinator memory: anything that rebuilds the coordinator
// (a remount, a new table store) forgets what it did, and the guard has
// to survive that. An explicit reset clears the entry and re-arms the
// fill, which is what makes double-click-to-reset still work.
const currentSize = columnSizing[autoSizeColumn.id]
if (currentSize !== undefined && currentSize !== applied?.grown) {
if (currentSize !== undefined && currentSize !== state.applied?.grown) {
return false
}
@@ -264,13 +572,14 @@ function createDataGridAutoSizeController<TData extends object>(
// user hasn't manually resized that column since, so visibility
// toggles cannot ratchet the table wider than its container forever.
const revert =
applied && columnSizing[applied.columnId] === applied.grown
? applied
state.applied &&
columnSizing[state.applied.columnId] === state.applied.grown
? state.applied
: null
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
const grown = base + fillWidth
applied = { columnId: autoSizeColumn.id, base, grown }
state.applied = { columnId: autoSizeColumn.id, base, grown }
table.setColumnSizing((old) => {
const next = { ...old, [autoSizeColumn.id]: grown }
if (revert && next[revert.columnId] === revert.grown) {
@@ -291,6 +600,104 @@ export type DataGridRequestParams = {
columnFilters?: ColumnFiltersState
}
/** Which spreadsheet operation produced a cell change batch. */
export type DataGridCellsChangeSource =
| "paste"
| "cut"
| "clear"
| "fill"
| "edit"
export interface DataGridCellChange<TData> {
rowId: string
columnId: string
/** The consumer's row object, so immutable write-back needs no second lookup. */
row: TData
/** Accessor value at dispatch time; enables consumer-side undo stacks. */
previousValue: unknown
/** Typed value produced by `meta.cellEdit.parse`, or `clearValue` for clear/cut. */
value: unknown
}
/** What onCellsCopy receives after the grid writes the clipboard. */
export interface DataGridCopyDetails {
/** The TSV text that reached the clipboard. */
text: string
/** The same content as a row-major grid of formatted fields. */
grid: string[][]
/** True when the write came from a cut, which clears the region next. */
cut: boolean
}
/** One resolved positive region, in pre-paginated display indexes. */
export interface DataGridCellSelectionBound {
minRowIndex: number
maxRowIndex: number
minColumnIndex: number
maxColumnIndex: number
}
/** What onCellSelectionChange receives, resolved from the live selection. */
export interface DataGridCellSelectionSnapshot {
/** The virtually focused cell, the active range's anchor. Null when clear. */
focused: { rowId: string; columnId: string } | null
/** Every positive region, geometric order. */
bounds: DataGridCellSelectionBound[]
/** The most recent region, the one copy and paste act on. */
activeBound: DataGridCellSelectionBound | null
/**
* Cells of the selection the view can show - the count that matches the
* painted range and what a batch will touch.
*/
visibleCellCount: number
}
export interface DataGridCellRejection {
rowId: string
columnId: string
raw: string
reason: "readonly" | "invalid"
}
/**
* Lifecycle of a row for the optional CRUD indications: "new" and "dirty"
* tint the row, "deleted" mutes and strikes it. Purely presentational - the
* consumer's state stays the only owner of what the statuses mean.
*/
export type DataGridRowStatus = "new" | "dirty" | "deleted"
/**
* Lifecycle of a cell for the optional CRUD indications: a corner mark in
* the amber tone for "dirty", the destructive tone for "invalid".
*/
export type DataGridCellStatus = "dirty" | "invalid"
/**
* Asks the consumer to open its editor for the focused cell: Enter or F2, or
* a typed printable character (then `initialText` carries it, the
* Notion/Airtable type-to-replace flow). Dispatched only for cells whose
* column is writable via `meta.cellEdit`.
*/
export interface DataGridCellEditRequest<TData> {
rowId: string
columnId: string
row: TData
previousValue: unknown
/** The typed character that started the edit; absent for Enter and F2. */
initialText?: string
}
/**
* One batch per operation, however many cells it spans: a 10k-cell paste is
* one callback, one consumer setState, one table rebuild. The grid never
* writes data itself.
*/
export interface DataGridCellsChangeDetails<TData> {
source: DataGridCellsChangeSource
changes: DataGridCellChange<TData>[]
rejected: DataGridCellRejection[]
}
export interface DataGridProps<
TFeatures extends TableFeatures,
TData extends object,
@@ -300,7 +707,62 @@ export interface DataGridProps<
recordCount: number
children?: ReactNode
onRowClick?: (row: TData) => void
/**
* Receives every spreadsheet write batch (paste, cut, clear, fill, edit).
* Served through the props getter like `onRowClick`, so an inline identity
* never republishes the context. Absent: copy still works, writes no-op.
*/
onCellsChange?: (details: DataGridCellsChangeDetails<TData>) => void
/**
* Opens the consumer's editor from the keyboard: Enter, F2, or typing on
* the focused cell. Absent: Enter keeps moving down, typing does nothing.
*/
onCellEditRequest?: (request: DataGridCellEditRequest<TData>) => void
/**
* Fires on every cell-selection change (focus moves, ranges grow or
* clear) with a resolved snapshot, so a selection count, formula bar or
* context panel needs no reach into TanStack internals.
*/
onCellSelectionChange?: (snapshot: DataGridCellSelectionSnapshot) => void
/**
* Fires after the grid writes the clipboard (Cmd/Ctrl+C or X, or the
* native copy and cut events), e.g. to confirm with a toast.
*/
onCellsCopy?: (details: DataGridCopyDetails) => void
/**
* Renders an "Add row" affordance as the table body's last row; clicking
* it is the consumer's cue to start creating a row. Absent: no row.
*/
onRowCreate?: () => void
/** Label of the onRowCreate affordance. Defaults to "Add row". */
rowCreateLabel?: ReactNode
/**
* Rendered inside the table body after the data rows: the slot a DRAFT
* row lives in while it is being created, before it joins `data` and the
* pagination math. Supply a `<tr>` of cells; the colgroup sizes them.
*/
appendRow?: ReactNode
/**
* Optional CRUD indication per row. Return undefined for no indication;
* omit the prop entirely to render none anywhere.
*/
getRowStatus?: (row: TData) => DataGridRowStatus | undefined
/**
* Optional CRUD indication per cell: the classic corner mark on cells the
* consumer tracks as edited or invalid.
*/
getCellStatus?: (
row: TData,
columnId: string
) => DataGridCellStatus | undefined
isLoading?: boolean
/**
* Overrides for every built-in string: menu items, aria labels,
* pagination copy, state messages. Merged over the defaults; a more
* specific component prop (`rowCreateLabel`, `loadingMessage`, the
* pagination label props) still wins over its `i18n` counterpart.
*/
i18n?: DataGridI18nOverrides
loadingMode?: "skeleton" | "spinner"
loadingMessage?: ReactNode | string
fetchingMoreMessage?: ReactNode | string
@@ -325,6 +787,36 @@ export interface DataGridProps<
columnsDraggable?: boolean
rowsDraggable?: boolean
rowsPinnable?: boolean
/** Spreadsheet cell range selection: mouse ranges, keyboard, clipboard chrome. */
cellSelection?: boolean
/**
* "range" (default): drag, Shift and Ctrl/Cmd gestures and Shift+keys
* grow multi-cell regions. "single": one focused cell only - every
* gesture collapses to it and Ctrl/Cmd+A is inert.
*/
cellSelectionMode?: "single" | "range"
/** Drag-to-fill handle on the selection corner. Requires cellSelection. */
cellFillHandle?: boolean
/**
* How a cell opens its editor from the mouse: "dblclick" (default,
* the spreadsheet standard - first click focuses, second edits) or
* "click", opening the editor as soon as a plain click lands on the
* focused cell; modifier clicks and drags still select.
*/
cellEditMode?: "dblclick" | "click"
/**
* When true, committing an edit with Enter moves focus down (Shift+Enter
* up), the Sheets and Excel convention. Off by default: the commit keeps
* focus on the edited cell. Tab always commits and moves across.
*/
cellEditEnterAdvance?: boolean
/**
* The handle's look: "dot" is a solid primary dot kept inside the
* cell corner; "ring" is the Sheets-style hollow circle riding the
* corner point, clamped at the grid's own boundary; "square" is the
* Excel-style solid square inside the corner. Default "dot".
*/
cellFillHandleVariant?: "dot" | "ring" | "square"
}
tableClassNames?: {
base?: string
@@ -335,6 +827,20 @@ export interface DataGridProps<
bodyRow?: string
footer?: string
edgeCell?: string
/** Override the getRowStatus tints; defaults are the warning-muted set. */
rowNew?: string
rowDirty?: string
rowDeleted?: string
/**
* Override the pinned-row chrome (muted tint, boundary shadow). A grid
* that pins a draft row for quick-create wants it to read as an active
* row, not a muted one.
*/
rowPinned?: string
/** The onRowCreate affordance row, e.g. to match the grid's row height. */
rowCreate?: string
/** The fill handle, e.g. to restyle or resize it beyond the built-in variants. */
cellFillHandle?: string
}
}
@@ -372,11 +878,16 @@ function DataGridProvider<TData extends object>({
}) {
// Latest-props ref: context reads always resolve fresh props through the
// getter below without the memoized context value depending on unstable
// Stable for the grid's lifetime; ARIA ids and relations hang off it.
const gridId = useId()
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
// otherwise publish a new context value on every consumer render - at
// mousemove rate during a resize drag, piercing the body-rows memo).
const propsRef = useRef(props)
propsRef.current = props
const i18n = mergeDataGridI18n(props.i18n)
const i18nRef = useRef(i18n)
i18nRef.current = i18n
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on
// every state change. Depending on it directly would republish the context
@@ -401,6 +912,32 @@ function DataGridProvider<TData extends object>({
table.setOptions((old) => ({ ...old, columnResizeMode: resizeMode }))
}, [table, resizeMode])
// With the selection UI on, every paste/fill/clear batch replaces `data`,
// and the feature's default autoResetCellSelection would wipe the selection
// after each write. Ranges are row-id based and unresolvable ids drop out
// of the bounds safely, so keeping state across data edits is the correct
// default here.
const cellSelectionOn = !!props.tableLayout?.cellSelection
useEffect(() => {
if (!cellSelectionOn || table.atoms.cellSelection == null) return
if (table.options.autoResetCellSelection === false) return
table.setOptions((old) => ({ ...old, autoResetCellSelection: false }))
}, [table, cellSelectionOn])
// Rendered row indexes re-base per page, so a range kept across a page flip
// re-lights the same positions over different rows. A page change clears
// the selection instead - the rule Excel-family grids apply to paginated
// range selection.
const pageKey = `${table.state.pagination?.pageIndex}:${table.state.pagination?.pageSize}`
const previousPageKeyRef = useRef(pageKey)
useEffect(() => {
if (previousPageKeyRef.current === pageKey) return
previousPageKeyRef.current = pageKey
if (!cellSelectionOn || table.atoms.cellSelection == null) return
tableRef.current.resetCellSelection(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageKey, cellSelectionOn])
// One autoSize coordinator per table instance so split header/body viewports
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps
// stable for the life of the table, rather than on `table` itself: the
@@ -419,6 +956,9 @@ function DataGridProvider<TData extends object>({
// ReactNode/function props (messages, onRowClick) are also excluded: they
// are served fresh through the props getter, so unstable inline identities
// cannot invalidate the context value.
// `cellSelection` state is excluded on purpose too: a drag writes it once
// per cell crossed, and nothing reads it through context - cells and the
// selection bar subscribe to `table.atoms.cellSelection` directly.
const value = useMemo(
() => ({
get props() {
@@ -427,8 +967,12 @@ function DataGridProvider<TData extends object>({
get table() {
return tableRef.current
},
get i18n() {
return i18nRef.current
},
recordCount: props.recordCount,
isLoading: props.isLoading || false,
gridId,
autoSize,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -495,6 +1039,8 @@ function DataGrid<TFeatures extends TableFeatures, TData extends object>({
columnsDraggable: false,
rowsDraggable: false,
rowsPinnable: false,
cellSelection: false,
cellFillHandle: false,
},
tableClassNames: {
base: "",
@@ -555,7 +1101,9 @@ function DataGridContainer({
return (
<div
data-slot="data-grid"
className={cn("w-full overflow-hidden", className)}
// relative: anchors floating chrome composed inside the container,
// like the fill-drag preview outline.
className={cn("relative w-full overflow-hidden", className)}
>
{children}
</div>
@@ -1,237 +0,0 @@
"use client"
import { useMemo } from "react"
import {
EventCalendarViewContext,
useEventCalendar,
useEventCalendarSelector,
useEventCalendarSettings,
useEventCalendarViewConfig,
} from "@/components/reui/event-calendar/event-calendar"
import { EventCalendarEvent } from "@/components/reui/event-calendar/event-calendar-event"
import {
getDayKey,
getRangeKey,
toZoned,
zonedStartOfDay,
} from "@/components/reui/event-calendar/event-calendar-lib"
import type {
EventCalendarDateRange,
EventCalendarSegment,
} from "@/components/reui/event-calendar/event-calendar-types"
import { IconStack } from "@/components/reui/icon-stack"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { addDays, format } from "date-fns"
import { cn } from "@evobgp/ui/lib/utils"
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
import { CalendarIcon } from "lucide-react"
// The agenda window length is the agendaDayCount SETTING (the store derives
// visibleRange from it); a per-view prop here would silently disagree.
type EventCalendarAgendaViewProps = useRender.ComponentProps<"div">
function EventCalendarAgendaView({
className,
render,
...props
}: EventCalendarAgendaViewProps) {
const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()
const visibleRange = useEventCalendarSelector<
unknown,
EventCalendarDateRange
>((state) => state.visibleRange, {
isEqual: (a, b) => getRangeKey(a) === getRangeKey(b),
})
// Subscribe to event changes via the day-bucket content of the whole range
useEventCalendarSelector((state) => state.events)
const days = useMemo(() => {
const result: Date[] = []
let cursor = zonedStartOfDay(visibleRange.start, settings.timeZone)
while (cursor < visibleRange.end) {
result.push(cursor)
cursor = zonedStartOfDay(
addDays(toZoned(cursor, settings.timeZone), 1),
settings.timeZone
)
}
return result
}, [visibleRange, settings.timeZone])
const index = instance.internals.getIndex()
const groups = days
.map((day) => ({
day,
bucket: index.byDay.get(getDayKey(day, settings.timeZone)),
}))
.filter((group) => {
const total =
(group.bucket?.allDay.length ?? 0) + (group.bucket?.timed.length ?? 0)
return total > 0
})
const isToday = (day: Date) =>
getDayKey(day, settings.timeZone) ===
getDayKey(new Date(), settings.timeZone)
const native = viewConfig.scrollbars === "native"
const body = (
<>
{groups.length === 0 ? (
<div
data-slot="event-calendar-no-events"
className={cn(
"flex min-h-72 flex-col items-center justify-center gap-4 py-16",
viewConfig.classNames?.noEvents
)}
>
{viewConfig.renderNoEvents?.() ?? (
<>
<IconStack>
<CalendarIcon className="size-5" aria-hidden="true" />
</IconStack>
<span className="text-muted-foreground text-sm">
{settings.i18n.labels.noEvents}
</span>
</>
)}
</div>
) : (
// Drop the very last row's bottom border so it does not double up with
// the calendar container's own bottom border. Targets the last day
// group's last child (its last agenda item); per-item `border-b` is
// kept everywhere else, including each day's internal rows.
<div className="flex flex-col [&>*:last-child>*:last-child]:border-b-0">
{groups.map(({ day, bucket }) => {
const items = [...(bucket?.allDay ?? []), ...(bucket?.timed ?? [])]
const zoned = toZoned(day, settings.timeZone)
const weekday = format(zoned, "EEEE", { locale: settings.locale })
const dayDate = format(zoned, "MMMM d, yyyy", {
locale: settings.locale,
})
return (
<div
key={day.getTime()}
data-slot="event-calendar-agenda-day"
data-today={isToday(day) || undefined}
// A named group per day so a screen reader can step day by day
// (and hear how full one is) instead of arrowing every row.
role="group"
aria-label={`${weekday}, ${dayDate}, ${settings.i18n.labels.events(items.length)}`}
>
{/* Group header: weekday (leading) + full date (trailing) */}
<div
data-slot="event-calendar-agenda-day-header"
// The day bar is the agenda's only structure, so give it a
// heading level: the H key and the rotor can jump between
// days, which is the whole point of a long agenda.
role="heading"
aria-level={3}
className={cn(
"bg-muted/60 sticky top-0 z-10 flex items-baseline justify-between gap-4 border-b px-4 py-2",
// The custom ScrollArea's overlay scrollbar (w-2.5 = 10px)
// is painted UNDER this sticky, z-10, opaque header, so the
// thumb vanishes behind the day bar at the top of the view.
// Inset the header by the scrollbar lane so its background
// stops before the scrollbar instead of covering it. Native
// scrollbars already sit outside the content box, so this
// only applies to the custom-scrollbar path.
!native && "me-2.5",
viewConfig.classNames?.agendaDayHeader
)}
>
<span
className={cn(
"text-foreground font-semibold",
isToday(day) && "text-primary"
)}
>
{weekday}
</span>
<span className="text-muted-foreground font-medium tabular-nums">
{dayDate}
</span>
</div>
{items.map((segment) => (
<EventCalendarAgendaItem
key={segment.occurrence.key}
segment={segment}
/>
))}
</div>
)
})}
</div>
)}
</>
)
const defaultProps = {
"data-slot": "event-calendar-agenda-view",
"data-view": "agenda",
// Unlike the grid views the agenda has no row/column semantics to carry a
// name, so label the region with the day range it covers - through
// formatDayRange, so a consumer override reaches it.
role: "group",
"aria-label": settings.i18n.functions.formatDayRange(visibleRange, {
locale: settings.locale,
}),
className: cn(
"flex min-h-0 flex-1 flex-col overflow-hidden border-t",
viewConfig.classNames?.agendaView,
className
),
children: native ? (
<div
data-slot="scroll-area-viewport"
data-ec-native-scroll=""
className="h-full overflow-y-auto"
>
{body}
</div>
) : (
<ScrollArea className="h-full">{body}</ScrollArea>
),
}
return (
<EventCalendarViewContext.Provider value={{ view: "agenda" }}>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</EventCalendarViewContext.Provider>
)
}
/**
* One agenda row: a full-width, selectable table row - time column, color dot,
* and title (all replaceable via renderAgendaEvent). Clicking selects the
* event (drag/resize stay off in the agenda).
*/
function EventCalendarAgendaItem({
segment,
}: {
segment: EventCalendarSegment
}) {
const viewConfig = useEventCalendarViewConfig()
return (
<EventCalendarEvent
segment={segment}
className={cn(
// read-only list: hover only, no selected/focused styling on click
"hover:bg-accent/40 gap-3 rounded-none border-b px-4 py-2.5 transition-colors",
viewConfig.classNames?.agendaItem
)}
/>
)
}
export { EventCalendarAgendaView }
export type { EventCalendarAgendaViewProps }
@@ -1,80 +0,0 @@
import { type ComponentType, type ReactNode } from "react"
import {
useEventCalendarSelector,
useEventCalendarViewConfig,
} from "@/components/reui/event-calendar/event-calendar"
import { EventCalendarAgendaView } from "@/components/reui/event-calendar/event-calendar-agenda-view"
import { EventCalendarMonthView } from "@/components/reui/event-calendar/event-calendar-month-view"
import { EventCalendarResourceView } from "@/components/reui/event-calendar/event-calendar-resource-view"
import {
EventCalendarDaysView,
EventCalendarDayView,
EventCalendarWeekView,
} from "@/components/reui/event-calendar/event-calendar-time-grid"
import type { CalendarView } from "@/components/reui/event-calendar/event-calendar-types"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@evobgp/ui/lib/utils"
const DEFAULT_VIEW_COMPONENTS: Record<CalendarView, ComponentType> = {
month: EventCalendarMonthView,
week: EventCalendarWeekView,
day: EventCalendarDayView,
days: EventCalendarDaysView,
agenda: EventCalendarAgendaView,
resource: EventCalendarResourceView,
}
interface EventCalendarContentProps extends Omit<
useRender.ComponentProps<"div">,
"children"
> {
/** Swap individual view implementations. */
components?: Partial<Record<CalendarView, ComponentType>>
/** Replaces the switchboard entirely; read useEventCalendarView() inside. */
children?: ReactNode
}
function EventCalendarContent({
className,
render,
components,
children,
...props
}: EventCalendarContentProps) {
const viewConfig = useEventCalendarViewConfig()
const view = useEventCalendarSelector((state) => state.view)
const loading = useEventCalendarSelector((state) => state.loading)
const resolved = {
...DEFAULT_VIEW_COMPONENTS,
...viewConfig.components,
...components,
}
// A spread copies keys that hold `undefined`, so `components={{ month: isPro
// ? ProMonth : undefined }}` would erase the default and render <undefined />.
const ActiveView = resolved[view] ?? DEFAULT_VIEW_COMPONENTS[view]
const defaultProps = {
"data-slot": "event-calendar-content",
"data-view": view,
"data-loading": loading || undefined,
className: cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col",
"data-loading:pointer-events-none data-loading:opacity-60",
viewConfig.classNames?.content,
className
),
children: children ?? <ActiveView />,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
export { DEFAULT_VIEW_COMPONENTS, EventCalendarContent }
export type { EventCalendarContentProps }
File diff suppressed because it is too large. Load diff
@@ -1,555 +0,0 @@
import {
createContext,
useContext,
useMemo,
type CSSProperties,
type ReactNode,
} from "react"
import {
useEventCalendar,
useEventCalendarSelector,
useEventCalendarViewConfig,
useEventCalendarViewContext,
} from "@/components/reui/event-calendar/event-calendar"
import {
markChipPress,
useEventCalendarGestures,
wasRecentDrag,
} from "@/components/reui/event-calendar/event-calendar-dnd"
import {
spansMultipleDays,
toZoned,
zonedStartOfDay,
} from "@/components/reui/event-calendar/event-calendar-lib"
import type {
EventCalendarOccurrence,
EventCalendarSegment,
} from "@/components/reui/event-calendar/event-calendar-types"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { addDays, format } from "date-fns"
import { cn } from "@evobgp/ui/lib/utils"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@evobgp/ui/components/tooltip"
import { RepeatIcon } from "lucide-react"
/** Event color presets; each stays legible on light and dark surfaces. */
const EVENT_CALENDAR_COLORS: Array<{ name: string; value: string }> = [
{ name: "Blue", value: "var(--color-blue-500)" },
{ name: "Emerald", value: "var(--color-emerald-500)" },
{ name: "Violet", value: "var(--color-violet-500)" },
{ name: "Rose", value: "var(--color-rose-500)" },
{ name: "Amber", value: "var(--color-amber-500)" },
{ name: "Cyan", value: "var(--color-cyan-500)" },
{ name: "Orange", value: "var(--color-orange-500)" },
{ name: "Pink", value: "var(--color-pink-500)" },
{ name: "Teal", value: "var(--color-teal-500)" },
{ name: "Indigo", value: "var(--color-indigo-500)" },
]
/**
* Drag-ghost surfaces, shared verbatim by every view. A move CARRIES the
* event: the dnd engine attaches a full clone to the cursor
* (data-slot=event-calendar-drag-carry), so this in-grid ghost is only the
* dashed placeholder for the snapped drop slot. A resize STRETCHES instead:
* the chip itself at the proposed extent, dashed rather than solid.
*/
const EVENT_CALENDAR_GHOST = {
move: "rounded-sm border border-dashed border-(--ec-event-color)/50 bg-(--ec-event-color)/8",
resize:
"rounded-sm border border-dashed border-(--ec-event-color)/70 overflow-hidden",
invalid: "border-destructive/70 bg-destructive/10",
invalidResize: "border-destructive/70",
/** Applied to the clone inside an invalid resize ghost. */
invalidContent: "opacity-60",
} as const
/**
* Fade-out truncation for stacked timed blocks, where squeezed cascade
* columns clip titles into a mash of glyphs; a right-edge mask fade reads
* cleaner than an ellipsis at those widths. Masked ONLY below a 10rem
* container: mask-image forces text off subpixel antialiasing, so masking
* wide chips makes the whole grid read bolder and shimmer while resizing.
* Wide chips keep the plain ellipsis. Exported for consumer renderEvent.
*/
const EVENT_CALENDAR_FADE_TRUNCATE =
"w-full truncate @max-[10rem]:text-clip @max-[10rem]:[mask-image:linear-gradient(to_right,#000_calc(100%-0.75rem),transparent)] @max-[10rem]:rtl:[mask-image:linear-gradient(to_left,#000_calc(100%-0.75rem),transparent)]"
/**
* The drag-to-create selection, shared by every view: a dashed primary
* outline over a faint wash with the range printed inside. `box` is the timed
* grid's single minute-positioned rectangle; `segment` is one day-cell slice
* of a multi-cell draft, with side borders and rounding only on the run's two
* ends so it reads as one dashed box rather than a row of them.
*/
const EVENT_CALENDAR_SLOT_DRAFT = {
box: "rounded-sm border border-dashed border-primary/40 bg-primary/5",
segment: "border-y border-dashed border-primary/40",
segmentStart: "rounded-s-sm border-s",
segmentEnd: "rounded-e-sm border-e",
/**
* The wash for segmented views, on the CELL not the dashed overlay: the
* overlay stacks above the chips, so tinting it would wash them instead.
*/
surface: "bg-primary/5",
/**
* The range readout. `leading-none` is load-bearing: the shortest timed
* draft is one snap interval tall (16px at the default 15-minute snap and
* 4rem hour height), and looser leading renders ~18px, clipped by the
* draft's own overflow-hidden.
*/
label:
"text-primary truncate px-1 py-0.5 text-[0.6875rem] leading-none font-medium",
} as const
interface EventCalendarChipContextValue<TData = unknown> {
occurrence: EventCalendarOccurrence<TData>
segment: EventCalendarSegment<TData>
isDragging: boolean
isSelected: boolean
}
const EventCalendarChipContext =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createContext<EventCalendarChipContextValue<any> | null>(null)
/** The chip's subject; usable inside renderEvent content and chip children. */
function useEventCalendarEventChip<
TData = unknown,
>(): EventCalendarChipContextValue<TData> {
const ctx = useContext(EventCalendarChipContext)
if (!ctx) {
throw new Error(
"useEventCalendarEventChip must be used within <EventCalendarEvent>"
)
}
return ctx as EventCalendarChipContextValue<TData>
}
interface EventCalendarEventProps<TData = unknown> extends Omit<
useRender.ComponentProps<"button">,
"children"
> {
segment: EventCalendarSegment<TData>
/** Replaces the default chip CONTENT; the wrapper stays calendar-owned. */
children?: ReactNode
/**
* Static drag clone: the chip as-is but inert - no gestures, resize
* handles, selection/drag state, focus or pointer events.
*/
preview?: boolean
}
/**
* The one interactive event element in every view. The wrapper owns a11y,
* data attributes, selection and drag/resize wiring; content comes from
* children, else renderEvent, else the built-in default.
*/
function EventCalendarEvent<TData = unknown>({
segment,
className,
render,
children,
preview = false,
...props
}: EventCalendarEventProps<TData>) {
const instance = useEventCalendar<TData>()
const viewConfig = useEventCalendarViewConfig<TData>()
const { view } = useEventCalendarViewContext()
const gestures = useEventCalendarGestures<TData>()
const { settings } = instance
const occurrence = segment.occurrence
const event = occurrence.event
const isSelectedRaw = useEventCalendarSelector<TData, boolean>(
(state) => state.selection.eventKeys.includes(occurrence.key),
{ calendar: instance }
)
const isDraggingRaw = useEventCalendarSelector<TData, boolean>(
(state) => state.drag?.occurrence.key === occurrence.key,
{ calendar: instance }
)
// reactive, unlike gestures.canResize: api.setInteractions({ resize })
// must add/remove the handles without waiting for an unrelated re-render
const resizeOn = useEventCalendarSelector<TData, boolean>(
(state) => state.interactions.resize,
{ calendar: instance }
)
// A preview clone must never inherit the source's selected/dragging state
// (the drag key matches, which would dim the clone itself).
const isSelected = preview ? false : isSelectedRaw
const isDragging = preview ? false : isDraggingRaw
const isBar =
occurrence.allDay || spansMultipleDays(occurrence, settings.timeZone)
const inTimeGrid =
view === "week" || view === "day" || view === "days" || view === "resource"
const interactive = view !== "agenda" && !preview
const timedBlock = inTimeGrid && !isBar
const horizontalBar = isBar && !inTimeGrid
// >= compactEventMinutes renders the stacked (title over time) layout, where
// squeezed cascade columns fade-truncate instead of clipping into neighbors
const stackedBlock =
timedBlock &&
(segment.endMin ?? 0) - (segment.startMin ?? 0) >=
viewConfig.compactEventMinutes
const defaultContent = (
<>
{/* leading dot for single-row chips (month cells, all-day bars); a
time-grid block takes its color from the tinted surface instead, and
in its stacked layout a dot would sit alone on the first line */}
{!timedBlock && (
<span
aria-hidden
data-slot="event-calendar-event-dot"
// -me-0.5 tightens only the dot-to-title gap; the chip keeps gap-1.5
className="-me-0.5 size-1.5 shrink-0 rounded-full bg-(--ec-event-color)"
/>
)}
{occurrence.isRecurring && (
<RepeatIcon className="size-2.5 shrink-0 opacity-70" aria-hidden="true" />
)}
<span
className={cn(
"font-medium",
stackedBlock ? EVENT_CALENDAR_FADE_TRUNCATE : "truncate"
)}
>
{event.title}
</span>
{/* month cells are narrow: a compact never-shrinking start time keeps
the title readable; grids show the full range */}
{!occurrence.allDay &&
segment.isStart &&
(view === "month" ? (
<span className="text-muted-foreground shrink-0">
{format(
toZoned(occurrence.start, settings.timeZone),
settings.i18n.formats.eventTime,
{ locale: settings.locale }
)}
</span>
) : (
<span
className={cn(
"text-muted-foreground hidden @[8rem]:inline",
stackedBlock ? EVENT_CALENDAR_FADE_TRUNCATE : "truncate"
)}
>
{settings.i18n.functions.formatEventTime(
toZoned(occurrence.start, settings.timeZone),
toZoned(occurrence.end, settings.timeZone),
occurrence.allDay,
{ locale: settings.locale }
)}
</span>
))}
</>
)
// Per-day time text for a multi-day event: "From 9:00 AM", "All day",
// "Until 5:00 PM". Boundaries come from the occurrence vs segment.day, never
// the packing flags - lane merging rewrites those on shared segments.
const agendaTimeText = (() => {
if (view !== "agenda") return ""
if (occurrence.allDay) return settings.i18n.labels.allDay
const dayStart = zonedStartOfDay(segment.day, settings.timeZone)
const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1)
const startsBefore = occurrence.start < dayStart
const endsAfter = occurrence.end > dayEnd
if (startsBefore && endsAfter) return settings.i18n.labels.allDay
if (endsAfter) {
return settings.i18n.labels.timeFrom(
format(
toZoned(occurrence.start, settings.timeZone),
settings.i18n.formats.eventTime,
{ locale: settings.locale }
)
)
}
if (startsBefore) {
return settings.i18n.labels.timeUntil(
format(
toZoned(occurrence.end, settings.timeZone),
settings.i18n.formats.eventTime,
{ locale: settings.locale }
)
)
}
return settings.i18n.functions.formatEventTime(
toZoned(occurrence.start, settings.timeZone),
toZoned(occurrence.end, settings.timeZone),
false,
{ locale: settings.locale }
)
})()
// Agenda default row: time column, color-dot badge, plain title
const agendaDefaultContent = (
<>
<span className="text-muted-foreground w-40 shrink-0 truncate tabular-nums">
{agendaTimeText}
</span>
<span
aria-hidden
data-slot="event-calendar-agenda-dot"
className="size-2 shrink-0 rounded-full bg-(--ec-event-color)"
/>
<span className="truncate text-sm">{event.title}</span>
{occurrence.isRecurring && (
<RepeatIcon className="text-muted-foreground size-2.5 shrink-0" aria-hidden="true" />
)}
</>
)
// Memoized so a drag - which re-renders the lane on every pointer move -
// never re-invokes the consumer's renderEvent per frame: a referentially
// stable element lets React skip the custom subtree instead of flickering it.
// The render fns are deps, so an inline arrow from the consumer defeats it.
const customContent = useMemo(() => {
const renderProps = { occurrence, segment, view, isDragging, isSelected }
return view === "agenda"
? viewConfig.renderAgendaEvent?.(renderProps)
: viewConfig.renderEvent?.(renderProps)
}, [
occurrence,
segment,
view,
isDragging,
isSelected,
viewConfig.renderAgendaEvent,
viewConfig.renderEvent,
])
const content =
children ??
customContent ??
(view === "agenda" ? agendaDefaultContent : defaultContent)
const timeLabel = settings.i18n.functions.formatEventTime(
toZoned(occurrence.start, settings.timeZone),
toZoned(occurrence.end, settings.timeZone),
occurrence.allDay,
{ locale: settings.locale }
)
// native hover tooltip text; a formatter returning undefined drops the title
const label = settings.i18n.functions.formatEventLabel
? settings.i18n.functions.formatEventLabel(event.title, timeLabel)
: `${event.title}, ${timeLabel}`
// Optional styled tooltip (viewConfig.eventTooltip, default off). It replaces
// the native title so the two never stack, and a preview never gets one. A
// falsy renderEventTooltip result (including the false/"" of `cond && <node>`)
// falls back to the label; an empty label leaves no content and skips it.
const tooltipOpts =
typeof viewConfig.eventTooltip === "object" ? viewConfig.eventTooltip : null
const tooltipContent =
!preview && viewConfig.eventTooltip
? viewConfig.renderEventTooltip?.({
occurrence,
segment,
view,
label,
}) || label
: null
const tooltipOn = Boolean(tooltipContent)
const showResize =
interactive && resizeOn && !event.readOnly && event.resizable !== false
// Hover grip pill (mirrors the gantt bars) marking the resize direction.
// Shown on compact sub-compactEventMinutes blocks too: the 1.5rem chip
// min-height leaves edge room without colliding with the centered title.
const grip = (
<span
aria-hidden
data-slot="event-calendar-resize-grip"
className={cn(
"bg-foreground/40 rounded-full",
timedBlock ? "h-0.5 w-2.5" : "h-2.5 w-0.5",
viewConfig.classNames?.resizeGrip
)}
/>
)
const resizeHandles = showResize && (
<>
{timedBlock && segment.isStart && (
<span
data-slot="event-calendar-resize-handle"
data-edge="start"
className={cn(
"absolute inset-x-1 top-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
viewConfig.classNames?.resizeHandle
)}
onPointerDown={(e) => gestures.beginResize(e, segment, "start")}
>
{grip}
</span>
)}
{timedBlock && segment.isEnd && (
<span
data-slot="event-calendar-resize-handle"
data-edge="end"
className={cn(
"absolute inset-x-1 bottom-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
viewConfig.classNames?.resizeHandle
)}
onPointerDown={(e) => gestures.beginResize(e, segment, "end")}
>
{grip}
</span>
)}
{(horizontalBar || (isBar && inTimeGrid)) && segment.isStart && (
<span
data-slot="event-calendar-resize-handle"
data-edge="start"
className={cn(
"absolute inset-y-0 start-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
viewConfig.classNames?.resizeHandle
)}
onPointerDown={(e) => gestures.beginResize(e, segment, "start")}
>
{grip}
</span>
)}
{(horizontalBar || (isBar && inTimeGrid)) && segment.isEnd && (
<span
data-slot="event-calendar-resize-handle"
data-edge="end"
className={cn(
"absolute inset-y-0 end-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
viewConfig.classNames?.resizeHandle
)}
onPointerDown={(e) => gestures.beginResize(e, segment, "end")}
>
{grip}
</span>
)}
</>
)
const defaultProps = {
type: "button" as const,
"data-slot": "event-calendar-event",
"data-view": view,
"data-all-day": occurrence.allDay || undefined,
"data-recurring": occurrence.isRecurring || undefined,
"data-selected": isSelected || undefined,
"data-dragging": isDragging || undefined,
"data-preview": preview || undefined,
"data-past": occurrence.end.getTime() < Date.now() || undefined,
title: preview || tooltipOn ? undefined : label,
"aria-label":
settings.i18n.functions.formatEventAriaLabel?.(
event.title,
timeLabel,
segment.continuesBefore || segment.continuesAfter
) ??
`${event.title}, ${timeLabel}${
segment.continuesBefore || segment.continuesAfter
? `, ${settings.i18n.labels.continues}`
: ""
}`,
// A background tint alone conveys selection, so the chip is a real toggle
// wherever it is interactive (agenda rows never select, previews are inert).
"aria-pressed": interactive ? isSelected : undefined,
"aria-hidden": preview || undefined,
tabIndex: preview ? -1 : undefined,
style: {
"--ec-event-color": event.color ?? "var(--color-primary)",
} as CSSProperties,
onPointerDown: (e: React.PointerEvent) => {
e.stopPropagation()
// suppress the trailing slot-create click when this press yields no drag
markChipPress()
if (interactive) gestures.beginMove(e, segment)
},
onClick: (e: React.MouseEvent) => {
e.stopPropagation()
if (wasRecentDrag()) return
// consumer first: e.preventDefault() opts out of built-in selection
settings.onEventClick?.(occurrence, e)
// the agenda is a read-only list: a click never selects/focuses a row
if (e.defaultPrevented || view === "agenda") return
instance.api.selectEvent(occurrence.key)
},
onDoubleClick: (e: React.MouseEvent) => {
e.stopPropagation()
settings.onEventDoubleClick?.(occurrence, e)
},
className: cn(
"group/ec-event text-foreground relative flex w-full min-w-0 cursor-pointer touch-none items-center overflow-hidden text-start select-none",
"focus-visible:ring-ring/50 outline-none focus-visible:ring-2",
preview && "pointer-events-none",
view === "agenda"
? // plain list row: color lives in the dot badge, not a tinted pill;
// hover AND selection surfaces are owned by the agenda row wrapper
"gap-3 rounded-md text-sm"
: cn(
// @container removes intrinsic sizing; only grid chips are containers
// py-1: room above/below inline badges (attendee pill etc.)
"@container gap-1.5 rounded-sm px-1.5 py-1 leading-normal",
// soft tint + inset ring, not an accent border: legible on both themes
"bg-(--ec-event-color)/15 hover:bg-(--ec-event-color)/25",
// a flat tint reads darker on a dark surface, so lift it there
"dark:bg-(--ec-event-color)/20 dark:hover:bg-(--ec-event-color)/30",
"inset-ring inset-ring-(--ec-event-color)/15",
"transition-[background-color,box-shadow] duration-150",
"data-dragging:opacity-40",
"data-selected:bg-(--ec-event-color)/30 data-selected:inset-ring-(--ec-event-color)/40",
segment.continuesBefore && "rounded-s-none",
segment.continuesAfter && "rounded-e-none"
),
viewConfig.classNames?.event,
className
),
children: (
<>
{content}
{resizeHandles}
</>
),
}
const chip = useRender({
defaultTagName: "button",
render,
props: mergeProps<"button">(defaultProps, props),
})
return (
<EventCalendarChipContext.Provider
value={{ occurrence, segment, isDragging, isSelected }}
>
{tooltipOn ? (
<TooltipProvider delay={tooltipOpts?.delay ?? 600}>
<Tooltip>
<TooltipTrigger render={chip} />
<TooltipContent
side={tooltipOpts?.side ?? "top"}
className={viewConfig.classNames?.eventTooltip}
>
{tooltipContent}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
chip
)}
</EventCalendarChipContext.Provider>
)
}
export {
EVENT_CALENDAR_COLORS,
EVENT_CALENDAR_FADE_TRUNCATE,
EVENT_CALENDAR_GHOST,
EVENT_CALENDAR_SLOT_DRAFT,
EventCalendarEvent,
useEventCalendarEventChip,
}
export type { EventCalendarChipContextValue, EventCalendarEventProps }
@@ -1,291 +0,0 @@
import type {
CalendarView,
EventCalendarDateRange,
} from "@/components/reui/event-calendar/event-calendar-types"
import {
format,
isSameMonth,
isSameYear,
subMilliseconds,
type Locale,
} from "date-fns"
interface EventCalendarI18nConfig {
labels: {
today: string
previous: string
next: string
addEvent: string
allDay: string
more: (count: number) => string
noEvents: string
loading: string
event: string
events: (count: number) => string
selectView: string
week: (weekNumber: number) => string
resources: string
goToDate: string
/** Cursor hint while a drag/resize hovers a rejected position. */
dropNotAllowed: string
/** Aria-label suffix on chip segments that continue past the cell. */
continues: string
/** Agenda label for the first day of a multi-day event. */
timeFrom: (time: string) => string
/** Agenda label for the last day of a multi-day event. */
timeUntil: (time: string) => string
/** View-switcher shortcut hint characters, per view. */
viewShortcuts: Record<CalendarView, string>
/** Aria-label of the agenda day collapse/expand toggle. */
toggleDayEvents: (count: number, expanded: boolean) => string
/** Aria-label of the agenda event details toggle. */
eventDetails: (title: string) => string
/** Compact "+N" overflow (agenda summary dot stack). */
moreCompact: (count: number) => string
/** Joins a bounded from-to time span. */
timeRange: (from: string, to: string) => string
}
viewNames: {
month: string
week: string
day: string
days: (count: number) => string
agenda: string
resource: string
}
/** date-fns format strings, applied with the calendar `locale`. */
formats: {
monthTitle: string
/** Undefined = smart cross-month range via functions.formatTitle. */
weekTitle?: string
dayTitle: string
/** Undefined = smart range label via functions.formatTitle. */
agendaTitle?: string
monthDayHeader: string
/** Narrow variant used by the month view below the compact breakpoint. */
monthDayHeaderNarrow: string
timeGridDayHeader: string
agendaDayHeader: string
/** Agenda date-gutter day number. */
agendaDayNumber: string
/** Agenda date-gutter weekday label. */
agendaWeekday: string
/** "+N more" popover day header. */
moreDayHeader: string
/** Month cell aria-label date. */
monthCellAriaLabel: string
/** Time-grid day column aria-label date. */
dayAria: string
/** Undefined = the resource view title falls back to dayTitle. */
resourceTitle?: string
timeGutter: string
/** Sub-hour gutter labels (interval below 60 minutes). */
timeGutterMinute: string
eventTime: string
monthCellDay: string
}
functions: {
formatTitle: (
view: CalendarView,
ctx: {
date: Date
activeRange: EventCalendarDateRange
visibleRange: EventCalendarDateRange
locale?: Locale
}
) => string
formatEventTime: (
start: Date,
end: Date,
allDay: boolean,
/** date-fns options (the calendar `locale`); trailing so a 3-arg override still fits. */
opts?: { locale?: Locale }
) => string
formatDayRange: (
range: EventCalendarDateRange,
opts?: { locale?: Locale }
) => string
/** Chip native tooltip text; return undefined to drop the attribute. */
formatEventLabel?: (title: string, timeLabel: string) => string | undefined
/** Chip aria-label composition. */
formatEventAriaLabel?: (
title: string,
timeLabel: string,
continues: boolean
) => string
}
}
const DEFAULT_LABELS: EventCalendarI18nConfig["labels"] = {
today: "Today",
previous: "Previous",
next: "Next",
addEvent: "Add event",
allDay: "All day",
more: (count) => `+${count} more`,
noEvents: "No events",
loading: "Loading events",
event: "event",
events: (count) => (count === 1 ? "1 event" : `${count} events`),
selectView: "Select view",
week: (weekNumber) => `W${weekNumber}`,
resources: "Resources",
goToDate: "Go to date",
dropNotAllowed: "Can't place here",
continues: "continues",
timeFrom: (time) => `From ${time}`,
timeUntil: (time) => `Until ${time}`,
viewShortcuts: {
month: "M",
week: "W",
day: "D",
days: "5",
agenda: "A",
resource: "G",
},
toggleDayEvents: (count) => (count === 1 ? "1 event" : `${count} events`),
eventDetails: (title) => title,
moreCompact: (count) => `+${count}`,
timeRange: (from, to) => `${from} - ${to}`,
}
const DEFAULT_VIEW_NAMES: EventCalendarI18nConfig["viewNames"] = {
month: "Month",
week: "Week",
day: "Day",
days: (count) => (count === 1 ? "1 day" : `${count} days`),
agenda: "Agenda",
resource: "Time Grid",
}
const DEFAULT_FORMATS: EventCalendarI18nConfig["formats"] = {
monthTitle: "MMMM yyyy",
weekTitle: undefined,
dayTitle: "EEEE, MMMM d, yyyy",
agendaTitle: undefined,
monthDayHeader: "EEE",
monthDayHeaderNarrow: "EEEEE",
timeGridDayHeader: "EEE d",
agendaDayHeader: "EEEE, MMMM d",
agendaDayNumber: "d",
agendaWeekday: "EEE",
moreDayHeader: "EEEE, MMMM d",
monthCellAriaLabel: "PPPP",
dayAria: "PPPP",
resourceTitle: undefined,
timeGutter: "h a",
timeGutterMinute: "h:mm a",
eventTime: "h:mm a",
monthCellDay: "d",
}
/**
* Default formatting functions BOUND to a config's labels/formats, so that
* `formats` overrides flow into the default renderers (a consumer overriding
* formats.monthTitle without replacing formatTitle still sees it applied).
*/
function makeDefaultFunctions(
cfg: Pick<EventCalendarI18nConfig, "labels" | "formats">
): EventCalendarI18nConfig["functions"] {
return {
formatTitle: (view, { date, activeRange, locale }) => {
const opts = { locale }
if (view === "month") {
return format(date, cfg.formats.monthTitle, opts)
}
if (view === "resource") {
return format(
date,
cfg.formats.resourceTitle ?? cfg.formats.dayTitle,
opts
)
}
if (view === "day") {
return format(date, cfg.formats.dayTitle, opts)
}
if (view === "week" && cfg.formats.weekTitle) {
return format(date, cfg.formats.weekTitle, opts)
}
if (view === "agenda" && cfg.formats.agendaTitle) {
return format(date, cfg.formats.agendaTitle, opts)
}
// week / days / agenda: smart range label, last day is activeRange.end - 1ms.
// subMilliseconds keeps the zoned date type (a plain new Date(ms)
// would flip the label to the machine zone near midnight)
const rangeEnd = subMilliseconds(activeRange.end, 1)
const start = activeRange.start
if (isSameMonth(start, rangeEnd)) {
return `${format(start, "MMMM d", opts)} - ${format(rangeEnd, "d, yyyy", opts)}`
}
if (isSameYear(start, rangeEnd)) {
return `${format(start, "MMM d", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
}
return `${format(start, "MMM d, yyyy", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
},
formatEventTime: (start, end, allDay, opts) => {
if (allDay) return cfg.labels.allDay
const fmt = cfg.formats.eventTime
// Multi-day timed events carry the date on both sides. Compare calendar
// days off the last rendered instant (end is exclusive, so a 14:00 to
// midnight event still ends on the start day). Elapsed ms would miss an
// exactly-24h event and a DST day that only runs 23 hours.
const lastInstant =
end.getTime() - 1 >= start.getTime() ? subMilliseconds(end, 1) : start
if (format(start, "yyyy-MM-dd") !== format(lastInstant, "yyyy-MM-dd")) {
return `${format(start, `MMM d, ${fmt}`, opts)} - ${format(end, `MMM d, ${fmt}`, opts)}`
}
return `${format(start, fmt, opts)} - ${format(end, fmt, opts)}`
},
formatDayRange: (range, opts) => {
// subMilliseconds keeps the zoned date type, same reason as formatTitle
const rangeEnd = subMilliseconds(range.end, 1)
return `${format(range.start, "MMM d", opts)} - ${format(rangeEnd, "MMM d", opts)}`
},
}
}
const DEFAULT_EVENT_CALENDAR_I18N: EventCalendarI18nConfig = {
labels: DEFAULT_LABELS,
viewNames: DEFAULT_VIEW_NAMES,
formats: DEFAULT_FORMATS,
functions: makeDefaultFunctions({
labels: DEFAULT_LABELS,
formats: DEFAULT_FORMATS,
}),
}
/**
* One level deeper than `Partial`, because the merge below is per nested
* section: overriding a single label must not force a consumer to restate the
* other 22. Unknown keys are still rejected by the excess property check.
*/
type EventCalendarI18nOverrides = {
[K in keyof EventCalendarI18nConfig]?: Partial<EventCalendarI18nConfig[K]>
}
/**
* Shallow merge per nested object, matching the filters.tsx i18n contract:
* a partial override replaces individual keys, never whole sections. Default
* functions are re-bound to the MERGED labels/formats; explicit `functions`
* overrides still win.
*/
function mergeEventCalendarI18n(
overrides?: EventCalendarI18nOverrides
): EventCalendarI18nConfig {
if (!overrides) return DEFAULT_EVENT_CALENDAR_I18N
const labels = { ...DEFAULT_LABELS, ...overrides.labels }
const viewNames = { ...DEFAULT_VIEW_NAMES, ...overrides.viewNames }
const formats = { ...DEFAULT_FORMATS, ...overrides.formats }
return {
labels,
viewNames,
formats,
functions: {
...makeDefaultFunctions({ labels, formats }),
...overrides.functions,
},
}
}
export { DEFAULT_EVENT_CALENDAR_I18N, mergeEventCalendarI18n }
export type { EventCalendarI18nConfig, EventCalendarI18nOverrides }
@@ -1,677 +0,0 @@
import { expandRecurrence } from "@/components/reui/event-calendar/event-calendar-recurrence"
import type {
CalendarEvent,
CalendarView,
EventCalendarDateRange,
EventCalendarOccurrence,
EventCalendarOffDaysConfig,
EventCalendarResource,
EventCalendarSegment,
} from "@/components/reui/event-calendar/event-calendar-types"
import { TZDate } from "@date-fns/tz"
import {
addDays,
addMonths,
addWeeks,
differenceInCalendarDays,
differenceInMinutes,
format,
startOfDay,
startOfMonth,
startOfWeek,
} from "date-fns"
type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6
/** Packing-effective minimum in minutes so tiny events do not stack invisibly. */
const MIN_PACK_SLOT = 30
/** The instant re-expressed in the display time zone (TZDate extends Date). */
function toZoned(date: Date, timeZone: string): TZDate {
return new TZDate(date.getTime(), timeZone)
}
/** Zoned midnight of the day containing the instant. */
function zonedStartOfDay(date: Date, timeZone: string): TZDate {
return startOfDay(toZoned(date, timeZone))
}
/** Stable per-day key in the display time zone. */
function getDayKey(date: Date, timeZone: string): string {
return format(toZoned(date, timeZone), "yyyy-MM-dd")
}
/** Day length in minutes; 1380/1500 on DST transition days - never assume 1440. */
function getDayTotalMinutes(dayStart: Date, timeZone: string): number {
const next = zonedStartOfDay(
addDays(toZoned(dayStart, timeZone), 1),
timeZone
)
return differenceInMinutes(next, dayStart)
}
function snapMinutes(minutes: number, snap: number): number {
return Math.round(minutes / snap) * snap
}
interface ViewRangeOptions {
timeZone: string
weekStartsOn: WeekStartsOn
dayCount: number
agendaDayCount: number
fixedWeeks: boolean
}
interface ViewDateRanges {
visibleRange: EventCalendarDateRange
activeRange: EventCalendarDateRange
}
function getViewDateRange(
view: CalendarView,
date: Date,
opts: ViewRangeOptions
): ViewDateRanges {
const { timeZone, weekStartsOn, dayCount, agendaDayCount, fixedWeeks } = opts
const zoned = toZoned(date, timeZone)
if (view === "month") {
const activeStart = startOfMonth(zoned)
const activeEnd = startOfMonth(addMonths(zoned, 1))
const visibleStart = startOfWeek(activeStart, { weekStartsOn })
let visibleEnd: Date
if (fixedWeeks) {
visibleEnd = addDays(visibleStart, 42)
} else {
visibleEnd = startOfWeek(addDays(activeEnd, -1), { weekStartsOn })
visibleEnd = addWeeks(visibleEnd, 1)
}
return {
activeRange: { start: activeStart, end: activeEnd },
visibleRange: { start: visibleStart, end: visibleEnd },
}
}
if (view === "week") {
const start = startOfWeek(zoned, { weekStartsOn })
const range = { start, end: addWeeks(start, 1) }
return { activeRange: range, visibleRange: range }
}
if (view === "day" || view === "resource") {
const start = startOfDay(zoned)
const range = { start, end: addDays(start, 1) }
return { activeRange: range, visibleRange: range }
}
if (view === "days") {
const start = startOfDay(zoned)
const range = { start, end: addDays(start, Math.max(1, dayCount)) }
return { activeRange: range, visibleRange: range }
}
// agenda
const start = startOfDay(zoned)
const range = { start, end: addDays(start, Math.max(1, agendaDayCount)) }
return { activeRange: range, visibleRange: range }
}
/** Day of month of the last day of the month containing the zoned date. */
function lastDayOfZonedMonth(date: Date): number {
return addDays(startOfMonth(addMonths(date, 1)), -1).getDate()
}
/** The anchor date stepped one period forward or backward for the view. */
function stepDate(
view: CalendarView,
date: Date,
direction: 1 | -1,
opts: Pick<ViewRangeOptions, "timeZone" | "dayCount" | "agendaDayCount">
): Date {
const zoned = toZoned(date, opts.timeZone)
if (view === "month") {
const stepped = addMonths(zoned, direction)
// addMonths clamps the day down into a shorter month and never restores
// it, so next-then-prev from the 31st would leave the anchor on the 28th.
// Sticking a month end to the target month's end keeps stepping
// invertible, which matters because the anchor is what day and week view
// open on after a month navigation.
if (zoned.getDate() !== lastDayOfZonedMonth(zoned)) return stepped
return addDays(stepped, lastDayOfZonedMonth(stepped) - stepped.getDate())
}
if (view === "week") return addWeeks(zoned, direction)
if (view === "day" || view === "resource") return addDays(zoned, direction)
if (view === "days")
return addDays(zoned, direction * Math.max(1, opts.dayCount))
return addDays(zoned, direction * Math.max(1, opts.agendaDayCount))
}
function rangesIntersect(
a: EventCalendarDateRange,
b: EventCalendarDateRange
): boolean {
return a.start < b.end && a.end > b.start
}
function eventsOverlap(
a: { start: Date; end: Date },
b: { start: Date; end: Date }
): boolean {
return a.start < b.end && a.end > b.start
}
/**
* The one canonical multi-day segmentation. Splits an occurrence into per-day
* segments clamped to the range. Rules (unit-tested in M1): exclusive end - an
* event ending exactly at zoned midnight emits NO segment for that day;
* zero-duration events emit one min-height segment; allDay occurrences walk
* the same absolute instants as timed ones and only drop startMin/endMin, so
* their bounds have to already BE display-zone midnights (see
* CalendarEvent.allDay) or the bar paints on the wrong days.
*/
function segmentOccurrence<TData>(
occurrence: EventCalendarOccurrence<TData>,
range: EventCalendarDateRange,
timeZone: string
): EventCalendarSegment<TData>[] {
const occStart = occurrence.start
const occEnd = occurrence.end
const isZeroLength = occEnd.getTime() === occStart.getTime()
const clampStart = occStart > range.start ? occStart : range.start
const clampEnd = occEnd < range.end ? occEnd : range.end
if (clampEnd < clampStart) return []
if (clampEnd.getTime() === clampStart.getTime() && !isZeroLength) return []
const segments: EventCalendarSegment<TData>[] = []
let cursor = zonedStartOfDay(clampStart, timeZone)
while (cursor < clampEnd || (isZeroLength && segments.length === 0)) {
const next = zonedStartOfDay(
addDays(toZoned(cursor, timeZone), 1),
timeZone
)
const segStart = clampStart > cursor ? clampStart : cursor
const segEnd = clampEnd < next ? clampEnd : next
const emptySeg = segEnd.getTime() <= segStart.getTime()
if (!emptySeg || isZeroLength) {
const isStart = segStart.getTime() === occStart.getTime()
const isEnd = segEnd.getTime() === occEnd.getTime()
segments.push({
occurrence,
day: cursor,
isStart,
isEnd,
continuesBefore: !isStart,
continuesAfter: !isEnd,
startMin: occurrence.allDay
? undefined
: differenceInMinutes(segStart, cursor),
endMin: occurrence.allDay
? undefined
: Math.max(
differenceInMinutes(segEnd, cursor),
differenceInMinutes(segStart, cursor)
),
})
}
if (isZeroLength) break
cursor = next
}
return segments
}
/** True when the occurrence should render as a bar (all-day row / month lanes). */
function isBarOccurrence(
occurrence: EventCalendarOccurrence,
timeZone?: string
): boolean {
return occurrence.allDay || spansMultipleDays(occurrence, timeZone)
}
function spansMultipleDays(
occ: { start: Date; end: Date },
timeZone?: string
): boolean {
// An event ending exactly at the next midnight is still single-day
// (exclusive end), so compare against a strictly-later instant. The
// yardstick is the length of the day the event starts on, never a flat 24h:
// a fall-back day is 25h long, and a 00:00-to-00:00 shift on it is still one
// calendar day that belongs in the hour track, not in the all-day row.
// Without a display zone the dates answer in their own frame (TZDate) or in
// the host zone.
const dayStart = startOfDay(
timeZone ? toZoned(occ.start, timeZone) : occ.start
)
const nextDayStart = startOfDay(addDays(dayStart, 1))
return (
occ.end.getTime() - occ.start.getTime() >
nextDayStart.getTime() - dayStart.getTime()
)
}
interface PackedPosition {
column: number
columnCount: number
columnSpan: number
}
/**
* Google-style overlap packing for one day's timed segments.
* Mutates column/columnCount/columnSpan on the segments, in place.
* z resolution happens at render: event.zIndex verbatim, else 10 + column.
*/
function packTimedSegments<TData>(
segments: EventCalendarSegment<TData>[]
): void {
if (segments.length === 0) return
type Working = {
seg: EventCalendarSegment<TData>
startMin: number
effEnd: number
}
const items: Working[] = segments
.map((seg) => {
const startMin = seg.startMin ?? 0
const endMin = seg.endMin ?? startMin
return {
seg,
startMin,
effEnd: Math.max(endMin, startMin + MIN_PACK_SLOT),
}
})
.sort(
(a, b) =>
a.startMin - b.startMin ||
b.effEnd - b.startMin - (a.effEnd - a.startMin) ||
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
)
// Sweep into connected clusters
const clusters: Working[][] = []
let current: Working[] = []
let clusterEnd = -Infinity
for (const item of items) {
if (item.startMin >= clusterEnd) {
current = []
clusters.push(current)
clusterEnd = -Infinity
}
current.push(item)
clusterEnd = Math.max(clusterEnd, item.effEnd)
}
for (const cluster of clusters) {
// Greedy column assignment
const colEnds: number[] = []
const byColumn = new Map<number, Working[]>()
for (const item of cluster) {
let col = colEnds.findIndex((end) => end <= item.startMin)
if (col === -1) {
col = colEnds.length
colEnds.push(0)
}
colEnds[col] = item.effEnd
item.seg.column = col
const bucket = byColumn.get(col) ?? []
bucket.push(item)
byColumn.set(col, bucket)
}
const columnCount = colEnds.length
// Partial-overlap expansion: widen rightward into free columns
for (const item of cluster) {
let span = 1
const col = item.seg.column ?? 0
while (col + span < columnCount) {
const occupants = byColumn.get(col + span) ?? []
const blocked = occupants.some(
(o) => o.startMin < item.effEnd && o.effEnd > item.startMin
)
if (blocked) break
span++
}
item.seg.columnCount = columnCount
item.seg.columnSpan = span
}
}
}
/**
* Greedy lane packing for bar segments within one week row (7 columns).
* Mutates lane/rowIndex/colStart/colSpan on the segments, in place.
*/
/**
* Build the laned month-row bars for one week: consecutive-day segments of
* the same occurrence merge into ONE bar (colStart -> colSpan) stacked into
* lanes. Returns NEW segment objects - the shared per-day segments (also
* rendered by the all-day rows and day cells) must stay pristine: mutating
* their isEnd/continues flags gave the first-day chip a whole-bar shape and
* a bogus end resize handle in the week all-day row, where dragging it
* collapsed the event to a single day.
*/
function packWeekRowLanes<TData>(
segments: EventCalendarSegment<TData>[],
rowIndex: number,
rowStart: Date,
timeZone: string
): EventCalendarSegment<TData>[] {
type Bar = {
seg: EventCalendarSegment<TData>
colStart: number
colSpan: number
isStart: boolean
isEnd: boolean
lane: number
}
const bars: Bar[] = segments.map((seg) => {
const dayIndex = Math.round(
(zonedStartOfDay(seg.day, timeZone).getTime() -
zonedStartOfDay(rowStart, timeZone).getTime()) /
(24 * 60 * 60 * 1000)
)
return {
seg,
colStart: Math.max(0, Math.min(6, dayIndex)),
colSpan: 1,
isStart: seg.isStart,
isEnd: seg.isEnd,
lane: 0,
}
})
// Merge consecutive-day segments of the same occurrence into one bar per row
const merged = new Map<string, Bar>()
for (const bar of bars) {
const key = bar.seg.occurrence.key
const existing = merged.get(key)
if (existing) {
const start = Math.min(existing.colStart, bar.colStart)
const end = Math.max(
existing.colStart + existing.colSpan,
bar.colStart + bar.colSpan
)
existing.colStart = start
existing.colSpan = end - start
existing.isStart = existing.isStart || bar.isStart
existing.isEnd = existing.isEnd || bar.isEnd
} else {
merged.set(key, bar)
}
}
const rowBars = Array.from(merged.values()).sort(
(a, b) =>
a.colStart - b.colStart ||
b.colSpan - a.colSpan ||
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
)
const lanes: boolean[][] = []
for (const bar of rowBars) {
let lane = 0
for (;;) {
lanes[lane] ??= new Array(7).fill(false)
const row = lanes[lane]
let free = true
for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
if (row[c]) {
free = false
break
}
}
if (free) break
lane++
}
for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
lanes[lane][c] = true
}
bar.lane = lane
}
return rowBars.map((bar) => ({
...bar.seg,
isStart: bar.isStart,
isEnd: bar.isEnd,
continuesBefore: !bar.isStart,
continuesAfter: !bar.isEnd,
lane: bar.lane,
rowIndex,
colStart: bar.colStart,
colSpan: bar.colSpan,
}))
}
interface EventCalendarDayBucket<TData = unknown> {
allDay: EventCalendarSegment<TData>[]
timed: EventCalendarSegment<TData>[]
}
interface EventCalendarWeekRow<TData = unknown> {
rowIndex: number
rowStart: Date
/** Laned bar segments (one per occurrence per row). */
bars: EventCalendarSegment<TData>[]
}
interface EventCalendarIndex<TData = unknown> {
occurrences: EventCalendarOccurrence<TData>[]
byDay: Map<string, EventCalendarDayBucket<TData>>
weekRows: EventCalendarWeekRow<TData>[]
}
interface BuildIndexOptions<TData> {
timeZone: string
weekStartsOn: WeekStartsOn
eventOrder?: (
a: EventCalendarOccurrence<TData>,
b: EventCalendarOccurrence<TData>
) => number
getOccurrences?: (
event: CalendarEvent<TData>,
range: EventCalendarDateRange,
ctx: { timeZone: string }
) => Array<{ start: Date; end: Date }> | null
}
function defaultEventOrder(
a: EventCalendarOccurrence,
b: EventCalendarOccurrence
): number {
return (
a.start.getTime() - b.start.getTime() ||
b.end.getTime() -
b.start.getTime() -
(a.end.getTime() - a.start.getTime()) ||
a.key.localeCompare(b.key)
)
}
function buildEventIndex<TData>(
events: CalendarEvent<TData>[],
visibleRange: EventCalendarDateRange,
opts: BuildIndexOptions<TData>
): EventCalendarIndex<TData> {
const { timeZone, weekStartsOn } = opts
const order = opts.eventOrder ?? defaultEventOrder
// RECURRENCE-ID override replacement: an event carrying recurringEventId +
// originalStart is an edited single occurrence of that series. The parent's
// expansion drops the replaced instant; the override renders as its own
// occurrence through the normal path below.
const overrideTimes = new Map<string, Set<number>>()
for (const event of events) {
if (!event.recurringEventId || !event.originalStart) continue
let times = overrideTimes.get(event.recurringEventId)
if (!times) overrideTimes.set(event.recurringEventId, (times = new Set()))
times.add(event.originalStart.getTime())
}
const occurrences: EventCalendarOccurrence<TData>[] = []
for (const event of events) {
const replaced = overrideTimes.get(event.id)
const custom = opts.getOccurrences?.(event, visibleRange, { timeZone })
if (custom) {
custom.forEach((occ, i) => {
if (replaced?.has(occ.start.getTime())) return
if (!rangesIntersect({ start: occ.start, end: occ.end }, visibleRange))
return
occurrences.push({
key: `${event.id}::${occ.start.toISOString()}`,
eventId: event.id,
event,
start: occ.start,
end: occ.end,
allDay: event.allDay ?? false,
isRecurring: true,
recurrenceIndex: i,
})
})
continue
}
const expanded = expandRecurrence(event, visibleRange, { timeZone })
occurrences.push(
...(replaced
? expanded.filter((occ) => !replaced.has(occ.start.getTime()))
: expanded)
)
}
occurrences.sort(order)
const byDay = new Map<string, EventCalendarDayBucket<TData>>()
const barSegmentsByRow = new Map<number, EventCalendarSegment<TData>[]>()
const firstRowStart = startOfWeek(toZoned(visibleRange.start, timeZone), {
weekStartsOn,
})
for (const occurrence of occurrences) {
const segments = segmentOccurrence(occurrence, visibleRange, timeZone)
const bar = isBarOccurrence(occurrence, timeZone)
for (const seg of segments) {
const key = getDayKey(seg.day, timeZone)
let bucket = byDay.get(key)
if (!bucket) {
bucket = { allDay: [], timed: [] }
byDay.set(key, bucket)
}
if (bar) {
bucket.allDay.push(seg)
// calendar-day math, not a fixed 168h divisor: DST transition weeks
// are 167/169h long and the fixed divisor mis-buckets every later
// Sunday one row early (which then clamps into the wrong column)
const rowIndex = Math.floor(
differenceInCalendarDays(toZoned(seg.day, timeZone), firstRowStart) /
7
)
const rowBucket = barSegmentsByRow.get(rowIndex) ?? []
rowBucket.push(seg)
barSegmentsByRow.set(rowIndex, rowBucket)
} else {
bucket.timed.push(seg)
}
}
}
for (const bucket of byDay.values()) {
packTimedSegments(bucket.timed)
}
const weekRows: EventCalendarWeekRow<TData>[] = []
for (const [rowIndex, segs] of barSegmentsByRow) {
const rowStart = addWeeks(firstRowStart, rowIndex)
weekRows.push({
rowIndex,
rowStart,
bars: packWeekRowLanes(segs, rowIndex, rowStart, timeZone),
})
}
weekRows.sort((a, b) => a.rowIndex - b.rowIndex)
return { occurrences, byDay, weekRows }
}
/** Cache key for index memoization; cheap string compare. */
function getRangeKey(range: EventCalendarDateRange): string {
return `${range.start.getTime()}-${range.end.getTime()}`
}
/** Depth-first flatten of the resource tree (parents included). */
function flattenResources(
resources: EventCalendarResource[],
depth = 0
): Array<{ resource: EventCalendarResource; depth: number }> {
const rows: Array<{ resource: EventCalendarResource; depth: number }> = []
for (const resource of resources) {
rows.push({ resource, depth })
if (resource.children?.length) {
rows.push(...flattenResources(resource.children, depth + 1))
}
}
return rows
}
const DEFAULT_WEEKEND_DAYS = [0, 6]
/**
* Resolves whether a day is an off day (non-working) in the display zone.
* Callers pass the calendar's own weekendDays so the shading cannot contradict
* the weekend the rest of the calendar renders; an explicit offDays.weekendDays
* still wins over it.
*/
function resolveOffDay(
day: Date,
timeZone: string,
config: boolean | EventCalendarOffDaysConfig | undefined,
defaultWeekendDays?: number[]
): boolean {
if (!config) return false
const resolved: EventCalendarOffDaysConfig = config === true ? {} : config
const weekendDays =
resolved.weekendDays ?? defaultWeekendDays ?? DEFAULT_WEEKEND_DAYS
const zoned = toZoned(day, timeZone)
if (weekendDays.includes(zoned.getDay())) return true
if (resolved.dates?.length) {
const key = getDayKey(day, timeZone)
if (resolved.dates.some((date) => getDayKey(date, timeZone) === key)) {
return true
}
}
return resolved.isOffDay?.(day) ?? false
}
export {
buildEventIndex,
defaultEventOrder,
eventsOverlap,
flattenResources,
getDayKey,
getDayTotalMinutes,
getRangeKey,
getViewDateRange,
isBarOccurrence,
MIN_PACK_SLOT,
packTimedSegments,
packWeekRowLanes,
rangesIntersect,
resolveOffDay,
segmentOccurrence,
snapMinutes,
spansMultipleDays,
stepDate,
toZoned,
zonedStartOfDay,
}
export type {
BuildIndexOptions,
EventCalendarDayBucket,
EventCalendarIndex,
EventCalendarWeekRow,
ViewDateRanges,
ViewRangeOptions,
WeekStartsOn,
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff. Show more