AcadeDoc/apps/client/src/features/acadenice/database-view/renderers/calendar-renderer.tsx
Corentin f3fae2ac78 feat(client): add kanban + calendar renderers + inline edit for R3.1.d
- KanbanRenderer: @dnd-kit drag-drop, group by single_select field,
  optimistic update + rollback, empty column placeholder, read-only mode
- CalendarRenderer: @fullcalendar month/week/day toggle, eventDrop ->
  PATCH date field, event click -> RowDetailModal, stub deps noted
- InlineEditor: polymorphic (text/number/date/select/multi-select),
  save on blur/Enter, cancel on Escape, permission denied read-only
- RowDetailModal: simple field detail view opened from calendar events
- useUpdateRow: generic PATCH with React Query v5 optimistic+rollback
- usePermissions: reads acadenice_permissions from window global or cookie
- patchRow helper added to bridge-client.ts
- DatabaseViewComponent: dispatches kanban/calendar to real renderers
- TableRenderer: integrates InlineEditor on double-click cells
- i18n: +12 keys (kanban.*, calendar.*, edit.*, row_detail.*) in en-US + fr-FR
- 5 new test suites: kanban, calendar, inline-editor, use-update-row, use-permissions
- Updated database-view-component.test.tsx for R3.1.d dispatch

Deps to install: @dnd-kit/core@^6 @dnd-kit/sortable@^8 @dnd-kit/utilities@^3
  @fullcalendar/react@^6 @fullcalendar/daygrid@^6
  @fullcalendar/timegrid@^6 @fullcalendar/interaction@^6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:24:12 +02:00

239 lines
7.2 KiB
TypeScript

/**
* DEPENDENCIES NEEDED (not installed — convention fork):
* @fullcalendar/react@^6
* @fullcalendar/daygrid@^6
* @fullcalendar/timegrid@^6
* @fullcalendar/interaction@^6
*
* Rationale for FullCalendar over react-big-calendar:
* - More mature accessibility (ARIA roles, keyboard nav)
* - Better drag-drop support via @fullcalendar/interaction
* - Built-in month/week/day views with consistent API
* - Active maintenance and large community
*/
import { useState } from "react";
import { Text, Stack, Skeleton, Alert, Button, SegmentedControl } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDisclosure } from "@mantine/hooks";
// FullCalendar imports — these will resolve once the deps are installed.
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin, { EventDropArg } from "@fullcalendar/interaction";
import { EventClickArg } from "@fullcalendar/core";
import { useViewData } from "../hooks/use-view-data";
import { useUpdateRow } from "../hooks/use-update-row";
import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates";
import { usePermissions } from "../hooks/use-permissions";
import { RowDetailModal } from "../components/row-detail-modal";
import type { BridgeRow, BridgeField } from "../types/database-view.types";
import styles from "./calendar-renderer.module.css";
const PAGE_SIZE = 500;
type CalendarView = "dayGridMonth" | "timeGridWeek" | "timeGridDay";
interface CalendarRendererProps {
tableId: string;
viewId: string;
bridgeUrl?: string | null;
}
/** Resolve the date field used to position events on the calendar. */
function resolveDateField(
fields: BridgeField[],
viewMeta?: { dateFieldId?: string },
): BridgeField | undefined {
if (viewMeta?.dateFieldId) {
return fields.find((f) => f.id === viewMeta.dateFieldId);
}
return fields.find((f) => f.type === "date" || f.type === "created_on");
}
/** Convert a row to a FullCalendar EventInput. Returns null when no date. */
function rowToEvent(
row: BridgeRow,
dateField: BridgeField,
primaryField?: BridgeField,
): { id: string; title: string; start: string; extendedProps: { row: BridgeRow; fields: BridgeField[] } } | null {
const rawDate = row.fields[dateField.name] ?? row.fields[dateField.id];
if (!rawDate) return null;
const dateStr = typeof rawDate === "string" ? rawDate : String(rawDate);
// Validate the date string.
if (isNaN(new Date(dateStr).getTime())) return null;
const primaryValue = primaryField
? (row.fields[primaryField.name] ?? row.fields[primaryField.id])
: row.id;
const title =
typeof primaryValue === "string"
? primaryValue
: typeof primaryValue === "number"
? String(primaryValue)
: row.id;
return {
id: row.id,
title: title || row.id,
start: dateStr,
extendedProps: { row, fields: [] },
};
}
function CalendarSkeleton() {
return (
<Stack gap="xs">
<Skeleton height={32} width={200} />
<Skeleton height={400} radius="sm" />
</Stack>
);
}
export function CalendarRenderer({ tableId, viewId, bridgeUrl }: CalendarRendererProps) {
const { t } = useTranslation();
const [calView, setCalView] = useState<CalendarView>("dayGridMonth");
const [selectedRow, setSelectedRow] = useState<BridgeRow | null>(null);
const [selectedFields, setSelectedFields] = useState<BridgeField[]>([]);
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
const { data, isLoading, isError, error, refetch } = useViewData({
viewId,
bridgeUrl,
page: 1,
size: PAGE_SIZE,
});
const { canWriteRows } = usePermissions();
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl);
if (isLoading) return <CalendarSkeleton />;
if (isError) {
const status = (error as { response?: { status?: number } })?.response?.status;
let message = t("database_view.error.generic");
if (status === 403) message = t("database_view.error.permission_denied");
else if (status === 404) message = t("database_view.error.view_not_found");
return (
<Alert
icon={<IconAlertCircle size={16} />}
color="red"
title={t("database_view.error.title")}
>
<Stack gap="xs">
<Text size="sm">{message}</Text>
<Button size="xs" variant="subtle" onClick={() => refetch()}>
{t("database_view.error.retry")}
</Button>
</Stack>
</Alert>
);
}
const { rows, fields } = data ?? { rows: [], fields: [] };
const dateField = resolveDateField(fields);
if (!dateField) {
return (
<Alert icon={<IconAlertCircle size={16} />} color="yellow">
<Text size="sm">{t("database_view.calendar.no_date_field")}</Text>
</Alert>
);
}
const primaryField = fields.find((f) => f.primary) ?? fields[0];
const events = rows
.map((row) => rowToEvent(row, dateField, primaryField))
.filter((e): e is NonNullable<typeof e> => e !== null)
.map((e) => ({ ...e, extendedProps: { ...e.extendedProps, fields } }));
function handleEventClick(arg: EventClickArg) {
const row = arg.event.extendedProps.row as BridgeRow;
const eventFields = arg.event.extendedProps.fields as BridgeField[];
setSelectedRow(row);
setSelectedFields(eventFields);
openModal();
}
function handleEventDrop(arg: EventDropArg) {
if (!canWriteRows) {
arg.revert();
return;
}
const rowId = arg.event.id;
const newStart = arg.event.start;
if (!newStart) {
arg.revert();
return;
}
updateRow.mutate(
{
rowId,
payload: {
fields: {
[dateField.name]: newStart.toISOString(),
},
},
},
{
onError: () => {
arg.revert();
},
},
);
}
const viewOptions = [
{ label: t("database_view.calendar.view_month"), value: "dayGridMonth" },
{ label: t("database_view.calendar.view_week"), value: "timeGridWeek" },
{ label: t("database_view.calendar.view_day"), value: "timeGridDay" },
];
return (
<div>
<div className={styles.toolbar}>
<SegmentedControl
size="xs"
value={calView}
onChange={(v) => setCalView(v as CalendarView)}
data={viewOptions}
/>
</div>
<div className={styles.calendarWrapper}>
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView={calView}
key={calView}
events={events}
editable={canWriteRows}
droppable={canWriteRows}
eventClick={handleEventClick}
eventDrop={handleEventDrop}
headerToolbar={{
left: "prev,next today",
center: "title",
right: "",
}}
height="auto"
eventClassNames={() => [styles.calendarEvent]}
/>
</div>
<RowDetailModal
row={selectedRow}
fields={selectedFields}
opened={modalOpened}
onClose={closeModal}
/>
</div>
);
}