feat(issues): structured field-issue reporting throughout migration pipeline

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 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-21 15:25:23 -04:00
parent 53eb206d89
commit b2bbcac842
9 changed files with 294 additions and 67 deletions

View File

@ -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
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:
warnings.append(
f"Conditional '{label}': {len(predicates)} predicates with "
f"anyOrAll={ca.get('anyOrAll')} — only first EQUALS predicate mapped, "
f"remaining conditions ignored"
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}")

39
src/models/field_issue.py Normal file
View File

@ -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)

View File

@ -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:

View File

@ -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,
}

View File

@ -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;

View File

@ -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'
? `<span class="badge badge-green">${escHtml(r.action || 'success')}</span>`
? `<span class="badge badge-green">${escHtml(r.action || 'success')}</span>${hasIssues ? '<span class="badge badge-amber">partial</span>' : ''}`
: `<span class="badge badge-${r.status === 'skipped' ? 'gray' : r.status === 'dry_run' ? 'gray' : 'red'}">${escHtml(r.status || '—')}</span>`;
const checksum = r.checksum_sha256 || r.checksum || '';
@ -130,13 +131,14 @@ function _historyRow(r) {
: '<span style="color:var(--text-muted)">—</span>'}
</td>
</tr>
${(r.blockers || r.warnings || r.error) ? `
${(r.blockers || r.warnings || r.error || (r.field_issues||[]).length) ? `
<tr class="row-expanded-content" style="display:none">
<td colspan="6">
<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(r.field_issues)}
</div>
</td>
</tr>` : ''}
@ -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 || '',

View File

@ -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'
? `<span class="badge badge-green">${r.action || 'success'}</span>`
? `<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>`;
const warnings = r.warnings || [];
return `
<div class="result-row">
<div class="result-header">
@ -374,12 +378,14 @@ function _resultRow(r) {
<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>` : ''}
${warnings.length ? `<span class="result-meta">⚠ ${warnings.length} warning${warnings.length > 1 ? 's' : ''}</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>
${warnings.length || r.error ? `
${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>
`;

View File

@ -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 = `<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 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 ? `
<tr class="row-expanded-content" style="display:none">
<td colspan="4">
@ -453,6 +455,7 @@ function _renderDetailTab(t, tabKey) {
${(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>` : '';
@ -462,6 +465,7 @@ function _renderDetailTab(t, tabKey) {
<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>
@ -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 = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history.</div>`;

View File

@ -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 =>
`<div class="field-issue-row">
<span class="field-issue-field">${escHtml(i.field_name)}</span>
<span class="field-issue-msg">${escHtml(i.message)}</span>
</div>`
).join('');
return `
<div class="field-issue-group">
<div class="field-issue-group-header">
<span class="badge badge-amber" style="font-size:10px">${items.length}</span>
${escHtml(label)}
</div>
<div class="field-issue-group-body">${rows}</div>
</div>`;
}).join('');
return `<div class="field-issues-block">${groupHtml}</div>`;
}