fix(web): унифицировать таблицу VPS и перенести колонки в popover «Вид»
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -9,10 +9,10 @@ import {
|
|||||||
type SortingState,
|
type SortingState,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
|
type OnChangeFn,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
import { Columns3Icon } from 'lucide-react'
|
import { Columns3Icon } from 'lucide-react'
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
|
||||||
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
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> {
|
export interface DataGridCardProps<TData extends object> {
|
||||||
title?: ReactNode
|
title?: ReactNode
|
||||||
description?: ReactNode
|
description?: ReactNode
|
||||||
@@ -86,6 +104,11 @@ export interface DataGridCardProps<TData extends object> {
|
|||||||
onRowSelectionChange?: (selectedIds: string[]) => void
|
onRowSelectionChange?: (selectedIds: string[]) => void
|
||||||
/** Показать picker видимости колонок. */
|
/** Показать picker видимости колонок. */
|
||||||
enableColumnVisibility?: boolean
|
enableColumnVisibility?: boolean
|
||||||
|
/** Управляемая видимость колонок (для внешнего UI, напр. тулбар «Вид»). */
|
||||||
|
columnVisibility?: VisibilityState
|
||||||
|
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
|
||||||
|
/** Показать встроенную кнопку «Колонки». По умолчанию true при enableColumnVisibility. */
|
||||||
|
columnVisibilityTrigger?: boolean
|
||||||
/** Ключ localStorage для сохранения видимости колонок. */
|
/** Ключ localStorage для сохранения видимости колонок. */
|
||||||
columnVisibilityStorageKey?: string
|
columnVisibilityStorageKey?: string
|
||||||
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
|
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
|
||||||
@@ -93,6 +116,28 @@ export interface DataGridCardProps<TData extends object> {
|
|||||||
className?: string
|
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() {
|
function DataGridPaginationBar() {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-2.5">
|
<div className="px-4 py-2.5">
|
||||||
@@ -190,23 +235,32 @@ export function DataGridCard<TData extends object>({
|
|||||||
enableRowSelection = false,
|
enableRowSelection = false,
|
||||||
onRowSelectionChange,
|
onRowSelectionChange,
|
||||||
enableColumnVisibility = false,
|
enableColumnVisibility = false,
|
||||||
|
columnVisibility: columnVisibilityProp,
|
||||||
|
onColumnVisibilityChange,
|
||||||
|
columnVisibilityTrigger,
|
||||||
columnVisibilityStorageKey,
|
columnVisibilityStorageKey,
|
||||||
initialColumnVisibility,
|
initialColumnVisibility,
|
||||||
className,
|
className,
|
||||||
}: DataGridCardProps<TData>) {
|
}: DataGridCardProps<TData>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => {
|
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() => {
|
||||||
const stored = columnVisibilityStorageKey
|
const stored = columnVisibilityStorageKey
|
||||||
? loadStoredColumnVisibility(columnVisibilityStorageKey)
|
? loadStoredColumnVisibility(columnVisibilityStorageKey)
|
||||||
: undefined
|
: undefined
|
||||||
return { ...initialColumnVisibility, ...stored }
|
return { ...initialColumnVisibility, ...stored }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isColumnVisibilityControlled = columnVisibilityProp !== undefined
|
||||||
|
const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility
|
||||||
|
const setColumnVisibility: OnChangeFn<VisibilityState> = isColumnVisibilityControlled
|
||||||
|
? (onColumnVisibilityChange ?? (() => undefined))
|
||||||
|
: setInternalColumnVisibility
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!columnVisibilityStorageKey) return
|
if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return
|
||||||
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
|
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
|
||||||
}, [columnVisibility, columnVisibilityStorageKey])
|
}, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled])
|
||||||
|
|
||||||
const selectColumn: ColumnDef<TData, unknown> = {
|
const selectColumn: ColumnDef<TData, unknown> = {
|
||||||
id: 'select',
|
id: 'select',
|
||||||
@@ -274,11 +328,14 @@ export function DataGridCard<TData extends object>({
|
|||||||
enableHiding: enableColumnVisibility,
|
enableHiding: enableColumnVisibility,
|
||||||
})
|
})
|
||||||
|
|
||||||
const columnVisibilityAction = enableColumnVisibility ? (
|
const showColumnVisibilityTrigger =
|
||||||
|
enableColumnVisibility && (columnVisibilityTrigger ?? true)
|
||||||
|
|
||||||
|
const columnVisibilityAction = showColumnVisibilityTrigger ? (
|
||||||
<DataGridColumnVisibility
|
<DataGridColumnVisibility
|
||||||
table={table}
|
table={table}
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
<Columns3Icon data-icon="inline-start" />
|
<Columns3Icon data-icon="inline-start" />
|
||||||
Колонки
|
Колонки
|
||||||
</Button>
|
</Button>
|
||||||
@@ -286,37 +343,25 @@ export function DataGridCard<TData extends object>({
|
|||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
const headerActions = (
|
const headerActions = actions ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{columnVisibilityAction}
|
{columnVisibilityAction}
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
columnVisibilityAction
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasHeader = Boolean(title || description || actions || enableColumnVisibility)
|
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
if (!hasHeader) {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn('ring-0 shadow-none', className)}>
|
<div className={cn('flex flex-col gap-3', className)}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
{hasHeader ? (
|
||||||
<div className="flex flex-col gap-1">
|
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
) : null}
|
||||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||||
</div>
|
</div>
|
||||||
{headerActions ? <div className="flex items-center gap-2">{headerActions}</div> : null}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 (
|
return (
|
||||||
<Card className={cn('ring-0 shadow-none', className)}>
|
<div className={cn('flex flex-col gap-3', className)}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-border/50 pb-4">
|
{hasHeader ? (
|
||||||
<div className="flex flex-col gap-1">
|
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
) : null}
|
||||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
{gridBody}
|
||||||
</div>
|
</div>
|
||||||
{(actions || enableColumnVisibility) ? (
|
|
||||||
<div className="flex items-center gap-2">{headerActions}</div>
|
|
||||||
) : null}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0 pt-2">{gridBody}</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { Slider } from '@cfdm/ui/components/slider'
|
|||||||
import { PlusIcon } from 'lucide-react'
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar'
|
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 {
|
import {
|
||||||
Filters,
|
Filters,
|
||||||
@@ -47,6 +49,9 @@ interface VpsFiltersToolbarProps {
|
|||||||
projectNameOptions: string[]
|
projectNameOptions: string[]
|
||||||
shownCount: number
|
shownCount: number
|
||||||
totalCount: number
|
totalCount: number
|
||||||
|
columnVisibilityOptions?: DataGridColumnVisibilityOption[]
|
||||||
|
columnVisibility?: VisibilityState
|
||||||
|
onColumnVisibilityChange?: (columnId: string, visible: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const RU_I18N: FilterI18nConfig = {
|
const RU_I18N: FilterI18nConfig = {
|
||||||
@@ -171,6 +176,9 @@ export function VpsFiltersToolbar({
|
|||||||
projectNameOptions,
|
projectNameOptions,
|
||||||
shownCount,
|
shownCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
|
columnVisibilityOptions,
|
||||||
|
columnVisibility,
|
||||||
|
onColumnVisibilityChange,
|
||||||
}: VpsFiltersToolbarProps) {
|
}: VpsFiltersToolbarProps) {
|
||||||
const [presets, setPresets] = useState<VpsFilterPreset[]>(() => loadFilterPresets())
|
const [presets, setPresets] = useState<VpsFilterPreset[]>(() => loadFilterPresets())
|
||||||
|
|
||||||
@@ -519,6 +527,24 @@ export function VpsFiltersToolbar({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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 />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { PageShell } from '@/components/page-shell'
|
|||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
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 type { DataTableColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
|
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
|
||||||
import { CountryFlag } from '@/components/country-flag'
|
import { CountryFlag } from '@/components/country-flag'
|
||||||
@@ -274,6 +275,30 @@ function VpsPage() {
|
|||||||
[customFieldDefs],
|
[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 columns: DataTableColumn<Vps>[] = useMemo(() => {
|
||||||
const base: DataTableColumn<Vps>[] = [
|
const base: DataTableColumn<Vps>[] = [
|
||||||
{
|
{
|
||||||
@@ -437,6 +462,11 @@ function VpsPage() {
|
|||||||
deleteMutation,
|
deleteMutation,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const columnVisibilityOptions = useMemo(
|
||||||
|
() => dataTableColumnVisibilityOptions(columns),
|
||||||
|
[columns],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -485,6 +515,9 @@ function VpsPage() {
|
|||||||
cityOptions={filterCityOptions}
|
cityOptions={filterCityOptions}
|
||||||
shownCount={filteredVps.length}
|
shownCount={filteredVps.length}
|
||||||
totalCount={snap.vps.length}
|
totalCount={snap.vps.length}
|
||||||
|
columnVisibilityOptions={columnVisibilityOptions}
|
||||||
|
columnVisibility={columnVisibility}
|
||||||
|
onColumnVisibilityChange={handleColumnVisibilityChange}
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Ничего не найдено"
|
title="Ничего не найдено"
|
||||||
@@ -521,6 +554,9 @@ function VpsPage() {
|
|||||||
cityOptions={filterCityOptions}
|
cityOptions={filterCityOptions}
|
||||||
shownCount={filteredVps.length}
|
shownCount={filteredVps.length}
|
||||||
totalCount={snap.vps.length}
|
totalCount={snap.vps.length}
|
||||||
|
columnVisibilityOptions={columnVisibilityOptions}
|
||||||
|
columnVisibility={columnVisibility}
|
||||||
|
onColumnVisibilityChange={handleColumnVisibilityChange}
|
||||||
/>
|
/>
|
||||||
{tableSections.map((section) => (
|
{tableSections.map((section) => (
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
@@ -537,8 +573,9 @@ function VpsPage() {
|
|||||||
virtualization={section.items.length > 200}
|
virtualization={section.items.length > 200}
|
||||||
height={560}
|
height={560}
|
||||||
enableColumnVisibility
|
enableColumnVisibility
|
||||||
columnVisibilityStorageKey="vps-column-visibility"
|
columnVisibility={columnVisibility}
|
||||||
initialColumnVisibility={customColumnVisibility}
|
onColumnVisibilityChange={setColumnVisibility}
|
||||||
|
columnVisibilityTrigger={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user