feat(scheduling): prefill participant availability
This commit is contained in:
@@ -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.");
|
||||
|
||||
@@ -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({}));
|
||||
}
|
||||
|
||||
@@ -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 | ""
|
||||
|
||||
Reference in New Issue
Block a user