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
@@ -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<AddressBook[]>([]);
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
@@ -926,10 +940,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [notice, setNotice] = useState("");
const [bookDialog, setBookDialog] = useState<BookDialogState | null>(null);
const [bookForm, setBookForm] = useState<BookFormState>(EMPTY_BOOK_FORM);
const [bookFormBaseline, setBookFormBaseline] = useState("");
const [listDialog, setListDialog] = useState<ListDialogState | null>(null);
const [listForm, setListForm] = useState<ListFormState>(EMPTY_LIST_FORM);
const [listFormBaseline, setListFormBaseline] = useState("");
const [contactDialog, setContactDialog] = useState<ContactDialogState | null>(null);
const [contactForm, setContactForm] = useState<ContactFormState>(EMPTY_CONTACT_FORM);
const [contactFormBaseline, setContactFormBaseline] = useState("");
const [governanceContact, setGovernanceContact] = useState<Contact | null>(null);
const [channelRules, setChannelRules] = useState<ContactChannelRule[]>([]);
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 [conflictMergeChoices, setConflictMergeChoices] = useState<Record<string, ConflictMergeChoice>>({});
const [confirmState, setConfirmState] = useState<ConfirmState>(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<HTMLFormElement>) {
event.preventDefault();
async function submitBook(event?: FormEvent<HTMLFormElement>): Promise<boolean> {
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<HTMLFormElement>) {
event.preventDefault();
if (!selectedBook) return;
async function submitList(event?: FormEvent<HTMLFormElement>): Promise<boolean> {
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<HTMLFormElement>) {
event.preventDefault();
if (!selectedBook) return;
async function submitContact(event?: FormEvent<HTMLFormElement>): Promise<boolean> {
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)
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</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">
<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>
</div>
<div className="button-row compact-actions address-icon-actions">
<DocumentationHelpLink reference={ADDRESSES_DOCUMENTATION} />
{renderSelectedBookActions()}
</div>
</header>
@@ -3008,18 +3102,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Dialog
open={Boolean(bookDialog)}
title={bookDialog?.mode === "edit" ? "Edit address book" : "Add address book"}
onClose={() => setBookDialog(null)}
onClose={closeBookDialog}
closeDisabled={saving}
footerClassName="button-row compact-actions"
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>
</>
}>
<form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
<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 }))}>
<option value="user">Personal</option>
<option value="group" disabled={auth.groups.length === 0}>Group</option>
@@ -3028,7 +3122,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</select>
</FormField>
{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 }))}>
{auth.groups.length === 0 && <option value="">No groups available</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>
}
</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>
</form>
</Dialog>
@@ -3044,21 +3138,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Dialog
open={Boolean(listDialog)}
title={listDialog?.mode === "edit" ? "Edit address list" : "Add address list"}
onClose={() => setListDialog(null)}
onClose={closeListDialog}
closeDisabled={saving}
footerClassName="button-row compact-actions"
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>
</>
}>
<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>
<FormField label="Address book">
<FormField label="Address book" documentation={ADDRESS_FIELDS_DOCUMENTATION}>
<input value={selectedBook?.name ?? ""} disabled readOnly />
</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 />
</FormField>
<FormField label="Description">
@@ -3070,24 +3164,24 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Dialog
open={Boolean(contactDialog)}
title={contactDialog?.mode === "edit" ? "Edit contact" : "Add contact"}
onClose={() => setContactDialog(null)}
onClose={closeContactDialog}
closeDisabled={saving}
className="address-contact-dialog"
footerClassName="button-row compact-actions"
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>
</>
}>
<form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
<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="Family name"><input value={contactForm.family_name} onChange={(event) => setContactForm((current) => ({ ...current, family_name: event.target.value }))} /></FormField>
</div>
<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>
</div>
<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>
</div>
<div className="form-grid two">
<FormField label="Channel">
<FormField label="Channel" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
<select
value={channelRuleForm.channel}
onChange={(event) => setChannelRuleForm((current) => ({
@@ -3228,7 +3322,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<option value="portal">Portal</option>
</select>
</FormField>
<FormField label="Contact point">
<FormField label="Contact point" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
<select
value={channelRuleForm.contact_point_id}
disabled={governancePointOptions.length === 0}
@@ -3239,7 +3333,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</FormField>
</div>
<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 }))}>
<option value="allowed">Allowed</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>
</select>
</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 }))} />
</FormField>
</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="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>
<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>
}
</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;