From 32d9dc6acb1c599a2c65395afae1be4b1ad224f4 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 12 Aug 2026 12:42:01 +0700 Subject: [PATCH] feat(directories): edit communities and DoH via row actions menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В data-grid справочников добавлено меню «⋯» с пунктом «Редактировать» и FormDrawer на PATCH /v1/communities/{id} и /v1/doh-profiles/{id}. Co-authored-by: Cursor --- .../directories/community-create-dialog.tsx | 55 +++++++++++---- .../directories-communities-grid.tsx | 29 ++++++-- .../directories/directories-doh-grid.tsx | 29 ++++++-- .../directories/directories-row-actions.tsx | 36 ++++++++++ .../directories/doh-profile-create-dialog.tsx | 68 ++++++++++++++----- apps/web/src/queries/directories.ts | 30 ++++++++ apps/web/src/routes/_auth/directories.tsx | 57 ++++++++++++++-- 7 files changed, 258 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/components/directories/directories-row-actions.tsx diff --git a/apps/web/src/components/directories/community-create-dialog.tsx b/apps/web/src/components/directories/community-create-dialog.tsx index ac55f56..d19fd82 100644 --- a/apps/web/src/components/directories/community-create-dialog.tsx +++ b/apps/web/src/components/directories/community-create-dialog.tsx @@ -7,24 +7,40 @@ import { Label } from '@evobgp/ui/components/label' import { FormDrawer } from '@/components/form-drawer' import { LoadingButton } from '@/components/loading-button' -import { useCreateCommunityMutation } from '@/queries/directories' -import type { BgpCommunityCreate } from '@/types/api' +import { + useCreateCommunityMutation, + useUpdateCommunityMutation, +} from '@/queries/directories' +import type { BgpCommunity, BgpCommunityCreate, BgpCommunityPatch } from '@/types/api' -interface CommunityCreateDialogProps { +interface CommunityFormDialogProps { open: boolean onOpenChange: (open: boolean) => void + editTarget?: BgpCommunity | null } -export function CommunityCreateDialog({ open, onOpenChange }: CommunityCreateDialogProps) { +/** @see https://reui.io/preview/base/form-7 */ +export function CommunityFormDialog({ + open, + onOpenChange, + editTarget = null, +}: CommunityFormDialogProps) { const createMutation = useCreateCommunityMutation() + const updateMutation = useUpdateCommunityMutation() + const saving = createMutation.isPending || updateMutation.isPending const [community, setCommunity] = useState('') const [title, setTitle] = useState('') useEffect(() => { if (!open) return - setCommunity('') - setTitle('') - }, [open]) + if (editTarget) { + setCommunity(editTarget.community ?? '') + setTitle(editTarget.title ?? '') + } else { + setCommunity('') + setTitle('') + } + }, [editTarget, open]) async function save() { const value = community.trim() @@ -32,11 +48,19 @@ export function CommunityCreateDialog({ open, onOpenChange }: CommunityCreateDia toast.error('Укажите community') return } - const body: BgpCommunityCreate = { community: value } - const t = title.trim() - if (t) body.title = t + const titleTrimmed = title.trim() try { - await createMutation.mutateAsync(body) + if (editTarget) { + const body: BgpCommunityPatch = { + community: value, + title: titleTrimmed || '', + } + await updateMutation.mutateAsync({ id: editTarget.id, body }) + } else { + const body: BgpCommunityCreate = { community: value } + if (titleTrimmed) body.title = titleTrimmed + await createMutation.mutateAsync(body) + } onOpenChange(false) } catch { // toast in mutation @@ -47,7 +71,7 @@ export function CommunityCreateDialog({ open, onOpenChange }: CommunityCreateDia onOpenChange(false)}> Отмена - void save()}> - Создать + void save()}> + {editTarget ? 'Сохранить' : 'Создать'} } @@ -82,3 +106,6 @@ export function CommunityCreateDialog({ open, onOpenChange }: CommunityCreateDia ) } + +/** @deprecated Use CommunityFormDialog */ +export const CommunityCreateDialog = CommunityFormDialog diff --git a/apps/web/src/components/directories/directories-communities-grid.tsx b/apps/web/src/components/directories/directories-communities-grid.tsx index d9f3dc8..7cc0c71 100644 --- a/apps/web/src/components/directories/directories-communities-grid.tsx +++ b/apps/web/src/components/directories/directories-communities-grid.tsx @@ -4,6 +4,7 @@ import { useMemo } from 'react' import { CategoryBadge } from '@/components/category-badge' import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridSection } from '@/components/data-grid-shell' +import { DirectoriesRowActions } from '@/components/directories/directories-row-actions' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { useClientDataGrid } from '@/hooks/use-client-data-grid' import type { BgpCommunity } from '@/types/api' @@ -11,12 +12,16 @@ import type { BgpCommunity } from '@/types/api' export function DirectoriesCommunitiesGrid({ items, isLoading = false, + canWrite = false, + onEdit, }: { items: BgpCommunity[] isLoading?: boolean + canWrite?: boolean + onEdit?: (row: BgpCommunity) => void }) { - const columns = useMemo[]>( - () => [ + const columns = useMemo[]>(() => { + const cols: ColumnDef[] = [ { accessorKey: 'title', header: ({ column }) => , @@ -38,9 +43,23 @@ export function DirectoriesCommunitiesGrid({ cell: () => community, meta: { headerTitle: 'Тип' }, }, - ], - [], - ) + ] + + if (canWrite && onEdit) { + cols.push({ + id: 'actions', + enableSorting: false, + enableHiding: false, + header: () => Действия, + cell: ({ row }) => ( + onEdit(row.original)} /> + ), + meta: { headerTitle: 'Действия' }, + }) + } + + return cols + }, [canWrite, onEdit]) const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ data: items, diff --git a/apps/web/src/components/directories/directories-doh-grid.tsx b/apps/web/src/components/directories/directories-doh-grid.tsx index e049dd2..6591808 100644 --- a/apps/web/src/components/directories/directories-doh-grid.tsx +++ b/apps/web/src/components/directories/directories-doh-grid.tsx @@ -4,6 +4,7 @@ import { useMemo } from 'react' import { CategoryBadge } from '@/components/category-badge' import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridSection } from '@/components/data-grid-shell' +import { DirectoriesRowActions } from '@/components/directories/directories-row-actions' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { useClientDataGrid } from '@/hooks/use-client-data-grid' import type { DohProfile } from '@/types/api' @@ -11,12 +12,16 @@ import type { DohProfile } from '@/types/api' export function DirectoriesDohGrid({ items, isLoading = false, + canWrite = false, + onEdit, }: { items: DohProfile[] isLoading?: boolean + canWrite?: boolean + onEdit?: (row: DohProfile) => void }) { - const columns = useMemo[]>( - () => [ + const columns = useMemo[]>(() => { + const cols: ColumnDef[] = [ { id: 'name', accessorFn: (row) => row.name ?? row.url, @@ -43,9 +48,23 @@ export function DirectoriesDohGrid({ cell: () => , meta: { headerTitle: 'По умолчанию' }, }, - ], - [], - ) + ] + + if (canWrite && onEdit) { + cols.push({ + id: 'actions', + enableSorting: false, + enableHiding: false, + header: () => Действия, + cell: ({ row }) => ( + onEdit(row.original)} /> + ), + meta: { headerTitle: 'Действия' }, + }) + } + + return cols + }, [canWrite, onEdit]) const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ data: items, diff --git a/apps/web/src/components/directories/directories-row-actions.tsx b/apps/web/src/components/directories/directories-row-actions.tsx new file mode 100644 index 0000000..e93261b --- /dev/null +++ b/apps/web/src/components/directories/directories-row-actions.tsx @@ -0,0 +1,36 @@ +import { MoreHorizontalIcon, PencilIcon } from 'lucide-react' + +import { Button } from '@evobgp/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@evobgp/ui/components/dropdown-menu' + +/** + * Data-grid row actions — ⋯ menu (ReUI / shadcn DropdownMenu). + * @see https://reui.io/preview/base/components/c-dropdown-menu-12 + * @see https://reui.io/docs/components/base/dropdown-menu + */ +export function DirectoriesRowActions({ onEdit }: { onEdit: () => void }) { + return ( +
+ + + + + } + /> + + + + Редактировать + + + +
+ ) +} diff --git a/apps/web/src/components/directories/doh-profile-create-dialog.tsx b/apps/web/src/components/directories/doh-profile-create-dialog.tsx index 68699b3..c14bda5 100644 --- a/apps/web/src/components/directories/doh-profile-create-dialog.tsx +++ b/apps/web/src/components/directories/doh-profile-create-dialog.tsx @@ -7,26 +7,47 @@ import { Label } from '@evobgp/ui/components/label' import { FormDrawer } from '@/components/form-drawer' import { LoadingButton } from '@/components/loading-button' -import { useCreateDohProfileMutation } from '@/queries/directories' -import type { DohProfileCreate } from '@/types/api' +import { + useCreateDohProfileMutation, + useUpdateDohProfileMutation, +} from '@/queries/directories' +import type { DohProfile, DohProfileCreate, DohProfilePatch } from '@/types/api' -interface DohProfileCreateDialogProps { +interface DohProfileFormDialogProps { open: boolean onOpenChange: (open: boolean) => void + editTarget?: DohProfile | null } -export function DohProfileCreateDialog({ open, onOpenChange }: DohProfileCreateDialogProps) { +/** @see https://reui.io/preview/base/form-7 */ +export function DohProfileFormDialog({ + open, + onOpenChange, + editTarget = null, +}: DohProfileFormDialogProps) { const createMutation = useCreateDohProfileMutation() + const updateMutation = useUpdateDohProfileMutation() + const saving = createMutation.isPending || updateMutation.isPending const [name, setName] = useState('') const [url, setUrl] = useState('') const [timeoutMs, setTimeoutMs] = useState('') useEffect(() => { if (!open) return - setName('') - setUrl('') - setTimeoutMs('') - }, [open]) + if (editTarget) { + setName(editTarget.name ?? '') + setUrl(editTarget.url ?? '') + setTimeoutMs( + editTarget.timeout_ms === null || editTarget.timeout_ms === undefined + ? '' + : String(editTarget.timeout_ms), + ) + } else { + setName('') + setUrl('') + setTimeoutMs('') + } + }, [editTarget, open]) async function save() { const trimmedUrl = url.trim() @@ -45,20 +66,32 @@ export function DohProfileCreateDialog({ open, onOpenChange }: DohProfileCreateD return } - const body: DohProfileCreate = { url: trimmedUrl } - const n = name.trim() - if (n) body.name = n + let timeout: number | null = null if (timeoutMs.trim() !== '') { const ms = Number(timeoutMs) if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) { toast.error('Timeout должен быть целым числом > 0') return } - body.timeout_ms = ms + timeout = ms } + const nameTrimmed = name.trim() + try { - await createMutation.mutateAsync(body) + if (editTarget) { + const body: DohProfilePatch = { + url: trimmedUrl, + name: nameTrimmed || undefined, + timeout_ms: timeout, + } + await updateMutation.mutateAsync({ id: editTarget.id, body }) + } else { + const body: DohProfileCreate = { url: trimmedUrl } + if (nameTrimmed) body.name = nameTrimmed + if (timeout !== null) body.timeout_ms = timeout + await createMutation.mutateAsync(body) + } onOpenChange(false) } catch { // toast in mutation @@ -69,7 +102,7 @@ export function DohProfileCreateDialog({ open, onOpenChange }: DohProfileCreateD onOpenChange(false)}> Отмена - void save()}> - Создать + void save()}> + {editTarget ? 'Сохранить' : 'Создать'} } @@ -116,3 +149,6 @@ export function DohProfileCreateDialog({ open, onOpenChange }: DohProfileCreateD ) } + +/** @deprecated Use DohProfileFormDialog */ +export const DohProfileCreateDialog = DohProfileFormDialog diff --git a/apps/web/src/queries/directories.ts b/apps/web/src/queries/directories.ts index aa57574..7547bf8 100644 --- a/apps/web/src/queries/directories.ts +++ b/apps/web/src/queries/directories.ts @@ -5,9 +5,11 @@ import { apiJSON, apiMutate } from '@/lib/api-client' import type { BgpCommunity, BgpCommunityCreate, + BgpCommunityPatch, CommunitiesResponse, DohProfile, DohProfileCreate, + DohProfilePatch, DohProfilesResponse, } from '@/types/api' @@ -47,6 +49,20 @@ export function useCreateCommunityMutation() { }) } +export function useUpdateCommunityMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) => + apiMutate(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }), + onSuccess: () => { + toast.success('Сообщество обновлено') + void qc.invalidateQueries({ queryKey: directoriesKeys.communities() }) + }, + onError: (e) => + toast.error(e instanceof Error ? e.message : 'Не удалось обновить сообщество'), + }) +} + export function useCreateDohProfileMutation() { const qc = useQueryClient() return useMutation({ @@ -60,3 +76,17 @@ export function useCreateDohProfileMutation() { toast.error(e instanceof Error ? e.message : 'Не удалось создать DoH-профиль'), }) } + +export function useUpdateDohProfileMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: DohProfilePatch }) => + apiMutate(`/v1/doh-profiles/${id}`, 'PATCH', body, { idempotent: false }), + onSuccess: () => { + toast.success('DoH-профиль обновлён') + void qc.invalidateQueries({ queryKey: directoriesKeys.doh() }) + }, + onError: (e) => + toast.error(e instanceof Error ? e.message : 'Не удалось обновить DoH-профиль'), + }) +} diff --git a/apps/web/src/routes/_auth/directories.tsx b/apps/web/src/routes/_auth/directories.tsx index 66646d2..7299b80 100644 --- a/apps/web/src/routes/_auth/directories.tsx +++ b/apps/web/src/routes/_auth/directories.tsx @@ -5,10 +5,14 @@ import { useState } from 'react' import { Button } from '@evobgp/ui/components/button' import { BadgeTabs, TabsContent } from '@/components/badge-tabs' -import { CommunityCreateDialog } from '@/components/directories/community-create-dialog' +import { + CommunityFormDialog, +} from '@/components/directories/community-create-dialog' import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid' import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid' -import { DohProfileCreateDialog } from '@/components/directories/doh-profile-create-dialog' +import { + DohProfileFormDialog, +} from '@/components/directories/doh-profile-create-dialog' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { Badge } from '@/components/reui/badge' @@ -17,6 +21,7 @@ import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons' import { sessionCanWriteDirectories } from '@/lib/auth' import { authSessionQueryOptions } from '@/queries/auth' import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories' +import type { BgpCommunity, DohProfile } from '@/types/api' export const Route = createFileRoute('/_auth/directories')({ component: DirectoriesComponent, @@ -24,7 +29,9 @@ export const Route = createFileRoute('/_auth/directories')({ function DirectoriesComponent() { const [communityOpen, setCommunityOpen] = useState(false) + const [communityEdit, setCommunityEdit] = useState(null) const [dohOpen, setDohOpen] = useState(false) + const [dohEdit, setDohEdit] = useState(null) const sessionQ = useQuery(authSessionQueryOptions()) const canWrite = sessionCanWriteDirectories(sessionQ.data) @@ -69,15 +76,35 @@ function DirectoriesComponent() { }, ] + function openCreateCommunity() { + setCommunityEdit(null) + setCommunityOpen(true) + } + + function openEditCommunity(row: BgpCommunity) { + setCommunityEdit(row) + setCommunityOpen(true) + } + + function openCreateDoh() { + setDohEdit(null) + setDohOpen(true) + } + + function openEditDoh(row: DohProfile) { + setDohEdit(row) + setDohOpen(true) + } + const addCommunityButton = canWrite ? ( - ) : null const addDohButton = canWrite ? ( - @@ -134,6 +161,8 @@ function DirectoriesComponent() { )} @@ -161,6 +190,8 @@ function DirectoriesComponent() { )} @@ -170,8 +201,22 @@ function DirectoriesComponent() { {canWrite ? ( <> - - + { + setCommunityOpen(open) + if (!open) setCommunityEdit(null) + }} + editTarget={communityEdit} + /> + { + setDohOpen(open) + if (!open) setDohEdit(null) + }} + editTarget={dohEdit} + /> ) : null}