17 Commits
Author SHA1 Message Date
zemion 64bdc3dd6b Release v0.1.18
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 21:07:52 +02:00
zemion 67219a3d20 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 20:34:13 +02:00
zemion f81483f004 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:32 +02:00
zemion 3623078235 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:10:27 +02:00
zemion 0047ae56dc Make package publication retries hash-safe 2026-08-04 14:32:20 +02:00
zemion a9e43321ed Harden module package publication 2026-08-04 14:02:41 +02:00
zemion 9fd2ccd0a5 Enforce tenant policy on public scheduling links 2026-08-04 09:29:36 +02:00
zemion 22555f2603 Add protected package release workflow 2026-08-04 04:14:07 +02:00
zemion c17cbdae63 Complete guided Scheduling interface patterns 2026-08-03 10:36:14 +02:00
zemion 3d8272b28e Apply guided scheduling interface patterns 2026-08-03 08:29:29 +02:00
zemion 3e9a14a970 docs: declare institutional architecture boundary 2026-08-01 17:48:39 +02:00
zemion 480ccdb3b6 refactor: target workflow engine runtime 2026-07-31 16:59:23 +02:00
zemion d4cbf7e4d5 Align Scheduling WebUI runtime dependencies 2026-07-31 02:48:56 +02:00
zemion 1f2001daf8 fix: complete scheduling interface translations 2026-07-30 17:42:10 +02:00
zemion 9b029c8edf Refactor scheduling reconciliation into change plans 2026-07-29 17:21:13 +02:00
zemion b9f557e95b perf: batch scheduling reconciliation and add widget 2026-07-29 14:16:29 +02:00
zemion 60b01e5a16 Declare scheduling route View surface 2026-07-28 21:04:55 +02:00
22 changed files with 2650 additions and 813 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Scheduling Codex Guide
## Scope
This repository owns poll-backed meeting scheduling, candidate slots, participants, constraints, availability, reminders, decisions, and Calendar handoff.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Scheduling internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- Poll owns reusable responses; Calendar owns events and free/busy; Notifications and Mail own delivery.
- Keep optional integrations capability-driven and preserve signed-link privacy and abuse controls.
+21
View File
@@ -132,6 +132,27 @@ poll-backed scheduling requests:
- a first Scheduling WebUI package with request creation, slot matrix, Calendar
actions, decisions, and notification-job creation
## Interface workflow and contextual guidance
The route-by-route migration record and verification contract are documented in
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
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
identity policy is agreed, Calendar hold cleanup after decision, and advanced
scoring constraints such as required participants and quorum rules.
+45
View File
@@ -0,0 +1,45 @@
# Scheduling Interface Pattern Migration
Scheduling implements the platform interface pattern language on its owned
surfaces without importing optional module internals.
## Surface Map
| Surface | Pattern | Consequential actions | Help context |
| --- | --- | --- | --- |
| `/scheduling` request list and detail | Persistent list-detail workspace with a lifecycle rail | Open, close, remind, decide, create holds, create final event | `scheduling.list`, `scheduling.request` |
| `/scheduling` create/edit | Bounded editor with unsaved-change guard and typed Core controls | Save or discard a request definition | `scheduling.editor` |
| `/scheduling/public/:requestId/:token` | Privacy-bounded public participation form | Submit or replace the invited participant's response | `scheduling.public-participation` |
| `scheduling.widget.open-requests` | Compact dashboard contribution | Navigate to the selected request | `scheduling.request` |
## Interaction Contract
- Closing a poll, creating reminder jobs or Calendar objects, and deciding a
final slot require a shared confirmation dialog. Invitation-link revocation
uses the same component with danger emphasis.
- Disabled actions expose the active busy, permission, immutable-response, or
optional-capability reason through Core's action-tooltip and blocker
components.
- Calendar selection uses the optional `calendar.picker` UI capability and
bounded Calendar scopes. Scheduling never imports Calendar WebUI code.
- Participant selection uses Core's provider-backed `PeoplePicker`; public
participation receives only the privacy-bounded request projection.
- Stable documentation topics are available from the list, editor, detail,
public participation page, and dashboard widget. Core resolves them through
Docs when enabled and through hosted documentation otherwise.
## Verification
Run:
```bash
cd webui
npm run test:view-model
npm run test:ui-structure
```
The structural check guards shared components, optional-module boundaries,
confirmation gates, contextual documentation, public credential controls, and
request-specific widget navigation. Backend tests validate manifest metadata,
permission boundaries, lifecycle transitions, public participation, and
Calendar capability behavior.
+3 -3
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-scheduling"
version = "0.1.11"
version = "0.1.18"
description = "GovOPlaN meeting scheduling and Terminfindung module seed."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.11",
"govoplan-poll>=0.1.11",
"govoplan-core>=0.1.18",
"govoplan-poll>=0.1.18",
]
[tool.setuptools.packages.find]
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.11"
__version__ = "0.1.18"
+162 -6
View File
@@ -7,7 +7,9 @@ from govoplan_core.core.calendar import CAPABILITY_CALENDAR_SCHEDULING
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationTopic,
DocumentationCondition,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
@@ -18,19 +20,25 @@ from govoplan_core.core.modules import (
PublicFrontendRoute,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.people import (
CAPABILITY_ACCESS_PEOPLE_SEARCH,
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
)
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.poll_participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING, PollCapabilityError
from govoplan_core.core.poll_participation import (
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
PollResponseGatewayRef,
poll_participation_gateway_provider,
)
from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
MODULE_ID = "scheduling"
MODULE_NAME = "Scheduling"
MODULE_VERSION = "0.1.11"
MODULE_VERSION = "0.1.18"
READ_SCOPE = "scheduling:schedule:read"
WRITE_SCOPE = "scheduling:schedule:write"
ADMIN_SCOPE = "scheduling:schedule:admin"
@@ -88,11 +96,88 @@ DOCUMENTATION = (
"flows remain possible."
),
layer="available",
documentation_types=("admin",),
audience=("operator", "module_admin", "product_owner"),
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
related_modules=("poll", "evaluation", "calendar", "appointments", "mail", "notifications", "portal"),
metadata={"seed": True},
),
DocumentationTopic(
id="scheduling.find-and-decide-meeting-time",
title="Find and decide a meeting time",
summary="Create candidate slots, invite internal or external participants, compare availability, and turn the selected slot into a calendar event when Calendar is available.",
body=(
"Scheduling records participant requirements, quorum and weighting constraints, response deadlines, reminders, and yes/no/maybe availability through Poll. "
"Calendar-aware organizers can inspect conflicts and create tentative holds before deciding. After a decision, Scheduling releases unused holds, creates or links the final event, and records notification handoff state. "
"Signed external links expose only the bounded request information allowed by the request's participation and privacy policy. Their governed Poll invitation resolves the tenant before Scheduling runs, so tenant module policy can withdraw public participation without weakening token validation."
),
layer="configured",
documentation_types=("user",),
audience=("user", "organizer", "participant"),
conditions=(
DocumentationCondition(
any_scopes=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE, RESPOND_SCOPE),
),
),
related_modules=("poll", "calendar", "notifications", "mail"),
metadata={
"kind": "workflow",
"route": "/scheduling",
"screen": "Scheduling",
"help_contexts": [
"scheduling.list",
"scheduling.request",
"scheduling.editor",
"scheduling.public-participation",
],
"steps": [
"Prepare candidate times and participation controls.",
"Collect and review availability.",
"Close the poll and confirm the selected time.",
"Hand the decision to Calendar when configured.",
],
"outcome": "A recorded scheduling decision with bounded participation and optional Calendar handoff.",
"verification": "The request detail shows the decided slot, lifecycle state, participant aggregate, and any Calendar event 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",
"help_contexts": ["scheduling.calendar-integration", "scheduling.calendar-coordination"],
},
),
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",
"help_contexts": [
"scheduling.public-participation-blocker",
"scheduling.public-participation",
],
},
),
)
@@ -131,12 +216,53 @@ def _scheduling_router(context: ModuleContext):
return router
def _public_tenant_resolver(request: object, session: object) -> str | None:
path_params = getattr(request, "path_params", {})
request_id = str(path_params.get("request_id") or "").strip()
token = str(path_params.get("token") or "").strip()
path = str(getattr(getattr(request, "url", None), "path", ""))
if not request_id or not token or "/scheduling/public/" not in path:
return None
from govoplan_scheduling.backend.db.models import SchedulingRequest
app = getattr(request, "app", None)
registry = getattr(getattr(app, "state", None), "govoplan_registry", None)
provider = poll_participation_gateway_provider(registry)
if provider is None:
return None
gateway = PollResponseGatewayRef(
module_id=MODULE_ID,
resource_type="scheduling_request",
resource_id=request_id,
)
try:
invitation = provider.resolve_public_invitation(
session,
token=token,
gateway=gateway,
)
except PollCapabilityError:
return None
scheduling_request = (
session.query(SchedulingRequest)
.filter(
SchedulingRequest.id == request_id,
SchedulingRequest.tenant_id == invitation.tenant_id,
SchedulingRequest.poll_id == invitation.poll_id,
SchedulingRequest.deleted_at.is_(None),
)
.one_or_none()
)
return scheduling_request.tenant_id if scheduling_request is not None else None
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("poll",),
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "policy", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "policy", "portal", "workflow_engine", "tasks", "idm", "organizations", "addresses"),
optional_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -171,6 +297,14 @@ manifest = ModuleManifest(
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/scheduling-webui",
routes=(
FrontendRoute(
path="/scheduling",
component="SchedulingPage",
required_any=(READ_SCOPE,),
order=56,
),
),
public_routes=(
PublicFrontendRoute(
path="/scheduling/public/:requestId/:token",
@@ -179,8 +313,18 @@ manifest = ModuleManifest(
),
),
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
view_surfaces=(
ViewSurface(
id="scheduling.widget.open-requests",
module_id=MODULE_ID,
kind="section",
label="Scheduling requests widget",
order=45,
),
),
),
route_factory=_scheduling_router,
public_tenant_resolver=_public_tenant_resolver,
tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec(
module_id=MODULE_ID,
@@ -206,6 +350,18 @@ manifest = ModuleManifest(
),
),
documentation=DOCUMENTATION,
architecture=declared_module_architecture(
layer="communication_participation",
kind="domain",
maturity="vertical_slice",
documentation_ref="README.md",
test_ref="tests/test_service.py",
known_limits=("Reference deployment notification delivery and every calendar-provider constraint remain incomplete.",),
owned_concepts=("scheduling request", "candidate slot", "scheduling participant", "scheduling decision"),
non_owned_concepts=("poll response primitive", "calendar event", "mail delivery"),
recovery_docs=("README.md",),
security_docs=("README.md",),
),
)
+4 -21
View File
@@ -59,7 +59,6 @@ from govoplan_scheduling.backend.service import (
list_visible_scheduling_requests,
issue_scheduling_participant_invitation,
open_scheduling_request,
refresh_participant_response_state,
require_visible_scheduling_results,
revoke_scheduling_participant_invitation,
scheduling_notification_response,
@@ -297,6 +296,7 @@ def api_search_scheduling_people(
@router.get("/requests", response_model=SchedulingRequestListResponse)
def api_list_scheduling_requests(
status_filter: str | None = Query(default=None, alias="status"),
limit: int = 100,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestListResponse:
@@ -307,21 +307,11 @@ def api_list_scheduling_requests(
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
status=status_filter,
limit=limit,
)
actor_ids = _principal_actor_ids(principal)
for request in requests:
refresh_participant_response_state(
session,
request=request,
actor_ids=actor_ids,
)
response = SchedulingRequestListResponse(
return SchedulingRequestListResponse(
requests=[_request_response(request, principal=principal) for request in requests]
)
# Poll responses are authoritative, while Scheduling keeps a durable
# participant projection used by its task-oriented list.
session.commit()
return response
@router.post("/requests", response_model=SchedulingRequestResponse, status_code=status.HTTP_201_CREATED)
@@ -407,16 +397,9 @@ def api_get_scheduling_request(
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
)
refresh_participant_response_state(
session,
request=request,
actor_ids=_principal_actor_ids(principal),
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = _request_response(request, principal=principal)
session.commit()
return response
return _request_response(request, principal=principal)
@router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse)
File diff suppressed because it is too large Load Diff
+12
View File
@@ -29,6 +29,7 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIn("addresses.people_search", manifest.optional_capabilities)
self.assertIn("evaluation", manifest.optional_dependencies)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.public_tenant_resolver)
self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend)
self.assertEqual(
@@ -52,6 +53,17 @@ class SchedulingManifestTests(unittest.TestCase):
):
self.assertEqual("0.1.11", required_interfaces[interface_name].version_min)
documentation = {topic.id: topic for topic in manifest.documentation}
workflow = documentation["scheduling.find-and-decide-meeting-time"]
self.assertEqual("workflow", workflow.metadata["kind"])
self.assertEqual("/scheduling", workflow.metadata["route"])
self.assertIn("scheduling.request", workflow.metadata["help_contexts"])
self.assertIn("scheduling.public-participation", workflow.metadata["help_contexts"])
self.assertIn(
"scheduling.calendar-coordination",
documentation["scheduling.calendar-coordination"].metadata["help_contexts"],
)
if __name__ == "__main__":
unittest.main()
+283
View File
@@ -0,0 +1,283 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from govoplan_scheduling.backend.db.models import (
SchedulingCandidateSlot,
SchedulingParticipant,
SchedulingRequest,
)
from govoplan_scheduling.backend.schemas import (
SchedulingCandidateSlotReconcileInput,
SchedulingParticipantReconcileInput,
SchedulingRequestUpdateRequest,
)
from govoplan_scheduling.backend.service import (
SchedulingError,
_plan_scheduling_participant_reconciliation,
_plan_scheduling_request_update,
_plan_scheduling_slot_reconciliation,
scheduling_participant_revision,
scheduling_slot_revision,
)
NOW = datetime(2026, 7, 29, 9, tzinfo=timezone.utc)
def _request() -> SchedulingRequest:
return SchedulingRequest(
id="request-1",
tenant_id="tenant-1",
title="Steering group",
timezone="Europe/Berlin",
status="collecting",
poll_id="poll-1",
allow_external_participants=True,
allow_participant_updates=True,
result_visibility="after_close",
participant_visibility="aggregates_only",
notify_on_answers=True,
single_choice=False,
max_participants_per_option=None,
allow_maybe=True,
allow_comments=False,
participant_email_required=False,
anonymous_password_protection_enabled=False,
)
def _slot(
request: SchedulingRequest,
*,
slot_id: str,
position: int,
start_offset: int,
) -> SchedulingCandidateSlot:
slot = SchedulingCandidateSlot(
id=slot_id,
tenant_id=request.tenant_id,
request=request,
poll_option_id=f"option-{slot_id}",
label=f"Slot {position + 1}",
start_at=NOW + timedelta(hours=start_offset),
end_at=NOW + timedelta(hours=start_offset + 1),
timezone=request.timezone,
position=position,
freebusy_conflicts=[],
metadata_={},
)
return slot
def _slot_input(
slot: SchedulingCandidateSlot,
*,
label: str | None = None,
) -> SchedulingCandidateSlotReconcileInput:
return SchedulingCandidateSlotReconcileInput(
id=slot.id,
revision=scheduling_slot_revision(slot),
label=label or slot.label,
start_at=slot.start_at,
end_at=slot.end_at,
timezone=slot.timezone,
location=slot.location,
metadata=slot.metadata_ or {},
)
def _participant(
request: SchedulingRequest,
*,
participant_id: str,
respondent_id: str,
email: str,
required: bool,
status: str = "invited",
invitation_id: str | None = None,
) -> SchedulingParticipant:
return SchedulingParticipant(
id=participant_id,
tenant_id=request.tenant_id,
request=request,
respondent_id=respondent_id,
display_name=participant_id.title(),
email=email,
participant_type="internal",
required=required,
status=status,
poll_invitation_id=invitation_id,
participation_gateway="scheduling" if invitation_id else None,
metadata_={},
)
def _participant_input(
participant: SchedulingParticipant,
**changes: object,
) -> SchedulingParticipantReconcileInput:
values = {
"id": participant.id,
"revision": scheduling_participant_revision(participant),
"respondent_id": participant.respondent_id,
"display_name": participant.display_name,
"email": participant.email,
"participant_type": participant.participant_type,
"required": participant.required,
"metadata": participant.metadata_ or {},
}
values.update(changes)
return SchedulingParticipantReconcileInput.model_validate(values)
def test_slot_plan_is_inspectable_and_does_not_mutate_models() -> None:
request = _request()
first = _slot(request, slot_id="slot-1", position=0, start_offset=1)
second = _slot(request, slot_id="slot-2", position=1, start_offset=3)
supplied = [
_slot_input(first, label="Updated first slot"),
SchedulingCandidateSlotReconcileInput(
label="New slot",
start_at=NOW + timedelta(hours=5),
end_at=NOW + timedelta(hours=6),
timezone=request.timezone,
),
]
plan = _plan_scheduling_slot_reconciliation(
request=request,
supplied_slots=supplied,
option_mutation_available=True,
)
assert [update.slot_id for update in plan.updates] == ["slot-1"]
assert plan.updates[0].changes.label == "Updated first slot"
assert len(plan.additions) == 1
assert plan.removals == ("slot-2",)
assert plan.changed is True
assert first.label == "Slot 1"
assert second.deleted_at is None
assert len(request.slots) == 2
def test_exact_slot_replay_produces_a_noop_plan() -> None:
request = _request()
first = _slot(request, slot_id="slot-1", position=0, start_offset=1)
plan = _plan_scheduling_slot_reconciliation(
request=request,
supplied_slots=[_slot_input(first)],
option_mutation_available=True,
)
assert plan.changed is False
assert plan.updates == ()
assert plan.additions == ()
assert plan.removals == ()
def test_slot_plan_rejects_removal_of_a_tentative_calendar_hold() -> None:
request = _request()
held = _slot(request, slot_id="slot-held", position=0, start_offset=1)
held.tentative_hold_event_id = "event-1"
with pytest.raises(SchedulingError, match="tentative calendar hold"):
_plan_scheduling_slot_reconciliation(
request=request,
supplied_slots=[],
option_mutation_available=True,
)
def test_participant_plan_distinguishes_updates_additions_and_retirements() -> None:
request = _request()
alice = _participant(
request,
participant_id="alice",
respondent_id="user-alice",
email="alice@example.test",
required=True,
invitation_id="invitation-alice",
)
bob = _participant(
request,
participant_id="bob",
respondent_id="user-bob",
email="bob@example.test",
required=False,
status="responded",
)
supplied = [
_participant_input(alice, email="alice.new@example.test", required=False),
SchedulingParticipantReconcileInput(
respondent_id="user-charlie",
display_name="Charlie",
email="charlie@example.test",
participant_type="internal",
required=True,
),
]
plan = _plan_scheduling_participant_reconciliation(
request=request,
supplied_participants=supplied,
participation_available=True,
retirement_available=True,
)
assert len(plan.updates) == 1
assert plan.updates[0].participant_id == "alice"
assert plan.updates[0].revoke_invitation is True
assert set(plan.updates[0].changed_fields) == {"email", "required"}
assert plan.creations[0].replaces_participant_id is None
assert plan.creations[0].supplied.required is True
assert plan.retirements == ("bob",)
assert alice.email == "alice@example.test"
assert alice.required is True
assert bob.status == "responded"
def test_request_plan_records_invitation_expiry_work_without_tokens() -> None:
request = _request()
participant = _participant(
request,
participant_id="alice",
respondent_id="user-alice",
email="alice@example.test",
required=True,
invitation_id="invitation-alice",
)
deadline = NOW + timedelta(days=2)
plan = _plan_scheduling_request_update(
request=request,
payload=SchedulingRequestUpdateRequest(deadline_at=deadline),
participation_available=True,
)
assert plan.deadline_changed is True
assert plan.retained_invitation_ids == ((participant.id, "invitation-alice"),)
assert "token" not in repr(plan).casefold()
assert request.deadline_at is None
def test_request_plan_rejects_policy_change_after_link_issuance() -> None:
request = _request()
_participant(
request,
participant_id="alice",
respondent_id="user-alice",
email="alice@example.test",
required=True,
invitation_id="invitation-alice",
)
with pytest.raises(SchedulingError, match="cannot change"):
_plan_scheduling_request_update(
request=request,
payload=SchedulingRequestUpdateRequest(single_choice=True),
participation_available=True,
)
+41 -5
View File
@@ -33,7 +33,12 @@ from govoplan_scheduling.backend.db.models import (
SchedulingParticipant,
SchedulingRequest,
)
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.manifest import (
ADMIN_SCOPE,
RESPOND_SCOPE,
WRITE_SCOPE,
get_manifest as get_scheduling_manifest,
)
from govoplan_scheduling.backend.router import (
api_get_my_scheduling_availability,
api_submit_scheduling_availability,
@@ -78,6 +83,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
registry.register(get_poll_manifest())
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
configure_runtime(registry=registry)
self.registry = registry
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
@@ -512,6 +518,26 @@ class SchedulingResponseEditingTests(unittest.TestCase):
)
self.assertEqual(tokens, {})
token = self._issue_copy(public_request, public_request.participants[0])
resolver = get_scheduling_manifest().public_tenant_resolver
self.assertIsNotNone(resolver)
public_http_request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(govoplan_registry=self.registry)
),
path_params={
"request_id": public_request.id,
"token": token,
},
url=SimpleNamespace(
path=(
f"/api/v1/scheduling/public/{public_request.id}/{token}"
)
),
)
self.assertEqual(
"tenant-1",
resolver(public_http_request, self.session),
)
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == public_request.participants[0].poll_invitation_id
).one()
@@ -1708,10 +1734,20 @@ class SchedulingResponseEditingTests(unittest.TestCase):
notice_until,
)
with patch.object(
scheduling_service,
"_now",
return_value=cancelled_at + timedelta(days=1),
with (
patch.object(
scheduling_service,
"_now",
return_value=cancelled_at + timedelta(days=1),
),
patch(
"govoplan_poll.backend.service._now",
return_value=cancelled_at + timedelta(days=1),
),
patch(
"govoplan_poll.backend.participation_service._now",
return_value=cancelled_at + timedelta(days=1),
),
):
notice = get_public_scheduling_participation(
self.session,
+28 -1
View File
@@ -17,7 +17,13 @@ from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.registry import PlatformRegistry
from govoplan_access.backend.db.models import Account, User
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarOutboxOperation, CalendarSyncSource
from govoplan_calendar.backend.db.models import (
CalendarCollection,
CalendarEvent,
CalendarMigrationBatch,
CalendarOutboxOperation,
CalendarSyncSource,
)
from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest
from govoplan_poll.backend.db.models import (
Poll,
@@ -93,6 +99,23 @@ from govoplan_scheduling.backend.runtime import configure_runtime
class SchedulingServiceTests(unittest.TestCase):
def setUp(self) -> None:
fixed_now = datetime(2026, 7, 19, 12, tzinfo=timezone.utc)
self.now_patches = (
patch(
"govoplan_scheduling.backend.service._now",
return_value=fixed_now,
),
patch(
"govoplan_poll.backend.service._now",
return_value=fixed_now,
),
patch(
"govoplan_poll.backend.participation_service._now",
return_value=fixed_now,
),
)
for now_patch in self.now_patches:
now_patch.start()
registry = PlatformRegistry()
registry.register(get_poll_manifest())
registry.register(get_calendar_manifest())
@@ -112,6 +135,7 @@ class SchedulingServiceTests(unittest.TestCase):
CalendarEvent.__table__,
CalendarSyncSource.__table__,
CalendarOutboxOperation.__table__,
CalendarMigrationBatch.__table__,
ChangeSequenceEntry.__table__,
Account.__table__,
User.__table__,
@@ -125,6 +149,8 @@ class SchedulingServiceTests(unittest.TestCase):
self.session: Session = self.Session()
def tearDown(self) -> None:
for now_patch in reversed(self.now_patches):
now_patch.stop()
self.session.close()
Base.metadata.drop_all(
self.engine,
@@ -136,6 +162,7 @@ class SchedulingServiceTests(unittest.TestCase):
User.__table__,
Account.__table__,
ChangeSequenceEntry.__table__,
CalendarMigrationBatch.__table__,
CalendarOutboxOperation.__table__,
CalendarSyncSource.__table__,
CalendarEvent.__table__,
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/scheduling-webui",
"version": "0.1.11",
"version": "0.1.18",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -18,14 +18,14 @@
"test:ui-structure": "node scripts/test-scheduling-page-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.11",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@vitejs/plugin-react": "^4.3.4",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
"vite": "^7.3.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -6,10 +6,12 @@ const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPag
const publicPagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPublicPage.tsx", import.meta.url));
const apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url));
const modulePath = fileURLToPath(new URL("../src/module.ts", import.meta.url));
const widgetPath = fileURLToPath(new URL("../src/features/scheduling/SchedulingRequestsWidget.tsx", import.meta.url));
const page = readFileSync(pagePath, "utf8");
const publicPage = readFileSync(publicPagePath, "utf8");
const api = readFileSync(apiPath, "utf8");
const moduleSource = readFileSync(modulePath, "utf8");
const widget = readFileSync(widgetPath, "utf8");
assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calendar\.picker"\)/);
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
@@ -17,6 +19,7 @@ assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && c
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.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, /<aside className="scheduling-request-sidebar">/);
@@ -41,6 +44,11 @@ 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\.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\.participantPrivacy\}>/);
assert.match(editor, /<FormField label=\{I18N\.title\}>/);
@@ -95,10 +103,18 @@ assert.match(participantGrid, /minimumSlots=\{3\}/);
assert.ok(participantGrid.indexOf('id: "copy-invitation"') < participantGrid.indexOf('id: "send-invitation"'));
assert.ok(participantGrid.indexOf('id: "send-invitation"') < participantGrid.indexOf('id: "revoke-invitation"'));
assert.match(participantGrid, /schedulingInvitationActionBlocks\(request, participant, now\)/);
assert.match(participantGrid, /disabledReason: copyDisabledReason/);
assert.match(participantGrid, /disabledReason: deliveryDisabledReason/);
assert.match(participantGrid, /disabledReason: revokeDisabledReason/);
assert.match(participantGrid, /disabledReason: saving \? I18N\.saving : copyDisabledReason/);
assert.match(participantGrid, /disabledReason: saving \? I18N\.saving : deliveryDisabledReason/);
assert.match(participantGrid, /disabledReason: saving \? I18N\.saving : revokeDisabledReason/);
assert.match(page, /<ConfirmDialog[\s\S]*title=\{I18N\.revokeInvitationLabel\}[\s\S]*tone="danger"/);
assert.match(page, /setConsequentialAction\(\{ kind: "close"/);
assert.match(page, /setConsequentialAction\(\{ kind: "reminder"/);
assert.match(page, /setConsequentialAction\(\{ kind: "holds"/);
assert.match(page, /setConsequentialAction\(\{ kind: "final-event"/);
assert.match(page, /setDecisionTarget\(\{ requestId: selected\.id, slot \}\)/);
assert.match(page, /open=\{Boolean\(consequentialAction && consequentialActionCopy\)\}/);
assert.match(page, /open=\{Boolean\(decisionTarget\)\}/);
assert.match(page, /disabledReason=\{saving \? I18N\.saving/);
assert.match(page, /navigator\.clipboard\.writeText\(value\)/);
assert.match(page, /navigator\.clipboard\.write\(\[new ClipboardItem/);
assert.match(page, /schedulingPublicInvitationUrl\(response\.action_url, window\.location\.origin\)/);
@@ -149,7 +165,11 @@ assert.match(page, /Promise\.allSettled/);
assert.match(moduleSource, /publicRoutes:[\s\S]*path: "\/scheduling\/public\/:requestId\/:token"/);
assert.match(moduleSource, /SchedulingPublicPage/);
assert.match(publicPage, /Card,[\s\S]*DismissibleAlert,[\s\S]*FormField,[\s\S]*LoadingFrame,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(publicPage, /Card,[\s\S]*DismissibleAlert,[\s\S]*DocumentationHelpLink,[\s\S]*FormField,[\s\S]*LoadingFrame,[\s\S]*PasswordField,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(publicPage, /<PasswordField[\s\S]*autoComplete="current-password"/);
assert.doesNotMatch(publicPage, /<input[\s\S]{0,120}type="password"/);
assert.match(publicPage, /topicId: "scheduling\.find-and-decide-meeting-time"/);
assert.match(publicPage, /disabledReason=\{saving \? I18N\.saving/);
assert.match(publicPage, /getPublicSchedulingParticipation\(settings, requestId, token, \{\}\)/);
assert.match(publicPage, /applySchedulingAvailabilityChoice\(/);
assert.match(publicPage, /option_revision: slot\.revision/);
@@ -157,4 +177,9 @@ assert.match(publicPage, /idempotency_key: newIdempotencyKey\(\)/);
assert.doesNotMatch(publicPage, /window\.(?:alert|confirm)\(/);
assert.doesNotMatch(publicPage, /(?:localStorage|sessionStorage).*token|token.*(?:localStorage|sessionStorage)/);
assert.match(widget, /DocumentationHelpLink/);
assert.match(widget, /to: `\/scheduling\?request_id=\$\{encodeURIComponent\(request\.id\)\}`/);
assert.match(widget, /label=\{request\.status === "collecting" \? I18N\.open : I18N\.draft\}/);
assert.doesNotMatch(widget, /Loading scheduling requests|Open scheduling|No scheduling requests are awaiting responses/);
console.log("Scheduling pages satisfy the two-pane editor, public response, and policy contracts.");
+225 -29
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import { useSearchParams } from "react-router";
import {
Bell,
CalendarCheck,
@@ -15,6 +15,7 @@ import {
XCircle
} from "lucide-react";
import {
ActionBlockerHint,
Button,
Card,
ConfirmDialog,
@@ -22,6 +23,7 @@ import {
DataGridRowActions,
DateTimeField,
DismissibleAlert,
DocumentationHelpLink,
FormField,
MetricCard,
IconButton,
@@ -34,6 +36,7 @@ import {
TableActionGroup,
ToggleSwitch,
StatusBadge,
StageRail,
hasScope,
i18nMessage,
isApiError,
@@ -84,12 +87,14 @@ import {
participantDraftsFromPicker,
participantPayload,
schedulingInvitationActionBlocks,
schedulingLifecycleStages,
schedulingPublicInvitationUrl,
schedulingRelevantTimestamp,
schedulingRequestIsOwned,
schedulingSortPhase,
type SchedulingActor,
type SchedulingInvitationActionBlock,
type SchedulingLifecycleStageState,
type SchedulingParticipantDraft,
type SchedulingRequestGroups
} from "./schedulingViewModel";
@@ -111,6 +116,14 @@ type InvitationRevokeTarget = {
requestId: string;
participant: SchedulingParticipant;
};
type ConsequentialAction = {
kind: "close" | "reminder" | "holds" | "final-event";
requestId: string;
};
type DecisionTarget = {
requestId: string;
slot: SchedulingCandidateSlot;
};
const I18N = {
actions: "i18n:govoplan-core.actions.c3cd636a",
@@ -128,8 +141,10 @@ const I18N = {
configuredCalendar: "i18n:govoplan-scheduling.configured_calendar.e2e8ebd5",
calendarDescription: "i18n:govoplan-scheduling.use_a_calendar_for_availability_checks_tentative_holds_a.20ccc1fa",
finalEventLabel: "i18n:govoplan-scheduling.create_calendar_event.0b87cfcf",
finalEventEffect: "i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252",
calendarIntegration: "i18n:govoplan-scheduling.calendar_integration.181ad18b",
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e",
calendarRequiredAction: "i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106",
candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5",
candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b",
cancellationNoticeExpired: "i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c",
@@ -137,11 +152,14 @@ const I18N = {
chooseAvailability: "i18n:govoplan-scheduling.choose_availability.ac95b8f6",
clipboardUnavailable: "i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc",
closePoll: "i18n:govoplan-scheduling.close_poll.a6a18916",
closePollEffect: "i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519",
closed: "i18n:govoplan-scheduling.closed.88d86b77",
copyInvitationLink: "i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79",
description: "i18n:govoplan-scheduling.description.55f8ebc8",
determined: "i18n:govoplan-scheduling.determined.9f23293d",
decideUnavailable: "i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d",
decision: "i18n:govoplan-scheduling.decision.7f59a1f1",
decideOn: "i18n:govoplan-scheduling.decide_on_value.196409cd",
discard: "i18n:govoplan-scheduling.discard.36fff63c",
discardConfirm: "i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2",
edit: "i18n:govoplan-scheduling.edit_scheduling_request.7e749c19",
@@ -150,6 +168,7 @@ const I18N = {
generalSettings: "i18n:govoplan-scheduling.participation_settings.8dc6f62c",
free: "i18n:govoplan-scheduling.free.75f52718",
holds: "i18n:govoplan-scheduling.create_tentative_holds.51c4744e",
holdsEffect: "i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884",
invitationDeliveryUnavailable: "i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3",
invitationDeliveryFailed: "i18n:govoplan-scheduling.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306",
invitationDeliveryRequested: "i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba",
@@ -209,12 +228,14 @@ const I18N = {
refresh: "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1",
reloadInvitation: "i18n:govoplan-scheduling.reload_the_request_before_changing_this_invitation.9e685df4",
reminder: "i18n:govoplan-scheduling.send_reminder.cf5eb3bf",
reminderEffect: "i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797",
revokeInvitation: "i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa",
revokeInvitationConfirm: "i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817",
revokeInvitationLabel: "i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf",
revokeLink: "i18n:govoplan-scheduling.revoke_link.da371ee1",
requestFailed: "i18n:govoplan-scheduling.request_failed.9fcda32c",
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",
requests: "i18n:govoplan-scheduling.scheduling_requests.b3c12f4d",
requiresAvailabilityRead: "i18n:govoplan-scheduling.requires_calendar_availability_read_access.b48ed91b",
@@ -236,6 +257,11 @@ const I18N = {
statusLabel: "i18n:govoplan-scheduling.status.bae7d5be",
title: "i18n:govoplan-scheduling.title.768e0c1c",
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",
unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79",
updateResponse: "i18n:govoplan-scheduling.update_response.346233cf",
@@ -288,6 +314,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
const [notificationsUnavailable, setNotificationsUnavailable] = useState(false);
const [detailsLoading, setDetailsLoading] = useState(false);
const [revokeInvitationTarget, setRevokeInvitationTarget] = useState<InvitationRevokeTarget | null>(null);
const [consequentialAction, setConsequentialAction] = useState<ConsequentialAction | null>(null);
const [decisionTarget, setDecisionTarget] = useState<DecisionTarget | null>(null);
const [invitationActionClock, setInvitationActionClock] = useState(() => new Date());
const detailLoadSequence = useRef(0);
@@ -352,6 +380,9 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
() => availabilityComment !== savedAvailabilityComment || !availabilityValuesEqual(availability, savedAvailability),
[availability, availabilityComment, savedAvailability, savedAvailabilityComment]
);
const consequentialActionCopy = consequentialAction
? schedulingActionConfirmation(consequentialAction.kind)
: null;
const editorOriginal = editorMode === "edit"
? requests.find((request) => request.id === editingRequestId) ?? null
: null;
@@ -785,6 +816,31 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
await runParticipantInvitationAction(target.requestId, target.participant, "revoke");
}
async function confirmSchedulingAction() {
if (!consequentialAction || selected?.id !== consequentialAction.requestId) return;
const action = consequentialAction;
setConsequentialAction(null);
if (action.kind === "close") {
await runAction(() => closeSchedulingRequest(settings, action.requestId));
} else if (action.kind === "reminder") {
await runAction(() => createSchedulingNotifications(settings, action.requestId, "reminder"));
} else if (action.kind === "holds") {
await runAction(() => createSchedulingHolds(settings, action.requestId));
} else {
await runAction(() => createSchedulingCalendarEvent(settings, action.requestId));
}
}
async function confirmSchedulingDecision() {
if (!decisionTarget || selected?.id !== decisionTarget.requestId) return;
const target = decisionTarget;
setDecisionTarget(null);
await runAction(() => decideSchedulingRequest(settings, target.requestId, {
slot_id: target.slot.id,
handoff_to_calendar: selected.create_calendar_event_on_decision
}));
}
async function sendAvailability(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await persistAvailability();
@@ -846,9 +902,20 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
label={I18N.refresh}
icon={<RefreshCw aria-hidden="true" size={16} />}
onClick={() => requestNavigation(() => void loadRequests(selected?.id))}
disabled={loading || saving} />
disabled={loading || saving}
disabledReason={saving ? I18N.saving : undefined} />
<DocumentationHelpLink
reference={{
topicId: "scheduling.find-and-decide-meeting-time",
documentationType: "user"
}} />
{canCreateOrWrite ? (
<Button type="button" variant="primary" onClick={beginCreate} disabled={saving}>
<Button
type="button"
variant="primary"
onClick={beginCreate}
disabled={saving}
disabledReason={saving ? I18N.saving : undefined}>
<Plus aria-hidden="true" size={16} /> {I18N.add}
</Button>
) : null}
@@ -896,8 +963,24 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
</div>
</div>
<div className="scheduling-page-actions">
<Button type="button" onClick={discardEditor} disabled={saving}>{I18N.discard}</Button>
<Button type="submit" form="scheduling-editor-form" variant="primary" disabled={saving || !canCreateOrWrite}>
<DocumentationHelpLink
reference={{
topicId: "scheduling.find-and-decide-meeting-time",
documentationType: "user"
}} />
<Button
type="button"
onClick={discardEditor}
disabled={saving}
disabledReason={saving ? I18N.saving : undefined}>
{I18N.discard}
</Button>
<Button
type="submit"
form="scheduling-editor-form"
variant="primary"
disabled={saving || !canCreateOrWrite}
disabledReason={saving ? I18N.saving : !canCreateOrWrite ? I18N.unavailable : undefined}>
<Save aria-hidden="true" size={16} /> {saving ? I18N.saving : I18N.save}
</Button>
</div>
@@ -935,7 +1018,25 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
if (!checked) setCalendarId("");
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 ? (
<CalendarPicker
settings={settings}
@@ -1030,12 +1131,42 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
</div>
) : selected ? (
<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
title={selected.title}
actions={(
<div className="scheduling-actions">
<DocumentationHelpLink
reference={{
topicId: "scheduling.find-and-decide-meeting-time",
documentationType: "user"
}} />
{canEditSelected ? (
<Button type="button" onClick={() => beginEdit(selected)} disabled={saving}>
<Button
type="button"
onClick={() => beginEdit(selected)}
disabled={saving}
disabledReason={saving ? I18N.saving : undefined}>
<Pencil aria-hidden="true" size={16} /> {I18N.edit}
</Button>
) : null}
@@ -1044,8 +1175,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
request={selected}
saving={saving}
onOpen={() => void runAction(() => openSchedulingRequest(settings, selected.id))}
onClose={() => requestNavigation(() => void runAction(() => closeSchedulingRequest(settings, selected.id)))}
onReminder={() => void runAction(() => createSchedulingNotifications(settings, selected.id, "reminder"))} />
onClose={() => requestNavigation(() => setConsequentialAction({ kind: "close", requestId: selected.id }))}
onReminder={() => setConsequentialAction({ kind: "reminder", requestId: selected.id })} />
) : null}
</div>
)}>
@@ -1070,9 +1201,23 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
) : null}
{selected.public_participation_policy_enforcement_available === false ? (
<DismissibleAlert tone="warning" dismissible={false} compact>
{selected.public_participation_policy_enforcement_reason || I18N.publicPolicyUnavailable}
</DismissibleAlert>
<ActionBlockerHint
reason={{
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}
{selectedParticipant && selected.status === "collecting" ? (
@@ -1083,7 +1228,16 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
variant="primary"
type="submit"
form="scheduling-response-form"
disabled={saving || availabilityLoading || !canRespond || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}>
disabled={saving || availabilityLoading || !canRespond || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}
disabledReason={saving
? I18N.saving
: availabilityLoading
? I18N.loading
: !canRespond
? I18N.unavailable
: selectedParticipant.status === "responded" && !selected.allow_participant_updates
? I18N.responseRecorded
: undefined}>
<Send aria-hidden="true" size={16} />
{selectedParticipant.status === "responded" ? I18N.updateResponse : I18N.sendResponse}
</Button>
@@ -1139,10 +1293,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
saving={saving}
allowMaybe={selected.allow_maybe}
maxParticipantsPerOption={selected.max_participants_per_option}
onDecide={(slot) => void runAction(() => decideSchedulingRequest(settings, selected.id, {
slot_id: slot.id,
handoff_to_calendar: selected.create_calendar_event_on_decision
}))} />
onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} />
</Card>
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id) ? (
@@ -1154,7 +1305,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<Button
type="button"
disabled={saving || !canReadAvailability}
disabledReason={!canReadAvailability ? I18N.requiresAvailabilityRead : undefined}
disabledReason={saving ? I18N.saving : !canReadAvailability ? I18N.requiresAvailabilityRead : undefined}
onClick={() => void runAction(() => evaluateSchedulingFreeBusy(settings, selected.id))}>
<RefreshCw aria-hidden="true" size={16} /> {I18N.checkFreeBusy}
</Button>
@@ -1163,8 +1314,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<Button
type="button"
disabled={saving || !canWriteCalendarEvent}
disabledReason={!canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
onClick={() => void runAction(() => createSchedulingHolds(settings, selected.id))}>
disabledReason={saving ? I18N.saving : !canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
onClick={() => setConsequentialAction({ kind: "holds", requestId: selected.id })}>
<Clock aria-hidden="true" size={16} /> {I18N.holds}
</Button>
) : null}
@@ -1172,8 +1323,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<Button
type="button"
disabled={saving || !canWriteCalendarEvent}
disabledReason={!canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
onClick={() => void runAction(() => createSchedulingCalendarEvent(settings, selected.id))}>
disabledReason={saving ? I18N.saving : !canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
onClick={() => setConsequentialAction({ kind: "final-event", requestId: selected.id })}>
<CalendarCheck aria-hidden="true" size={16} /> {I18N.finalEventLabel}
</Button>
) : null}
@@ -1255,6 +1406,25 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
busy={saving}
onCancel={() => setRevokeInvitationTarget(null)}
onConfirm={() => void confirmRevokeInvitation()} />
<ConfirmDialog
open={Boolean(consequentialAction && consequentialActionCopy)}
title={consequentialActionCopy?.title ?? I18N.unavailable}
message={consequentialActionCopy?.message ?? I18N.unavailable}
confirmLabel={consequentialActionCopy?.confirmLabel}
tone={consequentialAction?.kind === "close" ? "danger" : "default"}
busy={saving}
onCancel={() => setConsequentialAction(null)}
onConfirm={() => void confirmSchedulingAction()} />
<ConfirmDialog
open={Boolean(decisionTarget)}
title={I18N.decision}
message={i18nMessage(I18N.decideOn, { value0: decisionTarget?.slot.label ?? "" })}
confirmLabel={decisionTarget
? i18nMessage(I18N.decideOn, { value0: decisionTarget.slot.label })
: I18N.decision}
busy={saving}
onCancel={() => setDecisionTarget(null)}
onConfirm={() => void confirmSchedulingDecision()} />
</main>
);
@@ -1542,6 +1712,23 @@ function ParticipationStats({
);
}
function schedulingActionConfirmation(kind: ConsequentialAction["kind"]): {
title: string;
message: string;
confirmLabel: string;
} {
if (kind === "close") {
return { title: I18N.closePoll, message: I18N.closePollEffect, confirmLabel: I18N.closePoll };
}
if (kind === "reminder") {
return { title: I18N.reminder, message: I18N.reminderEffect, confirmLabel: I18N.reminder };
}
if (kind === "holds") {
return { title: I18N.holds, message: I18N.holdsEffect, confirmLabel: I18N.holds };
}
return { title: I18N.finalEventLabel, message: I18N.finalEventEffect, confirmLabel: I18N.finalEventLabel };
}
function LifecycleActions({
request,
saving,
@@ -1556,13 +1743,13 @@ function LifecycleActions({
onReminder: () => void;
}) {
if (request.status === "draft") {
return <Button type="button" variant="primary" disabled={saving} onClick={onOpen}><Send aria-hidden="true" size={16} /> {I18N.openPoll}</Button>;
return <Button type="button" variant="primary" disabled={saving} disabledReason={saving ? I18N.saving : undefined} onClick={onOpen}><Send aria-hidden="true" size={16} /> {I18N.openPoll}</Button>;
}
if (request.status === "collecting") {
return (
<div className="scheduling-actions">
<Button type="button" disabled={saving} onClick={onReminder}><Bell aria-hidden="true" size={16} /> {I18N.reminder}</Button>
<Button type="button" variant="primary" disabled={saving} onClick={onClose}><XCircle aria-hidden="true" size={16} /> {I18N.closePoll}</Button>
<Button type="button" disabled={saving} disabledReason={saving ? I18N.saving : undefined} onClick={onReminder}><Bell aria-hidden="true" size={16} /> {I18N.reminder}</Button>
<Button type="button" variant="primary" disabled={saving} disabledReason={saving ? I18N.saving : undefined} onClick={onClose}><XCircle aria-hidden="true" size={16} /> {I18N.closePoll}</Button>
</div>
);
}
@@ -1788,7 +1975,7 @@ function CandidateSlotsGrid({
label: i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label }),
icon: <Check aria-hidden="true" size={16} />,
disabled: saving || !decisionEnabled,
disabledReason: !decisionEnabled ? I18N.decideUnavailable : undefined,
disabledReason: saving ? I18N.saving : !decisionEnabled ? I18N.decideUnavailable : undefined,
onClick: () => onDecide(slot)
}]} />
} satisfies DataGridColumn<SchedulingCandidateSlot>] : [])
@@ -1854,7 +2041,7 @@ function ParticipantsGrid({
label: i18nMessage(I18N.copyInvitationLink, { value0: label }),
icon: <Copy aria-hidden="true" size={16} />,
disabled: saving || Boolean(copyDisabledReason),
disabledReason: copyDisabledReason,
disabledReason: saving ? I18N.saving : copyDisabledReason,
onClick: () => onCopy(participant)
},
{
@@ -1862,7 +2049,7 @@ function ParticipantsGrid({
label: i18nMessage(I18N.sendInvitation, { value0: label }),
icon: <Send aria-hidden="true" size={16} />,
disabled: saving || Boolean(deliveryDisabledReason),
disabledReason: deliveryDisabledReason,
disabledReason: saving ? I18N.saving : deliveryDisabledReason,
onClick: () => onSend(participant)
},
{
@@ -1871,7 +2058,7 @@ function ParticipantsGrid({
icon: <Link2Off aria-hidden="true" size={16} />,
variant: "danger",
disabled: saving || Boolean(revokeDisabledReason),
disabledReason: revokeDisabledReason,
disabledReason: saving ? I18N.saving : revokeDisabledReason,
onClick: () => onRevoke(participant)
}
]} />
@@ -1957,6 +2144,15 @@ function firstRequest(groups: SchedulingRequestGroups, includeManaged: boolean):
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 {
const phase = schedulingSortPhase(request, actor);
if (phase === "past") return I18N.past;
@@ -1,11 +1,13 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Link, useParams } from "react-router-dom";
import { Link, useParams } from "react-router";
import {
Button,
Card,
DismissibleAlert,
DocumentationHelpLink,
FormField,
LoadingFrame,
PasswordField,
formatDateTime,
type ApiSettings,
type AuthInfo
@@ -44,6 +46,7 @@ const I18N = {
password: "i18n:govoplan-scheduling.guest_password.94545e82",
response: "i18n:govoplan-scheduling.your_availability.f86c8215",
saved: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d",
saving: "i18n:govoplan-scheduling.saving.56a2285c",
submit: "i18n:govoplan-scheduling.submit_response.a5f0c053",
unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79"
} as const;
@@ -166,7 +169,15 @@ export default function SchedulingPublicPage({ settings, auth }: SchedulingPubli
)}
{!response && !loading && (
<Card title={I18N.accessDetails}>
<Card
title={I18N.accessDetails}
actions={(
<DocumentationHelpLink
reference={{
topicId: "scheduling.find-and-decide-meeting-time",
documentationType: "user"
}} />
)}>
<form className="scheduling-public-access-form" onSubmit={openRequest}>
<p className="muted">{I18N.accessHelp}</p>
{accessAttempted && error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>}
@@ -180,16 +191,20 @@ export default function SchedulingPublicPage({ settings, auth }: SchedulingPubli
/>
</FormField>
<FormField label={I18N.password}>
<input
type="password"
<PasswordField
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
onValueChange={setPassword} />
</FormField>
</div>
<div className="scheduling-public-actions">
<Button type="submit" variant="primary">{I18N.accessRequest}</Button>
<Button
type="submit"
variant="primary"
disabled={loading}
disabledReason={loading ? I18N.loading : undefined}>
{I18N.accessRequest}
</Button>
</div>
</form>
</Card>
@@ -197,7 +212,15 @@ export default function SchedulingPublicPage({ settings, auth }: SchedulingPubli
{response && (
<form className="scheduling-public-content" onSubmit={saveResponse}>
<Card title={response.title}>
<Card
title={response.title}
actions={(
<DocumentationHelpLink
reference={{
topicId: "scheduling.find-and-decide-meeting-time",
documentationType: "user"
}} />
)}>
{response.description && <p className="scheduling-public-description">{response.description}</p>}
<dl className="scheduling-public-summary">
{response.location && <><dt>i18n:govoplan-scheduling.location.d219c681</dt><dd>{response.location}</dd></>}
@@ -263,7 +286,13 @@ export default function SchedulingPublicPage({ settings, auth }: SchedulingPubli
)}
{collecting && (
<div className="scheduling-public-actions">
<Button type="submit" variant="primary" disabled={saving}>{I18N.submit}</Button>
<Button
type="submit"
variant="primary"
disabled={saving}
disabledReason={saving ? I18N.saving : undefined}>
{I18N.submit}
</Button>
</div>
)}
</Card>}
@@ -0,0 +1,138 @@
import { useCallback, type ReactNode } from "react";
import { CalendarClock } from "lucide-react";
import { Link } from "react-router";
import {
DashboardWidgetList,
DismissibleAlert,
DocumentationHelpLink,
LoadingFrame,
StatusBadge,
formatDateTime,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listSchedulingRequests,
type SchedulingRequest
} from "../../api/scheduling";
const I18N = {
candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b",
deadline: "i18n:govoplan-scheduling.response_deadline.7fd9e3aa",
draft: "i18n:govoplan-scheduling.draft.23d33e22",
empty: "i18n:govoplan-scheduling.no_scheduling_request_selected.ac940664",
loading: "i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d",
open: "i18n:govoplan-scheduling.open.cf9b7706",
openScheduling: "i18n:govoplan-scheduling.open_in_scheduling.48df1541",
responded: "i18n:govoplan-scheduling.responded.4f218211"
} as const;
export default function SchedulingRequestsWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const includeDrafts = configuration.includeDrafts === true;
const load = useCallback(async () => {
const response = await listSchedulingRequests(settings);
return response.requests
.filter(
(request) =>
request.status === "collecting"
|| (includeDrafts && request.status === "draft")
)
.sort(compareRequests)
.slice(0, maxItems);
}, [includeDrafts, maxItems, settings]);
const { data: requests, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label={I18N.loading}>
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText={I18N.empty}
items={(requests ?? []).map((request) => ({
id: request.id,
title: request.title,
detail: responseLabel(request),
meta: deadlineLabel(request),
leading: <CalendarClock size={17} aria-hidden="true" />,
trailing: (
<StatusBadge
status={request.status}
label={request.status === "collecting" ? I18N.open : I18N.draft}
/>
),
to: `/scheduling?request_id=${encodeURIComponent(request.id)}`
}))}
/>
<div className="dashboard-contribution-footer">
<DocumentationHelpLink
reference={{
topicId: "scheduling.find-and-decide-meeting-time",
documentationType: "user"
}} />
<Link className="btn btn-secondary" to="/scheduling">
{I18N.openScheduling}
</Link>
</div>
</LoadingFrame>
);
}
function compareRequests(
left: SchedulingRequest,
right: SchedulingRequest
): number {
if (left.status !== right.status) {
return left.status === "collecting" ? -1 : 1;
}
const leftDeadline = left.deadline_at
? new Date(left.deadline_at).getTime()
: Number.POSITIVE_INFINITY;
const rightDeadline = right.deadline_at
? new Date(right.deadline_at).getTime()
: Number.POSITIVE_INFINITY;
return (
leftDeadline - rightDeadline
|| new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime()
);
}
function responseLabel(request: SchedulingRequest): ReactNode {
const responded = request.participant_aggregate.status_counts.responded ?? 0;
return <>{responded}/{request.participant_aggregate.total} {I18N.responded}</>;
}
function deadlineLabel(request: SchedulingRequest): ReactNode {
if (!request.deadline_at) return <>{request.slots.length} {I18N.candidateSlots}</>;
return <>{I18N.deadline}: {formatDateTime(request.deadline_at, {
day: "2-digit",
month: "short"
})}</>;
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}
@@ -35,6 +35,21 @@ export type SchedulingRequestGroups = {
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 =
| "unanswered"
| "answered"
@@ -282,6 +297,37 @@ export function schedulingRequestIsPast(
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(
request: SchedulingRequest,
participant: SchedulingParticipant,
+16
View File
@@ -78,6 +78,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Configured calendar",
"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.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_slots.c414946b": "Candidate slots",
"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_this_unsaved_scheduling_request.4a956be2": "Discard this unsaved scheduling request?",
"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.error.7f2f6a15": "Error",
"i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Every candidate slot must end after it starts.",
@@ -124,6 +126,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Other scheduling requests",
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "Participant email",
"i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a": "Participant names and statuses are hidden. Aggregate counts remain visible.",
"i18n:govoplan-scheduling.participant_removed.0cf4ec4c": "Participant removed",
"i18n:govoplan-scheduling.participant_replaced.2623752d": "Participant replaced",
"i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0": "Share participant names and response statuses",
"i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361": "When enabled, participants can see other participants' names and response statuses. Email addresses and invitation details remain private.",
"i18n:govoplan-scheduling.participants.cd56e083": "Participants",
@@ -131,6 +135,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.pending.96f608c1": "Pending",
"i18n:govoplan-scheduling.queued.6a599877": "Queued",
"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.remove.e963907d": "Remove",
"i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Remove participant {value0}",
@@ -155,6 +160,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.sent.35f49dcf": "Sent",
"i18n:govoplan-scheduling.skipped.5a000ad7": "Skipped",
"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.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.",
@@ -168,6 +174,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.value_participants.e776b092": "{value0} participants",
"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.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.your_response_has_been_recorded.b855088d": "Your response has been recorded."
},
@@ -250,6 +258,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Ausgewählter Kalender",
"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.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_slots.c414946b": "Terminvorschläge",
"i18n:govoplan-scheduling.check_free_busy.e9700e00": "Frei/Belegt prüfen",
@@ -269,6 +278,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.discard.36fff63c": "Verwerfen",
"i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Diese ungespeicherte Terminanfrage verwerfen?",
"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.error.7f2f6a15": "Fehler",
"i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Jeder Terminvorschlag muss nach seinem Beginn enden.",
@@ -296,6 +306,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Andere Terminanfragen",
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "E-Mail der teilnehmenden Person",
"i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a": "Namen und Antwortstatus der Teilnehmenden sind ausgeblendet. Zusammengefasste Anzahlen bleiben sichtbar.",
"i18n:govoplan-scheduling.participant_removed.0cf4ec4c": "Teilnehmende Person entfernt",
"i18n:govoplan-scheduling.participant_replaced.2623752d": "Teilnehmende Person ersetzt",
"i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0": "Namen und Antwortstatus der Teilnehmenden freigeben",
"i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361": "Wenn aktiviert, sehen Teilnehmende die Namen und Antwortstatus anderer Teilnehmender. E-Mail-Adressen und Einladungsdetails bleiben privat.",
"i18n:govoplan-scheduling.participants.cd56e083": "Teilnehmende",
@@ -303,6 +315,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.pending.96f608c1": "Ausstehend",
"i18n:govoplan-scheduling.queued.6a599877": "Eingereiht",
"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.remove.e963907d": "Entfernen",
"i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Teilnehmende Person {value0} entfernen",
@@ -327,6 +340,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.sent.35f49dcf": "Gesendet",
"i18n:govoplan-scheduling.skipped.5a000ad7": "Übersprungen",
"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.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.",
@@ -340,6 +354,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.value_participants.e776b092": "{value0} Teilnehmende",
"i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} Antworten",
"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.your_response_has_been_recorded.b855088d": "Ihre Antwort wurde gespeichert."
}
+62 -2
View File
@@ -1,5 +1,9 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import type {
DashboardWidgetsUiCapability,
PlatformWebModule
} from "@govoplan/core-webui";
import SchedulingRequestsWidget from "./features/scheduling/SchedulingRequestsWidget";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/scheduling.css";
@@ -7,6 +11,50 @@ const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage")
const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage"));
const scheduleRead = ["scheduling:schedule:read"];
const schedulingDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "scheduling.open-requests",
surfaceId: "scheduling.widget.open-requests",
title: "Scheduling requests",
description: "Open scheduling polls and their response progress.",
moduleId: "scheduling",
category: "Planning",
order: 45,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: scheduleRead,
refreshIntervalMs: 60_000,
defaultConfiguration: {
maxItems: 5,
includeDrafts: false
},
configurationFields: [
{
id: "maxItems",
label: "Maximum requests",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
},
{
id: "includeDrafts",
label: "Include drafts",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(SchedulingRequestsWidget, {
settings,
refreshKey,
configuration
})
}
]
};
export const schedulingModule: PlatformWebModule = {
id: "scheduling",
@@ -15,6 +63,15 @@ export const schedulingModule: PlatformWebModule = {
dependencies: ["poll"],
optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
translations: generatedTranslations,
viewSurfaces: [
{
id: "scheduling.widget.open-requests",
moduleId: "scheduling",
kind: "section",
label: "Scheduling requests widget",
order: 45
}
],
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }],
routes: [
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
@@ -25,7 +82,10 @@ export const schedulingModule: PlatformWebModule = {
order: 10,
render: ({ settings, auth }) => createElement(SchedulingPublicPage, { settings, auth })
}
]
],
uiCapabilities: {
"dashboard.widgets": schedulingDashboardWidgets
}
};
export default schedulingModule;
+29
View File
@@ -8,11 +8,40 @@ import {
participantDraftsFromPicker,
participantPayload,
schedulingInvitationActionBlocks,
schedulingLifecycleStages,
schedulingPublicInvitationUrl,
schedulingSortPhase,
type SchedulingActor
} 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", () => {
let sequence = 0;
const selected = participantDraftsFromPicker([