From b2bbcac842aafeebaf1b672f587bc20c29de93ca Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Tue, 21 Apr 2026 15:25:23 -0400 Subject: [PATCH] feat(issues): structured field-issue reporting throughout migration pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces flat warning strings with machine-readable FieldIssue objects (code, field_name, message, severity) emitted during compose and surfaced in all migration result paths via a new field_issues[] key. Codes: CROSS_RECIPIENT_CONDITIONAL, UNSUPPORTED_OPERATOR, HIDE_ACTION, MULTI_PREDICATE, INVALID_PARENT_TAB, FIELD_TYPE_SKIPPED, PARTIAL_FIELD_TYPE Cross-recipient conditional detection: compose now builds a field→assignee map and flags conditions where the trigger field belongs to a different recipient — the main cause of the CONDITIONALTAB_HAS_INVALID_PARENT 400. UI changes: - Success rows with field_issues show ⚠️ icon + amber "partial" badge - Results, History & Audit, and Template Detail history tab all show field issues grouped by code in collapsible sections within expanded rows Co-Authored-By: Claude Sonnet 4.6 --- src/compose_docusign_template.py | 162 +++++++++++++++++++++---------- src/models/field_issue.py | 39 ++++++++ tests/test_regression.py | 4 +- web/routers/migrate.py | 14 ++- web/static/css/cards.css | 43 ++++++++ web/static/js/history.js | 10 +- web/static/js/migration.js | 20 ++-- web/static/js/templates.js | 12 ++- web/static/js/utils.js | 57 +++++++++++ 9 files changed, 294 insertions(+), 67 deletions(-) create mode 100644 src/models/field_issue.py diff --git a/src/compose_docusign_template.py b/src/compose_docusign_template.py index db1393f..a73df89 100644 --- a/src/compose_docusign_template.py +++ b/src/compose_docusign_template.py @@ -35,9 +35,19 @@ Conditional logic: import base64 import json -import os from pathlib import Path +from src.models.field_issue import ( + FieldIssue, + CROSS_RECIPIENT_CONDITIONAL, + UNSUPPORTED_OPERATOR, + HIDE_ACTION, + MULTI_PREDICATE, + INVALID_PARENT_TAB, + FIELD_TYPE_SKIPPED, + PARTIAL_FIELD_TYPE, +) + DOCUMENT_ID = "1" @@ -154,7 +164,14 @@ def _sized_tabs(locations: list, label: str, extra: dict | None = None) -> list: # Conditional logic # --------------------------------------------------------------------------- -def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict: +def _apply_conditional_to_tabs( + tabs: dict, + field: dict, + warnings: list, + issues: list, + current_assignee: str = "", + field_assignee: dict | None = None, +) -> dict: """ Apply DocuSign conditionalParentLabel / conditionalParentValue to tabs based on an Adobe Sign conditionalAction. @@ -169,6 +186,8 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict: Mapping limitations: - Only SHOW action is supported. DocuSign has no native HIDE — condition skipped. - Only EQUALS operator is supported. Others are skipped. + - Cross-recipient conditions not supported — DocuSign conditionals only work within + a single recipient's tab set. - Only one predicate is mapped. Multi-predicate ANY/ALL logic is not supported; the first EQUALS predicate is used and a warning is logged. """ @@ -184,33 +203,54 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict: action = ca.get("action", "SHOW") if action != "SHOW": - warnings.append( - f"Conditional '{label}': action={action} is not supported in DocuSign " - f"(only SHOW is supported) — condition skipped" + msg = ( + f"Field '{label}' has a HIDE condition which DocuSign does not support — " + f"condition dropped. The field will always be visible." ) + warnings.append(msg) + issues.append(FieldIssue(HIDE_ACTION, label, msg).to_dict()) return tabs predicate = next((p for p in predicates if p.get("operator") == "EQUALS"), None) if not predicate: - warnings.append( - f"Conditional '{label}': no EQUALS predicate found " - f"(operators: {[p.get('operator') for p in predicates]}) — condition skipped" + ops = [p.get("operator") for p in predicates] + msg = ( + f"Field '{label}' uses unsupported condition operator(s) {ops} — " + f"only EQUALS is supported in DocuSign. Condition dropped; field will always be visible." ) + warnings.append(msg) + issues.append(FieldIssue(UNSUPPORTED_OPERATOR, label, msg).to_dict()) return tabs - if len(predicates) > 1: - warnings.append( - f"Conditional '{label}': {len(predicates)} predicates with " - f"anyOrAll={ca.get('anyOrAll')} — only first EQUALS predicate mapped, " - f"remaining conditions ignored" - ) + parent_field_name = predicate["fieldName"] + + # Cross-recipient check: DocuSign does not support conditionals across recipients + if field_assignee is not None and current_assignee: + parent_assignee = field_assignee.get(parent_field_name, "") + if parent_assignee and parent_assignee != current_assignee: + msg = ( + f"Field '{label}' has a show/hide condition controlled by '{parent_field_name}', " + f"which belongs to a different recipient ({parent_assignee} vs {current_assignee}). " + f"DocuSign does not support cross-recipient conditional logic — condition dropped." + ) + warnings.append(msg) + issues.append(FieldIssue(CROSS_RECIPIENT_CONDITIONAL, label, msg).to_dict()) + return tabs + + if len(predicates) > 1: + msg = ( + f"Field '{label}' has {len(predicates)} conditions combined with " + f"anyOrAll={ca.get('anyOrAll')} — only the first EQUALS predicate was mapped. " + f"Remaining conditions were dropped." + ) + warnings.append(msg) + issues.append(FieldIssue(MULTI_PREDICATE, label, msg).to_dict()) - parent_label = predicate["fieldName"] parent_value = predicate["value"] for tab_list in tabs.values(): for tab in tab_list: - tab["conditionalParentLabel"] = parent_label + tab["conditionalParentLabel"] = parent_field_name tab["conditionalParentValue"] = parent_value return tabs @@ -220,11 +260,12 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict: # Tab builder # --------------------------------------------------------------------------- -def build_tabs_for_field(field: dict, warnings: list) -> dict: +def build_tabs_for_field(field: dict, warnings: list, issues: list) -> dict: """ Convert one Adobe Sign field into the correct DocuSign tabs structure. Returns a dict of tab-group keys, e.g. {"textTabs": [...]}. - Unmappable fields are skipped and a warning is appended. + Unmappable fields are skipped; a warning string and a structured FieldIssue + are both appended so callers have both human-readable and machine-readable output. """ input_type = field.get("inputType", "") label = field.get("name", "unnamed") @@ -240,22 +281,16 @@ def build_tabs_for_field(field: dict, warnings: list) -> dict: if input_type == "TEXT_FIELD": if content_type == "SIGNATURE_DATE": - # Auto-populated with the signing date return {"dateSignedTabs": _sized_tabs(locations, label)} elif content_type == "SIGNER_NAME": - # Auto-populated with the signer's full name return {"fullNameTabs": _sized_tabs(locations, label)} elif content_type == "SIGNER_EMAIL": - # Auto-populated with the signer's email address return {"emailAddressTabs": _sized_tabs(locations, label)} elif content_type in ("COMPANY", "SIGNER_COMPANY"): - # Auto-populated with the signer's company return {"companyTabs": _sized_tabs(locations, label)} elif content_type in ("TITLE", "SIGNER_TITLE"): - # Auto-populated with the signer's title return {"titleTabs": _sized_tabs(locations, label)} elif content_type == "DATA" and validation == "DATE": - # User-entered date field (not auto-signed date) return {"dateTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})} elif content_type == "DATA" and validation == "NUMBER": return {"numberTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})} @@ -263,15 +298,12 @@ def build_tabs_for_field(field: dict, warnings: list) -> dict: return {"textTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})} elif input_type == "SIGNATURE": - # Each signature/initials location is an independent signing action — - # emit one tab per location but do not size them (DocuSign controls size) if content_type == "SIGNER_INITIALS": return {"initialHereTabs": [_make_base_tab(loc, label) for loc in locations]} else: return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]} elif input_type == "BLOCK" and content_type == "SIGNATURE_BLOCK": - # Composite signature block — map to signHere at block's location return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]} elif input_type == "DATE": @@ -286,7 +318,6 @@ def build_tabs_for_field(field: dict, warnings: list) -> dict: return {"listTabs": _sized_tabs(locations, label, {"required": required_str, "listItems": list_items})} elif input_type == "RADIO": - # Each location is one radio button within the group — not tab merging options = field.get("hiddenOptions") or [] radios = [] for i, loc in enumerate(locations): @@ -296,26 +327,40 @@ def build_tabs_for_field(field: dict, warnings: list) -> dict: return {"radioGroupTabs": [{"groupName": label, "documentId": DOCUMENT_ID, "radios": radios}]} elif input_type == "FILE_CHOOSER": - warnings.append(f"FILE_CHOOSER '{label}' → mapped to signerAttachmentTabs (manual review recommended)") + msg = ( + f"Field '{label}' is a FILE_CHOOSER — mapped to a signerAttachmentTabs tab. " + f"DocuSign attachment tabs behave differently from Adobe file upload fields; manual review recommended." + ) + warnings.append(msg) + issues.append(FieldIssue(PARTIAL_FIELD_TYPE, label, msg).to_dict()) tab = _make_base_tab(locations[0], label, {"optional": "true" if not field.get("required") else "false"}) return {"signerAttachmentTabs": [tab]} elif input_type == "INLINE_IMAGE": - warnings.append(f"INLINE_IMAGE '{label}' → skipped (no DocuSign equivalent)") + msg = f"Field '{label}' is an INLINE_IMAGE — skipped. There is no equivalent tab type in DocuSign." + warnings.append(msg) + issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict()) return {} elif input_type == "STAMP": - # DocuSign stampTabs — signer uploads or selects a hanko/seal stamp image. - # Requires the stamp feature to be enabled on the DocuSign account. - warnings.append(f"STAMP '{label}' → stampTabs (verify stamp feature is enabled on your DocuSign account)") + msg = ( + f"Field '{label}' is a STAMP — mapped to stampTabs. " + f"This requires the stamp feature to be enabled on your DocuSign account." + ) + warnings.append(msg) + issues.append(FieldIssue(PARTIAL_FIELD_TYPE, label, msg).to_dict()) return {"stampTabs": [_make_base_tab(loc, label) for loc in locations]} elif input_type == "PARTICIPATION_STAMP": - warnings.append(f"PARTICIPATION_STAMP '{label}' → skipped (no DocuSign equivalent)") + msg = f"Field '{label}' is a PARTICIPATION_STAMP — skipped. There is no equivalent tab type in DocuSign." + warnings.append(msg) + issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict()) return {} else: - warnings.append(f"Unknown field type '{input_type}' (contentType='{content_type}') for field '{label}' → skipped") + msg = f"Field '{label}' has unknown type '{input_type}' (contentType='{content_type}') — skipped." + warnings.append(msg) + issues.append(FieldIssue(FIELD_TYPE_SKIPPED, label, msg).to_dict()) return {} @@ -332,7 +377,7 @@ _INVALID_PARENT_TAB_TYPES = { } -def _strip_invalid_conditionals(signers: list, warnings: list) -> None: +def _strip_invalid_conditionals(signers: list, warnings: list, issues: list) -> None: """ Remove conditionalParentLabel/Value from any tab whose parent label either doesn't exist in the template or points to a tab type DocuSign forbids as a @@ -347,20 +392,24 @@ def _strip_invalid_conditionals(signers: list, warnings: list) -> None: if tab_type in _INVALID_PARENT_TAB_TYPES: continue for tab in tab_list: - label = tab.get("tabLabel") or tab.get("groupName") - if label: - valid_labels.add(label) + lbl = tab.get("tabLabel") or tab.get("groupName") + if lbl: + valid_labels.add(lbl) # Strip references to invalid/missing parents - for tab_type, tab_list in tabs.items(): + for tab_list in tabs.values(): for tab in tab_list: parent = tab.get("conditionalParentLabel") if parent and parent not in valid_labels: - warnings.append( - f"Conditional parent '{parent}' not found or not a valid " - f"parent tab type — conditional stripped from tab " - f"'{tab.get('tabLabel', '?')}'" + field_name = tab.get("tabLabel") or tab.get("groupName") or "?" + msg = ( + f"Field '{field_name}' has a conditional that references parent " + f"'{parent}', which either does not exist as a tab or is a " + f"signature/auto-fill tab (forbidden as a DocuSign conditional parent). " + f"Condition stripped — field will always be visible." ) + warnings.append(msg) + issues.append(FieldIssue(INVALID_PARENT_TAB, field_name, msg).to_dict()) tab.pop("conditionalParentLabel", None) tab.pop("conditionalParentValue", None) @@ -369,7 +418,7 @@ def _strip_invalid_conditionals(signers: list, warnings: list) -> None: # Main compose function # --------------------------------------------------------------------------- -def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[str]]: +def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[str], list[dict]]: """ Build a DocuSign template JSON from a downloaded Adobe Sign template folder. @@ -379,10 +428,13 @@ def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[st output_path: where to write the resulting DocuSign template JSON Returns: - (template_dict, warnings_list) + (template_dict, warnings_list, field_issues_list) + field_issues_list contains structured FieldIssue dicts describing properties + that were dropped or approximated during migration (see src/models/field_issue.py). """ template_dir = Path(template_dir) warnings: list[str] = [] + issues: list[dict] = [] # Load source files metadata = json.loads((template_dir / "metadata.json").read_text()) @@ -416,19 +468,27 @@ def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[st "tabs": {}, }) + # Build field→assignee lookup for cross-recipient conditional detection + field_assignee: dict[str, str] = {} + for f in fields: + name = f.get("name", "") + assignee = f.get("assignee") or f"recipient{max(f.get('signerIndex', 0), 0)}" + if name: + field_assignee[name] = assignee + # Assign tabs to the correct signer for field in fields: assignee = field.get("assignee") or f"recipient{max(field.get('signerIndex', 0), 0)}" idx = assignee_to_index(assignee, recipients) if idx >= len(signers): idx = 0 - tabs = build_tabs_for_field(field, warnings) - tabs = _apply_conditional_to_tabs(tabs, field, warnings) + tabs = build_tabs_for_field(field, warnings, issues) + tabs = _apply_conditional_to_tabs(tabs, field, warnings, issues, assignee, field_assignee) signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs) # Post-process: strip conditionalParentLabel references that point to # non-existent or invalid parents (signature/initial tabs can't be parents). - _strip_invalid_conditionals(signers, warnings) + _strip_invalid_conditionals(signers, warnings, issues) template = { "name": metadata.get("name", template_dir.name), @@ -450,7 +510,7 @@ def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[st with open(output_path, "w") as f: json.dump(template, f, indent=2) - return template, warnings + return template, warnings, issues # --------------------------------------------------------------------------- @@ -471,7 +531,7 @@ if __name__ == "__main__": output_path = Path(__file__).parent.parent / "migration-output" / template_dir.name / "docusign-template.json" print(f"\n--- {template_dir.name} ---") try: - _, warnings = compose_template(str(template_dir), str(output_path)) + _, warnings, issues = compose_template(str(template_dir), str(output_path)) print(f" Written: {output_path}") for w in warnings: print(f" WARNING: {w}") diff --git a/src/models/field_issue.py b/src/models/field_issue.py new file mode 100644 index 0000000..fef0dcf --- /dev/null +++ b/src/models/field_issue.py @@ -0,0 +1,39 @@ +""" +Structured field-level issue emitted during compose/migration. +Distinct from validation blockers — a field issue means the field +migrated but something was silently dropped or approximated. +""" + +from dataclasses import dataclass, asdict + + +# Machine-readable codes used in field_issues lists +CROSS_RECIPIENT_CONDITIONAL = "CROSS_RECIPIENT_CONDITIONAL" +UNSUPPORTED_OPERATOR = "UNSUPPORTED_OPERATOR" +HIDE_ACTION = "HIDE_ACTION" +MULTI_PREDICATE = "MULTI_PREDICATE" +INVALID_PARENT_TAB = "INVALID_PARENT_TAB" +FIELD_TYPE_SKIPPED = "FIELD_TYPE_SKIPPED" +PARTIAL_FIELD_TYPE = "PARTIAL_FIELD_TYPE" + +# Human-readable labels for each code (used by the UI) +CODE_LABELS = { + CROSS_RECIPIENT_CONDITIONAL: "Cross-recipient conditional dropped", + UNSUPPORTED_OPERATOR: "Unsupported condition operator dropped", + HIDE_ACTION: "Hide condition dropped (no DocuSign equivalent)", + MULTI_PREDICATE: "Multi-condition logic simplified to first match", + INVALID_PARENT_TAB: "Conditional parent tab invalid or missing", + FIELD_TYPE_SKIPPED: "Field type skipped (no DocuSign equivalent)", + PARTIAL_FIELD_TYPE: "Field type approximated", +} + + +@dataclass +class FieldIssue: + code: str # one of the constants above + field_name: str # Adobe field name + message: str # human-readable description of what was dropped and why + severity: str = "warning" # "warning" | "info" + + def to_dict(self) -> dict: + return asdict(self) diff --git a/tests/test_regression.py b/tests/test_regression.py index 65d923e..588d568 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -55,7 +55,7 @@ def test_compose_regression(template_name, update_snapshots): output_path = tf.name try: - result, warnings = compose_template(template_dir, output_path) + result, warnings, _ = compose_template(template_dir, output_path) if update_snapshots: os.makedirs(FIXTURES_DIR, exist_ok=True) @@ -121,7 +121,7 @@ def test_no_tabs_lost_on_recompose(): with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf: output_path = tf.name try: - result, _ = compose_template(template_dir, output_path) + result, _, _issues = compose_template(template_dir, output_path) total_tabs = sum(_count_tabs(result).values()) assert total_tabs > 0, f"No tabs produced for {template_name}" finally: diff --git a/web/routers/migrate.py b/web/routers/migrate.py index 9df56e0..8437a7d 100644 --- a/web/routers/migrate.py +++ b/web/routers/migrate.py @@ -165,6 +165,7 @@ async def _migrate_one( "error": "Adobe Sign download failed", "warnings": [], "blockers": [], + "field_issues": [], "dry_run": options.dry_run, } @@ -185,14 +186,19 @@ async def _migrate_one( "error": f"Validation blockers: {'; '.join(validation['blockers'])}", "warnings": validation["warnings"], "blockers": validation["blockers"], + "field_issues": [], "dry_run": options.dry_run, } # 3. Compose composed_file = os.path.join(tmpdir, "docusign-template.json") + compose_issues: list = [] try: compose_fn = _load_compose() - compose_fn(download_dir, composed_file) + compose_result = compose_fn(download_dir, composed_file) + # compose_template returns (template, warnings, issues) + if isinstance(compose_result, tuple) and len(compose_result) >= 3: + compose_issues = compose_result[2] or [] except Exception as exc: return { "timestamp": timestamp, @@ -204,6 +210,7 @@ async def _migrate_one( "error": f"Compose failed: {exc}", "warnings": validation["warnings"], "blockers": [], + "field_issues": [], "dry_run": options.dry_run, } if not os.path.exists(composed_file): @@ -217,6 +224,7 @@ async def _migrate_one( "error": "Compose produced no output file", "warnings": validation["warnings"], "blockers": [], + "field_issues": [], "dry_run": options.dry_run, } @@ -232,6 +240,7 @@ async def _migrate_one( "error": None, "warnings": validation["warnings"], "blockers": [], + "field_issues": compose_issues, "dry_run": True, } @@ -275,6 +284,7 @@ async def _migrate_one( "error": None, "warnings": validation["warnings"] + ["Skipped: template already exists (overwrite_if_exists=false)"], "blockers": [], + "field_issues": compose_issues, "dry_run": False, } @@ -300,6 +310,7 @@ async def _migrate_one( "error": f"DocuSign upload failed ({up_resp.status_code}): {up_resp.text[:200]}", "warnings": validation["warnings"], "blockers": [], + "field_issues": compose_issues, "dry_run": False, } @@ -313,6 +324,7 @@ async def _migrate_one( "error": None, "warnings": validation["warnings"], "blockers": [], + "field_issues": compose_issues, "dry_run": False, } diff --git a/web/static/css/cards.css b/web/static/css/cards.css index 4ed1998..4ebe968 100644 --- a/web/static/css/cards.css +++ b/web/static/css/cards.css @@ -190,6 +190,49 @@ .result-body { padding: 12px 16px; border-top: 1px solid var(--border); background: #FAFBFC; display: none; } .result-row.open .result-body { display: block; } +/* ── Field issues block (structured dropped-feature list) ── */ +.field-issues-block { + margin-top: 10px; + border-top: 1px solid var(--border); + padding-top: 8px; +} +.field-issue-group { + margin-bottom: 6px; +} +.field-issue-group-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--warning); + margin-bottom: 4px; + cursor: pointer; + user-select: none; +} +.field-issue-group-body { + display: none; + padding-left: 12px; +} +.field-issue-group.open .field-issue-group-body { display: block; } +.field-issue-row { + display: flex; + gap: 8px; + font-size: 11px; + padding: 3px 0; + border-bottom: 1px solid var(--border); + align-items: baseline; +} +.field-issue-row:last-child { border-bottom: none; } +.field-issue-field { + font-weight: 600; + color: var(--text); + white-space: nowrap; + flex-shrink: 0; + min-width: 120px; +} +.field-issue-msg { color: var(--text-muted); line-height: 1.4; } + /* ── DS template link pill ── */ .ds-pill { display: inline-flex; diff --git a/web/static/js/history.js b/web/static/js/history.js index dee48f5..c63e3a8 100644 --- a/web/static/js/history.js +++ b/web/static/js/history.js @@ -1,7 +1,7 @@ // History & Audit view — filterable, exportable migration history import { api } from './api.js'; -import { escHtml, formatDateTime, shortHash, downloadCsv, debounce } from './utils.js'; +import { escHtml, formatDateTime, shortHash, downloadCsv, debounce, renderFieldIssues, bindFieldIssueToggles } from './utils.js'; let _allRecords = []; let _filter = { search: '', status: 'all', from: '', to: '' }; @@ -108,8 +108,9 @@ function _th(col, label) { } function _historyRow(r) { + const hasIssues = (r.field_issues || []).length > 0; const statusBadge = r.status === 'success' - ? `${escHtml(r.action || 'success')}` + ? `${escHtml(r.action || 'success')}${hasIssues ? 'partial' : ''}` : `${escHtml(r.status || '—')}`; const checksum = r.checksum_sha256 || r.checksum || ''; @@ -130,13 +131,14 @@ function _historyRow(r) { : ''} - ${(r.blockers || r.warnings || r.error) ? ` + ${(r.blockers || r.warnings || r.error || (r.field_issues||[]).length) ? `
${(r.blockers||[]).map(b => `
🚫 ${escHtml(b)}
`).join('')} ${(r.warnings||[]).map(w => `
⚠ ${escHtml(w)}
`).join('')} ${r.error ? `
❌ ${escHtml(r.error)}
` : ''} + ${renderFieldIssues(r.field_issues)}
` : ''} @@ -206,6 +208,8 @@ function _bindEvents(filtered) { }); }); + bindFieldIssueToggles(); + document.getElementById('btn-export-history')?.addEventListener('click', () => { downloadCsv('migration-history.csv', filtered.map(r => ({ timestamp: r.timestamp || '', diff --git a/web/static/js/migration.js b/web/static/js/migration.js index 32099d2..eb3ecbe 100644 --- a/web/static/js/migration.js +++ b/web/static/js/migration.js @@ -2,7 +2,7 @@ import { api } from './api.js'; import { state, setState } from './state.js'; -import { escHtml, formatDateTime, downloadCsv } from './utils.js'; +import { escHtml, formatDateTime, downloadCsv, renderFieldIssues, bindFieldIssueToggles } from './utils.js'; import { navigate } from './router.js'; import { refreshTemplates } from './templates.js'; @@ -335,6 +335,8 @@ export function renderResults() { }); }); + bindFieldIssueToggles(); + // Export CSV document.getElementById('btn-export-results')?.addEventListener('click', () => { downloadCsv('migration-results.csv', templateResults.map(r => ({ @@ -357,16 +359,18 @@ export function renderResults() { } 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' - ? (r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️') + ? (issues.length ? '⚠️' : r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️') : (r.status === 'skipped' ? '⏭' : r.status === 'blocked' ? '🚫' : '❌'); const statusBadge = r.status === 'success' - ? `${r.action || 'success'}` + ? `${r.action || 'success'}${issues.length ? 'partial' : ''}` : `${r.status}`; - const warnings = r.warnings || []; - return `
@@ -374,12 +378,14 @@ function _resultRow(r) { ${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' : ''}` : ''} + ${issues.length ? `⚠ ${issues.length} field issue${issues.length > 1 ? 's' : ''}` : ''} + ${warnings.length ? `${warnings.length} warning${warnings.length > 1 ? 's' : ''}` : ''}
- ${warnings.length || r.error ? ` + ${hasDetail ? `
${warnings.map(w => `
${escHtml(w)}
`).join('')} ${r.error ? `
${escHtml(r.error)}
` : ''} + ${renderFieldIssues(issues)}
` : ''}
`; diff --git a/web/static/js/templates.js b/web/static/js/templates.js index 7b95943..20f0201 100644 --- a/web/static/js/templates.js +++ b/web/static/js/templates.js @@ -2,7 +2,7 @@ import { api } from './api.js'; import { state, setState, updateDerivedState } from './state.js'; -import { escHtml, formatDate, formatRelative, debounce } from './utils.js'; +import { escHtml, formatDate, formatRelative, debounce, renderFieldIssues, bindFieldIssueToggles } from './utils.js'; import { navigate } from './router.js'; // ── Readiness badge ──────────────────────────────────────────────────────── @@ -445,7 +445,9 @@ function _renderDetailTab(t, tabKey) { content.innerHTML = `
ℹ️No migration history for this template yet.
`; } else { const rows = [...records].reverse().map(r => { - const hasDetail = r.error || (r.blockers||[]).length || (r.warnings||[]).length; + const fieldIssues = r.field_issues || []; + const hasIssues = fieldIssues.length > 0; + const hasDetail = r.error || (r.blockers||[]).length || (r.warnings||[]).length || hasIssues; const detailHtml = hasDetail ? ` @@ -453,6 +455,7 @@ function _renderDetailTab(t, tabKey) { ${(r.blockers||[]).map(b => `
🚫 ${escHtml(b)}
`).join('')} ${(r.warnings||[]).map(w => `
⚠ ${escHtml(w)}
`).join('')} ${r.error ? `
❌ ${escHtml(r.error)}
` : ''} + ${renderFieldIssues(fieldIssues)} ` : ''; @@ -462,6 +465,7 @@ function _renderDetailTab(t, tabKey) { ${escHtml(r.action||'—')} ${r.status} + ${hasIssues ? 'partial' : ''} ${hasDetail ? '▶ click for details' : ''} ${escHtml(r.docusign_template_id||'—')} @@ -484,10 +488,12 @@ function _renderDetailTab(t, tabKey) { if (next?.classList.contains('row-expanded-content')) { const open = next.style.display !== 'none'; next.style.display = open ? 'none' : 'table-row'; - row.querySelector('span[style*="text-muted"]').textContent = open ? '▶ click for details' : '▼ hide details'; + const hint = row.querySelector('span[style*="text-muted"]'); + if (hint) hint.textContent = open ? '▶ click for details' : '▼ hide details'; } }); }); + bindFieldIssueToggles(content); } }).catch(() => { content.innerHTML = `
Failed to load history.
`; diff --git a/web/static/js/utils.js b/web/static/js/utils.js index 138a846..e4de895 100644 --- a/web/static/js/utils.js +++ b/web/static/js/utils.js @@ -95,3 +95,60 @@ export function downloadCsv(filename, rows) { export function shortHash(hash, len = 8) { return hash ? hash.slice(0, len) : '—'; } + +// Human-readable labels for field issue codes (mirrors src/models/field_issue.py) +export const FIELD_ISSUE_LABELS = { + CROSS_RECIPIENT_CONDITIONAL: 'Cross-recipient conditional dropped', + UNSUPPORTED_OPERATOR: 'Unsupported condition operator dropped', + HIDE_ACTION: 'Hide condition dropped (no DocuSign equivalent)', + MULTI_PREDICATE: 'Multi-condition logic simplified to first match', + INVALID_PARENT_TAB: 'Conditional parent tab invalid or missing', + FIELD_TYPE_SKIPPED: 'Field type skipped (no DocuSign equivalent)', + PARTIAL_FIELD_TYPE: 'Field type approximated', +}; + +/** + * Wire click-to-expand on all .field-issue-group elements within root. + * Call this after injecting renderFieldIssues() HTML into the DOM. + */ +export function bindFieldIssueToggles(root = document) { + root.querySelectorAll('.field-issue-group-header').forEach(hdr => { + hdr.addEventListener('click', () => hdr.parentElement.classList.toggle('open')); + }); +} + +/** + * Render a grouped field-issues section as an HTML string. + * Groups issues by code, shows count + label, expands to field names + messages. + * Returns '' if no issues. + */ +export function renderFieldIssues(issues) { + if (!issues || !issues.length) return ''; + + // Group by code + const groups = {}; + issues.forEach(i => { + if (!groups[i.code]) groups[i.code] = []; + groups[i.code].push(i); + }); + + const groupHtml = Object.entries(groups).map(([code, items]) => { + const label = FIELD_ISSUE_LABELS[code] || code; + const rows = items.map(i => + `
+ ${escHtml(i.field_name)} + ${escHtml(i.message)} +
` + ).join(''); + return ` +
+
+ ${items.length} + ${escHtml(label)} +
+
${rows}
+
`; + }).join(''); + + return `
${groupHtml}
`; +}