Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d56405af7b | ||
|
|
927e27640a |
@@ -28,6 +28,9 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['src/components/reui/**/*.{ts,tsx}'],
|
files: ['src/components/reui/**/*.{ts,tsx}'],
|
||||||
|
linterOptions: {
|
||||||
|
reportUnusedDisableDirectives: 'off',
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-unused-vars': 'off',
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
'react-hooks/exhaustive-deps': 'off',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.0.0",
|
"@base-ui/react": "^1.0.0",
|
||||||
|
"@date-fns/tz": "^1.5.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -26,8 +27,8 @@
|
|||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@tanstack/react-router": "^1.130.2",
|
"@tanstack/react-router": "^1.130.2",
|
||||||
"@tanstack/react-router-devtools": "^1.130.2",
|
"@tanstack/react-router-devtools": "^1.130.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^9.1.2",
|
||||||
"@tanstack/react-virtual": "^3.14.4",
|
"@tanstack/react-virtual": "^3.14.10",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Plus, RefreshCw } from 'lucide-react'
|
import { Plus, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
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 { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
||||||
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
@@ -47,46 +45,38 @@ export function AccessApiKeysCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FrameDataGrid
|
<QueryState
|
||||||
title="API-ключи"
|
data={items}
|
||||||
description="Полный токен показывается только при создании и ротации."
|
isLoading={isLoading}
|
||||||
className="min-w-0"
|
isError={isError}
|
||||||
actions={
|
error={error}
|
||||||
<>
|
empty={false}
|
||||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
onRetry={onRetry}
|
||||||
Обновить
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Создать
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<QueryState
|
{(data) => (
|
||||||
data={items}
|
<AccessApiKeysGrid
|
||||||
isLoading={isLoading}
|
items={data}
|
||||||
isError={isError}
|
isLoading={isLoading}
|
||||||
error={error}
|
onRotate={handleRotated}
|
||||||
empty={items.length === 0}
|
onRevoke={(id) => revoke.mutate(id)}
|
||||||
emptyTitle="Нет ключей"
|
rotatePending={rotate.isPending}
|
||||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
revokePending={revoke.isPending}
|
||||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
actions={
|
||||||
onRetry={onRetry}
|
<>
|
||||||
>
|
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||||
{(data) => (
|
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||||
<AccessApiKeysGrid
|
Обновить
|
||||||
items={data}
|
</Button>
|
||||||
isLoading={isLoading}
|
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
||||||
onRotate={handleRotated}
|
<Plus />
|
||||||
onRevoke={(id) => revoke.mutate(id)}
|
Создать
|
||||||
rotatePending={rotate.isPending}
|
</Button>
|
||||||
revokePending={revoke.isPending}
|
</>
|
||||||
/>
|
}
|
||||||
)}
|
/>
|
||||||
</QueryState>
|
)}
|
||||||
</FrameDataGrid>
|
</QueryState>
|
||||||
|
|
||||||
<ApiKeyCreateDialog
|
<ApiKeyCreateDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
import { KeyRound, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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 { 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'
|
import type { ApiKey } from '@/types/api'
|
||||||
|
|
||||||
|
const filterFields: FilterField[] = [
|
||||||
|
createSearchFilterField('search', 'Поиск', 'Поиск API-ключей…'),
|
||||||
|
]
|
||||||
|
|
||||||
export function AccessApiKeysGrid({
|
export function AccessApiKeysGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -22,6 +28,7 @@ export function AccessApiKeysGrid({
|
|||||||
onRevoke,
|
onRevoke,
|
||||||
rotatePending = false,
|
rotatePending = false,
|
||||||
revokePending = false,
|
revokePending = false,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
items: ApiKey[]
|
items: ApiKey[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
@@ -29,16 +36,21 @@ export function AccessApiKeysGrid({
|
|||||||
onRevoke: (id: string) => void
|
onRevoke: (id: string) => void
|
||||||
rotatePending?: boolean
|
rotatePending?: boolean
|
||||||
revokePending?: boolean
|
revokePending?: boolean
|
||||||
|
actions?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const columns = useMemo<ColumnDef<ApiKey>[]>(
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumnDef<ApiKey>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridNameCell
|
||||||
|
icon={KeyRound}
|
||||||
title={row.original.name}
|
title={row.original.name}
|
||||||
accent="primary"
|
|
||||||
subtitle={`${row.original.prefix}…`}
|
subtitle={`${row.original.prefix}…`}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -135,24 +147,24 @@ export function AccessApiKeysGrid({
|
|||||||
[onRevoke, onRotate, revokePending, rotatePending],
|
[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 (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title="API-ключи"
|
||||||
recordCount={filteredCount}
|
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}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет ключей"
|
primaryAction={actions}
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
pinLastColumn
|
||||||
searchValue={globalFilter}
|
emptyState={{ title: 'Нет ключей' }}
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск API-ключей…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
|
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import { PanelCard } from '@/components/panel-card'
|
||||||
|
|
||||||
|
/** 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({
|
export function AnalyticsCardShell({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { type ComponentProps } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { type DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||||
|
import { type ColumnDef } from "@tanstack/react-table"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@evobgp/ui/components/dropdown-menu"
|
||||||
|
import { type ModuleRecord, type ModuleStatus } from "./data"
|
||||||
|
import { CalendarDaysIcon, FileTextIcon, StarIcon, MoreHorizontalIcon, CopyIcon, ArchiveIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export type ModuleRowAction = "open" | "favorite" | "duplicate" | "archive"
|
||||||
|
|
||||||
|
const moduleStatusVariant: Record<
|
||||||
|
ModuleStatus,
|
||||||
|
ComponentProps<typeof Badge>["variant"]
|
||||||
|
> = {
|
||||||
|
Planned: "info-outline",
|
||||||
|
Backlog: "outline",
|
||||||
|
"In Progress": "warning-outline",
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleStatusDotClass: Record<ModuleStatus, string> = {
|
||||||
|
Planned: "bg-sky-500 dark:bg-sky-400",
|
||||||
|
Backlog: "bg-muted-foreground/50",
|
||||||
|
"In Progress": "bg-amber-500 dark:bg-amber-400",
|
||||||
|
}
|
||||||
|
|
||||||
|
function DotSeparator() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="bg-muted-foreground/45 size-1 shrink-0 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressToneClass(value: number) {
|
||||||
|
if (value >= 75) return "text-emerald-500 dark:text-emerald-400"
|
||||||
|
if (value >= 40) return "text-amber-500 dark:text-amber-400"
|
||||||
|
if (value > 0) return "text-sky-500 dark:text-sky-400"
|
||||||
|
|
||||||
|
return "text-muted-foreground/35"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowDurationLabel(module: ModuleRecord) {
|
||||||
|
const start = new Date(module.dateStart).getTime()
|
||||||
|
const end = new Date(module.dateEnd).getTime()
|
||||||
|
const dayMs = 24 * 60 * 60 * 1000
|
||||||
|
const days = Math.max(1, Math.round((end - start) / dayMs))
|
||||||
|
|
||||||
|
return `${days}-day window`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompactDateRange(module: ModuleRecord) {
|
||||||
|
return module.dateRange.replace(/, 2026/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleProgress({ module }: { module: ModuleRecord }) {
|
||||||
|
const value = module.progress
|
||||||
|
const radius = 18
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
const dashOffset = circumference - (value / 100) * circumference
|
||||||
|
const progressClassName = getProgressToneClass(value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<div className="relative size-10 shrink-0">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 44 44"
|
||||||
|
className="absolute inset-0 size-10 -rotate-90"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="22"
|
||||||
|
cy="22"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-muted-foreground/20"
|
||||||
|
strokeWidth="3.25"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="22"
|
||||||
|
cy="22"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
className={cn("stroke-current", progressClassName)}
|
||||||
|
strokeWidth="3.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-muted-foreground absolute inset-0 flex items-center justify-center text-[9px] leading-none font-medium tabular-nums">
|
||||||
|
{value}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-foreground text-sm font-medium tabular-nums">
|
||||||
|
{value}% ready
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs tabular-nums">
|
||||||
|
{module.tasksCompleted}/{module.tasksTotal} tasks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleNameCell({ module }: { module: ModuleRecord }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="text-foreground truncate text-sm leading-5 font-medium">
|
||||||
|
{module.name}
|
||||||
|
</span>
|
||||||
|
<div className="text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
|
||||||
|
<span className="shrink-0">{module.kind}</span>
|
||||||
|
<DotSeparator />
|
||||||
|
<span className="truncate">{module.owner.name}</span>
|
||||||
|
<DotSeparator />
|
||||||
|
<span className="truncate font-mono tracking-wide uppercase">
|
||||||
|
{module.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleDateCell({ module }: { module: ModuleRecord }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-foreground truncate text-sm font-medium tabular-nums">
|
||||||
|
{getCompactDateRange(module)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground inline-flex min-w-0 items-center gap-1.5 truncate text-xs">
|
||||||
|
<CalendarDaysIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
|
{getWindowDurationLabel(module)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleStatusCell({ module }: { module: ModuleRecord }) {
|
||||||
|
return (
|
||||||
|
<Badge variant={moduleStatusVariant[module.status]}>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 shrink-0 rounded-full!",
|
||||||
|
moduleStatusDotClass[module.status]
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{module.status}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleActions({
|
||||||
|
module,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
module: ModuleRecord
|
||||||
|
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Open ${module.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onAction("open", module)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileTextIcon aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`${module.favorite ? "Unfavorite" : "Favorite"} ${
|
||||||
|
module.name
|
||||||
|
}`}
|
||||||
|
className={cn(module.favorite && "text-amber-500")}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onAction("favorite", module)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StarIcon aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`More actions for ${module.name}`}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon aria-hidden="true" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem onClick={() => onAction("duplicate", module)}>
|
||||||
|
<CopyIcon aria-hidden="true" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onAction("archive", module)}
|
||||||
|
>
|
||||||
|
<ArchiveIcon aria-hidden="true" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModuleGridColumns({
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
|
||||||
|
}): ColumnDef<DataGridFeatures, ModuleRecord>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessorKey: "progress",
|
||||||
|
id: "progress",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} visibility={true} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <ModuleProgress module={row.original} />,
|
||||||
|
size: 210,
|
||||||
|
enableSorting: true,
|
||||||
|
enableHiding: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Progress",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
id: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} visibility={true} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <ModuleNameCell module={row.original} />,
|
||||||
|
minSize: 300,
|
||||||
|
enableSorting: true,
|
||||||
|
enableHiding: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: {
|
||||||
|
autoSize: true,
|
||||||
|
headerTitle: "Module",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "dateStart",
|
||||||
|
id: "dateStart",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} visibility={true} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <ModuleDateCell module={row.original} />,
|
||||||
|
sortFn: (rowA, rowB) =>
|
||||||
|
new Date(rowA.original.dateStart).getTime() -
|
||||||
|
new Date(rowB.original.dateStart).getTime(),
|
||||||
|
size: 180,
|
||||||
|
enableSorting: true,
|
||||||
|
enableHiding: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Window",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
id: "status",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} visibility={true} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <ModuleStatusCell module={row.original} />,
|
||||||
|
size: 126,
|
||||||
|
enableSorting: true,
|
||||||
|
enableHiding: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Status",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} visibility={true} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<ModuleActions module={row.original} onAction={onAction} />
|
||||||
|
),
|
||||||
|
size: 104,
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Actions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import {
|
||||||
|
DataGrid,
|
||||||
|
dataGridFeatures,
|
||||||
|
} from "@/components/reui/data-grid/data-grid"
|
||||||
|
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||||
|
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||||
|
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
type PaginationState,
|
||||||
|
type SortingState,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@evobgp/ui/components/dropdown-menu"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@evobgp/ui/components/input-group"
|
||||||
|
import { createModuleGridColumns, type ModuleRowAction } from "./columns"
|
||||||
|
import {
|
||||||
|
MODULE_RECORDS,
|
||||||
|
MODULE_STATUS_OPTIONS,
|
||||||
|
type ModuleRecord,
|
||||||
|
type ModuleStatus,
|
||||||
|
} from "./data"
|
||||||
|
import { FlagIcon, ChevronRightIcon, PackageIcon, SearchIcon, XIcon, ArrowUpDownIcon, ChevronDownIcon, FilterIcon } from "lucide-react"
|
||||||
|
|
||||||
|
type ModuleSort = "name" | "dateStart" | "progress" | "status"
|
||||||
|
|
||||||
|
const sortLabels: Record<ModuleSort, string> = {
|
||||||
|
name: "Name",
|
||||||
|
dateStart: "Window",
|
||||||
|
progress: "Progress",
|
||||||
|
status: "Status",
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSorting(sortBy: ModuleSort): SortingState {
|
||||||
|
return [{ id: sortBy, desc: false }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModuleSearchBlob(module: ModuleRecord) {
|
||||||
|
return [
|
||||||
|
module.name,
|
||||||
|
module.id,
|
||||||
|
module.kind,
|
||||||
|
module.owner.name,
|
||||||
|
module.owner.role,
|
||||||
|
module.health,
|
||||||
|
module.status,
|
||||||
|
module.dateRange,
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModulesDataGridView() {
|
||||||
|
const [modules, setModules] = useState<ModuleRecord[]>(MODULE_RECORDS)
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [selectedStatuses, setSelectedStatuses] = useState<ModuleStatus[]>([])
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 5,
|
||||||
|
})
|
||||||
|
const [sortBy, setSortBy] = useState<ModuleSort>("name")
|
||||||
|
const [sorting, setSorting] = useState<SortingState>(() =>
|
||||||
|
buildSorting("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredModules = useMemo(() => {
|
||||||
|
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
|
||||||
|
|
||||||
|
return modules.filter((module) => {
|
||||||
|
const matchesSearch =
|
||||||
|
normalizedSearchQuery.length === 0 ||
|
||||||
|
getModuleSearchBlob(module).includes(normalizedSearchQuery)
|
||||||
|
const matchesStatus =
|
||||||
|
selectedStatuses.length === 0 ||
|
||||||
|
selectedStatuses.includes(module.status)
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
}, [modules, searchQuery, selectedStatuses])
|
||||||
|
|
||||||
|
const activeFilterCount = selectedStatuses.length
|
||||||
|
|
||||||
|
const resetPagination = useCallback(() => {
|
||||||
|
setPagination((current) => ({
|
||||||
|
...current,
|
||||||
|
pageIndex: 0,
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setSearchQuery(value)
|
||||||
|
resetPagination()
|
||||||
|
},
|
||||||
|
[resetPagination]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleStatusToggle = useCallback(
|
||||||
|
(status: ModuleStatus, checked: boolean) => {
|
||||||
|
setSelectedStatuses((current) => {
|
||||||
|
if (checked) {
|
||||||
|
return current.includes(status) ? current : [...current, status]
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.filter((item) => item !== status)
|
||||||
|
})
|
||||||
|
resetPagination()
|
||||||
|
},
|
||||||
|
[resetPagination]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSortChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const nextSort = value as ModuleSort
|
||||||
|
setSortBy(nextSort)
|
||||||
|
setSorting(buildSorting(nextSort))
|
||||||
|
resetPagination()
|
||||||
|
},
|
||||||
|
[resetPagination]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleModuleAction = useCallback(
|
||||||
|
(action: ModuleRowAction, module: ModuleRecord) => {
|
||||||
|
if (action === "favorite") {
|
||||||
|
setModules((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === module.id
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
favorite: !item.favorite,
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
toast.success(module.favorite ? "Removed favorite" : "Module starred", {
|
||||||
|
description: module.name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "open") {
|
||||||
|
toast.info("Open module", {
|
||||||
|
description: `${module.name} (${module.kind})`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.message(
|
||||||
|
action === "duplicate" ? "Duplicate module" : "Archive module",
|
||||||
|
{
|
||||||
|
description: `Connect this action to your ${module.name} module flow.`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddModule = () => {
|
||||||
|
toast.success("Add module", {
|
||||||
|
description: "Open your module creation dialog from this primary action.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => createModuleGridColumns({ onAction: handleModuleAction }),
|
||||||
|
[handleModuleAction]
|
||||||
|
)
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
|
data: filteredModules,
|
||||||
|
columns,
|
||||||
|
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
sorting,
|
||||||
|
},
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGrid
|
||||||
|
table={table}
|
||||||
|
recordCount={filteredModules.length}
|
||||||
|
emptyMessage="No modules match the selected filters."
|
||||||
|
tableLayout={{
|
||||||
|
dense: true,
|
||||||
|
rowBorder: true,
|
||||||
|
headerSticky: false,
|
||||||
|
columnsVisibility: false,
|
||||||
|
columnsResizable: false,
|
||||||
|
columnsMovable: false,
|
||||||
|
width: "fixed",
|
||||||
|
}}
|
||||||
|
tableClassNames={{
|
||||||
|
bodyRow: "group/module-row [&>td]:h-16",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex w-full max-w-6xl flex-col">
|
||||||
|
<div className="flex flex-col gap-3 border-b px-0 py-3 lg:min-h-14 lg:flex-row lg:items-center lg:gap-4 lg:py-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<FlagIcon className="size-3.5 shrink-0 fill-amber-400 text-amber-400" aria-hidden="true" />
|
||||||
|
<span className="text-muted-foreground truncate text-sm">
|
||||||
|
Keenthemes
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon className="text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
|
||||||
|
<PackageIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
||||||
|
<h2 className="text-foreground truncate text-sm font-medium">
|
||||||
|
Modules
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2 lg:ml-auto lg:flex-nowrap">
|
||||||
|
<InputGroup className="w-full min-w-40 sm:w-48">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<SearchIcon className="text-muted-foreground size-4" aria-hidden="true" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => handleSearchChange(event.target.value)}
|
||||||
|
placeholder="Search..."
|
||||||
|
aria-label="Search modules"
|
||||||
|
/>
|
||||||
|
{searchQuery.length > 0 ? (
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<InputGroupButton
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="Clear search"
|
||||||
|
onClick={() => handleSearchChange("")}
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" aria-hidden="true" />
|
||||||
|
</InputGroupButton>
|
||||||
|
</InputGroupAddon>
|
||||||
|
) : null}
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
<ArrowUpDownIcon data-icon="inline-start" aria-hidden="true" />
|
||||||
|
{sortLabels[sortBy]}
|
||||||
|
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-44">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
{(["name", "dateStart", "progress", "status"] as const).map(
|
||||||
|
(value) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={value}
|
||||||
|
onClick={() => handleSortChange(value)}
|
||||||
|
>
|
||||||
|
{sortLabels[value]}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
<FilterIcon data-icon="inline-start" aria-hidden="true" />
|
||||||
|
Filters
|
||||||
|
{activeFilterCount > 0 ? (
|
||||||
|
<Badge variant="outline" radius="full">
|
||||||
|
{activeFilterCount}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-48">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||||
|
{MODULE_STATUS_OPTIONS.map((status) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={status}
|
||||||
|
checked={selectedStatuses.includes(status)}
|
||||||
|
closeOnClick={false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleStatusToggle(status, checked === true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
{activeFilterCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
closeOnClick={false}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedStatuses([])
|
||||||
|
resetPagination()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset filters
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button type="button" onClick={handleAddModule}>
|
||||||
|
Add Module
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataGridScrollArea>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGridScrollArea>
|
||||||
|
|
||||||
|
<div className="border-t px-0 py-3">
|
||||||
|
{filteredModules.length > 0 ? (
|
||||||
|
<DataGridPagination
|
||||||
|
sizes={[5, 10, 15]}
|
||||||
|
info="{from} - {to} of {count} modules"
|
||||||
|
className="py-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">0 modules</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DataGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
export type ModuleStatus = "Planned" | "Backlog" | "In Progress"
|
||||||
|
|
||||||
|
export type ModuleKind = "System" | "Feature" | "Area"
|
||||||
|
|
||||||
|
export type ModuleHealth = "On Track" | "Watch" | "Blocked"
|
||||||
|
|
||||||
|
export interface ModuleOwner {
|
||||||
|
name: string
|
||||||
|
initials: string
|
||||||
|
role: string
|
||||||
|
avatarSrc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleRecord {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kind: ModuleKind
|
||||||
|
owner: ModuleOwner
|
||||||
|
progress: number
|
||||||
|
tasksCompleted: number
|
||||||
|
tasksTotal: number
|
||||||
|
contributors: number
|
||||||
|
blockers: number
|
||||||
|
health: ModuleHealth
|
||||||
|
dateStart: string
|
||||||
|
dateEnd: string
|
||||||
|
dateRange: string
|
||||||
|
status: ModuleStatus
|
||||||
|
favorite: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODULE_STATUS_OPTIONS: ModuleStatus[] = [
|
||||||
|
"Planned",
|
||||||
|
"Backlog",
|
||||||
|
"In Progress",
|
||||||
|
]
|
||||||
|
|
||||||
|
const moduleOwners = {
|
||||||
|
maya: {
|
||||||
|
name: "Maya Patel",
|
||||||
|
initials: "MP",
|
||||||
|
role: "Release lead",
|
||||||
|
avatarSrc:
|
||||||
|
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&h=80&dpr=2&q=80",
|
||||||
|
},
|
||||||
|
jonah: {
|
||||||
|
name: "Jonah Lee",
|
||||||
|
initials: "JL",
|
||||||
|
role: "Product ops",
|
||||||
|
avatarSrc:
|
||||||
|
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=80&h=80&dpr=2&q=80",
|
||||||
|
},
|
||||||
|
nina: {
|
||||||
|
name: "Nina Santos",
|
||||||
|
initials: "NS",
|
||||||
|
role: "Workspace admin",
|
||||||
|
avatarSrc:
|
||||||
|
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=80&h=80&dpr=2&q=80",
|
||||||
|
},
|
||||||
|
elijah: {
|
||||||
|
name: "Elijah Morgan",
|
||||||
|
initials: "EM",
|
||||||
|
role: "Billing lead",
|
||||||
|
avatarSrc:
|
||||||
|
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=80&h=80&dpr=2&q=80",
|
||||||
|
},
|
||||||
|
priya: {
|
||||||
|
name: "Priya Shah",
|
||||||
|
initials: "PS",
|
||||||
|
role: "Lifecycle PM",
|
||||||
|
avatarSrc:
|
||||||
|
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=80&h=80&dpr=2&q=80",
|
||||||
|
},
|
||||||
|
omar: {
|
||||||
|
name: "Omar Haddad",
|
||||||
|
initials: "OH",
|
||||||
|
role: "Security owner",
|
||||||
|
avatarSrc:
|
||||||
|
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=80&h=80&dpr=2&q=80",
|
||||||
|
},
|
||||||
|
sofia: {
|
||||||
|
name: "Sofia Romero",
|
||||||
|
initials: "SR",
|
||||||
|
role: "Content lead",
|
||||||
|
avatarSrc:
|
||||||
|
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=80&h=80&dpr=2&q=80",
|
||||||
|
},
|
||||||
|
kenji: {
|
||||||
|
name: "Kenji Tan",
|
||||||
|
initials: "KT",
|
||||||
|
role: "Platform lead",
|
||||||
|
avatarSrc:
|
||||||
|
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=80&h=80&dpr=2&q=80",
|
||||||
|
},
|
||||||
|
lena: {
|
||||||
|
name: "Lena Brooks",
|
||||||
|
initials: "LB",
|
||||||
|
role: "Developer tools",
|
||||||
|
avatarSrc:
|
||||||
|
"https://images.unsplash.com/photo-1551836022-d5d88e9218df?w=80&h=80&dpr=2&q=80",
|
||||||
|
},
|
||||||
|
} satisfies Record<string, ModuleOwner>
|
||||||
|
|
||||||
|
export const MODULE_RECORDS: ModuleRecord[] = [
|
||||||
|
{
|
||||||
|
id: "core-workflow",
|
||||||
|
name: "Core Workflow",
|
||||||
|
kind: "System",
|
||||||
|
owner: moduleOwners.maya,
|
||||||
|
progress: 25,
|
||||||
|
tasksCompleted: 8,
|
||||||
|
tasksTotal: 32,
|
||||||
|
contributors: 6,
|
||||||
|
blockers: 0,
|
||||||
|
health: "On Track",
|
||||||
|
dateStart: "2026-04-17",
|
||||||
|
dateEnd: "2026-05-01",
|
||||||
|
dateRange: "Apr 17 - May 01, 2026",
|
||||||
|
status: "Planned",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "onboarding-flow",
|
||||||
|
name: "Onboarding Flow",
|
||||||
|
kind: "Feature",
|
||||||
|
owner: moduleOwners.jonah,
|
||||||
|
progress: 0,
|
||||||
|
tasksCompleted: 0,
|
||||||
|
tasksTotal: 18,
|
||||||
|
contributors: 4,
|
||||||
|
blockers: 0,
|
||||||
|
health: "Watch",
|
||||||
|
dateStart: "2026-04-19",
|
||||||
|
dateEnd: "2026-05-03",
|
||||||
|
dateRange: "Apr 19 - May 03, 2026",
|
||||||
|
status: "Backlog",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-setup",
|
||||||
|
name: "Workspace Setup",
|
||||||
|
kind: "Area",
|
||||||
|
owner: moduleOwners.nina,
|
||||||
|
progress: 0,
|
||||||
|
tasksCompleted: 2,
|
||||||
|
tasksTotal: 14,
|
||||||
|
contributors: 3,
|
||||||
|
blockers: 1,
|
||||||
|
health: "Blocked",
|
||||||
|
dateStart: "2026-04-21",
|
||||||
|
dateEnd: "2026-05-05",
|
||||||
|
dateRange: "Apr 21 - May 05, 2026",
|
||||||
|
status: "In Progress",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "permission-matrix",
|
||||||
|
name: "Permission Matrix",
|
||||||
|
kind: "System",
|
||||||
|
owner: moduleOwners.maya,
|
||||||
|
progress: 42,
|
||||||
|
tasksCompleted: 11,
|
||||||
|
tasksTotal: 26,
|
||||||
|
contributors: 5,
|
||||||
|
blockers: 0,
|
||||||
|
health: "Watch",
|
||||||
|
dateStart: "2026-04-22",
|
||||||
|
dateEnd: "2026-05-06",
|
||||||
|
dateRange: "Apr 22 - May 06, 2026",
|
||||||
|
status: "In Progress",
|
||||||
|
favorite: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "billing-rules",
|
||||||
|
name: "Billing Rules",
|
||||||
|
kind: "Feature",
|
||||||
|
owner: moduleOwners.elijah,
|
||||||
|
progress: 64,
|
||||||
|
tasksCompleted: 21,
|
||||||
|
tasksTotal: 33,
|
||||||
|
contributors: 7,
|
||||||
|
blockers: 0,
|
||||||
|
health: "On Track",
|
||||||
|
dateStart: "2026-04-18",
|
||||||
|
dateEnd: "2026-05-02",
|
||||||
|
dateRange: "Apr 18 - May 02, 2026",
|
||||||
|
status: "In Progress",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notification-center",
|
||||||
|
name: "Notification Center",
|
||||||
|
kind: "Area",
|
||||||
|
owner: moduleOwners.priya,
|
||||||
|
progress: 18,
|
||||||
|
tasksCompleted: 5,
|
||||||
|
tasksTotal: 28,
|
||||||
|
contributors: 4,
|
||||||
|
blockers: 2,
|
||||||
|
health: "Blocked",
|
||||||
|
dateStart: "2026-04-23",
|
||||||
|
dateEnd: "2026-05-09",
|
||||||
|
dateRange: "Apr 23 - May 09, 2026",
|
||||||
|
status: "Backlog",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "audit-trail",
|
||||||
|
name: "Audit Trail",
|
||||||
|
kind: "System",
|
||||||
|
owner: moduleOwners.omar,
|
||||||
|
progress: 76,
|
||||||
|
tasksCompleted: 19,
|
||||||
|
tasksTotal: 25,
|
||||||
|
contributors: 5,
|
||||||
|
blockers: 0,
|
||||||
|
health: "On Track",
|
||||||
|
dateStart: "2026-04-15",
|
||||||
|
dateEnd: "2026-04-30",
|
||||||
|
dateRange: "Apr 15 - Apr 30, 2026",
|
||||||
|
status: "In Progress",
|
||||||
|
favorite: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "template-library",
|
||||||
|
name: "Template Library",
|
||||||
|
kind: "Feature",
|
||||||
|
owner: moduleOwners.sofia,
|
||||||
|
progress: 33,
|
||||||
|
tasksCompleted: 10,
|
||||||
|
tasksTotal: 30,
|
||||||
|
contributors: 6,
|
||||||
|
blockers: 0,
|
||||||
|
health: "Watch",
|
||||||
|
dateStart: "2026-04-24",
|
||||||
|
dateEnd: "2026-05-10",
|
||||||
|
dateRange: "Apr 24 - May 10, 2026",
|
||||||
|
status: "Planned",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "integration-hub",
|
||||||
|
name: "Integration Hub",
|
||||||
|
kind: "Area",
|
||||||
|
owner: moduleOwners.kenji,
|
||||||
|
progress: 58,
|
||||||
|
tasksCompleted: 14,
|
||||||
|
tasksTotal: 24,
|
||||||
|
contributors: 8,
|
||||||
|
blockers: 1,
|
||||||
|
health: "Watch",
|
||||||
|
dateStart: "2026-04-20",
|
||||||
|
dateEnd: "2026-05-04",
|
||||||
|
dateRange: "Apr 20 - May 04, 2026",
|
||||||
|
status: "In Progress",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "api-console",
|
||||||
|
name: "API Console",
|
||||||
|
kind: "Feature",
|
||||||
|
owner: moduleOwners.lena,
|
||||||
|
progress: 91,
|
||||||
|
tasksCompleted: 29,
|
||||||
|
tasksTotal: 32,
|
||||||
|
contributors: 4,
|
||||||
|
blockers: 0,
|
||||||
|
health: "On Track",
|
||||||
|
dateStart: "2026-04-12",
|
||||||
|
dateEnd: "2026-04-26",
|
||||||
|
dateRange: "Apr 12 - Apr 26, 2026",
|
||||||
|
status: "In Progress",
|
||||||
|
favorite: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "role-automation",
|
||||||
|
name: "Role Automation",
|
||||||
|
kind: "System",
|
||||||
|
owner: moduleOwners.maya,
|
||||||
|
progress: 12,
|
||||||
|
tasksCompleted: 3,
|
||||||
|
tasksTotal: 25,
|
||||||
|
contributors: 3,
|
||||||
|
blockers: 1,
|
||||||
|
health: "Blocked",
|
||||||
|
dateStart: "2026-04-25",
|
||||||
|
dateEnd: "2026-05-12",
|
||||||
|
dateRange: "Apr 25 - May 12, 2026",
|
||||||
|
status: "Backlog",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-invites",
|
||||||
|
name: "Workspace Invites",
|
||||||
|
kind: "Feature",
|
||||||
|
owner: moduleOwners.jonah,
|
||||||
|
progress: 47,
|
||||||
|
tasksCompleted: 15,
|
||||||
|
tasksTotal: 32,
|
||||||
|
contributors: 5,
|
||||||
|
blockers: 0,
|
||||||
|
health: "Watch",
|
||||||
|
dateStart: "2026-04-19",
|
||||||
|
dateEnd: "2026-05-06",
|
||||||
|
dateRange: "Apr 19 - May 06, 2026",
|
||||||
|
status: "Planned",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "release-checklist",
|
||||||
|
name: "Release Checklist",
|
||||||
|
kind: "Area",
|
||||||
|
owner: moduleOwners.nina,
|
||||||
|
progress: 84,
|
||||||
|
tasksCompleted: 26,
|
||||||
|
tasksTotal: 31,
|
||||||
|
contributors: 7,
|
||||||
|
blockers: 0,
|
||||||
|
health: "On Track",
|
||||||
|
dateStart: "2026-04-16",
|
||||||
|
dateEnd: "2026-05-01",
|
||||||
|
dateRange: "Apr 16 - May 01, 2026",
|
||||||
|
status: "In Progress",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reporting-digest",
|
||||||
|
name: "Reporting Digest",
|
||||||
|
kind: "Feature",
|
||||||
|
owner: moduleOwners.priya,
|
||||||
|
progress: 5,
|
||||||
|
tasksCompleted: 2,
|
||||||
|
tasksTotal: 38,
|
||||||
|
contributors: 3,
|
||||||
|
blockers: 0,
|
||||||
|
health: "Watch",
|
||||||
|
dateStart: "2026-04-28",
|
||||||
|
dateEnd: "2026-05-16",
|
||||||
|
dateRange: "Apr 28 - May 16, 2026",
|
||||||
|
status: "Backlog",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "security-review",
|
||||||
|
name: "Security Review",
|
||||||
|
kind: "System",
|
||||||
|
owner: moduleOwners.omar,
|
||||||
|
progress: 69,
|
||||||
|
tasksCompleted: 18,
|
||||||
|
tasksTotal: 26,
|
||||||
|
contributors: 6,
|
||||||
|
blockers: 2,
|
||||||
|
health: "Blocked",
|
||||||
|
dateStart: "2026-04-18",
|
||||||
|
dateEnd: "2026-05-07",
|
||||||
|
dateRange: "Apr 18 - May 07, 2026",
|
||||||
|
status: "In Progress",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "help-center",
|
||||||
|
name: "Help Center",
|
||||||
|
kind: "Area",
|
||||||
|
owner: moduleOwners.sofia,
|
||||||
|
progress: 39,
|
||||||
|
tasksCompleted: 9,
|
||||||
|
tasksTotal: 23,
|
||||||
|
contributors: 4,
|
||||||
|
blockers: 0,
|
||||||
|
health: "On Track",
|
||||||
|
dateStart: "2026-04-23",
|
||||||
|
dateEnd: "2026-05-11",
|
||||||
|
dateRange: "Apr 23 - May 11, 2026",
|
||||||
|
status: "Planned",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "data-retention",
|
||||||
|
name: "Data Retention",
|
||||||
|
kind: "System",
|
||||||
|
owner: moduleOwners.kenji,
|
||||||
|
progress: 22,
|
||||||
|
tasksCompleted: 7,
|
||||||
|
tasksTotal: 32,
|
||||||
|
contributors: 5,
|
||||||
|
blockers: 1,
|
||||||
|
health: "Watch",
|
||||||
|
dateStart: "2026-04-27",
|
||||||
|
dateEnd: "2026-05-14",
|
||||||
|
dateRange: "Apr 27 - May 14, 2026",
|
||||||
|
status: "Backlog",
|
||||||
|
favorite: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ModulesDataGridView } from "./components/data-grid-view"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="mx-auto flex min-h-svh w-full max-w-6xl items-start justify-center p-4 pt-8 sm:p-8 sm:pt-12"
|
||||||
|
aria-labelledby="page-heading"
|
||||||
|
>
|
||||||
|
<h1 id="page-heading" className="sr-only">
|
||||||
|
Modules data grid
|
||||||
|
</h1>
|
||||||
|
<ModulesDataGridView />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { type ReactNode } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { type DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||||
|
import {
|
||||||
|
DataGridTableRowSelect,
|
||||||
|
DataGridTableRowSelectAll,
|
||||||
|
} from "@/components/reui/data-grid/data-grid-table"
|
||||||
|
import { Rating } from "@/components/reui/rating"
|
||||||
|
import { type ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { format, parseISO } from "date-fns"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@evobgp/ui/components/avatar"
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@evobgp/ui/components/dropdown-menu"
|
||||||
|
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||||
|
import { Switch } from "@evobgp/ui/components/switch"
|
||||||
|
import {
|
||||||
|
type AutomationKind,
|
||||||
|
type AutomationOwnerAvailability,
|
||||||
|
type AutomationState,
|
||||||
|
type IAutomationRecord,
|
||||||
|
} from "./data"
|
||||||
|
import { GitBranchIcon, RouteIcon, SparklesIcon, MailIcon, BellRingIcon, EllipsisVerticalIcon, PencilIcon, EyeIcon, CircleCheckIcon, ArchiveIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export type AutomationAction = "edit" | "open" | "archive"
|
||||||
|
|
||||||
|
const automationKindStyles: Record<
|
||||||
|
AutomationKind,
|
||||||
|
{ chipClassName: string; icon: ReactNode }
|
||||||
|
> = {
|
||||||
|
sequence: {
|
||||||
|
chipClassName: "text-sky-600 dark:text-sky-300",
|
||||||
|
icon: (
|
||||||
|
<GitBranchIcon className="size-4" aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
routing: {
|
||||||
|
chipClassName: "text-violet-600 dark:text-violet-300",
|
||||||
|
icon: (
|
||||||
|
<RouteIcon className="size-4" aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
enrichment: {
|
||||||
|
chipClassName: "text-emerald-600 dark:text-emerald-300",
|
||||||
|
icon: (
|
||||||
|
<SparklesIcon className="size-4" aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
digest: {
|
||||||
|
chipClassName: "text-amber-600 dark:text-amber-300",
|
||||||
|
icon: (
|
||||||
|
<MailIcon className="size-4" aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
escalation: {
|
||||||
|
chipClassName: "text-rose-600 dark:text-rose-300",
|
||||||
|
icon: (
|
||||||
|
<BellRingIcon className="size-4" aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateBadgeStyles: Record<
|
||||||
|
AutomationState,
|
||||||
|
{ label: string; dotClassName?: string }
|
||||||
|
> = {
|
||||||
|
live: {
|
||||||
|
label: "Live",
|
||||||
|
dotClassName: "bg-emerald-500",
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
label: "Needs approval",
|
||||||
|
dotClassName: "bg-amber-500",
|
||||||
|
},
|
||||||
|
drafts: {
|
||||||
|
label: "Draft",
|
||||||
|
dotClassName: "bg-slate-400 dark:bg-slate-300",
|
||||||
|
},
|
||||||
|
paused: {
|
||||||
|
label: "Paused",
|
||||||
|
dotClassName: "bg-zinc-400 dark:bg-zinc-300",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedBucketLabel: Record<IAutomationRecord["updatedBucket"], string> = {
|
||||||
|
today: "Today",
|
||||||
|
"this-week": "This week",
|
||||||
|
older: "Older",
|
||||||
|
}
|
||||||
|
|
||||||
|
const availabilityColor: Record<AutomationOwnerAvailability, string> = {
|
||||||
|
online: "bg-green-500",
|
||||||
|
away: "bg-yellow-400",
|
||||||
|
busy: "bg-red-500",
|
||||||
|
offline: "bg-gray-500",
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutomationKindChip({ kind }: { kind: AutomationKind }) {
|
||||||
|
const style = automationKindStyles[kind]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
render={<span />}
|
||||||
|
className={cn(
|
||||||
|
"p-0",
|
||||||
|
"border-background bg-muted flex size-9 items-center justify-center border-2 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4",
|
||||||
|
style.chipClassName
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
{style.icon}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutomationNameCell({ automation }: { automation: IAutomationRecord }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<AutomationKindChip kind={automation.kind} />
|
||||||
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
|
<span className="text-foreground truncate text-sm font-medium">
|
||||||
|
{automation.title}
|
||||||
|
</span>
|
||||||
|
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
|
||||||
|
<span className="truncate">{automation.runWindowLabel}</span>
|
||||||
|
<span
|
||||||
|
className="bg-input size-1 shrink-0 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{automation.audienceLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OwnerCell({ automation }: { automation: IAutomationRecord }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<Avatar className="size-8">
|
||||||
|
{automation.owner.avatar ? (
|
||||||
|
<AvatarImage
|
||||||
|
src={automation.owner.avatar}
|
||||||
|
alt={automation.owner.name}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback>{automation.owner.initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
|
||||||
|
availabilityColor[automation.owner.availability]
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-foreground line-clamp-1 font-medium">
|
||||||
|
{automation.owner.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-muted-foreground line-clamp-1 text-xs"
|
||||||
|
title={automation.owner.email}
|
||||||
|
>
|
||||||
|
{automation.owner.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StateCell({ automation }: { automation: IAutomationRecord }) {
|
||||||
|
const stateStyle = stateBadgeStyles[automation.state]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center">
|
||||||
|
<Badge variant="outline" className="gap-1.5">
|
||||||
|
{stateStyle.dotClassName ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 shrink-0 rounded-full",
|
||||||
|
stateStyle.dotClassName
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{stateStyle.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpdatedCell({ automation }: { automation: IAutomationRecord }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
|
<span className="text-foreground text-sm">
|
||||||
|
{format(parseISO(automation.updatedAt), "MMM d, yyyy")}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{updatedBucketLabel[automation.updatedBucket]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingCell({ automation }: { automation: IAutomationRecord }) {
|
||||||
|
return <Rating rating={automation.rating} size="sm" showValue={true} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutomationActionsCell({
|
||||||
|
automation,
|
||||||
|
onAction,
|
||||||
|
onToggleEnabled,
|
||||||
|
}: {
|
||||||
|
automation: IAutomationRecord
|
||||||
|
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
|
||||||
|
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Open actions for ${automation.title}`}
|
||||||
|
>
|
||||||
|
<EllipsisVerticalIcon className="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Content */}
|
||||||
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem onClick={() => onAction("edit", automation)}>
|
||||||
|
<PencilIcon className="size-4" aria-hidden="true" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onAction("open", automation)}>
|
||||||
|
<EyeIcon className="size-4" aria-hidden="true" />
|
||||||
|
View Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
closeOnClick={false}
|
||||||
|
onClick={(event) => {
|
||||||
|
// The Switch toggles itself and its click bubbles here; skip it
|
||||||
|
// so item-level activation (row click, Enter/Space) toggles once.
|
||||||
|
if (
|
||||||
|
event.target instanceof Element &&
|
||||||
|
event.target.closest('[data-slot="switch"]')
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onToggleEnabled(automation, !automation.enabled)
|
||||||
|
}}
|
||||||
|
className="justify-between gap-4"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<CircleCheckIcon className="size-4" aria-hidden="true" />
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Toggle ${automation.title}`}
|
||||||
|
checked={automation.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onToggleEnabled(automation, checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onAction("archive", automation)}
|
||||||
|
>
|
||||||
|
<ArchiveIcon className="size-4" aria-hidden="true" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAutomationColumns({
|
||||||
|
onAction,
|
||||||
|
onToggleEnabled,
|
||||||
|
}: {
|
||||||
|
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
|
||||||
|
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
|
||||||
|
}): ColumnDef<DataGridFeatures, IAutomationRecord>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: () => <DataGridTableRowSelectAll />,
|
||||||
|
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||||
|
size: 30,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (row) => row.title,
|
||||||
|
id: "workflow",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader title="Workflow" column={column} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <AutomationNameCell automation={row.original} />,
|
||||||
|
size: 340,
|
||||||
|
minSize: 200,
|
||||||
|
enableSorting: true,
|
||||||
|
enableHiding: false,
|
||||||
|
meta: {
|
||||||
|
autoSize: true,
|
||||||
|
headerClassName: "pl-3!",
|
||||||
|
cellClassName: "pl-3!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (row) => row.owner.name,
|
||||||
|
id: "owner",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader title="Owner" column={column} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <OwnerCell automation={row.original} />,
|
||||||
|
size: 175,
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (row) => row.rating,
|
||||||
|
id: "rating",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader title="Score" column={column} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <RatingCell automation={row.original} />,
|
||||||
|
size: 150,
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (row) => row.state,
|
||||||
|
id: "state",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader title="State" column={column} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <StateCell automation={row.original} />,
|
||||||
|
size: 150,
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (row) => parseISO(row.updatedAt).getTime(),
|
||||||
|
id: "updatedAt",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader title="Last updated" column={column} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <UpdatedCell automation={row.original} />,
|
||||||
|
size: 125,
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<AutomationActionsCell
|
||||||
|
automation={row.original}
|
||||||
|
onAction={onAction}
|
||||||
|
onToggleEnabled={onToggleEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
size: 56,
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import {
|
||||||
|
DataGrid,
|
||||||
|
dataGridFeatures,
|
||||||
|
} from "@/components/reui/data-grid/data-grid"
|
||||||
|
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||||
|
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||||
|
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||||
|
import { Filters } from "@/components/reui/filters/filters"
|
||||||
|
import {
|
||||||
|
createFilterQuery,
|
||||||
|
createFilterRule,
|
||||||
|
flattenFilterConditions,
|
||||||
|
} from "@/components/reui/filters/filters-query"
|
||||||
|
import type { FilterCondition } from "@/components/reui/filters/filters-query"
|
||||||
|
import type {
|
||||||
|
FilterField,
|
||||||
|
FilterQuery,
|
||||||
|
} from "@/components/reui/filters/filters-types"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
type PaginationState,
|
||||||
|
type RowSelectionState,
|
||||||
|
type SortingState,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@evobgp/ui/components/alert-dialog"
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import { Separator } from "@evobgp/ui/components/separator"
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@evobgp/ui/components/tabs"
|
||||||
|
import { createAutomationColumns } from "./columns"
|
||||||
|
import {
|
||||||
|
AUTOMATION_TABS,
|
||||||
|
AUTOMATIONS,
|
||||||
|
DELIVERY_FILTER_OPTIONS,
|
||||||
|
getAutomationTabFromState,
|
||||||
|
OWNER_FILTER_OPTIONS,
|
||||||
|
UPDATED_FILTER_OPTIONS,
|
||||||
|
type AutomationState,
|
||||||
|
type AutomationTab,
|
||||||
|
type IAutomationRecord,
|
||||||
|
} from "./data"
|
||||||
|
import { SearchIcon, UsersIcon, RouteIcon, ClockIcon, PlusIcon, FilterIcon, FunnelXIcon } from "lucide-react"
|
||||||
|
|
||||||
|
type ToastTone = "success" | "neutral" | "destructive"
|
||||||
|
|
||||||
|
const toneStyles: Record<ToastTone, { dot: string }> = {
|
||||||
|
success: { dot: "bg-emerald-500" },
|
||||||
|
neutral: { dot: "bg-sky-500" },
|
||||||
|
destructive: { dot: "bg-rose-500" },
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAutomationToast({
|
||||||
|
tone,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
tone: ToastTone
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}) {
|
||||||
|
toast.custom((id) => (
|
||||||
|
<div className="bg-popover text-popover-foreground border-border flex w-[356px] flex-col gap-3 rounded-md border p-4 shadow-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-1 flex size-2 shrink-0 rounded-full",
|
||||||
|
toneStyles[tone].dot
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<p className="text-sm font-semibold">{title}</p>
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed text-pretty">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="xs" variant="outline" onClick={() => toast.dismiss(id)}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutomationSearchBlob(automation: IAutomationRecord) {
|
||||||
|
return [
|
||||||
|
automation.title,
|
||||||
|
automation.kind,
|
||||||
|
automation.state,
|
||||||
|
automation.deliveryMode,
|
||||||
|
automation.audienceLabel,
|
||||||
|
automation.runWindowLabel,
|
||||||
|
automation.owner.name,
|
||||||
|
automation.owner.email,
|
||||||
|
automation.owner.teamLabel,
|
||||||
|
automation.approvalRequired ? "approval required" : "auto-approved",
|
||||||
|
automation.enabled ? "enabled" : "disabled",
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operators that take no value, so an empty `values` list is expected. */
|
||||||
|
const VALUELESS_OPERATORS = new Set(["empty", "not_empty"])
|
||||||
|
|
||||||
|
function getActiveFilters(filters: FilterCondition[]) {
|
||||||
|
return filters.filter((filter) => {
|
||||||
|
const { operator, values } = filter
|
||||||
|
if (VALUELESS_OPERATORS.has(operator)) return true
|
||||||
|
if (!values || values.length === 0) return false
|
||||||
|
if (
|
||||||
|
values.every((value) => typeof value === "string" && value.trim() === "")
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (values.every((value) => value === null || value === undefined)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectedCount(values: unknown[]) {
|
||||||
|
if (values.length === 0) return "Select..."
|
||||||
|
if (values.length > 1) return `${values.length} selected`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSingleSelectedLabel(
|
||||||
|
values: unknown[],
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
) {
|
||||||
|
const state = renderSelectedCount(values)
|
||||||
|
if (state) return state
|
||||||
|
|
||||||
|
const option = options.find((item) => item.value === values[0])
|
||||||
|
return option?.label ?? String(values[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterFieldValue(
|
||||||
|
automation: IAutomationRecord,
|
||||||
|
field: string
|
||||||
|
): unknown {
|
||||||
|
switch (field) {
|
||||||
|
case "workflow":
|
||||||
|
return getAutomationSearchBlob(automation)
|
||||||
|
case "ownerTeam":
|
||||||
|
return automation.owner.team
|
||||||
|
case "deliveryMode":
|
||||||
|
return automation.deliveryMode
|
||||||
|
case "updatedBucket":
|
||||||
|
return automation.updatedBucket
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilterCondition(
|
||||||
|
fieldValue: unknown,
|
||||||
|
operator: string,
|
||||||
|
values: unknown[]
|
||||||
|
): boolean {
|
||||||
|
switch (operator) {
|
||||||
|
case "is":
|
||||||
|
return values.includes(fieldValue)
|
||||||
|
case "is_not":
|
||||||
|
return !values.includes(fieldValue)
|
||||||
|
case "is_any_of":
|
||||||
|
return values.some((value) => fieldValue === value)
|
||||||
|
case "is_none_of":
|
||||||
|
return !values.some((value) => fieldValue === value)
|
||||||
|
case "contains": {
|
||||||
|
const tokens = values.map((value) => String(value).trim()).filter(Boolean)
|
||||||
|
if (tokens.length === 0) return true
|
||||||
|
return tokens.some((token) =>
|
||||||
|
String(fieldValue).toLowerCase().includes(token.toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case "not_contains":
|
||||||
|
return !values.some((value) =>
|
||||||
|
String(fieldValue).toLowerCase().includes(String(value).toLowerCase())
|
||||||
|
)
|
||||||
|
case "starts_with":
|
||||||
|
return values.some((value) =>
|
||||||
|
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase())
|
||||||
|
)
|
||||||
|
case "ends_with":
|
||||||
|
return values.some((value) =>
|
||||||
|
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase())
|
||||||
|
)
|
||||||
|
case "empty":
|
||||||
|
return fieldValue === "" || fieldValue == null
|
||||||
|
case "not_empty":
|
||||||
|
return fieldValue !== "" && fieldValue != null
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFiltersToData(
|
||||||
|
data: IAutomationRecord[],
|
||||||
|
filters: FilterCondition[]
|
||||||
|
): IAutomationRecord[] {
|
||||||
|
const active = getActiveFilters(filters)
|
||||||
|
let result = [...data]
|
||||||
|
|
||||||
|
active.forEach((filter) => {
|
||||||
|
const { field, operator, values, negated } = filter
|
||||||
|
|
||||||
|
result = result.filter((item) => {
|
||||||
|
const raw = filterFieldValue(item, field)
|
||||||
|
const fieldValue = raw != null ? raw : ""
|
||||||
|
const matches = matchesFilterCondition(fieldValue, operator, values)
|
||||||
|
|
||||||
|
// A chip negated from its menu keeps its operator and flips its meaning.
|
||||||
|
return negated ? !matches : matches
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWNER_TEAM_FILTER_OPTIONS = OWNER_FILTER_OPTIONS.filter(
|
||||||
|
(option) => option.value !== "everyone"
|
||||||
|
).map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const DELIVERY_MODE_FILTER_OPTIONS = DELIVERY_FILTER_OPTIONS.filter(
|
||||||
|
(option) => option.value !== "any"
|
||||||
|
).map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const UPDATED_BUCKET_FILTER_OPTIONS = UPDATED_FILTER_OPTIONS.filter(
|
||||||
|
(option) => option.value !== "any"
|
||||||
|
).map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const filterFields: FilterField[] = [
|
||||||
|
{
|
||||||
|
id: "workflow",
|
||||||
|
label: "Workflow",
|
||||||
|
icon: (
|
||||||
|
<SearchIcon className="size-3.5" aria-hidden />
|
||||||
|
),
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Search...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ownerTeam",
|
||||||
|
label: "Owner team",
|
||||||
|
icon: (
|
||||||
|
<UsersIcon className="size-3.5" aria-hidden />
|
||||||
|
),
|
||||||
|
type: "select",
|
||||||
|
searchable: false,
|
||||||
|
options: OWNER_TEAM_FILTER_OPTIONS,
|
||||||
|
renderValue: ({ values }) =>
|
||||||
|
renderSingleSelectedLabel(values, OWNER_TEAM_FILTER_OPTIONS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "deliveryMode",
|
||||||
|
label: "Delivery mode",
|
||||||
|
icon: (
|
||||||
|
<RouteIcon className="size-3.5" aria-hidden />
|
||||||
|
),
|
||||||
|
type: "select",
|
||||||
|
searchable: false,
|
||||||
|
options: DELIVERY_MODE_FILTER_OPTIONS,
|
||||||
|
renderValue: ({ values }) =>
|
||||||
|
renderSingleSelectedLabel(values, DELIVERY_MODE_FILTER_OPTIONS),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "updatedBucket",
|
||||||
|
label: "Last updated",
|
||||||
|
icon: (
|
||||||
|
<ClockIcon className="size-3.5" aria-hidden />
|
||||||
|
),
|
||||||
|
type: "select",
|
||||||
|
searchable: false,
|
||||||
|
options: UPDATED_BUCKET_FILTER_OPTIONS,
|
||||||
|
renderValue: ({ values }) =>
|
||||||
|
renderSingleSelectedLabel(values, UPDATED_BUCKET_FILTER_OPTIONS),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function createDefaultAutomationFilters(): FilterQuery {
|
||||||
|
return createFilterQuery([
|
||||||
|
createFilterRule({
|
||||||
|
id: "workflow-1",
|
||||||
|
path: ["workflow"],
|
||||||
|
operator: "contains",
|
||||||
|
value: "",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTabCounts(records: IAutomationRecord[]) {
|
||||||
|
return {
|
||||||
|
all: records.length,
|
||||||
|
live: records.filter((record) => record.state === "live").length,
|
||||||
|
review: records.filter((record) => record.state === "review").length,
|
||||||
|
drafts: records.filter((record) => record.state === "drafts").length,
|
||||||
|
paused: records.filter((record) => record.state === "paused").length,
|
||||||
|
} satisfies Record<AutomationTab, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutomationLibraryGridView() {
|
||||||
|
const [automations, setAutomations] =
|
||||||
|
useState<IAutomationRecord[]>(AUTOMATIONS)
|
||||||
|
const [activeTab, setActiveTab] = useState<AutomationTab>("all")
|
||||||
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(
|
||||||
|
createDefaultAutomationFilters
|
||||||
|
)
|
||||||
|
const filters = useMemo(
|
||||||
|
() => flattenFilterConditions(filterQuery),
|
||||||
|
[filterQuery]
|
||||||
|
)
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: "updatedAt", desc: true },
|
||||||
|
])
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 5,
|
||||||
|
})
|
||||||
|
const [automationPendingArchive, setAutomationPendingArchive] =
|
||||||
|
useState<IAutomationRecord | null>(null)
|
||||||
|
|
||||||
|
const resetPagination = useCallback(() => {
|
||||||
|
setPagination((current) =>
|
||||||
|
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredBaseAutomations = useMemo(() => {
|
||||||
|
return applyFiltersToData(automations, filters)
|
||||||
|
}, [automations, filters])
|
||||||
|
|
||||||
|
const filteredAutomations = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredBaseAutomations.filter((automation) =>
|
||||||
|
activeTab === "all"
|
||||||
|
? true
|
||||||
|
: getAutomationTabFromState(automation.state) === activeTab
|
||||||
|
),
|
||||||
|
[activeTab, filteredBaseAutomations]
|
||||||
|
)
|
||||||
|
|
||||||
|
const tabCounts = useMemo(
|
||||||
|
() => getTabCounts(filteredBaseAutomations),
|
||||||
|
[filteredBaseAutomations]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredLiveCount = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredAutomations.filter((automation) => automation.state === "live")
|
||||||
|
.length,
|
||||||
|
[filteredAutomations]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredReviewCount = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredAutomations.filter((automation) => automation.state === "review")
|
||||||
|
.length,
|
||||||
|
[filteredAutomations]
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedCount = useMemo(
|
||||||
|
() => Object.keys(rowSelection).length,
|
||||||
|
[rowSelection]
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() =>
|
||||||
|
createAutomationColumns({
|
||||||
|
onAction: (action, automation) => {
|
||||||
|
if (action === "archive") {
|
||||||
|
setAutomationPendingArchive(automation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "edit") {
|
||||||
|
showAutomationToast({
|
||||||
|
tone: "neutral",
|
||||||
|
title: "Workflow editor",
|
||||||
|
description: `Connect "${automation.title}" to your builder, side panel, or automation step editor.`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showAutomationToast({
|
||||||
|
tone: "success",
|
||||||
|
title: "Workflow details",
|
||||||
|
description: `"${automation.title}" is ready for a detail route, run history drawer, or audit panel.`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onToggleEnabled: (automation, nextValue) => {
|
||||||
|
const nextState: AutomationState =
|
||||||
|
nextValue && automation.state === "paused"
|
||||||
|
? "live"
|
||||||
|
: nextValue && automation.state === "drafts"
|
||||||
|
? "review"
|
||||||
|
: !nextValue && automation.state === "live"
|
||||||
|
? "paused"
|
||||||
|
: automation.state
|
||||||
|
|
||||||
|
setAutomations((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === automation.id
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
enabled: nextValue,
|
||||||
|
state: nextState,
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
showAutomationToast({
|
||||||
|
tone: nextValue ? "success" : "neutral",
|
||||||
|
title: nextValue ? "Workflow enabled" : "Workflow paused",
|
||||||
|
description: nextValue
|
||||||
|
? `"${automation.title}" is ready to run in the ${nextState === "review" ? "review" : "live"} queue.`
|
||||||
|
: `"${automation.title}" will stay available but will not continue running until resumed.`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
|
data: filteredAutomations,
|
||||||
|
columns,
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
rowSelection,
|
||||||
|
pagination,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClearControls = useCallback(() => {
|
||||||
|
setFilterQuery(createDefaultAutomationFilters())
|
||||||
|
resetPagination()
|
||||||
|
}, [resetPagination])
|
||||||
|
|
||||||
|
const handleFiltersChange = useCallback(
|
||||||
|
(nextQuery: FilterQuery) => {
|
||||||
|
setFilterQuery(nextQuery)
|
||||||
|
resetPagination()
|
||||||
|
},
|
||||||
|
[resetPagination]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleArchiveAutomation = useCallback(() => {
|
||||||
|
if (!automationPendingArchive) return
|
||||||
|
|
||||||
|
const automationToArchive = automationPendingArchive
|
||||||
|
|
||||||
|
setAutomations((current) =>
|
||||||
|
current.filter(
|
||||||
|
(automation) => automation.id !== automationPendingArchive.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setRowSelection((current) => {
|
||||||
|
const next = { ...current }
|
||||||
|
delete next[automationPendingArchive.id]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setAutomationPendingArchive(null)
|
||||||
|
resetPagination()
|
||||||
|
|
||||||
|
showAutomationToast({
|
||||||
|
tone: "destructive",
|
||||||
|
title: "Workflow archived",
|
||||||
|
description: `"${automationToArchive.title}" was removed from this automation library.`,
|
||||||
|
})
|
||||||
|
}, [automationPendingArchive, resetPagination])
|
||||||
|
|
||||||
|
const emptyMessage =
|
||||||
|
"No workflows match this automation slice. Switch tabs or clear the filters."
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Table */}
|
||||||
|
<DataGrid
|
||||||
|
table={table}
|
||||||
|
recordCount={filteredAutomations.length}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
tableLayout={{
|
||||||
|
dense: true,
|
||||||
|
}}
|
||||||
|
tableClassNames={{
|
||||||
|
edgeCell: "first:ps-3 last:pe-3",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||||
|
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-px">
|
||||||
|
<FrameTitle className="text-balance">
|
||||||
|
Automation Library
|
||||||
|
</FrameTitle>
|
||||||
|
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||||
|
<span>
|
||||||
|
{filteredAutomations.length} workflow
|
||||||
|
{filteredAutomations.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="bg-input size-1 shrink-0 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{filteredLiveCount} live</span>
|
||||||
|
<span
|
||||||
|
className="bg-input size-1 shrink-0 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{filteredReviewCount} review</span>
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="bg-input size-1 shrink-0 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{selectedCount} selected</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</FrameDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" className="shrink-0">
|
||||||
|
<PlusIcon className="size-4" aria-hidden="true" />
|
||||||
|
New workflow
|
||||||
|
</Button>
|
||||||
|
</FrameHeader>
|
||||||
|
|
||||||
|
<FramePanel className="p-0 shadow-none!">
|
||||||
|
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setActiveTab(value as AutomationTab)
|
||||||
|
resetPagination()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabsList variant="line" className="gap-5">
|
||||||
|
{AUTOMATION_TABS.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className="gap-2 px-0 pt-0 pb-(--frame-panel-header-py) text-sm"
|
||||||
|
>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||||
|
{tabCounts[tab.value]}
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
|
||||||
|
<Filters
|
||||||
|
query={filterQuery}
|
||||||
|
fields={filterFields}
|
||||||
|
onQueryChange={handleFiltersChange}
|
||||||
|
size="default"
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline" aria-label="Filters">
|
||||||
|
<FilterIcon className="size-4" aria-hidden="true" />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<Badge size="sm" variant="secondary">
|
||||||
|
{selectedCount} selected
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClearControls}
|
||||||
|
>
|
||||||
|
<FunnelXIcon className="size-4" aria-hidden="true" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<DataGridScrollArea>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGridScrollArea>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<FrameFooter>
|
||||||
|
<DataGridPagination />
|
||||||
|
</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</DataGrid>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={automationPendingArchive != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setAutomationPendingArchive(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Archive workflow?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{automationPendingArchive
|
||||||
|
? `Archive "${automationPendingArchive.title}" from this automation library. Run history and ownership context can stay available in your backend, but this row will disappear from the grid preview.`
|
||||||
|
: "Archive this workflow from the automation library."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleArchiveAutomation}
|
||||||
|
render={
|
||||||
|
<Button type="button" variant="destructive">
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
export type AutomationTab = "all" | "live" | "review" | "drafts" | "paused"
|
||||||
|
|
||||||
|
export type AutomationKind =
|
||||||
|
| "sequence"
|
||||||
|
| "routing"
|
||||||
|
| "enrichment"
|
||||||
|
| "digest"
|
||||||
|
| "escalation"
|
||||||
|
|
||||||
|
export type OwnerFilter =
|
||||||
|
| "everyone"
|
||||||
|
| "product"
|
||||||
|
| "engineering"
|
||||||
|
| "operations"
|
||||||
|
| "revenue"
|
||||||
|
| "support"
|
||||||
|
|
||||||
|
export type DeliveryFilter =
|
||||||
|
| "any"
|
||||||
|
| "scheduled"
|
||||||
|
| "event-driven"
|
||||||
|
| "manual"
|
||||||
|
| "hybrid"
|
||||||
|
|
||||||
|
export type UpdatedFilter = "any" | "today" | "this-week" | "older"
|
||||||
|
|
||||||
|
export type AutomationState = Exclude<AutomationTab, "all">
|
||||||
|
export type AutomationOwnerAvailability = "online" | "away" | "busy" | "offline"
|
||||||
|
|
||||||
|
export interface IAutomationOwner {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
initials: string
|
||||||
|
avatar?: string
|
||||||
|
availability: AutomationOwnerAvailability
|
||||||
|
team: Exclude<OwnerFilter, "everyone">
|
||||||
|
teamLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAutomationRecord {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
kind: AutomationKind
|
||||||
|
state: AutomationState
|
||||||
|
rating: number
|
||||||
|
deliveryMode: Exclude<DeliveryFilter, "any">
|
||||||
|
owner: IAutomationOwner
|
||||||
|
updatedAt: string
|
||||||
|
updatedBucket: Exclude<UpdatedFilter, "any">
|
||||||
|
enabled: boolean
|
||||||
|
approvalRequired: boolean
|
||||||
|
audienceLabel: string
|
||||||
|
runWindowLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWNERS: Record<string, IAutomationOwner> = {
|
||||||
|
maya: {
|
||||||
|
id: "maya-patel",
|
||||||
|
name: "Maya Patel",
|
||||||
|
email: "[email protected]",
|
||||||
|
initials: "MP",
|
||||||
|
avatar:
|
||||||
|
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||||
|
availability: "online",
|
||||||
|
team: "product",
|
||||||
|
teamLabel: "Product",
|
||||||
|
},
|
||||||
|
jonas: {
|
||||||
|
id: "jonas-reed",
|
||||||
|
name: "Jonas Reed",
|
||||||
|
email: "[email protected]",
|
||||||
|
initials: "JR",
|
||||||
|
avatar:
|
||||||
|
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||||
|
availability: "busy",
|
||||||
|
team: "engineering",
|
||||||
|
teamLabel: "Engineering",
|
||||||
|
},
|
||||||
|
priya: {
|
||||||
|
id: "priya-nair",
|
||||||
|
name: "Priya Nair",
|
||||||
|
email: "[email protected]",
|
||||||
|
initials: "PN",
|
||||||
|
avatar:
|
||||||
|
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
||||||
|
availability: "away",
|
||||||
|
team: "operations",
|
||||||
|
teamLabel: "Operations",
|
||||||
|
},
|
||||||
|
emil: {
|
||||||
|
id: "emil-novak",
|
||||||
|
name: "Emil Novak",
|
||||||
|
email: "[email protected]",
|
||||||
|
initials: "EN",
|
||||||
|
avatar:
|
||||||
|
"https://images.unsplash.com/photo-1560250097-0b93528c311a?w=96&h=96&dpr=2&q=80",
|
||||||
|
availability: "offline",
|
||||||
|
team: "revenue",
|
||||||
|
teamLabel: "Revenue",
|
||||||
|
},
|
||||||
|
nora: {
|
||||||
|
id: "nora-ibrahim",
|
||||||
|
name: "Nora Ibrahim",
|
||||||
|
email: "[email protected]",
|
||||||
|
initials: "NI",
|
||||||
|
avatar:
|
||||||
|
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
|
||||||
|
availability: "online",
|
||||||
|
team: "support",
|
||||||
|
teamLabel: "Support",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function automation(
|
||||||
|
input: Omit<IAutomationRecord, "owner"> & {
|
||||||
|
owner: keyof typeof OWNERS
|
||||||
|
}
|
||||||
|
): IAutomationRecord {
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
owner: OWNERS[input.owner],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTOMATION_TABS: { value: AutomationTab; label: string }[] = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "live", label: "Live" },
|
||||||
|
{ value: "review", label: "Needs Review" },
|
||||||
|
{ value: "drafts", label: "Drafts" },
|
||||||
|
{ value: "paused", label: "Paused" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const OWNER_FILTER_OPTIONS: {
|
||||||
|
value: OwnerFilter
|
||||||
|
label: string
|
||||||
|
}[] = [
|
||||||
|
{ value: "everyone", label: "Owner team" },
|
||||||
|
{ value: "product", label: "Product" },
|
||||||
|
{ value: "engineering", label: "Engineering" },
|
||||||
|
{ value: "operations", label: "Operations" },
|
||||||
|
{ value: "revenue", label: "Revenue" },
|
||||||
|
{ value: "support", label: "Support" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DELIVERY_FILTER_OPTIONS: {
|
||||||
|
value: DeliveryFilter
|
||||||
|
label: string
|
||||||
|
}[] = [
|
||||||
|
{ value: "any", label: "Delivery mode" },
|
||||||
|
{ value: "scheduled", label: "Scheduled" },
|
||||||
|
{ value: "event-driven", label: "Event-driven" },
|
||||||
|
{ value: "manual", label: "Manual" },
|
||||||
|
{ value: "hybrid", label: "Hybrid" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const UPDATED_FILTER_OPTIONS: {
|
||||||
|
value: UpdatedFilter
|
||||||
|
label: string
|
||||||
|
}[] = [
|
||||||
|
{ value: "any", label: "Last updated" },
|
||||||
|
{ value: "today", label: "Today" },
|
||||||
|
{ value: "this-week", label: "This week" },
|
||||||
|
{ value: "older", label: "Older" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getAutomationTabFromState(
|
||||||
|
state: AutomationState
|
||||||
|
): Exclude<AutomationTab, "all"> {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTOMATIONS: IAutomationRecord[] = [
|
||||||
|
automation({
|
||||||
|
id: "renewal-touchpoint-orchestration",
|
||||||
|
title: "Renewal touchpoint orchestration",
|
||||||
|
kind: "sequence",
|
||||||
|
state: "live",
|
||||||
|
rating: 4.8,
|
||||||
|
deliveryMode: "scheduled",
|
||||||
|
owner: "emil",
|
||||||
|
updatedAt: "2026-04-11",
|
||||||
|
updatedBucket: "today",
|
||||||
|
enabled: true,
|
||||||
|
approvalRequired: false,
|
||||||
|
audienceLabel: "Renewal accounts",
|
||||||
|
runWindowLabel: "Weekdays 09:00",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "delegated-sender-review-route",
|
||||||
|
title: "Delegated sender review route",
|
||||||
|
kind: "routing",
|
||||||
|
state: "review",
|
||||||
|
rating: 4.2,
|
||||||
|
deliveryMode: "manual",
|
||||||
|
owner: "priya",
|
||||||
|
updatedAt: "2026-04-11",
|
||||||
|
updatedBucket: "today",
|
||||||
|
enabled: false,
|
||||||
|
approvalRequired: true,
|
||||||
|
audienceLabel: "Delegated senders",
|
||||||
|
runWindowLabel: "Queue-based release",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "launch-handoff-digest",
|
||||||
|
title: "Launch handoff digest",
|
||||||
|
kind: "digest",
|
||||||
|
state: "live",
|
||||||
|
rating: 4.7,
|
||||||
|
deliveryMode: "scheduled",
|
||||||
|
owner: "maya",
|
||||||
|
updatedAt: "2026-04-10",
|
||||||
|
updatedBucket: "this-week",
|
||||||
|
enabled: true,
|
||||||
|
approvalRequired: false,
|
||||||
|
audienceLabel: "Launch squad",
|
||||||
|
runWindowLabel: "Daily 08:30",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "sla-escalation-watch",
|
||||||
|
title: "SLA escalation watch",
|
||||||
|
kind: "escalation",
|
||||||
|
state: "live",
|
||||||
|
rating: 4.9,
|
||||||
|
deliveryMode: "event-driven",
|
||||||
|
owner: "nora",
|
||||||
|
updatedAt: "2026-04-10",
|
||||||
|
updatedBucket: "this-week",
|
||||||
|
enabled: true,
|
||||||
|
approvalRequired: false,
|
||||||
|
audienceLabel: "Priority tickets",
|
||||||
|
runWindowLabel: "On trigger",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "lead-enrichment-pass",
|
||||||
|
title: "Lead enrichment pass",
|
||||||
|
kind: "enrichment",
|
||||||
|
state: "paused",
|
||||||
|
rating: 3.9,
|
||||||
|
deliveryMode: "hybrid",
|
||||||
|
owner: "jonas",
|
||||||
|
updatedAt: "2026-04-09",
|
||||||
|
updatedBucket: "this-week",
|
||||||
|
enabled: false,
|
||||||
|
approvalRequired: false,
|
||||||
|
audienceLabel: "Inbound pipeline",
|
||||||
|
runWindowLabel: "Hourly batch",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "sandbox-onboarding-sequence",
|
||||||
|
title: "Sandbox onboarding sequence",
|
||||||
|
kind: "sequence",
|
||||||
|
state: "drafts",
|
||||||
|
rating: 4.1,
|
||||||
|
deliveryMode: "scheduled",
|
||||||
|
owner: "priya",
|
||||||
|
updatedAt: "2026-04-08",
|
||||||
|
updatedBucket: "this-week",
|
||||||
|
enabled: false,
|
||||||
|
approvalRequired: false,
|
||||||
|
audienceLabel: "Trial workspaces",
|
||||||
|
runWindowLabel: "Pending QA",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "partner-routing-fallback",
|
||||||
|
title: "Partner routing fallback",
|
||||||
|
kind: "routing",
|
||||||
|
state: "live",
|
||||||
|
rating: 4.4,
|
||||||
|
deliveryMode: "hybrid",
|
||||||
|
owner: "emil",
|
||||||
|
updatedAt: "2026-04-07",
|
||||||
|
updatedBucket: "this-week",
|
||||||
|
enabled: true,
|
||||||
|
approvalRequired: false,
|
||||||
|
audienceLabel: "Partner renewals",
|
||||||
|
runWindowLabel: "Live + nightly",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "weekly-adoption-digest",
|
||||||
|
title: "Weekly adoption digest",
|
||||||
|
kind: "digest",
|
||||||
|
state: "paused",
|
||||||
|
rating: 3.8,
|
||||||
|
deliveryMode: "scheduled",
|
||||||
|
owner: "maya",
|
||||||
|
updatedAt: "2026-04-06",
|
||||||
|
updatedBucket: "this-week",
|
||||||
|
enabled: false,
|
||||||
|
approvalRequired: false,
|
||||||
|
audienceLabel: "Workspace champions",
|
||||||
|
runWindowLabel: "Fridays 16:00",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "enterprise-risk-escalation",
|
||||||
|
title: "Enterprise risk escalation",
|
||||||
|
kind: "escalation",
|
||||||
|
state: "review",
|
||||||
|
rating: 4.3,
|
||||||
|
deliveryMode: "manual",
|
||||||
|
owner: "nora",
|
||||||
|
updatedAt: "2026-04-05",
|
||||||
|
updatedBucket: "older",
|
||||||
|
enabled: false,
|
||||||
|
approvalRequired: true,
|
||||||
|
audienceLabel: "Enterprise accounts",
|
||||||
|
runWindowLabel: "Manual release",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "crm-enrichment-backfill",
|
||||||
|
title: "CRM enrichment backfill",
|
||||||
|
kind: "enrichment",
|
||||||
|
state: "live",
|
||||||
|
rating: 4.6,
|
||||||
|
deliveryMode: "scheduled",
|
||||||
|
owner: "jonas",
|
||||||
|
updatedAt: "2026-04-04",
|
||||||
|
updatedBucket: "older",
|
||||||
|
enabled: true,
|
||||||
|
approvalRequired: false,
|
||||||
|
audienceLabel: "Open opportunities",
|
||||||
|
runWindowLabel: "Nightly 01:00",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "trial-conversion-follow-up",
|
||||||
|
title: "Trial conversion follow-up",
|
||||||
|
kind: "sequence",
|
||||||
|
state: "drafts",
|
||||||
|
rating: 4.0,
|
||||||
|
deliveryMode: "scheduled",
|
||||||
|
owner: "emil",
|
||||||
|
updatedAt: "2026-04-03",
|
||||||
|
updatedBucket: "older",
|
||||||
|
enabled: false,
|
||||||
|
approvalRequired: false,
|
||||||
|
audienceLabel: "Product-led signups",
|
||||||
|
runWindowLabel: "Awaiting copy",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "owner-assignment-router",
|
||||||
|
title: "Owner assignment router",
|
||||||
|
kind: "routing",
|
||||||
|
state: "live",
|
||||||
|
rating: 4.5,
|
||||||
|
deliveryMode: "event-driven",
|
||||||
|
owner: "priya",
|
||||||
|
updatedAt: "2026-04-02",
|
||||||
|
updatedBucket: "older",
|
||||||
|
enabled: true,
|
||||||
|
approvalRequired: false,
|
||||||
|
audienceLabel: "Workspace requests",
|
||||||
|
runWindowLabel: "Immediate",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "ops-exception-digest",
|
||||||
|
title: "Ops exception digest",
|
||||||
|
kind: "digest",
|
||||||
|
state: "review",
|
||||||
|
rating: 4.1,
|
||||||
|
deliveryMode: "scheduled",
|
||||||
|
owner: "priya",
|
||||||
|
updatedAt: "2026-04-11",
|
||||||
|
updatedBucket: "today",
|
||||||
|
enabled: false,
|
||||||
|
approvalRequired: true,
|
||||||
|
audienceLabel: "Ops leadership",
|
||||||
|
runWindowLabel: "Daily 18:00",
|
||||||
|
}),
|
||||||
|
automation({
|
||||||
|
id: "billing-retry-escalation",
|
||||||
|
title: "Billing retry escalation",
|
||||||
|
kind: "escalation",
|
||||||
|
state: "live",
|
||||||
|
rating: 4.7,
|
||||||
|
deliveryMode: "event-driven",
|
||||||
|
owner: "nora",
|
||||||
|
updatedAt: "2026-04-01",
|
||||||
|
updatedBucket: "older",
|
||||||
|
enabled: true,
|
||||||
|
approvalRequired: false,
|
||||||
|
audienceLabel: "Recovery queue",
|
||||||
|
runWindowLabel: "On failure",
|
||||||
|
}),
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { AutomationLibraryGridView } from "./components/data-grid-view"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
|
||||||
|
aria-labelledby="page-heading"
|
||||||
|
>
|
||||||
|
<h1 id="page-heading" className="sr-only">
|
||||||
|
Automation library data grid
|
||||||
|
</h1>
|
||||||
|
<AutomationLibraryGridView />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { type CSSProperties } from "react"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameFooter,
|
||||||
|
FramePanel,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
import { IconStack } from "@/components/reui/icon-stack"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@evobgp/ui/components/empty"
|
||||||
|
import { Separator } from "@evobgp/ui/components/separator"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@evobgp/ui/components/tooltip"
|
||||||
|
import { Building2Icon, InfoIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function EmptyState() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex w-full max-w-5xl flex-col gap-8"
|
||||||
|
aria-labelledby="organizations-heading"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h2
|
||||||
|
id="organizations-heading"
|
||||||
|
className="text-2xl font-semibold tracking-tight"
|
||||||
|
>
|
||||||
|
Organizations
|
||||||
|
</h2>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TooltipProvider delay={200}>
|
||||||
|
<Frame spacing="xs" className="w-full" aria-label="Organizations setup">
|
||||||
|
<FramePanel className="flex min-h-[274px] items-center justify-center px-6 py-12">
|
||||||
|
<Empty className="max-w-md flex-none bg-transparent p-0">
|
||||||
|
<EmptyHeader className="gap-5">
|
||||||
|
<EmptyMedia className="mb-0">
|
||||||
|
<IconStack
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-20 w-18"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--icon-stack-content-x": "70%",
|
||||||
|
"--icon-stack-content-y": "57%",
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Building2Icon className="size-4.5" strokeWidth="1.8" aria-hidden="true" />
|
||||||
|
</IconStack>
|
||||||
|
</EmptyMedia>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<EmptyTitle className="text-base font-semibold tracking-tight">
|
||||||
|
Enable organizations
|
||||||
|
</EmptyTitle>
|
||||||
|
<EmptyDescription className="max-w-sm text-sm/relaxed">
|
||||||
|
Enable your users to create organizations, manage team
|
||||||
|
members, and control access with role-based permissions.
|
||||||
|
</EmptyDescription>
|
||||||
|
</div>
|
||||||
|
</EmptyHeader>
|
||||||
|
|
||||||
|
<EmptyContent className="mt-5 max-w-none gap-0">
|
||||||
|
<Button type="button">Enable organizations</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
|
</FramePanel>
|
||||||
|
|
||||||
|
<FrameFooter className="flex-row items-center justify-start">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<a
|
||||||
|
href="#organizations-docs"
|
||||||
|
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-sm text-xs font-medium transition-colors outline-none focus-visible:ring-[3px]"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Learn more about{" "}
|
||||||
|
<span className="text-primary">Organizations</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-64">
|
||||||
|
Organizations group users, teams, roles, and permissions under
|
||||||
|
one account boundary.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</FrameFooter>
|
||||||
|
</Frame>
|
||||||
|
</TooltipProvider>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { EmptyState } from "./components/empty-state"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-10"
|
||||||
|
aria-labelledby="organizations-heading"
|
||||||
|
>
|
||||||
|
<EmptyState />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
import type { BadgeProps } from "@/components/reui/badge"
|
||||||
|
|
||||||
|
import { ClaudeAiIcon } from "@evobgp/ui/components/svgs/claudeAiIcon"
|
||||||
|
import { CursorDark } from "@evobgp/ui/components/svgs/cursorDark"
|
||||||
|
import { CursorLight } from "@evobgp/ui/components/svgs/cursorLight"
|
||||||
|
import { GoogleCalendar } from "@evobgp/ui/components/svgs/googleCalendar"
|
||||||
|
import { ResendIconBlack } from "@evobgp/ui/components/svgs/resendIconBlack"
|
||||||
|
import { ResendIconWhite } from "@evobgp/ui/components/svgs/resendIconWhite"
|
||||||
|
import { Slack } from "@evobgp/ui/components/svgs/slack"
|
||||||
|
import { Stripe } from "@evobgp/ui/components/svgs/stripe"
|
||||||
|
import { Supabase } from "@evobgp/ui/components/svgs/supabase"
|
||||||
|
|
||||||
|
export type IntegrationStatus = "connected" | "available" | "preview"
|
||||||
|
|
||||||
|
export interface IntegrationStatusMeta {
|
||||||
|
label: string
|
||||||
|
variant: BadgeProps["variant"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrationApp {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
logo: ReactNode
|
||||||
|
status: IntegrationStatus
|
||||||
|
actionLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INTEGRATION_STATUS_META: Record<
|
||||||
|
IntegrationStatus,
|
||||||
|
IntegrationStatusMeta
|
||||||
|
> = {
|
||||||
|
connected: {
|
||||||
|
label: "Connected",
|
||||||
|
variant: "success-light",
|
||||||
|
},
|
||||||
|
available: {
|
||||||
|
label: "Available",
|
||||||
|
variant: "secondary",
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
label: "Preview",
|
||||||
|
variant: "info-light",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoClassName = "block size-5"
|
||||||
|
|
||||||
|
function ThemeLogo({ light, dark }: { light: ReactNode; dark: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex size-5 items-center justify-center leading-none dark:hidden"
|
||||||
|
>
|
||||||
|
{light}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="hidden size-5 items-center justify-center leading-none dark:flex"
|
||||||
|
>
|
||||||
|
{dark}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURSOR_LOGO = (
|
||||||
|
<ThemeLogo
|
||||||
|
light={<CursorLight className={logoClassName} />}
|
||||||
|
dark={<CursorDark className={logoClassName} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const RESEND_LOGO = (
|
||||||
|
<ThemeLogo
|
||||||
|
light={<ResendIconBlack className={logoClassName} />}
|
||||||
|
dark={<ResendIconWhite className={logoClassName} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const INTEGRATION_APPS: IntegrationApp[] = [
|
||||||
|
{
|
||||||
|
id: "supabase",
|
||||||
|
name: "Supabase",
|
||||||
|
description:
|
||||||
|
"Sync workspace records, owners, and lifecycle events from your product database.",
|
||||||
|
logo: <Supabase className={logoClassName} aria-hidden="true" />,
|
||||||
|
status: "connected",
|
||||||
|
actionLabel: "Configure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slack",
|
||||||
|
name: "Slack",
|
||||||
|
description:
|
||||||
|
"Route approvals, launch notes, and incident updates into selected team channels.",
|
||||||
|
logo: <Slack className={logoClassName} aria-hidden="true" />,
|
||||||
|
status: "connected",
|
||||||
|
actionLabel: "Configure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stripe",
|
||||||
|
name: "Stripe",
|
||||||
|
description:
|
||||||
|
"Mirror subscription, invoice, and renewal signals into account reviews.",
|
||||||
|
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||||
|
status: "connected",
|
||||||
|
actionLabel: "Configure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "google-calendar",
|
||||||
|
name: "Google Calendar",
|
||||||
|
description:
|
||||||
|
"Create planning reviews from milestones, customer dates, and release windows.",
|
||||||
|
logo: <GoogleCalendar className={logoClassName} aria-hidden="true" />,
|
||||||
|
status: "available",
|
||||||
|
actionLabel: "Install",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resend",
|
||||||
|
name: "Resend",
|
||||||
|
description:
|
||||||
|
"Send branded transactional updates when workflows change status or ownership.",
|
||||||
|
logo: RESEND_LOGO,
|
||||||
|
status: "available",
|
||||||
|
actionLabel: "Install",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cursor",
|
||||||
|
name: "Cursor",
|
||||||
|
description:
|
||||||
|
"Attach implementation context and AI coding notes to active product tasks.",
|
||||||
|
logo: CURSOR_LOGO,
|
||||||
|
status: "preview",
|
||||||
|
actionLabel: "Configure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude",
|
||||||
|
name: "Claude",
|
||||||
|
description:
|
||||||
|
"Draft summaries, support notes, and rollout briefs from approved workspace data.",
|
||||||
|
logo: <ClaudeAiIcon className={logoClassName} aria-hidden="true" />,
|
||||||
|
status: "preview",
|
||||||
|
actionLabel: "Install",
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
} from "@evobgp/ui/components/item"
|
||||||
|
|
||||||
|
import {
|
||||||
|
INTEGRATION_APPS,
|
||||||
|
INTEGRATION_STATUS_META,
|
||||||
|
type IntegrationApp,
|
||||||
|
} from "./data"
|
||||||
|
|
||||||
|
function IntegrationLogo({ app }: { app: IntegrationApp }) {
|
||||||
|
return (
|
||||||
|
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:block [&_svg]:size-5">
|
||||||
|
<ItemMedia variant="icon" className="size-auto translate-y-0! self-center!">
|
||||||
|
{app.logo}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegrationRow({ app }: { app: IntegrationApp }) {
|
||||||
|
const status = INTEGRATION_STATUS_META[app.status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item role="listitem" className="items-center px-3.5 py-2.5 sm:px-4">
|
||||||
|
{/* Media */}
|
||||||
|
<ItemMedia className="translate-y-0! self-center!">
|
||||||
|
<IntegrationLogo app={app} />
|
||||||
|
</ItemMedia>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ItemContent className="min-w-0 gap-0">
|
||||||
|
<ItemTitle className="w-full min-w-0 gap-2">
|
||||||
|
<span className="truncate">{app.name}</span>
|
||||||
|
<Badge variant={status.variant}>{status.label}</Badge>
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription className="line-clamp-1">
|
||||||
|
{app.description}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<ItemActions className="ml-auto shrink-0 justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm">
|
||||||
|
{app.actionLabel}
|
||||||
|
</Button>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IntegrationSettings() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-4xl flex-col gap-5">
|
||||||
|
{/* Section */}
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-sm font-semibold">Connected Apps</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Manage source systems for automation, scheduling, support, and AI
|
||||||
|
workflows.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" size="sm" className="w-full sm:w-auto">
|
||||||
|
Build Connector
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Frame>
|
||||||
|
<FrameHeader className="sr-only">
|
||||||
|
<FrameTitle>Integration Apps</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Available workspace apps and configuration actions.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0!">
|
||||||
|
<ItemGroup className="gap-0">
|
||||||
|
{INTEGRATION_APPS.map((app, index) => (
|
||||||
|
<div key={app.id}>
|
||||||
|
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||||
|
<IntegrationRow app={app} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ItemGroup>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { IntegrationSettings } from "./components/integration-settings"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12"
|
||||||
|
aria-labelledby="page-heading"
|
||||||
|
>
|
||||||
|
<h1 id="page-heading" className="sr-only">
|
||||||
|
Integration settings
|
||||||
|
</h1>
|
||||||
|
<IntegrationSettings />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import { FieldLegend, FieldSet } from "@evobgp/ui/components/field"
|
||||||
|
import { ACCENT_COLORS } from "./data"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
// ── Color Picker ──
|
||||||
|
|
||||||
|
export function ColorPicker() {
|
||||||
|
const [selected, setSelected] = useState(ACCENT_COLORS[0].value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldSet className="gap-0">
|
||||||
|
<FieldLegend className="sr-only">Accent color</FieldLegend>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{ACCENT_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.value}
|
||||||
|
type="button"
|
||||||
|
aria-label={color.name}
|
||||||
|
onClick={() => setSelected(color.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 items-center justify-center rounded-full transition-shadow",
|
||||||
|
selected === color.value &&
|
||||||
|
"ring-ring ring-offset-background ring-2 ring-offset-2"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: color.value }}
|
||||||
|
>
|
||||||
|
{selected === color.value ? (
|
||||||
|
<CheckIcon className="size-3 text-white" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FieldSet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { type ReactNode } from "react"
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export type SelectOption = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Preference = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
defaultChecked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccentColorOption = {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettingBadgeVariant =
|
||||||
|
| "primary-light"
|
||||||
|
| "destructive-light"
|
||||||
|
| "info-light"
|
||||||
|
|
||||||
|
export type SettingFieldProps = {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
badge?: { label: string; variant: SettingBadgeVariant }
|
||||||
|
children: ReactNode
|
||||||
|
last?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateFormatOption = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data ──
|
||||||
|
|
||||||
|
export const DAYS: SelectOption[] = [
|
||||||
|
{ value: "monday", label: "Monday" },
|
||||||
|
{ value: "tuesday", label: "Tuesday" },
|
||||||
|
{ value: "wednesday", label: "Wednesday" },
|
||||||
|
{ value: "thursday", label: "Thursday" },
|
||||||
|
{ value: "friday", label: "Friday" },
|
||||||
|
{ value: "saturday", label: "Saturday" },
|
||||||
|
{ value: "sunday", label: "Sunday" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CURRENCIES: SelectOption[] = [
|
||||||
|
{ value: "usd", label: "USD · US Dollar" },
|
||||||
|
{ value: "eur", label: "EUR · Euro" },
|
||||||
|
{ value: "gbp", label: "GBP · British Pound" },
|
||||||
|
{ value: "jpy", label: "JPY · Japanese Yen" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const REGIONS: SelectOption[] = [
|
||||||
|
{ value: "us", label: "United States" },
|
||||||
|
{ value: "eu", label: "European Union" },
|
||||||
|
{ value: "uk", label: "United Kingdom" },
|
||||||
|
{ value: "jp", label: "Japan" },
|
||||||
|
{ value: "au", label: "Australia" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ACCENT_COLORS: AccentColorOption[] = [
|
||||||
|
{ name: "Slate", value: "#64748b" },
|
||||||
|
{ name: "Rose", value: "#f43f5e" },
|
||||||
|
{ name: "Orange", value: "#f97316" },
|
||||||
|
{ name: "Violet", value: "#8b5cf6" },
|
||||||
|
{ name: "Emerald", value: "#10b981" },
|
||||||
|
{ name: "Sky", value: "#0ea5e9" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PREFERENCES: Preference[] = [
|
||||||
|
{
|
||||||
|
id: "keyboard-shortcuts",
|
||||||
|
label: "Enable keyboard shortcuts.",
|
||||||
|
description: "Use shortcuts to speed up your workflow.",
|
||||||
|
defaultChecked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "spellcheck",
|
||||||
|
label: "Auto spell-check.",
|
||||||
|
description: "Highlight spelling errors in text fields.",
|
||||||
|
defaultChecked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "compact-mode",
|
||||||
|
label: "Compact Mode.",
|
||||||
|
description: "Reduce spacing for a denser interface layout.",
|
||||||
|
defaultChecked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "animations",
|
||||||
|
label: "Reduce animations.",
|
||||||
|
description: "Minimize motion effects throughout the UI.",
|
||||||
|
defaultChecked: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DATE_FORMAT_OPTIONS: DateFormatOption[] = [
|
||||||
|
{ value: "mdy", label: "MM/DD/YYYY" },
|
||||||
|
{ value: "dmy", label: "DD/MM/YYYY" },
|
||||||
|
{ value: "ymd", label: "YYYY/MM/DD" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
import { Switch } from "@evobgp/ui/components/switch"
|
||||||
|
|
||||||
|
import { PREFERENCES } from "./data"
|
||||||
|
|
||||||
|
// ── Editor Preferences ──
|
||||||
|
|
||||||
|
export function EditorPreferences() {
|
||||||
|
const [values, setValues] = useState<Record<string, boolean>>(() =>
|
||||||
|
Object.fromEntries(PREFERENCES.map((p) => [p.id, p.defaultChecked]))
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">Editor preferences</FieldLegend>
|
||||||
|
{/* Description */}
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Configure editing behavior and interface density.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<FieldGroup className="gap-3">
|
||||||
|
{PREFERENCES.map((pref) => (
|
||||||
|
<Field key={pref.id} orientation="horizontal" className="gap-3">
|
||||||
|
<Switch
|
||||||
|
id={`settings-3-${pref.id}`}
|
||||||
|
checked={values[pref.id]}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
setValues((prev) => ({ ...prev, [pref.id]: value }))
|
||||||
|
}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldContent className="gap-0.5">
|
||||||
|
<FieldLabel htmlFor={`settings-3-${pref.id}`}>
|
||||||
|
{pref.label}
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldDescription className="text-xs">
|
||||||
|
{pref.description}
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
import { Input } from "@evobgp/ui/components/input"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupText,
|
||||||
|
} from "@evobgp/ui/components/input-group"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@evobgp/ui/components/select"
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@evobgp/ui/components/toggle-group"
|
||||||
|
import { ColorPicker } from "./color-picker"
|
||||||
|
import { CURRENCIES, DAYS, REGIONS } from "./data"
|
||||||
|
import { EditorPreferences } from "./editor-preferences"
|
||||||
|
import { SettingField } from "./setting-field"
|
||||||
|
import { PlusIcon, UploadIcon, GlobeIcon, CircleDollarSignIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function GeneralSettings() {
|
||||||
|
const [dateFormat, setDateFormat] = useState(["mdy"])
|
||||||
|
const [startOfWeek, setStartOfWeek] = useState("monday")
|
||||||
|
const [region, setRegion] = useState("us")
|
||||||
|
const [currency, setCurrency] = useState("usd")
|
||||||
|
const handleStartOfWeekChange = (value: string | null) => {
|
||||||
|
if (value !== null) {
|
||||||
|
setStartOfWeek(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleRegionChange = (value: string | null) => {
|
||||||
|
if (value !== null) {
|
||||||
|
setRegion(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleCurrencyChange = (value: string | null) => {
|
||||||
|
if (value !== null) {
|
||||||
|
setCurrency(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame className="w-full max-w-3xl">
|
||||||
|
<FrameHeader className="px-2! py-2.5!">
|
||||||
|
<FrameTitle>General Settings</FrameTitle>
|
||||||
|
<FrameDescription>Core app preferences.</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<FieldGroup className="gap-0">
|
||||||
|
{/* ── Project name ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Project name"
|
||||||
|
description="The display name for your project across the platform."
|
||||||
|
labelFor="settings-3-project-name"
|
||||||
|
>
|
||||||
|
<Input id="settings-3-project-name" defaultValue="Acme Dashboard" />
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── API endpoint ── */}
|
||||||
|
<SettingField
|
||||||
|
title="API endpoint"
|
||||||
|
description="Set the base URL for your project API."
|
||||||
|
badge={{ label: "Required", variant: "destructive-light" }}
|
||||||
|
labelFor="settings-3-api-endpoint"
|
||||||
|
>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>https://</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-3-api-endpoint"
|
||||||
|
defaultValue="api.acme.io"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── Start of week ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Start of week"
|
||||||
|
description="Choose which day marks the start of your week."
|
||||||
|
labelFor="settings-3-start-of-week"
|
||||||
|
>
|
||||||
|
<Select value={startOfWeek} onValueChange={handleStartOfWeekChange}>
|
||||||
|
<SelectTrigger id="settings-3-start-of-week" className="w-full">
|
||||||
|
<SelectValue>{getOptionLabel(DAYS, startOfWeek)}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<SelectItem key={day.value} value={day.value}>
|
||||||
|
{day.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── Allowed origins ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Allowed origins"
|
||||||
|
description="Define the trusted domains for CORS requests."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button variant="outline">
|
||||||
|
<PlusIcon aria-hidden="true" />
|
||||||
|
Add origin
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline">
|
||||||
|
<UploadIcon aria-hidden="true" />
|
||||||
|
Import from file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── Accent color ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Accent color"
|
||||||
|
description="Select a color to represent your brand."
|
||||||
|
badge={{ label: "Upgrade to Pro", variant: "primary-light" }}
|
||||||
|
>
|
||||||
|
<ColorPicker />
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── Region & currency ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Region & currency"
|
||||||
|
description="Adjust your regional preferences and currency."
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">Region and currency</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Regional and financial formatting preferences.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup className="gap-3">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-3-region">Region</FieldLabel>
|
||||||
|
<Select value={region} onValueChange={handleRegionChange}>
|
||||||
|
<SelectTrigger id="settings-3-region" className="w-full">
|
||||||
|
<GlobeIcon aria-hidden="true" />
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(REGIONS, region)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{REGIONS.map((region) => (
|
||||||
|
<SelectItem key={region.value} value={region.value}>
|
||||||
|
{region.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-3-currency">
|
||||||
|
Currency
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={currency} onValueChange={handleCurrencyChange}>
|
||||||
|
<SelectTrigger id="settings-3-currency" className="w-full">
|
||||||
|
<CircleDollarSignIcon aria-hidden="true" />
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(CURRENCIES, currency)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{CURRENCIES.map((currency) => (
|
||||||
|
<SelectItem
|
||||||
|
key={currency.value}
|
||||||
|
value={currency.value}
|
||||||
|
>
|
||||||
|
{currency.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── Date display format ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Date display format"
|
||||||
|
description="Choose your preferred format for dates."
|
||||||
|
>
|
||||||
|
<ToggleGroup
|
||||||
|
multiple={false}
|
||||||
|
value={dateFormat}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value.length > 0) setDateFormat(value)
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Date display format"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="mdy">MM/DD/YYYY</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="dmy">DD/MM/YYYY</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="ymd">YYYY/MM/DD</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</SettingField>
|
||||||
|
|
||||||
|
{/* ── Editor preferences ── */}
|
||||||
|
<SettingField
|
||||||
|
title="Editor preferences"
|
||||||
|
description="Adjust your editing environment and display options."
|
||||||
|
last
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<EditorPreferences />
|
||||||
|
</SettingField>
|
||||||
|
</FieldGroup>
|
||||||
|
</FramePanel>
|
||||||
|
|
||||||
|
<FrameFooter className="flex flex-row justify-end gap-3">
|
||||||
|
<Button variant="outline">Reset</Button>
|
||||||
|
<Button>Save changes</Button>
|
||||||
|
</FrameFooter>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper functions ──
|
||||||
|
|
||||||
|
function getOptionLabel(
|
||||||
|
options: Array<{ value: string; label: string }>,
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
|
return options.find((option) => option.value === value)?.label ?? value
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { type ComponentProps, type ReactNode } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldLabel,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
|
||||||
|
interface SettingFieldProps {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
badge?: {
|
||||||
|
label: string
|
||||||
|
variant: ComponentProps<typeof Badge>["variant"]
|
||||||
|
}
|
||||||
|
children: ReactNode
|
||||||
|
last?: boolean
|
||||||
|
labelFor?: string
|
||||||
|
contentClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setting Field ──
|
||||||
|
|
||||||
|
export function SettingField({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
badge,
|
||||||
|
children,
|
||||||
|
last,
|
||||||
|
labelFor,
|
||||||
|
contentClassName,
|
||||||
|
}: SettingFieldProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{labelFor ? (
|
||||||
|
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||||
|
) : (
|
||||||
|
<FieldTitle>{title}</FieldTitle>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{badge ? (
|
||||||
|
<Badge variant={badge.variant} size="sm">
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldDescription className="text-xs">{description}</FieldDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldContent
|
||||||
|
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{!last ? <FieldSeparator /> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
import { AccountSettings } from "./components/account-settings"
|
import { GeneralSettings } from "./components/general-settings"
|
||||||
|
|
||||||
export function Page() {
|
export function Page() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 md:p-10">
|
<div className="flex min-h-svh w-full items-start justify-center p-8 md:p-16">
|
||||||
<AccountSettings />
|
<GeneralSettings />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
export type WizardStepId = "company" | "ownership" | "documents"
|
||||||
|
export type RegistrationStatus = "registered" | "invited" | "new"
|
||||||
|
export type VendorCategory = "software" | "services" | "data" | "hardware"
|
||||||
|
export type VendorCountry = "us" | "gb" | "de" | "uz" | "sg"
|
||||||
|
export type DocumentId =
|
||||||
|
| "certificate"
|
||||||
|
| "tax-letter"
|
||||||
|
| "ownership-chart"
|
||||||
|
| "bank-letter"
|
||||||
|
|
||||||
|
export interface WizardStep {
|
||||||
|
id: WizardStepId
|
||||||
|
step: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardOption<TValue extends string = string> {
|
||||||
|
value: TValue
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OwnerRecord {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
share: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentRequirement {
|
||||||
|
id: DocumentId
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status: "Ready" | "Needs upload" | "Review"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardValues {
|
||||||
|
companyName: string
|
||||||
|
registrationStatus: RegistrationStatus
|
||||||
|
portalId: string
|
||||||
|
category: VendorCategory
|
||||||
|
country: VendorCountry
|
||||||
|
contactEmail: string
|
||||||
|
website: string
|
||||||
|
owners: OwnerRecord[]
|
||||||
|
taxId: string
|
||||||
|
incorporationDate: string
|
||||||
|
ownershipAttested: boolean
|
||||||
|
documentIds: DocumentId[]
|
||||||
|
submissionNotes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WIZARD_STEPS: WizardStep[] = [
|
||||||
|
{
|
||||||
|
id: "company",
|
||||||
|
step: 1,
|
||||||
|
title: "Company Details",
|
||||||
|
description: "Vendor identity and procurement profile.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ownership",
|
||||||
|
step: 2,
|
||||||
|
title: "Compliance",
|
||||||
|
description: "Owners and tax IDs.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "documents",
|
||||||
|
step: 3,
|
||||||
|
title: "Submit",
|
||||||
|
description: "Review and submit.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const REGISTRATION_OPTIONS: WizardOption<RegistrationStatus>[] = [
|
||||||
|
{
|
||||||
|
value: "registered",
|
||||||
|
label: "Yes",
|
||||||
|
description: "Vendor record already exists in the portal.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "invited",
|
||||||
|
label: "Invited",
|
||||||
|
description: "Invitation sent, registration is pending.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "new",
|
||||||
|
label: "No",
|
||||||
|
description: "Create a fresh procurement record.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CATEGORY_OPTIONS: WizardOption<VendorCategory>[] = [
|
||||||
|
{
|
||||||
|
value: "software",
|
||||||
|
label: "Software subscription",
|
||||||
|
description: "SaaS, licenses, and usage-based software.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "services",
|
||||||
|
label: "Professional services",
|
||||||
|
description: "Implementation, advisory, and support work.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "data",
|
||||||
|
label: "Data provider",
|
||||||
|
description: "Market data, enrichment, and data feeds.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "hardware",
|
||||||
|
label: "Hardware supplier",
|
||||||
|
description: "Devices, equipment, and physical goods.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const COUNTRY_OPTIONS: WizardOption<VendorCountry>[] = [
|
||||||
|
{
|
||||||
|
value: "us",
|
||||||
|
label: "United States",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gb",
|
||||||
|
label: "United Kingdom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "de",
|
||||||
|
label: "Germany",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "uz",
|
||||||
|
label: "Uzbekistan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "sg",
|
||||||
|
label: "Singapore",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DOCUMENT_REQUIREMENTS: DocumentRequirement[] = [
|
||||||
|
{
|
||||||
|
id: "certificate",
|
||||||
|
title: "Certificate of incorporation",
|
||||||
|
description: "Legal entity proof.",
|
||||||
|
status: "Ready",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tax-letter",
|
||||||
|
title: "Tax residency letter",
|
||||||
|
description: "Needed for payment release.",
|
||||||
|
status: "Needs upload",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ownership-chart",
|
||||||
|
title: "Ownership chart",
|
||||||
|
description: "Confirms ownership threshold.",
|
||||||
|
status: "Review",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bank-letter",
|
||||||
|
title: "Bank verification letter",
|
||||||
|
description: "Verifies payee details.",
|
||||||
|
status: "Ready",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_WIZARD_VALUES: WizardValues = {
|
||||||
|
companyName: "ReUI Labs",
|
||||||
|
registrationStatus: "registered",
|
||||||
|
portalId: "VEN-2048",
|
||||||
|
category: "software",
|
||||||
|
country: "us",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
website: "https://reui.dev",
|
||||||
|
owners: [
|
||||||
|
{
|
||||||
|
id: "owner-1",
|
||||||
|
name: "Maya Chen",
|
||||||
|
address: "214 Mission Street, San Francisco, CA",
|
||||||
|
share: "52%",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "owner-2",
|
||||||
|
name: "Kai Morgan",
|
||||||
|
address: "18 King Street, Seattle, WA",
|
||||||
|
share: "28%",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
taxId: "US-82-4917721",
|
||||||
|
incorporationDate: "08/08/2025",
|
||||||
|
ownershipAttested: true,
|
||||||
|
documentIds: ["certificate", "bank-letter"],
|
||||||
|
submissionNotes:
|
||||||
|
"Route final review to finance once tax residency is uploaded.",
|
||||||
|
}
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
import { Input } from "@evobgp/ui/components/input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@evobgp/ui/components/select"
|
||||||
|
import { Textarea } from "@evobgp/ui/components/textarea"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@evobgp/ui/components/tooltip"
|
||||||
|
import {
|
||||||
|
CATEGORY_OPTIONS,
|
||||||
|
COUNTRY_OPTIONS,
|
||||||
|
DOCUMENT_REQUIREMENTS,
|
||||||
|
REGISTRATION_OPTIONS,
|
||||||
|
type DocumentId,
|
||||||
|
type OwnerRecord,
|
||||||
|
type VendorCategory,
|
||||||
|
type VendorCountry,
|
||||||
|
type WizardOption,
|
||||||
|
type WizardValues,
|
||||||
|
} from "./data"
|
||||||
|
import { InfoIcon, Trash2Icon, PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export type WizardErrors = Partial<Record<keyof WizardValues, string>>
|
||||||
|
|
||||||
|
type WizardValueChange = (
|
||||||
|
field: keyof WizardValues,
|
||||||
|
value: WizardValues[keyof WizardValues]
|
||||||
|
) => void
|
||||||
|
|
||||||
|
interface WizardSummary {
|
||||||
|
categoryLabel: string
|
||||||
|
countryLabel: string
|
||||||
|
registrationLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequiredMark() {
|
||||||
|
return <span className="text-destructive">*</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabelHint({
|
||||||
|
htmlFor,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
htmlFor?: string
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-fit items-center gap-1.5 leading-snug">
|
||||||
|
<FieldLabel htmlFor={htmlFor}>{children}</FieldLabel>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex size-4 shrink-0 items-center justify-center rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
|
aria-label={`${label} hint`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-56 text-xs text-balance">
|
||||||
|
{hint}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WizardSelectField<TValue extends string>({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
value: TValue
|
||||||
|
options: WizardOption<TValue>[]
|
||||||
|
placeholder: string
|
||||||
|
onValueChange: (value: TValue) => void
|
||||||
|
}) {
|
||||||
|
const selectedOption =
|
||||||
|
options.find((option) => option.value === value) ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={selectedOption}
|
||||||
|
items={options}
|
||||||
|
onValueChange={(nextValue) => {
|
||||||
|
if (nextValue) {
|
||||||
|
onValueChange(nextValue.value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={id} className="w-full [&_small]:hidden">
|
||||||
|
<SelectValue>
|
||||||
|
{(item: WizardOption<TValue> | null) => item?.label ?? placeholder}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent alignItemWithTrigger={false}>
|
||||||
|
<SelectGroup>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option}>
|
||||||
|
{option.description ? (
|
||||||
|
<span className="flex min-w-0 flex-col items-start gap-px">
|
||||||
|
<span className="truncate font-medium">{option.label}</span>
|
||||||
|
<small className="text-muted-foreground truncate text-xs">
|
||||||
|
{option.description}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
option.label
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryLine({ label, value }: { label: string; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-4 text-sm">
|
||||||
|
<dt className="text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="text-foreground min-w-0 text-right font-medium break-words">
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummarySection({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={[
|
||||||
|
"border-border/70 flex min-w-0 flex-col gap-3 rounded-md border p-4",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeading({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h2 id={id} className="text-base font-semibold">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RemoveRowButton({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-destructive justify-self-start sm:justify-self-end"
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OwnerRows({
|
||||||
|
owners,
|
||||||
|
error,
|
||||||
|
onOwnerChange,
|
||||||
|
onAddOwner,
|
||||||
|
onRemoveOwner,
|
||||||
|
}: {
|
||||||
|
owners: OwnerRecord[]
|
||||||
|
error?: string
|
||||||
|
onOwnerChange: (id: string, field: keyof OwnerRecord, value: string) => void
|
||||||
|
onAddOwner: () => void
|
||||||
|
onRemoveOwner: (id: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Field data-invalid={Boolean(error)} className="gap-3 sm:col-span-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{owners.map((owner, index) => (
|
||||||
|
<div
|
||||||
|
key={owner.id}
|
||||||
|
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)_5rem_auto]"
|
||||||
|
>
|
||||||
|
<Field className="gap-1.5">
|
||||||
|
<FieldLabel htmlFor={`${owner.id}-name`} className="sr-only">
|
||||||
|
Owner {index + 1} name
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`${owner.id}-name`}
|
||||||
|
value={owner.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
onOwnerChange(owner.id, "name", event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Owner full name"
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field className="gap-1.5">
|
||||||
|
<FieldLabel htmlFor={`${owner.id}-address`} className="sr-only">
|
||||||
|
Owner {index + 1} address
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`${owner.id}-address`}
|
||||||
|
value={owner.address}
|
||||||
|
onChange={(event) =>
|
||||||
|
onOwnerChange(owner.id, "address", event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Registered address"
|
||||||
|
autoComplete="street-address"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field className="gap-1.5">
|
||||||
|
<FieldLabel htmlFor={`${owner.id}-share`} className="sr-only">
|
||||||
|
Owner {index + 1} share
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`${owner.id}-share`}
|
||||||
|
value={owner.share}
|
||||||
|
onChange={(event) =>
|
||||||
|
onOwnerChange(owner.id, "share", event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Share"
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<RemoveRowButton
|
||||||
|
label={`Remove owner ${index + 1}`}
|
||||||
|
onClick={() => onRemoveOwner(owner.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <FieldError>{error}</FieldError> : null}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-auto"
|
||||||
|
onClick={onAddOwner}
|
||||||
|
>
|
||||||
|
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||||
|
Add owner
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttestationCheckbox({
|
||||||
|
id,
|
||||||
|
checked,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
onCheckedChange,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
checked: boolean
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
error?: string
|
||||||
|
onCheckedChange: (checked: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
data-invalid={Boolean(error)}
|
||||||
|
orientation="horizontal"
|
||||||
|
className="gap-3"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={id}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(nextChecked) => onCheckedChange(nextChecked === true)}
|
||||||
|
aria-invalid={Boolean(error)}
|
||||||
|
/>
|
||||||
|
<FieldContent>
|
||||||
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||||
|
<FieldDescription>{description}</FieldDescription>
|
||||||
|
</FieldContent>
|
||||||
|
{error ? <FieldError>{error}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyStepFields({
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
summary,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
values: WizardValues
|
||||||
|
errors: WizardErrors
|
||||||
|
summary: WizardSummary
|
||||||
|
onValueChange: WizardValueChange
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-6"
|
||||||
|
aria-labelledby="wizard-2-company-title"
|
||||||
|
>
|
||||||
|
<SectionHeading
|
||||||
|
id="wizard-2-company-title"
|
||||||
|
title="Company Details"
|
||||||
|
description="Identify the vendor record before compliance review."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldGroup className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<Field data-invalid={Boolean(errors.companyName)} className="gap-2">
|
||||||
|
<FieldLabelHint
|
||||||
|
htmlFor="wizard-2-company"
|
||||||
|
label="Legal Company Name"
|
||||||
|
hint="Match the registered legal entity name."
|
||||||
|
>
|
||||||
|
Legal Company Name <RequiredMark />
|
||||||
|
</FieldLabelHint>
|
||||||
|
<Input
|
||||||
|
id="wizard-2-company"
|
||||||
|
value={values.companyName}
|
||||||
|
onChange={(event) =>
|
||||||
|
onValueChange("companyName", event.target.value)
|
||||||
|
}
|
||||||
|
aria-invalid={Boolean(errors.companyName)}
|
||||||
|
autoComplete="organization"
|
||||||
|
/>
|
||||||
|
{errors.companyName ? (
|
||||||
|
<FieldError>{errors.companyName}</FieldError>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field className="gap-2">
|
||||||
|
<FieldLabelHint
|
||||||
|
htmlFor="wizard-2-registration"
|
||||||
|
label="Portal Registration"
|
||||||
|
hint="Use the status from the vendor portal."
|
||||||
|
>
|
||||||
|
Portal Registration
|
||||||
|
</FieldLabelHint>
|
||||||
|
<WizardSelectField
|
||||||
|
id="wizard-2-registration"
|
||||||
|
value={values.registrationStatus}
|
||||||
|
options={REGISTRATION_OPTIONS}
|
||||||
|
placeholder="Select status"
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onValueChange("registrationStatus", value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldDescription>{summary.registrationLabel}</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field className="gap-2">
|
||||||
|
<FieldLabel htmlFor="wizard-2-category">Category</FieldLabel>
|
||||||
|
<WizardSelectField<VendorCategory>
|
||||||
|
id="wizard-2-category"
|
||||||
|
value={values.category}
|
||||||
|
options={CATEGORY_OPTIONS}
|
||||||
|
placeholder="Select category"
|
||||||
|
onValueChange={(value) => onValueChange("category", value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field className="gap-2">
|
||||||
|
<FieldLabel htmlFor="wizard-2-country">Country</FieldLabel>
|
||||||
|
<WizardSelectField<VendorCountry>
|
||||||
|
id="wizard-2-country"
|
||||||
|
value={values.country}
|
||||||
|
options={COUNTRY_OPTIONS}
|
||||||
|
placeholder="Select country"
|
||||||
|
onValueChange={(value) => onValueChange("country", value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.contactEmail)} className="gap-2">
|
||||||
|
<FieldLabel htmlFor="wizard-2-email">
|
||||||
|
Vendor Email <RequiredMark />
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="wizard-2-email"
|
||||||
|
value={values.contactEmail}
|
||||||
|
onChange={(event) =>
|
||||||
|
onValueChange("contactEmail", event.target.value)
|
||||||
|
}
|
||||||
|
aria-invalid={Boolean(errors.contactEmail)}
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
{errors.contactEmail ? (
|
||||||
|
<FieldError>{errors.contactEmail}</FieldError>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field className="gap-2">
|
||||||
|
<FieldLabel htmlFor="wizard-2-website">Website</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="wizard-2-website"
|
||||||
|
value={values.website}
|
||||||
|
onChange={(event) => onValueChange("website", event.target.value)}
|
||||||
|
autoComplete="url"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OwnershipStepFields({
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
onValueChange,
|
||||||
|
onOwnerChange,
|
||||||
|
onAddOwner,
|
||||||
|
onRemoveOwner,
|
||||||
|
}: {
|
||||||
|
values: WizardValues
|
||||||
|
errors: WizardErrors
|
||||||
|
onValueChange: WizardValueChange
|
||||||
|
onOwnerChange: (id: string, field: keyof OwnerRecord, value: string) => void
|
||||||
|
onRemoveOwner: (id: string) => void
|
||||||
|
onAddOwner: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-6"
|
||||||
|
aria-labelledby="wizard-2-ownership-title"
|
||||||
|
>
|
||||||
|
<SectionHeading
|
||||||
|
id="wizard-2-ownership-title"
|
||||||
|
title="Compliance"
|
||||||
|
description="Confirm ownership and tax records."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldGroup className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<OwnerRows
|
||||||
|
owners={values.owners}
|
||||||
|
error={errors.owners}
|
||||||
|
onOwnerChange={onOwnerChange}
|
||||||
|
onAddOwner={onAddOwner}
|
||||||
|
onRemoveOwner={onRemoveOwner}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.taxId)} className="gap-2">
|
||||||
|
<FieldLabelHint
|
||||||
|
htmlFor="wizard-2-tax"
|
||||||
|
label="Tax/VAT ID"
|
||||||
|
hint="Enter the identifier finance will validate."
|
||||||
|
>
|
||||||
|
Tax/VAT ID <RequiredMark />
|
||||||
|
</FieldLabelHint>
|
||||||
|
<Input
|
||||||
|
id="wizard-2-tax"
|
||||||
|
value={values.taxId}
|
||||||
|
onChange={(event) => onValueChange("taxId", event.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.taxId)}
|
||||||
|
placeholder="Enter tax number"
|
||||||
|
/>
|
||||||
|
{errors.taxId ? <FieldError>{errors.taxId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
data-invalid={Boolean(errors.incorporationDate)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<FieldLabelHint
|
||||||
|
htmlFor="wizard-2-incorporated"
|
||||||
|
label="Date Incorporated"
|
||||||
|
hint="Use the date from the registration record."
|
||||||
|
>
|
||||||
|
Date Incorporated <RequiredMark />
|
||||||
|
</FieldLabelHint>
|
||||||
|
<Input
|
||||||
|
id="wizard-2-incorporated"
|
||||||
|
value={values.incorporationDate}
|
||||||
|
onChange={(event) =>
|
||||||
|
onValueChange("incorporationDate", event.target.value)
|
||||||
|
}
|
||||||
|
aria-invalid={Boolean(errors.incorporationDate)}
|
||||||
|
placeholder="MM/DD/YYYY"
|
||||||
|
/>
|
||||||
|
{errors.incorporationDate ? (
|
||||||
|
<FieldError>{errors.incorporationDate}</FieldError>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<AttestationCheckbox
|
||||||
|
id="wizard-2-ownership-attested"
|
||||||
|
checked={values.ownershipAttested}
|
||||||
|
label="Ownership reviewed"
|
||||||
|
description="Records match company documents."
|
||||||
|
error={errors.ownershipAttested}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onValueChange("ownershipAttested", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentsStepFields({
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
summary,
|
||||||
|
onValueChange,
|
||||||
|
onDocumentToggle,
|
||||||
|
}: {
|
||||||
|
values: WizardValues
|
||||||
|
errors: WizardErrors
|
||||||
|
summary: WizardSummary
|
||||||
|
onValueChange: WizardValueChange
|
||||||
|
onDocumentToggle: (documentId: DocumentId, checked: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-6"
|
||||||
|
aria-labelledby="wizard-2-documents-title"
|
||||||
|
>
|
||||||
|
<SectionHeading
|
||||||
|
id="wizard-2-documents-title"
|
||||||
|
title="Submit"
|
||||||
|
description="Review files and confirm."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldGroup className="grid gap-6">
|
||||||
|
<Field data-invalid={Boolean(errors.documentIds)} className="gap-3">
|
||||||
|
<FieldLabelHint
|
||||||
|
label="Required Documents"
|
||||||
|
hint="Select files already received from the vendor."
|
||||||
|
>
|
||||||
|
Required Documents
|
||||||
|
</FieldLabelHint>
|
||||||
|
<FieldGroup className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{DOCUMENT_REQUIREMENTS.map((document) => (
|
||||||
|
<FieldLabel key={document.id}>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
checked={values.documentIds.includes(document.id)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onDocumentToggle(document.id, checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldContent>
|
||||||
|
<FieldTitle>{document.title}</FieldTitle>
|
||||||
|
<FieldDescription>{document.description}</FieldDescription>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</FieldLabel>
|
||||||
|
))}
|
||||||
|
</FieldGroup>
|
||||||
|
{errors.documentIds ? (
|
||||||
|
<FieldError>{errors.documentIds}</FieldError>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field className="gap-2">
|
||||||
|
<FieldLabel htmlFor="wizard-2-notes">Reviewer Notes</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
id="wizard-2-notes"
|
||||||
|
value={values.submissionNotes}
|
||||||
|
onChange={(event) =>
|
||||||
|
onValueChange("submissionNotes", event.target.value)
|
||||||
|
}
|
||||||
|
className="min-h-24 resize-none"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<SummarySection title="Vendor">
|
||||||
|
<dl className="flex flex-col gap-2.5">
|
||||||
|
<SummaryLine label="Company" value={values.companyName} />
|
||||||
|
<SummaryLine label="Category" value={summary.categoryLabel} />
|
||||||
|
<SummaryLine label="Country" value={summary.countryLabel} />
|
||||||
|
</dl>
|
||||||
|
</SummarySection>
|
||||||
|
|
||||||
|
<SummarySection title="Compliance">
|
||||||
|
<dl className="flex flex-col gap-2.5">
|
||||||
|
<SummaryLine label="Tax/VAT" value={values.taxId} />
|
||||||
|
<SummaryLine label="Owners" value={values.owners.length} />
|
||||||
|
<SummaryLine label="Status" value="Reviewed" />
|
||||||
|
</dl>
|
||||||
|
</SummarySection>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState, type FormEvent } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
import {
|
||||||
|
Stepper,
|
||||||
|
StepperContent,
|
||||||
|
StepperIndicator,
|
||||||
|
StepperItem,
|
||||||
|
StepperNav,
|
||||||
|
StepperPanel,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperTrigger,
|
||||||
|
} from "@/components/reui/stepper"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import { Spinner } from "@evobgp/ui/components/spinner"
|
||||||
|
import {
|
||||||
|
CATEGORY_OPTIONS,
|
||||||
|
COUNTRY_OPTIONS,
|
||||||
|
DEFAULT_WIZARD_VALUES,
|
||||||
|
REGISTRATION_OPTIONS,
|
||||||
|
WIZARD_STEPS,
|
||||||
|
type DocumentId,
|
||||||
|
type OwnerRecord,
|
||||||
|
type WizardOption,
|
||||||
|
type WizardStepId,
|
||||||
|
type WizardValues,
|
||||||
|
} from "./data"
|
||||||
|
import {
|
||||||
|
CompanyStepFields,
|
||||||
|
DocumentsStepFields,
|
||||||
|
OwnershipStepFields,
|
||||||
|
type WizardErrors,
|
||||||
|
} from "./procurement-vendor-steps"
|
||||||
|
import { CheckIcon, FlagIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function createDefaultValues(): WizardValues {
|
||||||
|
return {
|
||||||
|
...DEFAULT_WIZARD_VALUES,
|
||||||
|
owners: DEFAULT_WIZARD_VALUES.owners.map((owner) => ({ ...owner })),
|
||||||
|
documentIds: [...DEFAULT_WIZARD_VALUES.documentIds],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidEmail(value: string) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionLabel<TValue extends string>(
|
||||||
|
options: WizardOption<TValue>[],
|
||||||
|
value: TValue
|
||||||
|
) {
|
||||||
|
return options.find((option) => option.value === value)?.label ?? "Not set"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStepErrors(stepId: WizardStepId, values: WizardValues) {
|
||||||
|
const nextErrors: WizardErrors = {}
|
||||||
|
|
||||||
|
if (stepId === "company") {
|
||||||
|
if (!values.companyName.trim()) {
|
||||||
|
nextErrors.companyName = "Enter the legal company name."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidEmail(values.contactEmail)) {
|
||||||
|
nextErrors.contactEmail = "Enter a valid procurement email."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepId === "ownership") {
|
||||||
|
if (!values.taxId.trim()) {
|
||||||
|
nextErrors.taxId = "Enter the Tax/VAT ID."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.incorporationDate.trim()) {
|
||||||
|
nextErrors.incorporationDate = "Enter the incorporation date."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.owners.some((owner) => owner.name.trim())) {
|
||||||
|
nextErrors.owners = "Add at least one beneficial owner."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.ownershipAttested) {
|
||||||
|
nextErrors.ownershipAttested = "Confirm beneficial ownership review."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepId === "documents" && values.documentIds.length < 2) {
|
||||||
|
nextErrors.documentIds = "Select at least two received documents."
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllRequiredErrors(values: WizardValues) {
|
||||||
|
return {
|
||||||
|
...getStepErrors("company", values),
|
||||||
|
...getStepErrors("ownership", values),
|
||||||
|
...getStepErrors("documents", values),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstInvalidStep(values: WizardValues) {
|
||||||
|
if (Object.keys(getStepErrors("company", values)).length > 0) return 1
|
||||||
|
if (Object.keys(getStepErrors("ownership", values)).length > 0) return 2
|
||||||
|
if (Object.keys(getStepErrors("documents", values)).length > 0) return 3
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function DotSeparator() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="size-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcurementVendorWizard() {
|
||||||
|
const [currentStep, setCurrentStep] = useState(2)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [values, setValues] = useState<WizardValues>(createDefaultValues)
|
||||||
|
const [errors, setErrors] = useState<WizardErrors>({})
|
||||||
|
|
||||||
|
const currentStepConfig = useMemo(
|
||||||
|
() =>
|
||||||
|
WIZARD_STEPS.find((step) => step.step === currentStep) ?? WIZARD_STEPS[0],
|
||||||
|
[currentStep]
|
||||||
|
)
|
||||||
|
|
||||||
|
const summary = useMemo(
|
||||||
|
() => ({
|
||||||
|
categoryLabel: getOptionLabel(CATEGORY_OPTIONS, values.category),
|
||||||
|
countryLabel: getOptionLabel(COUNTRY_OPTIONS, values.country),
|
||||||
|
registrationLabel: getOptionLabel(
|
||||||
|
REGISTRATION_OPTIONS,
|
||||||
|
values.registrationStatus
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[values.category, values.country, values.registrationStatus]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleValueChange = useCallback(
|
||||||
|
(field: keyof WizardValues, value: WizardValues[keyof WizardValues]) => {
|
||||||
|
setValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[field]: value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setErrors((current) => {
|
||||||
|
if (!current[field]) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextErrors = { ...current }
|
||||||
|
delete nextErrors[field]
|
||||||
|
return nextErrors
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOwnerChange = useCallback(
|
||||||
|
(id: string, field: keyof OwnerRecord, value: string) => {
|
||||||
|
setValues((current) => ({
|
||||||
|
...current,
|
||||||
|
owners: current.owners.map((owner) =>
|
||||||
|
owner.id === id ? { ...owner, [field]: value } : owner
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
setErrors((current) => {
|
||||||
|
if (!current.owners) return current
|
||||||
|
const nextErrors = { ...current }
|
||||||
|
delete nextErrors.owners
|
||||||
|
return nextErrors
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddOwner = useCallback(() => {
|
||||||
|
setValues((current) => ({
|
||||||
|
...current,
|
||||||
|
owners: [
|
||||||
|
...current.owners,
|
||||||
|
{
|
||||||
|
id: `owner-${Date.now()}`,
|
||||||
|
name: "",
|
||||||
|
address: "",
|
||||||
|
share: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRemoveOwner = useCallback((id: string) => {
|
||||||
|
setValues((current) => ({
|
||||||
|
...current,
|
||||||
|
owners: current.owners.filter((owner) => owner.id !== id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
setErrors((current) => {
|
||||||
|
if (!current.owners) return current
|
||||||
|
const nextErrors = { ...current }
|
||||||
|
delete nextErrors.owners
|
||||||
|
return nextErrors
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDocumentToggle = useCallback(
|
||||||
|
(documentId: DocumentId, checked: boolean) => {
|
||||||
|
setValues((current) => ({
|
||||||
|
...current,
|
||||||
|
documentIds: checked
|
||||||
|
? Array.from(new Set([...current.documentIds, documentId]))
|
||||||
|
: current.documentIds.filter((id) => id !== documentId),
|
||||||
|
}))
|
||||||
|
|
||||||
|
setErrors((current) => {
|
||||||
|
if (!current.documentIds) return current
|
||||||
|
const nextErrors = { ...current }
|
||||||
|
delete nextErrors.documentIds
|
||||||
|
return nextErrors
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
setCurrentStep((step) => Math.max(1, step - 1))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (isSubmitting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStepConfig.id !== "documents") {
|
||||||
|
const stepErrors = getStepErrors(currentStepConfig.id, values)
|
||||||
|
|
||||||
|
if (Object.keys(stepErrors).length > 0) {
|
||||||
|
setErrors((current) => ({ ...current, ...stepErrors }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStep = Math.min(currentStep + 1, WIZARD_STEPS.length)
|
||||||
|
setCurrentStep(nextStep)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredErrors = getAllRequiredErrors(values)
|
||||||
|
const invalidStep = getFirstInvalidStep(values)
|
||||||
|
|
||||||
|
if (Object.keys(requiredErrors).length > 0 && invalidStep !== null) {
|
||||||
|
setErrors(requiredErrors)
|
||||||
|
setCurrentStep(invalidStep)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
toast.success("Vendor file submitted", {
|
||||||
|
description: `${values.companyName} is ready for procurement review.`,
|
||||||
|
})
|
||||||
|
}, 900)
|
||||||
|
},
|
||||||
|
[currentStep, currentStepConfig.id, isSubmitting, values]
|
||||||
|
)
|
||||||
|
|
||||||
|
const isLastStep = currentStep === WIZARD_STEPS.length
|
||||||
|
const canGoBack = currentStep > 1 && !isSubmitting
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex w-full max-w-2xl flex-col"
|
||||||
|
aria-label="Procurement vendor onboarding wizard"
|
||||||
|
>
|
||||||
|
<Stepper
|
||||||
|
value={currentStep}
|
||||||
|
onValueChange={setCurrentStep}
|
||||||
|
indicators={{
|
||||||
|
completed: (
|
||||||
|
<CheckIcon className="size-3.5" aria-hidden="true" />
|
||||||
|
),
|
||||||
|
loading: <Spinner className="size-3.5" />,
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Frame stacked={true} className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
|
<FrameTitle>Procurement Vendor Setup</FrameTitle>
|
||||||
|
<FrameDescription className="inline-flex min-w-0 items-center gap-1.5 text-sm">
|
||||||
|
<span className="shrink-0">{values.portalId}</span>
|
||||||
|
<DotSeparator />
|
||||||
|
<span className="truncate">{summary.categoryLabel}</span>
|
||||||
|
</FrameDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="warning-light">Review file</Badge>
|
||||||
|
</div>
|
||||||
|
</FrameHeader>
|
||||||
|
|
||||||
|
<FramePanel className="flex items-center overflow-x-auto">
|
||||||
|
<StepperNav
|
||||||
|
aria-label="Wizard progress"
|
||||||
|
className="min-w-full items-center"
|
||||||
|
>
|
||||||
|
{WIZARD_STEPS.map((step, index) => (
|
||||||
|
<StepperItem
|
||||||
|
key={step.id}
|
||||||
|
step={step.step}
|
||||||
|
loading={isSubmitting && step.step === currentStep}
|
||||||
|
className="relative min-w-0"
|
||||||
|
>
|
||||||
|
<StepperTrigger
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 justify-start gap-2 disabled:opacity-100"
|
||||||
|
>
|
||||||
|
<StepperIndicator className="data-[state=active]:bg-primary/10 data-[state=active]:text-primary data-[state=completed]:bg-primary data-[state=inactive]:border-border data-[state=inactive]:text-foreground data-[state=active]:before:border-primary relative isolate size-6 overflow-visible rounded-full border before:pointer-events-none before:absolute before:inset-0 before:z-10 before:rounded-full before:border before:border-dashed before:border-transparent before:content-[''] data-[state=active]:border-0 data-[state=active]:before:animate-[spin_8s_linear_infinite] motion-reduce:data-[state=active]:before:animate-none">
|
||||||
|
{index + 1}
|
||||||
|
</StepperIndicator>
|
||||||
|
<StepperTitle className="text-foreground hidden truncate text-sm capitalize sm:block">
|
||||||
|
{step.title}
|
||||||
|
</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
|
||||||
|
{WIZARD_STEPS.length > index + 1 ? (
|
||||||
|
<StepperSeparator className="bg-border group-data-[state=completed]/step:bg-primary mx-2" />
|
||||||
|
) : null}
|
||||||
|
</StepperItem>
|
||||||
|
))}
|
||||||
|
</StepperNav>
|
||||||
|
</FramePanel>
|
||||||
|
|
||||||
|
<FramePanel>
|
||||||
|
<StepperPanel className="text-sm">
|
||||||
|
{WIZARD_STEPS.map((step) => (
|
||||||
|
<StepperContent key={step.id} value={step.step}>
|
||||||
|
{step.id === "company" ? (
|
||||||
|
<CompanyStepFields
|
||||||
|
values={values}
|
||||||
|
errors={errors}
|
||||||
|
summary={summary}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step.id === "ownership" ? (
|
||||||
|
<OwnershipStepFields
|
||||||
|
values={values}
|
||||||
|
errors={errors}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
onOwnerChange={handleOwnerChange}
|
||||||
|
onAddOwner={handleAddOwner}
|
||||||
|
onRemoveOwner={handleRemoveOwner}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step.id === "documents" ? (
|
||||||
|
<DocumentsStepFields
|
||||||
|
values={values}
|
||||||
|
errors={errors}
|
||||||
|
summary={summary}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
onDocumentToggle={handleDocumentToggle}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</StepperContent>
|
||||||
|
))}
|
||||||
|
</StepperPanel>
|
||||||
|
</FramePanel>
|
||||||
|
|
||||||
|
<FrameFooter className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<FrameDescription className="hidden min-w-0 items-center gap-1.5 text-sm sm:flex">
|
||||||
|
<FlagIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||||
|
{currentStepConfig.description}
|
||||||
|
</FrameDescription>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
|
||||||
|
{canGoBack ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Spinner data-icon="inline-start" /> : null}
|
||||||
|
{isLastStep ? "Submit" : "Next Step"}
|
||||||
|
{!isLastStep && !isSubmitting ? (
|
||||||
|
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FrameFooter>
|
||||||
|
</Frame>
|
||||||
|
</Stepper>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ProcurementVendorWizard } from "./components/procurement-vendor-wizard"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 lg:p-10">
|
||||||
|
<ProcurementVendorWizard />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,70 +1,47 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate } from '@tanstack/react-router'
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import {
|
import { BoxesIcon, PlusIcon, SearchIcon } from 'lucide-react'
|
||||||
ArrowUpDownIcon,
|
|
||||||
BoxesIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
FilterIcon,
|
|
||||||
PlusIcon,
|
|
||||||
SearchIcon,
|
|
||||||
XIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridNameCell } 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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
|
||||||
import {
|
import {
|
||||||
DataGridTable,
|
ResourcePage,
|
||||||
DataGridTableHeader,
|
createTextFilterQuery,
|
||||||
} from '@/components/reui/data-grid/data-grid-table'
|
type DataGridColumnDef,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
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 { moduleTypeRu } from '@/lib/ui-labels'
|
||||||
import type { ModuleRow } from '@/types/api'
|
import type { ModuleRow } from '@/types/api'
|
||||||
import {
|
|
||||||
getCoreRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type PaginationState,
|
|
||||||
type SortingState,
|
|
||||||
} from '@tanstack/react-table'
|
|
||||||
|
|
||||||
type ModuleSort = 'name' | 'type' | 'priority'
|
const MODULE_TABS = [
|
||||||
type EnabledFilter = 'all' | 'enabled' | 'disabled'
|
{ id: 'all', label: 'Все' },
|
||||||
|
{ id: 'enabled', label: 'Вкл' },
|
||||||
|
{ id: 'disabled', label: 'Выкл' },
|
||||||
|
]
|
||||||
|
|
||||||
const sortLabels: Record<ModuleSort, string> = {
|
const filterFields: FilterField[] = [
|
||||||
name: 'Название',
|
{
|
||||||
type: 'Тип',
|
id: 'search',
|
||||||
priority: 'Приоритет',
|
label: 'Поиск',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Поиск модулей…',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function getFilterFieldValue(item: ModuleRow, field: string): unknown {
|
||||||
|
if (field === 'search') {
|
||||||
|
return `${item.name} ${item.type} ${moduleTypeRu(item.type)}`
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
|
function tabFilter(item: ModuleRow, tabId: string): boolean {
|
||||||
|
if (tabId === 'enabled') return item.enabled !== false
|
||||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
if (tabId === 'disabled') return item.enabled === false
|
||||||
return [{ id: sortBy, desc: false }]
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardModulesGrid({
|
export function DashboardModulesGrid({
|
||||||
@@ -75,53 +52,17 @@ export function DashboardModulesGrid({
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>('all')
|
createTextFilterQuery('search'),
|
||||||
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>[]>(
|
const columns = useMemo<DataGridColumnDef<ModuleRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
id: 'name',
|
id: 'name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <DataGridNameCell icon={BoxesIcon} title={row.original.name} />,
|
||||||
<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,
|
minSize: 180,
|
||||||
meta: { headerTitle: 'Модуль' },
|
meta: { headerTitle: 'Модуль' },
|
||||||
},
|
},
|
||||||
@@ -146,188 +87,31 @@ export function DashboardModulesGrid({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<DashboardFramePanel
|
<ResourcePage
|
||||||
title="Модули"
|
title="Модули"
|
||||||
description="Поиск, сортировка и быстрый переход к настройке"
|
description="Поиск и быстрый переход к настройке"
|
||||||
className="min-w-0"
|
tabs={MODULE_TABS}
|
||||||
actions={
|
tabFilter={tabFilter}
|
||||||
|
filterFields={filterFields}
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
onFilterQueryChange={setFilterQuery}
|
||||||
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||||
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
|
columns={columns}
|
||||||
|
data={modules}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
isLoading={isLoading}
|
||||||
|
primaryAction={
|
||||||
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
|
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
onRowClick={(row) =>
|
||||||
<DataGrid
|
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })
|
||||||
table={table}
|
}
|
||||||
recordCount={filteredModules.length}
|
emptyState={{ title: 'Нет модулей по выбранным фильтрам.' }}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import { ListTodo } from 'lucide-react'
|
||||||
|
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridShell } from '@/components/data-grid-shell'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
|
||||||
import { jobKindRu } from '@/lib/ui-labels'
|
import { jobKindRu } from '@/lib/ui-labels'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
export function DashboardRecentJobsGrid({
|
export function DashboardRecentJobsGrid({
|
||||||
@@ -21,13 +19,14 @@ export function DashboardRecentJobsGrid({
|
|||||||
}) {
|
}) {
|
||||||
const data = useMemo(() => jobs.slice(0, 8), [jobs])
|
const data = useMemo(() => jobs.slice(0, 8), [jobs])
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<JobRow>[]>(
|
const columns = useMemo<DataGridColumnDef<JobRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridNameCell
|
||||||
|
icon={ListTodo}
|
||||||
title={jobKindRu(row.original.kind)}
|
title={jobKindRu(row.original.kind)}
|
||||||
subtitle={
|
subtitle={
|
||||||
row.original.meta?.module_id
|
row.original.meta?.module_id
|
||||||
@@ -48,27 +47,16 @@ export function DashboardRecentJobsGrid({
|
|||||||
[nameById],
|
[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 (
|
return (
|
||||||
<DataGridShell
|
<FrameDataGrid
|
||||||
table={table}
|
title="Недавние задачи"
|
||||||
recordCount={filteredCount}
|
description="Последние фоновые операции"
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
rowId={(row) => row.job_id}
|
||||||
|
emptyTitle="Нет задач"
|
||||||
|
pagination={false}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет задач"
|
|
||||||
showPagination={false}
|
|
||||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import { GitCommitHorizontal } from 'lucide-react'
|
||||||
|
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridShell } from '@/components/data-grid-shell'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import type { RevisionRow } from '@/types/api'
|
import type { RevisionRow } from '@/types/api'
|
||||||
|
|
||||||
export function DashboardRecentRevisionsGrid({
|
export function DashboardRecentRevisionsGrid({
|
||||||
@@ -17,14 +15,14 @@ export function DashboardRecentRevisionsGrid({
|
|||||||
}) {
|
}) {
|
||||||
const data = useMemo(() => revisions.slice(0, 8), [revisions])
|
const data = useMemo(() => revisions.slice(0, 8), [revisions])
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<RevisionRow>[]>(
|
const columns = useMemo<DataGridColumnDef<RevisionRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: 'id',
|
id: 'id',
|
||||||
accessorFn: (row) => row.id,
|
accessorFn: (row) => row.id,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell title={`${row.original.id.slice(0, 10)}…`} accent="mono" />
|
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 10)}…`} />
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'ID' },
|
meta: { headerTitle: 'ID' },
|
||||||
},
|
},
|
||||||
@@ -42,22 +40,16 @@ export function DashboardRecentRevisionsGrid({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, filteredCount } = useClientDataGrid({
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) => row.id,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
pageSize: 8,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridShell
|
<FrameDataGrid
|
||||||
table={table}
|
title="Последние ревизии"
|
||||||
recordCount={filteredCount}
|
description="История конфигураций"
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
rowId={(row) => row.id}
|
||||||
|
emptyTitle="Нет ревизий"
|
||||||
|
pagination={false}
|
||||||
isLoading={isLoading}
|
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 type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
const ACCENT_CLASS = {
|
/**
|
||||||
primary: 'font-medium text-primary',
|
* Name cell DNA — IconTile elevated size-10.5 + truncate.
|
||||||
default: 'font-medium text-foreground',
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
mono: 'font-mono text-sm text-primary',
|
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||||
} as const
|
*/
|
||||||
|
export function DataGridNameCell({
|
||||||
export function DataGridPrimaryCell({
|
icon: Icon,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
accent = 'default',
|
iconClassName = 'text-muted-foreground',
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
|
icon: LucideIcon
|
||||||
title: ReactNode
|
title: ReactNode
|
||||||
subtitle?: ReactNode
|
subtitle?: ReactNode
|
||||||
accent?: keyof typeof ACCENT_CLASS
|
iconClassName?: string
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-w-0 flex-col gap-0.5', className)}>
|
<div className={cn('flex min-w-0 items-center gap-2.5', className)}>
|
||||||
<span className={cn('truncate', ACCENT_CLASS[accent])}>{title}</span>
|
<IconTile variant="elevated" className="size-10.5">
|
||||||
{subtitle ? (
|
<Icon className={iconClassName} aria-hidden />
|
||||||
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
|
</IconTile>
|
||||||
) : null}
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -42,3 +50,16 @@ export function DataGridMutedCell({
|
|||||||
</span>
|
</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, useState, type ReactNode } from 'react'
|
||||||
import { useMemo } from 'react'
|
import { Tags } from 'lucide-react'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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'
|
import type { BgpCommunity } from '@/types/api'
|
||||||
|
|
||||||
|
const filterFields: FilterField[] = [
|
||||||
|
createSearchFilterField('search', 'Поиск', 'Поиск community…'),
|
||||||
|
]
|
||||||
|
|
||||||
export function DirectoriesCommunitiesGrid({
|
export function DirectoriesCommunitiesGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
canWrite = false,
|
canWrite = false,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
items: BgpCommunity[]
|
items: BgpCommunity[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
canWrite?: boolean
|
canWrite?: boolean
|
||||||
onEdit?: (row: BgpCommunity) => void
|
onEdit?: (row: BgpCommunity) => void
|
||||||
|
actions?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const columns = useMemo<ColumnDef<BgpCommunity>[]>(() => {
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
const cols: ColumnDef<BgpCommunity>[] = [
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumnDef<BgpCommunity>[]>(() => {
|
||||||
|
const cols: DataGridColumnDef<BgpCommunity>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} 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: 'Название' },
|
meta: { headerTitle: 'Название' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'community',
|
accessorKey: 'community',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <DataGridMonoCell>{row.original.community}</DataGridMonoCell>,
|
||||||
<DataGridPrimaryCell title={row.original.community} accent="mono" />
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Значение' },
|
meta: { headerTitle: 'Значение' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -51,9 +64,7 @@ export function DirectoriesCommunitiesGrid({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <DirectoriesRowActions onEdit={() => onEdit(row.original)} />,
|
||||||
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Действия' },
|
meta: { headerTitle: 'Действия' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -61,22 +72,22 @@ export function DirectoriesCommunitiesGrid({
|
|||||||
return cols
|
return cols
|
||||||
}, [canWrite, onEdit])
|
}, [canWrite, onEdit])
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: items,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) => `${row.title} ${row.community}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title="BGP community"
|
||||||
recordCount={filteredCount}
|
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}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет community"
|
primaryAction={actions}
|
||||||
searchValue={globalFilter}
|
pinLastColumn={Boolean(canWrite && onEdit)}
|
||||||
onSearchChange={setGlobalFilter}
|
emptyState={{ title: 'Нет community', action: actions }}
|
||||||
searchPlaceholder="Поиск community…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,51 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import { useMemo } from 'react'
|
import { Globe } from 'lucide-react'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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'
|
import type { DohProfile } from '@/types/api'
|
||||||
|
|
||||||
|
const filterFields: FilterField[] = [
|
||||||
|
createSearchFilterField('search', 'Поиск', 'Поиск DoH профилей…'),
|
||||||
|
]
|
||||||
|
|
||||||
export function DirectoriesDohGrid({
|
export function DirectoriesDohGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
canWrite = false,
|
canWrite = false,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
items: DohProfile[]
|
items: DohProfile[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
canWrite?: boolean
|
canWrite?: boolean
|
||||||
onEdit?: (row: DohProfile) => void
|
onEdit?: (row: DohProfile) => void
|
||||||
|
actions?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const columns = useMemo<ColumnDef<DohProfile>[]>(() => {
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
const cols: ColumnDef<DohProfile>[] = [
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumnDef<DohProfile>[]>(() => {
|
||||||
|
const cols: DataGridColumnDef<DohProfile>[] = [
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row) => row.name ?? row.url,
|
accessorFn: (row) => row.name ?? row.url,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridNameCell
|
||||||
|
icon={Globe}
|
||||||
title={row.original.name ?? row.original.url}
|
title={row.original.name ?? row.original.url}
|
||||||
subtitle={row.original.name ? row.original.url : undefined}
|
subtitle={row.original.name ? row.original.url : undefined}
|
||||||
accent="primary"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Название' },
|
meta: { headerTitle: 'Название' },
|
||||||
@@ -38,7 +53,7 @@ export function DirectoriesDohGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'url',
|
accessorKey: 'url',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="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' },
|
meta: { headerTitle: 'URL' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -56,9 +71,7 @@ export function DirectoriesDohGrid({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <DirectoriesRowActions onEdit={() => onEdit(row.original)} />,
|
||||||
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Действия' },
|
meta: { headerTitle: 'Действия' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -66,22 +79,22 @@ export function DirectoriesDohGrid({
|
|||||||
return cols
|
return cols
|
||||||
}, [canWrite, onEdit])
|
}, [canWrite, onEdit])
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: items,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) => `${row.name ?? ''} ${row.url}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title="DoH профили"
|
||||||
recordCount={filteredCount}
|
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}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет DoH профилей"
|
primaryAction={actions}
|
||||||
searchValue={globalFilter}
|
pinLastColumn={Boolean(canWrite && onEdit)}
|
||||||
onSearchChange={setGlobalFilter}
|
emptyState={{ title: 'Нет DoH профилей', action: actions }}
|
||||||
searchPlaceholder="Поиск DoH профилей…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ import { toast } from 'sonner'
|
|||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||||
|
|
||||||
import { CommunitySelect } from '@/components/modules/community-select'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
} from '@/components/reui/alert'
|
} 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 { ApiError, apiMutate } from '@/lib/api-client'
|
||||||
import type {
|
import type {
|
||||||
BgpCommunity,
|
BgpCommunity,
|
||||||
@@ -22,8 +28,10 @@ import type {
|
|||||||
} from '@/types/api'
|
} 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/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 {
|
function hostPrefixFromIp(ip: string): string {
|
||||||
@@ -34,6 +42,31 @@ function moduleTypeForKind(kind: LookupQueryKind): ModuleRow['type'] {
|
|||||||
return kind === 'domain' ? 'DOMAINS' : 'IP_RANGES'
|
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({
|
export function LookupAddStep({
|
||||||
data,
|
data,
|
||||||
modules,
|
modules,
|
||||||
@@ -48,49 +81,58 @@ export function LookupAddStep({
|
|||||||
onAdded: () => void | Promise<void>
|
onAdded: () => void | Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const wantedType = moduleTypeForKind(data.query_kind)
|
const wantedType = moduleTypeForKind(data.query_kind)
|
||||||
|
const allowEmptyCommunity = data.query_kind === 'domain'
|
||||||
const eligible = useMemo(
|
const eligible = useMemo(
|
||||||
() => modules.filter((m) => m.type === wantedType),
|
() => modules.filter((m) => m.type === wantedType),
|
||||||
[modules, wantedType],
|
[modules, wantedType],
|
||||||
)
|
)
|
||||||
|
|
||||||
const [moduleId, setModuleId] = useState('')
|
const [pick, setPick] = useState('')
|
||||||
const [communityId, setCommunityId] = useState<string | null>(null)
|
|
||||||
const [saving, setSaving] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (eligible.length === 0) {
|
if (eligible.length === 0) {
|
||||||
setModuleId('')
|
setPick('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setModuleId((prev) =>
|
const first = eligible[0]!
|
||||||
prev && eligible.some((m) => m.id === prev) ? prev : eligible[0]!.id,
|
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])
|
}, [eligible])
|
||||||
|
|
||||||
useEffect(() => {
|
const parsed = pick ? parsePick(pick) : null
|
||||||
const mod = eligible.find((m) => m.id === moduleId)
|
const moduleId = parsed?.moduleId ?? ''
|
||||||
if (!mod) {
|
const communityId = parsed?.communityId ?? null
|
||||||
setCommunityId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setCommunityId(mod.default_community_id ?? null)
|
|
||||||
}, [moduleId, eligible])
|
|
||||||
|
|
||||||
const moduleItems = useMemo(
|
|
||||||
() =>
|
|
||||||
eligible.map((m) => ({
|
|
||||||
value: m.id,
|
|
||||||
label: m.name,
|
|
||||||
})),
|
|
||||||
[eligible],
|
|
||||||
)
|
|
||||||
|
|
||||||
async function handleAdd() {
|
async function handleAdd() {
|
||||||
if (!moduleId) {
|
if (!moduleId) {
|
||||||
toast.error('Выберите модуль')
|
toast.error('Выберите модуль')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (data.query_kind !== 'domain' && !communityId) {
|
if (!allowEmptyCommunity && !communityId) {
|
||||||
toast.error('Укажите community')
|
toast.error('Укажите community')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -151,28 +193,34 @@ export function LookupAddStep({
|
|||||||
«{valueLabel}» отсутствует в списках. Выберите модуль и community.
|
«{valueLabel}» отсутствует в списках. Выберите модуль и community.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<Field>
|
||||||
<Field>
|
<FieldLabel htmlFor="lookup-add-cascader">Модуль и community</FieldLabel>
|
||||||
<FieldLabel htmlFor="lookup-add-module">Модуль ({wantedType})</FieldLabel>
|
<Cascader
|
||||||
<SelectMenu
|
items={cascaderItems}
|
||||||
id="lookup-add-module"
|
value={pick}
|
||||||
items={moduleItems}
|
onValueChange={setPick}
|
||||||
value={moduleId}
|
selectable={allowEmptyCommunity ? 'any' : 'leaf'}
|
||||||
placeholder="Выберите модуль"
|
mode="columns"
|
||||||
onValueChange={(v) => {
|
labels={{
|
||||||
if (v) setModuleId(v)
|
search: 'Поиск',
|
||||||
}}
|
empty: 'Ничего не найдено',
|
||||||
/>
|
back: 'Назад',
|
||||||
</Field>
|
rootLevel: 'Модули',
|
||||||
<CommunitySelect
|
}}
|
||||||
id="lookup-add-comm"
|
>
|
||||||
label="Community"
|
<CascaderTrigger
|
||||||
value={communityId}
|
id="lookup-add-cascader"
|
||||||
onValueChange={setCommunityId}
|
className="w-full"
|
||||||
communities={communities}
|
aria-label="Модуль и community"
|
||||||
nullable={data.query_kind === 'domain'}
|
>
|
||||||
/>
|
<CascaderValue placeholder="Выберите модуль и community" display="path" />
|
||||||
</div>
|
</CascaderTrigger>
|
||||||
|
<CascaderContent>
|
||||||
|
<CascaderInput />
|
||||||
|
<CascaderPanel />
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
</Field>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
|
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
Назад
|
Назад
|
||||||
@@ -180,7 +228,7 @@ export function LookupAddStep({
|
|||||||
<LoadingButton
|
<LoadingButton
|
||||||
loading={saving}
|
loading={saving}
|
||||||
onClick={() => void handleAdd()}
|
onClick={() => void handleAdd()}
|
||||||
disabled={!moduleId || (data.query_kind !== 'domain' && !communityId)}
|
disabled={!moduleId || (!allowEmptyCommunity && !communityId)}
|
||||||
>
|
>
|
||||||
Добавить
|
Добавить
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState } from 'react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { Boxes, SearchIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMonoCell, DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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'
|
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/wizard-2
|
||||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
* @see https://reui.io/docs/components/base/badge
|
* @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({
|
export function LookupMatchesGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -24,8 +38,11 @@ export function LookupMatchesGrid({
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<LookupMatch>[]>(
|
const columns = useMemo<DataGridColumnDef<LookupMatch>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'layer',
|
accessorKey: 'layer',
|
||||||
@@ -44,10 +61,10 @@ export function LookupMatchesGrid({
|
|||||||
accessorKey: 'module_name',
|
accessorKey: 'module_name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridNameCell
|
||||||
|
icon={Boxes}
|
||||||
title={row.original.module_name}
|
title={row.original.module_name}
|
||||||
subtitle={row.original.module_type}
|
subtitle={row.original.module_type}
|
||||||
accent="primary"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Модуль' },
|
meta: { headerTitle: 'Модуль' },
|
||||||
@@ -56,15 +73,14 @@ export function LookupMatchesGrid({
|
|||||||
accessorKey: 'matched_value',
|
accessorKey: 'matched_value',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
title={row.original.matched_value}
|
<DataGridMonoCell>{row.original.matched_value}</DataGridMonoCell>
|
||||||
subtitle={
|
<DataGridMutedCell>
|
||||||
row.original.resolved_ip
|
{row.original.resolved_ip
|
||||||
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
|
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
|
||||||
: row.original.match_kind
|
: row.original.match_kind}
|
||||||
}
|
</DataGridMutedCell>
|
||||||
accent="mono"
|
</div>
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Совпадение' },
|
meta: { headerTitle: 'Совпадение' },
|
||||||
},
|
},
|
||||||
@@ -76,13 +92,15 @@ export function LookupMatchesGrid({
|
|||||||
const title = row.original.community_title?.trim()
|
const title = row.original.community_title?.trim()
|
||||||
const value = row.original.community?.trim()
|
const value = row.original.community?.trim()
|
||||||
if (!title && !value) {
|
if (!title && !value) {
|
||||||
return <span className="text-muted-foreground text-sm">—</span>
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<DataGridPrimaryCell
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
title={title || value || '—'}
|
<DataGridMonoCell>{title || value || '—'}</DataGridMonoCell>
|
||||||
subtitle={title && value && title !== value ? value : undefined}
|
{title && value && title !== value ? (
|
||||||
/>
|
<DataGridMutedCell>{value}</DataGridMutedCell>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
meta: { headerTitle: 'Community' },
|
meta: { headerTitle: 'Community' },
|
||||||
@@ -95,7 +113,7 @@ export function LookupMatchesGrid({
|
|||||||
row.original.source ? (
|
row.original.source ? (
|
||||||
<CategoryBadge>{row.original.source}</CategoryBadge>
|
<CategoryBadge>{row.original.source}</CategoryBadge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">—</span>
|
<DataGridMutedCell>—</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Источник' },
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<ResourcePage
|
||||||
<div className="flex flex-col gap-px">
|
title="Совпадения"
|
||||||
<h3 className="text-sm font-semibold">Совпадения</h3>
|
description="Entries и snapshots · клик по строке открывает модуль"
|
||||||
<p className="text-muted-foreground text-sm">
|
hideHeader
|
||||||
Entries и snapshots · клик по строке открывает модуль
|
filterFields={filterFields}
|
||||||
</p>
|
filterQuery={filterQuery}
|
||||||
</div>
|
onFilterQueryChange={setFilterQuery}
|
||||||
<DataGridSection
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||||
table={table}
|
getFilterFieldValue={(item, field) => {
|
||||||
recordCount={filteredCount}
|
if (field !== 'search') return undefined
|
||||||
isLoading={isLoading}
|
return `${item.layer} ${item.module_name} ${item.module_type} ${item.matched_value} ${item.community ?? ''} ${item.community_title ?? ''} ${item.source ?? ''}`
|
||||||
emptyMessage="Нет совпадений"
|
}}
|
||||||
searchValue={globalFilter}
|
columns={columns}
|
||||||
onSearchChange={setGlobalFilter}
|
data={items}
|
||||||
searchPlaceholder="Фильтр совпадений…"
|
getRowId={(row) =>
|
||||||
onRowClick={(row) =>
|
`${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`
|
||||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
}
|
||||||
}
|
isLoading={isLoading}
|
||||||
/>
|
onRowClick={(row) =>
|
||||||
</div>
|
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
||||||
|
}
|
||||||
|
emptyState={{ title: 'Нет совпадений' }}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { Pencil, SearchIcon, Trash2 } from 'lucide-react'
|
||||||
import { Pencil, Trash2 } from 'lucide-react'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMonoCell, DataGridMutedCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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 { formatDateTime } from '@/lib/modules/display'
|
||||||
import { communityLabel } from '@/lib/modules/helpers'
|
import { communityLabel } from '@/lib/modules/helpers'
|
||||||
import { cdnSourceKindRu } from '@/lib/ui-labels'
|
import { cdnSourceKindRu } from '@/lib/ui-labels'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import type {
|
import type {
|
||||||
AsEntry,
|
AsEntry,
|
||||||
BgpCommunity,
|
BgpCommunity,
|
||||||
@@ -27,6 +30,18 @@ type DeleteTarget =
|
|||||||
| { kind: 'cdn'; entry: CdnSource }
|
| { kind: 'cdn'; entry: CdnSource }
|
||||||
| { kind: 'as'; entry: AsEntry }
|
| { 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 }) {
|
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-1">
|
<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({
|
export function ModuleEntriesGrid({
|
||||||
mod,
|
mod,
|
||||||
rows,
|
rows,
|
||||||
@@ -54,6 +75,14 @@ export function ModuleEntriesGrid({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
isError = false,
|
||||||
|
error = null,
|
||||||
|
onRetry,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
emptyTitle = 'Нет записей',
|
||||||
|
emptyDescription,
|
||||||
}: {
|
}: {
|
||||||
mod: ModuleRow
|
mod: ModuleRow
|
||||||
rows: Record<string, unknown>[]
|
rows: Record<string, unknown>[]
|
||||||
@@ -61,198 +90,207 @@ export function ModuleEntriesGrid({
|
|||||||
onEdit: (target: DeleteTarget) => void
|
onEdit: (target: DeleteTarget) => void
|
||||||
onDelete: (target: DeleteTarget) => void
|
onDelete: (target: DeleteTarget) => void
|
||||||
isLoading?: boolean
|
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') {
|
if (mod.type === 'DOMAINS') {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'fqdn',
|
accessorKey: 'fqdn',
|
||||||
header: ({ column }: { column: { id: string } }) => (
|
header: ({ column }) => <DataGridColumnHeader column={column} title="FQDN" />,
|
||||||
<DataGridColumnHeader column={column as never} title="FQDN" />
|
cell: ({ row }) => (
|
||||||
),
|
<DataGridMonoCell>{(row.original as DomainEntry).fqdn}</DataGridMonoCell>
|
||||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
|
||||||
<DataGridPrimaryCell title={row.original.fqdn} accent="mono" />
|
|
||||||
),
|
),
|
||||||
|
meta: { headerTitle: 'FQDN' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'community',
|
id: 'community',
|
||||||
header: 'Community',
|
header: 'Community',
|
||||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{communityLabel(row.original.community_id, communities)}
|
{communityLabel((row.original as DomainEntry).community_id, communities)}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<RowActions
|
<RowActions
|
||||||
onEdit={() => onEdit({ kind: 'domain', entry: row.original })}
|
onEdit={() => onEdit({ kind: 'domain', entry: row.original as DomainEntry })}
|
||||||
onDelete={() => onDelete({ kind: 'domain', entry: row.original })}
|
onDelete={() => onDelete({ kind: 'domain', entry: row.original as DomainEntry })}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] as ColumnDef<DomainEntry>[]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mod.type === 'IP_RANGES') {
|
if (mod.type === 'IP_RANGES') {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'prefix',
|
accessorKey: 'prefix',
|
||||||
header: ({ column }: { column: { id: string } }) => (
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс (CIDR)" />,
|
||||||
<DataGridColumnHeader column={column as never} title="Префикс (CIDR)" />
|
cell: ({ row }) => (
|
||||||
),
|
<DataGridMonoCell>{(row.original as IpRangeEntry).prefix}</DataGridMonoCell>
|
||||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
|
||||||
<DataGridPrimaryCell title={row.original.prefix} accent="mono" />
|
|
||||||
),
|
),
|
||||||
|
meta: { headerTitle: 'Префикс (CIDR)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'community',
|
id: 'community',
|
||||||
header: 'Community',
|
header: 'Community',
|
||||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{communityLabel(row.original.community_id, communities)}
|
{communityLabel((row.original as IpRangeEntry).community_id, communities)}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<RowActions
|
<RowActions
|
||||||
onEdit={() => onEdit({ kind: 'ip-range', entry: row.original })}
|
onEdit={() => onEdit({ kind: 'ip-range', entry: row.original as IpRangeEntry })}
|
||||||
onDelete={() => onDelete({ kind: 'ip-range', entry: row.original })}
|
onDelete={() => onDelete({ kind: 'ip-range', entry: row.original as IpRangeEntry })}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] as ColumnDef<IpRangeEntry>[]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mod.type === 'CDN_CIDRS') {
|
if (mod.type === 'CDN_CIDRS') {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'url',
|
accessorKey: 'url',
|
||||||
header: ({ column }: { column: { id: string } }) => (
|
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
|
||||||
<DataGridColumnHeader column={column as never} title="URL" />
|
cell: ({ row }) => (
|
||||||
),
|
<DataGridMonoCell className="max-w-xs">
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
{(row.original as CdnSource).url}
|
||||||
<DataGridPrimaryCell title={row.original.url} accent="mono" className="max-w-xs" />
|
</DataGridMonoCell>
|
||||||
),
|
),
|
||||||
|
meta: { headerTitle: 'URL' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'source_kind',
|
accessorKey: 'source_kind',
|
||||||
header: 'Тип',
|
header: 'Тип',
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
cell: ({ row }) => (
|
||||||
<CategoryBadge>{cdnSourceKindRu(row.original.source_kind)}</CategoryBadge>
|
<CategoryBadge>{cdnSourceKindRu((row.original as CdnSource).source_kind)}</CategoryBadge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'community',
|
id: 'community',
|
||||||
header: 'Community',
|
header: 'Community',
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{communityLabel(row.original.community_id, communities)}
|
{communityLabel((row.original as CdnSource).community_id, communities)}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'last_refreshed_at',
|
id: 'last_refreshed_at',
|
||||||
accessorFn: (row: CdnSource) => row.last_refreshed_at ?? '',
|
accessorFn: (row) => (row as CdnSource).last_refreshed_at ?? '',
|
||||||
header: ({ column }: { column: { id: string } }) => (
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||||
<DataGridColumnHeader column={column as never} title="Обновлено" />
|
cell: ({ row }) => (
|
||||||
),
|
<DataGridMutedCell>
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
{formatDateTime((row.original as CdnSource).last_refreshed_at)}
|
||||||
<DataGridMutedCell>{formatDateTime(row.original.last_refreshed_at)}</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
|
meta: { headerTitle: 'Обновлено' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
cell: ({ row }) => (
|
||||||
<RowActions
|
<RowActions
|
||||||
onEdit={() => onEdit({ kind: 'cdn', entry: row.original })}
|
onEdit={() => onEdit({ kind: 'cdn', entry: row.original as CdnSource })}
|
||||||
onDelete={() => onDelete({ kind: 'cdn', entry: row.original })}
|
onDelete={() => onDelete({ kind: 'cdn', entry: row.original as CdnSource })}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] as ColumnDef<CdnSource>[]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: 'asn',
|
accessorKey: 'asn',
|
||||||
header: ({ column }: { column: { id: string } }) => (
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||||
<DataGridColumnHeader column={column as never} title="ASN" />
|
cell: ({ row }) => <DataGridMonoCell>{(row.original as AsEntry).asn}</DataGridMonoCell>,
|
||||||
),
|
meta: { headerTitle: 'ASN' },
|
||||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
|
||||||
<span className="font-mono text-sm">{row.original.asn}</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'asn_name',
|
accessorKey: 'asn_name',
|
||||||
header: 'Имя',
|
header: 'Имя',
|
||||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm">{row.original.asn_name ?? '—'}</span>
|
<span className="text-sm">{(row.original as AsEntry).asn_name ?? '—'}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'prefix_count',
|
accessorKey: 'prefix_count',
|
||||||
header: 'Префиксов',
|
header: 'Префиксов',
|
||||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-sm">{row.original.prefix_count ?? '—'}</span>
|
<DataGridMonoCell>{(row.original as AsEntry).prefix_count ?? '—'}</DataGridMonoCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'community',
|
id: 'community',
|
||||||
header: 'Community',
|
header: 'Community',
|
||||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{communityLabel(row.original.community_id, communities)}
|
{communityLabel((row.original as AsEntry).community_id, communities)}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
cell: ({ row }) => (
|
||||||
<RowActions
|
<RowActions
|
||||||
onEdit={() => onEdit({ kind: 'as', entry: row.original })}
|
onEdit={() => onEdit({ kind: 'as', entry: row.original as AsEntry })}
|
||||||
onDelete={() => onDelete({ kind: 'as', entry: row.original })}
|
onDelete={() => onDelete({ kind: 'as', entry: row.original as AsEntry })}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] as ColumnDef<AsEntry>[]
|
]
|
||||||
}, [communities, mod.type, onDelete, onEdit])
|
}, [communities, mod.type, onDelete, onEdit])
|
||||||
|
|
||||||
type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry
|
const data = rows as unknown as EntryRow[]
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title={title}
|
||||||
recordCount={filteredCount}
|
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}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет записей"
|
isError={isError}
|
||||||
searchValue={globalFilter}
|
error={error}
|
||||||
onSearchChange={setGlobalFilter}
|
onRetry={onRetry}
|
||||||
searchPlaceholder="Поиск записей…"
|
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 { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
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 { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
|
||||||
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
|
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
|
||||||
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<FrameDataGrid
|
<ModuleEntriesGrid
|
||||||
|
mod={mod}
|
||||||
|
rows={items}
|
||||||
|
communities={communities}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
onRetry={onRetry}
|
||||||
title={meta.title}
|
title={meta.title}
|
||||||
description={meta.description}
|
description={meta.description}
|
||||||
actions={
|
actions={addButton}
|
||||||
<Button size="sm" type="button" onClick={openCreate}>
|
emptyTitle={meta.emptyTitle}
|
||||||
<Plus />
|
emptyDescription={meta.emptyDescription}
|
||||||
Добавить
|
onEdit={(target) => {
|
||||||
</Button>
|
if (target.kind === 'domain') setEditDomain(target.entry)
|
||||||
}
|
if (target.kind === 'ip-range') setEditIpRange(target.entry)
|
||||||
>
|
if (target.kind === 'cdn') setEditCdn(target.entry)
|
||||||
<QueryState
|
if (target.kind === 'as') setEditAs(target.entry)
|
||||||
data={items}
|
setDialogOpen(true)
|
||||||
isLoading={isLoading}
|
}}
|
||||||
isError={isError}
|
onDelete={setDeleteTarget}
|
||||||
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>
|
|
||||||
|
|
||||||
{mod.type === 'DOMAINS' ? (
|
{mod.type === 'DOMAINS' ? (
|
||||||
<ModuleDomainEntryDialog
|
<ModuleDomainEntryDialog
|
||||||
|
|||||||
@@ -1,35 +1,91 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { Boxes } from 'lucide-react'
|
import { Boxes, SearchIcon } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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 { moduleTypeRu } from '@/lib/ui-labels'
|
||||||
import type { ModuleRow } from '@/types/api'
|
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({
|
export function ModulesListGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
title = 'Все модули',
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
items: ModuleRow[]
|
items: ModuleRow[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
title?: string
|
||||||
|
actions?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<ModuleRow>[]>(
|
const columns = useMemo<DataGridColumnDef<ModuleRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
<DataGridNameCell icon={Boxes} title={row.original.name} className="max-w-[280px]" />
|
||||||
<Boxes className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[280px]" />
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Название' },
|
meta: { headerTitle: 'Название' },
|
||||||
},
|
},
|
||||||
@@ -65,34 +121,32 @@ export function ModulesListGrid({
|
|||||||
: '—'}
|
: '—'}
|
||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
sortingFn: (a, b) => {
|
|
||||||
const av = a.original.last_refreshed_at ?? ''
|
|
||||||
const bv = b.original.last_refreshed_at ?? ''
|
|
||||||
return av.localeCompare(bv)
|
|
||||||
},
|
|
||||||
meta: { headerTitle: 'Обновлено' },
|
meta: { headerTitle: 'Обновлено' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: items,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'включён' : 'выключен'}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title={title}
|
||||||
recordCount={filteredCount}
|
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}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет модулей"
|
primaryAction={actions}
|
||||||
searchValue={globalFilter}
|
onRowClick={(row) =>
|
||||||
onSearchChange={setGlobalFilter}
|
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })
|
||||||
searchPlaceholder="Поиск модулей…"
|
}
|
||||||
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 { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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 {
|
import {
|
||||||
isReadyCheckOk,
|
isReadyCheckOk,
|
||||||
isSystemReady,
|
isSystemReady,
|
||||||
@@ -14,8 +12,6 @@ import {
|
|||||||
} from '@/lib/metrics'
|
} from '@/lib/metrics'
|
||||||
import { readyCheckRu } from '@/lib/ui-labels'
|
import { readyCheckRu } from '@/lib/ui-labels'
|
||||||
import type { ReadyStatus } from '@/queries/monitoring'
|
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> = {
|
const READY_CHECK_ICONS: Record<string, typeof Database> = {
|
||||||
postgres: Database,
|
postgres: Database,
|
||||||
@@ -79,32 +75,19 @@ export function MonitoringReadyGrid({
|
|||||||
return rows
|
return rows
|
||||||
}, [health?.ok, ready.checks, ready.status])
|
}, [health?.ok, ready.checks, ready.status])
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<ReadyCheckRow>[]>(
|
const columns = useMemo<DataGridColumnDef<ReadyCheckRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'label',
|
accessorKey: 'label',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Проверка" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Проверка" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const Icon = row.original.icon
|
<DataGridNameCell
|
||||||
return (
|
icon={row.original.icon}
|
||||||
<div className="flex min-w-0 items-center gap-2.5 py-0.5">
|
title={row.original.label}
|
||||||
<Item
|
subtitle={row.original.subtitle}
|
||||||
className={cn(
|
iconClassName={row.original.iconClassName}
|
||||||
'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>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
meta: { headerTitle: 'Проверка' },
|
meta: { headerTitle: 'Проверка' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -112,9 +95,7 @@ export function MonitoringReadyGrid({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: 'Статус',
|
header: 'Статус',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex justify-start">
|
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
||||||
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Статус' },
|
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 (
|
return (
|
||||||
<DataGridSection
|
<FrameDataGrid
|
||||||
table={table}
|
title="Доступность и готовность"
|
||||||
recordCount={filteredCount}
|
description="Проверки живучести и готовности (/v1/health, /v1/ready)"
|
||||||
showPagination={false}
|
columns={columns}
|
||||||
emptyMessage="Нет проверок"
|
data={data}
|
||||||
searchValue={globalFilter}
|
rowId={(row) => row.id}
|
||||||
onSearchChange={setGlobalFilter}
|
emptyTitle="Нет проверок"
|
||||||
searchPlaceholder="Поиск проверок…"
|
pagination={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import { CheckIcon, Network, SearchIcon, XIcon } from 'lucide-react'
|
||||||
import { CheckIcon, SearchIcon, XIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
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 { FormDrawer } from '@/components/form-drawer'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
import {
|
import {
|
||||||
createFilter,
|
ResourcePage,
|
||||||
type Filter,
|
createTextFilterQuery,
|
||||||
type FilterFieldConfig,
|
type DataGridColumnDef,
|
||||||
} from '@/components/reui/filters'
|
} from '@/components/reui-kit'
|
||||||
import { ResourcePage } from '@/components/reui-kit'
|
|
||||||
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||||
import {
|
import {
|
||||||
useApproveDiscoveredPeerMutation,
|
useApproveDiscoveredPeerMutation,
|
||||||
@@ -40,17 +39,12 @@ function speakerLabel(s: SpeakerRow): string {
|
|||||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultFilters(): Filter[] {
|
const filterFields: FilterField[] = [
|
||||||
return [createFilter('neighbor', 'contains', [''])]
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterFields: FilterFieldConfig[] = [
|
|
||||||
{
|
{
|
||||||
key: 'neighbor',
|
id: 'neighbor',
|
||||||
label: 'Сосед',
|
label: 'Сосед',
|
||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-48',
|
|
||||||
placeholder: 'IP или ID соседа…',
|
placeholder: 'IP или ID соседа…',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -74,7 +68,9 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
}: NetworkDiscoveredPeersCardProps) {
|
}: NetworkDiscoveredPeersCardProps) {
|
||||||
const approveMutation = useApproveDiscoveredPeerMutation()
|
const approveMutation = useApproveDiscoveredPeerMutation()
|
||||||
const rejectMutation = useRejectDiscoveredPeerMutation()
|
const rejectMutation = useRejectDiscoveredPeerMutation()
|
||||||
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('neighbor'),
|
||||||
|
)
|
||||||
const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null)
|
const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null)
|
||||||
const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null)
|
const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
@@ -88,7 +84,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
[speakers],
|
[speakers],
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<PeerDiscoveryRow, unknown>[]>(
|
const columns = useMemo<DataGridColumnDef<PeerDiscoveryRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: 'neighbor_id',
|
id: 'neighbor_id',
|
||||||
@@ -97,10 +93,10 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
<DataGridColumnHeader column={column} title="ID соседа" />
|
<DataGridColumnHeader column={column} title="ID соседа" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridNameCell
|
||||||
|
icon={Network}
|
||||||
title={row.original.neighbor_id || '—'}
|
title={row.original.neighbor_id || '—'}
|
||||||
subtitle={row.original.neighbor}
|
subtitle={row.original.neighbor}
|
||||||
accent="mono"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'ID соседа' },
|
meta: { headerTitle: 'ID соседа' },
|
||||||
@@ -109,7 +105,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
accessorKey: 'remote_asn',
|
accessorKey: 'remote_asn',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||||
cell: ({ row }) => (
|
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' },
|
meta: { headerTitle: 'ASN' },
|
||||||
},
|
},
|
||||||
@@ -186,9 +182,9 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
title="На одобрение"
|
title="На одобрение"
|
||||||
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
|
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
|
||||||
filterFields={filterFields}
|
filterFields={filterFields}
|
||||||
filters={filters}
|
filterQuery={filterQuery}
|
||||||
onFiltersChange={setFilters}
|
onFilterQueryChange={setFilterQuery}
|
||||||
onClearFilters={() => setFilters(createDefaultFilters())}
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('neighbor'))}
|
||||||
getFilterFieldValue={getFilterFieldValue}
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
@@ -197,6 +193,7 @@ export function NetworkDiscoveredPeersCard({
|
|||||||
isError={isError}
|
isError={isError}
|
||||||
error={error instanceof Error ? error : null}
|
error={error instanceof Error ? error : null}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
|
pinLastColumn
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет ожидающих пиров',
|
title: 'Нет ожидающих пиров',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { Plus, SearchIcon, ActivityIcon, Trash2 } from 'lucide-react'
|
import { Plus, SearchIcon, ActivityIcon, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
@@ -11,12 +10,13 @@ import {
|
|||||||
peerColumns,
|
peerColumns,
|
||||||
peerTabFilter,
|
peerTabFilter,
|
||||||
} from '@/components/network/network-peers-grid'
|
} from '@/components/network/network-peers-grid'
|
||||||
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
import {
|
import {
|
||||||
createFilter,
|
ResourcePage,
|
||||||
type Filter,
|
createTextFilterQuery,
|
||||||
type FilterFieldConfig,
|
renderSingleSelectedLabel,
|
||||||
} from '@/components/reui/filters'
|
type DataGridColumnDef,
|
||||||
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { useDeletePeerMutation } from '@/queries/network'
|
import { useDeletePeerMutation } from '@/queries/network'
|
||||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
@@ -46,37 +46,29 @@ const SESSION_STATE_OPTIONS = [
|
|||||||
{ value: 'OpenConfirm', label: 'Open подтверждён' },
|
{ value: 'OpenConfirm', label: 'Open подтверждён' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDefaultPeerFilters(): Filter[] {
|
const peerFilterFields: FilterField[] = [
|
||||||
return [createFilter('name', 'contains', [''])]
|
|
||||||
}
|
|
||||||
|
|
||||||
const peerFilterFields: FilterFieldConfig[] = [
|
|
||||||
{
|
{
|
||||||
key: 'name',
|
id: 'name',
|
||||||
label: 'Имя',
|
label: 'Имя',
|
||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-48',
|
|
||||||
placeholder: 'Поиск по имени…',
|
placeholder: 'Поиск по имени…',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'neighbor',
|
id: 'neighbor',
|
||||||
label: 'Сосед',
|
label: 'Сосед',
|
||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-48',
|
|
||||||
placeholder: 'Адрес соседа…',
|
placeholder: 'Адрес соседа…',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'session_state',
|
id: 'session_state',
|
||||||
label: 'Состояние',
|
label: 'Состояние',
|
||||||
icon: <ActivityIcon className="size-3.5" aria-hidden />,
|
icon: <ActivityIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
searchable: false,
|
searchable: false,
|
||||||
className: 'w-[160px]',
|
|
||||||
options: SESSION_STATE_OPTIONS,
|
options: SESSION_STATE_OPTIONS,
|
||||||
customValueRenderer: (values) =>
|
renderValue: ({ values }) => renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
|
||||||
renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -100,7 +92,7 @@ export function NetworkPeersCard({
|
|||||||
const deleteMutation = useDeletePeerMutation()
|
const deleteMutation = useDeletePeerMutation()
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<PeerRow | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<PeerRow | null>(null)
|
||||||
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() => createTextFilterQuery('name'))
|
||||||
|
|
||||||
const addButton = useMemo(
|
const addButton = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -112,7 +104,7 @@ export function NetworkPeersCard({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<PeerRow, unknown>[]>(
|
const columns = useMemo<DataGridColumnDef<PeerRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
...peerColumns,
|
...peerColumns,
|
||||||
{
|
{
|
||||||
@@ -147,9 +139,9 @@ export function NetworkPeersCard({
|
|||||||
tabs={PEER_TABS}
|
tabs={PEER_TABS}
|
||||||
tabFilter={peerTabFilter}
|
tabFilter={peerTabFilter}
|
||||||
filterFields={peerFilterFields}
|
filterFields={peerFilterFields}
|
||||||
filters={filters}
|
filterQuery={filterQuery}
|
||||||
onFiltersChange={setFilters}
|
onFilterQueryChange={setFilterQuery}
|
||||||
onClearFilters={() => setFilters(createDefaultPeerFilters())}
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('name'))}
|
||||||
getFilterFieldValue={getPeerFilterFieldValue}
|
getFilterFieldValue={getPeerFilterFieldValue}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
@@ -159,6 +151,7 @@ export function NetworkPeersCard({
|
|||||||
error={error instanceof Error ? error : null}
|
error={error instanceof Error ? error : null}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
primaryAction={addButton}
|
primaryAction={addButton}
|
||||||
|
pinLastColumn
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет пиров',
|
title: 'Нет пиров',
|
||||||
description: 'Добавьте первого BGP-соседа.',
|
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 { 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 { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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 { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||||
import type { PeerRow } from '@/types/api'
|
import type { PeerRow } from '@/types/api'
|
||||||
|
|
||||||
export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
|
export const peerColumns: DataGridColumnDef<PeerRow>[] = [
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row) => row.name ?? row.neighbor,
|
accessorFn: (row) => row.name ?? row.neighbor,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridNameCell
|
||||||
|
icon={Share2}
|
||||||
title={row.original.name ?? row.original.neighbor}
|
title={row.original.name ?? row.original.neighbor}
|
||||||
subtitle={row.original.name ? row.original.neighbor : undefined}
|
subtitle={row.original.name ? row.original.neighbor : undefined}
|
||||||
accent="primary"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Имя' },
|
meta: { headerTitle: 'Имя' },
|
||||||
@@ -24,16 +25,14 @@ export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'neighbor',
|
accessorKey: 'neighbor',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <DataGridMonoCell>{row.original.neighbor}</DataGridMonoCell>,
|
||||||
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Адрес соседа' },
|
meta: { headerTitle: 'Адрес соседа' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'remote_asn',
|
accessorKey: 'remote_asn',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||||
cell: ({ row }) => (
|
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' },
|
meta: { headerTitle: 'ASN' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { Plus, SearchIcon, TagIcon, Trash2 } from 'lucide-react'
|
import { Plus, SearchIcon, TagIcon, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
@@ -11,12 +10,13 @@ import {
|
|||||||
speakerColumns,
|
speakerColumns,
|
||||||
speakerTabFilter,
|
speakerTabFilter,
|
||||||
} from '@/components/network/network-speakers-grid'
|
} from '@/components/network/network-speakers-grid'
|
||||||
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
import {
|
import {
|
||||||
createFilter,
|
ResourcePage,
|
||||||
type Filter,
|
createTextFilterQuery,
|
||||||
type FilterFieldConfig,
|
renderSingleSelectedLabel,
|
||||||
} from '@/components/reui/filters'
|
type DataGridColumnDef,
|
||||||
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { useDeleteSpeakerMutation } from '@/queries/network'
|
import { useDeleteSpeakerMutation } from '@/queries/network'
|
||||||
import type { SpeakerRow } from '@/types/api'
|
import type { SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
@@ -40,29 +40,22 @@ const ROLE_OPTIONS = [
|
|||||||
{ value: 'master', label: 'Мастер' },
|
{ value: 'master', label: 'Мастер' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function createDefaultSpeakerFilters(): Filter[] {
|
const speakerFilterFields: FilterField[] = [
|
||||||
return [createFilter('endpoint', 'contains', [''])]
|
|
||||||
}
|
|
||||||
|
|
||||||
const speakerFilterFields: FilterFieldConfig[] = [
|
|
||||||
{
|
{
|
||||||
key: 'endpoint',
|
id: 'endpoint',
|
||||||
label: 'Конечная точка',
|
label: 'Конечная точка',
|
||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-52',
|
|
||||||
placeholder: 'Адрес агента…',
|
placeholder: 'Адрес агента…',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'role',
|
id: 'role',
|
||||||
label: 'Роль',
|
label: 'Роль',
|
||||||
icon: <TagIcon className="size-3.5" aria-hidden />,
|
icon: <TagIcon className="size-3.5" aria-hidden />,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
searchable: false,
|
searchable: false,
|
||||||
className: 'w-[140px]',
|
|
||||||
options: ROLE_OPTIONS,
|
options: ROLE_OPTIONS,
|
||||||
customValueRenderer: (values) =>
|
renderValue: ({ values }) => renderSingleSelectedLabel(values, ROLE_OPTIONS),
|
||||||
renderSingleSelectedLabel(values, ROLE_OPTIONS),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -84,7 +77,9 @@ export function NetworkSpeakersCard({
|
|||||||
const deleteMutation = useDeleteSpeakerMutation()
|
const deleteMutation = useDeleteSpeakerMutation()
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<SpeakerRow | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<SpeakerRow | null>(null)
|
||||||
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('endpoint'),
|
||||||
|
)
|
||||||
|
|
||||||
const addButton = useMemo(
|
const addButton = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -96,7 +91,7 @@ export function NetworkSpeakersCard({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<SpeakerRow, unknown>[]>(
|
const columns = useMemo<DataGridColumnDef<SpeakerRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
...speakerColumns,
|
...speakerColumns,
|
||||||
{
|
{
|
||||||
@@ -131,9 +126,9 @@ export function NetworkSpeakersCard({
|
|||||||
tabs={SPEAKER_TABS}
|
tabs={SPEAKER_TABS}
|
||||||
tabFilter={speakerTabFilter}
|
tabFilter={speakerTabFilter}
|
||||||
filterFields={speakerFilterFields}
|
filterFields={speakerFilterFields}
|
||||||
filters={filters}
|
filterQuery={filterQuery}
|
||||||
onFiltersChange={setFilters}
|
onFilterQueryChange={setFilterQuery}
|
||||||
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('endpoint'))}
|
||||||
getFilterFieldValue={getSpeakerFilterFieldValue}
|
getFilterFieldValue={getSpeakerFilterFieldValue}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
@@ -143,6 +138,7 @@ export function NetworkSpeakersCard({
|
|||||||
error={error instanceof Error ? error : null}
|
error={error instanceof Error ? error : null}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
primaryAction={addButton}
|
primaryAction={addButton}
|
||||||
|
pinLastColumn
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет спикеров',
|
title: 'Нет спикеров',
|
||||||
description: 'Добавьте первого BIRD-агента на ноде.',
|
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 { 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 { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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 { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
|
||||||
import type { SpeakerRow } from '@/types/api'
|
import type { SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'endpoint',
|
accessorKey: 'endpoint',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
||||||
cell: ({ row }) => (
|
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: 'Конечная точка' },
|
meta: { headerTitle: 'Конечная точка' },
|
||||||
},
|
},
|
||||||
@@ -51,9 +56,9 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
|||||||
const live = row.original.live
|
const live = row.original.live
|
||||||
if (!live) return '—'
|
if (!live) return '—'
|
||||||
return (
|
return (
|
||||||
<span className="text-xs">
|
<DataGridMonoCell className="text-xs">
|
||||||
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
||||||
</span>
|
</DataGridMonoCell>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
meta: { headerTitle: 'BGP' },
|
meta: { headerTitle: 'BGP' },
|
||||||
@@ -63,7 +68,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
|||||||
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
|
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'endpoint':
|
case 'endpoint':
|
||||||
return item.endpoint
|
return `${item.endpoint} ${item.agent_domain ?? ''}`
|
||||||
case 'role':
|
case 'role':
|
||||||
return item.role
|
return item.role
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -284,7 +284,8 @@ function SpeakerInstallStep({
|
|||||||
<AlertTitle>Сохраните сейчас</AlertTitle>
|
<AlertTitle>Сохраните сейчас</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
agent_secret и node_token больше не будут показаны. Traefik на ноде выпускает
|
agent_secret и node_token больше не будут показаны. Traefik на ноде выпускает
|
||||||
сертификат через DNS-01 (Cloudflare).
|
сертификат через DNS-01 (Cloudflare). MikroTik стучится на IP ноды:179; 80/443 —
|
||||||
|
только агент панели. Логи: docker compose logs -f bird2 evobgp-agent.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -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 { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
|
||||||
|
import { OperationsJobsKanban } from '@/components/operations/operations-jobs-kanban'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { ToggleGroup, ToggleGroupItem } from '@evobgp/ui/components/toggle-group'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
type JobTab = 'all' | 'active' | 'failed' | 'succeeded'
|
/**
|
||||||
|
* Jobs ResourcePage + optional Kanban board (не замена грида).
|
||||||
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
if (tab === 'all') return items
|
* @see https://reui.io/docs/components/base/kanban
|
||||||
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. */
|
|
||||||
export function OperationsJobsCard({
|
export function OperationsJobsCard({
|
||||||
jobs,
|
jobs,
|
||||||
nameById,
|
nameById,
|
||||||
@@ -46,41 +30,54 @@ export function OperationsJobsCard({
|
|||||||
error: unknown
|
error: unknown
|
||||||
onRetry: () => void
|
onRetry: () => void
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<JobTab>('all')
|
const [view, setView] = useState<'table' | 'board'>('table')
|
||||||
const counts = useMemo(() => tabCounts(jobs), [jobs])
|
|
||||||
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FrameDataGrid title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="px-5 pt-3">
|
<div className="flex justify-end">
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
|
<ToggleGroup
|
||||||
<TabsList variant="line" className="w-full justify-start gap-6">
|
multiple={false}
|
||||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
value={[view]}
|
||||||
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger>
|
onValueChange={(values) => {
|
||||||
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
|
const next = values[0]
|
||||||
<TabsTrigger value="failed">Ошибки ({counts.failed})</TabsTrigger>
|
if (next === 'table' || next === 'board') setView(next)
|
||||||
</TabsList>
|
}}
|
||||||
</Tabs>
|
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>
|
</div>
|
||||||
<QueryState
|
<QueryState
|
||||||
data={filtered}
|
data={jobs}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
error={error}
|
error={error}
|
||||||
empty={filtered.length === 0}
|
empty={false}
|
||||||
emptyTitle="Нет задач в выборке"
|
|
||||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
>
|
>
|
||||||
{(items) => (
|
{(items) =>
|
||||||
<OperationsJobsGrid
|
view === 'board' ? (
|
||||||
items={items}
|
<OperationsJobsKanban items={items} nameById={nameById} />
|
||||||
nameById={nameById}
|
) : (
|
||||||
qc={qc}
|
<OperationsJobsGrid
|
||||||
isLoading={isLoading && items.length > 0}
|
items={items}
|
||||||
/>
|
nameById={nameById}
|
||||||
)}
|
qc={qc}
|
||||||
|
isLoading={isLoading && items.length > 0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
</QueryState>
|
</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 { useMutation } from '@tanstack/react-query'
|
||||||
import { useMemo } from 'react'
|
import { ListTodo, SearchIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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 { apiMutate } from '@/lib/api-client'
|
||||||
import { jobKindRu } from '@/lib/ui-labels'
|
import { jobKindRu } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
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({
|
export function OperationsJobsGrid({
|
||||||
items,
|
items,
|
||||||
nameById,
|
nameById,
|
||||||
@@ -26,6 +54,9 @@ export function OperationsJobsGrid({
|
|||||||
qc: QueryClient
|
qc: QueryClient
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
const cancelMutation = useMutation({
|
const cancelMutation = useMutation({
|
||||||
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
|
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -35,13 +66,14 @@ export function OperationsJobsGrid({
|
|||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<JobRow>[]>(
|
const columns = useMemo<DataGridColumnDef<JobRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell
|
<DataGridNameCell
|
||||||
|
icon={ListTodo}
|
||||||
title={jobKindRu(row.original.kind)}
|
title={jobKindRu(row.original.kind)}
|
||||||
subtitle={
|
subtitle={
|
||||||
row.original.meta?.module_id
|
row.original.meta?.module_id
|
||||||
@@ -112,27 +144,30 @@ export function OperationsJobsGrid({
|
|||||||
[cancelMutation, nameById],
|
[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 (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title="Задачи"
|
||||||
recordCount={filteredCount}
|
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}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет задач"
|
pinLastColumn
|
||||||
searchValue={globalFilter}
|
virtualization={items.length > 80}
|
||||||
onSearchChange={setGlobalFilter}
|
emptyState={{ title: 'Нет задач' }}
|
||||||
searchPlaceholder="Поиск задач…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import { DataGridMutedCell } from '@/components/data-grid-cell'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import {
|
||||||
|
Kanban,
|
||||||
|
KanbanBoard,
|
||||||
|
KanbanColumn,
|
||||||
|
KanbanColumnContent,
|
||||||
|
KanbanItem,
|
||||||
|
} from '@/components/reui/kanban'
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { jobKindRu } from '@/lib/ui-labels'
|
||||||
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations jobs board view — ReUI Kanban (read-only columns).
|
||||||
|
* @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>
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
{columns[columnId]?.length ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<KanbanColumnContent value={columnId}>
|
||||||
|
{(columns[columnId] ?? []).map((job) => (
|
||||||
|
<KanbanItem
|
||||||
|
key={job.job_id}
|
||||||
|
value={job.job_id}
|
||||||
|
disabled
|
||||||
|
className="bg-background flex flex-col gap-1 rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-tight font-medium">
|
||||||
|
{jobKindRu(job.kind)}
|
||||||
|
</p>
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
{job.meta?.module_id ? (
|
||||||
|
<DataGridMutedCell>
|
||||||
|
{nameById.get(String(job.meta.module_id)) ??
|
||||||
|
String(job.meta.module_id)}
|
||||||
|
</DataGridMutedCell>
|
||||||
|
) : null}
|
||||||
|
</KanbanItem>
|
||||||
|
))}
|
||||||
|
</KanbanColumnContent>
|
||||||
|
</KanbanColumn>
|
||||||
|
))}
|
||||||
|
</KanbanBoard>
|
||||||
|
</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 { useMutation } from '@tanstack/react-query'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { GitCommitHorizontal, RefreshCw, SearchIcon } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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 { apiMutate } from '@/lib/api-client'
|
||||||
import type { RevisionRow } from '@/types/api'
|
import type { RevisionRow } from '@/types/api'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
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({
|
export function OperationsRevisionsGrid({
|
||||||
items,
|
items,
|
||||||
qc,
|
qc,
|
||||||
@@ -24,6 +36,9 @@ export function OperationsRevisionsGrid({
|
|||||||
qc: QueryClient
|
qc: QueryClient
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
|
createTextFilterQuery('search'),
|
||||||
|
)
|
||||||
const rollbackMutation = useMutation({
|
const rollbackMutation = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) =>
|
||||||
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
|
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
|
||||||
@@ -34,14 +49,14 @@ export function OperationsRevisionsGrid({
|
|||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<RevisionRow>[]>(
|
const columns = useMemo<DataGridColumnDef<RevisionRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: 'id',
|
id: 'id',
|
||||||
accessorFn: (row) => row.id,
|
accessorFn: (row) => row.id,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||||
cell: ({ row }) => (
|
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' },
|
meta: { headerTitle: 'ID' },
|
||||||
},
|
},
|
||||||
@@ -88,22 +103,21 @@ export function OperationsRevisionsGrid({
|
|||||||
[rollbackMutation],
|
[rollbackMutation],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: items,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) => `${row.id} ${row.materialized_prefix_count}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridSection
|
<ResourcePage
|
||||||
table={table}
|
title="История ревизий"
|
||||||
recordCount={filteredCount}
|
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}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет ревизий"
|
pinLastColumn
|
||||||
searchValue={globalFilter}
|
virtualization={items.length > 80}
|
||||||
onSearchChange={setGlobalFilter}
|
emptyState={{ title: 'Нет ревизий' }}
|
||||||
searchPlaceholder="Поиск ревизий…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Frame shell class constants (legacy Card names kept for DataGridShell / toolbar).
|
* Frame shell for analytics / comparison panels (not list grids).
|
||||||
* @see https://reui.io/docs/components/base/frame
|
* @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 panelCardClassName = 'w-full gap-0 p-0'
|
||||||
export const panelCardHeaderClassName = 'border-b'
|
export const panelCardHeaderClassName = 'border-b'
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { 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')
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,18 @@
|
|||||||
import type { Filter } from '@/components/reui/filters'
|
import {
|
||||||
|
createFilterQuery,
|
||||||
|
createFilterRule,
|
||||||
|
flattenFilterConditions,
|
||||||
|
type FilterCondition,
|
||||||
|
} from '@/components/reui/filters/filters-query'
|
||||||
|
import type { FilterField, 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) => {
|
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 || values.length === 0) return false
|
||||||
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
|
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
|
||||||
return false
|
return false
|
||||||
@@ -17,63 +27,110 @@ 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[],
|
data: T[],
|
||||||
filters: Filter[],
|
filters: FilterCondition[],
|
||||||
getFieldValue: (item: T, field: string) => unknown,
|
getFieldValue: (item: T, field: string) => unknown,
|
||||||
): T[] {
|
): T[] {
|
||||||
const active = getActiveFilters(filters)
|
const active = getActiveFilters(filters)
|
||||||
let result = [...data]
|
let result = [...data]
|
||||||
|
|
||||||
for (const filter of active) {
|
for (const filter of active) {
|
||||||
const { field, operator, values } = filter
|
const { field, operator, values, negated } = filter
|
||||||
result = result.filter((item) => {
|
result = result.filter((item) => {
|
||||||
const raw = getFieldValue(item, field)
|
const raw = getFieldValue(item, field)
|
||||||
const fieldValue = raw != null ? raw : ''
|
const fieldValue = raw != null ? raw : ''
|
||||||
|
const matches = matchesFilterCondition(fieldValue, operator, values)
|
||||||
switch (operator) {
|
return negated ? !matches : matches
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
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 renderSelectedCount(values: unknown[]) {
|
export function renderSelectedCount(values: unknown[]) {
|
||||||
if (values.length === 0) return 'Выберите…'
|
if (values.length === 0) return 'Выберите…'
|
||||||
if (values.length > 1) return `${values.length} выбрано`
|
if (values.length > 1) return `${values.length} выбрано`
|
||||||
|
|||||||
@@ -1,52 +1,461 @@
|
|||||||
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 {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
FrameHeader,
|
FrameHeader,
|
||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} 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).
|
* Единый tableLayout для всех ops-гридов.
|
||||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
* Visual SoT: установленный data-grid-filtering-2 (`dense: true`, без zebra).
|
||||||
* @see https://reui.io/docs/components/base/frame
|
* 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 — единственный tableClassNames в kit, не в domain. */
|
||||||
|
export const kitDataGridTableClassNames = {
|
||||||
|
edgeCell: 'first:ps-3 last:pe-3',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
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,
|
title,
|
||||||
description,
|
description,
|
||||||
actions,
|
actions,
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
}: {
|
||||||
title?: ReactNode
|
title?: ReactNode
|
||||||
description?: ReactNode
|
description?: ReactNode
|
||||||
actions?: ReactNode
|
actions?: ReactNode
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
}) {
|
}) {
|
||||||
const hasHeader = Boolean(title || description || actions)
|
if (!title && !description && !actions) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
{hasHeader ? (
|
{title ? <FrameTitle className="text-balance">{title}</FrameTitle> : null}
|
||||||
<FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
|
{description ? (
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
|
||||||
{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}
|
) : 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,
|
||||||
|
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(
|
||||||
|
[
|
||||||
|
...(expandedContent ? [expandColumn] : []),
|
||||||
|
...(enableRowSelection ? [selectColumn] : []),
|
||||||
|
...columns,
|
||||||
|
],
|
||||||
|
columnPinControls,
|
||||||
|
)
|
||||||
|
|
||||||
|
const lastColId = pinLastColumn ? (tableColumns[tableColumns.length - 1]?.id ?? '') : ''
|
||||||
|
const pinLeft = pinLeftColumnIds ?? []
|
||||||
|
const enablePinning = pinLastColumn || pinLeft.length > 0
|
||||||
|
const columnPinning = {
|
||||||
|
start: pinLeft,
|
||||||
|
end: pinLastColumn && lastColId ? [lastColId] : [],
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
export {
|
export {
|
||||||
applyFiltersToData,
|
applyFiltersToData,
|
||||||
|
applyFilterConditionsToData,
|
||||||
|
createEmptyFilterQuery,
|
||||||
|
createSearchFilterField,
|
||||||
|
createTextFilterQuery,
|
||||||
getActiveFilters,
|
getActiveFilters,
|
||||||
renderSelectedCount,
|
renderSelectedCount,
|
||||||
renderSingleSelectedLabel,
|
renderSingleSelectedLabel,
|
||||||
@@ -22,6 +26,13 @@ export {
|
|||||||
} from './kpi-stat-grid'
|
} from './kpi-stat-grid'
|
||||||
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||||
export { OpsDashboard } from './ops-dashboard'
|
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 { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell } from './settings-shell'
|
export { SettingsShell } from './settings-shell'
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
getCoreRowModel,
|
useTable,
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type ColumnDef,
|
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
@@ -12,16 +8,17 @@ import {
|
|||||||
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
|
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { Badge } from '@/components/reui/badge'
|
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 { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
import {
|
import { Filters } from '@/components/reui/filters/filters'
|
||||||
Filters,
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
type Filter,
|
|
||||||
type FilterFieldConfig,
|
|
||||||
} from '@/components/reui/filters'
|
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
@@ -30,18 +27,20 @@ import {
|
|||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Separator } from '@evobgp/ui/components/separator'
|
import { Separator } from '@evobgp/ui/components/separator'
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
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 { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||||
import { FILTERS_I18N_RU } from '@/lib/filters-i18n'
|
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
|
||||||
import { applyFiltersToData } from './filter-utils'
|
import { applyFiltersToData, createEmptyFilterQuery } from './filter-utils'
|
||||||
|
import {
|
||||||
|
FrameDataGrid,
|
||||||
|
kitDataGridTableClassNames,
|
||||||
|
kitDataGridTableLayout,
|
||||||
|
type DataGridColumnDef,
|
||||||
|
type FrameDataGridProps,
|
||||||
|
} from './frame-data-grid'
|
||||||
|
|
||||||
export interface ResourcePageTab {
|
export interface ResourcePageTab {
|
||||||
id: string
|
id: string
|
||||||
@@ -49,21 +48,44 @@ export interface ResourcePageTab {
|
|||||||
count?: number
|
count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourcePageProps<T extends object> {
|
type SimpleGridPassthrough<T extends object> = Pick<
|
||||||
title: string
|
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
|
description?: string
|
||||||
tabs?: ResourcePageTab[]
|
tabs?: ResourcePageTab[]
|
||||||
activeTab?: string
|
activeTab?: string
|
||||||
onTabChange?: (tabId: string) => void
|
onTabChange?: (tabId: string) => void
|
||||||
tabFilter?: (item: T, tabId: string) => boolean
|
tabFilter?: (item: T, tabId: string) => boolean
|
||||||
filterFields: FilterFieldConfig[]
|
filterFields?: FilterField[]
|
||||||
filters: Filter[]
|
filterQuery?: FilterQuery
|
||||||
onFiltersChange: (filters: Filter[]) => void
|
onFilterQueryChange?: (query: FilterQuery) => void
|
||||||
onClearFilters?: () => void
|
onClearFilters?: () => void
|
||||||
getFilterFieldValue: (item: T, field: string) => unknown
|
getFilterFieldValue?: (item: T, field: string) => unknown
|
||||||
columns: ColumnDef<T, unknown>[]
|
columns: DataGridColumnDef<T>[]
|
||||||
data: T[]
|
data: T[]
|
||||||
getRowId: (row: T) => string
|
getRowId: (row: T, index?: number) => string
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
isError?: boolean
|
isError?: boolean
|
||||||
error?: Error | null
|
error?: Error | null
|
||||||
@@ -79,6 +101,11 @@ export interface ResourcePageProps<T extends object> {
|
|||||||
}) => ReactNode
|
}) => ReactNode
|
||||||
toolbarExtra?: ReactNode
|
toolbarExtra?: ReactNode
|
||||||
hideHeader?: boolean
|
hideHeader?: boolean
|
||||||
|
pinLastColumn?: boolean
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
|
emptyAction?: ReactNode
|
||||||
|
actions?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourcePageSkeleton() {
|
function ResourcePageSkeleton() {
|
||||||
@@ -100,18 +127,35 @@ function ResourcePageSkeleton() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourcePage<T extends object>({
|
function ResourceLoadError({
|
||||||
title,
|
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,
|
description,
|
||||||
tabs,
|
|
||||||
activeTab: controlledTab,
|
|
||||||
onTabChange,
|
|
||||||
tabFilter,
|
|
||||||
filterFields,
|
|
||||||
filters,
|
|
||||||
onFiltersChange,
|
|
||||||
onClearFilters,
|
|
||||||
getFilterFieldValue,
|
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
getRowId,
|
getRowId,
|
||||||
@@ -120,15 +164,130 @@ export function ResourcePage<T extends object>({
|
|||||||
error = null,
|
error = null,
|
||||||
onRetry,
|
onRetry,
|
||||||
primaryAction,
|
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,
|
emptyState,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
enableRowSelection = false,
|
enableRowSelection = false,
|
||||||
selectionToolbar,
|
selectionToolbar,
|
||||||
toolbarExtra,
|
toolbarExtra,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
|
pinLastColumn = false,
|
||||||
|
enableColumnVisibility = false,
|
||||||
|
onRowClick,
|
||||||
|
virtualization = false,
|
||||||
|
height = 480,
|
||||||
}: ResourcePageProps<T>) {
|
}: ResourcePageProps<T>) {
|
||||||
|
const headerActions = primaryAction ?? actions
|
||||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||||
const activeTab = controlledTab ?? internalTab
|
const activeTab = controlledTab ?? internalTab
|
||||||
|
const showFilters = filterFields.length > 0
|
||||||
|
|
||||||
|
const [internalQuery, setInternalQuery] = useState<FilterQuery>(createEmptyFilterQuery)
|
||||||
|
const isQueryControlled = controlledQuery !== undefined
|
||||||
|
const filterQuery = isQueryControlled ? controlledQuery : internalQuery
|
||||||
|
const setFilterQuery = isQueryControlled ? onFilterQueryChange : setInternalQuery
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
@@ -144,25 +303,23 @@ export function ResourcePage<T extends object>({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
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') {
|
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
||||||
result = result.filter((item) => tabFilter(item, activeTab))
|
result = result.filter((item) => tabFilter(item, activeTab))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
|
}, [data, filterQuery, getFilterFieldValue, tabs, tabFilter, activeTab, showFilters])
|
||||||
|
|
||||||
const tabCounts = useMemo(() => {
|
const tabCounts = useMemo(() => {
|
||||||
if (!tabs?.length || !tabFilter) return {}
|
if (!tabs?.length || !tabFilter) return {}
|
||||||
const base = applyFiltersToData(data, filters, getFilterFieldValue)
|
const base = showFilters ? applyFiltersToData(data, filterQuery, getFilterFieldValue) : data
|
||||||
const counts: Record<string, number> = {}
|
const counts: Record<string, number> = {}
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
counts[tab.id] =
|
counts[tab.id] =
|
||||||
tab.id === 'all'
|
tab.id === 'all' ? base.length : base.filter((item) => tabFilter(item, tab.id)).length
|
||||||
? base.length
|
|
||||||
: base.filter((item) => tabFilter(item, tab.id)).length
|
|
||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
|
}, [tabs, tabFilter, data, filterQuery, getFilterFieldValue, showFilters])
|
||||||
|
|
||||||
const selectedIds = useMemo(
|
const selectedIds = useMemo(
|
||||||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||||
@@ -171,22 +328,33 @@ export function ResourcePage<T extends object>({
|
|||||||
|
|
||||||
const selectedCount = selectedIds.length
|
const selectedCount = selectedIds.length
|
||||||
|
|
||||||
|
const lastColId = pinLastColumn ? (columns[columns.length - 1]?.id ?? '') : ''
|
||||||
|
const enablePinning = Boolean(pinLastColumn && lastColId)
|
||||||
|
const columnPinning = {
|
||||||
|
start: [] as string[],
|
||||||
|
end: enablePinning ? [lastColId] : [],
|
||||||
|
}
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
const clearSelection = useCallback(() => {
|
||||||
setRowSelection({})
|
setRowSelection({})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
columns,
|
columns,
|
||||||
getRowId,
|
getRowId: (row, index) => getRowId(row, index),
|
||||||
state: { sorting, rowSelection, pagination },
|
state: {
|
||||||
|
sorting,
|
||||||
|
rowSelection,
|
||||||
|
pagination,
|
||||||
|
...(enablePinning ? { columnPinning } : {}),
|
||||||
|
},
|
||||||
|
initialState: enablePinning ? { columnPinning } : undefined,
|
||||||
enableRowSelection,
|
enableRowSelection,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleTabChange = useCallback(
|
const handleTabChange = useCallback(
|
||||||
@@ -199,17 +367,18 @@ export function ResourcePage<T extends object>({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleFiltersChange = useCallback(
|
const handleFiltersChange = useCallback(
|
||||||
(next: Filter[]) => {
|
(next: FilterQuery) => {
|
||||||
onFiltersChange(next)
|
setFilterQuery(next)
|
||||||
resetPagination()
|
resetPagination()
|
||||||
},
|
},
|
||||||
[onFiltersChange, resetPagination],
|
[setFilterQuery, resetPagination],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
onClearFilters?.()
|
onClearFilters?.()
|
||||||
|
if (!isQueryControlled) setInternalQuery(createEmptyFilterQuery())
|
||||||
resetPagination()
|
resetPagination()
|
||||||
}, [onClearFilters, resetPagination])
|
}, [onClearFilters, isQueryControlled, resetPagination])
|
||||||
|
|
||||||
const countedTabs = useMemo(
|
const countedTabs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -221,38 +390,24 @@ export function ResourcePage<T extends object>({
|
|||||||
[tabs, tabCounts],
|
[tabs, tabCounts],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) return <ResourcePageSkeleton />
|
||||||
return <ResourcePageSkeleton />
|
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
|
||||||
}
|
|
||||||
|
|
||||||
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 (data.length === 0 && emptyState) {
|
if (data.length === 0 && emptyState) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<Frame dense spacing="sm" className="w-full">
|
||||||
title={emptyState.title}
|
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-center justify-center p-0">
|
||||||
description={emptyState.description}
|
<EmptyState
|
||||||
action={emptyState.action}
|
title={emptyState.title}
|
||||||
/>
|
description={emptyState.description}
|
||||||
|
action={emptyState.action}
|
||||||
|
/>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
const tableNode = <DataGridTable />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -266,8 +421,15 @@ export function ResourcePage<T extends object>({
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredData.length}
|
recordCount={filteredData.length}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage="Нет записей по выбранным фильтрам."
|
||||||
tableLayout={{ dense: true }}
|
onRowClick={onRowClick}
|
||||||
|
tableLayout={kitDataGridTableLayout({
|
||||||
|
dense: true,
|
||||||
|
width: 'fixed',
|
||||||
|
columnsPinnable: enablePinning,
|
||||||
|
columnsVisibility: enableColumnVisibility,
|
||||||
|
})}
|
||||||
|
tableClassNames={kitDataGridTableClassNames}
|
||||||
>
|
>
|
||||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||||
{!hideHeader ? (
|
{!hideHeader ? (
|
||||||
@@ -277,28 +439,20 @@ export function ResourcePage<T extends object>({
|
|||||||
{description ? (
|
{description ? (
|
||||||
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||||
<span>{description}</span>
|
<span>{description}</span>
|
||||||
<span
|
<span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
|
||||||
className="bg-input size-1 shrink-0 rounded-full"
|
<span className="tabular-nums">{filteredData.length} записей</span>
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="tabular-nums">
|
|
||||||
{filteredData.length} записей
|
|
||||||
</span>
|
|
||||||
{selectedCount > 0 ? (
|
{selectedCount > 0 ? (
|
||||||
<>
|
<>
|
||||||
<span
|
<span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
|
||||||
className="bg-input size-1 shrink-0 rounded-full"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span>{selectedCount} выбрано</span>
|
<span>{selectedCount} выбрано</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{primaryAction ? (
|
{headerActions ? (
|
||||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
{primaryAction}
|
{headerActions}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
@@ -318,20 +472,25 @@ export function ResourcePage<T extends object>({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
|
||||||
<Filters
|
{showFilters ? (
|
||||||
filters={filters}
|
<Filters
|
||||||
fields={filterFields}
|
query={filterQuery}
|
||||||
onChange={handleFiltersChange}
|
fields={filterFields}
|
||||||
size="default"
|
onQueryChange={handleFiltersChange}
|
||||||
i18n={FILTERS_I18N_RU}
|
size="default"
|
||||||
trigger={
|
labels={FILTERS_LABELS_RU}
|
||||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
|
||||||
<FilterIcon className="size-4" aria-hidden="true" />
|
trigger={
|
||||||
Фильтры
|
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||||
</Button>
|
<FilterIcon className="size-4" aria-hidden="true" />
|
||||||
}
|
Фильтры
|
||||||
/>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
{toolbarExtra}
|
{toolbarExtra}
|
||||||
{selectedCount > 0 ? (
|
{selectedCount > 0 ? (
|
||||||
@@ -339,7 +498,7 @@ export function ResourcePage<T extends object>({
|
|||||||
{selectedCount} выбрано
|
{selectedCount} выбрано
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{onClearFilters ? (
|
{showFilters ? (
|
||||||
<Button type="button" variant="outline" onClick={handleClear}>
|
<Button type="button" variant="outline" onClick={handleClear}>
|
||||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||||
Сбросить
|
Сбросить
|
||||||
@@ -350,17 +509,18 @@ export function ResourcePage<T extends object>({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<DataGridScrollArea>
|
{virtualization ? (
|
||||||
<DataGridTable />
|
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||||
</DataGridScrollArea>
|
{tableNode}
|
||||||
|
</DataGridScrollArea>
|
||||||
|
) : (
|
||||||
|
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<FrameFooter>
|
<FrameFooter>
|
||||||
<DataGridPagination
|
<DataGridPagination {...DATA_GRID_PAGINATION_RU} sizes={[5, 10, 20, 50]} />
|
||||||
{...DATA_GRID_PAGINATION_RU}
|
|
||||||
sizes={[5, 10, 20, 50]}
|
|
||||||
/>
|
|
||||||
</FrameFooter>
|
</FrameFooter>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
@@ -368,3 +528,17 @@ export function ResourcePage<T extends object>({
|
|||||||
</div>
|
</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.
|
* Settings layout — page chrome only.
|
||||||
* Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
|
* Side-tab rail lives in `SettingsPageShell` (settings-3 / settings-16 DNA).
|
||||||
* @see https://reui.io/preview/base/settings-7
|
* @see https://reui.io/preview/base/settings-3
|
||||||
|
* @see https://reui.io/preview/base/settings-16
|
||||||
* @see https://reui.io/blocks
|
* @see https://reui.io/blocks
|
||||||
*/
|
*/
|
||||||
export function SettingsShell() {
|
export function SettingsShell() {
|
||||||
|
|||||||
@@ -163,7 +163,10 @@ function AutocompleteItem({
|
|||||||
<AutocompletePrimitive.Item
|
<AutocompletePrimitive.Item
|
||||||
data-slot="autocomplete-item"
|
data-slot="autocomplete-item"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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
@@ -0,0 +1,854 @@
|
|||||||
|
import type {
|
||||||
|
CascaderCollapse,
|
||||||
|
CascaderFlatNode,
|
||||||
|
CascaderIndex,
|
||||||
|
CascaderNode,
|
||||||
|
CascaderPathSegment,
|
||||||
|
CascaderSelectable,
|
||||||
|
} from "@/components/reui/cascader/cascader-types"
|
||||||
|
|
||||||
|
/** Root level key in `childrenOf`. NUL-prefixed so no real value collides. */
|
||||||
|
export const CASCADER_ROOT_KEY = "\u0000root"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paging pseudo-node prefix. Written as an escape sequence, not a raw 0x00
|
||||||
|
* byte: a literal NUL makes this module read as binary and `grep -I` skips it.
|
||||||
|
*/
|
||||||
|
export const CASCADER_MORE_PREFIX = "\u0000more:"
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Scroll layout */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four scroll classes. In the lib because `cascader-columns.tsx` needs them
|
||||||
|
* too and must not import `cascader.tsx`. Shell > bound > scrollport > rows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bound. `min()` over an undefined custom property is an INVALID
|
||||||
|
* declaration that drops the whole max-height, hence the `100vh` fallback: an
|
||||||
|
* inline panel has no positioner, so no `--available-height`. `24rem` is the
|
||||||
|
* default cap, so the common case sets no variable at all.
|
||||||
|
*/
|
||||||
|
export const CASCADER_LIST_HEIGHT_CLASS =
|
||||||
|
"max-h-[min(var(--available-height,100vh),var(--cascader-max-height,24rem))]"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each style's list padding: rows take it as `padding`, the scrollport as
|
||||||
|
* `scroll-padding` floored at 4px so lyra's `0` does not strand a row.
|
||||||
|
* Mirrored per style from `registry/styles/style-*.css`; keep them in step.
|
||||||
|
*/
|
||||||
|
export const CASCADER_LIST_PAD_CLASS =
|
||||||
|
"[--cascader-list-pad:4px]"
|
||||||
|
|
||||||
|
/** The scrollport: a `max-h-*` on the ScrollArea ROOT bounds nothing. */
|
||||||
|
export const CASCADER_SCROLL_CLASS =
|
||||||
|
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain **:data-[slot=scroll-area-viewport]:scroll-py-[max(var(--cascader-list-pad,4px),4px)]"
|
||||||
|
|
||||||
|
/** The rows' box; `data-empty:p-0` keeps an empty state from double inset. */
|
||||||
|
export const CASCADER_ROWS_CLASS = "p-(--cascader-list-pad,4px) data-empty:p-0"
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Tab order */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What counts as a keyboard stop. Base UI's ScrollArea viewport makes ITSELF
|
||||||
|
* tabbable when content overflows (`hiddenState.x && hiddenState.y ? -1 : 0`),
|
||||||
|
* an unnamed stop per level that `ui/scroll-area.tsx` will not let us suppress.
|
||||||
|
*/
|
||||||
|
const CASCADER_TAB_STOP_SELECTOR =
|
||||||
|
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
|
||||||
|
|
||||||
|
/** The stop to step over: nothing here focuses the scrollport on purpose. */
|
||||||
|
const CASCADER_TAB_SKIP_SELECTOR = '[data-slot="scroll-area-viewport"]'
|
||||||
|
|
||||||
|
/** Out of the tab order whatever they contain; layout is never consulted. */
|
||||||
|
const CASCADER_TAB_HIDDEN_SELECTOR = "[hidden],[inert],[aria-hidden='true']"
|
||||||
|
|
||||||
|
/** The panel's real keyboard stops, in DOM order. */
|
||||||
|
function getCascaderTabStops(panel: HTMLElement): HTMLElement[] {
|
||||||
|
const stops = Array.from(
|
||||||
|
panel.querySelectorAll<HTMLElement>(CASCADER_TAB_STOP_SELECTOR)
|
||||||
|
)
|
||||||
|
return stops.filter(
|
||||||
|
(stop) =>
|
||||||
|
// Option and trail rows are real `<button>`s with `tabindex="-1"`.
|
||||||
|
stop.getAttribute("tabindex") !== "-1" &&
|
||||||
|
!stop.matches(CASCADER_TAB_SKIP_SELECTOR) &&
|
||||||
|
!stop.closest(CASCADER_TAB_HIDDEN_SELECTOR)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The footer's own stops; an `aria-disabled` command still counts as one. */
|
||||||
|
export function getCascaderFooterStops(scope: HTMLElement): HTMLElement[] {
|
||||||
|
const footer = scope.matches('[data-slot="cascader-footer"]')
|
||||||
|
? scope
|
||||||
|
: scope.querySelector<HTMLElement>('[data-slot="cascader-footer"]')
|
||||||
|
if (!footer) return []
|
||||||
|
return getCascaderTabStops(footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where Tab lands, or `null` to let the browser out: focus is never trapped. */
|
||||||
|
export function getCascaderTabTarget(
|
||||||
|
panel: HTMLElement,
|
||||||
|
from: Element | null,
|
||||||
|
backwards: boolean
|
||||||
|
): HTMLElement | null {
|
||||||
|
const stops = getCascaderTabStops(panel)
|
||||||
|
if (stops.length === 0 || !from) return null
|
||||||
|
|
||||||
|
const index = stops.indexOf(from as HTMLElement)
|
||||||
|
if (index !== -1) return stops[index + (backwards ? -1 : 1)] ?? null
|
||||||
|
|
||||||
|
if (backwards) {
|
||||||
|
for (let i = stops.length - 1; i >= 0; i -= 1) {
|
||||||
|
const position = from.compareDocumentPosition(stops[i])
|
||||||
|
if (position & Node.DOCUMENT_POSITION_PRECEDING) return stops[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const stop of stops) {
|
||||||
|
const position = from.compareDocumentPosition(stop)
|
||||||
|
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return stop
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The writing direction at `element`. `DirectionProvider` may only vote yes:
|
||||||
|
* unmounted it answers `"ltr"`, which would override a real `<html dir="rtl">`.
|
||||||
|
*/
|
||||||
|
export function isCascaderRtl(element: Element, provided: string): boolean {
|
||||||
|
if (provided === "rtl") return true
|
||||||
|
const explicit = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
|
||||||
|
if (explicit === "rtl") return true
|
||||||
|
if (explicit === "ltr") return false
|
||||||
|
return (
|
||||||
|
element.ownerDocument?.defaultView?.getComputedStyle(element).direction ===
|
||||||
|
"rtl"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The paging row as a REAL node, not a DOM-only row after the list: every index
|
||||||
|
* Base UI hands out indexes the RENDERED array, so a DOM-only row shifts them.
|
||||||
|
*/
|
||||||
|
export function createCascaderMoreNode<T = unknown>(
|
||||||
|
parentKey: string,
|
||||||
|
loadedCount = 0
|
||||||
|
): CascaderNode<T> {
|
||||||
|
return {
|
||||||
|
value: `${CASCADER_MORE_PREFIX}${parentKey}`,
|
||||||
|
// Empty on purpose: a typeahead letter must never reach the paging row.
|
||||||
|
label: "",
|
||||||
|
// Feeds the row's "Loading more..." wording, with no second prop to thread.
|
||||||
|
count: loadedCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCascaderMoreNode<T>(node: CascaderNode<T>): boolean {
|
||||||
|
return node.value.startsWith(CASCADER_MORE_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The level key a paging pseudo-node belongs to, or `null` for a real node. */
|
||||||
|
export function getCascaderMoreParent<T>(node: CascaderNode<T>): string | null {
|
||||||
|
if (!isCascaderMoreNode(node)) return null
|
||||||
|
return node.value.slice(CASCADER_MORE_PREFIX.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Either input shape into one index. Cycle-guarded depths; first wins. */
|
||||||
|
export function buildCascaderIndex<T = unknown>(
|
||||||
|
items: readonly CascaderNode<T>[] | undefined,
|
||||||
|
getParent?: (node: CascaderNode<T>) => string | null | undefined
|
||||||
|
): CascaderIndex<T> {
|
||||||
|
const byValue = new Map<string, CascaderNode<T>>()
|
||||||
|
const childrenOf = new Map<string, CascaderNode<T>[]>()
|
||||||
|
const parentOf = new Map<string, string | null>()
|
||||||
|
const depthOf = new Map<string, number>()
|
||||||
|
const all: CascaderNode<T>[] = []
|
||||||
|
|
||||||
|
const push = (parentKey: string, node: CascaderNode<T>) => {
|
||||||
|
const bucket = childrenOf.get(parentKey)
|
||||||
|
if (bucket) {
|
||||||
|
bucket.push(node)
|
||||||
|
} else {
|
||||||
|
childrenOf.set(parentKey, [node])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getParent) {
|
||||||
|
for (const node of items ?? []) {
|
||||||
|
// A nullish entry is a data bug: skip it like a duplicate, say so in dev.
|
||||||
|
if (node == null) {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
warnCascaderOnce(
|
||||||
|
"nullish-entry",
|
||||||
|
"Ignored a null or undefined entry in `items` or `children`. Check the arrays you pass in."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (byValue.has(node.value)) continue
|
||||||
|
byValue.set(node.value, node)
|
||||||
|
all.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of all) {
|
||||||
|
const rawParent = getParent(node)
|
||||||
|
// An unknown parent makes the node a root rather than dropping the row.
|
||||||
|
const parent =
|
||||||
|
rawParent != null && byValue.has(rawParent) ? rawParent : null
|
||||||
|
parentOf.set(node.value, parent)
|
||||||
|
push(parent ?? CASCADER_ROOT_KEY, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of all) {
|
||||||
|
let depth = 0
|
||||||
|
let cursor = parentOf.get(node.value) ?? null
|
||||||
|
const seen = new Set<string>([node.value])
|
||||||
|
while (cursor != null && !seen.has(cursor)) {
|
||||||
|
seen.add(cursor)
|
||||||
|
depth += 1
|
||||||
|
cursor = parentOf.get(cursor) ?? null
|
||||||
|
}
|
||||||
|
depthOf.set(node.value, depth)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const walk = (
|
||||||
|
nodes: readonly CascaderNode<T>[] | undefined,
|
||||||
|
parent: string | null,
|
||||||
|
depth: number
|
||||||
|
) => {
|
||||||
|
for (const node of nodes ?? []) {
|
||||||
|
// Same degradation as the flat path above.
|
||||||
|
if (node == null) {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
warnCascaderOnce(
|
||||||
|
"nullish-entry",
|
||||||
|
"Ignored a null or undefined entry in `items` or `children`. Check the arrays you pass in."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (byValue.has(node.value)) continue
|
||||||
|
byValue.set(node.value, node)
|
||||||
|
parentOf.set(node.value, parent)
|
||||||
|
depthOf.set(node.value, depth)
|
||||||
|
all.push(node)
|
||||||
|
push(parent ?? CASCADER_ROOT_KEY, node)
|
||||||
|
if (node.children?.length) walk(node.children, node.value, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(items, null, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
byValue,
|
||||||
|
childrenOf,
|
||||||
|
parentOf,
|
||||||
|
depthOf,
|
||||||
|
roots: childrenOf.get(CASCADER_ROOT_KEY) ?? [],
|
||||||
|
all,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds loaded pages into an index built from `items`, so a re-render with a
|
||||||
|
* new `items` array keeps what the user drilled into. Static `items` wins;
|
||||||
|
* `detached` is `byValue` ONLY, or deep search doubles it and `childrenOf` lies.
|
||||||
|
*/
|
||||||
|
export function mergeCascaderIndex<T = unknown>(
|
||||||
|
base: CascaderIndex<T>,
|
||||||
|
pages: ReadonlyMap<string, readonly CascaderNode<T>[]>,
|
||||||
|
detached?: ReadonlyMap<string, CascaderNode<T>>
|
||||||
|
): CascaderIndex<T> {
|
||||||
|
// Identity stability: every downstream `useMemo` is keyed on this index.
|
||||||
|
if (pages.size === 0 && !detached?.size) return base
|
||||||
|
|
||||||
|
const byValue = new Map(base.byValue)
|
||||||
|
const childrenOf = new Map(base.childrenOf)
|
||||||
|
const parentOf = new Map(base.parentOf)
|
||||||
|
const depthOf = new Map(base.depthOf)
|
||||||
|
|
||||||
|
// Copy-on-write per bucket: only levels that got a page pay for a new array.
|
||||||
|
const copied = new Set<string>()
|
||||||
|
const append = (parentKey: string, node: CascaderNode<T>) => {
|
||||||
|
let bucket = childrenOf.get(parentKey)
|
||||||
|
if (!copied.has(parentKey)) {
|
||||||
|
bucket = bucket ? bucket.slice() : []
|
||||||
|
childrenOf.set(parentKey, bucket)
|
||||||
|
copied.add(parentKey)
|
||||||
|
}
|
||||||
|
bucket!.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
const insert = (parentKey: string, nodes: readonly CascaderNode<T>[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (byValue.has(node.value)) continue
|
||||||
|
byValue.set(node.value, node)
|
||||||
|
parentOf.set(
|
||||||
|
node.value,
|
||||||
|
parentKey === CASCADER_ROOT_KEY ? null : parentKey
|
||||||
|
)
|
||||||
|
append(parentKey, node)
|
||||||
|
if (node.children?.length) insert(node.value, node.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [parentKey, nodes] of pages) insert(parentKey, nodes)
|
||||||
|
|
||||||
|
for (const value of byValue.keys()) {
|
||||||
|
if (depthOf.has(value)) continue
|
||||||
|
let depth = 0
|
||||||
|
let cursor = parentOf.get(value) ?? null
|
||||||
|
const seen = new Set<string>([value])
|
||||||
|
while (cursor != null && !seen.has(cursor)) {
|
||||||
|
seen.add(cursor)
|
||||||
|
depth += 1
|
||||||
|
cursor = parentOf.get(cursor) ?? null
|
||||||
|
}
|
||||||
|
depthOf.set(value, depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots = childrenOf.get(CASCADER_ROOT_KEY) ?? []
|
||||||
|
|
||||||
|
// Depth first over the MERGED tree, so `all` keeps document order.
|
||||||
|
const all: CascaderNode<T>[] = []
|
||||||
|
const visited = new Set<string>()
|
||||||
|
const walk = (nodes: readonly CascaderNode<T>[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (visited.has(node.value)) continue
|
||||||
|
visited.add(node.value)
|
||||||
|
all.push(node)
|
||||||
|
const children = childrenOf.get(node.value)
|
||||||
|
if (children?.length) walk(children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(roots)
|
||||||
|
|
||||||
|
// A page whose parent never arrived stays searchable rather than vanishing.
|
||||||
|
for (const [value, node] of byValue) {
|
||||||
|
if (visited.has(value)) continue
|
||||||
|
visited.add(value)
|
||||||
|
all.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detached) {
|
||||||
|
for (const [value, node] of detached) {
|
||||||
|
if (byValue.has(value)) continue
|
||||||
|
byValue.set(value, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { byValue, childrenOf, parentOf, depthOf, roots, all }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Children of `parent`, or the root level when `parent` is nullish. */
|
||||||
|
export function getCascaderChildren<T>(
|
||||||
|
index: CascaderIndex<T>,
|
||||||
|
parent?: string | null
|
||||||
|
): CascaderNode<T>[] {
|
||||||
|
return index.childrenOf.get(parent ?? CASCADER_ROOT_KEY) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A branch has known children, or `hasChildren` for an unfetched level. */
|
||||||
|
export function isCascaderBranch<T>(
|
||||||
|
index: CascaderIndex<T>,
|
||||||
|
node: CascaderNode<T>
|
||||||
|
): boolean {
|
||||||
|
if (node.hasChildren) return true
|
||||||
|
return (index.childrenOf.get(node.value)?.length ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trailing count for the default row. Explicit `count` wins over the tree. */
|
||||||
|
export function getCascaderCount<T>(
|
||||||
|
index: CascaderIndex<T>,
|
||||||
|
node: CascaderNode<T>
|
||||||
|
): number {
|
||||||
|
if (typeof node.count === "number") return node.count
|
||||||
|
return index.childrenOf.get(node.value)?.length ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a node may be committed as a selection. */
|
||||||
|
export function isCascaderSelectable<T>(
|
||||||
|
index: CascaderIndex<T>,
|
||||||
|
node: CascaderNode<T>,
|
||||||
|
selectable: CascaderSelectable<T>
|
||||||
|
): boolean {
|
||||||
|
// Before the branches a consumer controls: `selectable="any"` says yes to
|
||||||
|
// every node, and committing the paging row would select a level's name.
|
||||||
|
if (isCascaderMoreNode(node)) return false
|
||||||
|
if (node.disabled) return false
|
||||||
|
// A disabled ANCESTOR refuses the whole subtree: `searchCascaderDeep` still
|
||||||
|
// surfaces children of a branch the UI will not let anyone open.
|
||||||
|
{
|
||||||
|
const seen = new Set<string>([node.value])
|
||||||
|
let cursor = index.parentOf.get(node.value) ?? null
|
||||||
|
while (cursor != null && !seen.has(cursor)) {
|
||||||
|
seen.add(cursor)
|
||||||
|
if (index.byValue.get(cursor)?.disabled) return false
|
||||||
|
cursor = index.parentOf.get(cursor) ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof selectable === "function") return selectable(node)
|
||||||
|
if (selectable === "any") return true
|
||||||
|
return !isCascaderBranch(index, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ancestor chain for `value`, root first. Empty while async data loads. */
|
||||||
|
export function getCascaderPath<T>(
|
||||||
|
index: CascaderIndex<T>,
|
||||||
|
value: string | null | undefined
|
||||||
|
): CascaderNode<T>[] {
|
||||||
|
if (value == null) return []
|
||||||
|
const chain: CascaderNode<T>[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
let cursor: string | null | undefined = value
|
||||||
|
while (cursor != null && !seen.has(cursor)) {
|
||||||
|
seen.add(cursor)
|
||||||
|
const node = index.byValue.get(cursor)
|
||||||
|
if (!node) break
|
||||||
|
chain.push(node)
|
||||||
|
cursor = index.parentOf.get(cursor) ?? null
|
||||||
|
}
|
||||||
|
return chain.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `toLocaleLowerCase`, not `toLowerCase`: the invariant mapping turns Turkish
|
||||||
|
* "I" into "i" not "ı", so "ışık" would never match "IŞIK". Both sides fold here.
|
||||||
|
*/
|
||||||
|
export function foldCascaderText(text: string): string {
|
||||||
|
return text.toLocaleLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Folds once so callers can hoist the cost out of a per-node loop. */
|
||||||
|
export function normalizeCascaderQuery(query: string): string {
|
||||||
|
return foldCascaderText(query.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive substring over label and keywords; pre-fold the query. */
|
||||||
|
export function matchesCascaderQuery<T>(
|
||||||
|
node: CascaderNode<T>,
|
||||||
|
normalized: string
|
||||||
|
): boolean {
|
||||||
|
if (!normalized) return true
|
||||||
|
// Coerced, not trusted: a label-less node is malformed data, not a crash.
|
||||||
|
if (foldCascaderText(node.label ?? "").includes(normalized)) return true
|
||||||
|
if (node.keywords) {
|
||||||
|
for (const keyword of node.keywords) {
|
||||||
|
if (foldCascaderText(keyword).includes(normalized)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters one level in place-order. Returns the input when the query is empty. */
|
||||||
|
export function filterCascaderLevel<T>(
|
||||||
|
nodes: readonly CascaderNode<T>[],
|
||||||
|
query: string,
|
||||||
|
matches: (
|
||||||
|
node: CascaderNode<T>,
|
||||||
|
normalized: string
|
||||||
|
) => boolean = matchesCascaderQuery
|
||||||
|
): CascaderNode<T>[] {
|
||||||
|
const normalized = normalizeCascaderQuery(query)
|
||||||
|
if (!normalized) return nodes as CascaderNode<T>[]
|
||||||
|
return nodes.filter((node) => matches(node, normalized))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Searches every node, optionally under `within`, in one pass over `all`. */
|
||||||
|
export function searchCascaderDeep<T>(
|
||||||
|
index: CascaderIndex<T>,
|
||||||
|
query: string,
|
||||||
|
options: {
|
||||||
|
within?: string | null
|
||||||
|
limit?: number
|
||||||
|
matches?: (node: CascaderNode<T>, normalized: string) => boolean
|
||||||
|
} = {}
|
||||||
|
): CascaderNode<T>[] {
|
||||||
|
const normalized = normalizeCascaderQuery(query)
|
||||||
|
if (!normalized) return []
|
||||||
|
|
||||||
|
const { within, limit = 200, matches = matchesCascaderQuery } = options
|
||||||
|
const results: CascaderNode<T>[] = []
|
||||||
|
|
||||||
|
// One memoised ancestry map per query. The provisional `false` written on the
|
||||||
|
// way up doubles as the cycle guard; the trail is promoted once `within` hits.
|
||||||
|
const ancestry = within == null ? null : new Map<string, boolean>()
|
||||||
|
const isWithin = (node: CascaderNode<T>) => {
|
||||||
|
if (within == null || !ancestry) return true
|
||||||
|
const trail: string[] = []
|
||||||
|
let answer = false
|
||||||
|
let cursor: string | null | undefined = index.parentOf.get(node.value)
|
||||||
|
while (cursor != null) {
|
||||||
|
if (cursor === within) {
|
||||||
|
answer = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const cached = ancestry.get(cursor)
|
||||||
|
if (cached !== undefined) {
|
||||||
|
answer = cached
|
||||||
|
break
|
||||||
|
}
|
||||||
|
ancestry.set(cursor, false)
|
||||||
|
trail.push(cursor)
|
||||||
|
cursor = index.parentOf.get(cursor) ?? null
|
||||||
|
}
|
||||||
|
if (answer) for (const value of trail) ancestry.set(value, true)
|
||||||
|
return answer
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of index.all) {
|
||||||
|
if (results.length >= limit) break
|
||||||
|
if (!matches(node, normalized)) continue
|
||||||
|
if (!isWithin(node)) continue
|
||||||
|
results.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens the tree into tree mode's visible rows. A `sentinels` paging row
|
||||||
|
* COUNTS as a sibling in `aria-setsize`: it holds an index of its own.
|
||||||
|
*/
|
||||||
|
export function flattenCascaderTree<T>(
|
||||||
|
index: CascaderIndex<T>,
|
||||||
|
expanded: ReadonlySet<string>,
|
||||||
|
sentinels?: ReadonlySet<string>
|
||||||
|
): CascaderFlatNode<T>[] {
|
||||||
|
const rows: CascaderFlatNode<T>[] = []
|
||||||
|
|
||||||
|
const walk = (
|
||||||
|
nodes: readonly CascaderNode<T>[],
|
||||||
|
parentKey: string,
|
||||||
|
depth: number
|
||||||
|
) => {
|
||||||
|
const sentinel = sentinels?.has(parentKey) ?? false
|
||||||
|
const setSize = nodes.length + (sentinel ? 1 : 0)
|
||||||
|
for (let i = 0; i < nodes.length; i += 1) {
|
||||||
|
const node = nodes[i]
|
||||||
|
const branch = isCascaderBranch(index, node)
|
||||||
|
const isExpanded = branch && expanded.has(node.value)
|
||||||
|
rows.push({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
branch,
|
||||||
|
expanded: isExpanded,
|
||||||
|
setSize,
|
||||||
|
posInSet: i + 1,
|
||||||
|
})
|
||||||
|
if (isExpanded) {
|
||||||
|
walk(index.childrenOf.get(node.value) ?? [], node.value, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sentinel) {
|
||||||
|
rows.push({
|
||||||
|
node: createCascaderMoreNode<T>(parentKey, nodes.length),
|
||||||
|
depth,
|
||||||
|
branch: false,
|
||||||
|
expanded: false,
|
||||||
|
setSize,
|
||||||
|
posInSet: setSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(index.roots, CASCADER_ROOT_KEY, 0)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shortens a path; the ellipsis segment still carries the hidden nodes. */
|
||||||
|
export function collapseCascaderPath<T>(
|
||||||
|
path: readonly CascaderNode<T>[],
|
||||||
|
options: { maxSegments?: number; collapse?: CascaderCollapse } = {}
|
||||||
|
): CascaderPathSegment<T>[] {
|
||||||
|
const { maxSegments = 3, collapse = "middle" } = options
|
||||||
|
|
||||||
|
const asNodes = (
|
||||||
|
nodes: readonly CascaderNode<T>[]
|
||||||
|
): CascaderPathSegment<T>[] =>
|
||||||
|
nodes.map((node) => ({ type: "node" as const, node }))
|
||||||
|
|
||||||
|
if (collapse === "none" || maxSegments <= 0 || path.length <= maxSegments) {
|
||||||
|
return asNodes(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collapse === "start") {
|
||||||
|
const tail = path.slice(path.length - maxSegments)
|
||||||
|
return [
|
||||||
|
{ type: "ellipsis", hidden: path.slice(0, path.length - maxSegments) },
|
||||||
|
...asNodes(tail),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// "middle": keep the root for orientation and as much of the tail as fits.
|
||||||
|
const head = path.slice(0, 1)
|
||||||
|
const tailCount = maxSegments - 1
|
||||||
|
const tail = path.slice(path.length - tailCount)
|
||||||
|
const hidden = path.slice(1, path.length - tailCount)
|
||||||
|
|
||||||
|
if (hidden.length === 0) return asNodes(path)
|
||||||
|
|
||||||
|
return [...asNodes(head), { type: "ellipsis", hidden }, ...asNodes(tail)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Cascade selection */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every LOADED node under `value`, root first. Reaching an unfetched descendant
|
||||||
|
* would mean a network request per level of the subtree on every selection.
|
||||||
|
*/
|
||||||
|
export function collectCascaderSubtree<T>(
|
||||||
|
index: CascaderIndex<T>,
|
||||||
|
value: string
|
||||||
|
): CascaderNode<T>[] {
|
||||||
|
const root = index.byValue.get(value)
|
||||||
|
if (!root) return []
|
||||||
|
|
||||||
|
const out: CascaderNode<T>[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const walk = (node: CascaderNode<T>) => {
|
||||||
|
if (seen.has(node.value)) return
|
||||||
|
seen.add(node.value)
|
||||||
|
out.push(node)
|
||||||
|
const children = index.childrenOf.get(node.value)
|
||||||
|
if (children) for (const child of children) walk(child)
|
||||||
|
}
|
||||||
|
walk(root)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles `value` with its loaded subtree, then reconciles ancestors. One flat
|
||||||
|
* array, no hidden "checked" state: a branch is in the selection exactly when
|
||||||
|
* every selectable loaded child of it is, which keeps a row's checked state an
|
||||||
|
* O(1) lookup. Nodes `isSelectable` rejects are skipped down AND ignored up,
|
||||||
|
* the pressed node excepted.
|
||||||
|
*/
|
||||||
|
export function applyCascadeSelection<T>(
|
||||||
|
index: CascaderIndex<T>,
|
||||||
|
selected: readonly string[],
|
||||||
|
value: string,
|
||||||
|
select: boolean,
|
||||||
|
isSelectable: (node: CascaderNode<T>) => boolean = () => true
|
||||||
|
): string[] {
|
||||||
|
const next = new Set(selected)
|
||||||
|
const subtree = collectCascaderSubtree(index, value)
|
||||||
|
|
||||||
|
// An unknown value still toggles itself, so an early async selection lands.
|
||||||
|
if (subtree.length === 0) {
|
||||||
|
if (select) next.add(value)
|
||||||
|
else next.delete(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of subtree) {
|
||||||
|
// The pressed node is exempt: it was committed, so it is selectable.
|
||||||
|
if (node.value !== value && !isSelectable(node)) continue
|
||||||
|
if (select) next.add(node.value)
|
||||||
|
else next.delete(node.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom up: a parent can only answer once its children have.
|
||||||
|
const seen = new Set<string>([value])
|
||||||
|
let cursor = index.parentOf.get(value) ?? null
|
||||||
|
while (cursor != null && !seen.has(cursor)) {
|
||||||
|
seen.add(cursor)
|
||||||
|
const parent = index.byValue.get(cursor)
|
||||||
|
const children = index.childrenOf.get(cursor) ?? []
|
||||||
|
const selectable = children.filter((child) => isSelectable(child))
|
||||||
|
const full =
|
||||||
|
selectable.length > 0 &&
|
||||||
|
selectable.every((child) => next.has(child.value)) &&
|
||||||
|
// Never promote a node the consumer said may not be committed. Under
|
||||||
|
// `selectable="leaf"` no branch qualifies, hence the `cascade` warning.
|
||||||
|
!!parent &&
|
||||||
|
isSelectable(parent)
|
||||||
|
if (full) next.add(cursor)
|
||||||
|
else next.delete(cursor)
|
||||||
|
cursor = index.parentOf.get(cursor) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many selected nodes each value has BELOW it, itself excluded. Walks UP
|
||||||
|
* from each selection: a per-row subtree scan would be quadratic and re-paid.
|
||||||
|
*/
|
||||||
|
export function getCascaderSelectedDescendants<T>(
|
||||||
|
index: CascaderIndex<T>,
|
||||||
|
selected: readonly string[]
|
||||||
|
): Map<string, number> {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const value of selected) {
|
||||||
|
const seen = new Set<string>([value])
|
||||||
|
let cursor = index.parentOf.get(value) ?? null
|
||||||
|
while (cursor != null && !seen.has(cursor)) {
|
||||||
|
seen.add(cursor)
|
||||||
|
counts.set(cursor, (counts.get(cursor) ?? 0) + 1)
|
||||||
|
cursor = index.parentOf.get(cursor) ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The PARTIALLY selected values, read off counts the caller already holds. */
|
||||||
|
export function getCascaderIndeterminateFrom(
|
||||||
|
counts: ReadonlyMap<string, number>,
|
||||||
|
selected: readonly string[]
|
||||||
|
): Set<string> {
|
||||||
|
const selectedSet = new Set(selected)
|
||||||
|
const partial = new Set<string>()
|
||||||
|
|
||||||
|
for (const [value, count] of counts) {
|
||||||
|
if (count > 0 && !selectedSet.has(value)) partial.add(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return partial
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one-call form; the root uses the two halves above and its own counts. */
|
||||||
|
export function getCascaderIndeterminate<T>(
|
||||||
|
index: CascaderIndex<T>,
|
||||||
|
selected: readonly string[]
|
||||||
|
): Set<string> {
|
||||||
|
return getCascaderIndeterminateFrom(
|
||||||
|
getCascaderSelectedDescendants(index, selected),
|
||||||
|
selected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How `getCascaderCheckedValues` condenses a full-closure selection. */
|
||||||
|
export type CascaderCheckedStrategy = "all" | "parent" | "child"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cascade selection under a reporting strategy. DERIVED OUTPUT ONLY: the
|
||||||
|
* STORED value stays the full closure, which keeps a checked state an O(1)
|
||||||
|
* lookup. `"all"` is the closure; `"parent"` drops a value whose parent is
|
||||||
|
* selected; `"child"` drops one with a selected child.
|
||||||
|
*/
|
||||||
|
export function getCascaderCheckedValues<T>(
|
||||||
|
index: CascaderIndex<T>,
|
||||||
|
selected: readonly string[],
|
||||||
|
strategy: CascaderCheckedStrategy
|
||||||
|
): readonly string[] {
|
||||||
|
if (strategy === "all") return selected
|
||||||
|
|
||||||
|
const set = new Set(selected)
|
||||||
|
if (strategy === "parent") {
|
||||||
|
return selected.filter((value) => {
|
||||||
|
const parent = index.parentOf.get(value)
|
||||||
|
return parent == null || !set.has(parent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected.filter((value) => {
|
||||||
|
const children = index.childrenOf.get(value)
|
||||||
|
if (!children?.length) return true
|
||||||
|
return !children.some((child) => set.has(child.value))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Values in `nodes` whose label collides, so the chip must show its path. */
|
||||||
|
export function findAmbiguousCascaderLabels<T>(
|
||||||
|
nodes: readonly CascaderNode<T>[]
|
||||||
|
): Set<string> {
|
||||||
|
const firstByLabel = new Map<string, string>()
|
||||||
|
const ambiguous = new Set<string>()
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const first = firstByLabel.get(node.label)
|
||||||
|
if (first === undefined) {
|
||||||
|
firstByLabel.set(node.label, node.value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ambiguous.add(node.value)
|
||||||
|
ambiguous.add(first)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ambiguous
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Development only */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** Warnings already emitted. Module scoped, or they repeat once per render. */
|
||||||
|
const CASCADER_WARNED = new Set<string>()
|
||||||
|
|
||||||
|
/** Warns once per `key`, never in production, never by throwing. */
|
||||||
|
export function warnCascaderOnce(key: string, message: string): void {
|
||||||
|
if (process.env.NODE_ENV === "production") return
|
||||||
|
if (CASCADER_WARNED.has(key)) return
|
||||||
|
CASCADER_WARNED.add(key)
|
||||||
|
console.warn(`[Cascader] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empties the ledger. Tests only; a warning is meant to be seen once. */
|
||||||
|
export function resetCascaderWarnings(): void {
|
||||||
|
CASCADER_WARNED.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a dev-time scan of the consumer's `items` found wrong with it. */
|
||||||
|
export interface CascaderDataIssues {
|
||||||
|
/** Values appearing more than once. First wins, so a duplicate drops a row. */
|
||||||
|
duplicates: string[]
|
||||||
|
/** Values on a `getParent` cycle. Depth is clamped, so the tree reads wrong. */
|
||||||
|
cycles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dev-time scan, kept out of `buildCascaderIndex` so the build stays hot. */
|
||||||
|
export function findCascaderDataIssues<T>(
|
||||||
|
items: readonly CascaderNode<T>[] | undefined,
|
||||||
|
getParent?: (node: CascaderNode<T>) => string | null | undefined
|
||||||
|
): CascaderDataIssues {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const duplicates = new Set<string>()
|
||||||
|
const flat: CascaderNode<T>[] = []
|
||||||
|
|
||||||
|
const visit = (nodes: readonly CascaderNode<T>[] | undefined) => {
|
||||||
|
for (const node of nodes ?? []) {
|
||||||
|
// The build skips a nullish entry, and this runs BEFORE its warning.
|
||||||
|
if (node == null) continue
|
||||||
|
if (seen.has(node.value)) duplicates.add(node.value)
|
||||||
|
else seen.add(node.value)
|
||||||
|
flat.push(node)
|
||||||
|
// Flat mode walks them too: BOTH `children` and `getParent` is a bug.
|
||||||
|
if (node.children?.length) visit(node.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(items)
|
||||||
|
|
||||||
|
const cycles: string[] = []
|
||||||
|
if (getParent) {
|
||||||
|
const parentOf = new Map<string, string | null>()
|
||||||
|
for (const node of flat) {
|
||||||
|
if (parentOf.has(node.value)) continue
|
||||||
|
const raw = getParent(node)
|
||||||
|
parentOf.set(node.value, raw != null && seen.has(raw) ? raw : null)
|
||||||
|
}
|
||||||
|
for (const node of flat) {
|
||||||
|
const walked = new Set<string>([node.value])
|
||||||
|
let cursor = parentOf.get(node.value) ?? null
|
||||||
|
while (cursor != null) {
|
||||||
|
if (walked.has(cursor)) {
|
||||||
|
cycles.push(node.value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
walked.add(cursor)
|
||||||
|
cursor = parentOf.get(cursor) ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { duplicates: Array.from(duplicates), cycles }
|
||||||
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
useCascaderActions,
|
||||||
|
useCascaderState,
|
||||||
|
} from "@/components/reui/cascader/cascader-context"
|
||||||
|
import { resolveCascaderSearchLabel } from "@/components/reui/cascader/cascader-i18n"
|
||||||
|
import {
|
||||||
|
collapseCascaderPath,
|
||||||
|
getCascaderFooterStops,
|
||||||
|
getCascaderPath,
|
||||||
|
isCascaderRtl,
|
||||||
|
} from "@/components/reui/cascader/cascader-lib"
|
||||||
|
import type {
|
||||||
|
CascaderCollapse,
|
||||||
|
CascaderNode,
|
||||||
|
CascaderValueDisplay,
|
||||||
|
} from "@/components/reui/cascader/cascader-types"
|
||||||
|
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||||
|
import { useDirection } from "@base-ui/react/direction-provider"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import { ChevronRightIcon, ChevronLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Separator */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function PathChevron() {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon className="size-3 shrink-0 opacity-50 rtl:-scale-x-100" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Nav */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export type CascaderNavProps = useRender.ComponentProps<"div">
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header: back control, search input, and the separator under them. The
|
||||||
|
* breadcrumb belongs with the list below: it describes the rows, not the field.
|
||||||
|
*/
|
||||||
|
function CascaderNav({ className, ...props }: CascaderNavProps) {
|
||||||
|
const defaultProps = {
|
||||||
|
"data-slot": "cascader-nav",
|
||||||
|
className: cn(
|
||||||
|
// `py-1`, not `py-1.5`: the row inside sets its own per-style height, so
|
||||||
|
// even padding read too tall. 6px beside the field, 4px above and below.
|
||||||
|
"border-border/60 flex shrink-0 flex-col gap-1 border-b px-1.5 py-1",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
render: props.render,
|
||||||
|
props: mergeProps<"div">(defaultProps, props),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Back */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export interface CascaderBackProps extends Omit<
|
||||||
|
useRender.ComponentProps<"button">,
|
||||||
|
"children"
|
||||||
|
> {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pops one level. Renders nothing at the root, so no dead header space. */
|
||||||
|
function CascaderBack({ className, children, ...props }: CascaderBackProps) {
|
||||||
|
const { popLevel, labels, mode } = useCascaderActions()
|
||||||
|
const { path } = useCascaderState()
|
||||||
|
|
||||||
|
// Checked after `useRender`, never before it: an early return would change
|
||||||
|
// the hook count between the root level and any deeper one.
|
||||||
|
const hidden = mode !== "drill" || path.length === 0
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
"data-slot": "cascader-back",
|
||||||
|
type: "button" as const,
|
||||||
|
"aria-label": labels.back,
|
||||||
|
onClick: () => popLevel(),
|
||||||
|
className: cn(
|
||||||
|
"text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 flex shrink-0 items-center justify-center rounded-md outline-hidden transition-colors focus-visible:ring-2",
|
||||||
|
// A notch under the row height: an affordance, not a second field.
|
||||||
|
"size-6",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
children: children ?? (
|
||||||
|
<ChevronLeftIcon className="size-4 rtl:-scale-x-100" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = useRender({
|
||||||
|
defaultTagName: "button",
|
||||||
|
render: props.render,
|
||||||
|
props: mergeProps<"button">(defaultProps, props),
|
||||||
|
})
|
||||||
|
|
||||||
|
return hidden ? null : element
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Breadcrumb */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export interface CascaderBreadcrumbProps extends Omit<
|
||||||
|
useRender.ComponentProps<"nav">,
|
||||||
|
"children"
|
||||||
|
> {
|
||||||
|
/** Maximum visible node segments before the middle collapses. */
|
||||||
|
maxSegments?: number
|
||||||
|
collapse?: CascaderCollapse
|
||||||
|
/** Clicking a segment navigates back to that level. Defaults to true. */
|
||||||
|
interactive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact trail of the current level AND its ancestors, collapsed by the same
|
||||||
|
* helper as `CascaderValue` so panel and trigger cannot disagree. The current
|
||||||
|
* level IS the last crumb: dropping it read as a rendering bug, a trail naming
|
||||||
|
* only the places you are not.
|
||||||
|
*/
|
||||||
|
function CascaderBreadcrumb({
|
||||||
|
className,
|
||||||
|
maxSegments = 3,
|
||||||
|
collapse = "middle",
|
||||||
|
interactive = true,
|
||||||
|
...props
|
||||||
|
}: CascaderBreadcrumbProps) {
|
||||||
|
const { goToDepth, mode, labels } = useCascaderActions()
|
||||||
|
const { path, index } = useCascaderState()
|
||||||
|
|
||||||
|
// The WHOLE path, current level included; unresolvable entries drop out.
|
||||||
|
const nodes = path
|
||||||
|
.map((value) => index.byValue.get(value))
|
||||||
|
.filter(Boolean) as CascaderNode[]
|
||||||
|
const segments = collapseCascaderPath(nodes, { maxSegments, collapse })
|
||||||
|
// `collapseCascaderPath` never collapses the last node away, so this is
|
||||||
|
// always the final segment - derived rather than assumed, so a `collapse`
|
||||||
|
// mode that ever changes that cannot mark an ancestor as the current page.
|
||||||
|
const currentValue = nodes.length ? nodes[nodes.length - 1].value : null
|
||||||
|
|
||||||
|
// See `CascaderBack`: the hide check must not short-circuit past `useRender`.
|
||||||
|
const hidden = mode !== "drill" || nodes.length === 0
|
||||||
|
|
||||||
|
const goTo = (node: CascaderNode) => {
|
||||||
|
const depth = path.indexOf(node.value)
|
||||||
|
if (depth < 0) return
|
||||||
|
// `goToDepth`, not a bare `setPath`: it reports `reason: "breadcrumb"`,
|
||||||
|
// clears the query and drops any pending navigation.
|
||||||
|
goToDepth(depth + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
"data-slot": "cascader-breadcrumb",
|
||||||
|
"aria-label": labels.breadcrumbLabel,
|
||||||
|
className: cn(
|
||||||
|
"text-muted-foreground flex min-w-0 shrink-0 items-center gap-0.5 pt-1.5 pb-0.5 text-xs",
|
||||||
|
// Lines up with the ROWS, not the header: each style's list padding plus
|
||||||
|
// its row inset. vega/mira/rhea `p-1`+`pl-2`, nova `p-1`+`pl-1.5`, maia
|
||||||
|
// `p-1`+`pl-3`, lyra `pl-2`, luma/sera `p-1.5`+`pl-3`, per style-*.css.
|
||||||
|
"px-2.5",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
children: segments.map((segment, i) => (
|
||||||
|
<React.Fragment
|
||||||
|
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
|
||||||
|
>
|
||||||
|
{i > 0 ? <PathChevron /> : null}
|
||||||
|
{segment.type === "ellipsis" ? (
|
||||||
|
<span
|
||||||
|
data-slot="cascader-breadcrumb-ellipsis"
|
||||||
|
title={segment.hidden
|
||||||
|
.map((n) => n.label)
|
||||||
|
.join(` ${labels.pathSeparator} `)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : interactive && segment.node.value !== currentValue ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-slot="cascader-breadcrumb-item"
|
||||||
|
onClick={() => goTo(segment.node)}
|
||||||
|
className="hover:text-foreground focus-visible:ring-ring/50 max-w-32 truncate rounded-sm outline-hidden transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
{segment.node.label}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
data-slot="cascader-breadcrumb-item"
|
||||||
|
/* The level on screen: a span because it goes nowhere, plus
|
||||||
|
`aria-current="page"` so it is not read as one of a flat list. */
|
||||||
|
{...(segment.node.value === currentValue
|
||||||
|
? { "aria-current": "page" as const }
|
||||||
|
: null)}
|
||||||
|
className={cn(
|
||||||
|
"max-w-32 truncate",
|
||||||
|
segment.node.value === currentValue &&
|
||||||
|
"text-foreground font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{segment.node.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = useRender({
|
||||||
|
defaultTagName: "nav",
|
||||||
|
render: props.render,
|
||||||
|
props: mergeProps<"nav">(defaultProps, props),
|
||||||
|
})
|
||||||
|
|
||||||
|
return hidden ? null : element
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Input */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export interface CascaderInputProps extends ComboboxPrimitive.Input.Props {
|
||||||
|
/** Renders the back control inline, before the field. Defaults to true. */
|
||||||
|
showBack?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base UI hands its input handlers an event carrying the veto hook. */
|
||||||
|
type CascaderInputKeyEvent = Parameters<
|
||||||
|
NonNullable<ComboboxPrimitive.Input.Props["onKeyDown"]>
|
||||||
|
>[0]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search field. MUST render inside the positioner: only there does Base UI skip
|
||||||
|
* the refill from the committed selection that would fight every level swap.
|
||||||
|
*/
|
||||||
|
function CascaderInput({
|
||||||
|
className,
|
||||||
|
showBack = true,
|
||||||
|
placeholder,
|
||||||
|
onKeyDown,
|
||||||
|
"aria-describedby": ariaDescribedBy,
|
||||||
|
...props
|
||||||
|
}: CascaderInputProps) {
|
||||||
|
const {
|
||||||
|
labels,
|
||||||
|
popLevel,
|
||||||
|
mode,
|
||||||
|
getHighlighted,
|
||||||
|
isBranch,
|
||||||
|
navigate,
|
||||||
|
index,
|
||||||
|
toggleExpanded,
|
||||||
|
inline,
|
||||||
|
invalid,
|
||||||
|
baseId,
|
||||||
|
} = useCascaderActions()
|
||||||
|
const direction = useDirection()
|
||||||
|
const { currentParent, query, path, renderedItems, treeRows } =
|
||||||
|
useCascaderState()
|
||||||
|
|
||||||
|
const resolvedPlaceholder =
|
||||||
|
placeholder ?? resolveCascaderSearchLabel(labels, currentParent?.label)
|
||||||
|
|
||||||
|
// Not `showBack`: `CascaderBack` renders nothing at the root or outside
|
||||||
|
// drill mode, where the field's own leading `px-1.5` is wanted. Beside the
|
||||||
|
// button it stacks with the row's `gap-1` into a 10px hole, hence `ps-0`.
|
||||||
|
const backVisible = showBack && mode === "drill" && path.length > 0
|
||||||
|
|
||||||
|
const hintId = `${baseId}-hint`
|
||||||
|
// Base UI names no list while inline; columns mode moves the real listbox to
|
||||||
|
// the deepest panel, which is the `path.length` one.
|
||||||
|
const listId = `${baseId}-column-${mode === "columns" ? path.length : 0}`
|
||||||
|
|
||||||
|
// The direction the HINT is worded for. `handleKeyDown` re-reads
|
||||||
|
// `isCascaderRtl` per keystroke; this is text on screen before any key, so
|
||||||
|
// the same check runs once against the mounted DOM (on the hint span, so no
|
||||||
|
// second ref into Base UI's input). SSR renders the "ltr" default and
|
||||||
|
// corrects on hydration. `direction` is the only dep: a `dir` attribute and
|
||||||
|
// the stylesheet are declarations, not state, so nothing to resubscribe to.
|
||||||
|
const hintRef = React.useRef<HTMLSpanElement>(null)
|
||||||
|
const [hintDir, setHintDir] = React.useState<"ltr" | "rtl">("ltr")
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const hint = hintRef.current
|
||||||
|
if (!hint) return
|
||||||
|
setHintDir(isCascaderRtl(hint, direction) ? "rtl" : "ltr")
|
||||||
|
}, [direction])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the highlight to `targetIndex` in `treeRows`. Base UI has no
|
||||||
|
* imperative setter (`actionsRef` is `{ unmount }`), so the move is arrow
|
||||||
|
* presses, sound only because `useListNavigation` fires `onItemHighlighted`
|
||||||
|
* SYNCHRONOUSLY per keydown. A press that fails to close the gap ends it.
|
||||||
|
*/
|
||||||
|
const moveHighlightTo = (field: HTMLInputElement, targetIndex: number) => {
|
||||||
|
const rowIndex = () => {
|
||||||
|
const highlighted = getHighlighted()
|
||||||
|
if (!highlighted) return -1
|
||||||
|
return treeRows.findIndex((row) => row.node.value === highlighted.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = rowIndex()
|
||||||
|
for (let step = 0; step < treeRows.length; step += 1) {
|
||||||
|
if (current === -1 || current === targetIndex) return
|
||||||
|
const key = current > targetIndex ? "ArrowUp" : "ArrowDown"
|
||||||
|
field.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
|
||||||
|
)
|
||||||
|
const next = rowIndex()
|
||||||
|
const progressed =
|
||||||
|
key === "ArrowUp"
|
||||||
|
? next < current && next >= targetIndex
|
||||||
|
: next > current && next <= targetIndex
|
||||||
|
if (next === -1 || !progressed) return
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tree pattern's two level keys; Base UI leaves both alone (list
|
||||||
|
* navigation is vertical-only, the chip handler no-ops without
|
||||||
|
* `Combobox.Chips`). Passed in rather than hardcoded because RTL swaps them:
|
||||||
|
* APG defines them as "toward the children", not as a physical arrow.
|
||||||
|
*/
|
||||||
|
const handleTreeKeyDown = (
|
||||||
|
event: CascaderInputKeyEvent,
|
||||||
|
field: HTMLInputElement,
|
||||||
|
caretAtStart: boolean,
|
||||||
|
caretAtEnd: boolean,
|
||||||
|
forwardKey: "ArrowLeft" | "ArrowRight",
|
||||||
|
backKey: "ArrowLeft" | "ArrowRight"
|
||||||
|
) => {
|
||||||
|
const forward = event.key === forwardKey
|
||||||
|
if (!forward && event.key !== backKey) return
|
||||||
|
if (forward ? !caretAtEnd : !caretAtStart) return
|
||||||
|
|
||||||
|
const highlighted = getHighlighted()
|
||||||
|
if (!highlighted) return
|
||||||
|
const rowIndex = treeRows.findIndex(
|
||||||
|
(row) => row.node.value === highlighted.value
|
||||||
|
)
|
||||||
|
if (rowIndex < 0) return
|
||||||
|
const row = treeRows[rowIndex]
|
||||||
|
|
||||||
|
if (forward) {
|
||||||
|
if (!row.branch) return
|
||||||
|
event.preventDefault()
|
||||||
|
if (!row.expanded) {
|
||||||
|
// `navigate`, not `toggleExpanded`: it waits for an unloaded branch's
|
||||||
|
// children, so the keyboard path in matches the expander.
|
||||||
|
navigate(row.node)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// `flattenCascaderTree` emits children right after their parent, so the
|
||||||
|
// first child is the next row. The depth check covers an empty branch.
|
||||||
|
const child = treeRows[rowIndex + 1]
|
||||||
|
if (child && child.depth === row.depth + 1) {
|
||||||
|
moveHighlightTo(field, rowIndex + 1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.branch && row.expanded) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleExpanded(row.node.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentValue = index.parentOf.get(row.node.value)
|
||||||
|
if (!parentValue) return
|
||||||
|
const parentIndex = treeRows.findIndex(
|
||||||
|
(entry) => entry.node.value === parentValue
|
||||||
|
)
|
||||||
|
if (parentIndex < 0) return
|
||||||
|
event.preventDefault()
|
||||||
|
moveHighlightTo(field, parentIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: CascaderInputKeyEvent) => {
|
||||||
|
onKeyDown?.(event)
|
||||||
|
if (event.defaultPrevented) return
|
||||||
|
|
||||||
|
const field = event.currentTarget
|
||||||
|
const caretAtStart = field.selectionStart === 0 && field.selectionEnd === 0
|
||||||
|
const caretAtEnd =
|
||||||
|
field.selectionStart === query.length &&
|
||||||
|
field.selectionEnd === query.length
|
||||||
|
|
||||||
|
// ArrowDown at the END of the list hands real focus to the pinned footer,
|
||||||
|
// the key a combobox is actually navigated with; Tab still works and still
|
||||||
|
// skips the scroll area. An EMPTY list hands off at once, and the footer
|
||||||
|
// owns the way back. Counted from state, so a windowed list still answers.
|
||||||
|
if (event.key === "ArrowDown" && !event.altKey) {
|
||||||
|
const rowCount = mode === "tree" ? treeRows.length : renderedItems.length
|
||||||
|
const lastValue =
|
||||||
|
mode === "tree"
|
||||||
|
? treeRows[rowCount - 1]?.node.value
|
||||||
|
: renderedItems[rowCount - 1]?.value
|
||||||
|
const highlighted = getHighlighted()
|
||||||
|
const atEnd =
|
||||||
|
rowCount === 0 ||
|
||||||
|
(highlighted != null && highlighted.value === lastValue)
|
||||||
|
if (atEnd) {
|
||||||
|
const panel = field.closest<HTMLElement>('[data-slot="cascader-panel"]')
|
||||||
|
const stop = panel ? getCascaderFooterStops(panel)[0] : undefined
|
||||||
|
if (stop) {
|
||||||
|
event.preventDefault()
|
||||||
|
// Base UI's handler is deliberately NOT vetoed: its wrap's first
|
||||||
|
// press CLEARS the highlight, so no stale row stays active behind the
|
||||||
|
// focused command, and the emptied highlight completes the ring.
|
||||||
|
stop.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The level keys are LOGICAL, not physical: in RTL, ArrowLeft opens a
|
||||||
|
// branch and ArrowRight goes back. They act only at the caret edge, and
|
||||||
|
// those guards do NOT mirror: `selectionStart === 0` is the logical start.
|
||||||
|
const rtl = isCascaderRtl(field, direction)
|
||||||
|
const forwardKey = rtl ? "ArrowLeft" : "ArrowRight"
|
||||||
|
const backKey = rtl ? "ArrowRight" : "ArrowLeft"
|
||||||
|
|
||||||
|
if (mode === "tree") {
|
||||||
|
handleTreeKeyDown(
|
||||||
|
event,
|
||||||
|
field,
|
||||||
|
caretAtStart,
|
||||||
|
caretAtEnd,
|
||||||
|
forwardKey,
|
||||||
|
backKey
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === forwardKey && caretAtEnd) {
|
||||||
|
const highlighted = getHighlighted()
|
||||||
|
if (highlighted && isBranch(highlighted)) {
|
||||||
|
event.preventDefault()
|
||||||
|
navigate(highlighted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backspace on an empty query pops a level too, so the keyboard way out of
|
||||||
|
// a level is symmetric with typing into it.
|
||||||
|
if (
|
||||||
|
(event.key === backKey && caretAtStart) ||
|
||||||
|
(event.key === "Backspace" && query.length === 0)
|
||||||
|
) {
|
||||||
|
if (path.length > 0) {
|
||||||
|
event.preventDefault()
|
||||||
|
popLevel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="cascader-input-row"
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center gap-1",
|
||||||
|
/* Each style sizes a combobox search field only as a DIRECT child of
|
||||||
|
the popup; this row is nested, so the ladder is mirrored here rather
|
||||||
|
than hardcoding one height for all eight styles. */
|
||||||
|
"h-8"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showBack ? <CascaderBack /> : null}
|
||||||
|
<ComboboxPrimitive.Input
|
||||||
|
data-slot="cascader-input"
|
||||||
|
placeholder={resolvedPlaceholder}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/* Nothing on screen says the level keys exist. */
|
||||||
|
aria-describedby={[ariaDescribedBy, hintId].filter(Boolean).join(" ")}
|
||||||
|
/* Conditional: an explicit `undefined` deletes Base UI's own value. */
|
||||||
|
{...(mode === "tree" ? { "aria-haspopup": "tree" as const } : null)}
|
||||||
|
/* Base UI omits BOTH `aria-expanded` and `aria-controls` while inline,
|
||||||
|
and an embedded panel is permanently expanded, so supply both. */
|
||||||
|
{...(inline
|
||||||
|
? { "aria-expanded": true, "aria-controls": listId }
|
||||||
|
: null)}
|
||||||
|
/* No trigger to carry the invalid state in the `inline` case. */
|
||||||
|
{...(invalid ? { "aria-invalid": true, "data-invalid": "" } : null)}
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground h-full w-full min-w-0 flex-1 bg-transparent px-1.5 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
backVisible && "ps-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<span id={hintId} ref={hintRef} className="sr-only">
|
||||||
|
{/* `hintDir`, not `direction`: the context alone misses an RTL app that
|
||||||
|
uses a `dir` attribute instead of `DirectionProvider`. */}
|
||||||
|
{labels.keyboardHint(mode, hintDir)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Value */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export interface CascaderValueProps extends Omit<
|
||||||
|
useRender.ComponentProps<"span">,
|
||||||
|
"children"
|
||||||
|
> {
|
||||||
|
/** `path` shows the full trail, `leaf` only the node, `count` only a total. */
|
||||||
|
display?: CascaderValueDisplay
|
||||||
|
maxSegments?: number
|
||||||
|
collapse?: CascaderCollapse
|
||||||
|
separator?: React.ReactNode
|
||||||
|
/** Renders the selected node's icon before the trail. */
|
||||||
|
showIcon?: boolean
|
||||||
|
placeholder?: React.ReactNode
|
||||||
|
/** Replaces the whole rendering. Receives the resolved selection and path. */
|
||||||
|
children?: (selected: CascaderNode[], path: CascaderNode[]) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger display. Defaults to the collapsed selection path rather than a bare
|
||||||
|
* leaf label, which in a nested picker is frequently ambiguous.
|
||||||
|
*/
|
||||||
|
function CascaderValue({
|
||||||
|
className,
|
||||||
|
display = "path",
|
||||||
|
maxSegments = 3,
|
||||||
|
collapse = "middle",
|
||||||
|
separator,
|
||||||
|
showIcon = true,
|
||||||
|
placeholder,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: CascaderValueProps) {
|
||||||
|
const { labels, multiple, resolveNode } = useCascaderActions()
|
||||||
|
const { index, selectedValues } = useCascaderState()
|
||||||
|
|
||||||
|
// `resolveNode`, not `index.byValue`: a selection missing from `items` (async
|
||||||
|
// children, a removed item) must still render a label, not an empty trigger.
|
||||||
|
const selected = selectedValues.map(resolveNode)
|
||||||
|
|
||||||
|
const resolvedPath =
|
||||||
|
selectedValues.length === 1 ? getCascaderPath(index, selectedValues[0]) : []
|
||||||
|
// No ancestor chain: fall back to the node so `display="path"` shows a leaf.
|
||||||
|
const path =
|
||||||
|
resolvedPath.length > 0
|
||||||
|
? resolvedPath
|
||||||
|
: selected.length === 1
|
||||||
|
? selected
|
||||||
|
: []
|
||||||
|
|
||||||
|
let content: React.ReactNode
|
||||||
|
|
||||||
|
if (children) {
|
||||||
|
content = children(selected, path)
|
||||||
|
} else if (selectedValues.length === 0) {
|
||||||
|
content = (
|
||||||
|
<span className="text-muted-foreground truncate">{placeholder}</span>
|
||||||
|
)
|
||||||
|
} else if (display === "count" || (multiple && selectedValues.length > 1)) {
|
||||||
|
content = labels.selectedCount(selectedValues.length)
|
||||||
|
} else {
|
||||||
|
const leaf = path[path.length - 1] ?? selected[0]
|
||||||
|
const segments =
|
||||||
|
display === "leaf"
|
||||||
|
? [{ type: "node" as const, node: leaf }]
|
||||||
|
: collapseCascaderPath(path, { maxSegments, collapse })
|
||||||
|
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
{showIcon && leaf?.icon ? (
|
||||||
|
<span
|
||||||
|
data-slot="cascader-value-icon"
|
||||||
|
className="text-muted-foreground flex shrink-0 items-center"
|
||||||
|
>
|
||||||
|
{leaf.icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{segments.map((segment, i) => (
|
||||||
|
<React.Fragment
|
||||||
|
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
|
||||||
|
>
|
||||||
|
{i > 0 ? (separator ?? <PathChevron />) : null}
|
||||||
|
{segment.type === "ellipsis" ? (
|
||||||
|
<span
|
||||||
|
data-slot="cascader-value-ellipsis"
|
||||||
|
title={segment.hidden
|
||||||
|
.map((n) => n.label)
|
||||||
|
.join(` ${labels.pathSeparator} `)}
|
||||||
|
className="text-muted-foreground shrink-0"
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"truncate",
|
||||||
|
i < segments.length - 1 && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{segment.node?.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
"data-slot": "cascader-value",
|
||||||
|
className: cn("flex min-w-0 items-center gap-1 truncate", className),
|
||||||
|
children: content,
|
||||||
|
}
|
||||||
|
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
render: props.render,
|
||||||
|
props: mergeProps<"span">(defaultProps, props),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
CascaderNav,
|
||||||
|
CascaderBack,
|
||||||
|
CascaderBreadcrumb,
|
||||||
|
CascaderInput,
|
||||||
|
CascaderValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type * as React from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A node in the cascader tree. Two input shapes normalize to one internal
|
||||||
|
* index: nested (nodes carry `children`), or flat adjacency plus the root's
|
||||||
|
* `getParent`, which skips the re-nesting step for large datasets.
|
||||||
|
*/
|
||||||
|
export interface CascaderNode<T = unknown> {
|
||||||
|
/** Stable id. Also the committed selection value. */
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
description?: string
|
||||||
|
children?: CascaderNode<T>[]
|
||||||
|
/** Declares a branch before load; async nodes read as leaves without it. */
|
||||||
|
hasChildren?: boolean
|
||||||
|
/** Trailing count. Defaults to known children; set it for async nodes. */
|
||||||
|
count?: number
|
||||||
|
disabled?: boolean
|
||||||
|
keywords?: string[]
|
||||||
|
/**
|
||||||
|
* Only read on a node nested inside a `getChildren` result. For the level a
|
||||||
|
* node OWNS the authoritative signal is that level's `CascaderLoadResult`.
|
||||||
|
*/
|
||||||
|
hasMore?: boolean
|
||||||
|
data?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One footer ACTION. Deliberately not a `CascaderNode`, so a command can never
|
||||||
|
* be passed where an option is expected and join the ring, filter or selection.
|
||||||
|
*/
|
||||||
|
export interface CascaderActionItem {
|
||||||
|
/** Stable key. Falls back to the label when it is a string, then the index. */
|
||||||
|
value?: string
|
||||||
|
label: React.ReactNode
|
||||||
|
icon?: React.ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
/** Ignored when `items` is present - a flyout opens instead. */
|
||||||
|
onSelect?: () => void
|
||||||
|
/** Turns the row into a submenu trigger. One level deep on purpose. */
|
||||||
|
items?: CascaderActionItem[]
|
||||||
|
/** Consecutive entries sharing a heading are drawn under one. */
|
||||||
|
group?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Panel layout. See the docs for the keyboard map of each. */
|
||||||
|
export type CascaderMode = "drill" | "columns" | "tree"
|
||||||
|
|
||||||
|
export type CascaderSearchScope = "level" | "deep"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which nodes may be committed. The predicate arm is generic over the payload
|
||||||
|
* rather than quantified per call: a per-call `<T>` would force every predicate
|
||||||
|
* to accept EVERY payload, so `(node: CascaderNode<Member>) => boolean` could
|
||||||
|
* never satisfy it.
|
||||||
|
*/
|
||||||
|
export type CascaderSelectable<T = unknown> =
|
||||||
|
| "leaf"
|
||||||
|
| "any"
|
||||||
|
| ((node: CascaderNode<T>) => boolean)
|
||||||
|
|
||||||
|
export type CascaderCollapse = "middle" | "start" | "none"
|
||||||
|
|
||||||
|
export type CascaderValueDisplay = "path" | "leaf" | "count"
|
||||||
|
|
||||||
|
export type CascaderChangeReason = "select" | "deselect" | "clear"
|
||||||
|
|
||||||
|
/** Second argument to `onValueChange`: resolved nodes, so no re-lookup. */
|
||||||
|
export interface CascaderChangeDetails<T = unknown> {
|
||||||
|
/** The node committed or toggled. Null when the selection was cleared. */
|
||||||
|
node: CascaderNode<T> | null
|
||||||
|
/** Ancestor chain of `node`, root first, node last. */
|
||||||
|
path: CascaderNode<T>[]
|
||||||
|
nodes: CascaderNode<T>[]
|
||||||
|
reason: CascaderChangeReason
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalized tree, built once per `items` identity, shared by every mode. */
|
||||||
|
export interface CascaderIndex<T = unknown> {
|
||||||
|
byValue: Map<string, CascaderNode<T>>
|
||||||
|
/** Children by parent value. Root children are keyed by `ROOT_KEY`. */
|
||||||
|
childrenOf: Map<string, CascaderNode<T>[]>
|
||||||
|
parentOf: Map<string, string | null>
|
||||||
|
/** Zero-based depth by node value. */
|
||||||
|
depthOf: Map<string, number>
|
||||||
|
/** Top level nodes, in input order. */
|
||||||
|
roots: CascaderNode<T>[]
|
||||||
|
/** Every node in a stable, depth-first order. Used by deep search. */
|
||||||
|
all: CascaderNode<T>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CascaderFlatNode<T = unknown> {
|
||||||
|
node: CascaderNode<T>
|
||||||
|
depth: number
|
||||||
|
/** Known children, or a declared `hasChildren`. */
|
||||||
|
branch: boolean
|
||||||
|
expanded: boolean
|
||||||
|
/** Sibling count, plus one when the level has a paging row. */
|
||||||
|
setSize: number
|
||||||
|
/** One-based index among siblings. */
|
||||||
|
posInSet: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One segment of a rendered path, after collapsing. */
|
||||||
|
export type CascaderPathSegment<T = unknown> =
|
||||||
|
| { type: "node"; node: CascaderNode<T> }
|
||||||
|
| { type: "ellipsis"; hidden: CascaderNode<T>[] }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async load state for one node's children. Deliberately WITHOUT a `status`
|
||||||
|
* field: "declared a branch, never fetched" and "fetched and genuinely empty"
|
||||||
|
* are told apart by MAP MEMBERSHIP, and a second source of that truth would
|
||||||
|
* eventually disagree with the first.
|
||||||
|
*/
|
||||||
|
export interface CascaderLoadState {
|
||||||
|
loading: boolean
|
||||||
|
error: boolean
|
||||||
|
hasMore: boolean
|
||||||
|
/** Opaque cursor handed back to `getChildren` for the next page. */
|
||||||
|
cursor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a level was fetched. `resolve`: `resolveValue` hit an unloaded node. */
|
||||||
|
export type CascaderLoadReason =
|
||||||
|
| "level"
|
||||||
|
| "more"
|
||||||
|
| "prefetch"
|
||||||
|
| "retry"
|
||||||
|
| "resolve"
|
||||||
|
|
||||||
|
/** Argument handed to `getChildren`. */
|
||||||
|
export interface CascaderLoadContext {
|
||||||
|
/** Aborted when the request is superseded or the popup closes. */
|
||||||
|
signal: AbortSignal
|
||||||
|
cursor?: string
|
||||||
|
reason?: CascaderLoadReason
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Value returned by `getChildren`. A bare array is also accepted. */
|
||||||
|
export interface CascaderLoadResult<T = unknown> {
|
||||||
|
items: CascaderNode<T>[]
|
||||||
|
nextCursor?: string
|
||||||
|
/** Defaults to whether `nextCursor` was supplied. */
|
||||||
|
hasMore?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Argument handed to `onSearch`. */
|
||||||
|
export interface CascaderSearchContext {
|
||||||
|
signal: AbortSignal
|
||||||
|
/** The path the user is searching within, deepest last. */
|
||||||
|
path: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every user facing string, so the primitive ships no hardcoded copy. The
|
||||||
|
* callbacks take plain labels, not nodes: a `CascaderNode<T>` parameter would
|
||||||
|
* force this object to carry the item generic. `*Announcement` is live-region.
|
||||||
|
*/
|
||||||
|
export interface CascaderLabels {
|
||||||
|
search: string | ((parentLabel?: string) => string)
|
||||||
|
back: string
|
||||||
|
/** A level's FIRST page. `loadingMore` is the next, `loadMore` its idle row. */
|
||||||
|
loading: string
|
||||||
|
loadingMore: string
|
||||||
|
loadMore: string
|
||||||
|
error: string
|
||||||
|
retry: string
|
||||||
|
empty: string
|
||||||
|
/** Rendered by `CascaderValue` when `display="count"`. */
|
||||||
|
selectedCount: (count: number) => string
|
||||||
|
breadcrumbLabel: string
|
||||||
|
chipsLabel: string
|
||||||
|
removeChip: (label: string) => string
|
||||||
|
/** Trail separator. Not every locale writes one with a slash. */
|
||||||
|
pathSeparator: string
|
||||||
|
/** Names the root level wherever there is no parent node to name it. */
|
||||||
|
rootLevel: string
|
||||||
|
itemCount: (count: number) => string
|
||||||
|
/** Appended to a branch row's name outside tree mode: it opens another list. */
|
||||||
|
branchAffordance: string
|
||||||
|
/** Columns-trail rows are plain buttons, so they carry no `aria-selected`. */
|
||||||
|
selectedState: string
|
||||||
|
/** Same for the mixed state, which a `role="button"` row may not carry. */
|
||||||
|
partiallySelectedState: string
|
||||||
|
columnsLabel: string
|
||||||
|
actionsLabel: string
|
||||||
|
submenuAffordance: string
|
||||||
|
panelLabel: string
|
||||||
|
/** Read per mode AND per direction: the level keys mirror in RTL. */
|
||||||
|
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => string
|
||||||
|
rootAnnouncement: (count: number) => string
|
||||||
|
expandedAnnouncement: (label: string, count: number) => string
|
||||||
|
collapsedAnnouncement: (label: string) => string
|
||||||
|
levelAnnouncement: (
|
||||||
|
parentLabel: string,
|
||||||
|
depth: number,
|
||||||
|
count: number
|
||||||
|
) => string
|
||||||
|
resultsAnnouncement: (count: number) => string
|
||||||
|
maxReachedAnnouncement: (max: number) => string
|
||||||
|
/** `count` is the descendants swept along; `selecting` is the direction. */
|
||||||
|
cascadeAnnouncement: (
|
||||||
|
label: string,
|
||||||
|
count: number,
|
||||||
|
selecting: boolean
|
||||||
|
) => string
|
||||||
|
searchingAnnouncement: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
|
||||||
|
import {
|
||||||
|
useCascaderActions,
|
||||||
|
useCascaderHighlight,
|
||||||
|
useCascaderState,
|
||||||
|
} from "@/components/reui/cascader/cascader-context"
|
||||||
|
import type { CascaderColumn } from "@/components/reui/cascader/cascader-context"
|
||||||
|
import {
|
||||||
|
CascaderItem,
|
||||||
|
CascaderItems,
|
||||||
|
getCascaderMoreProps,
|
||||||
|
} from "@/components/reui/cascader/cascader-item"
|
||||||
|
import {
|
||||||
|
defaultRangeExtractor,
|
||||||
|
useVirtualizer,
|
||||||
|
type Range,
|
||||||
|
type Virtualizer,
|
||||||
|
} from "@tanstack/react-virtual"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windowing for the cascader, in its own file so the primitive's own install
|
||||||
|
* never pulls `@tanstack/react-virtual` in. Base UI forces three rules:
|
||||||
|
*
|
||||||
|
* 1. A windowed row MUST carry an explicit `index`; the fallback
|
||||||
|
* `findItemIndex` is O(n) per row and returns `-1` on the frame a level swap
|
||||||
|
* renders empty, dangling `aria-activedescendant`.
|
||||||
|
* 2. That index is DESTRUCTIVE while unvirtualized (`useCompositeListItem`
|
||||||
|
* skips registration, `CompositeList` truncates `elementsRef` to zero and
|
||||||
|
* the first arrow key highlights nothing), so rows stay plain until the root
|
||||||
|
* flips `virtualized`, gated inside `CascaderItem`.
|
||||||
|
* 3. No `Combobox.Collection` and no FUNCTION CHILD on `Combobox.List`, which
|
||||||
|
* implicitly wraps one in a Collection: a Collection renders every filtered
|
||||||
|
* item.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Virtualizer */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export interface UseCascaderVirtualizerOptions {
|
||||||
|
count: number
|
||||||
|
getScrollElement: () => HTMLElement | null
|
||||||
|
estimateSize?: number
|
||||||
|
overscan?: number
|
||||||
|
/** Key rows by node value, never by index: a tree expand shifts every index. */
|
||||||
|
getItemKey: (index: number) => string | number
|
||||||
|
/** Render index of the highlighted row, or `-1`. Pinned into the window. */
|
||||||
|
activeIndex?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CascaderVirtualizer = Virtualizer<HTMLElement, HTMLElement>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cascader's virtualizer: TanStack plus the highlight pinning and
|
||||||
|
* scroll-into-view a combobox list needs. `activeIndex` is clamped here, not by
|
||||||
|
* the caller: emitted from a layout effect, it can be one commit ahead of a
|
||||||
|
* level that just shrank and would otherwise scroll to a row that is gone.
|
||||||
|
*/
|
||||||
|
export function useCascaderVirtualizer({
|
||||||
|
count,
|
||||||
|
getScrollElement,
|
||||||
|
estimateSize = 32,
|
||||||
|
overscan = 8,
|
||||||
|
getItemKey,
|
||||||
|
activeIndex = -1,
|
||||||
|
}: UseCascaderVirtualizerOptions): CascaderVirtualizer {
|
||||||
|
const active = activeIndex >= 0 && activeIndex < count ? activeIndex : -1
|
||||||
|
|
||||||
|
// Keeps the highlighted row mounted so `aria-activedescendant` never dangles.
|
||||||
|
const rangeExtractor = React.useCallback(
|
||||||
|
(range: Range) => {
|
||||||
|
const indices = new Set(defaultRangeExtractor(range))
|
||||||
|
if (active !== -1) indices.add(active)
|
||||||
|
return Array.from(indices).sort((a, b) => a - b)
|
||||||
|
},
|
||||||
|
[active]
|
||||||
|
)
|
||||||
|
|
||||||
|
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
|
||||||
|
|
||||||
|
// React Compiler bails on `useVirtualizer`; harmless, rows memoise one by one.
|
||||||
|
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
|
||||||
|
count,
|
||||||
|
getScrollElement,
|
||||||
|
estimateSize: measureEstimate,
|
||||||
|
overscan,
|
||||||
|
getItemKey,
|
||||||
|
rangeExtractor,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Base UI's scroll-into-view no-ops on a windowed-out row (empty `listRef`).
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (active === -1) return
|
||||||
|
virtualizer.scrollToIndex(active, { align: "auto" })
|
||||||
|
}, [active, count, virtualizer])
|
||||||
|
|
||||||
|
return virtualizer
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Geometry */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface CascaderVirtualGutter {
|
||||||
|
block: number
|
||||||
|
inline: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_GUTTER: CascaderVirtualGutter = { block: 0, inline: 0 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ROWS' BOX's own padding, never the scrollport's (separate element, reads
|
||||||
|
* a flat zero). Measured because every style sets its own: a windowed row is
|
||||||
|
* positioned against this box's PADDING box while the scroll-height spacer is
|
||||||
|
* an in-flow child of its CONTENT box, so without it rows sit flush.
|
||||||
|
*/
|
||||||
|
function useCascaderVirtualGutter(
|
||||||
|
contentElement: HTMLElement | null
|
||||||
|
): CascaderVirtualGutter {
|
||||||
|
const [gutter, setGutter] = React.useState<CascaderVirtualGutter>(NO_GUTTER)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!contentElement) return
|
||||||
|
const styles = getComputedStyle(contentElement)
|
||||||
|
const block = Number.parseFloat(styles.paddingTop) || 0
|
||||||
|
const inline =
|
||||||
|
Number.parseFloat(styles.paddingInlineStart || styles.paddingLeft) || 0
|
||||||
|
setGutter((previous) =>
|
||||||
|
previous.block === block && previous.inline === inline
|
||||||
|
? previous
|
||||||
|
: { block, inline }
|
||||||
|
)
|
||||||
|
}, [contentElement])
|
||||||
|
|
||||||
|
return gutter
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logical insets, not `left` / `width`, so RTL needs no second code path. */
|
||||||
|
function cascaderVirtualRowStyle(
|
||||||
|
start: number,
|
||||||
|
gutter: CascaderVirtualGutter
|
||||||
|
): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
insetInlineStart: gutter.inline,
|
||||||
|
insetInlineEnd: gutter.inline,
|
||||||
|
transform: `translateY(${start + gutter.block}px)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The element that actually SCROLLS: the `ScrollArea` viewport above the rows'
|
||||||
|
* box, not the rows' box itself, which never overflows, so a virtualizer aimed
|
||||||
|
* at it would read a full `clientHeight` and render every row. `closest` rather
|
||||||
|
* than `parentElement`, plus a fallback to the content element, keeps a
|
||||||
|
* hand-written `overflow-y-auto` container working.
|
||||||
|
*/
|
||||||
|
function cascaderScrollElement(
|
||||||
|
content: HTMLElement | null
|
||||||
|
): HTMLElement | null {
|
||||||
|
if (!content) return null
|
||||||
|
return (
|
||||||
|
content.closest<HTMLElement>('[data-slot="scroll-area-viewport"]') ??
|
||||||
|
content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `role="presentation"` keeps the listbox owning nothing but options, and this
|
||||||
|
* node's parent IS the rows' box, saving a ref threaded through `CascaderList`.
|
||||||
|
*/
|
||||||
|
function CascaderVirtualSpacer({
|
||||||
|
height,
|
||||||
|
onContentElement,
|
||||||
|
}: {
|
||||||
|
height: number
|
||||||
|
onContentElement: (element: HTMLElement | null) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(node) => onContentElement(node?.parentElement ?? null)}
|
||||||
|
role="presentation"
|
||||||
|
data-slot="cascader-virtual-spacer"
|
||||||
|
style={{ height }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Rows */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export interface CascaderVirtualItemsProps {
|
||||||
|
/** Row height before measurement. Defaults to the root `estimateRowSize`. */
|
||||||
|
estimateSize?: number
|
||||||
|
/** Rows rendered beyond each edge. Defaults to the root `overscan`. */
|
||||||
|
overscan?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windowed replacement for `CascaderItems`, dropped inside `CascaderList`. The
|
||||||
|
* registration is a LAYOUT effect because the root derives `virtualized` from
|
||||||
|
* it and the flip has to land before the browser paints. Until the root has
|
||||||
|
* seen it, this renders exactly what `CascaderItems` does, so no frame carries
|
||||||
|
* indexed rows while Base UI still owns the composite list.
|
||||||
|
*/
|
||||||
|
function CascaderVirtualItems(props: CascaderVirtualItemsProps) {
|
||||||
|
const { virtualized, registerVirtualRenderer } = useCascaderActions()
|
||||||
|
|
||||||
|
React.useLayoutEffect(
|
||||||
|
() => registerVirtualRenderer(),
|
||||||
|
[registerVirtualRenderer]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!virtualized) return <CascaderItems />
|
||||||
|
|
||||||
|
return <CascaderVirtualRows {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CascaderVirtualRows({
|
||||||
|
estimateSize,
|
||||||
|
overscan,
|
||||||
|
}: CascaderVirtualItemsProps) {
|
||||||
|
const {
|
||||||
|
estimateRowSize,
|
||||||
|
overscan: rootOverscan,
|
||||||
|
mode,
|
||||||
|
isBranch,
|
||||||
|
isSelectable,
|
||||||
|
isSelected,
|
||||||
|
isIndeterminate,
|
||||||
|
} = useCascaderActions()
|
||||||
|
const { renderedItems, treeRows, deepResults, loadStates } =
|
||||||
|
useCascaderState()
|
||||||
|
// Re-renders on every highlight change, and has to: the pinned row and the
|
||||||
|
// scroll target are both derived from it.
|
||||||
|
const highlight = useCascaderHighlight()
|
||||||
|
|
||||||
|
const [contentElement, setContentElement] =
|
||||||
|
React.useState<HTMLElement | null>(null)
|
||||||
|
const gutter = useCascaderVirtualGutter(contentElement)
|
||||||
|
|
||||||
|
const tree = mode === "tree"
|
||||||
|
const count = tree ? treeRows.length : renderedItems.length
|
||||||
|
const showPath = !tree && deepResults !== null
|
||||||
|
|
||||||
|
const getItemKey = React.useCallback(
|
||||||
|
(index: number) =>
|
||||||
|
(tree ? treeRows[index]?.node.value : renderedItems[index]?.value) ??
|
||||||
|
index,
|
||||||
|
[tree, treeRows, renderedItems]
|
||||||
|
)
|
||||||
|
|
||||||
|
const virtualizer = useCascaderVirtualizer({
|
||||||
|
count,
|
||||||
|
getScrollElement: () => cascaderScrollElement(contentElement),
|
||||||
|
estimateSize: estimateSize ?? estimateRowSize,
|
||||||
|
overscan: overscan ?? rootOverscan,
|
||||||
|
getItemKey,
|
||||||
|
activeIndex: highlight.index,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CascaderVirtualSpacer
|
||||||
|
height={virtualizer.getTotalSize()}
|
||||||
|
onContentElement={setContentElement}
|
||||||
|
/>
|
||||||
|
{virtualizer.getVirtualItems().map((row) => {
|
||||||
|
const flat = tree ? treeRows[row.index] : undefined
|
||||||
|
const node = tree ? flat?.node : renderedItems[row.index]
|
||||||
|
// For one commit after a query narrows the level, the window can name
|
||||||
|
// a row it no longer has; the count recomputes on the same tick.
|
||||||
|
if (!node) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CascaderItem
|
||||||
|
key={row.key}
|
||||||
|
/* Measured, not estimated: rows are two lines with a `description`
|
||||||
|
and three in deep search, and row height is per style. An
|
||||||
|
estimate alone would make `scrollToIndex` land on the wrong row. */
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
data-index={row.index}
|
||||||
|
style={cascaderVirtualRowStyle(row.start, gutter)}
|
||||||
|
node={node}
|
||||||
|
index={row.index}
|
||||||
|
depth={flat?.depth}
|
||||||
|
expanded={flat?.expanded}
|
||||||
|
branch={flat ? flat.branch : isBranch(node)}
|
||||||
|
selectable={isSelectable(node)}
|
||||||
|
selected={isSelected(node)}
|
||||||
|
indeterminate={isIndeterminate(node)}
|
||||||
|
{...getCascaderMoreProps(node, loadStates)}
|
||||||
|
showPath={showPath}
|
||||||
|
aria-setsize={flat ? flat.setSize : count}
|
||||||
|
aria-posinset={flat ? flat.posInSet : row.index + 1}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Column */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export interface CascaderVirtualColumnProps extends CascaderVirtualItemsProps {
|
||||||
|
column: CascaderColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windowed replacement for one Miller column, passed through the
|
||||||
|
* `CascaderColumns` render slot. One virtualizer PER COLUMN, since columns
|
||||||
|
* scroll independently, and only the deepest is Base UI's listbox: the trail
|
||||||
|
* behind is `as="button"` rows outside Base UI, so it windows on its own row
|
||||||
|
* count rather than waiting for the root to flip.
|
||||||
|
*/
|
||||||
|
function CascaderVirtualColumn({
|
||||||
|
column,
|
||||||
|
estimateSize,
|
||||||
|
overscan,
|
||||||
|
}: CascaderVirtualColumnProps) {
|
||||||
|
const {
|
||||||
|
virtualized,
|
||||||
|
registerVirtualRenderer,
|
||||||
|
virtualize,
|
||||||
|
virtualizeThreshold,
|
||||||
|
} = useCascaderActions()
|
||||||
|
|
||||||
|
React.useLayoutEffect(
|
||||||
|
() => registerVirtualRenderer(),
|
||||||
|
[registerVirtualRenderer]
|
||||||
|
)
|
||||||
|
|
||||||
|
const windowed = column.active
|
||||||
|
? virtualized
|
||||||
|
: (virtualize ?? column.items.length >= virtualizeThreshold)
|
||||||
|
|
||||||
|
if (!windowed) return <CascaderColumnPanel column={column} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CascaderColumnPanel column={column} virtualized>
|
||||||
|
{column.active ? (
|
||||||
|
<CascaderVirtualActiveColumnRows
|
||||||
|
column={column}
|
||||||
|
estimateSize={estimateSize}
|
||||||
|
overscan={overscan}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CascaderVirtualColumnRows
|
||||||
|
column={column}
|
||||||
|
estimateSize={estimateSize}
|
||||||
|
overscan={overscan}
|
||||||
|
activeIndex={-1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CascaderColumnPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the active column subscribes to the highlight; a trail column must not. */
|
||||||
|
function CascaderVirtualActiveColumnRows(props: CascaderVirtualColumnProps) {
|
||||||
|
const highlight = useCascaderHighlight()
|
||||||
|
return <CascaderVirtualColumnRows {...props} activeIndex={highlight.index} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CascaderVirtualColumnRows({
|
||||||
|
column,
|
||||||
|
estimateSize,
|
||||||
|
overscan,
|
||||||
|
activeIndex,
|
||||||
|
}: CascaderVirtualColumnProps & { activeIndex: number }) {
|
||||||
|
const {
|
||||||
|
estimateRowSize,
|
||||||
|
overscan: rootOverscan,
|
||||||
|
baseId,
|
||||||
|
isBranch,
|
||||||
|
isSelectable,
|
||||||
|
isSelected,
|
||||||
|
isIndeterminate,
|
||||||
|
} = useCascaderActions()
|
||||||
|
const { loadStates } = useCascaderState()
|
||||||
|
|
||||||
|
const [contentElement, setContentElement] =
|
||||||
|
React.useState<HTMLElement | null>(null)
|
||||||
|
const gutter = useCascaderVirtualGutter(contentElement)
|
||||||
|
|
||||||
|
const items = column.items
|
||||||
|
const getItemKey = React.useCallback(
|
||||||
|
(index: number) => items[index]?.value ?? index,
|
||||||
|
[items]
|
||||||
|
)
|
||||||
|
|
||||||
|
const virtualizer = useCascaderVirtualizer({
|
||||||
|
count: items.length,
|
||||||
|
getScrollElement: () => cascaderScrollElement(contentElement),
|
||||||
|
estimateSize: estimateSize ?? estimateRowSize,
|
||||||
|
overscan: overscan ?? rootOverscan,
|
||||||
|
getItemKey,
|
||||||
|
activeIndex,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CascaderVirtualSpacer
|
||||||
|
height={virtualizer.getTotalSize()}
|
||||||
|
onContentElement={setContentElement}
|
||||||
|
/>
|
||||||
|
{virtualizer.getVirtualItems().map((row) => {
|
||||||
|
const node = items[row.index]
|
||||||
|
if (!node) return null
|
||||||
|
const open = node.value === column.activeValue
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CascaderItem
|
||||||
|
key={row.key}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
data-index={row.index}
|
||||||
|
style={cascaderVirtualRowStyle(row.start, gutter)}
|
||||||
|
node={node}
|
||||||
|
as={column.active ? "option" : "button"}
|
||||||
|
depth={column.depth}
|
||||||
|
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}
|
||||||
|
/* Only the listbox column may carry an index or listbox metadata.
|
||||||
|
A trail row is a `role="button"`, which allows neither. */
|
||||||
|
{...(column.active
|
||||||
|
? {
|
||||||
|
index: row.index,
|
||||||
|
"aria-setsize": items.length,
|
||||||
|
"aria-posinset": row.index + 1,
|
||||||
|
}
|
||||||
|
: null)}
|
||||||
|
{...(!column.active && open
|
||||||
|
? {
|
||||||
|
"aria-expanded": true,
|
||||||
|
"aria-controls": `${baseId}-column-${column.depth + 1}`,
|
||||||
|
}
|
||||||
|
: null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CascaderVirtualColumn, CascaderVirtualItems }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { Column } from "@tanstack/react-table"
|
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import type { Column } from "@tanstack/react-table"
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
@@ -13,8 +16,8 @@ import {
|
|||||||
import { Separator } from "@evobgp/ui/components/separator"
|
import { Separator } from "@evobgp/ui/components/separator"
|
||||||
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
interface DataGridColumnFilterProps<TData, TValue> {
|
interface DataGridColumnFilterProps<TData extends object, TValue> {
|
||||||
column?: Column<TData, TValue>
|
column?: Column<DataGridFeatures, TData, TValue>
|
||||||
title?: string
|
title?: string
|
||||||
options: {
|
options: {
|
||||||
label: string
|
label: string
|
||||||
@@ -23,7 +26,7 @@ interface DataGridColumnFilterProps<TData, TValue> {
|
|||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridColumnFilter<TData, TValue>({
|
function DataGridColumnFilter<TData extends object, TValue>({
|
||||||
column,
|
column,
|
||||||
title,
|
title,
|
||||||
options,
|
options,
|
||||||
@@ -61,7 +64,7 @@ function DataGridColumnFilter<TData, TValue>({
|
|||||||
<div className="hidden space-x-1 lg:flex">
|
<div className="hidden space-x-1 lg:flex">
|
||||||
{selectedValues.size > 2 ? (
|
{selectedValues.size > 2 ? (
|
||||||
<Badge variant="secondary" className="px-1 font-normal">
|
<Badge variant="secondary" className="px-1 font-normal">
|
||||||
{selectedValues.size} выбрано
|
{selectedValues.size} selected
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
options
|
options
|
||||||
@@ -94,7 +97,7 @@ function DataGridColumnFilter<TData, TValue>({
|
|||||||
<div className="max-h-[300px] overflow-y-auto">
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
{filteredOptions.length === 0 ? (
|
{filteredOptions.length === 0 ? (
|
||||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||||
Ничего не найдено.
|
No results found.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
@@ -170,7 +173,7 @@ function DataGridColumnFilter<TData, TValue>({
|
|||||||
}}
|
}}
|
||||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
||||||
>
|
>
|
||||||
Сбросить фильтры
|
Clear filters
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client"
|
import { memo, useMemo } from "react"
|
||||||
|
import type { HTMLAttributes, ReactNode } from "react"
|
||||||
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
|
|
||||||
import {
|
import {
|
||||||
getColumnHeaderLabel,
|
getColumnHeaderLabel,
|
||||||
useDataGrid,
|
useDataGrid,
|
||||||
} from "@/components/reui/data-grid/data-grid"
|
} from "@/components/reui/data-grid/data-grid"
|
||||||
import { Column } from "@tanstack/react-table"
|
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import { Subscribe } from "@tanstack/react-table"
|
||||||
|
import type { Column } from "@tanstack/react-table"
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
@@ -25,10 +26,10 @@ import {
|
|||||||
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
|
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
|
||||||
|
|
||||||
interface DataGridColumnHeaderProps<
|
interface DataGridColumnHeaderProps<
|
||||||
TData,
|
TData extends object,
|
||||||
TValue,
|
TValue,
|
||||||
> extends HTMLAttributes<HTMLDivElement> {
|
> extends HTMLAttributes<HTMLDivElement> {
|
||||||
column: Column<TData, TValue>
|
column: Column<DataGridFeatures, TData, TValue>
|
||||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
||||||
title?: string
|
title?: string
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
@@ -38,7 +39,7 @@ interface DataGridColumnHeaderProps<
|
|||||||
visibility?: boolean
|
visibility?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridColumnHeaderInner<TData, TValue>({
|
function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||||
column,
|
column,
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
@@ -46,13 +47,19 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
|||||||
filter,
|
filter,
|
||||||
visibility = false,
|
visibility = false,
|
||||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||||
const { isLoading, table, props, recordCount } = useDataGrid()
|
const { isLoading, table, props } = useDataGrid()
|
||||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||||
|
|
||||||
const columnOrder = table.getState().columnOrder
|
// TanStack's columnOrder defaults to [] until a consumer seeds it; fall
|
||||||
|
// back to the definition order so Move Left/Right work out of the box.
|
||||||
|
const columnOrderState = table.state.columnOrder
|
||||||
|
const columnOrder =
|
||||||
|
columnOrderState.length > 0
|
||||||
|
? columnOrderState
|
||||||
|
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
|
||||||
const columnVisibilityKey =
|
const columnVisibilityKey =
|
||||||
props.tableLayout?.columnsVisibility && visibility
|
props.tableLayout?.columnsVisibility && visibility
|
||||||
? JSON.stringify(table.getState().columnVisibility)
|
? JSON.stringify(table.state.columnVisibility)
|
||||||
: ""
|
: ""
|
||||||
const isSorted = column.getIsSorted()
|
const isSorted = column.getIsSorted()
|
||||||
const isPinned = column.getIsPinned()
|
const isPinned = column.getIsPinned()
|
||||||
@@ -166,21 +173,21 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
|||||||
items.push(
|
items.push(
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key="pin-left"
|
key="pin-left"
|
||||||
onClick={() => column.pin(isPinned === "left" ? false : "left")}
|
onClick={() => column.pin(isPinned === "start" ? false : "start")}
|
||||||
>
|
>
|
||||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||||
<span className="grow">Pin to left</span>
|
<span className="grow">Pin to left</span>
|
||||||
{isPinned === "left" && (
|
{isPinned === "start" && (
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>,
|
</DropdownMenuItem>,
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key="pin-right"
|
key="pin-right"
|
||||||
onClick={() => column.pin(isPinned === "right" ? false : "right")}
|
onClick={() => column.pin(isPinned === "end" ? false : "end")}
|
||||||
>
|
>
|
||||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||||
<span className="grow">Pin to right</span>
|
<span className="grow">Pin to right</span>
|
||||||
{isPinned === "right" && (
|
{isPinned === "end" && (
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -289,7 +296,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={headerButtonClassName}
|
className={headerButtonClassName}
|
||||||
disabled={isLoading || recordCount === 0}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{icon && icon}
|
{icon && icon}
|
||||||
{resolvedTitle}
|
{resolvedTitle}
|
||||||
@@ -323,7 +330,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={headerButtonClassName}
|
className={headerButtonClassName}
|
||||||
disabled={isLoading || recordCount === 0}
|
disabled={isLoading}
|
||||||
onClick={handleSort}
|
onClick={handleSort}
|
||||||
>
|
>
|
||||||
{icon && icon}
|
{icon && icon}
|
||||||
@@ -342,8 +349,47 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const DataGridColumnHeader = memo(
|
const DataGridColumnHeaderMemo = memo(DataGridColumnHeaderInner) as <
|
||||||
DataGridColumnHeaderInner
|
TData extends object,
|
||||||
) as typeof DataGridColumnHeaderInner
|
TValue,
|
||||||
|
>(
|
||||||
|
props: DataGridColumnHeaderProps<TData, TValue> & {
|
||||||
|
/** Internal: the state slices the header re-renders on. Not part of the public API. */
|
||||||
|
subscribedState?: unknown
|
||||||
|
}
|
||||||
|
) => ReactNode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort and pin state reaches this header through builder calls on `column`
|
||||||
|
* (`getIsSorted()`, `getIsPinned()`), and `column` is a stable reference. That
|
||||||
|
* combination is the one v9's fresh-table-per-state-change does NOT cover:
|
||||||
|
* React Compiler is free to memoize against the stable column and never
|
||||||
|
* re-evaluate those reads, which shows up as frozen sort arrows and pin
|
||||||
|
* controls. The `Subscribe` below turns the slices this header actually reads
|
||||||
|
* into a real reactive dependency, and threading the selection through as a
|
||||||
|
* prop is what lets it past the `memo` - which would otherwise see unchanged
|
||||||
|
* props and skip the render anyway.
|
||||||
|
*/
|
||||||
|
function DataGridColumnHeader<TData extends object, TValue>(
|
||||||
|
props: DataGridColumnHeaderProps<TData, TValue>
|
||||||
|
) {
|
||||||
|
const { table } = useDataGrid()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Subscribe
|
||||||
|
source={table.store}
|
||||||
|
selector={(state) => ({
|
||||||
|
sorting: state.sorting,
|
||||||
|
columnPinning: state.columnPinning,
|
||||||
|
columnOrder: state.columnOrder,
|
||||||
|
columnVisibility: state.columnVisibility,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{(subscribed) => (
|
||||||
|
<DataGridColumnHeaderMemo {...props} subscribedState={subscribed} />
|
||||||
|
)}
|
||||||
|
</Subscribe>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { ReactElement } from "react"
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactElement } from "react"
|
||||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
||||||
import { Table } from "@tanstack/react-table"
|
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import type { Table } from "@tanstack/react-table"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -11,11 +14,11 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@evobgp/ui/components/dropdown-menu"
|
} from "@evobgp/ui/components/dropdown-menu"
|
||||||
|
|
||||||
function DataGridColumnVisibility<TData>({
|
function DataGridColumnVisibility<TData extends object>({
|
||||||
table,
|
table,
|
||||||
trigger,
|
trigger,
|
||||||
}: {
|
}: {
|
||||||
table: Table<TData>
|
table: Table<DataGridFeatures, TData>
|
||||||
trigger: ReactElement<Record<string, unknown>>
|
trigger: ReactElement<Record<string, unknown>>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
"use client"
|
import type { JSX, ReactNode } from "react"
|
||||||
|
|
||||||
import React, { ReactNode } from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
@@ -32,18 +30,18 @@ interface DataGridPaginationProps {
|
|||||||
ellipsisText?: string
|
ellipsisText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||||
const { table, recordCount, isLoading } = useDataGrid()
|
const { table, recordCount, isLoading } = useDataGrid()
|
||||||
|
|
||||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||||
sizes: [5, 10, 25, 50, 100],
|
sizes: [5, 10, 25, 50, 100],
|
||||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||||
moreLimit: 5,
|
moreLimit: 5,
|
||||||
info: "{from}–{to} из {count}",
|
info: "{from} - {to} of {count}",
|
||||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||||
rowsPerPageLabel: "Строк на странице",
|
rowsPerPageLabel: "Rows per page",
|
||||||
previousPageLabel: "Предыдущая страница",
|
previousPageLabel: "Go to previous page",
|
||||||
nextPageLabel: "Следующая страница",
|
nextPageLabel: "Go to next page",
|
||||||
ellipsisText: "...",
|
ellipsisText: "...",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +49,8 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
|
|
||||||
const btnBaseClasses = "p-0 text-sm"
|
const btnBaseClasses = "p-0 text-sm"
|
||||||
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
|
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
|
||||||
const pageIndex = table.getState().pagination.pageIndex
|
const pageIndex = table.state.pagination.pageIndex
|
||||||
const pageSize = table.getState().pagination.pageSize
|
const pageSize = table.state.pagination.pageSize
|
||||||
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
|
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
|
||||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||||
const pageCount = table.getPageCount()
|
const pageCount = table.getPageCount()
|
||||||
@@ -161,7 +159,11 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
<SelectTrigger className="w-16" size="sm">
|
<SelectTrigger className="w-16" size="sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent side="top" className="min-w-18">
|
<SelectContent
|
||||||
|
align="start"
|
||||||
|
alignItemWithTrigger={false}
|
||||||
|
className="min-w-(--anchor-width)"
|
||||||
|
>
|
||||||
{mergedProps.sizes?.map((size: number) => (
|
{mergedProps.sizes?.map((size: number) => (
|
||||||
<SelectItem key={size} value={`${size}`}>
|
<SelectItem key={size} value={`${size}`}>
|
||||||
{size}
|
{size}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import {
|
"use client"
|
||||||
PointerEvent,
|
|
||||||
ReactNode,
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
useCallback,
|
import type { PointerEvent, ReactNode } from "react"
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||||
|
|
||||||
@@ -94,8 +90,9 @@ function DataGridScrollArea({
|
|||||||
orientation = "both",
|
orientation = "both",
|
||||||
...props
|
...props
|
||||||
}: DataGridScrollAreaProps) {
|
}: DataGridScrollAreaProps) {
|
||||||
const { props: dataGridProps } = useDataGrid()
|
const { props: dataGridProps, table } = useDataGrid()
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const overlayRef = useRef<HTMLDivElement | null>(null)
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||||
const dragRef = useRef<{
|
const dragRef = useRef<{
|
||||||
pointerId: number
|
pointerId: number
|
||||||
@@ -114,6 +111,11 @@ function DataGridScrollArea({
|
|||||||
const showVertical = orientation !== "horizontal"
|
const showVertical = orientation !== "horizontal"
|
||||||
const usesCustomVerticalScrollbar =
|
const usesCustomVerticalScrollbar =
|
||||||
showVertical && !!dataGridProps.tableLayout?.headerSticky
|
showVertical && !!dataGridProps.tableLayout?.headerSticky
|
||||||
|
// Pinned columns are sticky and never scroll, so the horizontal scrollbar
|
||||||
|
// track is inset to span only the scrollable center region between them.
|
||||||
|
const isColumnsPinnable = !!dataGridProps.tableLayout?.columnsPinnable
|
||||||
|
const scrollbarInsetStart = isColumnsPinnable ? table.getStartTotalSize() : 0
|
||||||
|
const scrollbarInsetEnd = isColumnsPinnable ? table.getEndTotalSize() : 0
|
||||||
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
|
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
|
||||||
useState(false)
|
useState(false)
|
||||||
|
|
||||||
@@ -123,12 +125,19 @@ function DataGridScrollArea({
|
|||||||
document.body.style.webkitUserSelect = ""
|
document.body.style.webkitUserSelect = ""
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const resetMetrics = useCallback(() => {
|
// The overlay is mounted one commit after the sync that detected overflow,
|
||||||
const container = containerRef.current
|
// so it misses that sync's write. Seeding it from the ref callback lands the
|
||||||
|
// geometry during commit, before the browser paints the track.
|
||||||
|
const setOverlayRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
|
overlayRef.current = node
|
||||||
|
|
||||||
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
if (node) applyMetrics(node, metricsRef.current)
|
||||||
applyMetrics(container, INITIAL_METRICS)
|
}, [])
|
||||||
|
|
||||||
|
const resetMetrics = useCallback(() => {
|
||||||
|
if (!areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
||||||
metricsRef.current = INITIAL_METRICS
|
metricsRef.current = INITIAL_METRICS
|
||||||
|
if (overlayRef.current) applyMetrics(overlayRef.current, INITIAL_METRICS)
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
|
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
|
||||||
@@ -196,8 +205,13 @@ function DataGridScrollArea({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
|
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
|
||||||
applyMetrics(container, nextMetrics)
|
|
||||||
metricsRef.current = nextMetrics
|
metricsRef.current = nextMetrics
|
||||||
|
// Scoped to the overlay, never to the container. These four properties
|
||||||
|
// inherit, and thumbTop changes on essentially every scroll frame, so
|
||||||
|
// writing them on the element that wraps the whole grid invalidates
|
||||||
|
// computed style for every row and cell each frame. The overlay subtree
|
||||||
|
// is their only reader.
|
||||||
|
if (overlayRef.current) applyMetrics(overlayRef.current, nextMetrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasCustomVerticalOverflow((prev) =>
|
setHasCustomVerticalOverflow((prev) =>
|
||||||
@@ -218,21 +232,6 @@ function DataGridScrollArea({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
observedElementsRef.current = {
|
|
||||||
header: container.querySelector(
|
|
||||||
'[data-slot="data-grid-table"] thead'
|
|
||||||
) as HTMLElement | null,
|
|
||||||
horizontalScrollbar: container.querySelector(
|
|
||||||
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
|
||||||
) as HTMLElement | null,
|
|
||||||
table: container.querySelector(
|
|
||||||
'[data-slot="data-grid-table"]'
|
|
||||||
) as HTMLElement | null,
|
|
||||||
tableViewport: container.querySelector(
|
|
||||||
'[data-slot="data-grid-table-viewport"]'
|
|
||||||
) as HTMLElement | null,
|
|
||||||
}
|
|
||||||
|
|
||||||
let frame = 0
|
let frame = 0
|
||||||
|
|
||||||
const scheduleSync = () => {
|
const scheduleSync = () => {
|
||||||
@@ -240,25 +239,69 @@ function DataGridScrollArea({
|
|||||||
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleSync()
|
|
||||||
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
|
||||||
|
|
||||||
const observer =
|
const observer =
|
||||||
typeof ResizeObserver === "undefined"
|
typeof ResizeObserver === "undefined"
|
||||||
? null
|
? null
|
||||||
: new ResizeObserver(scheduleSync)
|
: new ResizeObserver(scheduleSync)
|
||||||
|
const observed = new Set<HTMLElement>()
|
||||||
|
|
||||||
observer?.observe(viewport)
|
const observeElement = (element: HTMLElement | null) => {
|
||||||
observedElementsRef.current.header &&
|
if (element && observer && !observed.has(element)) {
|
||||||
observer?.observe(observedElementsRef.current.header)
|
observer.observe(element)
|
||||||
observedElementsRef.current.table &&
|
observed.add(element)
|
||||||
observer?.observe(observedElementsRef.current.table)
|
}
|
||||||
observedElementsRef.current.tableViewport &&
|
}
|
||||||
observer?.observe(observedElementsRef.current.tableViewport)
|
|
||||||
|
const resolveObservedElements = () => {
|
||||||
|
observedElementsRef.current = {
|
||||||
|
header: container.querySelector(
|
||||||
|
'[data-slot="data-grid-table"] thead'
|
||||||
|
) as HTMLElement | null,
|
||||||
|
horizontalScrollbar: container.querySelector(
|
||||||
|
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
||||||
|
) as HTMLElement | null,
|
||||||
|
table: container.querySelector(
|
||||||
|
'[data-slot="data-grid-table"]'
|
||||||
|
) as HTMLElement | null,
|
||||||
|
tableViewport: container.querySelector(
|
||||||
|
'[data-slot="data-grid-table-viewport"]'
|
||||||
|
) as HTMLElement | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
observeElement(observedElementsRef.current.header)
|
||||||
|
observeElement(observedElementsRef.current.table)
|
||||||
|
observeElement(observedElementsRef.current.tableViewport)
|
||||||
|
|
||||||
|
return !!(
|
||||||
|
observedElementsRef.current.header && observedElementsRef.current.table
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
observeElement(viewport)
|
||||||
|
const resolvedOnMount = resolveObservedElements()
|
||||||
|
|
||||||
|
scheduleSync()
|
||||||
|
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
||||||
|
|
||||||
|
// A table that mounts after this effect (empty state swapped for data)
|
||||||
|
// would otherwise never be observed and the custom scrollbar would
|
||||||
|
// overlap the sticky header. One-shot: disconnects once resolved.
|
||||||
|
let mutationObserver: MutationObserver | null = null
|
||||||
|
if (!resolvedOnMount && typeof MutationObserver !== "undefined") {
|
||||||
|
mutationObserver = new MutationObserver(() => {
|
||||||
|
if (resolveObservedElements()) {
|
||||||
|
mutationObserver?.disconnect()
|
||||||
|
mutationObserver = null
|
||||||
|
scheduleSync()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mutationObserver.observe(container, { childList: true, subtree: true })
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(frame)
|
cancelAnimationFrame(frame)
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
|
mutationObserver?.disconnect()
|
||||||
viewport.removeEventListener("scroll", scheduleSync)
|
viewport.removeEventListener("scroll", scheduleSync)
|
||||||
clearDragState()
|
clearDragState()
|
||||||
}
|
}
|
||||||
@@ -350,6 +393,10 @@ function DataGridScrollArea({
|
|||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
<ScrollAreaPrimitive.Root
|
<ScrollAreaPrimitive.Root
|
||||||
data-slot="data-grid-scroll-area"
|
data-slot="data-grid-scroll-area"
|
||||||
|
// Styling hook: present while the sticky-header scroll mode detects
|
||||||
|
// vertical overflow, so consumers can style scrollable vs short
|
||||||
|
// grids with a plain ancestor attribute selector.
|
||||||
|
data-overflow-vertical={hasCustomVerticalOverflow ? "true" : undefined}
|
||||||
className={cn("relative", className)}
|
className={cn("relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -369,6 +416,14 @@ function DataGridScrollArea({
|
|||||||
data-orientation="horizontal"
|
data-orientation="horizontal"
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
className={SCROLLBAR_CLASSNAME}
|
className={SCROLLBAR_CLASSNAME}
|
||||||
|
style={
|
||||||
|
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
|
||||||
|
? {
|
||||||
|
marginInlineStart: scrollbarInsetStart || undefined,
|
||||||
|
marginInlineEnd: scrollbarInsetEnd || undefined,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Thumb
|
<ScrollAreaPrimitive.Thumb
|
||||||
data-slot="data-grid-thumb"
|
data-slot="data-grid-thumb"
|
||||||
@@ -394,6 +449,7 @@ function DataGridScrollArea({
|
|||||||
|
|
||||||
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
||||||
<div
|
<div
|
||||||
|
ref={setOverlayRef}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
CSSProperties,
|
memo,
|
||||||
ReactNode,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useId,
|
useId,
|
||||||
@@ -11,15 +9,23 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
|
import type { CSSProperties, ReactNode } from "react"
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import type {
|
||||||
|
DataGridFeatures,
|
||||||
|
DataGridTableInstance,
|
||||||
|
} from "@/components/reui/data-grid/data-grid"
|
||||||
import {
|
import {
|
||||||
DataGridTableBase,
|
DataGridTableBase,
|
||||||
DataGridTableBody,
|
DataGridTableBody,
|
||||||
DataGridTableBodyRow,
|
DataGridTableBodyRow,
|
||||||
DataGridTableBodyRowCell,
|
DataGridTableBodyRowCell,
|
||||||
|
DataGridTableBodyRowExpandded,
|
||||||
DataGridTableBodyRowSkeleton,
|
DataGridTableBodyRowSkeleton,
|
||||||
DataGridTableBodyRowSkeletonCell,
|
DataGridTableBodyRowSkeletonCell,
|
||||||
DataGridTableEmpty,
|
DataGridTableEmpty,
|
||||||
|
DataGridTableFillBodyCell,
|
||||||
|
DataGridTableFillHeadCell,
|
||||||
DataGridTableFoot,
|
DataGridTableFoot,
|
||||||
DataGridTableHead,
|
DataGridTableHead,
|
||||||
DataGridTableHeadRow,
|
DataGridTableHeadRow,
|
||||||
@@ -31,23 +37,33 @@ import {
|
|||||||
import {
|
import {
|
||||||
closestCenter,
|
closestCenter,
|
||||||
DndContext,
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
MouseSensor,
|
MouseSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
UniqueIdentifier,
|
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
type CollisionDetection,
|
||||||
|
type DragCancelEvent,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
|
type DragMoveEvent,
|
||||||
|
type DragOverEvent,
|
||||||
|
type DragStartEvent,
|
||||||
type Modifier,
|
type Modifier,
|
||||||
|
type UniqueIdentifier,
|
||||||
} from "@dnd-kit/core"
|
} from "@dnd-kit/core"
|
||||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
useSortable,
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
|
type SortingStrategy,
|
||||||
} from "@dnd-kit/sortable"
|
} from "@dnd-kit/sortable"
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
|
import { flexRender } from "@tanstack/react-table"
|
||||||
|
import type { Cell, HeaderGroup, Row, Table } from "@tanstack/react-table"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
@@ -60,20 +76,65 @@ const SortableRowContext = createContext<Pick<
|
|||||||
"attributes" | "listeners"
|
"attributes" | "listeners"
|
||||||
> | null>(null)
|
> | null>(null)
|
||||||
|
|
||||||
function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
/**
|
||||||
|
* Tree metadata attached to every sortable row, readable from
|
||||||
|
* `active.data.current` / `over.data.current` in any drag event. Cross-parent
|
||||||
|
* drops can be resolved from it without re-deriving the shape of the table.
|
||||||
|
*/
|
||||||
|
type DataGridTableDndRowData = {
|
||||||
|
type: "data-grid-row"
|
||||||
|
/** Tree depth, 0 for root rows. */
|
||||||
|
depth: number
|
||||||
|
/** Index within the parent's children, or within the root rows. */
|
||||||
|
index: number
|
||||||
|
/** Parent row id, or null for root rows. */
|
||||||
|
parentId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-row render slot for drop indicators and depth guides. The returned node
|
||||||
|
* is positioned over the row, so it never adds a column, shifts striping, or
|
||||||
|
* gets clipped by a truncating resizable cell.
|
||||||
|
*/
|
||||||
|
type DataGridTableDndRowDecoration<TData extends object> = (context: {
|
||||||
|
row: Row<DataGridFeatures, TData>
|
||||||
|
isDragging: boolean
|
||||||
|
isOver: boolean
|
||||||
|
}) => ReactNode
|
||||||
|
|
||||||
|
function DataGridTableDndRowHandle({
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
disabledLabel = "Reordering unavailable",
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
/**
|
||||||
|
* Renders the grip inert instead of withdrawing it. A grid that reorders on
|
||||||
|
* one truth (manual order) and sorts on another cannot honour both at once,
|
||||||
|
* but dropping the handle entirely collapses the gutter and reads as broken
|
||||||
|
* rather than as unavailable. Keep the column's shape, mute the control.
|
||||||
|
*/
|
||||||
|
disabled?: boolean
|
||||||
|
/** Announced and shown on hover in place of the drag affordance. */
|
||||||
|
disabledLabel?: string
|
||||||
|
}) {
|
||||||
const context = useContext(SortableRowContext)
|
const context = useContext(SortableRowContext)
|
||||||
|
|
||||||
if (!context) {
|
if (!context || disabled) {
|
||||||
// Fallback if context is not available (shouldn't happen in normal usage)
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||||
|
// The Button's own disabled treatment supplies the muting; only the
|
||||||
|
// cursor needs saying, so the grip reads as unavailable rather than
|
||||||
|
// merely unresponsive.
|
||||||
|
disabled && "cursor-not-allowed",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label="Перетащить строку"
|
aria-label={disabled ? disabledLabel : "Drag to reorder row"}
|
||||||
|
title={disabled ? disabledLabel : undefined}
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<GripHorizontalIcon aria-hidden="true" />
|
<GripHorizontalIcon aria-hidden="true" />
|
||||||
@@ -89,7 +150,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
|||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label="Перетащить строку"
|
aria-label="Drag to reorder row"
|
||||||
{...context.attributes}
|
{...context.attributes}
|
||||||
{...context.listeners}
|
{...context.listeners}
|
||||||
>
|
>
|
||||||
@@ -98,60 +159,357 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
/**
|
||||||
|
* The rows do not move while one is being carried.
|
||||||
|
*
|
||||||
|
* Sliding the siblings apart opens a gap the carried row could go into, which
|
||||||
|
* reads well in a list of identical rows and badly in a table: the gap is the
|
||||||
|
* height of the row you are holding, so with rows of unequal height it never
|
||||||
|
* matches the slot it claims to be, and the row you picked up slides away from
|
||||||
|
* where it started - which is exactly the position you need to remember if you
|
||||||
|
* decide not to drop it.
|
||||||
|
*
|
||||||
|
* Holding everything still keeps the origin legible, and nothing is lost: the
|
||||||
|
* DragOverlay clone follows the pointer and the drop indicator names the seam.
|
||||||
|
* Pass `verticalListSortingStrategy` as `sortingStrategy` for the old feel.
|
||||||
|
*/
|
||||||
|
const holdRowsInPlaceStrategy: SortingStrategy = () => null
|
||||||
|
|
||||||
|
function DataGridTableDndRow<TData extends object>({
|
||||||
|
row,
|
||||||
|
renderRowDecoration,
|
||||||
|
dropIndicator = true,
|
||||||
|
}: {
|
||||||
|
row: Row<DataGridFeatures, TData>
|
||||||
|
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
|
||||||
|
dropIndicator?: boolean
|
||||||
|
}) {
|
||||||
|
const rowData: DataGridTableDndRowData = {
|
||||||
|
type: "data-grid-row",
|
||||||
|
depth: row.depth,
|
||||||
|
index: row.index,
|
||||||
|
parentId: row.getParentRow()?.id ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
transform,
|
transform,
|
||||||
transition,
|
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
isDragging,
|
isDragging,
|
||||||
|
isOver,
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
|
index,
|
||||||
|
activeIndex,
|
||||||
|
overIndex,
|
||||||
} = useSortable({
|
} = useSortable({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
data: rowData,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Which edge of THIS row the carried row would land on, or null when it is
|
||||||
|
// not the drop target. Nothing slides apart any more, so the bar is the only
|
||||||
|
// thing that says where the drop goes: it marks the row at the destination
|
||||||
|
// index, on the side the carried row comes to rest.
|
||||||
|
//
|
||||||
|
// Dragging down it lands after the target, dragging up before it, so the
|
||||||
|
// edge follows the direction of travel.
|
||||||
|
const dropEdge =
|
||||||
|
dropIndicator && activeIndex !== -1 && index === overIndex && !isDragging
|
||||||
|
? activeIndex < overIndex
|
||||||
|
? "bottom"
|
||||||
|
: "top"
|
||||||
|
: null
|
||||||
|
|
||||||
const style: CSSProperties = {
|
const style: CSSProperties = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition: transition,
|
// dnd-kit's transition is deliberately dropped. A transition on a transform
|
||||||
opacity: isDragging ? 0.8 : 1,
|
// property of a `tr` does not merely fail to animate in Chrome, it stops the
|
||||||
|
// transform applying at all: the element sits at the start value forever.
|
||||||
|
// The drag source escapes it because dnd-kit disables its own transition
|
||||||
|
// while it is being dragged, which is why the carried row used to be the
|
||||||
|
// ONLY one that moved and every other row silently refused to open a gap.
|
||||||
|
// Displacement therefore lands in one step, which is what a table wants.
|
||||||
zIndex: isDragging ? 1 : 0,
|
zIndex: isDragging ? 1 : 0,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
cursor: isDragging ? "grabbing" : undefined,
|
||||||
|
// The row you are holding is drawn by the DragOverlay, so the one left
|
||||||
|
// behind is not a second copy of it - it is the slot you came from, and it
|
||||||
|
// stays exactly where it was. Fading alone read as "this row is busy";
|
||||||
|
// the outline says "this is the space you are moving out of", which is the
|
||||||
|
// thing you need if you change your mind mid-drag.
|
||||||
|
...(isDragging && {
|
||||||
|
opacity: 0.4,
|
||||||
|
// Inset so the dashes sit inside the row box and cannot be clipped by
|
||||||
|
// the neighbouring row's border.
|
||||||
|
outline: "1px dashed var(--border)",
|
||||||
|
outlineOffset: "-1px",
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decoration = renderRowDecoration?.({ row, isDragging, isOver })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||||
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
|
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
|
||||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
|
{row
|
||||||
return (
|
.getVisibleCells()
|
||||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
.map((cell: Cell<DataGridFeatures, TData, unknown>, index, cells) => {
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
return (
|
||||||
</DataGridTableBodyRowCell>
|
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
||||||
)
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
})}
|
{decoration && index === cells.length - 1 ? (
|
||||||
|
// Rides inside the last cell rather than in a `td` of its own.
|
||||||
|
// An absolutely positioned `td` is still a cell as far as table
|
||||||
|
// layout is concerned, so it added a NINTH column with no width
|
||||||
|
// of its own, and under `table-layout: fixed` that new column
|
||||||
|
// swallowed the whole surplus the real columns had been sharing
|
||||||
|
// — every column snapped back to its declared size and the row's
|
||||||
|
// content visibly narrowed the moment a drag began. A plain
|
||||||
|
// element adds no column. It still anchors to the ROW, because
|
||||||
|
// the row is the nearest positioned ancestor, so the decoration
|
||||||
|
// spans the full width and is not clipped by the cell.
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="data-grid-table-row-decoration"
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
>
|
||||||
|
{decoration}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{dropEdge && index === cells.length - 1 ? (
|
||||||
|
// Same anchoring trick as the decoration above: a plain
|
||||||
|
// element inside the last cell, so it adds no column and
|
||||||
|
// cannot disturb `table-layout: fixed`. It spans the row
|
||||||
|
// because the row is the nearest positioned ancestor.
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="data-grid-table-row-drop-indicator"
|
||||||
|
data-edge={dropEdge}
|
||||||
|
className="pointer-events-none absolute inset-0 z-20"
|
||||||
|
>
|
||||||
|
{/* Two solid pixels down the leading edge, the same marker
|
||||||
|
the tree drag uses for its drop target. A wash across
|
||||||
|
the row has to stay faint enough not to read as a
|
||||||
|
selected row, and in the achromatic styles primary
|
||||||
|
carries no chroma at all, so faint plus colourless is
|
||||||
|
just grey. The bar reads at any weight and leaves the
|
||||||
|
row's own background to hover and selection.
|
||||||
|
|
||||||
|
The bar is the whole indicator: the gap the rows have
|
||||||
|
already opened says which side, so a rule across the
|
||||||
|
seam as well only competes with the row borders it sits
|
||||||
|
between. `data-edge` still carries the direction for
|
||||||
|
anyone styling their own. */}
|
||||||
|
<span className="bg-primary absolute inset-y-0 start-0 w-0.5" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DataGridTableBodyRowCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<DataGridTableFillBodyCell />
|
||||||
</DataGridTableBodyRow>
|
</DataGridTableBodyRow>
|
||||||
|
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
||||||
</SortableRowContext.Provider>
|
</SortableRowContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridTableDndRows<TData>({
|
function DataGridTableDndRowsBody<TData extends object>({
|
||||||
|
table,
|
||||||
|
dataIds,
|
||||||
|
renderRowDecoration,
|
||||||
|
dropIndicator,
|
||||||
|
sortingStrategy,
|
||||||
|
}: {
|
||||||
|
table: DataGridTableInstance<TData>
|
||||||
|
dataIds: UniqueIdentifier[]
|
||||||
|
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
|
||||||
|
dropIndicator?: boolean
|
||||||
|
sortingStrategy: SortingStrategy
|
||||||
|
}) {
|
||||||
|
const { isLoading, props } = useDataGrid()
|
||||||
|
const pagination = table.state.pagination
|
||||||
|
|
||||||
|
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||||
|
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||||
|
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||||
|
return (
|
||||||
|
<DataGridTableBodyRowSkeletonCell
|
||||||
|
column={column}
|
||||||
|
key={colIndex}
|
||||||
|
>
|
||||||
|
{column.columnDef.meta?.skeleton}
|
||||||
|
</DataGridTableBodyRowSkeletonCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<DataGridTableFillBodyCell />
|
||||||
|
</DataGridTableBodyRowSkeleton>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableContext items={dataIds} strategy={sortingStrategy}>
|
||||||
|
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
|
||||||
|
return (
|
||||||
|
<DataGridTableDndRow
|
||||||
|
row={row}
|
||||||
|
renderRowDecoration={renderRowDecoration}
|
||||||
|
dropIndicator={dropIndicator}
|
||||||
|
key={row.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoized body rows: skip re-renders during active column resize.
|
||||||
|
* Column widths update via CSS variables on the <table> element,
|
||||||
|
* so the browser handles width changes without React re-renders.
|
||||||
|
*/
|
||||||
|
const MemoizedDataGridTableDndRowsBody = memo(
|
||||||
|
DataGridTableDndRowsBody,
|
||||||
|
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
|
||||||
|
) as typeof DataGridTableDndRowsBody
|
||||||
|
|
||||||
|
function DataGridTableDndRows<TData extends object>({
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
dataIds,
|
dataIds,
|
||||||
footerContent,
|
footerContent,
|
||||||
|
collisionDetection = closestCenter,
|
||||||
|
modifiers,
|
||||||
|
sortingStrategy = holdRowsInPlaceStrategy,
|
||||||
|
renderRowDecoration,
|
||||||
|
dropIndicator = true,
|
||||||
|
onDragStart,
|
||||||
|
onDragMove,
|
||||||
|
onDragOver,
|
||||||
|
onDragCancel,
|
||||||
}: {
|
}: {
|
||||||
handleDragEnd: (event: DragEndEvent) => void
|
handleDragEnd: (event: DragEndEvent) => void
|
||||||
dataIds: UniqueIdentifier[]
|
dataIds: UniqueIdentifier[]
|
||||||
footerContent?: ReactNode
|
footerContent?: ReactNode
|
||||||
|
/** Overrides the default `closestCenter` strategy. */
|
||||||
|
collisionDetection?: CollisionDetection
|
||||||
|
/**
|
||||||
|
* Replaces the default axis restriction, e.g. drop `restrictToVerticalAxis`
|
||||||
|
* to allow the horizontal gesture that tree re-parenting relies on. The
|
||||||
|
* table container clamp is always applied after these, so a dragged row
|
||||||
|
* cannot leave the grid.
|
||||||
|
*/
|
||||||
|
modifiers?: Modifier[]
|
||||||
|
/**
|
||||||
|
* Replaces the default `verticalListSortingStrategy`. Return null from a
|
||||||
|
* strategy to leave every row exactly where it is. A tree needs that: its drop
|
||||||
|
* is either INTO the hovered row or BETWEEN two rows, and which one it is
|
||||||
|
* flips as the pointer crosses a single row, so a gap that opens for one and
|
||||||
|
* shuts for the other flickers the whole surface. Such a caller draws its own
|
||||||
|
* insertion line instead, and pairs this with a modifier that holds the
|
||||||
|
* carried row still, since a gap nothing moves into is just a hole.
|
||||||
|
*/
|
||||||
|
sortingStrategy?: SortingStrategy
|
||||||
|
/** Per-row slot for drop indicators and depth guides. */
|
||||||
|
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
|
||||||
|
/**
|
||||||
|
* Draws a line on the seam the carried row would land on. On by default;
|
||||||
|
* pass `false` when `renderRowDecoration` paints its own insertion affordance
|
||||||
|
* and the two would compete.
|
||||||
|
*/
|
||||||
|
dropIndicator?: boolean
|
||||||
|
onDragStart?: (event: DragStartEvent) => void
|
||||||
|
onDragMove?: (event: DragMoveEvent) => void
|
||||||
|
onDragOver?: (event: DragOverEvent) => void
|
||||||
|
onDragCancel?: (event: DragCancelEvent) => void
|
||||||
}) {
|
}) {
|
||||||
const { table, isLoading, props } = useDataGrid()
|
const { table, props } = useDataGrid<TData>()
|
||||||
const pagination = table.getState().pagination
|
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
const tableContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
||||||
|
// The overlay is portalled to the document body. dnd-kit renders DragOverlay
|
||||||
|
// in place, and it positions with `position: fixed` against viewport
|
||||||
|
// coordinates - so any ancestor that establishes a containing block for fixed
|
||||||
|
// descendants silently re-anchors it. `content-visibility`, `contain`,
|
||||||
|
// `transform`, `filter` and `will-change` all do that, and the first two are
|
||||||
|
// exactly what a card grid uses to defer off-screen work. The clone then
|
||||||
|
// lands offset by that ancestor's own top/left, and the container clamp
|
||||||
|
// below mis-clamps too, because its rects are measured in viewport space.
|
||||||
|
//
|
||||||
|
// Resolved in an effect rather than read at render so the server and the
|
||||||
|
// first client render agree. A drag cannot start before hydration, so the
|
||||||
|
// overlay being absent for one frame costs nothing.
|
||||||
|
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPortalTarget(document.body)
|
||||||
|
}, [])
|
||||||
|
// The row being carried, plus the column widths measured off the header the
|
||||||
|
// moment the drag starts. The clone lives outside the table, so it has no
|
||||||
|
// columns of its own and has to be told what they are.
|
||||||
|
const [carried, setCarried] = useState<{
|
||||||
|
id: UniqueIdentifier
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
columns: number[]
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const pickUpRow = useCallback((id: UniqueIdentifier) => {
|
||||||
|
const container = tableContainerRef.current
|
||||||
|
const head = container?.querySelector("thead tr")
|
||||||
|
if (!container || !head) {
|
||||||
|
setCarried(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clone has to be exactly as tall as the row it was lifted from.
|
||||||
|
// A fixed height reads as the grid growing under the pointer the moment
|
||||||
|
// you pick a row up, and it is wrong in both directions: rows whose
|
||||||
|
// content wraps are taller than any constant, and dense rows are shorter.
|
||||||
|
const source = Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>("tbody tr[data-row-id]")
|
||||||
|
).find((candidate) => candidate.dataset.rowId === String(id))
|
||||||
|
const height = source?.getBoundingClientRect().height ?? 0
|
||||||
|
|
||||||
|
// The fill cell is a header-only spacer that soaks up the surplus a column
|
||||||
|
// resize leaves behind, and the clone renders data cells only. Measuring it
|
||||||
|
// in would make the clone's table wider than the cells it actually holds,
|
||||||
|
// and `table-fixed` hands that orphaned width back out across every column
|
||||||
|
// -- the carried row comes out visibly wider than the row it was lifted
|
||||||
|
// from. So the width is the sum of what we render, never the header's own.
|
||||||
|
const columns = Array.from(head.children)
|
||||||
|
.filter(
|
||||||
|
(cell) =>
|
||||||
|
cell.getAttribute("data-slot") !== "data-grid-table-fill-head-cell"
|
||||||
|
)
|
||||||
|
.map((cell) => cell.getBoundingClientRect().width)
|
||||||
|
|
||||||
|
setCarried({
|
||||||
|
id,
|
||||||
|
width: columns.reduce((total, width) => total + width, 0),
|
||||||
|
height,
|
||||||
|
columns,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const carriedRow = carried
|
||||||
|
? table
|
||||||
|
.getRowModel()
|
||||||
|
.rows.find((row: Row<DataGridFeatures, TData>) => row.id === carried.id)
|
||||||
|
: undefined
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, {}),
|
useSensor(MouseSensor, {}),
|
||||||
useSensor(TouchSensor, {}),
|
useSensor(TouchSensor, {}),
|
||||||
useSensor(KeyboardSensor, {})
|
// Keyboard reordering moves one sortable position per keypress instead
|
||||||
|
// of the sensor's raw 25px default.
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -170,7 +528,7 @@ function DataGridTableDndRows<TData>({
|
|||||||
}
|
}
|
||||||
}, [isDraggingRow])
|
}, [isDraggingRow])
|
||||||
|
|
||||||
const modifiers = useMemo(() => {
|
const resolvedModifiers = useMemo(() => {
|
||||||
const restrictToTableContainer: Modifier = ({
|
const restrictToTableContainer: Modifier = ({
|
||||||
transform,
|
transform,
|
||||||
draggingNodeRect,
|
draggingNodeRect,
|
||||||
@@ -189,25 +547,48 @@ function DataGridTableDndRows<TData>({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...transform,
|
...transform,
|
||||||
x: Math.max(minX, Math.min(maxX, x)),
|
// The horizontal rail only engages while the default axis restriction
|
||||||
|
// is in force. A row is exactly as wide as the viewport, so minX and
|
||||||
|
// maxX both collapse to 0 and clamping x erases it entirely: harmless
|
||||||
|
// under restrictToVerticalAxis, which zeroes x anyway, but fatal for a
|
||||||
|
// caller that replaced the restriction precisely to READ x, as a tree
|
||||||
|
// does to resolve drop depth. Vertical is railed either way, which is
|
||||||
|
// what actually keeps a dragged row inside the grid.
|
||||||
|
x: modifiers ? x : Math.max(minX, Math.min(maxX, x)),
|
||||||
y: Math.max(minY, Math.min(maxY, y)),
|
y: Math.max(minY, Math.min(maxY, y)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [restrictToVerticalAxis, restrictToTableContainer]
|
// The container clamp is a safety rail rather than a policy, so it stays
|
||||||
}, [])
|
// applied even when the caller replaces the axis restriction.
|
||||||
|
return [
|
||||||
|
...(modifiers ?? [restrictToVerticalAxis]),
|
||||||
|
restrictToTableContainer,
|
||||||
|
]
|
||||||
|
}, [modifiers])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
id={useId()}
|
id={useId()}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={collisionDetection}
|
||||||
modifiers={modifiers}
|
modifiers={resolvedModifiers}
|
||||||
onDragCancel={() => setIsDraggingRow(false)}
|
onDragCancel={(event) => {
|
||||||
|
setIsDraggingRow(false)
|
||||||
|
setCarried(null)
|
||||||
|
onDragCancel?.(event)
|
||||||
|
}}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
setIsDraggingRow(false)
|
setIsDraggingRow(false)
|
||||||
|
setCarried(null)
|
||||||
handleDragEnd(event)
|
handleDragEnd(event)
|
||||||
}}
|
}}
|
||||||
onDragStart={() => setIsDraggingRow(true)}
|
onDragMove={onDragMove}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
setIsDraggingRow(true)
|
||||||
|
pickUpRow(event.active.id)
|
||||||
|
onDragStart?.(event)
|
||||||
|
}}
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
>
|
>
|
||||||
<DataGridTableViewport
|
<DataGridTableViewport
|
||||||
@@ -222,38 +603,43 @@ function DataGridTableDndRows<TData>({
|
|||||||
<DataGridTableHead>
|
<DataGridTableHead>
|
||||||
{table
|
{table
|
||||||
.getHeaderGroups()
|
.getHeaderGroups()
|
||||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
.map(
|
||||||
return (
|
(headerGroup: HeaderGroup<DataGridFeatures, TData>, index) => {
|
||||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
return (
|
||||||
{headerGroup.headers.map((header, index) => {
|
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||||
const { column } = header
|
{headerGroup.headers.map((header, index) => {
|
||||||
|
const { column } = header
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridTableHeadRowCell header={header} key={index}>
|
<DataGridTableHeadRowCell header={header} key={index}>
|
||||||
{header.isPlaceholder ? null : props.tableLayout
|
{header.isPlaceholder ? null : props.tableLayout
|
||||||
?.columnsResizable && column.getCanResize() ? (
|
?.columnsResizable && column.getCanResize() ? (
|
||||||
<div className="truncate">
|
<>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
)}
|
||||||
</DataGridTableHeadRowCell>
|
{props.tableLayout?.columnsResizable &&
|
||||||
)
|
column.getCanResize() && (
|
||||||
})}
|
<DataGridTableHeadRowCellResize
|
||||||
</DataGridTableHeadRow>
|
header={header}
|
||||||
)
|
/>
|
||||||
})}
|
)}
|
||||||
|
</DataGridTableHeadRowCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<DataGridTableFillHeadCell />
|
||||||
|
</DataGridTableHeadRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)}
|
||||||
</DataGridTableHead>
|
</DataGridTableHead>
|
||||||
|
|
||||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||||
@@ -261,35 +647,13 @@ function DataGridTableDndRows<TData>({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DataGridTableBody>
|
<DataGridTableBody>
|
||||||
{props.loadingMode === "skeleton" &&
|
<MemoizedDataGridTableDndRowsBody
|
||||||
isLoading &&
|
table={table}
|
||||||
pagination?.pageSize ? (
|
dataIds={dataIds}
|
||||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
renderRowDecoration={renderRowDecoration}
|
||||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
dropIndicator={dropIndicator}
|
||||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
sortingStrategy={sortingStrategy}
|
||||||
return (
|
/>
|
||||||
<DataGridTableBodyRowSkeletonCell
|
|
||||||
column={column}
|
|
||||||
key={colIndex}
|
|
||||||
>
|
|
||||||
{column.columnDef.meta?.skeleton}
|
|
||||||
</DataGridTableBodyRowSkeletonCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableBodyRowSkeleton>
|
|
||||||
))
|
|
||||||
) : table.getRowModel().rows.length ? (
|
|
||||||
<SortableContext
|
|
||||||
items={dataIds}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
|
||||||
return <DataGridTableDndRow row={row} key={row.id} />
|
|
||||||
})}
|
|
||||||
</SortableContext>
|
|
||||||
) : (
|
|
||||||
<DataGridTableEmpty />
|
|
||||||
)}
|
|
||||||
</DataGridTableBody>
|
</DataGridTableBody>
|
||||||
|
|
||||||
{footerContent && (
|
{footerContent && (
|
||||||
@@ -297,8 +661,75 @@ function DataGridTableDndRows<TData>({
|
|||||||
)}
|
)}
|
||||||
</DataGridTableBase>
|
</DataGridTableBase>
|
||||||
</DataGridTableViewport>
|
</DataGridTableViewport>
|
||||||
|
|
||||||
|
{/* The row you are actually holding. It is a real clone rendered outside
|
||||||
|
the table, which is the only way a dragged row can follow the pointer
|
||||||
|
without disturbing the grid: it adds no cell, so it cannot alter the
|
||||||
|
column widths, and it floats above the rows rather than through them.
|
||||||
|
Its presence also tells dnd-kit to stop translating the source row, so
|
||||||
|
the row left behind simply dims in place.
|
||||||
|
|
||||||
|
Portalled to the body so the fixed positioning resolves against the
|
||||||
|
viewport wherever the grid is mounted. React context crosses a portal,
|
||||||
|
so DndContext still reaches it. */}
|
||||||
|
{portalTarget
|
||||||
|
? createPortal(
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{carried && carriedRow ? (
|
||||||
|
<table
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ width: carried.width, tableLayout: "fixed" }}
|
||||||
|
className="bg-background border-border pointer-events-none cursor-grabbing rounded-md border shadow-lg"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
{/* Padding rides on the inner element, not the cell. A `td` can
|
||||||
|
never render narrower than its own horizontal padding, so a
|
||||||
|
column resized below that would silently widen here and the
|
||||||
|
clone would stop matching the row it came from. Height comes
|
||||||
|
from the measured source row for the same reason the widths
|
||||||
|
do: the clone has no row of its own to inherit it from. */}
|
||||||
|
<tr
|
||||||
|
style={{ height: carried.height || undefined }}
|
||||||
|
className="[&>td]:p-0 [&>td]:align-middle"
|
||||||
|
>
|
||||||
|
{carriedRow
|
||||||
|
.getVisibleCells()
|
||||||
|
.map(
|
||||||
|
(
|
||||||
|
cell: Cell<DataGridFeatures, TData, unknown>,
|
||||||
|
index: number
|
||||||
|
) => (
|
||||||
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
// Falls back to the column's own size so an unforeseen
|
||||||
|
// header/cell count mismatch degrades to a real width
|
||||||
|
// rather than to `auto`.
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
carried.columns[index] ??
|
||||||
|
cell.column.getSize(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="truncate px-3">
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>,
|
||||||
|
portalTarget
|
||||||
|
)
|
||||||
|
: null}
|
||||||
</DndContext>
|
</DndContext>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||||
|
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CSSProperties,
|
|
||||||
Fragment,
|
Fragment,
|
||||||
ReactNode,
|
memo,
|
||||||
useEffect,
|
useEffect,
|
||||||
useId,
|
useId,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
|
import type { CSSProperties, ReactNode } from "react"
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import type {
|
||||||
|
DataGridFeatures,
|
||||||
|
DataGridTableInstance,
|
||||||
|
} from "@/components/reui/data-grid/data-grid"
|
||||||
import {
|
import {
|
||||||
DataGridTableBase,
|
DataGridTableBase,
|
||||||
DataGridTableBody,
|
DataGridTableBody,
|
||||||
@@ -18,6 +24,8 @@ import {
|
|||||||
DataGridTableBodyRowSkeleton,
|
DataGridTableBodyRowSkeleton,
|
||||||
DataGridTableBodyRowSkeletonCell,
|
DataGridTableBodyRowSkeletonCell,
|
||||||
DataGridTableEmpty,
|
DataGridTableEmpty,
|
||||||
|
DataGridTableFillBodyCell,
|
||||||
|
DataGridTableFillHeadCell,
|
||||||
DataGridTableFoot,
|
DataGridTableFoot,
|
||||||
DataGridTableHead,
|
DataGridTableHead,
|
||||||
DataGridTableHeadRow,
|
DataGridTableHeadRow,
|
||||||
@@ -30,34 +38,36 @@ import {
|
|||||||
closestCenter,
|
closestCenter,
|
||||||
DndContext,
|
DndContext,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
Modifier,
|
|
||||||
MouseSensor,
|
MouseSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
|
type Modifier,
|
||||||
} from "@dnd-kit/core"
|
} from "@dnd-kit/core"
|
||||||
import {
|
import {
|
||||||
horizontalListSortingStrategy,
|
horizontalListSortingStrategy,
|
||||||
SortableContext,
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
useSortable,
|
useSortable,
|
||||||
} from "@dnd-kit/sortable"
|
} from "@dnd-kit/sortable"
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
import {
|
import { flexRender } from "@tanstack/react-table"
|
||||||
|
import type {
|
||||||
Cell,
|
Cell,
|
||||||
flexRender,
|
|
||||||
Header,
|
Header,
|
||||||
HeaderGroup,
|
HeaderGroup,
|
||||||
Row,
|
Row,
|
||||||
|
Table,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
import { GripVerticalIcon } from "lucide-react"
|
import { GripVerticalIcon } from "lucide-react"
|
||||||
|
|
||||||
function DataGridTableDndHeader<TData>({
|
function DataGridTableDndHeader<TData extends object>({
|
||||||
header,
|
header,
|
||||||
}: {
|
}: {
|
||||||
header: Header<TData, unknown>
|
header: Header<DataGridFeatures, TData, unknown>
|
||||||
}) {
|
}) {
|
||||||
const { props } = useDataGrid()
|
const { props } = useDataGrid()
|
||||||
const { column } = header
|
const { column } = header
|
||||||
@@ -105,7 +115,7 @@ function DataGridTableDndHeader<TData>({
|
|||||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
aria-label="Перетащить для изменения порядка"
|
aria-label="Drag to reorder"
|
||||||
>
|
>
|
||||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -123,7 +133,11 @@ function DataGridTableDndHeader<TData>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
function DataGridTableDndCell<TData extends object>({
|
||||||
|
cell,
|
||||||
|
}: {
|
||||||
|
cell: Cell<DataGridFeatures, TData, unknown>
|
||||||
|
}) {
|
||||||
const { props } = useDataGrid()
|
const { props } = useDataGrid()
|
||||||
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
||||||
id: cell.column.id,
|
id: cell.column.id,
|
||||||
@@ -148,22 +162,93 @@ function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridTableDnd<TData>({
|
function DataGridTableDndBodyRows<TData extends object>({
|
||||||
|
table,
|
||||||
|
}: {
|
||||||
|
table: DataGridTableInstance<TData>
|
||||||
|
}) {
|
||||||
|
const { isLoading, props } = useDataGrid()
|
||||||
|
const pagination = table.state.pagination
|
||||||
|
|
||||||
|
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||||
|
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||||
|
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||||
|
return (
|
||||||
|
<DataGridTableBodyRowSkeletonCell
|
||||||
|
column={column}
|
||||||
|
key={colIndex}
|
||||||
|
>
|
||||||
|
{column.columnDef.meta?.skeleton}
|
||||||
|
</DataGridTableBodyRowSkeletonCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<DataGridTableFillBodyCell />
|
||||||
|
</DataGridTableBodyRowSkeleton>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
|
||||||
|
return (
|
||||||
|
<Fragment key={row.id}>
|
||||||
|
<DataGridTableBodyRow row={row}>
|
||||||
|
<SortableContext
|
||||||
|
items={table.state.columnOrder}
|
||||||
|
strategy={horizontalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{row
|
||||||
|
.getVisibleCells()
|
||||||
|
.map((cell: Cell<DataGridFeatures, TData, unknown>) => (
|
||||||
|
<DataGridTableDndCell cell={cell} key={cell.id} />
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
<DataGridTableFillBodyCell />
|
||||||
|
</DataGridTableBodyRow>
|
||||||
|
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoized body rows: skip re-renders during active column resize.
|
||||||
|
* Column widths update via CSS variables on the <table> element,
|
||||||
|
* so the browser handles width changes without React re-renders.
|
||||||
|
*/
|
||||||
|
const MemoizedDataGridTableDndBodyRows = memo(
|
||||||
|
DataGridTableDndBodyRows,
|
||||||
|
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
|
||||||
|
) as typeof DataGridTableDndBodyRows
|
||||||
|
|
||||||
|
function DataGridTableDnd<TData extends object>({
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
footerContent,
|
footerContent,
|
||||||
}: {
|
}: {
|
||||||
handleDragEnd: (event: DragEndEvent) => void
|
handleDragEnd: (event: DragEndEvent) => void
|
||||||
footerContent?: ReactNode
|
footerContent?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const { table, isLoading, props } = useDataGrid()
|
const { table, props } = useDataGrid()
|
||||||
const pagination = table.getState().pagination
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, {}),
|
useSensor(MouseSensor, {}),
|
||||||
useSensor(TouchSensor, {}),
|
useSensor(TouchSensor, {}),
|
||||||
useSensor(KeyboardSensor, {})
|
// Keyboard reordering moves one sortable position per keypress instead
|
||||||
|
// of the sensor's raw 25px default.
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -237,23 +322,26 @@ function DataGridTableDnd<TData>({
|
|||||||
<DataGridTableHead>
|
<DataGridTableHead>
|
||||||
{table
|
{table
|
||||||
.getHeaderGroups()
|
.getHeaderGroups()
|
||||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
.map(
|
||||||
return (
|
(headerGroup: HeaderGroup<DataGridFeatures, TData>, index) => {
|
||||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
return (
|
||||||
<SortableContext
|
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||||
items={table.getState().columnOrder}
|
<SortableContext
|
||||||
strategy={horizontalListSortingStrategy}
|
items={table.state.columnOrder}
|
||||||
>
|
strategy={horizontalListSortingStrategy}
|
||||||
{headerGroup.headers.map((header) => (
|
>
|
||||||
<DataGridTableDndHeader
|
{headerGroup.headers.map((header) => (
|
||||||
header={header}
|
<DataGridTableDndHeader
|
||||||
key={header.id}
|
header={header}
|
||||||
/>
|
key={header.id}
|
||||||
))}
|
/>
|
||||||
</SortableContext>
|
))}
|
||||||
</DataGridTableHeadRow>
|
</SortableContext>
|
||||||
)
|
<DataGridTableFillHeadCell />
|
||||||
})}
|
</DataGridTableHeadRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)}
|
||||||
</DataGridTableHead>
|
</DataGridTableHead>
|
||||||
|
|
||||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||||
@@ -261,48 +349,7 @@ function DataGridTableDnd<TData>({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DataGridTableBody>
|
<DataGridTableBody>
|
||||||
{props.loadingMode === "skeleton" &&
|
<MemoizedDataGridTableDndBodyRows table={table} />
|
||||||
isLoading &&
|
|
||||||
pagination?.pageSize ? (
|
|
||||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
|
||||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
|
||||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowSkeletonCell
|
|
||||||
column={column}
|
|
||||||
key={colIndex}
|
|
||||||
>
|
|
||||||
{column.columnDef.meta?.skeleton}
|
|
||||||
</DataGridTableBodyRowSkeletonCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableBodyRowSkeleton>
|
|
||||||
))
|
|
||||||
) : table.getRowModel().rows.length ? (
|
|
||||||
table.getRowModel().rows.map((row: Row<TData>) => {
|
|
||||||
return (
|
|
||||||
<Fragment key={row.id}>
|
|
||||||
<DataGridTableBodyRow row={row}>
|
|
||||||
<SortableContext
|
|
||||||
items={table.getState().columnOrder}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{row
|
|
||||||
.getVisibleCells()
|
|
||||||
.map((cell: Cell<TData, unknown>) => (
|
|
||||||
<DataGridTableDndCell cell={cell} key={cell.id} />
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
</DataGridTableBodyRow>
|
|
||||||
{row.getIsExpanded() && (
|
|
||||||
<DataGridTableBodyRowExpandded row={row} />
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<DataGridTableEmpty />
|
|
||||||
)}
|
|
||||||
</DataGridTableBody>
|
</DataGridTableBody>
|
||||||
|
|
||||||
{footerContent && (
|
{footerContent && (
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
"use client"
|
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import type { CSSProperties, ReactNode } from "react"
|
||||||
import {
|
|
||||||
CSSProperties,
|
|
||||||
memo,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import type {
|
||||||
|
DataGridFeatures,
|
||||||
|
DataGridTableInstance,
|
||||||
|
} from "@/components/reui/data-grid/data-grid"
|
||||||
import {
|
import {
|
||||||
DataGridTableBase,
|
DataGridTableBase,
|
||||||
DataGridTableBody,
|
DataGridTableBody,
|
||||||
@@ -23,14 +19,16 @@ import {
|
|||||||
DataGridTableRenderedRow,
|
DataGridTableRenderedRow,
|
||||||
DataGridTableRowSpacer,
|
DataGridTableRowSpacer,
|
||||||
DataGridTableViewport,
|
DataGridTableViewport,
|
||||||
|
getDataGridScrollAreaViewport,
|
||||||
getDataGridTableMergedHeaderGroups,
|
getDataGridTableMergedHeaderGroups,
|
||||||
getDataGridTableRowSections,
|
getDataGridTableRowSections,
|
||||||
getPinningStyles,
|
getPinningStyles,
|
||||||
hasDataGridTableRightPinnedColumns,
|
hasDataGridTableRightPinnedColumns,
|
||||||
} from "@/components/reui/data-grid/data-grid-table"
|
} from "@/components/reui/data-grid/data-grid-table"
|
||||||
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
|
import { flexRender } from "@tanstack/react-table"
|
||||||
import {
|
import type { Column, Row, Table } from "@tanstack/react-table"
|
||||||
useVirtualizer,
|
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||||
|
import type {
|
||||||
VirtualItem,
|
VirtualItem,
|
||||||
Virtualizer,
|
Virtualizer,
|
||||||
VirtualizerOptions,
|
VirtualizerOptions,
|
||||||
@@ -49,21 +47,226 @@ type DataGridTableVirtualizerInstance = Virtualizer<
|
|||||||
HTMLTableRowElement
|
HTMLTableRowElement
|
||||||
>
|
>
|
||||||
|
|
||||||
type DataGridTableVirtualizerOptions<TData> = Omit<
|
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
|
||||||
|
|
||||||
|
type DataGridTableVirtualScrollRequest = {
|
||||||
|
align: DataGridTableVirtualScrollAlignment
|
||||||
|
behavior: ScrollBehavior
|
||||||
|
containerElement: HTMLDivElement
|
||||||
|
headerSticky: boolean
|
||||||
|
isVirtualizationEnabled: boolean
|
||||||
|
rowId: string | undefined
|
||||||
|
rowIndex: number
|
||||||
|
scrollElement: HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDataGridTableScrollRequest(
|
||||||
|
previous: DataGridTableVirtualScrollRequest | null,
|
||||||
|
next: DataGridTableVirtualScrollRequest
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
previous?.align === next.align &&
|
||||||
|
previous.behavior === next.behavior &&
|
||||||
|
previous.containerElement === next.containerElement &&
|
||||||
|
previous.headerSticky === next.headerSticky &&
|
||||||
|
previous.isVirtualizationEnabled === next.isVirtualizationEnabled &&
|
||||||
|
previous.rowId === next.rowId &&
|
||||||
|
previous.rowIndex === next.rowIndex &&
|
||||||
|
previous.scrollElement === next.scrollElement
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataGridTableScrollTarget({
|
||||||
|
align,
|
||||||
|
clientHeight,
|
||||||
|
rowBottom,
|
||||||
|
rowHeight,
|
||||||
|
rowTop,
|
||||||
|
scrollHeight,
|
||||||
|
scrollTop,
|
||||||
|
viewportTopOffset = 0,
|
||||||
|
}: {
|
||||||
|
align: DataGridTableVirtualScrollAlignment
|
||||||
|
clientHeight: number
|
||||||
|
rowBottom: number
|
||||||
|
rowHeight: number
|
||||||
|
rowTop: number
|
||||||
|
scrollHeight: number
|
||||||
|
scrollTop: number
|
||||||
|
viewportTopOffset?: number
|
||||||
|
}) {
|
||||||
|
const visibleHeight = Math.max(0, clientHeight - viewportTopOffset)
|
||||||
|
const viewportTop = scrollTop + viewportTopOffset
|
||||||
|
const viewportBottom = scrollTop + clientHeight
|
||||||
|
|
||||||
|
const targetTop =
|
||||||
|
align === "auto"
|
||||||
|
? rowTop < viewportTop
|
||||||
|
? rowTop - viewportTopOffset
|
||||||
|
: rowBottom > viewportBottom
|
||||||
|
? rowBottom - clientHeight
|
||||||
|
: null
|
||||||
|
: align === "start"
|
||||||
|
? rowTop - viewportTopOffset
|
||||||
|
: align === "end"
|
||||||
|
? rowBottom - clientHeight
|
||||||
|
: rowTop -
|
||||||
|
viewportTopOffset -
|
||||||
|
Math.max(0, (visibleHeight - rowHeight) / 2)
|
||||||
|
|
||||||
|
if (targetTop === null) return null
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
Math.max(0, targetTop),
|
||||||
|
Math.max(0, scrollHeight - clientHeight)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataGridTableHeaderOffset({
|
||||||
|
containerElement,
|
||||||
|
headerSticky,
|
||||||
|
scrollElement,
|
||||||
|
}: {
|
||||||
|
containerElement: HTMLDivElement
|
||||||
|
headerSticky: boolean
|
||||||
|
scrollElement: HTMLElement
|
||||||
|
}) {
|
||||||
|
if (!headerSticky) return 0
|
||||||
|
|
||||||
|
const headerElement = containerElement.querySelector<HTMLElement>(
|
||||||
|
':scope > [data-slot="data-grid-table"] > thead'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!headerElement) return 0
|
||||||
|
|
||||||
|
const scrollRect = scrollElement.getBoundingClientRect()
|
||||||
|
const headerRect = headerElement.getBoundingClientRect()
|
||||||
|
const headerBottomOffset = headerRect.bottom - scrollRect.top
|
||||||
|
const overlapsViewportTop =
|
||||||
|
headerRect.top <= scrollRect.top + 0.5 && headerBottomOffset > 0
|
||||||
|
|
||||||
|
if (!overlapsViewportTop) return 0
|
||||||
|
|
||||||
|
return Math.min(scrollElement.clientHeight, Math.max(0, headerBottomOffset))
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollDataGridTableToOffset({
|
||||||
|
behavior,
|
||||||
|
scrollElement,
|
||||||
|
targetTop,
|
||||||
|
virtualizer,
|
||||||
|
}: {
|
||||||
|
behavior: ScrollBehavior
|
||||||
|
scrollElement: HTMLElement
|
||||||
|
targetTop: number
|
||||||
|
virtualizer?: DataGridTableVirtualizerInstance
|
||||||
|
}) {
|
||||||
|
if (virtualizer) {
|
||||||
|
virtualizer.scrollToOffset(targetTop, { align: "start", behavior })
|
||||||
|
} else if (typeof scrollElement.scrollTo === "function") {
|
||||||
|
scrollElement.scrollTo({ behavior, top: targetTop })
|
||||||
|
} else {
|
||||||
|
scrollElement.scrollTop = targetTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollDataGridTableRowIntoView({
|
||||||
|
align,
|
||||||
|
behavior,
|
||||||
|
cancelPendingScroll = false,
|
||||||
|
containerElement,
|
||||||
|
headerSticky,
|
||||||
|
rowIndex,
|
||||||
|
scrollElement,
|
||||||
|
virtualizer,
|
||||||
|
}: {
|
||||||
|
align: DataGridTableVirtualScrollAlignment
|
||||||
|
behavior: ScrollBehavior
|
||||||
|
cancelPendingScroll?: boolean
|
||||||
|
containerElement: HTMLDivElement | null
|
||||||
|
headerSticky: boolean
|
||||||
|
rowIndex: number
|
||||||
|
scrollElement: HTMLElement | null
|
||||||
|
virtualizer?: DataGridTableVirtualizerInstance
|
||||||
|
}) {
|
||||||
|
if (!containerElement || !scrollElement) return false
|
||||||
|
|
||||||
|
const rowElement = containerElement.querySelector<HTMLTableRowElement>(
|
||||||
|
`:scope > [data-slot="data-grid-table"] > tbody > tr[data-index="${rowIndex}"]`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!rowElement) return false
|
||||||
|
|
||||||
|
const scrollRect = scrollElement.getBoundingClientRect()
|
||||||
|
const rowRect = rowElement.getBoundingClientRect()
|
||||||
|
const viewportTopOffset = getDataGridTableHeaderOffset({
|
||||||
|
containerElement,
|
||||||
|
headerSticky,
|
||||||
|
scrollElement,
|
||||||
|
})
|
||||||
|
const rowTop = scrollElement.scrollTop + rowRect.top - scrollRect.top
|
||||||
|
const rowBottom = scrollElement.scrollTop + rowRect.bottom - scrollRect.top
|
||||||
|
const targetTop = getDataGridTableScrollTarget({
|
||||||
|
align,
|
||||||
|
clientHeight: scrollElement.clientHeight,
|
||||||
|
rowBottom,
|
||||||
|
rowHeight: rowRect.height || rowElement.offsetHeight,
|
||||||
|
rowTop,
|
||||||
|
scrollHeight: scrollElement.scrollHeight,
|
||||||
|
scrollTop: scrollElement.scrollTop,
|
||||||
|
viewportTopOffset,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
targetTop === null ||
|
||||||
|
Math.abs(targetTop - scrollElement.scrollTop) < 0.5
|
||||||
|
) {
|
||||||
|
if (cancelPendingScroll) {
|
||||||
|
scrollDataGridTableToOffset({
|
||||||
|
behavior: "auto",
|
||||||
|
scrollElement,
|
||||||
|
targetTop: scrollElement.scrollTop,
|
||||||
|
virtualizer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollDataGridTableToOffset({
|
||||||
|
behavior,
|
||||||
|
scrollElement,
|
||||||
|
targetTop,
|
||||||
|
virtualizer,
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataGridTableVirtualizerOptions<TData extends object> = Omit<
|
||||||
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
||||||
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
||||||
> & {
|
> & {
|
||||||
estimateSize?: (index: number, row: Row<TData>) => number
|
estimateSize?: (index: number, row: Row<DataGridFeatures, TData>) => number
|
||||||
getItemKey?: (index: number, row: Row<TData>) => string | number
|
getItemKey?: (
|
||||||
|
index: number,
|
||||||
|
row: Row<DataGridFeatures, TData>
|
||||||
|
) => string | number
|
||||||
getScrollElement?: (
|
getScrollElement?: (
|
||||||
elements: DataGridTableVirtualScrollElements
|
elements: DataGridTableVirtualScrollElements
|
||||||
) => HTMLElement | null
|
) => HTMLElement | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DataGridTableVirtualProps<TData> {
|
interface DataGridTableVirtualProps<TData extends object> {
|
||||||
height?: number | string
|
height?: number | string
|
||||||
estimateSize?: number
|
estimateSize?: number
|
||||||
overscan?: number
|
overscan?: number
|
||||||
|
/** Scroll animation used when revealing a controlled target row. */
|
||||||
|
scrollBehavior?: ScrollBehavior
|
||||||
|
/** Alignment used when revealing a controlled target row. Defaults to auto. */
|
||||||
|
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
|
||||||
|
/** Index within the center (non-pinned) row section to reveal. */
|
||||||
|
scrollToRowIndex?: number
|
||||||
footerContent?: ReactNode
|
footerContent?: ReactNode
|
||||||
renderHeader?: boolean
|
renderHeader?: boolean
|
||||||
onFetchMore?: () => void
|
onFetchMore?: () => void
|
||||||
@@ -73,11 +276,11 @@ interface DataGridTableVirtualProps<TData> {
|
|||||||
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VirtualBodyProps<TData> {
|
interface VirtualBodyProps<TData extends object> {
|
||||||
table: Table<TData>
|
table: DataGridTableInstance<TData>
|
||||||
topRows: Row<TData>[]
|
topRows: Row<DataGridFeatures, TData>[]
|
||||||
centerRows: Row<TData>[]
|
centerRows: Row<DataGridFeatures, TData>[]
|
||||||
bottomRows: Row<TData>[]
|
bottomRows: Row<DataGridFeatures, TData>[]
|
||||||
virtualItems: VirtualItem[]
|
virtualItems: VirtualItem[]
|
||||||
totalSize: number
|
totalSize: number
|
||||||
isVirtualizationEnabled: boolean
|
isVirtualizationEnabled: boolean
|
||||||
@@ -89,16 +292,16 @@ interface VirtualBodyProps<TData> {
|
|||||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
|
function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
|
||||||
column,
|
column,
|
||||||
}: {
|
}: {
|
||||||
column: Column<TData>
|
column: Column<DataGridFeatures, TData, unknown>
|
||||||
}) {
|
}) {
|
||||||
const { props } = useDataGrid()
|
const { props } = useDataGrid()
|
||||||
const isPinned = column.getIsPinned()
|
const isPinned = column.getIsPinned()
|
||||||
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
|
const isLastStartPinned =
|
||||||
const isFirstRightPinned =
|
isPinned === "start" && column.getIsLastColumn("start")
|
||||||
isPinned === "right" && column.getIsFirstColumn("right")
|
const isFirstEndPinned = isPinned === "end" && column.getIsFirstColumn("end")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
@@ -113,20 +316,20 @@ function DataGridTableVirtualPinnedPlaceholderCell<TData>({
|
|||||||
}}
|
}}
|
||||||
data-pinned={isPinned || undefined}
|
data-pinned={isPinned || undefined}
|
||||||
data-last-col={
|
data-last-col={
|
||||||
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
|
isLastStartPinned ? "start" : isFirstEndPinned ? "end" : undefined
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-0",
|
"p-0",
|
||||||
props.tableLayout?.cellBorder && "border-e",
|
props.tableLayout?.cellBorder && "border-e",
|
||||||
props.tableLayout?.columnsPinnable &&
|
props.tableLayout?.columnsPinnable &&
|
||||||
column.getCanPin() &&
|
column.getCanPin() &&
|
||||||
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
|
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=end][data-last-col=end]]:shadow-[inset_1px_0_0_0_var(--border)] [&[data-pinned=start][data-last-col=start]]:shadow-[inset_-1px_0_0_0_var(--border)]"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridTableVirtualUtilityRow<TData>({
|
function DataGridTableVirtualUtilityRow<TData extends object>({
|
||||||
table,
|
table,
|
||||||
children,
|
children,
|
||||||
centerCellClassName,
|
centerCellClassName,
|
||||||
@@ -134,7 +337,7 @@ function DataGridTableVirtualUtilityRow<TData>({
|
|||||||
rowClassName,
|
rowClassName,
|
||||||
ariaHidden,
|
ariaHidden,
|
||||||
}: {
|
}: {
|
||||||
table: Table<TData>
|
table: DataGridTableInstance<TData>
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
centerCellClassName?: string
|
centerCellClassName?: string
|
||||||
centerCellStyle?: CSSProperties
|
centerCellStyle?: CSSProperties
|
||||||
@@ -142,9 +345,9 @@ function DataGridTableVirtualUtilityRow<TData>({
|
|||||||
ariaHidden?: boolean
|
ariaHidden?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { props } = useDataGrid()
|
const { props } = useDataGrid()
|
||||||
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
|
const leftVisibleColumns = table.getStartVisibleLeafColumns()
|
||||||
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
||||||
const rightVisibleColumns = table.getRightVisibleLeafColumns()
|
const rightVisibleColumns = table.getEndVisibleLeafColumns()
|
||||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -178,11 +381,11 @@ function DataGridTableVirtualUtilityRow<TData>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridTableVirtualSpacer<TData>({
|
function DataGridTableVirtualSpacer<TData extends object>({
|
||||||
table,
|
table,
|
||||||
height,
|
height,
|
||||||
}: {
|
}: {
|
||||||
table: Table<TData>
|
table: DataGridTableInstance<TData>
|
||||||
height: number
|
height: number
|
||||||
}) {
|
}) {
|
||||||
if (height <= 0) return null
|
if (height <= 0) return null
|
||||||
@@ -199,12 +402,12 @@ function DataGridTableVirtualSpacer<TData>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridTableVirtualStatusRow<TData>({
|
function DataGridTableVirtualStatusRow<TData extends object>({
|
||||||
table,
|
table,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
table: Table<TData>
|
table: DataGridTableInstance<TData>
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
@@ -221,7 +424,7 @@ function DataGridTableVirtualStatusRow<TData>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridTableVirtualBody<TData>({
|
function DataGridTableVirtualBody<TData extends object>({
|
||||||
table,
|
table,
|
||||||
topRows,
|
topRows,
|
||||||
centerRows,
|
centerRows,
|
||||||
@@ -236,9 +439,25 @@ function DataGridTableVirtualBody<TData>({
|
|||||||
allRowsLoadedMessage,
|
allRowsLoadedMessage,
|
||||||
measureRowRef,
|
measureRowRef,
|
||||||
}: VirtualBodyProps<TData>) {
|
}: VirtualBodyProps<TData>) {
|
||||||
|
const { isLoading } = useDataGrid()
|
||||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||||
|
|
||||||
if (!totalRows) return <DataGridTableEmpty />
|
if (!totalRows) {
|
||||||
|
// Initial load must not flash the empty state as if the query returned
|
||||||
|
// nothing.
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<DataGridTableVirtualStatusRow table={table}>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Spinner className="size-4 opacity-60" />
|
||||||
|
{loadingMoreMessage}
|
||||||
|
</div>
|
||||||
|
</DataGridTableVirtualStatusRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DataGridTableEmpty />
|
||||||
|
}
|
||||||
|
|
||||||
const hasCenterRows = centerRows.length > 0
|
const hasCenterRows = centerRows.length > 0
|
||||||
const showFetchingRow = isInfiniteMode && isFetchingMore
|
const showFetchingRow = isInfiniteMode && isFetchingMore
|
||||||
@@ -291,6 +510,7 @@ function DataGridTableVirtualBody<TData>({
|
|||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
rowRef={measureRowRef}
|
rowRef={measureRowRef}
|
||||||
|
rowIndex={virtualRow.index}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -305,8 +525,10 @@ function DataGridTableVirtualBody<TData>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
centerRows.forEach((row) => {
|
centerRows.forEach((row, rowIndex) => {
|
||||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
renderedRows.push(
|
||||||
|
<DataGridTableRenderedRow key={row.id} row={row} rowIndex={rowIndex} />
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,13 +579,16 @@ function DataGridTableVirtualBody<TData>({
|
|||||||
*/
|
*/
|
||||||
const MemoizedVirtualBody = memo(
|
const MemoizedVirtualBody = memo(
|
||||||
DataGridTableVirtualBody,
|
DataGridTableVirtualBody,
|
||||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
|
||||||
) as typeof DataGridTableVirtualBody
|
) as typeof DataGridTableVirtualBody
|
||||||
|
|
||||||
function DataGridTableVirtual<TData>({
|
function DataGridTableVirtual<TData extends object>({
|
||||||
height,
|
height,
|
||||||
estimateSize = 48,
|
estimateSize = 48,
|
||||||
overscan = 10,
|
overscan = 10,
|
||||||
|
scrollBehavior = "auto",
|
||||||
|
scrollToRowAlign = "auto",
|
||||||
|
scrollToRowIndex,
|
||||||
footerContent,
|
footerContent,
|
||||||
renderHeader = true,
|
renderHeader = true,
|
||||||
onFetchMore,
|
onFetchMore,
|
||||||
@@ -372,7 +597,7 @@ function DataGridTableVirtual<TData>({
|
|||||||
fetchMoreOffset = 0,
|
fetchMoreOffset = 0,
|
||||||
virtualizerOptions,
|
virtualizerOptions,
|
||||||
}: DataGridTableVirtualProps<TData>) {
|
}: DataGridTableVirtualProps<TData>) {
|
||||||
const { table, props } = useDataGrid()
|
const { table, props } = useDataGrid<TData>()
|
||||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||||
@@ -397,17 +622,16 @@ function DataGridTableVirtual<TData>({
|
|||||||
|
|
||||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||||
const loadingMoreMessage =
|
const loadingMoreMessage =
|
||||||
props.fetchingMoreMessage || props.loadingMessage || "Загрузка…"
|
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||||
const allRowsLoadedMessage =
|
const allRowsLoadedMessage =
|
||||||
props.allRowsLoadedMessage || "Все записи загружены"
|
props.allRowsLoadedMessage || "All records loaded"
|
||||||
|
|
||||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
setViewportElements({
|
setViewportElements({
|
||||||
containerElement: node,
|
containerElement: node,
|
||||||
scrollElement:
|
scrollElement: node
|
||||||
(node?.closest(
|
? (getDataGridScrollAreaViewport(node) ?? node)
|
||||||
'[data-slot="scroll-area-viewport"]'
|
: null,
|
||||||
) as HTMLElement | null) ?? node,
|
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -464,6 +688,134 @@ function DataGridTableVirtual<TData>({
|
|||||||
? virtualizer.measureElement
|
? virtualizer.measureElement
|
||||||
: undefined
|
: undefined
|
||||||
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
|
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
|
||||||
|
const scrollToRowId =
|
||||||
|
scrollToRowIndex !== undefined
|
||||||
|
? centerRows[scrollToRowIndex]?.id
|
||||||
|
: undefined
|
||||||
|
const scrollToRowVirtualItem =
|
||||||
|
isVirtualizationEnabled && scrollToRowIndex !== undefined
|
||||||
|
? virtualItems.find((item) => item.index === scrollToRowIndex)
|
||||||
|
: undefined
|
||||||
|
const pendingScrollToRowIndexRef = useRef<number | null>(null)
|
||||||
|
const lastScrollRequestRef = useRef<DataGridTableVirtualScrollRequest | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
// Latch onFetchMore per row count: virtualItems gets a new identity every
|
||||||
|
// scroll frame, so without it the effect fires duplicate page requests
|
||||||
|
// before the consumer flips isFetchingMore, and loops at end-of-data when
|
||||||
|
// hasMore is never set.
|
||||||
|
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
// Resolve after every commit so a stable getter can expose a replaced ref;
|
||||||
|
// the request signature prevents duplicate scrolling on ordinary renders.
|
||||||
|
useEffect(() => {
|
||||||
|
const previousRequest = lastScrollRequestRef.current
|
||||||
|
|
||||||
|
if (
|
||||||
|
scrollToRowIndex === undefined ||
|
||||||
|
scrollToRowIndex < 0 ||
|
||||||
|
scrollToRowIndex >= centerRows.length
|
||||||
|
) {
|
||||||
|
pendingScrollToRowIndexRef.current = null
|
||||||
|
lastScrollRequestRef.current = null
|
||||||
|
|
||||||
|
if (previousRequest) {
|
||||||
|
const scrollElement = resolveScrollElement()
|
||||||
|
|
||||||
|
if (scrollElement) {
|
||||||
|
scrollDataGridTableToOffset({
|
||||||
|
behavior: "auto",
|
||||||
|
scrollElement,
|
||||||
|
targetTop: scrollElement.scrollTop,
|
||||||
|
virtualizer: isVirtualizationEnabled ? virtualizer : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollElement = resolveScrollElement()
|
||||||
|
const containerElement = viewportElements.containerElement
|
||||||
|
if (!containerElement || !scrollElement) return
|
||||||
|
|
||||||
|
const headerSticky = renderHeader && !!props.tableLayout?.headerSticky
|
||||||
|
const nextRequest: DataGridTableVirtualScrollRequest = {
|
||||||
|
align: scrollToRowAlign,
|
||||||
|
behavior: scrollBehavior,
|
||||||
|
containerElement,
|
||||||
|
headerSticky,
|
||||||
|
isVirtualizationEnabled,
|
||||||
|
rowId: scrollToRowId,
|
||||||
|
rowIndex: scrollToRowIndex,
|
||||||
|
scrollElement,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSameDataGridTableScrollRequest(previousRequest, nextRequest)) return
|
||||||
|
|
||||||
|
pendingScrollToRowIndexRef.current = null
|
||||||
|
|
||||||
|
const rowWasHandled = scrollDataGridTableRowIntoView({
|
||||||
|
align: scrollToRowAlign,
|
||||||
|
behavior: scrollBehavior,
|
||||||
|
cancelPendingScroll: previousRequest !== null,
|
||||||
|
containerElement,
|
||||||
|
headerSticky,
|
||||||
|
rowIndex: scrollToRowIndex,
|
||||||
|
scrollElement,
|
||||||
|
virtualizer: isVirtualizationEnabled ? virtualizer : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rowWasHandled) {
|
||||||
|
lastScrollRequestRef.current = nextRequest
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVirtualizationEnabled) return
|
||||||
|
|
||||||
|
pendingScrollToRowIndexRef.current = scrollToRowIndex
|
||||||
|
lastScrollRequestRef.current = nextRequest
|
||||||
|
virtualizer.scrollToIndex(scrollToRowIndex, {
|
||||||
|
align: scrollToRowAlign,
|
||||||
|
behavior: scrollBehavior,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isVirtualizationEnabled ||
|
||||||
|
scrollToRowIndex === undefined ||
|
||||||
|
pendingScrollToRowIndexRef.current !== scrollToRowIndex ||
|
||||||
|
!scrollToRowVirtualItem
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowWasHandled = scrollDataGridTableRowIntoView({
|
||||||
|
align: scrollToRowAlign,
|
||||||
|
behavior: "auto",
|
||||||
|
cancelPendingScroll: true,
|
||||||
|
containerElement: viewportElements.containerElement,
|
||||||
|
headerSticky: renderHeader && !!props.tableLayout?.headerSticky,
|
||||||
|
rowIndex: scrollToRowIndex,
|
||||||
|
scrollElement: resolveScrollElement(),
|
||||||
|
virtualizer,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rowWasHandled) {
|
||||||
|
pendingScrollToRowIndexRef.current = null
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isVirtualizationEnabled,
|
||||||
|
props.tableLayout?.headerSticky,
|
||||||
|
renderHeader,
|
||||||
|
resolveScrollElement,
|
||||||
|
scrollToRowAlign,
|
||||||
|
scrollToRowIndex,
|
||||||
|
scrollToRowVirtualItem,
|
||||||
|
virtualizer,
|
||||||
|
viewportElements.containerElement,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -478,7 +830,10 @@ function DataGridTableVirtual<TData>({
|
|||||||
const lastItem = virtualItems[virtualItems.length - 1]
|
const lastItem = virtualItems[virtualItems.length - 1]
|
||||||
if (!lastItem) return
|
if (!lastItem) return
|
||||||
|
|
||||||
|
if (fetchMoreFiredAtCountRef.current === centerRows.length) return
|
||||||
|
|
||||||
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
||||||
|
fetchMoreFiredAtCountRef.current = centerRows.length
|
||||||
onFetchMore?.()
|
onFetchMore?.()
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -499,7 +854,15 @@ function DataGridTableVirtual<TData>({
|
|||||||
style={
|
style={
|
||||||
usesExternalScrollArea
|
usesExternalScrollArea
|
||||||
? undefined
|
? undefined
|
||||||
: { height, overflow: "auto", position: "relative" }
|
: {
|
||||||
|
height,
|
||||||
|
overflow: "auto",
|
||||||
|
position: "relative",
|
||||||
|
// Standalone mode: this node IS the scroll container, so it
|
||||||
|
// must stay at its parent's width (not the resizable table
|
||||||
|
// width) or horizontal scrolling becomes impossible.
|
||||||
|
width: "auto",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DataGridTableBase>
|
<DataGridTableBase>
|
||||||
@@ -508,7 +871,7 @@ function DataGridTableVirtual<TData>({
|
|||||||
{mergedHeaderGroups.map((headerGroup) => (
|
{mergedHeaderGroups.map((headerGroup) => (
|
||||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
||||||
{headerGroup.headers
|
{headerGroup.headers
|
||||||
.filter((header) => header.column.getIsPinned() !== "right")
|
.filter((header) => header.column.getIsPinned() !== "end")
|
||||||
.map((header) => {
|
.map((header) => {
|
||||||
const { column } = header
|
const { column } = header
|
||||||
|
|
||||||
@@ -532,7 +895,7 @@ function DataGridTableVirtual<TData>({
|
|||||||
<DataGridTableFillHeadCell />
|
<DataGridTableFillHeadCell />
|
||||||
) : null}
|
) : null}
|
||||||
{headerGroup.headers
|
{headerGroup.headers
|
||||||
.filter((header) => header.column.getIsPinned() === "right")
|
.filter((header) => header.column.getIsPinned() === "end")
|
||||||
.map((header) => {
|
.map((header) => {
|
||||||
const { column } = header
|
const { column } = header
|
||||||
|
|
||||||
@@ -593,6 +956,7 @@ function DataGridTableVirtual<TData>({
|
|||||||
|
|
||||||
export { DataGridTableVirtual }
|
export { DataGridTableVirtual }
|
||||||
export type {
|
export type {
|
||||||
|
DataGridTableVirtualScrollAlignment,
|
||||||
DataGridTableVirtualProps,
|
DataGridTableVirtualProps,
|
||||||
DataGridTableVirtualScrollElements,
|
DataGridTableVirtualScrollElements,
|
||||||
DataGridTableVirtualizerOptions,
|
DataGridTableVirtualizerOptions,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,153 @@
|
|||||||
"use client"
|
import { createContext, useContext, useEffect, useMemo, useRef } from "react"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
import { createContext, ReactNode, useContext, useMemo } from "react"
|
|
||||||
import {
|
import {
|
||||||
|
columnFacetingFeature,
|
||||||
|
columnFilteringFeature,
|
||||||
|
columnOrderingFeature,
|
||||||
|
columnPinningFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnVisibilityFeature,
|
||||||
|
createExpandedRowModel,
|
||||||
|
createFacetedRowModel,
|
||||||
|
createFacetedUniqueValues,
|
||||||
|
createFilteredRowModel,
|
||||||
|
createPaginatedRowModel,
|
||||||
|
createSortedRowModel,
|
||||||
|
globalFilteringFeature,
|
||||||
|
metaHelper,
|
||||||
|
rowExpandingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
rowPinningFeature,
|
||||||
|
rowSelectionFeature,
|
||||||
|
rowSortingFeature,
|
||||||
|
sortFn_alphanumeric,
|
||||||
|
sortFn_alphanumericCaseSensitive,
|
||||||
|
sortFn_basic,
|
||||||
|
sortFn_datetime,
|
||||||
|
sortFn_text,
|
||||||
|
sortFn_textCaseSensitive,
|
||||||
|
tableFeatures,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type {
|
||||||
Column,
|
Column,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
|
ReactTable,
|
||||||
RowData,
|
RowData,
|
||||||
SortingState,
|
SortingState,
|
||||||
Table,
|
Table,
|
||||||
|
TableFeatures,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|
||||||
declare module "@tanstack/react-table" {
|
/**
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
* Per-column extras the grid reads off `columnDef.meta`.
|
||||||
interface ColumnMeta<TData extends RowData, TValue> {
|
*
|
||||||
headerTitle?: string
|
* TanStack v9 resolves this through the `columnMeta` slot on the feature
|
||||||
headerClassName?: string
|
* bundle below instead of a global `declare module` augmentation, so
|
||||||
cellClassName?: string
|
* installing the data grid no longer widens `ColumnMeta` for every other
|
||||||
skeleton?: ReactNode
|
* table in the consuming app.
|
||||||
expandedContent?: (row: TData) => ReactNode
|
*/
|
||||||
autoSize?: boolean
|
export interface DataGridColumnMeta<TData> {
|
||||||
}
|
headerTitle?: string
|
||||||
|
headerClassName?: string
|
||||||
|
cellClassName?: string
|
||||||
|
skeleton?: ReactNode
|
||||||
|
expandedContent?: (row: TData) => ReactNode
|
||||||
|
autoSize?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The batteries-included feature bundle every ReUI data-grid example builds
|
||||||
|
* on. v9 requires each table to declare its features up front, and the grid's
|
||||||
|
* render path needs the ones registered here: `columnVisibilityFeature` alone
|
||||||
|
* gates `row.getVisibleCells()`, so even a grid that never hides a column
|
||||||
|
* needs it to render at all.
|
||||||
|
*
|
||||||
|
* Pass it straight through for the full grid:
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* const table = useTable({ features: dataGridFeatures, columns, data })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Extend it when a grid needs more, keeping each prerequisite feature ahead of
|
||||||
|
* the slot that depends on it:
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* const features = tableFeatures({
|
||||||
|
* ...dataGridFeatures,
|
||||||
|
* columnGroupingFeature,
|
||||||
|
* groupedRowModel: createGroupedRowModel(),
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Or drop it entirely and hand `<DataGrid>` a leaner table - the components
|
||||||
|
* accept any bundle, so you keep full ownership of the TanStack core.
|
||||||
|
*/
|
||||||
|
export const dataGridFeatures = tableFeatures({
|
||||||
|
columnVisibilityFeature,
|
||||||
|
columnOrderingFeature,
|
||||||
|
columnPinningFeature,
|
||||||
|
columnSizingFeature,
|
||||||
|
// columnResizingFeature requires columnSizingFeature, declared above.
|
||||||
|
columnResizingFeature,
|
||||||
|
columnFilteringFeature,
|
||||||
|
// Powers DataGridColumnFilter's column.getFacetedUniqueValues(). On v8 an
|
||||||
|
// unregistered facet silently returned an empty map; on v9 the method would
|
||||||
|
// not exist at all, so the faceted row models below are required, not
|
||||||
|
// optional.
|
||||||
|
columnFacetingFeature,
|
||||||
|
// globalFilteringFeature requires columnFilteringFeature, declared above.
|
||||||
|
globalFilteringFeature,
|
||||||
|
rowSortingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
rowSelectionFeature,
|
||||||
|
rowExpandingFeature,
|
||||||
|
rowPinningFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
filteredRowModel: createFilteredRowModel(),
|
||||||
|
paginatedRowModel: createPaginatedRowModel(),
|
||||||
|
expandedRowModel: createExpandedRowModel(),
|
||||||
|
facetedRowModel: createFacetedRowModel(),
|
||||||
|
facetedUniqueValues: createFacetedUniqueValues(),
|
||||||
|
// Every built-in v9 ships. A string `sortFn` resolves against this map
|
||||||
|
// alone, and `sortFn: "auto"` infers a name ("alphanumeric", "text" or
|
||||||
|
// "datetime") from the first row's value - so a partial map makes auto
|
||||||
|
// sorting warn and silently fall back on ordinary string columns.
|
||||||
|
sortFns: {
|
||||||
|
alphanumeric: sortFn_alphanumeric,
|
||||||
|
alphanumericCaseSensitive: sortFn_alphanumericCaseSensitive,
|
||||||
|
basic: sortFn_basic,
|
||||||
|
datetime: sortFn_datetime,
|
||||||
|
text: sortFn_text,
|
||||||
|
textCaseSensitive: sortFn_textCaseSensitive,
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
columnMeta: metaHelper<DataGridColumnMeta<any>>(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The feature set `dataGridFeatures` registers. */
|
||||||
|
export type DataGridFeatures = typeof dataGridFeatures
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grid's internal view of the table.
|
||||||
|
*
|
||||||
|
* `TFeatures` is invariant in v9 and an unresolved generic one collapses to a
|
||||||
|
* union that includes the bare core arm, so no generic signature can call
|
||||||
|
* `getVisibleCells()`, `getStartVisibleLeafColumns()` and friends. The public
|
||||||
|
* components stay generic so consumers can pass any bundle they like; the
|
||||||
|
* table is widened to this concrete type exactly once, on the way into
|
||||||
|
* context, and every internal component reads it from there.
|
||||||
|
*/
|
||||||
|
export type DataGridTableInstance<TData extends object> = ReactTable<
|
||||||
|
DataGridFeatures,
|
||||||
|
TData
|
||||||
|
>
|
||||||
|
|
||||||
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
|
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
|
||||||
export function getColumnHeaderLabel<TData, TValue>(
|
export function getColumnHeaderLabel<TData extends RowData, TValue>(
|
||||||
column: Column<TData, TValue>
|
column: Column<DataGridFeatures, TData, TValue>
|
||||||
): string {
|
): string {
|
||||||
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
|
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
|
||||||
if (typeof meta?.headerTitle === "string") return meta.headerTitle
|
if (typeof meta?.headerTitle === "string") return meta.headerTitle
|
||||||
@@ -51,11 +173,115 @@ export type DataGridApiResponse<T> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything `<DataGrid>` accepts except the two props the provider consumes
|
||||||
|
* itself. Kept feature-agnostic: layout and messaging never depend on which
|
||||||
|
* TanStack features the consumer registered.
|
||||||
|
*/
|
||||||
|
export type DataGridLayoutProps<TData extends object> = Omit<
|
||||||
|
DataGridProps<TableFeatures, TData>,
|
||||||
|
"table" | "children"
|
||||||
|
>
|
||||||
|
|
||||||
export interface DataGridContextProps<TData extends object> {
|
export interface DataGridContextProps<TData extends object> {
|
||||||
props: DataGridProps<TData>
|
props: DataGridLayoutProps<TData>
|
||||||
table: Table<TData>
|
table: DataGridTableInstance<TData>
|
||||||
recordCount: number
|
recordCount: number
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
/**
|
||||||
|
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
|
||||||
|
* so every table variant and viewport instance shares one application state.
|
||||||
|
*/
|
||||||
|
autoSize?: DataGridAutoSizeController
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataGridAutoSizeController = {
|
||||||
|
/**
|
||||||
|
* Grows the first visible `meta.autoSize` column by the given free space.
|
||||||
|
* Applies at most once per column id; safe to call from every viewport
|
||||||
|
* measurement. Returns true when a sizing update was dispatched.
|
||||||
|
*/
|
||||||
|
apply: (fillWidth: number) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDataGridAutoSizeController<TData extends object>(
|
||||||
|
/**
|
||||||
|
* A getter, not the table itself.
|
||||||
|
*
|
||||||
|
* v8 handed back one stable table whose state mutated in place, so a
|
||||||
|
* controller could close over it. v9 returns a NEW table wrapper on every
|
||||||
|
* state change, and a captured one keeps reporting the state it was built
|
||||||
|
* with - here that meant `columnSizing` looked permanently empty, the
|
||||||
|
* applied-once guard re-armed on every measurement, and the fill overwrote
|
||||||
|
* whatever width the user had just dragged the column to.
|
||||||
|
*/
|
||||||
|
getTable: () => DataGridTableInstance<TData>
|
||||||
|
): DataGridAutoSizeController {
|
||||||
|
let applied: { columnId: string; base: number; grown: number } | null = null
|
||||||
|
|
||||||
|
return {
|
||||||
|
apply(fillWidth: number) {
|
||||||
|
const table = getTable()
|
||||||
|
const columnSizing = table.state.columnSizing
|
||||||
|
|
||||||
|
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
|
||||||
|
// controlled state replacement) so the column re-fills instead of
|
||||||
|
// leaving a dead blank strip.
|
||||||
|
if (applied && columnSizing[applied.columnId] === undefined) {
|
||||||
|
applied = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fillWidth <= 0) return false
|
||||||
|
|
||||||
|
const autoSizeColumn = table
|
||||||
|
.getVisibleLeafColumns()
|
||||||
|
.find(
|
||||||
|
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!autoSizeColumn || applied?.columnId === autoSizeColumn.id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// A width this coordinator did not write belongs to someone else -
|
||||||
|
// almost always the user, who just dragged the column's resize handle.
|
||||||
|
// Filling over it is what made a `meta.autoSize` column look
|
||||||
|
// un-resizable: the drag committed, the next viewport measurement
|
||||||
|
// stamped the fill back on top, and the column snapped to its old width.
|
||||||
|
//
|
||||||
|
// Deliberately keyed on observed state rather than on `applied`, which
|
||||||
|
// is per-coordinator memory: anything that rebuilds the coordinator
|
||||||
|
// (a remount, a new table store) forgets what it did, and the guard has
|
||||||
|
// to survive that. An explicit reset clears the entry and re-arms the
|
||||||
|
// fill, which is what makes double-click-to-reset still work.
|
||||||
|
const currentSize = columnSizing[autoSizeColumn.id]
|
||||||
|
if (currentSize !== undefined && currentSize !== applied?.grown) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate switched (e.g. the grown column was hidden and another
|
||||||
|
// meta.autoSize column took over): revert the previous growth if the
|
||||||
|
// user hasn't manually resized that column since, so visibility
|
||||||
|
// toggles cannot ratchet the table wider than its container forever.
|
||||||
|
const revert =
|
||||||
|
applied && columnSizing[applied.columnId] === applied.grown
|
||||||
|
? applied
|
||||||
|
: null
|
||||||
|
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
|
||||||
|
const grown = base + fillWidth
|
||||||
|
|
||||||
|
applied = { columnId: autoSizeColumn.id, base, grown }
|
||||||
|
table.setColumnSizing((old) => {
|
||||||
|
const next = { ...old, [autoSizeColumn.id]: grown }
|
||||||
|
if (revert && next[revert.columnId] === revert.grown) {
|
||||||
|
next[revert.columnId] = revert.base
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DataGridRequestParams = {
|
export type DataGridRequestParams = {
|
||||||
@@ -65,9 +291,12 @@ export type DataGridRequestParams = {
|
|||||||
columnFilters?: ColumnFiltersState
|
columnFilters?: ColumnFiltersState
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataGridProps<TData extends object> {
|
export interface DataGridProps<
|
||||||
|
TFeatures extends TableFeatures,
|
||||||
|
TData extends object,
|
||||||
|
> {
|
||||||
className?: string
|
className?: string
|
||||||
table?: Table<TData>
|
table?: Table<TFeatures, TData>
|
||||||
recordCount: number
|
recordCount: number
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
onRowClick?: (row: TData) => void
|
onRowClick?: (row: TData) => void
|
||||||
@@ -114,8 +343,19 @@ const DataGridContext = createContext<
|
|||||||
DataGridContextProps<any> | undefined
|
DataGridContextProps<any> | undefined
|
||||||
>(undefined)
|
>(undefined)
|
||||||
|
|
||||||
function useDataGrid() {
|
/**
|
||||||
const context = useContext(DataGridContext)
|
* Reads the grid context. Pass `TData` from the calling component when the
|
||||||
|
* table, a row or a cell is handed on to something typed against that row
|
||||||
|
* shape: v9 declares `TData` invariant, so the default `any` no longer
|
||||||
|
* unifies with a concrete row type the way it did on v8.
|
||||||
|
*/
|
||||||
|
function useDataGrid<
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
TData extends object = any,
|
||||||
|
>(): DataGridContextProps<TData> {
|
||||||
|
const context = useContext(DataGridContext) as
|
||||||
|
| DataGridContextProps<TData>
|
||||||
|
| undefined
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("useDataGrid must be used within a DataGridProvider")
|
throw new Error("useDataGrid must be used within a DataGridProvider")
|
||||||
}
|
}
|
||||||
@@ -126,38 +366,77 @@ function DataGridProvider<TData extends object>({
|
|||||||
children,
|
children,
|
||||||
table,
|
table,
|
||||||
...props
|
...props
|
||||||
}: DataGridProps<TData> & { table: Table<TData> }) {
|
}: DataGridLayoutProps<TData> & {
|
||||||
const tableState = table.getState()
|
table: DataGridTableInstance<TData>
|
||||||
const resolvedColumnsResizeMode =
|
children?: ReactNode
|
||||||
props.tableLayout?.columnsResizeMode ?? "onEnd"
|
}) {
|
||||||
|
// Latest-props ref: context reads always resolve fresh props through the
|
||||||
|
// getter below without the memoized context value depending on unstable
|
||||||
|
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
|
||||||
|
// otherwise publish a new context value on every consumer render - at
|
||||||
|
// mousemove rate during a resize drag, piercing the body-rows memo).
|
||||||
|
const propsRef = useRef(props)
|
||||||
|
propsRef.current = props
|
||||||
|
|
||||||
// Keep resize mode aligned with the DataGrid contract every render so
|
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on
|
||||||
// consumer-level useReactTable options cannot flip it back between drags.
|
// every state change. Depending on it directly would republish the context
|
||||||
if (props.tableLayout?.columnsResizable) {
|
// on each resize tick, which is exactly what the memo below exists to
|
||||||
table.options.columnResizeMode = resolvedColumnsResizeMode
|
// prevent; the getter still hands every consumer the current instance.
|
||||||
}
|
const tableRef = useRef(table)
|
||||||
|
tableRef.current = table
|
||||||
|
|
||||||
|
// Re-assert an explicit tableLayout resize mode so consumer-level useTable
|
||||||
|
// options cannot flip it back between drags. v9 makes `table.options`
|
||||||
|
// readonly, so this goes through setOptions in an effect rather than a
|
||||||
|
// render-phase mutation. Without an explicit mode, the consumer's own
|
||||||
|
// tanstack columnResizeMode (default "onEnd") is honored.
|
||||||
|
const resizeMode =
|
||||||
|
props.tableLayout?.columnsResizable && props.tableLayout.columnsResizeMode
|
||||||
|
? props.tableLayout.columnsResizeMode
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resizeMode) return
|
||||||
|
if (table.options.columnResizeMode === resizeMode) return
|
||||||
|
table.setOptions((old) => ({ ...old, columnResizeMode: resizeMode }))
|
||||||
|
}, [table, resizeMode])
|
||||||
|
|
||||||
|
// One autoSize coordinator per table instance so split header/body viewports
|
||||||
|
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps
|
||||||
|
// stable for the life of the table, rather than on `table` itself: the
|
||||||
|
// wrapper is re-created on every state change, and re-creating the
|
||||||
|
// controller with it would reset its applied-once bookkeeping mid-drag.
|
||||||
|
const autoSize = useMemo(
|
||||||
|
() => createDataGridAutoSizeController(() => tableRef.current),
|
||||||
|
[table.store]
|
||||||
|
)
|
||||||
|
|
||||||
|
const tableState = table.state
|
||||||
|
|
||||||
// Memoize context value so consumers don't re-render during column resize.
|
// Memoize context value so consumers don't re-render during column resize.
|
||||||
// Column sizing state is intentionally excluded from deps -- CSS variables
|
// Column sizing state is intentionally excluded from deps -- CSS variables
|
||||||
// on the <table> element handle width updates without React re-renders.
|
// on the <table> element handle width updates without React re-renders.
|
||||||
|
// ReactNode/function props (messages, onRowClick) are also excluded: they
|
||||||
|
// are served fresh through the props getter, so unstable inline identities
|
||||||
|
// cannot invalidate the context value.
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
props,
|
get props() {
|
||||||
table,
|
return propsRef.current
|
||||||
|
},
|
||||||
|
get table() {
|
||||||
|
return tableRef.current
|
||||||
|
},
|
||||||
recordCount: props.recordCount,
|
recordCount: props.recordCount,
|
||||||
isLoading: props.isLoading || false,
|
isLoading: props.isLoading || false,
|
||||||
|
autoSize,
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[
|
[
|
||||||
table,
|
autoSize,
|
||||||
props.recordCount,
|
props.recordCount,
|
||||||
props.isLoading,
|
props.isLoading,
|
||||||
props.loadingMode,
|
props.loadingMode,
|
||||||
props.loadingMessage,
|
|
||||||
props.fetchingMoreMessage,
|
|
||||||
props.allRowsLoadedMessage,
|
|
||||||
props.emptyMessage,
|
|
||||||
props.onRowClick,
|
|
||||||
props.className,
|
props.className,
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
JSON.stringify(props.tableLayout),
|
JSON.stringify(props.tableLayout),
|
||||||
@@ -167,6 +446,7 @@ function DataGridProvider<TData extends object>({
|
|||||||
tableState.pagination,
|
tableState.pagination,
|
||||||
tableState.columnFilters,
|
tableState.columnFilters,
|
||||||
tableState.rowSelection,
|
tableState.rowSelection,
|
||||||
|
tableState.rowPinning,
|
||||||
tableState.expanded,
|
tableState.expanded,
|
||||||
tableState.columnVisibility,
|
tableState.columnVisibility,
|
||||||
tableState.columnOrder,
|
tableState.columnOrder,
|
||||||
@@ -176,18 +456,24 @@ function DataGridProvider<TData extends object>({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridContext.Provider value={value}>
|
// One React context serves every TData, but v9 declares both TFeatures and
|
||||||
|
// TData invariant, so a `DataGridContextProps<any>` context cannot accept a
|
||||||
|
// `DataGridContextProps<TData>` value structurally. The erasure happens
|
||||||
|
// here and is undone by the TData generic on each consumer component.
|
||||||
|
<DataGridContext.Provider
|
||||||
|
value={value as unknown as DataGridContextProps<TData>}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</DataGridContext.Provider>
|
</DataGridContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGrid<TData extends object>({
|
function DataGrid<TFeatures extends TableFeatures, TData extends object>({
|
||||||
children,
|
children,
|
||||||
table,
|
table,
|
||||||
...props
|
...props
|
||||||
}: DataGridProps<TData>) {
|
}: DataGridProps<TFeatures, TData>) {
|
||||||
const defaultProps: Partial<DataGridProps<TData>> = {
|
const defaultProps: Partial<DataGridProps<TFeatures, TData>> = {
|
||||||
loadingMode: "skeleton",
|
loadingMode: "skeleton",
|
||||||
tableLayout: {
|
tableLayout: {
|
||||||
dense: false,
|
dense: false,
|
||||||
@@ -202,7 +488,8 @@ function DataGrid<TData extends object>({
|
|||||||
width: "fixed",
|
width: "fixed",
|
||||||
columnsVisibility: false,
|
columnsVisibility: false,
|
||||||
columnsResizable: false,
|
columnsResizable: false,
|
||||||
columnsResizeMode: "onEnd",
|
// columnsResizeMode has no default on purpose: when unset, the
|
||||||
|
// consumer's tanstack columnResizeMode (default "onEnd") is honored.
|
||||||
columnsPinnable: false,
|
columnsPinnable: false,
|
||||||
columnsMovable: false,
|
columnsMovable: false,
|
||||||
columnsDraggable: false,
|
columnsDraggable: false,
|
||||||
@@ -213,7 +500,10 @@ function DataGrid<TData extends object>({
|
|||||||
base: "",
|
base: "",
|
||||||
header: "",
|
header: "",
|
||||||
headerRow: "",
|
headerRow: "",
|
||||||
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
|
// z-40 keeps the sticky header above pinned body cells (zIndex 30 in
|
||||||
|
// getPinningStyles), which would otherwise paint over it while
|
||||||
|
// scrolling vertically with columnsPinnable enabled.
|
||||||
|
headerSticky: "sticky top-0 z-40 bg-background/90 backdrop-blur-xs",
|
||||||
body: "",
|
body: "",
|
||||||
bodyRow: "",
|
bodyRow: "",
|
||||||
footer: "",
|
footer: "",
|
||||||
@@ -221,7 +511,7 @@ function DataGrid<TData extends object>({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedProps: DataGridProps<TData> = {
|
const mergedProps: DataGridProps<TFeatures, TData> = {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
...props,
|
...props,
|
||||||
tableLayout: {
|
tableLayout: {
|
||||||
@@ -239,8 +529,15 @@ function DataGrid<TData extends object>({
|
|||||||
throw new Error('DataGrid requires a "table" prop')
|
throw new Error('DataGrid requires a "table" prop')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The single widening point. Consumers own the TanStack core and may hand
|
||||||
|
// over any feature bundle; internals need a concrete one to resolve the
|
||||||
|
// feature-gated APIs they call, and v9's invariant TFeatures rules out
|
||||||
|
// expressing that with a generic constraint.
|
||||||
|
const internalTable = table as unknown as DataGridTableInstance<TData>
|
||||||
|
const internalProps = mergedProps as unknown as DataGridLayoutProps<TData>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridProvider table={table} {...mergedProps}>
|
<DataGridProvider table={internalTable} {...internalProps}>
|
||||||
{children}
|
{children}
|
||||||
</DataGridProvider>
|
</DataGridProvider>
|
||||||
)
|
)
|
||||||
@@ -249,10 +546,10 @@ function DataGrid<TData extends object>({
|
|||||||
function DataGridContainer({
|
function DataGridContainer({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
border = true,
|
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
/** Accepted for backwards compatibility; currently has no effect. */
|
||||||
border?: boolean
|
border?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { ChangeEvent, ComponentProps } from "react"
|
||||||
import {
|
import {
|
||||||
ChangeEvent,
|
|
||||||
ComponentProps,
|
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
@@ -18,8 +17,7 @@ import {
|
|||||||
parse,
|
parse,
|
||||||
subMonths,
|
subMonths,
|
||||||
} from "date-fns"
|
} from "date-fns"
|
||||||
import { DayButton } from "react-day-picker"
|
import type { DateRange, DayButton } from "react-day-picker"
|
||||||
import type { DateRange } from "react-day-picker"
|
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
@@ -1233,7 +1231,7 @@ export function DateSelector({
|
|||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
className={cn(
|
className={cn(
|
||||||
// Base Styles
|
// Base Styles
|
||||||
"absolute end-2.5 top-1/2 size-4 -translate-y-1/2 cursor-pointer rounded-xs",
|
"rounded-xs absolute end-2.5 top-1/2 size-4 -translate-y-1/2 cursor-pointer",
|
||||||
// Visual States
|
// Visual States
|
||||||
"opacity-70 transition-opacity hover:opacity-100",
|
"opacity-70 transition-opacity hover:opacity-100",
|
||||||
// Focus States
|
// Focus States
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
EventCalendarViewContext,
|
||||||
|
useEventCalendar,
|
||||||
|
useEventCalendarSelector,
|
||||||
|
useEventCalendarSettings,
|
||||||
|
useEventCalendarViewConfig,
|
||||||
|
} from "@/components/reui/event-calendar/event-calendar"
|
||||||
|
import { EventCalendarEvent } from "@/components/reui/event-calendar/event-calendar-event"
|
||||||
|
import {
|
||||||
|
getDayKey,
|
||||||
|
getRangeKey,
|
||||||
|
toZoned,
|
||||||
|
zonedStartOfDay,
|
||||||
|
} from "@/components/reui/event-calendar/event-calendar-lib"
|
||||||
|
import type {
|
||||||
|
EventCalendarDateRange,
|
||||||
|
EventCalendarSegment,
|
||||||
|
} from "@/components/reui/event-calendar/event-calendar-types"
|
||||||
|
import { IconStack } from "@/components/reui/icon-stack"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { addDays, format } from "date-fns"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
|
||||||
|
import { CalendarIcon } from "lucide-react"
|
||||||
|
|
||||||
|
// The agenda window length is the agendaDayCount SETTING (the store derives
|
||||||
|
// visibleRange from it); a per-view prop here would silently disagree.
|
||||||
|
type EventCalendarAgendaViewProps = useRender.ComponentProps<"div">
|
||||||
|
|
||||||
|
function EventCalendarAgendaView({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: EventCalendarAgendaViewProps) {
|
||||||
|
const instance = useEventCalendar()
|
||||||
|
const settings = useEventCalendarSettings()
|
||||||
|
const viewConfig = useEventCalendarViewConfig()
|
||||||
|
const visibleRange = useEventCalendarSelector<
|
||||||
|
unknown,
|
||||||
|
EventCalendarDateRange
|
||||||
|
>((state) => state.visibleRange, {
|
||||||
|
isEqual: (a, b) => getRangeKey(a) === getRangeKey(b),
|
||||||
|
})
|
||||||
|
// Subscribe to event changes via the day-bucket content of the whole range
|
||||||
|
useEventCalendarSelector((state) => state.events)
|
||||||
|
|
||||||
|
const days = useMemo(() => {
|
||||||
|
const result: Date[] = []
|
||||||
|
let cursor = zonedStartOfDay(visibleRange.start, settings.timeZone)
|
||||||
|
while (cursor < visibleRange.end) {
|
||||||
|
result.push(cursor)
|
||||||
|
cursor = zonedStartOfDay(
|
||||||
|
addDays(toZoned(cursor, settings.timeZone), 1),
|
||||||
|
settings.timeZone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, [visibleRange, settings.timeZone])
|
||||||
|
|
||||||
|
const index = instance.internals.getIndex()
|
||||||
|
const groups = days
|
||||||
|
.map((day) => ({
|
||||||
|
day,
|
||||||
|
bucket: index.byDay.get(getDayKey(day, settings.timeZone)),
|
||||||
|
}))
|
||||||
|
.filter((group) => {
|
||||||
|
const total =
|
||||||
|
(group.bucket?.allDay.length ?? 0) + (group.bucket?.timed.length ?? 0)
|
||||||
|
return total > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const isToday = (day: Date) =>
|
||||||
|
getDayKey(day, settings.timeZone) ===
|
||||||
|
getDayKey(new Date(), settings.timeZone)
|
||||||
|
|
||||||
|
const native = viewConfig.scrollbars === "native"
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<div
|
||||||
|
data-slot="event-calendar-no-events"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-72 flex-col items-center justify-center gap-4 py-16",
|
||||||
|
viewConfig.classNames?.noEvents
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{viewConfig.renderNoEvents?.() ?? (
|
||||||
|
<>
|
||||||
|
<IconStack>
|
||||||
|
<CalendarIcon className="size-5" aria-hidden="true" />
|
||||||
|
</IconStack>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{settings.i18n.labels.noEvents}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Drop the very last row's bottom border so it does not double up with
|
||||||
|
// the calendar container's own bottom border. Targets the last day
|
||||||
|
// group's last child (its last agenda item); per-item `border-b` is
|
||||||
|
// kept everywhere else, including each day's internal rows.
|
||||||
|
<div className="flex flex-col [&>*:last-child>*:last-child]:border-b-0">
|
||||||
|
{groups.map(({ day, bucket }) => {
|
||||||
|
const items = [...(bucket?.allDay ?? []), ...(bucket?.timed ?? [])]
|
||||||
|
const zoned = toZoned(day, settings.timeZone)
|
||||||
|
const weekday = format(zoned, "EEEE", { locale: settings.locale })
|
||||||
|
const dayDate = format(zoned, "MMMM d, yyyy", {
|
||||||
|
locale: settings.locale,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day.getTime()}
|
||||||
|
data-slot="event-calendar-agenda-day"
|
||||||
|
data-today={isToday(day) || undefined}
|
||||||
|
// A named group per day so a screen reader can step day by day
|
||||||
|
// (and hear how full one is) instead of arrowing every row.
|
||||||
|
role="group"
|
||||||
|
aria-label={`${weekday}, ${dayDate}, ${settings.i18n.labels.events(items.length)}`}
|
||||||
|
>
|
||||||
|
{/* Group header: weekday (leading) + full date (trailing) */}
|
||||||
|
<div
|
||||||
|
data-slot="event-calendar-agenda-day-header"
|
||||||
|
// The day bar is the agenda's only structure, so give it a
|
||||||
|
// heading level: the H key and the rotor can jump between
|
||||||
|
// days, which is the whole point of a long agenda.
|
||||||
|
role="heading"
|
||||||
|
aria-level={3}
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/60 sticky top-0 z-10 flex items-baseline justify-between gap-4 border-b px-4 py-2",
|
||||||
|
// The custom ScrollArea's overlay scrollbar (w-2.5 = 10px)
|
||||||
|
// is painted UNDER this sticky, z-10, opaque header, so the
|
||||||
|
// thumb vanishes behind the day bar at the top of the view.
|
||||||
|
// Inset the header by the scrollbar lane so its background
|
||||||
|
// stops before the scrollbar instead of covering it. Native
|
||||||
|
// scrollbars already sit outside the content box, so this
|
||||||
|
// only applies to the custom-scrollbar path.
|
||||||
|
!native && "me-2.5",
|
||||||
|
viewConfig.classNames?.agendaDayHeader
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-foreground font-semibold",
|
||||||
|
isToday(day) && "text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{weekday}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground font-medium tabular-nums">
|
||||||
|
{dayDate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{items.map((segment) => (
|
||||||
|
<EventCalendarAgendaItem
|
||||||
|
key={segment.occurrence.key}
|
||||||
|
segment={segment}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
"data-slot": "event-calendar-agenda-view",
|
||||||
|
"data-view": "agenda",
|
||||||
|
// Unlike the grid views the agenda has no row/column semantics to carry a
|
||||||
|
// name, so label the region with the day range it covers - through
|
||||||
|
// formatDayRange, so a consumer override reaches it.
|
||||||
|
role: "group",
|
||||||
|
"aria-label": settings.i18n.functions.formatDayRange(visibleRange, {
|
||||||
|
locale: settings.locale,
|
||||||
|
}),
|
||||||
|
className: cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col overflow-hidden border-t",
|
||||||
|
viewConfig.classNames?.agendaView,
|
||||||
|
className
|
||||||
|
),
|
||||||
|
children: native ? (
|
||||||
|
<div
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
data-ec-native-scroll=""
|
||||||
|
className="h-full overflow-y-auto"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-full">{body}</ScrollArea>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventCalendarViewContext.Provider value={{ view: "agenda" }}>
|
||||||
|
{useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
render,
|
||||||
|
props: mergeProps<"div">(defaultProps, props),
|
||||||
|
})}
|
||||||
|
</EventCalendarViewContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One agenda row: a full-width, selectable table row - time column, color dot,
|
||||||
|
* and title (all replaceable via renderAgendaEvent). Clicking selects the
|
||||||
|
* event (drag/resize stay off in the agenda).
|
||||||
|
*/
|
||||||
|
function EventCalendarAgendaItem({
|
||||||
|
segment,
|
||||||
|
}: {
|
||||||
|
segment: EventCalendarSegment
|
||||||
|
}) {
|
||||||
|
const viewConfig = useEventCalendarViewConfig()
|
||||||
|
return (
|
||||||
|
<EventCalendarEvent
|
||||||
|
segment={segment}
|
||||||
|
className={cn(
|
||||||
|
// read-only list: hover only, no selected/focused styling on click
|
||||||
|
"hover:bg-accent/40 gap-3 rounded-none border-b px-4 py-2.5 transition-colors",
|
||||||
|
viewConfig.classNames?.agendaItem
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EventCalendarAgendaView }
|
||||||
|
export type { EventCalendarAgendaViewProps }
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { type ComponentType, type ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
useEventCalendarSelector,
|
||||||
|
useEventCalendarViewConfig,
|
||||||
|
} from "@/components/reui/event-calendar/event-calendar"
|
||||||
|
import { EventCalendarAgendaView } from "@/components/reui/event-calendar/event-calendar-agenda-view"
|
||||||
|
import { EventCalendarMonthView } from "@/components/reui/event-calendar/event-calendar-month-view"
|
||||||
|
import { EventCalendarResourceView } from "@/components/reui/event-calendar/event-calendar-resource-view"
|
||||||
|
import {
|
||||||
|
EventCalendarDaysView,
|
||||||
|
EventCalendarDayView,
|
||||||
|
EventCalendarWeekView,
|
||||||
|
} from "@/components/reui/event-calendar/event-calendar-time-grid"
|
||||||
|
import type { CalendarView } from "@/components/reui/event-calendar/event-calendar-types"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|
||||||
|
const DEFAULT_VIEW_COMPONENTS: Record<CalendarView, ComponentType> = {
|
||||||
|
month: EventCalendarMonthView,
|
||||||
|
week: EventCalendarWeekView,
|
||||||
|
day: EventCalendarDayView,
|
||||||
|
days: EventCalendarDaysView,
|
||||||
|
agenda: EventCalendarAgendaView,
|
||||||
|
resource: EventCalendarResourceView,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventCalendarContentProps extends Omit<
|
||||||
|
useRender.ComponentProps<"div">,
|
||||||
|
"children"
|
||||||
|
> {
|
||||||
|
/** Swap individual view implementations. */
|
||||||
|
components?: Partial<Record<CalendarView, ComponentType>>
|
||||||
|
/** Replaces the switchboard entirely; read useEventCalendarView() inside. */
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventCalendarContent({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
components,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: EventCalendarContentProps) {
|
||||||
|
const viewConfig = useEventCalendarViewConfig()
|
||||||
|
const view = useEventCalendarSelector((state) => state.view)
|
||||||
|
const loading = useEventCalendarSelector((state) => state.loading)
|
||||||
|
|
||||||
|
const resolved = {
|
||||||
|
...DEFAULT_VIEW_COMPONENTS,
|
||||||
|
...viewConfig.components,
|
||||||
|
...components,
|
||||||
|
}
|
||||||
|
// A spread copies keys that hold `undefined`, so `components={{ month: isPro
|
||||||
|
// ? ProMonth : undefined }}` would erase the default and render <undefined />.
|
||||||
|
const ActiveView = resolved[view] ?? DEFAULT_VIEW_COMPONENTS[view]
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
"data-slot": "event-calendar-content",
|
||||||
|
"data-view": view,
|
||||||
|
"data-loading": loading || undefined,
|
||||||
|
className: cn(
|
||||||
|
"relative flex min-h-0 min-w-0 flex-1 flex-col",
|
||||||
|
"data-loading:pointer-events-none data-loading:opacity-60",
|
||||||
|
viewConfig.classNames?.content,
|
||||||
|
className
|
||||||
|
),
|
||||||
|
children: children ?? <ActiveView />,
|
||||||
|
}
|
||||||
|
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
render,
|
||||||
|
props: mergeProps<"div">(defaultProps, props),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_VIEW_COMPONENTS, EventCalendarContent }
|
||||||
|
export type { EventCalendarContentProps }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,555 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react"
|
||||||
|
import {
|
||||||
|
useEventCalendar,
|
||||||
|
useEventCalendarSelector,
|
||||||
|
useEventCalendarViewConfig,
|
||||||
|
useEventCalendarViewContext,
|
||||||
|
} from "@/components/reui/event-calendar/event-calendar"
|
||||||
|
import {
|
||||||
|
markChipPress,
|
||||||
|
useEventCalendarGestures,
|
||||||
|
wasRecentDrag,
|
||||||
|
} from "@/components/reui/event-calendar/event-calendar-dnd"
|
||||||
|
import {
|
||||||
|
spansMultipleDays,
|
||||||
|
toZoned,
|
||||||
|
zonedStartOfDay,
|
||||||
|
} from "@/components/reui/event-calendar/event-calendar-lib"
|
||||||
|
import type {
|
||||||
|
EventCalendarOccurrence,
|
||||||
|
EventCalendarSegment,
|
||||||
|
} from "@/components/reui/event-calendar/event-calendar-types"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { addDays, format } from "date-fns"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@evobgp/ui/components/tooltip"
|
||||||
|
import { RepeatIcon } from "lucide-react"
|
||||||
|
|
||||||
|
/** Event color presets; each stays legible on light and dark surfaces. */
|
||||||
|
const EVENT_CALENDAR_COLORS: Array<{ name: string; value: string }> = [
|
||||||
|
{ name: "Blue", value: "var(--color-blue-500)" },
|
||||||
|
{ name: "Emerald", value: "var(--color-emerald-500)" },
|
||||||
|
{ name: "Violet", value: "var(--color-violet-500)" },
|
||||||
|
{ name: "Rose", value: "var(--color-rose-500)" },
|
||||||
|
{ name: "Amber", value: "var(--color-amber-500)" },
|
||||||
|
{ name: "Cyan", value: "var(--color-cyan-500)" },
|
||||||
|
{ name: "Orange", value: "var(--color-orange-500)" },
|
||||||
|
{ name: "Pink", value: "var(--color-pink-500)" },
|
||||||
|
{ name: "Teal", value: "var(--color-teal-500)" },
|
||||||
|
{ name: "Indigo", value: "var(--color-indigo-500)" },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-ghost surfaces, shared verbatim by every view. A move CARRIES the
|
||||||
|
* event: the dnd engine attaches a full clone to the cursor
|
||||||
|
* (data-slot=event-calendar-drag-carry), so this in-grid ghost is only the
|
||||||
|
* dashed placeholder for the snapped drop slot. A resize STRETCHES instead:
|
||||||
|
* the chip itself at the proposed extent, dashed rather than solid.
|
||||||
|
*/
|
||||||
|
const EVENT_CALENDAR_GHOST = {
|
||||||
|
move: "rounded-sm border border-dashed border-(--ec-event-color)/50 bg-(--ec-event-color)/8",
|
||||||
|
resize:
|
||||||
|
"rounded-sm border border-dashed border-(--ec-event-color)/70 overflow-hidden",
|
||||||
|
invalid: "border-destructive/70 bg-destructive/10",
|
||||||
|
invalidResize: "border-destructive/70",
|
||||||
|
/** Applied to the clone inside an invalid resize ghost. */
|
||||||
|
invalidContent: "opacity-60",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fade-out truncation for stacked timed blocks, where squeezed cascade
|
||||||
|
* columns clip titles into a mash of glyphs; a right-edge mask fade reads
|
||||||
|
* cleaner than an ellipsis at those widths. Masked ONLY below a 10rem
|
||||||
|
* container: mask-image forces text off subpixel antialiasing, so masking
|
||||||
|
* wide chips makes the whole grid read bolder and shimmer while resizing.
|
||||||
|
* Wide chips keep the plain ellipsis. Exported for consumer renderEvent.
|
||||||
|
*/
|
||||||
|
const EVENT_CALENDAR_FADE_TRUNCATE =
|
||||||
|
"w-full truncate @max-[10rem]:text-clip @max-[10rem]:[mask-image:linear-gradient(to_right,#000_calc(100%-0.75rem),transparent)] @max-[10rem]:rtl:[mask-image:linear-gradient(to_left,#000_calc(100%-0.75rem),transparent)]"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The drag-to-create selection, shared by every view: a dashed primary
|
||||||
|
* outline over a faint wash with the range printed inside. `box` is the timed
|
||||||
|
* grid's single minute-positioned rectangle; `segment` is one day-cell slice
|
||||||
|
* of a multi-cell draft, with side borders and rounding only on the run's two
|
||||||
|
* ends so it reads as one dashed box rather than a row of them.
|
||||||
|
*/
|
||||||
|
const EVENT_CALENDAR_SLOT_DRAFT = {
|
||||||
|
box: "rounded-sm border border-dashed border-primary/40 bg-primary/5",
|
||||||
|
segment: "border-y border-dashed border-primary/40",
|
||||||
|
segmentStart: "rounded-s-sm border-s",
|
||||||
|
segmentEnd: "rounded-e-sm border-e",
|
||||||
|
/**
|
||||||
|
* The wash for segmented views, on the CELL not the dashed overlay: the
|
||||||
|
* overlay stacks above the chips, so tinting it would wash them instead.
|
||||||
|
*/
|
||||||
|
surface: "bg-primary/5",
|
||||||
|
/**
|
||||||
|
* The range readout. `leading-none` is load-bearing: the shortest timed
|
||||||
|
* draft is one snap interval tall (16px at the default 15-minute snap and
|
||||||
|
* 4rem hour height), and looser leading renders ~18px, clipped by the
|
||||||
|
* draft's own overflow-hidden.
|
||||||
|
*/
|
||||||
|
label:
|
||||||
|
"text-primary truncate px-1 py-0.5 text-[0.6875rem] leading-none font-medium",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
interface EventCalendarChipContextValue<TData = unknown> {
|
||||||
|
occurrence: EventCalendarOccurrence<TData>
|
||||||
|
segment: EventCalendarSegment<TData>
|
||||||
|
isDragging: boolean
|
||||||
|
isSelected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventCalendarChipContext =
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
createContext<EventCalendarChipContextValue<any> | null>(null)
|
||||||
|
|
||||||
|
/** The chip's subject; usable inside renderEvent content and chip children. */
|
||||||
|
function useEventCalendarEventChip<
|
||||||
|
TData = unknown,
|
||||||
|
>(): EventCalendarChipContextValue<TData> {
|
||||||
|
const ctx = useContext(EventCalendarChipContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useEventCalendarEventChip must be used within <EventCalendarEvent>"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ctx as EventCalendarChipContextValue<TData>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventCalendarEventProps<TData = unknown> extends Omit<
|
||||||
|
useRender.ComponentProps<"button">,
|
||||||
|
"children"
|
||||||
|
> {
|
||||||
|
segment: EventCalendarSegment<TData>
|
||||||
|
/** Replaces the default chip CONTENT; the wrapper stays calendar-owned. */
|
||||||
|
children?: ReactNode
|
||||||
|
/**
|
||||||
|
* Static drag clone: the chip as-is but inert - no gestures, resize
|
||||||
|
* handles, selection/drag state, focus or pointer events.
|
||||||
|
*/
|
||||||
|
preview?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one interactive event element in every view. The wrapper owns a11y,
|
||||||
|
* data attributes, selection and drag/resize wiring; content comes from
|
||||||
|
* children, else renderEvent, else the built-in default.
|
||||||
|
*/
|
||||||
|
function EventCalendarEvent<TData = unknown>({
|
||||||
|
segment,
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
children,
|
||||||
|
preview = false,
|
||||||
|
...props
|
||||||
|
}: EventCalendarEventProps<TData>) {
|
||||||
|
const instance = useEventCalendar<TData>()
|
||||||
|
const viewConfig = useEventCalendarViewConfig<TData>()
|
||||||
|
const { view } = useEventCalendarViewContext()
|
||||||
|
const gestures = useEventCalendarGestures<TData>()
|
||||||
|
const { settings } = instance
|
||||||
|
const occurrence = segment.occurrence
|
||||||
|
const event = occurrence.event
|
||||||
|
|
||||||
|
const isSelectedRaw = useEventCalendarSelector<TData, boolean>(
|
||||||
|
(state) => state.selection.eventKeys.includes(occurrence.key),
|
||||||
|
{ calendar: instance }
|
||||||
|
)
|
||||||
|
const isDraggingRaw = useEventCalendarSelector<TData, boolean>(
|
||||||
|
(state) => state.drag?.occurrence.key === occurrence.key,
|
||||||
|
{ calendar: instance }
|
||||||
|
)
|
||||||
|
// reactive, unlike gestures.canResize: api.setInteractions({ resize })
|
||||||
|
// must add/remove the handles without waiting for an unrelated re-render
|
||||||
|
const resizeOn = useEventCalendarSelector<TData, boolean>(
|
||||||
|
(state) => state.interactions.resize,
|
||||||
|
{ calendar: instance }
|
||||||
|
)
|
||||||
|
// A preview clone must never inherit the source's selected/dragging state
|
||||||
|
// (the drag key matches, which would dim the clone itself).
|
||||||
|
const isSelected = preview ? false : isSelectedRaw
|
||||||
|
const isDragging = preview ? false : isDraggingRaw
|
||||||
|
|
||||||
|
const isBar =
|
||||||
|
occurrence.allDay || spansMultipleDays(occurrence, settings.timeZone)
|
||||||
|
const inTimeGrid =
|
||||||
|
view === "week" || view === "day" || view === "days" || view === "resource"
|
||||||
|
const interactive = view !== "agenda" && !preview
|
||||||
|
const timedBlock = inTimeGrid && !isBar
|
||||||
|
const horizontalBar = isBar && !inTimeGrid
|
||||||
|
// >= compactEventMinutes renders the stacked (title over time) layout, where
|
||||||
|
// squeezed cascade columns fade-truncate instead of clipping into neighbors
|
||||||
|
const stackedBlock =
|
||||||
|
timedBlock &&
|
||||||
|
(segment.endMin ?? 0) - (segment.startMin ?? 0) >=
|
||||||
|
viewConfig.compactEventMinutes
|
||||||
|
|
||||||
|
const defaultContent = (
|
||||||
|
<>
|
||||||
|
{/* leading dot for single-row chips (month cells, all-day bars); a
|
||||||
|
time-grid block takes its color from the tinted surface instead, and
|
||||||
|
in its stacked layout a dot would sit alone on the first line */}
|
||||||
|
{!timedBlock && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="event-calendar-event-dot"
|
||||||
|
// -me-0.5 tightens only the dot-to-title gap; the chip keeps gap-1.5
|
||||||
|
className="-me-0.5 size-1.5 shrink-0 rounded-full bg-(--ec-event-color)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{occurrence.isRecurring && (
|
||||||
|
<RepeatIcon className="size-2.5 shrink-0 opacity-70" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-medium",
|
||||||
|
stackedBlock ? EVENT_CALENDAR_FADE_TRUNCATE : "truncate"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{event.title}
|
||||||
|
</span>
|
||||||
|
{/* month cells are narrow: a compact never-shrinking start time keeps
|
||||||
|
the title readable; grids show the full range */}
|
||||||
|
{!occurrence.allDay &&
|
||||||
|
segment.isStart &&
|
||||||
|
(view === "month" ? (
|
||||||
|
<span className="text-muted-foreground shrink-0">
|
||||||
|
{format(
|
||||||
|
toZoned(occurrence.start, settings.timeZone),
|
||||||
|
settings.i18n.formats.eventTime,
|
||||||
|
{ locale: settings.locale }
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground hidden @[8rem]:inline",
|
||||||
|
stackedBlock ? EVENT_CALENDAR_FADE_TRUNCATE : "truncate"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{settings.i18n.functions.formatEventTime(
|
||||||
|
toZoned(occurrence.start, settings.timeZone),
|
||||||
|
toZoned(occurrence.end, settings.timeZone),
|
||||||
|
occurrence.allDay,
|
||||||
|
{ locale: settings.locale }
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-day time text for a multi-day event: "From 9:00 AM", "All day",
|
||||||
|
// "Until 5:00 PM". Boundaries come from the occurrence vs segment.day, never
|
||||||
|
// the packing flags - lane merging rewrites those on shared segments.
|
||||||
|
const agendaTimeText = (() => {
|
||||||
|
if (view !== "agenda") return ""
|
||||||
|
if (occurrence.allDay) return settings.i18n.labels.allDay
|
||||||
|
const dayStart = zonedStartOfDay(segment.day, settings.timeZone)
|
||||||
|
const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1)
|
||||||
|
const startsBefore = occurrence.start < dayStart
|
||||||
|
const endsAfter = occurrence.end > dayEnd
|
||||||
|
if (startsBefore && endsAfter) return settings.i18n.labels.allDay
|
||||||
|
if (endsAfter) {
|
||||||
|
return settings.i18n.labels.timeFrom(
|
||||||
|
format(
|
||||||
|
toZoned(occurrence.start, settings.timeZone),
|
||||||
|
settings.i18n.formats.eventTime,
|
||||||
|
{ locale: settings.locale }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (startsBefore) {
|
||||||
|
return settings.i18n.labels.timeUntil(
|
||||||
|
format(
|
||||||
|
toZoned(occurrence.end, settings.timeZone),
|
||||||
|
settings.i18n.formats.eventTime,
|
||||||
|
{ locale: settings.locale }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return settings.i18n.functions.formatEventTime(
|
||||||
|
toZoned(occurrence.start, settings.timeZone),
|
||||||
|
toZoned(occurrence.end, settings.timeZone),
|
||||||
|
false,
|
||||||
|
{ locale: settings.locale }
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Agenda default row: time column, color-dot badge, plain title
|
||||||
|
const agendaDefaultContent = (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground w-40 shrink-0 truncate tabular-nums">
|
||||||
|
{agendaTimeText}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="event-calendar-agenda-dot"
|
||||||
|
className="size-2 shrink-0 rounded-full bg-(--ec-event-color)"
|
||||||
|
/>
|
||||||
|
<span className="truncate text-sm">{event.title}</span>
|
||||||
|
{occurrence.isRecurring && (
|
||||||
|
<RepeatIcon className="text-muted-foreground size-2.5 shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Memoized so a drag - which re-renders the lane on every pointer move -
|
||||||
|
// never re-invokes the consumer's renderEvent per frame: a referentially
|
||||||
|
// stable element lets React skip the custom subtree instead of flickering it.
|
||||||
|
// The render fns are deps, so an inline arrow from the consumer defeats it.
|
||||||
|
const customContent = useMemo(() => {
|
||||||
|
const renderProps = { occurrence, segment, view, isDragging, isSelected }
|
||||||
|
return view === "agenda"
|
||||||
|
? viewConfig.renderAgendaEvent?.(renderProps)
|
||||||
|
: viewConfig.renderEvent?.(renderProps)
|
||||||
|
}, [
|
||||||
|
occurrence,
|
||||||
|
segment,
|
||||||
|
view,
|
||||||
|
isDragging,
|
||||||
|
isSelected,
|
||||||
|
viewConfig.renderAgendaEvent,
|
||||||
|
viewConfig.renderEvent,
|
||||||
|
])
|
||||||
|
const content =
|
||||||
|
children ??
|
||||||
|
customContent ??
|
||||||
|
(view === "agenda" ? agendaDefaultContent : defaultContent)
|
||||||
|
|
||||||
|
const timeLabel = settings.i18n.functions.formatEventTime(
|
||||||
|
toZoned(occurrence.start, settings.timeZone),
|
||||||
|
toZoned(occurrence.end, settings.timeZone),
|
||||||
|
occurrence.allDay,
|
||||||
|
{ locale: settings.locale }
|
||||||
|
)
|
||||||
|
// native hover tooltip text; a formatter returning undefined drops the title
|
||||||
|
const label = settings.i18n.functions.formatEventLabel
|
||||||
|
? settings.i18n.functions.formatEventLabel(event.title, timeLabel)
|
||||||
|
: `${event.title}, ${timeLabel}`
|
||||||
|
|
||||||
|
// Optional styled tooltip (viewConfig.eventTooltip, default off). It replaces
|
||||||
|
// the native title so the two never stack, and a preview never gets one. A
|
||||||
|
// falsy renderEventTooltip result (including the false/"" of `cond && <node>`)
|
||||||
|
// falls back to the label; an empty label leaves no content and skips it.
|
||||||
|
const tooltipOpts =
|
||||||
|
typeof viewConfig.eventTooltip === "object" ? viewConfig.eventTooltip : null
|
||||||
|
const tooltipContent =
|
||||||
|
!preview && viewConfig.eventTooltip
|
||||||
|
? viewConfig.renderEventTooltip?.({
|
||||||
|
occurrence,
|
||||||
|
segment,
|
||||||
|
view,
|
||||||
|
label,
|
||||||
|
}) || label
|
||||||
|
: null
|
||||||
|
const tooltipOn = Boolean(tooltipContent)
|
||||||
|
|
||||||
|
const showResize =
|
||||||
|
interactive && resizeOn && !event.readOnly && event.resizable !== false
|
||||||
|
// Hover grip pill (mirrors the gantt bars) marking the resize direction.
|
||||||
|
// Shown on compact sub-compactEventMinutes blocks too: the 1.5rem chip
|
||||||
|
// min-height leaves edge room without colliding with the centered title.
|
||||||
|
const grip = (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="event-calendar-resize-grip"
|
||||||
|
className={cn(
|
||||||
|
"bg-foreground/40 rounded-full",
|
||||||
|
timedBlock ? "h-0.5 w-2.5" : "h-2.5 w-0.5",
|
||||||
|
viewConfig.classNames?.resizeGrip
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const resizeHandles = showResize && (
|
||||||
|
<>
|
||||||
|
{timedBlock && segment.isStart && (
|
||||||
|
<span
|
||||||
|
data-slot="event-calendar-resize-handle"
|
||||||
|
data-edge="start"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-x-1 top-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
|
||||||
|
viewConfig.classNames?.resizeHandle
|
||||||
|
)}
|
||||||
|
onPointerDown={(e) => gestures.beginResize(e, segment, "start")}
|
||||||
|
>
|
||||||
|
{grip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{timedBlock && segment.isEnd && (
|
||||||
|
<span
|
||||||
|
data-slot="event-calendar-resize-handle"
|
||||||
|
data-edge="end"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-x-1 bottom-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
|
||||||
|
viewConfig.classNames?.resizeHandle
|
||||||
|
)}
|
||||||
|
onPointerDown={(e) => gestures.beginResize(e, segment, "end")}
|
||||||
|
>
|
||||||
|
{grip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(horizontalBar || (isBar && inTimeGrid)) && segment.isStart && (
|
||||||
|
<span
|
||||||
|
data-slot="event-calendar-resize-handle"
|
||||||
|
data-edge="start"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 start-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
|
||||||
|
viewConfig.classNames?.resizeHandle
|
||||||
|
)}
|
||||||
|
onPointerDown={(e) => gestures.beginResize(e, segment, "start")}
|
||||||
|
>
|
||||||
|
{grip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(horizontalBar || (isBar && inTimeGrid)) && segment.isEnd && (
|
||||||
|
<span
|
||||||
|
data-slot="event-calendar-resize-handle"
|
||||||
|
data-edge="end"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 end-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
|
||||||
|
viewConfig.classNames?.resizeHandle
|
||||||
|
)}
|
||||||
|
onPointerDown={(e) => gestures.beginResize(e, segment, "end")}
|
||||||
|
>
|
||||||
|
{grip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
type: "button" as const,
|
||||||
|
"data-slot": "event-calendar-event",
|
||||||
|
"data-view": view,
|
||||||
|
"data-all-day": occurrence.allDay || undefined,
|
||||||
|
"data-recurring": occurrence.isRecurring || undefined,
|
||||||
|
"data-selected": isSelected || undefined,
|
||||||
|
"data-dragging": isDragging || undefined,
|
||||||
|
"data-preview": preview || undefined,
|
||||||
|
"data-past": occurrence.end.getTime() < Date.now() || undefined,
|
||||||
|
title: preview || tooltipOn ? undefined : label,
|
||||||
|
"aria-label":
|
||||||
|
settings.i18n.functions.formatEventAriaLabel?.(
|
||||||
|
event.title,
|
||||||
|
timeLabel,
|
||||||
|
segment.continuesBefore || segment.continuesAfter
|
||||||
|
) ??
|
||||||
|
`${event.title}, ${timeLabel}${
|
||||||
|
segment.continuesBefore || segment.continuesAfter
|
||||||
|
? `, ${settings.i18n.labels.continues}`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
// A background tint alone conveys selection, so the chip is a real toggle
|
||||||
|
// wherever it is interactive (agenda rows never select, previews are inert).
|
||||||
|
"aria-pressed": interactive ? isSelected : undefined,
|
||||||
|
"aria-hidden": preview || undefined,
|
||||||
|
tabIndex: preview ? -1 : undefined,
|
||||||
|
style: {
|
||||||
|
"--ec-event-color": event.color ?? "var(--color-primary)",
|
||||||
|
} as CSSProperties,
|
||||||
|
onPointerDown: (e: React.PointerEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
// suppress the trailing slot-create click when this press yields no drag
|
||||||
|
markChipPress()
|
||||||
|
if (interactive) gestures.beginMove(e, segment)
|
||||||
|
},
|
||||||
|
onClick: (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (wasRecentDrag()) return
|
||||||
|
// consumer first: e.preventDefault() opts out of built-in selection
|
||||||
|
settings.onEventClick?.(occurrence, e)
|
||||||
|
// the agenda is a read-only list: a click never selects/focuses a row
|
||||||
|
if (e.defaultPrevented || view === "agenda") return
|
||||||
|
instance.api.selectEvent(occurrence.key)
|
||||||
|
},
|
||||||
|
onDoubleClick: (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
settings.onEventDoubleClick?.(occurrence, e)
|
||||||
|
},
|
||||||
|
className: cn(
|
||||||
|
"group/ec-event text-foreground relative flex w-full min-w-0 cursor-pointer touch-none items-center overflow-hidden text-start select-none",
|
||||||
|
"focus-visible:ring-ring/50 outline-none focus-visible:ring-2",
|
||||||
|
preview && "pointer-events-none",
|
||||||
|
view === "agenda"
|
||||||
|
? // plain list row: color lives in the dot badge, not a tinted pill;
|
||||||
|
// hover AND selection surfaces are owned by the agenda row wrapper
|
||||||
|
"gap-3 rounded-md text-sm"
|
||||||
|
: cn(
|
||||||
|
// @container removes intrinsic sizing; only grid chips are containers
|
||||||
|
// py-1: room above/below inline badges (attendee pill etc.)
|
||||||
|
"@container gap-1.5 rounded-sm px-1.5 py-1 leading-normal",
|
||||||
|
// soft tint + inset ring, not an accent border: legible on both themes
|
||||||
|
"bg-(--ec-event-color)/15 hover:bg-(--ec-event-color)/25",
|
||||||
|
// a flat tint reads darker on a dark surface, so lift it there
|
||||||
|
"dark:bg-(--ec-event-color)/20 dark:hover:bg-(--ec-event-color)/30",
|
||||||
|
"inset-ring inset-ring-(--ec-event-color)/15",
|
||||||
|
"transition-[background-color,box-shadow] duration-150",
|
||||||
|
"data-dragging:opacity-40",
|
||||||
|
"data-selected:bg-(--ec-event-color)/30 data-selected:inset-ring-(--ec-event-color)/40",
|
||||||
|
segment.continuesBefore && "rounded-s-none",
|
||||||
|
segment.continuesAfter && "rounded-e-none"
|
||||||
|
),
|
||||||
|
viewConfig.classNames?.event,
|
||||||
|
className
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
{resizeHandles}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const chip = useRender({
|
||||||
|
defaultTagName: "button",
|
||||||
|
render,
|
||||||
|
props: mergeProps<"button">(defaultProps, props),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventCalendarChipContext.Provider
|
||||||
|
value={{ occurrence, segment, isDragging, isSelected }}
|
||||||
|
>
|
||||||
|
{tooltipOn ? (
|
||||||
|
<TooltipProvider delay={tooltipOpts?.delay ?? 600}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={chip} />
|
||||||
|
<TooltipContent
|
||||||
|
side={tooltipOpts?.side ?? "top"}
|
||||||
|
className={viewConfig.classNames?.eventTooltip}
|
||||||
|
>
|
||||||
|
{tooltipContent}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
chip
|
||||||
|
)}
|
||||||
|
</EventCalendarChipContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
EVENT_CALENDAR_COLORS,
|
||||||
|
EVENT_CALENDAR_FADE_TRUNCATE,
|
||||||
|
EVENT_CALENDAR_GHOST,
|
||||||
|
EVENT_CALENDAR_SLOT_DRAFT,
|
||||||
|
EventCalendarEvent,
|
||||||
|
useEventCalendarEventChip,
|
||||||
|
}
|
||||||
|
export type { EventCalendarChipContextValue, EventCalendarEventProps }
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import type {
|
||||||
|
CalendarView,
|
||||||
|
EventCalendarDateRange,
|
||||||
|
} from "@/components/reui/event-calendar/event-calendar-types"
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
isSameMonth,
|
||||||
|
isSameYear,
|
||||||
|
subMilliseconds,
|
||||||
|
type Locale,
|
||||||
|
} from "date-fns"
|
||||||
|
|
||||||
|
interface EventCalendarI18nConfig {
|
||||||
|
labels: {
|
||||||
|
today: string
|
||||||
|
previous: string
|
||||||
|
next: string
|
||||||
|
addEvent: string
|
||||||
|
allDay: string
|
||||||
|
more: (count: number) => string
|
||||||
|
noEvents: string
|
||||||
|
loading: string
|
||||||
|
event: string
|
||||||
|
events: (count: number) => string
|
||||||
|
selectView: string
|
||||||
|
week: (weekNumber: number) => string
|
||||||
|
resources: string
|
||||||
|
goToDate: string
|
||||||
|
/** Cursor hint while a drag/resize hovers a rejected position. */
|
||||||
|
dropNotAllowed: string
|
||||||
|
/** Aria-label suffix on chip segments that continue past the cell. */
|
||||||
|
continues: string
|
||||||
|
/** Agenda label for the first day of a multi-day event. */
|
||||||
|
timeFrom: (time: string) => string
|
||||||
|
/** Agenda label for the last day of a multi-day event. */
|
||||||
|
timeUntil: (time: string) => string
|
||||||
|
/** View-switcher shortcut hint characters, per view. */
|
||||||
|
viewShortcuts: Record<CalendarView, string>
|
||||||
|
/** Aria-label of the agenda day collapse/expand toggle. */
|
||||||
|
toggleDayEvents: (count: number, expanded: boolean) => string
|
||||||
|
/** Aria-label of the agenda event details toggle. */
|
||||||
|
eventDetails: (title: string) => string
|
||||||
|
/** Compact "+N" overflow (agenda summary dot stack). */
|
||||||
|
moreCompact: (count: number) => string
|
||||||
|
/** Joins a bounded from-to time span. */
|
||||||
|
timeRange: (from: string, to: string) => string
|
||||||
|
}
|
||||||
|
viewNames: {
|
||||||
|
month: string
|
||||||
|
week: string
|
||||||
|
day: string
|
||||||
|
days: (count: number) => string
|
||||||
|
agenda: string
|
||||||
|
resource: string
|
||||||
|
}
|
||||||
|
/** date-fns format strings, applied with the calendar `locale`. */
|
||||||
|
formats: {
|
||||||
|
monthTitle: string
|
||||||
|
/** Undefined = smart cross-month range via functions.formatTitle. */
|
||||||
|
weekTitle?: string
|
||||||
|
dayTitle: string
|
||||||
|
/** Undefined = smart range label via functions.formatTitle. */
|
||||||
|
agendaTitle?: string
|
||||||
|
monthDayHeader: string
|
||||||
|
/** Narrow variant used by the month view below the compact breakpoint. */
|
||||||
|
monthDayHeaderNarrow: string
|
||||||
|
timeGridDayHeader: string
|
||||||
|
agendaDayHeader: string
|
||||||
|
/** Agenda date-gutter day number. */
|
||||||
|
agendaDayNumber: string
|
||||||
|
/** Agenda date-gutter weekday label. */
|
||||||
|
agendaWeekday: string
|
||||||
|
/** "+N more" popover day header. */
|
||||||
|
moreDayHeader: string
|
||||||
|
/** Month cell aria-label date. */
|
||||||
|
monthCellAriaLabel: string
|
||||||
|
/** Time-grid day column aria-label date. */
|
||||||
|
dayAria: string
|
||||||
|
/** Undefined = the resource view title falls back to dayTitle. */
|
||||||
|
resourceTitle?: string
|
||||||
|
timeGutter: string
|
||||||
|
/** Sub-hour gutter labels (interval below 60 minutes). */
|
||||||
|
timeGutterMinute: string
|
||||||
|
eventTime: string
|
||||||
|
monthCellDay: string
|
||||||
|
}
|
||||||
|
functions: {
|
||||||
|
formatTitle: (
|
||||||
|
view: CalendarView,
|
||||||
|
ctx: {
|
||||||
|
date: Date
|
||||||
|
activeRange: EventCalendarDateRange
|
||||||
|
visibleRange: EventCalendarDateRange
|
||||||
|
locale?: Locale
|
||||||
|
}
|
||||||
|
) => string
|
||||||
|
formatEventTime: (
|
||||||
|
start: Date,
|
||||||
|
end: Date,
|
||||||
|
allDay: boolean,
|
||||||
|
/** date-fns options (the calendar `locale`); trailing so a 3-arg override still fits. */
|
||||||
|
opts?: { locale?: Locale }
|
||||||
|
) => string
|
||||||
|
formatDayRange: (
|
||||||
|
range: EventCalendarDateRange,
|
||||||
|
opts?: { locale?: Locale }
|
||||||
|
) => string
|
||||||
|
/** Chip native tooltip text; return undefined to drop the attribute. */
|
||||||
|
formatEventLabel?: (title: string, timeLabel: string) => string | undefined
|
||||||
|
/** Chip aria-label composition. */
|
||||||
|
formatEventAriaLabel?: (
|
||||||
|
title: string,
|
||||||
|
timeLabel: string,
|
||||||
|
continues: boolean
|
||||||
|
) => string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LABELS: EventCalendarI18nConfig["labels"] = {
|
||||||
|
today: "Today",
|
||||||
|
previous: "Previous",
|
||||||
|
next: "Next",
|
||||||
|
addEvent: "Add event",
|
||||||
|
allDay: "All day",
|
||||||
|
more: (count) => `+${count} more`,
|
||||||
|
noEvents: "No events",
|
||||||
|
loading: "Loading events",
|
||||||
|
event: "event",
|
||||||
|
events: (count) => (count === 1 ? "1 event" : `${count} events`),
|
||||||
|
selectView: "Select view",
|
||||||
|
week: (weekNumber) => `W${weekNumber}`,
|
||||||
|
resources: "Resources",
|
||||||
|
goToDate: "Go to date",
|
||||||
|
dropNotAllowed: "Can't place here",
|
||||||
|
continues: "continues",
|
||||||
|
timeFrom: (time) => `From ${time}`,
|
||||||
|
timeUntil: (time) => `Until ${time}`,
|
||||||
|
viewShortcuts: {
|
||||||
|
month: "M",
|
||||||
|
week: "W",
|
||||||
|
day: "D",
|
||||||
|
days: "5",
|
||||||
|
agenda: "A",
|
||||||
|
resource: "G",
|
||||||
|
},
|
||||||
|
toggleDayEvents: (count) => (count === 1 ? "1 event" : `${count} events`),
|
||||||
|
eventDetails: (title) => title,
|
||||||
|
moreCompact: (count) => `+${count}`,
|
||||||
|
timeRange: (from, to) => `${from} - ${to}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VIEW_NAMES: EventCalendarI18nConfig["viewNames"] = {
|
||||||
|
month: "Month",
|
||||||
|
week: "Week",
|
||||||
|
day: "Day",
|
||||||
|
days: (count) => (count === 1 ? "1 day" : `${count} days`),
|
||||||
|
agenda: "Agenda",
|
||||||
|
resource: "Time Grid",
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORMATS: EventCalendarI18nConfig["formats"] = {
|
||||||
|
monthTitle: "MMMM yyyy",
|
||||||
|
weekTitle: undefined,
|
||||||
|
dayTitle: "EEEE, MMMM d, yyyy",
|
||||||
|
agendaTitle: undefined,
|
||||||
|
monthDayHeader: "EEE",
|
||||||
|
monthDayHeaderNarrow: "EEEEE",
|
||||||
|
timeGridDayHeader: "EEE d",
|
||||||
|
agendaDayHeader: "EEEE, MMMM d",
|
||||||
|
agendaDayNumber: "d",
|
||||||
|
agendaWeekday: "EEE",
|
||||||
|
moreDayHeader: "EEEE, MMMM d",
|
||||||
|
monthCellAriaLabel: "PPPP",
|
||||||
|
dayAria: "PPPP",
|
||||||
|
resourceTitle: undefined,
|
||||||
|
timeGutter: "h a",
|
||||||
|
timeGutterMinute: "h:mm a",
|
||||||
|
eventTime: "h:mm a",
|
||||||
|
monthCellDay: "d",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default formatting functions BOUND to a config's labels/formats, so that
|
||||||
|
* `formats` overrides flow into the default renderers (a consumer overriding
|
||||||
|
* formats.monthTitle without replacing formatTitle still sees it applied).
|
||||||
|
*/
|
||||||
|
function makeDefaultFunctions(
|
||||||
|
cfg: Pick<EventCalendarI18nConfig, "labels" | "formats">
|
||||||
|
): EventCalendarI18nConfig["functions"] {
|
||||||
|
return {
|
||||||
|
formatTitle: (view, { date, activeRange, locale }) => {
|
||||||
|
const opts = { locale }
|
||||||
|
if (view === "month") {
|
||||||
|
return format(date, cfg.formats.monthTitle, opts)
|
||||||
|
}
|
||||||
|
if (view === "resource") {
|
||||||
|
return format(
|
||||||
|
date,
|
||||||
|
cfg.formats.resourceTitle ?? cfg.formats.dayTitle,
|
||||||
|
opts
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (view === "day") {
|
||||||
|
return format(date, cfg.formats.dayTitle, opts)
|
||||||
|
}
|
||||||
|
if (view === "week" && cfg.formats.weekTitle) {
|
||||||
|
return format(date, cfg.formats.weekTitle, opts)
|
||||||
|
}
|
||||||
|
if (view === "agenda" && cfg.formats.agendaTitle) {
|
||||||
|
return format(date, cfg.formats.agendaTitle, opts)
|
||||||
|
}
|
||||||
|
// week / days / agenda: smart range label, last day is activeRange.end - 1ms.
|
||||||
|
// subMilliseconds keeps the zoned date type (a plain new Date(ms)
|
||||||
|
// would flip the label to the machine zone near midnight)
|
||||||
|
const rangeEnd = subMilliseconds(activeRange.end, 1)
|
||||||
|
const start = activeRange.start
|
||||||
|
if (isSameMonth(start, rangeEnd)) {
|
||||||
|
return `${format(start, "MMMM d", opts)} - ${format(rangeEnd, "d, yyyy", opts)}`
|
||||||
|
}
|
||||||
|
if (isSameYear(start, rangeEnd)) {
|
||||||
|
return `${format(start, "MMM d", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
|
||||||
|
}
|
||||||
|
return `${format(start, "MMM d, yyyy", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
|
||||||
|
},
|
||||||
|
formatEventTime: (start, end, allDay, opts) => {
|
||||||
|
if (allDay) return cfg.labels.allDay
|
||||||
|
const fmt = cfg.formats.eventTime
|
||||||
|
// Multi-day timed events carry the date on both sides. Compare calendar
|
||||||
|
// days off the last rendered instant (end is exclusive, so a 14:00 to
|
||||||
|
// midnight event still ends on the start day). Elapsed ms would miss an
|
||||||
|
// exactly-24h event and a DST day that only runs 23 hours.
|
||||||
|
const lastInstant =
|
||||||
|
end.getTime() - 1 >= start.getTime() ? subMilliseconds(end, 1) : start
|
||||||
|
if (format(start, "yyyy-MM-dd") !== format(lastInstant, "yyyy-MM-dd")) {
|
||||||
|
return `${format(start, `MMM d, ${fmt}`, opts)} - ${format(end, `MMM d, ${fmt}`, opts)}`
|
||||||
|
}
|
||||||
|
return `${format(start, fmt, opts)} - ${format(end, fmt, opts)}`
|
||||||
|
},
|
||||||
|
formatDayRange: (range, opts) => {
|
||||||
|
// subMilliseconds keeps the zoned date type, same reason as formatTitle
|
||||||
|
const rangeEnd = subMilliseconds(range.end, 1)
|
||||||
|
return `${format(range.start, "MMM d", opts)} - ${format(rangeEnd, "MMM d", opts)}`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EVENT_CALENDAR_I18N: EventCalendarI18nConfig = {
|
||||||
|
labels: DEFAULT_LABELS,
|
||||||
|
viewNames: DEFAULT_VIEW_NAMES,
|
||||||
|
formats: DEFAULT_FORMATS,
|
||||||
|
functions: makeDefaultFunctions({
|
||||||
|
labels: DEFAULT_LABELS,
|
||||||
|
formats: DEFAULT_FORMATS,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One level deeper than `Partial`, because the merge below is per nested
|
||||||
|
* section: overriding a single label must not force a consumer to restate the
|
||||||
|
* other 22. Unknown keys are still rejected by the excess property check.
|
||||||
|
*/
|
||||||
|
type EventCalendarI18nOverrides = {
|
||||||
|
[K in keyof EventCalendarI18nConfig]?: Partial<EventCalendarI18nConfig[K]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shallow merge per nested object, matching the filters.tsx i18n contract:
|
||||||
|
* a partial override replaces individual keys, never whole sections. Default
|
||||||
|
* functions are re-bound to the MERGED labels/formats; explicit `functions`
|
||||||
|
* overrides still win.
|
||||||
|
*/
|
||||||
|
function mergeEventCalendarI18n(
|
||||||
|
overrides?: EventCalendarI18nOverrides
|
||||||
|
): EventCalendarI18nConfig {
|
||||||
|
if (!overrides) return DEFAULT_EVENT_CALENDAR_I18N
|
||||||
|
const labels = { ...DEFAULT_LABELS, ...overrides.labels }
|
||||||
|
const viewNames = { ...DEFAULT_VIEW_NAMES, ...overrides.viewNames }
|
||||||
|
const formats = { ...DEFAULT_FORMATS, ...overrides.formats }
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
viewNames,
|
||||||
|
formats,
|
||||||
|
functions: {
|
||||||
|
...makeDefaultFunctions({ labels, formats }),
|
||||||
|
...overrides.functions,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_EVENT_CALENDAR_I18N, mergeEventCalendarI18n }
|
||||||
|
export type { EventCalendarI18nConfig, EventCalendarI18nOverrides }
|
||||||
@@ -0,0 +1,677 @@
|
|||||||
|
import { expandRecurrence } from "@/components/reui/event-calendar/event-calendar-recurrence"
|
||||||
|
import type {
|
||||||
|
CalendarEvent,
|
||||||
|
CalendarView,
|
||||||
|
EventCalendarDateRange,
|
||||||
|
EventCalendarOccurrence,
|
||||||
|
EventCalendarOffDaysConfig,
|
||||||
|
EventCalendarResource,
|
||||||
|
EventCalendarSegment,
|
||||||
|
} from "@/components/reui/event-calendar/event-calendar-types"
|
||||||
|
import { TZDate } from "@date-fns/tz"
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
addMonths,
|
||||||
|
addWeeks,
|
||||||
|
differenceInCalendarDays,
|
||||||
|
differenceInMinutes,
|
||||||
|
format,
|
||||||
|
startOfDay,
|
||||||
|
startOfMonth,
|
||||||
|
startOfWeek,
|
||||||
|
} from "date-fns"
|
||||||
|
|
||||||
|
type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||||
|
|
||||||
|
/** Packing-effective minimum in minutes so tiny events do not stack invisibly. */
|
||||||
|
const MIN_PACK_SLOT = 30
|
||||||
|
|
||||||
|
/** The instant re-expressed in the display time zone (TZDate extends Date). */
|
||||||
|
function toZoned(date: Date, timeZone: string): TZDate {
|
||||||
|
return new TZDate(date.getTime(), timeZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zoned midnight of the day containing the instant. */
|
||||||
|
function zonedStartOfDay(date: Date, timeZone: string): TZDate {
|
||||||
|
return startOfDay(toZoned(date, timeZone))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable per-day key in the display time zone. */
|
||||||
|
function getDayKey(date: Date, timeZone: string): string {
|
||||||
|
return format(toZoned(date, timeZone), "yyyy-MM-dd")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Day length in minutes; 1380/1500 on DST transition days - never assume 1440. */
|
||||||
|
function getDayTotalMinutes(dayStart: Date, timeZone: string): number {
|
||||||
|
const next = zonedStartOfDay(
|
||||||
|
addDays(toZoned(dayStart, timeZone), 1),
|
||||||
|
timeZone
|
||||||
|
)
|
||||||
|
return differenceInMinutes(next, dayStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapMinutes(minutes: number, snap: number): number {
|
||||||
|
return Math.round(minutes / snap) * snap
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewRangeOptions {
|
||||||
|
timeZone: string
|
||||||
|
weekStartsOn: WeekStartsOn
|
||||||
|
dayCount: number
|
||||||
|
agendaDayCount: number
|
||||||
|
fixedWeeks: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewDateRanges {
|
||||||
|
visibleRange: EventCalendarDateRange
|
||||||
|
activeRange: EventCalendarDateRange
|
||||||
|
}
|
||||||
|
|
||||||
|
function getViewDateRange(
|
||||||
|
view: CalendarView,
|
||||||
|
date: Date,
|
||||||
|
opts: ViewRangeOptions
|
||||||
|
): ViewDateRanges {
|
||||||
|
const { timeZone, weekStartsOn, dayCount, agendaDayCount, fixedWeeks } = opts
|
||||||
|
const zoned = toZoned(date, timeZone)
|
||||||
|
|
||||||
|
if (view === "month") {
|
||||||
|
const activeStart = startOfMonth(zoned)
|
||||||
|
const activeEnd = startOfMonth(addMonths(zoned, 1))
|
||||||
|
const visibleStart = startOfWeek(activeStart, { weekStartsOn })
|
||||||
|
let visibleEnd: Date
|
||||||
|
if (fixedWeeks) {
|
||||||
|
visibleEnd = addDays(visibleStart, 42)
|
||||||
|
} else {
|
||||||
|
visibleEnd = startOfWeek(addDays(activeEnd, -1), { weekStartsOn })
|
||||||
|
visibleEnd = addWeeks(visibleEnd, 1)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
activeRange: { start: activeStart, end: activeEnd },
|
||||||
|
visibleRange: { start: visibleStart, end: visibleEnd },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === "week") {
|
||||||
|
const start = startOfWeek(zoned, { weekStartsOn })
|
||||||
|
const range = { start, end: addWeeks(start, 1) }
|
||||||
|
return { activeRange: range, visibleRange: range }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === "day" || view === "resource") {
|
||||||
|
const start = startOfDay(zoned)
|
||||||
|
const range = { start, end: addDays(start, 1) }
|
||||||
|
return { activeRange: range, visibleRange: range }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === "days") {
|
||||||
|
const start = startOfDay(zoned)
|
||||||
|
const range = { start, end: addDays(start, Math.max(1, dayCount)) }
|
||||||
|
return { activeRange: range, visibleRange: range }
|
||||||
|
}
|
||||||
|
|
||||||
|
// agenda
|
||||||
|
const start = startOfDay(zoned)
|
||||||
|
const range = { start, end: addDays(start, Math.max(1, agendaDayCount)) }
|
||||||
|
return { activeRange: range, visibleRange: range }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Day of month of the last day of the month containing the zoned date. */
|
||||||
|
function lastDayOfZonedMonth(date: Date): number {
|
||||||
|
return addDays(startOfMonth(addMonths(date, 1)), -1).getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The anchor date stepped one period forward or backward for the view. */
|
||||||
|
function stepDate(
|
||||||
|
view: CalendarView,
|
||||||
|
date: Date,
|
||||||
|
direction: 1 | -1,
|
||||||
|
opts: Pick<ViewRangeOptions, "timeZone" | "dayCount" | "agendaDayCount">
|
||||||
|
): Date {
|
||||||
|
const zoned = toZoned(date, opts.timeZone)
|
||||||
|
if (view === "month") {
|
||||||
|
const stepped = addMonths(zoned, direction)
|
||||||
|
// addMonths clamps the day down into a shorter month and never restores
|
||||||
|
// it, so next-then-prev from the 31st would leave the anchor on the 28th.
|
||||||
|
// Sticking a month end to the target month's end keeps stepping
|
||||||
|
// invertible, which matters because the anchor is what day and week view
|
||||||
|
// open on after a month navigation.
|
||||||
|
if (zoned.getDate() !== lastDayOfZonedMonth(zoned)) return stepped
|
||||||
|
return addDays(stepped, lastDayOfZonedMonth(stepped) - stepped.getDate())
|
||||||
|
}
|
||||||
|
if (view === "week") return addWeeks(zoned, direction)
|
||||||
|
if (view === "day" || view === "resource") return addDays(zoned, direction)
|
||||||
|
if (view === "days")
|
||||||
|
return addDays(zoned, direction * Math.max(1, opts.dayCount))
|
||||||
|
return addDays(zoned, direction * Math.max(1, opts.agendaDayCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangesIntersect(
|
||||||
|
a: EventCalendarDateRange,
|
||||||
|
b: EventCalendarDateRange
|
||||||
|
): boolean {
|
||||||
|
return a.start < b.end && a.end > b.start
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventsOverlap(
|
||||||
|
a: { start: Date; end: Date },
|
||||||
|
b: { start: Date; end: Date }
|
||||||
|
): boolean {
|
||||||
|
return a.start < b.end && a.end > b.start
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one canonical multi-day segmentation. Splits an occurrence into per-day
|
||||||
|
* segments clamped to the range. Rules (unit-tested in M1): exclusive end - an
|
||||||
|
* event ending exactly at zoned midnight emits NO segment for that day;
|
||||||
|
* zero-duration events emit one min-height segment; allDay occurrences walk
|
||||||
|
* the same absolute instants as timed ones and only drop startMin/endMin, so
|
||||||
|
* their bounds have to already BE display-zone midnights (see
|
||||||
|
* CalendarEvent.allDay) or the bar paints on the wrong days.
|
||||||
|
*/
|
||||||
|
function segmentOccurrence<TData>(
|
||||||
|
occurrence: EventCalendarOccurrence<TData>,
|
||||||
|
range: EventCalendarDateRange,
|
||||||
|
timeZone: string
|
||||||
|
): EventCalendarSegment<TData>[] {
|
||||||
|
const occStart = occurrence.start
|
||||||
|
const occEnd = occurrence.end
|
||||||
|
const isZeroLength = occEnd.getTime() === occStart.getTime()
|
||||||
|
|
||||||
|
const clampStart = occStart > range.start ? occStart : range.start
|
||||||
|
const clampEnd = occEnd < range.end ? occEnd : range.end
|
||||||
|
if (clampEnd < clampStart) return []
|
||||||
|
if (clampEnd.getTime() === clampStart.getTime() && !isZeroLength) return []
|
||||||
|
|
||||||
|
const segments: EventCalendarSegment<TData>[] = []
|
||||||
|
let cursor = zonedStartOfDay(clampStart, timeZone)
|
||||||
|
|
||||||
|
while (cursor < clampEnd || (isZeroLength && segments.length === 0)) {
|
||||||
|
const next = zonedStartOfDay(
|
||||||
|
addDays(toZoned(cursor, timeZone), 1),
|
||||||
|
timeZone
|
||||||
|
)
|
||||||
|
const segStart = clampStart > cursor ? clampStart : cursor
|
||||||
|
const segEnd = clampEnd < next ? clampEnd : next
|
||||||
|
|
||||||
|
const emptySeg = segEnd.getTime() <= segStart.getTime()
|
||||||
|
if (!emptySeg || isZeroLength) {
|
||||||
|
const isStart = segStart.getTime() === occStart.getTime()
|
||||||
|
const isEnd = segEnd.getTime() === occEnd.getTime()
|
||||||
|
segments.push({
|
||||||
|
occurrence,
|
||||||
|
day: cursor,
|
||||||
|
isStart,
|
||||||
|
isEnd,
|
||||||
|
continuesBefore: !isStart,
|
||||||
|
continuesAfter: !isEnd,
|
||||||
|
startMin: occurrence.allDay
|
||||||
|
? undefined
|
||||||
|
: differenceInMinutes(segStart, cursor),
|
||||||
|
endMin: occurrence.allDay
|
||||||
|
? undefined
|
||||||
|
: Math.max(
|
||||||
|
differenceInMinutes(segEnd, cursor),
|
||||||
|
differenceInMinutes(segStart, cursor)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (isZeroLength) break
|
||||||
|
cursor = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the occurrence should render as a bar (all-day row / month lanes). */
|
||||||
|
function isBarOccurrence(
|
||||||
|
occurrence: EventCalendarOccurrence,
|
||||||
|
timeZone?: string
|
||||||
|
): boolean {
|
||||||
|
return occurrence.allDay || spansMultipleDays(occurrence, timeZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
function spansMultipleDays(
|
||||||
|
occ: { start: Date; end: Date },
|
||||||
|
timeZone?: string
|
||||||
|
): boolean {
|
||||||
|
// An event ending exactly at the next midnight is still single-day
|
||||||
|
// (exclusive end), so compare against a strictly-later instant. The
|
||||||
|
// yardstick is the length of the day the event starts on, never a flat 24h:
|
||||||
|
// a fall-back day is 25h long, and a 00:00-to-00:00 shift on it is still one
|
||||||
|
// calendar day that belongs in the hour track, not in the all-day row.
|
||||||
|
// Without a display zone the dates answer in their own frame (TZDate) or in
|
||||||
|
// the host zone.
|
||||||
|
const dayStart = startOfDay(
|
||||||
|
timeZone ? toZoned(occ.start, timeZone) : occ.start
|
||||||
|
)
|
||||||
|
const nextDayStart = startOfDay(addDays(dayStart, 1))
|
||||||
|
return (
|
||||||
|
occ.end.getTime() - occ.start.getTime() >
|
||||||
|
nextDayStart.getTime() - dayStart.getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackedPosition {
|
||||||
|
column: number
|
||||||
|
columnCount: number
|
||||||
|
columnSpan: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google-style overlap packing for one day's timed segments.
|
||||||
|
* Mutates column/columnCount/columnSpan on the segments, in place.
|
||||||
|
* z resolution happens at render: event.zIndex verbatim, else 10 + column.
|
||||||
|
*/
|
||||||
|
function packTimedSegments<TData>(
|
||||||
|
segments: EventCalendarSegment<TData>[]
|
||||||
|
): void {
|
||||||
|
if (segments.length === 0) return
|
||||||
|
|
||||||
|
type Working = {
|
||||||
|
seg: EventCalendarSegment<TData>
|
||||||
|
startMin: number
|
||||||
|
effEnd: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: Working[] = segments
|
||||||
|
.map((seg) => {
|
||||||
|
const startMin = seg.startMin ?? 0
|
||||||
|
const endMin = seg.endMin ?? startMin
|
||||||
|
return {
|
||||||
|
seg,
|
||||||
|
startMin,
|
||||||
|
effEnd: Math.max(endMin, startMin + MIN_PACK_SLOT),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.startMin - b.startMin ||
|
||||||
|
b.effEnd - b.startMin - (a.effEnd - a.startMin) ||
|
||||||
|
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sweep into connected clusters
|
||||||
|
const clusters: Working[][] = []
|
||||||
|
let current: Working[] = []
|
||||||
|
let clusterEnd = -Infinity
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.startMin >= clusterEnd) {
|
||||||
|
current = []
|
||||||
|
clusters.push(current)
|
||||||
|
clusterEnd = -Infinity
|
||||||
|
}
|
||||||
|
current.push(item)
|
||||||
|
clusterEnd = Math.max(clusterEnd, item.effEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
// Greedy column assignment
|
||||||
|
const colEnds: number[] = []
|
||||||
|
const byColumn = new Map<number, Working[]>()
|
||||||
|
for (const item of cluster) {
|
||||||
|
let col = colEnds.findIndex((end) => end <= item.startMin)
|
||||||
|
if (col === -1) {
|
||||||
|
col = colEnds.length
|
||||||
|
colEnds.push(0)
|
||||||
|
}
|
||||||
|
colEnds[col] = item.effEnd
|
||||||
|
item.seg.column = col
|
||||||
|
const bucket = byColumn.get(col) ?? []
|
||||||
|
bucket.push(item)
|
||||||
|
byColumn.set(col, bucket)
|
||||||
|
}
|
||||||
|
const columnCount = colEnds.length
|
||||||
|
|
||||||
|
// Partial-overlap expansion: widen rightward into free columns
|
||||||
|
for (const item of cluster) {
|
||||||
|
let span = 1
|
||||||
|
const col = item.seg.column ?? 0
|
||||||
|
while (col + span < columnCount) {
|
||||||
|
const occupants = byColumn.get(col + span) ?? []
|
||||||
|
const blocked = occupants.some(
|
||||||
|
(o) => o.startMin < item.effEnd && o.effEnd > item.startMin
|
||||||
|
)
|
||||||
|
if (blocked) break
|
||||||
|
span++
|
||||||
|
}
|
||||||
|
item.seg.columnCount = columnCount
|
||||||
|
item.seg.columnSpan = span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Greedy lane packing for bar segments within one week row (7 columns).
|
||||||
|
* Mutates lane/rowIndex/colStart/colSpan on the segments, in place.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Build the laned month-row bars for one week: consecutive-day segments of
|
||||||
|
* the same occurrence merge into ONE bar (colStart -> colSpan) stacked into
|
||||||
|
* lanes. Returns NEW segment objects - the shared per-day segments (also
|
||||||
|
* rendered by the all-day rows and day cells) must stay pristine: mutating
|
||||||
|
* their isEnd/continues flags gave the first-day chip a whole-bar shape and
|
||||||
|
* a bogus end resize handle in the week all-day row, where dragging it
|
||||||
|
* collapsed the event to a single day.
|
||||||
|
*/
|
||||||
|
function packWeekRowLanes<TData>(
|
||||||
|
segments: EventCalendarSegment<TData>[],
|
||||||
|
rowIndex: number,
|
||||||
|
rowStart: Date,
|
||||||
|
timeZone: string
|
||||||
|
): EventCalendarSegment<TData>[] {
|
||||||
|
type Bar = {
|
||||||
|
seg: EventCalendarSegment<TData>
|
||||||
|
colStart: number
|
||||||
|
colSpan: number
|
||||||
|
isStart: boolean
|
||||||
|
isEnd: boolean
|
||||||
|
lane: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const bars: Bar[] = segments.map((seg) => {
|
||||||
|
const dayIndex = Math.round(
|
||||||
|
(zonedStartOfDay(seg.day, timeZone).getTime() -
|
||||||
|
zonedStartOfDay(rowStart, timeZone).getTime()) /
|
||||||
|
(24 * 60 * 60 * 1000)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
seg,
|
||||||
|
colStart: Math.max(0, Math.min(6, dayIndex)),
|
||||||
|
colSpan: 1,
|
||||||
|
isStart: seg.isStart,
|
||||||
|
isEnd: seg.isEnd,
|
||||||
|
lane: 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Merge consecutive-day segments of the same occurrence into one bar per row
|
||||||
|
const merged = new Map<string, Bar>()
|
||||||
|
for (const bar of bars) {
|
||||||
|
const key = bar.seg.occurrence.key
|
||||||
|
const existing = merged.get(key)
|
||||||
|
if (existing) {
|
||||||
|
const start = Math.min(existing.colStart, bar.colStart)
|
||||||
|
const end = Math.max(
|
||||||
|
existing.colStart + existing.colSpan,
|
||||||
|
bar.colStart + bar.colSpan
|
||||||
|
)
|
||||||
|
existing.colStart = start
|
||||||
|
existing.colSpan = end - start
|
||||||
|
existing.isStart = existing.isStart || bar.isStart
|
||||||
|
existing.isEnd = existing.isEnd || bar.isEnd
|
||||||
|
} else {
|
||||||
|
merged.set(key, bar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowBars = Array.from(merged.values()).sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.colStart - b.colStart ||
|
||||||
|
b.colSpan - a.colSpan ||
|
||||||
|
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
|
||||||
|
)
|
||||||
|
|
||||||
|
const lanes: boolean[][] = []
|
||||||
|
for (const bar of rowBars) {
|
||||||
|
let lane = 0
|
||||||
|
for (;;) {
|
||||||
|
lanes[lane] ??= new Array(7).fill(false)
|
||||||
|
const row = lanes[lane]
|
||||||
|
let free = true
|
||||||
|
for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
|
||||||
|
if (row[c]) {
|
||||||
|
free = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (free) break
|
||||||
|
lane++
|
||||||
|
}
|
||||||
|
for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
|
||||||
|
lanes[lane][c] = true
|
||||||
|
}
|
||||||
|
bar.lane = lane
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowBars.map((bar) => ({
|
||||||
|
...bar.seg,
|
||||||
|
isStart: bar.isStart,
|
||||||
|
isEnd: bar.isEnd,
|
||||||
|
continuesBefore: !bar.isStart,
|
||||||
|
continuesAfter: !bar.isEnd,
|
||||||
|
lane: bar.lane,
|
||||||
|
rowIndex,
|
||||||
|
colStart: bar.colStart,
|
||||||
|
colSpan: bar.colSpan,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventCalendarDayBucket<TData = unknown> {
|
||||||
|
allDay: EventCalendarSegment<TData>[]
|
||||||
|
timed: EventCalendarSegment<TData>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventCalendarWeekRow<TData = unknown> {
|
||||||
|
rowIndex: number
|
||||||
|
rowStart: Date
|
||||||
|
/** Laned bar segments (one per occurrence per row). */
|
||||||
|
bars: EventCalendarSegment<TData>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventCalendarIndex<TData = unknown> {
|
||||||
|
occurrences: EventCalendarOccurrence<TData>[]
|
||||||
|
byDay: Map<string, EventCalendarDayBucket<TData>>
|
||||||
|
weekRows: EventCalendarWeekRow<TData>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildIndexOptions<TData> {
|
||||||
|
timeZone: string
|
||||||
|
weekStartsOn: WeekStartsOn
|
||||||
|
eventOrder?: (
|
||||||
|
a: EventCalendarOccurrence<TData>,
|
||||||
|
b: EventCalendarOccurrence<TData>
|
||||||
|
) => number
|
||||||
|
getOccurrences?: (
|
||||||
|
event: CalendarEvent<TData>,
|
||||||
|
range: EventCalendarDateRange,
|
||||||
|
ctx: { timeZone: string }
|
||||||
|
) => Array<{ start: Date; end: Date }> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultEventOrder(
|
||||||
|
a: EventCalendarOccurrence,
|
||||||
|
b: EventCalendarOccurrence
|
||||||
|
): number {
|
||||||
|
return (
|
||||||
|
a.start.getTime() - b.start.getTime() ||
|
||||||
|
b.end.getTime() -
|
||||||
|
b.start.getTime() -
|
||||||
|
(a.end.getTime() - a.start.getTime()) ||
|
||||||
|
a.key.localeCompare(b.key)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEventIndex<TData>(
|
||||||
|
events: CalendarEvent<TData>[],
|
||||||
|
visibleRange: EventCalendarDateRange,
|
||||||
|
opts: BuildIndexOptions<TData>
|
||||||
|
): EventCalendarIndex<TData> {
|
||||||
|
const { timeZone, weekStartsOn } = opts
|
||||||
|
const order = opts.eventOrder ?? defaultEventOrder
|
||||||
|
|
||||||
|
// RECURRENCE-ID override replacement: an event carrying recurringEventId +
|
||||||
|
// originalStart is an edited single occurrence of that series. The parent's
|
||||||
|
// expansion drops the replaced instant; the override renders as its own
|
||||||
|
// occurrence through the normal path below.
|
||||||
|
const overrideTimes = new Map<string, Set<number>>()
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event.recurringEventId || !event.originalStart) continue
|
||||||
|
let times = overrideTimes.get(event.recurringEventId)
|
||||||
|
if (!times) overrideTimes.set(event.recurringEventId, (times = new Set()))
|
||||||
|
times.add(event.originalStart.getTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
const occurrences: EventCalendarOccurrence<TData>[] = []
|
||||||
|
for (const event of events) {
|
||||||
|
const replaced = overrideTimes.get(event.id)
|
||||||
|
const custom = opts.getOccurrences?.(event, visibleRange, { timeZone })
|
||||||
|
if (custom) {
|
||||||
|
custom.forEach((occ, i) => {
|
||||||
|
if (replaced?.has(occ.start.getTime())) return
|
||||||
|
if (!rangesIntersect({ start: occ.start, end: occ.end }, visibleRange))
|
||||||
|
return
|
||||||
|
occurrences.push({
|
||||||
|
key: `${event.id}::${occ.start.toISOString()}`,
|
||||||
|
eventId: event.id,
|
||||||
|
event,
|
||||||
|
start: occ.start,
|
||||||
|
end: occ.end,
|
||||||
|
allDay: event.allDay ?? false,
|
||||||
|
isRecurring: true,
|
||||||
|
recurrenceIndex: i,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const expanded = expandRecurrence(event, visibleRange, { timeZone })
|
||||||
|
occurrences.push(
|
||||||
|
...(replaced
|
||||||
|
? expanded.filter((occ) => !replaced.has(occ.start.getTime()))
|
||||||
|
: expanded)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
occurrences.sort(order)
|
||||||
|
|
||||||
|
const byDay = new Map<string, EventCalendarDayBucket<TData>>()
|
||||||
|
const barSegmentsByRow = new Map<number, EventCalendarSegment<TData>[]>()
|
||||||
|
const firstRowStart = startOfWeek(toZoned(visibleRange.start, timeZone), {
|
||||||
|
weekStartsOn,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const occurrence of occurrences) {
|
||||||
|
const segments = segmentOccurrence(occurrence, visibleRange, timeZone)
|
||||||
|
const bar = isBarOccurrence(occurrence, timeZone)
|
||||||
|
for (const seg of segments) {
|
||||||
|
const key = getDayKey(seg.day, timeZone)
|
||||||
|
let bucket = byDay.get(key)
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = { allDay: [], timed: [] }
|
||||||
|
byDay.set(key, bucket)
|
||||||
|
}
|
||||||
|
if (bar) {
|
||||||
|
bucket.allDay.push(seg)
|
||||||
|
// calendar-day math, not a fixed 168h divisor: DST transition weeks
|
||||||
|
// are 167/169h long and the fixed divisor mis-buckets every later
|
||||||
|
// Sunday one row early (which then clamps into the wrong column)
|
||||||
|
const rowIndex = Math.floor(
|
||||||
|
differenceInCalendarDays(toZoned(seg.day, timeZone), firstRowStart) /
|
||||||
|
7
|
||||||
|
)
|
||||||
|
const rowBucket = barSegmentsByRow.get(rowIndex) ?? []
|
||||||
|
rowBucket.push(seg)
|
||||||
|
barSegmentsByRow.set(rowIndex, rowBucket)
|
||||||
|
} else {
|
||||||
|
bucket.timed.push(seg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bucket of byDay.values()) {
|
||||||
|
packTimedSegments(bucket.timed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekRows: EventCalendarWeekRow<TData>[] = []
|
||||||
|
for (const [rowIndex, segs] of barSegmentsByRow) {
|
||||||
|
const rowStart = addWeeks(firstRowStart, rowIndex)
|
||||||
|
weekRows.push({
|
||||||
|
rowIndex,
|
||||||
|
rowStart,
|
||||||
|
bars: packWeekRowLanes(segs, rowIndex, rowStart, timeZone),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
weekRows.sort((a, b) => a.rowIndex - b.rowIndex)
|
||||||
|
|
||||||
|
return { occurrences, byDay, weekRows }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache key for index memoization; cheap string compare. */
|
||||||
|
function getRangeKey(range: EventCalendarDateRange): string {
|
||||||
|
return `${range.start.getTime()}-${range.end.getTime()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-first flatten of the resource tree (parents included). */
|
||||||
|
function flattenResources(
|
||||||
|
resources: EventCalendarResource[],
|
||||||
|
depth = 0
|
||||||
|
): Array<{ resource: EventCalendarResource; depth: number }> {
|
||||||
|
const rows: Array<{ resource: EventCalendarResource; depth: number }> = []
|
||||||
|
for (const resource of resources) {
|
||||||
|
rows.push({ resource, depth })
|
||||||
|
if (resource.children?.length) {
|
||||||
|
rows.push(...flattenResources(resource.children, depth + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_WEEKEND_DAYS = [0, 6]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves whether a day is an off day (non-working) in the display zone.
|
||||||
|
* Callers pass the calendar's own weekendDays so the shading cannot contradict
|
||||||
|
* the weekend the rest of the calendar renders; an explicit offDays.weekendDays
|
||||||
|
* still wins over it.
|
||||||
|
*/
|
||||||
|
function resolveOffDay(
|
||||||
|
day: Date,
|
||||||
|
timeZone: string,
|
||||||
|
config: boolean | EventCalendarOffDaysConfig | undefined,
|
||||||
|
defaultWeekendDays?: number[]
|
||||||
|
): boolean {
|
||||||
|
if (!config) return false
|
||||||
|
const resolved: EventCalendarOffDaysConfig = config === true ? {} : config
|
||||||
|
const weekendDays =
|
||||||
|
resolved.weekendDays ?? defaultWeekendDays ?? DEFAULT_WEEKEND_DAYS
|
||||||
|
const zoned = toZoned(day, timeZone)
|
||||||
|
if (weekendDays.includes(zoned.getDay())) return true
|
||||||
|
if (resolved.dates?.length) {
|
||||||
|
const key = getDayKey(day, timeZone)
|
||||||
|
if (resolved.dates.some((date) => getDayKey(date, timeZone) === key)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved.isOffDay?.(day) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildEventIndex,
|
||||||
|
defaultEventOrder,
|
||||||
|
eventsOverlap,
|
||||||
|
flattenResources,
|
||||||
|
getDayKey,
|
||||||
|
getDayTotalMinutes,
|
||||||
|
getRangeKey,
|
||||||
|
getViewDateRange,
|
||||||
|
isBarOccurrence,
|
||||||
|
MIN_PACK_SLOT,
|
||||||
|
packTimedSegments,
|
||||||
|
packWeekRowLanes,
|
||||||
|
rangesIntersect,
|
||||||
|
resolveOffDay,
|
||||||
|
segmentOccurrence,
|
||||||
|
snapMinutes,
|
||||||
|
spansMultipleDays,
|
||||||
|
stepDate,
|
||||||
|
toZoned,
|
||||||
|
zonedStartOfDay,
|
||||||
|
}
|
||||||
|
export type {
|
||||||
|
BuildIndexOptions,
|
||||||
|
EventCalendarDayBucket,
|
||||||
|
EventCalendarIndex,
|
||||||
|
EventCalendarWeekRow,
|
||||||
|
ViewDateRanges,
|
||||||
|
ViewRangeOptions,
|
||||||
|
WeekStartsOn,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user