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>
389 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|