Migrate Addresses interface patterns

This commit is contained in:
2026-08-03 13:40:34 +02:00
parent 2c421022d4
commit f9a7185ce3
7 changed files with 384 additions and 35 deletions
+35
View File
@@ -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.
@@ -35,6 +35,7 @@ from govoplan_core.core.provider_governance import (
ProviderObjectDeclaration, ProviderObjectDeclaration,
declared_module_architecture, declared_module_architecture,
) )
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_addresses.backend.provider_state import ( from govoplan_addresses.backend.provider_state import (
CARDDAV_PROVIDER_ID, CARDDAV_PROVIDER_ID,
@@ -312,6 +313,14 @@ manifest = ModuleManifest(
package_name="@govoplan/addresses-webui", package_name="@govoplan/addresses-webui",
routes=(FrontendRoute(path="/address-book", component="AddressBookPage", required_any=("addresses:contact:read",), order=80),), 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),), 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( migration_spec=MigrationSpec(
module_id="addresses", module_id="addresses",
@@ -380,6 +389,16 @@ manifest = ModuleManifest(
audience=("tenant_admin", "operator", "module_admin"), audience=("tenant_admin", "operator", "module_admin"),
related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"), related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
order=30, order=30,
metadata={
"seed": True,
"help_contexts": [
"addresses.page",
"addresses.sources",
"addresses.contacts",
"addresses.detail",
"addresses.state.read-only",
],
},
), ),
DocumentationTopic( DocumentationTopic(
id="addresses.contact-point-resolution", id="addresses.contact-point-resolution",
@@ -396,6 +415,16 @@ manifest = ModuleManifest(
audience=("tenant_admin", "operator", "module_admin"), audience=("tenant_admin", "operator", "module_admin"),
related_modules=("dist_lists", "campaigns", "policy", "templates"), related_modules=("dist_lists", "campaigns", "policy", "templates"),
order=31, 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( DocumentationTopic(
id="addresses.tabular-imports", id="addresses.tabular-imports",
@@ -450,6 +479,44 @@ manifest = ModuleManifest(
related_modules=("campaigns", "dist_lists", "policy", "audit"), related_modules=("campaigns", "dist_lists", "policy", "audit"),
order=32, 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_providers=(CARDDAV_PROVIDER, LDAP_PROVIDER),
external_provider_state_providers=( external_provider_state_providers=(
@@ -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()
@@ -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 { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import { import {
ApiError, ApiError,
ActionBlockerHint,
Button, Button,
ConfirmDialog, ConfirmDialog,
DataGridPaginationBar, DataGridPaginationBar,
Dialog, Dialog,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
ExplorerTree, ExplorerTree,
fetchAuthGroups, fetchAuthGroups,
@@ -20,6 +22,8 @@ import {
StatusBadge, StatusBadge,
ToggleSwitch, ToggleSwitch,
hasScope, hasScope,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings, type ApiSettings,
type AuthInfo, type AuthInfo,
type AuthUpdate, type AuthUpdate,
@@ -102,6 +106,12 @@ import {
type ContactPointQualityDecision, type ContactPointQualityDecision,
type ContactPointQualityState type ContactPointQualityState
} from "../../api/addresses"; } from "../../api/addresses";
import {
ADDRESS_FIELDS_DOCUMENTATION,
ADDRESS_GOVERNANCE_DOCUMENTATION,
ADDRESSES_DOCUMENTATION,
ADDRESSES_I18N
} from "./interfacePatterns";
type Props = { type Props = {
settings: ApiSettings; settings: ApiSettings;
@@ -905,6 +915,10 @@ function disabledReason(...conditions: Array<[boolean, string]>): string {
return conditions.find(([applies]) => applies)?.[1] ?? ""; return conditions.find(([applies]) => applies)?.[1] ?? "";
} }
function formKey(value: unknown): string {
return JSON.stringify(value);
}
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) { export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
const [books, setBooks] = useState<AddressBook[]>([]); const [books, setBooks] = useState<AddressBook[]>([]);
const [addressLists, setAddressLists] = useState<AddressList[]>([]); const [addressLists, setAddressLists] = useState<AddressList[]>([]);
@@ -926,10 +940,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [bookDialog, setBookDialog] = useState<BookDialogState | null>(null); const [bookDialog, setBookDialog] = useState<BookDialogState | null>(null);
const [bookForm, setBookForm] = useState<BookFormState>(EMPTY_BOOK_FORM); const [bookForm, setBookForm] = useState<BookFormState>(EMPTY_BOOK_FORM);
const [bookFormBaseline, setBookFormBaseline] = useState("");
const [listDialog, setListDialog] = useState<ListDialogState | null>(null); const [listDialog, setListDialog] = useState<ListDialogState | null>(null);
const [listForm, setListForm] = useState<ListFormState>(EMPTY_LIST_FORM); const [listForm, setListForm] = useState<ListFormState>(EMPTY_LIST_FORM);
const [listFormBaseline, setListFormBaseline] = useState("");
const [contactDialog, setContactDialog] = useState<ContactDialogState | null>(null); const [contactDialog, setContactDialog] = useState<ContactDialogState | null>(null);
const [contactForm, setContactForm] = useState<ContactFormState>(EMPTY_CONTACT_FORM); const [contactForm, setContactForm] = useState<ContactFormState>(EMPTY_CONTACT_FORM);
const [contactFormBaseline, setContactFormBaseline] = useState("");
const [governanceContact, setGovernanceContact] = useState<Contact | null>(null); const [governanceContact, setGovernanceContact] = useState<Contact | null>(null);
const [channelRules, setChannelRules] = useState<ContactChannelRule[]>([]); const [channelRules, setChannelRules] = useState<ContactChannelRule[]>([]);
const [channelRuleForm, setChannelRuleForm] = useState<ChannelRuleFormState>(EMPTY_CHANNEL_RULE_FORM); const [channelRuleForm, setChannelRuleForm] = useState<ChannelRuleFormState>(EMPTY_CHANNEL_RULE_FORM);
@@ -977,6 +994,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [conflictDialog, setConflictDialog] = useState<AddressSyncConflict | null>(null); const [conflictDialog, setConflictDialog] = useState<AddressSyncConflict | null>(null);
const [conflictMergeChoices, setConflictMergeChoices] = useState<Record<string, ConflictMergeChoice>>({}); const [conflictMergeChoices, setConflictMergeChoices] = useState<Record<string, ConflictMergeChoice>>({});
const [confirmState, setConfirmState] = useState<ConfirmState>(null); const [confirmState, setConfirmState] = useState<ConfirmState>(null);
const { requestDiscard } = useUnsavedChanges();
const canWriteBooks = hasScope(auth, "addresses:address_book:write"); const canWriteBooks = hasScope(auth, "addresses:address_book:write");
const canDeleteBooks = hasScope(auth, "addresses:address_book:delete"); 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 canWriteGovernance = hasScope(auth, "addresses:governance:write");
const canReadSync = hasScope(auth, "addresses:sync:read"); const canReadSync = hasScope(auth, "addresses:sync:read");
const canWriteSync = hasScope(auth, "addresses:sync:write"); 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(() => { useEffect(() => {
if ((!cardDavOpen && !ldapOpen) || !canWriteSync) { if ((!cardDavOpen && !ldapOpen) || !canWriteSync) {
@@ -1478,38 +1523,67 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}, [selectedContactId, visibleContacts]); }, [selectedContactId, visibleContacts]);
function openCreateBookDialog() { function openCreateBookDialog() {
setBookForm({ const next = {
...EMPTY_BOOK_FORM, ...EMPTY_BOOK_FORM,
group_id: auth.groups[0]?.id ?? "" group_id: auth.groups[0]?.id ?? ""
}); };
setBookForm(next);
setBookFormBaseline(formKey(next));
setBookDialog({ mode: "create" }); setBookDialog({ mode: "create" });
} }
function openEditBookDialog(book: AddressBook) { function openEditBookDialog(book: AddressBook) {
setBookForm(bookFormFromBook(book)); const next = bookFormFromBook(book);
setBookForm(next);
setBookFormBaseline(formKey(next));
setBookDialog({ mode: "edit", book }); setBookDialog({ mode: "edit", book });
} }
function openCreateListDialog() { function openCreateListDialog() {
setListForm(EMPTY_LIST_FORM); setListForm(EMPTY_LIST_FORM);
setListFormBaseline(formKey(EMPTY_LIST_FORM));
setListDialog({ mode: "create" }); setListDialog({ mode: "create" });
} }
function openEditListDialog(list: AddressList) { function openEditListDialog(list: AddressList) {
setListForm(listFormFromList(list)); const next = listFormFromList(list);
setListForm(next);
setListFormBaseline(formKey(next));
setListDialog({ mode: "edit", list }); setListDialog({ mode: "edit", list });
} }
function openCreateContactDialog() { function openCreateContactDialog() {
setContactForm(emptyContactForm()); const next = emptyContactForm();
setContactForm(next);
setContactFormBaseline(formKey(next));
setContactDialog({ mode: "create" }); setContactDialog({ mode: "create" });
} }
function openEditContactDialog(contact: Contact) { function openEditContactDialog(contact: Contact) {
setContactForm(contactFormFromContact(contact)); const next = contactFormFromContact(contact);
setContactForm(next);
setContactFormBaseline(formKey(next));
setContactDialog({ mode: "edit", contact }); 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) { async function openGovernanceDialog(contact: Contact) {
setGovernanceContact(contact); setGovernanceContact(contact);
setChannelRuleForm(EMPTY_CHANNEL_RULE_FORM); setChannelRuleForm(EMPTY_CHANNEL_RULE_FORM);
@@ -1820,8 +1894,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}); });
} }
async function submitBook(event: FormEvent<HTMLFormElement>) { async function submitBook(event?: FormEvent<HTMLFormElement>): Promise<boolean> {
event.preventDefault(); event?.preventDefault();
if (bookSaveReason) return false;
setSaving(true); setSaving(true);
setError(""); setError("");
setNotice(""); setNotice("");
@@ -1838,16 +1913,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
} }
setBookDialog(null); setBookDialog(null);
await refreshAll(); await refreshAll();
return true;
} catch (err) { } catch (err) {
setError(errorMessage(err)); setError(errorMessage(err));
return false;
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
async function submitList(event: FormEvent<HTMLFormElement>) { async function submitList(event?: FormEvent<HTMLFormElement>): Promise<boolean> {
event.preventDefault(); event?.preventDefault();
if (!selectedBook) return; if (!selectedBook || listSaveReason) return false;
setSaving(true); setSaving(true);
setError(""); setError("");
setNotice(""); setNotice("");
@@ -1874,16 +1951,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}); });
await refreshBooks(); await refreshBooks();
await refreshListEntries(savedList.id); await refreshListEntries(savedList.id);
return true;
} catch (err) { } catch (err) {
setError(errorMessage(err)); setError(errorMessage(err));
return false;
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
async function submitContact(event: FormEvent<HTMLFormElement>) { async function submitContact(event?: FormEvent<HTMLFormElement>): Promise<boolean> {
event.preventDefault(); event?.preventDefault();
if (!selectedBook) return; if (!selectedBook || contactSaveReason) return false;
setSaving(true); setSaving(true);
setError(""); setError("");
setNotice(""); setNotice("");
@@ -1927,8 +2006,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
setSelectedContactId(savedContact.id); setSelectedContactId(savedContact.id);
await refreshBooks(); await refreshBooks();
await refreshContacts(selectedBook.id, query); await refreshContacts(selectedBook.id, query);
return true;
} catch (err) { } catch (err) {
setError(errorMessage(err)); setError(errorMessage(err));
return false;
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -2899,6 +2980,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen"> <div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>} {notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
{!canWriteBooks && !canWriteLists && !canWriteContacts && <ActionBlockerHint
tone="info"
reason={{
summary: "Addresses are read-only",
details: ADDRESSES_I18N.permissionDetails,
requiredAction: ADDRESSES_I18N.permissionAction,
actor: ADDRESSES_I18N.permissionActor,
target: ADDRESSES_I18N.permissionDestination
}}
labels={{ requiredAction: ADDRESSES_I18N.requiredAction, actor: ADDRESSES_I18N.actor, target: ADDRESSES_I18N.destination }}
documentation={ADDRESSES_DOCUMENTATION}
/>}
<LoadingFrame loading={loading} label="Loading address books..." className="address-workspace-frame"> <LoadingFrame loading={loading} label="Loading address books..." className="address-workspace-frame">
<div className="address-book-workspace"> <div className="address-book-workspace">
@@ -2909,6 +3002,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<p>{books.length} book{books.length === 1 ? "" : "s"}</p> <p>{books.length} book{books.length === 1 ? "" : "s"}</p>
</div> </div>
<div className="button-row compact-actions address-icon-actions"> <div className="button-row compact-actions address-icon-actions">
<DocumentationHelpLink reference={ADDRESSES_DOCUMENTATION} />
{renderSelectedBookActions()} {renderSelectedBookActions()}
</div> </div>
</header> </header>
@@ -3008,18 +3102,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Dialog <Dialog
open={Boolean(bookDialog)} open={Boolean(bookDialog)}
title={bookDialog?.mode === "edit" ? "Edit address book" : "Add address book"} title={bookDialog?.mode === "edit" ? "Edit address book" : "Add address book"}
onClose={() => setBookDialog(null)} onClose={closeBookDialog}
closeDisabled={saving} closeDisabled={saving}
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<Button type="button" onClick={() => setBookDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button> <Button type="button" onClick={closeBookDialog} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</Button> <Button type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</Button>
</> </>
}> }>
<form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}> <form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
<div className="form-grid two"> <div className="form-grid two">
<FormField label="Scope"> <FormField label="Scope" documentation={ADDRESS_FIELDS_DOCUMENTATION}>
<select value={bookForm.scope_type} disabled={bookDialog?.mode === "edit"} onChange={(event) => setBookForm((current) => ({ ...current, scope_type: event.target.value as AddressBookScope }))}> <select value={bookForm.scope_type} disabled={bookDialog?.mode === "edit"} onChange={(event) => setBookForm((current) => ({ ...current, scope_type: event.target.value as AddressBookScope }))}>
<option value="user">Personal</option> <option value="user">Personal</option>
<option value="group" disabled={auth.groups.length === 0}>Group</option> <option value="group" disabled={auth.groups.length === 0}>Group</option>
@@ -3028,7 +3122,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</select> </select>
</FormField> </FormField>
{bookForm.scope_type === "group" && {bookForm.scope_type === "group" &&
<FormField label="Group"> <FormField label="Group" documentation={ADDRESS_FIELDS_DOCUMENTATION}>
<select value={bookForm.group_id} disabled={bookDialog?.mode === "edit"} onChange={(event) => setBookForm((current) => ({ ...current, group_id: event.target.value }))}> <select value={bookForm.group_id} disabled={bookDialog?.mode === "edit"} onChange={(event) => setBookForm((current) => ({ ...current, group_id: event.target.value }))}>
{auth.groups.length === 0 && <option value="">No groups available</option>} {auth.groups.length === 0 && <option value="">No groups available</option>}
{auth.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)} {auth.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
@@ -3036,7 +3130,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</FormField> </FormField>
} }
</div> </div>
<FormField label="Name"><input value={bookForm.name} onChange={(event) => setBookForm((current) => ({ ...current, name: event.target.value }))} autoFocus /></FormField> <FormField label="Name" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={bookForm.name} onChange={(event) => setBookForm((current) => ({ ...current, name: event.target.value }))} autoFocus /></FormField>
<FormField label="Description"><textarea value={bookForm.description} onChange={(event) => setBookForm((current) => ({ ...current, description: event.target.value }))} rows={3} /></FormField> <FormField label="Description"><textarea value={bookForm.description} onChange={(event) => setBookForm((current) => ({ ...current, description: event.target.value }))} rows={3} /></FormField>
</form> </form>
</Dialog> </Dialog>
@@ -3044,21 +3138,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Dialog <Dialog
open={Boolean(listDialog)} open={Boolean(listDialog)}
title={listDialog?.mode === "edit" ? "Edit address list" : "Add address list"} title={listDialog?.mode === "edit" ? "Edit address list" : "Add address list"}
onClose={() => setListDialog(null)} onClose={closeListDialog}
closeDisabled={saving} closeDisabled={saving}
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<Button type="button" onClick={() => setListDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button> <Button type="button" onClick={closeListDialog} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</Button> <Button type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</Button>
</> </>
}> }>
<form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}> <form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
<p className="muted">Lists group contacts inside the selected address book.</p> <p className="muted">Lists group contacts inside the selected address book.</p>
<FormField label="Address book"> <FormField label="Address book" documentation={ADDRESS_FIELDS_DOCUMENTATION}>
<input value={selectedBook?.name ?? ""} disabled readOnly /> <input value={selectedBook?.name ?? ""} disabled readOnly />
</FormField> </FormField>
<FormField label="Name"> <FormField label="Name" documentation={ADDRESS_FIELDS_DOCUMENTATION}>
<input value={listForm.name} onChange={(event) => setListForm((current) => ({ ...current, name: event.target.value }))} autoFocus /> <input value={listForm.name} onChange={(event) => setListForm((current) => ({ ...current, name: event.target.value }))} autoFocus />
</FormField> </FormField>
<FormField label="Description"> <FormField label="Description">
@@ -3070,24 +3164,24 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Dialog <Dialog
open={Boolean(contactDialog)} open={Boolean(contactDialog)}
title={contactDialog?.mode === "edit" ? "Edit contact" : "Add contact"} title={contactDialog?.mode === "edit" ? "Edit contact" : "Add contact"}
onClose={() => setContactDialog(null)} onClose={closeContactDialog}
closeDisabled={saving} closeDisabled={saving}
className="address-contact-dialog" className="address-contact-dialog"
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={ footer={
<> <>
<Button type="button" onClick={() => setContactDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button> <Button type="button" onClick={closeContactDialog} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</Button> <Button type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</Button>
</> </>
}> }>
<form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}> <form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
<div className="form-grid three"> <div className="form-grid three">
<FormField label="Display name"><input value={contactForm.display_name} onChange={(event) => setContactForm((current) => ({ ...current, display_name: event.target.value }))} autoFocus /></FormField> <FormField label="Display name" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={contactForm.display_name} onChange={(event) => setContactForm((current) => ({ ...current, display_name: event.target.value }))} autoFocus /></FormField>
<FormField label="Given name"><input value={contactForm.given_name} onChange={(event) => setContactForm((current) => ({ ...current, given_name: event.target.value }))} /></FormField> <FormField label="Given name"><input value={contactForm.given_name} onChange={(event) => setContactForm((current) => ({ ...current, given_name: event.target.value }))} /></FormField>
<FormField label="Family name"><input value={contactForm.family_name} onChange={(event) => setContactForm((current) => ({ ...current, family_name: event.target.value }))} /></FormField> <FormField label="Family name"><input value={contactForm.family_name} onChange={(event) => setContactForm((current) => ({ ...current, family_name: event.target.value }))} /></FormField>
</div> </div>
<div className="form-grid two"> <div className="form-grid two">
<FormField label="Organization"><input value={contactForm.organization} onChange={(event) => setContactForm((current) => ({ ...current, organization: event.target.value }))} /></FormField> <FormField label="Organization" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={contactForm.organization} onChange={(event) => setContactForm((current) => ({ ...current, organization: event.target.value }))} /></FormField>
<FormField label="Role title"><input value={contactForm.role_title} onChange={(event) => setContactForm((current) => ({ ...current, role_title: event.target.value }))} /></FormField> <FormField label="Role title"><input value={contactForm.role_title} onChange={(event) => setContactForm((current) => ({ ...current, role_title: event.target.value }))} /></FormField>
</div> </div>
<section className="address-form-section"> <section className="address-form-section">
@@ -3214,7 +3308,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" variant="primary" disabledReason={channelRuleSaveReason}><Plus size={15} /> Add</Button> <Button type="submit" variant="primary" disabledReason={channelRuleSaveReason}><Plus size={15} /> Add</Button>
</div> </div>
<div className="form-grid two"> <div className="form-grid two">
<FormField label="Channel"> <FormField label="Channel" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
<select <select
value={channelRuleForm.channel} value={channelRuleForm.channel}
onChange={(event) => setChannelRuleForm((current) => ({ onChange={(event) => setChannelRuleForm((current) => ({
@@ -3228,7 +3322,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<option value="portal">Portal</option> <option value="portal">Portal</option>
</select> </select>
</FormField> </FormField>
<FormField label="Contact point"> <FormField label="Contact point" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
<select <select
value={channelRuleForm.contact_point_id} value={channelRuleForm.contact_point_id}
disabled={governancePointOptions.length === 0} disabled={governancePointOptions.length === 0}
@@ -3239,7 +3333,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</FormField> </FormField>
</div> </div>
<div className="form-grid two"> <div className="form-grid two">
<FormField label="Decision"> <FormField label="Decision" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
<select value={channelRuleForm.decision} onChange={(event) => setChannelRuleForm((current) => ({ ...current, decision: event.target.value as AddressChannelDecision }))}> <select value={channelRuleForm.decision} onChange={(event) => setChannelRuleForm((current) => ({ ...current, decision: event.target.value as AddressChannelDecision }))}>
<option value="allowed">Allowed</option> <option value="allowed">Allowed</option>
<option value="opted_in">Opted in</option> <option value="opted_in">Opted in</option>
@@ -3251,7 +3345,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<option value="temporarily_unavailable">Temporarily unavailable</option> <option value="temporarily_unavailable">Temporarily unavailable</option>
</select> </select>
</FormField> </FormField>
<FormField label="Communication purpose"> <FormField label="Communication purpose" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
<input value={channelRuleForm.purpose} placeholder="All purposes" onChange={(event) => setChannelRuleForm((current) => ({ ...current, purpose: event.target.value }))} /> <input value={channelRuleForm.purpose} placeholder="All purposes" onChange={(event) => setChannelRuleForm((current) => ({ ...current, purpose: event.target.value }))} />
</FormField> </FormField>
</div> </div>
@@ -3265,7 +3359,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Effective until"><input type="datetime-local" value={channelRuleForm.effective_until} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_until: event.target.value }))} /></FormField> <FormField label="Effective until"><input type="datetime-local" value={channelRuleForm.effective_until} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_until: event.target.value }))} /></FormField>
<FormField label="Preference rank"><input type="number" min="0" max="10000" value={channelRuleForm.preference_rank} onChange={(event) => setChannelRuleForm((current) => ({ ...current, preference_rank: event.target.value }))} /></FormField> <FormField label="Preference rank"><input type="number" min="0" max="10000" value={channelRuleForm.preference_rank} onChange={(event) => setChannelRuleForm((current) => ({ ...current, preference_rank: event.target.value }))} /></FormField>
</div> </div>
<FormField label="Reason"><textarea rows={2} value={channelRuleForm.reason} onChange={(event) => setChannelRuleForm((current) => ({ ...current, reason: event.target.value }))} /></FormField> <FormField label="Reason" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}><textarea rows={2} value={channelRuleForm.reason} onChange={(event) => setChannelRuleForm((current) => ({ ...current, reason: event.target.value }))} /></FormField>
</form> </form>
} }
</div> </div>
@@ -0,0 +1,26 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const ADDRESSES_DOCUMENTATION = {
topicId: "addresses.boundary",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const ADDRESS_FIELDS_DOCUMENTATION = {
topicId: "addresses.reference.fields-and-consequences",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const ADDRESS_GOVERNANCE_DOCUMENTATION = {
topicId: "addresses.contact-point-resolution",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const ADDRESSES_I18N = {
requiredAction: "i18n:govoplan-addresses.required_action",
actor: "i18n:govoplan-addresses.actor",
destination: "i18n:govoplan-addresses.destination",
permissionDetails: "i18n:govoplan-addresses.permission_details",
permissionAction: "i18n:govoplan-addresses.permission_action",
permissionActor: "i18n:govoplan-addresses.permission_actor",
permissionDestination: "i18n:govoplan-addresses.permission_destination"
} as const;
+82 -2
View File
@@ -45,7 +45,47 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-addresses.tenant_directory_and_approved_shared_contacts.fa671f1b": "Tenant directory and approved shared contacts.", "i18n:govoplan-addresses.tenant_directory_and_approved_shared_contacts.fa671f1b": "Tenant directory and approved shared contacts.",
"i18n:govoplan-addresses.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9": "Tenant-wide contacts, functional mailboxes, and approved shared entries.", "i18n:govoplan-addresses.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9": "Tenant-wide contacts, functional mailboxes, and approved shared entries.",
"i18n:govoplan-addresses.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a": "Use contacts in To, Cc, Bcc, sender, and reply-to fields.", "i18n:govoplan-addresses.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a": "Use contacts in To, Cc, Bcc, sender, and reply-to fields.",
"i18n:govoplan-addresses.used_recently.af75cea7": "Used recently" "i18n:govoplan-addresses.used_recently.af75cea7": "Used recently",
"i18n:govoplan-addresses.sources": "Address sources",
"i18n:govoplan-addresses.contact_detail": "Contact detail",
"i18n:govoplan-addresses.communication_governance": "Communication governance",
"i18n:govoplan-addresses.required_action": "Required action",
"i18n:govoplan-addresses.actor": "Responsible actor",
"i18n:govoplan-addresses.destination": "Where to continue",
"i18n:govoplan-addresses.permission_details": "Your account can inspect Addresses but cannot create or change address books, lists, or contacts.",
"i18n:govoplan-addresses.permission_action": "Ask for the address-book, list, or contact permission needed for the intended task.",
"i18n:govoplan-addresses.permission_actor": "A tenant administrator or owner of the address-book scope",
"i18n:govoplan-addresses.permission_destination": "Access administration for the current tenant or group",
"i18n:govoplan-addresses.unsaved_book_title": "Unsaved address book",
"i18n:govoplan-addresses.unsaved_list_title": "Unsaved address list",
"i18n:govoplan-addresses.unsaved_contact_title": "Unsaved contact",
"i18n:govoplan-addresses.unsaved_message": "Save or discard this draft before leaving the editor.",
"Addresses are read-only": "Addresses are read-only",
"Address books": "Address books",
"Address sources": "Address sources",
"Contact detail": "Contact detail",
"Show archived": "Show archived",
"Search contacts": "Search contacts",
"No address books found.": "No address books found.",
"No contact selected": "No contact selected",
"Add address book": "Add address book",
"Edit address book": "Edit address book",
"Add address list": "Add address list",
"Edit address list": "Edit address list",
"Add contact": "Add contact",
"Edit contact": "Edit contact",
"Communication governance": "Communication governance",
"Display name": "Display name",
"Given name": "Given name",
"Family name": "Family name",
"Organization": "Organization",
"Role title": "Role title",
"Email addresses": "Email addresses",
"Phone numbers": "Phone numbers",
"Postal addresses": "Postal addresses",
"Primary": "Primary",
"Description": "Description",
"Note": "Note"
}, },
de: { de: {
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Kontakt hinzufügen", "i18n:govoplan-addresses.add_contact.6da0b4b8": "Kontakt hinzufügen",
@@ -91,6 +131,46 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-addresses.tenant_directory_and_approved_shared_contacts.fa671f1b": "Mandantenverzeichnis und freigegebene geteilte Kontakte.", "i18n:govoplan-addresses.tenant_directory_and_approved_shared_contacts.fa671f1b": "Mandantenverzeichnis und freigegebene geteilte Kontakte.",
"i18n:govoplan-addresses.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9": "Mandantenweite Kontakte, Funktionspostfächer und freigegebene Einträge.", "i18n:govoplan-addresses.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9": "Mandantenweite Kontakte, Funktionspostfächer und freigegebene Einträge.",
"i18n:govoplan-addresses.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a": "Kontakte in An-, Cc-, Bcc-, Absender- und Antwortfeldern verwenden.", "i18n:govoplan-addresses.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a": "Kontakte in An-, Cc-, Bcc-, Absender- und Antwortfeldern verwenden.",
"i18n:govoplan-addresses.used_recently.af75cea7": "Kürzlich verwendet" "i18n:govoplan-addresses.used_recently.af75cea7": "Kürzlich verwendet",
"i18n:govoplan-addresses.sources": "Adressquellen",
"i18n:govoplan-addresses.contact_detail": "Kontaktdetails",
"i18n:govoplan-addresses.communication_governance": "Kommunikationssteuerung",
"i18n:govoplan-addresses.required_action": "Erforderliche Aktion",
"i18n:govoplan-addresses.actor": "Verantwortliche Stelle",
"i18n:govoplan-addresses.destination": "Fortsetzung",
"i18n:govoplan-addresses.permission_details": "Ihr Konto darf Adressen einsehen, aber keine Adressbücher, Listen oder Kontakte erstellen oder ändern.",
"i18n:govoplan-addresses.permission_action": "Fordern Sie die für die Aufgabe erforderliche Adressbuch-, Listen- oder Kontaktberechtigung an.",
"i18n:govoplan-addresses.permission_actor": "Mandantenadministration oder Eigentümer des Adressbuchbereichs",
"i18n:govoplan-addresses.permission_destination": "Zugriffsverwaltung des aktuellen Mandanten oder der Gruppe",
"i18n:govoplan-addresses.unsaved_book_title": "Ungespeichertes Adressbuch",
"i18n:govoplan-addresses.unsaved_list_title": "Ungespeicherte Adressliste",
"i18n:govoplan-addresses.unsaved_contact_title": "Ungespeicherter Kontakt",
"i18n:govoplan-addresses.unsaved_message": "Speichern oder verwerfen Sie diesen Entwurf, bevor Sie den Editor verlassen.",
"Addresses are read-only": "Adressen sind schreibgeschützt",
"Address books": "Adressbücher",
"Address sources": "Adressquellen",
"Contact detail": "Kontaktdetails",
"Show archived": "Archivierte anzeigen",
"Search contacts": "Kontakte suchen",
"No address books found.": "Keine Adressbücher gefunden.",
"No contact selected": "Kein Kontakt ausgewählt",
"Add address book": "Adressbuch hinzufügen",
"Edit address book": "Adressbuch bearbeiten",
"Add address list": "Adressliste hinzufügen",
"Edit address list": "Adressliste bearbeiten",
"Add contact": "Kontakt hinzufügen",
"Edit contact": "Kontakt bearbeiten",
"Communication governance": "Kommunikationssteuerung",
"Display name": "Anzeigename",
"Given name": "Vorname",
"Family name": "Nachname",
"Organization": "Organisation",
"Role title": "Funktionsbezeichnung",
"Email addresses": "E-Mail-Adressen",
"Phone numbers": "Telefonnummern",
"Postal addresses": "Postanschriften",
"Primary": "Primär",
"Description": "Beschreibung",
"Note": "Notiz"
} }
}; };
+9 -1
View File
@@ -17,8 +17,16 @@ export const addressesModule: PlatformWebModule = {
dependencies: [], dependencies: [],
optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"], optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"],
translations, translations,
viewSurfaces: [
{ id: "addresses.page", moduleId: "addresses", kind: "route", label: "i18n:govoplan-addresses.address_book.f6327f59", order: 80 },
{ id: "addresses.sources", moduleId: "addresses", kind: "section", label: "i18n:govoplan-addresses.sources", parentId: "addresses.page", order: 10 },
{ id: "addresses.contacts", moduleId: "addresses", kind: "section", label: "i18n:govoplan-addresses.contacts.b0dd615c", parentId: "addresses.page", order: 20 },
{ id: "addresses.detail", moduleId: "addresses", kind: "section", label: "i18n:govoplan-addresses.contact_detail", parentId: "addresses.page", order: 30 },
{ id: "addresses.governance", moduleId: "addresses", kind: "action", label: "i18n:govoplan-addresses.communication_governance", parentId: "addresses.detail", order: 40 },
{ id: "addresses.sync", moduleId: "addresses", kind: "action", label: "i18n:govoplan-addresses.sync.905f6309", parentId: "addresses.sources", order: 50 }
],
navItems: [{ to: "/address-book", label: "i18n:govoplan-addresses.address_book.f6327f59", iconName: "book-user", anyOf: ["addresses:contact:read"], order: 80 }], navItems: [{ to: "/address-book", label: "i18n:govoplan-addresses.address_book.f6327f59", iconName: "book-user", anyOf: ["addresses:contact:read"], order: 80 }],
routes: [{ path: "/address-book", anyOf: ["addresses:contact:read"], order: 80, render: ({ settings, auth, onAuthChange }) => createElement(AddressBookPage, { settings, auth, onAuthChange }) }] routes: [{ path: "/address-book", anyOf: ["addresses:contact:read"], order: 80, surfaceId: "addresses.page", render: ({ settings, auth, onAuthChange }) => createElement(AddressBookPage, { settings, auth, onAuthChange }) }]
}; };
export default addressesModule; export default addressesModule;