feat(imports): resume persisted address runs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.");
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user