feat(web): Lists/Rules UX parity с Агентами и правило kpi-hybrid
Hybrid row icons, row click, icon actions; DetailPanel header; ToggleGroup режима фильтра (settings-3). Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { Globe, List, Network } from 'lucide-react'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||
import { isManualListType } from '@evofw/shared'
|
||||
|
||||
type ListTypeIconProps = {
|
||||
type: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI-style type tile for list rows.
|
||||
* Preview DNA: https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function ListTypeIcon({ type, className }: ListTypeIconProps) {
|
||||
const kind = isManualListType(type)
|
||||
? 'static'
|
||||
: type === 'evobgp_community'
|
||||
? 'evobgp_community'
|
||||
: 'json_url'
|
||||
|
||||
const Icon =
|
||||
kind === 'evobgp_community' ? Network : kind === 'json_url' ? Globe : List
|
||||
const color =
|
||||
kind === 'evobgp_community'
|
||||
? 'text-info'
|
||||
: kind === 'json_url'
|
||||
? 'text-warning'
|
||||
: 'text-muted-foreground'
|
||||
|
||||
return (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 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-4',
|
||||
color,
|
||||
className,
|
||||
)}
|
||||
aria-label={
|
||||
kind === 'evobgp_community'
|
||||
? 'EvoBGP'
|
||||
: kind === 'json_url'
|
||||
? 'JSON URL'
|
||||
: 'Ручной'
|
||||
}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<Icon aria-hidden />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { ListIcon, RefreshCwIcon, Trash2 } from 'lucide-react'
|
||||
import { RefreshCwIcon, Trash2 } from 'lucide-react'
|
||||
import type { FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
DataGridPrimaryCell,
|
||||
} from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ListTypeIcon } from '@/components/lists/list-type-icon'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { isManualListType, type IpList } from '@evofw/shared'
|
||||
|
||||
@@ -58,15 +58,8 @@ export function createListColumns(opts: {
|
||||
<DataGridColumnHeader column={column} title="Список" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to="/lists/$id"
|
||||
params={{ id: row.original.id }}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<ListIcon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<ListTypeIcon type={row.original.type} />
|
||||
<DataGridPrimaryCell
|
||||
accent="primary"
|
||||
title={row.original.name}
|
||||
@@ -78,7 +71,7 @@ export function createListColumns(opts: {
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Список' },
|
||||
},
|
||||
@@ -137,15 +130,6 @@ export function createListColumns(opts: {
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
render={
|
||||
<Link to="/lists/$id" params={{ id: row.original.id }} />
|
||||
}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@evofw/ui/components/toggle-group'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
type PolicyMode = 'blacklist' | 'whitelist'
|
||||
|
||||
type PolicyModeToggleProps = {
|
||||
value: PolicyMode
|
||||
onChange: (mode: PolicyMode) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter mode — settings-3 ToggleGroup pattern.
|
||||
* Preview: https://reui.io/preview/base/settings-3
|
||||
* Docs: https://ui.shadcn.com/docs/components/base/toggle-group
|
||||
*/
|
||||
export function PolicyModeToggle({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
}: PolicyModeToggleProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn(className)}>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Режим фильтра</FrameTitle>
|
||||
<FrameDescription>
|
||||
Чёрный список: блокировать deny. Белый список: пропускать только
|
||||
allow, остальное (forward) — DROP.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={[value]}
|
||||
onValueChange={(next) => {
|
||||
const mode = next[0]
|
||||
if (mode === 'blacklist' || mode === 'whitelist') {
|
||||
onChange(mode)
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
aria-label="Режим фильтра"
|
||||
className="flex flex-wrap justify-start gap-1"
|
||||
>
|
||||
<ToggleGroupItem value="blacklist" aria-label="Чёрный список">
|
||||
Чёрный список
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="whitelist" aria-label="Белый список">
|
||||
Белый список
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Shield } from 'lucide-react'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||
|
||||
type PolicySetIconProps = {
|
||||
mode?: 'blacklist' | 'whitelist' | string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI-style tile for policy set rows.
|
||||
* Preview DNA: https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function PolicySetIcon({ mode, className }: PolicySetIconProps) {
|
||||
const isWhitelist = mode === 'whitelist'
|
||||
return (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 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-4',
|
||||
isWhitelist ? 'text-warning' : 'text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
aria-label={isWhitelist ? 'Whitelist' : 'Blacklist'}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<Shield aria-hidden />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import {
|
||||
DetailPanel,
|
||||
PageHeader,
|
||||
PageShell,
|
||||
ResourcePage,
|
||||
} from '@/components/reui-kit'
|
||||
@@ -300,7 +299,9 @@ function ListDetailPage() {
|
||||
if (listQ.isLoading) {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title="Список" description="Загрузка…" />
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header title="Список" description="Загрузка…" />
|
||||
</DetailPanel>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
@@ -313,83 +314,73 @@ function ListDetailPage() {
|
||||
if (!detail) {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Список не найден"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={<Link to="/lists" />}
|
||||
>
|
||||
К спискам
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Список не найден"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={<Link to="/lists" />}
|
||||
>
|
||||
К спискам
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</DetailPanel>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={detail.name}
|
||||
description={
|
||||
manual
|
||||
? 'IP, CIDR, домены и вложенные списки'
|
||||
: 'Записи из внешнего источника (только чтение)'
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={<Link to="/lists" />}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5" />
|
||||
Списки
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={refresh.isPending}
|
||||
onClick={() => refresh.mutate()}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={
|
||||
refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5'
|
||||
}
|
||||
/>
|
||||
Обновить
|
||||
</Button>
|
||||
{manual ? (
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
Добавить
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteListOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Удалить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={detail.name}
|
||||
description={
|
||||
manual
|
||||
? 'Ручной список'
|
||||
: detail.type === 'json_url'
|
||||
? 'JSON по URL'
|
||||
: 'EvoBGP community'
|
||||
? 'IP, CIDR, домены и вложенные списки'
|
||||
: 'Записи из внешнего источника (только чтение)'
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={<Link to="/lists" />}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5" />
|
||||
Списки
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={refresh.isPending}
|
||||
onClick={() => refresh.mutate()}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={
|
||||
refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5'
|
||||
}
|
||||
/>
|
||||
Обновить
|
||||
</Button>
|
||||
{manual ? (
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
Добавить
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteListOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Удалить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
actions={<StatusBadge status={detail.type} />}
|
||||
/>
|
||||
|
||||
<DetailPanel.Metrics
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { RefreshCwIcon } from 'lucide-react'
|
||||
import { Plus, RefreshCwIcon } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||
@@ -152,6 +152,7 @@ function ListsPage() {
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Создать
|
||||
</Button>
|
||||
</>
|
||||
@@ -185,6 +186,7 @@ function ListsPage() {
|
||||
description: 'Создайте первый список для политики firewall.',
|
||||
action: (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Создать
|
||||
</Button>
|
||||
),
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { PolicyModeToggle } from '@/components/rules/policy-mode-toggle'
|
||||
import {
|
||||
agentsQueryOptions,
|
||||
listsQueryOptions,
|
||||
@@ -56,6 +56,12 @@ import {
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
export const Route = createFileRoute('/_auth/rules/$setId')({
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
const set = await queryClient.ensureQueryData(
|
||||
policySetQueryOptions(params.setId),
|
||||
)
|
||||
return { breadcrumb: set.name }
|
||||
},
|
||||
component: PolicySetDetailPage,
|
||||
})
|
||||
|
||||
@@ -286,10 +292,12 @@ function PolicySetDetailPage() {
|
||||
<Switch
|
||||
checked={set.enabled}
|
||||
onCheckedChange={(v) => patchSet.mutate({ enabled: v })}
|
||||
disabled={patchSet.isPending}
|
||||
aria-label={set.enabled ? 'Выключить набор' : 'Включить набор'}
|
||||
/>
|
||||
<StatusBadge
|
||||
status={set.enabled ? 'enabled' : 'disabled'}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{set.enabled ? 'Включён' : 'Выключен'}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" render={<Link to="/rules" />}>
|
||||
Назад
|
||||
@@ -332,51 +340,11 @@ function PolicySetDetailPage() {
|
||||
/>
|
||||
|
||||
<DetailPanel.Section>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Режим фильтра</FrameTitle>
|
||||
<FrameDescription>
|
||||
Blacklist: блокировать deny. Whitelist: пропускать только
|
||||
allow, остальное (forward) — DROP.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={
|
||||
policyMode === 'blacklist' ? 'default' : 'outline'
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
patchSet.mutate({ policy_mode: 'blacklist' })
|
||||
}
|
||||
>
|
||||
Чёрный список
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
policyMode === 'whitelist' ? 'default' : 'outline'
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
patchSet.mutate({ policy_mode: 'whitelist' })
|
||||
}
|
||||
>
|
||||
Белый список
|
||||
</Button>
|
||||
<Badge
|
||||
variant={
|
||||
policyMode === 'whitelist'
|
||||
? 'warning-light'
|
||||
: 'secondary'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{policyMode}
|
||||
</Badge>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<PolicyModeToggle
|
||||
value={policyMode}
|
||||
disabled={patchSet.isPending}
|
||||
onChange={(mode) => patchSet.mutate({ policy_mode: mode })}
|
||||
/>
|
||||
</DetailPanel.Section>
|
||||
|
||||
<DetailPanel.Section
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
@@ -11,6 +11,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
|
||||
import { policySetsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
@@ -33,7 +34,7 @@ export const Route = createFileRoute('/_auth/rules/')({
|
||||
|
||||
/**
|
||||
* Policy sets list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · stats-12
|
||||
* Empty: https://reui.io/preview/base/empty-state-12
|
||||
* Create sheet: https://reui.io/preview/base/sheet-8
|
||||
*/
|
||||
@@ -119,17 +120,14 @@ function PolicySetsPage() {
|
||||
<DataGridColumnHeader column={column} title="Набор" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to="/rules/$setId"
|
||||
params={{ setId: row.original.id }}
|
||||
className="min-w-0"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<PolicySetIcon mode={row.original.policy_mode} />
|
||||
<DataGridPrimaryCell
|
||||
accent="primary"
|
||||
title={row.original.name}
|
||||
subtitle={row.original.description ?? undefined}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -190,16 +188,18 @@ function PolicySetsPage() {
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
render={
|
||||
<Link
|
||||
to="/rules/$setId"
|
||||
params={{ setId: row.original.id }}
|
||||
/>
|
||||
}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Открыть"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void navigate({
|
||||
to: '/rules/$setId',
|
||||
params: { setId: row.original.id },
|
||||
})
|
||||
}}
|
||||
>
|
||||
Открыть
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
{row.original.id !== 'set-shared-default' ? (
|
||||
<Button
|
||||
@@ -207,7 +207,10 @@ function PolicySetsPage() {
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
aria-label="Удалить"
|
||||
onClick={() => setDeleteId(row.original.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(row.original.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
@@ -216,7 +219,14 @@ function PolicySetsPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const addButton = (
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Новый набор
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -224,11 +234,7 @@ function PolicySetsPage() {
|
||||
<PageHeader
|
||||
title="Наборы правил"
|
||||
description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS."
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
Новый набор
|
||||
</Button>
|
||||
}
|
||||
actions={addButton}
|
||||
/>
|
||||
|
||||
<ResourcePage
|
||||
@@ -254,14 +260,13 @@ function PolicySetsPage() {
|
||||
isError={setsQ.isError}
|
||||
error={setsQ.error}
|
||||
onRetry={() => void setsQ.refetch()}
|
||||
onRowClick={(row) =>
|
||||
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
|
||||
}
|
||||
emptyState={{
|
||||
title: 'Нет наборов',
|
||||
description: 'Создайте первый набор правил политики.',
|
||||
action: (
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
Новый набор
|
||||
</Button>
|
||||
),
|
||||
action: addButton,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user