diff --git a/package.json b/package.json index c51d610..0405256 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/addresses-webui", - "version": "0.1.21", + "version": "0.1.22", "private": true, "type": "module", "main": "webui/src/index.ts", @@ -18,7 +18,7 @@ "README.md" ], "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/pyproject.toml b/pyproject.toml index 85c52f4..4c98269 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-addresses" -version = "0.1.21" +version = "0.1.22" description = "GovOPlaN reusable address and recipient-source module." readme = "README.md" requires-python = ">=3.12" authors = [{ name = "GovOPlaN" }] dependencies = [ "defusedxml>=0.7.1", - "govoplan-core>=0.1.18", + "govoplan-core>=0.1.45", "ldap3>=2.9.1,<3", "openpyxl>=3.1.5,<4", ] diff --git a/src/govoplan_addresses/backend/manifest.py b/src/govoplan_addresses/backend/manifest.py index e334b70..5e01df9 100644 --- a/src/govoplan_addresses/backend/manifest.py +++ b/src/govoplan_addresses/backend/manifest.py @@ -438,7 +438,7 @@ LDAP_PROVIDER = ExternalProviderDeclaration( manifest = ModuleManifest( id="addresses", name="Addresses", - version="0.1.21", + version="0.1.22", required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, @@ -716,7 +716,15 @@ manifest = ModuleManifest( body=( "Campaigns may keep immutable campaign-local recipient snapshots, but durable address directories, " "recipient-source definitions, consent metadata, provenance, deduplication, and import/export workflows " - "are owned by govoplan-addresses." + "are owned by govoplan-addresses. The Address Book workspace keeps Reload directly " + "before Add address book at the upper right. Import / export, Connections, and Address " + "quality open labelled, scoped tools; Manage applies to the selected book or list and " + "separates archive actions from editing. Export version is chosen in Import / export. " + "Contact creation remains beside the contact list. Folder icons alone expand or collapse " + "the tree; labels select a group, book, or list without changing expansion. A selected " + "group is navigation, not an aggregate contact book. Reload preserves collapsed " + "branches. All permission and read-only reasons, confirmations, import safeguards, " + "and contact-to-list drag and drop still apply." ), layer="configured", documentation_types=("admin", "user"), @@ -740,7 +748,15 @@ manifest = ModuleManifest( "body": ( "Campaigns darf unveränderliche campaignlokale Empfänger-Snapshots halten. Dauerhafte Adressverzeichnisse, " "Empfängerquellendefinitionen, Einwilligungsmetadaten, Herkunft, Dublettenbereinigung sowie Import- und Exportabläufe " - "gehören jedoch govoplan-addresses." + "gehören jedoch govoplan-addresses. Im Adressbuch steht Neuladen oben rechts unmittelbar vor " + "Adressbuch hinzufügen. Import / Export, Verbindungen und Adressqualität öffnen beschriftete, " + "kontextbezogene Werkzeuge. Verwalten bezieht sich auf das ausgewählte Adressbuch oder die Liste " + "und trennt Archivieren vom Bearbeiten. Die Exportversion wird unter Import / Export gewählt. " + "Kontakte werden weiterhin direkt neben der Kontaktliste angelegt. Nur Ordnersymbole klappen " + "den Baum auf oder zu; Beschriftungen wählen eine Gruppe, ein Adressbuch oder eine Liste aus, " + "ohne die Aufklappstellung zu ändern. Eine ausgewählte Gruppe dient der Navigation und ist " + "kein zusammengefasstes Adressbuch. Neuladen bewahrt zugeklappte Zweige. Berechtigungs- und " + "Schreibschutzgründe, Bestätigungen, Importsicherungen und das Ziehen von Kontakten in Listen gelten unverändert." ), } }, @@ -748,6 +764,7 @@ manifest = ModuleManifest( "seed": True, "help_contexts": [ "addresses.page", + "addresses.explorer.transfer", "addresses.sources", "addresses.contacts", "addresses.detail", diff --git a/webui/package.json b/webui/package.json index 0fc0d35..9d07e25 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/addresses-webui", - "version": "0.1.21", + "version": "0.1.22", "private": true, "type": "module", "main": "src/index.ts", @@ -18,7 +18,7 @@ "test:import-run": "rm -rf .import-run-test-build && mkdir -p .import-run-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-run-test-build/package.json && ../../govoplan-core/webui/node_modules/.bin/tsc -p tsconfig.import-run-tests.json && node .import-run-test-build/tests/import-run-state.test.js" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/webui/scripts/test-selection-list-structure.mjs b/webui/scripts/test-selection-list-structure.mjs index f63b3f1..f656e7d 100644 --- a/webui/scripts/test-selection-list-structure.mjs +++ b/webui/scripts/test-selection-list-structure.mjs @@ -17,5 +17,14 @@ assert.match(page, /[\s\S]*role="group"[\s assert.doesNotMatch(page, / { +function defaultConflictMergeChoices(_conflict: AddressSyncConflict): Record { return Object.fromEntries(CONFLICT_PAYLOAD_FIELDS.map((field) => [field, "local" as ConflictMergeChoice])); } @@ -963,7 +964,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) const [selectedBookId, setSelectedBookId] = useState(""); const [selectedListId, setSelectedListId] = useState(""); const [selectedContactId, setSelectedContactId] = useState(""); + const [selectedTreeGroup, setSelectedTreeGroup] = useState | null>(null); const [expandedTreeIds, setExpandedTreeIds] = useState>(() => new Set()); + const knownTreeBranchIds = useRef(new Set()); + const [addressActionsOpen, setAddressActionsOpen] = useState<"book" | "transfer" | "connections" | null>(null); const [query, setQuery] = useState(""); const [showArchived, setShowArchived] = useState(false); const [loading, setLoading] = useState(false); @@ -1136,8 +1140,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) .then((run) => { if (!active) return; setImportRun(run); + setSelectedTreeGroup(null); setSelectedBookId(run.address_book_id); - setSelectedImportProfileId(run.profile_id); + setSelectedImportProfileId(run.profile_id ?? ""); }) .catch((err) => { if (!active) return; @@ -1161,9 +1166,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) return () => {active = false;}; }, [auth.groups_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); - const selectedBook = books.find((book) => book.id === selectedBookId) ?? books[0] ?? null; + const selectedBook = selectedTreeGroup ? null : books.find((book) => book.id === selectedBookId) ?? books[0] ?? null; const importLifecycle = importRun ? importRunLifecycle(importRun.status) : null; - const selectedList = addressLists.find((list) => list.id === selectedListId) ?? null; + const selectedList = selectedTreeGroup ? null : addressLists.find((list) => list.id === selectedListId) ?? null; const selectedBookSyncSources = useMemo( () => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [], [selectedBook, syncSources] @@ -1172,7 +1177,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]); const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]); const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]); - const visibleContacts = contacts; + const visibleContacts = selectedTreeGroup ? [] : contacts; const memberCandidateContacts = useMemo(() => { const normalizedQuery = memberQuery.trim().toLowerCase(); return memberCandidates @@ -1208,7 +1213,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) return []; }, [channelRuleForm.channel, governanceContact]); const selectedContactListEntries = selectedContact && selectedList ? contactListEntries(addressListEntries, selectedContact.id) : []; - const activeTreeId = selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : ""; + const activeTreeId = selectedTreeGroup?.id ?? (selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : ""); const loadingReason = loading ? "Address books are loading." : ""; const savingReason = saving ? "An address-book action is already in progress." : ""; const createBookReason = disabledReason( @@ -1570,10 +1575,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) useEffect(() => { const defaultExpanded = expandedAddressTreeIds(addressTreeNodes); - if (defaultExpanded.size === 0) return; + const previousBranchIds = knownTreeBranchIds.current; + knownTreeBranchIds.current = defaultExpanded; setExpandedTreeIds((current) => { - const next = new Set(current); - for (const id of defaultExpanded) next.add(id); + const next = new Set([...current].filter((id) => defaultExpanded.has(id))); + for (const id of defaultExpanded) { + if (!previousBranchIds.has(id)) next.add(id); + } return next; }); }, [addressTreeNodes]); @@ -1950,6 +1958,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) function openTreeNode(node: AddressTreeNode) { if (node.kind === "book" && node.book) { + setSelectedTreeGroup(null); setContactPage(1); setSelectedBookId(node.book.id); setSelectedListId(""); @@ -1957,13 +1966,16 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) return; } if (node.kind === "list" && node.list) { + setSelectedTreeGroup(null); setContactPage(1); setSelectedBookId(node.list.address_book_id); setSelectedListId(node.list.id); setSelectedContactId(""); return; } - toggleTreeNode(node); + setSelectedTreeGroup({ id: node.id, label: node.label }); + setSelectedListId(""); + setSelectedContactId(""); } function toggleTreeNode(node: AddressTreeNode) { @@ -2450,8 +2462,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) try { const run = await getAddressImportRun(settings, importRun?.id || requestedImportRunId); retainImportRun(run); + setSelectedTreeGroup(null); setSelectedBookId(run.address_book_id); - setSelectedImportProfileId(run.profile_id); + setSelectedImportProfileId(run.profile_id ?? ""); } catch (err) { setImportRun(null); setImportRunUnavailable( @@ -2555,7 +2568,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) function downloadImportCorrections() { if (!importRun) return; - const quote = (value: unknown) => `"${String(value ?? "").replaceAll('"', '""')}"`; + const quote = (value: unknown) => `"${String(value ?? "").replace(/"/g, '""')}"`; const lines = [ ["severity", "row", "field", "code", "message"].map(quote).join(","), ...importRun.diagnostics.map((item) => [item.severity, item.row_number ?? "", item.field ?? "", item.code, item.message].map(quote).join(",")) @@ -2911,6 +2924,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) } function renderSelectedBookPanel() { + if (selectedTreeGroup) return
+ {selectedTreeGroup.label} +

i18n:govoplan-addresses.explorer.choose_book_in_group

+
; if (!selectedBook) return

No address book selected.

; return (
@@ -2952,40 +2969,68 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) ); } - function renderSelectedBookActions() { - return ( - <> - - - - - - - - - - - - - {selectedList ? - selectedList.deleted_at ? - : - <> - - - : - selectedBook?.deleted_at ? - : - <> - - - - } - - ); + function chooseAddressAction(action: () => void) { + setAddressActionsOpen(null); + action(); + } + + function renderAddressActions() { + return setAddressActionsOpen(null)} + footer={}> + {addressActionsOpen === "book" && <> + + + + {selectedList?.deleted_at ? + : selectedList ? + : selectedBook?.deleted_at ? + : + } + + + {(selectedList ? !selectedList.deleted_at : selectedBook && !selectedBook.deleted_at) && + + {selectedList ? + : selectedBook && + } + + } + } + {addressActionsOpen === "transfer" && <> + + + + + + + + + + + + } + {addressActionsOpen === "connections" && <> + + + + + + + + + + + + + + } + ; } function renderContactRow(contact: Contact) { @@ -3196,6 +3241,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) return (
+ void refreshAll(), loading, disabledReason: refreshReason }} + contextActions={<> + + + + } + helpAction={} + createAction={} + /> {error && {error}} {notice && !error && {notice}} {!canWriteBooks && !canWriteLists && !canWriteContacts && Address books

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

-
- - {renderSelectedBookActions()} -
+

{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}

- {contactTotal} contact{contactTotal === 1 ? "" : "s"} + {selectedTreeGroup ? 0 : contactTotal} contact{!selectedTreeGroup && contactTotal === 1 ? "" : "s"} {selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}

@@ -3274,7 +3330,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {selectedList && } - +
@@ -3299,7 +3355,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) + {renderAddressActions()} +
- {rule.channel.replace("_", " ")} · {rule.decision.replaceAll("_", " ")} + {rule.channel.replace("_", " ")} · {rule.decision.replace(/_/g, " ")} {rule.purpose || "All purposes"}{rule.reason ? ` · ${rule.reason}` : ""} @@ -4480,7 +4538,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {conflictDialog.resolution && }
- {conflictDialog.metadata?.message &&

{String(conflictDialog.metadata.message)}

} + {Boolean(conflictDialog.metadata?.message) &&

{String(conflictDialog.metadata.message)}

} {!canApplyRemoteConflict(conflictDialog) && This conflict predates stored field payloads or came from a stale write. It can be marked resolved or ignored, but the remote value cannot be applied automatically.}
diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index c2c5a98..b5170f1 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -2,6 +2,33 @@ import type { PlatformTranslations } from "@govoplan/core-webui"; export const generatedTranslations: PlatformTranslations = { en: { + "i18n:govoplan-addresses.explorer.page_actions": "Address book actions", + "i18n:govoplan-addresses.explorer.transfer": "Import / export", + "i18n:govoplan-addresses.explorer.connections": "Connections", + "i18n:govoplan-addresses.explorer.quality": "Address quality", + "i18n:govoplan-addresses.explorer.manage": "Manage", + "i18n:govoplan-addresses.explorer.manage_selection": "Manage selected book or list", + "i18n:govoplan-addresses.explorer.no_book": "Select an address book first.", + "i18n:govoplan-addresses.explorer.choose_book_in_group": "Select an address book in this group. Use the folder icon to expand or collapse it; clicking the label only selects it.", + "i18n:govoplan-addresses.explorer.archive_section": "Archive selected book or list", + "i18n:govoplan-addresses.explorer.import_section": "Import into the selected book", + "i18n:govoplan-addresses.explorer.export_section": "Export the selected book or list", + "i18n:govoplan-addresses.explorer.vcard_version": "vCard export version", + "i18n:govoplan-addresses.explorer.export_list": "Export address list", + "i18n:govoplan-addresses.explorer.export_book": "Export address book", + "i18n:govoplan-addresses.explorer.connect_section": "Connect an address source", + "i18n:govoplan-addresses.explorer.sync_section": "Synchronization for the selected book", + "i18n:govoplan-addresses.explorer.no_sync_source": "The selected book has no connected synchronization source.", + "Restore address list": "Restore address list", + "Restore address book": "Restore address book", + "Delete address list": "Delete address list", + "Delete address book": "Delete address book", + "Import contacts": "Import contacts", + "Connect CardDAV": "Connect CardDAV", + "Connect LDAP or Active Directory": "Connect LDAP or Active Directory", + "Inspect sync source": "Inspect sync source", + "Preview sync": "Preview sync", + "Run sync": "Run sync", "i18n:govoplan-addresses.add_contact.6da0b4b8": "Add contact", "i18n:govoplan-addresses.address_book.f6327f59": "Address Book", "i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Address book scopes", @@ -88,6 +115,33 @@ export const generatedTranslations: PlatformTranslations = { "Note": "Note" }, de: { + "i18n:govoplan-addresses.explorer.page_actions": "Adressbuchaktionen", + "i18n:govoplan-addresses.explorer.transfer": "Import / Export", + "i18n:govoplan-addresses.explorer.connections": "Verbindungen", + "i18n:govoplan-addresses.explorer.quality": "Adressqualität", + "i18n:govoplan-addresses.explorer.manage": "Verwalten", + "i18n:govoplan-addresses.explorer.manage_selection": "Ausgewähltes Adressbuch oder Liste verwalten", + "i18n:govoplan-addresses.explorer.no_book": "Wählen Sie zuerst ein Adressbuch aus.", + "i18n:govoplan-addresses.explorer.choose_book_in_group": "Wählen Sie ein Adressbuch in dieser Gruppe aus. Das Ordnersymbol klappt auf oder zu; ein Klick auf die Beschriftung wählt nur aus.", + "i18n:govoplan-addresses.explorer.archive_section": "Ausgewähltes Adressbuch oder Liste archivieren", + "i18n:govoplan-addresses.explorer.import_section": "In das ausgewählte Adressbuch importieren", + "i18n:govoplan-addresses.explorer.export_section": "Ausgewähltes Adressbuch oder Liste exportieren", + "i18n:govoplan-addresses.explorer.vcard_version": "vCard-Exportversion", + "i18n:govoplan-addresses.explorer.export_list": "Adressliste exportieren", + "i18n:govoplan-addresses.explorer.export_book": "Adressbuch exportieren", + "i18n:govoplan-addresses.explorer.connect_section": "Eine Adressquelle verbinden", + "i18n:govoplan-addresses.explorer.sync_section": "Synchronisierung des ausgewählten Adressbuchs", + "i18n:govoplan-addresses.explorer.no_sync_source": "Das ausgewählte Adressbuch hat keine verbundene Synchronisierungsquelle.", + "Restore address list": "Adressliste wiederherstellen", + "Restore address book": "Adressbuch wiederherstellen", + "Delete address list": "Adressliste löschen", + "Delete address book": "Adressbuch löschen", + "Import contacts": "Kontakte importieren", + "Connect CardDAV": "CardDAV verbinden", + "Connect LDAP or Active Directory": "LDAP oder Active Directory verbinden", + "Inspect sync source": "Synchronisierungsquelle prüfen", + "Preview sync": "Synchronisierungsvorschau", + "Run sync": "Synchronisierung starten", "i18n:govoplan-addresses.add_contact.6da0b4b8": "Kontakt hinzufügen", "i18n:govoplan-addresses.address_book.f6327f59": "Adressbuch", "i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Adressbuch-Bereiche", diff --git a/webui/src/styles/addresses.css b/webui/src/styles/addresses.css index 741e454..8201b17 100644 --- a/webui/src/styles/addresses.css +++ b/webui/src/styles/addresses.css @@ -34,8 +34,8 @@ } .address-book-page.address-book-fullscreen { - display: grid; - grid-template-rows: 1fr; + display: flex; + flex-direction: column; height: calc(100vh - 115px); overflow: hidden; padding: 0; @@ -43,7 +43,8 @@ } .address-workspace-frame { - height: 100%; + flex: 1 1 auto; + height: auto; min-height: 0; } @@ -73,22 +74,10 @@ background: var(--panel-header); } -.address-tree-header .button-row { - flex-wrap: wrap; -} - -.address-icon-actions { - max-width: 150px; -} - -.address-icon-actions .btn { - align-items: center; - aspect-ratio: 1; - display: inline-flex; - justify-content: center; - min-height: 30px; - padding: 0; - width: 30px; +.address-page-actions { + border-bottom: var(--border-line); + flex: 0 0 auto; + padding: var(--space-3); } .address-tree-filter-row {