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 base64
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
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"
|
DOCUMENT_ID = "1"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -154,7 +164,14 @@ def _sized_tabs(locations: list, label: str, extra: dict | None = None) -> list:
|
||||||
# Conditional logic
|
# 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
|
Apply DocuSign conditionalParentLabel / conditionalParentValue to tabs based
|
||||||
on an Adobe Sign conditionalAction.
|
on an Adobe Sign conditionalAction.
|
||||||
|
|
@ -169,6 +186,8 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict:
|
||||||
Mapping limitations:
|
Mapping limitations:
|
||||||
- Only SHOW action is supported. DocuSign has no native HIDE — condition skipped.
|
- Only SHOW action is supported. DocuSign has no native HIDE — condition skipped.
|
||||||
- Only EQUALS operator is supported. Others are 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;
|
- Only one predicate is mapped. Multi-predicate ANY/ALL logic is not supported;
|
||||||
the first EQUALS predicate is used and a warning is logged.
|
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")
|
action = ca.get("action", "SHOW")
|
||||||
|
|
||||||
if action != "SHOW":
|
if action != "SHOW":
|
||||||
warnings.append(
|
msg = (
|
||||||
f"Conditional '{label}': action={action} is not supported in DocuSign "
|
f"Field '{label}' has a HIDE condition which DocuSign does not support — "
|
||||||
f"(only SHOW is supported) — condition skipped"
|
f"condition dropped. The field will always be visible."
|
||||||
)
|
)
|
||||||
|
warnings.append(msg)
|
||||||
|
issues.append(FieldIssue(HIDE_ACTION, label, msg).to_dict())
|
||||||
return tabs
|
return tabs
|
||||||
|
|
||||||
predicate = next((p for p in predicates if p.get("operator") == "EQUALS"), None)
|
predicate = next((p for p in predicates if p.get("operator") == "EQUALS"), None)
|
||||||
if not predicate:
|
if not predicate:
|
||||||
warnings.append(
|
ops = [p.get("operator") for p in predicates]
|
||||||
f"Conditional '{label}': no EQUALS predicate found "
|
msg = (
|
||||||
f"(operators: {[p.get('operator') for p in predicates]}) — condition skipped"
|
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
|
return tabs
|
||||||
|
|
||||||
if len(predicates) > 1:
|
parent_field_name = predicate["fieldName"]
|
||||||
warnings.append(
|
|
||||||
f"Conditional '{label}': {len(predicates)} predicates with "
|
# Cross-recipient check: DocuSign does not support conditionals across recipients
|
||||||
f"anyOrAll={ca.get('anyOrAll')} — only first EQUALS predicate mapped, "
|
if field_assignee is not None and current_assignee:
|
||||||
f"remaining conditions ignored"
|
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"]
|
parent_value = predicate["value"]
|
||||||
|
|
||||||
for tab_list in tabs.values():
|
for tab_list in tabs.values():
|
||||||
for tab in tab_list:
|
for tab in tab_list:
|
||||||
tab["conditionalParentLabel"] = parent_label
|
tab["conditionalParentLabel"] = parent_field_name
|
||||||
tab["conditionalParentValue"] = parent_value
|
tab["conditionalParentValue"] = parent_value
|
||||||
|
|
||||||
return tabs
|
return tabs
|
||||||
|
|
@ -220,11 +260,12 @@ def _apply_conditional_to_tabs(tabs: dict, field: dict, warnings: list) -> dict:
|
||||||
# Tab builder
|
# 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.
|
Convert one Adobe Sign field into the correct DocuSign tabs structure.
|
||||||
Returns a dict of tab-group keys, e.g. {"textTabs": [...]}.
|
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", "")
|
input_type = field.get("inputType", "")
|
||||||
label = field.get("name", "unnamed")
|
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 input_type == "TEXT_FIELD":
|
||||||
if content_type == "SIGNATURE_DATE":
|
if content_type == "SIGNATURE_DATE":
|
||||||
# Auto-populated with the signing date
|
|
||||||
return {"dateSignedTabs": _sized_tabs(locations, label)}
|
return {"dateSignedTabs": _sized_tabs(locations, label)}
|
||||||
elif content_type == "SIGNER_NAME":
|
elif content_type == "SIGNER_NAME":
|
||||||
# Auto-populated with the signer's full name
|
|
||||||
return {"fullNameTabs": _sized_tabs(locations, label)}
|
return {"fullNameTabs": _sized_tabs(locations, label)}
|
||||||
elif content_type == "SIGNER_EMAIL":
|
elif content_type == "SIGNER_EMAIL":
|
||||||
# Auto-populated with the signer's email address
|
|
||||||
return {"emailAddressTabs": _sized_tabs(locations, label)}
|
return {"emailAddressTabs": _sized_tabs(locations, label)}
|
||||||
elif content_type in ("COMPANY", "SIGNER_COMPANY"):
|
elif content_type in ("COMPANY", "SIGNER_COMPANY"):
|
||||||
# Auto-populated with the signer's company
|
|
||||||
return {"companyTabs": _sized_tabs(locations, label)}
|
return {"companyTabs": _sized_tabs(locations, label)}
|
||||||
elif content_type in ("TITLE", "SIGNER_TITLE"):
|
elif content_type in ("TITLE", "SIGNER_TITLE"):
|
||||||
# Auto-populated with the signer's title
|
|
||||||
return {"titleTabs": _sized_tabs(locations, label)}
|
return {"titleTabs": _sized_tabs(locations, label)}
|
||||||
elif content_type == "DATA" and validation == "DATE":
|
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})}
|
return {"dateTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
|
||||||
elif content_type == "DATA" and validation == "NUMBER":
|
elif content_type == "DATA" and validation == "NUMBER":
|
||||||
return {"numberTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
|
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})}
|
return {"textTabs": _sized_tabs(locations, label, {"required": required_str, "locked": locked_str})}
|
||||||
|
|
||||||
elif input_type == "SIGNATURE":
|
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":
|
if content_type == "SIGNER_INITIALS":
|
||||||
return {"initialHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
return {"initialHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
||||||
else:
|
else:
|
||||||
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
||||||
|
|
||||||
elif input_type == "BLOCK" and content_type == "SIGNATURE_BLOCK":
|
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]}
|
return {"signHereTabs": [_make_base_tab(loc, label) for loc in locations]}
|
||||||
|
|
||||||
elif input_type == "DATE":
|
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})}
|
return {"listTabs": _sized_tabs(locations, label, {"required": required_str, "listItems": list_items})}
|
||||||
|
|
||||||
elif input_type == "RADIO":
|
elif input_type == "RADIO":
|
||||||
# Each location is one radio button within the group — not tab merging
|
|
||||||
options = field.get("hiddenOptions") or []
|
options = field.get("hiddenOptions") or []
|
||||||
radios = []
|
radios = []
|
||||||
for i, loc in enumerate(locations):
|
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}]}
|
return {"radioGroupTabs": [{"groupName": label, "documentId": DOCUMENT_ID, "radios": radios}]}
|
||||||
|
|
||||||
elif input_type == "FILE_CHOOSER":
|
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"})
|
tab = _make_base_tab(locations[0], label, {"optional": "true" if not field.get("required") else "false"})
|
||||||
return {"signerAttachmentTabs": [tab]}
|
return {"signerAttachmentTabs": [tab]}
|
||||||
|
|
||||||
elif input_type == "INLINE_IMAGE":
|
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 {}
|
return {}
|
||||||
|
|
||||||
elif input_type == "STAMP":
|
elif input_type == "STAMP":
|
||||||
# DocuSign stampTabs — signer uploads or selects a hanko/seal stamp image.
|
msg = (
|
||||||
# Requires the stamp feature to be enabled on the DocuSign account.
|
f"Field '{label}' is a STAMP — mapped to stampTabs. "
|
||||||
warnings.append(f"STAMP '{label}' → stampTabs (verify stamp feature is enabled on your DocuSign account)")
|
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]}
|
return {"stampTabs": [_make_base_tab(loc, label) for loc in locations]}
|
||||||
|
|
||||||
elif input_type == "PARTICIPATION_STAMP":
|
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 {}
|
return {}
|
||||||
|
|
||||||
else:
|
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 {}
|
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
|
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
|
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:
|
if tab_type in _INVALID_PARENT_TAB_TYPES:
|
||||||
continue
|
continue
|
||||||
for tab in tab_list:
|
for tab in tab_list:
|
||||||
label = tab.get("tabLabel") or tab.get("groupName")
|
lbl = tab.get("tabLabel") or tab.get("groupName")
|
||||||
if label:
|
if lbl:
|
||||||
valid_labels.add(label)
|
valid_labels.add(lbl)
|
||||||
|
|
||||||
# Strip references to invalid/missing parents
|
# 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:
|
for tab in tab_list:
|
||||||
parent = tab.get("conditionalParentLabel")
|
parent = tab.get("conditionalParentLabel")
|
||||||
if parent and parent not in valid_labels:
|
if parent and parent not in valid_labels:
|
||||||
warnings.append(
|
field_name = tab.get("tabLabel") or tab.get("groupName") or "?"
|
||||||
f"Conditional parent '{parent}' not found or not a valid "
|
msg = (
|
||||||
f"parent tab type — conditional stripped from tab "
|
f"Field '{field_name}' has a conditional that references parent "
|
||||||
f"'{tab.get('tabLabel', '?')}'"
|
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("conditionalParentLabel", None)
|
||||||
tab.pop("conditionalParentValue", None)
|
tab.pop("conditionalParentValue", None)
|
||||||
|
|
||||||
|
|
@ -369,7 +418,7 @@ def _strip_invalid_conditionals(signers: list, warnings: list) -> None:
|
||||||
# Main compose function
|
# 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.
|
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
|
output_path: where to write the resulting DocuSign template JSON
|
||||||
|
|
||||||
Returns:
|
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)
|
template_dir = Path(template_dir)
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
issues: list[dict] = []
|
||||||
|
|
||||||
# Load source files
|
# Load source files
|
||||||
metadata = json.loads((template_dir / "metadata.json").read_text())
|
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": {},
|
"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
|
# Assign tabs to the correct signer
|
||||||
for field in fields:
|
for field in fields:
|
||||||
assignee = field.get("assignee") or f"recipient{max(field.get('signerIndex', 0), 0)}"
|
assignee = field.get("assignee") or f"recipient{max(field.get('signerIndex', 0), 0)}"
|
||||||
idx = assignee_to_index(assignee, recipients)
|
idx = assignee_to_index(assignee, recipients)
|
||||||
if idx >= len(signers):
|
if idx >= len(signers):
|
||||||
idx = 0
|
idx = 0
|
||||||
tabs = build_tabs_for_field(field, warnings)
|
tabs = build_tabs_for_field(field, warnings, issues)
|
||||||
tabs = _apply_conditional_to_tabs(tabs, field, warnings)
|
tabs = _apply_conditional_to_tabs(tabs, field, warnings, issues, assignee, field_assignee)
|
||||||
signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs)
|
signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs)
|
||||||
|
|
||||||
# Post-process: strip conditionalParentLabel references that point to
|
# Post-process: strip conditionalParentLabel references that point to
|
||||||
# non-existent or invalid parents (signature/initial tabs can't be parents).
|
# non-existent or invalid parents (signature/initial tabs can't be parents).
|
||||||
_strip_invalid_conditionals(signers, warnings)
|
_strip_invalid_conditionals(signers, warnings, issues)
|
||||||
|
|
||||||
template = {
|
template = {
|
||||||
"name": metadata.get("name", template_dir.name),
|
"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:
|
with open(output_path, "w") as f:
|
||||||
json.dump(template, f, indent=2)
|
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"
|
output_path = Path(__file__).parent.parent / "migration-output" / template_dir.name / "docusign-template.json"
|
||||||
print(f"\n--- {template_dir.name} ---")
|
print(f"\n--- {template_dir.name} ---")
|
||||||
try:
|
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}")
|
print(f" Written: {output_path}")
|
||||||
for w in warnings:
|
for w in warnings:
|
||||||
print(f" WARNING: {w}")
|
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
|
output_path = tf.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result, warnings = compose_template(template_dir, output_path)
|
result, warnings, _ = compose_template(template_dir, output_path)
|
||||||
|
|
||||||
if update_snapshots:
|
if update_snapshots:
|
||||||
os.makedirs(FIXTURES_DIR, exist_ok=True)
|
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:
|
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf:
|
||||||
output_path = tf.name
|
output_path = tf.name
|
||||||
try:
|
try:
|
||||||
result, _ = compose_template(template_dir, output_path)
|
result, _, _issues = compose_template(template_dir, output_path)
|
||||||
total_tabs = sum(_count_tabs(result).values())
|
total_tabs = sum(_count_tabs(result).values())
|
||||||
assert total_tabs > 0, f"No tabs produced for {template_name}"
|
assert total_tabs > 0, f"No tabs produced for {template_name}"
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ async def _migrate_one(
|
||||||
"error": "Adobe Sign download failed",
|
"error": "Adobe Sign download failed",
|
||||||
"warnings": [],
|
"warnings": [],
|
||||||
"blockers": [],
|
"blockers": [],
|
||||||
|
"field_issues": [],
|
||||||
"dry_run": options.dry_run,
|
"dry_run": options.dry_run,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,14 +186,19 @@ async def _migrate_one(
|
||||||
"error": f"Validation blockers: {'; '.join(validation['blockers'])}",
|
"error": f"Validation blockers: {'; '.join(validation['blockers'])}",
|
||||||
"warnings": validation["warnings"],
|
"warnings": validation["warnings"],
|
||||||
"blockers": validation["blockers"],
|
"blockers": validation["blockers"],
|
||||||
|
"field_issues": [],
|
||||||
"dry_run": options.dry_run,
|
"dry_run": options.dry_run,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 3. Compose
|
# 3. Compose
|
||||||
composed_file = os.path.join(tmpdir, "docusign-template.json")
|
composed_file = os.path.join(tmpdir, "docusign-template.json")
|
||||||
|
compose_issues: list = []
|
||||||
try:
|
try:
|
||||||
compose_fn = _load_compose()
|
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:
|
except Exception as exc:
|
||||||
return {
|
return {
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
|
|
@ -204,6 +210,7 @@ async def _migrate_one(
|
||||||
"error": f"Compose failed: {exc}",
|
"error": f"Compose failed: {exc}",
|
||||||
"warnings": validation["warnings"],
|
"warnings": validation["warnings"],
|
||||||
"blockers": [],
|
"blockers": [],
|
||||||
|
"field_issues": [],
|
||||||
"dry_run": options.dry_run,
|
"dry_run": options.dry_run,
|
||||||
}
|
}
|
||||||
if not os.path.exists(composed_file):
|
if not os.path.exists(composed_file):
|
||||||
|
|
@ -217,6 +224,7 @@ async def _migrate_one(
|
||||||
"error": "Compose produced no output file",
|
"error": "Compose produced no output file",
|
||||||
"warnings": validation["warnings"],
|
"warnings": validation["warnings"],
|
||||||
"blockers": [],
|
"blockers": [],
|
||||||
|
"field_issues": [],
|
||||||
"dry_run": options.dry_run,
|
"dry_run": options.dry_run,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,6 +240,7 @@ async def _migrate_one(
|
||||||
"error": None,
|
"error": None,
|
||||||
"warnings": validation["warnings"],
|
"warnings": validation["warnings"],
|
||||||
"blockers": [],
|
"blockers": [],
|
||||||
|
"field_issues": compose_issues,
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,6 +284,7 @@ async def _migrate_one(
|
||||||
"error": None,
|
"error": None,
|
||||||
"warnings": validation["warnings"] + ["Skipped: template already exists (overwrite_if_exists=false)"],
|
"warnings": validation["warnings"] + ["Skipped: template already exists (overwrite_if_exists=false)"],
|
||||||
"blockers": [],
|
"blockers": [],
|
||||||
|
"field_issues": compose_issues,
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,6 +310,7 @@ async def _migrate_one(
|
||||||
"error": f"DocuSign upload failed ({up_resp.status_code}): {up_resp.text[:200]}",
|
"error": f"DocuSign upload failed ({up_resp.status_code}): {up_resp.text[:200]}",
|
||||||
"warnings": validation["warnings"],
|
"warnings": validation["warnings"],
|
||||||
"blockers": [],
|
"blockers": [],
|
||||||
|
"field_issues": compose_issues,
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,6 +324,7 @@ async def _migrate_one(
|
||||||
"error": None,
|
"error": None,
|
||||||
"warnings": validation["warnings"],
|
"warnings": validation["warnings"],
|
||||||
"blockers": [],
|
"blockers": [],
|
||||||
|
"field_issues": compose_issues,
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,49 @@
|
||||||
.result-body { padding: 12px 16px; border-top: 1px solid var(--border); background: #FAFBFC; display: none; }
|
.result-body { padding: 12px 16px; border-top: 1px solid var(--border); background: #FAFBFC; display: none; }
|
||||||
.result-row.open .result-body { display: block; }
|
.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 template link pill ── */
|
||||||
.ds-pill {
|
.ds-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// History & Audit view — filterable, exportable migration history
|
// History & Audit view — filterable, exportable migration history
|
||||||
|
|
||||||
import { api } from './api.js';
|
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 _allRecords = [];
|
||||||
let _filter = { search: '', status: 'all', from: '', to: '' };
|
let _filter = { search: '', status: 'all', from: '', to: '' };
|
||||||
|
|
@ -108,8 +108,9 @@ function _th(col, label) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _historyRow(r) {
|
function _historyRow(r) {
|
||||||
|
const hasIssues = (r.field_issues || []).length > 0;
|
||||||
const statusBadge = r.status === 'success'
|
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>`;
|
: `<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 || '';
|
const checksum = r.checksum_sha256 || r.checksum || '';
|
||||||
|
|
@ -130,13 +131,14 @@ function _historyRow(r) {
|
||||||
: '<span style="color:var(--text-muted)">—</span>'}
|
: '<span style="color:var(--text-muted)">—</span>'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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">
|
<tr class="row-expanded-content" style="display:none">
|
||||||
<td colspan="6">
|
<td colspan="6">
|
||||||
<div class="row-expand-body">
|
<div class="row-expand-body">
|
||||||
${(r.blockers||[]).map(b => `<div style="color:var(--error);font-size:12px">🚫 ${escHtml(b)}</div>`).join('')}
|
${(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.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>` : ''}
|
${r.error ? `<div style="color:var(--error);font-size:12px">❌ ${escHtml(r.error)}</div>` : ''}
|
||||||
|
${renderFieldIssues(r.field_issues)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>` : ''}
|
</tr>` : ''}
|
||||||
|
|
@ -206,6 +208,8 @@ function _bindEvents(filtered) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bindFieldIssueToggles();
|
||||||
|
|
||||||
document.getElementById('btn-export-history')?.addEventListener('click', () => {
|
document.getElementById('btn-export-history')?.addEventListener('click', () => {
|
||||||
downloadCsv('migration-history.csv', filtered.map(r => ({
|
downloadCsv('migration-history.csv', filtered.map(r => ({
|
||||||
timestamp: r.timestamp || '',
|
timestamp: r.timestamp || '',
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { state, setState } from './state.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 { navigate } from './router.js';
|
||||||
import { refreshTemplates } from './templates.js';
|
import { refreshTemplates } from './templates.js';
|
||||||
|
|
||||||
|
|
@ -335,6 +335,8 @@ export function renderResults() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bindFieldIssueToggles();
|
||||||
|
|
||||||
// Export CSV
|
// Export CSV
|
||||||
document.getElementById('btn-export-results')?.addEventListener('click', () => {
|
document.getElementById('btn-export-results')?.addEventListener('click', () => {
|
||||||
downloadCsv('migration-results.csv', templateResults.map(r => ({
|
downloadCsv('migration-results.csv', templateResults.map(r => ({
|
||||||
|
|
@ -357,16 +359,18 @@ export function renderResults() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _resultRow(r) {
|
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'
|
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' ? '🚫' : '❌');
|
: (r.status === 'skipped' ? '⏭' : r.status === 'blocked' ? '🚫' : '❌');
|
||||||
|
|
||||||
const statusBadge = r.status === 'success'
|
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>`;
|
: `<span class="badge badge-${r.status === 'skipped' ? 'gray' : 'red'}">${r.status}</span>`;
|
||||||
|
|
||||||
const warnings = r.warnings || [];
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="result-row">
|
<div class="result-row">
|
||||||
<div class="result-header">
|
<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>
|
<span class="result-name">${escHtml(r.adobe_template_name || r.adobe_template_id)}</span>
|
||||||
${statusBadge}
|
${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>` : ''}
|
${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>
|
</div>
|
||||||
${warnings.length || r.error ? `
|
${hasDetail ? `
|
||||||
<div class="result-body">
|
<div class="result-body">
|
||||||
${warnings.map(w => `<div class="result-warn-item"><span class="ri">⚠</span>${escHtml(w)}</div>`).join('')}
|
${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>` : ''}
|
${r.error ? `<div class="result-warn-item" style="color:var(--error)"><span class="ri">❌</span>${escHtml(r.error)}</div>` : ''}
|
||||||
|
${renderFieldIssues(issues)}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { state, setState, updateDerivedState } from './state.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';
|
import { navigate } from './router.js';
|
||||||
|
|
||||||
// ── Readiness badge ────────────────────────────────────────────────────────
|
// ── 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>`;
|
content.innerHTML = `<div class="callout info"><span class="callout-icon">ℹ️</span>No migration history for this template yet.</div>`;
|
||||||
} else {
|
} else {
|
||||||
const rows = [...records].reverse().map(r => {
|
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 ? `
|
const detailHtml = hasDetail ? `
|
||||||
<tr class="row-expanded-content" style="display:none">
|
<tr class="row-expanded-content" style="display:none">
|
||||||
<td colspan="4">
|
<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.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.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>` : ''}
|
${r.error ? `<div style="color:var(--error);font-size:12px">❌ ${escHtml(r.error)}</div>` : ''}
|
||||||
|
${renderFieldIssues(fieldIssues)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>` : '';
|
</tr>` : '';
|
||||||
|
|
@ -462,6 +465,7 @@ function _renderDetailTab(t, tabKey) {
|
||||||
<td>${escHtml(r.action||'—')}</td>
|
<td>${escHtml(r.action||'—')}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge ${r.status==='success'?'badge-green':'badge-red'}">${r.status}</span>
|
<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>' : ''}
|
${hasDetail ? '<span style="font-size:10px;color:var(--text-muted);margin-left:4px">▶ click for details</span>' : ''}
|
||||||
</td>
|
</td>
|
||||||
<td class="mono">${escHtml(r.docusign_template_id||'—')}</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')) {
|
if (next?.classList.contains('row-expanded-content')) {
|
||||||
const open = next.style.display !== 'none';
|
const open = next.style.display !== 'none';
|
||||||
next.style.display = open ? 'none' : 'table-row';
|
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(() => {
|
}).catch(() => {
|
||||||
content.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history.</div>`;
|
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) {
|
export function shortHash(hash, len = 8) {
|
||||||
return hash ? hash.slice(0, len) : '—';
|
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