diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..e558883 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,24 @@ +# Calendar interface pattern migration + +Calendar uses the platform workspace pattern without changing ownership of calendar or synchronization state. + +## Surfaces + +- `calendar.page` is the route-level workspace. +- `calendar.page.sidebar` contains calendar visibility and collection actions; `calendar.page.agenda` is its event list. +- `calendar.page.workspace` contains continuous, month, week, workweek, and day views. +- `calendar.event-editor` owns event create, edit, occurrence/series selection, and deletion confirmation. +- `calendar.collection-editor` owns local and external collection configuration. Its `calendar.sync-status`, `calendar.outbox`, and `calendar.migration` children expose synchronization state and recovery. +- `calendar.settings.preferences` and `calendar.widget.upcoming` remain composed Settings and Dashboard surfaces. + +The backend and WebUI manifests publish the same identifiers and parent hierarchy so Views can filter the route and contributed surfaces consistently. + +## Consequences and recovery + +Event and collection drafts use the shared unsaved-change guard. A write that completes but whose refresh fails is not repeated blindly by Calendar synchronization workers; durable outbox and migration state remain the source of recovery evidence. Event deletion uses the shared confirmation dialog. Collection removal and destructive remote moves keep their specialized confirmation because they must expose event counts, transfer choices, authorization text, and evidence. + +Unavailable consequential actions remain visible where possible and explain the missing permission, input, active write, or migration lock. Contextual help resolves through `govoplan-docs` when installed and otherwise uses the hosted documentation fallback. + +## Optional boundaries + +Calendar does not import optional Mail, Campaign, Scheduling, Notifications, Connectors, Audit, or Ops implementations. Integrations continue through declared capabilities, interfaces, and stable references. Local calendars and the Calendar route remain usable without those optional modules. diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index 401a430..01f2b1f 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -545,7 +545,23 @@ manifest = ModuleManifest( documentation_types=("user",), audience=("user", "calendar_manager"), related_modules=("scheduling", "notifications"), - metadata={"kind": "reference"}, + metadata={ + "kind": "reference", + "help_contexts": [ + "calendar.page", + "calendar.page.sidebar", + "calendar.page.agenda", + "calendar.page.workspace", + "calendar.event-editor", + "calendar.settings.preferences", + "calendar.widget.upcoming", + "calendar.state.read-only", + ], + "consequence_classes": { + "save_event": "create or update the authoritative event and queue synchronized writes when required", + "delete_event": "delete the selected occurrence or series and queue synchronized deletion when required", + }, + }, ), DocumentationTopic( id="calendar.external-sources-and-sync", @@ -555,7 +571,22 @@ manifest = ModuleManifest( documentation_types=("admin", "user"), audience=("user", "calendar_manager", "operator"), related_modules=("connectors", "audit", "ops"), - metadata={"kind": "reference"}, + metadata={ + "kind": "reference", + "help_contexts": [ + "calendar.collection-editor", + "calendar.sync-status", + "calendar.migration", + "calendar.state.source-admin-required", + ], + "consequence_classes": { + "change_collection": "change calendar identity, source configuration, credentials, and synchronization policy", + "delete_or_remove_collection": "delete a local calendar or remove an external source after explicit event handling", + "synchronize_source": "read and, where configured, write remote state using bounded synchronization evidence", + "force_full_sync": "re-read the complete remote source and reconcile it against local state", + "execute_remote_move": "copy all destination resources before conditionally deleting source resources", + }, + }, ), DocumentationTopic( id="calendar.campaign-invitations-and-replies", @@ -593,7 +624,16 @@ manifest = ModuleManifest( documentation_types=("admin", "user"), audience=("calendar_manager", "operator", "tenant_admin"), related_modules=("ops", "audit"), - metadata={"kind": "runbook"}, + metadata={ + "kind": "runbook", + "help_contexts": [ + "calendar.outbox", + "calendar.state.outbox-recovery", + ], + "consequence_classes": { + "reconcile_outbox": "compare the latest desired generation with remote state before retry or discard", + }, + }, ), ), capability_factories={ @@ -616,6 +656,84 @@ manifest = ModuleManifest( ), nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),), view_surfaces=( + ViewSurface( + id="calendar.navigation", + module_id="calendar", + kind="navigation", + label="Calendar navigation", + order=10, + ), + ViewSurface( + id="calendar.page", + module_id="calendar", + kind="route", + label="Calendar workspace", + order=20, + ), + ViewSurface( + id="calendar.page.sidebar", + module_id="calendar", + kind="section", + label="Calendar list", + parent_id="calendar.page", + order=10, + ), + ViewSurface( + id="calendar.page.agenda", + module_id="calendar", + kind="section", + label="Calendar agenda", + parent_id="calendar.page.sidebar", + order=20, + ), + ViewSurface( + id="calendar.page.workspace", + module_id="calendar", + kind="section", + label="Calendar view", + parent_id="calendar.page", + order=30, + ), + ViewSurface( + id="calendar.event-editor", + module_id="calendar", + kind="action", + label="Event editor", + parent_id="calendar.page", + order=40, + ), + ViewSurface( + id="calendar.collection-editor", + module_id="calendar", + kind="action", + label="Calendar source editor", + parent_id="calendar.page.sidebar", + order=50, + ), + ViewSurface( + id="calendar.sync-status", + module_id="calendar", + kind="section", + label="Calendar synchronization status", + parent_id="calendar.collection-editor", + order=60, + ), + ViewSurface( + id="calendar.outbox", + module_id="calendar", + kind="action", + label="Outbound calendar changes", + parent_id="calendar.sync-status", + order=70, + ), + ViewSurface( + id="calendar.migration", + module_id="calendar", + kind="action", + label="Remote calendar move", + parent_id="calendar.sync-status", + order=80, + ), ViewSurface( id="calendar.widget.upcoming", module_id="calendar", diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..7994f5d --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + +from govoplan_calendar.backend.manifest import get_manifest + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class CalendarInterfaceDocumentationContractTests(unittest.TestCase): + def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None: + frontend = get_manifest().frontend + self.assertIsNotNone(frontend) + surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr] + expected = { + "calendar.navigation", + "calendar.page", + "calendar.page.sidebar", + "calendar.page.agenda", + "calendar.page.workspace", + "calendar.event-editor", + "calendar.collection-editor", + "calendar.sync-status", + "calendar.outbox", + "calendar.migration", + "calendar.settings.preferences", + "calendar.widget.upcoming", + } + self.assertEqual(expected, set(surfaces)) + self.assertEqual("calendar.page", surfaces["calendar.page.sidebar"].parent_id) + self.assertEqual("calendar.page.sidebar", surfaces["calendar.page.agenda"].parent_id) + self.assertEqual("calendar.page", surfaces["calendar.page.workspace"].parent_id) + self.assertEqual("calendar.page", surfaces["calendar.event-editor"].parent_id) + self.assertEqual("calendar.page.sidebar", surfaces["calendar.collection-editor"].parent_id) + self.assertEqual("calendar.collection-editor", surfaces["calendar.sync-status"].parent_id) + self.assertEqual("calendar.sync-status", surfaces["calendar.outbox"].parent_id) + self.assertEqual("calendar.sync-status", surfaces["calendar.migration"].parent_id) + + def test_help_and_consequence_metadata_remain_published(self) -> None: + topics = {topic.id: topic for topic in get_manifest().documentation} + events = topics["calendar.manage-calendars-and-events"] + sources = topics["calendar.external-sources-and-sync"] + recovery = topics["calendar.outbound-change-recovery"] + + self.assertIn("calendar.event-editor", events.metadata["help_contexts"]) + self.assertIn("save_event", events.metadata["consequence_classes"]) + self.assertIn("delete_event", events.metadata["consequence_classes"]) + self.assertIn("calendar.collection-editor", sources.metadata["help_contexts"]) + self.assertIn("force_full_sync", sources.metadata["consequence_classes"]) + self.assertIn("execute_remote_move", sources.metadata["consequence_classes"]) + self.assertIn("calendar.outbox", recovery.metadata["help_contexts"]) + self.assertIn("reconcile_outbox", recovery.metadata["consequence_classes"]) + + def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None: + event_dialog = (REPO_ROOT / "webui/src/features/calendar/CalendarEventDialog.tsx").read_text(encoding="utf-8") + collection_dialog = (REPO_ROOT / "webui/src/features/calendar/CalendarCollectionDialogs.tsx").read_text(encoding="utf-8") + settings_panel = (REPO_ROOT / "webui/src/features/calendar/CalendarSettingsPanel.tsx").read_text(encoding="utf-8") + + for component in ("ActionBlockerHint", "ConfirmDialog", "DocumentationHelpLink", "useUnsavedDraftGuard"): + self.assertIn(component, event_dialog) + for component in ("ActionBlockerHint", "DocumentationHelpLink", "useUnsavedDraftGuard"): + self.assertIn(component, collection_dialog) + for component in ("DocumentationHelpLink", "useUnsavedDraftGuard"): + self.assertIn(component, settings_panel) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/calendar/CalendarCollectionDialogs.tsx b/webui/src/features/calendar/CalendarCollectionDialogs.tsx index 99be022..33fbba0 100644 --- a/webui/src/features/calendar/CalendarCollectionDialogs.tsx +++ b/webui/src/features/calendar/CalendarCollectionDialogs.tsx @@ -7,9 +7,11 @@ import { } from "react"; import { ArrowRightLeft, ListChecks, RefreshCw, Trash2 } from "lucide-react"; import { + ActionBlockerHint, Button, ColorPickerField, Dialog, + DocumentationHelpLink, PasswordField, SegmentedControl, ToggleSwitch, @@ -41,6 +43,10 @@ import { errorText, normalizeHexColor, } from "./calendarViewModel"; +import { + CALENDAR_I18N, + CALENDAR_SOURCE_DOCUMENTATION, +} from "./interfacePatterns"; export type CalendarSourceMode = "local" | CalendarSyncSourceKind | "open_xchange"; type CalendarSourceSwitchMode = @@ -161,12 +167,18 @@ export function CalendarCollectionDialog({ !isExistingSyncSource && !canEditSource || canEditSource && (!effectiveCollectionUrl || effectiveAuthType === "basic" && !username.trim() || needsSourceSecret)); - const saveDisabled = - saving || - migrationLocked || - !canWrite || - !name.trim() || - sourceDetailsInvalid; + const saveDisabledReason = saving + ? CALENDAR_I18N.saving + : migrationLocked + ? CALENDAR_I18N.migrationLocked + : !canWrite + ? CALENDAR_I18N.calendarWriteRequired + : !name.trim() + ? CALENDAR_I18N.calendarNameRequired + : sourceDetailsInvalid + ? CALENDAR_I18N.sourceDetailsRequired + : undefined; + const saveDisabled = Boolean(saveDisabledReason); const syncing = source ? syncingSourceId === source.id : false; @@ -348,25 +360,41 @@ export function CalendarCollectionDialog({ <>