From 38df597f119ead6a8a78df61c784881932e51f88 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 27 Aug 2026 21:34:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(#359):=20W4a=20Phase=202=20=E2=80=94=20zen?= =?UTF-8?q?traler=20Import/Export-Dialog=20(Frontend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../shell/importExportDialog.test.ts | 70 +++ .../importexport/ImportExportDialog.tsx | 414 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 26 ++ frontend/src/i18n/locales/en.json | 26 ++ frontend/src/pages/ContactsList.tsx | 23 +- 5 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 frontend/src/__tests__/shell/importExportDialog.test.ts create mode 100644 frontend/src/components/importexport/ImportExportDialog.tsx diff --git a/frontend/src/__tests__/shell/importExportDialog.test.ts b/frontend/src/__tests__/shell/importExportDialog.test.ts new file mode 100644 index 0000000..b211c27 --- /dev/null +++ b/frontend/src/__tests__/shell/importExportDialog.test.ts @@ -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'"); + }); +}); diff --git a/frontend/src/components/importexport/ImportExportDialog.tsx b/frontend/src/components/importexport/ImportExportDialog.tsx new file mode 100644 index 0000000..0025208 --- /dev/null +++ b/frontend/src/components/importexport/ImportExportDialog.tsx @@ -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('csv'); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(null); + + // ── Import state ── + const [step, setStep] = useState(1); + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [mapping, setMapping] = useState>({}); + const [validateResult, setValidateResult] = useState(null); + const [importResult, setImportResult] = useState(null); + const [jobStatus, setJobStatus] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const pollRef = useRef | 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 ( + + {/* Tabs */} +
+ + +
+ + {tab === 'export' && ( +
+

+ {t('importexport.exportHint', { entity: entityType })} +

+
+ {FORMATS.map((f) => ( + + ))} +
+ {exportError &&

{exportError}

} + +
+ )} + + {tab === 'import' && ( +
+ {/* Step indicator */} +
    + {[1, 2, 3, 4].map((n) => ( +
  1. + {n}. {t(`importexport.step${n}`)} +
  2. + ))} +
+ + {error &&

{error}

} + + {/* Step 1 — file */} + {step === 1 && ( +
+ + 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 &&
+ )} + + {/* Step 2 — mapping */} + {step === 2 && preview && ( +
+

+ {t('importexport.mappingHint', { total: preview.total_rows })} +

+
+ + + + + + + + + {preview.columns.map((col) => ( + + + + + ))} + +
{t('importexport.fileColumn')}{t('importexport.targetField')}
{col} + +
+
+ +
+ )} + + {/* Step 3 — dry run */} + {step === 3 && validateResult && ( +
+
+ + + + +
+ {validateResult.error_report && validateResult.error_report.errors.length > 0 && ( +
+ {validateResult.error_report.errors.map((e, i) => ( +

+ {t('importexport.row')} {e.row} + {' — '}{e.message} +

+ ))} +
+ )} + +
+ )} + + {/* Step 4 — result */} + {step === 4 && (importResult || jobStatus) && ( +
+ {jobStatus && jobStatus.status !== 'completed' && jobStatus.status !== 'partial_success' && jobStatus.status !== 'failed' ? ( +
+
+ ) : ( + <> +
+ + {(importResult?.total ?? jobStatus?.total) ?? 0} {t('importexport.total')} + + + {(importResult?.succeeded ?? jobStatus?.succeeded) ?? 0} {t('importexport.imported')} + + + {(importResult?.failed ?? jobStatus?.failed) ?? 0} {t('importexport.failed')} + +
+

+

+ + )} +
+ )} +
+ )} +
+ ); +} + +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(' '); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 4bdc859..fdc357b 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1440,5 +1440,31 @@ "targetRoom": "Ziel-Raum", "targetRoomDescription": "Name des Raums in der Kommunikation, an den Status-Meldungen gesendet werden.", "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 …" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3cf840e..5e060e1 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1440,5 +1440,31 @@ "targetRoom": "Target room", "targetRoomDescription": "Name of the room in Communication that status messages are sent to.", "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 …" } } diff --git a/frontend/src/pages/ContactsList.tsx b/frontend/src/pages/ContactsList.tsx index 70e7637..1174494 100644 --- a/frontend/src/pages/ContactsList.tsx +++ b/frontend/src/pages/ContactsList.tsx @@ -19,7 +19,8 @@ import { SavedFilters } from '@/components/SavedFilters'; import { SavedFilterBar } from '@/components/common/SavedFilterBar'; import { TagSelector } from '@/components/tags/TagSelector'; 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 { SortPanel, applySorting, emptySortState, type SortState } from '@/components/contacts/SortPanel'; import { GroupPanel, applyGrouping, emptyGroupState, type GroupState, type GroupedContacts } from '@/components/contacts/GroupPanel'; @@ -222,6 +223,9 @@ export function ContactsListPage() { setActiveView('detail'); }, []); + // Import/Export dialog (W4a #359) + const [importExportOpen, setImportExportOpen] = useState(false); + // Handle create const handleCreate = useCallback(() => { const windowId = openWindow({ @@ -374,6 +378,16 @@ export function ContactsListPage() { icon: , 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: , + onClick: () => setImportExportOpen(true), + }] : []), // View mode dropdown (list / table / cards) { id: 'view-mode', @@ -782,6 +796,13 @@ export function ContactsListPage() { hasSort={sortState.conditions.length > 0} hasFolder={selectedFilter !== 'all' || multiSelectFolders.length > 0} /> + + {/* Import/Export Dialog (W4a #359) */} + setImportExportOpen(false)} + entityType="contacts" + /> ); }