diff --git a/src/govoplan_scheduling/backend/manifest.py b/src/govoplan_scheduling/backend/manifest.py index 4f600dd..5dedb5e 100644 --- a/src/govoplan_scheduling/backend/manifest.py +++ b/src/govoplan_scheduling/backend/manifest.py @@ -15,6 +15,7 @@ from govoplan_core.core.modules import ( ModuleManifest, NavItem, PermissionDefinition, + PublicFrontendRoute, RoleTemplate, ) from govoplan_core.db.base import Base @@ -162,6 +163,13 @@ manifest = ModuleManifest( frontend=FrontendModule( module_id=MODULE_ID, package_name="@govoplan/scheduling-webui", + public_routes=( + PublicFrontendRoute( + path="/scheduling/public/:requestId/:token", + component="SchedulingPublicPage", + order=10, + ), + ), nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),), ), route_factory=_scheduling_router, diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 680197d..d34c59f 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -29,6 +29,10 @@ class SchedulingManifestTests(unittest.TestCase): self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.migration_spec) self.assertIsNotNone(manifest.frontend) + self.assertEqual( + ["/scheduling/public/:requestId/:token"], + [route.path for route in manifest.frontend.public_routes], + ) self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.response_collection", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces}) diff --git a/webui/scripts/test-scheduling-page-structure.mjs b/webui/scripts/test-scheduling-page-structure.mjs index 6b7e28f..84a4cfe 100644 --- a/webui/scripts/test-scheduling-page-structure.mjs +++ b/webui/scripts/test-scheduling-page-structure.mjs @@ -3,9 +3,13 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPage.tsx", import.meta.url)); +const publicPagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPublicPage.tsx", import.meta.url)); const apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url)); +const modulePath = fileURLToPath(new URL("../src/module.ts", import.meta.url)); const page = readFileSync(pagePath, "utf8"); +const publicPage = readFileSync(publicPagePath, "utf8"); const api = readFileSync(apiPath, "utf8"); +const moduleSource = readFileSync(modulePath, "utf8"); assert.match(page, /usePlatformUiCapability\("calendar\.picker"\)/); assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/); @@ -112,4 +116,14 @@ assert.match(api, /\/api\/v1\/scheduling\/public\/\$\{encodeURIComponent\(reques assert.match(page, /useSearchParams\(\)/); assert.match(page, /Promise\.allSettled/); -console.log("Scheduling page structure satisfies the two-pane editor, response, and policy contract."); +assert.match(moduleSource, /publicRoutes:[\s\S]*path: "\/scheduling\/public\/:requestId\/:token"/); +assert.match(moduleSource, /SchedulingPublicPage/); +assert.match(publicPage, /Card,[\s\S]*DismissibleAlert,[\s\S]*FormField,[\s\S]*LoadingFrame,[\s\S]*from "@govoplan\/core-webui"/); +assert.match(publicPage, /getPublicSchedulingParticipation\(settings, requestId, token, \{\}\)/); +assert.match(publicPage, /applySchedulingAvailabilityChoice\(/); +assert.match(publicPage, /option_revision: slot\.revision/); +assert.match(publicPage, /idempotency_key: newIdempotencyKey\(\)/); +assert.doesNotMatch(publicPage, /window\.(?:alert|confirm)\(/); +assert.doesNotMatch(publicPage, /(?:localStorage|sessionStorage).*token|token.*(?:localStorage|sessionStorage)/); + +console.log("Scheduling pages satisfy the two-pane editor, public response, and policy contracts."); diff --git a/webui/src/features/scheduling/SchedulingPublicPage.tsx b/webui/src/features/scheduling/SchedulingPublicPage.tsx new file mode 100644 index 0000000..7d782a6 --- /dev/null +++ b/webui/src/features/scheduling/SchedulingPublicPage.tsx @@ -0,0 +1,268 @@ +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { Link, useParams } from "react-router-dom"; +import { + Button, + Card, + DismissibleAlert, + FormField, + LoadingFrame, + formatDateTime, + type ApiSettings, + type AuthInfo +} from "@govoplan/core-webui"; +import { + getPublicSchedulingParticipation, + submitPublicSchedulingParticipation, + type SchedulingAvailabilityValue, + type SchedulingPublicParticipationAccessPayload, + type SchedulingPublicParticipationResponse +} from "../../api/scheduling"; +import { applySchedulingAvailabilityChoice } from "./schedulingViewModel"; + +type SchedulingPublicPageProps = { + settings: ApiSettings; + auth: AuthInfo | null; +}; + +const I18N = { + accessDetails: "i18n:govoplan-scheduling.access_details.79c06b89", + accessHelp: "i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c", + accessRequest: "i18n:govoplan-scheduling.open_scheduling_request.31829cce", + alreadySubmitted: "i18n:govoplan-scheduling.responses_may_be_updated_while_this_request_remains_open.4faecbbe", + answerRequired: "i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f", + available: "i18n:govoplan-scheduling.available.7c62a142", + backToScheduling: "i18n:govoplan-scheduling.open_in_scheduling.48df1541", + comment: "i18n:govoplan-scheduling.comment.d03495b1", + deadline: "i18n:govoplan-scheduling.response_deadline.7fd9e3aa", + email: "i18n:govoplan-scheduling.participant_email.2cadfd9e", + invalidAccess: "i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197", + loading: "i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b", + maybe: "i18n:govoplan-scheduling.maybe.56dd8d0b", + noLongerOpen: "i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a", + password: "i18n:govoplan-scheduling.guest_password.94545e82", + response: "i18n:govoplan-scheduling.your_availability.f86c8215", + saved: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d", + submit: "i18n:govoplan-scheduling.submit_response.a5f0c053", + unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79" +} as const; + +function accessPayload(email: string, password: string): SchedulingPublicParticipationAccessPayload { + return { + participant_email: email.trim() || null, + password: password || null + }; +} + +function initialAvailability(response: SchedulingPublicParticipationResponse): Record { + const previous = new Map(response.answers.map((answer) => [answer.slot_id, answer.value])); + return Object.fromEntries(response.slots.map((slot) => [slot.id, previous.get(slot.id) ?? ""])); +} + +function newIdempotencyKey(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + return `scheduling-response-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export default function SchedulingPublicPage({ settings, auth }: SchedulingPublicPageProps) { + const { requestId = "", token = "" } = useParams(); + const [response, setResponse] = useState(null); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [availability, setAvailability] = useState>({}); + const [comment, setComment] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [accessAttempted, setAccessAttempted] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + const slotIds = useMemo(() => response?.slots.map((slot) => slot.id) ?? [], [response]); + const collecting = response?.status === "collecting"; + + function applyResponse(next: SchedulingPublicParticipationResponse) { + setResponse(next); + setAvailability(initialAvailability(next)); + setComment(next.comment ?? ""); + setError(""); + } + + useEffect(() => { + let cancelled = false; + setLoading(true); + setResponse(null); + setAccessAttempted(false); + setError(""); + void getPublicSchedulingParticipation(settings, requestId, token, {}) + .then((next) => { + if (!cancelled) applyResponse(next); + }) + .catch(() => undefined) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [requestId, settings.apiBaseUrl, settings.apiKey, token]); + + async function openRequest(event: FormEvent) { + event.preventDefault(); + setLoading(true); + setAccessAttempted(true); + setError(""); + try { + applyResponse(await getPublicSchedulingParticipation(settings, requestId, token, accessPayload(email, password))); + } catch { + setResponse(null); + setError(I18N.invalidAccess); + } finally { + setLoading(false); + } + } + + async function saveResponse(event: FormEvent) { + event.preventDefault(); + if (!response) return; + if (!response.slots.some((slot) => availability[slot.id])) { + setError(I18N.answerRequired); + return; + } + setSaving(true); + setError(""); + setSuccess(""); + try { + const next = await submitPublicSchedulingParticipation(settings, requestId, token, { + ...accessPayload(email, password), + answers: response.slots + .filter((slot) => availability[slot.id]) + .map((slot) => ({ + slot_id: slot.id, + value: availability[slot.id] as SchedulingAvailabilityValue, + option_revision: slot.revision + })), + comment: response.allow_comments ? comment : null, + idempotency_key: newIdempotencyKey() + }); + applyResponse(next); + setSuccess(I18N.saved); + } catch { + setError(I18N.invalidAccess); + } finally { + setSaving(false); + } + } + + return ( +
+ + {auth && ( +
+ + {I18N.backToScheduling} + +
+ )} + + {!response && !loading && ( + +
+

{I18N.accessHelp}

+ {accessAttempted && error && {error}} +
+ + setEmail(event.target.value)} + /> + + + setPassword(event.target.value)} + /> + +
+
+ +
+
+
+ )} + + {response && ( +
+ + {response.description &&

{response.description}

} +
+ {response.location && <>
i18n:govoplan-scheduling.location.d219c681
{response.location}
} + {response.deadline_at && <>
{I18N.deadline}
{formatDateTime(response.deadline_at)}
} +
+ {!collecting && {I18N.noLongerOpen}} + {response.has_response && collecting && {I18N.alreadySubmitted}} +
+ + {error && {error}} + {success && {success}} + + +
+ {response.slots.map((slot) => ( +
+ {slot.label} +

{formatDateTime(slot.start_at)} – {formatDateTime(slot.end_at)}

+ {slot.description &&

{slot.description}

} + {(slot.location || response.location) &&

{slot.location || response.location}

} +
+ {([ + ["available", I18N.available], + ...(response.allow_maybe ? [["maybe", I18N.maybe] as const] : []), + ["unavailable", I18N.unavailable] + ] as Array<[SchedulingAvailabilityValue, string]>).map(([value, label]) => ( + + ))} +
+
+ ))} +
+ {response.allow_comments && ( + +