Changeset 0.35.0 (#209)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
@@ -589,6 +589,7 @@ type WorkflowPageData struct {
|
||||
TotalStages int
|
||||
CurrentStage int
|
||||
SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode)
|
||||
BrandingJSON string // v0.35.0: workflow branding JSON (accent_color, logo_url, tagline)
|
||||
}
|
||||
|
||||
// WorkflowLandingPageData is passed to workflow-landing.html.
|
||||
@@ -640,13 +641,21 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// Load workflow stage info for form rendering (v0.29.3)
|
||||
var stageMode, stageName, formTplJSON, surfacePkgID string
|
||||
var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string
|
||||
var totalStages, currentStage int
|
||||
stageMode = "chat_only" // default
|
||||
if e.stores.Channels != nil && channelID != "" {
|
||||
ws, wsErr := e.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID)
|
||||
if wsErr == nil && ws != nil && ws.WorkflowID != nil {
|
||||
currentStage = ws.CurrentStage
|
||||
|
||||
// v0.35.0: Load workflow branding
|
||||
if wf, wfErr := e.stores.Workflows.GetByID(c.Request.Context(), *ws.WorkflowID); wfErr == nil && wf != nil {
|
||||
if len(wf.Branding) > 0 && string(wf.Branding) != "{}" {
|
||||
brandingJSON = string(wf.Branding)
|
||||
}
|
||||
}
|
||||
|
||||
if stages, sErr := e.stores.Workflows.ListStages(c.Request.Context(), *ws.WorkflowID); sErr == nil && len(stages) > 0 {
|
||||
totalStages = len(stages)
|
||||
if currentStage < len(stages) {
|
||||
@@ -684,6 +693,7 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
||||
TotalStages: totalStages,
|
||||
CurrentStage: currentStage,
|
||||
SurfacePkgID: surfacePkgID,
|
||||
BrandingJSON: brandingJSON,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,6 +134,32 @@
|
||||
.wf-body-split .wf-form { flex: 1; border-right: 1px solid var(--border); overflow-y: auto; }
|
||||
.wf-body-split .wf-chat-col { flex: 1; display: flex; flex-direction: column; }
|
||||
.wf-body-split .wf-chat { flex: 1; }
|
||||
|
||||
/* ── Branding (v0.35.0) ────────── */
|
||||
.wf-branding-logo { max-height: 32px; margin-bottom: 4px; }
|
||||
.wf-branding-tagline { font-size: 13px; color: var(--text-2); margin-top: 2px; }
|
||||
|
||||
/* ── Progressive Forms (v0.35.0) ── */
|
||||
.wf-fieldset-nav {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 16px; padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.wf-fieldset-nav .step-label { font-size: 14px; font-weight: 600; }
|
||||
.wf-fieldset-nav .step-counter { font-size: 12px; color: var(--text-3); }
|
||||
.wf-fieldset-buttons { display: flex; gap: 8px; margin-top: 16px; }
|
||||
.wf-fieldset-buttons button {
|
||||
padding: 8px 20px; border-radius: 8px; font-size: 14px; cursor: pointer;
|
||||
font-weight: 600; border: 1px solid var(--border);
|
||||
background: var(--bg-surface); color: var(--text);
|
||||
}
|
||||
.wf-fieldset-buttons button.primary {
|
||||
background: var(--accent); color: #fff; border-color: var(--accent);
|
||||
}
|
||||
.wf-fieldset-buttons button.primary:hover { background: var(--accent-hover); }
|
||||
|
||||
/* ── Conditional field (hidden) ── */
|
||||
.wf-form-field.cond-hidden { display: none; }
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
@@ -149,7 +175,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="wf-shell">
|
||||
<div class="wf-header">
|
||||
<div class="wf-header" id="wfHeader">
|
||||
<h1>{{.Data.ChannelTitle}}</h1>
|
||||
{{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}}
|
||||
</div>
|
||||
@@ -183,7 +209,10 @@
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Data.StageMode "review"}}
|
||||
<div id="reviewArea" style="flex:1;overflow-y:auto"></div>
|
||||
<div id="reviewArea" style="flex:1;overflow-y:auto;display:flex">
|
||||
<div id="reviewDataPanel" style="flex:1;overflow-y:auto;border-right:1px solid var(--border)"></div>
|
||||
<div id="reviewActionPanel" style="flex:1;overflow-y:auto;display:flex;flex-direction:column"></div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="wf-chat" id="chatMessages"></div>
|
||||
<div class="wf-input">
|
||||
@@ -211,6 +240,33 @@
|
||||
var FORM_TPL = null;
|
||||
try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {}
|
||||
|
||||
// v0.35.0: Branding
|
||||
var BRANDING = null;
|
||||
try { BRANDING = JSON.parse('{{.Data.BrandingJSON}}' || 'null'); } catch(e) {}
|
||||
|
||||
// v0.35.0: Apply branding
|
||||
(function() {
|
||||
if (!BRANDING) return;
|
||||
var header = document.getElementById('wfHeader');
|
||||
if (!header) return;
|
||||
if (BRANDING.accent_color) {
|
||||
document.documentElement.style.setProperty('--accent', BRANDING.accent_color);
|
||||
}
|
||||
if (BRANDING.logo_url) {
|
||||
var img = document.createElement('img');
|
||||
img.src = BRANDING.logo_url;
|
||||
img.className = 'wf-branding-logo';
|
||||
img.alt = '';
|
||||
header.insertBefore(img, header.firstChild);
|
||||
}
|
||||
if (BRANDING.tagline) {
|
||||
var tag = document.createElement('div');
|
||||
tag.className = 'wf-branding-tagline';
|
||||
tag.textContent = BRANDING.tagline;
|
||||
header.appendChild(tag);
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Progress dots ──────────────────
|
||||
(function() {
|
||||
var dotsEl = document.getElementById('progressDots');
|
||||
@@ -224,10 +280,17 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Form rendering ─────────────────
|
||||
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL && FORM_TPL.fields) {
|
||||
// ── Form rendering (v0.35.0: progressive forms + conditional fields) ──
|
||||
var _fieldsetIndex = 0;
|
||||
var _fieldsetCount = 0;
|
||||
|
||||
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL) {
|
||||
var formArea = document.getElementById('formArea');
|
||||
renderForm(formArea, FORM_TPL);
|
||||
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
|
||||
renderProgressiveForm(formArea, FORM_TPL);
|
||||
} else if (FORM_TPL.fields && FORM_TPL.fields.length > 0) {
|
||||
renderForm(formArea, FORM_TPL);
|
||||
}
|
||||
}
|
||||
|
||||
function renderForm(container, tpl) {
|
||||
@@ -238,6 +301,84 @@
|
||||
}
|
||||
html += '<div class="wf-form-submit"><button id="formSubmitBtn" onclick="submitForm()">Submit</button></div>';
|
||||
container.innerHTML = html;
|
||||
applyConditionalFields(tpl.fields);
|
||||
}
|
||||
|
||||
function renderProgressiveForm(container, tpl) {
|
||||
_fieldsetCount = tpl.fieldsets.length;
|
||||
_fieldsetIndex = 0;
|
||||
var html = '';
|
||||
for (var s = 0; s < tpl.fieldsets.length; s++) {
|
||||
var fs = tpl.fieldsets[s];
|
||||
var display = s === 0 ? '' : ' style="display:none"';
|
||||
html += '<div class="wf-fieldset" id="fieldset_' + s + '"' + display + '>';
|
||||
html += '<div class="wf-fieldset-nav"><span class="step-label">' + escHtml(fs.label) + '</span>';
|
||||
html += '<span class="step-counter">Step ' + (s + 1) + ' of ' + _fieldsetCount + '</span></div>';
|
||||
for (var i = 0; i < fs.fields.length; i++) {
|
||||
html += renderField(fs.fields[i]);
|
||||
}
|
||||
html += '<div class="wf-fieldset-buttons">';
|
||||
if (s > 0) html += '<button onclick="showFieldset(' + (s - 1) + ')">Back</button>';
|
||||
if (s < tpl.fieldsets.length - 1) {
|
||||
html += '<button class="primary" onclick="showFieldset(' + (s + 1) + ')">Next</button>';
|
||||
} else {
|
||||
html += '<button class="primary" id="formSubmitBtn" onclick="submitForm()">Submit</button>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
// Apply conditional fields for all fieldsets
|
||||
for (var s = 0; s < tpl.fieldsets.length; s++) {
|
||||
applyConditionalFields(tpl.fieldsets[s].fields);
|
||||
}
|
||||
}
|
||||
|
||||
window.showFieldset = function(idx) {
|
||||
if (idx < 0 || idx >= _fieldsetCount) return;
|
||||
document.getElementById('fieldset_' + _fieldsetIndex).style.display = 'none';
|
||||
document.getElementById('fieldset_' + idx).style.display = '';
|
||||
_fieldsetIndex = idx;
|
||||
};
|
||||
|
||||
function applyConditionalFields(fields) {
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
var f = fields[i];
|
||||
if (!f.condition) continue;
|
||||
var when = f.condition.when;
|
||||
var srcEl = document.getElementById('ff_' + when);
|
||||
if (!srcEl) continue;
|
||||
(function(field) {
|
||||
var handler = function() { evaluateCondition(field); };
|
||||
srcEl.addEventListener('change', handler);
|
||||
srcEl.addEventListener('input', handler);
|
||||
handler(); // initial evaluation
|
||||
})(f);
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateCondition(f) {
|
||||
var cond = f.condition;
|
||||
var srcEl = document.getElementById('ff_' + cond.when);
|
||||
var tgtEl = document.querySelector('.wf-form-field[data-key="' + f.key + '"]');
|
||||
if (!srcEl || !tgtEl) return;
|
||||
|
||||
var val = srcEl.type === 'checkbox' ? srcEl.checked : srcEl.value;
|
||||
var op = cond.op || 'eq';
|
||||
var visible = false;
|
||||
|
||||
switch (op) {
|
||||
case 'exists': visible = val !== '' && val !== false; break;
|
||||
case 'eq': visible = String(val) === String(cond.value); break;
|
||||
case 'neq': visible = String(val) !== String(cond.value); break;
|
||||
case 'in': visible = Array.isArray(cond.value) && cond.value.map(String).indexOf(String(val)) >= 0; break;
|
||||
default: visible = true;
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
tgtEl.classList.remove('cond-hidden');
|
||||
} else {
|
||||
tgtEl.classList.add('cond-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function renderField(f) {
|
||||
@@ -308,12 +449,22 @@
|
||||
el.textContent = '';
|
||||
});
|
||||
|
||||
// Collect form data
|
||||
// Collect form data (v0.35.0: get all fields from fieldsets or top-level)
|
||||
var allFields = FORM_TPL.fields || [];
|
||||
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
|
||||
allFields = [];
|
||||
for (var s = 0; s < FORM_TPL.fieldsets.length; s++) {
|
||||
allFields = allFields.concat(FORM_TPL.fieldsets[s].fields);
|
||||
}
|
||||
}
|
||||
var data = {};
|
||||
for (var i = 0; i < FORM_TPL.fields.length; i++) {
|
||||
var f = FORM_TPL.fields[i];
|
||||
for (var i = 0; i < allFields.length; i++) {
|
||||
var f = allFields[i];
|
||||
var el = document.getElementById('ff_' + f.key);
|
||||
if (!el) continue;
|
||||
// v0.35.0: Skip hidden conditional fields
|
||||
var fieldWrap = document.querySelector('.wf-form-field[data-key="' + f.key + '"]');
|
||||
if (fieldWrap && fieldWrap.classList.contains('cond-hidden')) continue;
|
||||
if (f.type === 'checkbox') {
|
||||
data[f.key] = el.checked;
|
||||
} else if (f.type === 'number') {
|
||||
@@ -480,19 +631,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Review surface (v0.30.2) ──────────
|
||||
// ── Review surface (v0.35.0: structured review with side-by-side + comments) ──
|
||||
if (STAGE_MODE === 'review') {
|
||||
var reviewArea = document.getElementById('reviewArea');
|
||||
if (reviewArea) {
|
||||
loadReviewSurface(reviewArea);
|
||||
var dataPanel = document.getElementById('reviewDataPanel');
|
||||
var actionPanel = document.getElementById('reviewActionPanel');
|
||||
if (dataPanel && actionPanel) {
|
||||
loadReviewSurface(dataPanel, actionPanel);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReviewSurface(container) {
|
||||
container.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading review data\u2026</div>';
|
||||
async function loadReviewSurface(dataPanel, actionPanel) {
|
||||
dataPanel.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading\u2026</div>';
|
||||
|
||||
var html = '<div style="padding:24px;max-width:640px;margin:0 auto">';
|
||||
html += '<h3 style="margin-bottom:16px">Review</h3>';
|
||||
// Left panel: structured data card
|
||||
var html = '<div style="padding:24px">';
|
||||
html += '<h3 style="margin-bottom:16px">Collected Data</h3>';
|
||||
|
||||
try {
|
||||
var resp = await fetch(BASE + '/api/v1/channels/' + CHAN_ID + '/workflow/status', {
|
||||
@@ -500,33 +653,54 @@
|
||||
});
|
||||
if (resp.ok) {
|
||||
var status = await resp.json();
|
||||
html += '<div style="margin-bottom:16px">';
|
||||
html += '<h4 style="margin-bottom:8px;font-size:14px;color:var(--text-2)">Collected Data</h4>';
|
||||
|
||||
if (status.stage_data && typeof status.stage_data === 'object' && Object.keys(status.stage_data).length > 0) {
|
||||
if (status.stage_data && typeof status.stage_data === 'object') {
|
||||
html += '<table style="width:100%;border-collapse:collapse">';
|
||||
for (var key in status.stage_data) {
|
||||
if (!status.stage_data.hasOwnProperty(key)) continue;
|
||||
if (!status.stage_data.hasOwnProperty(key) || key.startsWith('_')) continue;
|
||||
html += '<tr style="border-bottom:1px solid var(--border)">';
|
||||
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%">' + escHtml(key) + '</td>';
|
||||
html += '<td style="padding:8px;font-size:13px">' + escHtml(String(status.stage_data[key])) + '</td>';
|
||||
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%;vertical-align:top">' + escHtml(key) + '</td>';
|
||||
var val = status.stage_data[key];
|
||||
var display = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
|
||||
html += '<td style="padding:8px;font-size:13px;white-space:pre-wrap">' + escHtml(display) + '</td>';
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
} else {
|
||||
html += '<p style="color:var(--text-3)">No data collected yet.</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
html += '<p style="color:var(--danger)">Failed to load review data.</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
dataPanel.innerHTML = html;
|
||||
|
||||
html += '<div style="display:flex;gap:8px;margin-top:24px">';
|
||||
html += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Approve & Advance</button>';
|
||||
html += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Reject</button>';
|
||||
html += '</div></div>';
|
||||
container.innerHTML = html;
|
||||
// Right panel: comment input + approve/reject buttons
|
||||
var actHtml = '<div style="padding:24px;flex:1;display:flex;flex-direction:column">';
|
||||
actHtml += '<h3 style="margin-bottom:16px">Review Actions</h3>';
|
||||
actHtml += '<div style="margin-bottom:16px">';
|
||||
actHtml += '<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px">Add Comment</label>';
|
||||
actHtml += '<textarea id="reviewComment" rows="3" style="width:100%;padding:8px;font-size:14px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text);font-family:var(--font);resize:vertical" placeholder="Optional comment\u2026"></textarea>';
|
||||
actHtml += '</div>';
|
||||
actHtml += '<div style="display:flex;gap:8px">';
|
||||
actHtml += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Approve & Advance</button>';
|
||||
actHtml += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Reject</button>';
|
||||
actHtml += '</div>';
|
||||
actHtml += '<div style="font-size:12px;color:var(--text-3);margin-top:8px">Ctrl+Enter: Approve · Ctrl+Shift+Enter: Reject</div>';
|
||||
actHtml += '</div>';
|
||||
actionPanel.innerHTML = actHtml;
|
||||
|
||||
// Keyboard shortcuts (v0.35.0)
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
document.getElementById('reviewRejectBtn').click();
|
||||
} else {
|
||||
document.getElementById('reviewAdvanceBtn').click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() {
|
||||
this.disabled = true;
|
||||
@@ -536,7 +710,7 @@
|
||||
body: JSON.stringify({ data: {} }),
|
||||
});
|
||||
if (r.ok) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced.</p></div>';
|
||||
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced.</p></div>';
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
} else {
|
||||
var err = await r.json().catch(function() { return {}; });
|
||||
@@ -547,7 +721,8 @@
|
||||
});
|
||||
|
||||
document.getElementById('reviewRejectBtn').addEventListener('click', async function() {
|
||||
var reason = prompt('Rejection reason:');
|
||||
var comment = document.getElementById('reviewComment').value;
|
||||
var reason = comment || prompt('Rejection reason:');
|
||||
if (!reason) return;
|
||||
this.disabled = true;
|
||||
try {
|
||||
@@ -556,7 +731,7 @@
|
||||
body: JSON.stringify({ reason: reason }),
|
||||
});
|
||||
if (r.ok) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';
|
||||
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
} else {
|
||||
var err = await r.json().catch(function() { return {}; });
|
||||
|
||||
Reference in New Issue
Block a user