Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81483f004 | ||
|
|
3623078235 | ||
|
|
0047ae56dc | ||
|
|
a9e43321ed | ||
|
|
9fd2ccd0a5 | ||
|
|
22555f2603 | ||
|
|
c17cbdae63 | ||
|
|
3d8272b28e | ||
|
|
3e9a14a970 | ||
|
|
480ccdb3b6 | ||
|
|
d4cbf7e4d5 | ||
|
|
1f2001daf8 | ||
|
|
9b029c8edf | ||
|
|
b9f557e95b | ||
|
|
60b01e5a16 |
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-scheduling"
|
||||
version = "0.1.11"
|
||||
version = "0.1.16"
|
||||
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.16",
|
||||
"govoplan-poll>=0.1.16",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.11"
|
||||
__version__ = "0.1.16"
|
||||
|
||||
@@ -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.16"
|
||||
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",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/scheduling-webui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.16",
|
||||
"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.16",
|
||||
"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.");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user