feat(#359): W4a Phase 2 — zentraler Import/Export-Dialog (Frontend)

- ImportExportDialog.tsx (neu): Modal lg/xl nach bestehendem ui/Modal-Muster
- Export-Tab: Formatauswahl (csv/xlsx/json), Download, Fehler-Handling
- Import-Tab: 4 Schritte (Datei -> Mapping -> Dry-Run -> Ausführung+Report),
  Mapping-Vorschau mit Modul-Heuristik, Background-Job-Polling ab 1000 Zeilen
- i18n: 24 importexport.*-Keys in de.json + en.json (keine hardcoded Strings)
- Integration: ContactsList Toolbar-Button (contacts:read-Gate, Upload-Icon,
  entityType=contacts vorgewählt) über bestehendes pluginToolbarStore-Muster

Gates: Vitest 12/12 (routePermissions + importExportDialog), tsc exit 0,
Production-Build exit 0 (vor Commit). 6 Failures in contacts/shell Suiten
als Vorbestand bewiesen (Stash-Test: identisch auf clean HEAD f27f047).

fixes #359 (Phase 2)
This commit is contained in:
Agent Zero
2026-08-27 21:34:13 +02:00
parent cd8ef7500c
commit 38df597f11
5 changed files with 558 additions and 1 deletions
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* W4a Phase 2 (Spec #359): Central Import/Export dialog.
*
* Source-inspection based regression test — proves the dialog exists,
* is wired to the contacts toolbar, and covers the Spec flows
* (export 1 step, import 4 steps, background job polling).
* (Render-level tests of the AppShell have a known worker-hang issue,
* see PROGRESS.md — source inspection is the stable proof here.)
*/
const dialogSource = readFileSync(
join(__dirname, '..', '..', 'components', 'importexport', 'ImportExportDialog.tsx'),
'utf-8',
);
const contactsSource = readFileSync(
join(__dirname, '..', '..', 'pages', 'ContactsList.tsx'),
'utf-8',
);
describe('ImportExportDialog (W4a Phase 2)', () => {
it('dialog exists with Export and Import tabs', () => {
expect(dialogSource).toContain("'export'");
expect(dialogSource).toContain("'import'");
expect(dialogSource).toContain("data-testid=\"ie-tab-export\"");
expect(dialogSource).toContain("data-testid=\"ie-tab-import\"");
});
it('export tab offers csv, xlsx and json formats', () => {
expect(dialogSource).toContain("id: 'csv'");
expect(dialogSource).toContain("id: 'xlsx'");
expect(dialogSource).toContain("id: 'json'");
expect(dialogSource).toContain("data-testid=\"ie-export-download\"");
});
it('import flow covers all 4 spec steps', () => {
// Step 1: file
expect(dialogSource).toContain("data-testid=\"ie-file-input\"");
// Step 2: mapping
expect(dialogSource).toContain("data-testid=\"ie-import-validate\"");
expect(dialogSource).toContain('mapping_suggestion');
// Step 3: dry-run
expect(dialogSource).toContain("data-testid=\"ie-dry-valid\"");
expect(dialogSource).toContain("data-testid=\"ie-dry-invalid\"");
// Step 4: execution + report
expect(dialogSource).toContain("data-testid=\"ie-import-execute\"");
expect(dialogSource).toContain("data-testid=\"ie-import-result\"");
});
it('background job polling is implemented', () => {
expect(dialogSource).toContain('getImportJobStatus');
expect(dialogSource).toContain('setInterval');
});
it('contacts page opens the dialog via toolbar button', () => {
expect(contactsSource).toContain('ImportExportDialog');
expect(contactsSource).toContain("id: 'import-export'");
expect(contactsSource).toContain('setImportExportOpen(true)');
// Entity is pre-selected
expect(contactsSource).toContain('entityType="contacts"');
});
it('dialog uses the shared ui/Modal component', () => {
expect(dialogSource).toContain("from '@/components/ui/Modal'");
});
});
@@ -0,0 +1,414 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import {
CheckCircle2,
Download,
FileSpreadsheet,
FileText,
Loader2,
Upload,
XCircle,
} from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import {
exportData,
getImportJobStatus,
importCsv,
previewImport,
validateImport,
type ImportResult,
type JobStatus,
type PreviewResult,
type ValidateResult,
} from '@/api/importExport';
/**
* Central import/export dialog (W4a, Spec #359).
*
* Opened from every module list toolbar via the plugin's Toolbar-Button.
* The module is pre-selected; the dialog shows Export (1 step) and
* Import (4 steps: file → mapping → dry-run → execution + report).
*/
export interface ImportExportDialogProps {
open: boolean;
onClose: () => void;
/** Entity type identifier contributed by the module (e.g. 'contacts', 'companies'). */
entityType: string;
/** Default tab when opening. */
defaultTab?: 'export' | 'import';
}
const FORMATS = [
{ id: 'csv', label: 'CSV', icon: FileText },
{ id: 'xlsx', label: 'XLSX', icon: FileSpreadsheet },
{ id: 'json', label: 'JSON', icon: FileText },
] as const;
type ImportStep = 1 | 2 | 3 | 4;
export function ImportExportDialog({ open, onClose, entityType, defaultTab = 'export' }: ImportExportDialogProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<'export' | 'import'>(defaultTab);
// ── Export state ──
const [exportFormat, setExportFormat] = useState<string>('csv');
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
// ── Import state ──
const [step, setStep] = useState<ImportStep>(1);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [validateResult, setValidateResult] = useState<ValidateResult | null>(null);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [jobStatus, setJobStatus] = useState<JobStatus | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (open) {
setTab(defaultTab);
resetImport();
}
}, [open, defaultTab]);
useEffect(() => () => {
if (pollRef.current) clearInterval(pollRef.current);
}, []);
const resetImport = useCallback(() => {
setStep(1);
setFile(null);
setPreview(null);
setMapping({});
setValidateResult(null);
setImportResult(null);
setJobStatus(null);
setBusy(false);
setError(null);
}, []);
// ── Export ──
const handleExport = useCallback(async () => {
setExporting(true);
setExportError(null);
try {
await exportData(entityType, exportFormat);
} catch (err) {
setExportError(err instanceof Error ? err.message : t('common.error'));
} finally {
setExporting(false);
}
}, [entityType, exportFormat, t]);
// ── Import flow ──
const handleFile = useCallback(async (f: File | null) => {
setFile(f);
setPreview(null);
setMapping({});
setValidateResult(null);
if (!f) return;
setBusy(true);
setError(null);
try {
const result = await previewImport(f, entityType);
setPreview(result);
setMapping(result.mapping_suggestion);
setStep(2);
} catch (err) {
setError(err instanceof Error ? err.message : t('common.error'));
} finally {
setBusy(false);
}
}, [entityType, t]);
const handleValidate = useCallback(async () => {
if (!file) return;
setBusy(true);
setError(null);
try {
const result = await validateImport(file, entityType, mapping);
setValidateResult(result);
setStep(3);
} catch (err) {
setError(err instanceof Error ? err.message : t('common.error'));
} finally {
setBusy(false);
}
}, [entityType, file, mapping, t]);
const handleExecute = useCallback(async () => {
if (!file) return;
setBusy(true);
setError(null);
try {
const result = await importCsv(file, entityType, false, mapping);
if (result.status === 'pending' && result.job_id) {
// Background job — poll until done
setStep(4);
setImportResult(result);
pollRef.current = setInterval(async () => {
const status = await getImportJobStatus(result.job_id as string);
setJobStatus(status);
if (status.status === 'completed' || status.status === 'partial_success' || status.status === 'failed') {
if (pollRef.current) clearInterval(pollRef.current);
setBusy(false);
}
}, 2000);
} else {
setStep(4);
setImportResult(result);
setBusy(false);
}
} catch (err) {
setError(err instanceof Error ? err.message : t('common.error'));
setBusy(false);
}
}, [entityType, file, mapping, t]);
const canExecute = validateResult !== null && (validateResult.succeeded > 0);
return (
<Modal
open={open}
onClose={onClose}
title={t('importexport.dialogTitle')}
size="xl"
>
{/* Tabs */}
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => setTab('export')}
className={clsxTab(tab === 'export')}
data-testid="ie-tab-export"
>
<Download className="w-4 h-4" aria-hidden="true" />
{t('importexport.export')}
</button>
<button
type="button"
onClick={() => { setTab('import'); resetImport(); }}
className={clsxTab(tab === 'import')}
data-testid="ie-tab-import"
>
<Upload className="w-4 h-4" aria-hidden="true" />
{t('importexport.import')}
</button>
</div>
{tab === 'export' && (
<div data-testid="ie-export-panel">
<p className="text-sm text-secondary-600 mb-3">
{t('importexport.exportHint', { entity: entityType })}
</p>
<div className="flex flex-wrap gap-2 mb-4">
{FORMATS.map((f) => (
<button
key={f.id}
type="button"
onClick={() => setExportFormat(f.id)}
className={clsxFormat(exportFormat === f.id)}
data-testid={`ie-format-${f.id}`}
>
<f.icon className="w-4 h-4" aria-hidden="true" />
{f.label}
</button>
))}
</div>
{exportError && <p className="text-sm text-red-600 mb-2" role="alert">{exportError}</p>}
<button
type="button"
onClick={handleExport}
disabled={exporting}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 min-h-touch"
data-testid="ie-export-download"
>
{exporting
? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />
: <Download className="w-4 h-4" aria-hidden="true" />}
{t('importexport.download')}
</button>
</div>
)}
{tab === 'import' && (
<div data-testid="ie-import-panel">
{/* Step indicator */}
<ol className="flex gap-2 mb-4 text-xs">
{[1, 2, 3, 4].map((n) => (
<li
key={n}
className={clsx(
'px-2 py-1 rounded',
step === n ? 'bg-primary-100 text-primary-700 font-semibold' : 'bg-secondary-100 text-secondary-500',
)}
aria-current={step === n ? 'step' : undefined}
>
{n}. {t(`importexport.step${n}`)}
</li>
))}
</ol>
{error && <p className="text-sm text-red-600 mb-2" role="alert">{error}</p>}
{/* Step 1 — file */}
{step === 1 && (
<div>
<label
htmlFor="ie-file-input"
className="block text-sm font-medium text-secondary-700 mb-2"
>
{t('importexport.chooseFile')}
</label>
<input
id="ie-file-input"
type="file"
accept=".csv,.json,.xlsx"
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm border border-secondary-300 rounded-md p-2"
data-testid="ie-file-input"
/>
{busy && <Loader2 className="w-4 h-4 animate-spin mt-2" aria-hidden="true" />}
</div>
)}
{/* Step 2 — mapping */}
{step === 2 && preview && (
<div>
<p className="text-sm text-secondary-600 mb-2">
{t('importexport.mappingHint', { total: preview.total_rows })}
</p>
<div className="max-h-48 overflow-y-auto border border-secondary-200 rounded mb-3">
<table className="w-full text-xs">
<thead>
<tr className="bg-secondary-50">
<th className="text-left px-2 py-1">{t('importexport.fileColumn')}</th>
<th className="text-left px-2 py-1">{t('importexport.targetField')}</th>
</tr>
</thead>
<tbody>
{preview.columns.map((col) => (
<tr key={col} className="border-t border-secondary-100">
<td className="px-2 py-1 font-mono">{col}</td>
<td className="px-2 py-1">
<select
value={mapping[col] ?? ''}
onChange={(e) => setMapping((m) => ({ ...m, [col]: e.target.value }))}
className="border border-secondary-300 rounded px-1 py-0.5 w-full"
aria-label={`${t('importexport.targetField')}: ${col}`}
>
<option value=""> {t('common.skip')} </option>
{preview.target_fields.map((tf) => (
<option key={tf} value={tf}>{tf}</option>
))}
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
onClick={handleValidate}
disabled={busy}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 min-h-touch"
data-testid="ie-import-validate"
>
{busy && <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />}
{t('importexport.validate')}
</button>
</div>
)}
{/* Step 3 — dry run */}
{step === 3 && validateResult && (
<div>
<div className="flex gap-4 mb-3">
<span className="inline-flex items-center gap-1 text-sm text-green-700" data-testid="ie-dry-valid">
<CheckCircle2 className="w-4 h-4" aria-hidden="true" />
{validateResult.succeeded} {t('importexport.valid')}
</span>
<span className="inline-flex items-center gap-1 text-sm text-red-700" data-testid="ie-dry-invalid">
<XCircle className="w-4 h-4" aria-hidden="true" />
{validateResult.failed} {t('importexport.invalid')}
</span>
</div>
{validateResult.error_report && validateResult.error_report.errors.length > 0 && (
<div className="max-h-40 overflow-y-auto border border-secondary-200 rounded mb-3 text-xs">
{validateResult.error_report.errors.map((e, i) => (
<p key={i} className="px-2 py-1 border-b border-secondary-100 last:border-b-0">
<span className="font-mono">{t('importexport.row')} {e.row}</span>
{' — '}{e.message}
</p>
))}
</div>
)}
<button
type="button"
onClick={handleExecute}
disabled={busy || !canExecute}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 min-h-touch"
data-testid="ie-import-execute"
>
{busy && <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />}
{t('importexport.executeImport')}
</button>
</div>
)}
{/* Step 4 — result */}
{step === 4 && (importResult || jobStatus) && (
<div data-testid="ie-import-result">
{jobStatus && jobStatus.status !== 'completed' && jobStatus.status !== 'partial_success' && jobStatus.status !== 'failed' ? (
<div className="flex items-center gap-2 text-sm text-secondary-600">
<Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />
{t('importexport.backgroundRunning')}
</div>
) : (
<>
<div className="flex gap-4 mb-3">
<span className="text-sm">
<strong>{(importResult?.total ?? jobStatus?.total) ?? 0}</strong> {t('importexport.total')}
</span>
<span className="text-sm text-green-700">
<strong>{(importResult?.succeeded ?? jobStatus?.succeeded) ?? 0}</strong> {t('importexport.imported')}
</span>
<span className="text-sm text-red-700">
<strong>{(importResult?.failed ?? jobStatus?.failed) ?? 0}</strong> {t('importexport.failed')}
</span>
</div>
<p className="text-sm text-green-700 flex items-center gap-1" role="status">
<CheckCircle2 className="w-4 h-4" aria-hidden="true" />
{t('importexport.importDone')}
</p>
</>
)}
</div>
)}
</div>
)}
</Modal>
);
}
function clsxTab(active: boolean): string {
return [
'inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm min-h-touch',
active ? 'bg-primary-100 text-primary-700 font-semibold' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200',
].join(' ');
}
function clsxFormat(active: boolean): string {
return [
'inline-flex items-center gap-2 px-3 py-2 rounded-md border text-sm min-h-touch',
active
? 'border-primary-500 bg-primary-50 text-primary-700 font-semibold'
: 'border-secondary-300 text-secondary-600 hover:border-secondary-400',
].join(' ');
}
+26
View File
@@ -1440,5 +1440,31 @@
"targetRoom": "Ziel-Raum", "targetRoom": "Ziel-Raum",
"targetRoomDescription": "Name des Raums in der Kommunikation, an den Status-Meldungen gesendet werden.", "targetRoomDescription": "Name des Raums in der Kommunikation, an den Status-Meldungen gesendet werden.",
"defaultRoomName": "Live KI" "defaultRoomName": "Live KI"
},
"importexport": {
"dialogTitle": "Import / Export",
"export": "Export",
"import": "Import",
"download": "Herunterladen",
"exportHint": "Daten des Moduls {{entity}} exportieren — Format wählen:",
"chooseFile": "Datei wählen (CSV, JSON oder XLSX)",
"step1": "Datei",
"step2": "Zuordnung",
"step3": "Prüfung",
"step4": "Ergebnis",
"mappingHint": "{{total}} Zeilen erkannt — Dateispalten den Zielfeldern zuordnen:",
"fileColumn": "Dateispalte",
"targetField": "Zielfeld",
"skip": "überspringen",
"validate": "Prüfen",
"valid": "gültig",
"invalid": "ungültig",
"row": "Zeile",
"executeImport": "Import ausführen",
"total": "gesamt",
"imported": "importiert",
"failed": "fehlgeschlagen",
"importDone": "Import abgeschlossen",
"backgroundRunning": "Import läuft im Hintergrund …"
} }
} }
+26
View File
@@ -1440,5 +1440,31 @@
"targetRoom": "Target room", "targetRoom": "Target room",
"targetRoomDescription": "Name of the room in Communication that status messages are sent to.", "targetRoomDescription": "Name of the room in Communication that status messages are sent to.",
"defaultRoomName": "Live AI" "defaultRoomName": "Live AI"
},
"importexport": {
"dialogTitle": "Import / Export",
"export": "Export",
"import": "Import",
"download": "Download",
"exportHint": "Export data of module {{entity}} — choose format:",
"chooseFile": "Choose file (CSV, JSON or XLSX)",
"step1": "File",
"step2": "Mapping",
"step3": "Check",
"step4": "Result",
"mappingHint": "{{total}} rows detected — map file columns to target fields:",
"fileColumn": "File column",
"targetField": "Target field",
"skip": "skip",
"validate": "Check",
"valid": "valid",
"invalid": "invalid",
"row": "Row",
"executeImport": "Run import",
"total": "total",
"imported": "imported",
"failed": "failed",
"importDone": "Import finished",
"backgroundRunning": "Import running in background …"
} }
} }
+22 -1
View File
@@ -19,7 +19,8 @@ import { SavedFilters } from '@/components/SavedFilters';
import { SavedFilterBar } from '@/components/common/SavedFilterBar'; import { SavedFilterBar } from '@/components/common/SavedFilterBar';
import { TagSelector } from '@/components/tags/TagSelector'; import { TagSelector } from '@/components/tags/TagSelector';
import type { Tag } from '@/api/tags'; import type { Tag } from '@/api/tags';
import { ArrowDownAZ, ArrowUpZA, Bookmark, ChevronLeft, ExternalLink, LayoutGrid, List, Plus, Printer, Table2, X } from 'lucide-react'; import { ArrowDownAZ, ArrowUpZA, Bookmark, ChevronLeft, ExternalLink, LayoutGrid, List, Plus, Printer, Table2, Upload, X } from 'lucide-react';
import { ImportExportDialog } from '@/components/importexport/ImportExportDialog';
import { FilterPanel, applyFilters, emptyFilterState, type FilterState, type SavedFilter } from '@/components/contacts/FilterPanel'; import { FilterPanel, applyFilters, emptyFilterState, type FilterState, type SavedFilter } from '@/components/contacts/FilterPanel';
import { SortPanel, applySorting, emptySortState, type SortState } from '@/components/contacts/SortPanel'; import { SortPanel, applySorting, emptySortState, type SortState } from '@/components/contacts/SortPanel';
import { GroupPanel, applyGrouping, emptyGroupState, type GroupState, type GroupedContacts } from '@/components/contacts/GroupPanel'; import { GroupPanel, applyGrouping, emptyGroupState, type GroupState, type GroupedContacts } from '@/components/contacts/GroupPanel';
@@ -222,6 +223,9 @@ export function ContactsListPage() {
setActiveView('detail'); setActiveView('detail');
}, []); }, []);
// Import/Export dialog (W4a #359)
const [importExportOpen, setImportExportOpen] = useState(false);
// Handle create // Handle create
const handleCreate = useCallback(() => { const handleCreate = useCallback(() => {
const windowId = openWindow({ const windowId = openWindow({
@@ -374,6 +378,16 @@ export function ContactsListPage() {
icon: <Plus className="w-3.5 h-3.5" strokeWidth={2} />, icon: <Plus className="w-3.5 h-3.5" strokeWidth={2} />,
onClick: handleCreate, onClick: handleCreate,
}] : []), }] : []),
// Import/Export dialog (W4a #359)
...(canAccess('contacts:read') ? [{
id: 'import-export',
plugin: 'contacts',
label: t('importexport.dialogTitle'),
type: 'button' as const,
group: 'create',
icon: <Upload className="w-3.5 h-3.5" strokeWidth={2} />,
onClick: () => setImportExportOpen(true),
}] : []),
// View mode dropdown (list / table / cards) // View mode dropdown (list / table / cards)
{ {
id: 'view-mode', id: 'view-mode',
@@ -782,6 +796,13 @@ export function ContactsListPage() {
hasSort={sortState.conditions.length > 0} hasSort={sortState.conditions.length > 0}
hasFolder={selectedFilter !== 'all' || multiSelectFolders.length > 0} hasFolder={selectedFilter !== 'all' || multiSelectFolders.length > 0}
/> />
{/* Import/Export Dialog (W4a #359) */}
<ImportExportDialog
open={importExportOpen}
onClose={() => setImportExportOpen(false)}
entityType="contacts"
/>
</div> </div>
); );
} }