- 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>
270 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|