fix(web): унифицировать таблицу VPS и перенести колонки в popover «Вид»
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user