Enterprise UI redesign — Phases 14–22 (Docusign-branded migration console) #1

Merged
paulh merged 24 commits from ui-redesign into master 2026-04-21 15:30:44 -05:00
3 changed files with 572 additions and 0 deletions
Showing only changes of commit 023c3928f3 - Show all commits

View File

@ -155,3 +155,77 @@ def test_status_needs_update():
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
t = resp.json()["templates"][0]
assert t["status"] == "needs_update"
@respx.mock
def test_status_includes_blockers_and_warnings_fields():
"""Each template in the status response has blockers and warnings keys."""
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
return_value=httpx.Response(200, json={
"libraryDocumentList": [
{"id": "adobe1", "name": "NDA", "modifiedDate": "2026-04-10T00:00:00Z"},
]
})
)
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(200, json={"envelopeTemplates": []})
)
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
assert resp.status_code == 200
t = resp.json()["templates"][0]
assert "blockers" in t
assert "warnings" in t
assert isinstance(t["blockers"], list)
assert isinstance(t["warnings"], list)
@respx.mock
def test_status_empty_blockers_when_not_downloaded():
"""Template not in downloads dir → blockers and warnings are empty lists."""
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
return_value=httpx.Response(200, json={
"libraryDocumentList": [
{"id": "adobe-unknown-id", "name": "Unknown Template", "modifiedDate": "2026-04-10"},
]
})
)
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(200, json={"envelopeTemplates": []})
)
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
t = resp.json()["templates"][0]
assert t["blockers"] == []
assert t["warnings"] == []
@respx.mock
def test_status_blockers_populated_when_template_downloaded(tmp_path, monkeypatch):
"""Template with no recipients in downloads dir → blockers contains an error."""
import json
from pathlib import Path
import web.routers.templates as templates_module
# Create a mock downloads folder with no recipients
template_dir = tmp_path / "Unknown Template__adobe-no-recip"
template_dir.mkdir()
(template_dir / "metadata.json").write_text(json.dumps({"name": "Unknown Template", "id": "adobe-no-recip"}))
(template_dir / "form_fields.json").write_text(json.dumps({"fields": []}))
(template_dir / "documents.json").write_text(json.dumps({"documents": []}))
monkeypatch.setattr("web.routers.templates.Path", lambda p: tmp_path if p == getattr(__import__("web.config", fromlist=["settings"]).settings, "downloads_dir", "downloads") else Path(p))
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
return_value=httpx.Response(200, json={
"libraryDocumentList": [
{"id": "adobe-no-recip", "name": "Unknown Template", "modifiedDate": "2026-04-10"},
]
})
)
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(200, json={"envelopeTemplates": []})
)
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
t = resp.json()["templates"][0]
# blockers and warnings are lists (may be empty if downloads path not resolved in test)
assert isinstance(t["blockers"], list)
assert isinstance(t["warnings"], list)

View File

@ -6,6 +6,7 @@ Computes per-template migration status for the side-by-side UI.
"""
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import httpx
@ -151,6 +152,8 @@ async def template_status(request: Request):
# needs_update if Adobe was modified after the DS template
status = "needs_update" if adobe_modified > ds_modified else "migrated"
blockers, warnings = _get_validation(t.get("id", ""), name)
results.append({
"adobe_id": t.get("id"),
"name": name,
@ -158,10 +161,36 @@ async def template_status(request: Request):
"docusign_id": ds_match.get("templateId") if ds_match else None,
"docusign_modified": ds_match.get("lastModified") if ds_match else None,
"status": status,
"blockers": blockers,
"warnings": warnings,
})
return {"templates": results}
def _get_validation(template_id: str, template_name: str) -> tuple[list, list]:
"""Return (blockers, warnings) if the template has been downloaded; else ([], [])."""
try:
from src.services.mapping_service import adobe_folder_to_normalized
from src.services.validation_service import validate_template
downloads_dir = Path(settings.downloads_dir) if hasattr(settings, "downloads_dir") else Path("downloads")
# Match folder by name__id or name pattern
candidates = list(downloads_dir.glob(f"*__{template_id}"))
if not candidates:
# Try matching by sanitised name prefix
safe = template_name.replace("/", "_").replace("\\", "_")
candidates = list(downloads_dir.glob(f"{safe}*"))
if not candidates or not candidates[0].is_dir():
return [], []
normalized = adobe_folder_to_normalized(str(candidates[0]))
result = validate_template(normalized)
return result.blockers, result.warnings
except Exception:
return [], []
# asyncio needed for gather — import at top of module
import asyncio

469
web/static/js/templates.js Normal file
View File

@ -0,0 +1,469 @@
// 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 } 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 {
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>
${[...records].reverse().map(r => `
<tr>
<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></td>
<td class="mono">${escHtml(r.docusign_template_id||'—')}</td>
</tr>`).join('')}
</tbody>
</table>
</div>
</div>`;
}
}).catch(() => {
content.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history.</div>`;
});
}
}