Apply guided scheduling interface patterns
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
Reference in New Issue
Block a user