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:
parent
71c2abad8a
commit
f3fae2ac78
21 changed files with 2445 additions and 35 deletions
|
|
@ -1031,5 +1031,16 @@
|
||||||
"database_view.modal.no_views": "No views found for this table.",
|
"database_view.modal.no_views": "No views found for this table.",
|
||||||
"database_view.modal.select_view": "Select a view to embed:",
|
"database_view.modal.select_view": "Select a view to embed:",
|
||||||
"database_view.modal.back": "Back",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -985,5 +985,16 @@
|
||||||
"database_view.modal.no_views": "Aucune vue trouvée pour cette table.",
|
"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.select_view": "Sélectionnez une vue à intégrer :",
|
||||||
"database_view.modal.back": "Retour",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
/**
|
/**
|
||||||
* Tests for DatabaseViewComponent (NodeViewWrapper).
|
* Tests for DatabaseViewComponent (NodeViewWrapper).
|
||||||
*
|
*
|
||||||
* Covers:
|
* Covers (updated R3.1.d):
|
||||||
* - renders TableRenderer when viewType is "grid" or "table"
|
* - renders TableRenderer when viewType is "grid" or "table"
|
||||||
* - renders PlaceholderRenderer for unsupported viewTypes
|
* - renders KanbanRenderer when viewType is "kanban"
|
||||||
* - passes tableId/viewId/bridgeUrl to TableRenderer
|
* - 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
|
* - shows "selected" class when the node is selected in ProseMirror
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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.
|
// Mock PlaceholderRenderer.
|
||||||
vi.mock("../renderers/placeholder-renderer", () => ({
|
vi.mock("../renderers/placeholder-renderer", () => ({
|
||||||
PlaceholderRenderer: ({ viewType }: { viewType: string }) => (
|
PlaceholderRenderer: ({ viewType }: { viewType: string }) => (
|
||||||
|
|
@ -110,7 +142,7 @@ describe("DatabaseViewComponent", () => {
|
||||||
expect(screen.getByTestId("table-renderer")).toBeInTheDocument();
|
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({
|
const props = makeNodeViewProps({
|
||||||
tableId: "t3",
|
tableId: "t3",
|
||||||
viewId: "v3",
|
viewId: "v3",
|
||||||
|
|
@ -122,13 +154,11 @@ describe("DatabaseViewComponent", () => {
|
||||||
<DatabaseViewComponent {...props} />
|
<DatabaseViewComponent {...props} />
|
||||||
</Wrapper>,
|
</Wrapper>,
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("placeholder-renderer")).toBeInTheDocument();
|
expect(screen.getByTestId("kanban-renderer")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("placeholder-renderer")).toHaveTextContent(
|
expect(screen.getByTestId("kanban-renderer")).toHaveTextContent("kanban:t3:v3");
|
||||||
"placeholder:kanban",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders PlaceholderRenderer for unsupported viewType calendar", () => {
|
it("renders CalendarRenderer for viewType calendar (R3.1.d)", () => {
|
||||||
const props = makeNodeViewProps({
|
const props = makeNodeViewProps({
|
||||||
tableId: "t4",
|
tableId: "t4",
|
||||||
viewId: "v4",
|
viewId: "v4",
|
||||||
|
|
@ -140,7 +170,26 @@ describe("DatabaseViewComponent", () => {
|
||||||
<DatabaseViewComponent {...props} />
|
<DatabaseViewComponent {...props} />
|
||||||
</Wrapper>,
|
</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")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("placeholder-renderer")).toHaveTextContent(
|
||||||
|
"placeholder:gallery",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the node header label", () => {
|
it("shows the node header label", () => {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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] }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,10 @@ import type { NodeViewProps } from "@tiptap/react";
|
||||||
import { IconTable } from "@tabler/icons-react";
|
import { IconTable } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { SUPPORTED_VIEW_TYPES } from "../types/database-view.types";
|
|
||||||
import type { DatabaseViewAttrs, ViewType } from "../types/database-view.types";
|
import type { DatabaseViewAttrs, ViewType } from "../types/database-view.types";
|
||||||
import { TableRenderer } from "../renderers/table-renderer";
|
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 { PlaceholderRenderer } from "../renderers/placeholder-renderer";
|
||||||
import styles from "./database-view.module.css";
|
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.
|
* React NodeViewWrapper for the `database-view` Tiptap node.
|
||||||
*
|
*
|
||||||
* Dispatches on `attrs.viewType`:
|
* Dispatches on `attrs.viewType`:
|
||||||
* - "grid" | "table" -> TableRenderer (R3.1.c)
|
* - "grid" | "table" -> TableRenderer (R3.1.c, now with inline edit R3.1.d)
|
||||||
* - everything else -> PlaceholderRenderer (rendered in R3.1.d)
|
* - "kanban" -> KanbanRenderer (R3.1.d)
|
||||||
*
|
* - "calendar" -> CalendarRenderer (R3.1.d)
|
||||||
* Edit inline (row mutations) is intentionally absent — R3.1.c is read-only.
|
* - anything else -> PlaceholderRenderer
|
||||||
*/
|
*/
|
||||||
export function DatabaseViewComponent({ node, selected }: NodeViewProps) {
|
export function DatabaseViewComponent({ node, selected }: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const attrs = node.attrs as DatabaseViewAttrs;
|
const attrs = node.attrs as DatabaseViewAttrs;
|
||||||
const { tableId, viewId, viewType, bridgeUrl } = attrs;
|
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 (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
|
|
@ -39,15 +52,7 @@ export function DatabaseViewComponent({ node, selected }: NodeViewProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{isSupported ? (
|
{renderContent(viewType as ViewType)}
|
||||||
<TableRenderer
|
|
||||||
tableId={tableId}
|
|
||||||
viewId={viewId}
|
|
||||||
bridgeUrl={bridgeUrl}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<PlaceholderRenderer viewType={viewType} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,10 @@ import { Text, Button, Group, Skeleton, Stack, Alert } from "@mantine/core";
|
||||||
import { IconAlertCircle, IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
import { IconAlertCircle, IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useViewData } from "../hooks/use-view-data";
|
import { useViewData } from "../hooks/use-view-data";
|
||||||
|
import { useUpdateRow } from "../hooks/use-update-row";
|
||||||
import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates";
|
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 type { BridgeField, BridgeRow } from "../types/database-view.types";
|
||||||
import styles from "./table-renderer.module.css";
|
import styles from "./table-renderer.module.css";
|
||||||
|
|
||||||
|
|
@ -30,6 +33,11 @@ interface TableRendererProps {
|
||||||
bridgeUrl?: string | null;
|
bridgeUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EditingCell {
|
||||||
|
rowId: string;
|
||||||
|
fieldId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Display format for a raw cell value — keeps the table readable without edit. */
|
/** Display format for a raw cell value — keeps the table readable without edit. */
|
||||||
function formatCellValue(value: unknown): string {
|
function formatCellValue(value: unknown): string {
|
||||||
if (value === null || value === undefined) return "";
|
if (value === null || value === undefined) return "";
|
||||||
|
|
@ -66,9 +74,22 @@ function TableSkeleton() {
|
||||||
interface TableBodyProps {
|
interface TableBodyProps {
|
||||||
fields: BridgeField[];
|
fields: BridgeField[];
|
||||||
rows: BridgeRow[];
|
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();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
|
@ -87,11 +108,35 @@ function TableBody({ fields, rows }: TableBodyProps) {
|
||||||
<>
|
<>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.id} className={styles.tr}>
|
<tr key={row.id} className={styles.tr}>
|
||||||
{fields.map((field) => (
|
{fields.map((field) => {
|
||||||
<td key={field.id} className={styles.td}>
|
const isEditing =
|
||||||
{formatCellValue(row.fields[field.name] ?? row.fields[field.id])}
|
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>
|
</td>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
@ -101,6 +146,7 @@ function TableBody({ fields, rows }: TableBodyProps) {
|
||||||
export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps) {
|
export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, isError, error, refetch } = useViewData({
|
const { data, isLoading, isError, error, refetch } = useViewData({
|
||||||
viewId,
|
viewId,
|
||||||
|
|
@ -109,6 +155,9 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
|
||||||
size: PAGE_SIZE,
|
size: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { canWriteRows } = usePermissions();
|
||||||
|
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
|
||||||
|
|
||||||
// Subscribe to SSE updates — invalidates React Query cache on row/view events.
|
// Subscribe to SSE updates — invalidates React Query cache on row/view events.
|
||||||
useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl);
|
useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl);
|
||||||
|
|
||||||
|
|
@ -147,6 +196,14 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function handleCellSave(rowId: string, field: BridgeField, value: unknown) {
|
||||||
|
setEditingCell(null);
|
||||||
|
updateRow.mutate({
|
||||||
|
rowId,
|
||||||
|
payload: { fields: { [field.name]: value } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
|
|
@ -161,7 +218,17 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -81,3 +81,26 @@ export function getBridgeClient(bridgeUrl: string): AxiosInstance {
|
||||||
}
|
}
|
||||||
return _clients.get(bridgeUrl)!;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
/** View types the bridge exposes. Renderers for kanban/calendar arrive in R3.1.d. */
|
/** View types the bridge exposes. Renderers for kanban/calendar arrive in R3.1.d. */
|
||||||
export type ViewType = "grid" | "table" | "kanban" | "calendar" | string;
|
export type ViewType = "grid" | "table" | "kanban" | "calendar" | string;
|
||||||
|
|
||||||
/** Supported view types in R3.1.c. Others render a placeholder. */
|
/** Supported view types in R3.1.d. Others render a placeholder. */
|
||||||
export const SUPPORTED_VIEW_TYPES: readonly ViewType[] = ["grid", "table"];
|
export const SUPPORTED_VIEW_TYPES: readonly ViewType[] = ["grid", "table", "kanban", "calendar"];
|
||||||
|
|
||||||
/** Attrs stored on the Tiptap node. */
|
/** Attrs stored on the Tiptap node. */
|
||||||
export interface DatabaseViewAttrs {
|
export interface DatabaseViewAttrs {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue