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 (
{Array.from({ length: 5 }).map((_, i) => (
))}
); } 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 ( {t("database_view.table.empty_state")} ); } return ( <> {rows.map((row) => ( {fields.map((field) => { const isEditing = editingCell?.rowId === row.id && editingCell?.fieldId === field.id; const cellValue = row.fields[field.name] ?? row.fields[field.id]; return ( { if (!isEditing) { onCellDoubleClick(row.id, field.id); } }} > {isEditing ? ( onCellSave(row.id, field, v)} onCancel={onCellCancel} /> ) : ( formatCellValue(cellValue) )} ); })} ))} ); } export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps) { const { t } = useTranslation(); const [page, setPage] = useState(1); const [editingCell, setEditingCell] = useState(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(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: ( La colonne {field.name} et toutes ses valeurs seront définitivement supprimées. Action irréversible. ), 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 ; } 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 ( } color="red" title={t("database_view.error.title")} > {message} ); } 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 (
{fields.map((field) => ( ))} {canAdminTables && ( )} setEditingCell({ rowId, fieldId }) } onCellSave={handleCellSave} onCellCancel={() => setEditingCell(null)} />
{field.name} {canAdminTables && !field.primary && ( } onClick={() => openEditField(field)} > Modifier } color="red" onClick={() => confirmDeleteField(field)} > Supprimer )}
{/* Pagination — only shown when there is more than one page. */} {(page > 1 || hasNextPage) && (
{t("database_view.table.page_info", { page, total, size: PAGE_SIZE, })}
)}
); }