- 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>
239 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|