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.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, /aria-label=\{i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
|
||||||
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
|
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.doesNotMatch(page, /<p>\{selected\.calendar_id\}<\/p>/);
|
||||||
assert.match(page, /className="scheduling-selected-calendar"/);
|
assert.match(page, /className="scheduling-selected-calendar"/);
|
||||||
assert.match(page, /showPlanningCalendarActions/);
|
assert.match(page, /showPlanningCalendarActions/);
|
||||||
assert.match(page, /title=\{!canReadAvailability \? I18N\.requiresAvailabilityRead/);
|
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/);
|
||||||
|
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/);
|
||||||
|
|
||||||
console.log("Scheduling page structure satisfies the focused list/create/respond contract.");
|
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 = {
|
export type SchedulingCalendarActionResponse = {
|
||||||
request: SchedulingRequest;
|
request: SchedulingRequest;
|
||||||
created_event_ids: string[];
|
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> {
|
export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
|
||||||
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
|
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
createSchedulingRequest,
|
createSchedulingRequest,
|
||||||
decideSchedulingRequest,
|
decideSchedulingRequest,
|
||||||
evaluateSchedulingFreeBusy,
|
evaluateSchedulingFreeBusy,
|
||||||
|
getSchedulingAvailabilityResponse,
|
||||||
listSchedulingNotifications,
|
listSchedulingNotifications,
|
||||||
listSchedulingRequests,
|
listSchedulingRequests,
|
||||||
openSchedulingRequest,
|
openSchedulingRequest,
|
||||||
@@ -176,6 +177,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
const [slots, setSlots] = useState<SlotDraft[]>(() => initialSlots(translateText));
|
const [slots, setSlots] = useState<SlotDraft[]>(() => initialSlots(translateText));
|
||||||
const [participants, setParticipants] = useState<ParticipantDraft[]>(() => [emptyParticipant()]);
|
const [participants, setParticipants] = useState<ParticipantDraft[]>(() => [emptyParticipant()]);
|
||||||
const [availability, setAvailability] = useState<Record<string, SchedulingAvailabilityValue | "">>({});
|
const [availability, setAvailability] = useState<Record<string, SchedulingAvailabilityValue | "">>({});
|
||||||
|
const [availabilityLoading, setAvailabilityLoading] = useState(false);
|
||||||
|
|
||||||
const canWrite = hasScope(auth, "scheduling:schedule:write");
|
const canWrite = hasScope(auth, "scheduling:schedule:write");
|
||||||
const canRespond = hasScope(auth, "scheduling:availability:write");
|
const canRespond = hasScope(auth, "scheduling:availability:write");
|
||||||
@@ -240,6 +242,39 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
void loadDetails(selected.id);
|
void loadDetails(selected.id);
|
||||||
}, [creating, 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(() => {
|
useEffect(() => {
|
||||||
if (!creating || !draftDirty) return undefined;
|
if (!creating || !draftDirty) return undefined;
|
||||||
const preventUnsavedExit = (event: BeforeUnloadEvent) => event.preventDefault();
|
const preventUnsavedExit = (event: BeforeUnloadEvent) => event.preventDefault();
|
||||||
@@ -366,7 +401,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
|
|
||||||
async function sendAvailability(event: FormEvent<HTMLFormElement>) {
|
async function sendAvailability(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!selected || !selectedParticipant || !canRespond) return;
|
if (!selected || !selectedParticipant || !canRespond || availabilityLoading) return;
|
||||||
const answers = selected.slots
|
const answers = selected.slots
|
||||||
.map((slot) => ({ slot_id: slot.id, value: availability[slot.id] }))
|
.map((slot) => ({ slot_id: slot.id, value: availability[slot.id] }))
|
||||||
.filter((answer): answer is { slot_id: string; value: SchedulingAvailabilityValue } => Boolean(answer.value));
|
.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"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
form="scheduling-response-form"
|
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} />
|
<Send aria-hidden="true" size={16} />
|
||||||
{selectedParticipant.status === "responded" ? I18N.updateResponse : I18N.sendResponse}
|
{selectedParticipant.status === "responded" ? I18N.updateResponse : I18N.sendResponse}
|
||||||
</Button>
|
</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"
|
<p>{selectedParticipant.status === "responded"
|
||||||
? (selected.allow_participant_updates ? I18N.responseReplace : I18N.responseRecorded)
|
? (selected.allow_participant_updates ? I18N.responseReplace : I18N.responseRecorded)
|
||||||
: I18N.invitationHelp}</p>
|
: 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>
|
<span><strong>{slot.label}</strong><small>{formatRange(slot.start_at, slot.end_at)}</small></span>
|
||||||
<select
|
<select
|
||||||
value={availability[slot.id] ?? ""}
|
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) => ({
|
onChange={(event) => setAvailability((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[slot.id]: event.target.value as SchedulingAvailabilityValue | ""
|
[slot.id]: event.target.value as SchedulingAvailabilityValue | ""
|
||||||
|
|||||||
Reference in New Issue
Block a user