Migrate Distribution Lists interface patterns

This commit is contained in:
2026-08-03 13:28:05 +02:00
parent 27a790976e
commit 6cdd804039
8 changed files with 462 additions and 45 deletions
+32
View File
@@ -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.
+46 -1
View File
@@ -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;
+141
View File
@@ -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
View File
@@ -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 })
}