feat(web): upgrade lists to ReUI Table v9 and EventCalendar
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 39s
quality / web (push) Successful in 1m29s
quality / go (push) Successful in 1m3s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 3m43s
CD / publish (push) Failing after 8m29s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 39s
quality / web (push) Successful in 1m29s
quality / go (push) Successful in 1m3s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 3m43s
CD / publish (push) Failing after 8m29s
Единый kitDataGridTableLayout и ResourcePage/FrameDataGrid на всех списках. Календарь задач переведён на EventCalendar, lookup — на cascader, у операций появился вид доски. Удалены settings-7 и самописные DataGridSection/toolbar. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,52 +1,461 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
useTable,
|
||||
type ColumnDef,
|
||||
type ColumnVisibilityState,
|
||||
type ExpandedState,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
import { Columns3Icon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
DataGrid,
|
||||
dataGridFeatures,
|
||||
type DataGridFeatures,
|
||||
type DataGridTableInstance,
|
||||
} from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import {
|
||||
DataGridTable,
|
||||
DataGridTableRowExpand,
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
|
||||
export type DataGridColumnDef<TData extends object> = ColumnDef<DataGridFeatures, TData>
|
||||
|
||||
/**
|
||||
* Frame shell for list/grid sections (replaces DataGridCard Card-named API).
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
* Единый tableLayout для всех ops-гридов.
|
||||
* Visual SoT: установленный data-grid-filtering-2 (`dense: true`, без zebra).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* Docs: https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
export function FrameDataGrid({
|
||||
export function kitDataGridTableLayout(
|
||||
opts: {
|
||||
dense?: boolean
|
||||
width?: 'fixed' | 'auto'
|
||||
columnsPinnable?: boolean
|
||||
columnsVisibility?: boolean
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
dense: opts.dense ?? true,
|
||||
rowBorder: true,
|
||||
headerSticky: false,
|
||||
headerBackground: false,
|
||||
headerBorder: true,
|
||||
width: opts.width ?? ('fixed' as const),
|
||||
columnsVisibility: opts.columnsVisibility ?? false,
|
||||
columnsResizable: false,
|
||||
columnsPinnable: opts.columnsPinnable ?? false,
|
||||
columnsMovable: false,
|
||||
rowsDraggable: false,
|
||||
rowsPinnable: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** filtering-2 edge inset — единственный tableClassNames в kit, не в domain. */
|
||||
export const kitDataGridTableClassNames = {
|
||||
edgeCell: 'first:ps-3 last:pe-3',
|
||||
} as const
|
||||
|
||||
function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return undefined
|
||||
return JSON.parse(raw) as ColumnVisibilityState
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export { loadStoredColumnVisibility }
|
||||
|
||||
function applyColumnPinControls<T extends object>(
|
||||
columns: DataGridColumnDef<T>[],
|
||||
columnPinControls: boolean,
|
||||
): DataGridColumnDef<T>[] {
|
||||
return columns.map((col) => {
|
||||
const origHeader = col.header
|
||||
if (typeof origHeader !== 'function') return col
|
||||
return {
|
||||
...col,
|
||||
header: (ctx) => {
|
||||
const node = origHeader(ctx)
|
||||
if (isValidElement(node) && node.type === DataGridColumnHeader) {
|
||||
return cloneElement(node as ReactElement<{ pinnable?: boolean }>, {
|
||||
pinnable: columnPinControls,
|
||||
})
|
||||
}
|
||||
return node
|
||||
},
|
||||
} as DataGridColumnDef<T>
|
||||
})
|
||||
}
|
||||
|
||||
export interface FrameDataGridProps<TData extends object> {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
columns: DataGridColumnDef<TData>[]
|
||||
data: TData[]
|
||||
rowId?: (row: TData, index: number) => string
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
onRowClick?: (row: TData) => void
|
||||
pagination?: boolean
|
||||
pageSize?: number
|
||||
footerContent?: ReactNode
|
||||
dense?: boolean
|
||||
pinLastColumn?: boolean
|
||||
initialSorting?: SortingState
|
||||
virtualization?: boolean
|
||||
height?: number
|
||||
enableRowSelection?: boolean
|
||||
onRowSelectionChange?: (selectedIds: string[]) => void
|
||||
enableColumnVisibility?: boolean
|
||||
columnVisibility?: ColumnVisibilityState
|
||||
onColumnVisibilityChange?: OnChangeFn<ColumnVisibilityState>
|
||||
columnVisibilityTrigger?: boolean
|
||||
columnVisibilityStorageKey?: string
|
||||
initialColumnVisibility?: ColumnVisibilityState
|
||||
className?: string
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
getRowCanExpand?: (row: TData) => boolean
|
||||
pinLeftColumnIds?: string[]
|
||||
horizontalScroll?: boolean
|
||||
tableWidth?: 'fixed' | 'auto'
|
||||
columnPinControls?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function DataGridSectionHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
if (!title && !description && !actions) return null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
<FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? (
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
{title ? <FrameTitle className="text-balance">{title}</FrameTitle> : null}
|
||||
{description ? (
|
||||
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
|
||||
) : null}
|
||||
<div className="p-0">{children}</div>
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameDataGridBody<TData extends object>({
|
||||
table,
|
||||
data,
|
||||
emptyTitle,
|
||||
onRowClick,
|
||||
dense,
|
||||
virtualization,
|
||||
height,
|
||||
footerContent,
|
||||
showPagination,
|
||||
enableColumnVisibility,
|
||||
columnsPinnable,
|
||||
tableWidth,
|
||||
}: {
|
||||
table: DataGridTableInstance<TData>
|
||||
data: TData[]
|
||||
emptyTitle: string
|
||||
onRowClick?: (row: TData) => void
|
||||
dense: boolean
|
||||
virtualization: boolean
|
||||
height: number
|
||||
footerContent?: ReactNode
|
||||
showPagination: boolean
|
||||
enableColumnVisibility: boolean
|
||||
columnsPinnable: boolean
|
||||
tableWidth: 'fixed' | 'auto'
|
||||
}) {
|
||||
const tableNode = virtualization ? (
|
||||
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
||||
) : (
|
||||
<DataGridTable footerContent={footerContent} />
|
||||
)
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
onRowClick={onRowClick}
|
||||
emptyMessage={emptyTitle}
|
||||
tableLayout={kitDataGridTableLayout({
|
||||
dense,
|
||||
width: tableWidth,
|
||||
columnsPinnable,
|
||||
columnsVisibility: enableColumnVisibility,
|
||||
})}
|
||||
tableClassNames={kitDataGridTableClassNames}
|
||||
>
|
||||
{virtualization ? (
|
||||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||
{tableNode}
|
||||
</DataGridScrollArea>
|
||||
) : (
|
||||
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
|
||||
)}
|
||||
{showPagination ? (
|
||||
<>
|
||||
<Separator />
|
||||
<FrameFooter>
|
||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
||||
</FrameFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
export function FrameDataGrid<TData extends object>({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
columns,
|
||||
data,
|
||||
rowId,
|
||||
emptyTitle = 'Нет записей',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
onRowClick,
|
||||
pagination,
|
||||
pageSize = 10,
|
||||
footerContent,
|
||||
dense = true,
|
||||
pinLastColumn = false,
|
||||
initialSorting,
|
||||
virtualization = false,
|
||||
height = 480,
|
||||
enableRowSelection = false,
|
||||
onRowSelectionChange,
|
||||
enableColumnVisibility = false,
|
||||
columnVisibility: columnVisibilityProp,
|
||||
onColumnVisibilityChange,
|
||||
columnVisibilityTrigger,
|
||||
columnVisibilityStorageKey,
|
||||
initialColumnVisibility,
|
||||
className,
|
||||
expandedContent,
|
||||
getRowCanExpand,
|
||||
pinLeftColumnIds,
|
||||
tableWidth = 'fixed',
|
||||
columnPinControls = false,
|
||||
}: FrameDataGridProps<TData>) {
|
||||
const showPagination = pagination ?? true
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||
const [paginationState, setPaginationState] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY,
|
||||
})
|
||||
const [internalColumnVisibility, setInternalColumnVisibility] = useState<ColumnVisibilityState>(
|
||||
() => {
|
||||
const stored = columnVisibilityStorageKey
|
||||
? loadStoredColumnVisibility(columnVisibilityStorageKey)
|
||||
: undefined
|
||||
return { ...initialColumnVisibility, ...stored }
|
||||
},
|
||||
)
|
||||
|
||||
const isColumnVisibilityControlled = columnVisibilityProp !== undefined
|
||||
const columnVisibility = isColumnVisibilityControlled
|
||||
? columnVisibilityProp
|
||||
: internalColumnVisibility
|
||||
const setColumnVisibility: OnChangeFn<ColumnVisibilityState> = isColumnVisibilityControlled
|
||||
? (onColumnVisibilityChange ?? (() => undefined))
|
||||
: setInternalColumnVisibility
|
||||
|
||||
useEffect(() => {
|
||||
setPaginationState((current) => ({
|
||||
pageIndex: showPagination ? current.pageIndex : 0,
|
||||
pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY,
|
||||
}))
|
||||
}, [pageSize, showPagination])
|
||||
|
||||
useEffect(() => {
|
||||
if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return
|
||||
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
|
||||
}, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled])
|
||||
|
||||
const selectColumn: DataGridColumnDef<TData> = {
|
||||
id: 'select',
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { cellClassName: 'w-10' },
|
||||
}
|
||||
|
||||
const expandColumn: DataGridColumnDef<TData> = {
|
||||
id: 'expand',
|
||||
header: () => null,
|
||||
cell: ({ row }) => <DataGridTableRowExpand row={row} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: {
|
||||
cellClassName: 'w-10',
|
||||
expandedContent,
|
||||
},
|
||||
}
|
||||
|
||||
const tableColumns: DataGridColumnDef<TData>[] = applyColumnPinControls(
|
||||
[
|
||||
...(expandedContent ? [expandColumn] : []),
|
||||
...(enableRowSelection ? [selectColumn] : []),
|
||||
...columns,
|
||||
],
|
||||
columnPinControls,
|
||||
)
|
||||
|
||||
const lastColId = pinLastColumn ? (tableColumns[tableColumns.length - 1]?.id ?? '') : ''
|
||||
const pinLeft = pinLeftColumnIds ?? []
|
||||
const enablePinning = pinLastColumn || pinLeft.length > 0
|
||||
const columnPinning = {
|
||||
start: pinLeft,
|
||||
end: pinLastColumn && lastColId ? [lastColId] : [],
|
||||
}
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns: tableColumns,
|
||||
state: {
|
||||
sorting,
|
||||
pagination: paginationState,
|
||||
columnVisibility,
|
||||
expanded,
|
||||
...(enablePinning ? { columnPinning } : {}),
|
||||
...(enableRowSelection ? { rowSelection } : {}),
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPaginationState,
|
||||
onExpandedChange: setExpanded,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: enableRowSelection
|
||||
? (updater) => {
|
||||
setRowSelection((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater
|
||||
if (onRowSelectionChange && rowId) {
|
||||
const ids = Object.keys(next).filter((k) => next[k])
|
||||
onRowSelectionChange(ids)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
: undefined,
|
||||
initialState: enablePinning ? { columnPinning } : undefined,
|
||||
getRowId: rowId ? (row, index) => rowId(row, index) : undefined,
|
||||
getRowCanExpand: expandedContent
|
||||
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
|
||||
: undefined,
|
||||
enableRowSelection,
|
||||
enableHiding: enableColumnVisibility,
|
||||
})
|
||||
|
||||
const showColumnVisibilityTrigger =
|
||||
enableColumnVisibility && (columnVisibilityTrigger ?? true)
|
||||
|
||||
const columnVisibilityAction = showColumnVisibilityTrigger ? (
|
||||
<DataGridColumnVisibility
|
||||
table={table}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Columns3Icon data-icon="inline-start" />
|
||||
Колонки
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
|
||||
const headerActions = actions ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{columnVisibilityAction}
|
||||
{actions}
|
||||
</div>
|
||||
) : (
|
||||
columnVisibilityAction
|
||||
)
|
||||
|
||||
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
|
||||
{hasHeader ? (
|
||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||
) : null}
|
||||
<FramePanel className="flex min-h-72 w-full flex-col items-center justify-center">
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
|
||||
{hasHeader ? (
|
||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||
) : null}
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<FrameDataGridBody
|
||||
table={table}
|
||||
data={data}
|
||||
emptyTitle={emptyTitle}
|
||||
onRowClick={onRowClick}
|
||||
dense={dense}
|
||||
virtualization={virtualization}
|
||||
height={height}
|
||||
footerContent={footerContent}
|
||||
showPagination={showPagination}
|
||||
enableColumnVisibility={enableColumnVisibility}
|
||||
columnsPinnable={enablePinning}
|
||||
tableWidth={tableWidth}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user