diff --git a/tests/test_api_templates.py b/tests/test_api_templates.py
index 894037f..5058367 100644
--- a/tests/test_api_templates.py
+++ b/tests/test_api_templates.py
@@ -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)
diff --git a/web/routers/templates.py b/web/routers/templates.py
index 141cae7..378bde8 100644
--- a/web/routers/templates.py
+++ b/web/routers/templates.py
@@ -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
diff --git a/web/static/js/templates.js b/web/static/js/templates.js
new file mode 100644
index 0000000..c1278b7
--- /dev/null
+++ b/web/static/js/templates.js
@@ -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 = `
`;
+ 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 = `
+
+
+ ${!state.auth.adobe || !state.auth.docusign ? `
+
+ ℹ️
+ Connect both Adobe Sign and DocuSign in the top bar to load templates.
+
+ ` : ''}
+
+
+
+
+
+ ${_filterTab('all', `All ${counts.total}`)}
+ ${_filterTab('not_migrated', `Not Migrated ${counts.not_migrated}`)}
+ ${_filterTab('migrated', `Migrated ${counts.migrated}`)}
+ ${_filterTab('needs_update', `Needs Update ${counts.needs_update}`)}
+
+
+ ${_filterTab2('blocked', `Blocked ${counts.blocked}`)}
+ ${_filterTab2('caveats', `Caveats ${counts.caveats}`)}
+
+
+
+
+
+ ${state.selectedIds.size} template(s) selected
+
+
+
+
+
+
+
+
+
+
+ |
+
+ |
+ ${_th('name', 'Template Name')}
+ ${_th('readiness', 'Readiness')}
+ ${_th('warnings', 'Issues')}
+ ${_th('adobe_modified','Last Modified')}
+ ${_th('status', 'DS Status')}
+ Actions |
+
+
+
+ ${templates.length
+ ? templates.map(t => _templateRow(t)).join('')
+ : `
+
+ 📄
+ ${state.templates.length ? 'No templates match your filter' : 'No templates found'}
+ ${state.templates.length ? 'Try clearing the search or filter.' : 'Connect Adobe Sign to load templates.'}
+
+ |
`
+ }
+
+
+
+
+ `;
+
+ _bindEvents();
+}
+
+function _filterTab(key, label) {
+ return ``;
+}
+
+function _filterTab2(key, label) {
+ // readiness-based filters
+ return ``;
+}
+
+function _th(col, label) {
+ const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
+ return `${label} | `;
+}
+
+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 `
+
+ |
+
+ ${escHtml(t.name)}
+ ${escHtml(t.adobe_id)}
+ |
+ ${r.label} |
+ ${issueLabel} |
+ ${formatRelative(t.adobe_modified)} |
+
+ ${t.docusign_id
+ ? `In DocuSign`
+ : `Not Migrated`}
+ |
+
+
+
+
+
+ |
+
+ `;
+}
+
+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 = `
+
+
🔍
+
Template not found
+
+
`;
+ return;
+ }
+
+ const r = readiness(t);
+ outlet.innerHTML = `
+
+
+
+
Overview
+
Issues ${(t.blockers||[]).length + (t.warnings||[]).length > 0
+ ? `${(t.blockers||[]).length + (t.warnings||[]).length}` : ''}
+
Migration History
+
+
+
+ `;
+
+ 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 = `
+
+
+
+
+ Adobe Sign ID
+ ${escHtml(t.adobe_id)}
+
+
+ Last Modified
+ ${formatDate(t.adobe_modified)}
+
+
+ Migration Status
+ ${t.status.replace('_', ' ')}
+
+ ${t.docusign_id ? `
+
+ DocuSign Template ID
+ ${escHtml(t.docusign_id)}
+
` : ''}
+
+
`;
+ } else if (tabKey === 'issues') {
+ const blockers = t.blockers || [];
+ const warnings = t.warnings || [];
+ if (!blockers.length && !warnings.length) {
+ content.innerHTML = `✓No issues found. This template is ready to migrate.
`;
+ } else {
+ content.innerHTML = `
+ ${blockers.length ? `
+
+
+
+ ${blockers.map(b => `
+
`).join('')}
+
+
` : ''}
+ ${warnings.length ? `
+
+
+
+ ${warnings.map(w => `
+
`).join('')}
+
+
` : ''}`;
+ }
+ } 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 = `ℹ️No migration history for this template yet.
`;
+ } else {
+ content.innerHTML = `
+
+
+
+ | Time | Action | Status | DocuSign ID |
+
+ ${[...records].reverse().map(r => `
+
+ | ${(r.timestamp||'').slice(0,19).replace('T',' ')} |
+ ${escHtml(r.action||'—')} |
+ ${r.status} |
+ ${escHtml(r.docusign_template_id||'—')} |
+
`).join('')}
+
+
+
+
`;
+ }
+ }).catch(() => {
+ content.innerHTML = `❌Failed to load history.
`;
+ });
+ }
+}