feat(acadenice): add timeline view (Gantt) for databases — R4.1

- New TimelineRenderer using @fullcalendar/timeline + @fullcalendar/resource-timeline
- Column mapping config (title/start/end/resource) persisted in bridge Redis TTL 30d
- useTimelineConfig hook (GET+POST /views/:viewId/timeline-config)
- Inline config panel shown on first use; re-accessible via Configure button
- Resource swimlane mode activated automatically when resourceCol is configured
- eventResize persists endCol via useUpdateRow (respects canWriteRows)
- End date fallback: start + 1 day when endCol is absent
- InsertDatabaseModal extended with step 3 for column mapping on timeline views
- database-view-component dispatches timeline viewType to TimelineRenderer
- 14 client Vitest tests + 12 bridge Vitest tests (26 total new, all green)
- 0 TypeScript errors (client + bridge)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-08 11:27:11 +02:00
parent 3c6478826a
commit d0b75774d8
9 changed files with 1267 additions and 20 deletions

View file

@ -23,7 +23,9 @@
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/resource-timeline": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@fullcalendar/timeline": "^6.1.20",
"@mantine/core": "^8.3.18",
"@mantine/dates": "^8.3.18",
"@mantine/form": "^8.3.18",

View file

@ -0,0 +1,406 @@
/**
* Tests for TimelineRenderer.
*
* Approach: mock FullCalendar (same pattern as calendar-renderer.test.tsx),
* mock useTimelineConfig and useViewData, verify UI states and interaction handlers.
*
* Covers:
* 1. shows skeleton when data loading
* 2. shows skeleton when config loading
* 3. shows error alert on data load error
* 4. shows config panel when no config saved (config === null)
* 5. renders timeline stub when data + config present
* 6. passes correct events from rows to FullCalendar
* 7. rows missing start date are excluded from events
* 8. end_date fallback: when endCol absent, end = start + 1 day
* 9. resource swimlane: resourceId set on events when resourceCol configured
* 10. eventResize calls updateRow.mutate with new end date when canWriteRows true
* 11. eventResize calls revert when canWriteRows is false
* 12. eventClick opens RowDetailModal
* 13. configure button shows config panel
* 14. empty events shows empty state alert
*/
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 { TimelineRenderer } from "../renderers/timeline-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() }]),
}));
const saveConfigMock = vi.fn();
vi.mock("../hooks/use-timeline-config", () => ({
useTimelineConfig: vi.fn(() => ({
config: null,
isLoading: false,
isError: false,
saveConfig: saveConfigMock,
isSaving: false,
})),
}));
// Stub FullCalendar to expose captured props for handler testing.
let capturedFcProps: Record<string, unknown> = {};
vi.mock("@fullcalendar/react", () => ({
default: (props: Record<string, unknown>) => {
capturedFcProps = props;
const events = (props.events as Array<{
id: string;
title: string;
start: string;
end: string;
resourceId?: string;
}>) ?? [];
return (
<div data-testid="fullcalendar-stub">
{events.map((e) => (
<div
key={e.id}
data-testid={`event-${e.id}`}
data-start={e.start}
data-end={e.end}
data-resource={e.resourceId ?? ""}
>
{e.title}
</div>
))}
</div>
);
},
}));
vi.mock("@fullcalendar/timeline", () => ({ default: {} }));
vi.mock("@fullcalendar/resource-timeline", () => ({ default: {} }));
vi.mock("@fullcalendar/interaction", () => ({ default: {} }));
import { useViewData } from "../hooks/use-view-data";
import { usePermissions } from "../hooks/use-permissions";
import { useTimelineConfig } from "../hooks/use-timeline-config";
const mockUseViewData = useViewData as ReturnType<typeof vi.fn>;
const mockUsePermissions = usePermissions as ReturnType<typeof vi.fn>;
const mockUseTimelineConfig = useTimelineConfig 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: "Start", type: "date", options: null },
{ id: "f3", name: "End", type: "date", options: null },
{ id: "f4", name: "Team", type: "text", options: null },
];
const CONFIG = {
titleCol: "Name",
startCol: "Start",
endCol: "End",
resourceCol: null,
};
const ROWS = [
{
id: "r1",
tableId: "t1",
fields: { Name: "Task A", Start: "2026-06-01T00:00:00Z", End: "2026-06-05T00:00:00Z" },
},
{
id: "r2",
tableId: "t1",
fields: { Name: "Task B", Start: "2026-06-10T00:00:00Z", End: "2026-06-20T00:00:00Z" },
},
{
id: "r3",
tableId: "t1",
fields: { Name: "No start date" },
},
];
function setupDefaults() {
mockUsePermissions.mockReturnValue({ canWriteRows: true, isAdmin: false, isResolved: true });
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
error: null,
refetch: vi.fn(),
});
mockUseTimelineConfig.mockReturnValue({
config: CONFIG,
isLoading: false,
isError: false,
saveConfig: saveConfigMock,
isSaving: false,
});
}
describe("TimelineRenderer", () => {
beforeEach(() => {
vi.clearAllMocks();
capturedFcProps = {};
setupDefaults();
});
it("shows skeleton when data is loading", () => {
mockUseViewData.mockReturnValue({
isLoading: true,
isError: false,
data: null,
error: null,
refetch: vi.fn(),
});
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
expect(screen.queryByTestId("fullcalendar-stub")).not.toBeInTheDocument();
expect(screen.queryByTestId("timeline-renderer")).not.toBeInTheDocument();
});
it("shows skeleton when config is loading", () => {
mockUseTimelineConfig.mockReturnValue({
config: null,
isLoading: true,
isError: false,
saveConfig: saveConfigMock,
isSaving: false,
});
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
expect(screen.queryByTestId("fullcalendar-stub")).not.toBeInTheDocument();
});
it("shows error alert on data load error", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: true,
data: null,
error: { response: { status: 500 } },
refetch: vi.fn(),
});
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
expect(screen.getByText("database_view.error.generic")).toBeInTheDocument();
});
it("shows config panel when no config saved", () => {
mockUseTimelineConfig.mockReturnValue({
config: null,
isLoading: false,
isError: false,
saveConfig: saveConfigMock,
isSaving: false,
});
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
expect(screen.getByTestId("timeline-config-panel")).toBeInTheDocument();
expect(screen.queryByTestId("fullcalendar-stub")).not.toBeInTheDocument();
});
it("renders timeline stub when data and config are present", () => {
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
expect(screen.getByTestId("timeline-renderer")).toBeInTheDocument();
expect(screen.getByTestId("fullcalendar-stub")).toBeInTheDocument();
});
it("passes only rows with a valid start date as events (excludes r3)", () => {
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
expect(screen.getByTestId("event-r1")).toBeInTheDocument();
expect(screen.getByTestId("event-r2")).toBeInTheDocument();
expect(screen.queryByTestId("event-r3")).not.toBeInTheDocument();
});
it("sets event end = start + 1 day when endCol is null", () => {
mockUseTimelineConfig.mockReturnValue({
config: { ...CONFIG, endCol: null },
isLoading: false,
isError: false,
saveConfig: saveConfigMock,
isSaving: false,
});
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
const eventEl = screen.getByTestId("event-r1");
const startStr = eventEl.getAttribute("data-start")!;
const endStr = eventEl.getAttribute("data-end")!;
const start = new Date(startStr);
const end = new Date(endStr);
const diffDays = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24);
expect(diffDays).toBe(1);
});
it("attaches resourceId to events when resourceCol is configured", () => {
const rowsWithTeam = [
{
id: "r1",
tableId: "t1",
fields: {
Name: "Task A",
Start: "2026-06-01T00:00:00Z",
End: "2026-06-05T00:00:00Z",
Team: "Alpha",
},
},
];
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: { rows: rowsWithTeam, fields: FIELDS, total: 1, hasNextPage: false },
error: null,
refetch: vi.fn(),
});
mockUseTimelineConfig.mockReturnValue({
config: { ...CONFIG, resourceCol: "Team" },
isLoading: false,
isError: false,
saveConfig: saveConfigMock,
isSaving: false,
});
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
const eventEl = screen.getByTestId("event-r1");
expect(eventEl.getAttribute("data-resource")).toBe("Alpha");
});
it("eventResize calls mutate with new end date when canWriteRows is true", () => {
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
const newEnd = new Date("2026-06-25T00:00:00Z");
const revert = vi.fn();
const resizeArg = {
event: { id: "r1", end: newEnd },
revert,
};
const eventResizeFn = capturedFcProps.eventResize as (arg: typeof resizeArg) => void;
expect(typeof eventResizeFn).toBe("function");
eventResizeFn(resizeArg);
expect(mutateMock).toHaveBeenCalledWith(
{
rowId: "r1",
payload: { fields: { End: newEnd.toISOString() } },
},
expect.any(Object),
);
});
it("eventResize calls revert when canWriteRows is false", () => {
mockUsePermissions.mockReturnValue({ canWriteRows: false, isAdmin: false, isResolved: true });
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
const revert = vi.fn();
const resizeFn = capturedFcProps.eventResize as (arg: {
event: { id: string; end: Date };
revert: () => void;
}) => void;
resizeFn({ event: { id: "r1", end: new Date() }, revert });
expect(revert).toHaveBeenCalled();
expect(mutateMock).not.toHaveBeenCalled();
});
it("shows empty state alert when no events can be built", () => {
mockUseViewData.mockReturnValue({
isLoading: false,
isError: false,
data: {
rows: [{ id: "r1", tableId: "t1", fields: { Name: "No date" } }],
fields: FIELDS,
total: 1,
hasNextPage: false,
},
error: null,
refetch: vi.fn(),
});
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
expect(screen.getByText("database_view.timeline.no_events")).toBeInTheDocument();
});
it("configure button re-shows the config panel", async () => {
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
expect(screen.getByTestId("timeline-renderer")).toBeInTheDocument();
const configBtn = screen.getByRole("button", {
name: "database_view.timeline.configure",
});
fireEvent.click(configBtn);
await waitFor(() => {
expect(screen.getByTestId("timeline-config-panel")).toBeInTheDocument();
});
});
it("eventClick handler is a function exposed to FullCalendar", () => {
render(
<Wrapper>
<TimelineRenderer tableId="t1" viewId="v1" />
</Wrapper>,
);
expect(typeof capturedFcProps.eventClick).toBe("function");
});
});

View file

@ -7,6 +7,7 @@ import type { DatabaseViewAttrs, ViewType } from "../types/database-view.types";
import { TableRenderer } from "../renderers/table-renderer";
import { KanbanRenderer } from "../renderers/kanban-renderer";
import { CalendarRenderer } from "../renderers/calendar-renderer";
import { TimelineRenderer } from "../renderers/timeline-renderer";
import { PlaceholderRenderer } from "../renderers/placeholder-renderer";
import styles from "./database-view.module.css";
@ -33,6 +34,8 @@ export function DatabaseViewComponent({ node, selected }: NodeViewProps) {
return <KanbanRenderer tableId={tableId} viewId={viewId} bridgeUrl={bridgeUrl} />;
case "calendar":
return <CalendarRenderer tableId={tableId} viewId={viewId} bridgeUrl={bridgeUrl} />;
case "timeline":
return <TimelineRenderer tableId={tableId} viewId={viewId} bridgeUrl={bridgeUrl} />;
default:
return <PlaceholderRenderer viewType={vt} />;
}

View file

@ -0,0 +1,72 @@
/**
* Hook to read and write timeline column-mapping config.
*
* Config is persisted in bridge Redis (TTL 30d) keyed by viewId.
* GET /api/views/:viewId/timeline-config
* POST /api/views/:viewId/timeline-config
*/
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client";
export interface TimelineConfig {
startCol: string;
endCol: string | null;
resourceCol: string | null;
titleCol: string;
}
interface BridgeConfigResponse {
data: TimelineConfig | null;
}
export const TIMELINE_CONFIG_QUERY_KEY = "timeline-config";
export function timelineConfigQueryKey(viewId: string, bridgeUrl: string) {
return [TIMELINE_CONFIG_QUERY_KEY, viewId, bridgeUrl] as const;
}
interface UseTimelineConfigOptions {
viewId: string;
bridgeUrl?: string | null;
}
export function useTimelineConfig({ viewId, bridgeUrl }: UseTimelineConfigOptions) {
const url = resolveBridgeUrl(bridgeUrl);
const queryClient = useQueryClient();
const [isSaving, setIsSaving] = useState(false);
const query = useQuery<TimelineConfig | null>({
queryKey: timelineConfigQueryKey(viewId, url),
enabled: Boolean(viewId),
queryFn: async () => {
const client = getBridgeClient(url);
const res = await (client.get(
`/api/v1/views/${viewId}/timeline-config`,
) as unknown as Promise<BridgeConfigResponse>);
return res.data ?? null;
},
staleTime: 60_000,
});
async function saveConfig(config: TimelineConfig): Promise<void> {
setIsSaving(true);
try {
const client = getBridgeClient(url);
await client.post(`/api/v1/views/${viewId}/timeline-config`, config);
// Optimistically update the cache so the UI switches to timeline immediately.
queryClient.setQueryData(timelineConfigQueryKey(viewId, url), config);
} finally {
setIsSaving(false);
}
}
return {
config: query.data ?? null,
isLoading: query.isLoading,
isError: query.isError,
saveConfig,
isSaving,
};
}

View file

@ -0,0 +1,49 @@
.timelineWrapper {
font-size: var(--mantine-font-size-sm);
overflow-x: auto;
}
.timelineWrapper :global(.fc-toolbar-title) {
font-size: var(--mantine-font-size-md);
font-weight: 600;
}
.timelineWrapper :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);
}
.timelineWrapper :global(.fc-button:hover) {
background-color: var(--mantine-color-gray-2);
}
.timelineWrapper :global(.fc-button-primary:not(:disabled):active),
.timelineWrapper :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"] .timelineWrapper :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"] .timelineWrapper :global(.fc-button:hover) {
background-color: var(--mantine-color-dark-4);
}
.timelineEvent {
cursor: pointer;
border-radius: var(--mantine-radius-xs) !important;
font-size: var(--mantine-font-size-xs) !important;
}
/* Resource lane styling */
.timelineWrapper :global(.fc-resource-timeline-divider) {
width: 3px;
}

View file

@ -0,0 +1,457 @@
/**
* TimelineRenderer Gantt-style timeline view using FullCalendar Timeline plugin.
*
* Column mapping (resolved from user config stored in bridge Redis):
* - titleCol : required row field used as the event label
* - startCol : required ISO date field for event start
* - endCol : optional ISO date field for event end (fallback: start + 1 day)
* - resourceCol : optional field value used as swimlane resource id/title
*
* When no config exists the user is prompted to configure via the inline
* TimelineConfigPanel before the calendar renders.
*/
import { useState, useMemo } from "react";
import {
Text,
Stack,
Skeleton,
Alert,
Button,
Select,
Group,
Paper,
Title,
} from "@mantine/core";
import { IconAlertCircle, IconSettings } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDisclosure } from "@mantine/hooks";
import FullCalendar from "@fullcalendar/react";
import timelinePlugin from "@fullcalendar/timeline";
import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
import interactionPlugin from "@fullcalendar/interaction";
import type { EventClickArg } from "@fullcalendar/core";
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
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 { useTimelineConfig } from "../hooks/use-timeline-config";
import { RowDetailModal } from "../components/row-detail-modal";
import type { BridgeRow, BridgeField } from "../types/database-view.types";
import styles from "./timeline-renderer.module.css";
const PAGE_SIZE = 500;
interface TimelineRendererProps {
tableId: string;
viewId: string;
bridgeUrl?: string | null;
}
// ---------------------------------------------------------------------------
// Date helpers
// ---------------------------------------------------------------------------
function parseDateSafe(raw: unknown): Date | null {
if (!raw) return null;
const str = typeof raw === "string" ? raw : String(raw);
const d = new Date(str);
return isNaN(d.getTime()) ? null : d;
}
function addOneDay(d: Date): Date {
const copy = new Date(d);
copy.setDate(copy.getDate() + 1);
return copy;
}
// ---------------------------------------------------------------------------
// Row -> FullCalendar EventInput
// ---------------------------------------------------------------------------
interface MappedConfig {
titleCol: string;
startCol: string;
endCol: string | null;
resourceCol: string | null;
}
function rowToEvent(
row: BridgeRow,
config: MappedConfig,
fields: BridgeField[],
): {
id: string;
title: string;
start: string;
end: string;
resourceId?: string;
extendedProps: { row: BridgeRow; fields: BridgeField[] };
} | null {
// Resolve field value by name or id to handle Baserow's dual key format.
function resolveField(colName: string): unknown {
const field = fields.find((f) => f.name === colName || f.id === colName);
if (!field) return row.fields[colName];
return row.fields[field.name] ?? row.fields[field.id];
}
const startRaw = resolveField(config.startCol);
const startDate = parseDateSafe(startRaw);
if (!startDate) return null;
const endRaw = config.endCol ? resolveField(config.endCol) : null;
const endDate = endRaw ? (parseDateSafe(endRaw) ?? addOneDay(startDate)) : addOneDay(startDate);
const titleRaw = resolveField(config.titleCol);
const title =
typeof titleRaw === "string" && titleRaw.trim()
? titleRaw
: typeof titleRaw === "number"
? String(titleRaw)
: row.id;
const resourceRaw = config.resourceCol ? resolveField(config.resourceCol) : null;
const resourceId =
resourceRaw !== null && resourceRaw !== undefined ? String(resourceRaw) : undefined;
return {
id: row.id,
title,
start: startDate.toISOString(),
end: endDate.toISOString(),
...(resourceId !== undefined ? { resourceId } : {}),
extendedProps: { row, fields },
};
}
// ---------------------------------------------------------------------------
// Skeleton
// ---------------------------------------------------------------------------
function TimelineSkeleton() {
return (
<Stack gap="xs" aria-busy="true" aria-label="Loading timeline">
<Skeleton height={32} width={240} />
<Skeleton height={60} radius="sm" />
<Skeleton height={300} radius="sm" />
</Stack>
);
}
// ---------------------------------------------------------------------------
// Config panel (shown when no config saved yet)
// ---------------------------------------------------------------------------
interface TimelineConfigPanelProps {
fields: BridgeField[];
onSave: (config: MappedConfig) => Promise<void>;
saving: boolean;
}
function TimelineConfigPanel({ fields, onSave, saving }: TimelineConfigPanelProps) {
const { t } = useTranslation();
const dateFields = fields.filter(
(f) => f.type === "date" || f.type === "created_on" || f.type === "last_modified",
);
const allFields = fields;
const fieldOptions = allFields.map((f) => ({ value: f.name, label: f.name }));
const dateOptions = dateFields.map((f) => ({ value: f.name, label: f.name }));
const [titleCol, setTitleCol] = useState<string>(
fields.find((f) => f.primary)?.name ?? fields[0]?.name ?? "",
);
const [startCol, setStartCol] = useState<string>(dateFields[0]?.name ?? "");
const [endCol, setEndCol] = useState<string | null>(dateFields[1]?.name ?? null);
const [resourceCol, setResourceCol] = useState<string | null>(null);
const isValid = Boolean(titleCol && startCol);
async function handleSave() {
if (!isValid) return;
await onSave({ titleCol, startCol, endCol, resourceCol });
}
return (
<Paper p="md" withBorder aria-label={t("database_view.timeline.config_panel_label")}>
<Stack gap="md">
<Group gap="xs">
<IconSettings size={18} />
<Title order={5}>{t("database_view.timeline.config_title")}</Title>
</Group>
<Text size="sm" c="dimmed">
{t("database_view.timeline.config_description")}
</Text>
<Select
label={t("database_view.timeline.title_col")}
description={t("database_view.timeline.title_col_desc")}
data={fieldOptions}
value={titleCol}
onChange={(v) => setTitleCol(v ?? "")}
required
aria-required="true"
/>
<Select
label={t("database_view.timeline.start_col")}
description={t("database_view.timeline.start_col_desc")}
data={dateOptions.length > 0 ? dateOptions : fieldOptions}
value={startCol}
onChange={(v) => setStartCol(v ?? "")}
required
aria-required="true"
error={!startCol ? t("database_view.timeline.start_col_required") : undefined}
/>
<Select
label={t("database_view.timeline.end_col")}
description={t("database_view.timeline.end_col_desc")}
data={[
{ value: "__none__", label: t("database_view.timeline.none") },
...(dateOptions.length > 0 ? dateOptions : fieldOptions),
]}
value={endCol ?? "__none__"}
onChange={(v) => setEndCol(v === "__none__" ? null : (v ?? null))}
clearable={false}
/>
<Select
label={t("database_view.timeline.resource_col")}
description={t("database_view.timeline.resource_col_desc")}
data={[
{ value: "__none__", label: t("database_view.timeline.none") },
...fieldOptions,
]}
value={resourceCol ?? "__none__"}
onChange={(v) => setResourceCol(v === "__none__" ? null : (v ?? null))}
clearable={false}
/>
<Group justify="flex-end">
<Button
size="sm"
disabled={!isValid || saving}
loading={saving}
onClick={handleSave}
aria-label={t("database_view.timeline.save_config")}
>
{t("database_view.timeline.save_config")}
</Button>
</Group>
</Stack>
</Paper>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function TimelineRenderer({ tableId, viewId, bridgeUrl }: TimelineRendererProps) {
const { t } = useTranslation();
const [selectedRow, setSelectedRow] = useState<BridgeRow | null>(null);
const [selectedFields, setSelectedFields] = useState<BridgeField[]>([]);
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
const [configPanelVisible, setConfigPanelVisible] = useState(false);
const { data, isLoading: dataLoading, isError: dataError, error, refetch } = useViewData({
viewId,
bridgeUrl,
page: 1,
size: PAGE_SIZE,
});
const {
config,
isLoading: configLoading,
isError: configError,
saveConfig,
isSaving,
} = useTimelineConfig({ viewId, bridgeUrl });
const { canWriteRows } = usePermissions();
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl);
const isLoading = dataLoading || configLoading;
const { rows, fields } = data ?? { rows: [], fields: [] };
// Build FullCalendar events from rows — always computed so hooks are never conditional.
const events = useMemo(
() =>
!isLoading && !dataError && !configError && config
? rows
.map((row) => rowToEvent(row, config, fields))
.filter((e): e is NonNullable<typeof e> => e !== null)
: [],
[rows, config, fields, isLoading, dataError, configError],
);
// Build resource list when resourceCol is set.
const resources = useMemo(() => {
if (!config?.resourceCol) return undefined;
const seen = new Set<string>();
const list: { id: string; title: string }[] = [];
for (const event of events) {
const rid = event.resourceId;
if (rid && !seen.has(rid)) {
seen.add(rid);
list.push({ id: rid, title: rid });
}
}
return list.length > 0 ? list : undefined;
}, [events, config?.resourceCol]);
if (isLoading) return <TimelineSkeleton />;
if (dataError) {
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>
);
}
if (configError) {
return (
<Alert icon={<IconAlertCircle size={16} />} color="orange">
<Text size="sm">{t("database_view.timeline.config_load_error")}</Text>
</Alert>
);
}
// Show config panel when: no config saved, or user opened it manually.
const showConfigPanel = configPanelVisible || !config;
if (showConfigPanel) {
return (
<Stack gap="sm" data-testid="timeline-config-panel">
{fields.length === 0 ? (
<Alert icon={<IconAlertCircle size={16} />} color="yellow">
<Text size="sm">{t("database_view.timeline.no_fields")}</Text>
</Alert>
) : (
<TimelineConfigPanel
fields={fields}
saving={isSaving}
onSave={async (cfg) => {
await saveConfig(cfg);
setConfigPanelVisible(false);
}}
/>
)}
</Stack>
);
}
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 handleEventResize(arg: EventResizeDoneArg) {
if (!canWriteRows || !config) {
arg.revert();
return;
}
const rowId = arg.event.id;
const newEnd = arg.event.end;
if (!newEnd) {
arg.revert();
return;
}
const payload: Record<string, unknown> = {};
if (config.endCol) {
payload[config.endCol] = newEnd.toISOString();
}
if (Object.keys(payload).length === 0) {
arg.revert();
return;
}
updateRow.mutate(
{ rowId, payload: { fields: payload } },
{ onError: () => arg.revert() },
);
}
const initialView = resources ? "resourceTimelineMonth" : "timelineMonth";
return (
<div data-testid="timeline-renderer">
<Group justify="flex-end" mb="xs">
<Button
size="xs"
variant="subtle"
leftSection={<IconSettings size={14} />}
onClick={() => setConfigPanelVisible(true)}
aria-label={t("database_view.timeline.configure")}
>
{t("database_view.timeline.configure")}
</Button>
</Group>
{events.length === 0 && (
<Alert icon={<IconAlertCircle size={16} />} color="blue" mb="sm">
<Text size="sm">{t("database_view.timeline.no_events")}</Text>
</Alert>
)}
<div
className={styles.timelineWrapper}
aria-label={t("database_view.timeline.aria_label")}
>
<FullCalendar
plugins={
resources
? [resourceTimelinePlugin, timelinePlugin, interactionPlugin]
: [timelinePlugin, interactionPlugin]
}
initialView={initialView}
events={events}
resources={resources}
editable={canWriteRows}
eventResize={handleEventResize}
eventClick={handleEventClick}
headerToolbar={{
left: "prev,next today",
center: "title",
right: resources ? "resourceTimelineMonth,resourceTimelineWeek" : "timelineMonth,timelineWeek",
}}
height="auto"
schedulerLicenseKey="GPL-My-Project-Is-Open-Source"
eventClassNames={() => [styles.timelineEvent]}
/>
</div>
<RowDetailModal
row={selectedRow}
fields={selectedFields}
opened={modalOpened}
onClose={closeModal}
/>
</div>
);
}

View file

@ -8,17 +8,26 @@ import {
Alert,
Loader,
TextInput,
Select,
} from "@mantine/core";
import { IconAlertCircle, IconTable, IconChevronLeft } from "@tabler/icons-react";
import {
IconAlertCircle,
IconTable,
IconChevronLeft,
IconTimeline,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import type { Editor } from "@tiptap/core";
import { useTables } from "../hooks/use-tables";
import { useViews } from "../hooks/use-views";
import type { BridgeTable, BridgeView } from "../types/database-view.types";
import { useViewData } from "../hooks/use-view-data";
import { useTimelineConfig } from "../hooks/use-timeline-config";
import { resolveBridgeUrl } from "../services/bridge-client";
import type { BridgeTable, BridgeView, BridgeField } from "../types/database-view.types";
import styles from "./insert-database-modal.module.css";
type Step = "table" | "view";
type Step = "table" | "view" | "timeline-mapping";
interface InsertDatabaseModalProps {
opened: boolean;
@ -28,12 +37,14 @@ interface InsertDatabaseModalProps {
}
/**
* Two-step Mantine modal to insert a database-view node.
* Multi-step Mantine modal to insert a database-view node.
*
* Step 1: pick a table from GET /api/v1/tables
* Step 2: pick a view from GET /api/v1/views/table/:tableId
* Step 1 : pick a table
* Step 2 : pick a view (selecting a timeline view enables step 3)
* Step 3 : configure column mapping for timeline views
*
* On confirmation, inserts the node via editor.commands.insertDatabaseView().
* For timeline views, the mapping config is saved to bridge Redis before insert.
*/
export function InsertDatabaseModal({
opened,
@ -48,6 +59,12 @@ export function InsertDatabaseModal({
const [selectedView, setSelectedView] = useState<BridgeView | null>(null);
const [tableSearch, setTableSearch] = useState("");
// Timeline column mapping state
const [titleCol, setTitleCol] = useState("");
const [startCol, setStartCol] = useState("");
const [endCol, setEndCol] = useState<string | null>(null);
const [resourceCol, setResourceCol] = useState<string | null>(null);
const {
data: tables,
isLoading: tablesLoading,
@ -62,11 +79,35 @@ export function InsertDatabaseModal({
refetch: refetchViews,
} = useViews(selectedTable?.id, bridgeUrl);
// Fetch field metadata when on the timeline-mapping step.
const isTimelineStep = step === "timeline-mapping";
const resolvedUrl = resolveBridgeUrl(bridgeUrl);
const {
data: viewData,
isLoading: fieldsLoading,
isError: fieldsError,
} = useViewData({
viewId: selectedView?.id ?? "",
bridgeUrl,
page: 1,
size: 1,
});
const mappableFields: BridgeField[] = isTimelineStep ? (viewData?.fields ?? []) : [];
const { saveConfig, isSaving } = useTimelineConfig({
viewId: selectedView?.id ?? "",
bridgeUrl,
});
function handleReset() {
setStep("table");
setSelectedTable(null);
setSelectedView(null);
setTableSearch("");
setTitleCol("");
setStartCol("");
setEndCol(null);
setResourceCol(null);
}
function handleClose() {
@ -81,13 +122,32 @@ export function InsertDatabaseModal({
}
function handleBack() {
if (step === "timeline-mapping") {
setStep("view");
} else {
setStep("table");
setSelectedView(null);
}
}
function handleInsert() {
function handleViewSelect(view: BridgeView) {
setSelectedView(view);
}
async function handleInsert() {
if (!selectedTable || !selectedView) return;
if (selectedView.type === "timeline") {
// Save the mapping config to bridge Redis before inserting.
if (!startCol || !titleCol) return;
await saveConfig({
titleCol,
startCol,
endCol,
resourceCol,
});
}
editor.commands.insertDatabaseView({
tableId: selectedTable.id,
viewId: selectedView.id,
@ -98,9 +158,33 @@ export function InsertDatabaseModal({
handleClose();
}
const filteredTables = (tables ?? []).filter((t) =>
t.name.toLowerCase().includes(tableSearch.toLowerCase()),
function handleAdvanceToTimelineMapping() {
if (!selectedView) return;
// Pre-populate defaults from date fields.
const dateFields = mappableFields.filter(
(f) => f.type === "date" || f.type === "created_on",
);
setTitleCol(mappableFields.find((f) => f.primary)?.name ?? mappableFields[0]?.name ?? "");
setStartCol(dateFields[0]?.name ?? "");
setEndCol(dateFields[1]?.name ?? null);
setResourceCol(null);
setStep("timeline-mapping");
}
const filteredTables = (tables ?? []).filter((tbl) =>
tbl.name.toLowerCase().includes(tableSearch.toLowerCase()),
);
const fieldOptions = mappableFields.map((f) => ({ value: f.name, label: f.name }));
const dateFieldOptions = mappableFields
.filter((f) => f.type === "date" || f.type === "created_on")
.map((f) => ({ value: f.name, label: f.name }));
const timelineMappingValid = Boolean(titleCol && startCol);
const isTimeline = selectedView?.type === "timeline";
// Insert is disabled for timeline until mapping step is filled.
const insertDisabled = !selectedView || (isTimeline && !timelineMappingValid);
return (
<Modal
@ -127,8 +211,17 @@ export function InsertDatabaseModal({
<Text size="xs" c={step === "view" ? "blue" : "dimmed"}>
{t("database_view.modal.step2")}
</Text>
<div
className={clsx(styles.stepDot, {
[styles.active]: step === "timeline-mapping",
})}
/>
<Text size="xs" c={step === "timeline-mapping" ? "blue" : "dimmed"}>
{t("database_view.modal.step3_timeline")}
</Text>
</div>
{/* ---- STEP 1: table selection ---- */}
{step === "table" && (
<Stack gap="sm">
<TextInput
@ -186,6 +279,7 @@ export function InsertDatabaseModal({
</Stack>
)}
{/* ---- STEP 2: view selection ---- */}
{step === "view" && (
<Stack gap="sm">
<Group gap="xs">
@ -236,14 +330,18 @@ export function InsertDatabaseModal({
className={clsx(styles.viewBadge, {
[styles.selected]: selectedView?.id === view.id,
})}
onClick={() => setSelectedView(view)}
onClick={() => handleViewSelect(view)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setSelectedView(view);
if (e.key === "Enter" || e.key === " ") handleViewSelect(view);
}}
>
{view.type === "timeline" ? (
<IconTimeline size={12} />
) : (
<IconTable size={12} />
)}
<span>{view.name}</span>
<Text size="xs" c="dimmed">
({view.type})
@ -258,10 +356,110 @@ export function InsertDatabaseModal({
<Button variant="default" size="sm" onClick={handleClose}>
{t("Cancel")}
</Button>
{isTimeline ? (
<Button
size="sm"
disabled={!selectedView}
onClick={handleInsert}
disabled={!selectedView || fieldsLoading}
loading={fieldsLoading}
onClick={handleAdvanceToTimelineMapping}
data-testid="timeline-next-btn"
>
{t("database_view.modal.next")}
</Button>
) : (
<Button size="sm" disabled={!selectedView} onClick={() => void handleInsert()}>
{t("database_view.modal.insert")}
</Button>
)}
</Group>
</Stack>
)}
{/* ---- STEP 3: timeline column mapping ---- */}
{step === "timeline-mapping" && (
<Stack gap="sm">
<Group gap="xs">
<Button
size="xs"
variant="subtle"
leftSection={<IconChevronLeft size={14} />}
onClick={handleBack}
>
{t("database_view.modal.back")}
</Button>
<Group gap="xs">
<IconTimeline size={16} />
<Text size="sm" fw={500}>
{t("database_view.modal.timeline_mapping_title")}
</Text>
</Group>
</Group>
{fieldsError && (
<Alert icon={<IconAlertCircle size={16} />} color="red">
<Text size="sm">{t("database_view.error.generic")}</Text>
</Alert>
)}
<Select
label={t("database_view.timeline.title_col")}
description={t("database_view.timeline.title_col_desc")}
data={fieldOptions}
value={titleCol}
onChange={(v) => setTitleCol(v ?? "")}
required
aria-required="true"
data-testid="title-col-select"
/>
<Select
label={t("database_view.timeline.start_col")}
description={t("database_view.timeline.start_col_desc")}
data={dateFieldOptions.length > 0 ? dateFieldOptions : fieldOptions}
value={startCol}
onChange={(v) => setStartCol(v ?? "")}
required
aria-required="true"
error={!startCol ? t("database_view.timeline.start_col_required") : undefined}
data-testid="start-col-select"
/>
<Select
label={t("database_view.timeline.end_col")}
description={t("database_view.timeline.end_col_desc")}
data={[
{ value: "__none__", label: t("database_view.timeline.none") },
...(dateFieldOptions.length > 0 ? dateFieldOptions : fieldOptions),
]}
value={endCol ?? "__none__"}
onChange={(v) => setEndCol(v === "__none__" ? null : (v ?? null))}
clearable={false}
data-testid="end-col-select"
/>
<Select
label={t("database_view.timeline.resource_col")}
description={t("database_view.timeline.resource_col_desc")}
data={[
{ value: "__none__", label: t("database_view.timeline.none") },
...fieldOptions,
]}
value={resourceCol ?? "__none__"}
onChange={(v) => setResourceCol(v === "__none__" ? null : (v ?? null))}
clearable={false}
data-testid="resource-col-select"
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" size="sm" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button
size="sm"
disabled={!timelineMappingValid || isSaving}
loading={isSaving}
onClick={() => void handleInsert()}
data-testid="timeline-insert-btn"
>
{t("database_view.modal.insert")}
</Button>

View file

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

60
pnpm-lock.yaml generated
View file

@ -283,9 +283,15 @@ importers:
'@fullcalendar/react':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@fullcalendar/resource-timeline':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)(@fullcalendar/resource@6.1.20(@fullcalendar/core@6.1.20))
'@fullcalendar/timegrid':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/timeline':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
'@mantine/core':
specifier: ^8.3.18
version: 8.3.18(@mantine/hooks@8.3.18(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -2508,6 +2514,11 @@ packages:
peerDependencies:
'@fullcalendar/core': ~6.1.20
'@fullcalendar/premium-common@6.1.20':
resolution: {integrity: sha512-rT+AitNnRyZuFEtYvsB1OJ2g1Bq2jmTR6qdn/dEU6LwkIj/4L499goLtMOena/JyJ31VBztdHrccX//36QrY3w==}
peerDependencies:
'@fullcalendar/core': ~6.1.20
'@fullcalendar/react@6.1.20':
resolution: {integrity: sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==}
peerDependencies:
@ -2515,11 +2526,32 @@ packages:
react: ^16.7.0 || ^17 || ^18 || ^19
react-dom: ^16.7.0 || ^17 || ^18 || ^19
'@fullcalendar/resource-timeline@6.1.20':
resolution: {integrity: sha512-HAlM/I+9xJPzZx3Wry7l5oibc8n5Pv/iL8tp2dxUu/0zqS0UqADbHItJucuANfDDeL7PEbCbh/uFx9VvzRUIkQ==}
peerDependencies:
'@fullcalendar/core': ~6.1.20
'@fullcalendar/resource': ~6.1.20
'@fullcalendar/resource@6.1.20':
resolution: {integrity: sha512-vpQs1eYJbc1zGOzF3obVVr+XsHTMTG7STKVQBEGy3AeFgfosRkUz+3DUawmy98vSjJUYOAQHO+pWW0ek0n5g0w==}
peerDependencies:
'@fullcalendar/core': ~6.1.20
'@fullcalendar/scrollgrid@6.1.20':
resolution: {integrity: sha512-M55m0hxpou4IPObto5f0nVcXvIj3rkSTba0ypclSFDwBz3JxuCPS6l8kaUznqlZCr2Ld/HFJr+jwyvY070AafQ==}
peerDependencies:
'@fullcalendar/core': ~6.1.20
'@fullcalendar/timegrid@6.1.20':
resolution: {integrity: sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==}
peerDependencies:
'@fullcalendar/core': ~6.1.20
'@fullcalendar/timeline@6.1.20':
resolution: {integrity: sha512-yhTgMNDWfB+XqEUTLWrpPjM4fcvGYLOA9DvTp1ysdeqhRGoZnRK9Iv2WW5BaKT+VXhXoAPrj2Ud/lXt6youWAQ==}
peerDependencies:
'@fullcalendar/core': ~6.1.20
'@hocuspocus/common@3.4.4':
resolution: {integrity: sha512-RykIJ0tsHHMP4Xk+4UCbc7SO5LgGxGUSTdbh6anJEsaALAyqinf1Nn5HYuMjLPolAmsar1v++m9zufR09NLpXA==}
@ -13078,17 +13110,45 @@ snapshots:
dependencies:
'@fullcalendar/core': 6.1.20
'@fullcalendar/premium-common@6.1.20(@fullcalendar/core@6.1.20)':
dependencies:
'@fullcalendar/core': 6.1.20
'@fullcalendar/react@6.1.20(@fullcalendar/core@6.1.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@fullcalendar/core': 6.1.20
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@fullcalendar/resource-timeline@6.1.20(@fullcalendar/core@6.1.20)(@fullcalendar/resource@6.1.20(@fullcalendar/core@6.1.20))':
dependencies:
'@fullcalendar/core': 6.1.20
'@fullcalendar/premium-common': 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/resource': 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/scrollgrid': 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/timeline': 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/resource@6.1.20(@fullcalendar/core@6.1.20)':
dependencies:
'@fullcalendar/core': 6.1.20
'@fullcalendar/premium-common': 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/scrollgrid@6.1.20(@fullcalendar/core@6.1.20)':
dependencies:
'@fullcalendar/core': 6.1.20
'@fullcalendar/premium-common': 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/timegrid@6.1.20(@fullcalendar/core@6.1.20)':
dependencies:
'@fullcalendar/core': 6.1.20
'@fullcalendar/daygrid': 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/timeline@6.1.20(@fullcalendar/core@6.1.20)':
dependencies:
'@fullcalendar/core': 6.1.20
'@fullcalendar/premium-common': 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/scrollgrid': 6.1.20(@fullcalendar/core@6.1.20)
'@hocuspocus/common@3.4.4':
dependencies:
lib0: 0.2.117