feat(web): командная палитра ⌘K — поиск агентов/списков/наборов и действия (ReUI c-command-7 + cmdk)
This commit is contained in:
@@ -26,6 +26,8 @@
|
|||||||
"@tanstack/react-virtual": "^3.14.7",
|
"@tanstack/react-virtual": "^3.14.7",
|
||||||
"@tanstack/router-plugin": "^1.120.0",
|
"@tanstack/router-plugin": "^1.120.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"cn": "^0.4.0",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@@ -1,36 +1,55 @@
|
|||||||
import { useEffect, useId, useMemo, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { SearchIcon } from 'lucide-react'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
ListIcon,
|
||||||
|
ListPlusIcon,
|
||||||
|
PlusIcon,
|
||||||
|
ServerIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
ShieldPlusIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
import { NAV_ITEMS } from '@/lib/nav'
|
import { NAV_ITEMS } from '@/lib/nav'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { useCan } from '@/lib/permissions'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
agentsQueryOptions,
|
||||||
DialogContent,
|
listsQueryOptions,
|
||||||
DialogDescription,
|
policySetsQueryOptions,
|
||||||
DialogHeader,
|
} from '@/queries'
|
||||||
DialogTitle,
|
|
||||||
} from '@evofw/ui/components/dialog'
|
|
||||||
import { Input } from '@evofw/ui/components/input'
|
|
||||||
import {
|
import {
|
||||||
Item,
|
CommandDialog,
|
||||||
ItemContent,
|
CommandEmpty,
|
||||||
ItemGroup,
|
CommandGroup,
|
||||||
ItemMedia,
|
CommandInput,
|
||||||
ItemTitle,
|
CommandItem,
|
||||||
} from '@evofw/ui/components/item'
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from '@evofw/ui/components/command'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command-K palette — навигация, поиск ресурсов и действия.
|
||||||
|
* DNA: https://reui.io/components/command/c-command-7 (hotkey-only по контракту).
|
||||||
|
*/
|
||||||
|
|
||||||
/** Command-K search — hotkey dialog (no header chrome trigger). */
|
|
||||||
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||||
|
void hotkeyOnly
|
||||||
|
return <SearchMenuDialog />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchMenuDialog() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const searchInputId = useId()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const can = useCan()
|
||||||
|
|
||||||
|
const agentsQ = useQuery({ ...agentsQueryOptions(), enabled: open })
|
||||||
|
const listsQ = useQuery({ ...listsQueryOptions(), enabled: open })
|
||||||
|
const setsQ = useQuery({ ...policySetsQueryOptions(), enabled: open })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setOpen(true)
|
setOpen((prev) => !prev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,99 +57,164 @@ export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
|||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
const go = (run: () => void) => {
|
||||||
if (!open) setQuery('')
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase()
|
|
||||||
if (!q) return NAV_ITEMS
|
|
||||||
return NAV_ITEMS.filter(
|
|
||||||
(item) =>
|
|
||||||
item.label.toLowerCase().includes(q) ||
|
|
||||||
item.keywords.some((k) => k.includes(q)),
|
|
||||||
)
|
|
||||||
}, [query])
|
|
||||||
|
|
||||||
function goTo(to: string) {
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
void navigate({ to })
|
run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navItems = NAV_ITEMS.filter(
|
||||||
|
(item) => !item.permission || can(item.permission),
|
||||||
|
)
|
||||||
|
|
||||||
|
const agents = (agentsQ.data?.items ?? []).slice(0, 50)
|
||||||
|
const lists = (listsQ.data?.items ?? []).slice(0, 50)
|
||||||
|
const sets = (setsQ.data?.items ?? []).slice(0, 50)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<CommandDialog
|
||||||
{hotkeyOnly ? null : (
|
open={open}
|
||||||
<Button
|
onOpenChange={setOpen}
|
||||||
type="button"
|
title="Палитра команд"
|
||||||
variant="ghost"
|
description="Поиск разделов, агентов, списков и действий"
|
||||||
size="icon"
|
className="max-w-xl"
|
||||||
aria-label="Поиск"
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-expanded={open}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
>
|
||||||
<SearchIcon
|
<CommandInput placeholder="Поиск разделов, агентов, списков…" />
|
||||||
className="size-4.5 transition-colors"
|
<CommandList>
|
||||||
aria-hidden="true"
|
<CommandEmpty>Ничего не найдено.</CommandEmpty>
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<CommandGroup heading="Действия">
|
||||||
<DialogHeader className="sr-only">
|
{can('fw:agents:write') ? (
|
||||||
<DialogTitle>Поиск</DialogTitle>
|
<CommandItem
|
||||||
<DialogDescription>
|
value="Добавить агента agent invite"
|
||||||
Переход к разделам приложения
|
onSelect={() =>
|
||||||
</DialogDescription>
|
go(() =>
|
||||||
</DialogHeader>
|
void navigate({
|
||||||
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
|
to: '/agents',
|
||||||
<div className="relative flex items-center gap-3 border-b px-4 py-2">
|
search: { view: 'cards', add: true },
|
||||||
<SearchIcon
|
}),
|
||||||
aria-hidden="true"
|
)
|
||||||
className="pointer-events-none size-4 opacity-60 select-none"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id={searchInputId}
|
|
||||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
|
||||||
autoFocus
|
|
||||||
placeholder="Перейти к разделу…"
|
|
||||||
aria-label="Поиск разделов"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && filtered[0]) {
|
|
||||||
e.preventDefault()
|
|
||||||
goTo(filtered[0].to)
|
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ItemGroup className="max-h-72 overflow-y-auto p-2">
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
|
|
||||||
Ничего не найдено
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
filtered.map((item) => (
|
|
||||||
<Item
|
|
||||||
key={item.to}
|
|
||||||
size="sm"
|
|
||||||
variant="muted"
|
|
||||||
className="cursor-pointer border-0"
|
|
||||||
render={<Link to={item.to} onClick={() => setOpen(false)} />}
|
|
||||||
>
|
>
|
||||||
<ItemMedia variant="icon">
|
<PlusIcon aria-hidden />
|
||||||
<item.icon aria-hidden="true" />
|
Добавить агента
|
||||||
</ItemMedia>
|
</CommandItem>
|
||||||
<ItemContent>
|
) : null}
|
||||||
<ItemTitle>{item.label}</ItemTitle>
|
{can('fw:lists:write') ? (
|
||||||
</ItemContent>
|
<CommandItem
|
||||||
</Item>
|
value="Создать список blocklist"
|
||||||
))
|
onSelect={() =>
|
||||||
)}
|
go(() =>
|
||||||
</ItemGroup>
|
void navigate({ to: '/lists', search: { create: true } }),
|
||||||
</DialogContent>
|
)
|
||||||
</Dialog>
|
}
|
||||||
|
>
|
||||||
|
<ListPlusIcon aria-hidden />
|
||||||
|
Создать список
|
||||||
|
</CommandItem>
|
||||||
|
) : null}
|
||||||
|
{can('fw:policies:write') ? (
|
||||||
|
<CommandItem
|
||||||
|
value="Создать набор правил policy"
|
||||||
|
onSelect={() =>
|
||||||
|
go(() =>
|
||||||
|
void navigate({ to: '/rules', search: { create: true } }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ShieldPlusIcon aria-hidden />
|
||||||
|
Создать набор правил
|
||||||
|
</CommandItem>
|
||||||
|
) : null}
|
||||||
|
</CommandGroup>
|
||||||
|
|
||||||
|
<CommandSeparator />
|
||||||
|
|
||||||
|
<CommandGroup heading="Навигация">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={item.to}
|
||||||
|
value={`${item.label} ${item.keywords.join(' ')}`}
|
||||||
|
onSelect={() => go(() => void navigate({ to: item.to }))}
|
||||||
|
>
|
||||||
|
<item.icon aria-hidden />
|
||||||
|
{item.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
|
||||||
|
{agents.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Агенты">
|
||||||
|
{agents.map((a) => (
|
||||||
|
<CommandItem
|
||||||
|
key={a.id}
|
||||||
|
value={`${a.name} ${a.hostname ?? ''} ${a.last_seen_ip ?? ''}`}
|
||||||
|
onSelect={() =>
|
||||||
|
go(() =>
|
||||||
|
void navigate({
|
||||||
|
to: '/agents',
|
||||||
|
search: { view: 'cards', agent: a.id },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ServerIcon aria-hidden />
|
||||||
|
<span className="truncate">{a.name}</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">
|
||||||
|
{a.hostname ?? a.status}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
</>
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{lists.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Списки">
|
||||||
|
{lists.map((l) => (
|
||||||
|
<CommandItem
|
||||||
|
key={l.id}
|
||||||
|
value={l.name}
|
||||||
|
onSelect={() =>
|
||||||
|
go(() => void navigate({ to: '/lists/$id', params: { id: l.id } }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListIcon aria-hidden />
|
||||||
|
<span className="truncate">{l.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sets.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Наборы правил">
|
||||||
|
{sets.map((s) => (
|
||||||
|
<CommandItem
|
||||||
|
key={s.id}
|
||||||
|
value={s.name}
|
||||||
|
onSelect={() =>
|
||||||
|
go(() =>
|
||||||
|
void navigate({
|
||||||
|
to: '/rules/$setId',
|
||||||
|
params: { setId: s.id },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ShieldIcon aria-hidden />
|
||||||
|
<span className="truncate">{s.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ import type { Agent } from '@evofw/shared'
|
|||||||
type AgentsSearch = {
|
type AgentsSearch = {
|
||||||
agent?: string
|
agent?: string
|
||||||
view: 'cards' | 'table'
|
view: 'cards' | 'table'
|
||||||
|
/** Открыть sheet добавления агента (из ⌘K-палитры). */
|
||||||
|
add?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
|
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
|
||||||
@@ -76,7 +78,8 @@ function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
|
|||||||
typeof search.agent === 'string' && search.agent.length > 0
|
typeof search.agent === 'string' && search.agent.length > 0
|
||||||
? search.agent
|
? search.agent
|
||||||
: undefined
|
: undefined
|
||||||
return { view, agent }
|
const add = search.add === true
|
||||||
|
return { view, agent, add }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/agents/')({
|
export const Route = createFileRoute('/_auth/agents/')({
|
||||||
@@ -101,13 +104,14 @@ function formatNames(names: string[]): string {
|
|||||||
|
|
||||||
function AgentsPage() {
|
function AgentsPage() {
|
||||||
const navigate = useNavigate({ from: Route.fullPath })
|
const navigate = useNavigate({ from: Route.fullPath })
|
||||||
const { agent: detailAgentId, view } = Route.useSearch()
|
const { agent: detailAgentId, view, add: addParam } = Route.useSearch()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const agentsQ = useQuery(agentsQueryOptions())
|
const agentsQ = useQuery(agentsQueryOptions())
|
||||||
const settingsQ = useQuery(settingsQueryOptions())
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
const { copyToClipboard } = useCopyToClipboard()
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
const canWrite = useCan()('fw:agents:write')
|
const canWrite = useCan()('fw:agents:write')
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpenState, setCreateOpenState] = useState(false)
|
||||||
|
const createOpen = createOpenState || addParam === true
|
||||||
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
|
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
|
||||||
const [approveAllOpen, setApproveAllOpen] = useState(false)
|
const [approveAllOpen, setApproveAllOpen] = useState(false)
|
||||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
|
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
|
||||||
@@ -130,6 +134,10 @@ function AgentsPage() {
|
|||||||
next.agent !== undefined
|
next.agent !== undefined
|
||||||
? next.agent || undefined
|
? next.agent || undefined
|
||||||
: base.agent,
|
: base.agent,
|
||||||
|
add:
|
||||||
|
next.add !== undefined
|
||||||
|
? next.add || undefined
|
||||||
|
: base.add,
|
||||||
} satisfies AgentsSearch
|
} satisfies AgentsSearch
|
||||||
},
|
},
|
||||||
replace: true,
|
replace: true,
|
||||||
@@ -463,7 +471,7 @@ function AgentsPage() {
|
|||||||
icon: <Plus aria-hidden />,
|
icon: <Plus aria-hidden />,
|
||||||
iconClassName: 'text-primary [&_svg]:text-current',
|
iconClassName: 'text-primary [&_svg]:text-current',
|
||||||
badgeLabel: 'Открыть',
|
badgeLabel: 'Открыть',
|
||||||
onSelect: () => setCreateOpen(true),
|
onSelect: () => setCreateOpenState(true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pending',
|
id: 'pending',
|
||||||
@@ -568,7 +576,7 @@ function AgentsPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const addButton = canWrite ? (
|
const addButton = canWrite ? (
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||||||
<Plus data-icon="inline-start" />
|
<Plus data-icon="inline-start" />
|
||||||
Добавить агента
|
Добавить агента
|
||||||
</Button>
|
</Button>
|
||||||
@@ -726,7 +734,13 @@ function AgentsPage() {
|
|||||||
</QueryState>
|
</QueryState>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
|
<AddAgentSheet
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCreateOpenState(open)
|
||||||
|
if (!open && addParam) setSearch({ add: false })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<AgentDetailSheet
|
<AgentDetailSheet
|
||||||
agentId={detailAgentId ?? null}
|
agentId={detailAgentId ?? null}
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ import {
|
|||||||
import { guessListSourceFromInput } from '@evofw/shared'
|
import { guessListSourceFromInput } from '@evofw/shared'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/lists/')({
|
export const Route = createFileRoute('/_auth/lists/')({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
/** Открыть sheet создания (из ⌘K-палитры). */
|
||||||
|
create: search.create === true || undefined,
|
||||||
|
}),
|
||||||
component: ListsPage,
|
component: ListsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -69,7 +73,9 @@ function ListsPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const canWrite = useCan()('fw:lists:write')
|
const canWrite = useCan()('fw:lists:write')
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const createParam = Route.useSearch().create === true
|
||||||
|
const [createOpenState, setCreateOpenState] = useState(false)
|
||||||
|
const createOpen = createOpenState || createParam
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [source, setSource] = useState<CreateSource>('static')
|
const [source, setSource] = useState<CreateSource>('static')
|
||||||
const [extra, setExtra] = useState('')
|
const [extra, setExtra] = useState('')
|
||||||
@@ -202,7 +208,7 @@ function ListsPage() {
|
|||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
{canWrite ? (
|
{canWrite ? (
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||||||
<Plus data-icon="inline-start" />
|
<Plus data-icon="inline-start" />
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</Button>
|
||||||
@@ -241,7 +247,7 @@ function ListsPage() {
|
|||||||
title: 'Нет списков',
|
title: 'Нет списков',
|
||||||
description: 'Создайте первый список для политики firewall.',
|
description: 'Создайте первый список для политики firewall.',
|
||||||
action: (
|
action: (
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||||||
<Plus data-icon="inline-start" />
|
<Plus data-icon="inline-start" />
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</Button>
|
||||||
@@ -262,7 +268,15 @@ function ListsPage() {
|
|||||||
disabled={removeList.isPending}
|
disabled={removeList.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
<Sheet
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCreateOpenState(open)
|
||||||
|
if (!open && createParam) {
|
||||||
|
void navigate({ to: '/lists', search: { create: undefined }, replace: true })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||||
<SheetHeader className="shrink-0">
|
<SheetHeader className="shrink-0">
|
||||||
<SheetTitle>Новый список</SheetTitle>
|
<SheetTitle>Новый список</SheetTitle>
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import {
|
|||||||
import type { PolicySet } from '@evofw/shared'
|
import type { PolicySet } from '@evofw/shared'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/rules/')({
|
export const Route = createFileRoute('/_auth/rules/')({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
/** Открыть sheet создания (из ⌘K-палитры). */
|
||||||
|
create: search.create === true || undefined,
|
||||||
|
}),
|
||||||
component: PolicySetsPage,
|
component: PolicySetsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -42,7 +46,9 @@ function PolicySetsPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const setsQ = useQuery(policySetsQueryOptions())
|
const setsQ = useQuery(policySetsQueryOptions())
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const createParam = Route.useSearch().create === true
|
||||||
|
const [sheetOpenState, setSheetOpenState] = useState(false)
|
||||||
|
const sheetOpen = sheetOpenState || createParam
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
@@ -63,7 +69,7 @@ function PolicySetsPage() {
|
|||||||
toast.success('Набор создан')
|
toast.success('Набор создан')
|
||||||
setName('')
|
setName('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
setSheetOpen(false)
|
setSheetOpenState(false)
|
||||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||||
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
|
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
|
||||||
},
|
},
|
||||||
@@ -203,7 +209,7 @@ function PolicySetsPage() {
|
|||||||
|
|
||||||
const canCreate = useCan()('fw:policies:write')
|
const canCreate = useCan()('fw:policies:write')
|
||||||
const addButton = canCreate ? (
|
const addButton = canCreate ? (
|
||||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
<Button size="sm" onClick={() => setSheetOpenState(true)}>
|
||||||
<Plus data-icon="inline-start" />
|
<Plus data-icon="inline-start" />
|
||||||
Новый набор
|
Новый набор
|
||||||
</Button>
|
</Button>
|
||||||
@@ -260,7 +266,15 @@ function PolicySetsPage() {
|
|||||||
disabled={remove.isPending}
|
disabled={remove.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
<Sheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setSheetOpenState(open)
|
||||||
|
if (!open && createParam) {
|
||||||
|
void navigate({ to: '/rules', search: { create: undefined }, replace: true })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||||
<SheetHeader className="shrink-0">
|
<SheetHeader className="shrink-0">
|
||||||
<SheetTitle>Новый набор</SheetTitle>
|
<SheetTitle>Новый набор</SheetTitle>
|
||||||
@@ -289,7 +303,7 @@ function PolicySetsPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||||
<Button variant="outline" onClick={() => setSheetOpen(false)}>
|
<Button variant="outline" onClick={() => setSheetOpenState(false)}>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.14",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { cn } from "@evofw/ui/lib/utils"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@evofw/ui/components/dialog"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
} from "@evofw/ui/components/input-group"
|
||||||
|
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||||
|
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className={cn("py-6 text-center text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user