diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 78f1cd42..ed5d1c24 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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." } diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 102b1b8b..72e797c8 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -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." } diff --git a/apps/client/src/features/acadenice/database-view/__tests__/calendar-renderer.test.tsx b/apps/client/src/features/acadenice/database-view/__tests__/calendar-renderer.test.tsx new file mode 100644 index 00000000..01c2b34c --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/__tests__/calendar-renderer.test.tsx @@ -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 = {}; +vi.mock("@fullcalendar/react", () => ({ + default: (props: Record) => { + capturedProps = props; + const events = (props.events as { id: string; title: string; start: string }[]) ?? []; + return ( +
+ {events.map((e) => ( +
+ {e.title} +
+ ))} +
+ ); + }, +})); + +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; +const mockUsePermissions = usePermissions as ReturnType; + +function Wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {children} + + ); +} + +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + + // 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(); + + 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(); + }); +}); diff --git a/apps/client/src/features/acadenice/database-view/__tests__/database-view-component.test.tsx b/apps/client/src/features/acadenice/database-view/__tests__/database-view-component.test.tsx index 965c796a..42d74230 100644 --- a/apps/client/src/features/acadenice/database-view/__tests__/database-view-component.test.tsx +++ b/apps/client/src/features/acadenice/database-view/__tests__/database-view-component.test.tsx @@ -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; + }) => ( +
+ kanban:{tableId}:{viewId} +
+ ), +})); + +// Mock CalendarRenderer (R3.1.d). +vi.mock("../renderers/calendar-renderer", () => ({ + CalendarRenderer: ({ + tableId, + viewId, + }: { + tableId: string; + viewId: string; + }) => ( +
+ calendar:{tableId}:{viewId} +
+ ), +})); + // 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", () => { , ); - 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", () => { , ); + 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( + + + , + ); expect(screen.getByTestId("placeholder-renderer")).toBeInTheDocument(); + expect(screen.getByTestId("placeholder-renderer")).toHaveTextContent( + "placeholder:gallery", + ); }); it("shows the node header label", () => { diff --git a/apps/client/src/features/acadenice/database-view/__tests__/inline-editor.test.tsx b/apps/client/src/features/acadenice/database-view/__tests__/inline-editor.test.tsx new file mode 100644 index 00000000..be028d08 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/__tests__/inline-editor.test.tsx @@ -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 {children}; +} + +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( + + + , + ); + + expect(screen.getByText("Alice")).toBeInTheDocument(); + // No input rendered. + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + }); + + it("renders text input when canWrite is true", () => { + render( + + + , + ); + + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("calls onSave with updated value on Enter key", async () => { + const onSave = vi.fn(); + const user = userEvent.setup(); + + render( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + // 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( + + + , + ); + + await waitFor(() => { + // Mantine Select renders an input with role="combobox". + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/client/src/features/acadenice/database-view/__tests__/kanban-renderer.test.tsx b/apps/client/src/features/acadenice/database-view/__tests__/kanban-renderer.test.tsx new file mode 100644 index 00000000..8d0bc8a4 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/__tests__/kanban-renderer.test.tsx @@ -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; +const mockUsePermissions = usePermissions as ReturnType; + +function Wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {children} + + ); +} + +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(); + // 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(); + 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(); + 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(); + // 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(); + 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(); + // "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(); + expect(screen.getByText("database_view.edit.read_only_mode")).toBeInTheDocument(); + }); +}); diff --git a/apps/client/src/features/acadenice/database-view/__tests__/use-permissions.test.tsx b/apps/client/src/features/acadenice/database-view/__tests__/use-permissions.test.tsx new file mode 100644 index 00000000..59bdd8d4 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/__tests__/use-permissions.test.tsx @@ -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)["__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)["__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)["__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)["__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); + }); +}); diff --git a/apps/client/src/features/acadenice/database-view/__tests__/use-update-row.test.tsx b/apps/client/src/features/acadenice/database-view/__tests__/use-update-row.test.tsx new file mode 100644 index 00000000..5ffe9ab7 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/__tests__/use-update-row.test.tsx @@ -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(); + return { + ...original, + getBridgeClient: vi.fn(), + resolveBridgeUrl: vi.fn(() => "http://localhost:4000"), + }; +}); + +function makeWrapper(qc: QueryClient) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +const TABLE_ID = "tbl1"; +const VIEW_ID = "view1"; +const ROW_ID = "row1"; + +describe("useUpdateRow", () => { + let qc: QueryClient; + let patchMock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + patchMock = vi.fn(); + (bridgeClientModule.getBridgeClient as ReturnType).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] }), + ); + }); +}); diff --git a/apps/client/src/features/acadenice/database-view/components/inline-editor.module.css b/apps/client/src/features/acadenice/database-view/components/inline-editor.module.css new file mode 100644 index 00000000..a8523e2e --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/components/inline-editor.module.css @@ -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); +} diff --git a/apps/client/src/features/acadenice/database-view/components/inline-editor.tsx b/apps/client/src/features/acadenice/database-view/components/inline-editor.tsx new file mode 100644 index 00000000..99eeb1f1 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/components/inline-editor.tsx @@ -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(initialValue); + const inputRef = useRef(null); + + useEffect(() => { + // Auto-focus on mount. + setTimeout(() => inputRef.current?.focus(), 0); + }, []); + + if (!canWrite) { + return ( + + {formatDisplayValue(initialValue)} + + ); + } + + const handleKeyDown = (e: KeyboardEvent) => { + 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 ( + } + 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 ( + { + setValue(v ? v.toISOString() : null); + }} + onBlur={handleBlur} + className={styles.input} + size="xs" + clearable + /> + ); + + case "single_select": + return ( + } + 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 ( + 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, + })); +} diff --git a/apps/client/src/features/acadenice/database-view/components/row-detail-modal.tsx b/apps/client/src/features/acadenice/database-view/components/row-detail-modal.tsx new file mode 100644 index 00000000..0455f94c --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/components/row-detail-modal.tsx @@ -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 ( + + + {fields.map((field) => { + const rawValue = row.fields[field.name] ?? row.fields[field.id]; + return ( +
+ + + {field.name} + + {field.primary && ( + + {t("database_view.row_detail.primary_badge")} + + )} + + {formatValue(rawValue)} + +
+ ); + })} + + {fields.length === 0 && ( + + {t("database_view.row_detail.no_fields")} + + )} +
+
+ ); +} + +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); +} diff --git a/apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx b/apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx index 84871cfa..abe3e818 100644 --- a/apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx +++ b/apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx @@ -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 ; + case "kanban": + return ; + case "calendar": + return ; + default: + return ; + } + } return ( @@ -39,15 +52,7 @@ export function DatabaseViewComponent({ node, selected }: NodeViewProps) {
- {isSupported ? ( - - ) : ( - - )} + {renderContent(viewType as ViewType)}
diff --git a/apps/client/src/features/acadenice/database-view/hooks/use-permissions.ts b/apps/client/src/features/acadenice/database-view/hooks/use-permissions.ts new file mode 100644 index 00000000..bd7089bf --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/hooks/use-permissions.ts @@ -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)["__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; + } +} diff --git a/apps/client/src/features/acadenice/database-view/hooks/use-update-row.ts b/apps/client/src/features/acadenice/database-view/hooks/use-update-row.ts new file mode 100644 index 00000000..9419d464 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/hooks/use-update-row.ts @@ -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; +} + +export interface UpdateRowContext { + /** Previous paginated cache entries for rollback on error. */ + previousData: Map; +} + +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({ + 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(); + 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({ + 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, + }); + }, + }); +} diff --git a/apps/client/src/features/acadenice/database-view/renderers/calendar-renderer.module.css b/apps/client/src/features/acadenice/database-view/renderers/calendar-renderer.module.css new file mode 100644 index 00000000..b7277837 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/renderers/calendar-renderer.module.css @@ -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; +} diff --git a/apps/client/src/features/acadenice/database-view/renderers/calendar-renderer.tsx b/apps/client/src/features/acadenice/database-view/renderers/calendar-renderer.tsx new file mode 100644 index 00000000..59e6b861 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/renderers/calendar-renderer.tsx @@ -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 ( + + + + + ); +} + +export function CalendarRenderer({ tableId, viewId, bridgeUrl }: CalendarRendererProps) { + const { t } = useTranslation(); + const [calView, setCalView] = useState("dayGridMonth"); + const [selectedRow, setSelectedRow] = useState(null); + const [selectedFields, setSelectedFields] = useState([]); + 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 ; + + 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 ( + } + color="red" + title={t("database_view.error.title")} + > + + {message} + + + + ); + } + + const { rows, fields } = data ?? { rows: [], fields: [] }; + const dateField = resolveDateField(fields); + + if (!dateField) { + return ( + } color="yellow"> + {t("database_view.calendar.no_date_field")} + + ); + } + + const primaryField = fields.find((f) => f.primary) ?? fields[0]; + + const events = rows + .map((row) => rowToEvent(row, dateField, primaryField)) + .filter((e): e is NonNullable => 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 ( +
+
+ setCalView(v as CalendarView)} + data={viewOptions} + /> +
+ +
+ [styles.calendarEvent]} + /> +
+ + +
+ ); +} diff --git a/apps/client/src/features/acadenice/database-view/renderers/kanban-renderer.module.css b/apps/client/src/features/acadenice/database-view/renderers/kanban-renderer.module.css new file mode 100644 index 00000000..4518de9c --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/renderers/kanban-renderer.module.css @@ -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); +} diff --git a/apps/client/src/features/acadenice/database-view/renderers/kanban-renderer.tsx b/apps/client/src/features/acadenice/database-view/renderers/kanban-renderer.tsx new file mode 100644 index 00000000..6bceff43 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/renderers/kanban-renderer.tsx @@ -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(); + + // 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 ( +
+ + {editing && primaryField ? ( + { + setEditing(false); + onRename(row, String(v ?? "")); + }} + onCancel={() => setEditing(false)} + /> + ) : ( + + {title || row.id} + + )} + +
+ ); +} + +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 ( +
+
+ + {column.label} + + + {column.rows.length} + +
+ +
+ r.id)} + strategy={verticalListSortingStrategy} + > + {column.rows.length === 0 ? ( +
+ + {t("database_view.kanban.empty_column")} + +
+ ) : ( + column.rows.map((row) => ( + + )) + )} +
+
+
+ ); +} + +// --- Skeleton --- + +function KanbanSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + + + + + +
+ ))} +
+ ); +} + +// --- Main component --- + +export function KanbanRenderer({ tableId, viewId, bridgeUrl }: KanbanRendererProps) { + const { t } = useTranslation(); + const [activeId, setActiveId] = useState(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 ; + + 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 ( + } + color="red" + title={t("database_view.error.title")} + > + + {message} + + + + ); + } + + const { rows, fields } = data ?? { rows: [], fields: [] }; + + const groupByField = resolveGroupByField(fields); + + if (!groupByField) { + return ( + } color="yellow"> + {t("database_view.kanban.no_groupby_field")} + + ); + } + + 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 ( +
+ {!canWriteRows && ( + + + + {t("database_view.edit.read_only_mode")} + + + )} + + +
+ {columns.map((col) => ( + + ))} +
+ + + {activeRow && ( + + + {String( + (primaryField + ? activeRow.fields[primaryField.name] ?? activeRow.fields[primaryField.id] + : activeRow.id) ?? activeRow.id, + )} + + + )} + +
+
+ ); +} diff --git a/apps/client/src/features/acadenice/database-view/renderers/table-renderer.tsx b/apps/client/src/features/acadenice/database-view/renderers/table-renderer.tsx index 3e989090..32eabbd9 100644 --- a/apps/client/src/features/acadenice/database-view/renderers/table-renderer.tsx +++ b/apps/client/src/features/acadenice/database-view/renderers/table-renderer.tsx @@ -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) => ( - {fields.map((field) => ( - - {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 ( + { + if (!isEditing) { + onCellDoubleClick(row.id, field.id); + } + }} + > + {isEditing ? ( + onCellSave(row.id, field, v)} + onCancel={onCellCancel} + /> + ) : ( + formatCellValue(cellValue) + )} + + ); + })} ))} @@ -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(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 (
@@ -161,7 +218,17 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps - + + setEditingCell({ rowId, fieldId }) + } + onCellSave={handleCellSave} + onCellCancel={() => setEditingCell(null)} + />
diff --git a/apps/client/src/features/acadenice/database-view/services/bridge-client.ts b/apps/client/src/features/acadenice/database-view/services/bridge-client.ts index da59f41c..e6a1dc80 100644 --- a/apps/client/src/features/acadenice/database-view/services/bridge-client.ts +++ b/apps/client/src/features/acadenice/database-view/services/bridge-client.ts @@ -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 }, + bridgeUrl: string, +): Promise { + const client = getBridgeClient(bridgeUrl); + return (client.patch( + `/api/v1/tables/${tableId}/rows/${rowId}`, + payload, + ) as unknown) as unknown; +} diff --git a/apps/client/src/features/acadenice/database-view/types/database-view.types.ts b/apps/client/src/features/acadenice/database-view/types/database-view.types.ts index 80b723ce..758614fa 100644 --- a/apps/client/src/features/acadenice/database-view/types/database-view.types.ts +++ b/apps/client/src/features/acadenice/database-view/types/database-view.types.ts @@ -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 {