AcadeDoc/apps/client/src/features/acadenice/database-view/renderers/table-renderer.tsx
Corentin fe75ea5c45 fix(database-view): send tableId to bridge and correct SSE path
useViewData omitted the tableId query param required by the bridge
GET /views/:id/data route -> 400 'tableId query param required' and a
blank 'Could not load view'. The SSE consumer hit /api/v1/events/sse
but the bridge mounts the stream at /api/events -> 404 reconnect loop.
Thread tableId through ViewDataParams and all five callers; point the
SSE URL at /api/events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:20:38 +00:00

389 lines
12 KiB
TypeScript

import { useState } from "react";
import {
Text,
Button,
Group,
Skeleton,
Stack,
Alert,
ActionIcon,
Menu,
} from "@mantine/core";
import {
IconAlertCircle,
IconChevronLeft,
IconChevronRight,
IconPlus,
IconDots,
IconPencil,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { modals } from "@mantine/modals";
import { useQueryClient } from "@tanstack/react-query";
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 { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
import { InlineEditor } from "../components/inline-editor";
import { FieldAdminModal } from "../components/field-admin-modal";
import { deleteField } from "../services/admin-client";
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}
data-testid={`cell-${row.id}-${field.name}`}
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,
tableId,
bridgeUrl,
page,
size: PAGE_SIZE,
});
const { canWriteRows } = usePermissions();
// Acadenice OSS: tout user (role Member par defaut) a tables:write.
// Le gate reste pour les roles custom restrictifs.
const acadenicePerms = useAcadenicePermissions();
const canAdminTables = acadenicePerms.hasPermission("tables:write");
const queryClient = useQueryClient();
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
// Field admin modal state.
const [fieldModalOpen, setFieldModalOpen] = useState(false);
const [editingField, setEditingField] = useState<BridgeField | null>(null);
function openCreateField() {
setEditingField(null);
setFieldModalOpen(true);
}
function openEditField(field: BridgeField) {
setEditingField(field);
setFieldModalOpen(true);
}
function refreshAfterFieldChange() {
// Invalidate the view-data cache so the table re-renders with new fields.
queryClient.invalidateQueries({ queryKey: ["database-view", viewId] });
queryClient.invalidateQueries({ queryKey: ["database-view"] });
}
function confirmDeleteField(field: BridgeField) {
modals.openConfirmModal({
title: "Supprimer la colonne",
children: (
<Text size="sm">
La colonne <strong>{field.name}</strong> et toutes ses valeurs seront
définitivement supprimées. Action irréversible.
</Text>
),
labels: { confirm: "Supprimer", cancel: "Annuler" },
confirmProps: { color: "red" },
onConfirm: async () => {
try {
await deleteField(Number(field.id), bridgeUrl);
refreshAfterFieldChange();
} catch (e) {
// best-effort surface — invalidate to refresh UI
refreshAfterFieldChange();
// eslint-disable-next-line no-console
console.error("delete field failed", e);
}
},
});
}
// 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} data-testid="table-renderer">
<thead>
<tr>
{fields.map((field) => (
<th key={field.id} className={styles.th}>
<Group gap={4} wrap="nowrap" justify="space-between">
<Text size="sm" fw={500} truncate>
{field.name}
</Text>
{canAdminTables && !field.primary && (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
size="xs"
variant="subtle"
aria-label={`Options ${field.name}`}
>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconPencil size={14} />}
onClick={() => openEditField(field)}
>
Modifier
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={() => confirmDeleteField(field)}
>
Supprimer
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Group>
</th>
))}
{canAdminTables && (
<th className={styles.th} style={{ width: 32 }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={openCreateField}
aria-label="Ajouter une colonne"
title="Ajouter une colonne"
>
<IconPlus size={16} />
</ActionIcon>
</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>
);
}