Migrate Distribution Lists interface patterns
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# Distribution Lists Interface Pattern Migration
|
||||
|
||||
This migration applies the GovOPlaN interface pattern language to the
|
||||
distribution-list catalogue, revision editor, expansion preview, snapshot
|
||||
evidence, and reusable picker.
|
||||
|
||||
## Surface Inventory
|
||||
|
||||
| Surface | Archetype | Consequence class | Contract |
|
||||
| --- | --- | --- | --- |
|
||||
| `/distribution-lists` catalogue | Governed work queue | Select, create, or delete a list | Shared loading, empty, permission, disabled-reason, and help states |
|
||||
| Definition editor | Consequential definition editor | Save immutable revision | Guarded list and nested-entry drafts with contextual field semantics |
|
||||
| Expansion preview | Evidence preview | Resolve current saved revision | Explicit exclusions, provider availability, provenance, and decision explanations |
|
||||
| Frozen snapshots | Evidence register | Retain exact expansion | Immutable recipients, exclusions, source revisions, parameters, provenance, and hash |
|
||||
| Distribution-list picker | Shared object selector | Reference a list | Searchable selector with contextual definition help |
|
||||
|
||||
## Consequence And Availability Rules
|
||||
|
||||
- Saving creates an immutable list revision; previews and snapshots use only a
|
||||
saved revision.
|
||||
- Include, exclude, and manual-override entries have distinct semantics. Manual
|
||||
override requires an auditable reason.
|
||||
- Freezing is confirmed because it creates retained evidence consumed by other
|
||||
modules. Deleting the reusable definition does not rewrite retained snapshots.
|
||||
- Provider absence is explained and never turns an optional integration into a
|
||||
hard dependency.
|
||||
- Missing write permission and incomplete or unchanged drafts expose a reason
|
||||
and required next action instead of a silent disabled control.
|
||||
|
||||
Backend and WebUI manifests publish the same surface identifiers. English and
|
||||
German catalogues cover the module-owned interaction vocabulary, and contextual
|
||||
help resolves through manifest documentation rather than detached UI guidance.
|
||||
@@ -100,7 +100,15 @@ DOCUMENTATION = (
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
related_modules=("addresses", "campaigns", "mail", "postbox", "notifications", "scheduling", "poll", "workflow_engine", "tasks"),
|
||||
metadata={"seed": True},
|
||||
metadata={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"dist_lists.page",
|
||||
"dist_lists.editor",
|
||||
"dist_lists.preview",
|
||||
"dist_lists.picker",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id=f"{MODULE_ID}.address-contact-resolution",
|
||||
@@ -138,6 +146,43 @@ DOCUMENTATION = (
|
||||
related_modules=("identity", "idm"),
|
||||
metadata={"seed": True},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id=f"{MODULE_ID}.reference.fields-and-consequences",
|
||||
title="Distribution-list fields and consequences",
|
||||
summary="Definition types, audience modes, effective dates, channel requests, expansion, and frozen-snapshot consequences.",
|
||||
body=(
|
||||
"Static definitions contain explicit entries; parameterized and dynamic definitions accept typed values or provider results; "
|
||||
"templates are reusable definitions that cannot run on their own. Include entries add audiences, exclusions remove them, and "
|
||||
"manual overrides require an auditable reason. Effective dates constrain when an entry participates. Requested channels narrow "
|
||||
"candidate delivery methods but do not bypass Policy, consent, suppression, or provider decisions. Saving creates an immutable "
|
||||
"revision. Previewing explains current recipients and exclusions without retaining evidence. Freezing records the exact revision, "
|
||||
"parameters, provider revisions, recipients, exclusions, provenance, and expansion hash as immutable consumer evidence. Deleting "
|
||||
"a definition does not rewrite frozen snapshots retained under their governing policy."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
related_modules=("addresses", "campaigns", "dataflow", "idm", "policy", "reporting", "workflow_engine"),
|
||||
metadata={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"dist_lists.field.definition-kind",
|
||||
"dist_lists.field.source",
|
||||
"dist_lists.field.entry-mode",
|
||||
"dist_lists.field.effective-period",
|
||||
"dist_lists.field.requested-channels",
|
||||
"dist_lists.action.expand",
|
||||
"dist_lists.action.freeze",
|
||||
"dist_lists.action.delete",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"save_revision": "Creates a new immutable revision while retaining prior revisions for existing evidence.",
|
||||
"expand_preview": "Resolves the saved revision without creating retained delivery evidence.",
|
||||
"freeze_snapshot": "Persists exact expansion inputs, decisions, recipients, exclusions, provenance, and hash.",
|
||||
"delete_definition": "Retires the reusable definition without rewriting retained frozen snapshots.",
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_dist_lists.backend.manifest import manifest
|
||||
|
||||
|
||||
class DistributionListsInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||
frontend = manifest.frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
self.assertEqual(
|
||||
{"/distribution-lists"},
|
||||
{item.path for item in frontend.routes}, # type: ignore[union-attr]
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"dist_lists.page",
|
||||
"dist_lists.editor",
|
||||
"dist_lists.preview",
|
||||
"dist_lists.picker",
|
||||
},
|
||||
{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["dist_lists.boundary"]
|
||||
reference = topics["dist_lists.reference.fields-and-consequences"]
|
||||
|
||||
self.assertIn("dist_lists.editor", boundary.metadata["help_contexts"])
|
||||
self.assertIn(
|
||||
"dist_lists.field.entry-mode",
|
||||
reference.metadata["help_contexts"],
|
||||
)
|
||||
self.assertIn(
|
||||
"freeze_snapshot",
|
||||
reference.metadata["consequence_classes"],
|
||||
)
|
||||
self.assertIn(
|
||||
"delete_definition",
|
||||
reference.metadata["consequence_classes"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
listDistributionLists,
|
||||
type DistributionList
|
||||
} from "../api/distLists";
|
||||
import { DIST_LISTS_FIELD_DOCUMENTATION } from "../features/distributionLists/interfacePatterns";
|
||||
|
||||
export type DistributionListPickerProps = {
|
||||
settings: ApiSettings;
|
||||
@@ -47,7 +48,7 @@ export default function DistributionListPicker({
|
||||
}, [settings]);
|
||||
|
||||
return (
|
||||
<FormField label={label}>
|
||||
<FormField label={label} documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||
<SearchableSelect
|
||||
value={value}
|
||||
selectedOption={selectedOption}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DataGrid,
|
||||
DataGridRowActions,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
IconButton,
|
||||
@@ -62,6 +63,11 @@ import {
|
||||
type ProviderOption,
|
||||
type SourceReference
|
||||
} from "../../api/distLists";
|
||||
import {
|
||||
DIST_LISTS_DOCUMENTATION,
|
||||
DIST_LISTS_FIELD_DOCUMENTATION,
|
||||
DIST_LISTS_I18N
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
@@ -110,7 +116,10 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createKind, setCreateKind] = useState<DefinitionKind>("static");
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [freezeOpen, setFreezeOpen] = useState(false);
|
||||
const [entryEditor, setEntryEditor] = useState<EntryEditorState | null>(null);
|
||||
const [entryEditorBaselineKey, setEntryEditorBaselineKey] = useState("");
|
||||
const [discardEntryOpen, setDiscardEntryOpen] = useState(false);
|
||||
const [providerUnavailable, setProviderUnavailable] = useState<ProviderCatalogue["unavailable_providers"]>([]);
|
||||
const providerCache = useRef(new Map<string, ProviderOption>());
|
||||
const [preview, setPreview] = useState<ExpansionResult | null>(null);
|
||||
@@ -125,6 +134,9 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
const canWrite = hasScope(auth, "dist_lists:list:write")
|
||||
|| hasScope(auth, "dist_lists:list:admin");
|
||||
const dirty = Boolean(selected && draftKey(draft) !== savedDraftKey);
|
||||
const entryEditorDirty = Boolean(
|
||||
entryEditor && entryEditorKey(entryEditor) !== entryEditorBaselineKey
|
||||
);
|
||||
|
||||
const applyItem = useCallback((item: DistributionList | null) => {
|
||||
const next = item ? draftFromItem(item) : emptyDraft();
|
||||
@@ -179,8 +191,8 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const createItem = async () => {
|
||||
if (!createName.trim()) return;
|
||||
const createItem = async (): Promise<boolean> => {
|
||||
if (!createName.trim()) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -194,20 +206,23 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
setCreateKind("static");
|
||||
setSuccess(`Created ${created.name}.`);
|
||||
await reload(created.id);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveItem = async () => {
|
||||
if (!selected || !draft.name.trim()) return false;
|
||||
const persistDraft = async (nextDraft: ListDraft) => {
|
||||
if (!selected || !nextDraft.name.trim()) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await updateDistributionList(settings, selected, payloadFromDraft(draft));
|
||||
const updated = await updateDistributionList(settings, selected, payloadFromDraft(nextDraft));
|
||||
setSuccess(`Saved revision ${updated.current_revision}.`);
|
||||
setEntryEditor(null);
|
||||
await reload(updated.id);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
@@ -218,12 +233,67 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const saveItem = async () => persistDraft(draft);
|
||||
|
||||
const saveItemWithEntryEditor = async () => {
|
||||
if (!entryEditor) return saveItem();
|
||||
const nextDraft = draftWithEditorEntry(draft, entryEditor);
|
||||
if (!nextDraft) return false;
|
||||
return persistDraft(nextDraft);
|
||||
};
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: saveItem,
|
||||
onDiscard: () => applyItem(selected)
|
||||
onDiscard: () => applyItem(selected),
|
||||
title: "i18n:govoplan-dist-lists.unsaved_title",
|
||||
message: "i18n:govoplan-dist-lists.unsaved_message"
|
||||
});
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: Boolean(createOpen && createName),
|
||||
onSave: createItem,
|
||||
onDiscard: () => {
|
||||
setCreateOpen(false);
|
||||
setCreateName("");
|
||||
setCreateKind("static");
|
||||
},
|
||||
title: "i18n:govoplan-dist-lists.unsaved_title",
|
||||
message: "i18n:govoplan-dist-lists.unsaved_message"
|
||||
});
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: entryEditorDirty,
|
||||
onSave: saveItemWithEntryEditor,
|
||||
onDiscard: () => {
|
||||
applyItem(selected);
|
||||
setEntryEditor(null);
|
||||
},
|
||||
title: "i18n:govoplan-dist-lists.entry_unsaved_title",
|
||||
message: "i18n:govoplan-dist-lists.entry_unsaved_message"
|
||||
});
|
||||
|
||||
const closeCreate = () => {
|
||||
if (busy) return;
|
||||
if (createName) requestDiscard(() => setCreateOpen(false));
|
||||
else setCreateOpen(false);
|
||||
};
|
||||
|
||||
const openEntryEditor = (next: EntryEditorState) => {
|
||||
setEntryEditor(next);
|
||||
setEntryEditorBaselineKey(entryEditorKey(next));
|
||||
setDiscardEntryOpen(false);
|
||||
};
|
||||
|
||||
const closeEntryEditor = () => {
|
||||
if (busy) return;
|
||||
if (entryEditorDirty) {
|
||||
setDiscardEntryOpen(true);
|
||||
return;
|
||||
}
|
||||
setEntryEditor(null);
|
||||
};
|
||||
|
||||
const removeItem = async () => {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
@@ -340,11 +410,12 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
icon={<Pencil size={15} />}
|
||||
variant="ghost"
|
||||
disabled={!canWrite}
|
||||
onClick={() => setEntryEditor(editorFromEntry(entry, index))}
|
||||
disabledReason={!canWrite ? DIST_LISTS_I18N.writeReason : undefined}
|
||||
onClick={() => openEntryEditor(editorFromEntry(entry, index))}
|
||||
/>
|
||||
<DataGridRowActions
|
||||
disabled={!canWrite}
|
||||
onAddBelow={() => setEntryEditor(emptyEntryEditor(index + 1))}
|
||||
onAddBelow={() => openEntryEditor(emptyEntryEditor(index + 1))}
|
||||
onRemove={() => removeEntry(index)}
|
||||
onMoveUp={index > 0 ? () => moveEntry(index, index - 1) : undefined}
|
||||
onMoveDown={index < draft.entries.length - 1 ? () => moveEntry(index, index + 1) : undefined}
|
||||
@@ -450,19 +521,9 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
|
||||
function saveEntryEditor() {
|
||||
if (!entryEditor) return;
|
||||
const entry = entryFromEditor(entryEditor);
|
||||
if (!entry) return;
|
||||
setDraft((current) => {
|
||||
const entries = [...current.entries];
|
||||
if (entryEditor.index !== null && entryEditor.index < entries.length) {
|
||||
entries[entryEditor.index] = entry;
|
||||
} else if (entryEditor.insertAt !== null) {
|
||||
entries.splice(Math.min(entryEditor.insertAt, entries.length), 0, entry);
|
||||
} else {
|
||||
entries.push(entry);
|
||||
}
|
||||
return { ...current, entries };
|
||||
});
|
||||
const nextDraft = draftWithEditorEntry(draft, entryEditor);
|
||||
if (!nextDraft) return;
|
||||
setDraft(nextDraft);
|
||||
setEntryEditor(null);
|
||||
}
|
||||
|
||||
@@ -478,6 +539,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
icon={<RefreshCw size={16} />}
|
||||
variant="ghost"
|
||||
disabled={loading || busy}
|
||||
disabledReason={loading ? DIST_LISTS_I18N.loading : busy ? DIST_LISTS_I18N.busy : undefined}
|
||||
onClick={() => void reload(selectedId)}
|
||||
/>
|
||||
<IconButton
|
||||
@@ -485,7 +547,8 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
icon={<Plus size={17} />}
|
||||
variant="primary"
|
||||
disabled={!canWrite}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabledReason={!canWrite ? DIST_LISTS_I18N.writeReason : undefined}
|
||||
onClick={() => requestDiscard(() => setCreateOpen(true))}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -526,6 +589,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
{selected ? <small>Revision {selected.current_revision} · {selected.scope_type} scope</small> : null}
|
||||
</span>
|
||||
<span className="dist-lists-toolbar-actions">
|
||||
<DocumentationHelpLink reference={DIST_LISTS_DOCUMENTATION} />
|
||||
<SegmentedControl<WorkspaceView>
|
||||
ariaLabel="Distribution-list workspace"
|
||||
value={view}
|
||||
@@ -539,6 +603,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!selected || !dirty || busy || !canWrite}
|
||||
disabledReason={busy ? DIST_LISTS_I18N.busy : !canWrite ? DIST_LISTS_I18N.writeReason : !selected ? DIST_LISTS_I18N.noSelection : !dirty ? DIST_LISTS_I18N.noChanges : undefined}
|
||||
onClick={() => void saveItem()}
|
||||
>
|
||||
<Save size={16} /> Save revision
|
||||
@@ -548,6 +613,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
icon={<Trash2 size={16} />}
|
||||
variant="danger"
|
||||
disabled={!selected || !canWrite}
|
||||
disabledReason={!canWrite ? DIST_LISTS_I18N.writeReason : !selected ? DIST_LISTS_I18N.noSelection : undefined}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
/>
|
||||
</span>
|
||||
@@ -567,13 +633,13 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
canWrite={canWrite}
|
||||
entryColumns={entryColumns}
|
||||
onChange={setDraft}
|
||||
onAddEntry={() => setEntryEditor(emptyEntryEditor(null))}
|
||||
onAddEntry={() => openEntryEditor(emptyEntryEditor(null))}
|
||||
/>
|
||||
) : view === "preview" ? (
|
||||
<div className="dist-lists-content">
|
||||
<section className="dist-lists-preview-controls">
|
||||
<div className="dist-lists-preview-inputs">
|
||||
<FormField label="Purpose" help="Purpose is passed to preference and Policy providers.">
|
||||
<FormField label="Purpose" help="Purpose is passed to preference and Policy providers." documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||
<input value={previewPurpose} onChange={(event) => setPreviewPurpose(event.target.value)} placeholder="Optional purpose" />
|
||||
</FormField>
|
||||
{selected.revision.parameters.map((parameter) => (
|
||||
@@ -589,15 +655,15 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || dirty}
|
||||
disabledReason={dirty ? "Save the current revision before expanding it." : undefined}
|
||||
disabledReason={busy ? DIST_LISTS_I18N.busy : dirty ? DIST_LISTS_I18N.saveBeforeExpansion : undefined}
|
||||
onClick={() => void runPreview(false)}
|
||||
>
|
||||
<Eye size={16} /> Expand preview
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || dirty}
|
||||
disabledReason={dirty ? "Save the current revision before freezing a snapshot." : undefined}
|
||||
onClick={() => void runPreview(true)}
|
||||
disabledReason={busy ? DIST_LISTS_I18N.busy : dirty ? DIST_LISTS_I18N.saveBeforeExpansion : undefined}
|
||||
onClick={() => setFreezeOpen(true)}
|
||||
>
|
||||
<Snowflake size={16} /> Freeze snapshot
|
||||
</Button>
|
||||
@@ -671,18 +737,18 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
title="New distribution list"
|
||||
onClose={() => !busy && setCreateOpen(false)}
|
||||
onClose={closeCreate}
|
||||
closeDisabled={busy}
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setCreateOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void createItem()} disabled={busy || !createName.trim()}>Create</Button>
|
||||
<Button onClick={closeCreate} disabled={busy} disabledReason={busy ? DIST_LISTS_I18N.busy : undefined}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void createItem()} disabled={busy || !createName.trim()} disabledReason={busy ? DIST_LISTS_I18N.busy : !createName.trim() ? DIST_LISTS_I18N.incomplete : undefined}>Create</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="dist-lists-dialog-form">
|
||||
<FormField label="Name"><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
|
||||
<FormField label="Definition type">
|
||||
<FormField label="Name" documentation={DIST_LISTS_FIELD_DOCUMENTATION}><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
|
||||
<FormField label="Definition type" documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||
<select value={createKind} onChange={(event) => setCreateKind(event.target.value as DefinitionKind)}>
|
||||
<option value="static">Static</option>
|
||||
<option value="parameterized">Parameterized</option>
|
||||
@@ -700,12 +766,38 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
||||
loadProviders={loadProviders}
|
||||
resolveProvider={(key) => providerCache.current.get(key) ?? null}
|
||||
onChange={setEntryEditor}
|
||||
onClose={() => setEntryEditor(null)}
|
||||
onClose={closeEntryEditor}
|
||||
onSave={saveEntryEditor}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={discardEntryOpen}
|
||||
title="i18n:govoplan-dist-lists.entry_unsaved_title"
|
||||
message="i18n:govoplan-dist-lists.entry_discard_message"
|
||||
confirmLabel="Discard entry changes"
|
||||
tone="danger"
|
||||
onConfirm={() => {
|
||||
setDiscardEntryOpen(false);
|
||||
setEntryEditor(null);
|
||||
}}
|
||||
onCancel={() => setDiscardEntryOpen(false)}
|
||||
/>
|
||||
|
||||
<ExplanationDialog target={explanationTarget} onClose={() => setExplanationTarget(null)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={freezeOpen}
|
||||
title="i18n:govoplan-dist-lists.snapshot_title"
|
||||
message="i18n:govoplan-dist-lists.snapshot_message"
|
||||
confirmLabel="Freeze snapshot"
|
||||
busy={busy}
|
||||
onConfirm={() => {
|
||||
setFreezeOpen(false);
|
||||
void runPreview(true);
|
||||
}}
|
||||
onCancel={() => setFreezeOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
title="Delete distribution list"
|
||||
@@ -736,10 +828,10 @@ function DefinitionEditor({
|
||||
return (
|
||||
<div className="dist-lists-content">
|
||||
<section className="dist-lists-definition-fields">
|
||||
<FormField label="Name">
|
||||
<FormField label="Name" documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.name} disabled={!canWrite} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Definition type">
|
||||
<FormField label="Definition type" documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||
<select value={draft.definition_kind} disabled={!canWrite} onChange={(event) => onChange({ ...draft, definition_kind: event.target.value as DefinitionKind })}>
|
||||
<option value="static">Static</option>
|
||||
<option value="parameterized">Parameterized</option>
|
||||
@@ -747,7 +839,7 @@ function DefinitionEditor({
|
||||
<option value="template">Template (not runnable)</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Default channel" help="Used only when no preference provider is available and several candidates exist.">
|
||||
<FormField label="Default channel" help="Used only when no preference provider is available and several candidates exist." documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={String(draft.constraints.default_channel ?? "email")}
|
||||
disabled={!canWrite}
|
||||
@@ -880,7 +972,7 @@ function EntryEditorDialog({
|
||||
/>
|
||||
</FormField>
|
||||
{state.sourceMode === "provider" ? (
|
||||
<FormField label="Provider object" help="Searches only enabled providers and pins their current revision/fingerprint.">
|
||||
<FormField label="Provider object" help="Searches only enabled providers and pins their current revision/fingerprint." documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||
<SearchableSelect
|
||||
value={state.providerKey}
|
||||
selectedOption={state.providerOption ? providerSearchOption(state.providerOption) : null}
|
||||
@@ -910,7 +1002,7 @@ function EntryEditorDialog({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="dist-lists-form-grid">
|
||||
<FormField label="Mode">
|
||||
<FormField label="Mode" documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||
<select value={state.mode} onChange={(event) => onChange({ ...state, mode: event.target.value as EntryMode })}>
|
||||
<option value="include">Include</option>
|
||||
<option value="exclude">Exclude</option>
|
||||
@@ -920,14 +1012,14 @@ function EntryEditorDialog({
|
||||
<FormField label="Display label"><input value={state.label} onChange={(event) => onChange({ ...state, label: event.target.value })} /></FormField>
|
||||
<FormField label="Purpose"><input value={state.purpose} onChange={(event) => onChange({ ...state, purpose: event.target.value })} placeholder="All purposes" /></FormField>
|
||||
{state.mode === "override" ? (
|
||||
<FormField label="Override reason" help="Manual overrides require an auditable explanation.">
|
||||
<FormField label="Override reason" help="Manual overrides require an auditable explanation." documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||
<input value={state.overrideReason} onChange={(event) => onChange({ ...state, overrideReason: event.target.value })} required />
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField label="Effective from">
|
||||
<FormField label="Effective from" documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||
<input type="datetime-local" value={state.effectiveFrom} onChange={(event) => onChange({ ...state, effectiveFrom: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Effective until">
|
||||
<FormField label="Effective until" documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||
<input type="datetime-local" value={state.effectiveUntil} onChange={(event) => onChange({ ...state, effectiveUntil: event.target.value })} />
|
||||
</FormField>
|
||||
{isAddressProviderEntry(state) ? (
|
||||
@@ -1198,6 +1290,35 @@ function entryFromEditor(editor: EntryEditorState): EntryDraft | null {
|
||||
};
|
||||
}
|
||||
|
||||
function draftWithEditorEntry(draft: ListDraft, editor: EntryEditorState): ListDraft | null {
|
||||
const entry = entryFromEditor(editor);
|
||||
if (!entry) return null;
|
||||
const entries = [...draft.entries];
|
||||
if (editor.index !== null && editor.index < entries.length) {
|
||||
entries[editor.index] = {
|
||||
...entry,
|
||||
clientId: entries[editor.index]?.clientId ?? entry.clientId
|
||||
};
|
||||
} else if (editor.insertAt !== null) {
|
||||
entries.splice(Math.min(editor.insertAt, entries.length), 0, entry);
|
||||
} else {
|
||||
entries.push(entry);
|
||||
}
|
||||
return { ...draft, entries };
|
||||
}
|
||||
|
||||
function entryEditorKey(editor: EntryEditorState): string {
|
||||
return JSON.stringify(editor, (_key, value) => {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
function directKind(mode: DirectSourceMode): EntryKind {
|
||||
if (mode === "email") return "raw_email";
|
||||
if (mode === "postal") return "raw_postal_address";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||
|
||||
export const DIST_LISTS_DOCUMENTATION = {
|
||||
topicId: "dist_lists.boundary",
|
||||
documentationType: "user"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const DIST_LISTS_FIELD_DOCUMENTATION = {
|
||||
topicId: "dist_lists.reference.fields-and-consequences",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const DIST_LISTS_I18N = {
|
||||
loading: "i18n:govoplan-dist-lists.loading_reason",
|
||||
busy: "i18n:govoplan-dist-lists.busy_reason",
|
||||
writeReason: "i18n:govoplan-dist-lists.write_permission_reason",
|
||||
noSelection: "i18n:govoplan-dist-lists.no_selection_reason",
|
||||
noChanges: "i18n:govoplan-dist-lists.no_changes_reason",
|
||||
incomplete: "i18n:govoplan-dist-lists.incomplete_reason",
|
||||
saveBeforeExpansion: "i18n:govoplan-dist-lists.save_before_expansion"
|
||||
} as const;
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
const en = {
|
||||
"i18n:govoplan-dist-lists.distribution_lists": "Distribution Lists",
|
||||
"i18n:govoplan-dist-lists.editor": "Distribution-list editor",
|
||||
"i18n:govoplan-dist-lists.preview": "Distribution-list expansion preview",
|
||||
"i18n:govoplan-dist-lists.picker": "Distribution-list picker",
|
||||
"i18n:govoplan-dist-lists.loading_reason": "Distribution Lists are still loading.",
|
||||
"i18n:govoplan-dist-lists.busy_reason": "Another Distribution List action is still running.",
|
||||
"i18n:govoplan-dist-lists.write_permission_reason": "Your account may not create or change Distribution Lists.",
|
||||
"i18n:govoplan-dist-lists.no_selection_reason": "Select or create a Distribution List first.",
|
||||
"i18n:govoplan-dist-lists.no_changes_reason": "There are no definition changes to save.",
|
||||
"i18n:govoplan-dist-lists.incomplete_reason": "Complete the required name, source, and override-reason fields first.",
|
||||
"i18n:govoplan-dist-lists.save_before_expansion": "Save the current revision before expanding or freezing it.",
|
||||
"i18n:govoplan-dist-lists.unsaved_title": "Unsaved Distribution List",
|
||||
"i18n:govoplan-dist-lists.unsaved_message": "Save or discard the Distribution List draft before leaving this surface.",
|
||||
"i18n:govoplan-dist-lists.entry_unsaved_title": "Unsaved audience entry",
|
||||
"i18n:govoplan-dist-lists.entry_unsaved_message": "Save the complete Distribution List revision or discard all changes before leaving this surface.",
|
||||
"i18n:govoplan-dist-lists.entry_discard_message": "Discard the changes made in this audience entry? Other changes to the Distribution List draft are kept.",
|
||||
"i18n:govoplan-dist-lists.snapshot_title": "Freeze recipient snapshot",
|
||||
"i18n:govoplan-dist-lists.snapshot_message": "Freeze this expansion? The exact recipients, exclusions, source revisions, decisions, provenance, and hash become immutable evidence for consumers.",
|
||||
"Distribution Lists": "Distribution Lists",
|
||||
"Refresh": "Refresh",
|
||||
"New distribution list": "New distribution list",
|
||||
"Search lists": "Search lists",
|
||||
"Search distribution lists": "Search distribution lists",
|
||||
"No distribution lists": "No distribution lists",
|
||||
"No distribution list selected": "No distribution list selected",
|
||||
"Definition": "Definition",
|
||||
"Preview": "Preview",
|
||||
"Snapshots": "Snapshots",
|
||||
"Save revision": "Save revision",
|
||||
"Delete distribution list": "Delete distribution list",
|
||||
"Purpose": "Purpose",
|
||||
"Expand preview": "Expand preview",
|
||||
"Freeze snapshot": "Freeze snapshot",
|
||||
"Included": "Included",
|
||||
"Excluded": "Excluded",
|
||||
"Providers": "Providers",
|
||||
"Source state": "Source state",
|
||||
"Frozen snapshots": "Frozen snapshots",
|
||||
"Refresh snapshots": "Refresh snapshots",
|
||||
"No frozen snapshots.": "No frozen snapshots.",
|
||||
"Cancel": "Cancel",
|
||||
"Create": "Create",
|
||||
"Name": "Name",
|
||||
"Definition type": "Definition type",
|
||||
"Static": "Static",
|
||||
"Parameterized": "Parameterized",
|
||||
"Dynamic": "Dynamic",
|
||||
"Template (not runnable)": "Template (not runnable)",
|
||||
"Add audience entry": "Add audience entry",
|
||||
"Edit audience entry": "Edit audience entry",
|
||||
"Apply": "Apply",
|
||||
"Source type": "Source type",
|
||||
"Provider": "Provider",
|
||||
"Email": "Email",
|
||||
"Postal": "Postal",
|
||||
"Internal": "Internal",
|
||||
"Portal": "Portal",
|
||||
"Provider object": "Provider object",
|
||||
"Mode": "Mode",
|
||||
"Display label": "Display label",
|
||||
"Override reason": "Override reason",
|
||||
"Effective from": "Effective from",
|
||||
"Effective until": "Effective until",
|
||||
"Requested channels": "Requested channels",
|
||||
"Delete": "Delete",
|
||||
"Discard entry changes": "Discard entry changes"
|
||||
} as const;
|
||||
|
||||
const de: Record<keyof typeof en, string> = {
|
||||
"i18n:govoplan-dist-lists.distribution_lists": "Verteilerlisten",
|
||||
"i18n:govoplan-dist-lists.editor": "Verteilerlisten-Editor",
|
||||
"i18n:govoplan-dist-lists.preview": "Vorschau der Verteilerauflösung",
|
||||
"i18n:govoplan-dist-lists.picker": "Auswahl einer Verteilerliste",
|
||||
"i18n:govoplan-dist-lists.loading_reason": "Verteilerlisten werden noch geladen.",
|
||||
"i18n:govoplan-dist-lists.busy_reason": "Eine andere Verteilerlistenaktion läuft noch.",
|
||||
"i18n:govoplan-dist-lists.write_permission_reason": "Ihr Konto darf Verteilerlisten nicht erstellen oder ändern.",
|
||||
"i18n:govoplan-dist-lists.no_selection_reason": "Wählen oder erstellen Sie zuerst eine Verteilerliste.",
|
||||
"i18n:govoplan-dist-lists.no_changes_reason": "Es gibt keine Definitionsänderungen zu speichern.",
|
||||
"i18n:govoplan-dist-lists.incomplete_reason": "Füllen Sie zuerst Name, Quelle und gegebenenfalls Überschreibungsgrund aus.",
|
||||
"i18n:govoplan-dist-lists.save_before_expansion": "Speichern Sie die aktuelle Revision, bevor Sie sie auflösen oder einfrieren.",
|
||||
"i18n:govoplan-dist-lists.unsaved_title": "Ungespeicherte Verteilerliste",
|
||||
"i18n:govoplan-dist-lists.unsaved_message": "Speichern oder verwerfen Sie den Verteilerlistenentwurf, bevor Sie diese Oberfläche verlassen.",
|
||||
"i18n:govoplan-dist-lists.entry_unsaved_title": "Ungespeicherter Zielgruppeneintrag",
|
||||
"i18n:govoplan-dist-lists.entry_unsaved_message": "Speichern Sie die vollständige Verteilerlistenrevision oder verwerfen Sie alle Änderungen, bevor Sie diese Oberfläche verlassen.",
|
||||
"i18n:govoplan-dist-lists.entry_discard_message": "Änderungen an diesem Zielgruppeneintrag verwerfen? Andere Änderungen am Verteilerlistenentwurf bleiben erhalten.",
|
||||
"i18n:govoplan-dist-lists.snapshot_title": "Empfänger-Snapshot einfrieren",
|
||||
"i18n:govoplan-dist-lists.snapshot_message": "Diese Auflösung einfrieren? Exakte Empfänger, Ausschlüsse, Quellrevisionen, Entscheidungen, Provenienz und Hash werden unveränderlicher Nachweis für Verbraucher.",
|
||||
"Distribution Lists": "Verteilerlisten",
|
||||
"Refresh": "Aktualisieren",
|
||||
"New distribution list": "Neue Verteilerliste",
|
||||
"Search lists": "Listen suchen",
|
||||
"Search distribution lists": "Verteilerlisten durchsuchen",
|
||||
"No distribution lists": "Keine Verteilerlisten",
|
||||
"No distribution list selected": "Keine Verteilerliste ausgewählt",
|
||||
"Definition": "Definition",
|
||||
"Preview": "Vorschau",
|
||||
"Snapshots": "Snapshots",
|
||||
"Save revision": "Revision speichern",
|
||||
"Delete distribution list": "Verteilerliste löschen",
|
||||
"Purpose": "Zweck",
|
||||
"Expand preview": "Vorschau auflösen",
|
||||
"Freeze snapshot": "Snapshot einfrieren",
|
||||
"Included": "Enthalten",
|
||||
"Excluded": "Ausgeschlossen",
|
||||
"Providers": "Anbieter",
|
||||
"Source state": "Quellstatus",
|
||||
"Frozen snapshots": "Eingefrorene Snapshots",
|
||||
"Refresh snapshots": "Snapshots aktualisieren",
|
||||
"No frozen snapshots.": "Keine eingefrorenen Snapshots.",
|
||||
"Cancel": "Abbrechen",
|
||||
"Create": "Erstellen",
|
||||
"Name": "Name",
|
||||
"Definition type": "Definitionsart",
|
||||
"Static": "Statisch",
|
||||
"Parameterized": "Parametrisiert",
|
||||
"Dynamic": "Dynamisch",
|
||||
"Template (not runnable)": "Vorlage (nicht ausführbar)",
|
||||
"Add audience entry": "Zielgruppeneintrag hinzufügen",
|
||||
"Edit audience entry": "Zielgruppeneintrag bearbeiten",
|
||||
"Apply": "Übernehmen",
|
||||
"Source type": "Quellenart",
|
||||
"Provider": "Anbieter",
|
||||
"Email": "E-Mail",
|
||||
"Postal": "Post",
|
||||
"Internal": "Intern",
|
||||
"Portal": "Portal",
|
||||
"Provider object": "Anbieterobjekt",
|
||||
"Mode": "Modus",
|
||||
"Display label": "Anzeigename",
|
||||
"Override reason": "Überschreibungsgrund",
|
||||
"Effective from": "Gültig ab",
|
||||
"Effective until": "Gültig bis",
|
||||
"Requested channels": "Angeforderte Kanäle",
|
||||
"Delete": "Löschen",
|
||||
"Discard entry changes": "Eintragsänderungen verwerfen"
|
||||
};
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||
+11
-2
@@ -1,5 +1,6 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/dist-lists.css";
|
||||
|
||||
const DistributionListsPage = lazy(
|
||||
@@ -14,7 +15,7 @@ const readScopes = [
|
||||
|
||||
export const distributionListsModule: PlatformWebModule = {
|
||||
id: "dist_lists",
|
||||
label: "Distribution Lists",
|
||||
label: "i18n:govoplan-dist-lists.distribution_lists",
|
||||
version: "0.1.14",
|
||||
optionalDependencies: [
|
||||
"addresses",
|
||||
@@ -24,10 +25,17 @@ export const distributionListsModule: PlatformWebModule = {
|
||||
"dataflow",
|
||||
"policy"
|
||||
],
|
||||
translations: generatedTranslations,
|
||||
viewSurfaces: [
|
||||
{ id: "dist_lists.page", moduleId: "dist_lists", kind: "route", label: "i18n:govoplan-dist-lists.distribution_lists", order: 74 },
|
||||
{ id: "dist_lists.editor", moduleId: "dist_lists", kind: "section", label: "i18n:govoplan-dist-lists.editor", parentId: "dist_lists.page", order: 10 },
|
||||
{ id: "dist_lists.preview", moduleId: "dist_lists", kind: "section", label: "i18n:govoplan-dist-lists.preview", parentId: "dist_lists.page", order: 20 },
|
||||
{ id: "dist_lists.picker", moduleId: "dist_lists", kind: "action", label: "i18n:govoplan-dist-lists.picker", order: 30 }
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/distribution-lists",
|
||||
label: "Distribution Lists",
|
||||
label: "i18n:govoplan-dist-lists.distribution_lists",
|
||||
iconName: "list-tree",
|
||||
anyOf: readScopes,
|
||||
order: 74
|
||||
@@ -38,6 +46,7 @@ export const distributionListsModule: PlatformWebModule = {
|
||||
path: "/distribution-lists",
|
||||
anyOf: readScopes,
|
||||
order: 74,
|
||||
surfaceId: "dist_lists.page",
|
||||
render: ({ settings, auth }) =>
|
||||
createElement(DistributionListsPage, { settings, auth })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user