From 587104d52058a8577b7e0bdf66f8a3a63f995277 Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Tue, 21 Apr 2026 11:38:04 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui-phase-17):=20migration=20workflow=20?= =?UTF-8?q?=E2=80=94=20options=20modal,=20progress=20polling,=20results=20?= =?UTF-8?q?view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Options modal: dry_run, overwrite_if_exists, include_documents toggles, target folder input. Launches POST /api/migrate/batch and polls GET /api/migrate/batch/{id} every 2s with per-template status icons. Results view: 5-stat summary grid, expandable per-template result rows, CSV export, Verify Templates button. Co-Authored-By: Claude Sonnet 4.6 --- web/static/js/migration.js | 365 +++++++++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 web/static/js/migration.js diff --git a/web/static/js/migration.js b/web/static/js/migration.js new file mode 100644 index 0000000..636c8b4 --- /dev/null +++ b/web/static/js/migration.js @@ -0,0 +1,365 @@ +// Migration workflow: options modal → progress → results view + +import { api } from './api.js'; +import { state, setState } from './state.js'; +import { escHtml, formatDateTime, downloadCsv } from './utils.js'; +import { navigate } from './router.js'; +import { refreshTemplates } from './templates.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function getSettings() { + try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; } + catch { return {}; } +} + +// ── Options modal ────────────────────────────────────────────────────────── + +export function showOptionsModal(ids) { + if (!ids || !ids.length) return; + const settings = getSettings(); + const names = ids.map(id => { + const t = state.templates.find(t => t.adobe_id === id); + return t ? t.name : id; + }); + + const existing = document.getElementById('migration-modal'); + if (existing) existing.remove(); + + const wrapper = document.createElement('div'); + wrapper.id = 'migration-modal'; + wrapper.innerHTML = ` + + + `; + document.body.appendChild(wrapper); + + // Wire toggles + wrapper.querySelectorAll('.toggle').forEach(btn => { + if (settings.defaultOverwrite && btn.id === 'opt-overwrite') btn.classList.add('on'); + btn.addEventListener('click', () => { + btn.classList.toggle('on'); + btn.setAttribute('aria-checked', btn.classList.contains('on')); + }); + }); + + document.getElementById('mm-close').onclick = () => wrapper.remove(); + document.getElementById('mm-cancel').onclick = () => wrapper.remove(); + document.getElementById('mm-run').onclick = () => _startMigration(ids, wrapper); +} + +// ── Start migration ──────────────────────────────────────────────────────── + +async function _startMigration(ids, wrapper) { + const dryRun = document.getElementById('opt-dry-run')?.classList.contains('on') || false; + const overwrite = document.getElementById('opt-overwrite')?.classList.contains('on') || false; + const includeDocs = !(document.getElementById('opt-include-docs')?.classList.contains('on') === false); + const folder = document.getElementById('opt-folder')?.value.trim() || undefined; + + // Replace modal body with progress view + const body = wrapper.querySelector('.modal-body'); + const footer = wrapper.querySelector('.modal-footer'); + wrapper.querySelector('.modal-title').textContent = dryRun ? 'Dry Run' : 'Migrating…'; + footer.innerHTML = ''; // hide footer during migration + + const names = ids.map(id => { + const t = state.templates.find(t => t.adobe_id === id); + return { id, name: t ? t.name : id }; + }); + + body.innerHTML = ` +
+
+
+ Starting… + 0 / ${ids.length} +
+
+
+
+ ${names.map(n => ` +
+
${escHtml(n.name)}
+ +
`).join('')} +
+
+ `; + + try { + const jobData = await api.migrate.batch({ + source_template_ids: ids, + target_folder: folder, + options: { dry_run: dryRun, overwrite_if_exists: overwrite, include_documents: includeDocs }, + }); + + const jobId = jobData.job_id; + await pollJob(jobId, (progress) => { + const { completed, total } = progress.progress || { completed: 0, total: ids.length }; + const pct = total > 0 ? Math.round(completed / total * 100) : 0; + document.getElementById('prog-bar') && (document.getElementById('prog-bar').style.width = pct + '%'); + document.getElementById('prog-count')&& (document.getElementById('prog-count').textContent = `${completed} / ${total}`); + document.getElementById('prog-label')&& (document.getElementById('prog-label').textContent = `Migrating… ${pct}%`); + + // Update per-template icons as results come in + (progress.results || []).forEach(r => { + const row = document.getElementById(`prog-row-${r.adobe_template_id}`); + if (!row) return; + const statusEl = row.querySelector('.progress-template-status'); + if (!statusEl) return; + statusEl.className = 'progress-template-status'; + if (r.status === 'success') statusEl.textContent = r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️'; + else if (r.status === 'skipped') statusEl.textContent = '⏭'; + else if (r.status === 'blocked') statusEl.textContent = '🚫'; + else statusEl.textContent = '❌'; + }); + }); + + // Migration done — show "View Results" button + document.getElementById('prog-label') && (document.getElementById('prog-label').textContent = 'Done!'); + footer.innerHTML = ` + + + `; + document.getElementById('mm-close-done').onclick = () => wrapper.remove(); + document.getElementById('mm-view-results').onclick = () => { + wrapper.remove(); + navigate('#/results'); + }; + + // Refresh template list + await refreshTemplates(); + state.selectedIds.clear(); + + } catch (err) { + body.innerHTML += `
+ Migration failed: ${escHtml(err.message)} +
`; + footer.innerHTML = ``; + document.getElementById('mm-err-close').onclick = () => wrapper.remove(); + } +} + +// ── Poll batch job ───────────────────────────────────────────────────────── + +export async function pollJob(jobId, onProgress) { + const POLL_MS = 2000; + const MAX_WAIT = 300000; // 5 minutes + const started = Date.now(); + + return new Promise((resolve, reject) => { + const tick = async () => { + try { + const data = await api.migrate.batchStatus(jobId); + if (onProgress) onProgress(data); + + if (data.status === 'done' || data.status === 'complete') { + setState('lastMigrationResults', data); + resolve(data); + } else if (data.status === 'failed') { + reject(new Error('Migration job failed')); + } else if (Date.now() - started > MAX_WAIT) { + reject(new Error('Migration timed out')); + } else { + setTimeout(tick, POLL_MS); + } + } catch (e) { + reject(e); + } + }; + tick(); + }); +} + +// ── Results view ─────────────────────────────────────────────────────────── + +export function renderResults() { + const outlet = document.getElementById('router-outlet'); + const results = state.lastMigrationResults; + + if (!results) { + outlet.innerHTML = ` +
+
📊
+
No migration results yet
+
Run a migration from the Templates view to see results here.
+
`; + return; + } + + const templateResults = results.results || []; + const summary = { + created: templateResults.filter(r => r.action === 'created').length, + updated: templateResults.filter(r => r.action === 'updated').length, + skipped: templateResults.filter(r => r.status === 'skipped').length, + blocked: templateResults.filter(r => r.status === 'blocked').length, + errors: templateResults.filter(r => r.status === 'error').length, + dry_run: templateResults.filter(r => r.status === 'dry_run').length, + }; + + const migratedIds = templateResults + .filter(r => r.status === 'success') + .map(r => r.adobe_template_id); + + outlet.innerHTML = ` + + + +
+
+
Created
+
${summary.created}
+
+
+
Updated
+
${summary.updated}
+
+
+
Skipped
+
${summary.skipped}
+
+
+
Blocked
+
${summary.blocked}
+
+
+
Errors
+
${summary.errors}
+
+ ${summary.dry_run ? `
Dry Run
${summary.dry_run}
` : ''} +
+ + +
+
+ Per-Template Results +
+
+ ${templateResults.map(r => _resultRow(r)).join('')} +
+
+ +
+ ← Back to Templates +
+ `; + + // Expand/collapse result rows + document.querySelectorAll('.result-header').forEach(hdr => { + hdr.addEventListener('click', () => { + hdr.parentElement.classList.toggle('open'); + }); + }); + + // Export CSV + document.getElementById('btn-export-results')?.addEventListener('click', () => { + downloadCsv('migration-results.csv', templateResults.map(r => ({ + name: r.adobe_template_name || r.adobe_template_id, + adobe_id: r.adobe_template_id, + docusign_id: r.docusign_template_id || '', + status: r.status, + action: r.action || '', + warnings: (r.warnings || []).join('; '), + }))); + }); + + // Verify button + document.getElementById('btn-verify-results')?.addEventListener('click', () => { + import('./verification.js').then(m => { + setState('verifyIds', migratedIds); + navigate('#/verify'); + }); + }); +} + +function _resultRow(r) { + const icon = r.status === 'success' + ? (r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️') + : (r.status === 'skipped' ? '⏭' : r.status === 'blocked' ? '🚫' : '❌'); + + const statusBadge = r.status === 'success' + ? `${r.action || 'success'}` + : `${r.status}`; + + const warnings = r.warnings || []; + + return ` +
+
+ ${icon} + ${escHtml(r.adobe_template_name || r.adobe_template_id)} + ${statusBadge} + ${r.docusign_template_id ? `DS: ${escHtml(r.docusign_template_id.slice(0,8))}…` : ''} + ${warnings.length ? `⚠ ${warnings.length} warning${warnings.length > 1 ? 's' : ''}` : ''} +
+ ${warnings.length || r.error ? ` +
+ ${warnings.map(w => `
${escHtml(w)}
`).join('')} + ${r.error ? `
${escHtml(r.error)}
` : ''} +
` : ''} +
+ `; +}