frontend fully integreated with the backend
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RowActionButton, RowActions } from '@/components/shared/row-actions';
|
||||
|
||||
import { TableToolbar } from './table-toolbar';
|
||||
import { TablePagination } from './table-pagination';
|
||||
@@ -27,7 +28,7 @@ import { exportTable } from './hooks/use-table-export';
|
||||
import { getNestedValue, resolveUrlTemplate } from './utils/nested-value';
|
||||
import type {
|
||||
TableConfig, ColumnDef, FieldConfig, SortState, FilterState, PageState,
|
||||
ServerSideParams, ExportFormat,
|
||||
ServerSideParams, ExportFormat, RowActionItem,
|
||||
} from './types';
|
||||
|
||||
interface DataTableProps<T extends object> {
|
||||
@@ -48,6 +49,29 @@ function normalizeField(f: FieldConfig | string): FieldConfig {
|
||||
return typeof f === 'string' ? { key: f } : f;
|
||||
}
|
||||
|
||||
function formatTextValue(raw: unknown): string {
|
||||
if (raw == null) return '';
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' || typeof raw === 'bigint') return String(raw);
|
||||
if (typeof raw === 'boolean') return raw ? 'Yes' : 'No';
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
if (raw.length === 0) return '';
|
||||
if (raw.every(item => item == null || ['string', 'number', 'bigint', 'boolean'].includes(typeof item))) {
|
||||
return raw.map(item => formatTextValue(item)).filter(Boolean).join(', ');
|
||||
}
|
||||
return `${raw.length} item${raw.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
if (typeof raw === 'object') {
|
||||
const record = raw as Record<string, unknown>;
|
||||
const namedValue = record.name ?? record.title ?? record.label ?? record.id;
|
||||
return namedValue == null ? '' : formatTextValue(namedValue);
|
||||
}
|
||||
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState }) {
|
||||
if (sortState.column !== colKey) {
|
||||
return (
|
||||
@@ -62,6 +86,36 @@ function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState
|
||||
: <HugeiconsIcon icon={ArrowDown01Icon} className="size-3" />;
|
||||
}
|
||||
|
||||
function DataTableRowActions<T extends object>({
|
||||
row,
|
||||
actions,
|
||||
}: {
|
||||
row: T;
|
||||
actions: RowActionItem<T>[];
|
||||
}) {
|
||||
const visibleActions = actions.filter(action => action.visible ? action.visible(row) : true);
|
||||
|
||||
if (visibleActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<RowActions>
|
||||
{visibleActions.map((action) => (
|
||||
<RowActionButton
|
||||
key={`${action.action}-${action.label}`}
|
||||
action={action.action}
|
||||
label={action.label}
|
||||
tone={action.tone}
|
||||
disabled={action.disabled?.(row)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick(row);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCard<T extends object>({
|
||||
row,
|
||||
columns,
|
||||
@@ -73,7 +127,7 @@ function MobileCard<T extends object>({
|
||||
mobileCardCfg?: TableConfig<T>['mobileCard'];
|
||||
onClick?: (row: T) => void;
|
||||
}) {
|
||||
const actionCol = columns.find(c => !c.title && c.cellRender);
|
||||
const actionCol = columns.find(c => c.key === '__row_actions' || (!c.title && c.cellRender));
|
||||
const dataColumns = columns.filter(c => !!c.title);
|
||||
|
||||
const primaryCol = dataColumns[0];
|
||||
@@ -153,11 +207,34 @@ function CellContent<T extends object>({
|
||||
const fields = col.fields.map(normalizeField);
|
||||
const layout = col.layout ?? 'vertical';
|
||||
|
||||
if (fields.length === 1 && (fields[0].renderer ?? 'text') === 'text') {
|
||||
const field = fields[0];
|
||||
const raw = getNestedValue(row, field.key);
|
||||
const text = field.transform
|
||||
? field.transform(raw, row)
|
||||
: `${field.prefix ?? ''}${formatTextValue(raw)}${field.suffix ?? ''}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-full',
|
||||
field.truncate ? 'truncate' : 'whitespace-normal break-words',
|
||||
field.primary ? 'font-medium text-foreground' : 'text-muted-foreground text-xs',
|
||||
'text-sm',
|
||||
field.class,
|
||||
)}
|
||||
style={{ color: field.primary ? 'var(--foreground)' : 'var(--muted-foreground)' }}
|
||||
>
|
||||
{text || '—'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rendered = fields.map((field, i) => {
|
||||
const raw = getNestedValue(row, field.key);
|
||||
const text = field.transform
|
||||
? field.transform(raw, row)
|
||||
: `${field.prefix ?? ''}${raw ?? ''}${field.suffix ?? ''}`;
|
||||
: `${field.prefix ?? ''}${formatTextValue(raw)}${field.suffix ?? ''}`;
|
||||
|
||||
const renderer = field.renderer ?? 'text';
|
||||
|
||||
@@ -190,17 +267,19 @@ function CellContent<T extends object>({
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
<div
|
||||
key={field.key}
|
||||
className={cn(
|
||||
'truncate block',
|
||||
field.primary ? 'font-medium' : 'text-muted-foreground text-xs',
|
||||
'max-w-full',
|
||||
field.truncate ? 'truncate' : 'whitespace-normal break-words',
|
||||
field.primary ? 'font-medium text-foreground' : 'text-muted-foreground text-xs',
|
||||
field.class,
|
||||
i === 0 && 'text-sm',
|
||||
)}
|
||||
style={{ color: field.primary || i === 0 ? 'var(--foreground)' : 'var(--muted-foreground)' }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -232,6 +311,7 @@ export function DataTable<T extends object>({
|
||||
onRowClick,
|
||||
onServerSideChange,
|
||||
}: DataTableProps<T>) {
|
||||
const safeData = useMemo(() => (Array.isArray(data) ? data : []), [data]);
|
||||
const [sortState, setSortState] = useState<SortState>({ column: null, direction: null });
|
||||
const [filterState, setFilterState] = useState<FilterState>({ global: '', columns: {} });
|
||||
const [pageState, setPageState] = useState<PageState>({
|
||||
@@ -257,11 +337,51 @@ export function DataTable<T extends object>({
|
||||
return String(row[config.rowKey as keyof T]);
|
||||
}
|
||||
|
||||
const normalizedColumns = useMemo(() =>
|
||||
config.columns.map(col => ({
|
||||
const normalizedColumns = useMemo(() => {
|
||||
const baseColumns = config.columns.map(col => ({
|
||||
...col,
|
||||
fields: col.fields.map(normalizeField) as FieldConfig[],
|
||||
})), [config.columns]);
|
||||
}));
|
||||
|
||||
if (!config.rowActions) {
|
||||
return baseColumns;
|
||||
}
|
||||
|
||||
const actionsColumn: ColumnDef<T> = {
|
||||
key: '__row_actions',
|
||||
title: config.rowActions.title ?? 'Action',
|
||||
fields: [],
|
||||
width: config.rowActions.width ?? 96,
|
||||
minWidth: config.rowActions.minWidth ?? 96,
|
||||
resizable: false,
|
||||
hideable: false,
|
||||
align: config.rowActions.align ?? 'right',
|
||||
exportable: false,
|
||||
cellRender: (row: T) => (
|
||||
<DataTableRowActions row={row} actions={config.rowActions!.actions(row)} />
|
||||
),
|
||||
};
|
||||
|
||||
return [
|
||||
...baseColumns,
|
||||
actionsColumn,
|
||||
];
|
||||
}, [config.columns, config.rowActions]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnOrder((prev) => {
|
||||
const nextKeys = normalizedColumns.map((col) => col.key);
|
||||
const kept = prev.filter((key) => nextKeys.includes(key));
|
||||
const missing = nextKeys.filter((key) => !kept.includes(key));
|
||||
const next = [...kept, ...missing];
|
||||
|
||||
if (next.length === prev.length && next.every((key, index) => key === prev[index])) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [normalizedColumns]);
|
||||
|
||||
const orderedColumns = useMemo(() =>
|
||||
columnOrder
|
||||
@@ -271,23 +391,23 @@ export function DataTable<T extends object>({
|
||||
);
|
||||
|
||||
const visibleColumns = useMemo(() =>
|
||||
orderedColumns.filter(c => !hiddenColumns.has(c.key)),
|
||||
orderedColumns.filter(c => c.hideable === false || !hiddenColumns.has(c.key)),
|
||||
[orderedColumns, hiddenColumns]
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() =>
|
||||
config.serverSide ? data : filterData(data, filterState, normalizedColumns as ColumnDef<T>[]),
|
||||
[data, filterState, normalizedColumns, config.serverSide]
|
||||
config.serverSide ? safeData : filterData(safeData, filterState, normalizedColumns as ColumnDef<T>[]),
|
||||
[safeData, filterState, normalizedColumns, config.serverSide]
|
||||
);
|
||||
|
||||
const sortedData = useMemo(() =>
|
||||
config.serverSide ? data : sortData(filteredData, sortState, normalizedColumns as ColumnDef<T>[]),
|
||||
config.serverSide ? safeData : sortData(filteredData, sortState, normalizedColumns as ColumnDef<T>[]),
|
||||
[filteredData, sortState, normalizedColumns, config.serverSide]
|
||||
);
|
||||
|
||||
const totalRecords = useMemo(() =>
|
||||
config.serverSide ? (totalCount ?? data.length) : filteredData.length,
|
||||
[config.serverSide, totalCount, data.length, filteredData.length]
|
||||
config.serverSide ? (totalCount ?? safeData.length) : filteredData.length,
|
||||
[config.serverSide, totalCount, safeData.length, filteredData.length]
|
||||
);
|
||||
|
||||
const rowHeightPx = useMemo(() => {
|
||||
@@ -353,7 +473,7 @@ export function DataTable<T extends object>({
|
||||
const next = new Set(selectedKeys);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
setSelectedKeys(next);
|
||||
onSelectionChange?.(data.filter(r => next.has(rowKey(r))));
|
||||
onSelectionChange?.(safeData.filter(r => next.has(rowKey(r))));
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
@@ -364,7 +484,7 @@ export function DataTable<T extends object>({
|
||||
selectablePageData.forEach(r => next.add(rowKey(r)));
|
||||
}
|
||||
setSelectedKeys(next);
|
||||
onSelectionChange?.(data.filter(r => next.has(rowKey(r))));
|
||||
onSelectionChange?.(safeData.filter(r => next.has(rowKey(r))));
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
@@ -382,6 +502,9 @@ export function DataTable<T extends object>({
|
||||
}
|
||||
|
||||
function toggleColumnVisibility(key: string) {
|
||||
const column = orderedColumns.find(col => col.key === key);
|
||||
if (!column || column.hideable === false) return;
|
||||
|
||||
setHiddenColumns(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
|
||||
Reference in New Issue
Block a user