feat(vps): визуальный редактор кастомных полей и колонки в таблице
Docker / build (push) Has been cancelled

Заменён JSON-редактор в настройках на UI с drag-reorder, добавлены динамические колонки VPS с picker видимости и типизированные контракты в @cfdm/shared.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-28 19:28:32 +07:00
co-authored by Cursor
parent 2e1ca9f2a7
commit 5a3241a7d1
16 changed files with 691 additions and 108 deletions
+80 -7
View File
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from 'react'
import { useState, useEffect, type ReactNode } from 'react'
import {
useReactTable,
getCoreRowModel,
@@ -8,10 +8,13 @@ import {
type ColumnDef,
type SortingState,
type RowSelectionState,
type VisibilityState,
} from '@tanstack/react-table'
import { Columns3Icon } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { Button } from '@cfdm/ui/components/button'
import { cn } from '@cfdm/ui/lib/utils'
import {
DataGrid,
@@ -22,6 +25,7 @@ import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-tabl
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { EmptyState } from './empty-state'
import type { DataTableColumn } from './data-grid-types'
@@ -38,6 +42,16 @@ function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
return ''
}
function loadStoredColumnVisibility(key: string): VisibilityState | undefined {
try {
const raw = localStorage.getItem(key)
if (!raw) return undefined
return JSON.parse(raw) as VisibilityState
} catch {
return undefined
}
}
export interface DataGridCardProps<TData extends object> {
title?: ReactNode
description?: ReactNode
@@ -70,6 +84,12 @@ export interface DataGridCardProps<TData extends object> {
enableRowSelection?: boolean
/** Callback при изменении выбора. */
onRowSelectionChange?: (selectedIds: string[]) => void
/** Показать picker видимости колонок. */
enableColumnVisibility?: boolean
/** Ключ localStorage для сохранения видимости колонок. */
columnVisibilityStorageKey?: string
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
initialColumnVisibility?: VisibilityState
className?: string
}
@@ -91,6 +111,7 @@ function DataGridCardBody<TData extends object>({
height,
footerContent,
showPagination,
enableColumnVisibility,
}: {
table: ReturnType<typeof useReactTable<TData>>
data: TData[]
@@ -101,6 +122,7 @@ function DataGridCardBody<TData extends object>({
height: number
footerContent?: ReactNode
showPagination: boolean
enableColumnVisibility: boolean
}) {
return (
<DataGridContainer border={false}>
@@ -117,7 +139,7 @@ function DataGridCardBody<TData extends object>({
headerBackground: true,
headerBorder: true,
width: 'auto',
columnsVisibility: false,
columnsVisibility: enableColumnVisibility,
columnsResizable: false,
columnsPinnable: false,
columnsMovable: false,
@@ -167,10 +189,24 @@ export function DataGridCard<TData extends object>({
height = 480,
enableRowSelection = false,
onRowSelectionChange,
enableColumnVisibility = false,
columnVisibilityStorageKey,
initialColumnVisibility,
className,
}: DataGridCardProps<TData>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => {
const stored = columnVisibilityStorageKey
? loadStoredColumnVisibility(columnVisibilityStorageKey)
: undefined
return { ...initialColumnVisibility, ...stored }
})
useEffect(() => {
if (!columnVisibilityStorageKey) return
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
}, [columnVisibility, columnVisibilityStorageKey])
const selectColumn: ColumnDef<TData, unknown> = {
id: 'select',
@@ -191,6 +227,7 @@ export function DataGridCard<TData extends object>({
/>
),
enableSorting: false,
enableHiding: false,
meta: { cellClassName: 'w-10' },
}
@@ -203,8 +240,13 @@ export function DataGridCard<TData extends object>({
const table = useReactTable<TData>({
data,
columns: tableColumns,
state: { sorting, ...(enableRowSelection ? { rowSelection } : {}) },
state: {
sorting,
columnVisibility,
...(enableRowSelection ? { rowSelection } : {}),
},
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: enableRowSelection
? (updater) => {
setRowSelection((prev) => {
@@ -229,9 +271,29 @@ export function DataGridCard<TData extends object>({
: undefined,
enableColumnPinning: pinLastColumn,
enableRowSelection,
enableHiding: enableColumnVisibility,
})
const hasHeader = Boolean(title || description || actions)
const columnVisibilityAction = enableColumnVisibility ? (
<DataGridColumnVisibility
table={table}
trigger={
<Button variant="outline" size="sm">
<Columns3Icon data-icon="inline-start" />
Колонки
</Button>
}
/>
) : null
const headerActions = (
<div className="flex items-center gap-2">
{columnVisibilityAction}
{actions}
</div>
)
const hasHeader = Boolean(title || description || actions || enableColumnVisibility)
if (data.length === 0) {
if (!hasHeader) {
@@ -249,7 +311,7 @@ export function DataGridCard<TData extends object>({
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
{headerActions ? <div className="flex items-center gap-2">{headerActions}</div> : null}
</CardHeader>
<CardContent>
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
@@ -269,11 +331,19 @@ export function DataGridCard<TData extends object>({
height={height}
footerContent={footerContent}
showPagination={showPagination}
enableColumnVisibility={enableColumnVisibility}
/>
)
if (!hasHeader) {
return <div className={className}>{gridBody}</div>
return (
<div className={className}>
{enableColumnVisibility ? (
<div className="mb-2 flex justify-end">{columnVisibilityAction}</div>
) : null}
{gridBody}
</div>
)
}
return (
@@ -283,7 +353,9 @@ export function DataGridCard<TData extends object>({
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
{(actions || enableColumnVisibility) ? (
<div className="flex items-center gap-2">{headerActions}</div>
) : null}
</CardHeader>
<CardContent className="p-0 pt-2">{gridBody}</CardContent>
</Card>
@@ -319,6 +391,7 @@ export function columnDefFromDataTable<T>(
: () => c.header,
cell: ({ row }) => c.cell(row.original, row.index),
enableSorting: sortable,
enableHiding: c.enableHiding ?? true,
meta: {
headerTitle: title || undefined,
cellClassName: c.className,
@@ -11,6 +11,7 @@ export interface DataTableColumn<T> {
headerTitle?: string
className?: string
headerClassName?: string
enableHiding?: boolean
}
/** Унифицированные классы колонок для DataGridCard. */
@@ -0,0 +1,111 @@
import { type UseFormSetValue, type UseFormWatch } from 'react-hook-form'
import { Input } from '@cfdm/ui/components/input'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { Label } from '@cfdm/ui/components/label'
import { Separator } from '@cfdm/ui/components/separator'
import { FormField } from '@/components/form-field'
import {
NumberField,
NumberFieldGroup,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/reui/number-field'
import type { CustomFieldDef } from '@/lib/custom-fields'
import type { VpsFormValues } from '@/lib/schemas'
interface CustomFieldValuesProps {
defs: CustomFieldDef[]
watch: UseFormWatch<VpsFormValues>
setValue: UseFormSetValue<VpsFormValues>
}
function setCustomFieldValue(
setValue: UseFormSetValue<VpsFormValues>,
watch: UseFormWatch<VpsFormValues>,
key: string,
value: string | number | boolean | undefined,
) {
const current = watch('customData') ?? {}
const next = { ...current }
if (value === undefined || value === '') {
delete next[key]
} else {
next[key] = value
}
setValue('customData', next, { shouldDirty: true })
}
export function CustomFieldValues({ defs, watch, setValue }: CustomFieldValuesProps) {
if (defs.length === 0) return null
const customData = watch('customData') ?? {}
return (
<>
<Separator />
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Дополнительные поля</p>
{defs.map((field) => {
if (field.type === 'bool') {
return (
<div key={field.key} className="flex items-center gap-2">
<Checkbox
id={`custom-${field.key}`}
checked={Boolean(customData[field.key])}
onCheckedChange={(v) => setCustomFieldValue(setValue, watch, field.key, Boolean(v))}
/>
<Label htmlFor={`custom-${field.key}`} className="font-normal">
{field.label}
</Label>
</div>
)
}
if (field.type === 'number') {
const raw = customData[field.key]
const numVal = typeof raw === 'number' && Number.isFinite(raw) ? raw : null
return (
<FormField key={field.key} label={field.label} htmlFor={`custom-${field.key}`}>
<NumberField
id={`custom-${field.key}`}
value={numVal}
onValueChange={(v) =>
setCustomFieldValue(
setValue,
watch,
field.key,
v == null || !Number.isFinite(v) ? undefined : v,
)
}
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput placeholder="0" />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</FormField>
)
}
return (
<FormField key={field.key} label={field.label} htmlFor={`custom-${field.key}`}>
<Input
id={`custom-${field.key}`}
value={String(customData[field.key] ?? '')}
onChange={(e) =>
setCustomFieldValue(
setValue,
watch,
field.key,
e.target.value || undefined,
)
}
/>
</FormField>
)
})}
</div>
</>
)
}
@@ -0,0 +1,253 @@
import { useRef, useState, type MutableRefObject } from 'react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVerticalIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import {
useFieldArray,
Controller,
type Control,
type FieldErrors,
type UseFormSetValue,
} from 'react-hook-form'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { FormField } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { EmptyState } from '@/components/empty-state'
import { slugifyCustomFieldKey } from '@/lib/custom-fields'
import type { SettingsFormValues } from '@/lib/schemas'
const FIELD_TYPES = [
{ value: 'text', label: 'Текст' },
{ value: 'number', label: 'Число' },
{ value: 'bool', label: 'Да/Нет' },
] as const
interface CustomFieldsEditorProps {
control: Control<SettingsFormValues>
setValue: UseFormSetValue<SettingsFormValues>
errors?: FieldErrors<SettingsFormValues>['customFields']
}
function SortableFieldRow({
id,
index,
control,
setValue,
errors,
onRemove,
manualKeysRef,
}: {
id: string
index: number
control: Control<SettingsFormValues>
setValue: UseFormSetValue<SettingsFormValues>
errors?: FieldErrors<SettingsFormValues>['customFields']
onRemove: () => void
manualKeysRef: MutableRefObject<Set<number>>
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
})
const rowErrors = errors?.[index]
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
className="flex flex-col gap-3 rounded-lg border border-border p-3 sm:flex-row sm:items-start"
>
<button
type="button"
className="mt-2 flex size-8 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground active:cursor-grabbing"
aria-label="Перетащить"
{...attributes}
{...listeners}
>
<GripVerticalIcon className="size-4" />
</button>
<div className="grid flex-1 gap-3 sm:grid-cols-3">
<Controller
control={control}
name={`customFields.${index}.label`}
render={({ field }) => (
<FormField
label="Название"
htmlFor={`custom-field-label-${index}`}
error={rowErrors?.label?.message}
>
<Input
id={`custom-field-label-${index}`}
placeholder="Панель управления"
value={field.value}
onChange={field.onChange}
onBlur={(e) => {
field.onBlur()
if (!manualKeysRef.current.has(index)) {
setValue(`customFields.${index}.key`, slugifyCustomFieldKey(e.target.value), {
shouldDirty: true,
})
}
}}
/>
</FormField>
)}
/>
<Controller
control={control}
name={`customFields.${index}.key`}
render={({ field }) => (
<FormField
label="Ключ"
htmlFor={`custom-field-key-${index}`}
error={rowErrors?.key?.message}
description="panel_url"
>
<Input
id={`custom-field-key-${index}`}
className="font-mono text-xs"
placeholder="panel_url"
value={field.value}
onChange={(e) => {
manualKeysRef.current.add(index)
field.onChange(e)
}}
onBlur={field.onBlur}
/>
</FormField>
)}
/>
<Controller
control={control}
name={`customFields.${index}.type`}
render={({ field }) => (
<FormField
label="Тип"
htmlFor={`custom-field-type-${index}`}
error={rowErrors?.type?.message}
>
<SelectField
triggerId={`custom-field-type-${index}`}
value={field.value ?? 'text'}
onValueChange={(v) => field.onChange(v ?? 'text')}
options={[...FIELD_TYPES]}
/>
</FormField>
)}
/>
</div>
<ConfirmDialog
trigger={
<Button
type="button"
variant="ghost"
size="icon"
className="mt-1 shrink-0 text-muted-foreground hover:text-destructive"
aria-label="Удалить поле"
>
<Trash2Icon className="size-4" />
</Button>
}
title="Удалить поле?"
description="Значения этого поля в VPS сохранятся в данных, но перестанут отображаться."
confirmLabel="Удалить"
destructive
onConfirm={onRemove}
/>
</div>
)
}
export function CustomFieldsEditor({ control, setValue, errors }: CustomFieldsEditorProps) {
const { fields, append, remove, move } = useFieldArray({
control,
name: 'customFields',
})
const manualKeysRef = useRef<Set<number>>(new Set())
const [rowIds, setRowIds] = useState<string[]>([])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const ids = fields.map((f, i) => rowIds[i] ?? f.id)
const oldIndex = ids.indexOf(String(active.id))
const newIndex = ids.indexOf(String(over.id))
if (oldIndex < 0 || newIndex < 0) return
move(oldIndex, newIndex)
setRowIds((prev) => arrayMove(prev.length ? prev : ids, oldIndex, newIndex))
}
const handleAdd = () => {
const n = fields.length + 1
append({ key: `field_${n}`, label: '', type: 'text' })
}
if (fields.length === 0) {
return (
<EmptyState
title="Кастомные поля не заданы"
description="Добавьте поля для отображения в таблице VPS и в форме редактирования сервера"
action={
<Button type="button" variant="outline" onClick={handleAdd}>
<PlusIcon data-icon="inline-start" />
Добавить поле
</Button>
}
/>
)
}
const ids = fields.map((f, i) => rowIds[i] ?? f.id)
return (
<div className="flex flex-col gap-3">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{fields.map((field, index) => (
<SortableFieldRow
key={field.id}
id={ids[index] ?? field.id}
index={index}
control={control}
setValue={setValue}
errors={errors}
onRemove={() => remove(index)}
manualKeysRef={manualKeysRef}
/>
))}
</SortableContext>
</DndContext>
<Button type="button" variant="outline" className="w-fit" onClick={handleAdd}>
<PlusIcon data-icon="inline-start" />
Добавить поле
</Button>
</div>
)
}
@@ -19,6 +19,7 @@ import { vpsSchema, type VpsFormValues } from '@/lib/schemas'
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
import { buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo'
import { VPS_SYNC_OVERRIDE_FIELDS, parseUserOverrides } from '@/lib/vps-sync-fields'
import { CustomFieldValues } from '@/components/domain/custom-field-values'
import { parseCustomData, type CustomFieldDef } from '@/lib/custom-fields'
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
import type { ZodType } from 'zod'
@@ -336,44 +337,7 @@ export function VpsEditSheet({
<FormField label="Заметки" htmlFor="vps-notes">
<Textarea id="vps-notes" {...register('notes')} />
</FormField>
{customFieldDefs.length > 0 ? (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Дополнительные поля</p>
{customFieldDefs.map((field) => {
const customData = watch('customData') ?? {}
if (field.type === 'bool') {
return (
<div key={field.key} className="flex items-center gap-2">
<Checkbox
id={`custom-${field.key}`}
checked={Boolean(customData[field.key])}
onCheckedChange={(v) =>
setValue('customData', { ...customData, [field.key]: Boolean(v) })
}
/>
<Label htmlFor={`custom-${field.key}`} className="font-normal">
{field.label}
</Label>
</div>
)
}
return (
<FormField key={field.key} label={field.label} htmlFor={`custom-${field.key}`}>
<Input
id={`custom-${field.key}`}
type={field.type === 'number' ? 'number' : 'text'}
value={String(customData[field.key] ?? '')}
onChange={(e) => {
const val =
field.type === 'number' ? Number(e.target.value) : e.target.value
setValue('customData', { ...customData, [field.key]: val })
}}
/>
</FormField>
)
})}
</div>
) : null}
<CustomFieldValues defs={customFieldDefs} watch={watch} setValue={setValue} />
{editingId ? (
<FormField
label="Не перезаписывать при синке"
@@ -24,7 +24,7 @@ function DataGridColumnVisibility<TData>({
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
Колонки
</DropdownMenuLabel>
{table
.getAllColumns()