diff --git a/README.md b/README.md index d11c314..98bdfcf 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,9 @@ poll-backed scheduling requests: ## 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 diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..5ad0891 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -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. diff --git a/src/govoplan_scheduling/backend/manifest.py b/src/govoplan_scheduling/backend/manifest.py index 7e69028..fd5d923 100644 --- a/src/govoplan_scheduling/backend/manifest.py +++ b/src/govoplan_scheduling/backend/manifest.py @@ -7,6 +7,7 @@ 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, @@ -108,8 +109,31 @@ DOCUMENTATION = ( 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": "reference"}, + 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", @@ -124,7 +148,10 @@ DOCUMENTATION = ( documentation_types=("admin", "user"), audience=("organizer", "module_admin", "tenant_admin"), related_modules=("calendar", "access", "policy"), - metadata={"kind": "reference", "context_ids": ["scheduling.calendar-integration"]}, + metadata={ + "kind": "reference", + "help_contexts": ["scheduling.calendar-integration", "scheduling.calendar-coordination"], + }, ), DocumentationTopic( id="scheduling.participation-governance", @@ -139,7 +166,13 @@ DOCUMENTATION = ( documentation_types=("admin",), audience=("operator", "module_admin", "tenant_admin"), related_modules=("poll", "policy", "access"), - metadata={"kind": "pattern", "context_ids": ["scheduling.public-participation-blocker"]}, + metadata={ + "kind": "pattern", + "help_contexts": [ + "scheduling.public-participation-blocker", + "scheduling.public-participation", + ], + }, ), ) diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 265932d..bce7ccc 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -52,6 +52,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() diff --git a/tests/test_service.py b/tests/test_service.py index dbc5f47..beaa90d 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -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, @@ -129,6 +135,7 @@ class SchedulingServiceTests(unittest.TestCase): CalendarEvent.__table__, CalendarSyncSource.__table__, CalendarOutboxOperation.__table__, + CalendarMigrationBatch.__table__, ChangeSequenceEntry.__table__, Account.__table__, User.__table__, @@ -155,6 +162,7 @@ class SchedulingServiceTests(unittest.TestCase): User.__table__, Account.__table__, ChangeSequenceEntry.__table__, + CalendarMigrationBatch.__table__, CalendarOutboxOperation.__table__, CalendarSyncSource.__table__, CalendarEvent.__table__, diff --git a/webui/scripts/test-scheduling-page-structure.mjs b/webui/scripts/test-scheduling-page-structure.mjs index 0bd8166..d2a7003 100644 --- a/webui/scripts/test-scheduling-page-structure.mjs +++ b/webui/scripts/test-scheduling-page-structure.mjs @@ -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\("calendar\.picker"\)/); assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/); @@ -101,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, /(null); + const [consequentialAction, setConsequentialAction] = useState(null); + const [decisionTarget, setDecisionTarget] = useState(null); const [invitationActionClock, setInvitationActionClock] = useState(() => new Date()); const detailLoadSequence = useRef(0); @@ -365,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; @@ -798,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) { event.preventDefault(); await persistAvailability(); @@ -859,9 +902,20 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin label={I18N.refresh} icon={