UMBRA/frontend/src/components/shared/EntityTable.tsx
Kyle Pope 1806e15487 Address all QA review warnings and suggestions for entity pages
Warnings fixed:
- 3.1: _compute_display_name stale-data bug on all-names-clear
- 3.3: Location getValue unsafe type cast replaced with typed helper
- 3.5: Explicit updated_at timestamp refresh in locations router
- 3.6: Drop deprecated relationship column (migration 021, model, schema, TS type)

Suggestions fixed:
- 4.1: CategoryAutocomplete keyboard navigation (ArrowUp/Down, Enter, Escape)
- 4.2: Mobile detail panel backdrop click-to-close on both pages
- 4.3: PersonCreate whitespace bypass in require_some_name validator
- 4.5/4.6: Extract SortIcon, DataRow, SectionHeader from EntityTable render body
- 4.8: PersonForm sends null instead of empty string for birthday
- 4.10: Remove unnecessary executeDelete wrapper in EntityDetailPanel

Also includes previously completed fixes from prior session:
- 2.1: Remove Z suffix from naive timestamp in formatUpdatedAt
- 3.2: Drag-then-click conflict prevention in SortableCategoryChip
- 3.4: localStorage JSON shape validation in useCategoryOrder
- 4.4: Category chip styling consistency (both pages use inline hsl styles)
- 4.9: restrictToHorizontalAxis modifier on CategoryFilterBar drag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 01:04:20 +08:00

206 lines
5.7 KiB
TypeScript

import React from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { VisibilityMode } from '@/hooks/useTableVisibility';
export interface ColumnDef<T> {
key: string;
label: string;
render: (item: T) => React.ReactNode;
sortable?: boolean;
visibilityLevel: VisibilityMode;
}
export interface RowGroup<T> {
label: string;
rows: T[];
}
interface EntityTableProps<T extends { id: number }> {
columns: ColumnDef<T>[];
groups: RowGroup<T>[];
pinnedRows: T[];
pinnedLabel: string;
showPinned: boolean;
selectedId: number | null;
onRowClick: (id: number) => void;
sortKey: string;
sortDir: 'asc' | 'desc';
onSort: (key: string) => void;
visibilityMode: VisibilityMode;
loading?: boolean;
}
const LEVEL_ORDER: VisibilityMode[] = ['essential', 'filtered', 'all'];
function isVisible(colLevel: VisibilityMode, mode: VisibilityMode): boolean {
return LEVEL_ORDER.indexOf(colLevel) <= LEVEL_ORDER.indexOf(mode);
}
function SkeletonRow({ colCount }: { colCount: number }) {
return (
<tr className="border-b border-border/50">
{Array.from({ length: colCount }).map((_, i) => (
<td key={i} className="px-3 py-2.5">
<div className="animate-pulse rounded-md bg-muted h-4 w-full" />
</td>
))}
</tr>
);
}
function SortIcon({
sortKey,
sortDir,
colKey,
}: {
sortKey: string;
sortDir: 'asc' | 'desc';
colKey: string;
}) {
if (sortKey !== colKey) return <ArrowUpDown className="h-3.5 w-3.5 ml-1 opacity-40" />;
return sortDir === 'asc' ? (
<ArrowUp className="h-3.5 w-3.5 ml-1" />
) : (
<ArrowDown className="h-3.5 w-3.5 ml-1" />
);
}
function DataRow<T extends { id: number }>({
item,
visibleColumns,
selectedId,
onRowClick,
}: {
item: T;
visibleColumns: ColumnDef<T>[];
selectedId: number | null;
onRowClick: (id: number) => void;
}) {
return (
<tr
className={`border-b border-border/50 cursor-pointer hover:bg-card-elevated transition-colors duration-150 outline-none focus-visible:ring-1 focus-visible:ring-ring ${
selectedId === item.id ? 'bg-accent/10' : ''
}`}
onClick={() => onRowClick(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onRowClick(item.id);
}
}}
tabIndex={0}
role="row"
aria-selected={selectedId === item.id}
>
{visibleColumns.map((col) => (
<td key={col.key} className="px-3 py-2.5 text-sm">
{col.render(item)}
</td>
))}
</tr>
);
}
function SectionHeader({ label, colCount }: { label: string; colCount: number }) {
return (
<tr>
<td
colSpan={colCount}
className="px-3 pt-4 pb-1.5 text-[11px] uppercase tracking-wider text-muted-foreground font-medium"
>
{label}
</td>
</tr>
);
}
export function EntityTable<T extends { id: number }>({
columns,
groups,
pinnedRows,
pinnedLabel,
showPinned,
selectedId,
onRowClick,
sortKey,
sortDir,
onSort,
visibilityMode,
loading = false,
}: EntityTableProps<T>) {
const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode));
const colCount = visibleColumns.length;
const showPinnedSection = showPinned && pinnedRows.length > 0;
return (
<div className="w-full">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-border">
{visibleColumns.map((col) => (
<th
key={col.key}
className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"
>
{col.sortable ? (
<button
type="button"
onClick={() => onSort(col.key)}
aria-label={`Sort by ${col.label}`}
className="flex items-center hover:text-foreground transition-colors duration-150"
>
{col.label}
<SortIcon sortKey={sortKey} sortDir={sortDir} colKey={col.key} />
</button>
) : (
col.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 6 }).map((_, i) => <SkeletonRow key={i} colCount={colCount} />)
) : (
<>
{showPinnedSection && (
<>
<SectionHeader label={pinnedLabel} colCount={colCount} />
{pinnedRows.map((item) => (
<DataRow
key={item.id}
item={item}
visibleColumns={visibleColumns}
selectedId={selectedId}
onRowClick={onRowClick}
/>
))}
</>
)}
{groups.map((group) => (
<React.Fragment key={group.label}>
{group.rows.length > 0 && (
<>
<SectionHeader label={group.label} colCount={colCount} />
{group.rows.map((item) => (
<DataRow
key={item.id}
item={item}
visibleColumns={visibleColumns}
selectedId={selectedId}
onRowClick={onRowClick}
/>
))}
</>
)}
</React.Fragment>
))}
</>
)}
</tbody>
</table>
</div>
);
}