Complete guided Scheduling interface patterns

This commit is contained in:
2026-08-03 10:36:14 +02:00
parent 3d8272b28e
commit c17cbdae63
9 changed files with 334 additions and 52 deletions
+140 -24
View File
@@ -116,6 +116,14 @@ type InvitationRevokeTarget = {
requestId: string;
participant: SchedulingParticipant;
};
type ConsequentialAction = {
kind: "close" | "reminder" | "holds" | "final-event";
requestId: string;
};
type DecisionTarget = {
requestId: string;
slot: SchedulingCandidateSlot;
};
const I18N = {
actions: "i18n:govoplan-core.actions.c3cd636a",
@@ -133,6 +141,7 @@ const I18N = {
configuredCalendar: "i18n:govoplan-scheduling.configured_calendar.e2e8ebd5",
calendarDescription: "i18n:govoplan-scheduling.use_a_calendar_for_availability_checks_tentative_holds_a.20ccc1fa",
finalEventLabel: "i18n:govoplan-scheduling.create_calendar_event.0b87cfcf",
finalEventEffect: "i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252",
calendarIntegration: "i18n:govoplan-scheduling.calendar_integration.181ad18b",
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e",
calendarRequiredAction: "i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106",
@@ -143,12 +152,14 @@ const I18N = {
chooseAvailability: "i18n:govoplan-scheduling.choose_availability.ac95b8f6",
clipboardUnavailable: "i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc",
closePoll: "i18n:govoplan-scheduling.close_poll.a6a18916",
closePollEffect: "i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519",
closed: "i18n:govoplan-scheduling.closed.88d86b77",
copyInvitationLink: "i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79",
description: "i18n:govoplan-scheduling.description.55f8ebc8",
determined: "i18n:govoplan-scheduling.determined.9f23293d",
decideUnavailable: "i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d",
decision: "i18n:govoplan-scheduling.decision.7f59a1f1",
decideOn: "i18n:govoplan-scheduling.decide_on_value.196409cd",
discard: "i18n:govoplan-scheduling.discard.36fff63c",
discardConfirm: "i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2",
edit: "i18n:govoplan-scheduling.edit_scheduling_request.7e749c19",
@@ -157,6 +168,7 @@ const I18N = {
generalSettings: "i18n:govoplan-scheduling.participation_settings.8dc6f62c",
free: "i18n:govoplan-scheduling.free.75f52718",
holds: "i18n:govoplan-scheduling.create_tentative_holds.51c4744e",
holdsEffect: "i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884",
invitationDeliveryUnavailable: "i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3",
invitationDeliveryFailed: "i18n:govoplan-scheduling.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306",
invitationDeliveryRequested: "i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba",
@@ -216,6 +228,7 @@ const I18N = {
refresh: "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1",
reloadInvitation: "i18n:govoplan-scheduling.reload_the_request_before_changing_this_invitation.9e685df4",
reminder: "i18n:govoplan-scheduling.send_reminder.cf5eb3bf",
reminderEffect: "i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797",
revokeInvitation: "i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa",
revokeInvitationConfirm: "i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817",
revokeInvitationLabel: "i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf",
@@ -301,6 +314,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
const [notificationsUnavailable, setNotificationsUnavailable] = useState(false);
const [detailsLoading, setDetailsLoading] = useState(false);
const [revokeInvitationTarget, setRevokeInvitationTarget] = useState<InvitationRevokeTarget | null>(null);
const [consequentialAction, setConsequentialAction] = useState<ConsequentialAction | null>(null);
const [decisionTarget, setDecisionTarget] = useState<DecisionTarget | null>(null);
const [invitationActionClock, setInvitationActionClock] = useState(() => new Date());
const detailLoadSequence = useRef(0);
@@ -365,6 +380,9 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
() => availabilityComment !== savedAvailabilityComment || !availabilityValuesEqual(availability, savedAvailability),
[availability, availabilityComment, savedAvailability, savedAvailabilityComment]
);
const consequentialActionCopy = consequentialAction
? schedulingActionConfirmation(consequentialAction.kind)
: null;
const editorOriginal = editorMode === "edit"
? requests.find((request) => request.id === editingRequestId) ?? null
: null;
@@ -798,6 +816,31 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
await runParticipantInvitationAction(target.requestId, target.participant, "revoke");
}
async function confirmSchedulingAction() {
if (!consequentialAction || selected?.id !== consequentialAction.requestId) return;
const action = consequentialAction;
setConsequentialAction(null);
if (action.kind === "close") {
await runAction(() => closeSchedulingRequest(settings, action.requestId));
} else if (action.kind === "reminder") {
await runAction(() => createSchedulingNotifications(settings, action.requestId, "reminder"));
} else if (action.kind === "holds") {
await runAction(() => createSchedulingHolds(settings, action.requestId));
} else {
await runAction(() => createSchedulingCalendarEvent(settings, action.requestId));
}
}
async function confirmSchedulingDecision() {
if (!decisionTarget || selected?.id !== decisionTarget.requestId) return;
const target = decisionTarget;
setDecisionTarget(null);
await runAction(() => decideSchedulingRequest(settings, target.requestId, {
slot_id: target.slot.id,
handoff_to_calendar: selected.create_calendar_event_on_decision
}));
}
async function sendAvailability(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await persistAvailability();
@@ -859,9 +902,20 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
label={I18N.refresh}
icon={<RefreshCw aria-hidden="true" size={16} />}
onClick={() => requestNavigation(() => void loadRequests(selected?.id))}
disabled={loading || saving} />
disabled={loading || saving}
disabledReason={saving ? I18N.saving : undefined} />
<DocumentationHelpLink
reference={{
topicId: "scheduling.find-and-decide-meeting-time",
documentationType: "user"
}} />
{canCreateOrWrite ? (
<Button type="button" variant="primary" onClick={beginCreate} disabled={saving}>
<Button
type="button"
variant="primary"
onClick={beginCreate}
disabled={saving}
disabledReason={saving ? I18N.saving : undefined}>
<Plus aria-hidden="true" size={16} /> {I18N.add}
</Button>
) : null}
@@ -909,8 +963,24 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
</div>
</div>
<div className="scheduling-page-actions">
<Button type="button" onClick={discardEditor} disabled={saving}>{I18N.discard}</Button>
<Button type="submit" form="scheduling-editor-form" variant="primary" disabled={saving || !canCreateOrWrite}>
<DocumentationHelpLink
reference={{
topicId: "scheduling.find-and-decide-meeting-time",
documentationType: "user"
}} />
<Button
type="button"
onClick={discardEditor}
disabled={saving}
disabledReason={saving ? I18N.saving : undefined}>
{I18N.discard}
</Button>
<Button
type="submit"
form="scheduling-editor-form"
variant="primary"
disabled={saving || !canCreateOrWrite}
disabledReason={saving ? I18N.saving : !canCreateOrWrite ? I18N.unavailable : undefined}>
<Save aria-hidden="true" size={16} /> {saving ? I18N.saving : I18N.save}
</Button>
</div>
@@ -1092,7 +1162,11 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
documentationType: "user"
}} />
{canEditSelected ? (
<Button type="button" onClick={() => beginEdit(selected)} disabled={saving}>
<Button
type="button"
onClick={() => beginEdit(selected)}
disabled={saving}
disabledReason={saving ? I18N.saving : undefined}>
<Pencil aria-hidden="true" size={16} /> {I18N.edit}
</Button>
) : null}
@@ -1101,8 +1175,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
request={selected}
saving={saving}
onOpen={() => void runAction(() => openSchedulingRequest(settings, selected.id))}
onClose={() => requestNavigation(() => void runAction(() => closeSchedulingRequest(settings, selected.id)))}
onReminder={() => void runAction(() => createSchedulingNotifications(settings, selected.id, "reminder"))} />
onClose={() => requestNavigation(() => setConsequentialAction({ kind: "close", requestId: selected.id }))}
onReminder={() => setConsequentialAction({ kind: "reminder", requestId: selected.id })} />
) : null}
</div>
)}>
@@ -1154,7 +1228,16 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
variant="primary"
type="submit"
form="scheduling-response-form"
disabled={saving || availabilityLoading || !canRespond || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}>
disabled={saving || availabilityLoading || !canRespond || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}
disabledReason={saving
? I18N.saving
: availabilityLoading
? I18N.loading
: !canRespond
? I18N.unavailable
: selectedParticipant.status === "responded" && !selected.allow_participant_updates
? I18N.responseRecorded
: undefined}>
<Send aria-hidden="true" size={16} />
{selectedParticipant.status === "responded" ? I18N.updateResponse : I18N.sendResponse}
</Button>
@@ -1210,10 +1293,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
saving={saving}
allowMaybe={selected.allow_maybe}
maxParticipantsPerOption={selected.max_participants_per_option}
onDecide={(slot) => void runAction(() => decideSchedulingRequest(settings, selected.id, {
slot_id: slot.id,
handoff_to_calendar: selected.create_calendar_event_on_decision
}))} />
onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} />
</Card>
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id) ? (
@@ -1225,7 +1305,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<Button
type="button"
disabled={saving || !canReadAvailability}
disabledReason={!canReadAvailability ? I18N.requiresAvailabilityRead : undefined}
disabledReason={saving ? I18N.saving : !canReadAvailability ? I18N.requiresAvailabilityRead : undefined}
onClick={() => void runAction(() => evaluateSchedulingFreeBusy(settings, selected.id))}>
<RefreshCw aria-hidden="true" size={16} /> {I18N.checkFreeBusy}
</Button>
@@ -1234,8 +1314,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<Button
type="button"
disabled={saving || !canWriteCalendarEvent}
disabledReason={!canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
onClick={() => void runAction(() => createSchedulingHolds(settings, selected.id))}>
disabledReason={saving ? I18N.saving : !canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
onClick={() => setConsequentialAction({ kind: "holds", requestId: selected.id })}>
<Clock aria-hidden="true" size={16} /> {I18N.holds}
</Button>
) : null}
@@ -1243,8 +1323,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<Button
type="button"
disabled={saving || !canWriteCalendarEvent}
disabledReason={!canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
onClick={() => void runAction(() => createSchedulingCalendarEvent(settings, selected.id))}>
disabledReason={saving ? I18N.saving : !canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
onClick={() => setConsequentialAction({ kind: "final-event", requestId: selected.id })}>
<CalendarCheck aria-hidden="true" size={16} /> {I18N.finalEventLabel}
</Button>
) : null}
@@ -1326,6 +1406,25 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
busy={saving}
onCancel={() => setRevokeInvitationTarget(null)}
onConfirm={() => void confirmRevokeInvitation()} />
<ConfirmDialog
open={Boolean(consequentialAction && consequentialActionCopy)}
title={consequentialActionCopy?.title ?? I18N.unavailable}
message={consequentialActionCopy?.message ?? I18N.unavailable}
confirmLabel={consequentialActionCopy?.confirmLabel}
tone={consequentialAction?.kind === "close" ? "danger" : "default"}
busy={saving}
onCancel={() => setConsequentialAction(null)}
onConfirm={() => void confirmSchedulingAction()} />
<ConfirmDialog
open={Boolean(decisionTarget)}
title={I18N.decision}
message={i18nMessage(I18N.decideOn, { value0: decisionTarget?.slot.label ?? "" })}
confirmLabel={decisionTarget
? i18nMessage(I18N.decideOn, { value0: decisionTarget.slot.label })
: I18N.decision}
busy={saving}
onCancel={() => setDecisionTarget(null)}
onConfirm={() => void confirmSchedulingDecision()} />
</main>
);
@@ -1613,6 +1712,23 @@ function ParticipationStats({
);
}
function schedulingActionConfirmation(kind: ConsequentialAction["kind"]): {
title: string;
message: string;
confirmLabel: string;
} {
if (kind === "close") {
return { title: I18N.closePoll, message: I18N.closePollEffect, confirmLabel: I18N.closePoll };
}
if (kind === "reminder") {
return { title: I18N.reminder, message: I18N.reminderEffect, confirmLabel: I18N.reminder };
}
if (kind === "holds") {
return { title: I18N.holds, message: I18N.holdsEffect, confirmLabel: I18N.holds };
}
return { title: I18N.finalEventLabel, message: I18N.finalEventEffect, confirmLabel: I18N.finalEventLabel };
}
function LifecycleActions({
request,
saving,
@@ -1627,13 +1743,13 @@ function LifecycleActions({
onReminder: () => void;
}) {
if (request.status === "draft") {
return <Button type="button" variant="primary" disabled={saving} onClick={onOpen}><Send aria-hidden="true" size={16} /> {I18N.openPoll}</Button>;
return <Button type="button" variant="primary" disabled={saving} disabledReason={saving ? I18N.saving : undefined} onClick={onOpen}><Send aria-hidden="true" size={16} /> {I18N.openPoll}</Button>;
}
if (request.status === "collecting") {
return (
<div className="scheduling-actions">
<Button type="button" disabled={saving} onClick={onReminder}><Bell aria-hidden="true" size={16} /> {I18N.reminder}</Button>
<Button type="button" variant="primary" disabled={saving} onClick={onClose}><XCircle aria-hidden="true" size={16} /> {I18N.closePoll}</Button>
<Button type="button" disabled={saving} disabledReason={saving ? I18N.saving : undefined} onClick={onReminder}><Bell aria-hidden="true" size={16} /> {I18N.reminder}</Button>
<Button type="button" variant="primary" disabled={saving} disabledReason={saving ? I18N.saving : undefined} onClick={onClose}><XCircle aria-hidden="true" size={16} /> {I18N.closePoll}</Button>
</div>
);
}
@@ -1859,7 +1975,7 @@ function CandidateSlotsGrid({
label: i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label }),
icon: <Check aria-hidden="true" size={16} />,
disabled: saving || !decisionEnabled,
disabledReason: !decisionEnabled ? I18N.decideUnavailable : undefined,
disabledReason: saving ? I18N.saving : !decisionEnabled ? I18N.decideUnavailable : undefined,
onClick: () => onDecide(slot)
}]} />
} satisfies DataGridColumn<SchedulingCandidateSlot>] : [])
@@ -1925,7 +2041,7 @@ function ParticipantsGrid({
label: i18nMessage(I18N.copyInvitationLink, { value0: label }),
icon: <Copy aria-hidden="true" size={16} />,
disabled: saving || Boolean(copyDisabledReason),
disabledReason: copyDisabledReason,
disabledReason: saving ? I18N.saving : copyDisabledReason,
onClick: () => onCopy(participant)
},
{
@@ -1933,7 +2049,7 @@ function ParticipantsGrid({
label: i18nMessage(I18N.sendInvitation, { value0: label }),
icon: <Send aria-hidden="true" size={16} />,
disabled: saving || Boolean(deliveryDisabledReason),
disabledReason: deliveryDisabledReason,
disabledReason: saving ? I18N.saving : deliveryDisabledReason,
onClick: () => onSend(participant)
},
{
@@ -1942,7 +2058,7 @@ function ParticipantsGrid({
icon: <Link2Off aria-hidden="true" size={16} />,
variant: "danger",
disabled: saving || Boolean(revokeDisabledReason),
disabledReason: revokeDisabledReason,
disabledReason: saving ? I18N.saving : revokeDisabledReason,
onClick: () => onRevoke(participant)
}
]} />