AcadeDoc/apps/client/src/features/acadenice/database-view/renderers/table-renderer.tsx
Corentin f3fae2ac78 feat(client): add kanban + calendar renderers + inline edit for R3.1.d
- KanbanRenderer: @dnd-kit drag-drop, group by single_select field,
  optimistic update + rollback, empty column placeholder, read-only mode
- CalendarRenderer: @fullcalendar month/week/day toggle, eventDrop ->
  PATCH date field, event click -> RowDetailModal, stub deps noted
- InlineEditor: polymorphic (text/number/date/select/multi-select),
  save on blur/Enter, cancel on Escape, permission denied read-only
- RowDetailModal: simple field detail view opened from calendar events
- useUpdateRow: generic PATCH with React Query v5 optimistic+rollback
- usePermissions: reads acadenice_permissions from window global or cookie
- patchRow helper added to bridge-client.ts
- DatabaseViewComponent: dispatches kanban/calendar to real renderers
- TableRenderer: integrates InlineEditor on double-click cells
- i18n: +12 keys (kanban.*, calendar.*, edit.*, row_detail.*) in en-US + fr-FR
- 5 new test suites: kanban, calendar, inline-editor, use-update-row, use-permissions
- Updated database-view-component.test.tsx for R3.1.d dispatch

Deps to install: @dnd-kit/core@^6 @dnd-kit/sortable@^8 @dnd-kit/utilities@^3
  @fullcalendar/react@^6 @fullcalendar/daygrid@^6
  @fullcalendar/timegrid@^6 @fullcalendar/interaction@^6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:24:12 +02:00

270 lines
7.9 KiB
TypeScript

import { useState } from "react";
import { Text, Button, Group, Skeleton, Stack, Alert } from "@mantine/core";
import { IconAlertCircle, IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useViewData } from "../hooks/use-view-data";
import { useUpdateRow } from "../hooks/use-update-row";
import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates";
import { usePermissions } from "../hooks/use-permissions";
import { InlineEditor } from "../components/inline-editor";
import type { BridgeField, BridgeRow } from "../types/database-view.types";
import styles from "./table-renderer.module.css";
/**
* NOTE: This renderer uses plain HTML table elements.
*
* TanStack Table v8 (@tanstack/react-table) is NOT yet installed in
* apps/client/package.json. The headless table logic here is a faithful
* placeholder that mirrors the TanStack Table v8 mental model (columns derived
* from BridgeField[], rows from BridgeRow[]) so migration is a drop-in.
*
* To migrate: install @tanstack/react-table@^8, then replace the manual
* column/row loops with useReactTable() + flexRender(). The data shape
* (fields + rows) is already correct.
*
* DEPENDENCY NEEDED: @tanstack/react-table@^8
*/
const PAGE_SIZE = 50;
interface TableRendererProps {
tableId: string;
viewId: string;
bridgeUrl?: string | null;
}
interface EditingCell {
rowId: string;
fieldId: string;
}
/** Display format for a raw cell value — keeps the table readable without edit. */
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "object") {
// Arrays of strings/objects (select, link fields in Baserow)
if (Array.isArray(value)) {
return value
.map((v) => (typeof v === "object" && v !== null ? (v as { value?: string }).value ?? JSON.stringify(v) : String(v)))
.join(", ");
}
// Objects (file fields, etc.)
return (value as { value?: string }).value ?? JSON.stringify(value);
}
return String(value);
}
/** Loading skeleton mimicking the table layout. */
function TableSkeleton() {
return (
<div className={styles.skeleton}>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className={styles.skeletonRow}>
<Skeleton height={18} width="20%" />
<Skeleton height={18} width="30%" />
<Skeleton height={18} width="25%" />
<Skeleton height={18} width="15%" />
</div>
))}
</div>
);
}
interface TableBodyProps {
fields: BridgeField[];
rows: BridgeRow[];
editingCell: EditingCell | null;
canWrite: boolean;
onCellDoubleClick: (rowId: string, fieldId: string) => void;
onCellSave: (rowId: string, field: BridgeField, value: unknown) => void;
onCellCancel: () => void;
}
function TableBody({
fields,
rows,
editingCell,
canWrite,
onCellDoubleClick,
onCellSave,
onCellCancel,
}: TableBodyProps) {
const { t } = useTranslation();
if (rows.length === 0) {
return (
<tr>
<td colSpan={fields.length} className={styles.emptyState}>
<Text size="sm" c="dimmed">
{t("database_view.table.empty_state")}
</Text>
</td>
</tr>
);
}
return (
<>
{rows.map((row) => (
<tr key={row.id} className={styles.tr}>
{fields.map((field) => {
const isEditing =
editingCell?.rowId === row.id && editingCell?.fieldId === field.id;
const cellValue = row.fields[field.name] ?? row.fields[field.id];
return (
<td
key={field.id}
className={styles.td}
onDoubleClick={() => {
if (!isEditing) {
onCellDoubleClick(row.id, field.id);
}
}}
>
{isEditing ? (
<InlineEditor
field={field}
initialValue={cellValue}
canWrite={canWrite}
onSave={(v) => onCellSave(row.id, field, v)}
onCancel={onCellCancel}
/>
) : (
formatCellValue(cellValue)
)}
</td>
);
})}
</tr>
))}
</>
);
}
export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps) {
const { t } = useTranslation();
const [page, setPage] = useState(1);
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const { data, isLoading, isError, error, refetch } = useViewData({
viewId,
bridgeUrl,
page,
size: PAGE_SIZE,
});
const { canWriteRows } = usePermissions();
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
// Subscribe to SSE updates — invalidates React Query cache on row/view events.
useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl);
if (isLoading) {
return <TableSkeleton />;
}
if (isError) {
const axiosError = error as { response?: { status?: number } };
const status = axiosError?.response?.status;
let message = t("database_view.error.generic");
if (status === 403) message = t("database_view.error.permission_denied");
else if (status === 404) message = t("database_view.error.view_not_found");
return (
<Alert
icon={<IconAlertCircle size={16} />}
color="red"
title={t("database_view.error.title")}
>
<Stack gap="xs">
<Text size="sm">{message}</Text>
<Button size="xs" variant="subtle" onClick={() => refetch()}>
{t("database_view.error.retry")}
</Button>
</Stack>
</Alert>
);
}
const { rows, fields, total, hasNextPage } = data ?? {
rows: [],
fields: [],
total: 0,
hasNextPage: false,
};
function handleCellSave(rowId: string, field: BridgeField, value: unknown) {
setEditingCell(null);
updateRow.mutate({
rowId,
payload: { fields: { [field.name]: value } },
});
}
return (
<div>
<div className={styles.wrapper}>
<table className={styles.table}>
<thead>
<tr>
{fields.map((field) => (
<th key={field.id} className={styles.th}>
{field.name}
</th>
))}
</tr>
</thead>
<tbody>
<TableBody
fields={fields}
rows={rows}
editingCell={editingCell}
canWrite={canWriteRows}
onCellDoubleClick={(rowId, fieldId) =>
setEditingCell({ rowId, fieldId })
}
onCellSave={handleCellSave}
onCellCancel={() => setEditingCell(null)}
/>
</tbody>
</table>
</div>
{/* Pagination — only shown when there is more than one page. */}
{(page > 1 || hasNextPage) && (
<div className={styles.pagination}>
<Text size="xs">
{t("database_view.table.page_info", {
page,
total,
size: PAGE_SIZE,
})}
</Text>
<Group gap="xs">
<Button
size="xs"
variant="subtle"
disabled={page === 1}
leftSection={<IconChevronLeft size={14} />}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{t("database_view.table.prev")}
</Button>
<Button
size="xs"
variant="subtle"
disabled={!hasNextPage}
rightSection={<IconChevronRight size={14} />}
onClick={() => setPage((p) => p + 1)}
>
{t("database_view.table.next")}
</Button>
</Group>
</div>
)}
</div>
);
}