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 -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"
]
}