Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc803bcb34 | ||
|
|
e469c421ca | ||
|
|
094f75913b | ||
|
|
764a4e5b4e | ||
|
|
2d29a892f9 | ||
|
|
d9bec85c02 | ||
|
|
d56405af7b | ||
|
|
927e27640a | ||
|
|
5255cd2d30 | ||
|
|
3723ba7ed1 | ||
|
|
c5148ac4a0 | ||
|
|
e6e319a275 | ||
|
|
869b13cb57 | ||
|
|
e190785d4f | ||
|
|
3fd05ff833 | ||
|
|
e7f24f0be4 | ||
|
|
1bfe460e4b | ||
|
|
e55d2c5aba | ||
|
|
f63e9b5fd0 | ||
|
|
0148d4ca37 |
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -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`, …
|
||||
|
||||
|
||||
@@ -6,18 +6,18 @@ alwaysApply: false
|
||||
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
+26
-2
@@ -14,7 +14,11 @@ Job **changes** вычисляет флаги по путям в diff. Полн
|
||||
|
||||
На **pull request** — **commitlint**. При изменении `deploy/docker/**` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||
|
||||
Кэш зависимостей: `actions/cache` с ключом `sha256sum` lockfile (`go.sum` / `pnpm-lock.yaml`). `hashFiles` в Gitea не используем.
|
||||
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`, `go env GOMODCACHE` / `GOCACHE`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||
|
||||
Кэшируется целиком: pnpm store + `node_modules` + corepack; Go modules + GOCACHE + `golangci-lint` в `GOBIN`. При hit: `pnpm install --offline`, `go mod download` без сети. `setup-go cache:` и `golangci-lint-action` не используем — они завязаны на `hashFiles`.
|
||||
|
||||
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||
|
||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR) и **publish** (CD).
|
||||
|
||||
@@ -24,7 +28,7 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR)
|
||||
|
||||
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||
3. Зеркало base-образов в `evobgp-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`).
|
||||
3. Зеркало base-образов в `evobgp-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evobgp` (`cleanup: false`).
|
||||
|
||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||
@@ -35,6 +39,8 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR)
|
||||
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||
|
||||
**`docker_hub_token`**: PAT Docker Hub (Settings → Actions → Secrets, можно на уровне организации). Логин перед зеркалом `docker.io` и `bake`, чтобы не ловить anonymous 429. Логин: `docker_hub_username` (если задан) или `gitea.actor`.
|
||||
|
||||
### Теги образов
|
||||
|
||||
```text
|
||||
@@ -58,3 +64,21 @@ docker pull git.shx.one/myuser/evobgp-api:1.2.3
|
||||
```
|
||||
|
||||
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
||||
|
||||
## act_runner: cache server
|
||||
|
||||
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||
|
||||
В `config.yaml` runner:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
enabled: true
|
||||
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||
port: 8088
|
||||
```
|
||||
|
||||
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||
|
||||
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||
|
||||
+31
-16
@@ -17,6 +17,8 @@ jobs:
|
||||
allow_registry_login: false
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
docker_hub_token: ${{ secrets.docker_hub_token }}
|
||||
docker_hub_username: ${{ secrets.docker_hub_username }}
|
||||
|
||||
publish:
|
||||
needs: [quality]
|
||||
@@ -39,18 +41,26 @@ jobs:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: ~/.local/share/pnpm/store
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: Install release tooling
|
||||
run: pnpm install --frozen-lockfile
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: sh scripts/ci/pnpm-ci.sh
|
||||
- name: Verify releasable commit messages
|
||||
run: pnpm exec node scripts/commit/verify-release-commits.mjs
|
||||
- name: Semantic release
|
||||
@@ -99,6 +109,12 @@ jobs:
|
||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username || gitea.actor }}
|
||||
password: ${{ secrets.docker_hub_token }}
|
||||
- name: Log in to Gitea Registry
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
@@ -108,10 +124,9 @@ jobs:
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: Mirror base images into buildcache
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
id: mirror
|
||||
continue-on-error: true
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
run: sh deploy/docker/mirror-base-images.sh
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
@@ -126,16 +141,16 @@ jobs:
|
||||
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||
CACHE_REF_BIRDC: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:birdc-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
working-directory: deploy/docker
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
cache="git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache"
|
||||
if [ "${{ steps.mirror.outcome }}" = "success" ]; then
|
||||
export BASE_GOLANG="${cache}:base-golang-1.24-alpine"
|
||||
export BASE_DEBIAN="${cache}:base-debian-bookworm-slim"
|
||||
export BASE_NODE="${cache}:base-node-22-alpine"
|
||||
export BASE_NGINX="${cache}:base-nginx-1.27-alpine"
|
||||
export BASE_UBUNTU="${cache}:base-ubuntu-noble"
|
||||
export BASE_DISTROLESS="${cache}:base-distroless-static-debian12-nonroot"
|
||||
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${MIRROR_ENV_FILE}"
|
||||
set +a
|
||||
fi
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||
-f deploy/docker/docker-bake.hcl default --push
|
||||
-f docker-bake.hcl default --push
|
||||
|
||||
@@ -21,3 +21,5 @@ jobs:
|
||||
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
docker_hub_token: ${{ secrets.docker_hub_token }}
|
||||
docker_hub_username: ${{ secrets.docker_hub_username }}
|
||||
|
||||
@@ -26,6 +26,10 @@ on:
|
||||
secrets:
|
||||
ACTIONS_PAT:
|
||||
required: false
|
||||
docker_hub_token:
|
||||
required: false
|
||||
docker_hub_username:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -183,20 +187,28 @@ jobs:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: ~/.local/share/pnpm/store
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, Redocly, codegen check
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec redocly lint docs/openapi.yaml
|
||||
chmod +x scripts/check-openapi-gen.sh
|
||||
sh scripts/check-openapi-gen.sh
|
||||
@@ -210,20 +222,28 @@ jobs:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: ~/.local/share/pnpm/store
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, typecheck, lint, test, build
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run test
|
||||
@@ -239,17 +259,29 @@ jobs:
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: false
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: go-hash
|
||||
run: echo "key=$(sha256sum go.sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: go-${{ runner.os }}-${{ steps.go-hash.outputs.key }}
|
||||
${{ env.GOMODCACHE }}
|
||||
${{ env.GOCACHE }}
|
||||
${{ env.GOBIN }}
|
||||
${{ env.GOLANGCI_LINT_CACHE }}
|
||||
key: go-${{ runner.os }}-1.24-gl1.64.8-${{ steps.go-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
go-${{ runner.os }}-
|
||||
go-${{ runner.os }}-1.24-gl1.64.8-
|
||||
go-${{ runner.os }}-1.24-
|
||||
- name: Download modules
|
||||
env:
|
||||
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||
GOCACHE: ${{ env.GOCACHE }}
|
||||
run: go mod download
|
||||
- name: Vet
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
run: go vet ./...
|
||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||
run: sh scripts/lint-httpapi.sh
|
||||
@@ -258,13 +290,20 @@ jobs:
|
||||
- name: Validate remote speaker compose
|
||||
run: sh scripts/validate-remote-speaker-compose.sh
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6.5.2
|
||||
with:
|
||||
version: v1.64.8
|
||||
skip-cache: true
|
||||
env:
|
||||
GOLANGCI_LINT_VERSION: v1.64.8
|
||||
run: sh scripts/ci/golangci-lint.sh
|
||||
- name: Test
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||
GOCACHE: ${{ env.GOCACHE }}
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||
GOCACHE: ${{ env.GOCACHE }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
out="${RUNNER_TEMP}/evobgp-bin"
|
||||
@@ -321,13 +360,19 @@ jobs:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: ~/.local/share/pnpm/store
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
@@ -335,9 +380,10 @@ jobs:
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
|
||||
docker-check:
|
||||
@@ -355,6 +401,12 @@ jobs:
|
||||
name: evobgp
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Log in to Docker Hub
|
||||
if: secrets.docker_hub_token != ''
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username || gitea.actor }}
|
||||
password: ${{ secrets.docker_hub_token }}
|
||||
- name: Log in to Gitea Registry
|
||||
if: inputs.allow_registry_login
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
@@ -366,12 +418,14 @@ jobs:
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||
- name: bake (no push)
|
||||
if: inputs.allow_registry_login
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
CACHE_REF_GO: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:web-buildcache
|
||||
CACHE_REF_BIRDC: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:birdc-buildcache
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}'],
|
||||
@@ -28,6 +28,9 @@ export default tseslint.config(
|
||||
},
|
||||
{
|
||||
files: ['src/components/reui/**/*.{ts,tsx}'],
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'off',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.0.0",
|
||||
"@date-fns/tz": "^1.5.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -26,8 +27,8 @@
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-router": "^1.130.2",
|
||||
"@tanstack/react-router-devtools": "^1.130.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.4",
|
||||
"@tanstack/react-table": "^9.1.2",
|
||||
"@tanstack/react-virtual": "^3.14.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
@@ -39,6 +40,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"recharts": "3.8.0",
|
||||
"shadcn": "^4.19.0",
|
||||
"sonner": "^1.7.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
|
||||
@@ -2,9 +2,7 @@ import { useState } from 'react'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
||||
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
@@ -47,46 +45,38 @@ export function AccessApiKeysCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<FrameDataGrid
|
||||
title="API-ключи"
|
||||
description="Полный токен показывается только при создании и ротации."
|
||||
className="min-w-0"
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={false}
|
||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет ключей"
|
||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<AccessApiKeysGrid
|
||||
items={data}
|
||||
isLoading={isLoading}
|
||||
onRotate={handleRotated}
|
||||
onRevoke={(id) => revoke.mutate(id)}
|
||||
rotatePending={rotate.isPending}
|
||||
revokePending={revoke.isPending}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</FrameDataGrid>
|
||||
{(data) => (
|
||||
<AccessApiKeysGrid
|
||||
items={data}
|
||||
isLoading={isLoading}
|
||||
onRotate={handleRotated}
|
||||
onRevoke={(id) => revoke.mutate(id)}
|
||||
rotatePending={rotate.isPending}
|
||||
revokePending={revoke.isPending}
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
<ApiKeyCreateDialog
|
||||
open={createOpen}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { KeyRound, RefreshCw, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createSearchFilterField,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { ApiKey } from '@/types/api'
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
createSearchFilterField('search', 'Поиск', 'Поиск API-ключей…'),
|
||||
]
|
||||
|
||||
export function AccessApiKeysGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
@@ -22,6 +28,7 @@ export function AccessApiKeysGrid({
|
||||
onRevoke,
|
||||
rotatePending = false,
|
||||
revokePending = false,
|
||||
actions,
|
||||
}: {
|
||||
items: ApiKey[]
|
||||
isLoading?: boolean
|
||||
@@ -29,16 +36,21 @@ export function AccessApiKeysGrid({
|
||||
onRevoke: (id: string) => void
|
||||
rotatePending?: boolean
|
||||
revokePending?: boolean
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<ApiKey>[]>(
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
|
||||
const columns = useMemo<DataGridColumnDef<ApiKey>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
<DataGridNameCell
|
||||
icon={KeyRound}
|
||||
title={row.original.name}
|
||||
accent="primary"
|
||||
subtitle={`${row.original.prefix}…`}
|
||||
/>
|
||||
),
|
||||
@@ -86,6 +98,7 @@ export function AccessApiKeysGrid({
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
size: 88,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const k = row.original
|
||||
@@ -135,24 +148,24 @@ export function AccessApiKeysGrid({
|
||||
[onRevoke, onRotate, revokePending, rotatePending],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
<ResourcePage
|
||||
title="API-ключи"
|
||||
description="Полный токен показывается только при создании и ротации."
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={(row) =>
|
||||
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`
|
||||
}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ключей"
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск API-ключей…"
|
||||
primaryAction={actions}
|
||||
pinLastColumn
|
||||
emptyState={{ title: 'Нет ключей' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,12 @@ 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
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function AnalyticsCardShell({
|
||||
title,
|
||||
description,
|
||||
@@ -47,7 +51,7 @@ export function AnalyticsCardShell({
|
||||
)
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
<FrameSection
|
||||
title={titleNode}
|
||||
description={description}
|
||||
actions={actions}
|
||||
@@ -57,6 +61,6 @@ export function AnalyticsCardShell({
|
||||
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,116 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, type ComponentType } from "react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
|
||||
import { BillingTab } from "./billing-tab"
|
||||
import { SETTINGS_TAB_ITEMS } from "./data"
|
||||
import { NotificationsTab } from "./notifications-tab"
|
||||
import { ProfileTab } from "./profile-tab"
|
||||
import { SecurityTab } from "./security-tab"
|
||||
|
||||
const TAB_COMPONENTS: Record<string, ComponentType> = {
|
||||
profile: ProfileTab,
|
||||
security: SecurityTab,
|
||||
notifications: NotificationsTab,
|
||||
billing: BillingTab,
|
||||
}
|
||||
|
||||
// ── Settings Navigation ──
|
||||
|
||||
function SettingsNavigation({
|
||||
isMobile,
|
||||
activeValue,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
activeValue: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("min-w-0", isMobile ? "w-full" : "w-40 shrink-0")}>
|
||||
{isMobile ? (
|
||||
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
|
||||
{SETTINGS_TAB_ITEMS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
|
||||
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
) : (
|
||||
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
|
||||
{SETTINGS_TAB_ITEMS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
|
||||
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccountSettings() {
|
||||
const isMobile = useIsMobile()
|
||||
const [activeTab, setActiveTab] = useState("profile")
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl space-y-8">
|
||||
{/* Header */}
|
||||
<header className="px-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Account Settings
|
||||
</h1>
|
||||
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||
Update your profile, access, notifications, and billing preferences.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
orientation={isMobile ? "horizontal" : "vertical"}
|
||||
className={cn("w-full gap-4 lg:gap-8")}
|
||||
>
|
||||
<SettingsNavigation isMobile={isMobile} activeValue={activeTab} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{SETTINGS_TAB_ITEMS.map((tab) => {
|
||||
const TabComponent = TAB_COMPONENTS[tab.value]
|
||||
|
||||
return (
|
||||
<TabsContent key={tab.value} value={tab.value} className="mt-0">
|
||||
<TabComponent />
|
||||
</TabsContent>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
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 { BILLING_PLANS, COUNTRIES } from "./data"
|
||||
import { SettingRow } from "./setting-row"
|
||||
import { SettingsCard } from "./settings-card"
|
||||
import { SettingsFieldGroup } from "./settings-field-group"
|
||||
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||
|
||||
export function BillingTab() {
|
||||
const [country, setCountry] = useState("us")
|
||||
const handleCountryChange = createSelectValueHandler(setCountry)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Card */}
|
||||
<SettingsCard title="Plan and billing" description="Current subscription">
|
||||
<SettingsFieldGroup
|
||||
legend="Plan and billing"
|
||||
description="Review your current subscription and plan options."
|
||||
>
|
||||
{BILLING_PLANS.map((plan, index) => (
|
||||
<SettingRow
|
||||
key={plan.id}
|
||||
title={plan.name}
|
||||
titleAddon={
|
||||
plan.current ? (
|
||||
<Badge variant="info-light" size="sm">
|
||||
Current
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<span className="text-foreground font-medium">
|
||||
{plan.price}
|
||||
</span>{" "}
|
||||
/ {plan.period} · {plan.features.join(", ")}
|
||||
</>
|
||||
}
|
||||
last={index === BILLING_PLANS.length - 1}
|
||||
>
|
||||
<Button variant={plan.current ? "outline" : "ghost"} size="sm">
|
||||
{plan.current ? "Manage" : "Switch"}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Billing details"
|
||||
description="Invoices and payments"
|
||||
footer={<Button>Update billing</Button>}
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Billing details"
|
||||
description="Manage invoice contacts and payment details."
|
||||
>
|
||||
<SettingRow
|
||||
title="Billing email"
|
||||
description="Receives invoices and renewal notices."
|
||||
labelFor="settings-7-billing-email"
|
||||
>
|
||||
<Input
|
||||
id="settings-7-billing-email"
|
||||
defaultValue="[email protected]"
|
||||
type="email"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Invoice profile"
|
||||
description="Business details used on receipts and tax forms."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Invoice profile</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Company identity used for invoices and tax documents.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-company-name">
|
||||
Company name
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="settings-7-company-name"
|
||||
defaultValue="Acme Labs"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-billing-country">
|
||||
Country
|
||||
</FieldLabel>
|
||||
<Select value={country} onValueChange={handleCountryChange}>
|
||||
<SelectTrigger
|
||||
id="settings-7-billing-country"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue>
|
||||
{getOptionLabel(COUNTRIES, country)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{COUNTRIES.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Tax ID"
|
||||
description="Optional number used for VAT or company invoices."
|
||||
labelFor="settings-7-tax-id"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>VAT</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-7-tax-id"
|
||||
defaultValue="US-2048-ACME"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Card on file"
|
||||
description="Used for monthly renewals."
|
||||
last
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Visa ending in 4242
|
||||
</span>
|
||||
<Button variant="outline">Update</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
import { UserIcon, ShieldIcon, BellIcon, CreditCardIcon, MonitorIcon, SmartphoneIcon } from "lucide-react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type BillingPlan = {
|
||||
id: string
|
||||
name: string
|
||||
price: string
|
||||
period: string
|
||||
current: boolean
|
||||
features: string[]
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
id: string
|
||||
device: string
|
||||
browser: string
|
||||
location: string
|
||||
lastActive: string
|
||||
current: boolean
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export type SettingsTabItem = {
|
||||
value: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||
{
|
||||
value: "profile",
|
||||
label: "My Profile",
|
||||
icon: (
|
||||
<UserIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "security",
|
||||
label: "Security",
|
||||
icon: (
|
||||
<ShieldIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "notifications",
|
||||
label: "Notifications",
|
||||
icon: (
|
||||
<BellIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "billing",
|
||||
label: "Billing",
|
||||
icon: (
|
||||
<CreditCardIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export const TIMEZONES: SelectOption[] = [
|
||||
{ value: "utc-8", label: "UTC-8 (Pacific Time)" },
|
||||
{ value: "utc-5", label: "UTC-5 (Eastern Time)" },
|
||||
{ value: "utc+0", label: "UTC+0 (London)" },
|
||||
{ value: "utc+1", label: "UTC+1 (Berlin)" },
|
||||
{ value: "utc+9", label: "UTC+9 (Tokyo)" },
|
||||
]
|
||||
|
||||
export const ROLES: SelectOption[] = [
|
||||
{ value: "engineering-lead", label: "Engineering Lead" },
|
||||
{ value: "developer", label: "Developer" },
|
||||
{ value: "designer", label: "Designer" },
|
||||
{ value: "product-manager", label: "Product Manager" },
|
||||
]
|
||||
|
||||
export const COUNTRIES: SelectOption[] = [
|
||||
{ value: "us", label: "United States" },
|
||||
{ value: "uk", label: "United Kingdom" },
|
||||
{ value: "de", label: "Germany" },
|
||||
{ value: "uz", label: "Uzbekistan" },
|
||||
]
|
||||
|
||||
export const TIMEOUTS: SelectOption[] = [
|
||||
{ value: "5", label: "5 minutes" },
|
||||
{ value: "10", label: "10 minutes" },
|
||||
{ value: "15", label: "15 minutes" },
|
||||
{ value: "30", label: "30 minutes" },
|
||||
]
|
||||
|
||||
export const QUIET_HOURS: SelectOption[] = [
|
||||
{ value: "18:00", label: "6:00 PM" },
|
||||
{ value: "20:00", label: "8:00 PM" },
|
||||
{ value: "22:00", label: "10:00 PM" },
|
||||
{ value: "23:00", label: "11:00 PM" },
|
||||
]
|
||||
|
||||
export const QUIET_HOURS_END: SelectOption[] = [
|
||||
{ value: "06:00", label: "6:00 AM" },
|
||||
{ value: "08:00", label: "8:00 AM" },
|
||||
{ value: "09:00", label: "9:00 AM" },
|
||||
{ value: "10:00", label: "10:00 AM" },
|
||||
]
|
||||
|
||||
export const DIGEST_CADENCE: SelectOption[] = [
|
||||
{ value: "daily", label: "Daily summary" },
|
||||
{ value: "weekly", label: "Weekly digest" },
|
||||
{ value: "mentions", label: "Only mentions" },
|
||||
]
|
||||
|
||||
export const RECOVERY_METHODS: SelectOption[] = [
|
||||
{ value: "authenticator", label: "Authenticator first" },
|
||||
{ value: "sms", label: "SMS fallback" },
|
||||
{ value: "email", label: "Email fallback" },
|
||||
]
|
||||
|
||||
export const BILLING_PLANS: BillingPlan[] = [
|
||||
{
|
||||
id: "free",
|
||||
name: "Free",
|
||||
price: "$0",
|
||||
period: "forever",
|
||||
current: false,
|
||||
features: ["1 workspace", "Basic exports", "Community support"],
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
price: "$29",
|
||||
period: "per month",
|
||||
current: true,
|
||||
features: ["Unlimited workspaces", "Automations", "Priority support"],
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
name: "Team",
|
||||
price: "$79",
|
||||
period: "per month",
|
||||
current: false,
|
||||
features: ["Everything in Pro", "SSO", "Shared billing"],
|
||||
},
|
||||
]
|
||||
|
||||
export const SESSIONS: Session[] = [
|
||||
{
|
||||
id: "sess-1",
|
||||
device: "macOS",
|
||||
browser: "Chrome",
|
||||
location: "San Francisco, CA",
|
||||
lastActive: "Active now",
|
||||
current: true,
|
||||
icon: (
|
||||
<MonitorIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sess-2",
|
||||
device: "iPhone",
|
||||
browser: "Safari",
|
||||
location: "San Francisco, CA",
|
||||
lastActive: "2 hours ago",
|
||||
current: false,
|
||||
icon: (
|
||||
<SmartphoneIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sess-3",
|
||||
device: "Windows",
|
||||
browser: "Firefox",
|
||||
location: "New York, NY",
|
||||
lastActive: "3 days ago",
|
||||
current: false,
|
||||
icon: (
|
||||
<MonitorIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -1,223 +0,0 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
|
||||
import { DIGEST_CADENCE, QUIET_HOURS, QUIET_HOURS_END, TIMEOUTS } from "./data"
|
||||
import { SettingRow } from "./setting-row"
|
||||
import { SettingsCard } from "./settings-card"
|
||||
import { SettingsFieldGroup } from "./settings-field-group"
|
||||
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||
|
||||
export function NotificationsTab() {
|
||||
const [autoDismiss, setAutoDismiss] = useState("10")
|
||||
const [quietStart, setQuietStart] = useState("20:00")
|
||||
const [quietEnd, setQuietEnd] = useState("08:00")
|
||||
const [digestCadence, setDigestCadence] = useState("weekly")
|
||||
const handleAutoDismissChange = createSelectValueHandler(setAutoDismiss)
|
||||
const handleQuietStartChange = createSelectValueHandler(setQuietStart)
|
||||
const handleQuietEndChange = createSelectValueHandler(setQuietEnd)
|
||||
const handleDigestCadenceChange = createSelectValueHandler(setDigestCadence)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Card */}
|
||||
<SettingsCard title="Activity alerts" description="Desktop and badge">
|
||||
<SettingsFieldGroup
|
||||
legend="Activity alerts"
|
||||
description="Control desktop, badge, and sound alerts."
|
||||
>
|
||||
<SettingRow
|
||||
title="Desktop notifications"
|
||||
description="Show alerts for mentions and approvals."
|
||||
labelFor="settings-7-desktop-notifications"
|
||||
>
|
||||
<Switch id="settings-7-desktop-notifications" defaultChecked />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Quiet hours"
|
||||
description="Hold non-urgent alerts outside your workday."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Quiet hours</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Define when non-urgent activity should stay muted.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-3 sm:grid sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-quiet-start">
|
||||
Start
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={quietStart}
|
||||
onValueChange={handleQuietStartChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="settings-7-quiet-start"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue>
|
||||
{getOptionLabel(QUIET_HOURS, quietStart)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{QUIET_HOURS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-quiet-end">End</FieldLabel>
|
||||
<Select value={quietEnd} onValueChange={handleQuietEndChange}>
|
||||
<SelectTrigger id="settings-7-quiet-end" className="w-full">
|
||||
<SelectValue>
|
||||
{getOptionLabel(QUIET_HOURS_END, quietEnd)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{QUIET_HOURS_END.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Unread badge"
|
||||
description="Display a badge when new activity arrives."
|
||||
labelFor="settings-7-unread-badge"
|
||||
>
|
||||
<Switch id="settings-7-unread-badge" defaultChecked />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Auto dismiss"
|
||||
description="Choose how long alerts stay visible."
|
||||
labelFor="settings-7-auto-dismiss"
|
||||
last
|
||||
>
|
||||
<Select value={autoDismiss} onValueChange={handleAutoDismissChange}>
|
||||
<SelectTrigger id="settings-7-auto-dismiss" className="w-full">
|
||||
<SelectValue>
|
||||
{getOptionLabel(TIMEOUTS, autoDismiss)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{TIMEOUTS.map((timeout) => (
|
||||
<SelectItem key={timeout.value} value={timeout.value}>
|
||||
{timeout.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Email updates"
|
||||
description="Inbox preferences"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline">Reset</Button>
|
||||
<Button>Save preferences</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Email updates"
|
||||
description="Control which emails reach your inbox."
|
||||
>
|
||||
<SettingRow
|
||||
title="Team communication"
|
||||
description="Messages, approvals, and activity summaries."
|
||||
labelFor="settings-7-team-communication"
|
||||
>
|
||||
<Switch id="settings-7-team-communication" defaultChecked />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Product announcements"
|
||||
description="Releases, improvements, and launches."
|
||||
labelFor="settings-7-product-announcements"
|
||||
>
|
||||
<Switch id="settings-7-product-announcements" />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Weekly digest"
|
||||
description="A recap of workspaces, mentions, and tasks."
|
||||
labelFor="settings-7-weekly-digest"
|
||||
>
|
||||
<Switch id="settings-7-weekly-digest" defaultChecked />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Digest cadence"
|
||||
description="Set how often summary emails arrive."
|
||||
labelFor="settings-7-digest-cadence"
|
||||
last
|
||||
>
|
||||
<Select
|
||||
value={digestCadence}
|
||||
onValueChange={handleDigestCadenceChange}
|
||||
>
|
||||
<SelectTrigger id="settings-7-digest-cadence" className="w-full">
|
||||
<SelectValue>
|
||||
{getOptionLabel(DIGEST_CADENCE, digestCadence)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{DIGEST_CADENCE.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useFileUpload } from "@/hooks/use-file-upload"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { UserCircle, XIcon, UploadIcon } from "lucide-react"
|
||||
|
||||
interface ProfileAvatarUploadProps {
|
||||
defaultAvatar: string
|
||||
alt: string
|
||||
inputId?: string
|
||||
}
|
||||
|
||||
export function ProfileAvatarUpload({
|
||||
defaultAvatar,
|
||||
alt,
|
||||
inputId,
|
||||
}: ProfileAvatarUploadProps) {
|
||||
const [removedCurrentPhoto, setRemovedCurrentPhoto] = useState(false)
|
||||
const [{ files }, { removeFile, openFileDialog, getInputProps }] =
|
||||
useFileUpload({
|
||||
accept: "image/*",
|
||||
})
|
||||
|
||||
const currentFile = files[0] ?? null
|
||||
const hasSavedPhoto = Boolean(defaultAvatar) && !removedCurrentPhoto
|
||||
const previewUrl =
|
||||
currentFile?.preview ?? (hasSavedPhoto ? defaultAvatar : null)
|
||||
const hasPhoto = Boolean(previewUrl)
|
||||
|
||||
const handleCancelUpload = () => {
|
||||
if (!currentFile) {
|
||||
return
|
||||
}
|
||||
|
||||
removeFile(currentFile.id)
|
||||
}
|
||||
|
||||
const handleRemovePhoto = () => {
|
||||
if (currentFile) {
|
||||
removeFile(currentFile.id)
|
||||
}
|
||||
|
||||
setRemovedCurrentPhoto(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-wrap items-center justify-start gap-2">
|
||||
{/* Actions */}
|
||||
<div className="relative">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage src={previewUrl ?? undefined} alt={alt} />
|
||||
<AvatarFallback className="text-muted-foreground bg-muted">
|
||||
<UserCircle aria-hidden="true" className="size-4 opacity-60" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{currentFile ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
onClick={handleCancelUpload}
|
||||
className="absolute -top-1 -right-1 size-4 rounded-full"
|
||||
aria-label={`Cancel ${currentFile.file.name}`}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative inline-flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openFileDialog}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<UploadIcon aria-hidden="true" />
|
||||
{hasPhoto ? "Change" : "Upload"}
|
||||
</Button>
|
||||
<input
|
||||
{...getInputProps({ id: inputId })}
|
||||
className="sr-only"
|
||||
aria-label="Upload profile image"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasPhoto ? (
|
||||
<Button variant="outline" size="sm" onClick={handleRemovePhoto}>
|
||||
<XIcon aria-hidden="true" />
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Alert,
|
||||
AlertAction,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/reui/alert"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
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 { Textarea } from "@evobgp/ui/components/textarea"
|
||||
import { ROLES, TIMEZONES } from "./data"
|
||||
import { ProfileAvatarUpload } from "./profile-avatar-upload"
|
||||
import { SettingRow } from "./setting-row"
|
||||
import { SettingsCard } from "./settings-card"
|
||||
import { SettingsFieldGroup } from "./settings-field-group"
|
||||
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||
import { UserIcon } from "lucide-react"
|
||||
|
||||
export function ProfileTab() {
|
||||
const [role, setRole] = useState("engineering-lead")
|
||||
const [timezone, setTimezone] = useState("utc-8")
|
||||
const handleRoleChange = createSelectValueHandler(setRole)
|
||||
const handleTimezoneChange = createSelectValueHandler(setTimezone)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="warning">
|
||||
<UserIcon aria-hidden="true" />
|
||||
<AlertTitle>Complete your profile.</AlertTitle>
|
||||
<AlertDescription>
|
||||
Add a photo and keep your role and timezone current.
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button type="button" variant="outline" size="xs">
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button type="button" size="xs">
|
||||
Update
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
|
||||
{/* Card */}
|
||||
<SettingsCard
|
||||
title="My profile"
|
||||
description="Public account details"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button>Save changes</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Profile fields"
|
||||
description="Update your personal profile details."
|
||||
>
|
||||
<SettingRow
|
||||
title="Photo"
|
||||
description="Shown in comments and mentions."
|
||||
>
|
||||
<ProfileAvatarUpload
|
||||
defaultAvatar="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80"
|
||||
alt="Alex Morgan"
|
||||
inputId="settings-7-profile-photo"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Full name"
|
||||
description="Used across the workspace."
|
||||
labelFor="settings-7-full-name"
|
||||
>
|
||||
<Input id="settings-7-full-name" defaultValue="Alex Morgan" />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Email address"
|
||||
description="Primary sign-in email."
|
||||
labelFor="settings-7-email"
|
||||
titleAddon={<Badge variant="success-light">Verified</Badge>}
|
||||
>
|
||||
<Input
|
||||
id="settings-7-email"
|
||||
defaultValue="[email protected]"
|
||||
type="email"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Username"
|
||||
description="Visible in mentions and links."
|
||||
labelFor="settings-7-username"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>@</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-7-username"
|
||||
defaultValue="alexmorgan"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Profile details"
|
||||
description="Public details shared across the workspace."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Profile details</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Public profile and workspace defaults.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-role">Role</FieldLabel>
|
||||
<Select value={role} onValueChange={handleRoleChange}>
|
||||
<SelectTrigger id="settings-7-role" className="w-full">
|
||||
<SelectValue>{getOptionLabel(ROLES, role)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{ROLES.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-timezone">
|
||||
Time zone
|
||||
</FieldLabel>
|
||||
<Select value={timezone} onValueChange={handleTimezoneChange}>
|
||||
<SelectTrigger id="settings-7-timezone" className="w-full">
|
||||
<SelectValue>
|
||||
{getOptionLabel(TIMEZONES, timezone)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{TIMEZONES.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-website">Website</FieldLabel>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>https://</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-7-website"
|
||||
defaultValue="alexmorgan.dev"
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Bio"
|
||||
description="Short profile summary."
|
||||
labelFor="settings-7-bio"
|
||||
last
|
||||
>
|
||||
<Textarea
|
||||
id="settings-7-bio"
|
||||
defaultValue="Building developer tools at Acme. Previously at Vercel and Stripe."
|
||||
rows={4}
|
||||
className="min-h-24 resize-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
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 {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from "@evobgp/ui/components/item"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
import { RECOVERY_METHODS, SESSIONS } from "./data"
|
||||
import { SettingRow } from "./setting-row"
|
||||
import { SettingsCard } from "./settings-card"
|
||||
import { SettingsFieldGroup } from "./settings-field-group"
|
||||
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||
import { LogOutIcon } from "lucide-react"
|
||||
|
||||
export function SecurityTab() {
|
||||
const [recoveryMethod, setRecoveryMethod] = useState("authenticator")
|
||||
const handleRecoveryMethodChange = createSelectValueHandler(setRecoveryMethod)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Card */}
|
||||
<SettingsCard
|
||||
title="Change password"
|
||||
description="Access protection"
|
||||
footer={<Button>Update password</Button>}
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Password settings"
|
||||
description="Update your password and keep your account protected."
|
||||
>
|
||||
<SettingRow
|
||||
title="Current password"
|
||||
description="Verify your identity before making changes."
|
||||
labelFor="settings-7-current-password"
|
||||
>
|
||||
<Input
|
||||
id="settings-7-current-password"
|
||||
type="password"
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="New password"
|
||||
description="Use at least 12 characters and a unique phrase."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
last
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">New password</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Create and confirm the next password for this account.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-new-password">
|
||||
New password
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="settings-7-new-password"
|
||||
type="password"
|
||||
placeholder="Create a new password"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-confirm-password">
|
||||
Confirm password
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="settings-7-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm the new password"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Two-step verification"
|
||||
description="Extra sign-in checks"
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Two-step verification"
|
||||
description="Add backup checks for future sign-ins."
|
||||
>
|
||||
<SettingRow
|
||||
title="Authenticator app"
|
||||
description="Use one-time codes from an app."
|
||||
labelFor="settings-7-authenticator-app"
|
||||
titleAddon={
|
||||
<Badge variant="info-light" size="sm">
|
||||
Recommended
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Switch id="settings-7-authenticator-app" defaultChecked />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Recovery phone"
|
||||
description="Used if you lose access to your authenticator app."
|
||||
labelFor="settings-7-recovery-phone"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>+1</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-7-recovery-phone"
|
||||
defaultValue="415 555 0148"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Delivery method"
|
||||
description="Choose how backup verification requests are delivered."
|
||||
labelFor="settings-7-recovery-method"
|
||||
>
|
||||
<Select
|
||||
value={recoveryMethod}
|
||||
onValueChange={handleRecoveryMethodChange}
|
||||
>
|
||||
<SelectTrigger id="settings-7-recovery-method" className="w-full">
|
||||
<SelectValue>
|
||||
{getOptionLabel(RECOVERY_METHODS, recoveryMethod)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{RECOVERY_METHODS.map((method) => (
|
||||
<SelectItem key={method.value} value={method.value}>
|
||||
{method.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Trusted devices"
|
||||
description="Skip repeat prompts on known devices."
|
||||
labelFor="settings-7-trusted-devices"
|
||||
last
|
||||
>
|
||||
<Switch id="settings-7-trusted-devices" defaultChecked />
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Active sessions" description="Signed-in devices">
|
||||
<ItemGroup className="gap-0">
|
||||
{SESSIONS.map((session, index) => (
|
||||
<Fragment key={session.id}>
|
||||
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
||||
<ItemMedia className="self-center!">
|
||||
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||
{session.icon}
|
||||
</Item>
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||
<ItemTitle className="gap-2 leading-5">
|
||||
{session.browser} on {session.device}
|
||||
{session.current ? (
|
||||
<Badge variant="success-light" size="xs">
|
||||
Current
|
||||
</Badge>
|
||||
) : null}
|
||||
</ItemTitle>
|
||||
<ItemDescription className="leading-5">
|
||||
{session.location} · {session.lastActive}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
|
||||
<ItemActions className="w-28 shrink-0 justify-end self-center">
|
||||
{session.current ? (
|
||||
<Button variant="outline" size="sm">
|
||||
This device
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm">
|
||||
<LogOutIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</Fragment>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Delete account" description="Irreversible changes">
|
||||
<SettingsFieldGroup
|
||||
legend="Delete account"
|
||||
description="Review irreversible actions before deleting your account."
|
||||
>
|
||||
<SettingRow
|
||||
title="Close account"
|
||||
description="Permanently remove your account, sessions, and recovery settings."
|
||||
last
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<div className="flex w-full flex-col items-start gap-2 @md/field-group:items-end">
|
||||
<p className="text-muted-foreground text-xs leading-5">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<Button variant="destructive">Delete account</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
/** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */
|
||||
export { SettingRow } from '@/components/settings/setting-row'
|
||||
@@ -1,2 +0,0 @@
|
||||
/** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */
|
||||
export { SettingsCard } from '@/components/settings/settings-card'
|
||||
@@ -1,2 +0,0 @@
|
||||
/** @deprecated Import from `@/components/settings/settings-field-group` — settings-7 is not a target. */
|
||||
export { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { type Dispatch, type SetStateAction } from "react"
|
||||
|
||||
import type { SelectOption } from "./data"
|
||||
|
||||
export function getOptionLabel<T extends SelectOption>(
|
||||
options: T[],
|
||||
value: string
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
export function createSelectValueHandler(
|
||||
setValue: Dispatch<SetStateAction<string>>
|
||||
) {
|
||||
return (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { AccountSettings } from "./components/account-settings"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 md:p-10">
|
||||
<AccountSettings />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,333 +0,0 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
ArrowUpDownIcon,
|
||||
BoxesIcon,
|
||||
ChevronDownIcon,
|
||||
FilterIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import {
|
||||
DataGridTable,
|
||||
DataGridTableHeader,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
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 { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
type ModuleSort = 'name' | 'type' | 'priority'
|
||||
type EnabledFilter = 'all' | 'enabled' | 'disabled'
|
||||
|
||||
const sortLabels: Record<ModuleSort, string> = {
|
||||
name: 'Название',
|
||||
type: 'Тип',
|
||||
priority: 'Приоритет',
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
|
||||
|
||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
||||
return [{ id: sortBy, desc: false }]
|
||||
}
|
||||
|
||||
export function DashboardModulesGrid({
|
||||
modules,
|
||||
isLoading = false,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>('all')
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [sortBy, setSortBy] = useState<ModuleSort>('name')
|
||||
const [sorting, setSorting] = useState<SortingState>(() => buildSorting('name'))
|
||||
|
||||
const filteredModules = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
return modules.filter((module) => {
|
||||
const matchesSearch =
|
||||
q.length === 0 ||
|
||||
`${module.name} ${module.type} ${moduleTypeRu(module.type)}`.toLowerCase().includes(q)
|
||||
const matchesEnabled =
|
||||
enabledFilter === 'all' ||
|
||||
(enabledFilter === 'enabled' ? module.enabled !== false : module.enabled === false)
|
||||
return matchesSearch && matchesEnabled
|
||||
})
|
||||
}, [modules, searchQuery, enabledFilter])
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) => ({ ...current, pageIndex: 0 }))
|
||||
}, [])
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: ModuleSort) => {
|
||||
setSortBy(value)
|
||||
setSorting(buildSorting(value))
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<ModuleRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
id: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
||||
<DataGridPrimaryCell title={row.original.name} accent="primary" />
|
||||
</div>
|
||||
),
|
||||
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: 'Приоритет' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredModules,
|
||||
columns,
|
||||
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
|
||||
state: { pagination, sorting },
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getRowId: (row) => row.id,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
const activeFilterCount = enabledFilter === 'all' ? 0 : 1
|
||||
|
||||
return (
|
||||
<DashboardFramePanel
|
||||
title="Модули"
|
||||
description="Поиск, сортировка и быстрый переход к настройке"
|
||||
className="min-w-0"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
||||
<PlusIcon />
|
||||
Создать
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredModules.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={EMPTY_MESSAGE}
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
rowBorder: true,
|
||||
headerSticky: false,
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
width: 'auto',
|
||||
}}
|
||||
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
|
||||
<InputGroup className="w-full min-w-0 @3xl:max-w-xs">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchQuery}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value)
|
||||
resetPagination()
|
||||
}}
|
||||
placeholder="Поиск модулей…"
|
||||
aria-label="Поиск модулей"
|
||||
/>
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="Очистить поиск"
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<ArrowUpDownIcon data-icon="inline-start" aria-hidden />
|
||||
{sortLabels[sortBy]}
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
<DropdownMenuGroup>
|
||||
{(Object.keys(sortLabels) as ModuleSort[]).map((value) => (
|
||||
<DropdownMenuItem key={value} onClick={() => handleSortChange(value)}>
|
||||
{sortLabels[value]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<FilterIcon data-icon="inline-start" aria-hidden />
|
||||
Фильтры
|
||||
{activeFilterCount > 0 ? (
|
||||
<Badge variant="outline" radius="full">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Состояние</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={enabledFilter === 'enabled'}
|
||||
closeOnClick={false}
|
||||
onCheckedChange={(checked) => {
|
||||
setEnabledFilter(checked ? 'enabled' : 'all')
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
Только включённые
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={enabledFilter === 'disabled'}
|
||||
closeOnClick={false}
|
||||
onCheckedChange={(checked) => {
|
||||
setEnabledFilter(checked ? 'disabled' : 'all')
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
Только выключенные
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuGroup>
|
||||
{activeFilterCount > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
closeOnClick={false}
|
||||
onClick={() => {
|
||||
setEnabledFilter('all')
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
Сбросить фильтры
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
) : (
|
||||
<>
|
||||
<DataGridScrollArea>
|
||||
<DataGridTableHeader />
|
||||
</DataGridScrollArea>
|
||||
<div className="text-muted-foreground flex min-h-40 items-center justify-center px-4 text-center text-sm">
|
||||
{EMPTY_MESSAGE}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridPagination
|
||||
{...DATA_GRID_PAGINATION_RU}
|
||||
sizes={[10, 15, 20]}
|
||||
className="py-0"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center text-sm">0 модулей</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataGrid>
|
||||
</DashboardFramePanel>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -16,7 +16,8 @@ const ACTIONS: QuickActionItem[] = [
|
||||
id: 'new-module',
|
||||
title: 'Создать модуль',
|
||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||
to: '/modules/new',
|
||||
to: '/modules',
|
||||
search: { create: true },
|
||||
icon: <Plus aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
@@ -60,7 +61,6 @@ const ACTIONS: QuickActionItem[] = [
|
||||
title: 'Мониторинг',
|
||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||
to: '/monitoring',
|
||||
search: { tab: 'system' },
|
||||
icon: <Gauge aria-hidden />,
|
||||
iconClassName: 'text-destructive',
|
||||
},
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
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<ColumnDef<JobRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'kind',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={jobKindRu(row.original.kind)}
|
||||
accent="mono"
|
||||
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],
|
||||
)
|
||||
|
||||
const { table, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => {
|
||||
const moduleName = row.meta?.module_id
|
||||
? (nameById.get(String(row.meta.module_id)) ?? '')
|
||||
: ''
|
||||
return `${jobKindRu(row.kind)} ${row.status} ${moduleName}`
|
||||
},
|
||||
getRowId: (row) => row.job_id,
|
||||
pageSize: 8,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
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<ColumnDef<RevisionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'id',
|
||||
accessorFn: (row) => row.id,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={`${row.original.id.slice(0, 10)}…`} accent="mono" />
|
||||
),
|
||||
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: 'Создана' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => row.id,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: 8,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ревизий"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,38 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
const ACCENT_CLASS = {
|
||||
primary: 'font-medium text-primary',
|
||||
default: 'font-medium text-foreground',
|
||||
mono: 'font-mono text-sm text-primary',
|
||||
} as const
|
||||
|
||||
export function DataGridPrimaryCell({
|
||||
/**
|
||||
* Name cell DNA — IconTile elevated size-10.5 + truncate.
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function DataGridNameCell({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
accent = 'default',
|
||||
iconClassName = 'text-muted-foreground',
|
||||
className,
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
title: ReactNode
|
||||
subtitle?: ReactNode
|
||||
accent?: keyof typeof ACCENT_CLASS
|
||||
iconClassName?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex min-w-0 flex-col gap-0.5', className)}>
|
||||
<span className={cn('truncate', ACCENT_CLASS[accent])}>{title}</span>
|
||||
{subtitle ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
|
||||
) : null}
|
||||
<div className={cn('flex min-w-0 items-center gap-2.5', className)}>
|
||||
<IconTile variant="elevated" className="size-10.5">
|
||||
<Icon className={iconClassName} aria-hidden />
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate font-medium">{title}</span>
|
||||
{subtitle ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -42,3 +50,16 @@ export function DataGridMutedCell({
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Mono / secondary cell without semantic primary color. */
|
||||
export function DataGridMonoCell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<span className={cn('truncate font-mono text-sm', className)}>{children}</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { DataGridToolbar } from '@/components/data-grid-toolbar'
|
||||
import {
|
||||
panelCardContentFlushClassName,
|
||||
panelCardFooterClassName,
|
||||
} from '@/components/panel-card'
|
||||
import { FrameDataGrid } from '@/components/reui-kit/frame-data-grid'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { FrameFooter } from '@/components/reui/frame'
|
||||
import {
|
||||
DATA_GRID_MESSAGES_RU,
|
||||
DATA_GRID_PAGINATION_RU,
|
||||
DATA_GRID_TABLE_CLASS_NAMES,
|
||||
DATA_GRID_TABLE_LAYOUT,
|
||||
} from '@/lib/data-grid-defaults'
|
||||
|
||||
interface DataGridShellProps<TData extends object> {
|
||||
table: Table<TData>
|
||||
recordCount: number
|
||||
isLoading?: boolean
|
||||
emptyMessage?: ReactNode
|
||||
showPagination?: boolean
|
||||
tableLayout?: typeof DATA_GRID_TABLE_LAYOUT
|
||||
className?: string
|
||||
onRowClick?: (row: TData) => void
|
||||
}
|
||||
|
||||
export function DataGridShell<TData extends object>({
|
||||
table,
|
||||
recordCount,
|
||||
isLoading = false,
|
||||
emptyMessage,
|
||||
showPagination = true,
|
||||
tableLayout = DATA_GRID_TABLE_LAYOUT,
|
||||
className,
|
||||
onRowClick,
|
||||
}: DataGridShellProps<TData>) {
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={recordCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage ?? DATA_GRID_MESSAGES_RU.emptyMessage}
|
||||
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||
tableLayout={tableLayout}
|
||||
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
|
||||
className={cn('min-w-0', className)}
|
||||
onRowClick={onRowClick}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
{showPagination ? (
|
||||
<FrameFooter className={cn(panelCardFooterClassName)}>
|
||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
||||
</FrameFooter>
|
||||
) : null}
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Prefer FrameDataGrid from @/components/reui-kit */
|
||||
export function DataGridCard({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<FrameDataGrid
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
className={className}
|
||||
>
|
||||
<div className={panelCardContentFlushClassName}>{children}</div>
|
||||
</FrameDataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataGridSectionProps<TData extends object> extends DataGridShellProps<TData> {
|
||||
searchValue: string
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
toolbarFilters?: ReactNode
|
||||
toolbarActions?: ReactNode
|
||||
beforeGrid?: ReactNode
|
||||
}
|
||||
|
||||
export function DataGridSection<TData extends object>({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
toolbarFilters,
|
||||
toolbarActions,
|
||||
beforeGrid,
|
||||
...shellProps
|
||||
}: DataGridSectionProps<TData>) {
|
||||
return (
|
||||
<>
|
||||
<DataGridToolbar
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
filters={toolbarFilters}
|
||||
actions={toolbarActions}
|
||||
/>
|
||||
{beforeGrid}
|
||||
<DataGridShell {...shellProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Field } from '@evobgp/ui/components/field'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import { ListFilterIcon, SearchIcon, XIcon } from 'lucide-react'
|
||||
|
||||
import { panelCardInsetClassName } from '@/components/panel-card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
interface DataGridToolbarProps {
|
||||
searchValue: string
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
/** ReUI Filters trigger or custom filter controls. Preview: https://reui.io/preview/base/data-grid-filtering-2 */
|
||||
filters?: ReactNode
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Search + optional ReUI Filters row for Frame data grids. */
|
||||
export function DataGridToolbar({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Поиск…',
|
||||
filters,
|
||||
actions,
|
||||
className,
|
||||
}: DataGridToolbarProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center gap-3 border-b', panelCardInsetClassName, className)}>
|
||||
<Field className="min-w-[200px] flex-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end" className="gap-1">
|
||||
{searchValue.length > 0 ? (
|
||||
<InputGroupButton
|
||||
aria-label="Очистить поиск"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => onSearchChange('')}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
) : null}
|
||||
{filters ? (
|
||||
filters
|
||||
) : (
|
||||
<InputGroupButton
|
||||
aria-label="Фильтры"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
disabled
|
||||
title="Передайте filters={<Filters … />} из @/components/reui/filters"
|
||||
>
|
||||
<ListFilterIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,52 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Tags } from 'lucide-react'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createSearchFilterField,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import type { BgpCommunity } from '@/types/api'
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
createSearchFilterField('search', 'Поиск', 'Поиск community…'),
|
||||
]
|
||||
|
||||
export function DirectoriesCommunitiesGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
canWrite = false,
|
||||
onEdit,
|
||||
actions,
|
||||
}: {
|
||||
items: BgpCommunity[]
|
||||
isLoading?: boolean
|
||||
canWrite?: boolean
|
||||
onEdit?: (row: BgpCommunity) => void
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<BgpCommunity>[]>(() => {
|
||||
const cols: ColumnDef<BgpCommunity>[] = [
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
|
||||
const columns = useMemo<DataGridColumnDef<BgpCommunity>[]>(() => {
|
||||
const cols: DataGridColumnDef<BgpCommunity>[] = [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.title} accent="primary" />,
|
||||
cell: ({ row }) => <DataGridNameCell icon={Tags} title={row.original.title} />,
|
||||
meta: { headerTitle: 'Название' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'community',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.community} accent="mono" />
|
||||
),
|
||||
cell: ({ row }) => <DataGridMonoCell>{row.original.community}</DataGridMonoCell>,
|
||||
meta: { headerTitle: 'Значение' },
|
||||
},
|
||||
{
|
||||
@@ -51,9 +64,7 @@ export function DirectoriesCommunitiesGrid({
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
|
||||
),
|
||||
cell: ({ row }) => <DirectoriesRowActions onEdit={() => onEdit(row.original)} />,
|
||||
meta: { headerTitle: 'Действия' },
|
||||
})
|
||||
}
|
||||
@@ -61,22 +72,22 @@ export function DirectoriesCommunitiesGrid({
|
||||
return cols
|
||||
}, [canWrite, onEdit])
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.title} ${row.community}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
<ResourcePage
|
||||
title="BGP community"
|
||||
description="Теги для префиксов в фильтрах BIRD"
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={(row) => `${row.title} ${row.community}`}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет community"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск community…"
|
||||
primaryAction={actions}
|
||||
pinLastColumn={Boolean(canWrite && onEdit)}
|
||||
emptyState={{ title: 'Нет community', action: actions }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,36 +1,51 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Globe } from 'lucide-react'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createSearchFilterField,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import type { DohProfile } from '@/types/api'
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
createSearchFilterField('search', 'Поиск', 'Поиск DoH профилей…'),
|
||||
]
|
||||
|
||||
export function DirectoriesDohGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
canWrite = false,
|
||||
onEdit,
|
||||
actions,
|
||||
}: {
|
||||
items: DohProfile[]
|
||||
isLoading?: boolean
|
||||
canWrite?: boolean
|
||||
onEdit?: (row: DohProfile) => void
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DohProfile>[]>(() => {
|
||||
const cols: ColumnDef<DohProfile>[] = [
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
|
||||
const columns = useMemo<DataGridColumnDef<DohProfile>[]>(() => {
|
||||
const cols: DataGridColumnDef<DohProfile>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.url,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
<DataGridNameCell
|
||||
icon={Globe}
|
||||
title={row.original.name ?? row.original.url}
|
||||
subtitle={row.original.name ? row.original.url : undefined}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Название' },
|
||||
@@ -38,7 +53,7 @@ export function DirectoriesDohGrid({
|
||||
{
|
||||
accessorKey: 'url',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.url} accent="mono" />,
|
||||
cell: ({ row }) => <DataGridMonoCell>{row.original.url}</DataGridMonoCell>,
|
||||
meta: { headerTitle: 'URL' },
|
||||
},
|
||||
{
|
||||
@@ -56,9 +71,7 @@ export function DirectoriesDohGrid({
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
|
||||
),
|
||||
cell: ({ row }) => <DirectoriesRowActions onEdit={() => onEdit(row.original)} />,
|
||||
meta: { headerTitle: 'Действия' },
|
||||
})
|
||||
}
|
||||
@@ -66,22 +79,22 @@ export function DirectoriesDohGrid({
|
||||
return cols
|
||||
}, [canWrite, onEdit])
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.name ?? ''} ${row.url}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
<ResourcePage
|
||||
title="DoH профили"
|
||||
description="Резолверы DNS-over-HTTPS для доменных модулей"
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={(row) => `${row.name ?? ''} ${row.url}`}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет DoH профилей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск DoH профилей…"
|
||||
primaryAction={actions}
|
||||
pinLastColumn={Boolean(canWrite && onEdit)}
|
||||
emptyState={{ title: 'Нет DoH профилей', action: actions }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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="[email protected]"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -5,14 +5,20 @@ import { toast } from 'sonner'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
import {
|
||||
Cascader,
|
||||
CascaderContent,
|
||||
CascaderPanel,
|
||||
CascaderTrigger,
|
||||
} from '@/components/reui/cascader/cascader'
|
||||
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 type {
|
||||
BgpCommunity,
|
||||
@@ -22,8 +28,10 @@ import type {
|
||||
} from '@/types/api'
|
||||
|
||||
/**
|
||||
* Lookup wizard step 3 — module + community (bare content for single Frame).
|
||||
* Lookup wizard step 3 — cascader module → community (wizard-2).
|
||||
* @see https://reui.io/preview/base/wizard-2
|
||||
* @see https://reui.io/docs/components/base/cascader
|
||||
* @see https://reui.io/docs/components/base/stepper
|
||||
*/
|
||||
|
||||
function hostPrefixFromIp(ip: string): string {
|
||||
@@ -34,6 +42,31 @@ function moduleTypeForKind(kind: LookupQueryKind): ModuleRow['type'] {
|
||||
return kind === 'domain' ? 'DOMAINS' : 'IP_RANGES'
|
||||
}
|
||||
|
||||
type LookupPick = { kind: 'module' } | { kind: 'community'; communityId: string }
|
||||
|
||||
function encodeModuleValue(moduleId: string): string {
|
||||
return `m:${moduleId}`
|
||||
}
|
||||
|
||||
function encodeCommunityValue(moduleId: string, communityId: string): string {
|
||||
return `c:${moduleId}:${communityId}`
|
||||
}
|
||||
|
||||
function parsePick(
|
||||
value: string,
|
||||
): { moduleId: string; communityId: string | null } | null {
|
||||
if (value.startsWith('m:')) {
|
||||
return { moduleId: value.slice(2), communityId: null }
|
||||
}
|
||||
if (value.startsWith('c:')) {
|
||||
const rest = value.slice(2)
|
||||
const sep = rest.indexOf(':')
|
||||
if (sep <= 0) return null
|
||||
return { moduleId: rest.slice(0, sep), communityId: rest.slice(sep + 1) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function LookupAddStep({
|
||||
data,
|
||||
modules,
|
||||
@@ -48,49 +81,58 @@ export function LookupAddStep({
|
||||
onAdded: () => void | Promise<void>
|
||||
}) {
|
||||
const wantedType = moduleTypeForKind(data.query_kind)
|
||||
const allowEmptyCommunity = data.query_kind === 'domain'
|
||||
const eligible = useMemo(
|
||||
() => modules.filter((m) => m.type === wantedType),
|
||||
[modules, wantedType],
|
||||
)
|
||||
|
||||
const [moduleId, setModuleId] = useState('')
|
||||
const [communityId, setCommunityId] = useState<string | null>(null)
|
||||
const [pick, setPick] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const cascaderItems = useMemo<CascaderNode<LookupPick>[]>(
|
||||
() =>
|
||||
eligible.map((mod) => ({
|
||||
value: encodeModuleValue(mod.id),
|
||||
label: mod.name,
|
||||
description: mod.id,
|
||||
data: { kind: 'module' },
|
||||
children: communities.map((community) => ({
|
||||
value: encodeCommunityValue(mod.id, community.id),
|
||||
label: community.title,
|
||||
description: community.community,
|
||||
data: { kind: 'community', communityId: community.id },
|
||||
})),
|
||||
})),
|
||||
[eligible, communities],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (eligible.length === 0) {
|
||||
setModuleId('')
|
||||
setPick('')
|
||||
return
|
||||
}
|
||||
setModuleId((prev) =>
|
||||
prev && eligible.some((m) => m.id === prev) ? prev : eligible[0]!.id,
|
||||
)
|
||||
const first = eligible[0]!
|
||||
const defaultCommunity = first.default_community_id
|
||||
setPick((prev) => {
|
||||
if (prev && parsePick(prev)?.moduleId && eligible.some((m) => m.id === parsePick(prev)?.moduleId)) {
|
||||
return prev
|
||||
}
|
||||
if (defaultCommunity) return encodeCommunityValue(first.id, defaultCommunity)
|
||||
return encodeModuleValue(first.id)
|
||||
})
|
||||
}, [eligible])
|
||||
|
||||
useEffect(() => {
|
||||
const mod = eligible.find((m) => m.id === moduleId)
|
||||
if (!mod) {
|
||||
setCommunityId(null)
|
||||
return
|
||||
}
|
||||
setCommunityId(mod.default_community_id ?? null)
|
||||
}, [moduleId, eligible])
|
||||
|
||||
const moduleItems = useMemo(
|
||||
() =>
|
||||
eligible.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.name,
|
||||
})),
|
||||
[eligible],
|
||||
)
|
||||
const parsed = pick ? parsePick(pick) : null
|
||||
const moduleId = parsed?.moduleId ?? ''
|
||||
const communityId = parsed?.communityId ?? null
|
||||
|
||||
async function handleAdd() {
|
||||
if (!moduleId) {
|
||||
toast.error('Выберите модуль')
|
||||
return
|
||||
}
|
||||
if (data.query_kind !== 'domain' && !communityId) {
|
||||
if (!allowEmptyCommunity && !communityId) {
|
||||
toast.error('Укажите community')
|
||||
return
|
||||
}
|
||||
@@ -128,7 +170,7 @@ export function LookupAddStep({
|
||||
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
||||
<AlertDescription>
|
||||
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
|
||||
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
|
||||
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules" search={{ create: true }} />}>
|
||||
Перейти к модулям
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
@@ -151,28 +193,34 @@ export function LookupAddStep({
|
||||
«{valueLabel}» отсутствует в списках. Выберите модуль и community.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lookup-add-module">Модуль ({wantedType})</FieldLabel>
|
||||
<SelectMenu
|
||||
id="lookup-add-module"
|
||||
items={moduleItems}
|
||||
value={moduleId}
|
||||
placeholder="Выберите модуль"
|
||||
onValueChange={(v) => {
|
||||
if (v) setModuleId(v)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<CommunitySelect
|
||||
id="lookup-add-comm"
|
||||
label="Community"
|
||||
value={communityId}
|
||||
onValueChange={setCommunityId}
|
||||
communities={communities}
|
||||
nullable={data.query_kind === 'domain'}
|
||||
/>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lookup-add-cascader">Модуль и community</FieldLabel>
|
||||
<Cascader
|
||||
items={cascaderItems}
|
||||
value={pick}
|
||||
onValueChange={setPick}
|
||||
selectable={allowEmptyCommunity ? 'any' : 'leaf'}
|
||||
mode="columns"
|
||||
labels={{
|
||||
search: 'Поиск',
|
||||
empty: 'Ничего не найдено',
|
||||
back: 'Назад',
|
||||
rootLevel: 'Модули',
|
||||
}}
|
||||
>
|
||||
<CascaderTrigger
|
||||
id="lookup-add-cascader"
|
||||
className="w-full"
|
||||
aria-label="Модуль и community"
|
||||
>
|
||||
<CascaderValue placeholder="Выберите модуль и community" display="path" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent>
|
||||
<CascaderInput />
|
||||
<CascaderPanel />
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
</Field>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Назад
|
||||
@@ -180,7 +228,7 @@ export function LookupAddStep({
|
||||
<LoadingButton
|
||||
loading={saving}
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!moduleId || (data.query_kind !== 'domain' && !communityId)}
|
||||
disabled={!moduleId || (!allowEmptyCommunity && !communityId)}
|
||||
>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Boxes, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridMonoCell, DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import type { LookupMatch } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Lookup matches grid — bare section for wizard-2 single Frame.
|
||||
* Lookup matches grid — ResourcePage hideHeader inside wizard-2 Frame.
|
||||
* @see https://reui.io/preview/base/wizard-2
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
const filterFields: FilterField[] = [
|
||||
{
|
||||
id: 'search',
|
||||
label: 'Поиск',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
placeholder: 'Фильтр совпадений…',
|
||||
},
|
||||
]
|
||||
|
||||
export function LookupMatchesGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
@@ -24,8 +38,11 @@ export function LookupMatchesGrid({
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<LookupMatch>[]>(
|
||||
const columns = useMemo<DataGridColumnDef<LookupMatch>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'layer',
|
||||
@@ -44,10 +61,10 @@ export function LookupMatchesGrid({
|
||||
accessorKey: 'module_name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
<DataGridNameCell
|
||||
icon={Boxes}
|
||||
title={row.original.module_name}
|
||||
subtitle={row.original.module_type}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
@@ -56,15 +73,14 @@ export function LookupMatchesGrid({
|
||||
accessorKey: 'matched_value',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.matched_value}
|
||||
subtitle={
|
||||
row.original.resolved_ip
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<DataGridMonoCell>{row.original.matched_value}</DataGridMonoCell>
|
||||
<DataGridMutedCell>
|
||||
{row.original.resolved_ip
|
||||
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
|
||||
: row.original.match_kind
|
||||
}
|
||||
accent="mono"
|
||||
/>
|
||||
: row.original.match_kind}
|
||||
</DataGridMutedCell>
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Совпадение' },
|
||||
},
|
||||
@@ -76,13 +92,15 @@ export function LookupMatchesGrid({
|
||||
const title = row.original.community_title?.trim()
|
||||
const value = row.original.community?.trim()
|
||||
if (!title && !value) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<DataGridPrimaryCell
|
||||
title={title || value || '—'}
|
||||
subtitle={title && value && title !== value ? value : undefined}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<DataGridMonoCell>{title || value || '—'}</DataGridMonoCell>
|
||||
{title && value && title !== value ? (
|
||||
<DataGridMutedCell>{value}</DataGridMutedCell>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Community' },
|
||||
@@ -95,7 +113,7 @@ export function LookupMatchesGrid({
|
||||
row.original.source ? (
|
||||
<CategoryBadge>{row.original.source}</CategoryBadge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
<DataGridMutedCell>—</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Источник' },
|
||||
},
|
||||
@@ -103,35 +121,29 @@ export function LookupMatchesGrid({
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.layer} ${row.module_name} ${row.module_type} ${row.matched_value} ${row.community ?? ''} ${row.community_title ?? ''} ${row.source ?? ''}`,
|
||||
getRowId: (row) =>
|
||||
`${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h3 className="text-sm font-semibold">Совпадения</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Entries и snapshots · клик по строке открывает модуль
|
||||
</p>
|
||||
</div>
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет совпадений"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Фильтр совпадений…"
|
||||
onRowClick={(row) =>
|
||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ResourcePage
|
||||
title="Совпадения"
|
||||
description="Entries и snapshots · клик по строке открывает модуль"
|
||||
hideHeader
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={(item, field) => {
|
||||
if (field !== 'search') return undefined
|
||||
return `${item.layer} ${item.module_name} ${item.module_type} ${item.matched_value} ${item.community ?? ''} ${item.community_title ?? ''} ${item.source ?? ''}`
|
||||
}}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) =>
|
||||
`${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`
|
||||
}
|
||||
isLoading={isLoading}
|
||||
onRowClick={(row) =>
|
||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
||||
}
|
||||
emptyState={{ title: 'Нет совпадений' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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 { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { dohProfileShortLabel } from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
|
||||
import { useCreateModuleMutation } from '@/queries/modules'
|
||||
import type {
|
||||
BgpCommunity,
|
||||
DohProfile,
|
||||
DohResolverPolicy,
|
||||
ModuleCreate,
|
||||
ModuleRow,
|
||||
ModuleType,
|
||||
} from '@/types/api'
|
||||
|
||||
interface ModuleCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
communities: BgpCommunity[]
|
||||
dohProfiles: DohProfile[]
|
||||
onCreated?: (mod: ModuleRow) => void
|
||||
}
|
||||
|
||||
/** @see https://reui.io/preview/base/form-7 */
|
||||
/** @see https://reui.io/preview/base/sheet-8 */
|
||||
|
||||
const MODULE_TYPE_ITEMS: { value: ModuleType; label: string }[] = [
|
||||
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') },
|
||||
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
|
||||
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
|
||||
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
|
||||
]
|
||||
|
||||
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
|
||||
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
|
||||
{ value: 'failover', label: dohPolicyRu('failover') },
|
||||
{ value: 'union', label: dohPolicyRu('union') },
|
||||
]
|
||||
|
||||
export function ModuleCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
communities,
|
||||
dohProfiles,
|
||||
onCreated,
|
||||
}: ModuleCreateDialogProps) {
|
||||
const createMutation = useCreateModuleMutation()
|
||||
|
||||
const [type, setType] = useState<ModuleType>('IP_RANGES')
|
||||
const [name, setName] = useState('')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [priority, setPriority] = useState('0')
|
||||
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
|
||||
const [cronExpr, setCronExpr] = useState('')
|
||||
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
|
||||
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
|
||||
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
|
||||
|
||||
const isDomains = type === 'DOMAINS'
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setType('IP_RANGES')
|
||||
setName('')
|
||||
setEnabled(true)
|
||||
setPriority('0')
|
||||
setRefreshIntervalSec('')
|
||||
setCronExpr('')
|
||||
setDefaultCommunityId(null)
|
||||
setDohResolverPolicy('primary_only')
|
||||
setDohProfileIds([])
|
||||
}, [open])
|
||||
|
||||
function toggleDohProfile(id: string, checked: boolean) {
|
||||
setDohProfileIds((prev) => {
|
||||
if (checked) {
|
||||
if (prev.includes(id)) return prev
|
||||
return [...prev, id]
|
||||
}
|
||||
return prev.filter((x) => x !== id)
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
toast.error('Укажите название модуля')
|
||||
return
|
||||
}
|
||||
|
||||
const priorityNum = Number(priority)
|
||||
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
|
||||
toast.error('Приоритет должен быть целым числом')
|
||||
return
|
||||
}
|
||||
|
||||
let refresh: number | undefined
|
||||
if (refreshIntervalSec.trim() !== '') {
|
||||
const n = Number(refreshIntervalSec)
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
toast.error('Интервал обновления должен быть целым числом ≥ 0')
|
||||
return
|
||||
}
|
||||
refresh = n
|
||||
}
|
||||
|
||||
const body: ModuleCreate = {
|
||||
type,
|
||||
name: trimmedName,
|
||||
enabled,
|
||||
priority: priorityNum,
|
||||
}
|
||||
if (refresh !== undefined) {
|
||||
body.refresh_interval_sec = refresh
|
||||
}
|
||||
const cron = cronExpr.trim()
|
||||
if (cron) {
|
||||
body.cron_expr = cron
|
||||
}
|
||||
if (defaultCommunityId) {
|
||||
body.default_community_id = defaultCommunityId
|
||||
}
|
||||
if (isDomains) {
|
||||
body.doh_resolver_policy = dohResolverPolicy
|
||||
body.doh_profile_ids = dohProfileIds
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createMutation.mutateAsync(body)
|
||||
onOpenChange(false)
|
||||
onCreated?.(created)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новый модуль"
|
||||
description="Тип задаётся один раз. Записи добавляются на карточке модуля."
|
||||
className="sm:max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
id="mod-create-type"
|
||||
label="Тип"
|
||||
items={MODULE_TYPE_ITEMS}
|
||||
value={type}
|
||||
onValueChange={(v) => {
|
||||
if (v) setType(v)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mod-create-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="mod-create-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Имя модуля"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field orientation="horizontal">
|
||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<FieldLabel htmlFor="mod-create-enabled">Включён</FieldLabel>
|
||||
<FieldDescription>
|
||||
Выключенный модуль не участвует в обновлении и применении.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="mod-create-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mod-create-priority">Приоритет</FieldLabel>
|
||||
<Input
|
||||
id="mod-create-priority"
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mod-create-interval">Интервал обновления (сек)</FieldLabel>
|
||||
<Input
|
||||
id="mod-create-interval"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="пусто = по умолчанию"
|
||||
value={refreshIntervalSec}
|
||||
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="mod-create-cron">Cron (опционально)</FieldLabel>
|
||||
<Input
|
||||
id="mod-create-cron"
|
||||
placeholder="0 * * * *"
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<CommunitySelect
|
||||
id="mod-create-community"
|
||||
label="Community по умолчанию"
|
||||
value={defaultCommunityId}
|
||||
onValueChange={setDefaultCommunityId}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
|
||||
{isDomains ? (
|
||||
<>
|
||||
<SelectField
|
||||
id="mod-create-doh-policy"
|
||||
label="Политика DoH"
|
||||
items={DOH_POLICY_ITEMS}
|
||||
value={dohResolverPolicy}
|
||||
onValueChange={(v) => {
|
||||
if (v) setDohResolverPolicy(v)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>DoH профили</FieldLabel>
|
||||
{dohProfiles.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
{dohProfiles.map((p) => {
|
||||
const checked = dohProfileIds.includes(p.id)
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
htmlFor={`mod-create-doh-${p.id}`}
|
||||
className="flex cursor-pointer items-start gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={`mod-create-doh-${p.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{dohProfileShortLabel(p.id, dohProfiles)}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs" title={p.url}>
|
||||
{p.url}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { Pencil, SearchIcon, Trash2 } from 'lucide-react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridMonoCell, DataGridMutedCell } 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 { formatDateTime } from '@/lib/modules/display'
|
||||
import { communityLabel } from '@/lib/modules/helpers'
|
||||
import { cdnSourceKindRu } from '@/lib/ui-labels'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type {
|
||||
AsEntry,
|
||||
BgpCommunity,
|
||||
@@ -27,6 +30,18 @@ type DeleteTarget =
|
||||
| { kind: 'cdn'; entry: CdnSource }
|
||||
| { kind: 'as'; entry: AsEntry }
|
||||
|
||||
type EntryRow = DomainEntry | IpRangeEntry | CdnSource | AsEntry
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
{
|
||||
id: 'search',
|
||||
label: 'Поиск',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
placeholder: 'Поиск записей…',
|
||||
},
|
||||
]
|
||||
|
||||
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
@@ -47,6 +62,12 @@ function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () =>
|
||||
)
|
||||
}
|
||||
|
||||
function searchText(row: EntryRow): string {
|
||||
return Object.values(row as Record<string, unknown>)
|
||||
.filter((value) => typeof value === 'string' || typeof value === 'number')
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function ModuleEntriesGrid({
|
||||
mod,
|
||||
rows,
|
||||
@@ -54,6 +75,14 @@ export function ModuleEntriesGrid({
|
||||
onEdit,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
error = null,
|
||||
onRetry,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
emptyTitle = 'Нет записей',
|
||||
emptyDescription,
|
||||
}: {
|
||||
mod: ModuleRow
|
||||
rows: Record<string, unknown>[]
|
||||
@@ -61,198 +90,207 @@ export function ModuleEntriesGrid({
|
||||
onEdit: (target: DeleteTarget) => void
|
||||
onDelete: (target: DeleteTarget) => void
|
||||
isLoading?: boolean
|
||||
isError?: boolean
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}) {
|
||||
const columns = useMemo(() => {
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
|
||||
const columns = useMemo<DataGridColumnDef<EntryRow>[]>(() => {
|
||||
if (mod.type === 'DOMAINS') {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'fqdn',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="FQDN" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
||||
<DataGridPrimaryCell title={row.original.fqdn} accent="mono" />
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="FQDN" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMonoCell>{(row.original as DomainEntry).fqdn}</DataGridMonoCell>
|
||||
),
|
||||
meta: { headerTitle: 'FQDN' },
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
header: 'Community',
|
||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{communityLabel(row.original.community_id, communities)}
|
||||
</span>
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{communityLabel((row.original as DomainEntry).community_id, communities)}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
||||
cell: ({ row }) => (
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'domain', entry: row.original })}
|
||||
onDelete={() => onDelete({ kind: 'domain', entry: row.original })}
|
||||
onEdit={() => onEdit({ kind: 'domain', entry: row.original as DomainEntry })}
|
||||
onDelete={() => onDelete({ kind: 'domain', entry: row.original as DomainEntry })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<DomainEntry>[]
|
||||
]
|
||||
}
|
||||
|
||||
if (mod.type === 'IP_RANGES') {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'prefix',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="Префикс (CIDR)" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
||||
<DataGridPrimaryCell title={row.original.prefix} accent="mono" />
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс (CIDR)" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMonoCell>{(row.original as IpRangeEntry).prefix}</DataGridMonoCell>
|
||||
),
|
||||
meta: { headerTitle: 'Префикс (CIDR)' },
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
header: 'Community',
|
||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{communityLabel(row.original.community_id, communities)}
|
||||
</span>
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{communityLabel((row.original as IpRangeEntry).community_id, communities)}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
||||
cell: ({ row }) => (
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'ip-range', entry: row.original })}
|
||||
onDelete={() => onDelete({ kind: 'ip-range', entry: row.original })}
|
||||
onEdit={() => onEdit({ kind: 'ip-range', entry: row.original as IpRangeEntry })}
|
||||
onDelete={() => onDelete({ kind: 'ip-range', entry: row.original as IpRangeEntry })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<IpRangeEntry>[]
|
||||
]
|
||||
}
|
||||
|
||||
if (mod.type === 'CDN_CIDRS') {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'url',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="URL" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<DataGridPrimaryCell title={row.original.url} accent="mono" className="max-w-xs" />
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMonoCell className="max-w-xs">
|
||||
{(row.original as CdnSource).url}
|
||||
</DataGridMonoCell>
|
||||
),
|
||||
meta: { headerTitle: 'URL' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'source_kind',
|
||||
header: 'Тип',
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<CategoryBadge>{cdnSourceKindRu(row.original.source_kind)}</CategoryBadge>
|
||||
cell: ({ row }) => (
|
||||
<CategoryBadge>{cdnSourceKindRu((row.original as CdnSource).source_kind)}</CategoryBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
header: 'Community',
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{communityLabel(row.original.community_id, communities)}
|
||||
</span>
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{communityLabel((row.original as CdnSource).community_id, communities)}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row: CdnSource) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="Обновлено" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<DataGridMutedCell>{formatDateTime(row.original.last_refreshed_at)}</DataGridMutedCell>
|
||||
accessorFn: (row) => (row as CdnSource).last_refreshed_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{formatDateTime((row.original as CdnSource).last_refreshed_at)}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
cell: ({ row }) => (
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'cdn', entry: row.original })}
|
||||
onDelete={() => onDelete({ kind: 'cdn', entry: row.original })}
|
||||
onEdit={() => onEdit({ kind: 'cdn', entry: row.original as CdnSource })}
|
||||
onDelete={() => onDelete({ kind: 'cdn', entry: row.original as CdnSource })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<CdnSource>[]
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'asn',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="ASN" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<span className="font-mono text-sm">{row.original.asn}</span>
|
||||
),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => <DataGridMonoCell>{(row.original as AsEntry).asn}</DataGridMonoCell>,
|
||||
meta: { headerTitle: 'ASN' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'asn_name',
|
||||
header: 'Имя',
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<span className="text-sm">{row.original.asn_name ?? '—'}</span>
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">{(row.original as AsEntry).asn_name ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'prefix_count',
|
||||
header: 'Префиксов',
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<span className="font-mono text-sm">{row.original.prefix_count ?? '—'}</span>
|
||||
cell: ({ row }) => (
|
||||
<DataGridMonoCell>{(row.original as AsEntry).prefix_count ?? '—'}</DataGridMonoCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
header: 'Community',
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{communityLabel(row.original.community_id, communities)}
|
||||
</span>
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{communityLabel((row.original as AsEntry).community_id, communities)}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
cell: ({ row }) => (
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'as', entry: row.original })}
|
||||
onDelete={() => onDelete({ kind: 'as', entry: row.original })}
|
||||
onEdit={() => onEdit({ kind: 'as', entry: row.original as AsEntry })}
|
||||
onDelete={() => onDelete({ kind: 'as', entry: row.original as AsEntry })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<AsEntry>[]
|
||||
]
|
||||
}, [communities, mod.type, onDelete, onEdit])
|
||||
|
||||
type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry
|
||||
const data = rows as unknown as RowType[]
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns: columns as ColumnDef<RowType>[],
|
||||
getSearchText: (row) => {
|
||||
const r = row as Record<string, unknown>
|
||||
return Object.values(r)
|
||||
.filter((v) => typeof v === 'string' || typeof v === 'number')
|
||||
.join(' ')
|
||||
},
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
const data = rows as unknown as EntryRow[]
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
<ResourcePage
|
||||
title={title}
|
||||
description={description}
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={(item, field) => {
|
||||
if (field !== 'search') return undefined
|
||||
return searchText(item)
|
||||
}}
|
||||
columns={columns}
|
||||
data={data}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет записей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск записей…"
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
primaryAction={actions}
|
||||
pinLastColumn
|
||||
emptyState={{ title: emptyTitle, description: emptyDescription, action: actions }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,6 @@ import { toast } from 'sonner'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
|
||||
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
|
||||
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
|
||||
@@ -126,47 +123,37 @@ export function ModuleEntriesSection({
|
||||
}
|
||||
}
|
||||
|
||||
const addButton = (
|
||||
<Button size="sm" type="button" onClick={openCreate}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FrameDataGrid
|
||||
<ModuleEntriesGrid
|
||||
mod={mod}
|
||||
rows={items}
|
||||
communities={communities}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
title={meta.title}
|
||||
description={meta.description}
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={openCreate}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle={meta.emptyTitle}
|
||||
emptyDescription={meta.emptyDescription}
|
||||
skeleton={<TableSkeleton rows={5} cols={3} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(rows) => (
|
||||
<ModuleEntriesGrid
|
||||
mod={mod}
|
||||
rows={rows}
|
||||
communities={communities}
|
||||
isLoading={isLoading}
|
||||
onEdit={(target) => {
|
||||
if (target.kind === 'domain') setEditDomain(target.entry)
|
||||
if (target.kind === 'ip-range') setEditIpRange(target.entry)
|
||||
if (target.kind === 'cdn') setEditCdn(target.entry)
|
||||
if (target.kind === 'as') setEditAs(target.entry)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</FrameDataGrid>
|
||||
actions={addButton}
|
||||
emptyTitle={meta.emptyTitle}
|
||||
emptyDescription={meta.emptyDescription}
|
||||
onEdit={(target) => {
|
||||
if (target.kind === 'domain') setEditDomain(target.entry)
|
||||
if (target.kind === 'ip-range') setEditIpRange(target.entry)
|
||||
if (target.kind === 'cdn') setEditCdn(target.entry)
|
||||
if (target.kind === 'as') setEditAs(target.entry)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
|
||||
{mod.type === 'DOMAINS' ? (
|
||||
<ModuleDomainEntryDialog
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,35 +1,91 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Boxes } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { Boxes, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createTextFilterQuery,
|
||||
renderSingleSelectedLabel,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
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 TYPE_OPTIONS = [
|
||||
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
|
||||
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
|
||||
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
|
||||
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') },
|
||||
]
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
{
|
||||
id: 'search',
|
||||
label: 'Поиск',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
placeholder: 'Название или тип…',
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
label: 'Тип',
|
||||
type: 'select',
|
||||
searchable: false,
|
||||
options: TYPE_OPTIONS,
|
||||
renderValue: ({ values }) => renderSingleSelectedLabel(values, TYPE_OPTIONS),
|
||||
},
|
||||
]
|
||||
|
||||
function getFilterFieldValue(item: ModuleRow, field: string): unknown {
|
||||
switch (field) {
|
||||
case 'search':
|
||||
return `${item.name} ${item.type} ${moduleTypeRu(item.type)} ${item.enabled ? 'включён' : 'выключен'}`
|
||||
case 'type':
|
||||
return item.type
|
||||
default:
|
||||
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 ModulesListGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
title = 'Все модули',
|
||||
actions,
|
||||
}: {
|
||||
items: ModuleRow[]
|
||||
isLoading?: boolean
|
||||
title?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<ModuleRow>[]>(
|
||||
const columns = useMemo<DataGridColumnDef<ModuleRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes className="size-4 shrink-0 text-muted-foreground" />
|
||||
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[280px]" />
|
||||
</div>
|
||||
<DataGridNameCell icon={Boxes} title={row.original.name} className="max-w-[280px]" />
|
||||
),
|
||||
meta: { headerTitle: 'Название' },
|
||||
},
|
||||
@@ -65,34 +121,32 @@ export function ModulesListGrid({
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
sortingFn: (a, b) => {
|
||||
const av = a.original.last_refreshed_at ?? ''
|
||||
const bv = b.original.last_refreshed_at ?? ''
|
||||
return av.localeCompare(bv)
|
||||
},
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'включён' : 'выключен'}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
<ResourcePage
|
||||
title={title}
|
||||
description="Маршрутные списки"
|
||||
tabs={MODULE_TABS}
|
||||
tabFilter={tabFilter}
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет модулей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск модулей…"
|
||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||
primaryAction={actions}
|
||||
onRowClick={(row) =>
|
||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })
|
||||
}
|
||||
emptyState={{ title: 'Нет модулей' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
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 { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
|
||||
import {
|
||||
isReadyCheckOk,
|
||||
isSystemReady,
|
||||
@@ -14,8 +12,6 @@ import {
|
||||
} from '@/lib/metrics'
|
||||
import { readyCheckRu } from '@/lib/ui-labels'
|
||||
import type { ReadyStatus } from '@/queries/monitoring'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
const READY_CHECK_ICONS: Record<string, typeof Database> = {
|
||||
postgres: Database,
|
||||
@@ -79,32 +75,19 @@ export function MonitoringReadyGrid({
|
||||
return rows
|
||||
}, [health?.ok, ready.checks, ready.status])
|
||||
|
||||
const columns = useMemo<ColumnDef<ReadyCheckRow>[]>(
|
||||
const columns = useMemo<DataGridColumnDef<ReadyCheckRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'label',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Проверка" />,
|
||||
cell: ({ row }) => {
|
||||
const Icon = row.original.icon
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5 py-0.5">
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-3.5',
|
||||
row.original.iconClassName,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<Icon />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.label}
|
||||
subtitle={row.original.subtitle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<DataGridNameCell
|
||||
icon={row.original.icon}
|
||||
title={row.original.label}
|
||||
subtitle={row.original.subtitle}
|
||||
iconClassName={row.original.iconClassName}
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Проверка' },
|
||||
},
|
||||
{
|
||||
@@ -112,9 +95,7 @@ export function MonitoringReadyGrid({
|
||||
enableSorting: false,
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-start">
|
||||
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
||||
</div>
|
||||
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
@@ -122,23 +103,15 @@ export function MonitoringReadyGrid({
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.label} ${row.subtitle ?? ''} ${row.statusLabel}`,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: 20,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
showPagination={false}
|
||||
emptyMessage="Нет проверок"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск проверок…"
|
||||
<FrameDataGrid
|
||||
title="Доступность и готовность"
|
||||
description="Проверки живучести и готовности (/v1/health, /v1/ready)"
|
||||
columns={columns}
|
||||
data={data}
|
||||
rowId={(row) => row.id}
|
||||
emptyTitle="Нет проверок"
|
||||
pagination={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { CheckIcon, SearchIcon, XIcon } from 'lucide-react'
|
||||
import { CheckIcon, Network, SearchIcon, XIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
createFilter,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
ResourcePage,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||
import {
|
||||
useApproveDiscoveredPeerMutation,
|
||||
@@ -40,17 +39,12 @@ function speakerLabel(s: SpeakerRow): string {
|
||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||
}
|
||||
|
||||
function createDefaultFilters(): Filter[] {
|
||||
return [createFilter('neighbor', 'contains', [''])]
|
||||
}
|
||||
|
||||
const filterFields: FilterFieldConfig[] = [
|
||||
const filterFields: FilterField[] = [
|
||||
{
|
||||
key: 'neighbor',
|
||||
id: 'neighbor',
|
||||
label: 'Сосед',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-48',
|
||||
placeholder: 'IP или ID соседа…',
|
||||
},
|
||||
]
|
||||
@@ -74,7 +68,9 @@ export function NetworkDiscoveredPeersCard({
|
||||
}: NetworkDiscoveredPeersCardProps) {
|
||||
const approveMutation = useApproveDiscoveredPeerMutation()
|
||||
const rejectMutation = useRejectDiscoveredPeerMutation()
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('neighbor'),
|
||||
)
|
||||
const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null)
|
||||
const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
@@ -88,7 +84,7 @@ export function NetworkDiscoveredPeersCard({
|
||||
[speakers],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<PeerDiscoveryRow, unknown>[]>(
|
||||
const columns = useMemo<DataGridColumnDef<PeerDiscoveryRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'neighbor_id',
|
||||
@@ -97,10 +93,10 @@ export function NetworkDiscoveredPeersCard({
|
||||
<DataGridColumnHeader column={column} title="ID соседа" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
<DataGridNameCell
|
||||
icon={Network}
|
||||
title={row.original.neighbor_id || '—'}
|
||||
subtitle={row.original.neighbor}
|
||||
accent="mono"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'ID соседа' },
|
||||
@@ -109,7 +105,7 @@ export function NetworkDiscoveredPeersCard({
|
||||
accessorKey: 'remote_asn',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.remote_asn || '—'}</span>
|
||||
<DataGridMonoCell className="text-xs">{row.original.remote_asn || '—'}</DataGridMonoCell>
|
||||
),
|
||||
meta: { headerTitle: 'ASN' },
|
||||
},
|
||||
@@ -186,9 +182,9 @@ export function NetworkDiscoveredPeersCard({
|
||||
title="На одобрение"
|
||||
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultFilters())}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('neighbor'))}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
columns={columns}
|
||||
data={items}
|
||||
@@ -197,6 +193,7 @@ export function NetworkDiscoveredPeersCard({
|
||||
isError={isError}
|
||||
error={error instanceof Error ? error : null}
|
||||
onRetry={onRetry}
|
||||
pinLastColumn
|
||||
emptyState={{
|
||||
title: 'Нет ожидающих пиров',
|
||||
description:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Plus, SearchIcon, ActivityIcon, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
@@ -11,12 +10,13 @@ import {
|
||||
peerColumns,
|
||||
peerTabFilter,
|
||||
} from '@/components/network/network-peers-grid'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
createFilter,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
||||
ResourcePage,
|
||||
createTextFilterQuery,
|
||||
renderSingleSelectedLabel,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import { useDeletePeerMutation } from '@/queries/network'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
@@ -46,37 +46,29 @@ const SESSION_STATE_OPTIONS = [
|
||||
{ value: 'OpenConfirm', label: 'Open подтверждён' },
|
||||
]
|
||||
|
||||
function createDefaultPeerFilters(): Filter[] {
|
||||
return [createFilter('name', 'contains', [''])]
|
||||
}
|
||||
|
||||
const peerFilterFields: FilterFieldConfig[] = [
|
||||
const peerFilterFields: FilterField[] = [
|
||||
{
|
||||
key: 'name',
|
||||
id: 'name',
|
||||
label: 'Имя',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-48',
|
||||
placeholder: 'Поиск по имени…',
|
||||
},
|
||||
{
|
||||
key: 'neighbor',
|
||||
id: 'neighbor',
|
||||
label: 'Сосед',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-48',
|
||||
placeholder: 'Адрес соседа…',
|
||||
},
|
||||
{
|
||||
key: 'session_state',
|
||||
id: 'session_state',
|
||||
label: 'Состояние',
|
||||
icon: <ActivityIcon className="size-3.5" aria-hidden />,
|
||||
type: 'select',
|
||||
searchable: false,
|
||||
className: 'w-[160px]',
|
||||
options: SESSION_STATE_OPTIONS,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
|
||||
renderValue: ({ values }) => renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -100,7 +92,7 @@ export function NetworkPeersCard({
|
||||
const deleteMutation = useDeletePeerMutation()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<PeerRow | null>(null)
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() => createTextFilterQuery('name'))
|
||||
|
||||
const addButton = useMemo(
|
||||
() => (
|
||||
@@ -112,7 +104,7 @@ export function NetworkPeersCard({
|
||||
[],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<PeerRow, unknown>[]>(
|
||||
const columns = useMemo<DataGridColumnDef<PeerRow>[]>(
|
||||
() => [
|
||||
...peerColumns,
|
||||
{
|
||||
@@ -147,9 +139,9 @@ export function NetworkPeersCard({
|
||||
tabs={PEER_TABS}
|
||||
tabFilter={peerTabFilter}
|
||||
filterFields={peerFilterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultPeerFilters())}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('name'))}
|
||||
getFilterFieldValue={getPeerFilterFieldValue}
|
||||
columns={columns}
|
||||
data={items}
|
||||
@@ -159,6 +151,7 @@ export function NetworkPeersCard({
|
||||
error={error instanceof Error ? error : null}
|
||||
onRetry={onRetry}
|
||||
primaryAction={addButton}
|
||||
pinLastColumn
|
||||
emptyState={{
|
||||
title: 'Нет пиров',
|
||||
description: 'Добавьте первого BGP-соседа.',
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Share2 } from 'lucide-react'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import type { DataGridColumnDef } from '@/components/reui-kit'
|
||||
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||
import type { PeerRow } from '@/types/api'
|
||||
|
||||
export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
|
||||
export const peerColumns: DataGridColumnDef<PeerRow>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.neighbor,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
<DataGridNameCell
|
||||
icon={Share2}
|
||||
title={row.original.name ?? row.original.neighbor}
|
||||
subtitle={row.original.name ? row.original.neighbor : undefined}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
@@ -24,16 +25,14 @@ export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'neighbor',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
||||
),
|
||||
cell: ({ row }) => <DataGridMonoCell>{row.original.neighbor}</DataGridMonoCell>,
|
||||
meta: { headerTitle: 'Адрес соседа' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'remote_asn',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
|
||||
<DataGridMonoCell className="text-xs">{row.original.remote_asn ?? '—'}</DataGridMonoCell>
|
||||
),
|
||||
meta: { headerTitle: 'ASN' },
|
||||
},
|
||||
@@ -58,7 +57,7 @@ export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
|
||||
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':
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Plus, SearchIcon, TagIcon, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
@@ -11,12 +10,13 @@ import {
|
||||
speakerColumns,
|
||||
speakerTabFilter,
|
||||
} from '@/components/network/network-speakers-grid'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
createFilter,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
||||
ResourcePage,
|
||||
createTextFilterQuery,
|
||||
renderSingleSelectedLabel,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import { useDeleteSpeakerMutation } from '@/queries/network'
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
@@ -36,34 +36,26 @@ const SPEAKER_TABS = [
|
||||
]
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'primary', label: 'Основной' },
|
||||
{ value: 'secondary', label: 'Резервный' },
|
||||
{ value: 'speaker', label: 'Спикер' },
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер' },
|
||||
]
|
||||
|
||||
function createDefaultSpeakerFilters(): Filter[] {
|
||||
return [createFilter('endpoint', 'contains', [''])]
|
||||
}
|
||||
|
||||
const speakerFilterFields: FilterFieldConfig[] = [
|
||||
const speakerFilterFields: FilterField[] = [
|
||||
{
|
||||
key: 'endpoint',
|
||||
id: 'endpoint',
|
||||
label: 'Конечная точка',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Адрес агента…',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
id: 'role',
|
||||
label: 'Роль',
|
||||
icon: <TagIcon className="size-3.5" aria-hidden />,
|
||||
type: 'select',
|
||||
searchable: false,
|
||||
className: 'w-[140px]',
|
||||
options: ROLE_OPTIONS,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, ROLE_OPTIONS),
|
||||
renderValue: ({ values }) => renderSingleSelectedLabel(values, ROLE_OPTIONS),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -85,7 +77,9 @@ export function NetworkSpeakersCard({
|
||||
const deleteMutation = useDeleteSpeakerMutation()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<SpeakerRow | null>(null)
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('endpoint'),
|
||||
)
|
||||
|
||||
const addButton = useMemo(
|
||||
() => (
|
||||
@@ -97,7 +91,7 @@ export function NetworkSpeakersCard({
|
||||
[],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<SpeakerRow, unknown>[]>(
|
||||
const columns = useMemo<DataGridColumnDef<SpeakerRow>[]>(
|
||||
() => [
|
||||
...speakerColumns,
|
||||
{
|
||||
@@ -132,9 +126,9 @@ export function NetworkSpeakersCard({
|
||||
tabs={SPEAKER_TABS}
|
||||
tabFilter={speakerTabFilter}
|
||||
filterFields={speakerFilterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('endpoint'))}
|
||||
getFilterFieldValue={getSpeakerFilterFieldValue}
|
||||
columns={columns}
|
||||
data={items}
|
||||
@@ -144,6 +138,7 @@ export function NetworkSpeakersCard({
|
||||
error={error instanceof Error ? error : null}
|
||||
onRetry={onRetry}
|
||||
primaryAction={addButton}
|
||||
pinLastColumn
|
||||
emptyState={{
|
||||
title: 'Нет спикеров',
|
||||
description: 'Добавьте первого BIRD-агента на ноде.',
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { ServerCog } from 'lucide-react'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import type { DataGridColumnDef } from '@/components/reui-kit'
|
||||
import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||
export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
|
||||
{
|
||||
accessorKey: 'endpoint',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
||||
<DataGridNameCell
|
||||
icon={ServerCog}
|
||||
title={row.original.agent_domain ?? row.original.endpoint}
|
||||
subtitle={row.original.agent_domain ? row.original.endpoint : undefined}
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Конечная точка' },
|
||||
},
|
||||
@@ -51,9 +56,9 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||
const live = row.original.live
|
||||
if (!live) return '—'
|
||||
return (
|
||||
<span className="text-xs">
|
||||
<DataGridMonoCell className="text-xs">
|
||||
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
||||
</span>
|
||||
</DataGridMonoCell>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'BGP' },
|
||||
@@ -63,7 +68,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
|
||||
switch (field) {
|
||||
case 'endpoint':
|
||||
return item.endpoint
|
||||
return `${item.endpoint} ${item.agent_domain ?? ''} ${item.id}`
|
||||
case 'role':
|
||||
return item.role
|
||||
default:
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Copy, TriangleAlert } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { Textarea } from '@evobgp/ui/components/textarea'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { useCreateSpeakerMutation } from '@/queries/network'
|
||||
import type { BgpSpeakerCreate } from '@/types/api'
|
||||
import type { BgpSpeakerCreate, SpeakerRow } from '@/types/api'
|
||||
|
||||
interface SpeakerFormDialogProps {
|
||||
open: boolean
|
||||
@@ -35,8 +39,20 @@ function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string)
|
||||
return JSON.stringify(meta)
|
||||
}
|
||||
|
||||
function tlsIncomplete(
|
||||
agentDomain: string,
|
||||
letsencryptEmail: string,
|
||||
cfToken: string,
|
||||
panelIP: string,
|
||||
): boolean {
|
||||
return !agentDomain.trim() || !letsencryptEmail.trim() || !cfToken.trim() || !panelIP.trim()
|
||||
}
|
||||
|
||||
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
|
||||
const createMutation = useCreateSpeakerMutation()
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({
|
||||
onCopy: () => toast.success('Команда скопирована'),
|
||||
})
|
||||
|
||||
const [endpoint, setEndpoint] = useState('')
|
||||
const [role, setRole] = useState('replica')
|
||||
@@ -44,6 +60,13 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
const [nodeIpv4, setNodeIpv4] = useState('')
|
||||
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
|
||||
const [bgpSourceManual, setBgpSourceManual] = useState(false)
|
||||
const [letsencryptEmail, setLetsencryptEmail] = useState('')
|
||||
const [cfDnsToken, setCfDnsToken] = useState('')
|
||||
const [panelIP, setPanelIP] = useState('')
|
||||
const [created, setCreated] = useState<SpeakerRow | null>(null)
|
||||
|
||||
const isReplica = role === 'replica'
|
||||
const installCommands = created?.install?.docker_commands ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -53,6 +76,10 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
setNodeIpv4('')
|
||||
setBgpSourceIpv4('')
|
||||
setBgpSourceManual(false)
|
||||
setLetsencryptEmail('')
|
||||
setCfDnsToken('')
|
||||
setPanelIP('')
|
||||
setCreated(null)
|
||||
}, [open])
|
||||
|
||||
function handleEndpointChange(value: string) {
|
||||
@@ -81,82 +108,211 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
endpoint: ep,
|
||||
role: role.trim() || 'replica',
|
||||
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
|
||||
control_plane_url: window.location.origin,
|
||||
}
|
||||
if (isReplica) {
|
||||
if (letsencryptEmail.trim()) body.letsencrypt_email = letsencryptEmail.trim()
|
||||
if (cfDnsToken.trim()) body.cf_dns_api_token = cfDnsToken.trim()
|
||||
if (panelIP.trim()) body.panel_ip_whitelist = panelIP.trim()
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync(body)
|
||||
const row = await createMutation.mutateAsync(body)
|
||||
if (row.install?.docker_commands) {
|
||||
setCreated(row)
|
||||
return
|
||||
}
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
const showingInstall = created !== null && Boolean(installCommands)
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новый спикер"
|
||||
description="BIRD-агент на ноде реплики или плоскости управления"
|
||||
className="sm:max-w-md"
|
||||
title={showingInstall ? 'Установка на ноду' : 'Новый спикер'}
|
||||
description={
|
||||
showingInstall
|
||||
? 'Секреты показываются один раз. Скопируйте команду на VPS реплики.'
|
||||
: 'BIRD-агент на ноде реплики или плоскости управления'
|
||||
}
|
||||
className={showingInstall ? 'sm:max-w-2xl' : 'sm:max-w-md'}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
showingInstall ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(installCommands)}
|
||||
>
|
||||
<Copy />
|
||||
{isCopied ? 'Скопировано' : 'Копировать команду'}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||
Готово
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
||||
<Input
|
||||
id="speaker-endpoint"
|
||||
placeholder="https://node.example.com:8443"
|
||||
value={endpoint}
|
||||
onChange={(e) => handleEndpointChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="speaker-role"
|
||||
label="Роль"
|
||||
items={[
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
||||
<Input
|
||||
id="speaker-agent-domain"
|
||||
placeholder="bird-agent.example.com"
|
||||
value={agentDomain}
|
||||
onChange={(e) => setAgentDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
||||
<Input
|
||||
id="speaker-node-ipv4"
|
||||
placeholder="203.0.113.10"
|
||||
value={nodeIpv4}
|
||||
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
||||
<Input
|
||||
id="speaker-bgp-source"
|
||||
placeholder="203.0.113.10"
|
||||
value={bgpSourceIpv4}
|
||||
onChange={(e) => {
|
||||
setBgpSourceManual(true)
|
||||
setBgpSourceIpv4(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{created && installCommands ? (
|
||||
<SpeakerInstallStep created={created} commands={installCommands} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
||||
<Input
|
||||
id="speaker-endpoint"
|
||||
placeholder="https://node.example.com"
|
||||
value={endpoint}
|
||||
onChange={(e) => handleEndpointChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="speaker-role"
|
||||
label="Роль"
|
||||
items={[
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
||||
<Input
|
||||
id="speaker-agent-domain"
|
||||
placeholder="bird-agent.example.com"
|
||||
value={agentDomain}
|
||||
onChange={(e) => setAgentDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
||||
<Input
|
||||
id="speaker-node-ipv4"
|
||||
placeholder="203.0.113.10"
|
||||
value={nodeIpv4}
|
||||
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
||||
<Input
|
||||
id="speaker-bgp-source"
|
||||
placeholder="203.0.113.10"
|
||||
value={bgpSourceIpv4}
|
||||
onChange={(e) => {
|
||||
setBgpSourceManual(true)
|
||||
setBgpSourceIpv4(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isReplica ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-le-email">Email Let's Encrypt</Label>
|
||||
<Input
|
||||
id="speaker-le-email"
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
value={letsencryptEmail}
|
||||
onChange={(e) => setLetsencryptEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-cf-token">Cloudflare DNS API token</Label>
|
||||
<Input
|
||||
id="speaker-cf-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="Zone:DNS:Edit"
|
||||
value={cfDnsToken}
|
||||
onChange={(e) => setCfDnsToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-panel-ip">IP панели (whitelist)</Label>
|
||||
<Input
|
||||
id="speaker-panel-ip"
|
||||
placeholder="203.0.113.1/32"
|
||||
value={panelIP}
|
||||
onChange={(e) => setPanelIP(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{tlsIncomplete(agentDomain, letsencryptEmail, cfDnsToken, panelIP) ? (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Traefik не выпустит сертификат</AlertTitle>
|
||||
<AlertDescription>
|
||||
Нужны домен агента, email LE, Cloudflare token и IP панели. Иначе в
|
||||
команде останутся плейсхолдеры CHANGE_ME_*.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function SpeakerInstallStep({
|
||||
created,
|
||||
commands,
|
||||
}: {
|
||||
created: SpeakerRow
|
||||
commands: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Сохраните сейчас</AlertTitle>
|
||||
<AlertDescription>
|
||||
agent_secret и node_token больше не будут показаны. Traefik на ноде выпускает
|
||||
сертификат через DNS-01 (Cloudflare). MikroTik стучится на IP ноды:179; 80/443 —
|
||||
только агент панели. Логи: docker compose logs -f bird2 evobgp-agent.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>ID спикера</Label>
|
||||
<code className="break-all font-mono text-xs">{created.id}</code>
|
||||
</div>
|
||||
{created.agent_secret ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>agent_secret</Label>
|
||||
<code className="break-all font-mono text-xs">{created.agent_secret}</code>
|
||||
</div>
|
||||
) : null}
|
||||
{created.node_token ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>node_token</Label>
|
||||
<code className="break-all font-mono text-xs">{created.node_token}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-docker-commands">Docker-команды</Label>
|
||||
<Textarea
|
||||
id="speaker-docker-commands"
|
||||
readOnly
|
||||
value={commands}
|
||||
className="min-h-64 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,34 +1,18 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { LayoutGrid, Table2 } from 'lucide-react'
|
||||
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
|
||||
import { OperationsJobsKanban } from '@/components/operations/operations-jobs-kanban'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@evobgp/ui/components/toggle-group'
|
||||
import type { JobRow } from '@/types/api'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
type JobTab = 'all' | 'active' | 'failed' | 'succeeded'
|
||||
|
||||
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||
if (tab === 'all') return items
|
||||
if (tab === 'active') return items.filter((j) => j.status === 'running' || j.status === 'queued')
|
||||
if (tab === 'failed')
|
||||
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||
return items.filter((j) => j.status === 'succeeded')
|
||||
}
|
||||
|
||||
function tabCounts(items: JobRow[]) {
|
||||
return {
|
||||
all: items.length,
|
||||
active: items.filter((j) => j.status === 'running' || j.status === 'queued').length,
|
||||
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||
.length,
|
||||
succeeded: items.filter((j) => j.status === 'succeeded').length,
|
||||
}
|
||||
}
|
||||
|
||||
/** data-grid-filtering-1 style jobs card with status tabs. */
|
||||
/**
|
||||
* Jobs ResourcePage + optional Kanban board (не замена грида).
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/docs/components/base/kanban
|
||||
*/
|
||||
export function OperationsJobsCard({
|
||||
jobs,
|
||||
nameById,
|
||||
@@ -46,41 +30,54 @@ export function OperationsJobsCard({
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<JobTab>('all')
|
||||
const counts = useMemo(() => tabCounts(jobs), [jobs])
|
||||
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
|
||||
const [view, setView] = useState<'table' | 'board'>('table')
|
||||
|
||||
return (
|
||||
<FrameDataGrid title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
|
||||
<div className="px-5 pt-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
|
||||
<TabsList variant="line" className="w-full justify-start gap-6">
|
||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger>
|
||||
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
|
||||
<TabsTrigger value="failed">Ошибки ({counts.failed})</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex justify-end">
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={[view]}
|
||||
onValueChange={(values) => {
|
||||
const next = values[0]
|
||||
if (next === 'table' || next === 'board') setView(next)
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Вид задач"
|
||||
className="w-fit"
|
||||
>
|
||||
<ToggleGroupItem value="table" aria-label="Таблица">
|
||||
<Table2 className="size-4" />
|
||||
Таблица
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="board" aria-label="Доска">
|
||||
<LayoutGrid className="size-4" />
|
||||
Доска
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<QueryState
|
||||
data={filtered}
|
||||
data={jobs}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={filtered.length === 0}
|
||||
emptyTitle="Нет задач в выборке"
|
||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||
empty={false}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(items) => (
|
||||
<OperationsJobsGrid
|
||||
items={items}
|
||||
nameById={nameById}
|
||||
qc={qc}
|
||||
isLoading={isLoading && items.length > 0}
|
||||
/>
|
||||
)}
|
||||
{(items) =>
|
||||
view === 'board' ? (
|
||||
<OperationsJobsKanban items={items} nameById={nameById} />
|
||||
) : (
|
||||
<OperationsJobsGrid
|
||||
items={items}
|
||||
nameById={nameById}
|
||||
qc={qc}
|
||||
isLoading={isLoading && items.length > 0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
</FrameDataGrid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,48 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { ListTodo, SearchIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
const JOB_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'active', label: 'Активные' },
|
||||
{ id: 'succeeded', label: 'Успешные' },
|
||||
{ id: 'failed', label: 'Ошибки' },
|
||||
]
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
{
|
||||
id: 'search',
|
||||
label: 'Поиск',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
placeholder: 'Поиск задач…',
|
||||
},
|
||||
]
|
||||
|
||||
function tabFilter(item: JobRow, tabId: string): boolean {
|
||||
if (tabId === 'active') return item.status === 'running' || item.status === 'queued'
|
||||
if (tabId === 'failed')
|
||||
return ['failed', 'error', 'cancelled'].includes(item.status.toLowerCase())
|
||||
if (tabId === 'succeeded') return item.status === 'succeeded'
|
||||
return true
|
||||
}
|
||||
|
||||
export function OperationsJobsGrid({
|
||||
items,
|
||||
nameById,
|
||||
@@ -26,6 +54,9 @@ export function OperationsJobsGrid({
|
||||
qc: QueryClient
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
|
||||
onSuccess: () => {
|
||||
@@ -35,15 +66,15 @@ export function OperationsJobsGrid({
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
|
||||
})
|
||||
|
||||
const columns = useMemo<ColumnDef<JobRow>[]>(
|
||||
const columns = useMemo<DataGridColumnDef<JobRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'kind',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
<DataGridNameCell
|
||||
icon={ListTodo}
|
||||
title={jobKindRu(row.original.kind)}
|
||||
accent="mono"
|
||||
subtitle={
|
||||
row.original.meta?.module_id
|
||||
? (nameById.get(String(row.original.meta.module_id)) ??
|
||||
@@ -113,27 +144,30 @@ export function OperationsJobsGrid({
|
||||
[cancelMutation, nameById],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => {
|
||||
const moduleName = row.meta?.module_id
|
||||
? (nameById.get(String(row.meta.module_id)) ?? String(row.meta.module_id))
|
||||
: ''
|
||||
return `${jobKindRu(row.kind)} ${row.status} ${row.job_id} ${moduleName}`
|
||||
},
|
||||
getRowId: (row) => row.job_id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
<ResourcePage
|
||||
title="Задачи"
|
||||
description="Фильтр по статусу"
|
||||
tabs={JOB_TABS}
|
||||
tabFilter={tabFilter}
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
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}`
|
||||
}}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.job_id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск задач…"
|
||||
pinLastColumn
|
||||
virtualization={items.length > 80}
|
||||
emptyState={{ title: 'Нет задач' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridMutedCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
Kanban,
|
||||
KanbanBoard,
|
||||
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 — 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
|
||||
*/
|
||||
|
||||
const COLUMN_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const
|
||||
|
||||
const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
|
||||
queued: 'Очередь',
|
||||
running: 'Выполняются',
|
||||
succeeded: 'Успешные',
|
||||
failed: 'Ошибки',
|
||||
}
|
||||
|
||||
function columnForStatus(status: string): (typeof COLUMN_ORDER)[number] {
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'queued') return 'queued'
|
||||
if (s === 'running') return 'running'
|
||||
if (s === 'succeeded') return 'succeeded'
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
function emptyColumns(): Record<string, JobRow[]> {
|
||||
return {
|
||||
queued: [],
|
||||
running: [],
|
||||
succeeded: [],
|
||||
failed: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function OperationsJobsKanban({
|
||||
items,
|
||||
nameById,
|
||||
}: {
|
||||
items: JobRow[]
|
||||
nameById: Map<string, string>
|
||||
}) {
|
||||
const columns = useMemo(() => {
|
||||
const next = emptyColumns()
|
||||
for (const job of items) {
|
||||
next[columnForStatus(job.status)].push(job)
|
||||
}
|
||||
return next
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full min-w-0">
|
||||
<FramePanel className="p-4">
|
||||
<Kanban
|
||||
value={columns}
|
||||
onValueChange={() => undefined}
|
||||
getItemValue={(job) => job.job_id}
|
||||
>
|
||||
<KanbanBoard className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{COLUMN_ORDER.map((columnId) => (
|
||||
<KanbanColumn
|
||||
key={columnId}
|
||||
value={columnId}
|
||||
disabled
|
||||
className="bg-muted/30 flex min-h-40 flex-col gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{COLUMN_LABELS[columnId]}</p>
|
||||
<Badge variant="outline" size="sm">
|
||||
{columns[columnId]?.length ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
<KanbanColumnContent value={columnId}>
|
||||
{(columns[columnId] ?? []).map((job) => (
|
||||
<KanbanItem
|
||||
key={job.job_id}
|
||||
value={job.job_id}
|
||||
disabled
|
||||
className="p-0"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,32 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { GitCommitHorizontal, RefreshCw, SearchIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import type { RevisionRow } from '@/types/api'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
{
|
||||
id: 'search',
|
||||
label: 'Поиск',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
placeholder: 'Поиск ревизий…',
|
||||
},
|
||||
]
|
||||
|
||||
export function OperationsRevisionsGrid({
|
||||
items,
|
||||
qc,
|
||||
@@ -24,6 +36,9 @@ export function OperationsRevisionsGrid({
|
||||
qc: QueryClient
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
const rollbackMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
|
||||
@@ -34,14 +49,14 @@ export function OperationsRevisionsGrid({
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
|
||||
})
|
||||
|
||||
const columns = useMemo<ColumnDef<RevisionRow>[]>(
|
||||
const columns = useMemo<DataGridColumnDef<RevisionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'id',
|
||||
accessorFn: (row) => row.id,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={`${row.original.id.slice(0, 12)}…`} accent="mono" />
|
||||
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 12)}…`} />
|
||||
),
|
||||
meta: { headerTitle: 'ID' },
|
||||
},
|
||||
@@ -88,22 +103,21 @@ export function OperationsRevisionsGrid({
|
||||
[rollbackMutation],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.id} ${row.materialized_prefix_count}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
<ResourcePage
|
||||
title="История ревизий"
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={(item) => `${item.id} ${item.materialized_prefix_count}`}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ревизий"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск ревизий…"
|
||||
pinLastColumn
|
||||
virtualization={items.length > 80}
|
||||
emptyState={{ title: 'Нет ревизий' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,92 +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 class constants (legacy Card names kept for DataGridShell / toolbar).
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
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,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,21 +1,30 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
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. */
|
||||
export function ProjectsEmptyState() {
|
||||
/** empty-state-14 for first module. Preview: https://reui.io/preview/base/empty-state-14 */
|
||||
export function ProjectsEmptyState({
|
||||
canCreate = true,
|
||||
onCreate,
|
||||
}: {
|
||||
canCreate?: boolean
|
||||
onCreate?: () => void
|
||||
}) {
|
||||
return (
|
||||
<IllustratedEmptyState
|
||||
<EmptyState
|
||||
framed
|
||||
centered={false}
|
||||
icon={Boxes}
|
||||
title="Создайте первый модуль"
|
||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||
action={
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Новый модуль
|
||||
</Button>
|
||||
canCreate && onCreate ? (
|
||||
<Button size="sm" onClick={onCreate}>
|
||||
<Plus />
|
||||
Новый модуль
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
applyKitActionColumn,
|
||||
KIT_ACTION_COLUMN_SIZE,
|
||||
kitColumnPinning,
|
||||
kitDataGridTableClassNames,
|
||||
kitDataGridTableLayout,
|
||||
} from './frame-data-grid'
|
||||
|
||||
describe('kitDataGridTableLayout', () => {
|
||||
it('filtering-2 defaults: dense, headerBackground false, width fixed', () => {
|
||||
const layout = kitDataGridTableLayout()
|
||||
expect(layout.dense).toBe(true)
|
||||
expect(layout.headerBackground).toBe(false)
|
||||
expect(layout.width).toBe('fixed')
|
||||
expect(layout.headerSticky).toBe(false)
|
||||
expect('stripped' in layout ? layout.stripped : undefined).toBeUndefined()
|
||||
expect(layout.columnsPinnable).toBe(false)
|
||||
})
|
||||
|
||||
it('kit tableClassNames совпадает с filtering-2 edgeCell', () => {
|
||||
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-3 last:pe-3')
|
||||
expect(kitDataGridTableClassNames.base).toBe(
|
||||
'[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
||||
)
|
||||
})
|
||||
|
||||
it('именованные opts: auto + pin', () => {
|
||||
const layout = kitDataGridTableLayout({
|
||||
width: 'auto',
|
||||
columnsPinnable: true,
|
||||
})
|
||||
expect(layout.width).toBe('auto')
|
||||
expect(layout.columnsPinnable).toBe(true)
|
||||
expect(layout.headerBackground).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyKitActionColumn', () => {
|
||||
it('locks filtering-2 size on id=actions', () => {
|
||||
const [name, actions] = applyKitActionColumn([
|
||||
{ id: 'name', header: 'Name' },
|
||||
{ id: 'actions', header: () => null, cell: () => 'x' },
|
||||
])
|
||||
expect(name?.id).toBe('name')
|
||||
expect(actions?.size).toBe(KIT_ACTION_COLUMN_SIZE)
|
||||
expect(actions?.minSize).toBe(KIT_ACTION_COLUMN_SIZE)
|
||||
expect(actions?.maxSize).toBe(KIT_ACTION_COLUMN_SIZE)
|
||||
expect(actions?.enableSorting).toBe(false)
|
||||
expect(actions?.enableResizing).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps explicit size (data-grid-base-7 text button)', () => {
|
||||
const [actions] = applyKitActionColumn([
|
||||
{ id: 'actions', size: 104, cell: () => 'x' },
|
||||
])
|
||||
expect(actions?.size).toBe(104)
|
||||
expect(actions?.minSize).toBe(104)
|
||||
expect(actions?.maxSize).toBe(104)
|
||||
})
|
||||
|
||||
it('does not rewrite a non-actions last column', () => {
|
||||
const [col] = applyKitActionColumn([{ id: 'name', header: 'Name' }])
|
||||
expect(col?.size).toBeUndefined()
|
||||
expect(col?.enableResizing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies DNA when pinLastColumn even without id=actions', () => {
|
||||
const [col] = applyKitActionColumn([{ id: 'other', cell: () => 'x' }], {
|
||||
pinLastColumn: true,
|
||||
})
|
||||
expect(col?.size).toBe(KIT_ACTION_COLUMN_SIZE)
|
||||
expect(col?.maxSize).toBe(KIT_ACTION_COLUMN_SIZE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kitColumnPinning', () => {
|
||||
it('does not end-pin without horizontalScroll', () => {
|
||||
const result = kitColumnPinning({
|
||||
pinLastColumn: true,
|
||||
lastColId: 'actions',
|
||||
})
|
||||
expect(result.enablePinning).toBe(false)
|
||||
expect(result.columnPinning.end).toEqual([])
|
||||
})
|
||||
|
||||
it('end-pins only with horizontalScroll', () => {
|
||||
const result = kitColumnPinning({
|
||||
pinLastColumn: true,
|
||||
horizontalScroll: true,
|
||||
lastColId: 'actions',
|
||||
})
|
||||
expect(result.enablePinning).toBe(true)
|
||||
expect(result.columnPinning.end).toEqual(['actions'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { FrameDataGrid, type FrameDataGridProps } from './frame-data-grid'
|
||||
|
||||
/**
|
||||
* Frame + DataGrid with expandable rows.
|
||||
* Preview: https://reui.io/preview/base/components/c-data-grid-8
|
||||
* Docs: https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
export function ExpandableResourceGrid<TData extends object>({
|
||||
expandedContent,
|
||||
getRowCanExpand,
|
||||
...props
|
||||
}: FrameDataGridProps<TData> & {
|
||||
expandedContent: (row: TData) => ReactNode
|
||||
getRowCanExpand?: (row: TData) => boolean
|
||||
}) {
|
||||
return (
|
||||
<FrameDataGrid
|
||||
{...props}
|
||||
expandedContent={expandedContent}
|
||||
getRowCanExpand={getRowCanExpand}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,26 @@
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import {
|
||||
createFilterQuery,
|
||||
createFilterRule,
|
||||
flattenFilterConditions,
|
||||
flattenFilterRules,
|
||||
isFilterRule,
|
||||
updateFilterRule,
|
||||
type FilterCondition,
|
||||
} from '@/components/reui/filters/filters-query'
|
||||
import type {
|
||||
FilterField,
|
||||
FilterGroupNode,
|
||||
FilterNode,
|
||||
FilterQuery,
|
||||
} from '@/components/reui/filters/filters-types'
|
||||
|
||||
export function getActiveFilters(filters: Filter[]) {
|
||||
/** Operators that take no value, so an empty `values` list is expected. */
|
||||
const VALUELESS_OPERATORS = new Set(['empty', 'not_empty'])
|
||||
|
||||
export function getActiveFilters(filters: FilterCondition[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { values } = 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
|
||||
@@ -17,63 +35,199 @@ export function getActiveFilters(filters: Filter[]) {
|
||||
})
|
||||
}
|
||||
|
||||
export function applyFiltersToData<T>(
|
||||
function matchesFilterCondition(
|
||||
fieldValue: unknown,
|
||||
operator: string,
|
||||
values: unknown[],
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
case 'eq':
|
||||
return values.includes(fieldValue)
|
||||
case 'is_not':
|
||||
case 'neq':
|
||||
return !values.includes(fieldValue)
|
||||
case 'is_any_of':
|
||||
case 'has_any_of':
|
||||
return values.some((value) => fieldValue === value)
|
||||
case 'is_none_of':
|
||||
case 'is_not_any_of':
|
||||
case 'has_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
|
||||
}
|
||||
}
|
||||
|
||||
export function applyFilterConditionsToData<T>(
|
||||
data: T[],
|
||||
filters: Filter[],
|
||||
filters: FilterCondition[],
|
||||
getFieldValue: (item: T, field: string) => unknown,
|
||||
): T[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
|
||||
for (const filter of active) {
|
||||
const { field, operator, values } = filter
|
||||
const { field, operator, values, negated } = filter
|
||||
result = result.filter((item) => {
|
||||
const raw = getFieldValue(item, field)
|
||||
const fieldValue = raw != null ? raw : ''
|
||||
|
||||
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_not_any_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
|
||||
}
|
||||
const matches = matchesFilterCondition(fieldValue, operator, values)
|
||||
return negated ? !matches : matches
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyFiltersToData<T>(
|
||||
data: T[],
|
||||
query: FilterQuery,
|
||||
getFieldValue: (item: T, field: string) => unknown,
|
||||
): T[] {
|
||||
return applyFilterConditionsToData(data, flattenFilterConditions(query), getFieldValue)
|
||||
}
|
||||
|
||||
export function createEmptyFilterQuery(): FilterQuery {
|
||||
return createFilterQuery()
|
||||
}
|
||||
|
||||
export function createTextFilterQuery(fieldId: string, id = `${fieldId}-1`): FilterQuery {
|
||||
return createFilterQuery([
|
||||
createFilterRule({
|
||||
id,
|
||||
path: [fieldId],
|
||||
operator: 'contains',
|
||||
value: '',
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
export function createSearchFilterField(
|
||||
id: string,
|
||||
label: string,
|
||||
placeholder: string,
|
||||
): FilterField {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
type: 'text',
|
||||
placeholder,
|
||||
}
|
||||
}
|
||||
|
||||
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} выбрано`
|
||||
|
||||
@@ -1,52 +1,540 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
useTable,
|
||||
type ColumnDef,
|
||||
type ColumnVisibilityState,
|
||||
type ExpandedState,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
import { Columns3Icon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
DataGrid,
|
||||
dataGridFeatures,
|
||||
type DataGridFeatures,
|
||||
type DataGridTableInstance,
|
||||
} from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import {
|
||||
DataGridTable,
|
||||
DataGridTableRowExpand,
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
|
||||
export type DataGridColumnDef<TData extends object> = ColumnDef<DataGridFeatures, TData>
|
||||
|
||||
/**
|
||||
* Frame shell for list/grid sections (replaces DataGridCard Card-named API).
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
* Единый tableLayout для всех ops-гридов.
|
||||
* Visual SoT: установленный data-grid-filtering-2 (`dense: true`, без zebra).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* Docs: https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
export function FrameDataGrid({
|
||||
export function kitDataGridTableLayout(
|
||||
opts: {
|
||||
dense?: boolean
|
||||
width?: 'fixed' | 'auto'
|
||||
columnsPinnable?: boolean
|
||||
columnsVisibility?: boolean
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
dense: opts.dense ?? true,
|
||||
rowBorder: true,
|
||||
headerSticky: false,
|
||||
headerBackground: false,
|
||||
headerBorder: true,
|
||||
width: opts.width ?? ('fixed' as const),
|
||||
columnsVisibility: opts.columnsVisibility ?? false,
|
||||
columnsResizable: false,
|
||||
columnsPinnable: opts.columnsPinnable ?? false,
|
||||
columnsMovable: false,
|
||||
rowsDraggable: false,
|
||||
rowsPinnable: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** filtering-2 edge inset + Frame-surface pinned cells (not page --background). */
|
||||
export const kitDataGridTableClassNames = {
|
||||
base: '[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
||||
edgeCell: 'first:ps-3 last:pe-3',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Compact action column size from data-grid-filtering-2.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
export const KIT_ACTION_COLUMN_SIZE = 56
|
||||
|
||||
const ACTION_CELL_ALIGN = 'flex items-center justify-end'
|
||||
|
||||
function lastColumnId<T extends object>(columns: DataGridColumnDef<T>[]): string {
|
||||
const last = columns[columns.length - 1]
|
||||
if (!last) return ''
|
||||
if (last.id) return last.id
|
||||
if ('accessorKey' in last && typeof last.accessorKey === 'string') return last.accessorKey
|
||||
return ''
|
||||
}
|
||||
|
||||
function wrapActionCell<T extends object>(
|
||||
cell: DataGridColumnDef<T>['cell'],
|
||||
): DataGridColumnDef<T>['cell'] {
|
||||
if (typeof cell !== 'function') {
|
||||
return () => <div className={ACTION_CELL_ALIGN}>{cell as ReactNode}</div>
|
||||
}
|
||||
return (ctx) => <div className={ACTION_CELL_ALIGN}>{cell(ctx)}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* filtering-2 action column DNA: locked width, no sort/resize, inner justify-end
|
||||
* (flex on the cell wrapper, never on `td` — that breaks rowBorder alignment).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
export function applyKitActionColumn<T extends object>(
|
||||
columns: DataGridColumnDef<T>[],
|
||||
opts: { pinLastColumn?: boolean } = {},
|
||||
): DataGridColumnDef<T>[] {
|
||||
if (columns.length === 0) return columns
|
||||
const last = columns[columns.length - 1]
|
||||
const lastId = lastColumnId(columns)
|
||||
if (lastId !== 'actions' && !opts.pinLastColumn) return columns
|
||||
|
||||
const size = last.size ?? KIT_ACTION_COLUMN_SIZE
|
||||
return [
|
||||
...columns.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
size,
|
||||
minSize: last.minSize ?? size,
|
||||
maxSize: last.maxSize ?? size,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
cell: last.cell ? wrapActionCell(last.cell) : last.cell,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** End-pin only when the grid actually scrolls horizontally (not for action columns). */
|
||||
export function kitColumnPinning(opts: {
|
||||
pinLastColumn?: boolean
|
||||
horizontalScroll?: boolean
|
||||
lastColId: string
|
||||
pinLeftColumnIds?: string[]
|
||||
}): {
|
||||
enablePinning: boolean
|
||||
columnPinning: { start: string[]; end: string[] }
|
||||
} {
|
||||
const pinLeft = opts.pinLeftColumnIds ?? []
|
||||
const pinEnd = Boolean(opts.pinLastColumn && opts.horizontalScroll && opts.lastColId)
|
||||
return {
|
||||
enablePinning: pinEnd || pinLeft.length > 0,
|
||||
columnPinning: {
|
||||
start: pinLeft,
|
||||
end: pinEnd ? [opts.lastColId] : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return undefined
|
||||
return JSON.parse(raw) as ColumnVisibilityState
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export { loadStoredColumnVisibility }
|
||||
|
||||
function applyColumnPinControls<T extends object>(
|
||||
columns: DataGridColumnDef<T>[],
|
||||
columnPinControls: boolean,
|
||||
): DataGridColumnDef<T>[] {
|
||||
return columns.map((col) => {
|
||||
const origHeader = col.header
|
||||
if (typeof origHeader !== 'function') return col
|
||||
return {
|
||||
...col,
|
||||
header: (ctx) => {
|
||||
const node = origHeader(ctx)
|
||||
if (isValidElement(node) && node.type === DataGridColumnHeader) {
|
||||
return cloneElement(node as ReactElement<{ pinnable?: boolean }>, {
|
||||
pinnable: columnPinControls,
|
||||
})
|
||||
}
|
||||
return node
|
||||
},
|
||||
} as DataGridColumnDef<T>
|
||||
})
|
||||
}
|
||||
|
||||
export interface FrameDataGridProps<TData extends object> {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
columns: DataGridColumnDef<TData>[]
|
||||
data: TData[]
|
||||
rowId?: (row: TData, index: number) => string
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
onRowClick?: (row: TData) => void
|
||||
pagination?: boolean
|
||||
pageSize?: number
|
||||
footerContent?: ReactNode
|
||||
dense?: boolean
|
||||
pinLastColumn?: boolean
|
||||
initialSorting?: SortingState
|
||||
virtualization?: boolean
|
||||
height?: number
|
||||
enableRowSelection?: boolean
|
||||
onRowSelectionChange?: (selectedIds: string[]) => void
|
||||
enableColumnVisibility?: boolean
|
||||
columnVisibility?: ColumnVisibilityState
|
||||
onColumnVisibilityChange?: OnChangeFn<ColumnVisibilityState>
|
||||
columnVisibilityTrigger?: boolean
|
||||
columnVisibilityStorageKey?: string
|
||||
initialColumnVisibility?: ColumnVisibilityState
|
||||
className?: string
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
getRowCanExpand?: (row: TData) => boolean
|
||||
pinLeftColumnIds?: string[]
|
||||
horizontalScroll?: boolean
|
||||
tableWidth?: 'fixed' | 'auto'
|
||||
columnPinControls?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function DataGridSectionHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
if (!title && !description && !actions) return null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
<FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
|
||||
<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>
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
{title ? <FrameTitle className="text-balance">{title}</FrameTitle> : null}
|
||||
{description ? (
|
||||
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
|
||||
) : null}
|
||||
<div className="p-0">{children}</div>
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameDataGridBody<TData extends object>({
|
||||
table,
|
||||
data,
|
||||
emptyTitle,
|
||||
onRowClick,
|
||||
dense,
|
||||
virtualization,
|
||||
height,
|
||||
footerContent,
|
||||
showPagination,
|
||||
enableColumnVisibility,
|
||||
columnsPinnable,
|
||||
tableWidth,
|
||||
}: {
|
||||
table: DataGridTableInstance<TData>
|
||||
data: TData[]
|
||||
emptyTitle: string
|
||||
onRowClick?: (row: TData) => void
|
||||
dense: boolean
|
||||
virtualization: boolean
|
||||
height: number
|
||||
footerContent?: ReactNode
|
||||
showPagination: boolean
|
||||
enableColumnVisibility: boolean
|
||||
columnsPinnable: boolean
|
||||
tableWidth: 'fixed' | 'auto'
|
||||
}) {
|
||||
const tableNode = virtualization ? (
|
||||
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
||||
) : (
|
||||
<DataGridTable footerContent={footerContent} />
|
||||
)
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
onRowClick={onRowClick}
|
||||
emptyMessage={emptyTitle}
|
||||
tableLayout={kitDataGridTableLayout({
|
||||
dense,
|
||||
width: tableWidth,
|
||||
columnsPinnable,
|
||||
columnsVisibility: enableColumnVisibility,
|
||||
})}
|
||||
tableClassNames={kitDataGridTableClassNames}
|
||||
>
|
||||
{virtualization ? (
|
||||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||
{tableNode}
|
||||
</DataGridScrollArea>
|
||||
) : (
|
||||
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
|
||||
)}
|
||||
{showPagination ? (
|
||||
<>
|
||||
<Separator />
|
||||
<FrameFooter>
|
||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
||||
</FrameFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
export function FrameDataGrid<TData extends object>({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
columns,
|
||||
data,
|
||||
rowId,
|
||||
emptyTitle = 'Нет записей',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
onRowClick,
|
||||
pagination,
|
||||
pageSize = 10,
|
||||
footerContent,
|
||||
dense = true,
|
||||
pinLastColumn = false,
|
||||
initialSorting,
|
||||
virtualization = false,
|
||||
height = 480,
|
||||
enableRowSelection = false,
|
||||
onRowSelectionChange,
|
||||
enableColumnVisibility = false,
|
||||
columnVisibility: columnVisibilityProp,
|
||||
onColumnVisibilityChange,
|
||||
columnVisibilityTrigger,
|
||||
columnVisibilityStorageKey,
|
||||
initialColumnVisibility,
|
||||
className,
|
||||
expandedContent,
|
||||
getRowCanExpand,
|
||||
pinLeftColumnIds,
|
||||
horizontalScroll = false,
|
||||
tableWidth = 'fixed',
|
||||
columnPinControls = false,
|
||||
}: FrameDataGridProps<TData>) {
|
||||
const showPagination = pagination ?? true
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||
const [paginationState, setPaginationState] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const [internalColumnVisibility, setInternalColumnVisibility] = useState<ColumnVisibilityState>(
|
||||
() => {
|
||||
const stored = columnVisibilityStorageKey
|
||||
? loadStoredColumnVisibility(columnVisibilityStorageKey)
|
||||
: undefined
|
||||
return { ...initialColumnVisibility, ...stored }
|
||||
},
|
||||
)
|
||||
|
||||
const isColumnVisibilityControlled = columnVisibilityProp !== undefined
|
||||
const columnVisibility = isColumnVisibilityControlled
|
||||
? columnVisibilityProp
|
||||
: internalColumnVisibility
|
||||
const setColumnVisibility: OnChangeFn<ColumnVisibilityState> = isColumnVisibilityControlled
|
||||
? (onColumnVisibilityChange ?? (() => undefined))
|
||||
: setInternalColumnVisibility
|
||||
|
||||
useEffect(() => {
|
||||
setPaginationState((current) => ({
|
||||
pageIndex: showPagination ? current.pageIndex : 0,
|
||||
pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY,
|
||||
}))
|
||||
}, [pageSize, showPagination])
|
||||
|
||||
useEffect(() => {
|
||||
if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return
|
||||
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
|
||||
}, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled])
|
||||
|
||||
const selectColumn: DataGridColumnDef<TData> = {
|
||||
id: 'select',
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { cellClassName: 'w-10' },
|
||||
}
|
||||
|
||||
const expandColumn: DataGridColumnDef<TData> = {
|
||||
id: 'expand',
|
||||
header: () => null,
|
||||
cell: ({ row }) => <DataGridTableRowExpand row={row} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: {
|
||||
cellClassName: 'w-10',
|
||||
expandedContent,
|
||||
},
|
||||
}
|
||||
|
||||
const tableColumns: DataGridColumnDef<TData>[] = applyColumnPinControls(
|
||||
applyKitActionColumn(
|
||||
[
|
||||
...(expandedContent ? [expandColumn] : []),
|
||||
...(enableRowSelection ? [selectColumn] : []),
|
||||
...columns,
|
||||
],
|
||||
{ pinLastColumn },
|
||||
),
|
||||
columnPinControls,
|
||||
)
|
||||
|
||||
const { enablePinning, columnPinning } = kitColumnPinning({
|
||||
pinLastColumn,
|
||||
horizontalScroll,
|
||||
lastColId: lastColumnId(tableColumns),
|
||||
pinLeftColumnIds,
|
||||
})
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns: tableColumns,
|
||||
state: {
|
||||
sorting,
|
||||
pagination: paginationState,
|
||||
columnVisibility,
|
||||
expanded,
|
||||
...(enablePinning ? { columnPinning } : {}),
|
||||
...(enableRowSelection ? { rowSelection } : {}),
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPaginationState,
|
||||
onExpandedChange: setExpanded,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: enableRowSelection
|
||||
? (updater) => {
|
||||
setRowSelection((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater
|
||||
if (onRowSelectionChange && rowId) {
|
||||
const ids = Object.keys(next).filter((k) => next[k])
|
||||
onRowSelectionChange(ids)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
: undefined,
|
||||
initialState: enablePinning ? { columnPinning } : undefined,
|
||||
getRowId: rowId ? (row, index) => rowId(row, index) : undefined,
|
||||
getRowCanExpand: expandedContent
|
||||
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
|
||||
: undefined,
|
||||
enableRowSelection,
|
||||
enableHiding: enableColumnVisibility,
|
||||
})
|
||||
|
||||
const showColumnVisibilityTrigger =
|
||||
enableColumnVisibility && (columnVisibilityTrigger ?? true)
|
||||
|
||||
const columnVisibilityAction = showColumnVisibilityTrigger ? (
|
||||
<DataGridColumnVisibility
|
||||
table={table}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Columns3Icon data-icon="inline-start" />
|
||||
Колонки
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
|
||||
const headerActions = actions ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{columnVisibilityAction}
|
||||
{actions}
|
||||
</div>
|
||||
) : (
|
||||
columnVisibilityAction
|
||||
)
|
||||
|
||||
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
|
||||
{hasHeader ? (
|
||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||
) : null}
|
||||
<FramePanel className="flex min-h-72 w-full flex-col items-center justify-center">
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
|
||||
{hasHeader ? (
|
||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||
) : null}
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<FrameDataGridBody
|
||||
table={table}
|
||||
data={data}
|
||||
emptyTitle={emptyTitle}
|
||||
onRowClick={onRowClick}
|
||||
dense={dense}
|
||||
virtualization={virtualization}
|
||||
height={height}
|
||||
footerContent={footerContent}
|
||||
showPagination={showPagination}
|
||||
enableColumnVisibility={enableColumnVisibility}
|
||||
columnsPinnable={enablePinning}
|
||||
tableWidth={tableWidth}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
export {
|
||||
applyFiltersToData,
|
||||
applyFilterConditionsToData,
|
||||
createEmptyFilterQuery,
|
||||
createSearchFilterField,
|
||||
createTextFilterQuery,
|
||||
getActiveFilters,
|
||||
renderSelectedCount,
|
||||
renderSingleSelectedLabel,
|
||||
@@ -22,6 +26,22 @@ export {
|
||||
} from './kpi-stat-grid'
|
||||
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { FrameDataGrid } from './frame-data-grid'
|
||||
export {
|
||||
FrameDataGrid,
|
||||
kitDataGridTableClassNames,
|
||||
kitDataGridTableLayout,
|
||||
type DataGridColumnDef,
|
||||
type FrameDataGridProps,
|
||||
} from './frame-data-grid'
|
||||
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,27 +1,29 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
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 } from '@/components/reui/data-grid/data-grid'
|
||||
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,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
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,
|
||||
FrameDescription,
|
||||
@@ -30,40 +32,85 @@ import {
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} 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 {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
import { FILTERS_I18N_RU } from '@/lib/filters-i18n'
|
||||
import { applyFiltersToData } from './filter-utils'
|
||||
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
|
||||
import {
|
||||
applyFiltersToData,
|
||||
createEmptyFilterQuery,
|
||||
createTextFilterQuery,
|
||||
getActiveFilters,
|
||||
getExtraFilterFields,
|
||||
getFilterTextValue,
|
||||
getPrimaryTextField,
|
||||
mergeFieldRules,
|
||||
setFilterTextValue,
|
||||
stripFieldFromQuery,
|
||||
} from './filter-utils'
|
||||
import {
|
||||
FrameDataGrid,
|
||||
applyKitActionColumn,
|
||||
kitColumnPinning,
|
||||
kitDataGridTableClassNames,
|
||||
kitDataGridTableLayout,
|
||||
type DataGridColumnDef,
|
||||
type FrameDataGridProps,
|
||||
} from './frame-data-grid'
|
||||
|
||||
export interface ResourcePageTab {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
icon?: LucideIcon
|
||||
tone?: StatusToggleTone
|
||||
}
|
||||
|
||||
export interface ResourcePageProps<T extends object> {
|
||||
title: string
|
||||
type SimpleGridPassthrough<T extends object> = Pick<
|
||||
FrameDataGridProps<T>,
|
||||
| 'onRowClick'
|
||||
| 'pagination'
|
||||
| 'footerContent'
|
||||
| 'dense'
|
||||
| 'initialSorting'
|
||||
| 'virtualization'
|
||||
| 'height'
|
||||
| 'onRowSelectionChange'
|
||||
| 'enableColumnVisibility'
|
||||
| 'columnVisibility'
|
||||
| 'onColumnVisibilityChange'
|
||||
| 'columnVisibilityTrigger'
|
||||
| 'columnVisibilityStorageKey'
|
||||
| 'initialColumnVisibility'
|
||||
| 'className'
|
||||
| 'tableWidth'
|
||||
| 'horizontalScroll'
|
||||
| 'pinLeftColumnIds'
|
||||
| 'columnPinControls'
|
||||
>
|
||||
|
||||
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
|
||||
title?: string
|
||||
description?: string
|
||||
tabs?: ResourcePageTab[]
|
||||
activeTab?: string
|
||||
onTabChange?: (tabId: string) => void
|
||||
tabFilter?: (item: T, tabId: string) => boolean
|
||||
filterFields: FilterFieldConfig[]
|
||||
filters: Filter[]
|
||||
onFiltersChange: (filters: Filter[]) => void
|
||||
filterFields?: FilterField[]
|
||||
filterQuery?: FilterQuery
|
||||
onFilterQueryChange?: (query: FilterQuery) => void
|
||||
onClearFilters?: () => void
|
||||
getFilterFieldValue: (item: T, field: string) => unknown
|
||||
columns: ColumnDef<T, unknown>[]
|
||||
getFilterFieldValue?: (item: T, field: string) => unknown
|
||||
columns: DataGridColumnDef<T>[]
|
||||
data: T[]
|
||||
getRowId: (row: T) => string
|
||||
getRowId: (row: T, index?: number) => string
|
||||
isLoading?: boolean
|
||||
isError?: boolean
|
||||
error?: Error | null
|
||||
@@ -79,6 +126,11 @@ export interface ResourcePageProps<T extends object> {
|
||||
}) => ReactNode
|
||||
toolbarExtra?: ReactNode
|
||||
hideHeader?: boolean
|
||||
pinLastColumn?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
function ResourcePageSkeleton() {
|
||||
@@ -100,18 +152,35 @@ function ResourcePageSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
export function ResourcePage<T extends object>({
|
||||
title,
|
||||
function ResourceLoadError({
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
}) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
|
||||
{onRetry ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||
Повторить
|
||||
</Button>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const noopQueryChange = (_query: FilterQuery) => {}
|
||||
const defaultGetFilterFieldValue = (_item: unknown, _field: string) => undefined
|
||||
|
||||
function ResourcePageSimple<T extends object>({
|
||||
title = '',
|
||||
description,
|
||||
tabs,
|
||||
activeTab: controlledTab,
|
||||
onTabChange,
|
||||
tabFilter,
|
||||
filterFields,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onClearFilters,
|
||||
getFilterFieldValue,
|
||||
columns,
|
||||
data,
|
||||
getRowId,
|
||||
@@ -120,15 +189,153 @@ export function ResourcePage<T extends object>({
|
||||
error = null,
|
||||
onRetry,
|
||||
primaryAction,
|
||||
actions,
|
||||
emptyState,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
pageSize = 10,
|
||||
enableRowSelection = false,
|
||||
toolbarExtra,
|
||||
hideHeader = false,
|
||||
pinLastColumn = false,
|
||||
onRowClick,
|
||||
pagination,
|
||||
footerContent,
|
||||
dense,
|
||||
initialSorting,
|
||||
virtualization,
|
||||
height,
|
||||
onRowSelectionChange,
|
||||
enableColumnVisibility,
|
||||
columnVisibility,
|
||||
onColumnVisibilityChange,
|
||||
columnVisibilityTrigger,
|
||||
columnVisibilityStorageKey,
|
||||
initialColumnVisibility,
|
||||
className,
|
||||
tableWidth,
|
||||
horizontalScroll,
|
||||
pinLeftColumnIds,
|
||||
columnPinControls,
|
||||
}: ResourcePageProps<T>) {
|
||||
if (isLoading) return <ResourcePageSkeleton />
|
||||
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
|
||||
|
||||
const headerActions = primaryAction ?? actions
|
||||
const resolvedEmpty = emptyState ?? (
|
||||
emptyTitle || emptyDescription || emptyAction
|
||||
? { title: emptyTitle ?? 'Нет записей', description: emptyDescription, action: emptyAction }
|
||||
: undefined
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{toolbarExtra ? (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">{toolbarExtra}</div>
|
||||
) : null}
|
||||
<FrameDataGrid
|
||||
title={hideHeader ? undefined : title || undefined}
|
||||
description={hideHeader ? undefined : description}
|
||||
actions={hideHeader ? undefined : headerActions}
|
||||
columns={columns}
|
||||
data={data}
|
||||
rowId={getRowId}
|
||||
emptyTitle={resolvedEmpty?.title}
|
||||
emptyDescription={resolvedEmpty?.description}
|
||||
emptyAction={resolvedEmpty?.action}
|
||||
pinLastColumn={pinLastColumn}
|
||||
pageSize={pageSize}
|
||||
enableRowSelection={enableRowSelection}
|
||||
onRowClick={onRowClick}
|
||||
pagination={pagination}
|
||||
footerContent={footerContent}
|
||||
dense={dense}
|
||||
initialSorting={initialSorting}
|
||||
virtualization={virtualization}
|
||||
height={height}
|
||||
onRowSelectionChange={onRowSelectionChange}
|
||||
enableColumnVisibility={enableColumnVisibility}
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={onColumnVisibilityChange}
|
||||
columnVisibilityTrigger={columnVisibilityTrigger}
|
||||
columnVisibilityStorageKey={columnVisibilityStorageKey}
|
||||
initialColumnVisibility={initialColumnVisibility}
|
||||
className={className}
|
||||
tableWidth={tableWidth}
|
||||
horizontalScroll={horizontalScroll}
|
||||
pinLeftColumnIds={pinLeftColumnIds}
|
||||
columnPinControls={columnPinControls}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResourcePageFiltered<T extends object>({
|
||||
title = '',
|
||||
description,
|
||||
tabs,
|
||||
activeTab: controlledTab,
|
||||
onTabChange,
|
||||
tabFilter,
|
||||
filterFields = [],
|
||||
filterQuery: controlledQuery,
|
||||
onFilterQueryChange = noopQueryChange,
|
||||
onClearFilters,
|
||||
getFilterFieldValue = defaultGetFilterFieldValue as (item: T, field: string) => unknown,
|
||||
columns,
|
||||
data,
|
||||
getRowId,
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
error = null,
|
||||
onRetry,
|
||||
primaryAction,
|
||||
actions,
|
||||
emptyState,
|
||||
pageSize = 10,
|
||||
enableRowSelection = false,
|
||||
selectionToolbar,
|
||||
toolbarExtra,
|
||||
hideHeader = false,
|
||||
pinLastColumn = false,
|
||||
enableColumnVisibility = false,
|
||||
onRowClick,
|
||||
virtualization = false,
|
||||
height = 480,
|
||||
horizontalScroll = false,
|
||||
}: 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>({})
|
||||
@@ -144,25 +351,23 @@ export function ResourcePage<T extends object>({
|
||||
}, [])
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||
let result = showFilters ? applyFiltersToData(data, filterQuery, getFilterFieldValue) : data
|
||||
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
||||
result = result.filter((item) => tabFilter(item, activeTab))
|
||||
}
|
||||
return result
|
||||
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
|
||||
}, [data, filterQuery, getFilterFieldValue, tabs, tabFilter, activeTab, showFilters])
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
if (!tabs?.length || !tabFilter) return {}
|
||||
const base = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||
const base = showFilters ? applyFiltersToData(data, filterQuery, getFilterFieldValue) : data
|
||||
const counts: Record<string, number> = {}
|
||||
for (const tab of tabs) {
|
||||
counts[tab.id] =
|
||||
tab.id === 'all'
|
||||
? base.length
|
||||
: base.filter((item) => tabFilter(item, tab.id)).length
|
||||
tab.id === 'all' ? base.length : base.filter((item) => tabFilter(item, tab.id)).length
|
||||
}
|
||||
return counts
|
||||
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
|
||||
}, [tabs, tabFilter, data, filterQuery, getFilterFieldValue, showFilters])
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||
@@ -171,22 +376,36 @@ export function ResourcePage<T extends object>({
|
||||
|
||||
const selectedCount = selectedIds.length
|
||||
|
||||
const tableColumns = useMemo(
|
||||
() => applyKitActionColumn(columns, { pinLastColumn }),
|
||||
[columns, pinLastColumn],
|
||||
)
|
||||
const { enablePinning, columnPinning } = kitColumnPinning({
|
||||
pinLastColumn,
|
||||
horizontalScroll,
|
||||
lastColId: tableColumns[tableColumns.length - 1]?.id ?? '',
|
||||
})
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setRowSelection({})
|
||||
}, [])
|
||||
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data: filteredData,
|
||||
columns,
|
||||
getRowId,
|
||||
state: { sorting, rowSelection, pagination },
|
||||
columns: tableColumns,
|
||||
getRowId: (row, index) => getRowId(row, index),
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
pagination,
|
||||
...(enablePinning ? { columnPinning } : {}),
|
||||
},
|
||||
initialState: enablePinning ? { columnPinning } : undefined,
|
||||
enableRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
})
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
@@ -198,18 +417,38 @@ export function ResourcePage<T extends object>({
|
||||
[onTabChange, resetPagination],
|
||||
)
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(next: Filter[]) => {
|
||||
onFiltersChange(next)
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!primaryTextField) return
|
||||
setFilterQuery(setFilterTextValue(filterQuery, primaryTextField.id, value))
|
||||
resetPagination()
|
||||
},
|
||||
[onFiltersChange, 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(
|
||||
primaryTextField
|
||||
? createTextFilterQuery(primaryTextField.id)
|
||||
: createEmptyFilterQuery(),
|
||||
)
|
||||
}
|
||||
resetPagination()
|
||||
}, [onClearFilters, resetPagination])
|
||||
}, [onClearFilters, isQueryControlled, primaryTextField, resetPagination])
|
||||
|
||||
const countedTabs = useMemo(
|
||||
() =>
|
||||
@@ -217,42 +456,30 @@ export function ResourcePage<T extends object>({
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
count: tabCounts[tab.id] ?? tab.count ?? 0,
|
||||
icon: tab.icon,
|
||||
tone: tab.tone,
|
||||
})),
|
||||
[tabs, tabCounts],
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <ResourcePageSkeleton />
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
|
||||
{onRetry ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||
Повторить
|
||||
</Button>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
if (isLoading) return <ResourcePageSkeleton />
|
||||
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
|
||||
|
||||
if (data.length === 0 && emptyState) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={emptyState.title}
|
||||
description={emptyState.description}
|
||||
action={emptyState.action}
|
||||
/>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-center justify-center p-0">
|
||||
<EmptyState
|
||||
title={emptyState.title}
|
||||
description={emptyState.description}
|
||||
action={emptyState.action}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
||||
const tableNode = <DataGridTable />
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -266,8 +493,15 @@ export function ResourcePage<T extends object>({
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{ dense: true }}
|
||||
emptyMessage="Нет записей по выбранным фильтрам."
|
||||
onRowClick={onRowClick}
|
||||
tableLayout={kitDataGridTableLayout({
|
||||
dense: true,
|
||||
width: 'fixed',
|
||||
columnsPinnable: enablePinning,
|
||||
columnsVisibility: enableColumnVisibility,
|
||||
})}
|
||||
tableClassNames={kitDataGridTableClassNames}
|
||||
>
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
{!hideHeader ? (
|
||||
@@ -277,69 +511,84 @@ export function ResourcePage<T extends object>({
|
||||
{description ? (
|
||||
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||
<span>{description}</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="tabular-nums">
|
||||
{filteredData.length} записей
|
||||
</span>
|
||||
<span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
|
||||
<span className="tabular-nums">{filteredData.length} записей</span>
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
|
||||
<span>{selectedCount} выбрано</span>
|
||||
</>
|
||||
) : null}
|
||||
</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{primaryAction ? (
|
||||
{headerActions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{primaryAction}
|
||||
{headerActions}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : 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-(--frame-panel-header-py)">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
i18n={FILTERS_I18N_RU}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
) : null}
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
{selectedCount} выбрано
|
||||
</Badge>
|
||||
) : null}
|
||||
{onClearFilters ? (
|
||||
{hasDirtyFilters ? (
|
||||
<Button type="button" variant="outline" onClick={handleClear}>
|
||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить
|
||||
@@ -350,17 +599,18 @@ export function ResourcePage<T extends object>({
|
||||
|
||||
<Separator />
|
||||
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
{virtualization ? (
|
||||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||
{tableNode}
|
||||
</DataGridScrollArea>
|
||||
) : (
|
||||
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination
|
||||
{...DATA_GRID_PAGINATION_RU}
|
||||
sizes={[5, 10, 20, 50]}
|
||||
/>
|
||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} sizes={[5, 10, 20, 50]} />
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
@@ -368,3 +618,17 @@ export function ResourcePage<T extends object>({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ops list page: Frame + DataGrid (+ optional ReUI Filters / tabs).
|
||||
* Without filterFields/tabs → simple CRUD grid (FrameDataGrid).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
export function ResourcePage<T extends object>(props: ResourcePageProps<T>) {
|
||||
const showFilters = (props.filterFields?.length ?? 0) > 0
|
||||
const hasTabs = (props.tabs?.length ?? 0) > 0
|
||||
if (!showFilters && !hasTabs) {
|
||||
return <ResourcePageSimple {...props} />
|
||||
}
|
||||
return <ResourcePageFiltered {...props} />
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import { PageShell } from '@/components/page-shell'
|
||||
|
||||
/**
|
||||
* Settings layout — page chrome only.
|
||||
* Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
|
||||
* @see https://reui.io/preview/base/settings-7
|
||||
* Side-tab rail lives in `SettingsPageShell` (settings-3 / settings-16 DNA).
|
||||
* @see https://reui.io/preview/base/settings-3
|
||||
* @see https://reui.io/preview/base/settings-16
|
||||
* @see https://reui.io/blocks
|
||||
*/
|
||||
export function SettingsShell() {
|
||||
|
||||
@@ -163,7 +163,10 @@ function AutocompleteItem({
|
||||
<AutocompletePrimitive.Item
|
||||
data-slot="autocomplete-item"
|
||||
className={cn(
|
||||
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
|
||||
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5",
|
||||
"rounded-md",
|
||||
"data-highlighted:before:rounded-md",
|
||||
"px-1.5 py-1 text-sm ([class*='size-'])]:size-4 ([class*='size-'])]:size-4 [&_svg:not([class*='size-'])]:size-4 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
import * as React from "react"
|
||||
import { useCascaderState } from "@/components/reui/cascader/cascader-context"
|
||||
import {
|
||||
CASCADER_ROOT_KEY,
|
||||
isCascaderMoreNode,
|
||||
} from "@/components/reui/cascader/cascader-lib"
|
||||
import type {
|
||||
CascaderIndex,
|
||||
CascaderLoadContext,
|
||||
CascaderLoadReason,
|
||||
CascaderLoadResult,
|
||||
CascaderLoadState,
|
||||
CascaderNode,
|
||||
CascaderSearchContext,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
|
||||
/**
|
||||
* Async data for the cascader. Loaded pages live in their own store and are
|
||||
* MERGED onto the index built from `items`, never folded into that build:
|
||||
* `items` changes identity on any parent re-render, and folding would discard
|
||||
* every level the user drilled into. Map membership is the load discriminator:
|
||||
* no `states` entry means never fetched, while an entry with no `loading`, no
|
||||
* `error` and no children means empty for real.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Fetches one level. `node` is `null` for the root level. */
|
||||
export type CascaderGetChildren<T = unknown> = (
|
||||
node: CascaderNode<T> | null,
|
||||
context: CascaderLoadContext
|
||||
) =>
|
||||
| CascaderNode<T>[]
|
||||
| CascaderLoadResult<T>
|
||||
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
|
||||
|
||||
/** Server-side search, replacing the local index scan while the query is set. */
|
||||
export type CascaderOnSearch<T = unknown> = (
|
||||
query: string,
|
||||
context: CascaderSearchContext
|
||||
) =>
|
||||
| CascaderNode<T>[]
|
||||
| CascaderLoadResult<T>
|
||||
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
|
||||
|
||||
/** Resolves a selected value to its ancestor chain, root first, node last. */
|
||||
export type CascaderResolveValue<T = unknown> = (
|
||||
value: string,
|
||||
context: CascaderLoadContext
|
||||
) => CascaderNode<T>[] | Promise<CascaderNode<T>[]>
|
||||
|
||||
export interface CascaderLoaderStore<T = unknown> {
|
||||
/** Keyed by LEVEL: a parent's value, or `CASCADER_ROOT_KEY` for the root. */
|
||||
pages: Map<string, CascaderNode<T>[]>
|
||||
states: Map<string, CascaderLoadState>
|
||||
/** Keyed by node value: search hits and resolved selections, level-less. */
|
||||
detached: Map<string, CascaderNode<T>>
|
||||
}
|
||||
|
||||
export interface UseCascaderLoaderOptions<T = unknown> {
|
||||
/** The index built from `items`, before any pages are merged in. */
|
||||
base: CascaderIndex<T>
|
||||
getChildren?: CascaderGetChildren<T>
|
||||
onSearch?: CascaderOnSearch<T>
|
||||
resolveValue?: CascaderResolveValue<T>
|
||||
/** Milliseconds of quiet before `onSearch` fires. */
|
||||
searchDebounce?: number
|
||||
/** Changing this drops every cached page, state and detached node. */
|
||||
loadKey?: unknown
|
||||
/** Speculatively fetch a branch's children when it is highlighted. */
|
||||
prefetch?: boolean
|
||||
/** Called when a request fails. Never for an aborted or superseded one. */
|
||||
onLoadError?: (
|
||||
error: unknown,
|
||||
context: { parent: string | null; reason: string }
|
||||
) => void
|
||||
/** Whether the panel is live: the popup is open, or the cascader is inline. */
|
||||
enabled: boolean
|
||||
query: string
|
||||
/** Level keys that are currently on screen. Root is `CASCADER_ROOT_KEY`. */
|
||||
levels: string[]
|
||||
/** The navigation path, handed to `onSearch` as its scope. */
|
||||
path: string[]
|
||||
/** Current selection, for `resolveValue`. */
|
||||
values: string[]
|
||||
}
|
||||
|
||||
export interface CascaderLoader<T = unknown> {
|
||||
/** Whether a `getChildren` loader is configured at all. */
|
||||
active: boolean
|
||||
store: CascaderLoaderStore<T>
|
||||
states: ReadonlyMap<string, CascaderLoadState>
|
||||
/**
|
||||
* Async search hits, `null` when no `onSearch` is running. EMPTY while the
|
||||
* first request is in flight, so the level behind is not shown as the answer.
|
||||
*/
|
||||
searchResults: CascaderNode<T>[] | null
|
||||
searchState: CascaderLoadState | null
|
||||
/**
|
||||
* Fetches a level's FIRST page. No-ops on a `states` entry (in flight,
|
||||
* loaded or failed) or when `items` fills the level; pages are never
|
||||
* consulted, so a `resolveValue` chain still fetches. The level effect fires
|
||||
* only for on-screen levels, so a branch merely PRESSED is asked for by hand.
|
||||
*/
|
||||
ensureLevel: (parentKey: string, reason: CascaderLoadReason) => void
|
||||
/** Fetches the next page of a level. No-ops unless one is available. */
|
||||
loadMore: (parentKey: string) => void
|
||||
/** Refires a failed level. No-ops unless that level is in an error state. */
|
||||
retryLevel: (parentKey: string) => void
|
||||
/** Schedules a speculative fetch. Safe to call on every highlight move. */
|
||||
prefetchNode: (node: CascaderNode<T> | null | undefined) => void
|
||||
/**
|
||||
* Evicts ONE level: aborts its request, drops its `states`/`pages` entries
|
||||
* and paging latch, so the level effect refetches it. `null` = root level.
|
||||
*/
|
||||
invalidateLevel: (value: string | null) => void
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Constants */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Highlight dwell before `prefetch` fetches: long enough that holding ArrowDown
|
||||
* does not fire a request per row, short enough to beat the ArrowRight press.
|
||||
*/
|
||||
const PREFETCH_DELAY = 150
|
||||
|
||||
/** Request keys for the two non-level requests. Never collide with a value. */
|
||||
const SEARCH_KEY = "\u0000search"
|
||||
const RESOLVE_PREFIX = "\u0000resolve:"
|
||||
|
||||
const NO_STATE: CascaderLoadState = {
|
||||
loading: false,
|
||||
error: false,
|
||||
hasMore: false,
|
||||
}
|
||||
|
||||
/** Stable empty result, so an idle search never churns the state context. */
|
||||
const NO_RESULTS: CascaderNode<never>[] = []
|
||||
|
||||
function createStore<T>(): CascaderLoaderStore<T> {
|
||||
return { pages: new Map(), states: new Map(), detached: new Map() }
|
||||
}
|
||||
|
||||
function sameLoadState(a: CascaderLoadState, b: CascaderLoadState): boolean {
|
||||
return (
|
||||
a.loading === b.loading &&
|
||||
a.error === b.error &&
|
||||
a.hasMore === b.hasMore &&
|
||||
a.cursor === b.cursor
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Store transitions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Copy-on-write, and a NO-OP when nothing changed: the merged index is memoised
|
||||
* on store identity, so a fresh object for an unchanged state would rebuild it.
|
||||
*/
|
||||
function withLoadState<T>(
|
||||
store: CascaderLoaderStore<T>,
|
||||
key: string,
|
||||
update: (state: CascaderLoadState) => CascaderLoadState
|
||||
): CascaderLoaderStore<T> {
|
||||
const current = store.states.get(key) ?? NO_STATE
|
||||
const next = update(current)
|
||||
if (store.states.has(key) && sameLoadState(current, next)) return store
|
||||
const states = new Map(store.states)
|
||||
states.set(key, next)
|
||||
return { pages: store.pages, states, detached: store.detached }
|
||||
}
|
||||
|
||||
function withPage<T>(
|
||||
store: CascaderLoaderStore<T>,
|
||||
key: string,
|
||||
items: readonly CascaderNode<T>[],
|
||||
options: { append: boolean; hasMore: boolean; cursor?: string }
|
||||
): CascaderLoaderStore<T> {
|
||||
// A fresh level REPLACES its page so a `resolveValue` stub can be superseded.
|
||||
const previous = options.append ? (store.pages.get(key) ?? []) : []
|
||||
const seen = new Set(previous.map((node) => node.value))
|
||||
const merged = previous.slice()
|
||||
for (const item of items) {
|
||||
if (seen.has(item.value)) continue
|
||||
seen.add(item.value)
|
||||
merged.push(item)
|
||||
}
|
||||
|
||||
const pages = new Map(store.pages)
|
||||
pages.set(key, merged)
|
||||
const states = new Map(store.states)
|
||||
states.set(key, {
|
||||
loading: false,
|
||||
error: false,
|
||||
hasMore: options.hasMore,
|
||||
cursor: options.cursor,
|
||||
})
|
||||
return { pages, states, detached: store.detached }
|
||||
}
|
||||
|
||||
function withDetached<T>(
|
||||
store: CascaderLoaderStore<T>,
|
||||
items: readonly CascaderNode<T>[]
|
||||
): CascaderLoaderStore<T> {
|
||||
let detached: Map<string, CascaderNode<T>> | null = null
|
||||
for (const item of items) {
|
||||
if (store.detached.get(item.value) === item) continue
|
||||
detached = detached ?? new Map(store.detached)
|
||||
detached.set(item.value, item)
|
||||
}
|
||||
if (!detached) return store
|
||||
return { pages: store.pages, states: store.states, detached }
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a resolved ancestor chain into `pages`, root first. Writes no
|
||||
* `states`, so those levels still read as unloaded and a drill-in still fetches
|
||||
* for real. Mirrored into `detached` so the trigger keeps its label.
|
||||
*/
|
||||
function withChain<T>(
|
||||
store: CascaderLoaderStore<T>,
|
||||
chain: readonly CascaderNode<T>[]
|
||||
): CascaderLoaderStore<T> {
|
||||
if (chain.length === 0) return store
|
||||
const pages = new Map(store.pages)
|
||||
const detached = new Map(store.detached)
|
||||
let parentKey = CASCADER_ROOT_KEY
|
||||
|
||||
for (const node of chain) {
|
||||
const bucket = pages.get(parentKey)
|
||||
if (!bucket) {
|
||||
pages.set(parentKey, [node])
|
||||
} else if (!bucket.some((entry) => entry.value === node.value)) {
|
||||
pages.set(parentKey, [...bucket, node])
|
||||
}
|
||||
detached.set(node.value, node)
|
||||
parentKey = node.value
|
||||
}
|
||||
|
||||
return { pages, states: store.states, detached }
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Hook */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface CascaderLoaderLatest<T> {
|
||||
base: CascaderIndex<T>
|
||||
store: CascaderLoaderStore<T>
|
||||
getChildren?: CascaderGetChildren<T>
|
||||
onSearch?: CascaderOnSearch<T>
|
||||
resolveValue?: CascaderResolveValue<T>
|
||||
onLoadError?: (
|
||||
error: unknown,
|
||||
context: { parent: string | null; reason: string }
|
||||
) => void
|
||||
prefetch: boolean
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface CascaderSearchSlice<T> {
|
||||
query: string
|
||||
results: CascaderNode<T>[]
|
||||
loading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader. A SIBLING of the `buildCascaderIndex` memo, never inside it: the
|
||||
* build stays pure in `items`, the merge pure in that build plus this store.
|
||||
*/
|
||||
export function useCascaderLoader<T = unknown>({
|
||||
base,
|
||||
getChildren,
|
||||
onSearch,
|
||||
resolveValue,
|
||||
searchDebounce = 250,
|
||||
loadKey,
|
||||
prefetch = false,
|
||||
onLoadError,
|
||||
enabled,
|
||||
query,
|
||||
levels,
|
||||
path,
|
||||
values,
|
||||
}: UseCascaderLoaderOptions<T>): CascaderLoader<T> {
|
||||
const [store, setStore] = React.useState<CascaderLoaderStore<T>>(createStore)
|
||||
const [search, setSearch] = React.useState<CascaderSearchSlice<T> | null>(
|
||||
null
|
||||
)
|
||||
|
||||
/**
|
||||
* Latest callbacks, WRITTEN IN AN EFFECT: `getChildren` is inline in most
|
||||
* consumers, so closing over it would refire every in-flight request per
|
||||
* re-render. The ref is what keeps the request machinery `[]`-dep. Declared
|
||||
* FIRST, since effects run in declaration order, so the level effect below
|
||||
* already sees the current commit.
|
||||
*/
|
||||
const latest = React.useRef<CascaderLoaderLatest<T>>({
|
||||
base,
|
||||
store,
|
||||
getChildren,
|
||||
onSearch,
|
||||
resolveValue,
|
||||
onLoadError,
|
||||
prefetch,
|
||||
path,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
latest.current = {
|
||||
base,
|
||||
store,
|
||||
getChildren,
|
||||
onSearch,
|
||||
resolveValue,
|
||||
onLoadError,
|
||||
prefetch,
|
||||
path,
|
||||
}
|
||||
})
|
||||
|
||||
/** One AbortController PER KEY: columns mode runs several levels at once. */
|
||||
const controllers = React.useRef(new Map<string, AbortController>())
|
||||
/** Monotonic per key. The stale guard for out-of-order responses. */
|
||||
const requestIds = React.useRef(new Map<string, number>())
|
||||
/** In-flight `(level, cursor)` signatures, so a duplicate ask is free. */
|
||||
const inflight = React.useRef(new Map<string, string>())
|
||||
/**
|
||||
* The `(child count, cursor)` signature at the last paging fire, per level.
|
||||
* Guards what `hasMore` cannot: a page of zero new items while the server
|
||||
* still says `hasMore`. Cursor is IN the signature because an all-duplicates
|
||||
* page advances it while the count stands still - real progress, which a
|
||||
* count-only latch would brick forever.
|
||||
*/
|
||||
const moreLatch = React.useRef(new Map<string, string>())
|
||||
/** Values `resolveValue` has already been asked about, so it asks once. */
|
||||
const attempted = React.useRef(new Set<string>())
|
||||
/** Every node the loader has seen, so a level key can name its own node. */
|
||||
const known = React.useRef(new Map<string, CascaderNode<T>>())
|
||||
const timers = React.useRef<{
|
||||
prefetch: ReturnType<typeof setTimeout> | null
|
||||
}>({ prefetch: null })
|
||||
/** Bumped by a `loadKey` change, so responses from before it are dropped. */
|
||||
const epoch = React.useRef(0)
|
||||
|
||||
const remember = React.useCallback((nodes: readonly CascaderNode<T>[]) => {
|
||||
const map = known.current
|
||||
const walk = (list: readonly CascaderNode<T>[]) => {
|
||||
for (const node of list) {
|
||||
map.set(node.value, node)
|
||||
if (node.children?.length) walk(node.children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
}, [])
|
||||
|
||||
const abortKey = React.useCallback((key: string) => {
|
||||
const controller = controllers.current.get(key)
|
||||
if (!controller) return
|
||||
controllers.current.delete(key)
|
||||
inflight.current.delete(key)
|
||||
controller.abort()
|
||||
}, [])
|
||||
|
||||
/* ------------------------------- level load ------------------------------ */
|
||||
|
||||
const runLoad = React.useCallback(
|
||||
(
|
||||
key: string,
|
||||
reason: CascaderLoadReason,
|
||||
cursor: string | undefined,
|
||||
append: boolean
|
||||
) => {
|
||||
const { getChildren: loader, base: currentBase } = latest.current
|
||||
if (!loader) return
|
||||
|
||||
const signature = `${append ? "1" : "0"}:${cursor ?? ""}`
|
||||
if (inflight.current.get(key) === signature) return
|
||||
|
||||
abortKey(key)
|
||||
const controller = new AbortController()
|
||||
controllers.current.set(key, controller)
|
||||
inflight.current.set(key, signature)
|
||||
|
||||
const requestId = (requestIds.current.get(key) ?? 0) + 1
|
||||
requestIds.current.set(key, requestId)
|
||||
const startEpoch = epoch.current
|
||||
|
||||
const node =
|
||||
key === CASCADER_ROOT_KEY
|
||||
? null
|
||||
: (currentBase.byValue.get(key) ?? known.current.get(key) ?? null)
|
||||
|
||||
setStore((prev) =>
|
||||
withLoadState(prev, key, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: false,
|
||||
}))
|
||||
)
|
||||
|
||||
const settle = () => {
|
||||
if (inflight.current.get(key) === signature)
|
||||
inflight.current.delete(key)
|
||||
if (controllers.current.get(key) === controller) {
|
||||
controllers.current.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const stale = () =>
|
||||
controller.signal.aborted ||
|
||||
startEpoch !== epoch.current ||
|
||||
requestId !== requestIds.current.get(key)
|
||||
|
||||
// `Promise.resolve().then(...)`, not a direct call: it normalises a
|
||||
// SYNCHRONOUS throw into a rejection instead of taking the render down.
|
||||
Promise.resolve()
|
||||
.then(() => loader(node, { signal: controller.signal, cursor, reason }))
|
||||
.then((result) => {
|
||||
settle()
|
||||
if (stale()) return
|
||||
const items = Array.isArray(result) ? result : result.items
|
||||
const nextCursor = Array.isArray(result)
|
||||
? undefined
|
||||
: result.nextCursor
|
||||
const hasMore = Array.isArray(result)
|
||||
? false
|
||||
: (result.hasMore ?? nextCursor != null)
|
||||
|
||||
if (!append) moreLatch.current.delete(key)
|
||||
remember(items)
|
||||
setStore((prev) =>
|
||||
withPage(prev, key, items, { append, hasMore, cursor: nextCursor })
|
||||
)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
settle()
|
||||
if (stale()) return
|
||||
setStore((prev) =>
|
||||
withLoadState(prev, key, (state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}))
|
||||
)
|
||||
// Behind the stale guard: an abort is navigation, not a failure.
|
||||
latest.current.onLoadError?.(error, {
|
||||
parent: key === CASCADER_ROOT_KEY ? null : key,
|
||||
reason,
|
||||
})
|
||||
})
|
||||
},
|
||||
[abortKey, remember]
|
||||
)
|
||||
|
||||
const ensureLevel = React.useCallback(
|
||||
(key: string, reason: CascaderLoadReason) => {
|
||||
const {
|
||||
getChildren: loader,
|
||||
store: current,
|
||||
base: index,
|
||||
} = latest.current
|
||||
if (!loader) return
|
||||
if (current.states.has(key)) return
|
||||
// A level `items` already fills is not the loader's business: that is how
|
||||
// a static root plus `getChildren` for the branches works with no flag.
|
||||
if (index.childrenOf.has(key)) return
|
||||
runLoad(key, reason, undefined, false)
|
||||
},
|
||||
[runLoad]
|
||||
)
|
||||
|
||||
const loadMore = React.useCallback(
|
||||
(key: string) => {
|
||||
const { getChildren: loader, store: current } = latest.current
|
||||
if (!loader) return
|
||||
const state = current.states.get(key)
|
||||
if (!state || state.loading || !state.hasMore) return
|
||||
|
||||
const loaded = current.pages.get(key)?.length ?? 0
|
||||
const signature = `${loaded}:${state.cursor ?? ""}`
|
||||
if (moreLatch.current.get(key) === signature) return
|
||||
moreLatch.current.set(key, signature)
|
||||
|
||||
runLoad(key, "more", state.cursor, true)
|
||||
},
|
||||
[runLoad]
|
||||
)
|
||||
|
||||
const retryLevel = React.useCallback(
|
||||
(key: string) => {
|
||||
const { getChildren: loader, store: current } = latest.current
|
||||
if (!loader) return
|
||||
const state = current.states.get(key)
|
||||
if (!state?.error) return
|
||||
|
||||
const loaded = current.pages.get(key)?.length ?? 0
|
||||
// A retry must be able to re-fire the page the latch just blocked.
|
||||
moreLatch.current.delete(key)
|
||||
runLoad(key, "retry", loaded > 0 ? state.cursor : undefined, loaded > 0)
|
||||
},
|
||||
[runLoad]
|
||||
)
|
||||
|
||||
/**
|
||||
* `detached` is deliberately untouched: chains and search hits belong to no
|
||||
* level, and the trigger needs their labels while the new page is in flight.
|
||||
*/
|
||||
const invalidateLevel = React.useCallback(
|
||||
(value: string | null) => {
|
||||
const key = value ?? CASCADER_ROOT_KEY
|
||||
abortKey(key)
|
||||
// Bump the id too: a response past its signal check must still be stale.
|
||||
requestIds.current.set(key, (requestIds.current.get(key) ?? 0) + 1)
|
||||
moreLatch.current.delete(key)
|
||||
setStore((prev) => {
|
||||
if (!prev.states.has(key) && !prev.pages.has(key)) return prev
|
||||
const states = new Map(prev.states)
|
||||
states.delete(key)
|
||||
const pages = new Map(prev.pages)
|
||||
pages.delete(key)
|
||||
return { pages, states, detached: prev.detached }
|
||||
})
|
||||
},
|
||||
[abortKey]
|
||||
)
|
||||
|
||||
const prefetchNode = React.useCallback(
|
||||
(node: CascaderNode<T> | null | undefined) => {
|
||||
const {
|
||||
prefetch: on,
|
||||
getChildren: loader,
|
||||
store: current,
|
||||
} = latest.current
|
||||
if (!on || !loader || !node) return
|
||||
if (isCascaderMoreNode(node)) return
|
||||
if (!node.hasChildren) return
|
||||
if (current.states.has(node.value)) return
|
||||
|
||||
const holder = timers.current
|
||||
if (holder.prefetch) clearTimeout(holder.prefetch)
|
||||
// A TIMEOUT, not a direct call: `onItemHighlighted` fires from a layout
|
||||
// effect, where a synchronous setState is a render-phase cascade.
|
||||
holder.prefetch = setTimeout(() => {
|
||||
holder.prefetch = null
|
||||
ensureLevel(node.value, "prefetch")
|
||||
}, PREFETCH_DELAY)
|
||||
},
|
||||
[ensureLevel]
|
||||
)
|
||||
|
||||
/* --------------------------------- search -------------------------------- */
|
||||
|
||||
const runSearch = React.useCallback(
|
||||
(text: string) => {
|
||||
const { onSearch: searcher, path: currentPath } = latest.current
|
||||
if (!searcher) return
|
||||
|
||||
abortKey(SEARCH_KEY)
|
||||
const controller = new AbortController()
|
||||
controllers.current.set(SEARCH_KEY, controller)
|
||||
const requestId = (requestIds.current.get(SEARCH_KEY) ?? 0) + 1
|
||||
requestIds.current.set(SEARCH_KEY, requestId)
|
||||
const startEpoch = epoch.current
|
||||
|
||||
setSearch((prev) => ({
|
||||
query: text,
|
||||
results: prev?.results ?? [],
|
||||
loading: true,
|
||||
error: false,
|
||||
}))
|
||||
|
||||
const settle = () => {
|
||||
if (controllers.current.get(SEARCH_KEY) === controller) {
|
||||
controllers.current.delete(SEARCH_KEY)
|
||||
}
|
||||
}
|
||||
const stale = () =>
|
||||
controller.signal.aborted ||
|
||||
startEpoch !== epoch.current ||
|
||||
requestId !== requestIds.current.get(SEARCH_KEY)
|
||||
|
||||
Promise.resolve()
|
||||
.then(() =>
|
||||
searcher(text, { signal: controller.signal, path: currentPath })
|
||||
)
|
||||
.then((result) => {
|
||||
settle()
|
||||
if (stale()) return
|
||||
const items = Array.isArray(result) ? result : result.items
|
||||
remember(items)
|
||||
// Search hits go to `detached`, never a level: a hit lives anywhere in
|
||||
// the tree, and filing it under the open level would misplace it.
|
||||
setStore((prev) => withDetached(prev, items))
|
||||
setSearch({
|
||||
query: text,
|
||||
results: items,
|
||||
loading: false,
|
||||
error: false,
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
settle()
|
||||
if (stale()) return
|
||||
setSearch((prev) => ({
|
||||
query: text,
|
||||
results: prev?.results ?? [],
|
||||
loading: false,
|
||||
error: true,
|
||||
}))
|
||||
latest.current.onLoadError?.(error, {
|
||||
parent: null,
|
||||
reason: "search",
|
||||
})
|
||||
})
|
||||
},
|
||||
[abortKey, remember]
|
||||
)
|
||||
|
||||
/* --------------------------------- resolve ------------------------------- */
|
||||
|
||||
const runResolve = React.useCallback(
|
||||
(value: string) => {
|
||||
const { resolveValue: resolver } = latest.current
|
||||
if (!resolver) return
|
||||
|
||||
const key = `${RESOLVE_PREFIX}${value}`
|
||||
abortKey(key)
|
||||
const controller = new AbortController()
|
||||
controllers.current.set(key, controller)
|
||||
const startEpoch = epoch.current
|
||||
|
||||
const settle = () => {
|
||||
if (controllers.current.get(key) === controller) {
|
||||
controllers.current.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(() =>
|
||||
resolver(value, { signal: controller.signal, reason: "resolve" })
|
||||
)
|
||||
.then((chain) => {
|
||||
settle()
|
||||
if (controller.signal.aborted || startEpoch !== epoch.current) return
|
||||
if (!chain?.length) return
|
||||
remember(chain)
|
||||
setStore((prev) => withChain(prev, chain))
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
settle()
|
||||
// Un-attempt on failure: `attempted` is written BEFORE the call, so
|
||||
// without this a resolver that failed once could never be retried.
|
||||
attempted.current.delete(value)
|
||||
if (controller.signal.aborted || startEpoch !== epoch.current) return
|
||||
latest.current.onLoadError?.(error, {
|
||||
parent: null,
|
||||
reason: "resolve",
|
||||
})
|
||||
})
|
||||
},
|
||||
[abortKey, remember]
|
||||
)
|
||||
|
||||
/* --------------------------------- resets -------------------------------- */
|
||||
|
||||
const cancelAll = React.useCallback(() => {
|
||||
const keys = Array.from(controllers.current.keys())
|
||||
for (const controller of controllers.current.values()) controller.abort()
|
||||
controllers.current.clear()
|
||||
inflight.current.clear()
|
||||
|
||||
const holder = timers.current
|
||||
if (holder.prefetch) {
|
||||
clearTimeout(holder.prefetch)
|
||||
holder.prefetch = null
|
||||
}
|
||||
if (keys.length === 0) return
|
||||
|
||||
setStore((prev) => {
|
||||
let states: Map<string, CascaderLoadState> | null = null
|
||||
for (const key of keys) {
|
||||
const state = prev.states.get(key)
|
||||
if (!state?.loading) continue
|
||||
states = states ?? new Map(prev.states)
|
||||
if ((prev.pages.get(key)?.length ?? 0) > 0) {
|
||||
states.set(key, { ...state, loading: false })
|
||||
} else {
|
||||
// No entry AT ALL: membership is what says "loaded", so a stranded
|
||||
// `loading: true` would read as loaded-and-empty forever.
|
||||
states.delete(key)
|
||||
}
|
||||
}
|
||||
return states ? { ...prev, states } : prev
|
||||
})
|
||||
setSearch((prev) => (prev?.loading ? { ...prev, loading: false } : prev))
|
||||
}, [])
|
||||
|
||||
// A `loadKey` change drops everything. Declared BEFORE the level effect, so
|
||||
// a reset always lands before the levels are asked for again.
|
||||
const loadKeyRef = React.useRef<{ key: unknown } | null>(null)
|
||||
React.useEffect(() => {
|
||||
const previous = loadKeyRef.current
|
||||
loadKeyRef.current = { key: loadKey }
|
||||
if (!previous || Object.is(previous.key, loadKey)) return
|
||||
|
||||
epoch.current += 1
|
||||
for (const controller of controllers.current.values()) controller.abort()
|
||||
controllers.current.clear()
|
||||
inflight.current.clear()
|
||||
// `requestIds` is deliberately NOT cleared: the ids are stale-response
|
||||
// guards, not cache. Reusing an aborted predecessor's id would leave only
|
||||
// the epoch bump and the abort to reject the old response; monotonic per
|
||||
// key keeps all three guards independent. The unmount cleanup clears them.
|
||||
moreLatch.current.clear()
|
||||
attempted.current.clear()
|
||||
known.current.clear()
|
||||
|
||||
const holder = timers.current
|
||||
if (holder.prefetch) {
|
||||
clearTimeout(holder.prefetch)
|
||||
holder.prefetch = null
|
||||
}
|
||||
|
||||
setStore(createStore)
|
||||
setSearch(null)
|
||||
}, [loadKey])
|
||||
|
||||
// Closing the popup aborts every request but does NOT drop the cache:
|
||||
// reopening onto an already-loaded level is the point of keeping it.
|
||||
React.useEffect(() => {
|
||||
if (enabled) return
|
||||
if (controllers.current.size === 0) return
|
||||
cancelAll()
|
||||
}, [enabled, cancelAll])
|
||||
|
||||
React.useEffect(() => {
|
||||
const active = controllers.current
|
||||
const holder = timers.current
|
||||
const pending = inflight.current
|
||||
const ids = requestIds.current
|
||||
return () => {
|
||||
for (const controller of active.values()) controller.abort()
|
||||
active.clear()
|
||||
// Cleared WITH the controllers, for StrictMode's dev remount: a stale
|
||||
// inflight signature would make `runLoad` skip the refetch forever. The
|
||||
// aborted promises are stale-guarded, so emptying the ids is safe.
|
||||
pending.clear()
|
||||
ids.clear()
|
||||
if (holder.prefetch) clearTimeout(holder.prefetch)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ------------------------------- the effects ----------------------------- */
|
||||
|
||||
const hasLoader = typeof getChildren === "function"
|
||||
const hasSearch = typeof onSearch === "function"
|
||||
const hasResolve = typeof resolveValue === "function"
|
||||
const trimmed = query.trim()
|
||||
|
||||
// Serialised so the effects key on CONTENT, not on the array identity.
|
||||
const levelsKey = JSON.stringify(levels)
|
||||
const valuesKey = JSON.stringify(values)
|
||||
|
||||
// THE load trigger: one declarative effect on the levels on screen, not a
|
||||
// call from `pushLevel`/`navigate`, which a controlled `path` never touches.
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !hasLoader) return
|
||||
for (const key of levels) ensureLevel(key, "level")
|
||||
// `levels` enters through `levelsKey`; `store` re-runs after a load lands.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, hasLoader, levelsKey, store, ensureLevel])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasSearch) return undefined
|
||||
if (!enabled || !trimmed) {
|
||||
abortKey(SEARCH_KEY)
|
||||
setSearch((prev) => (prev === null ? prev : null))
|
||||
return undefined
|
||||
}
|
||||
const timer = setTimeout(() => runSearch(trimmed), searchDebounce)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
abortKey(SEARCH_KEY)
|
||||
}
|
||||
}, [hasSearch, enabled, trimmed, searchDebounce, abortKey, runSearch])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !hasResolve) return
|
||||
for (const value of values) {
|
||||
if (!value) continue
|
||||
if (attempted.current.has(value)) continue
|
||||
if (base.byValue.has(value) || known.current.has(value)) continue
|
||||
attempted.current.add(value)
|
||||
runResolve(value)
|
||||
}
|
||||
// `values` is depended on through `valuesKey`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, hasResolve, valuesKey, base, runResolve])
|
||||
|
||||
/* -------------------------------- the value ------------------------------ */
|
||||
|
||||
const searchResults = React.useMemo(() => {
|
||||
if (!hasSearch || !trimmed) return null
|
||||
if (!search || search.query !== trimmed) {
|
||||
return NO_RESULTS as CascaderNode<T>[]
|
||||
}
|
||||
return search.results
|
||||
}, [hasSearch, trimmed, search])
|
||||
|
||||
const searchState = React.useMemo<CascaderLoadState | null>(() => {
|
||||
if (!hasSearch || !trimmed) return null
|
||||
const settled = search?.query === trimmed
|
||||
return {
|
||||
loading: !settled || !!search?.loading,
|
||||
error: settled && !!search?.error,
|
||||
hasMore: false,
|
||||
}
|
||||
}, [hasSearch, trimmed, search])
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
active: hasLoader,
|
||||
store,
|
||||
states: store.states,
|
||||
searchResults,
|
||||
searchState,
|
||||
ensureLevel,
|
||||
loadMore,
|
||||
retryLevel,
|
||||
invalidateLevel,
|
||||
prefetchNode,
|
||||
}),
|
||||
[
|
||||
hasLoader,
|
||||
store,
|
||||
searchResults,
|
||||
searchState,
|
||||
ensureLevel,
|
||||
loadMore,
|
||||
retryLevel,
|
||||
invalidateLevel,
|
||||
prefetchNode,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Consumers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** One level's load state, `null` when never fetched. Omit for the root. */
|
||||
export function useCascaderLoadState(
|
||||
parent?: string | null
|
||||
): CascaderLoadState | null {
|
||||
const { loadStates } = useCascaderState()
|
||||
return loadStates.get(parent ?? CASCADER_ROOT_KEY) ?? null
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
useCascaderActions,
|
||||
useCascaderState,
|
||||
} from "@/components/reui/cascader/cascader-context"
|
||||
import type { CascaderColumn } from "@/components/reui/cascader/cascader-context"
|
||||
import {
|
||||
CascaderItem,
|
||||
getCascaderMoreProps,
|
||||
} from "@/components/reui/cascader/cascader-item"
|
||||
import {
|
||||
CASCADER_LIST_HEIGHT_CLASS,
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
CASCADER_ROOT_KEY,
|
||||
CASCADER_ROWS_CLASS,
|
||||
CASCADER_SCROLL_CLASS,
|
||||
warnCascaderOnce,
|
||||
} from "@/components/reui/cascader/cascader-lib"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
|
||||
import { LoaderCircleIcon } from "lucide-react"
|
||||
|
||||
export interface CascaderColumnsProps extends Omit<
|
||||
React.ComponentProps<"div">,
|
||||
"children"
|
||||
> {
|
||||
/** Width of each column. */
|
||||
columnWidth?: number | string
|
||||
/** Height CAP per column. Falls back to the root `maxHeight`, then 24rem. */
|
||||
maxHeight?: number | string
|
||||
/** Replaces the default panel; the seam a windowed column plugs into. */
|
||||
children?: (column: CascaderColumn) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Miller columns: the open trail side by side, one panel per level. Only the
|
||||
* DEEPEST column is a real listbox (Base UI owns exactly one list); the trail
|
||||
* behind is plain buttons, which keeps one state machine instead of a second,
|
||||
* 2D one. `CascaderInput` moves between columns with ArrowLeft/ArrowRight.
|
||||
*/
|
||||
function CascaderColumns({
|
||||
className,
|
||||
columnWidth = 220,
|
||||
maxHeight: maxHeightProp,
|
||||
children,
|
||||
...props
|
||||
}: CascaderColumnsProps) {
|
||||
const { maxHeight, mode, labels } = useCascaderActions()
|
||||
const { columns } = useCascaderState()
|
||||
|
||||
// Before the early return, so the hook count is the same in both modes.
|
||||
React.useEffect(() => {
|
||||
if (process.env.NODE_ENV === "production") return
|
||||
if (mode === "columns") return
|
||||
warnCascaderOnce(
|
||||
`columns-outside-columns-mode:${mode}`,
|
||||
`\`CascaderColumns\` renders nothing in \`mode="${mode}"\`, so \`columnWidth\` and everything else on it does nothing. Set \`mode="columns"\` on the root, or render \`CascaderList\` instead.`
|
||||
)
|
||||
}, [mode])
|
||||
|
||||
if (mode !== "columns") return null
|
||||
|
||||
const height = maxHeightProp ?? maxHeight
|
||||
const toCss = (value: number | string) =>
|
||||
typeof value === "number" ? `${value}px` : value
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="cascader-columns"
|
||||
role="group"
|
||||
aria-label={labels.columnsLabel}
|
||||
style={
|
||||
{
|
||||
"--cascader-column-width": toCss(columnWidth),
|
||||
/* Set only from an EXPLICIT cap: unset means "24rem or what the
|
||||
viewport leaves", via the `min()` fallback on the panel. A `?? 280`
|
||||
default here ignored short viewports and wasted tall ones. */
|
||||
...(height != null
|
||||
? { "--cascader-max-height": toCss(height) }
|
||||
: null),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
/* `max-h-full` with `min-h-0`: the trail is the panel's shrinking
|
||||
child, and the columns inside it size against this box. */
|
||||
"flex max-h-full min-h-0 items-stretch overflow-x-auto overscroll-x-contain",
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{columns.map((column) =>
|
||||
children ? (
|
||||
<React.Fragment key={column.depth}>{children(column)}</React.Fragment>
|
||||
) : (
|
||||
<CascaderColumnPanel key={column.depth} column={column} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One column's box. `columnWidth` is the width of the LIST, not the box: under
|
||||
* `border-box` the 1px inline-start divider on every column but the first would
|
||||
* come out of the rows, so bordered columns are widened by that pixel. The box
|
||||
* is also the BOUND, the same `min(--available-height, cap)` the single list
|
||||
* uses, and the `ScrollArea` inside scrolls, so every column shows a thumb.
|
||||
*/
|
||||
const PANEL_CLASS = `flex w-(--cascader-column-width) shrink-0 flex-col overscroll-contain not-first:w-[calc(var(--cascader-column-width)_+_1px)] not-first:border-border/60 not-first:border-s ${CASCADER_LIST_HEIGHT_CLASS}`
|
||||
|
||||
export interface CascaderColumnPanelProps {
|
||||
column: CascaderColumn
|
||||
/** Replaces the panel's rows; the empty state still wins on an empty column. */
|
||||
children?: React.ReactNode
|
||||
/** Containing block for the windowed column's absolutely positioned rows. */
|
||||
virtualized?: boolean
|
||||
}
|
||||
|
||||
function CascaderColumnPanel({
|
||||
column,
|
||||
children,
|
||||
virtualized,
|
||||
}: CascaderColumnPanelProps) {
|
||||
const {
|
||||
labels,
|
||||
baseId,
|
||||
isBranch,
|
||||
isSelectable,
|
||||
isSelected,
|
||||
isIndeterminate,
|
||||
retryLevel,
|
||||
} = useCascaderActions()
|
||||
const { loadStates } = useCascaderState()
|
||||
|
||||
// Keyed per level, not one global flag: columns load and land independently.
|
||||
const columnKey = column.parent?.value ?? CASCADER_ROOT_KEY
|
||||
const loadState = loadStates.get(columnKey)
|
||||
|
||||
let emptyBody: React.ReactNode = labels.empty
|
||||
if (loadState?.error) {
|
||||
emptyBody = (
|
||||
<>
|
||||
{labels.error}{" "}
|
||||
<button
|
||||
type="button"
|
||||
data-slot="cascader-retry"
|
||||
onClick={() => retryLevel(columnKey)}
|
||||
className="text-foreground hover:bg-accent focus-visible:ring-ring/50 rounded-md px-1 font-medium outline-hidden transition-colors focus-visible:ring-2"
|
||||
>
|
||||
{labels.retry}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
} else if (loadState?.loading) {
|
||||
emptyBody = (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<LoaderCircleIcon className="size-3.5 animate-spin" aria-hidden />
|
||||
{labels.loading}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const rows =
|
||||
column.items.length === 0 ? (
|
||||
<p
|
||||
data-slot="cascader-column-empty"
|
||||
data-state={
|
||||
loadState?.error ? "error" : loadState?.loading ? "loading" : "empty"
|
||||
}
|
||||
className="text-muted-foreground px-2 py-1.5 text-sm"
|
||||
>
|
||||
{emptyBody}
|
||||
</p>
|
||||
) : (
|
||||
(children ??
|
||||
column.items.map((node, i) => {
|
||||
const open = node.value === column.activeValue
|
||||
return (
|
||||
<CascaderItem
|
||||
key={node.value}
|
||||
node={node}
|
||||
/* A trail row must not compete for `aria-activedescendant`. */
|
||||
as={column.active ? "option" : "button"}
|
||||
depth={column.depth}
|
||||
/* Answered here, not in the row: the trail rows are memoised too. */
|
||||
branch={isBranch(node)}
|
||||
selectable={isSelectable(node)}
|
||||
selected={isSelected(node)}
|
||||
indeterminate={isIndeterminate(node)}
|
||||
{...getCascaderMoreProps(node, loadStates)}
|
||||
data-open={open || undefined}
|
||||
className={open ? "bg-accent/60 text-accent-foreground" : undefined}
|
||||
/* Set metadata is option-only; a trail row is a `role="button"`. */
|
||||
{...(column.active
|
||||
? {
|
||||
"aria-setsize": column.items.length,
|
||||
"aria-posinset": i + 1,
|
||||
}
|
||||
: null)}
|
||||
{...(!column.active && open
|
||||
? {
|
||||
"aria-expanded": true,
|
||||
"aria-controls": `${baseId}-column-${column.depth + 1}`,
|
||||
}
|
||||
: null)}
|
||||
/>
|
||||
)
|
||||
}))
|
||||
)
|
||||
|
||||
const shared = {
|
||||
"data-slot": "cascader-column",
|
||||
"data-active": column.active || undefined,
|
||||
"data-depth": column.depth,
|
||||
// Addressable so the opening trail row can point `aria-controls` here, and
|
||||
// named even at the root, which has no parent label to borrow.
|
||||
id: `${baseId}-column-${column.depth}`,
|
||||
"aria-label": column.parent?.label ?? labels.rootLevel,
|
||||
// Conditional spread, never an explicit `undefined`: the active column is a
|
||||
// Base UI element, and its `mergeProps` iterates own keys.
|
||||
...(virtualized ? { "data-virtualized": true } : null),
|
||||
}
|
||||
|
||||
// A windowed row is absolutely positioned, so the ROWS' box is the containing
|
||||
// block, not the scrollport: it carries the padding the geometry is measured
|
||||
// against.
|
||||
const rowsClass = cn(CASCADER_ROWS_CLASS, virtualized && "relative")
|
||||
|
||||
// The active column IS the Combobox list: only rows inside `Combobox.List`
|
||||
// reach the CompositeList, arrow-key navigation and `aria-activedescendant`.
|
||||
const body = column.active ? (
|
||||
<ComboboxPrimitive.List {...shared} className={rowsClass}>
|
||||
{rows}
|
||||
</ComboboxPrimitive.List>
|
||||
) : (
|
||||
// A named `group`, not a second listbox competing with the active column.
|
||||
<div {...shared} role="group" className={rowsClass}>
|
||||
{rows}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="cascader-column-bounds"
|
||||
/* Repeated from `shared`: this box, not the semantic element, owns the
|
||||
width, the divider and the height, so style hooks must reach it, and
|
||||
`:first-child` on the semantic element no longer means "first column"
|
||||
(it is its own scrollport's only child). */
|
||||
data-active={column.active || undefined}
|
||||
data-depth={column.depth}
|
||||
className={PANEL_CLASS}
|
||||
>
|
||||
<ScrollArea className={CASCADER_SCROLL_CLASS}>{body}</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { CascaderColumnPanel, CascaderColumns }
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import type {
|
||||
CascaderActionItem,
|
||||
CascaderChangeReason,
|
||||
CascaderFlatNode,
|
||||
CascaderIndex,
|
||||
CascaderLabels,
|
||||
CascaderLoadState,
|
||||
CascaderMode,
|
||||
CascaderNode,
|
||||
CascaderSearchScope,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
|
||||
/**
|
||||
* Four contexts, not one: actions (config and callbacks, near-stable), state
|
||||
* (every keystroke), render (`renderItem` identity) and highlight (every arrow
|
||||
* key and pointer move). One combined context re-rendered every row on every
|
||||
* keystroke, which is what made `React.memo` on the row worth nothing.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Shared types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderColumn<T = unknown> {
|
||||
parent: CascaderNode<T> | null
|
||||
items: CascaderNode<T>[]
|
||||
depth: number
|
||||
/** The node in this column that is drilled into, if any. */
|
||||
activeValue: string | null
|
||||
/** Whether this is the deepest column, the one Base UI owns. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface CascaderItemState<T = unknown> {
|
||||
branch: boolean
|
||||
selected: boolean
|
||||
disabled: boolean
|
||||
depth: number
|
||||
count: number
|
||||
/** Ancestor chain, root first. Populated for deep-search rows. */
|
||||
path: CascaderNode<T>[]
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* State */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Everything derived from the query, the path and the selection. One keystroke
|
||||
* rebuilds most of it, so never subscribe to it from a row.
|
||||
*/
|
||||
export interface CascaderStateContextValue<T = unknown> {
|
||||
/** Same `useMemo` identity as the actions context's `index`. */
|
||||
index: CascaderIndex<T>
|
||||
path: string[]
|
||||
expanded: ReadonlySet<string>
|
||||
query: string
|
||||
currentParent: CascaderNode<T> | null
|
||||
/** Rows for the current level, already filtered. */
|
||||
levelItems: CascaderNode<T>[]
|
||||
deepResults: CascaderNode<T>[] | null
|
||||
/** What `Combobox.Root` is currently rendering, in render order. */
|
||||
renderedItems: CascaderNode<T>[]
|
||||
columns: CascaderColumn<T>[]
|
||||
treeRows: CascaderFlatNode<T>[]
|
||||
selectedValues: string[]
|
||||
/** Selected nodes below each value, at any depth. Absent means zero. */
|
||||
selectedDescendants: ReadonlyMap<string, number>
|
||||
/**
|
||||
* Per level, keyed by parent value or `CASCADER_ROOT_KEY`. MEMBERSHIP is the
|
||||
* discriminator: no entry means never fetched, an entry with no `loading`, no
|
||||
* `error` and no children means fetched and genuinely empty.
|
||||
*/
|
||||
loadStates: ReadonlyMap<string, CascaderLoadState>
|
||||
/** Async search, `null` when idle. Separate: a search belongs to no level. */
|
||||
searchState: CascaderLoadState | null
|
||||
announcement: string
|
||||
}
|
||||
|
||||
const CascaderStateContext = React.createContext<
|
||||
CascaderStateContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
/**
|
||||
* One provider serves every `T`, so the context holds an erased `unknown` value
|
||||
* and this cast restores it. The primitive never inspects the payload.
|
||||
*/
|
||||
export function useCascaderState<T = unknown>(): CascaderStateContextValue<T> {
|
||||
const context = React.useContext(CascaderStateContext)
|
||||
if (!context) {
|
||||
throw new Error("useCascaderState must be used within a Cascader")
|
||||
}
|
||||
return context as unknown as CascaderStateContextValue<T>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Actions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Config and callbacks, slow enough that a memoised row can subscribe: the
|
||||
* mutators are `[]`-dep callbacks over a latest-props ref. The three predicates
|
||||
* are the exception, read DURING RENDER where a ref written in an effect would
|
||||
* return the previous commit's answer, so each is memoised on its own input.
|
||||
*/
|
||||
export interface CascaderActionsContextValue<T = unknown> {
|
||||
index: CascaderIndex<T>
|
||||
mode: CascaderMode
|
||||
multiple: boolean
|
||||
/** Multi-select only: a commit propagates over the LOADED subtree. */
|
||||
cascade: boolean
|
||||
/** Whether a BRANCH is committable. Per-LIST: the check gutter is a COLUMN. */
|
||||
branchesSelectable: boolean
|
||||
/** Draws the SINGLE-SELECT check and its gutter. Ignored in multi-select. */
|
||||
indicator: boolean
|
||||
expandTrigger?: "click" | "hover"
|
||||
actions: CascaderActionItem[]
|
||||
searchScope: CascaderSearchScope
|
||||
maxHeight?: number | string
|
||||
inline: boolean
|
||||
invalid: boolean
|
||||
/**
|
||||
* Id prefix; columns are `${baseId}-column-${depth}`. The SCHEME is contract:
|
||||
* the filters primitive's `FilterMenuPinKeeper` restores its highlight across
|
||||
* a live re-pin through `${baseId}-column-0` and the `cascader-item` slot.
|
||||
*/
|
||||
baseId: string
|
||||
labels: CascaderLabels
|
||||
|
||||
/**
|
||||
* Whether rows are WINDOWED. Also handed to `Combobox.Root`, which is what
|
||||
* makes an explicit row `index` legal: forwarding one while this is false
|
||||
* makes `aria-activedescendant` resolve to nothing. Latched per level.
|
||||
*/
|
||||
virtualized: boolean
|
||||
/** Mounts a windowing renderer, returns its unregister. LAYOUT effect only. */
|
||||
registerVirtualRenderer: () => () => void
|
||||
/** The root's `virtualize` prop. `undefined` means "decide by count". */
|
||||
virtualize?: boolean
|
||||
virtualizeThreshold: number
|
||||
estimateRowSize: number
|
||||
overscan: number
|
||||
|
||||
/** Tells "this level is empty" from "not fetched yet" before a load state. */
|
||||
hasLoader: boolean
|
||||
/** Next page of a level. Latched: a page with nothing new is not re-asked. */
|
||||
loadMore: (parentKey: string) => void
|
||||
retryLevel: (parentKey: string) => void
|
||||
/**
|
||||
* Evicts one level's async cache, `null` for the root, so membership reads
|
||||
* never-loaded. A level that is on screen when evicted refetches at once.
|
||||
*/
|
||||
invalidateLevel: (value: string | null) => void
|
||||
|
||||
/** Index as of the last commit. Use in the stable callbacks, not `index`. */
|
||||
getIndex: () => CascaderIndex<T>
|
||||
getState: () => CascaderStateContextValue<T>
|
||||
/** Highlighted row or null. A getter: the highlight moves per arrow key. */
|
||||
getHighlighted: () => CascaderNode<T> | null
|
||||
|
||||
setPath: (next: string[] | ((prev: string[]) => string[])) => void
|
||||
pushLevel: (value: string) => void
|
||||
popLevel: () => void
|
||||
goToDepth: (depth: number) => void
|
||||
toggleExpanded: (value: string) => void
|
||||
/**
|
||||
* Registers a footer submenu as open or closed. `Combobox` has no
|
||||
* `FloatingTree`, so one Escape would dismiss the flyout AND the cascader;
|
||||
* the root's `onOpenChange` guard cancels the close while any is open, from a
|
||||
* ref so it reads as of that event without re-rendering the root.
|
||||
*/
|
||||
setFlyoutOpen: (key: string, open: boolean) => void
|
||||
hasOpenFlyout: () => boolean
|
||||
setQuery: (next: string) => void
|
||||
/**
|
||||
* Replaces the selection. `onValueChange` diffs it against the current one
|
||||
* for its node and reason; pass `reason` only when the caller knows better.
|
||||
*/
|
||||
setSelection: (values: string[], reason?: CascaderChangeReason) => void
|
||||
/** Commits a node, for rows outside the listbox such as ancestor columns. */
|
||||
commit: (node: CascaderNode<T>) => void
|
||||
navigate: (node: CascaderNode<T>) => void
|
||||
/** Into `node` as a child of `depth`, replacing anything deeper. */
|
||||
navigateAt: (node: CascaderNode<T>, depth: number) => void
|
||||
/** Never undefined: falls back to a remembered label, then a synthetic node. */
|
||||
resolveNode: (value: string) => CascaderNode<T>
|
||||
isBranch: (node: CascaderNode<T>) => boolean
|
||||
isSelectable: (node: CascaderNode<T>) => boolean
|
||||
isSelected: (node: CascaderNode<T>) => boolean
|
||||
/** Always `false` without `cascade`: partial selection needs propagation. */
|
||||
isIndeterminate: (node: CascaderNode<T>) => boolean
|
||||
/**
|
||||
* O(1) read of `selectedDescendants`; a memoised row may not subscribe.
|
||||
* Unlike `isIndeterminate` this answers in every mode.
|
||||
*/
|
||||
selectedDescendantCount: (node: CascaderNode<T>) => number
|
||||
}
|
||||
|
||||
const CascaderActionsContext = React.createContext<
|
||||
CascaderActionsContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
export function useCascaderActions<
|
||||
T = unknown,
|
||||
>(): CascaderActionsContextValue<T> {
|
||||
const context = React.useContext(CascaderActionsContext)
|
||||
if (!context) {
|
||||
throw new Error("useCascaderActions must be used within a Cascader")
|
||||
}
|
||||
return context as unknown as CascaderActionsContextValue<T>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Render props */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Their own context, republished every render. They cannot ride on the actions
|
||||
* context: an inline closure read off a memoised object is whichever closure
|
||||
* that object captured, so the row would call a stale prop over stale state.
|
||||
*/
|
||||
export interface CascaderRenderContextValue<T = unknown> {
|
||||
renderItem?: (
|
||||
node: CascaderNode<T>,
|
||||
state: CascaderItemState<T>
|
||||
) => React.ReactNode
|
||||
renderLabel?: (
|
||||
node: CascaderNode<T>,
|
||||
state: CascaderItemState<T>
|
||||
) => React.ReactNode
|
||||
}
|
||||
|
||||
const CascaderRenderContext = React.createContext<CascaderRenderContextValue>(
|
||||
{}
|
||||
)
|
||||
|
||||
export function useCascaderRender<
|
||||
T = unknown,
|
||||
>(): CascaderRenderContextValue<T> {
|
||||
return React.useContext(
|
||||
CascaderRenderContext
|
||||
) as CascaderRenderContextValue<T>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Highlight store */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderHighlight {
|
||||
index: number
|
||||
value: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* An external store, deliberately NOT React state: `onItemHighlighted` fires on
|
||||
* every arrow key AND every pointer move over the list, so `setState` would
|
||||
* re-render the whole root at mousemove rate. A store re-renders subscribers
|
||||
* only, which inside the primitive is the virtualizer.
|
||||
*/
|
||||
export interface CascaderHighlightStore {
|
||||
subscribe: (onStoreChange: () => void) => () => void
|
||||
getSnapshot: () => CascaderHighlight
|
||||
set: (next: CascaderHighlight) => void
|
||||
}
|
||||
|
||||
const NO_HIGHLIGHT: CascaderHighlight = { index: -1, value: null }
|
||||
|
||||
export function createCascaderHighlightStore(): CascaderHighlightStore {
|
||||
let snapshot: CascaderHighlight = NO_HIGHLIGHT
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
return {
|
||||
subscribe(onStoreChange) {
|
||||
listeners.add(onStoreChange)
|
||||
return () => {
|
||||
listeners.delete(onStoreChange)
|
||||
}
|
||||
},
|
||||
// The SAME object until something changes; `useSyncExternalStore` needs it.
|
||||
getSnapshot() {
|
||||
return snapshot
|
||||
},
|
||||
set(next) {
|
||||
if (next.index === snapshot.index && next.value === snapshot.value) return
|
||||
snapshot = next
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared and permanently empty, so the hook degrades outside a `Cascader`. */
|
||||
const FALLBACK_HIGHLIGHT_STORE = createCascaderHighlightStore()
|
||||
|
||||
const CascaderHighlightContext = React.createContext<CascaderHighlightStore>(
|
||||
FALLBACK_HIGHLIGHT_STORE
|
||||
)
|
||||
|
||||
/** Subscribes to the highlight. Re-renders ONLY the calling component. */
|
||||
export function useCascaderHighlight(): CascaderHighlight {
|
||||
const store = React.useContext(CascaderHighlightContext)
|
||||
return React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
CascaderActionsContext,
|
||||
CascaderHighlightContext,
|
||||
CascaderRenderContext,
|
||||
CascaderStateContext,
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
import * as React from "react"
|
||||
import { useCascaderActions } from "@/components/reui/cascader/cascader-context"
|
||||
import {
|
||||
CASCADER_ACTION_CLASS,
|
||||
CascaderGroup,
|
||||
CascaderLabel,
|
||||
} from "@/components/reui/cascader/cascader-item"
|
||||
import {
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
getCascaderFooterStops,
|
||||
isCascaderRtl,
|
||||
} from "@/components/reui/cascader/cascader-lib"
|
||||
import type { CascaderActionItem } from "@/components/reui/cascader/cascader-types"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react"
|
||||
import { useDirection } from "@base-ui/react/direction-provider"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
|
||||
/**
|
||||
* The pinned footer, and the side-anchored flyout a footer row can open. These
|
||||
* are COMMANDS: nothing here joins the selection, the filter set or the
|
||||
* highlight. The flyout is a Base UI `Popover` rendered as a REACT CHILD of
|
||||
* `Combobox.Popup` with its OWN `Portal` and NO `container`: a nested portal
|
||||
* resolves to the parent portal node, so it is a DOM sibling of the combobox
|
||||
* popup (not clipped, not `aria-hidden`) but a React descendant, which is what
|
||||
* the outside-press and focus-out whitelists read. `Combobox` builds no
|
||||
* `FloatingTree`, so the flyout is not consulted first and one Escape would
|
||||
* dismiss both; hence `CascaderSubmenu` registering with the root to turn one
|
||||
* Escape into two. `Combobox.List` clicks its highlighted row on Enter, hence
|
||||
* the footer sitting outside `CascaderList`. And a `Positioner` throws without
|
||||
* its `Portal`, while `modal` stays `false` on the `Root` so the combobox
|
||||
* keeps its own dismissal behaviour.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Footer */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Keys the option list acts on, swallowed at the footer boundary. Escape and
|
||||
* Tab are absent on purpose: Escape must reach the root, Tab must keep moving.
|
||||
*/
|
||||
const FOOTER_SWALLOWED_KEYS = new Set([
|
||||
"Enter",
|
||||
" ",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
])
|
||||
|
||||
export type CascaderFooterProps = React.ComponentProps<"div">
|
||||
|
||||
/**
|
||||
* Actions pinned below the list, a SIBLING of `CascaderList`. Children win
|
||||
* over the root's `actions` prop; with neither it renders nothing.
|
||||
*/
|
||||
function CascaderFooter({
|
||||
className,
|
||||
children,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: CascaderFooterProps) {
|
||||
const { actions, labels } = useCascaderActions()
|
||||
const hasChildren = React.Children.count(children) > 0
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
if (!FOOTER_SWALLOWED_KEYS.has(event.key)) return
|
||||
event.stopPropagation()
|
||||
|
||||
// The strip's own vertical movement, and the way back from the list's
|
||||
// hand-off: either end returns focus to the search field, from which
|
||||
// Base UI's empty highlight resumes the list. Down wraps to the FIELD,
|
||||
// not a command (traps the arrows) or a row (no imperative highlight).
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
|
||||
const footer = event.currentTarget
|
||||
const stops = getCascaderFooterStops(footer)
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
const index = active ? stops.indexOf(active) : -1
|
||||
if (index === -1) return
|
||||
event.preventDefault()
|
||||
const next =
|
||||
event.key === "ArrowDown" ? stops[index + 1] : stops[index - 1]
|
||||
if (next) {
|
||||
next.focus()
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp" && index > 0) return
|
||||
footer
|
||||
.closest<HTMLElement>('[data-slot="cascader-panel"]')
|
||||
?.querySelector<HTMLElement>('[data-slot="cascader-input"]')
|
||||
?.focus()
|
||||
},
|
||||
[onKeyDown]
|
||||
)
|
||||
|
||||
if (!hasChildren && actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="cascader-footer"
|
||||
/* Named, not a bare div: without it a screen reader reaches the actions
|
||||
with nothing to say they are not more options. */
|
||||
role="group"
|
||||
aria-label={labels.actionsLabel}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"border-border/60 flex shrink-0 flex-col gap-0.5 border-t",
|
||||
/* The LIST's padding, not a flat `p-1`: with a padding of its own the
|
||||
two columns of text were 2px out in luma and sera and 4px out in
|
||||
lyra. It also gives a separator in here a number to cancel. */
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
"p-(--cascader-list-pad,4px)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{hasChildren ? children : <CascaderFooterActions actions={actions} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CascaderFooterActions({ actions }: { actions: CascaderActionItem[] }) {
|
||||
return (
|
||||
<>
|
||||
{actions.map((action, i) =>
|
||||
action.items?.length ? (
|
||||
<CascaderSubmenu key={actionKey(action, i)}>
|
||||
<CascaderSubmenuTrigger
|
||||
icon={action.icon}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{action.label}
|
||||
</CascaderSubmenuTrigger>
|
||||
<CascaderSubmenuContent>
|
||||
<CascaderActionList items={action.items} />
|
||||
</CascaderSubmenuContent>
|
||||
</CascaderSubmenu>
|
||||
) : (
|
||||
<CascaderAction
|
||||
key={actionKey(action, i)}
|
||||
icon={action.icon}
|
||||
disabled={action.disabled}
|
||||
onSelect={action.onSelect}
|
||||
>
|
||||
{action.label}
|
||||
</CascaderAction>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function actionKey(action: CascaderActionItem, index: number): string {
|
||||
if (action.value != null) return action.value
|
||||
if (typeof action.label === "string") return action.label
|
||||
return String(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consecutive entries sharing a `group`, as runs not buckets: two separated
|
||||
* runs with the same name stay two, so the author's order survives.
|
||||
*/
|
||||
function groupActionRuns(
|
||||
items: CascaderActionItem[]
|
||||
): { group?: string; items: CascaderActionItem[] }[] {
|
||||
const runs: { group?: string; items: CascaderActionItem[] }[] = []
|
||||
for (const item of items) {
|
||||
const last = runs[runs.length - 1]
|
||||
if (last && last.group === item.group) last.items.push(item)
|
||||
else runs.push({ group: item.group, items: [item] })
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
/**
|
||||
* Flyout body for a data-driven submenu. A named run becomes a real
|
||||
* `CascaderGroup`; unnamed runs stay unwrapped, as an unnamed group is noise.
|
||||
*/
|
||||
function CascaderActionList({ items }: { items: CascaderActionItem[] }) {
|
||||
const { close } = useCascaderSubmenu()
|
||||
const runs = React.useMemo(() => groupActionRuns(items), [items])
|
||||
|
||||
const renderAction = (item: CascaderActionItem, i: number) => (
|
||||
<CascaderAction
|
||||
key={actionKey(item, i)}
|
||||
icon={item.icon}
|
||||
disabled={item.disabled}
|
||||
onSelect={() => {
|
||||
item.onSelect?.()
|
||||
/* Closes behind the command, or the entries would read as toggles. */
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</CascaderAction>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{runs.map((run, runIndex) =>
|
||||
run.group ? (
|
||||
<CascaderGroup key={`${run.group}-${runIndex}`} className="gap-0.5">
|
||||
<CascaderLabel>{run.group}</CascaderLabel>
|
||||
{run.items.map(renderAction)}
|
||||
</CascaderGroup>
|
||||
) : (
|
||||
<React.Fragment key={`run-${runIndex}`}>
|
||||
{run.items.map(renderAction)}
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Action */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderActionProps extends Omit<
|
||||
React.ComponentProps<"button">,
|
||||
"onSelect"
|
||||
> {
|
||||
icon?: React.ReactNode
|
||||
/** Fires on press, after `onClick`, and not at all when disabled. */
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The cascader popup's panel per style, spelled with `style-<name>:` variants
|
||||
* rather than ReUI theme CSS so an installed footer needs only Tailwind.
|
||||
*/
|
||||
const FLYOUT_SURFACE_CLASS =
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-72 overflow-hidden ring-1 duration-100 ring-foreground/10 shadow-md rounded-lg"
|
||||
|
||||
/**
|
||||
* One footer command, shaped like a row and deliberately NOT one. A real
|
||||
* `<button>`, never a `Combobox.Item`: an item would join the arrow-key ring,
|
||||
* appear in `filteredItems`, and vanish the moment a query matched nothing -
|
||||
* exactly when "Create new attribute" is most useful. Disabled is
|
||||
* `aria-disabled`, never the native attribute: that one is not a tab stop and
|
||||
* the panel's Tab order reads `button:not([disabled])`, so a footer whose ONLY
|
||||
* row is a disabled command had no stop after the search field (measured on
|
||||
* `c-cascader-8`). Staying focusable costs the guards below, plus the
|
||||
* greyed-out look: `CASCADER_ACTION_CLASS` keys that off `aria-disabled`
|
||||
* instead of `:disabled`.
|
||||
*/
|
||||
function CascaderAction({
|
||||
className,
|
||||
icon,
|
||||
children,
|
||||
onSelect,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
...props
|
||||
}: CascaderActionProps) {
|
||||
// Set by `CascaderSubmenuContent`: a plain button in the footer's Tab ring,
|
||||
// a roving-focus `menuitem` inside a flyout, never both.
|
||||
const inMenu = React.useContext(CascaderMenuContext)
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
// Before the consumer's handler: a disabled command runs none of them.
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
onSelect?.()
|
||||
},
|
||||
[disabled, onClick, onSelect]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
// The two keys a `<button>` activates on, and only those.
|
||||
if (event.key === "Enter" || event.key === " ") event.preventDefault()
|
||||
return
|
||||
}
|
||||
onKeyDown?.(event)
|
||||
},
|
||||
[disabled, onKeyDown]
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="cascader-action"
|
||||
/* Conditional spread: `false` would publish `aria-disabled="false"`. */
|
||||
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
/* Conditional spread, so a consumer's own `role` or `tabIndex` wins. */
|
||||
{...(inMenu ? { role: "menuitem" as const, tabIndex: -1 } : null)}
|
||||
className={cn(CASCADER_ACTION_CLASS, className)}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<span
|
||||
data-slot="cascader-action-icon"
|
||||
className="text-muted-foreground flex shrink-0 items-center justify-center"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Submenu */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface CascaderSubmenuContextValue {
|
||||
rowRef: React.RefObject<HTMLButtonElement | null>
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
close: () => void
|
||||
/** Names the flyout: a menu is labelled by the control that opens it. */
|
||||
triggerId: string
|
||||
/**
|
||||
* Whether the pending open came from the KEYBOARD. Base UI's own `openType`
|
||||
* calls the opening arrow a POINTER open, because the trigger intercepts it
|
||||
* and calls `setOpen` (measured: focus landed on the popup, not the first
|
||||
* entry). A ref, so reading it in the focus phase cannot render.
|
||||
*/
|
||||
keyboardRef: React.RefObject<boolean>
|
||||
}
|
||||
|
||||
/** Marks the subtree INSIDE a flyout. See `inMenu` in `CascaderAction`. */
|
||||
const CascaderMenuContext = React.createContext(false)
|
||||
|
||||
/**
|
||||
* Every entry a menu's roving focus may land on, in DOM order, read from the
|
||||
* DOM because the entries are whatever the consumer composed. A disabled
|
||||
* `CascaderAction` is INCLUDED: it carries `aria-disabled`, not the native
|
||||
* attribute, so `:not([disabled])` excludes only a consumer's own natively
|
||||
* disabled `menuitem`, which cannot take focus.
|
||||
*/
|
||||
function menuItems(popup: HTMLElement | null): HTMLElement[] {
|
||||
if (!popup) return []
|
||||
return Array.from(
|
||||
popup.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])')
|
||||
)
|
||||
}
|
||||
|
||||
const CascaderSubmenuContext = React.createContext<
|
||||
CascaderSubmenuContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
/** The flyout's own state. `close()` is the one a custom entry usually wants. */
|
||||
export function useCascaderSubmenu(): CascaderSubmenuContextValue {
|
||||
const context = React.useContext(CascaderSubmenuContext)
|
||||
if (!context) {
|
||||
throw new Error("useCascaderSubmenu must be used within a CascaderSubmenu")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export interface CascaderSubmenuProps {
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A footer row plus the flyout it opens. Registers with the cascader root
|
||||
* while open, which turns one Escape into two. Cleared in an EFFECT, so the
|
||||
* flyout still reads as open during the event that closed it.
|
||||
*/
|
||||
function CascaderSubmenu({
|
||||
open: openProp,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: CascaderSubmenuProps) {
|
||||
const { setFlyoutOpen } = useCascaderActions()
|
||||
const key = React.useId()
|
||||
const triggerId = React.useId()
|
||||
const rowRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
const keyboardRef = React.useRef(false)
|
||||
const [uncontrolled, setUncontrolled] = React.useState(defaultOpen)
|
||||
const open = openProp ?? uncontrolled
|
||||
|
||||
const setOpen = React.useCallback(
|
||||
(next: boolean) => {
|
||||
if (openProp == null) setUncontrolled(next)
|
||||
onOpenChange?.(next)
|
||||
},
|
||||
[openProp, onOpenChange]
|
||||
)
|
||||
|
||||
const close = React.useCallback(() => setOpen(false), [setOpen])
|
||||
|
||||
React.useEffect(() => {
|
||||
setFlyoutOpen(key, open)
|
||||
return () => setFlyoutOpen(key, false)
|
||||
}, [setFlyoutOpen, key, open])
|
||||
|
||||
const context = React.useMemo<CascaderSubmenuContextValue>(
|
||||
() => ({ rowRef, open, setOpen, close, triggerId, keyboardRef }),
|
||||
[open, setOpen, close, triggerId]
|
||||
)
|
||||
|
||||
return (
|
||||
<CascaderSubmenuContext.Provider value={context}>
|
||||
<PopoverPrimitive.Root
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/* NEVER modal: it would disable the combobox that owns it. */
|
||||
modal={false}
|
||||
>
|
||||
{children}
|
||||
</PopoverPrimitive.Root>
|
||||
</CascaderSubmenuContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CascaderSubmenuTriggerProps extends Omit<
|
||||
React.ComponentProps<"button">,
|
||||
"onSelect"
|
||||
> {
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
/** Carries the handler-veto hook, derived so a Base UI bump cannot drift. */
|
||||
type CascaderSubmenuTriggerClickEvent = Parameters<
|
||||
NonNullable<PopoverPrimitive.Trigger.Props["onClick"]>
|
||||
>[0]
|
||||
|
||||
/**
|
||||
* The footer row that opens the flyout, and its anchor. `aria-haspopup="menu"`
|
||||
* rather than the `dialog` Base UI would announce: what opens is a list of
|
||||
* commands with roving focus. `disabled` is intercepted, not forwarded:
|
||||
* `Popover.Trigger` runs it through `useButton`, which writes the NATIVE
|
||||
* attribute for a native `<button>` and takes the row out of the panel's Tab
|
||||
* ring, the defect `CascaderAction` documents, and this component exposes no
|
||||
* `focusableWhenDisabled` to opt out. Published as `aria-disabled`, with the
|
||||
* arrow keys, Enter and Space, and the click closed by hand (`useClick`
|
||||
* ignores `defaultPrevented`).
|
||||
*/
|
||||
function CascaderSubmenuTrigger({
|
||||
className,
|
||||
icon,
|
||||
children,
|
||||
onKeyDown,
|
||||
onClick,
|
||||
disabled,
|
||||
...props
|
||||
}: CascaderSubmenuTriggerProps) {
|
||||
const { labels } = useCascaderActions()
|
||||
const { rowRef, setOpen, triggerId, keyboardRef } = useCascaderSubmenu()
|
||||
const direction = useDirection()
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: CascaderSubmenuTriggerClickEvent) => {
|
||||
if (disabled) {
|
||||
// `mergeProps` runs right to left, so this drops Base UI's own handler.
|
||||
event.preventDefault()
|
||||
event.preventBaseUIHandler()
|
||||
return
|
||||
}
|
||||
onClick?.(event)
|
||||
},
|
||||
[disabled, onClick]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
if (event.key === "Enter" || event.key === " ") event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
// The opening key points AT the flyout, so it flips with the writing
|
||||
// direction; ArrowDown stays "next command". Resolved per keydown, never
|
||||
// `useDirection()` alone: with no provider that answers "ltr" in RTL.
|
||||
const openKey = isCascaderRtl(event.currentTarget, direction)
|
||||
? "ArrowLeft"
|
||||
: "ArrowRight"
|
||||
|
||||
// Enter and Space open through the button's own click, so only FLAGGED.
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
keyboardRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== openKey) return
|
||||
|
||||
event.preventDefault()
|
||||
keyboardRef.current = true
|
||||
setOpen(true)
|
||||
},
|
||||
[disabled, onKeyDown, direction, setOpen, keyboardRef]
|
||||
)
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Trigger
|
||||
ref={rowRef}
|
||||
id={triggerId}
|
||||
data-slot="cascader-submenu-trigger"
|
||||
aria-haspopup="menu"
|
||||
/* NOT `disabled={disabled}`: Base UI writes the native attribute. */
|
||||
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
CASCADER_ACTION_CLASS,
|
||||
/* Painted while the flyout is open, as shadcn paints a SubTrigger.
|
||||
Keyed off `aria-expanded`, not shadcn's `data-[state=open]`: the
|
||||
popover trigger carries `aria-expanded` in both states. */
|
||||
"aria-expanded:bg-accent aria-expanded:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<span
|
||||
data-slot="cascader-action-icon"
|
||||
className="text-muted-foreground flex shrink-0 items-center justify-center"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
|
||||
{/* Nothing else says the row opens a MENU, not another tree level. */}
|
||||
<span className="sr-only">, {labels.submenuAffordance}</span>
|
||||
<ChevronRightIcon aria-hidden="true" className="text-muted-foreground -me-0.5 size-4 shrink-0 rtl:-scale-x-100" />
|
||||
</PopoverPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CascaderSubmenuContentProps
|
||||
extends
|
||||
PopoverPrimitive.Popup.Props,
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset"
|
||||
> {}
|
||||
|
||||
/** Carries Base UI's handler-veto hook, so a plain React event will not do. */
|
||||
type CascaderSubmenuKeyEvent = Parameters<
|
||||
NonNullable<PopoverPrimitive.Popup.Props["onKeyDown"]>
|
||||
>[0]
|
||||
|
||||
/**
|
||||
* The flyout itself. `Portal` with NO `container`: a nested portal resolves to
|
||||
* the parent portal node, the mechanism the file header describes.
|
||||
*/
|
||||
function CascaderSubmenuContent({
|
||||
className,
|
||||
children,
|
||||
onKeyDown,
|
||||
side = "inline-end",
|
||||
align = "end",
|
||||
sideOffset = 8,
|
||||
alignOffset = 0,
|
||||
...props
|
||||
}: CascaderSubmenuContentProps) {
|
||||
const { rowRef, close, triggerId, keyboardRef } = useCascaderSubmenu()
|
||||
const direction = useDirection()
|
||||
const popupRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const typeaheadRef = React.useRef({ buffer: "", at: 0 })
|
||||
|
||||
/**
|
||||
* Keyboard opens land on the first entry; a pointer open falls through to
|
||||
* Base UI's default, the popup, so a click paints no focus ring. Not an
|
||||
* effect of our own: Base UI's focus manager runs on open and wins the race.
|
||||
*/
|
||||
const initialFocus = React.useCallback(() => {
|
||||
const byKeyboard = keyboardRef.current
|
||||
keyboardRef.current = false
|
||||
if (!byKeyboard) return true
|
||||
return menuItems(popupRef.current)[0] ?? true
|
||||
}, [keyboardRef])
|
||||
|
||||
const closeAndReturn = React.useCallback(() => {
|
||||
close()
|
||||
rowRef.current?.focus()
|
||||
}, [close, rowRef])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: CascaderSubmenuKeyEvent) => {
|
||||
onKeyDown?.(event)
|
||||
// A DOM sibling of the combobox popup but a REACT descendant, so keys in
|
||||
// here reach its handlers unless stopped. Enter would commit a row.
|
||||
event.stopPropagation()
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
const popup = popupRef.current
|
||||
const items = menuItems(popup)
|
||||
if (items.length === 0) return
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
const index = active ? items.indexOf(active) : -1
|
||||
const move = (next: number) => {
|
||||
event.preventDefault()
|
||||
items[(next + items.length) % items.length]?.focus()
|
||||
}
|
||||
|
||||
// Mirrors the open key on the SAME per-keydown answer as the trigger.
|
||||
const closeKey = isCascaderRtl(event.currentTarget, direction)
|
||||
? "ArrowRight"
|
||||
: "ArrowLeft"
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
return move(index + 1)
|
||||
case "ArrowUp":
|
||||
// From the popup itself (a pointer open) Up means the LAST entry.
|
||||
return move(index === -1 ? items.length - 1 : index - 1)
|
||||
case "Home":
|
||||
return move(0)
|
||||
case "End":
|
||||
return move(items.length - 1)
|
||||
case closeKey:
|
||||
event.preventDefault()
|
||||
return closeAndReturn()
|
||||
case "Tab":
|
||||
// A menu never holds Tab: it closes and focus carries on from its row.
|
||||
close()
|
||||
rowRef.current?.focus()
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Typeahead, after the switch so it can never swallow a navigation key.
|
||||
if (
|
||||
event.key.length !== 1 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey
|
||||
)
|
||||
return
|
||||
const now = event.timeStamp
|
||||
const state = typeaheadRef.current
|
||||
state.buffer = now - state.at > 500 ? event.key : state.buffer + event.key
|
||||
state.at = now
|
||||
const prefix = state.buffer.toLowerCase()
|
||||
const from = index === -1 ? 0 : index
|
||||
// Starts AFTER the current entry so one letter cycles rather than sticks.
|
||||
const ordered = [
|
||||
...items.slice(state.buffer.length > 1 ? from : from + 1),
|
||||
...items.slice(0, state.buffer.length > 1 ? from : from + 1),
|
||||
]
|
||||
const hit = ordered.find((item) =>
|
||||
(item.textContent ?? "").trim().toLowerCase().startsWith(prefix)
|
||||
)
|
||||
if (hit) {
|
||||
event.preventDefault()
|
||||
hit.focus()
|
||||
}
|
||||
},
|
||||
[onKeyDown, direction, close, closeAndReturn, rowRef]
|
||||
)
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
/* The ROW, not whatever Base UI last treated as the trigger. */
|
||||
anchor={rowRef}
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
ref={popupRef}
|
||||
initialFocus={initialFocus}
|
||||
data-slot="cascader-submenu-content"
|
||||
/* Roving focus, named by the row that opens it. `tabIndex={-1}` lets
|
||||
a pointer open park focus here without adding a Tab stop. */
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby={triggerId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
/* Marks a menu surface for the docs design-system picker, which
|
||||
repaints menus by walking the DOM. An attribute, not a class. */
|
||||
data-menu-target=""
|
||||
className={cn(
|
||||
FLYOUT_SURFACE_CLASS,
|
||||
"flex max-w-(--available-width) min-w-48 flex-col gap-0.5 outline-hidden",
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
"p-(--cascader-list-pad,4px)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CascaderMenuContext.Provider value={true}>
|
||||
{children}
|
||||
</CascaderMenuContext.Provider>
|
||||
</PopoverPrimitive.Popup>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the footer would render anything, for a wrapper (a separator, a grid
|
||||
* row) that has to make the same call `CascaderFooter` already makes.
|
||||
*/
|
||||
export function useCascaderHasActions(): boolean {
|
||||
const { actions } = useCascaderActions()
|
||||
return actions.length > 0
|
||||
}
|
||||
|
||||
export {
|
||||
CascaderAction,
|
||||
CascaderFooter,
|
||||
CascaderSubmenu,
|
||||
CascaderSubmenuContent,
|
||||
CascaderSubmenuTrigger,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
CascaderLabels,
|
||||
CascaderMode,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
|
||||
/** Name of the root level. Hoisted so the root announcement can reuse it. */
|
||||
const ROOT_LEVEL = "Top level"
|
||||
|
||||
/** Hoisted for the same reason: several defaults end in an item count. */
|
||||
const itemCount = (count: number) =>
|
||||
`${count} ${count === 1 ? "item" : "items"}`
|
||||
|
||||
/**
|
||||
* English defaults. Every string the primitive can render lives here, so a
|
||||
* consumer can translate the whole surface by passing `labels`.
|
||||
*/
|
||||
export const CASCADER_LABELS: CascaderLabels = {
|
||||
// Deliberately NOT lowercased. `toLowerCase()` is locale-hostile - it maps
|
||||
// Turkish "İ" to a two-code-point sequence and German "İstanbul" style
|
||||
// proper nouns lose their casing - and a label is already written the way
|
||||
// its author wants it read.
|
||||
search: (parentLabel) =>
|
||||
parentLabel ? `Search ${parentLabel}...` : "Search...",
|
||||
back: "Back",
|
||||
loading: "Loading...",
|
||||
loadingMore: "Loading more...",
|
||||
loadMore: "Load more",
|
||||
error: "Could not load items.",
|
||||
retry: "Retry",
|
||||
empty: "No results found.",
|
||||
selectedCount: (count) => `${count} selected`,
|
||||
breadcrumbLabel: "Breadcrumb",
|
||||
chipsLabel: "Selected items",
|
||||
removeChip: (label) => `Remove ${label}`,
|
||||
pathSeparator: "/",
|
||||
rootLevel: ROOT_LEVEL,
|
||||
itemCount,
|
||||
branchAffordance: "submenu",
|
||||
selectedState: "selected",
|
||||
partiallySelectedState: "partially selected",
|
||||
columnsLabel: "Levels",
|
||||
actionsLabel: "Actions",
|
||||
submenuAffordance: "opens a menu",
|
||||
panelLabel: "Options",
|
||||
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => {
|
||||
// "Deeper" is the direction the text runs, so the level keys mirror in
|
||||
// RTL and the hint has to name the mirrored pair there - an LTR-worded
|
||||
// hint would teach exactly the wrong keys.
|
||||
const open = dir === "rtl" ? "Left" : "Right"
|
||||
const back = dir === "rtl" ? "Right" : "Left"
|
||||
if (mode === "tree") {
|
||||
return `Use the ${open} Arrow key to expand and the ${back} Arrow key to collapse.`
|
||||
}
|
||||
if (mode === "columns") {
|
||||
return `Use the ${open} Arrow key to open the next column and the ${back} Arrow key to go back.`
|
||||
}
|
||||
return `Use the ${open} Arrow key to open a branch and the ${back} Arrow key to go back.`
|
||||
},
|
||||
rootAnnouncement: (count) => `${ROOT_LEVEL}, ${itemCount(count)}`,
|
||||
expandedAnnouncement: (label, count) =>
|
||||
`${label} expanded, ${itemCount(count)}`,
|
||||
collapsedAnnouncement: (label) => `${label} collapsed`,
|
||||
levelAnnouncement: (parentLabel, depth, count) =>
|
||||
`${parentLabel}, level ${depth}, ${itemCount(count)}`,
|
||||
resultsAnnouncement: (count) =>
|
||||
count === 1 ? "1 result" : `${count} results`,
|
||||
maxReachedAnnouncement: (max) => `Selection limit of ${max} reached`,
|
||||
cascadeAnnouncement: (label, count, selecting) =>
|
||||
`${label} ${selecting ? "selected" : "deselected"}, ${itemCount(count)} followed`,
|
||||
searchingAnnouncement: "Searching...",
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-merges consumer overrides over the defaults, so `labels` can carry a
|
||||
* single key without restating the rest.
|
||||
*/
|
||||
export function resolveCascaderLabels(
|
||||
labels?: Partial<CascaderLabels>
|
||||
): CascaderLabels {
|
||||
if (!labels) return CASCADER_LABELS
|
||||
return { ...CASCADER_LABELS, ...labels }
|
||||
}
|
||||
|
||||
/** Resolves the search placeholder, which may be a string or a function. */
|
||||
export function resolveCascaderSearchLabel(
|
||||
labels: CascaderLabels,
|
||||
parentLabel?: string
|
||||
): string {
|
||||
return typeof labels.search === "function"
|
||||
? labels.search(parentLabel)
|
||||
: labels.search
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user