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);
|
||||
|
||||
@@ -46,6 +46,7 @@ export function TableToolbar<T>({
|
||||
onClearSelection,
|
||||
}: TableToolbarProps<T>) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const toggleableColumns = columns.filter(col => col.hideable !== false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 border-b">
|
||||
@@ -91,7 +92,7 @@ export function TableToolbar<T>({
|
||||
Columns
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{columns.map(col => (
|
||||
{toggleableColumns.map(col => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.key}
|
||||
checked={!hiddenColumns.has(col.key)}
|
||||
|
||||
@@ -3,6 +3,13 @@ export type ColumnLayout = 'vertical' | 'horizontal';
|
||||
export type SortDirection = 'asc' | 'desc' | null;
|
||||
export type ExportFormat = 'csv' | 'excel' | 'pdf';
|
||||
export type ColumnAlign = 'left' | 'center' | 'right';
|
||||
export type RowActionKind =
|
||||
| 'edit'
|
||||
| 'activate'
|
||||
| 'deactivate'
|
||||
| 'confirm'
|
||||
| 'close'
|
||||
| 'cancel';
|
||||
|
||||
export interface BadgeConfig {
|
||||
colorMap?: Record<string, string>;
|
||||
@@ -49,6 +56,7 @@ export interface ColumnDef<T = object> {
|
||||
minWidth?: number;
|
||||
resizable?: boolean;
|
||||
visible?: boolean;
|
||||
hideable?: boolean;
|
||||
align?: ColumnAlign;
|
||||
group?: string;
|
||||
exportable?: boolean;
|
||||
@@ -75,6 +83,23 @@ export interface MobileCardConfig<T = object> {
|
||||
detailHref?: (row: T) => string;
|
||||
}
|
||||
|
||||
export interface RowActionItem<T = object> {
|
||||
action: RowActionKind;
|
||||
label: string;
|
||||
onClick: (row: T) => void;
|
||||
visible?: (row: T) => boolean;
|
||||
disabled?: (row: T) => boolean;
|
||||
tone?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
export interface RowActionsConfig<T = object> {
|
||||
title?: string;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
align?: ColumnAlign;
|
||||
actions: (row: T) => RowActionItem<T>[];
|
||||
}
|
||||
|
||||
export interface TableConfig<T = object> {
|
||||
columns: ColumnDef<T>[];
|
||||
rowKey?: keyof T | ((row: T) => string);
|
||||
@@ -93,6 +118,7 @@ export interface TableConfig<T = object> {
|
||||
isRowSelectable?: (row: T) => boolean;
|
||||
expandRender?: (row: T) => React.ReactNode;
|
||||
mobileCard?: MobileCardConfig<T>;
|
||||
rowActions?: RowActionsConfig<T>;
|
||||
}
|
||||
|
||||
export interface SortState {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
type FieldPath,
|
||||
type Control,
|
||||
type ControllerRenderProps,
|
||||
type FieldError,
|
||||
} from 'react-hook-form'
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError as FieldErrorDisplay,
|
||||
} from '@/components/ui/field'
|
||||
|
||||
interface FormFieldProps<T extends FieldValues, TName extends FieldPath<T> = FieldPath<T>> {
|
||||
control: Control<T>
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
render: (props: {
|
||||
field: ControllerRenderProps<T, TName>
|
||||
error?: FieldError
|
||||
invalid: boolean
|
||||
}) => React.ReactNode
|
||||
}
|
||||
|
||||
export function FormField<T extends FieldValues, TName extends FieldPath<T> = FieldPath<T>>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
render,
|
||||
}: FormFieldProps<T>) {
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid || undefined}>
|
||||
{label && (
|
||||
<FieldLabel htmlFor={String(name)}>
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</FieldLabel>
|
||||
)}
|
||||
{render({ field, error: fieldState.error, invalid: fieldState.invalid })}
|
||||
{description && !fieldState.error && (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
)}
|
||||
{fieldState.error && (
|
||||
<FieldErrorDisplay errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
Cancel01Icon,
|
||||
CheckmarkCircle02Icon,
|
||||
Delete02Icon,
|
||||
PencilEdit01Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ACTION_ICONS = {
|
||||
activate: CheckmarkCircle02Icon,
|
||||
cancel: Delete02Icon,
|
||||
close: Cancel01Icon,
|
||||
confirm: CheckmarkCircle02Icon,
|
||||
deactivate: Cancel01Icon,
|
||||
edit: PencilEdit01Icon,
|
||||
} as const
|
||||
|
||||
type RowActionKind = keyof typeof ACTION_ICONS
|
||||
|
||||
function RowActions({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RowActionButton({
|
||||
action,
|
||||
label,
|
||||
tone = "default",
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "children"> & {
|
||||
action: RowActionKind
|
||||
label: string
|
||||
tone?: "default" | "destructive"
|
||||
}) {
|
||||
const icon = ACTION_ICONS[action]
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
tone === "destructive"
|
||||
? "text-destructive hover:text-destructive"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={icon} size={16} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { RowActions, RowActionButton }
|
||||
Reference in New Issue
Block a user