Compare commits

..

6 Commits

5 changed files with 369 additions and 246 deletions

View File

@@ -6,6 +6,8 @@ Create Date: 2026-07-12 00:00:00.000000
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
@@ -55,6 +57,62 @@ _NOTIFICATION_INDEXES = {
} }
def _slot_extension_problems(inspector: Any, slot_columns: set[str]) -> list[str]:
problems: list[str] = []
missing_columns = _CALENDAR_SLOT_COLUMNS - slot_columns
if missing_columns:
problems.append(
"scheduling_candidate_slots missing columns: "
f"{', '.join(sorted(missing_columns))}"
)
indexes = {item["name"] for item in inspector.get_indexes("scheduling_candidate_slots")}
missing_indexes = _CALENDAR_SLOT_INDEXES - indexes
if missing_indexes:
problems.append(
"scheduling_candidate_slots missing indexes: "
f"{', '.join(sorted(missing_indexes))}"
)
return problems
def _has_cascading_notification_request_foreign_key(inspector: Any) -> bool:
return any(
tuple(item.get("constrained_columns") or ()) == ("request_id",)
and item.get("referred_table") == "scheduling_requests"
and tuple(item.get("referred_columns") or ()) == ("id",)
and str((item.get("options") or {}).get("ondelete", "")).upper() == "CASCADE"
for item in inspector.get_foreign_keys("scheduling_notifications")
)
def _notification_extension_problems(inspector: Any) -> list[str]:
problems: list[str] = []
columns = {item["name"] for item in inspector.get_columns("scheduling_notifications")}
missing_columns = _NOTIFICATION_COLUMNS - columns
if missing_columns:
problems.append(
"scheduling_notifications missing columns: "
f"{', '.join(sorted(missing_columns))}"
)
indexes = {item["name"] for item in inspector.get_indexes("scheduling_notifications")}
missing_indexes = _NOTIFICATION_INDEXES - indexes
if missing_indexes:
problems.append(
"scheduling_notifications missing indexes: "
f"{', '.join(sorted(missing_indexes))}"
)
primary_key = tuple(
inspector.get_pk_constraint("scheduling_notifications").get("constrained_columns") or ()
)
if primary_key != ("id",):
problems.append(f"scheduling_notifications has unexpected primary key: {primary_key!r}")
if not _has_cascading_notification_request_foreign_key(inspector):
problems.append("scheduling_notifications is missing its cascading request_id foreign key")
return problems
def _adopt_existing_calendar_notifications() -> bool: def _adopt_existing_calendar_notifications() -> bool:
"""Adopt only the complete calendar/notification extension.""" """Adopt only the complete calendar/notification extension."""
@@ -71,59 +129,11 @@ def _adopt_existing_calendar_notifications() -> bool:
if not present_slot_columns and not notification_exists: if not present_slot_columns and not notification_exists:
return False return False
problems: list[str] = [] problems = _slot_extension_problems(inspector, slot_columns)
missing_slot_columns = _CALENDAR_SLOT_COLUMNS - slot_columns
if missing_slot_columns:
problems.append(
"scheduling_candidate_slots missing columns: "
f"{', '.join(sorted(missing_slot_columns))}"
)
slot_indexes = {item["name"] for item in inspector.get_indexes("scheduling_candidate_slots")}
missing_slot_indexes = _CALENDAR_SLOT_INDEXES - slot_indexes
if missing_slot_indexes:
problems.append(
"scheduling_candidate_slots missing indexes: "
f"{', '.join(sorted(missing_slot_indexes))}"
)
if not notification_exists: if not notification_exists:
problems.append("missing table: scheduling_notifications") problems.append("missing table: scheduling_notifications")
else: else:
notification_columns = { problems.extend(_notification_extension_problems(inspector))
item["name"] for item in inspector.get_columns("scheduling_notifications")
}
missing_notification_columns = _NOTIFICATION_COLUMNS - notification_columns
if missing_notification_columns:
problems.append(
"scheduling_notifications missing columns: "
f"{', '.join(sorted(missing_notification_columns))}"
)
notification_indexes = {
item["name"] for item in inspector.get_indexes("scheduling_notifications")
}
missing_notification_indexes = _NOTIFICATION_INDEXES - notification_indexes
if missing_notification_indexes:
problems.append(
"scheduling_notifications missing indexes: "
f"{', '.join(sorted(missing_notification_indexes))}"
)
primary_key = tuple(
inspector.get_pk_constraint("scheduling_notifications").get("constrained_columns") or ()
)
if primary_key != ("id",):
problems.append(f"scheduling_notifications has unexpected primary key: {primary_key!r}")
request_foreign_key_exists = any(
tuple(item.get("constrained_columns") or ()) == ("request_id",)
and item.get("referred_table") == "scheduling_requests"
and tuple(item.get("referred_columns") or ()) == ("id",)
and str((item.get("options") or {}).get("ondelete", "")).upper() == "CASCADE"
for item in inspector.get_foreign_keys("scheduling_notifications")
)
if not request_foreign_key_exists:
problems.append("scheduling_notifications is missing its cascading request_id foreign key")
if problems: if problems:
detail = "; ".join(problems) detail = "; ".join(problems)

View File

@@ -2,8 +2,9 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any, cast
from sqlalchemy.orm import Session, object_session from sqlalchemy.orm import Session, object_session
@@ -787,16 +788,9 @@ def list_visible_scheduling_notifications(
return visible return visible
def refresh_participant_response_state( def _participant_response_indexes(
session: Session, participants: list[SchedulingParticipant],
*, ) -> tuple[dict[str, SchedulingParticipant], dict[str, SchedulingParticipant]]:
request: SchedulingRequest,
actor_ids: tuple[str, ...] = (),
reset_missing: bool = False,
) -> None:
if request.poll_id is None:
return
participants = _active_participants(request)
by_invitation_id = { by_invitation_id = {
participant.poll_invitation_id: participant participant.poll_invitation_id: participant
for participant in participants for participant in participants
@@ -808,6 +802,97 @@ def refresh_participant_response_state(
for identity in (participant.respondent_id, stored_identity): for identity in (participant.respondent_id, stored_identity):
if isinstance(identity, str) and identity: if isinstance(identity, str) and identity:
by_respondent_id[identity] = participant by_respondent_id[identity] = participant
return by_invitation_id, by_respondent_id
def _response_participant(
response: PollResponseRef,
*,
by_invitation_id: dict[str, SchedulingParticipant],
by_respondent_id: dict[str, SchedulingParticipant],
actor_ids: set[str],
actor_participants: list[SchedulingParticipant],
) -> SchedulingParticipant | None:
participant = by_invitation_id.get(response.invitation_id)
if participant is None and response.respondent_id is not None:
participant = by_respondent_id.get(response.respondent_id)
# Poll deliberately ignores client-supplied invitation identifiers on
# authenticated submissions. While reconciling for that same actor, map
# the server-authenticated respondent to their unique Scheduling row.
if participant is None and response.respondent_id in actor_ids and len(actor_participants) == 1:
return actor_participants[0]
return participant
def _apply_participant_response(
session: Session,
*,
request: SchedulingRequest,
participant: SchedulingParticipant,
response: PollResponseRef,
) -> bool:
changed = False
if response.respondent_id:
metadata = participant.metadata_ or {}
if metadata.get("poll_response_respondent_id") != response.respondent_id:
participant.metadata_ = {
**metadata,
"poll_response_respondent_id": response.respondent_id,
}
changed = True
if participant.status == "responded":
return changed
participant.status = "responded"
participant.responded_at = response_datetime(response.submitted_at)
_emit_scheduling_center_notification(
session,
request=request,
participant=participant,
event_kind="scheduling.participant_responded",
subject=f"Scheduling response: {request.title}",
body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.",
)
return True
def _reset_missing_participant_responses(
participants: list[SchedulingParticipant],
*,
matched_participant_ids: set[str],
unmatched_response_count: int,
) -> bool:
changed = False
for participant in participants:
if participant.status != "responded" or participant.id in matched_participant_ids:
continue
stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id")
if not isinstance(stored_identity, str) and unmatched_response_count:
# Preserve an older projection while an active response exists
# that cannot yet be mapped safely to a participant.
continue
participant.status = "invited" if participant.poll_invitation_id else "draft"
participant.responded_at = None
if isinstance(stored_identity, str):
participant.metadata_ = {
key: value
for key, value in (participant.metadata_ or {}).items()
if key != "poll_response_respondent_id"
}
changed = True
return changed
def refresh_participant_response_state(
session: Session,
*,
request: SchedulingRequest,
actor_ids: tuple[str, ...] = (),
reset_missing: bool = False,
) -> None:
if request.poll_id is None:
return
participants = _active_participants(request)
by_invitation_id, by_respondent_id = _participant_response_indexes(participants)
try: try:
responses = _poll_provider().list_responses( responses = _poll_provider().list_responses(
session, session,
@@ -826,60 +911,28 @@ def refresh_participant_response_state(
matched_participant_ids: set[str] = set() matched_participant_ids: set[str] = set()
unmatched_response_count = 0 unmatched_response_count = 0
for response in responses: for response in responses:
participant = by_invitation_id.get(response.invitation_id) participant = _response_participant(
if participant is None and response.respondent_id is not None: response,
participant = by_respondent_id.get(response.respondent_id) by_invitation_id=by_invitation_id,
# Poll deliberately ignores client-supplied invitation identifiers on by_respondent_id=by_respondent_id,
# authenticated submissions. While reconciling for that same actor, actor_ids=ids,
# map the server-authenticated respondent id to their unique Scheduling actor_participants=actor_participants,
# participant row (which may have been invited by email only). )
if (
participant is None
and response.respondent_id in ids
and len(actor_participants) == 1
):
participant = actor_participants[0]
if participant is None: if participant is None:
unmatched_response_count += 1 unmatched_response_count += 1
continue continue
matched_participant_ids.add(participant.id) matched_participant_ids.add(participant.id)
if response.respondent_id: changed = _apply_participant_response(
metadata = participant.metadata_ or {}
if metadata.get("poll_response_respondent_id") != response.respondent_id:
participant.metadata_ = {
**metadata,
"poll_response_respondent_id": response.respondent_id,
}
changed = True
if participant.status != "responded":
participant.status = "responded"
participant.responded_at = response_datetime(response.submitted_at)
_emit_scheduling_center_notification(
session, session,
request=request, request=request,
participant=participant, participant=participant,
event_kind="scheduling.participant_responded", response=response,
subject=f"Scheduling response: {request.title}", ) or changed
body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.", if reset_missing and _reset_missing_participant_responses(
) participants,
changed = True matched_participant_ids=matched_participant_ids,
if reset_missing: unmatched_response_count=unmatched_response_count,
for participant in participants: ):
if participant.status != "responded" or participant.id in matched_participant_ids:
continue
stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id")
if not isinstance(stored_identity, str) and unmatched_response_count:
# Do not reset an older projection while an active response
# exists that cannot yet be mapped safely to a participant.
continue
participant.status = "invited" if participant.poll_invitation_id else "draft"
participant.responded_at = None
if isinstance(stored_identity, str):
participant.metadata_ = {
key: value
for key, value in (participant.metadata_ or {}).items()
if key != "poll_response_respondent_id"
}
changed = True changed = True
if changed: if changed:
session.flush() session.flush()
@@ -1364,6 +1417,121 @@ def update_scheduling_request(
return request return request
@dataclass(frozen=True, slots=True)
class _CandidateSlotChanges:
label: str
description: str | None
start_at: datetime
end_at: datetime
timezone: str
location: str | None
metadata: dict[str, Any]
normalized_start: datetime
normalized_end: datetime
def _candidate_slot_changes(
slot: SchedulingCandidateSlot,
payload: SchedulingCandidateSlotUpdateRequest,
) -> _CandidateSlotChanges:
supplied = payload.model_fields_set
for field in ("label", "start_at", "end_at", "timezone"):
if field in supplied and getattr(payload, field) is None:
raise SchedulingError(f"{field} cannot be null")
label = cast(str, payload.label if "label" in supplied else slot.label)
description = payload.description if "description" in supplied else slot.description
start_at = cast(datetime, payload.start_at if "start_at" in supplied else slot.start_at)
end_at = cast(datetime, payload.end_at if "end_at" in supplied else slot.end_at)
timezone_name = cast(str, payload.timezone if "timezone" in supplied else slot.timezone)
location = payload.location if "location" in supplied else slot.location
metadata = (payload.metadata or {}) if "metadata" in supplied else (slot.metadata_ or {})
normalized_start = cast(datetime, response_datetime(start_at))
normalized_end = cast(datetime, response_datetime(end_at))
if normalized_end <= normalized_start:
raise SchedulingError("end_at must be after start_at")
return _CandidateSlotChanges(
label=label,
description=description,
start_at=start_at,
end_at=end_at,
timezone=timezone_name,
location=location,
metadata=metadata,
normalized_start=normalized_start,
normalized_end=normalized_end,
)
def _candidate_slot_change_flags(
slot: SchedulingCandidateSlot,
changes: _CandidateSlotChanges,
) -> tuple[bool, bool]:
temporal_or_location_changed = (
changes.normalized_start != response_datetime(slot.start_at)
or changes.normalized_end != response_datetime(slot.end_at)
or changes.timezone != slot.timezone
or changes.location != slot.location
)
changed = (
changes.label != slot.label
or changes.description != slot.description
or temporal_or_location_changed
or changes.metadata != (slot.metadata_ or {})
)
return changed, temporal_or_location_changed
def _update_candidate_poll_option(
session: Session,
*,
tenant_id: str,
request: SchedulingRequest,
slot: SchedulingCandidateSlot,
changes: _CandidateSlotChanges,
) -> None:
try:
_poll_provider().update_option(
session,
tenant_id=tenant_id,
poll_id=cast(str, request.poll_id),
option_id=cast(str, slot.poll_option_id),
command=PollOptionUpdateCommand(
label=changes.label,
description=changes.description,
value={
"slot_id": slot.id,
"start_at": changes.normalized_start.isoformat(),
"end_at": changes.normalized_end.isoformat(),
"timezone": changes.timezone,
"location": changes.location,
},
metadata=changes.metadata,
),
)
except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc
def _apply_candidate_slot_changes(
slot: SchedulingCandidateSlot,
changes: _CandidateSlotChanges,
*,
temporal_or_location_changed: bool,
) -> None:
slot.label = changes.label
slot.description = changes.description
slot.start_at = changes.start_at
slot.end_at = changes.end_at
slot.timezone = changes.timezone
slot.location = changes.location
slot.metadata_ = changes.metadata
if temporal_or_location_changed:
slot.freebusy_checked_at = None
slot.freebusy_status = None
slot.freebusy_conflicts = []
def update_scheduling_candidate_slot( def update_scheduling_candidate_slot(
session: Session, session: Session,
*, *,
@@ -1388,35 +1556,8 @@ def update_scheduling_candidate_slot(
if slot.poll_option_id is None: if slot.poll_option_id is None:
raise SchedulingError("Scheduling slot has no backing poll option") raise SchedulingError("Scheduling slot has no backing poll option")
supplied = payload.model_fields_set changes = _candidate_slot_changes(slot, payload)
for field in ("label", "start_at", "end_at", "timezone"): changed, temporal_or_location_changed = _candidate_slot_change_flags(slot, changes)
if field in supplied and getattr(payload, field) is None:
raise SchedulingError(f"{field} cannot be null")
label = payload.label if "label" in supplied else slot.label
description = payload.description if "description" in supplied else slot.description
start_at = payload.start_at if "start_at" in supplied else slot.start_at
end_at = payload.end_at if "end_at" in supplied else slot.end_at
timezone_name = payload.timezone if "timezone" in supplied else slot.timezone
location = payload.location if "location" in supplied else slot.location
metadata = (payload.metadata or {}) if "metadata" in supplied else (slot.metadata_ or {})
normalized_start = response_datetime(start_at)
normalized_end = response_datetime(end_at)
if normalized_end <= normalized_start:
raise SchedulingError("end_at must be after start_at")
temporal_or_location_changed = (
normalized_start != response_datetime(slot.start_at)
or normalized_end != response_datetime(slot.end_at)
or timezone_name != slot.timezone
or location != slot.location
)
changed = (
label != slot.label
or description != slot.description
or temporal_or_location_changed
or metadata != (slot.metadata_ or {})
)
if not changed: if not changed:
return request return request
if temporal_or_location_changed and slot.tentative_hold_event_id: if temporal_or_location_changed and slot.tentative_hold_event_id:
@@ -1424,27 +1565,13 @@ def update_scheduling_candidate_slot(
"A slot with a tentative calendar hold cannot change time, timezone, or location" "A slot with a tentative calendar hold cannot change time, timezone, or location"
) )
try: _update_candidate_poll_option(
_poll_provider().update_option(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
poll_id=request.poll_id, request=request,
option_id=slot.poll_option_id, slot=slot,
command=PollOptionUpdateCommand( changes=changes,
label=label,
description=description,
value={
"slot_id": slot.id,
"start_at": normalized_start.isoformat(),
"end_at": normalized_end.isoformat(),
"timezone": timezone_name,
"location": location,
},
metadata=metadata,
),
) )
except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc
refresh_participant_response_state( refresh_participant_response_state(
session, session,
@@ -1452,17 +1579,11 @@ def update_scheduling_candidate_slot(
reset_missing=True, reset_missing=True,
) )
slot.label = label _apply_candidate_slot_changes(
slot.description = description slot,
slot.start_at = start_at changes,
slot.end_at = end_at temporal_or_location_changed=temporal_or_location_changed,
slot.timezone = timezone_name )
slot.location = location
slot.metadata_ = metadata
if temporal_or_location_changed:
slot.freebusy_checked_at = None
slot.freebusy_status = None
slot.freebusy_conflicts = []
session.flush() session.flush()
return request return request

View File

@@ -12,6 +12,7 @@ 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]*ModuleSubnav,[\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-full-editor"/);
assert.match(page, /className="scheduling-page-actions"/); assert.match(page, /className="scheduling-page-actions"/);
@@ -36,6 +37,9 @@ assert.match(browseBranch, /<h1>\{I18N\.requests\}<\/h1>/);
assert.match(browseBranch, /<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.add\}/); 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.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.doesNotMatch(page, /<button[\s\S]{0,160}scheduling-list-item/);
assert.match(createBranch, /<Card title=\{I18N\.basicInformation\}>/); assert.match(createBranch, /<Card title=\{I18N\.basicInformation\}>/);
assert.match(createBranch, /<Card title=\{I18N\.calendarIntegration\}>/); assert.match(createBranch, /<Card title=\{I18N\.calendarIntegration\}>/);
assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/); assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/);
@@ -52,7 +56,7 @@ 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, /header: I18N\.required|<table|scheduling-table|scheduling-card(?:\s|"|`)/);
assert.match(page, /aria-label=\{i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/); assert.match(page, /<TableActionGroup[\s\S]*label: i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/); assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
assert.match(page, /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\)/);

View File

@@ -11,12 +11,18 @@ import {
XCircle XCircle
} from "lucide-react"; } from "lucide-react";
import { import {
AdminIconButton,
Button, Button,
Card, Card,
DataGrid, DataGrid,
DataGridEmptyAction, DataGridEmptyAction,
DataGridRowActions, DataGridRowActions,
DateTimeField,
formatDateTime,
ModuleSubnav, ModuleSubnav,
SelectionList,
SelectionListItem,
TableActionGroup,
ToggleSwitch, ToggleSwitch,
hasScope, hasScope,
i18nMessage, i18nMessage,
@@ -26,6 +32,7 @@ import {
type AuthInfo, type AuthInfo,
type CalendarPickerUiCapability, type CalendarPickerUiCapability,
type DataGridColumn, type DataGridColumn,
type FormatDateTimeOptions,
type ModuleSubnavGroup type ModuleSubnavGroup
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -578,9 +585,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<h1>{I18N.requests}</h1> <h1>{I18N.requests}</h1>
</div> </div>
<div className="scheduling-page-actions"> <div className="scheduling-page-actions">
<Button type="button" className="scheduling-icon-button" onClick={() => void loadRequests()} title={I18N.refresh} aria-label={I18N.refresh}> <AdminIconButton label={I18N.refresh} icon={<RefreshCw aria-hidden="true" size={16} />} onClick={() => void loadRequests()} disabled={loading} />
<RefreshCw aria-hidden="true" size={16} />
</Button>
{canWrite ? ( {canWrite ? (
<Button type="button" variant="primary" onClick={beginCreate}> <Button type="button" variant="primary" onClick={beginCreate}>
<Plus aria-hidden="true" size={16} /> {I18N.add} <Plus aria-hidden="true" size={16} /> {I18N.add}
@@ -809,16 +814,20 @@ function RequestGroup({
return ( return (
<Card title={title}> <Card title={title}>
<div className="scheduling-list-group"> <div className="scheduling-list-group">
{requests.length ? requests.map((request) => ( {requests.length ? (
<button <SelectionList label={title} className="scheduling-request-list">
{requests.map((request) => (
<SelectionListItem
key={request.id} key={request.id}
className={`scheduling-list-item ${selectedId === request.id ? "is-selected" : ""}`} selected={selectedId === request.id}
type="button" className="scheduling-list-item"
onClick={() => onSelect(request.id)}> onClick={() => onSelect(request.id)}>
<span><strong>{request.title}</strong><small>{formatRelevantDate(request)}</small></span> <span><strong>{request.title}</strong><small>{formatRelevantDate(request)}</small></span>
<small className="scheduling-list-status">{requestStatusLabel(request, actor)}</small> <small className="scheduling-list-status">{requestStatusLabel(request, actor)}</small>
</button> </SelectionListItem>
)) : <p className="scheduling-list-empty">{I18N.noRequestsInGroup}</p>} ))}
</SelectionList>
) : <p className="scheduling-list-empty">{I18N.noRequestsInGroup}</p>}
</div> </div>
</Card> </Card>
); );
@@ -883,33 +892,31 @@ function EditableSlots({
{ {
id: "start", id: "start",
header: I18N.start, header: I18N.start,
minWidth: 200, minWidth: 280,
resizable: true, resizable: true,
value: (slot) => slot.start_at, value: (slot) => slot.start_at,
render: (slot, index) => ( render: (slot, index) => (
<input <DateTimeField
className="scheduling-grid-input" className="scheduling-grid-date-time"
required required
type="datetime-local"
aria-label={I18N.start} aria-label={I18N.start}
value={slot.start_at} value={slot.start_at}
onChange={(event) => onChange(index, "start_at", event.target.value)} /> onChange={(value) => onChange(index, "start_at", value)} />
) )
}, },
{ {
id: "end", id: "end",
header: I18N.end, header: I18N.end,
minWidth: 200, minWidth: 280,
resizable: true, resizable: true,
value: (slot) => slot.end_at, value: (slot) => slot.end_at,
render: (slot, index) => ( render: (slot, index) => (
<input <DateTimeField
className="scheduling-grid-input" className="scheduling-grid-date-time"
required required
type="datetime-local"
aria-label={I18N.end} aria-label={I18N.end}
value={slot.end_at} value={slot.end_at}
onChange={(event) => onChange(index, "end_at", event.target.value)} /> onChange={(value) => onChange(index, "end_at", value)} />
) )
}, },
{ {
@@ -1059,8 +1066,8 @@ function CandidateSlotsGrid({
</span> </span>
) )
}, },
{ id: "start", header: I18N.start, minWidth: 160, resizable: true, value: (slot) => slot.start_at, render: (slot) => formatDateTime(slot.start_at) }, { id: "start", header: I18N.start, minWidth: 160, resizable: true, value: (slot) => slot.start_at, render: (slot) => formatDateTime(slot.start_at, SHORT_DATE_TIME_OPTIONS) },
{ id: "end", header: I18N.end, minWidth: 160, resizable: true, value: (slot) => slot.end_at, render: (slot) => formatDateTime(slot.end_at) }, { id: "end", header: I18N.end, minWidth: 160, resizable: true, value: (slot) => slot.end_at, render: (slot) => formatDateTime(slot.end_at, SHORT_DATE_TIME_OPTIONS) },
{ {
id: "available", id: "available",
header: I18N.available, header: I18N.available,
@@ -1087,17 +1094,14 @@ function CandidateSlotsGrid({
header: I18N.actions, header: I18N.actions,
width: 72, width: 72,
sticky: "end", sticky: "end",
render: (slot) => canDecide ? ( render: (slot) => <TableActionGroup actions={[{
<Button id: "decide",
className="scheduling-icon-button" label: i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label }),
type="button" icon: <Check aria-hidden="true" size={16} />,
title={i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label })} applicable: canDecide,
aria-label={i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label })} disabled: saving,
disabled={saving} onClick: () => onDecide(slot)
onClick={() => onDecide(slot)}> }]} />
<Check aria-hidden="true" size={16} />
</Button>
) : null
} }
]; ];
@@ -1233,17 +1237,24 @@ function isoFromLocal(value: string): string {
return new Date(value).toISOString(); return new Date(value).toISOString();
} }
function formatDateTime(value: string): string { const SHORT_DATE_TIME_OPTIONS: FormatDateTimeOptions = {
return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); year: undefined,
} month: undefined,
day: undefined,
hour: undefined,
minute: undefined,
timeZoneName: undefined,
dateStyle: "short",
timeStyle: "short"
};
function formatRange(start: string, end: string): string { function formatRange(start: string, end: string): string {
return `${formatDateTime(start)} ${formatDateTime(end)}`; return `${formatDateTime(start, SHORT_DATE_TIME_OPTIONS)} ${formatDateTime(end, SHORT_DATE_TIME_OPTIONS)}`;
} }
function formatRelevantDate(request: SchedulingRequest): string { function formatRelevantDate(request: SchedulingRequest): string {
const timestamp = schedulingRelevantTimestamp(request); const timestamp = schedulingRelevantTimestamp(request);
return Number.isFinite(timestamp) ? formatDateTime(new Date(timestamp).toISOString()) : ""; return Number.isFinite(timestamp) ? formatDateTime(new Date(timestamp).toISOString(), SHORT_DATE_TIME_OPTIONS) : "";
} }
function errorMessage(error: unknown, fallback: string): string { function errorMessage(error: unknown, fallback: string): string {

View File

@@ -89,15 +89,6 @@
gap: 6px; gap: 6px;
} }
.scheduling-icon-button.btn {
width: 34px;
min-width: 34px;
height: 34px;
min-height: 34px;
padding: 0;
justify-content: center;
}
.scheduling-alert, .scheduling-alert,
.scheduling-success { .scheduling-success {
margin: 10px 14px 0; margin: 10px 14px 0;
@@ -107,8 +98,8 @@
} }
.scheduling-alert { .scheduling-alert {
border-color: var(--danger); border-color: var(--danger-border-strong);
color: var(--danger); color: var(--danger-text-strong);
background: var(--danger-soft); background: var(--danger-soft);
} }
@@ -145,30 +136,16 @@
overflow: auto; overflow: auto;
} }
.scheduling-request-list {
gap: 4px;
}
.scheduling-list-item { .scheduling-list-item {
width: 100%;
min-height: 50px; min-height: 50px;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
margin: 0 0 4px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 7px;
color: var(--text);
background: transparent;
text-align: left;
cursor: pointer;
}
.scheduling-list-item:hover {
background: var(--hover-bg);
}
.scheduling-list-item.is-selected {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
} }
.scheduling-list-item > span { .scheduling-list-item > span {
@@ -247,7 +224,7 @@
.scheduling-freebusy.busy, .scheduling-freebusy.busy,
.scheduling-freebusy.error { .scheduling-freebusy.error {
color: var(--danger); color: var(--danger-text-strong);
} }
.scheduling-columns { .scheduling-columns {
@@ -317,7 +294,7 @@
border: var(--border-line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
color: var(--text); color: var(--text);
background: var(--input-bg); background: var(--control-bg);
font: inherit; font: inherit;
} }