feat(scheduling): prefill participant availability

This commit is contained in:
2026-07-20 18:18:18 +02:00
parent ec538f524d
commit 6f298c36fe
3 changed files with 67 additions and 4 deletions

View File

@@ -52,10 +52,13 @@ assert.match(page, /type="email"[\s\S]*aria-label=\{I18N\.participantEmail\}/);
assert.doesNotMatch(page, /header: I18N\.required|<table|scheduling-table|scheduling-card(?:\s|"|`)/);
assert.match(page, /aria-label=\{i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);
assert.match(page, /setAvailability\(Object\.fromEntries\(response\.answers\.map/);
assert.doesNotMatch(page, /<p>\{selected\.calendar_id\}<\/p>/);
assert.match(page, /className="scheduling-selected-calendar"/);
assert.match(page, /showPlanningCalendarActions/);
assert.match(page, /title=\{!canReadAvailability \? I18N\.requiresAvailabilityRead/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/);
console.log("Scheduling page structure satisfies the focused list/create/respond contract.");

View File

@@ -124,6 +124,17 @@ export type SchedulingAvailabilityPayload = {
}>;
};
export type SchedulingAvailabilityResponse = {
request_id: string;
participant_id: string;
has_response: boolean;
submitted_at?: string | null;
answers: Array<{
slot_id: string;
value: SchedulingAvailabilityValue;
}>;
};
export type SchedulingCalendarActionResponse = {
request: SchedulingRequest;
created_event_ids: string[];
@@ -218,6 +229,16 @@ export function submitSchedulingAvailability(
);
}
export function getSchedulingAvailabilityResponse(
settings: ApiSettings,
requestId: string
): Promise<SchedulingAvailabilityResponse> {
return apiFetch<SchedulingAvailabilityResponse>(
settings,
`/api/v1/scheduling/requests/${requestId}/responses/me`
);
}
export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
}

View File

@@ -36,6 +36,7 @@ import {
createSchedulingRequest,
decideSchedulingRequest,
evaluateSchedulingFreeBusy,
getSchedulingAvailabilityResponse,
listSchedulingNotifications,
listSchedulingRequests,
openSchedulingRequest,
@@ -176,6 +177,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
const [slots, setSlots] = useState<SlotDraft[]>(() => initialSlots(translateText));
const [participants, setParticipants] = useState<ParticipantDraft[]>(() => [emptyParticipant()]);
const [availability, setAvailability] = useState<Record<string, SchedulingAvailabilityValue | "">>({});
const [availabilityLoading, setAvailabilityLoading] = useState(false);
const canWrite = hasScope(auth, "scheduling:schedule:write");
const canRespond = hasScope(auth, "scheduling:availability:write");
@@ -240,6 +242,39 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
void loadDetails(selected.id);
}, [creating, selected?.id]);
useEffect(() => {
if (!selected?.id || !selectedParticipant || !canRespond || creating || selected.status !== "collecting") {
setAvailabilityLoading(false);
return undefined;
}
let cancelled = false;
setAvailabilityLoading(true);
void getSchedulingAvailabilityResponse(settings, selected.id)
.then((response) => {
if (cancelled) return;
setAvailability(Object.fromEntries(response.answers.map((answer) => [answer.slot_id, answer.value])));
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err, translateText(I18N.requestFailed)));
})
.finally(() => {
if (!cancelled) setAvailabilityLoading(false);
});
return () => {
cancelled = true;
};
}, [
canRespond,
creating,
selected?.id,
selected?.status,
selectedParticipant?.id,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey,
translateText
]);
useEffect(() => {
if (!creating || !draftDirty) return undefined;
const preventUnsavedExit = (event: BeforeUnloadEvent) => event.preventDefault();
@@ -366,7 +401,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
async function sendAvailability(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selected || !selectedParticipant || !canRespond) return;
if (!selected || !selectedParticipant || !canRespond || availabilityLoading) return;
const answers = selected.slots
.map((slot) => ({ slot_id: slot.id, value: availability[slot.id] }))
.filter((answer): answer is { slot_id: string; value: SchedulingAvailabilityValue } => Boolean(answer.value));
@@ -578,12 +613,16 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
variant="primary"
type="submit"
form="scheduling-response-form"
disabled={saving || !canRespond || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}>
disabled={saving || availabilityLoading || !canRespond || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}>
<Send aria-hidden="true" size={16} />
{selectedParticipant.status === "responded" ? I18N.updateResponse : I18N.sendResponse}
</Button>
)}>
<form id="scheduling-response-form" className="scheduling-response-card" onSubmit={(event) => void sendAvailability(event)}>
<form
id="scheduling-response-form"
className="scheduling-response-card"
aria-busy={availabilityLoading}
onSubmit={(event) => void sendAvailability(event)}>
<p>{selectedParticipant.status === "responded"
? (selected.allow_participant_updates ? I18N.responseReplace : I18N.responseRecorded)
: I18N.invitationHelp}</p>
@@ -596,7 +635,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<span><strong>{slot.label}</strong><small>{formatRange(slot.start_at, slot.end_at)}</small></span>
<select
value={availability[slot.id] ?? ""}
disabled={selectedParticipant.status === "responded" && !selected.allow_participant_updates}
disabled={availabilityLoading || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}
onChange={(event) => setAvailability((current) => ({
...current,
[slot.id]: event.target.value as SchedulingAvailabilityValue | ""