adobe-to-docusign-migrator/web/static/js/migration.js

457 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Migration workflow: options modal → progress → results view
import { api } from './api.js';
import { state, setState } from './state.js';
import { escHtml, formatDateTime, downloadCsv, renderFieldIssues, bindFieldIssueToggles } from './utils.js';
import { navigate } from './router.js';
import { refreshTemplates } from './templates.js';
// ── Helpers ────────────────────────────────────────────────────────────────
const _RESULTS_STORAGE_KEY = 'migrator_last_batch_results';
function getSettings() {
try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; }
catch { return {}; }
}
function persistLastResults(results) {
try {
sessionStorage.setItem(_RESULTS_STORAGE_KEY, JSON.stringify(results));
} catch {
// Best-effort only.
}
}
function loadPersistedResults() {
try {
const raw = sessionStorage.getItem(_RESULTS_STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function buildResultsFromHistory(history) {
if (!history || !history.length) return null;
const sorted = [...history].sort((a, b) => String(b.timestamp || '').localeCompare(String(a.timestamp || '')));
const newestTimestamp = sorted[0]?.timestamp;
const recentBatch = sorted.filter(item => item.timestamp === newestTimestamp);
if (!recentBatch.length) return null;
return {
status: 'completed',
completed_at: newestTimestamp,
results: recentBatch,
summary: {
total: recentBatch.length,
success: recentBatch.filter(r => r.status === 'success').length,
failed: recentBatch.filter(r => r.status === 'failed' || r.status === 'blocked').length,
skipped: recentBatch.filter(r => r.status === 'skipped').length,
dry_run: recentBatch.filter(r => r.status === 'dry_run').length,
},
recovered_from_history: true,
};
}
// ── 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 = `
<div class="modal-backdrop"></div>
<div class="modal-box">
<div class="modal-header">
<span class="modal-title">Migration Options</span>
<button class="modal-close" id="mm-close">✕</button>
</div>
<div class="modal-body">
<div class="callout info" style="margin-bottom:16px">
<span class="callout-icon">📋</span>
<span>Migrating <strong>${ids.length}</strong> template${ids.length > 1 ? 's' : ''}:
${names.slice(0, 3).map(n => `<em>${escHtml(n)}</em>`).join(', ')}${names.length > 3 ? `… +${names.length - 3} more` : ''}</span>
</div>
<div class="options-panel">
<div class="options-title">Options</div>
<div class="option-row">
<button class="toggle" id="opt-dry-run" role="switch" aria-checked="false"></button>
<div class="option-body">
<div class="option-label">Dry Run</div>
<div class="option-desc">Validate and preview without creating Docusign templates</div>
</div>
</div>
<div class="option-row">
<button class="toggle" id="opt-overwrite" role="switch"
aria-checked="${settings.defaultOverwrite ? 'true' : 'false'}"
class="toggle ${settings.defaultOverwrite ? 'on' : ''}"></button>
<div class="option-body">
<div class="option-label">Overwrite Existing</div>
<div class="option-desc">Update Docusign templates that already exist (default: skip)</div>
</div>
</div>
<div class="option-row">
<button class="toggle on" id="opt-include-docs" role="switch" aria-checked="true"></button>
<div class="option-body">
<div class="option-label">Include Documents</div>
<div class="option-desc">Embed PDFs in the Docusign template payload</div>
</div>
</div>
<div class="option-row" style="align-items:center">
<div class="option-body">
<div class="option-label">Target Folder <span class="form-label-sub">(optional)</span></div>
<input type="text" class="form-input" id="opt-folder"
placeholder="e.g. Migrated Templates" style="margin-top:6px" />
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="mm-cancel">Cancel</button>
<button class="btn btn-primary" id="mm-run">Run Migration →</button>
</div>
</div>
`;
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 = `
<div class="migration-progress">
<div class="progress-wrap">
<div class="progress-label">
<span id="prog-label">Starting…</span>
<span id="prog-count">0 / ${ids.length}</span>
</div>
<div class="progress-bar"><div class="progress-fill" id="prog-bar" style="width:0%"></div></div>
</div>
<div class="progress-template-list" id="prog-list">
${names.map(n => `
<div class="progress-template-row" id="prog-row-${escHtml(n.id)}">
<div class="progress-template-name">${escHtml(n.name)}</div>
<span class="progress-template-status spinner spinner-sm"></span>
</div>`).join('')}
</div>
</div>
`;
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 = '❌';
if (r.status === 'blocked' || r.status === 'error' || r.status === 'failed') {
const msg = r.error || (r.blockers||[])[0] || 'Migration failed';
let hint = row.querySelector('.progress-template-error');
if (!hint) {
hint = document.createElement('div');
hint.className = 'progress-template-error';
row.appendChild(hint);
}
hint.textContent = msg;
}
});
});
// Migration done — show "View Results" button
const allResults = (jobData.results || []);
const failCount = allResults.filter(r => r.status === 'blocked' || r.status === 'error' || r.status === 'failed').length;
document.getElementById('prog-label') && (document.getElementById('prog-label').textContent = 'Done!');
if (failCount > 0) {
const hint = document.createElement('div');
hint.style.cssText = 'font-size:12px;color:var(--text-muted);margin-top:10px;text-align:center';
hint.textContent = `${failCount} template${failCount > 1 ? 's' : ''} had issues — select View Results for details.`;
body.appendChild(hint);
}
footer.innerHTML = `
<button class="btn btn-secondary" id="mm-close-done">Close</button>
<button class="btn btn-primary" id="mm-view-results">View Results →</button>
`;
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 += `<div class="callout error" style="margin-top:12px">
<span class="callout-icon">❌</span> Migration failed: ${escHtml(err.message)}
</div>`;
footer.innerHTML = `<button class="btn btn-secondary" id="mm-err-close">Close</button>`;
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' || data.status === 'completed') {
setState('lastMigrationResults', data);
persistLastResults(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 async function renderResults() {
const outlet = document.getElementById('router-outlet');
let results = state.lastMigrationResults || loadPersistedResults();
if (!results) {
try {
const data = await api.migrate.history();
results = buildResultsFromHistory(data.history || []);
} catch {
results = null;
}
}
if (results) {
setState('lastMigrationResults', results);
persistLastResults(results);
}
if (!results) {
outlet.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📊</div>
<div class="empty-state-title">No migration results yet</div>
<div class="empty-state-sub">Run a migration from the <a href="#/templates" style="color:var(--cobalt)">Templates</a> view to see results here. If templates already exist in DocuSign, use History &amp; Audit to review older runs.</div>
</div>`;
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 = `
<div class="page-header">
<div>
<div class="page-title">Migration Results</div>
<div class="page-subtitle">${formatDateTime(results.completed_at || new Date().toISOString())}</div>
</div>
<div class="page-actions">
<button class="btn btn-secondary btn-sm" id="btn-export-results">⬇ Export CSV</button>
${migratedIds.length ? `<button class="btn btn-primary btn-sm" id="btn-verify-results">Verify Templates →</button>` : ''}
</div>
</div>
${results.recovered_from_history ? `
<div class="callout info">
<span class="callout-icon"></span>
These results were recovered from recent migration history after the page state was reset.
</div>
` : ''}
<!-- Summary stat cards -->
<div class="stat-grid" style="grid-template-columns:repeat(${summary.dry_run ? 6 : 5},1fr)">
<div class="stat-card green">
<div class="stat-label">Created</div>
<div class="stat-value">${summary.created}</div>
</div>
<div class="stat-card blue">
<div class="stat-label">Updated</div>
<div class="stat-value">${summary.updated}</div>
</div>
<div class="stat-card gray">
<div class="stat-label">Skipped</div>
<div class="stat-value">${summary.skipped}</div>
</div>
<div class="stat-card red">
<div class="stat-label">Blocked</div>
<div class="stat-value">${summary.blocked}</div>
</div>
<div class="stat-card red">
<div class="stat-label">Errors</div>
<div class="stat-value">${summary.errors}</div>
</div>
${summary.dry_run ? `<div class="stat-card gray"><div class="stat-label">Dry Run</div><div class="stat-value">${summary.dry_run}</div></div>` : ''}
</div>
<!-- Per-template results -->
<div class="card">
<div class="card-header">
<span class="card-title">Per-Template Results</span>
</div>
<div id="results-list" style="padding:8px 16px">
${templateResults.map(r => _resultRow(r)).join('')}
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px">
<a href="#/templates" class="btn btn-secondary btn-sm">← Back to Templates</a>
</div>
`;
// Expand/collapse result rows
document.querySelectorAll('.result-header').forEach(hdr => {
hdr.addEventListener('click', () => {
hdr.parentElement.classList.toggle('open');
});
});
bindFieldIssueToggles();
// 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 issues = r.field_issues || [];
const warnings = r.warnings || [];
const hasDetail = warnings.length || r.error || issues.length;
const icon = r.status === 'success'
? (issues.length ? '⚠️' : r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️')
: (r.status === 'skipped' ? '⏭' : r.status === 'blocked' ? '🚫' : '❌');
const statusBadge = r.status === 'success'
? `<span class="badge badge-green">${r.action || 'success'}</span>${issues.length ? '<span class="badge badge-amber">partial</span>' : ''}`
: `<span class="badge badge-${r.status === 'skipped' ? 'gray' : 'red'}">${r.status}</span>`;
return `
<div class="result-row">
<div class="result-header">
<span class="result-icon">${icon}</span>
<span class="result-name">${escHtml(r.adobe_template_name || r.adobe_template_id)}</span>
${statusBadge}
${r.docusign_template_id ? `<span class="ds-pill" title="${escHtml(r.docusign_template_id)}">DS: ${escHtml(r.docusign_template_id.slice(0,8))}…</span>` : ''}
${issues.length ? `<span class="result-meta">⚠ ${issues.length} field issue${issues.length > 1 ? 's' : ''}</span>` : ''}
${warnings.length ? `<span class="result-meta">${warnings.length} warning${warnings.length > 1 ? 's' : ''}</span>` : ''}
</div>
${hasDetail ? `
<div class="result-body">
${warnings.map(w => `<div class="result-warn-item"><span class="ri">⚠</span>${escHtml(w)}</div>`).join('')}
${r.error ? `<div class="result-warn-item" style="color:var(--error)"><span class="ri">❌</span>${escHtml(r.error)}</div>` : ''}
${renderFieldIssues(issues)}
</div>` : ''}
</div>
`;
}