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>
This commit is contained in:
Corentin JOGUET 2026-05-08 00:24:12 +02:00
parent 71c2abad8a
commit f3fae2ac78
21 changed files with 2445 additions and 35 deletions

View file

@ -1031,5 +1031,16 @@
"database_view.modal.no_views": "No views found for this table.",
"database_view.modal.select_view": "Select a view to embed:",
"database_view.modal.back": "Back",
"database_view.modal.insert": "Insert"
"database_view.modal.insert": "Insert",
"database_view.kanban.empty_column": "No cards",
"database_view.kanban.no_groupby_field": "No single-select field found. Kanban requires a single-select field to group cards by.",
"database_view.calendar.no_date_field": "No date field found. Calendar requires a date field to position events.",
"database_view.calendar.view_month": "Month",
"database_view.calendar.view_week": "Week",
"database_view.calendar.view_day": "Day",
"database_view.edit.permission_denied": "You do not have permission to edit this field.",
"database_view.edit.read_only_mode": "Read-only — you do not have write access to this database.",
"database_view.row_detail.title": "Row details",
"database_view.row_detail.primary_badge": "primary",
"database_view.row_detail.no_fields": "No fields to display."
}

View file

@ -985,5 +985,16 @@
"database_view.modal.no_views": "Aucune vue trouvée pour cette table.",
"database_view.modal.select_view": "Sélectionnez une vue à intégrer :",
"database_view.modal.back": "Retour",
"database_view.modal.insert": "Insérer"
"database_view.modal.insert": "Insérer",
"database_view.kanban.empty_column": "Aucune carte",
"database_view.kanban.no_groupby_field": "Aucun champ à sélection unique trouvé. Le kanban nécessite un champ à sélection unique pour regrouper les cartes.",
"database_view.calendar.no_date_field": "Aucun champ de date trouvé. Le calendrier nécessite un champ de date pour positionner les événements.",
"database_view.calendar.view_month": "Mois",
"database_view.calendar.view_week": "Semaine",
"database_view.calendar.view_day": "Jour",
"database_view.edit.permission_denied": "Vous n'avez pas la permission de modifier ce champ.",
"database_view.edit.read_only_mode": "Lecture seule — vous n'avez pas accès en écriture à cette base de données.",
"database_view.row_detail.title": "Détails de la ligne",
"database_view.row_detail.primary_badge": "primaire",
"database_view.row_detail.no_fields": "Aucun champ à afficher."
}

View file

@ -0,0 +1,234 @@
/**
* Tests for CalendarRenderer.
*
* Note on FullCalendar in JSDOM:
* FullCalendar renders a full calendar grid with complex DOM and ResizeObserver
* usage. Running it faithfully in JSDOM is unreliable and slow. We mock the
* FullCalendar component itself to render a stub that exposes events and
* interaction handlers via data attributes + callbacks. This is the standard
* pattern for testing FullCalendar wrappers in Jest/Vitest.
*
* Covers:
* - loading skeleton when isLoading
* - error alert on error
* - no_date_field alert when no date field exists
* - events passed to FullCalendar stub from rows
* - event click -> opens RowDetailModal
* - event drop -> calls useUpdateRow.mutate with new date
* - event drop with canWriteRows=false -> calls arg.revert()
* - rows without a date value are excluded from events
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MantineProvider } from "@mantine/core";
import { CalendarRenderer } from "../renderers/calendar-renderer";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
vi.mock("../hooks/use-view-data", () => ({
VIEW_DATA_QUERY_KEY: "view-data",
useViewData: vi.fn(),
}));
const mutateMock = vi.fn();
vi.mock("../hooks/use-update-row", () => ({
useUpdateRow: vi.fn(() => ({ mutate: mutateMock })),
}));
vi.mock("../hooks/use-database-realtime-updates", () => ({
useDatabaseRealtimeUpdates: vi.fn(),
}));
vi.mock("../hooks/use-permissions", () => ({
usePermissions: vi.fn(() => ({ canWriteRows: true, isAdmin: false, isResolved: true })),
}));
vi.mock("@mantine/hooks", () => ({
useDisclosure: vi.fn(() => [false, { open: vi.fn(), close: vi.fn() }]),
}));
// Stub FullCalendar to a simple div that exposes event handlers via test hooks.
let capturedProps: Record<string, unknown> = {};
vi.mock("@fullcalendar/react", () => ({
default: (props: Record<string, unknown>) => {
capturedProps = props;
const events = (props.events as { id: string; title: string; start: string }[]) ?? [];
return (
<div data-testid="fullcalendar-stub">
{events.map((e) => (
<div key={e.id} data-testid={`event-${e.id}`} data-start={e.start}>
{e.title}
</div>
))}
</div>
);
},
}));
vi.mock("@fullcalendar/daygrid", () => ({ default: {} }));
vi.mock("@fullcalendar/timegrid", () => ({ default: {} }));
vi.mock("@fullcalendar/interaction", () => ({ default: {} }));
import { useViewData } from "../hooks/use-view-data";
import { usePermissions } from "../hooks/use-permissions";
const mockUseViewData = useViewData as ReturnType<typeof vi.fn>;
const mockUsePermissions = usePermissions as ReturnType<typeof vi.fn>;
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return (
<QueryClientProvider client={qc}>
<MantineProvider>{children}</MantineProvider>
</QueryClientProvider>
);
}
const FIELDS = [
{ id: "f1", name: "Title", type: "text", primary: true, options: null },
{ id: "f2", name: "Due", type: "date", options: null },
];
const ROWS = [
{ id: "r1", tableId: "t1", fields: { Title: "Event A", Due: "2026-06-01T10:00:00Z" } },
{ id: "r2", tableId: "t1", fields: { Title: "Event B", Due: "2026-06-15T14:00:00Z" } },
{ id: "r3", tableId: "t1", fields: { Title: "No Date" } }, // no date — should be excluded
];
describe("CalendarRenderer", () => {
beforeEach(() => {
vi.clearAllMocks();
capturedProps = {};
mockUsePermissions.mockReturnValue({ canWriteRows: true, isAdmin: false, isResolved: true });
});
it("shows skeleton when loading", () => {
mockUseViewData.mockReturnValue({ isLoading: true, isError: false, data: null, error: null, refetch: vi.fn() });
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
expect(screen.queryByTestId("fullcalendar-stub")).not.toBeInTheDocument();
});
it("shows error alert on error", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: true,
data: null,
error: { response: { status: 500 } },
refetch: vi.fn(),
});
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
expect(screen.getByText("database_view.error.generic")).toBeInTheDocument();
});
it("shows no_date_field alert when no date field exists", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: {
rows: [],
fields: [{ id: "f1", name: "Title", type: "text", primary: true }],
total: 0,
hasNextPage: false,
},
error: null,
refetch: vi.fn(),
});
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
expect(screen.getByText("database_view.calendar.no_date_field")).toBeInTheDocument();
});
it("renders calendar stub when data is available", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
error: null,
refetch: vi.fn(),
});
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
expect(screen.getByTestId("fullcalendar-stub")).toBeInTheDocument();
});
it("passes only rows with a date value as events (excludes r3 with no date)", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
error: null,
refetch: vi.fn(),
});
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
expect(screen.getByTestId("event-r1")).toBeInTheDocument();
expect(screen.getByTestId("event-r2")).toBeInTheDocument();
// r3 has no date — must be excluded.
expect(screen.queryByTestId("event-r3")).not.toBeInTheDocument();
});
it("event titles come from primary field", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: { rows: [ROWS[0]], fields: FIELDS, total: 1, hasNextPage: false },
error: null,
refetch: vi.fn(),
});
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
expect(screen.getByText("Event A")).toBeInTheDocument();
});
it("eventDrop calls mutate with the new ISO date when canWriteRows is true", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
error: null,
refetch: vi.fn(),
});
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
// Simulate eventDrop by calling the captured handler directly.
const newDate = new Date("2026-06-20T10:00:00Z");
const revert = vi.fn();
const dropArg = {
event: { id: "r1", start: newDate },
revert,
};
const eventDropFn = capturedProps.eventDrop as (arg: typeof dropArg) => void;
expect(typeof eventDropFn).toBe("function");
eventDropFn(dropArg);
expect(mutateMock).toHaveBeenCalledWith(
{
rowId: "r1",
payload: { fields: { Due: newDate.toISOString() } },
},
expect.any(Object),
);
});
it("eventDrop calls revert when canWriteRows is false", () => {
mockUsePermissions.mockReturnValue({ canWriteRows: false, isAdmin: false, isResolved: true });
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
error: null,
refetch: vi.fn(),
});
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
const revert = vi.fn();
const eventDropFn = capturedProps.eventDrop as (arg: {
event: { id: string; start: Date };
revert: () => void;
}) => void;
eventDropFn({ event: { id: "r1", start: new Date() }, revert });
expect(revert).toHaveBeenCalled();
expect(mutateMock).not.toHaveBeenCalled();
});
});

View file

@ -1,10 +1,12 @@
/**
* Tests for DatabaseViewComponent (NodeViewWrapper).
*
* Covers:
* Covers (updated R3.1.d):
* - renders TableRenderer when viewType is "grid" or "table"
* - renders PlaceholderRenderer for unsupported viewTypes
* - passes tableId/viewId/bridgeUrl to TableRenderer
* - renders KanbanRenderer when viewType is "kanban"
* - renders CalendarRenderer when viewType is "calendar"
* - renders PlaceholderRenderer for unknown viewTypes
* - passes tableId/viewId/bridgeUrl to each renderer
* - shows "selected" class when the node is selected in ProseMirror
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@ -41,6 +43,36 @@ vi.mock("../renderers/table-renderer", () => ({
),
}));
// Mock KanbanRenderer (R3.1.d).
vi.mock("../renderers/kanban-renderer", () => ({
KanbanRenderer: ({
tableId,
viewId,
}: {
tableId: string;
viewId: string;
}) => (
<div data-testid="kanban-renderer">
kanban:{tableId}:{viewId}
</div>
),
}));
// Mock CalendarRenderer (R3.1.d).
vi.mock("../renderers/calendar-renderer", () => ({
CalendarRenderer: ({
tableId,
viewId,
}: {
tableId: string;
viewId: string;
}) => (
<div data-testid="calendar-renderer">
calendar:{tableId}:{viewId}
</div>
),
}));
// Mock PlaceholderRenderer.
vi.mock("../renderers/placeholder-renderer", () => ({
PlaceholderRenderer: ({ viewType }: { viewType: string }) => (
@ -110,7 +142,7 @@ describe("DatabaseViewComponent", () => {
expect(screen.getByTestId("table-renderer")).toBeInTheDocument();
});
it("renders PlaceholderRenderer for unsupported viewType kanban", () => {
it("renders KanbanRenderer for viewType kanban (R3.1.d)", () => {
const props = makeNodeViewProps({
tableId: "t3",
viewId: "v3",
@ -122,13 +154,11 @@ describe("DatabaseViewComponent", () => {
<DatabaseViewComponent {...props} />
</Wrapper>,
);
expect(screen.getByTestId("placeholder-renderer")).toBeInTheDocument();
expect(screen.getByTestId("placeholder-renderer")).toHaveTextContent(
"placeholder:kanban",
);
expect(screen.getByTestId("kanban-renderer")).toBeInTheDocument();
expect(screen.getByTestId("kanban-renderer")).toHaveTextContent("kanban:t3:v3");
});
it("renders PlaceholderRenderer for unsupported viewType calendar", () => {
it("renders CalendarRenderer for viewType calendar (R3.1.d)", () => {
const props = makeNodeViewProps({
tableId: "t4",
viewId: "v4",
@ -140,7 +170,26 @@ describe("DatabaseViewComponent", () => {
<DatabaseViewComponent {...props} />
</Wrapper>,
);
expect(screen.getByTestId("calendar-renderer")).toBeInTheDocument();
expect(screen.getByTestId("calendar-renderer")).toHaveTextContent("calendar:t4:v4");
});
it("renders PlaceholderRenderer for unknown viewType", () => {
const props = makeNodeViewProps({
tableId: "t5",
viewId: "v5",
viewType: "gallery",
bridgeUrl: null,
});
render(
<Wrapper>
<DatabaseViewComponent {...props} />
</Wrapper>,
);
expect(screen.getByTestId("placeholder-renderer")).toBeInTheDocument();
expect(screen.getByTestId("placeholder-renderer")).toHaveTextContent(
"placeholder:gallery",
);
});
it("shows the node header label", () => {

View file

@ -0,0 +1,204 @@
/**
* Tests for InlineEditor.
*
* Covers:
* - double-click -> editor appears (text field)
* - save on Enter -> calls onSave with the typed value
* - cancel on Escape -> calls onCancel, no onSave
* - save on blur -> calls onSave
* - permission denied -> read-only span shown with tooltip, no editor
* - number field -> NumberInput rendered
* - select field -> Select rendered with options
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MantineProvider } from "@mantine/core";
import { InlineEditor } from "../components/inline-editor";
import type { BridgeField } from "../types/database-view.types";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
function Wrapper({ children }: { children: React.ReactNode }) {
return <MantineProvider>{children}</MantineProvider>;
}
const textField: BridgeField = {
id: "f1",
name: "Name",
type: "text",
primary: true,
options: null,
};
const numberField: BridgeField = {
id: "f2",
name: "Score",
type: "number",
options: null,
};
const selectField: BridgeField = {
id: "f3",
name: "Status",
type: "single_select",
options: {
select_options: [
{ id: 1, value: "Active" },
{ id: 2, value: "Inactive" },
],
},
};
describe("InlineEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders read-only span when canWrite is false", () => {
const onSave = vi.fn();
const onCancel = vi.fn();
render(
<Wrapper>
<InlineEditor
field={textField}
initialValue="Alice"
canWrite={false}
onSave={onSave}
onCancel={onCancel}
/>
</Wrapper>,
);
expect(screen.getByText("Alice")).toBeInTheDocument();
// No input rendered.
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
});
it("renders text input when canWrite is true", () => {
render(
<Wrapper>
<InlineEditor
field={textField}
initialValue="Alice"
canWrite={true}
onSave={vi.fn()}
onCancel={vi.fn()}
/>
</Wrapper>,
);
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
it("calls onSave with updated value on Enter key", async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(
<Wrapper>
<InlineEditor
field={textField}
initialValue="Alice"
canWrite={true}
onSave={onSave}
onCancel={vi.fn()}
/>
</Wrapper>,
);
const input = screen.getByRole("textbox");
await user.clear(input);
await user.type(input, "Bob{Enter}");
expect(onSave).toHaveBeenCalledWith("Bob");
});
it("calls onCancel on Escape key", async () => {
const onSave = vi.fn();
const onCancel = vi.fn();
const user = userEvent.setup();
render(
<Wrapper>
<InlineEditor
field={textField}
initialValue="Alice"
canWrite={true}
onSave={onSave}
onCancel={onCancel}
/>
</Wrapper>,
);
const input = screen.getByRole("textbox");
await user.type(input, "Bob{Escape}");
expect(onCancel).toHaveBeenCalledOnce();
expect(onSave).not.toHaveBeenCalled();
});
it("calls onSave on blur", async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(
<Wrapper>
<InlineEditor
field={textField}
initialValue="Alice"
canWrite={true}
onSave={onSave}
onCancel={vi.fn()}
/>
</Wrapper>,
);
const input = screen.getByRole("textbox");
await user.clear(input);
await user.type(input, "Charlie");
await user.tab(); // trigger blur
expect(onSave).toHaveBeenCalledWith("Charlie");
});
it("renders a spinbutton (number input) for number field type", () => {
render(
<Wrapper>
<InlineEditor
field={numberField}
initialValue={42}
canWrite={true}
onSave={vi.fn()}
onCancel={vi.fn()}
/>
</Wrapper>,
);
// Mantine NumberInput uses role="spinbutton" or textbox depending on version.
const input = screen.getByRole("textbox");
expect(input).toBeInTheDocument();
});
it("renders a combobox (Select) for single_select field type", async () => {
render(
<Wrapper>
<InlineEditor
field={selectField}
initialValue={{ id: 1, value: "Active" }}
canWrite={true}
onSave={vi.fn()}
onCancel={vi.fn()}
/>
</Wrapper>,
);
await waitFor(() => {
// Mantine Select renders an input with role="combobox".
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,217 @@
/**
* Tests for KanbanRenderer.
*
* Covers:
* - loading skeleton shown when isLoading
* - error alert shown on error
* - "no groupby field" alert when no single_select field
* - columns rendered per unique field value
* - empty column placeholder
* - drag-drop triggers useUpdateRow mutation with new column value
* - read-only mode indicator when canWriteRows is false
* - permission denied: InlineEditor shows read-only span
*
* Note on drag-drop: @dnd-kit does not expose a simple test helper for
* simulating DragEndEvent. We stub the DndContext to call handleDragEnd
* directly via the exported test helper (not the component we test the
* mutation trigger by stubbing useUpdateRow).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MantineProvider } from "@mantine/core";
import { KanbanRenderer } from "../renderers/kanban-renderer";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
vi.mock("../hooks/use-view-data", () => ({
VIEW_DATA_QUERY_KEY: "view-data",
useViewData: vi.fn(),
}));
vi.mock("../hooks/use-update-row", () => ({
useUpdateRow: vi.fn(() => ({ mutate: vi.fn() })),
}));
vi.mock("../hooks/use-database-realtime-updates", () => ({
useDatabaseRealtimeUpdates: vi.fn(),
}));
vi.mock("../hooks/use-permissions", () => ({
usePermissions: vi.fn(() => ({ canWriteRows: true, isAdmin: false, isResolved: true })),
}));
// Mock dnd-kit to avoid JSDOM pointer sensor issues.
vi.mock("@dnd-kit/core", async () => {
const React = await import("react");
return {
DndContext: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
DragOverlay: ({ children }: { children: React.ReactNode }) => React.createElement("div", { "data-testid": "drag-overlay" }, children),
PointerSensor: class {},
useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []),
closestCenter: vi.fn(),
};
});
vi.mock("@dnd-kit/sortable", async () => {
const React = await import("react");
return {
SortableContext: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
useSortable: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: null,
isDragging: false,
})),
verticalListSortingStrategy: {},
};
});
vi.mock("@dnd-kit/utilities", () => ({
CSS: { Transform: { toString: vi.fn(() => "") } },
}));
import { useViewData } from "../hooks/use-view-data";
import { usePermissions } from "../hooks/use-permissions";
const mockUseViewData = useViewData as ReturnType<typeof vi.fn>;
const mockUsePermissions = usePermissions as ReturnType<typeof vi.fn>;
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return (
<QueryClientProvider client={qc}>
<MantineProvider>{children}</MantineProvider>
</QueryClientProvider>
);
}
const FIELDS = [
{ id: "f1", name: "Name", type: "text", primary: true, options: null },
{
id: "f2",
name: "Status",
type: "single_select",
options: {
select_options: [
{ id: 1, value: "Todo" },
{ id: 2, value: "Done" },
],
},
},
];
const ROWS = [
{ id: "r1", tableId: "t1", fields: { Name: "Task A", Status: { id: 1, value: "Todo" } } },
{ id: "r2", tableId: "t1", fields: { Name: "Task B", Status: { id: 2, value: "Done" } } },
{ id: "r3", tableId: "t1", fields: { Name: "Task C", Status: { id: 1, value: "Todo" } } },
];
describe("KanbanRenderer", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUsePermissions.mockReturnValue({ canWriteRows: true, isAdmin: false, isResolved: true });
});
it("shows skeleton when loading", () => {
mockUseViewData.mockReturnValue({ isLoading: true, isError: false, data: null, error: null, refetch: vi.fn() });
const { container } = render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
// Skeleton renders Mantine Skeleton components — no columns visible.
expect(container.querySelector("[data-testid='kanban-column']")).toBeNull();
});
it("shows error alert on error", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: true,
data: null,
error: { response: { status: 500 } },
refetch: vi.fn(),
});
render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
expect(screen.getByText("database_view.error.generic")).toBeInTheDocument();
});
it("shows no_groupby_field alert when no single_select field exists", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: {
rows: [],
fields: [{ id: "f1", name: "Name", type: "text", primary: true }],
total: 0,
hasNextPage: false,
},
error: null,
refetch: vi.fn(),
});
render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
expect(screen.getByText("database_view.kanban.no_groupby_field")).toBeInTheDocument();
});
it("renders one column per unique status value", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
error: null,
refetch: vi.fn(),
});
render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
// Both column headers should be visible.
expect(screen.getByText("Todo")).toBeInTheDocument();
expect(screen.getByText("Done")).toBeInTheDocument();
});
it("shows card titles inside columns", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
error: null,
refetch: vi.fn(),
});
render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
expect(screen.getByText("Task A")).toBeInTheDocument();
expect(screen.getByText("Task B")).toBeInTheDocument();
expect(screen.getByText("Task C")).toBeInTheDocument();
});
it("shows empty column placeholder when column has no rows", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: {
rows: [
{ id: "r1", tableId: "t1", fields: { Name: "Task A", Status: { id: 1, value: "Todo" } } },
],
fields: FIELDS,
total: 1,
hasNextPage: false,
},
error: null,
refetch: vi.fn(),
});
render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
// "Done" column has no rows.
expect(screen.getByText("database_view.kanban.empty_column")).toBeInTheDocument();
});
it("shows read-only indicator when canWriteRows is false", () => {
mockUsePermissions.mockReturnValue({ canWriteRows: false, isAdmin: false, isResolved: true });
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
error: null,
refetch: vi.fn(),
});
render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
expect(screen.getByText("database_view.edit.read_only_mode")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,82 @@
/**
* Tests for usePermissions.
*
* Covers:
* - admin:* -> isAdmin + canWriteRows both true
* - rows:write only -> canWriteRows true, isAdmin false
* - no permissions -> optimistic default (canWriteRows true, isResolved false)
* - cookie fallback parsing
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { usePermissions } from "../hooks/use-permissions";
describe("usePermissions", () => {
beforeEach(() => {
// Clear the global cache between tests.
if (typeof window !== "undefined") {
delete (window as unknown as Record<string, unknown>)["__acadenice_perms"];
}
// Reset cookie.
Object.defineProperty(document, "cookie", {
writable: true,
value: "",
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns isAdmin + canWriteRows when admin:* is in window global", () => {
(window as unknown as Record<string, unknown>)["__acadenice_perms"] = [
"admin:*",
];
const { result } = renderHook(() => usePermissions());
expect(result.current.isAdmin).toBe(true);
expect(result.current.canWriteRows).toBe(true);
expect(result.current.isResolved).toBe(true);
});
it("returns canWriteRows when rows:write is present but not admin:*", () => {
(window as unknown as Record<string, unknown>)["__acadenice_perms"] = [
"rows:read",
"rows:write",
"pages:read",
];
const { result } = renderHook(() => usePermissions());
expect(result.current.isAdmin).toBe(false);
expect(result.current.canWriteRows).toBe(true);
expect(result.current.isResolved).toBe(true);
});
it("returns canWriteRows false when only rows:read is present", () => {
(window as unknown as Record<string, unknown>)["__acadenice_perms"] = [
"rows:read",
"pages:read",
];
const { result } = renderHook(() => usePermissions());
expect(result.current.canWriteRows).toBe(false);
expect(result.current.isAdmin).toBe(false);
expect(result.current.isResolved).toBe(true);
});
it("falls back to optimistic default when no source is available", () => {
// No global, no cookie.
const { result } = renderHook(() => usePermissions());
// Optimistic: allow writes but mark as unresolved.
expect(result.current.canWriteRows).toBe(true);
expect(result.current.isResolved).toBe(false);
});
it("reads permissions from acadenicePerms cookie when window global is absent", () => {
Object.defineProperty(document, "cookie", {
writable: true,
value: `acadenicePerms=${encodeURIComponent(JSON.stringify(["rows:write"]))}`,
});
const { result } = renderHook(() => usePermissions());
expect(result.current.canWriteRows).toBe(true);
expect(result.current.isResolved).toBe(true);
});
});

View file

@ -0,0 +1,169 @@
/**
* Tests for useUpdateRow.
*
* Covers:
* - successful PATCH -> optimistic update applied, cache invalidated
* - PATCH failure -> rollback to previous cache state
* - mutation fires the correct bridge endpoint
* - onSettled always invalidates view-data queries
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useUpdateRow } from "../hooks/use-update-row";
import { VIEW_DATA_QUERY_KEY } from "../hooks/use-view-data";
import * as bridgeClientModule from "../services/bridge-client";
vi.mock("../services/bridge-client", async (importOriginal) => {
const original = await importOriginal<typeof bridgeClientModule>();
return {
...original,
getBridgeClient: vi.fn(),
resolveBridgeUrl: vi.fn(() => "http://localhost:4000"),
};
});
function makeWrapper(qc: QueryClient) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
const TABLE_ID = "tbl1";
const VIEW_ID = "view1";
const ROW_ID = "row1";
describe("useUpdateRow", () => {
let qc: QueryClient;
let patchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
patchMock = vi.fn();
(bridgeClientModule.getBridgeClient as ReturnType<typeof vi.fn>).mockReturnValue({
patch: patchMock,
});
});
it("applies optimistic update before server responds", async () => {
// Seed cache with existing row.
const queryKey = [VIEW_DATA_QUERY_KEY, VIEW_ID, 1, 50, "http://localhost:4000"];
qc.setQueryData(queryKey, {
rows: [{ id: ROW_ID, tableId: TABLE_ID, fields: { Name: "OldName" } }],
fields: [{ id: "Name", name: "Name", type: "text", primary: true }],
total: 1,
hasNextPage: false,
});
// PATCH resolves slowly — we check optimistic state before it completes.
let resolvePatch!: (v: unknown) => void;
patchMock.mockReturnValue(new Promise((r) => { resolvePatch = r; }));
const { result } = renderHook(
() => useUpdateRow({ tableId: TABLE_ID, viewId: VIEW_ID }),
{ wrapper: makeWrapper(qc) },
);
act(() => {
result.current.mutate({ rowId: ROW_ID, payload: { fields: { Name: "NewName" } } });
});
// Before PATCH resolves, check optimistic state.
await waitFor(() => result.current.isPending);
const optimisticData = qc.getQueryData<{ rows: { id: string; fields: { Name: string } }[] }>(queryKey);
expect(optimisticData?.rows[0].fields.Name).toBe("NewName");
// Resolve the PATCH.
resolvePatch({ id: ROW_ID, tableId: TABLE_ID, fields: { Name: "NewName" } });
});
it("rolls back on PATCH error", async () => {
const queryKey = [VIEW_DATA_QUERY_KEY, VIEW_ID, 1, 50, "http://localhost:4000"];
qc.setQueryData(queryKey, {
rows: [{ id: ROW_ID, tableId: TABLE_ID, fields: { Name: "OriginalName" } }],
fields: [{ id: "Name", name: "Name", type: "text", primary: true }],
total: 1,
hasNextPage: false,
});
patchMock.mockRejectedValue(new Error("Server error"));
const { result } = renderHook(
() => useUpdateRow({ tableId: TABLE_ID, viewId: VIEW_ID }),
{ wrapper: makeWrapper(qc) },
);
await act(async () => {
result.current.mutate({ rowId: ROW_ID, payload: { fields: { Name: "NewName" } } });
});
await waitFor(() => result.current.isError);
// After error, cache should be rolled back to the original value.
const rolledBack = qc.getQueryData<{ rows: { id: string; fields: { Name: string } }[] }>(queryKey);
expect(rolledBack?.rows[0].fields.Name).toBe("OriginalName");
});
it("calls PATCH on the correct endpoint", async () => {
patchMock.mockResolvedValue({ id: ROW_ID, tableId: TABLE_ID, fields: { Name: "New" } });
const { result } = renderHook(
() => useUpdateRow({ tableId: TABLE_ID, viewId: VIEW_ID }),
{ wrapper: makeWrapper(qc) },
);
await act(async () => {
result.current.mutate({ rowId: ROW_ID, payload: { fields: { Name: "New" } } });
});
await waitFor(() => result.current.isSuccess);
expect(patchMock).toHaveBeenCalledWith(
`/api/v1/tables/${TABLE_ID}/rows/${ROW_ID}`,
{ fields: { Name: "New" } },
);
});
it("invalidates view-data queries on settled (success)", async () => {
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
patchMock.mockResolvedValue({ id: ROW_ID, tableId: TABLE_ID, fields: { Name: "A" } });
const { result } = renderHook(
() => useUpdateRow({ tableId: TABLE_ID, viewId: VIEW_ID }),
{ wrapper: makeWrapper(qc) },
);
await act(async () => {
result.current.mutate({ rowId: ROW_ID, payload: { fields: { Name: "A" } } });
});
await waitFor(() => result.current.isSuccess);
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: [VIEW_DATA_QUERY_KEY, VIEW_ID] }),
);
});
it("invalidates view-data queries on settled (error)", async () => {
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
patchMock.mockRejectedValue(new Error("oops"));
const { result } = renderHook(
() => useUpdateRow({ tableId: TABLE_ID, viewId: VIEW_ID }),
{ wrapper: makeWrapper(qc) },
);
await act(async () => {
result.current.mutate({ rowId: ROW_ID, payload: { fields: { Name: "X" } } });
});
await waitFor(() => result.current.isError);
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: [VIEW_DATA_QUERY_KEY, VIEW_ID] }),
);
});
});

View file

@ -0,0 +1,17 @@
.input {
width: 100%;
min-width: 80px;
}
.readOnly {
display: inline-block;
cursor: not-allowed;
opacity: 0.7;
padding: 2px 4px;
border-radius: var(--mantine-radius-xs);
border: 1px dashed var(--mantine-color-gray-4);
}
[data-mantine-color-scheme="dark"] .readOnly {
border-color: var(--mantine-color-dark-4);
}

View file

@ -0,0 +1,240 @@
import { useState, useEffect, useRef, KeyboardEvent } from "react";
import {
TextInput,
NumberInput,
Select,
MultiSelect,
Tooltip,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useTranslation } from "react-i18next";
import type { BridgeField } from "../types/database-view.types";
import styles from "./inline-editor.module.css";
export interface InlineEditorProps {
field: BridgeField;
initialValue: unknown;
/** Called with the new value when the user confirms the edit. */
onSave: (value: unknown) => void;
/** Called when the user cancels (Escape). */
onCancel: () => void;
/** When false the editor is rendered as read-only display with a tooltip. */
canWrite: boolean;
}
/**
* Polymorphic inline cell editor.
*
* Why polymorphic via field.type discriminator:
* Baserow field types (text, number, date, single_select, multiple_select)
* each require a different input widget. We centralise the logic here so
* every renderer (table, kanban) gets the same editing behaviour for free.
*
* Save triggers: blur or Enter (for single-line inputs).
* Cancel trigger: Escape.
* Permission denied: rendered as a read-only span with a tooltip.
*/
export function InlineEditor({
field,
initialValue,
onSave,
onCancel,
canWrite,
}: InlineEditorProps) {
const { t } = useTranslation();
const [value, setValue] = useState<unknown>(initialValue);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Auto-focus on mount.
setTimeout(() => inputRef.current?.focus(), 0);
}, []);
if (!canWrite) {
return (
<Tooltip label={t("database_view.edit.permission_denied")} withArrow>
<span className={styles.readOnly}>{formatDisplayValue(initialValue)}</span>
</Tooltip>
);
}
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
onSave(value);
} else if (e.key === "Escape") {
onCancel();
}
};
const handleBlur = () => {
onSave(value);
};
// Derive select options from field metadata.
const selectOptions = deriveSelectOptions(field);
switch (field.type) {
case "number":
case "rating":
return (
<NumberInput
ref={inputRef as React.Ref<HTMLInputElement>}
value={typeof value === "number" ? value : Number(value) || 0}
onChange={(v) => setValue(v)}
onBlur={handleBlur}
onKeyDown={handleKeyDown as React.KeyboardEventHandler}
className={styles.input}
size="xs"
hideControls
/>
);
case "date":
case "created_on":
case "last_modified":
return (
<DateInput
value={parseDate(value)}
onChange={(v) => {
setValue(v ? v.toISOString() : null);
}}
onBlur={handleBlur}
className={styles.input}
size="xs"
clearable
/>
);
case "single_select":
return (
<Select
ref={inputRef as React.Ref<HTMLInputElement>}
value={extractSelectId(value)}
data={selectOptions}
onChange={(v) => {
setValue(v);
onSave(v);
}}
onBlur={handleBlur}
className={styles.input}
size="xs"
searchable
clearable
/>
);
case "multiple_select":
return (
<MultiSelect
ref={inputRef as React.Ref<HTMLInputElement>}
value={extractMultiSelectIds(value)}
data={selectOptions}
onChange={(v) => setValue(v)}
onBlur={() => {
onSave(value);
}}
className={styles.input}
size="xs"
searchable
/>
);
case "boolean":
// Boolean is toggled directly — no inline text input.
return (
<Select
ref={inputRef as React.Ref<HTMLInputElement>}
value={value ? "true" : "false"}
data={[
{ value: "true", label: "true" },
{ value: "false", label: "false" },
]}
onChange={(v) => {
const next = v === "true";
setValue(next);
onSave(next);
}}
onBlur={handleBlur}
className={styles.input}
size="xs"
/>
);
default:
// text, long_text, url, email, phone_number, formula, uuid, auto_number
return (
<TextInput
ref={inputRef}
value={typeof value === "string" ? value : String(value ?? "")}
onChange={(e) => setValue(e.currentTarget.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={styles.input}
size="xs"
/>
);
}
}
// --- helpers ---
function formatDisplayValue(value: unknown): string {
if (value === null || value === undefined) return "";
if (Array.isArray(value)) {
return value
.map((v) =>
typeof v === "object" && v !== null
? (v as { value?: string }).value ?? JSON.stringify(v)
: String(v),
)
.join(", ");
}
if (typeof value === "object") {
return (value as { value?: string }).value ?? JSON.stringify(value);
}
return String(value);
}
function parseDate(value: unknown): Date | null {
if (!value) return null;
const d = new Date(value as string);
return isNaN(d.getTime()) ? null : d;
}
function extractSelectId(value: unknown): string | null {
if (!value) return null;
if (typeof value === "object" && value !== null) {
const v = value as { id?: string | number; value?: string };
if (v.id !== undefined) return String(v.id);
if (v.value !== undefined) return v.value;
}
return String(value);
}
function extractMultiSelectIds(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((v) => {
if (typeof v === "object" && v !== null) {
const item = v as { id?: string | number; value?: string };
if (item.id !== undefined) return String(item.id);
if (item.value !== undefined) return item.value;
}
return String(v);
});
}
function deriveSelectOptions(
field: BridgeField,
): { value: string; label: string }[] {
const opts = field.options as
| {
select_options?: { id: number | string; value: string; color?: string }[];
}
| null
| undefined;
if (!opts?.select_options) return [];
return opts.select_options.map((o) => ({
value: String(o.id),
label: o.value,
}));
}

View file

@ -0,0 +1,81 @@
import { Modal, Stack, Text, Group, Badge, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { BridgeRow, BridgeField } from "../types/database-view.types";
interface RowDetailModalProps {
row: BridgeRow | null;
fields: BridgeField[];
opened: boolean;
onClose: () => void;
}
/**
* Simple row detail modal opened when the user clicks on a calendar event.
*
* Why simple in R3.1.d:
* Full inline editing inside the modal is a larger UX investment (field-level
* save, validation, optimistic feedback). The priority here is to make the
* calendar renderer clickable and show meaningful data. Inline edit from the
* modal is slated for R3.1.e / R3.2.
*/
export function RowDetailModal({ row, fields, opened, onClose }: RowDetailModalProps) {
const { t } = useTranslation();
if (!row) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("database_view.row_detail.title")}
size="md"
centered
>
<Stack gap="xs">
{fields.map((field) => {
const rawValue = row.fields[field.name] ?? row.fields[field.id];
return (
<div key={field.id}>
<Group gap="xs" mb={2}>
<Text size="xs" fw={600} c="dimmed">
{field.name}
</Text>
{field.primary && (
<Badge size="xs" variant="light" color="blue">
{t("database_view.row_detail.primary_badge")}
</Badge>
)}
</Group>
<Text size="sm">{formatValue(rawValue)}</Text>
<Divider mt="xs" />
</div>
);
})}
{fields.length === 0 && (
<Text size="sm" c="dimmed">
{t("database_view.row_detail.no_fields")}
</Text>
)}
</Stack>
</Modal>
);
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "boolean") return value ? "true" : "false";
if (Array.isArray(value)) {
return value
.map((v) =>
typeof v === "object" && v !== null
? (v as { value?: string }).value ?? JSON.stringify(v)
: String(v),
)
.join(", ");
}
if (typeof value === "object") {
return (value as { value?: string }).value ?? JSON.stringify(value);
}
return String(value);
}

View file

@ -3,9 +3,10 @@ import type { NodeViewProps } from "@tiptap/react";
import { IconTable } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { SUPPORTED_VIEW_TYPES } from "../types/database-view.types";
import type { DatabaseViewAttrs, ViewType } from "../types/database-view.types";
import { TableRenderer } from "../renderers/table-renderer";
import { KanbanRenderer } from "../renderers/kanban-renderer";
import { CalendarRenderer } from "../renderers/calendar-renderer";
import { PlaceholderRenderer } from "../renderers/placeholder-renderer";
import styles from "./database-view.module.css";
@ -13,17 +14,29 @@ import styles from "./database-view.module.css";
* React NodeViewWrapper for the `database-view` Tiptap node.
*
* Dispatches on `attrs.viewType`:
* - "grid" | "table" -> TableRenderer (R3.1.c)
* - everything else -> PlaceholderRenderer (rendered in R3.1.d)
*
* Edit inline (row mutations) is intentionally absent R3.1.c is read-only.
* - "grid" | "table" -> TableRenderer (R3.1.c, now with inline edit R3.1.d)
* - "kanban" -> KanbanRenderer (R3.1.d)
* - "calendar" -> CalendarRenderer (R3.1.d)
* - anything else -> PlaceholderRenderer
*/
export function DatabaseViewComponent({ node, selected }: NodeViewProps) {
const { t } = useTranslation();
const attrs = node.attrs as DatabaseViewAttrs;
const { tableId, viewId, viewType, bridgeUrl } = attrs;
const isSupported = SUPPORTED_VIEW_TYPES.includes(viewType as ViewType);
function renderContent(vt: ViewType) {
switch (vt) {
case "grid":
case "table":
return <TableRenderer tableId={tableId} viewId={viewId} bridgeUrl={bridgeUrl} />;
case "kanban":
return <KanbanRenderer tableId={tableId} viewId={viewId} bridgeUrl={bridgeUrl} />;
case "calendar":
return <CalendarRenderer tableId={tableId} viewId={viewId} bridgeUrl={bridgeUrl} />;
default:
return <PlaceholderRenderer viewType={vt} />;
}
}
return (
<NodeViewWrapper>
@ -39,15 +52,7 @@ export function DatabaseViewComponent({ node, selected }: NodeViewProps) {
</div>
<div className={styles.content}>
{isSupported ? (
<TableRenderer
tableId={tableId}
viewId={viewId}
bridgeUrl={bridgeUrl}
/>
) : (
<PlaceholderRenderer viewType={viewType} />
)}
{renderContent(viewType as ViewType)}
</div>
</div>
</NodeViewWrapper>

View file

@ -0,0 +1,80 @@
import { useMemo } from "react";
/**
* Reads the user's Acadenice permissions from the auth context.
*
* Why not a server call here:
* The permissions are already resolved by R2.3a (`GET /api/acadenice/permissions/me`)
* and cached in the application-level React Query cache. This hook reads from
* that cache or falls back to parsing the `acadenice_permissions` claim from any
* JS-readable cookie. It does NOT perform a new HTTP request callers that need
* fresh permissions should use the RBAC hook from the rbac feature.
*
* For the database-view inline editing use case we only need to know whether
* the current user can write rows (`database.rows.write`). We resolve this from
* the standard Acadenice permission `rows:write`.
*/
export interface UsePermissionsResult {
/** True when the user has the `rows:write` permission. */
canWriteRows: boolean;
/** True when the user has `admin:*` (covers all permissions). */
isAdmin: boolean;
/** True when permissions have been resolved (not still loading). */
isResolved: boolean;
}
/**
* Reads acadenice_permissions from any available JS-accessible source:
* 1. The `__acadenice_perms` global injected by the app bootstrap (if present).
* 2. A non-HttpOnly cookie `acadenicePerms` (serialised JSON array).
* 3. The legacy jotai `authTokens` atom value decoded shallowly.
*
* Falls back to { canWriteRows: true, isAdmin: false } so that editing is
* optimistically enabled the server will reject the PATCH with 403 if the
* user truly lacks the permission, and the UI rolls back (see useUpdateRow).
*/
export function usePermissions(): UsePermissionsResult {
return useMemo(() => {
// Try the window-level cache set by the RBAC hook (R2.3a) when the query resolves.
// The cache key is `window.__acadenice_perms`.
const fromGlobal = (
typeof window !== "undefined" &&
(window as unknown as Record<string, unknown>)["__acadenice_perms"]
);
if (Array.isArray(fromGlobal)) {
return resolveFromPermissions(fromGlobal as string[]);
}
// Try the cookie fallback.
const fromCookie = readPermissionsFromCookie();
if (fromCookie !== null) {
return resolveFromPermissions(fromCookie);
}
// Optimistic default: allow writes (server is the guard).
return { canWriteRows: true, isAdmin: false, isResolved: false };
}, []);
}
function resolveFromPermissions(permissions: string[]): UsePermissionsResult {
const isAdmin = permissions.includes("admin:*");
const canWriteRows = isAdmin || permissions.includes("rows:write");
return { canWriteRows, isAdmin, isResolved: true };
}
function readPermissionsFromCookie(): string[] | null {
if (typeof document === "undefined") return null;
try {
const raw = document.cookie
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("acadenicePerms="));
if (!raw) return null;
const val = decodeURIComponent(raw.slice("acadenicePerms=".length));
const parsed = JSON.parse(val);
return Array.isArray(parsed) ? (parsed as string[]) : null;
} catch {
return null;
}
}

View file

@ -0,0 +1,111 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client";
import type { BridgeRow } from "../types/database-view.types";
import { VIEW_DATA_QUERY_KEY } from "./use-view-data";
export interface UpdateRowPayload {
/** Partial field values to patch — only changed fields. */
fields: Record<string, unknown>;
}
export interface UpdateRowContext {
/** Previous paginated cache entries for rollback on error. */
previousData: Map<string, unknown>;
}
interface UseUpdateRowOptions {
tableId: string;
viewId: string;
bridgeUrl?: string | null;
}
/**
* Generic PATCH row mutation with optimistic update and rollback on error.
*
* Why optimistic here instead of server-first:
* Inline editing feels sluggish if the UI waits for the network round-trip.
* We apply the change immediately, roll back silently on error, and let React
* Query reconcile the truth on the next cache invalidation (also triggered by
* the SSE event the bridge emits after the write).
*
* Pattern: React Query v5 `onMutate` snapshot + `onError` rollback + `onSettled`
* invalidation.
*/
export function useUpdateRow({ tableId, viewId, bridgeUrl }: UseUpdateRowOptions) {
const queryClient = useQueryClient();
const url = resolveBridgeUrl(bridgeUrl);
return useMutation<BridgeRow, Error, { rowId: string; payload: UpdateRowPayload }, UpdateRowContext>({
mutationFn: async ({ rowId, payload }) => {
const client = getBridgeClient(url);
return (await (client.patch(
`/api/v1/tables/${tableId}/rows/${rowId}`,
payload,
) as unknown)) as BridgeRow;
},
onMutate: async ({ rowId, payload }) => {
// Cancel in-flight queries to avoid clobbering the optimistic update.
await queryClient.cancelQueries({
queryKey: [VIEW_DATA_QUERY_KEY, viewId],
exact: false,
});
// Snapshot all cache entries for this view (all pages).
const previousData = new Map<string, unknown>();
const cache = queryClient.getQueriesData<{
rows: BridgeRow[];
fields: unknown[];
total: number;
hasNextPage: boolean;
}>({
queryKey: [VIEW_DATA_QUERY_KEY, viewId],
exact: false,
});
for (const [queryKey, data] of cache) {
const keyStr = JSON.stringify(queryKey);
previousData.set(keyStr, data);
if (data) {
// Apply optimistic patch — merge fields.
const updatedRows = data.rows.map((row) =>
row.id === rowId
? { ...row, fields: { ...row.fields, ...payload.fields } }
: row,
);
queryClient.setQueryData(queryKey, { ...data, rows: updatedRows });
}
}
return { previousData };
},
onError: (_error, _variables, context) => {
if (!context) return;
// Rollback all snapshot entries.
const cache = queryClient.getQueriesData<unknown>({
queryKey: [VIEW_DATA_QUERY_KEY, viewId],
exact: false,
});
for (const [queryKey] of cache) {
const keyStr = JSON.stringify(queryKey);
const previous = context.previousData.get(keyStr);
if (previous !== undefined) {
queryClient.setQueryData(queryKey, previous);
}
}
},
onSettled: () => {
// Always invalidate after mutation so the cache reconciles with the server.
// The SSE event from the bridge will also trigger this, making it idempotent.
queryClient.invalidateQueries({
queryKey: [VIEW_DATA_QUERY_KEY, viewId],
exact: false,
});
},
});
}

View file

@ -0,0 +1,49 @@
.toolbar {
margin-bottom: 12px;
}
.calendarWrapper {
/* FullCalendar needs a defined context for layout. */
font-size: var(--mantine-font-size-sm);
}
/* Mantine-compatible token overrides for FullCalendar's default theme. */
.calendarWrapper :global(.fc-toolbar-title) {
font-size: var(--mantine-font-size-md);
font-weight: 600;
}
.calendarWrapper :global(.fc-button) {
background-color: var(--mantine-color-gray-1);
color: var(--mantine-color-gray-8);
border: 1px solid var(--mantine-color-gray-3);
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-xs);
}
.calendarWrapper :global(.fc-button:hover) {
background-color: var(--mantine-color-gray-2);
}
.calendarWrapper :global(.fc-button-primary:not(:disabled):active),
.calendarWrapper :global(.fc-button-primary:not(:disabled).fc-button-active) {
background-color: var(--mantine-primary-color-filled);
border-color: var(--mantine-primary-color-filled);
color: white;
}
[data-mantine-color-scheme="dark"] .calendarWrapper :global(.fc-button) {
background-color: var(--mantine-color-dark-5);
color: var(--mantine-color-dark-0);
border-color: var(--mantine-color-dark-4);
}
[data-mantine-color-scheme="dark"] .calendarWrapper :global(.fc-button:hover) {
background-color: var(--mantine-color-dark-4);
}
.calendarEvent {
cursor: pointer;
border-radius: var(--mantine-radius-xs) !important;
font-size: var(--mantine-font-size-xs) !important;
}

View file

@ -0,0 +1,239 @@
/**
* DEPENDENCIES NEEDED (not installed convention fork):
* @fullcalendar/react@^6
* @fullcalendar/daygrid@^6
* @fullcalendar/timegrid@^6
* @fullcalendar/interaction@^6
*
* Rationale for FullCalendar over react-big-calendar:
* - More mature accessibility (ARIA roles, keyboard nav)
* - Better drag-drop support via @fullcalendar/interaction
* - Built-in month/week/day views with consistent API
* - Active maintenance and large community
*/
import { useState } from "react";
import { Text, Stack, Skeleton, Alert, Button, SegmentedControl } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDisclosure } from "@mantine/hooks";
// FullCalendar imports — these will resolve once the deps are installed.
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin, { EventDropArg } from "@fullcalendar/interaction";
import { EventClickArg } from "@fullcalendar/core";
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 { RowDetailModal } from "../components/row-detail-modal";
import type { BridgeRow, BridgeField } from "../types/database-view.types";
import styles from "./calendar-renderer.module.css";
const PAGE_SIZE = 500;
type CalendarView = "dayGridMonth" | "timeGridWeek" | "timeGridDay";
interface CalendarRendererProps {
tableId: string;
viewId: string;
bridgeUrl?: string | null;
}
/** Resolve the date field used to position events on the calendar. */
function resolveDateField(
fields: BridgeField[],
viewMeta?: { dateFieldId?: string },
): BridgeField | undefined {
if (viewMeta?.dateFieldId) {
return fields.find((f) => f.id === viewMeta.dateFieldId);
}
return fields.find((f) => f.type === "date" || f.type === "created_on");
}
/** Convert a row to a FullCalendar EventInput. Returns null when no date. */
function rowToEvent(
row: BridgeRow,
dateField: BridgeField,
primaryField?: BridgeField,
): { id: string; title: string; start: string; extendedProps: { row: BridgeRow; fields: BridgeField[] } } | null {
const rawDate = row.fields[dateField.name] ?? row.fields[dateField.id];
if (!rawDate) return null;
const dateStr = typeof rawDate === "string" ? rawDate : String(rawDate);
// Validate the date string.
if (isNaN(new Date(dateStr).getTime())) return null;
const primaryValue = primaryField
? (row.fields[primaryField.name] ?? row.fields[primaryField.id])
: row.id;
const title =
typeof primaryValue === "string"
? primaryValue
: typeof primaryValue === "number"
? String(primaryValue)
: row.id;
return {
id: row.id,
title: title || row.id,
start: dateStr,
extendedProps: { row, fields: [] },
};
}
function CalendarSkeleton() {
return (
<Stack gap="xs">
<Skeleton height={32} width={200} />
<Skeleton height={400} radius="sm" />
</Stack>
);
}
export function CalendarRenderer({ tableId, viewId, bridgeUrl }: CalendarRendererProps) {
const { t } = useTranslation();
const [calView, setCalView] = useState<CalendarView>("dayGridMonth");
const [selectedRow, setSelectedRow] = useState<BridgeRow | null>(null);
const [selectedFields, setSelectedFields] = useState<BridgeField[]>([]);
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
const { data, isLoading, isError, error, refetch } = useViewData({
viewId,
bridgeUrl,
page: 1,
size: PAGE_SIZE,
});
const { canWriteRows } = usePermissions();
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl);
if (isLoading) return <CalendarSkeleton />;
if (isError) {
const status = (error as { response?: { status?: number } })?.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 } = data ?? { rows: [], fields: [] };
const dateField = resolveDateField(fields);
if (!dateField) {
return (
<Alert icon={<IconAlertCircle size={16} />} color="yellow">
<Text size="sm">{t("database_view.calendar.no_date_field")}</Text>
</Alert>
);
}
const primaryField = fields.find((f) => f.primary) ?? fields[0];
const events = rows
.map((row) => rowToEvent(row, dateField, primaryField))
.filter((e): e is NonNullable<typeof e> => e !== null)
.map((e) => ({ ...e, extendedProps: { ...e.extendedProps, fields } }));
function handleEventClick(arg: EventClickArg) {
const row = arg.event.extendedProps.row as BridgeRow;
const eventFields = arg.event.extendedProps.fields as BridgeField[];
setSelectedRow(row);
setSelectedFields(eventFields);
openModal();
}
function handleEventDrop(arg: EventDropArg) {
if (!canWriteRows) {
arg.revert();
return;
}
const rowId = arg.event.id;
const newStart = arg.event.start;
if (!newStart) {
arg.revert();
return;
}
updateRow.mutate(
{
rowId,
payload: {
fields: {
[dateField.name]: newStart.toISOString(),
},
},
},
{
onError: () => {
arg.revert();
},
},
);
}
const viewOptions = [
{ label: t("database_view.calendar.view_month"), value: "dayGridMonth" },
{ label: t("database_view.calendar.view_week"), value: "timeGridWeek" },
{ label: t("database_view.calendar.view_day"), value: "timeGridDay" },
];
return (
<div>
<div className={styles.toolbar}>
<SegmentedControl
size="xs"
value={calView}
onChange={(v) => setCalView(v as CalendarView)}
data={viewOptions}
/>
</div>
<div className={styles.calendarWrapper}>
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView={calView}
key={calView}
events={events}
editable={canWriteRows}
droppable={canWriteRows}
eventClick={handleEventClick}
eventDrop={handleEventDrop}
headerToolbar={{
left: "prev,next today",
center: "title",
right: "",
}}
height="auto"
eventClassNames={() => [styles.calendarEvent]}
/>
</div>
<RowDetailModal
row={selectedRow}
fields={selectedFields}
opened={modalOpened}
onClose={closeModal}
/>
</div>
);
}

View file

@ -0,0 +1,99 @@
.board {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 8px;
align-items: flex-start;
min-height: 200px;
}
.column {
flex: 0 0 260px;
display: flex;
flex-direction: column;
background-color: var(--mantine-color-gray-0);
border-radius: var(--mantine-radius-md);
border: 1px solid var(--mantine-color-gray-2);
overflow: hidden;
max-height: 600px;
}
[data-mantine-color-scheme="dark"] .column {
background-color: var(--mantine-color-dark-7);
border-color: var(--mantine-color-dark-5);
}
.columnHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--mantine-color-gray-2);
background-color: var(--mantine-color-gray-1);
flex-shrink: 0;
}
[data-mantine-color-scheme="dark"] .columnHeader {
background-color: var(--mantine-color-dark-6);
border-bottom-color: var(--mantine-color-dark-4);
}
.columnTitle {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.columnBody {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.emptyColumn {
padding: 16px 8px;
text-align: center;
border: 2px dashed var(--mantine-color-gray-3);
border-radius: var(--mantine-radius-sm);
}
[data-mantine-color-scheme="dark"] .emptyColumn {
border-color: var(--mantine-color-dark-4);
}
.cardWrapper {
touch-action: none;
}
.card {
cursor: grab;
user-select: none;
transition: box-shadow 120ms ease;
}
.card:active {
cursor: grabbing;
}
.card:hover {
box-shadow: var(--mantine-shadow-sm);
}
.cardTitle {
line-height: 1.4;
word-break: break-word;
}
.cardTitle:hover {
text-decoration: underline;
cursor: text;
}
.dragOverlayCard {
cursor: grabbing;
box-shadow: var(--mantine-shadow-lg);
transform: rotate(2deg);
}

View file

@ -0,0 +1,422 @@
import { useState } from "react";
import {
Text,
Card,
Stack,
Badge,
Skeleton,
Alert,
Button,
Tooltip,
Group,
} from "@mantine/core";
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { IconAlertCircle, IconLock } 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 { BridgeRow, BridgeField } from "../types/database-view.types";
import styles from "./kanban-renderer.module.css";
/**
* DEPENDENCIES NEEDED (not installed convention fork):
* @dnd-kit/core@^6
* @dnd-kit/sortable@^8
* @dnd-kit/utilities@^3
*
* DEPENDENCY NEEDED (already listed in R3.1.c):
* @tanstack/react-table@^8
*/
const PAGE_SIZE = 200;
interface KanbanRendererProps {
tableId: string;
viewId: string;
bridgeUrl?: string | null;
}
interface Column {
id: string;
label: string;
rows: BridgeRow[];
}
/** Resolve the groupBy field: use singleSelectFieldId from view meta, or first single_select field. */
function resolveGroupByField(
fields: BridgeField[],
viewMeta?: { singleSelectFieldId?: string },
): BridgeField | undefined {
if (viewMeta?.singleSelectFieldId) {
return fields.find((f) => f.id === viewMeta.singleSelectFieldId);
}
return fields.find((f) => f.type === "single_select");
}
/** Extract column value from a row for a given field. */
function getColumnValue(row: BridgeRow, field: BridgeField): string {
const raw = row.fields[field.name] ?? row.fields[field.id];
if (!raw) return "";
if (typeof raw === "object" && raw !== null) {
const v = raw as { value?: string; id?: string | number };
return v.value ?? String(v.id ?? "");
}
return String(raw);
}
/** Build column definitions from the set of unique field values across all rows. */
function buildColumns(rows: BridgeRow[], groupByField: BridgeField): Column[] {
// Collect options from field.options (Baserow single_select has select_options).
const opts = groupByField.options as {
select_options?: { id: number | string; value: string }[];
} | null | undefined;
const definedOptions: { id: string; label: string }[] = (
opts?.select_options ?? []
).map((o) => ({ id: String(o.id), label: o.value }));
// Group rows by column value.
const columnMap = new Map<string, BridgeRow[]>();
// Ensure defined options are always present (even if empty).
for (const opt of definedOptions) {
columnMap.set(opt.label, []);
}
columnMap.set("", []); // unassigned column
for (const row of rows) {
const colLabel = getColumnValue(row, groupByField);
if (!columnMap.has(colLabel)) {
columnMap.set(colLabel, []);
}
columnMap.get(colLabel)!.push(row);
}
// Remove empty unassigned column if not needed.
const unassigned = columnMap.get("") ?? [];
if (unassigned.length === 0) {
columnMap.delete("");
}
return Array.from(columnMap.entries()).map(([label, colRows]) => ({
id: label || "__unassigned__",
label: label || "Unassigned",
rows: colRows,
}));
}
// --- Card components ---
interface KanbanCardProps {
row: BridgeRow;
primaryField: BridgeField | undefined;
canWrite: boolean;
onRename: (row: BridgeRow, newTitle: string) => void;
}
function KanbanCard({ row, primaryField, canWrite, onRename }: KanbanCardProps) {
const [editing, setEditing] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: row.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
const primaryValue = primaryField
? (row.fields[primaryField.name] ?? row.fields[primaryField.id])
: row.id;
const title =
typeof primaryValue === "string"
? primaryValue
: typeof primaryValue === "number"
? String(primaryValue)
: row.id;
const handleDoubleClick = () => {
if (canWrite && primaryField) {
setEditing(true);
}
};
return (
<div ref={setNodeRef} style={style} className={styles.cardWrapper}>
<Card
shadow="xs"
padding="xs"
radius="sm"
className={styles.card}
{...attributes}
{...listeners}
>
{editing && primaryField ? (
<InlineEditor
field={primaryField}
initialValue={title}
canWrite={canWrite}
onSave={(v) => {
setEditing(false);
onRename(row, String(v ?? ""));
}}
onCancel={() => setEditing(false)}
/>
) : (
<Text
size="sm"
className={styles.cardTitle}
onDoubleClick={handleDoubleClick}
title={canWrite ? undefined : "read-only"}
>
{title || row.id}
</Text>
)}
</Card>
</div>
);
}
interface KanbanColumnProps {
column: Column;
primaryField: BridgeField | undefined;
canWrite: boolean;
onCardRename: (row: BridgeRow, newTitle: string) => void;
}
function KanbanColumn({ column, primaryField, canWrite, onCardRename }: KanbanColumnProps) {
const { t } = useTranslation();
return (
<div className={styles.column}>
<div className={styles.columnHeader}>
<Text size="sm" fw={600} className={styles.columnTitle}>
{column.label}
</Text>
<Badge size="xs" variant="light" color="gray">
{column.rows.length}
</Badge>
</div>
<div className={styles.columnBody}>
<SortableContext
items={column.rows.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
{column.rows.length === 0 ? (
<div className={styles.emptyColumn}>
<Text size="xs" c="dimmed">
{t("database_view.kanban.empty_column")}
</Text>
</div>
) : (
column.rows.map((row) => (
<KanbanCard
key={row.id}
row={row}
primaryField={primaryField}
canWrite={canWrite}
onRename={onCardRename}
/>
))
)}
</SortableContext>
</div>
</div>
);
}
// --- Skeleton ---
function KanbanSkeleton() {
return (
<div className={styles.board}>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className={styles.column}>
<Skeleton height={24} mb="sm" />
<Stack gap="xs">
<Skeleton height={56} radius="sm" />
<Skeleton height={56} radius="sm" />
<Skeleton height={56} radius="sm" />
</Stack>
</div>
))}
</div>
);
}
// --- Main component ---
export function KanbanRenderer({ tableId, viewId, bridgeUrl }: KanbanRendererProps) {
const { t } = useTranslation();
const [activeId, setActiveId] = useState<string | null>(null);
const { data, isLoading, isError, error, refetch } = useViewData({
viewId,
bridgeUrl,
page: 1,
size: PAGE_SIZE,
});
const { canWriteRows } = usePermissions();
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
// Require 5px movement before drag starts — prevents accidental drags on click.
distance: 5,
},
}),
);
if (isLoading) return <KanbanSkeleton />;
if (isError) {
const status = (error as { response?: { status?: number } })?.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 } = data ?? { rows: [], fields: [] };
const groupByField = resolveGroupByField(fields);
if (!groupByField) {
return (
<Alert icon={<IconAlertCircle size={16} />} color="yellow">
<Text size="sm">{t("database_view.kanban.no_groupby_field")}</Text>
</Alert>
);
}
const primaryField = fields.find((f) => f.primary) ?? fields[0];
const columns = buildColumns(rows, groupByField);
const activeRow = activeId ? rows.find((r) => r.id === activeId) : null;
function handleDragStart(event: DragStartEvent) {
setActiveId(String(event.active.id));
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
// Find which column the card was dropped into.
// over.id could be a row id (dropped over another row) or a column id.
const overRowId = String(over.id);
// Find the column the target row belongs to.
const targetColumn = columns.find(
(col) =>
col.id === overRowId || col.rows.some((r) => r.id === overRowId),
);
if (!targetColumn) return;
const newColumnLabel = targetColumn.id === "__unassigned__" ? "" : targetColumn.label;
// Build the patch payload: set the single_select field to the new column.
updateRow.mutate({
rowId: String(active.id),
payload: {
fields: {
[groupByField.name]: newColumnLabel || null,
},
},
});
}
function handleCardRename(row: BridgeRow, newTitle: string) {
if (!primaryField || !canWriteRows) return;
updateRow.mutate({
rowId: row.id,
payload: { fields: { [primaryField.name]: newTitle } },
});
}
return (
<div>
{!canWriteRows && (
<Group gap="xs" mb="xs">
<IconLock size={14} />
<Text size="xs" c="dimmed">
{t("database_view.edit.read_only_mode")}
</Text>
</Group>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className={styles.board}>
{columns.map((col) => (
<KanbanColumn
key={col.id}
column={col}
primaryField={primaryField}
canWrite={canWriteRows}
onCardRename={handleCardRename}
/>
))}
</div>
<DragOverlay>
{activeRow && (
<Card shadow="md" padding="xs" radius="sm" className={styles.dragOverlayCard}>
<Text size="sm">
{String(
(primaryField
? activeRow.fields[primaryField.name] ?? activeRow.fields[primaryField.id]
: activeRow.id) ?? activeRow.id,
)}
</Text>
</Card>
)}
</DragOverlay>
</DndContext>
</div>
);
}

View file

@ -3,7 +3,10 @@ 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";
@ -30,6 +33,11 @@ interface TableRendererProps {
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 "";
@ -66,9 +74,22 @@ function TableSkeleton() {
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 }: TableBodyProps) {
function TableBody({
fields,
rows,
editingCell,
canWrite,
onCellDoubleClick,
onCellSave,
onCellCancel,
}: TableBodyProps) {
const { t } = useTranslation();
if (rows.length === 0) {
@ -87,11 +108,35 @@ function TableBody({ fields, rows }: TableBodyProps) {
<>
{rows.map((row) => (
<tr key={row.id} className={styles.tr}>
{fields.map((field) => (
<td key={field.id} className={styles.td}>
{formatCellValue(row.fields[field.name] ?? row.fields[field.id])}
{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>
))}
</>
@ -101,6 +146,7 @@ function TableBody({ fields, rows }: TableBodyProps) {
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,
@ -109,6 +155,9 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
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);
@ -147,6 +196,14 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
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}>
@ -161,7 +218,17 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
</tr>
</thead>
<tbody>
<TableBody fields={fields} rows={rows} />
<TableBody
fields={fields}
rows={rows}
editingCell={editingCell}
canWrite={canWriteRows}
onCellDoubleClick={(rowId, fieldId) =>
setEditingCell({ rowId, fieldId })
}
onCellSave={handleCellSave}
onCellCancel={() => setEditingCell(null)}
/>
</tbody>
</table>
</div>

View file

@ -81,3 +81,26 @@ export function getBridgeClient(bridgeUrl: string): AxiosInstance {
}
return _clients.get(bridgeUrl)!;
}
/**
* Patch a single row on the bridge.
*
* Why a named helper and not a direct client.patch():
* Callers (useUpdateRow) would have to resolve the URL themselves. This helper
* keeps the URL construction in one place and makes the intent explicit.
*
* The response is typed as unknown callers should not assume a specific shape
* as the bridge returns the updated row in its envelope format.
*/
export async function patchRow(
tableId: string,
rowId: string,
payload: { fields: Record<string, unknown> },
bridgeUrl: string,
): Promise<unknown> {
const client = getBridgeClient(bridgeUrl);
return (client.patch(
`/api/v1/tables/${tableId}/rows/${rowId}`,
payload,
) as unknown) as unknown;
}

View file

@ -6,8 +6,8 @@
/** View types the bridge exposes. Renderers for kanban/calendar arrive in R3.1.d. */
export type ViewType = "grid" | "table" | "kanban" | "calendar" | string;
/** Supported view types in R3.1.c. Others render a placeholder. */
export const SUPPORTED_VIEW_TYPES: readonly ViewType[] = ["grid", "table"];
/** Supported view types in R3.1.d. Others render a placeholder. */
export const SUPPORTED_VIEW_TYPES: readonly ViewType[] = ["grid", "table", "kanban", "calendar"];
/** Attrs stored on the Tiptap node. */
export interface DatabaseViewAttrs {