feat(imports): resume persisted address runs

This commit is contained in:
2026-08-19 20:39:21 +02:00
parent 7e5c1cf10c
commit ec32ad2c37
13 changed files with 396 additions and 21 deletions
+2
View File
@@ -142,11 +142,13 @@ dist
.policy-test-build/
.template-preview-test-build/
.import-test-build/
.import-run-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
webui/.import-run-test-build/
# ---> Python
# Byte-compiled / optimized / DLL files
+8
View File
@@ -207,6 +207,14 @@ matches its recorded post-apply hash. Arbitrary transforms remain Dataflow's
responsibility; Files and Datasources are optional origins, not prerequisites
for direct upload.
Persisted import runs can be resumed through `/address-book?import_run=<id>`.
The WebUI reloads the bounded run projection, selects its address book and
mapping version, and restores statistics, diagnostics, effects, and lifecycle
state. Apply and rollback both carry the reviewed plan hash. The normal read
projection never includes uploaded bytes or private before-images, and an
unknown, expired, hidden, or cross-tenant id is presented as one unavailable
state so the deep link cannot enumerate another tenant's imports.
## Quality, Deduplication, And Recovery
Quality is evidence about a concrete contact point, separate from communication
@@ -151,6 +151,7 @@ class AddressImportCommitRequest(BaseModel):
class AddressImportRollbackRequest(BaseModel):
expected_plan_hash: str = Field(min_length=64, max_length=64)
reason: str = Field(min_length=3, max_length=2000)
@@ -336,6 +336,8 @@ def rollback_address_import(
payload: AddressImportRollbackRequest,
) -> AddressImportRun:
run = get_import_run(session, principal, run_id)
if run.plan_hash != payload.expected_plan_hash:
raise AddressBookError("The reviewed import plan changed; reload the import run.")
if run.status == "rolled_back":
return run
if run.status != "applied":
+5 -2
View File
@@ -446,8 +446,11 @@ manifest = ModuleManifest(
"CSV and XLSX files can be mapped with scoped, reusable profile versions. Each preview validates headers, "
"encodings, source keys, duplicates, blank values, workbook limits, and contact identity before any mutation. "
"The reviewed input hash and plan hash are retained with row-level effects and diagnostics. Apply is idempotent, "
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. XLSX formulas, "
"macros, and legacy workbook formats are never executed or imported."
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. A persisted run "
"can be reopened with its run link after navigation or reload; previewed, applied, rolled-back, expired, and "
"unavailable states remain explicit. Both apply and rollback submit the reviewed plan hash. Missing, expired, "
"hidden, and cross-tenant runs disclose no source payload. XLSX formulas, macros, and legacy workbook formats "
"are never executed or imported."
),
layer="configured",
documentation_types=("admin", "user"),
+66 -1
View File
@@ -19,6 +19,7 @@ from govoplan_addresses.backend.import_schemas import (
from govoplan_addresses.backend.imports import (
apply_address_import,
create_import_profile,
get_import_run,
import_run_payload,
preview_address_import,
rollback_address_import,
@@ -44,6 +45,12 @@ class Principal:
}
class OtherTenantPrincipal(Principal):
@property
def tenant_id(self) -> str:
return "tenant-2"
def encoded(value: str) -> str:
return base64.b64encode(value.encode()).decode()
@@ -121,11 +128,69 @@ class AddressTabularImportTests(unittest.TestCase):
self.session,
self.principal,
run.id,
AddressImportRollbackRequest(reason="The operator selected the wrong monthly file."),
AddressImportRollbackRequest(
expected_plan_hash=run.plan_hash,
reason="The operator selected the wrong monthly file.",
),
)
self.assertEqual("rolled_back", rolled_back.status)
self.assertEqual(0, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
def test_rollback_rejects_a_stale_review_hash(self) -> None:
run = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="contacts.csv",
content_base64=encoded(
"id;first;last;email;organization\n"
"1;Ada;Lovelace;ada@example.test;Analysis Office\n"
),
),
)
apply_address_import(
self.session,
self.principal,
run.id,
expected_plan_hash=run.plan_hash,
)
with self.assertRaisesRegex(ValueError, "reviewed import plan changed"):
rollback_address_import(
self.session,
self.principal,
run.id,
AddressImportRollbackRequest(
expected_plan_hash="0" * 64,
reason="The operator selected the wrong monthly file.",
),
)
def test_persisted_run_read_is_tenant_bounded_and_source_safe(self) -> None:
source = "id;first;last;email;organization\n1;Ada;Lovelace;ada@example.test;Analysis Office\n"
run = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="contacts.csv",
content_base64=encoded(source),
),
)
self.session.flush()
payload = import_run_payload(get_import_run(self.session, self.principal, run.id))
self.assertEqual("previewed", payload["status"])
self.assertEqual(run.plan_hash, payload["plan_hash"])
self.assertNotIn("plan_data", payload)
self.assertNotIn(source, repr(payload))
with self.assertRaisesRegex(ValueError, "not found"):
get_import_run(self.session, OtherTenantPrincipal(), run.id)
def test_duplicate_keys_and_changed_targets_block_apply(self) -> None:
duplicate = preview_address_import(
self.session,
+2 -1
View File
@@ -14,7 +14,8 @@
"./styles/addresses.css": "./src/styles/addresses.css"
},
"scripts": {
"test:ui-structure": "node scripts/test-selection-list-structure.mjs"
"test:ui-structure": "node scripts/test-selection-list-structure.mjs",
"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",
+8 -4
View File
@@ -1100,17 +1100,21 @@ export function previewAddressImport(
});
}
export function getAddressImportRun(settings: ApiSettings, runId: string): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${encodeURIComponent(runId)}`);
}
export function applyAddressImport(settings: ApiSettings, run: AddressImportRun): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${run.id}/apply`, {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${encodeURIComponent(run.id)}/apply`, {
method: "POST",
body: JSON.stringify({ expected_plan_hash: run.plan_hash })
});
}
export function rollbackAddressImport(settings: ApiSettings, runId: string, reason: string): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${runId}/rollback`, {
export function rollbackAddressImport(settings: ApiSettings, run: AddressImportRun, reason: string): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${encodeURIComponent(run.id)}/rollback`, {
method: "POST",
body: JSON.stringify({ reason })
body: JSON.stringify({ expected_plan_hash: run.plan_hash, reason })
});
}
@@ -1,6 +1,7 @@
import { MetricGrid } from "@govoplan/core-webui";
import { Download, Edit3, GitMerge, History, Link2, Network, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import { useSearchParams } from "react-router";
import { DialogSection, DialogForm, FormGrid, ActionToolbar,
ApiError,
ActionBlockerHint,
@@ -51,6 +52,7 @@ import {
endContactChannelRule,
exportAddressBookVcards,
exportContactVcard,
getAddressImportRun,
importAddressBookVcards,
applyAddressImport,
getAddressQualitySummary,
@@ -71,6 +73,7 @@ import {
listContactProvenance,
previewAddressSyncSource,
previewAddressImport,
rollbackAddressImport,
mergeContacts,
recoverContactMerge,
restoreAddressBook,
@@ -114,6 +117,12 @@ import {
ADDRESSES_DOCUMENTATION,
ADDRESSES_I18N
} from "./interfacePatterns";
import {
importRunIdFromSearch,
importRunLifecycle,
unavailableImportRunMessage,
withImportRunSearch
} from "./importRunState";
type Props = {
settings: ApiSettings;
@@ -922,6 +931,8 @@ function formKey(value: unknown): string {
}
export default function AddressBookPage({ settings, auth, onAuthChange }: Props) {
const [searchParams, setSearchParams] = useSearchParams();
const requestedImportRunId = importRunIdFromSearch(searchParams);
const [books, setBooks] = useState<AddressBook[]>([]);
const [addressLists, setAddressLists] = useState<AddressList[]>([]);
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
@@ -980,6 +991,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [importProfileForm, setImportProfileForm] = useState<ImportProfileFormState>(EMPTY_IMPORT_PROFILE_FORM);
const [importFile, setImportFile] = useState<File | null>(null);
const [importRun, setImportRun] = useState<AddressImportRun | null>(null);
const [importRunLoading, setImportRunLoading] = useState(false);
const [importRunUnavailable, setImportRunUnavailable] = useState("");
const [importRollbackOpen, setImportRollbackOpen] = useState(false);
const [importRollbackReason, setImportRollbackReason] = useState("");
const [cardDavOpen, setCardDavOpen] = useState(false);
const [cardDavForm, setCardDavForm] = useState<CardDavFormState>(EMPTY_CARDDAV_FORM);
const [cardDavDiscovery, setCardDavDiscovery] = useState<AddressCardDavAddressBook[]>([]);
@@ -1077,13 +1092,41 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
if (!active) return;
setImportProfiles(profiles);
setSelectedImportProfileId((current) => current || profiles[0]?.id || "");
setCreatingImportProfile(profiles.length === 0);
setCreatingImportProfile(profiles.length === 0 && !requestedImportRunId);
})
.catch((err) => {
if (active) setError(errorMessage(err));
});
return () => { active = false; };
}, [importMode, importOpen, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
}, [importMode, importOpen, requestedImportRunId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (!requestedImportRunId) return;
let active = true;
setImportOpen(true);
setImportMode("tabular");
setCreatingImportProfile(false);
setImportRunLoading(true);
setImportRunUnavailable("");
getAddressImportRun(settings, requestedImportRunId)
.then((run) => {
if (!active) return;
setImportRun(run);
setSelectedBookId(run.address_book_id);
setSelectedImportProfileId(run.profile_id);
})
.catch((err) => {
if (!active) return;
setImportRun(null);
setImportRunUnavailable(
unavailableImportRunMessage(err instanceof ApiError ? err.status : undefined)
);
})
.finally(() => {
if (active) setImportRunLoading(false);
});
return () => { active = false; };
}, [requestedImportRunId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (auth.groups_loaded || !onAuthChange) return;
@@ -1095,6 +1138,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}, [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 importLifecycle = importRun ? importRunLifecycle(importRun.status) : null;
const selectedList = addressLists.find((list) => list.id === selectedListId) ?? null;
const selectedBookSyncSources = useMemo(
() => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [],
@@ -1261,7 +1305,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
[saving, savingReason],
[!importRun, "Preview the import first."],
[!importRun?.can_apply, "Resolve all import diagnostics before applying."],
[importRun?.status !== "previewed", "This import plan is no longer pending."]
[!importLifecycle?.canApply, "This import plan is no longer pending."]
);
const tabularRollbackReason = disabledReason(
[saving, savingReason],
[!importRun, "Load an applied import run before rolling it back."],
[!importLifecycle?.canRollback, "Only an applied import run can be rolled back."],
[importRollbackReason.trim().length < 3, "Record why this import is being rolled back."]
);
const connectLdapReason = disabledReason(
[!selectedBook, "Select an address book before connecting LDAP."],
@@ -2263,8 +2313,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}
function openImportDialog() {
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
setImportMode("vcard");
setImportRun(null);
setImportRunUnavailable("");
setImportFile(null);
setCreatingImportProfile(false);
setEditingImportProfileId("");
@@ -2272,6 +2324,46 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
setImportOpen(true);
}
function closeImportDialog() {
setImportOpen(false);
setImportRun(null);
setImportRunUnavailable("");
setImportRollbackOpen(false);
setImportRollbackReason("");
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
}
function retainImportRun(run: AddressImportRun) {
setImportRun(run);
setImportRunUnavailable("");
setSearchParams(withImportRunSearch(searchParams, run.id), { replace: true });
}
function clearRetainedImportRun() {
setImportRun(null);
setImportRunUnavailable("");
setSearchParams(withImportRunSearch(searchParams, null), { replace: true });
}
async function reloadImportRun() {
if (!importRun?.id && !requestedImportRunId) return;
setImportRunLoading(true);
setImportRunUnavailable("");
try {
const run = await getAddressImportRun(settings, importRun?.id || requestedImportRunId);
retainImportRun(run);
setSelectedBookId(run.address_book_id);
setSelectedImportProfileId(run.profile_id);
} catch (err) {
setImportRun(null);
setImportRunUnavailable(
unavailableImportRunMessage(err instanceof ApiError ? err.status : undefined)
);
} finally {
setImportRunLoading(false);
}
}
async function saveImportProfile() {
if (!selectedBook) return;
setSaving(true);
@@ -2315,7 +2407,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
setSelectedImportProfileId(profile.id);
setCreatingImportProfile(false);
setEditingImportProfileId("");
setImportRun(null);
clearRetainedImportRun();
setNotice(`Saved import mapping "${profile.name}" version ${profile.version}.`);
} catch (err) {
setError(errorMessage(err));
@@ -2328,7 +2420,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
setEditingImportProfileId("");
setImportProfileForm(EMPTY_IMPORT_PROFILE_FORM);
setCreatingImportProfile(true);
setImportRun(null);
clearRetainedImportRun();
}
function editSelectedImportProfile() {
@@ -2352,7 +2444,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
field_mappings: { ...config.field_mappings }
});
setCreatingImportProfile(true);
setImportRun(null);
clearRetainedImportRun();
}
function cancelImportProfileEditor() {
@@ -2383,7 +2475,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
filename: importFile.name,
content_base64: content
});
setImportRun(run);
retainImportRun(run);
setNotice(`Previewed ${run.row_count} row${run.row_count === 1 ? "" : "s"}.`);
} catch (err) {
setError(errorMessage(err));
@@ -2399,7 +2491,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
setNotice("");
try {
const run = await applyAddressImport(settings, importRun);
setImportRun(run);
retainImportRun(run);
setNotice(`Applied import plan: ${run.statistics.create ?? 0} created, ${run.statistics.update ?? 0} updated.`);
await refreshBooks();
await refreshContacts(selectedBook.id, query);
@@ -2410,6 +2502,26 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}
}
async function rollbackTabularImport() {
if (!importRun) return;
setSaving(true);
setError("");
setNotice("");
try {
const run = await rollbackAddressImport(settings, importRun, importRollbackReason.trim());
retainImportRun(run);
setImportRollbackOpen(false);
setImportRollbackReason("");
setNotice("Rolled back the persisted import run.");
await refreshBooks();
await refreshContacts(run.address_book_id, query);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
function openCardDavDialog() {
setCardDavForm(EMPTY_CARDDAV_FORM);
setCardDavDiscovery([]);
@@ -3662,13 +3774,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Dialog
open={importOpen}
title="Import contacts"
onClose={() => setImportOpen(false)}
onClose={closeImportDialog}
closeDisabled={saving}
className="address-import-dialog"
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setImportOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="button" onClick={closeImportDialog} disabledReason={dialogCancelReason}>Close</Button>
{importMode === "vcard" &&
<Button type="submit" form="address-vcard-import-form" variant="primary" disabledReason={vcardImportReason}><Upload size={16} /> Import</Button>}
{importMode === "tabular" && creatingImportProfile &&
@@ -3677,6 +3789,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="button" onClick={() => void previewTabularImport()} disabledReason={tabularPreviewReason}><Search size={16} /> Preview</Button>}
{importMode === "tabular" && !creatingImportProfile && importRun &&
<Button type="button" variant="primary" onClick={() => void applyTabularImport()} disabledReason={tabularApplyReason}><Upload size={16} /> Apply import</Button>}
{importMode === "tabular" && importLifecycle?.canRollback &&
<Button type="button" variant="danger" onClick={() => setImportRollbackOpen(true)} disabledReason={savingReason}><RotateCcw size={16} /> Roll back</Button>}
</>
}>
<DialogSection className="address-dialog-form">
@@ -3686,7 +3800,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
ariaLabel="Contact import format"
options={[{ id: "vcard", label: "vCard" }, { id: "tabular", label: "CSV / XLSX" }]}
value={importMode}
onChange={(mode) => { setImportMode(mode); setImportRun(null); }}
onChange={(mode) => { setImportMode(mode); clearRetainedImportRun(); }}
/>
{importMode === "vcard" &&
<DialogForm id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
@@ -3703,12 +3817,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</DialogForm>}
{importMode === "tabular" &&
<div className="address-import-workspace">
{(requestedImportRunId || importRun) &&
<div className="address-import-run-state">
<div>
<strong>Persisted import run</strong>
<span className="muted block">{importRun?.id ?? requestedImportRunId}</span>
</div>
{importRun && importLifecycle &&
<>
<StatusBadge status={importLifecycle.tone} label={importLifecycle.label} />
<p className="muted">{importLifecycle.guidance}</p>
</>}
<Button type="button" onClick={() => void reloadImportRun()} disabledReason={importRunLoading ? "The import run is loading." : undefined}>
<RefreshCw size={15} /> {importRunLoading ? "Loading…" : "Reload run"}
</Button>
</div>}
{importRunUnavailable &&
<DismissibleAlert tone="warning" resetKey={importRunUnavailable}>{importRunUnavailable}</DismissibleAlert>}
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Mapping profile">
<select
value={selectedImportProfileId}
disabled={creatingImportProfile}
onChange={(event) => { setSelectedImportProfileId(event.target.value); setImportRun(null); }}>
onChange={(event) => { setSelectedImportProfileId(event.target.value); clearRetainedImportRun(); }}>
<option value="">Select a saved mapping</option>
{importProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} · {profile.source_format.toUpperCase()} · v{profile.version}</option>)}
</select>
@@ -3784,11 +3915,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<input
type="file"
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); setImportRun(null); }}
onChange={(event) => { setImportFile(event.target.files?.[0] ?? null); clearRetainedImportRun(); }}
/>
</FormField>
{importRun &&
<div className="address-import-preview">
<p className="muted small-text">
{importRun.source_filename} · plan {importRun.plan_hash.slice(0, 12)} · updated {formatDateTime(importRun.updated_at)}
</p>
<div className="address-sync-plan-grid">
{(["create", "update", "unchanged", "ignored", "conflict", "errors"] as const).map((key) =>
<div key={key}><strong>{importRun.statistics[key] ?? 0}</strong><small>{key}</small></div>)}
@@ -3812,6 +3946,34 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</DialogSection>
</Dialog>
<Dialog
open={importRollbackOpen}
title="Roll back contact import"
onClose={() => setImportRollbackOpen(false)}
closeDisabled={saving}
footerClassName="button-row compact-actions"
footer={
<>
<Button type="button" onClick={() => setImportRollbackOpen(false)} disabledReason={dialogCancelReason}>Cancel</Button>
<Button type="button" variant="danger" onClick={() => void rollbackTabularImport()} disabledReason={tabularRollbackReason}><RotateCcw size={16} /> Roll back import</Button>
</>
}>
<DialogSection className="address-dialog-form">
<p className="muted">
Rollback is accepted only while every affected contact still matches the evidence recorded for plan {importRun?.plan_hash.slice(0, 12) ?? "-"}.
</p>
<FormField label="Reason">
<textarea
rows={3}
value={importRollbackReason}
onChange={(event) => setImportRollbackReason(event.target.value)}
placeholder="Why should this import be rolled back?"
autoFocus
/>
</FormField>
</DialogSection>
</Dialog>
<Dialog
open={cardDavOpen}
title="Connect CardDAV"
@@ -0,0 +1,72 @@
export type ImportRunLifecycle = {
label: string;
tone: "info" | "success" | "warning" | "inactive";
canApply: boolean;
canRollback: boolean;
guidance: string;
};
export function importRunIdFromSearch(search: URLSearchParams): string {
return search.get("import_run")?.trim() ?? "";
}
export function withImportRunSearch(search: URLSearchParams, runId?: string | null): URLSearchParams {
const next = new URLSearchParams(search);
const normalized = runId?.trim();
if (normalized) next.set("import_run", normalized);
else next.delete("import_run");
return next;
}
export function importRunLifecycle(status: string): ImportRunLifecycle {
if (status === "previewed") {
return {
label: "Ready for review",
tone: "info",
canApply: true,
canRollback: false,
guidance: "Review the persisted effects and diagnostics before applying this plan."
};
}
if (status === "applied") {
return {
label: "Applied",
tone: "success",
canApply: false,
canRollback: true,
guidance: "The plan has already been applied. Rollback remains guarded by its recorded plan hash and contact evidence."
};
}
if (status === "rolled_back") {
return {
label: "Rolled back",
tone: "inactive",
canApply: false,
canRollback: false,
guidance: "This run was rolled back and cannot be applied again. Create a new preview to import the source again."
};
}
if (status === "expired") {
return {
label: "Expired",
tone: "warning",
canApply: false,
canRollback: false,
guidance: "This preview is no longer actionable. Create a new preview from the original source."
};
}
return {
label: status || "Unavailable",
tone: "warning",
canApply: false,
canRollback: false,
guidance: "This run is not actionable in its current lifecycle state."
};
}
export function unavailableImportRunMessage(status?: number): string {
if (status === 404 || status === 410) {
return "This import run is missing, expired, or not available to your tenant. No source data was loaded.";
}
return "The persisted import run could not be loaded. Reload it after the service becomes available.";
}
+16
View File
@@ -163,6 +163,21 @@
min-height: 0;
}
.address-import-run-state {
align-items: center;
background: var(--panel-soft);
border: var(--border-line);
border-radius: var(--radius-compact);
display: grid;
gap: 10px 14px;
grid-template-columns: minmax(180px, 1fr) auto minmax(220px, 1.4fr) auto;
padding: 10px 12px;
}
.address-import-run-state p {
margin: 0;
}
.address-import-profile-actions {
align-items: end;
justify-content: flex-start;
@@ -811,6 +826,7 @@
@media (max-width: 1100px) {
.address-book-workspace,
.address-import-run-state,
.address-form-row-email,
.address-form-row-phone,
.address-form-row-postal {
+22
View File
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import {
importRunIdFromSearch,
importRunLifecycle,
unavailableImportRunMessage,
withImportRunSearch
} from "../src/features/addressbook/importRunState";
const initial = new URLSearchParams("q=Ada&import_run=run-123");
assert.equal(importRunIdFromSearch(initial), "run-123");
assert.equal(withImportRunSearch(initial, "run-456").toString(), "q=Ada&import_run=run-456");
assert.equal(withImportRunSearch(initial, null).toString(), "q=Ada");
assert.equal(importRunLifecycle("previewed").canApply, true);
assert.equal(importRunLifecycle("previewed").canRollback, false);
assert.equal(importRunLifecycle("applied").canApply, false);
assert.equal(importRunLifecycle("applied").canRollback, true);
assert.equal(importRunLifecycle("rolled_back").canRollback, false);
assert.equal(importRunLifecycle("expired").label, "Expired");
assert.match(unavailableImportRunMessage(404), /missing, expired, or not available to your tenant/);
console.log("Address import-run deep-link and lifecycle state tests passed.");
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"typeRoots": ["../../govoplan-core/webui/node_modules/@types"],
"types": ["node"],
"outDir": ".import-run-test-build"
},
"include": [
"src/features/addressbook/importRunState.ts",
"tests/import-run-state.test.ts"
]
}