feat(scheduling-webui): build two-pane request workspace
This commit is contained in:
@@ -11,42 +11,60 @@ assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calend
|
|||||||
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
|
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
|
||||||
assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
|
assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
|
||||||
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]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*ModuleSubnav,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
|
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*FormField,[\s\S]*MetricCard,[\s\S]*PasswordField,[\s\S]*SelectionList,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
|
||||||
assert.match(page, /SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
|
|
||||||
assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
|
assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
|
||||||
assert.match(page, /className="scheduling-full-editor"/);
|
|
||||||
assert.match(page, /className="scheduling-page-actions"/);
|
|
||||||
|
|
||||||
const editorActions = page.slice(
|
assert.match(page, /className="scheduling-workspace-layout"/);
|
||||||
page.indexOf('<div className="scheduling-page-actions">'),
|
assert.match(page, /<aside className="scheduling-request-sidebar">/);
|
||||||
page.indexOf('</div>', page.indexOf('<div className="scheduling-page-actions">'))
|
assert.match(page, /<section className="scheduling-main-panel">/);
|
||||||
);
|
assert.match(page, /title=\{I18N\.requests\}[\s\S]*<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.add\}/);
|
||||||
assert.ok(editorActions.indexOf("I18N.discard") < editorActions.indexOf("I18N.save"));
|
|
||||||
assert.match(editorActions, /form="scheduling-create-form"/);
|
|
||||||
|
|
||||||
const createBranchStart = page.indexOf(" if (creating) {");
|
|
||||||
const createReturnStart = page.indexOf("\n return (", createBranchStart);
|
|
||||||
const browseBranchStart = page.indexOf("\n return (", createReturnStart + 1);
|
|
||||||
const createBranch = page.slice(createBranchStart, browseBranchStart);
|
|
||||||
const browseBranch = page.slice(browseBranchStart);
|
|
||||||
assert.match(createBranch, /<ModuleSubnav/);
|
|
||||||
assert.match(createBranch, /className="scheduling-create-subnav"/);
|
|
||||||
assert.doesNotMatch(browseBranch, /scheduling-sidebar|<aside/);
|
|
||||||
|
|
||||||
assert.match(browseBranch, /<h1>\{I18N\.requests\}<\/h1>/);
|
|
||||||
assert.match(browseBranch, /<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.add\}/);
|
|
||||||
assert.match(page, /title=\{I18N\.myRequests\}/);
|
assert.match(page, /title=\{I18N\.myRequests\}/);
|
||||||
assert.match(page, /title=\{I18N\.invitedRequests\}/);
|
assert.match(page, /title=\{I18N\.invitedRequests\}/);
|
||||||
|
assert.ok(page.indexOf("title={I18N.myRequests}") < page.indexOf("title={I18N.invitedRequests}"));
|
||||||
assert.match(page, /<SelectionList label=\{title\} className="scheduling-request-list">/);
|
assert.match(page, /<SelectionList label=\{title\} className="scheduling-request-list">/);
|
||||||
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selectedId === request\.id\}[\s\S]*className="scheduling-list-item"/);
|
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selectedId === request\.id\}[\s\S]*className="scheduling-list-item"/);
|
||||||
assert.doesNotMatch(page, /<button[\s\S]{0,160}scheduling-list-item/);
|
|
||||||
assert.match(createBranch, /<Card title=\{I18N\.basicInformation\}>/);
|
assert.match(page, /editorMode \? \(/);
|
||||||
assert.match(createBranch, /<Card title=\{I18N\.calendarIntegration\}>/);
|
assert.match(page, /id="scheduling-editor-form"/);
|
||||||
|
assert.doesNotMatch(page, /ModuleSubnav|scheduling-create-subnav|scheduling-full-editor/);
|
||||||
|
const editorStart = page.indexOf('<div className="scheduling-editor-surface">');
|
||||||
|
const editorEnd = page.indexOf('</form>', editorStart);
|
||||||
|
const editor = page.slice(editorStart, editorEnd);
|
||||||
|
assert.ok(editor.indexOf("I18N.discard") < editor.indexOf("I18N.save"));
|
||||||
|
assert.match(editor, /form="scheduling-editor-form"/);
|
||||||
|
assert.match(editor, /<Card title=\{I18N\.basicInformation\}>/);
|
||||||
|
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(createBranch, /<ToggleSwitch[\s\S]*checked=\{calendarEnabled\}/);
|
assert.match(page, /<Card title=\{I18N\.generalSettings\}>/);
|
||||||
assert.match(page, /<ToggleSwitch[\s\S]*checked=\{participantRosterVisible\}/);
|
assert.match(page, /<Card title=\{I18N\.participantPrivacy\}>/);
|
||||||
assert.match(page, /participant_visibility: participantRosterVisible \? "names_and_statuses" : "aggregates_only"/);
|
assert.match(editor, /<FormField label=\{I18N\.title\}>/);
|
||||||
|
assert.match(page, /useUnsavedDraftGuard\(/);
|
||||||
|
assert.match(page, /requestDiscard\(exitEditor\)/);
|
||||||
|
assert.match(page, /dirty: responseDirty,[\s\S]*onSave: persistAvailability,[\s\S]*onDiscard: resetResponseDraft/);
|
||||||
|
assert.doesNotMatch(page, /window\.(?:alert|confirm)\(/);
|
||||||
|
|
||||||
|
for (const setting of [
|
||||||
|
"participantRosterVisible",
|
||||||
|
"notifyOnAnswers",
|
||||||
|
"singleChoice",
|
||||||
|
"participantLimitEnabled",
|
||||||
|
"allowMaybe",
|
||||||
|
"allowComments",
|
||||||
|
"participantEmailRequired",
|
||||||
|
"anonymousPasswordProtectionEnabled"
|
||||||
|
]) {
|
||||||
|
assert.match(page, new RegExp(`checked=\\{${setting}\\}`));
|
||||||
|
}
|
||||||
|
assert.match(page, /<PasswordField[\s\S]*minLength=\{8\}/);
|
||||||
|
assert.match(page, /type="number"[\s\S]*min=\{1\}/);
|
||||||
|
assert.match(page, /min=\{addLocalMinutes\(slot\.start_at, 1\)\}/);
|
||||||
|
assert.match(page, /create_participant_invitations: true/);
|
||||||
|
assert.doesNotMatch(page, /usesGatewayPolicy|updateSchedulingCandidateSlot/);
|
||||||
|
assert.match(page, /public_participation_policy_enforcement_available/);
|
||||||
|
assert.match(page, /const canCreateOrWrite = canWrite \|\| canAdminister/);
|
||||||
|
assert.match(page, /policyLocked=\{participationPolicyLocked\}/);
|
||||||
|
|
||||||
assert.match(page, /id="scheduling-create-candidate-slots-grid"/);
|
assert.match(page, /id="scheduling-create-candidate-slots-grid"/);
|
||||||
assert.match(page, /id="scheduling-create-participants-grid"/);
|
assert.match(page, /id="scheduling-create-participants-grid"/);
|
||||||
assert.match(page, /id="scheduling-candidate-slots-grid"/);
|
assert.match(page, /id="scheduling-candidate-slots-grid"/);
|
||||||
@@ -55,21 +73,43 @@ assert.match(page, /<DataGridRowActions/);
|
|||||||
assert.match(page, /<DataGridEmptyAction/);
|
assert.match(page, /<DataGridEmptyAction/);
|
||||||
assert.doesNotMatch(page, /EmailAddressInput|MailboxAddress|addressSuggestions|addressLookupQuery/);
|
assert.doesNotMatch(page, /EmailAddressInput|MailboxAddress|addressSuggestions|addressLookupQuery/);
|
||||||
assert.match(page, /type="email"[\s\S]*aria-label=\{I18N\.participantEmail\}/);
|
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, /<table|scheduling-table|scheduling-card(?:\s|"|`)/);
|
||||||
assert.match(page, /<TableActionGroup[\s\S]*label: i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
|
assert.match(page, /<TableActionGroup[\s\S]*disabled: saving \|\| !decisionEnabled/);
|
||||||
|
assert.match(page, /showDecisionAction=\{canManageSelected\}/);
|
||||||
|
assert.match(page, /<IconButton[\s\S]*label=\{I18N\.refresh\}/);
|
||||||
|
assert.doesNotMatch(page, /AdminIconButton/);
|
||||||
|
|
||||||
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
|
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
|
||||||
assert.match(page, /option_revision: slot\.revision/);
|
assert.match(page, /option_revision: slot\.revision/);
|
||||||
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);
|
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);
|
||||||
assert.match(page, /setAvailability\(Object\.fromEntries\(response\.answers\.map/);
|
assert.match(page, /const answers = Object\.fromEntries\(response\.answers\.map/);
|
||||||
assert.match(page, /selected\.participant_aggregate\.total/);
|
assert.match(page, /setSavedAvailability\(answers\)/);
|
||||||
|
assert.match(page, /const comment = response\.comment \?\? ""/);
|
||||||
|
assert.match(page, /setSavedAvailabilityComment\(comment\)/);
|
||||||
|
assert.match(page, /<ParticipationStats/);
|
||||||
assert.match(page, /selected\.effective_participant_visibility === "names_and_statuses"/);
|
assert.match(page, /selected\.effective_participant_visibility === "names_and_statuses"/);
|
||||||
assert.match(api, /participant_visibility: SchedulingParticipantVisibility/);
|
|
||||||
assert.match(api, /participant_aggregate: SchedulingParticipantAggregate/);
|
|
||||||
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, /title=\{!canReadAvailability \? I18N\.requiresAvailabilityRead/);
|
for (const field of [
|
||||||
|
"notify_on_answers",
|
||||||
|
"single_choice",
|
||||||
|
"max_participants_per_option",
|
||||||
|
"allow_maybe",
|
||||||
|
"allow_comments",
|
||||||
|
"participant_email_required",
|
||||||
|
"anonymous_password_protection_enabled",
|
||||||
|
"public_participation_policy_enforcement_available"
|
||||||
|
]) {
|
||||||
|
assert.match(api, new RegExp(`${field}:`));
|
||||||
|
}
|
||||||
|
assert.match(api, /method: "PATCH"/);
|
||||||
|
assert.match(page, /slots: slots\.map\(\(slot\) => \(\{/);
|
||||||
|
assert.match(page, /participants: participants[\s\S]*create_participant_invitations: true/);
|
||||||
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/);
|
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/);
|
||||||
|
assert.match(api, /\/api\/v1\/scheduling\/public\/\$\{encodeURIComponent\(requestId\)\}\/\$\{encodeURIComponent\(token\)\}/);
|
||||||
|
assert.match(page, /useSearchParams\(\)/);
|
||||||
|
assert.match(page, /Promise\.allSettled/);
|
||||||
|
|
||||||
console.log("Scheduling page structure satisfies the focused list/create/respond contract.");
|
console.log("Scheduling page structure satisfies the two-pane editor, response, and policy contract.");
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ export type SchedulingCandidateSlot = {
|
|||||||
|
|
||||||
export type SchedulingParticipant = {
|
export type SchedulingParticipant = {
|
||||||
id: string;
|
id: string;
|
||||||
|
is_current_participant: boolean;
|
||||||
respondent_id?: string | null;
|
respondent_id?: string | null;
|
||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
participant_type: string;
|
participant_type: string | null;
|
||||||
required: boolean;
|
required: boolean | null;
|
||||||
status: string;
|
status: string;
|
||||||
poll_invitation_id?: string | null;
|
poll_invitation_id?: string | null;
|
||||||
invitation_token?: string | null;
|
invitation_token?: string | null;
|
||||||
@@ -52,7 +53,7 @@ export type SchedulingParticipantVisibilityDecision = {
|
|||||||
|
|
||||||
export type SchedulingRequest = {
|
export type SchedulingRequest = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string;
|
tenant_id: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
@@ -69,11 +70,20 @@ export type SchedulingRequest = {
|
|||||||
effective_participant_visibility: SchedulingParticipantVisibility;
|
effective_participant_visibility: SchedulingParticipantVisibility;
|
||||||
participant_aggregate: SchedulingParticipantAggregate;
|
participant_aggregate: SchedulingParticipantAggregate;
|
||||||
participant_visibility_decision: SchedulingParticipantVisibilityDecision;
|
participant_visibility_decision: SchedulingParticipantVisibilityDecision;
|
||||||
calendar_integration_enabled: boolean;
|
notify_on_answers: boolean;
|
||||||
|
single_choice: boolean;
|
||||||
|
max_participants_per_option: number | null;
|
||||||
|
allow_maybe: boolean;
|
||||||
|
allow_comments: boolean;
|
||||||
|
participant_email_required: boolean;
|
||||||
|
anonymous_password_protection_enabled: boolean;
|
||||||
|
public_participation_policy_enforcement_available: boolean | null;
|
||||||
|
public_participation_policy_enforcement_reason?: string | null;
|
||||||
|
calendar_integration_enabled: boolean | null;
|
||||||
calendar_id?: string | null;
|
calendar_id?: string | null;
|
||||||
calendar_freebusy_enabled: boolean;
|
calendar_freebusy_enabled: boolean | null;
|
||||||
calendar_hold_enabled: boolean;
|
calendar_hold_enabled: boolean | null;
|
||||||
create_calendar_event_on_decision: boolean;
|
create_calendar_event_on_decision: boolean | null;
|
||||||
calendar_event_id?: string | null;
|
calendar_event_id?: string | null;
|
||||||
handed_off_at?: string | null;
|
handed_off_at?: string | null;
|
||||||
cancelled_at?: string | null;
|
cancelled_at?: string | null;
|
||||||
@@ -117,6 +127,14 @@ export type SchedulingRequestCreatePayload = {
|
|||||||
allow_participant_updates?: boolean;
|
allow_participant_updates?: boolean;
|
||||||
result_visibility?: "organizer" | "after_response" | "after_close" | "public";
|
result_visibility?: "organizer" | "after_response" | "after_close" | "public";
|
||||||
participant_visibility?: SchedulingParticipantVisibility;
|
participant_visibility?: SchedulingParticipantVisibility;
|
||||||
|
notify_on_answers?: boolean;
|
||||||
|
single_choice?: boolean;
|
||||||
|
max_participants_per_option?: number | null;
|
||||||
|
allow_maybe?: boolean;
|
||||||
|
allow_comments?: boolean;
|
||||||
|
participant_email_required?: boolean;
|
||||||
|
anonymous_password_protection_enabled?: boolean;
|
||||||
|
anonymous_password?: string;
|
||||||
calendar?: {
|
calendar?: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
calendar_id?: string | null;
|
calendar_id?: string | null;
|
||||||
@@ -130,6 +148,33 @@ export type SchedulingRequestCreatePayload = {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SchedulingRequestUpdatePayload = Omit<
|
||||||
|
SchedulingRequestCreatePayload,
|
||||||
|
"timezone" | "status" | "slots" | "participants"
|
||||||
|
> & {
|
||||||
|
slots?: SchedulingCandidateSlotReconcilePayload[];
|
||||||
|
participants?: SchedulingParticipantReconcilePayload[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SchedulingCandidateSlotReconcilePayload = SchedulingCandidateSlotPayload & {
|
||||||
|
id?: string;
|
||||||
|
revision?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SchedulingParticipantReconcilePayload = SchedulingParticipantPayload & {
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SchedulingCandidateSlotUpdatePayload = {
|
||||||
|
label?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
start_at?: string | null;
|
||||||
|
end_at?: string | null;
|
||||||
|
timezone?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type SchedulingDecisionPayload = {
|
export type SchedulingDecisionPayload = {
|
||||||
slot_id?: string | null;
|
slot_id?: string | null;
|
||||||
poll_option_id?: string | null;
|
poll_option_id?: string | null;
|
||||||
@@ -144,6 +189,7 @@ export type SchedulingAvailabilityPayload = {
|
|||||||
value: SchedulingAvailabilityValue;
|
value: SchedulingAvailabilityValue;
|
||||||
option_revision: string;
|
option_revision: string;
|
||||||
}>;
|
}>;
|
||||||
|
comment?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SchedulingAvailabilityResponse = {
|
export type SchedulingAvailabilityResponse = {
|
||||||
@@ -151,12 +197,62 @@ export type SchedulingAvailabilityResponse = {
|
|||||||
participant_id: string;
|
participant_id: string;
|
||||||
has_response: boolean;
|
has_response: boolean;
|
||||||
submitted_at?: string | null;
|
submitted_at?: string | null;
|
||||||
|
comment?: string | null;
|
||||||
answers: Array<{
|
answers: Array<{
|
||||||
slot_id: string;
|
slot_id: string;
|
||||||
value: SchedulingAvailabilityValue;
|
value: SchedulingAvailabilityValue;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SchedulingPublicCandidateSlot = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
start_at: string;
|
||||||
|
end_at: string;
|
||||||
|
timezone: string;
|
||||||
|
location?: string | null;
|
||||||
|
position: number;
|
||||||
|
revision: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SchedulingPublicParticipationResponse = {
|
||||||
|
request_id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
timezone: string;
|
||||||
|
status: SchedulingStatus;
|
||||||
|
deadline_at?: string | null;
|
||||||
|
participant_email_required: boolean;
|
||||||
|
anonymous_password_required: boolean;
|
||||||
|
single_choice: boolean;
|
||||||
|
max_participants_per_option: number | null;
|
||||||
|
allow_maybe: boolean;
|
||||||
|
allow_comments: boolean;
|
||||||
|
allow_participant_updates: boolean;
|
||||||
|
has_response: boolean;
|
||||||
|
submitted_at?: string | null;
|
||||||
|
answers: Array<{
|
||||||
|
slot_id: string;
|
||||||
|
value: SchedulingAvailabilityValue;
|
||||||
|
}>;
|
||||||
|
comment?: string | null;
|
||||||
|
replayed: boolean;
|
||||||
|
slots: SchedulingPublicCandidateSlot[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SchedulingPublicParticipationAccessPayload = {
|
||||||
|
participant_email?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SchedulingPublicParticipationSubmitPayload = SchedulingPublicParticipationAccessPayload & {
|
||||||
|
answers: SchedulingAvailabilityPayload["answers"];
|
||||||
|
comment?: string | null;
|
||||||
|
idempotency_key?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SchedulingCalendarActionResponse = {
|
export type SchedulingCalendarActionResponse = {
|
||||||
request: SchedulingRequest;
|
request: SchedulingRequest;
|
||||||
created_event_ids: string[];
|
created_event_ids: string[];
|
||||||
@@ -239,6 +335,29 @@ export function createSchedulingRequest(settings: ApiSettings, payload: Scheduli
|
|||||||
return apiFetch<SchedulingRequest>(settings, "/api/v1/scheduling/requests", json(payload));
|
return apiFetch<SchedulingRequest>(settings, "/api/v1/scheduling/requests", json(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateSchedulingRequest(
|
||||||
|
settings: ApiSettings,
|
||||||
|
requestId: string,
|
||||||
|
payload: SchedulingRequestUpdatePayload
|
||||||
|
): Promise<SchedulingRequest> {
|
||||||
|
return apiFetch<SchedulingRequest>(settings, `/api/v1/scheduling/requests/${requestId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSchedulingCandidateSlot(
|
||||||
|
settings: ApiSettings,
|
||||||
|
requestId: string,
|
||||||
|
slotId: string,
|
||||||
|
payload: SchedulingCandidateSlotUpdatePayload
|
||||||
|
): Promise<SchedulingRequest> {
|
||||||
|
return apiFetch<SchedulingRequest>(settings, `/api/v1/scheduling/requests/${requestId}/slots/${slotId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function submitSchedulingAvailability(
|
export function submitSchedulingAvailability(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
@@ -261,6 +380,32 @@ export function getSchedulingAvailabilityResponse(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPublicSchedulingParticipation(
|
||||||
|
settings: ApiSettings,
|
||||||
|
requestId: string,
|
||||||
|
token: string,
|
||||||
|
payload: SchedulingPublicParticipationAccessPayload
|
||||||
|
): Promise<SchedulingPublicParticipationResponse> {
|
||||||
|
return apiFetch<SchedulingPublicParticipationResponse>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/scheduling/public/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}`,
|
||||||
|
json(payload)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitPublicSchedulingParticipation(
|
||||||
|
settings: ApiSettings,
|
||||||
|
requestId: string,
|
||||||
|
token: string,
|
||||||
|
payload: SchedulingPublicParticipationSubmitPayload
|
||||||
|
): Promise<SchedulingPublicParticipationResponse> {
|
||||||
|
return apiFetch<SchedulingPublicParticipationResponse>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/scheduling/public/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}/responses`,
|
||||||
|
json(payload)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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({}));
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,8 @@
|
|||||||
import type { SchedulingParticipant, SchedulingRequest } from "../../api/scheduling";
|
import type {
|
||||||
|
SchedulingAvailabilityValue,
|
||||||
|
SchedulingParticipant,
|
||||||
|
SchedulingRequest
|
||||||
|
} from "../../api/scheduling";
|
||||||
|
|
||||||
export type SchedulingActor = {
|
export type SchedulingActor = {
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
@@ -28,20 +32,27 @@ export function schedulingActorIds(actor: SchedulingActor): string[] {
|
|||||||
actor.membershipId,
|
actor.membershipId,
|
||||||
actor.identityId,
|
actor.identityId,
|
||||||
actor.email
|
actor.email
|
||||||
].filter((value): value is string => Boolean(value))));
|
].filter((value): value is string => Boolean(value)).flatMap((value) =>
|
||||||
|
value.includes("@") ? [value, value.trim().toLowerCase()] : [value]
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function schedulingParticipantForActor(
|
export function schedulingParticipantForActor(
|
||||||
request: SchedulingRequest,
|
request: SchedulingRequest,
|
||||||
actor: SchedulingActor
|
actor: SchedulingActor
|
||||||
): SchedulingParticipant | null {
|
): SchedulingParticipant | null {
|
||||||
|
const projected = request.participants.find(
|
||||||
|
(participant) => participant.is_current_participant
|
||||||
|
);
|
||||||
|
if (projected) return projected;
|
||||||
const ids = new Set(schedulingActorIds(actor));
|
const ids = new Set(schedulingActorIds(actor));
|
||||||
return request.participants.find((participant) =>
|
return request.participants.find((participant) => {
|
||||||
Boolean(
|
const email = participant.email?.trim().toLowerCase();
|
||||||
|
return Boolean(
|
||||||
(participant.respondent_id && ids.has(participant.respondent_id)) ||
|
(participant.respondent_id && ids.has(participant.respondent_id)) ||
|
||||||
(participant.email && ids.has(participant.email))
|
(email && ids.has(email))
|
||||||
)
|
);
|
||||||
) ?? null;
|
}) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function schedulingRequestIsOwned(
|
export function schedulingRequestIsOwned(
|
||||||
@@ -137,6 +148,26 @@ 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 applySchedulingAvailabilityChoice(
|
||||||
|
slotIds: string[],
|
||||||
|
current: Record<string, SchedulingAvailabilityValue | "">,
|
||||||
|
slotId: string,
|
||||||
|
value: SchedulingAvailabilityValue | "",
|
||||||
|
singleChoice: boolean
|
||||||
|
): Record<string, SchedulingAvailabilityValue | ""> {
|
||||||
|
if (!singleChoice || !["available", "maybe"].includes(value)) {
|
||||||
|
return { ...current, [slotId]: value };
|
||||||
|
}
|
||||||
|
return Object.fromEntries(slotIds.map((candidateId) => [
|
||||||
|
candidateId,
|
||||||
|
candidateId === slotId
|
||||||
|
? value
|
||||||
|
: current[candidateId] && current[candidateId] !== "unavailable"
|
||||||
|
? "unavailable"
|
||||||
|
: current[candidateId] ?? ""
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
const SORT_PHASE_ORDER: Record<SchedulingSortPhase, number> = {
|
const SORT_PHASE_ORDER: Record<SchedulingSortPhase, number> = {
|
||||||
unanswered: 0,
|
unanswered: 0,
|
||||||
answered: 1,
|
answered: 1,
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
export const generatedTranslations = {
|
export const generatedTranslations = {
|
||||||
en: {
|
en: {
|
||||||
|
"i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1": "Save or discard your unsent availability changes before leaving.",
|
||||||
|
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "A slot can be selected after the request is closed.",
|
||||||
|
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Add Maybe between Available and Unavailable for each candidate slot.",
|
||||||
|
"i18n:govoplan-scheduling.allow_comments.d63202a6": "Allow comments",
|
||||||
|
"i18n:govoplan-scheduling.awaiting.42aa82e0": "Awaiting",
|
||||||
|
"i18n:govoplan-scheduling.capacity.d3c375f8": "Capacity",
|
||||||
|
"i18n:govoplan-scheduling.comment.d03495b1": "Comment",
|
||||||
|
"i18n:govoplan-scheduling.create_an_organizer_notification_whenever_a_response_is.253ddca8": "Create an organizer notification whenever a response is submitted or updated.",
|
||||||
|
"i18n:govoplan-scheduling.each_participant_can_answer_yes_to_at_most_one_candidate.5313a465": "Each participant can select Available or Maybe for at most one candidate slot.",
|
||||||
|
"i18n:govoplan-scheduling.edit_scheduling_request.7e749c19": "Edit scheduling request",
|
||||||
|
"i18n:govoplan-scheduling.invited_participant_identity_is_locked_remove_and_add_the_participant_to_change_it.34e1201a": "An invited participant's name and email are locked. Remove and add the participant to change them.",
|
||||||
|
"i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0": "Guest links are not issued while the configured participation controls cannot be enforced by the public response gateway. Signed-in participants can still respond here.",
|
||||||
|
"i18n:govoplan-scheduling.guest_password.94545e82": "Guest password",
|
||||||
|
"i18n:govoplan-scheduling.let_participants_add_a_comment_to_their_response.9dce8d17": "Let participants add a comment to their response.",
|
||||||
|
"i18n:govoplan-scheduling.limit_participants_per_option.1e9aa51d": "Limit participants per option",
|
||||||
|
"i18n:govoplan-scheduling.maximum_participants_per_option.5abdfb27": "Maximum participants per option",
|
||||||
|
"i18n:govoplan-scheduling.notify_me_about_each_answer.505749d6": "Notify me about each answer",
|
||||||
|
"i18n:govoplan-scheduling.participants_can_choose_only_one_option.4311f51c": "Participants can choose only one option",
|
||||||
|
"i18n:govoplan-scheduling.participation.9ad70cc4": "Participation",
|
||||||
|
"i18n:govoplan-scheduling.participation_rate.46bc5504": "Participation rate",
|
||||||
|
"i18n:govoplan-scheduling.participation_settings.8dc6f62c": "Participation settings",
|
||||||
|
"i18n:govoplan-scheduling.participant_privacy.108c470f": "Participant privacy",
|
||||||
|
"i18n:govoplan-scheduling.participation_controls_are_locked_after_invitation_links_are_issued.66f5a740": "Participation controls are locked after invitation links are issued. Participant visibility and answer notifications can still be changed.",
|
||||||
|
"i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Password-protect guest access",
|
||||||
|
"i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "People responding without an account must provide an email address.",
|
||||||
|
"i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "People who are not signed in must enter this password before viewing the request.",
|
||||||
|
"i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Provide a Maybe option",
|
||||||
|
"i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "Require an email address from guests",
|
||||||
|
"i18n:govoplan-scheduling.responses.3427e3ab": "Responses",
|
||||||
|
"i18n:govoplan-scheduling.response_results_are_not_available_for_this_view.1e82db18": "Response results are not available for this view.",
|
||||||
|
"i18n:govoplan-scheduling.notifications_could_not_be_loaded.f0e1b2c3": "Notifications could not be loaded.",
|
||||||
|
"i18n:govoplan-scheduling.stop_accepting_yes_responses_for_an_option_when_its_limit.79af21db": "Stop accepting Available responses for an option when its limit is reached.",
|
||||||
|
"i18n:govoplan-scheduling.use_at_least_8_characters_the_password_is_never_displayed.035708a2": "Use at least 8 characters. The password is never displayed after it is saved.",
|
||||||
"i18n:govoplan-scheduling.add_participant.6cff957d": "Add participant",
|
"i18n:govoplan-scheduling.add_participant.6cff957d": "Add participant",
|
||||||
"i18n:govoplan-scheduling.add_scheduling_request.3c71be15": "Add scheduling request",
|
"i18n:govoplan-scheduling.add_scheduling_request.3c71be15": "Add scheduling request",
|
||||||
"i18n:govoplan-scheduling.add_slot.9fcff3ed": "Add slot",
|
"i18n:govoplan-scheduling.add_slot.9fcff3ed": "Add slot",
|
||||||
@@ -21,7 +54,6 @@ export const generatedTranslations = {
|
|||||||
"i18n:govoplan-scheduling.close_poll.a6a18916": "Close poll",
|
"i18n:govoplan-scheduling.close_poll.a6a18916": "Close poll",
|
||||||
"i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519": "Close stops accepting new availability responses.",
|
"i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519": "Close stops accepting new availability responses.",
|
||||||
"i18n:govoplan-scheduling.closed.88d86b77": "Closed",
|
"i18n:govoplan-scheduling.closed.88d86b77": "Closed",
|
||||||
"i18n:govoplan-scheduling.create_and_open_scheduling_request.1dbc9345": "Create and open scheduling request",
|
|
||||||
"i18n:govoplan-scheduling.create_calendar_event.0b87cfcf": "Create calendar event",
|
"i18n:govoplan-scheduling.create_calendar_event.0b87cfcf": "Create calendar event",
|
||||||
"i18n:govoplan-scheduling.create_tentative_holds.51c4744e": "Create tentative holds",
|
"i18n:govoplan-scheduling.create_tentative_holds.51c4744e": "Create tentative holds",
|
||||||
"i18n:govoplan-scheduling.cancellation.319aaae4": "Cancellation",
|
"i18n:govoplan-scheduling.cancellation.319aaae4": "Cancellation",
|
||||||
@@ -108,6 +140,39 @@ export const generatedTranslations = {
|
|||||||
"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."
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
|
"i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1": "Speichern oder verwerfen Sie Ihre noch nicht gesendeten Verfügbarkeitsänderungen, bevor Sie die Ansicht verlassen.",
|
||||||
|
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "Ein Terminvorschlag kann ausgewählt werden, nachdem die Anfrage geschlossen wurde.",
|
||||||
|
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Für jeden Terminvorschlag Vielleicht zwischen Verfügbar und Nicht verfügbar anbieten.",
|
||||||
|
"i18n:govoplan-scheduling.allow_comments.d63202a6": "Kommentare erlauben",
|
||||||
|
"i18n:govoplan-scheduling.awaiting.42aa82e0": "Ausstehend",
|
||||||
|
"i18n:govoplan-scheduling.capacity.d3c375f8": "Kapazität",
|
||||||
|
"i18n:govoplan-scheduling.comment.d03495b1": "Kommentar",
|
||||||
|
"i18n:govoplan-scheduling.create_an_organizer_notification_whenever_a_response_is.253ddca8": "Bei jeder neuen oder geänderten Antwort eine Benachrichtigung für die organisierende Person erstellen.",
|
||||||
|
"i18n:govoplan-scheduling.each_participant_can_answer_yes_to_at_most_one_candidate.5313a465": "Jede teilnehmende Person kann höchstens einen Terminvorschlag als Verfügbar oder Vielleicht markieren.",
|
||||||
|
"i18n:govoplan-scheduling.edit_scheduling_request.7e749c19": "Terminanfrage bearbeiten",
|
||||||
|
"i18n:govoplan-scheduling.invited_participant_identity_is_locked_remove_and_add_the_participant_to_change_it.34e1201a": "Name und E-Mail-Adresse einer eingeladenen Person sind gesperrt. Entfernen Sie die Person und fügen Sie sie erneut hinzu, um diese Angaben zu ändern.",
|
||||||
|
"i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0": "Gastlinks werden nicht ausgegeben, solange die konfigurierten Teilnahmeregeln am öffentlichen Antwortzugang nicht durchgesetzt werden können. Angemeldete Teilnehmende können weiterhin hier antworten.",
|
||||||
|
"i18n:govoplan-scheduling.guest_password.94545e82": "Gastpasswort",
|
||||||
|
"i18n:govoplan-scheduling.let_participants_add_a_comment_to_their_response.9dce8d17": "Teilnehmende können ihrer Antwort einen Kommentar hinzufügen.",
|
||||||
|
"i18n:govoplan-scheduling.limit_participants_per_option.1e9aa51d": "Teilnehmende je Terminvorschlag begrenzen",
|
||||||
|
"i18n:govoplan-scheduling.maximum_participants_per_option.5abdfb27": "Maximale Teilnehmendenzahl je Terminvorschlag",
|
||||||
|
"i18n:govoplan-scheduling.notify_me_about_each_answer.505749d6": "Über jede Antwort benachrichtigen",
|
||||||
|
"i18n:govoplan-scheduling.participants_can_choose_only_one_option.4311f51c": "Teilnehmende können nur einen Terminvorschlag wählen",
|
||||||
|
"i18n:govoplan-scheduling.participation.9ad70cc4": "Teilnahme",
|
||||||
|
"i18n:govoplan-scheduling.participation_rate.46bc5504": "Teilnahmequote",
|
||||||
|
"i18n:govoplan-scheduling.participation_settings.8dc6f62c": "Teilnahmeeinstellungen",
|
||||||
|
"i18n:govoplan-scheduling.participant_privacy.108c470f": "Datenschutz für Teilnehmende",
|
||||||
|
"i18n:govoplan-scheduling.participation_controls_are_locked_after_invitation_links_are_issued.66f5a740": "Teilnahmeregeln sind gesperrt, nachdem Einladungslinks erstellt wurden. Sichtbarkeit und Antwortbenachrichtigungen können weiterhin geändert werden.",
|
||||||
|
"i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Gastzugang mit Passwort schützen",
|
||||||
|
"i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "Personen ohne Konto müssen für ihre Antwort eine E-Mail-Adresse angeben.",
|
||||||
|
"i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "Nicht angemeldete Personen müssen dieses Passwort eingeben, bevor sie die Anfrage sehen können.",
|
||||||
|
"i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Antwort Vielleicht anbieten",
|
||||||
|
"i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "E-Mail-Adresse von Gästen verlangen",
|
||||||
|
"i18n:govoplan-scheduling.responses.3427e3ab": "Antworten",
|
||||||
|
"i18n:govoplan-scheduling.response_results_are_not_available_for_this_view.1e82db18": "Antwortergebnisse sind für diese Ansicht nicht verfügbar.",
|
||||||
|
"i18n:govoplan-scheduling.notifications_could_not_be_loaded.f0e1b2c3": "Benachrichtigungen konnten nicht geladen werden.",
|
||||||
|
"i18n:govoplan-scheduling.stop_accepting_yes_responses_for_an_option_when_its_limit.79af21db": "Keine weiteren Verfügbar-Antworten annehmen, sobald die Begrenzung eines Terminvorschlags erreicht ist.",
|
||||||
|
"i18n:govoplan-scheduling.use_at_least_8_characters_the_password_is_never_displayed.035708a2": "Verwenden Sie mindestens 8 Zeichen. Das Passwort wird nach dem Speichern nicht mehr angezeigt.",
|
||||||
"i18n:govoplan-scheduling.add_participant.6cff957d": "Teilnehmende Person hinzufügen",
|
"i18n:govoplan-scheduling.add_participant.6cff957d": "Teilnehmende Person hinzufügen",
|
||||||
"i18n:govoplan-scheduling.add_scheduling_request.3c71be15": "Terminanfrage hinzufügen",
|
"i18n:govoplan-scheduling.add_scheduling_request.3c71be15": "Terminanfrage hinzufügen",
|
||||||
"i18n:govoplan-scheduling.add_slot.9fcff3ed": "Terminvorschlag hinzufügen",
|
"i18n:govoplan-scheduling.add_slot.9fcff3ed": "Terminvorschlag hinzufügen",
|
||||||
@@ -129,7 +194,6 @@ export const generatedTranslations = {
|
|||||||
"i18n:govoplan-scheduling.close_poll.a6a18916": "Abstimmung schließen",
|
"i18n:govoplan-scheduling.close_poll.a6a18916": "Abstimmung schließen",
|
||||||
"i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519": "Schließen beendet die Annahme neuer Verfügbarkeitsantworten.",
|
"i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519": "Schließen beendet die Annahme neuer Verfügbarkeitsantworten.",
|
||||||
"i18n:govoplan-scheduling.closed.88d86b77": "Geschlossen",
|
"i18n:govoplan-scheduling.closed.88d86b77": "Geschlossen",
|
||||||
"i18n:govoplan-scheduling.create_and_open_scheduling_request.1dbc9345": "Terminanfrage erstellen und öffnen",
|
|
||||||
"i18n:govoplan-scheduling.create_calendar_event.0b87cfcf": "Kalendertermin erstellen",
|
"i18n:govoplan-scheduling.create_calendar_event.0b87cfcf": "Kalendertermin erstellen",
|
||||||
"i18n:govoplan-scheduling.create_tentative_holds.51c4744e": "Vorläufige Reservierungen erstellen",
|
"i18n:govoplan-scheduling.create_tentative_holds.51c4744e": "Vorläufige Reservierungen erstellen",
|
||||||
"i18n:govoplan-scheduling.cancellation.319aaae4": "Stornierung",
|
"i18n:govoplan-scheduling.cancellation.319aaae4": "Stornierung",
|
||||||
|
|||||||
@@ -13,8 +13,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-workspace,
|
.scheduling-workspace {
|
||||||
.scheduling-full-editor {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -26,6 +25,72 @@
|
|||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scheduling-workspace-layout {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(310px, 370px) minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-request-sidebar,
|
||||||
|
.scheduling-main-panel,
|
||||||
|
.scheduling-editor-surface {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-request-sidebar {
|
||||||
|
padding: 12px;
|
||||||
|
border-right: var(--border-line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-request-sidebar > .card {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-request-sidebar > .card > .card-body {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-sidebar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-sidebar-actions .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-main-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-main-panel > .alert {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 12px 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-editor-surface {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.scheduling-page-header {
|
.scheduling-page-header {
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -89,28 +154,9 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-alert,
|
|
||||||
.scheduling-success {
|
|
||||||
margin: 10px 14px 0;
|
|
||||||
padding: 9px 11px;
|
|
||||||
border: var(--border-line);
|
|
||||||
border-radius: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-alert {
|
|
||||||
border-color: var(--danger-border-strong);
|
|
||||||
color: var(--danger-text-strong);
|
|
||||||
background: var(--danger-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-success {
|
|
||||||
border-color: var(--success);
|
|
||||||
color: var(--success);
|
|
||||||
background: color-mix(in srgb, var(--success) 10%, var(--panel));
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-detail,
|
.scheduling-detail,
|
||||||
.scheduling-editor-scroll {
|
.scheduling-editor-scroll {
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -124,15 +170,21 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-request-groups {
|
.scheduling-request-group + .scheduling-request-group {
|
||||||
display: grid;
|
margin-top: 14px;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
padding-top: 14px;
|
||||||
gap: 12px;
|
border-top: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-request-group h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-list-group {
|
.scheduling-list-group {
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
max-height: 270px;
|
max-height: min(31vh, 320px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,14 +223,6 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-list-status {
|
|
||||||
max-width: 120px;
|
|
||||||
padding: 3px 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--panel-soft);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-list-empty {
|
.scheduling-list-empty {
|
||||||
margin: 3px 0 8px;
|
margin: 3px 0 8px;
|
||||||
}
|
}
|
||||||
@@ -187,46 +231,12 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-status {
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: var(--text-strong);
|
|
||||||
background: var(--panel-soft);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-status-collecting,
|
|
||||||
.scheduling-status-handed_off {
|
|
||||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-slot-title {
|
.scheduling-slot-title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: start;
|
justify-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-freebusy {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 3px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--muted);
|
|
||||||
background: var(--panel-soft);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-freebusy.free {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-freebusy.busy,
|
|
||||||
.scheduling-freebusy.error {
|
|
||||||
color: var(--danger-text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-columns {
|
.scheduling-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -284,20 +294,6 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-response-grid select,
|
|
||||||
.scheduling-field input,
|
|
||||||
.scheduling-field textarea,
|
|
||||||
.scheduling-grid-input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 7px 9px;
|
|
||||||
border: var(--border-line);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--control-bg);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-action-help {
|
.scheduling-action-help {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -308,47 +304,14 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-editor-page {
|
.scheduling-setting-with-field {
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-editor-layout {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 198px minmax(0, 1fr);
|
gap: 10px;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-create-subnav {
|
.scheduling-setting-with-field .form-field {
|
||||||
min-height: 0;
|
padding-left: 48px;
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-create-section {
|
|
||||||
min-width: 0;
|
|
||||||
scroll-margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-field-wide {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-field {
|
|
||||||
display: grid;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-field > span {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-capability-note {
|
.scheduling-capability-note {
|
||||||
@@ -362,9 +325,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.scheduling-editor-layout {
|
.scheduling-workspace-layout {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(270px, 320px) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
@@ -375,16 +339,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-workspace,
|
.scheduling-workspace,
|
||||||
.scheduling-full-editor,
|
.scheduling-workspace-layout,
|
||||||
.scheduling-editor-layout,
|
.scheduling-main-panel,
|
||||||
|
.scheduling-editor-surface,
|
||||||
.scheduling-detail,
|
.scheduling-detail,
|
||||||
.scheduling-editor-scroll {
|
.scheduling-editor-scroll {
|
||||||
height: auto;
|
height: auto;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-columns,
|
.scheduling-workspace-layout {
|
||||||
.scheduling-form-grid {
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-request-sidebar {
|
||||||
|
max-height: 48vh;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-columns {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import type { SchedulingRequest } from "../src/api/scheduling.ts";
|
import type { SchedulingRequest } from "../src/api/scheduling.ts";
|
||||||
import {
|
import {
|
||||||
|
applySchedulingAvailabilityChoice,
|
||||||
groupSchedulingRequests,
|
groupSchedulingRequests,
|
||||||
schedulingSortPhase,
|
schedulingSortPhase,
|
||||||
type SchedulingActor
|
type SchedulingActor
|
||||||
@@ -47,6 +48,15 @@ function request(
|
|||||||
source_path: [],
|
source_path: [],
|
||||||
details: {}
|
details: {}
|
||||||
},
|
},
|
||||||
|
notify_on_answers: true,
|
||||||
|
single_choice: false,
|
||||||
|
max_participants_per_option: null,
|
||||||
|
allow_maybe: true,
|
||||||
|
allow_comments: false,
|
||||||
|
participant_email_required: false,
|
||||||
|
anonymous_password_protection_enabled: false,
|
||||||
|
public_participation_policy_enforcement_available: false,
|
||||||
|
public_participation_policy_enforcement_reason: "Public participation gateway not installed",
|
||||||
calendar_integration_enabled: false,
|
calendar_integration_enabled: false,
|
||||||
calendar_freebusy_enabled: false,
|
calendar_freebusy_enabled: false,
|
||||||
calendar_hold_enabled: false,
|
calendar_hold_enabled: false,
|
||||||
@@ -61,11 +71,13 @@ function request(
|
|||||||
end_at: options.end ?? "2026-07-21T10:00:00Z",
|
end_at: options.end ?? "2026-07-21T10:00:00Z",
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
position: 0,
|
position: 0,
|
||||||
|
revision: "0".repeat(64),
|
||||||
freebusy_conflicts: [],
|
freebusy_conflicts: [],
|
||||||
metadata: {}
|
metadata: {}
|
||||||
}],
|
}],
|
||||||
participants: options.participantStatus ? [{
|
participants: options.participantStatus ? [{
|
||||||
id: `participant-${id}`,
|
id: `participant-${id}`,
|
||||||
|
is_current_participant: true,
|
||||||
respondent_id: "membership-1",
|
respondent_id: "membership-1",
|
||||||
email: "person@example.test",
|
email: "person@example.test",
|
||||||
participant_type: "internal",
|
participant_type: "internal",
|
||||||
@@ -88,6 +100,29 @@ test("groups owned, invited, and administrator-only requests without hiding any"
|
|||||||
assert.deepEqual(groups.other.map((item) => item.id), ["managed"]);
|
assert.deepEqual(groups.other.map((item) => item.id), ["managed"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("matches email-only invitations without case sensitivity", () => {
|
||||||
|
const invited = request("email-case", { participantStatus: "invited" });
|
||||||
|
invited.participants[0].is_current_participant = false;
|
||||||
|
invited.participants[0].respondent_id = null;
|
||||||
|
invited.participants[0].email = "Person@Example.Test";
|
||||||
|
|
||||||
|
const groups = groupSchedulingRequests([invited], actor, now);
|
||||||
|
|
||||||
|
assert.deepEqual(groups.invited.map((item) => item.id), ["email-case"]);
|
||||||
|
assert.deepEqual(groups.other, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recognizes the current participant after identity bindings are redacted", () => {
|
||||||
|
const invited = request("safe-projection", { participantStatus: "invited" });
|
||||||
|
invited.participants[0].respondent_id = null;
|
||||||
|
invited.participants[0].email = null;
|
||||||
|
|
||||||
|
const groups = groupSchedulingRequests([invited], actor, now);
|
||||||
|
|
||||||
|
assert.deepEqual(groups.invited.map((item) => item.id), ["safe-projection"]);
|
||||||
|
assert.deepEqual(groups.other, []);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps an organizer's active request in the open phase even if they also responded", () => {
|
test("keeps an organizer's active request in the open phase even if they also responded", () => {
|
||||||
const owned = request("mine-and-invited", {
|
const owned = request("mine-and-invited", {
|
||||||
organizer: "account-1",
|
organizer: "account-1",
|
||||||
@@ -122,3 +157,31 @@ test("orders unanswered by nearest slot before answered, closed, determined, and
|
|||||||
assert.equal(schedulingSortPhase(groups.invited[0], actor, now), "unanswered");
|
assert.equal(schedulingSortPhase(groups.invited[0], actor, now), "unanswered");
|
||||||
assert.equal(schedulingSortPhase(groups.invited.at(-1)!, actor, now), "past");
|
assert.equal(schedulingSortPhase(groups.invited.at(-1)!, actor, now), "past");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("single-choice availability keeps one positive choice while preserving explicit no answers", () => {
|
||||||
|
const next = applySchedulingAvailabilityChoice(
|
||||||
|
["slot-a", "slot-b", "slot-c"],
|
||||||
|
{ "slot-a": "available", "slot-b": "maybe", "slot-c": "unavailable" },
|
||||||
|
"slot-b",
|
||||||
|
"available",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(next, {
|
||||||
|
"slot-a": "unavailable",
|
||||||
|
"slot-b": "available",
|
||||||
|
"slot-c": "unavailable"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multi-choice availability changes only the selected slot", () => {
|
||||||
|
const next = applySchedulingAvailabilityChoice(
|
||||||
|
["slot-a", "slot-b"],
|
||||||
|
{ "slot-a": "available" },
|
||||||
|
"slot-b",
|
||||||
|
"maybe",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(next, { "slot-a": "available", "slot-b": "maybe" });
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user