feat(calendar): expose an accessible calendar picker
This commit is contained in:
@@ -13,6 +13,9 @@
|
||||
},
|
||||
"./styles/calendar.css": "./src/styles/calendar.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
|
||||
114
webui/src/features/calendar/CalendarPicker.tsx
Normal file
114
webui/src/features/calendar/CalendarPicker.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import {
|
||||
FormField,
|
||||
hasScope,
|
||||
type CalendarPickerProps
|
||||
} from "@govoplan/core-webui";
|
||||
import { listCalendars, type CalendarCollection } from "../../api/calendar";
|
||||
import {
|
||||
CALENDAR_PICKER_READ_SCOPE,
|
||||
calendarPickerOptions,
|
||||
calendarPickerTenantId
|
||||
} from "./calendarPickerLogic";
|
||||
|
||||
const LABEL = "i18n:govoplan-calendar.calendar.adab5090";
|
||||
const SELECT_CALENDAR = "i18n:govoplan-calendar.select_calendar.f38b5ba2";
|
||||
const LOADING_CALENDARS = "i18n:govoplan-calendar.loading_calendars.afbb957b";
|
||||
const CALENDARS_UNAVAILABLE = "i18n:govoplan-calendar.calendars_are_unavailable.f074c862";
|
||||
const NO_CALENDARS_AVAILABLE = "i18n:govoplan-calendar.no_calendars_are_available.711d2cf3";
|
||||
const SELECTED_CALENDAR_UNAVAILABLE = "i18n:govoplan-calendar.the_selected_calendar_is_no_longer_available.39f0f1f5";
|
||||
|
||||
export default function CalendarPicker({
|
||||
settings,
|
||||
auth,
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
name,
|
||||
label = LABEL,
|
||||
emptyLabel = SELECT_CALENDAR,
|
||||
disabled = false,
|
||||
required = false,
|
||||
className
|
||||
}: CalendarPickerProps) {
|
||||
const generatedId = useId();
|
||||
const selectId = id ?? `calendar-picker-${generatedId.replace(/:/g, "")}`;
|
||||
const statusId = `${selectId}-status`;
|
||||
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const canRead = hasScope(auth, CALENDAR_PICKER_READ_SCOPE);
|
||||
const tenantId = calendarPickerTenantId(auth);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!canRead) {
|
||||
setCalendars([]);
|
||||
setLoading(false);
|
||||
setFailed(true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
listCalendars(settings)
|
||||
.then((response) => {
|
||||
if (!cancelled) setCalendars(response.calendars);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setCalendars([]);
|
||||
setFailed(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canRead, settings.accessToken, settings.apiBaseUrl, settings.apiKey, tenantId]);
|
||||
|
||||
const options = useMemo(() => calendarPickerOptions(calendars), [calendars]);
|
||||
const selectedUnavailable = Boolean(value) && !loading && !options.some((calendar) => calendar.id === value);
|
||||
const status = failed
|
||||
? CALENDARS_UNAVAILABLE
|
||||
: !loading && options.length === 0
|
||||
? NO_CALENDARS_AVAILABLE
|
||||
: selectedUnavailable
|
||||
? SELECTED_CALENDAR_UNAVAILABLE
|
||||
: "";
|
||||
const placeholder = loading
|
||||
? LOADING_CALENDARS
|
||||
: failed
|
||||
? CALENDARS_UNAVAILABLE
|
||||
: options.length === 0
|
||||
? NO_CALENDARS_AVAILABLE
|
||||
: emptyLabel;
|
||||
|
||||
return (
|
||||
<div className={["calendar-picker", className].filter(Boolean).join(" ")}>
|
||||
<FormField label={label}>
|
||||
<select
|
||||
id={selectId}
|
||||
name={name}
|
||||
value={value}
|
||||
required={required}
|
||||
disabled={disabled || loading || failed || options.length === 0}
|
||||
aria-busy={loading || undefined}
|
||||
aria-describedby={status ? statusId : undefined}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{selectedUnavailable ? <option value={value} disabled>{SELECTED_CALENDAR_UNAVAILABLE}</option> : null}
|
||||
{options.map((calendar) => (
|
||||
<option key={calendar.id} value={calendar.id}>{calendar.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
{status ? <div id={statusId} className="muted small-note" role={failed ? "alert" : "status"}>{status}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
webui/src/features/calendar/calendarPickerLogic.ts
Normal file
24
webui/src/features/calendar/calendarPickerLogic.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
type CalendarPickerAuth = {
|
||||
tenant: { id: string };
|
||||
active_tenant?: { id: string };
|
||||
};
|
||||
|
||||
export type CalendarPickerOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
};
|
||||
|
||||
export const CALENDAR_PICKER_READ_SCOPE = "calendar:calendar:read";
|
||||
|
||||
export function calendarPickerOptions<TCalendar extends CalendarPickerOption>(calendars: TCalendar[]): TCalendar[] {
|
||||
return [...calendars].sort((left, right) => {
|
||||
if (left.is_default !== right.is_default) return left.is_default ? -1 : 1;
|
||||
const nameDelta = left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
|
||||
return nameDelta !== 0 ? nameDelta : left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function calendarPickerTenantId(auth: CalendarPickerAuth): string {
|
||||
return (auth.active_tenant ?? auth.tenant).id;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type",
|
||||
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
|
||||
"i18n:govoplan-calendar.calendar.adab5090": "Calendar",
|
||||
"i18n:govoplan-calendar.calendars_are_unavailable.f074c862": "Calendars are unavailable.",
|
||||
"i18n:govoplan-calendar.calendars.94445018": "Calendars",
|
||||
"i18n:govoplan-calendar.cancel.77dfd213": "Cancel",
|
||||
"i18n:govoplan-calendar.cancelled.5587b0af": "CANCELLED",
|
||||
@@ -89,6 +90,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.last_attempt.82aee111": "Last attempt",
|
||||
"i18n:govoplan-calendar.last_sync.ef0ef267": "Last sync",
|
||||
"i18n:govoplan-calendar.loading_calendar.7eb8f548": "Loading calendar...",
|
||||
"i18n:govoplan-calendar.loading_calendars.afbb957b": "Loading calendars...",
|
||||
"i18n:govoplan-calendar.loading_event_count.716ad3c2": "Loading event count...",
|
||||
"i18n:govoplan-calendar.local_calendar.ed3f72f8": "Local calendar",
|
||||
"i18n:govoplan-calendar.local.dc99d54d": "Local",
|
||||
@@ -110,6 +112,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.next.bc981983": "Next",
|
||||
"i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b": "No compatible target calendar is available. Add a local calendar or an active two-way CalDAV calendar.",
|
||||
"i18n:govoplan-calendar.no_calendar_collections_found.6453624a": "No calendar collections found.",
|
||||
"i18n:govoplan-calendar.no_calendars_are_available.711d2cf3": "No calendars are available.",
|
||||
"i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars",
|
||||
"i18n:govoplan-calendar.no_events.e339ba73": "No events",
|
||||
"i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65": "No local target calendar is available. Add a local calendar to detach these events without changing the remote calendar.",
|
||||
@@ -147,6 +150,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.save.efc007a3": "Save",
|
||||
"i18n:govoplan-calendar.saving.ae7e8875": "Saving...",
|
||||
"i18n:govoplan-calendar.sequence.5c8f4e0e": "Sequence",
|
||||
"i18n:govoplan-calendar.select_calendar.f38b5ba2": "Select calendar",
|
||||
"i18n:govoplan-calendar.show_value.60e2ce8e": "Show {value0}",
|
||||
"i18n:govoplan-calendar.source_href.819fa147": "Source href",
|
||||
"i18n:govoplan-calendar.source_kind.7eda9bc4": "Source kind",
|
||||
@@ -165,6 +169,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.target_calendar.e533fe1d": "Target calendar",
|
||||
"i18n:govoplan-calendar.tentative.d19f9022": "TENTATIVE",
|
||||
"i18n:govoplan-calendar.the_selected_calendar.67caa12f": "the selected calendar",
|
||||
"i18n:govoplan-calendar.the_selected_calendar_is_no_longer_available.39f0f1f5": "The selected calendar is no longer available.",
|
||||
"i18n:govoplan-calendar.thu.3593ccd9": "Thu",
|
||||
"i18n:govoplan-calendar.timezone.d1f7dc89": "Timezone",
|
||||
"i18n:govoplan-calendar.title.768e0c1c": "Title",
|
||||
@@ -208,6 +213,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type",
|
||||
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
|
||||
"i18n:govoplan-calendar.calendar.adab5090": "Kalender",
|
||||
"i18n:govoplan-calendar.calendars_are_unavailable.f074c862": "Kalender sind nicht verfügbar.",
|
||||
"i18n:govoplan-calendar.calendars.94445018": "Calendars",
|
||||
"i18n:govoplan-calendar.cancel.77dfd213": "Abbrechen",
|
||||
"i18n:govoplan-calendar.cancelled.5587b0af": "CANCELLED",
|
||||
@@ -273,6 +279,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.last_attempt.82aee111": "Last attempt",
|
||||
"i18n:govoplan-calendar.last_sync.ef0ef267": "Last sync",
|
||||
"i18n:govoplan-calendar.loading_calendar.7eb8f548": "Loading calendar...",
|
||||
"i18n:govoplan-calendar.loading_calendars.afbb957b": "Kalender werden geladen...",
|
||||
"i18n:govoplan-calendar.loading_event_count.716ad3c2": "Loading event count...",
|
||||
"i18n:govoplan-calendar.local_calendar.ed3f72f8": "Local calendar",
|
||||
"i18n:govoplan-calendar.local.dc99d54d": "Local",
|
||||
@@ -294,6 +301,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.next.bc981983": "Weiter",
|
||||
"i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b": "Kein kompatibler Zielkalender ist verfügbar. Fügen Sie einen lokalen Kalender oder einen aktiven CalDAV-Kalender mit bidirektionaler Synchronisierung hinzu.",
|
||||
"i18n:govoplan-calendar.no_calendar_collections_found.6453624a": "No calendar collections found.",
|
||||
"i18n:govoplan-calendar.no_calendars_are_available.711d2cf3": "Es sind keine Kalender verfügbar.",
|
||||
"i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars",
|
||||
"i18n:govoplan-calendar.no_events.e339ba73": "No events",
|
||||
"i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65": "Kein lokaler Zielkalender ist verfügbar. Fügen Sie einen lokalen Kalender hinzu, um diese Termine abzutrennen, ohne den entfernten Kalender zu ändern.",
|
||||
@@ -331,6 +339,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.save.efc007a3": "Speichern",
|
||||
"i18n:govoplan-calendar.saving.ae7e8875": "Saving...",
|
||||
"i18n:govoplan-calendar.sequence.5c8f4e0e": "Sequence",
|
||||
"i18n:govoplan-calendar.select_calendar.f38b5ba2": "Kalender auswählen",
|
||||
"i18n:govoplan-calendar.show_value.60e2ce8e": "Show {value0}",
|
||||
"i18n:govoplan-calendar.source_href.819fa147": "Source href",
|
||||
"i18n:govoplan-calendar.source_kind.7eda9bc4": "Source kind",
|
||||
@@ -349,6 +358,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.target_calendar.e533fe1d": "Target calendar",
|
||||
"i18n:govoplan-calendar.tentative.d19f9022": "TENTATIVE",
|
||||
"i18n:govoplan-calendar.the_selected_calendar.67caa12f": "the selected calendar",
|
||||
"i18n:govoplan-calendar.the_selected_calendar_is_no_longer_available.39f0f1f5": "Der ausgewählte Kalender ist nicht mehr verfügbar.",
|
||||
"i18n:govoplan-calendar.thu.3593ccd9": "Thu",
|
||||
"i18n:govoplan-calendar.timezone.d1f7dc89": "Timezone",
|
||||
"i18n:govoplan-calendar.title.768e0c1c": "Title",
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export { calendarModule as default, calendarModule } from "./module";
|
||||
export * from "./api/calendar";
|
||||
export { default as CalendarPicker } from "./features/calendar/CalendarPicker";
|
||||
export * from "./features/calendar/calendarPickerLogic";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import type { CalendarPickerUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import "./styles/calendar.css";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import CalendarPicker from "./features/calendar/CalendarPicker";
|
||||
|
||||
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
||||
|
||||
@@ -11,6 +12,8 @@ const translations = {
|
||||
de: generatedTranslations.de
|
||||
};
|
||||
|
||||
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
|
||||
|
||||
export const calendarModule: PlatformWebModule = {
|
||||
id: "calendar",
|
||||
label: "i18n:govoplan-calendar.calendar.adab5090",
|
||||
@@ -20,8 +23,11 @@ export const calendarModule: PlatformWebModule = {
|
||||
translations,
|
||||
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
|
||||
routes: [
|
||||
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }]
|
||||
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
|
||||
uiCapabilities: {
|
||||
"calendar.picker": calendarPicker
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export default calendarModule;
|
||||
export default calendarModule;
|
||||
|
||||
20
webui/tests/calendar-picker-structure.test.mjs
Normal file
20
webui/tests/calendar-picker-structure.test.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const picker = fs.readFileSync(new URL("../src/features/calendar/CalendarPicker.tsx", import.meta.url), "utf8");
|
||||
const moduleSource = fs.readFileSync(new URL("../src/module.ts", import.meta.url), "utf8");
|
||||
const coreTypes = fs.readFileSync(new URL("../../../govoplan-core/webui/src/types.ts", import.meta.url), "utf8");
|
||||
|
||||
assert(moduleSource.includes('"calendar.picker": calendarPicker'), "Calendar must register the named picker capability");
|
||||
assert(coreTypes.includes("export type CalendarPickerUiCapability"), "Core must expose the narrow cross-module contract");
|
||||
assert(picker.includes("listCalendars(settings)"), "Picker must use Calendar's authenticated list API");
|
||||
assert(picker.includes("hasScope(auth, CALENDAR_PICKER_READ_SCOPE)"), "Picker must avoid loading without read permission");
|
||||
assert(picker.includes("value={value}"), "Picker must remain controlled by its consumer");
|
||||
assert(picker.includes("onChange={(event) => onChange(event.target.value)}"), "Picker must return the chosen calendar id");
|
||||
assert(picker.includes("aria-busy={loading || undefined}"), "Picker must expose loading state accessibly");
|
||||
assert(picker.includes("aria-describedby={status ? statusId : undefined}"), "Picker must associate availability feedback");
|
||||
|
||||
console.log("Calendar picker capability structure checks passed.");
|
||||
37
webui/tests/calendar-picker.test.ts
Normal file
37
webui/tests/calendar-picker.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
calendarPickerOptions,
|
||||
calendarPickerTenantId,
|
||||
type CalendarPickerOption
|
||||
} from "../src/features/calendar/calendarPickerLogic";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function calendar(id: string, name: string, isDefault = false): CalendarPickerOption {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
is_default: isDefault
|
||||
};
|
||||
}
|
||||
|
||||
const input = [
|
||||
calendar("z", "Zulu"),
|
||||
calendar("b", "Bravo", true),
|
||||
calendar("a", "alpha")
|
||||
];
|
||||
const ordered = calendarPickerOptions(input);
|
||||
|
||||
assert(ordered.map((item) => item.id).join(",") === "b,a,z", "default calendar should lead, followed by names");
|
||||
assert(input.map((item) => item.id).join(",") === "z,b,a", "picker sorting must not mutate API data");
|
||||
assert(
|
||||
calendarPickerTenantId({ tenant: { id: "fallback" }, active_tenant: { id: "active" } }) === "active",
|
||||
"active tenant should partition picker requests"
|
||||
);
|
||||
assert(
|
||||
calendarPickerTenantId({ tenant: { id: "fallback" } }) === "fallback",
|
||||
"legacy tenant should remain a supported fallback"
|
||||
);
|
||||
|
||||
console.log("Calendar picker behavior checks passed.");
|
||||
18
webui/tsconfig.calendar-picker-tests.json
Normal file
18
webui/tsconfig.calendar-picker-tests.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"noEmit": false,
|
||||
"outDir": ".calendar-picker-test-build",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"tests/calendar-picker.test.ts",
|
||||
"src/features/calendar/calendarPickerLogic.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user