155 lines
4.9 KiB
JavaScript
155 lines
4.9 KiB
JavaScript
// Shared utility functions
|
|
|
|
export function escHtml(str) {
|
|
return String(str ?? '').replace(/[&<>"']/g, c => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
})[c]);
|
|
}
|
|
|
|
export function formatDate(iso) {
|
|
if (!iso) return '—';
|
|
try {
|
|
return new Date(iso).toLocaleDateString('en-US', {
|
|
year: 'numeric', month: 'short', day: 'numeric'
|
|
});
|
|
} catch { return iso.slice(0, 10); }
|
|
}
|
|
|
|
export function formatDateTime(iso) {
|
|
if (!iso) return '—';
|
|
try {
|
|
return new Date(iso).toLocaleString('en-US', {
|
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit'
|
|
});
|
|
} catch { return iso.slice(0, 19).replace('T', ' '); }
|
|
}
|
|
|
|
export function formatRelative(iso) {
|
|
if (!iso) return '—';
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
const m = Math.floor(diff / 60000);
|
|
if (m < 1) return 'just now';
|
|
if (m < 60) return `${m}m ago`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}h ago`;
|
|
const d = Math.floor(h / 24);
|
|
if (d < 7) return `${d}d ago`;
|
|
return formatDate(iso);
|
|
}
|
|
|
|
export function debounce(fn, ms = 300) {
|
|
let timer;
|
|
return (...args) => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => fn(...args), ms);
|
|
};
|
|
}
|
|
|
|
export function uuid() {
|
|
return crypto.randomUUID
|
|
? crypto.randomUUID()
|
|
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
const r = Math.random() * 16 | 0;
|
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
});
|
|
}
|
|
|
|
// Truncate a string to maxLen chars, appending ellipsis if needed
|
|
export function truncate(str, maxLen = 40) {
|
|
if (!str) return '';
|
|
return str.length > maxLen ? str.slice(0, maxLen - 1) + '…' : str;
|
|
}
|
|
|
|
// First letter of a name for avatar initials
|
|
export function initials(name) {
|
|
if (!name) return '?';
|
|
const parts = name.trim().split(/\s+/);
|
|
return parts.length >= 2
|
|
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
: name.slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
// Download a string as a file
|
|
export function downloadText(filename, content, type = 'text/plain') {
|
|
const blob = new Blob([content], { type });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = filename;
|
|
document.body.appendChild(a); a.click();
|
|
document.body.removeChild(a); URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// Convert array of objects to CSV and download
|
|
export function downloadCsv(filename, rows) {
|
|
if (!rows.length) return;
|
|
const headers = Object.keys(rows[0]);
|
|
const csv = [
|
|
headers.join(','),
|
|
...rows.map(r => headers.map(h => JSON.stringify(r[h] ?? '')).join(','))
|
|
].join('\n');
|
|
downloadText(filename, csv, 'text/csv');
|
|
}
|
|
|
|
// Shorten a SHA-256 hash for display
|
|
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>`;
|
|
}
|