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:
parent
53eb206d89
commit
b2bbcac842
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue