Apply guided scheduling interface patterns

This commit is contained in:
2026-08-03 08:29:29 +02:00
parent 3e9a14a970
commit 3d8272b28e
7 changed files with 225 additions and 4 deletions
+18
View File
@@ -132,6 +132,24 @@ poll-backed scheduling requests:
- a first Scheduling WebUI package with request creation, slot matrix, Calendar - a first Scheduling WebUI package with request creation, slot matrix, Calendar
actions, decisions, and notification-job creation actions, decisions, and notification-job creation
## Interface workflow and contextual guidance
The request detail projects the existing backend lifecycle into three stable
user stages: prepare the request, collect participation, and decide. Draft,
collecting, closed, decided, handed-off, cancelled, and archived records remain
the authoritative backend states; the stage rail is only a readable projection
and does not invent a second workflow state machine. Cancelled requests keep the
reached stage visible as stopped, while later stages remain locked.
Unavailable Calendar coordination and public guest participation use Core's
action-blocker pattern. The UI names the reason, required remediation,
responsible administrator, and destination instead of displaying a generic
disabled control. Stable help links resolve to the configured Docs module when
present and to the hosted documentation otherwise. Scheduling contributes the
`scheduling.find-and-decide-meeting-time`, `scheduling.calendar-coordination`,
and `scheduling.participation-governance` topics; it does not import Docs,
Calendar, Poll, Policy, or Access implementation code.
The next slices should add generic self-enrolment links after their abuse and The next slices should add generic self-enrolment links after their abuse and
identity policy is agreed, Calendar hold cleanup after decision, and advanced identity policy is agreed, Calendar hold cleanup after decision, and advanced
scoring constraints such as required participants and quorum rules. scoring constraints such as required participants and quorum rules.
@@ -111,6 +111,36 @@ DOCUMENTATION = (
related_modules=("poll", "calendar", "notifications", "mail"), related_modules=("poll", "calendar", "notifications", "mail"),
metadata={"kind": "reference"}, metadata={"kind": "reference"},
), ),
DocumentationTopic(
id="scheduling.calendar-coordination",
title="Configure scheduling calendar coordination",
summary="Understand the Calendar capability and permissions required for conflict checks, tentative holds, and final event handoff.",
body=(
"Calendar coordination remains optional. It is available only when Calendar contributes its picker capability and the actor can read calendars and availability and write events. "
"A disabled Calendar control therefore names the missing integration or authority instead of silently accepting a configuration that cannot run. "
"Administrators should enable the Calendar module and grant the bounded calendar, availability, and event permissions needed by the organizer; Scheduling never imports Calendar internals."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("organizer", "module_admin", "tenant_admin"),
related_modules=("calendar", "access", "policy"),
metadata={"kind": "reference", "context_ids": ["scheduling.calendar-integration"]},
),
DocumentationTopic(
id="scheduling.participation-governance",
title="Govern public scheduling participation",
summary="Resolve disabled guest invitations without weakening signed-link privacy or participation policy.",
body=(
"Public invitation links are issued only when the configured response, privacy, password, email, and update controls can be enforced by the public participation gateway. "
"When enforcement is unavailable, signed-in participants may continue to respond through their assigned request, but the system does not issue a weaker guest link. "
"A system or tenant administrator must restore the governed Poll/public-participation capability or keep the request limited to signed-in participation."
),
layer="configured",
documentation_types=("admin",),
audience=("operator", "module_admin", "tenant_admin"),
related_modules=("poll", "policy", "access"),
metadata={"kind": "pattern", "context_ids": ["scheduling.public-participation-blocker"]},
),
) )
@@ -17,6 +17,7 @@ assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && c
assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/); assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/);
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridRowActions,[\s\S]*FormField,[\s\S]*MetricCard,[\s\S]*PasswordField,[\s\S]*PeoplePicker,[\s\S]*SelectionList,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/); assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridRowActions,[\s\S]*FormField,[\s\S]*MetricCard,[\s\S]*PasswordField,[\s\S]*PeoplePicker,[\s\S]*SelectionList,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//); assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
assert.match(page, /ActionBlockerHint,[\s\S]*DocumentationHelpLink,[\s\S]*StageRail,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(page, /className="scheduling-workspace-layout"/); assert.match(page, /className="scheduling-workspace-layout"/);
assert.match(page, /<aside className="scheduling-request-sidebar">/); assert.match(page, /<aside className="scheduling-request-sidebar">/);
@@ -41,6 +42,11 @@ assert.match(editor, /<Card title=\{I18N\.basicInformation\}>/);
assert.match(editor, /<Card title=\{I18N\.calendarIntegration\}>/); assert.match(editor, /<Card title=\{I18N\.calendarIntegration\}>/);
assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/); assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/);
assert.match(page, /<Card title=\{I18N\.participants\}>/); assert.match(page, /<Card title=\{I18N\.participants\}>/);
assert.match(page, /<StageRail[\s\S]*schedulingLifecycleStages\(selected\.status\)/);
assert.match(page, /topicId: "scheduling\.find-and-decide-meeting-time"/);
assert.match(page, /topicId: "scheduling\.calendar-coordination"/);
assert.match(page, /topicId: "scheduling\.participation-governance"/);
assert.match(page, /public_participation_policy_enforcement_available === false[\s\S]*<ActionBlockerHint/);
assert.match(page, /<Card title=\{I18N\.generalSettings\}>/); assert.match(page, /<Card title=\{I18N\.generalSettings\}>/);
assert.match(page, /<Card title=\{I18N\.participantPrivacy\}>/); assert.match(page, /<Card title=\{I18N\.participantPrivacy\}>/);
assert.match(editor, /<FormField label=\{I18N\.title\}>/); assert.match(editor, /<FormField label=\{I18N\.title\}>/);
@@ -15,6 +15,7 @@ import {
XCircle XCircle
} from "lucide-react"; } from "lucide-react";
import { import {
ActionBlockerHint,
Button, Button,
Card, Card,
ConfirmDialog, ConfirmDialog,
@@ -22,6 +23,7 @@ import {
DataGridRowActions, DataGridRowActions,
DateTimeField, DateTimeField,
DismissibleAlert, DismissibleAlert,
DocumentationHelpLink,
FormField, FormField,
MetricCard, MetricCard,
IconButton, IconButton,
@@ -34,6 +36,7 @@ import {
TableActionGroup, TableActionGroup,
ToggleSwitch, ToggleSwitch,
StatusBadge, StatusBadge,
StageRail,
hasScope, hasScope,
i18nMessage, i18nMessage,
isApiError, isApiError,
@@ -84,12 +87,14 @@ import {
participantDraftsFromPicker, participantDraftsFromPicker,
participantPayload, participantPayload,
schedulingInvitationActionBlocks, schedulingInvitationActionBlocks,
schedulingLifecycleStages,
schedulingPublicInvitationUrl, schedulingPublicInvitationUrl,
schedulingRelevantTimestamp, schedulingRelevantTimestamp,
schedulingRequestIsOwned, schedulingRequestIsOwned,
schedulingSortPhase, schedulingSortPhase,
type SchedulingActor, type SchedulingActor,
type SchedulingInvitationActionBlock, type SchedulingInvitationActionBlock,
type SchedulingLifecycleStageState,
type SchedulingParticipantDraft, type SchedulingParticipantDraft,
type SchedulingRequestGroups type SchedulingRequestGroups
} from "./schedulingViewModel"; } from "./schedulingViewModel";
@@ -130,6 +135,7 @@ const I18N = {
finalEventLabel: "i18n:govoplan-scheduling.create_calendar_event.0b87cfcf", finalEventLabel: "i18n:govoplan-scheduling.create_calendar_event.0b87cfcf",
calendarIntegration: "i18n:govoplan-scheduling.calendar_integration.181ad18b", calendarIntegration: "i18n:govoplan-scheduling.calendar_integration.181ad18b",
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e", 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",
candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5", candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5",
candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b", candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b",
cancellationNoticeExpired: "i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c", cancellationNoticeExpired: "i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c",
@@ -142,6 +148,7 @@ const I18N = {
description: "i18n:govoplan-scheduling.description.55f8ebc8", description: "i18n:govoplan-scheduling.description.55f8ebc8",
determined: "i18n:govoplan-scheduling.determined.9f23293d", determined: "i18n:govoplan-scheduling.determined.9f23293d",
decideUnavailable: "i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d", decideUnavailable: "i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d",
decision: "i18n:govoplan-scheduling.decision.7f59a1f1",
discard: "i18n:govoplan-scheduling.discard.36fff63c", discard: "i18n:govoplan-scheduling.discard.36fff63c",
discardConfirm: "i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2", discardConfirm: "i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2",
edit: "i18n:govoplan-scheduling.edit_scheduling_request.7e749c19", edit: "i18n:govoplan-scheduling.edit_scheduling_request.7e749c19",
@@ -215,6 +222,7 @@ const I18N = {
revokeLink: "i18n:govoplan-scheduling.revoke_link.da371ee1", revokeLink: "i18n:govoplan-scheduling.revoke_link.da371ee1",
requestFailed: "i18n:govoplan-scheduling.request_failed.9fcda32c", requestFailed: "i18n:govoplan-scheduling.request_failed.9fcda32c",
publicPolicyUnavailable: "i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0", publicPolicyUnavailable: "i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0",
publicPolicyRequiredAction: "i18n:govoplan-scheduling.enable_enforceable_public_participation_controls_or_keep_participation_signed_in.f1a20105",
policyLocked: "i18n:govoplan-scheduling.participation_controls_are_locked_after_invitation_links_are_issued.66f5a740", policyLocked: "i18n:govoplan-scheduling.participation_controls_are_locked_after_invitation_links_are_issued.66f5a740",
requests: "i18n:govoplan-scheduling.scheduling_requests.b3c12f4d", requests: "i18n:govoplan-scheduling.scheduling_requests.b3c12f4d",
requiresAvailabilityRead: "i18n:govoplan-scheduling.requires_calendar_availability_read_access.b48ed91b", requiresAvailabilityRead: "i18n:govoplan-scheduling.requires_calendar_availability_read_access.b48ed91b",
@@ -236,6 +244,11 @@ const I18N = {
statusLabel: "i18n:govoplan-scheduling.status.bae7d5be", statusLabel: "i18n:govoplan-scheduling.status.bae7d5be",
title: "i18n:govoplan-scheduling.title.768e0c1c", title: "i18n:govoplan-scheduling.title.768e0c1c",
titleRequired: "i18n:govoplan-scheduling.scheduling_request_title_is_required.a27338be", titleRequired: "i18n:govoplan-scheduling.scheduling_request_title_is_required.a27338be",
requiredAction: "i18n:govoplan-scheduling.required_action.f1a20101",
responsibleActor: "i18n:govoplan-scheduling.who_can_fix_it.f1a20102",
resolutionTarget: "i18n:govoplan-scheduling.where_to_go.f1a20103",
systemOrTenantAdministrator: "i18n:govoplan-scheduling.system_or_tenant_administrator.f1a20104",
administration: "i18n:govoplan-core.admin.4e7afebc",
unchecked: "i18n:govoplan-scheduling.unchecked.1b927dec", unchecked: "i18n:govoplan-scheduling.unchecked.1b927dec",
unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79", unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79",
updateResponse: "i18n:govoplan-scheduling.update_response.346233cf", updateResponse: "i18n:govoplan-scheduling.update_response.346233cf",
@@ -935,7 +948,25 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
if (!checked) setCalendarId(""); if (!checked) setCalendarId("");
setDraftDirty(true); setDraftDirty(true);
}} /> }} />
{!calendarIntegrationAvailable ? <p className="scheduling-capability-note">{I18N.calendarUnavailable}</p> : null} {!calendarIntegrationAvailable ? (
<ActionBlockerHint
tone="info"
reason={{
summary: I18N.calendarUnavailable,
requiredAction: I18N.calendarRequiredAction,
actor: I18N.systemOrTenantAdministrator,
target: I18N.administration
}}
labels={{
requiredAction: I18N.requiredAction,
actor: I18N.responsibleActor,
target: I18N.resolutionTarget
}}
documentation={{
topicId: "scheduling.calendar-coordination",
documentationType: "admin"
}} />
) : null}
{calendarEnabled && CalendarPicker ? ( {calendarEnabled && CalendarPicker ? (
<CalendarPicker <CalendarPicker
settings={settings} settings={settings}
@@ -1030,10 +1061,36 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
</div> </div>
) : selected ? ( ) : selected ? (
<div className="scheduling-detail"> <div className="scheduling-detail">
<StageRail
className="scheduling-lifecycle-rail"
ariaLabel={I18N.requests}
items={schedulingLifecycleStages(selected.status).map((stage) => ({
id: stage.id,
label: stage.id === "prepare"
? I18N.basicInformation
: stage.id === "participate"
? I18N.participation
: I18N.decision,
icon: stage.id === "prepare"
? <Pencil aria-hidden="true" size={15} />
: stage.id === "participate"
? <Send aria-hidden="true" size={15} />
: <Check aria-hidden="true" size={15} />,
tone: lifecycleStageTone(stage.state),
current: stage.current,
locked: stage.locked,
lockedLabel: I18N.unavailable,
statusLabel: stage.current ? requestStatusLabel(selected, actor) : undefined
}))} />
<Card <Card
title={selected.title} title={selected.title}
actions={( actions={(
<div className="scheduling-actions"> <div className="scheduling-actions">
<DocumentationHelpLink
reference={{
topicId: "scheduling.find-and-decide-meeting-time",
documentationType: "user"
}} />
{canEditSelected ? ( {canEditSelected ? (
<Button type="button" onClick={() => beginEdit(selected)} disabled={saving}> <Button type="button" onClick={() => beginEdit(selected)} disabled={saving}>
<Pencil aria-hidden="true" size={16} /> {I18N.edit} <Pencil aria-hidden="true" size={16} /> {I18N.edit}
@@ -1070,9 +1127,23 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
) : null} ) : null}
{selected.public_participation_policy_enforcement_available === false ? ( {selected.public_participation_policy_enforcement_available === false ? (
<DismissibleAlert tone="warning" dismissible={false} compact> <ActionBlockerHint
{selected.public_participation_policy_enforcement_reason || I18N.publicPolicyUnavailable} reason={{
</DismissibleAlert> summary: selected.public_participation_policy_enforcement_reason || I18N.publicPolicyUnavailable,
details: I18N.publicPolicyUnavailable,
requiredAction: I18N.publicPolicyRequiredAction,
actor: I18N.systemOrTenantAdministrator,
target: I18N.administration
}}
labels={{
requiredAction: I18N.requiredAction,
actor: I18N.responsibleActor,
target: I18N.resolutionTarget
}}
documentation={{
topicId: "scheduling.participation-governance",
documentationType: "admin"
}} />
) : null} ) : null}
{selectedParticipant && selected.status === "collecting" ? ( {selectedParticipant && selected.status === "collecting" ? (
@@ -1957,6 +2028,15 @@ function firstRequest(groups: SchedulingRequestGroups, includeManaged: boolean):
return groups.owned[0] ?? groups.invited[0] ?? (includeManaged ? groups.other[0] : null) ?? null; return groups.owned[0] ?? groups.invited[0] ?? (includeManaged ? groups.other[0] : null) ?? null;
} }
function lifecycleStageTone(
state: SchedulingLifecycleStageState
): "success" | "active" | "warning" | "neutral" {
if (state === "complete") return "success";
if (state === "current") return "active";
if (state === "stopped") return "warning";
return "neutral";
}
function requestStatusLabel(request: SchedulingRequest, actor: SchedulingActor): string { function requestStatusLabel(request: SchedulingRequest, actor: SchedulingActor): string {
const phase = schedulingSortPhase(request, actor); const phase = schedulingSortPhase(request, actor);
if (phase === "past") return I18N.past; if (phase === "past") return I18N.past;
@@ -35,6 +35,21 @@ export type SchedulingRequestGroups = {
other: SchedulingRequest[]; other: SchedulingRequest[];
}; };
export type SchedulingLifecycleStageId = "prepare" | "participate" | "decide";
export type SchedulingLifecycleStageState =
| "complete"
| "current"
| "locked"
| "stopped";
export type SchedulingLifecycleStage = {
id: SchedulingLifecycleStageId;
state: SchedulingLifecycleStageState;
current: boolean;
locked: boolean;
};
export type SchedulingSortPhase = export type SchedulingSortPhase =
| "unanswered" | "unanswered"
| "answered" | "answered"
@@ -282,6 +297,37 @@ export function schedulingRequestIsPast(
return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime()); return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime());
} }
export function schedulingLifecycleStages(
status: SchedulingRequest["status"]
): SchedulingLifecycleStage[] {
const currentIndex = status === "draft"
? 0
: status === "collecting" || status === "cancelled"
? 1
: 2;
const completedThrough = status === "draft"
? -1
: status === "collecting" || status === "cancelled"
? 0
: status === "closed"
? 1
: 2;
const stopped = status === "cancelled";
return (["prepare", "participate", "decide"] as const).map((id, index) => {
const current = index === currentIndex;
const locked = index > completedThrough + 1;
const state: SchedulingLifecycleStageState = stopped && current
? "stopped"
: index <= completedThrough
? "complete"
: current
? "current"
: "locked";
return { id, state, current, locked };
});
}
export function schedulingInvitationActionBlocks( export function schedulingInvitationActionBlocks(
request: SchedulingRequest, request: SchedulingRequest,
participant: SchedulingParticipant, participant: SchedulingParticipant,
+12
View File
@@ -78,6 +78,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Configured calendar", "i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Configured calendar",
"i18n:govoplan-scheduling.calendar_integration.181ad18b": "Calendar integration", "i18n:govoplan-scheduling.calendar_integration.181ad18b": "Calendar integration",
"i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e": "Calendar integration requires the Calendar module plus calendar-read, availability-read, and event-write access.", "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e": "Calendar integration requires the Calendar module plus calendar-read, availability-read, and event-write access.",
"i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106": "Enable Calendar and grant calendar, availability, and event access.",
"i18n:govoplan-scheduling.candidate_availability.9541c4b5": "Candidate availability", "i18n:govoplan-scheduling.candidate_availability.9541c4b5": "Candidate availability",
"i18n:govoplan-scheduling.candidate_slots.c414946b": "Candidate slots", "i18n:govoplan-scheduling.candidate_slots.c414946b": "Candidate slots",
"i18n:govoplan-scheduling.check_free_busy.e9700e00": "Check free/busy", "i18n:govoplan-scheduling.check_free_busy.e9700e00": "Check free/busy",
@@ -97,6 +98,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.discard.36fff63c": "Discard", "i18n:govoplan-scheduling.discard.36fff63c": "Discard",
"i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Discard this unsaved scheduling request?", "i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Discard this unsaved scheduling request?",
"i18n:govoplan-scheduling.draft.23d33e22": "Draft", "i18n:govoplan-scheduling.draft.23d33e22": "Draft",
"i18n:govoplan-scheduling.enable_enforceable_public_participation_controls_or_keep_participation_signed_in.f1a20105": "Enable enforceable public participation controls, or keep participation signed in.",
"i18n:govoplan-scheduling.end.a2bb9d34": "End", "i18n:govoplan-scheduling.end.a2bb9d34": "End",
"i18n:govoplan-scheduling.error.7f2f6a15": "Error", "i18n:govoplan-scheduling.error.7f2f6a15": "Error",
"i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Every candidate slot must end after it starts.", "i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Every candidate slot must end after it starts.",
@@ -133,6 +135,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.pending.96f608c1": "Pending", "i18n:govoplan-scheduling.pending.96f608c1": "Pending",
"i18n:govoplan-scheduling.queued.6a599877": "Queued", "i18n:govoplan-scheduling.queued.6a599877": "Queued",
"i18n:govoplan-scheduling.refresh_requests.0a3ed7a1": "Refresh requests", "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1": "Refresh requests",
"i18n:govoplan-scheduling.required_action.f1a20101": "Required action",
"i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797": "Reminder creates a notification job for every active participant.", "i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797": "Reminder creates a notification job for every active participant.",
"i18n:govoplan-scheduling.remove.e963907d": "Remove", "i18n:govoplan-scheduling.remove.e963907d": "Remove",
"i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Remove participant {value0}", "i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Remove participant {value0}",
@@ -157,6 +160,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.sent.35f49dcf": "Sent", "i18n:govoplan-scheduling.sent.35f49dcf": "Sent",
"i18n:govoplan-scheduling.skipped.5a000ad7": "Skipped", "i18n:govoplan-scheduling.skipped.5a000ad7": "Skipped",
"i18n:govoplan-scheduling.start.952f3754": "Start", "i18n:govoplan-scheduling.start.952f3754": "Start",
"i18n:govoplan-scheduling.system_or_tenant_administrator.f1a20104": "System or tenant administrator",
"i18n:govoplan-scheduling.status.bae7d5be": "Status", "i18n:govoplan-scheduling.status.bae7d5be": "Status",
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Submit response", "i18n:govoplan-scheduling.submit_response.a5f0c053": "Submit response",
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Tentative holds create one provisional calendar event per candidate slot.", "i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Tentative holds create one provisional calendar event per candidate slot.",
@@ -170,6 +174,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.value_participants.e776b092": "{value0} participants", "i18n:govoplan-scheduling.value_participants.e776b092": "{value0} participants",
"i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} responses", "i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} responses",
"i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e": "What do these actions do?", "i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e": "What do these actions do?",
"i18n:govoplan-scheduling.where_to_go.f1a20103": "Where to go",
"i18n:govoplan-scheduling.who_can_fix_it.f1a20102": "Who can fix it",
"i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53": "You can respond here or use the invitation link you received.", "i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53": "You can respond here or use the invitation link you received.",
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded." "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
}, },
@@ -252,6 +258,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Ausgewählter Kalender", "i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Ausgewählter Kalender",
"i18n:govoplan-scheduling.calendar_integration.181ad18b": "Kalenderintegration", "i18n:govoplan-scheduling.calendar_integration.181ad18b": "Kalenderintegration",
"i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e": "Die Kalenderintegration benötigt das Kalendermodul sowie Leserechte für Kalender und Verfügbarkeiten und Schreibrechte für Termine.", "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e": "Die Kalenderintegration benötigt das Kalendermodul sowie Leserechte für Kalender und Verfügbarkeiten und Schreibrechte für Termine.",
"i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106": "Aktivieren Sie Kalender und vergeben Sie Rechte für Kalender, Verfügbarkeiten und Termine.",
"i18n:govoplan-scheduling.candidate_availability.9541c4b5": "Verfügbarkeit zu den Vorschlägen", "i18n:govoplan-scheduling.candidate_availability.9541c4b5": "Verfügbarkeit zu den Vorschlägen",
"i18n:govoplan-scheduling.candidate_slots.c414946b": "Terminvorschläge", "i18n:govoplan-scheduling.candidate_slots.c414946b": "Terminvorschläge",
"i18n:govoplan-scheduling.check_free_busy.e9700e00": "Frei/Belegt prüfen", "i18n:govoplan-scheduling.check_free_busy.e9700e00": "Frei/Belegt prüfen",
@@ -271,6 +278,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.discard.36fff63c": "Verwerfen", "i18n:govoplan-scheduling.discard.36fff63c": "Verwerfen",
"i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Diese ungespeicherte Terminanfrage verwerfen?", "i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Diese ungespeicherte Terminanfrage verwerfen?",
"i18n:govoplan-scheduling.draft.23d33e22": "Entwurf", "i18n:govoplan-scheduling.draft.23d33e22": "Entwurf",
"i18n:govoplan-scheduling.enable_enforceable_public_participation_controls_or_keep_participation_signed_in.f1a20105": "Aktivieren Sie durchsetzbare Regeln für die öffentliche Teilnahme oder beschränken Sie die Teilnahme auf angemeldete Personen.",
"i18n:govoplan-scheduling.end.a2bb9d34": "Ende", "i18n:govoplan-scheduling.end.a2bb9d34": "Ende",
"i18n:govoplan-scheduling.error.7f2f6a15": "Fehler", "i18n:govoplan-scheduling.error.7f2f6a15": "Fehler",
"i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Jeder Terminvorschlag muss nach seinem Beginn enden.", "i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Jeder Terminvorschlag muss nach seinem Beginn enden.",
@@ -307,6 +315,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.pending.96f608c1": "Ausstehend", "i18n:govoplan-scheduling.pending.96f608c1": "Ausstehend",
"i18n:govoplan-scheduling.queued.6a599877": "Eingereiht", "i18n:govoplan-scheduling.queued.6a599877": "Eingereiht",
"i18n:govoplan-scheduling.refresh_requests.0a3ed7a1": "Anfragen aktualisieren", "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1": "Anfragen aktualisieren",
"i18n:govoplan-scheduling.required_action.f1a20101": "Erforderliche Maßnahme",
"i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797": "Erinnern erstellt für jede aktive teilnehmende Person einen Benachrichtigungsauftrag.", "i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797": "Erinnern erstellt für jede aktive teilnehmende Person einen Benachrichtigungsauftrag.",
"i18n:govoplan-scheduling.remove.e963907d": "Entfernen", "i18n:govoplan-scheduling.remove.e963907d": "Entfernen",
"i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Teilnehmende Person {value0} entfernen", "i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Teilnehmende Person {value0} entfernen",
@@ -331,6 +340,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.sent.35f49dcf": "Gesendet", "i18n:govoplan-scheduling.sent.35f49dcf": "Gesendet",
"i18n:govoplan-scheduling.skipped.5a000ad7": "Übersprungen", "i18n:govoplan-scheduling.skipped.5a000ad7": "Übersprungen",
"i18n:govoplan-scheduling.start.952f3754": "Beginn", "i18n:govoplan-scheduling.start.952f3754": "Beginn",
"i18n:govoplan-scheduling.system_or_tenant_administrator.f1a20104": "System- oder Mandantenadministration",
"i18n:govoplan-scheduling.status.bae7d5be": "Status", "i18n:govoplan-scheduling.status.bae7d5be": "Status",
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Antwort senden", "i18n:govoplan-scheduling.submit_response.a5f0c053": "Antwort senden",
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Vorläufige Reservierungen erstellen je Terminvorschlag einen provisorischen Kalendereintrag.", "i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Vorläufige Reservierungen erstellen je Terminvorschlag einen provisorischen Kalendereintrag.",
@@ -344,6 +354,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.value_participants.e776b092": "{value0} Teilnehmende", "i18n:govoplan-scheduling.value_participants.e776b092": "{value0} Teilnehmende",
"i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} Antworten", "i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} Antworten",
"i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e": "Was bewirken diese Aktionen?", "i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e": "Was bewirken diese Aktionen?",
"i18n:govoplan-scheduling.where_to_go.f1a20103": "Ziel",
"i18n:govoplan-scheduling.who_can_fix_it.f1a20102": "Zuständig",
"i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53": "Sie können hier oder über den erhaltenen Einladungslink antworten.", "i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53": "Sie können hier oder über den erhaltenen Einladungslink antworten.",
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Ihre Antwort wurde gespeichert." "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Ihre Antwort wurde gespeichert."
} }
+29
View File
@@ -8,11 +8,40 @@ import {
participantDraftsFromPicker, participantDraftsFromPicker,
participantPayload, participantPayload,
schedulingInvitationActionBlocks, schedulingInvitationActionBlocks,
schedulingLifecycleStages,
schedulingPublicInvitationUrl, schedulingPublicInvitationUrl,
schedulingSortPhase, schedulingSortPhase,
type SchedulingActor type SchedulingActor
} from "../src/features/scheduling/schedulingViewModel.ts"; } from "../src/features/scheduling/schedulingViewModel.ts";
test("derives the scheduling lifecycle without inventing new backend states", () => {
assert.deepEqual(schedulingLifecycleStages("draft"), [
{ id: "prepare", state: "current", current: true, locked: false },
{ id: "participate", state: "locked", current: false, locked: true },
{ id: "decide", state: "locked", current: false, locked: true }
]);
assert.deepEqual(schedulingLifecycleStages("collecting"), [
{ id: "prepare", state: "complete", current: false, locked: false },
{ id: "participate", state: "current", current: true, locked: false },
{ id: "decide", state: "locked", current: false, locked: true }
]);
assert.deepEqual(schedulingLifecycleStages("closed"), [
{ id: "prepare", state: "complete", current: false, locked: false },
{ id: "participate", state: "complete", current: false, locked: false },
{ id: "decide", state: "current", current: true, locked: false }
]);
assert.deepEqual(schedulingLifecycleStages("handed_off").map((stage) => stage.state), [
"complete",
"complete",
"complete"
]);
assert.deepEqual(schedulingLifecycleStages("cancelled"), [
{ id: "prepare", state: "complete", current: false, locked: false },
{ id: "participate", state: "stopped", current: true, locked: false },
{ id: "decide", state: "locked", current: false, locked: true }
]);
});
test("maps visible account and contact selections into bounded scheduling participant payloads", () => { test("maps visible account and contact selections into bounded scheduling participant payloads", () => {
let sequence = 0; let sequence = 0;
const selected = participantDraftsFromPicker([ const selected = participantDraftsFromPicker([