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

503 lines
20 KiB
JavaScript
Raw 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.

// Templates view — filterable table with readiness badges + template detail
import { api } from './api.js';
import { state, setState, updateDerivedState } from './state.js';
import { escHtml, formatDate, formatRelative, debounce, renderFieldIssues, bindFieldIssueToggles } from './utils.js';
import { navigate } from './router.js';
// ── Readiness badge ────────────────────────────────────────────────────────
function readiness(t) {
if (t.blockers && t.blockers.length > 0) {
return { key: 'blocked', label: 'Blocked', cls: 'badge-blocked' };
}
if (t.status === 'migrated') {
return t.warnings && t.warnings.length > 0
? { key: 'migrated-warn', label: 'Migrated', cls: 'badge-migrated' }
: { key: 'migrated', label: 'Migrated', cls: 'badge-migrated' };
}
if (t.status === 'needs_update') {
return { key: 'needs-update', label: 'Needs Update', cls: 'badge-needs-update' };
}
if (t.warnings && t.warnings.length > 0) {
return { key: 'caveats', label: 'Caveats', cls: 'badge-caveats' };
}
return { key: 'ready', label: 'Ready', cls: 'badge-ready' };
}
// ── Refresh templates from API ─────────────────────────────────────────────
export async function refreshTemplates() {
if (!state.auth.adobe || !state.auth.docusign) {
setState('templates', []);
updateDerivedState();
return;
}
try {
const data = await api.templates.status();
setState('templates', data.templates || []);
updateDerivedState();
} catch (e) {
console.warn('refreshTemplates failed:', e.message);
}
}
// ── Templates list view ────────────────────────────────────────────────────
let _filter = { search: '', status: 'all' };
let _sort = { col: 'name', dir: 'asc' };
export async function renderTemplates() {
const outlet = document.getElementById('router-outlet');
// Fetch if not loaded
if (!state.templates.length && state.auth.adobe && state.auth.docusign) {
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
await refreshTemplates();
}
_render();
}
function _render() {
const outlet = document.getElementById('router-outlet');
const templates = _applyFilter(state.templates);
const counts = _statusCounts(state.templates);
const anySelected = state.selectedIds.size > 0;
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Templates</div>
<div class="page-subtitle">${state.templates.length} Adobe Sign templates</div>
</div>
<div class="page-actions">
<button class="btn btn-secondary btn-sm" id="btn-refresh-templates">↻ Refresh</button>
</div>
</div>
${!state.auth.adobe || !state.auth.docusign ? `
<div class="callout info">
<span class="callout-icon"></span>
Connect both Adobe Sign and Docusign in the top bar to load templates.
</div>
` : ''}
<!-- Filter bar -->
<div class="filter-bar">
<input type="search" class="search-input" id="template-search"
placeholder="Search templates…" value="${escHtml(_filter.search)}" />
<div class="filter-tabs">
${_filterTab('all', `All <span class="tab-count">${counts.total}</span>`)}
${_filterTab('not_migrated', `Not Migrated <span class="tab-count">${counts.not_migrated}</span>`)}
${_filterTab('migrated', `Migrated <span class="tab-count">${counts.migrated}</span>`)}
${_filterTab('needs_update', `Needs Update <span class="tab-count">${counts.needs_update}</span>`)}
</div>
<div class="filter-tabs">
${_filterTab2('blocked', `Blocked <span class="tab-count">${counts.blocked}</span>`)}
${_filterTab2('caveats', `Caveats <span class="tab-count">${counts.caveats}</span>`)}
</div>
</div>
<!-- Bulk action toolbar -->
<div class="bulk-bar ${anySelected ? '' : 'hidden'}" id="bulk-bar">
<span class="bulk-bar-text">${state.selectedIds.size} template(s) selected</span>
<button class="btn btn-primary btn-sm" id="btn-migrate-selected">Migrate Selected →</button>
<button class="btn btn-secondary btn-sm" id="btn-clear-selection">Clear</button>
</div>
<!-- Templates table -->
<div class="card">
<div class="table-wrap">
<table id="templates-table">
<thead>
<tr>
<th style="width:34px">
<input type="checkbox" class="cb" id="select-all" title="Select all" />
</th>
${_th('name', 'Template Name')}
${_th('readiness', 'Readiness')}
${_th('warnings', 'Issues')}
${_th('adobe_modified','Last Modified')}
${_th('status', 'DS Status')}
<th>Actions</th>
</tr>
</thead>
<tbody>
${templates.length
? templates.map(t => _templateRow(t)).join('')
: `<tr><td colspan="7">
<div class="empty-state">
<div class="empty-state-icon">📄</div>
<div class="empty-state-title">${state.templates.length ? 'No templates match your filter' : 'No templates found'}</div>
<div class="empty-state-sub">${state.templates.length ? 'Try clearing the search or filter.' : 'Connect Adobe Sign to load templates.'}</div>
</div>
</td></tr>`
}
</tbody>
</table>
</div>
</div>
`;
_bindEvents();
}
function _filterTab(key, label) {
return `<button class="filter-tab ${_filter.status === key ? 'active' : ''}"
data-filter="${key}">${label}</button>`;
}
function _filterTab2(key, label) {
// readiness-based filters
return `<button class="filter-tab ${_filter.status === key ? 'active' : ''}"
data-filter="${key}">${label}</button>`;
}
function _th(col, label) {
const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
return `<th class="sortable ${dir}" data-col="${col}">${label}</th>`;
}
function _templateRow(t) {
const r = readiness(t);
const selected = state.selectedIds.has(t.adobe_id);
const warnCount = (t.warnings || []).length;
const blockCount = (t.blockers || []).length;
const issueClass = blockCount > 0 ? 'blocked' : (warnCount > 0 ? 'has-issues' : 'no-issues');
const issueLabel = blockCount > 0
? `🚫 ${blockCount} blocker${blockCount > 1 ? 's' : ''}`
: (warnCount > 0 ? `${warnCount} warning${warnCount > 1 ? 's' : ''}` : '✓ Clean');
return `
<tr class="${selected ? 'row-selected' : ''}" data-id="${escHtml(t.adobe_id)}">
<td><input type="checkbox" class="cb row-cb" data-id="${escHtml(t.adobe_id)}" ${selected ? 'checked' : ''} /></td>
<td>
<div class="table-name tpl-name-link" data-id="${escHtml(t.adobe_id)}">${escHtml(t.name)}</div>
<div class="table-sub">${escHtml(t.adobe_id)}</div>
</td>
<td><span class="badge ${r.cls}">${r.label}</span></td>
<td><span class="issue-count ${issueClass}">${issueLabel}</span></td>
<td>${formatRelative(t.adobe_modified)}</td>
<td>
${t.docusign_id
? `<span class="badge badge-blue" title="${escHtml(t.docusign_id)}">In Docusign</span>`
: `<span class="badge badge-gray">Not Migrated</span>`}
</td>
<td>
<div class="row-actions">
<button class="btn btn-ghost btn-xs btn-migrate-one" data-id="${escHtml(t.adobe_id)}" title="Migrate this template">→ Migrate</button>
<button class="btn btn-ghost btn-xs btn-view-detail" data-id="${escHtml(t.adobe_id)}" title="View details">Detail</button>
</div>
</td>
</tr>
`;
}
function _statusCounts(templates) {
return {
total: templates.length,
not_migrated: templates.filter(t => t.status === 'not_migrated').length,
migrated: templates.filter(t => t.status === 'migrated').length,
needs_update: templates.filter(t => t.status === 'needs_update').length,
blocked: templates.filter(t => t.blockers && t.blockers.length > 0).length,
caveats: templates.filter(t => (!t.blockers || !t.blockers.length) && t.warnings && t.warnings.length > 0).length,
};
}
function _applyFilter(templates) {
let list = [...templates];
// Text search
if (_filter.search) {
const q = _filter.search.toLowerCase();
list = list.filter(t => t.name.toLowerCase().includes(q));
}
// Status / readiness filter
if (_filter.status !== 'all') {
if (_filter.status === 'blocked') {
list = list.filter(t => t.blockers && t.blockers.length > 0);
} else if (_filter.status === 'caveats') {
list = list.filter(t => (!t.blockers || !t.blockers.length) && t.warnings && t.warnings.length > 0);
} else {
list = list.filter(t => t.status === _filter.status);
}
}
// Sorting
list.sort((a, b) => {
let va = a[_sort.col] || '';
let vb = b[_sort.col] || '';
if (_sort.col === 'readiness') { va = readiness(a).key; vb = readiness(b).key; }
if (_sort.col === 'warnings') { va = (a.blockers||[]).length + (a.warnings||[]).length; vb = (b.blockers||[]).length + (b.warnings||[]).length; }
if (typeof va === 'number') return _sort.dir === 'asc' ? va - vb : vb - va;
return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
return list;
}
// ── Event wiring ───────────────────────────────────────────────────────────
function _bindEvents() {
// Search
const searchEl = document.getElementById('template-search');
if (searchEl) {
searchEl.addEventListener('input', debounce(e => {
_filter.search = e.target.value;
_render();
}, 250));
}
// Filter tabs
document.querySelectorAll('.filter-tab').forEach(btn => {
btn.addEventListener('click', () => {
_filter.status = btn.dataset.filter;
_render();
});
});
// Sort headers
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
if (_sort.col === col) {
_sort.dir = _sort.dir === 'asc' ? 'desc' : 'asc';
} else {
_sort.col = col; _sort.dir = 'asc';
}
_render();
});
});
// Checkboxes
document.getElementById('select-all')?.addEventListener('change', e => {
const ids = _applyFilter(state.templates).map(t => t.adobe_id);
if (e.target.checked) { ids.forEach(id => state.selectedIds.add(id)); }
else { ids.forEach(id => state.selectedIds.delete(id)); }
_render();
});
document.querySelectorAll('.row-cb').forEach(cb => {
cb.addEventListener('change', e => {
const id = cb.dataset.id;
if (e.target.checked) state.selectedIds.add(id);
else state.selectedIds.delete(id);
_render();
});
});
// Migrate selected
document.getElementById('btn-migrate-selected')?.addEventListener('click', () => {
_launchMigration([...state.selectedIds]);
});
document.getElementById('btn-clear-selection')?.addEventListener('click', () => {
state.selectedIds.clear();
_render();
});
// Migrate individual
document.querySelectorAll('.btn-migrate-one').forEach(btn => {
btn.addEventListener('click', () => _launchMigration([btn.dataset.id]));
});
// View detail
document.querySelectorAll('.btn-view-detail, .tpl-name-link').forEach(el => {
el.addEventListener('click', () => navigate(`#/templates/${el.dataset.id}`));
});
// Refresh
document.getElementById('btn-refresh-templates')?.addEventListener('click', async () => {
await refreshTemplates();
_render();
});
}
async function _launchMigration(ids) {
if (!ids.length) return;
const { showOptionsModal } = await import('./migration.js');
showOptionsModal(ids);
}
// ── Template detail view ───────────────────────────────────────────────────
export async function renderTemplateDetail(adobeId) {
const outlet = document.getElementById('router-outlet');
const t = state.templates.find(t => t.adobe_id === adobeId);
if (!t) {
outlet.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🔍</div>
<div class="empty-state-title">Template not found</div>
<div class="empty-state-sub"><a href="#/templates" class="btn btn-ghost btn-sm">← Back to Templates</a></div>
</div>`;
return;
}
const r = readiness(t);
outlet.innerHTML = `
<div class="page-header">
<div>
<a href="#/templates" style="font-size:12px;color:var(--cobalt);text-decoration:none">← Templates</a>
<div class="page-title" style="margin-top:4px">${escHtml(t.name)}</div>
<div class="page-subtitle">Adobe ID: <span class="mono">${escHtml(t.adobe_id)}</span></div>
</div>
<div class="page-actions">
<span class="badge ${r.cls}">${r.label}</span>
<button class="btn btn-primary btn-sm" id="detail-migrate-btn">Migrate</button>
</div>
</div>
<div class="tabs" id="detail-tabs">
<div class="tab active" data-tab="overview">Overview</div>
<div class="tab" data-tab="issues">Issues ${(t.blockers||[]).length + (t.warnings||[]).length > 0
? `<span class="nav-badge" style="position:static;display:inline">${(t.blockers||[]).length + (t.warnings||[]).length}</span>` : ''}</div>
<div class="tab" data-tab="history">Migration History</div>
</div>
<div id="detail-tab-content"></div>
`;
document.getElementById('detail-migrate-btn')?.addEventListener('click', () => {
import('./migration.js').then(m => m.showOptionsModal([adobeId]));
});
_bindDetailTabs(t);
_renderDetailTab(t, 'overview');
}
function _bindDetailTabs(t) {
document.querySelectorAll('#detail-tabs .tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('#detail-tabs .tab').forEach(x => x.classList.remove('active'));
tab.classList.add('active');
_renderDetailTab(t, tab.dataset.tab);
});
});
}
function _renderDetailTab(t, tabKey) {
const content = document.getElementById('detail-tab-content');
if (tabKey === 'overview') {
content.innerHTML = `
<div class="card">
<div class="card-header"><span class="card-title">Template Overview</span></div>
<div class="card-body">
<div class="conn-info-row">
<span class="conn-info-label">Adobe Sign ID</span>
<span class="conn-info-value">${escHtml(t.adobe_id)}</span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">Last Modified</span>
<span class="conn-info-value">${formatDate(t.adobe_modified)}</span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">Migration Status</span>
<span class="conn-info-value"><span class="badge badge-${t.status === 'migrated' ? 'migrated' : t.status === 'needs_update' ? 'amber' : 'gray'}">${t.status.replace('_', ' ')}</span></span>
</div>
${t.docusign_id ? `
<div class="conn-info-row">
<span class="conn-info-label">Docusign Template ID</span>
<span class="conn-info-value mono">${escHtml(t.docusign_id)}</span>
</div>` : ''}
</div>
</div>`;
} else if (tabKey === 'issues') {
const blockers = t.blockers || [];
const warnings = t.warnings || [];
if (!blockers.length && !warnings.length) {
content.innerHTML = `<div class="callout success"><span class="callout-icon">✓</span>No issues found. This template is ready to migrate.</div>`;
} else {
content.innerHTML = `
${blockers.length ? `
<div class="card">
<div class="card-header"><span class="card-title" style="color:var(--error)">🚫 Blockers (${blockers.length})</span></div>
<div class="card-body">
${blockers.map(b => `
<div class="issue-row">
<span class="issue-severity blocker">BLOCKER</span>
<div class="issue-body"><div class="issue-title">${escHtml(b)}</div></div>
</div>`).join('')}
</div>
</div>` : ''}
${warnings.length ? `
<div class="card">
<div class="card-header"><span class="card-title" style="color:var(--warning)">⚠ Warnings (${warnings.length})</span></div>
<div class="card-body">
${warnings.map(w => `
<div class="issue-row">
<span class="issue-severity warn">WARNING</span>
<div class="issue-body"><div class="issue-title">${escHtml(w)}</div></div>
</div>`).join('')}
</div>
</div>` : ''}`;
}
} else if (tabKey === 'history') {
api.migrate.history().then(data => {
const records = (data.history || []).filter(r =>
r.adobe_template_id === t.adobe_id || r.adobe_template_name === t.name
);
if (!records.length) {
content.innerHTML = `<div class="callout info"><span class="callout-icon"></span>No migration history for this template yet.</div>`;
} else {
const rows = [...records].reverse().map(r => {
const fieldIssues = r.field_issues || [];
const hasIssues = fieldIssues.length > 0;
const hasDetail = r.error || (r.blockers||[]).length || (r.warnings||[]).length || hasIssues;
const detailHtml = hasDetail ? `
<tr class="row-expanded-content" style="display:none">
<td colspan="4">
<div class="row-expand-body">
${(r.blockers||[]).map(b => `<div style="color:var(--error);font-size:12px">🚫 ${escHtml(b)}</div>`).join('')}
${(r.warnings||[]).map(w => `<div style="color:var(--warning);font-size:12px">⚠ ${escHtml(w)}</div>`).join('')}
${r.error ? `<div style="color:var(--error);font-size:12px">❌ ${escHtml(r.error)}</div>` : ''}
${renderFieldIssues(fieldIssues)}
</div>
</td>
</tr>` : '';
return `
<tr class="${hasDetail ? 'row-expandable' : ''}" style="${hasDetail ? 'cursor:pointer' : ''}">
<td>${(r.timestamp||'').slice(0,19).replace('T',' ')}</td>
<td>${escHtml(r.action||'—')}</td>
<td>
<span class="badge ${r.status==='success'?'badge-green':'badge-red'}">${r.status}</span>
${hasIssues ? '<span class="badge badge-amber" style="font-size:10px">partial</span>' : ''}
${hasDetail ? '<span style="font-size:10px;color:var(--text-muted);margin-left:4px">▶ click for details</span>' : ''}
</td>
<td class="mono">${escHtml(r.docusign_template_id||'—')}</td>
</tr>${detailHtml}`;
}).join('');
content.innerHTML = `
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th>Time</th><th>Action</th><th>Status</th><th>Docusign ID</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>`;
content.querySelectorAll('.row-expandable').forEach(row => {
row.addEventListener('click', () => {
const next = row.nextElementSibling;
if (next?.classList.contains('row-expanded-content')) {
const open = next.style.display !== 'none';
next.style.display = open ? 'none' : 'table-row';
const hint = row.querySelector('span[style*="text-muted"]');
if (hint) hint.textContent = open ? '▶ click for details' : '▼ hide details';
}
});
});
bindFieldIssueToggles(content);
}
}).catch(() => {
content.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history.</div>`;
});
}
}