fix(web): унифицировать таблицу VPS и перенести колонки в popover «Вид»
Docker / build (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-28 22:07:29 +07:00
co-authored by Cursor
parent 52a8b0db85
commit 246ad1738a
3 changed files with 145 additions and 54 deletions
+79 -51
View File
@@ -9,10 +9,10 @@ import {
type SortingState,
type RowSelectionState,
type VisibilityState,
type OnChangeFn,
} 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'
@@ -52,6 +52,24 @@ function loadStoredColumnVisibility(key: string): VisibilityState | undefined {
}
}
export { loadStoredColumnVisibility }
export interface DataGridColumnVisibilityOption {
id: string
label: string
}
export function dataTableColumnVisibilityOptions<T>(
cols: DataTableColumn<T>[],
): DataGridColumnVisibilityOption[] {
return cols
.filter((c) => c.enableHiding !== false)
.map((c) => ({
id: c.key,
label: c.headerTitle ?? (typeof c.header === 'string' ? c.header : c.key),
}))
}
export interface DataGridCardProps<TData extends object> {
title?: ReactNode
description?: ReactNode
@@ -86,6 +104,11 @@ export interface DataGridCardProps<TData extends object> {
onRowSelectionChange?: (selectedIds: string[]) => void
/** Показать picker видимости колонок. */
enableColumnVisibility?: boolean
/** Управляемая видимость колонок (для внешнего UI, напр. тулбар «Вид»). */
columnVisibility?: VisibilityState
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
/** Показать встроенную кнопку «Колонки». По умолчанию true при enableColumnVisibility. */
columnVisibilityTrigger?: boolean
/** Ключ localStorage для сохранения видимости колонок. */
columnVisibilityStorageKey?: string
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
@@ -93,6 +116,28 @@ export interface DataGridCardProps<TData extends object> {
className?: string
}
function DataGridSectionHeader({
title,
description,
actions,
}: {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
}) {
if (!title && !description && !actions) return null
return (
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col gap-0.5">
{title ? <h3 className="text-sm font-medium">{title}</h3> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
)
}
function DataGridPaginationBar() {
return (
<div className="px-4 py-2.5">
@@ -190,23 +235,32 @@ export function DataGridCard<TData extends object>({
enableRowSelection = false,
onRowSelectionChange,
enableColumnVisibility = false,
columnVisibility: columnVisibilityProp,
onColumnVisibilityChange,
columnVisibilityTrigger,
columnVisibilityStorageKey,
initialColumnVisibility,
className,
}: DataGridCardProps<TData>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => {
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() => {
const stored = columnVisibilityStorageKey
? loadStoredColumnVisibility(columnVisibilityStorageKey)
: undefined
return { ...initialColumnVisibility, ...stored }
})
const isColumnVisibilityControlled = columnVisibilityProp !== undefined
const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility
const setColumnVisibility: OnChangeFn<VisibilityState> = isColumnVisibilityControlled
? (onColumnVisibilityChange ?? (() => undefined))
: setInternalColumnVisibility
useEffect(() => {
if (!columnVisibilityStorageKey) return
if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
}, [columnVisibility, columnVisibilityStorageKey])
}, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled])
const selectColumn: ColumnDef<TData, unknown> = {
id: 'select',
@@ -274,11 +328,14 @@ export function DataGridCard<TData extends object>({
enableHiding: enableColumnVisibility,
})
const columnVisibilityAction = enableColumnVisibility ? (
const showColumnVisibilityTrigger =
enableColumnVisibility && (columnVisibilityTrigger ?? true)
const columnVisibilityAction = showColumnVisibilityTrigger ? (
<DataGridColumnVisibility
table={table}
trigger={
<Button variant="outline" size="sm">
<Button variant="ghost" size="sm">
<Columns3Icon data-icon="inline-start" />
Колонки
</Button>
@@ -286,37 +343,25 @@ export function DataGridCard<TData extends object>({
/>
) : null
const headerActions = (
const headerActions = actions ? (
<div className="flex items-center gap-2">
{columnVisibilityAction}
{actions}
</div>
) : (
columnVisibilityAction
)
const hasHeader = Boolean(title || description || actions || enableColumnVisibility)
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
if (data.length === 0) {
if (!hasHeader) {
return (
<div className={className}>
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</div>
)
}
return (
<Card className={cn('ring-0 shadow-none', className)}>
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div className="flex flex-col gap-1">
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{headerActions ? <div className="flex items-center gap-2">{headerActions}</div> : null}
</CardHeader>
<CardContent>
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</CardContent>
</Card>
<div className={cn('flex flex-col gap-3', className)}>
{hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
) : null}
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</div>
)
}
@@ -335,30 +380,13 @@ export function DataGridCard<TData extends object>({
/>
)
if (!hasHeader) {
return (
<div className={className}>
{enableColumnVisibility ? (
<div className="mb-2 flex justify-end">{columnVisibilityAction}</div>
) : null}
{gridBody}
</div>
)
}
return (
<Card className={cn('ring-0 shadow-none', className)}>
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-border/50 pb-4">
<div className="flex flex-col gap-1">
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{(actions || enableColumnVisibility) ? (
<div className="flex items-center gap-2">{headerActions}</div>
) : null}
</CardHeader>
<CardContent className="p-0 pt-2">{gridBody}</CardContent>
</Card>
<div className={cn('flex flex-col gap-3', className)}>
{hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
) : null}
{gridBody}
</div>
)
}
@@ -14,6 +14,8 @@ import { Slider } from '@cfdm/ui/components/slider'
import { PlusIcon } from 'lucide-react'
import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar'
import type { DataGridColumnVisibilityOption } from '@/components/data-grid-card'
import type { VisibilityState } from '@tanstack/react-table'
import {
Filters,
@@ -47,6 +49,9 @@ interface VpsFiltersToolbarProps {
projectNameOptions: string[]
shownCount: number
totalCount: number
columnVisibilityOptions?: DataGridColumnVisibilityOption[]
columnVisibility?: VisibilityState
onColumnVisibilityChange?: (columnId: string, visible: boolean) => void
}
const RU_I18N: FilterI18nConfig = {
@@ -171,6 +176,9 @@ export function VpsFiltersToolbar({
projectNameOptions,
shownCount,
totalCount,
columnVisibilityOptions,
columnVisibility,
onColumnVisibilityChange,
}: VpsFiltersToolbarProps) {
const [presets, setPresets] = useState<VpsFilterPreset[]>(() => loadFilterPresets())
@@ -519,6 +527,24 @@ export function VpsFiltersToolbar({
</label>
</div>
{columnVisibilityOptions && columnVisibilityOptions.length > 0 && onColumnVisibilityChange ? (
<>
<Separator />
<div className="flex max-h-48 flex-col gap-2 overflow-y-auto">
<Label className="text-xs text-muted-foreground">Колонки</Label>
{columnVisibilityOptions.map((col) => (
<label key={col.id} className="flex items-center gap-2">
<Checkbox
checked={columnVisibility?.[col.id] !== false}
onCheckedChange={(v) => onColumnVisibilityChange(col.id, Boolean(v))}
/>
<span className="text-sm">{col.label}</span>
</label>
))}
</div>
</>
) : null}
<Separator />
<div className="flex flex-col gap-2">
+40 -3
View File
@@ -11,7 +11,8 @@ import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import { DataGridCard, columnDefFromDataTable, loadStoredColumnVisibility, dataTableColumnVisibilityOptions } from '@/components/data-grid-card'
import type { VisibilityState } from '@tanstack/react-table'
import type { DataTableColumn } from '@/components/data-grid-types'
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
import { CountryFlag } from '@/components/country-flag'
@@ -274,6 +275,30 @@ function VpsPage() {
[customFieldDefs],
)
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => ({
...(loadStoredColumnVisibility('vps-column-visibility') ?? {}),
}))
useEffect(() => {
setColumnVisibility((prev) => ({ ...customColumnVisibility, ...prev }))
}, [customColumnVisibility])
useEffect(() => {
localStorage.setItem('vps-column-visibility', JSON.stringify(columnVisibility))
}, [columnVisibility])
const handleColumnVisibilityChange = (columnId: string, visible: boolean) => {
setColumnVisibility((prev) => {
const next = { ...prev }
if (visible) {
delete next[columnId]
} else {
next[columnId] = false
}
return next
})
}
const columns: DataTableColumn<Vps>[] = useMemo(() => {
const base: DataTableColumn<Vps>[] = [
{
@@ -437,6 +462,11 @@ function VpsPage() {
deleteMutation,
])
const columnVisibilityOptions = useMemo(
() => dataTableColumnVisibilityOptions(columns),
[columns],
)
return (
<PageShell>
<PageHeader
@@ -485,6 +515,9 @@ function VpsPage() {
cityOptions={filterCityOptions}
shownCount={filteredVps.length}
totalCount={snap.vps.length}
columnVisibilityOptions={columnVisibilityOptions}
columnVisibility={columnVisibility}
onColumnVisibilityChange={handleColumnVisibilityChange}
/>
<EmptyState
title="Ничего не найдено"
@@ -521,6 +554,9 @@ function VpsPage() {
cityOptions={filterCityOptions}
shownCount={filteredVps.length}
totalCount={snap.vps.length}
columnVisibilityOptions={columnVisibilityOptions}
columnVisibility={columnVisibility}
onColumnVisibilityChange={handleColumnVisibilityChange}
/>
{tableSections.map((section) => (
<DataGridCard
@@ -537,8 +573,9 @@ function VpsPage() {
virtualization={section.items.length > 200}
height={560}
enableColumnVisibility
columnVisibilityStorageKey="vps-column-visibility"
initialColumnVisibility={customColumnVisibility}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnVisibilityTrigger={false}
/>
))}
</div>