diff --git a/.gitignore b/.gitignore index 2194766..0283361 100644 --- a/.gitignore +++ b/.gitignore @@ -118,6 +118,7 @@ dist .template-preview-test-build/ .import-test-build/ webui/.component-test-build/ +webui/.calendar-picker-test-build/ webui/.module-test-build/ webui/.policy-test-build/ webui/.template-preview-test-build/ diff --git a/webui/package.json b/webui/package.json index 59b4886..d338d3a 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/src/features/calendar/CalendarPicker.tsx b/webui/src/features/calendar/CalendarPicker.tsx new file mode 100644 index 0000000..3ee6135 --- /dev/null +++ b/webui/src/features/calendar/CalendarPicker.tsx @@ -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([]); + 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 ( +
+ + + + {status ?
{status}
: null} +
+ ); +} diff --git a/webui/src/features/calendar/calendarPickerLogic.ts b/webui/src/features/calendar/calendarPickerLogic.ts new file mode 100644 index 0000000..02bd6c7 --- /dev/null +++ b/webui/src/features/calendar/calendarPickerLogic.ts @@ -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(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; +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 43d3ba4..222c887 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -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", diff --git a/webui/src/index.ts b/webui/src/index.ts index e162c35..38e2b2e 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -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"; diff --git a/webui/src/module.ts b/webui/src/module.ts index 689f498..f9686b0 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -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; \ No newline at end of file +export default calendarModule; diff --git a/webui/tests/calendar-picker-structure.test.mjs b/webui/tests/calendar-picker-structure.test.mjs new file mode 100644 index 0000000..594d1d3 --- /dev/null +++ b/webui/tests/calendar-picker-structure.test.mjs @@ -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."); diff --git a/webui/tests/calendar-picker.test.ts b/webui/tests/calendar-picker.test.ts new file mode 100644 index 0000000..17e95fc --- /dev/null +++ b/webui/tests/calendar-picker.test.ts @@ -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."); diff --git a/webui/tsconfig.calendar-picker-tests.json b/webui/tsconfig.calendar-picker-tests.json new file mode 100644 index 0000000..465f4cb --- /dev/null +++ b/webui/tsconfig.calendar-picker-tests.json @@ -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" + ] +}