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:
parent
3c6478826a
commit
d0b75774d8
9 changed files with 1267 additions and 20 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
||||
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 (
|
||||
<Modal
|
||||
opened={opened}
|
||||
|
|
@ -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);
|
||||
}}
|
||||
>
|
||||
<IconTable size={12} />
|
||||
{view.type === "timeline" ? (
|
||||
<IconTimeline size={12} />
|
||||
) : (
|
||||
<IconTable size={12} />
|
||||
)}
|
||||
<span>{view.name}</span>
|
||||
<Text size="xs" c="dimmed">
|
||||
({view.type})
|
||||
|
|
@ -254,14 +352,114 @@ export function InsertDatabaseModal({
|
|||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" size="sm" onClick={handleClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
{isTimeline ? (
|
||||
<Button
|
||||
size="sm"
|
||||
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={!selectedView}
|
||||
onClick={handleInsert}
|
||||
disabled={!timelineMappingValid || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={() => void handleInsert()}
|
||||
data-testid="timeline-insert-btn"
|
||||
>
|
||||
{t("database_view.modal.insert")}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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
60
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue