From d0b75774d8620a32e3da387060ce37cbfa83c4f0 Mon Sep 17 00:00:00 2001 From: Corentin Date: Fri, 8 May 2026 11:27:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(acadenice):=20add=20timeline=20view=20(Gan?= =?UTF-8?q?tt)=20for=20databases=20=E2=80=94=20R4.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/client/package.json | 2 + .../__tests__/timeline-renderer.test.tsx | 406 ++++++++++++++++ .../extension/database-view-component.tsx | 3 + .../hooks/use-timeline-config.ts | 72 +++ .../renderers/timeline-renderer.module.css | 49 ++ .../renderers/timeline-renderer.tsx | 457 ++++++++++++++++++ .../slash-command/insert-database-modal.tsx | 230 ++++++++- .../types/database-view.types.ts | 8 +- pnpm-lock.yaml | 60 +++ 9 files changed, 1267 insertions(+), 20 deletions(-) create mode 100644 apps/client/src/features/acadenice/database-view/__tests__/timeline-renderer.test.tsx create mode 100644 apps/client/src/features/acadenice/database-view/hooks/use-timeline-config.ts create mode 100644 apps/client/src/features/acadenice/database-view/renderers/timeline-renderer.module.css create mode 100644 apps/client/src/features/acadenice/database-view/renderers/timeline-renderer.tsx diff --git a/apps/client/package.json b/apps/client/package.json index aa4401a8..a7f8181d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/client/src/features/acadenice/database-view/__tests__/timeline-renderer.test.tsx b/apps/client/src/features/acadenice/database-view/__tests__/timeline-renderer.test.tsx new file mode 100644 index 00000000..e53b29d0 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/__tests__/timeline-renderer.test.tsx @@ -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 = {}; +vi.mock("@fullcalendar/react", () => ({ + default: (props: Record) => { + capturedFcProps = props; + const events = (props.events as Array<{ + id: string; + title: string; + start: string; + end: string; + resourceId?: string; + }>) ?? []; + return ( +
+ {events.map((e) => ( +
+ {e.title} +
+ ))} +
+ ); + }, +})); + +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; +const mockUsePermissions = usePermissions as ReturnType; +const mockUseTimelineConfig = useTimelineConfig as ReturnType; + +function Wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {children} + + ); +} + +const FIELDS = [ + { id: "f1", name: "Name", type: "text", primary: true, options: null }, + { id: "f2", name: "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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + expect(screen.getByText("database_view.timeline.no_events")).toBeInTheDocument(); + }); + + it("configure button re-shows the config panel", async () => { + render( + + + , + ); + 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( + + + , + ); + expect(typeof capturedFcProps.eventClick).toBe("function"); + }); +}); diff --git a/apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx b/apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx index abe3e818..8d37a9f1 100644 --- a/apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx +++ b/apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx @@ -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 ; case "calendar": return ; + case "timeline": + return ; default: return ; } diff --git a/apps/client/src/features/acadenice/database-view/hooks/use-timeline-config.ts b/apps/client/src/features/acadenice/database-view/hooks/use-timeline-config.ts new file mode 100644 index 00000000..7ba2f8f5 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/hooks/use-timeline-config.ts @@ -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({ + 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); + return res.data ?? null; + }, + staleTime: 60_000, + }); + + async function saveConfig(config: TimelineConfig): Promise { + 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, + }; +} diff --git a/apps/client/src/features/acadenice/database-view/renderers/timeline-renderer.module.css b/apps/client/src/features/acadenice/database-view/renderers/timeline-renderer.module.css new file mode 100644 index 00000000..cab09a0d --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/renderers/timeline-renderer.module.css @@ -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; +} diff --git a/apps/client/src/features/acadenice/database-view/renderers/timeline-renderer.tsx b/apps/client/src/features/acadenice/database-view/renderers/timeline-renderer.tsx new file mode 100644 index 00000000..bb0ecbdb --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/renderers/timeline-renderer.tsx @@ -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 ( + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Config panel (shown when no config saved yet) +// --------------------------------------------------------------------------- + +interface TimelineConfigPanelProps { + fields: BridgeField[]; + onSave: (config: MappedConfig) => Promise; + 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( + fields.find((f) => f.primary)?.name ?? fields[0]?.name ?? "", + ); + const [startCol, setStartCol] = useState(dateFields[0]?.name ?? ""); + const [endCol, setEndCol] = useState(dateFields[1]?.name ?? null); + const [resourceCol, setResourceCol] = useState(null); + + const isValid = Boolean(titleCol && startCol); + + async function handleSave() { + if (!isValid) return; + await onSave({ titleCol, startCol, endCol, resourceCol }); + } + + return ( + + + + + {t("database_view.timeline.config_title")} + + + + {t("database_view.timeline.config_description")} + + + 0 ? dateOptions : fieldOptions} + value={startCol} + onChange={(v) => setStartCol(v ?? "")} + required + aria-required="true" + error={!startCol ? t("database_view.timeline.start_col_required") : undefined} + /> + + setResourceCol(v === "__none__" ? null : (v ?? null))} + clearable={false} + /> + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function TimelineRenderer({ tableId, viewId, bridgeUrl }: TimelineRendererProps) { + const { t } = useTranslation(); + + const [selectedRow, setSelectedRow] = useState(null); + const [selectedFields, setSelectedFields] = useState([]); + 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 => 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(); + 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 ; + + 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 ( + } + color="red" + title={t("database_view.error.title")} + > + + {message} + + + + ); + } + + if (configError) { + return ( + } color="orange"> + {t("database_view.timeline.config_load_error")} + + ); + } + + // Show config panel when: no config saved, or user opened it manually. + const showConfigPanel = configPanelVisible || !config; + + if (showConfigPanel) { + return ( + + {fields.length === 0 ? ( + } color="yellow"> + {t("database_view.timeline.no_fields")} + + ) : ( + { + await saveConfig(cfg); + setConfigPanelVisible(false); + }} + /> + )} + + ); + } + + 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 = {}; + 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 ( +
+ + + + + {events.length === 0 && ( + } color="blue" mb="sm"> + {t("database_view.timeline.no_events")} + + )} + +
+ [styles.timelineEvent]} + /> +
+ + +
+ ); +} diff --git a/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx b/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx index 5fb96d6d..05a08289 100644 --- a/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx +++ b/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx @@ -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(null); const [tableSearch, setTableSearch] = useState(""); + // Timeline column mapping state + const [titleCol, setTitleCol] = useState(""); + const [startCol, setStartCol] = useState(""); + const [endCol, setEndCol] = useState(null); + const [resourceCol, setResourceCol] = useState(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() { - setStep("table"); - setSelectedView(null); + 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,10 +158,34 @@ 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 ( {t("database_view.modal.step2")} +
+ + {t("database_view.modal.step3_timeline")} +
+ {/* ---- STEP 1: table selection ---- */} {step === "table" && ( )} + {/* ---- STEP 2: view selection ---- */} {step === "view" && ( @@ -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" ? ( + + ) : ( + + )} {view.name} ({view.type}) @@ -254,14 +352,114 @@ export function InsertDatabaseModal({ )} + + + {isTimeline ? ( + + ) : ( + + )} + + + )} + + {/* ---- STEP 3: timeline column mapping ---- */} + {step === "timeline-mapping" && ( + + + + + + + {t("database_view.modal.timeline_mapping_title")} + + + + + {fieldsError && ( + } color="red"> + {t("database_view.error.generic")} + + )} + + 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" + /> + + setResourceCol(v === "__none__" ? null : (v ?? null))} + clearable={false} + data-testid="resource-col-select" + /> + diff --git a/apps/client/src/features/acadenice/database-view/types/database-view.types.ts b/apps/client/src/features/acadenice/database-view/types/database-view.types.ts index 758614fa..db5abb8d 100644 --- a/apps/client/src/features/acadenice/database-view/types/database-view.types.ts +++ b/apps/client/src/features/acadenice/database-view/types/database-view.types.ts @@ -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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35d5bcbc..1ba7eede 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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