diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..b3a6aef --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,35 @@ +# Addresses Interface Pattern Migration + +This migration applies the GovOPlaN interface pattern language to the Address +Book route, source tree, contact directory/detail workspace, governance facts, +imports, synchronization, quality review, and reversible merge operations. + +## Surface Inventory + +| Surface | Archetype | Consequence class | Contract | +| --- | --- | --- | --- | +| `/address-book` source tree | Governed directory | Select, create, archive, restore, import, or connect source | Shared loading/empty/error, permission, read-only, help, and destructive confirmation states | +| Contact list/detail | Directory/list-detail | Inspect, create, revise, archive, restore, export, or add to list | Server pagination, stable selection, contextual contact semantics, and guarded drafts | +| Address-list editor/membership | Reference collection editor | Group reusable contact points | Same-book constraint, duplicate explanation, read-only provenance, and retained references | +| Communication governance | Effective-policy fact editor | Allow, suppress, prefer, or end a channel fact | Effective dates, legal/evidence context, required permissions, and retained history | +| CardDAV/LDAP/import/sync | External-provider operation | Preview and apply bounded external change | Explicit authority/direction, credentials, diagnostics, conflicts, stale state, and outcome evidence | +| Quality and merge | Governed correction workflow | Record quality, merge, undo, or split | Required reason, chosen survivor/field provenance, confirmation, redirects, and reversible evidence | + +## Consequence And Availability Rules + +- User, group, tenant, and authorized system scopes determine discovery and + management authority. Visible inherited or external sources can be read-only. +- Address lists group contact points in one address book. Cross-module reusable + recipient expansion remains owned by Distribution Lists. +- Archive/delete actions are confirmed and preserve governed history according + to retention. External-source disconnect retains local contacts but removes + source diagnostics and conflict state as explicitly stated. +- Imports and synchronization separate preview from apply; incomplete external + reads never infer deletions. +- Merge and communication-governance operations append auditable evidence and + never silently erase prior state. + +Backend and WebUI manifests publish matching route/section/action surfaces. +English and German catalogues include the owned interaction vocabulary; major +object drafts are guarded, and optional modules remain behind declared +capabilities rather than private imports. diff --git a/src/govoplan_addresses/backend/manifest.py b/src/govoplan_addresses/backend/manifest.py index 913a2ad..6f4f579 100644 --- a/src/govoplan_addresses/backend/manifest.py +++ b/src/govoplan_addresses/backend/manifest.py @@ -35,6 +35,7 @@ from govoplan_core.core.provider_governance import ( ProviderObjectDeclaration, declared_module_architecture, ) +from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base from govoplan_addresses.backend.provider_state import ( CARDDAV_PROVIDER_ID, @@ -312,6 +313,14 @@ manifest = ModuleManifest( package_name="@govoplan/addresses-webui", routes=(FrontendRoute(path="/address-book", component="AddressBookPage", required_any=("addresses:contact:read",), order=80),), nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),), + view_surfaces=( + ViewSurface(id="addresses.page", module_id="addresses", kind="route", label="Address Book", order=80), + ViewSurface(id="addresses.sources", module_id="addresses", kind="section", label="Address sources", order=10), + ViewSurface(id="addresses.contacts", module_id="addresses", kind="section", label="Contacts", order=20), + ViewSurface(id="addresses.detail", module_id="addresses", kind="section", label="Contact detail", order=30), + ViewSurface(id="addresses.governance", module_id="addresses", kind="action", label="Communication governance", order=40), + ViewSurface(id="addresses.sync", module_id="addresses", kind="action", label="Address synchronization", order=50), + ), ), migration_spec=MigrationSpec( module_id="addresses", @@ -380,6 +389,16 @@ manifest = ModuleManifest( audience=("tenant_admin", "operator", "module_admin"), related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"), order=30, + metadata={ + "seed": True, + "help_contexts": [ + "addresses.page", + "addresses.sources", + "addresses.contacts", + "addresses.detail", + "addresses.state.read-only", + ], + }, ), DocumentationTopic( id="addresses.contact-point-resolution", @@ -396,6 +415,16 @@ manifest = ModuleManifest( audience=("tenant_admin", "operator", "module_admin"), related_modules=("dist_lists", "campaigns", "policy", "templates"), order=31, + metadata={ + "seed": True, + "help_contexts": [ + "addresses.governance", + "addresses.field.channel", + "addresses.field.contact-point", + "addresses.field.communication-purpose", + "addresses.field.effective-period", + ], + }, ), DocumentationTopic( id="addresses.tabular-imports", @@ -450,6 +479,44 @@ manifest = ModuleManifest( related_modules=("campaigns", "dist_lists", "policy", "audit"), order=32, ), + DocumentationTopic( + id="addresses.reference.fields-and-consequences", + title="Address fields, scope, and action consequences", + summary="Scope, source authority, contact points, list membership, archival, synchronization, and merge consequences.", + body=( + "Address books are scoped to a user, group, tenant, or authorized system context. Inherited and externally authoritative " + "books may remain visible but read-only. Contacts own reusable name, organization, electronic, phone, postal, tag, note, " + "quality, and provenance facts; address lists reference contact points from the same book and do not replace Distribution " + "Lists. Archival hides a book, list, or contact from ordinary selection while preserving governed history and references. " + "CardDAV and LDAP sources expose their direction, authority, freshness, diagnostics, conflict, and stale-state behavior. " + "Imports and synchronization require preview before mutation. Contact merges select a survivor and field provenance, repair " + "list references transactionally, and retain redirects and evidence so a matching merge can be undone or split." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("tenant_admin", "operator", "module_admin", "power_user"), + related_modules=("dist_lists", "connectors", "datasources", "campaigns", "policy", "audit"), + order=35, + metadata={ + "seed": True, + "help_contexts": [ + "addresses.field.book-scope", + "addresses.field.contact-identity", + "addresses.field.organization", + "addresses.field.contact-point", + "addresses.action.archive", + "addresses.action.import", + "addresses.action.sync", + "addresses.action.merge", + ], + "consequence_classes": { + "archive": "Removes the object from ordinary selection while retaining governed history and references.", + "import_or_sync": "Applies only a reviewed bounded plan and retains source revision, diagnostics, and provenance.", + "merge": "Repoints governed references to a survivor and retains reversible redirect and provenance evidence.", + "governance_fact": "Adds or ends an effective-dated communication decision without erasing prior facts.", + }, + }, + ), ), external_providers=(CARDDAV_PROVIDER, LDAP_PROVIDER), external_provider_state_providers=( diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..886161f --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import unittest + +from govoplan_addresses.backend.manifest import manifest + + +class AddressesInterfaceDocumentationContractTests(unittest.TestCase): + def test_route_and_surfaces_remain_declared(self) -> None: + frontend = manifest.frontend + self.assertIsNotNone(frontend) + self.assertEqual({"/address-book"}, {item.path for item in frontend.routes}) # type: ignore[union-attr] + self.assertEqual( + { + "addresses.page", + "addresses.sources", + "addresses.contacts", + "addresses.detail", + "addresses.governance", + "addresses.sync", + }, + {item.id for item in frontend.view_surfaces}, # type: ignore[union-attr] + ) + + def test_help_and_consequence_metadata_remain_published(self) -> None: + topics = {topic.id: topic for topic in manifest.documentation} + boundary = topics["addresses.boundary"] + governance = topics["addresses.contact-point-resolution"] + reference = topics["addresses.reference.fields-and-consequences"] + + self.assertIn("addresses.state.read-only", boundary.metadata["help_contexts"]) + self.assertIn("addresses.field.communication-purpose", governance.metadata["help_contexts"]) + self.assertIn("addresses.action.sync", reference.metadata["help_contexts"]) + self.assertIn("merge", reference.metadata["consequence_classes"]) + self.assertIn("governance_fact", reference.metadata["consequence_classes"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/addressbook/AddressBookPage.tsx b/webui/src/features/addressbook/AddressBookPage.tsx index b73e7ea..7b2e8a0 100644 --- a/webui/src/features/addressbook/AddressBookPage.tsx +++ b/webui/src/features/addressbook/AddressBookPage.tsx @@ -2,10 +2,12 @@ import { Download, Edit3, GitMerge, History, Link2, Network, Plus, RefreshCw, Ro import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react"; import { ApiError, + ActionBlockerHint, Button, ConfirmDialog, DataGridPaginationBar, Dialog, + DocumentationHelpLink, DismissibleAlert, ExplorerTree, fetchAuthGroups, @@ -20,6 +22,8 @@ import { StatusBadge, ToggleSwitch, hasScope, + useUnsavedChanges, + useUnsavedDraftGuard, type ApiSettings, type AuthInfo, type AuthUpdate, @@ -102,6 +106,12 @@ import { type ContactPointQualityDecision, type ContactPointQualityState } from "../../api/addresses"; +import { + ADDRESS_FIELDS_DOCUMENTATION, + ADDRESS_GOVERNANCE_DOCUMENTATION, + ADDRESSES_DOCUMENTATION, + ADDRESSES_I18N +} from "./interfacePatterns"; type Props = { settings: ApiSettings; @@ -905,6 +915,10 @@ function disabledReason(...conditions: Array<[boolean, string]>): string { return conditions.find(([applies]) => applies)?.[1] ?? ""; } +function formKey(value: unknown): string { + return JSON.stringify(value); +} + export default function AddressBookPage({ settings, auth, onAuthChange }: Props) { const [books, setBooks] = useState([]); const [addressLists, setAddressLists] = useState([]); @@ -926,10 +940,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) const [notice, setNotice] = useState(""); const [bookDialog, setBookDialog] = useState(null); const [bookForm, setBookForm] = useState(EMPTY_BOOK_FORM); + const [bookFormBaseline, setBookFormBaseline] = useState(""); const [listDialog, setListDialog] = useState(null); const [listForm, setListForm] = useState(EMPTY_LIST_FORM); + const [listFormBaseline, setListFormBaseline] = useState(""); const [contactDialog, setContactDialog] = useState(null); const [contactForm, setContactForm] = useState(EMPTY_CONTACT_FORM); + const [contactFormBaseline, setContactFormBaseline] = useState(""); const [governanceContact, setGovernanceContact] = useState(null); const [channelRules, setChannelRules] = useState([]); const [channelRuleForm, setChannelRuleForm] = useState(EMPTY_CHANNEL_RULE_FORM); @@ -977,6 +994,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) const [conflictDialog, setConflictDialog] = useState(null); const [conflictMergeChoices, setConflictMergeChoices] = useState>({}); const [confirmState, setConfirmState] = useState(null); + const { requestDiscard } = useUnsavedChanges(); const canWriteBooks = hasScope(auth, "addresses:address_book:write"); const canDeleteBooks = hasScope(auth, "addresses:address_book:delete"); @@ -989,6 +1007,33 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) const canWriteGovernance = hasScope(auth, "addresses:governance:write"); const canReadSync = hasScope(auth, "addresses:sync:read"); const canWriteSync = hasScope(auth, "addresses:sync:write"); + const bookDraftDirty = Boolean(bookDialog && formKey(bookForm) !== bookFormBaseline); + const listDraftDirty = Boolean(listDialog && formKey(listForm) !== listFormBaseline); + const contactDraftDirty = Boolean(contactDialog && formKey(contactForm) !== contactFormBaseline); + + useUnsavedDraftGuard({ + dirty: bookDraftDirty, + onSave: () => submitBook(), + onDiscard: () => setBookDialog(null), + title: "i18n:govoplan-addresses.unsaved_book_title", + message: "i18n:govoplan-addresses.unsaved_message" + }); + + useUnsavedDraftGuard({ + dirty: listDraftDirty, + onSave: () => submitList(), + onDiscard: () => setListDialog(null), + title: "i18n:govoplan-addresses.unsaved_list_title", + message: "i18n:govoplan-addresses.unsaved_message" + }); + + useUnsavedDraftGuard({ + dirty: contactDraftDirty, + onSave: () => submitContact(), + onDiscard: () => setContactDialog(null), + title: "i18n:govoplan-addresses.unsaved_contact_title", + message: "i18n:govoplan-addresses.unsaved_message" + }); useEffect(() => { if ((!cardDavOpen && !ldapOpen) || !canWriteSync) { @@ -1478,38 +1523,67 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) }, [selectedContactId, visibleContacts]); function openCreateBookDialog() { - setBookForm({ + const next = { ...EMPTY_BOOK_FORM, group_id: auth.groups[0]?.id ?? "" - }); + }; + setBookForm(next); + setBookFormBaseline(formKey(next)); setBookDialog({ mode: "create" }); } function openEditBookDialog(book: AddressBook) { - setBookForm(bookFormFromBook(book)); + const next = bookFormFromBook(book); + setBookForm(next); + setBookFormBaseline(formKey(next)); setBookDialog({ mode: "edit", book }); } function openCreateListDialog() { setListForm(EMPTY_LIST_FORM); + setListFormBaseline(formKey(EMPTY_LIST_FORM)); setListDialog({ mode: "create" }); } function openEditListDialog(list: AddressList) { - setListForm(listFormFromList(list)); + const next = listFormFromList(list); + setListForm(next); + setListFormBaseline(formKey(next)); setListDialog({ mode: "edit", list }); } function openCreateContactDialog() { - setContactForm(emptyContactForm()); + const next = emptyContactForm(); + setContactForm(next); + setContactFormBaseline(formKey(next)); setContactDialog({ mode: "create" }); } function openEditContactDialog(contact: Contact) { - setContactForm(contactFormFromContact(contact)); + const next = contactFormFromContact(contact); + setContactForm(next); + setContactFormBaseline(formKey(next)); setContactDialog({ mode: "edit", contact }); } + function closeBookDialog() { + if (saving) return; + if (bookDraftDirty) requestDiscard(() => setBookDialog(null)); + else setBookDialog(null); + } + + function closeListDialog() { + if (saving) return; + if (listDraftDirty) requestDiscard(() => setListDialog(null)); + else setListDialog(null); + } + + function closeContactDialog() { + if (saving) return; + if (contactDraftDirty) requestDiscard(() => setContactDialog(null)); + else setContactDialog(null); + } + async function openGovernanceDialog(contact: Contact) { setGovernanceContact(contact); setChannelRuleForm(EMPTY_CHANNEL_RULE_FORM); @@ -1820,8 +1894,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) }); } - async function submitBook(event: FormEvent) { - event.preventDefault(); + async function submitBook(event?: FormEvent): Promise { + event?.preventDefault(); + if (bookSaveReason) return false; setSaving(true); setError(""); setNotice(""); @@ -1838,16 +1913,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) } setBookDialog(null); await refreshAll(); + return true; } catch (err) { setError(errorMessage(err)); + return false; } finally { setSaving(false); } } - async function submitList(event: FormEvent) { - event.preventDefault(); - if (!selectedBook) return; + async function submitList(event?: FormEvent): Promise { + event?.preventDefault(); + if (!selectedBook || listSaveReason) return false; setSaving(true); setError(""); setNotice(""); @@ -1874,16 +1951,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) }); await refreshBooks(); await refreshListEntries(savedList.id); + return true; } catch (err) { setError(errorMessage(err)); + return false; } finally { setSaving(false); } } - async function submitContact(event: FormEvent) { - event.preventDefault(); - if (!selectedBook) return; + async function submitContact(event?: FormEvent): Promise { + event?.preventDefault(); + if (!selectedBook || contactSaveReason) return false; setSaving(true); setError(""); setNotice(""); @@ -1927,8 +2006,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) setSelectedContactId(savedContact.id); await refreshBooks(); await refreshContacts(selectedBook.id, query); + return true; } catch (err) { setError(errorMessage(err)); + return false; } finally { setSaving(false); } @@ -2899,6 +2980,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{error && {error}} {notice && !error && {notice}} + {!canWriteBooks && !canWriteLists && !canWriteContacts && }
@@ -2909,6 +3002,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)

{books.length} book{books.length === 1 ? "" : "s"}

+ {renderSelectedBookActions()}
@@ -3008,18 +3102,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) setBookDialog(null)} + onClose={closeBookDialog} closeDisabled={saving} footerClassName="button-row compact-actions" footer={ <> - + }>
void submitBook(event)}>
- + {bookForm.scope_type === "group" && - + setBookForm((current) => ({ ...current, name: event.target.value }))} autoFocus /> + setBookForm((current) => ({ ...current, name: event.target.value }))} autoFocus />