Changeset 0.31.2 (#205)

This commit is contained in:
2026-03-19 13:29:27 +00:00
parent 8364440081
commit 6668e546fe
25 changed files with 1357 additions and 50 deletions

View File

@@ -173,6 +173,7 @@ Object.assign(UI, {
if (tab === 'members') UI.loadTeamManageMembers(teamId);
if (tab === 'providers') UI.loadTeamManageProviders(teamId);
if (tab === 'personas') UI.loadTeamManagePersonas(teamId);
if (tab === 'workflows') UI.loadTeamWorkflows(teamId);
if (tab === 'usage') UI.loadTeamUsage();
if (tab === 'activity') UI.loadTeamAuditLog(1);
if (tab === 'settings') UI.loadTeamManageSettings(teamId);

View File

@@ -117,3 +117,53 @@
API.completeAssignment = function(id) {
return this._post(`/api/v1/workflow-assignments/${id}/complete`, {});
};
// ── Team-Scoped Workflow CRUD (v0.31.2) ──
API.listTeamWorkflows = function(teamId) {
return this._get(`/api/v1/teams/${teamId}/workflows`);
};
API.getTeamWorkflow = function(teamId, id) {
return this._get(`/api/v1/teams/${teamId}/workflows/${id}`);
};
API.createTeamWorkflow = function(teamId, data) {
return this._post(`/api/v1/teams/${teamId}/workflows`, data);
};
API.updateTeamWorkflow = function(teamId, id, patch) {
return this._patch(`/api/v1/teams/${teamId}/workflows/${id}`, patch);
};
API.deleteTeamWorkflow = function(teamId, id) {
return this._del(`/api/v1/teams/${teamId}/workflows/${id}`);
};
API.listTeamWorkflowStages = function(teamId, workflowId) {
return this._get(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages`);
};
API.createTeamWorkflowStage = function(teamId, workflowId, data) {
return this._post(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages`, data);
};
API.updateTeamWorkflowStage = function(teamId, workflowId, stageId, data) {
return this._put(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/${stageId}`, data);
};
API.deleteTeamWorkflowStage = function(teamId, workflowId, stageId) {
return this._del(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/${stageId}`);
};
API.reorderTeamWorkflowStages = function(teamId, workflowId, orderedIds) {
return this._patch(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/reorder`, { ordered_ids: orderedIds });
};
API.publishTeamWorkflow = function(teamId, workflowId) {
return this._post(`/api/v1/teams/${teamId}/workflows/${workflowId}/publish`, {});
};
API.getTeamWorkflowVersion = function(teamId, workflowId, version) {
return this._get(`/api/v1/teams/${teamId}/workflows/${workflowId}/versions/${version}`);
};

View File

@@ -0,0 +1,707 @@
// ==========================================
// Chat Switchboard — Team Workflow Builder (v0.31.2)
// ==========================================
// Team admin panel for managing team-scoped workflow definitions,
// stages, and publishing. Adapted from workflow-admin.js using
// team-scoped API endpoints. Loaded as a team admin tab.
(function () {
'use strict';
var esc = typeof window.esc === 'function' ? window.esc : function (s) {
var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML;
};
// ── Scaffold HTML ────────────────────────
var SCAFFOLD_HTML =
'<div id="twfList" class="admin-list"></div>' +
'<div id="twfDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">' +
'<button class="btn-small" id="twfBackBtn">&larr; Back</button>' +
'<h4 id="twfDetailName" style="margin:0;flex:1"></h4>' +
'<span id="twfDetailVersion" class="badge"></span>' +
'<span id="twfDetailStatus" class="badge"></span>' +
'</div>' +
'<div class="settings-section" style="margin-bottom:16px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Name</label>' +
'<input type="text" id="twfEditName" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>Slug</label>' +
'<input type="text" id="twfEditSlug" style="width:100%" readonly></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:2"><label>Description</label>' +
'<textarea id="twfEditDesc" rows="2" style="width:100%"></textarea></div>' +
'<div class="form-group" style="flex:1"><label>Entry Mode</label>' +
'<select id="twfEditEntry" style="width:100%">' +
'<option value="team_only">Team Only</option>' +
'<option value="public_link">Public Link</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px;gap:12px;align-items:center">' +
'<label class="toggle-label"><input type="checkbox" id="twfEditActive"><span class="toggle-track"></span><span>Active</span></label>' +
'<button class="btn-small btn-primary" id="twfSaveBtn">Save Changes</button>' +
'<button class="btn-small" id="twfPublishBtn">Publish</button>' +
'<button class="btn-small btn-danger" id="twfDeleteBtn" style="margin-left:auto">Delete</button>' +
'</div>' +
'</div>' +
'<h5 style="margin:0 0 8px">Stages</h5>' +
'<div id="twfStageList" class="admin-list"></div>' +
'<button class="btn-small" id="twfAddStageBtn" style="margin-top:8px">+ Add Stage</button>' +
'<div id="twfStageEditor" class="settings-section" style="display:none;margin-top:12px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Stage Name</label>' +
'<input type="text" id="twfStageName" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>History Mode</label>' +
'<select id="twfStageHistory" style="width:100%">' +
'<option value="full">Full</option>' +
'<option value="summary">Summary</option>' +
'<option value="fresh">Fresh</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Persona</label>' +
'<select id="twfStagePersona" style="width:100%">' +
'<option value="">None (no AI for this stage)</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Stage Mode</label>' +
'<select id="twfStageMode" style="width:100%">' +
'<option value="chat_only">Chat Only</option>' +
'<option value="form_only">Form Only (no AI)</option>' +
'<option value="form_chat">Form + Chat</option>' +
'<option value="review">Review (Human)</option>' +
'</select></div>' +
'</div>' +
'<div id="twfFormBuilderWrap" style="display:none;margin-top:8px">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">' +
'<label style="margin:0;font-weight:600">Form Fields</label>' +
'<label class="toggle-label" style="font-size:12px"><input type="checkbox" id="twfFormJsonToggle"><span class="toggle-track"></span><span>Show JSON</span></label>' +
'</div>' +
'<div id="twfFormFields"></div>' +
'<button class="btn-small" id="twfAddFieldBtn" style="margin-top:4px">+ Add Field</button>' +
'<textarea id="twfStageForm" rows="4" style="display:none;width:100%;font-family:monospace;font-size:12px;margin-top:6px" placeholder=\'{"fields":[{"key":"name","type":"text","label":"Full Name","required":true}]}\'></textarea>' +
'</div>' +
'<div class="form-row" style="margin-top:8px;gap:8px">' +
'<button class="btn-small btn-primary" id="twfSaveStageBtn">Save Stage</button>' +
'<button class="btn-small" id="twfCancelStageBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<h5 style="margin:16px 0 8px">Public URL</h5>' +
'<div id="twfPublicUrl" class="text-muted" style="font-size:12px;word-break:break-all"></div>' +
'</div>';
// ── State ───────────────────────────────
var _teamId = null;
var _teamSlug = null;
var _currentWf = null;
var _editingStageId = null;
var _personaCache = [];
function _getTeamId() {
return _teamId || (UI && UI._managingTeamId) || null;
}
// Load team personas into the stage editor dropdown.
async function _twfLoadPersonaSelect(selectedId) {
var sel = document.getElementById('twfStagePersona');
if (!sel) return;
var teamId = _getTeamId();
if (_personaCache.length === 0 && teamId) {
try {
var resp = await API._get('/api/v1/teams/' + teamId + '/personas');
_personaCache = (resp.data || resp || []).map(function(p) {
return { id: p.id, name: p.name, icon: p.icon || '' };
});
} catch (e) {
// Fall back to user-visible personas
try {
var resp2 = await API.listPersonas();
_personaCache = (resp2.data || resp2 || []).map(function(p) {
return { id: p.id, name: p.name, icon: p.icon || '' };
});
} catch (e2) { /* no personas available */ }
}
}
sel.innerHTML = '<option value="">None (no AI for this stage)</option>';
_personaCache.forEach(function(p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = (p.icon ? p.icon + ' ' : '') + p.name;
sel.appendChild(opt);
});
if (selectedId) sel.value = selectedId;
}
// ── Loader (called from switchTeamTab) ──
UI.loadTeamWorkflows = async function(teamId) {
_teamId = teamId;
_teamSlug = null;
_currentWf = null;
_editingStageId = null;
_personaCache = [];
_twfWired = false;
// Fetch team slug for clean public URLs
try {
var teamResp = await API._get('/api/v1/admin/teams/' + teamId);
_teamSlug = teamResp.slug || null;
} catch (e) { /* fallback to team ID in URLs */ }
var target = document.getElementById('teamTabWorkflows');
if (!target) return;
// Self-scaffold
if (!document.getElementById('twfList')) {
target.innerHTML = SCAFFOLD_HTML;
_twfWireFormBuilder();
}
var list = document.getElementById('twfList');
var detail = document.getElementById('twfDetail');
if (!list) return;
detail.style.display = 'none';
list.style.display = '';
try {
var resp = await API.listTeamWorkflows(teamId);
var wfs = resp.data || resp || [];
if (wfs.length === 0) {
list.innerHTML =
'<div class="empty-hint">No workflows for this team</div>' +
'<button class="btn-small btn-primary" style="margin-top:8px" ' +
'data-action="_twfCreate">+ Create Workflow</button>';
return;
}
var html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
'<span class="text-muted">' + wfs.length + ' workflow(s)</span>' +
'<button class="btn-small btn-primary" data-action="_twfCreate">+ New</button>' +
'</div>';
html += '<table class="admin-table"><thead><tr>' +
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
'</tr></thead><tbody>';
for (var i = 0; i < wfs.length; i++) {
var wf = wfs[i];
html += '<tr style="cursor:pointer" data-action="_twfOpen" data-args=\'' + JSON.stringify([wf.id]) + '\'>' +
'<td>' + esc(wf.name) + '</td>' +
'<td><code>' + esc(wf.slug) + '</code></td>' +
'<td>' + esc(wf.entry_mode || 'team_only') + '</td>' +
'<td>' + (wf.is_active ? '✓' : '—') + '</td>' +
'<td>v' + (wf.version || 1) + '</td>' +
'</tr>';
}
html += '</tbody></table>';
list.innerHTML = html;
} catch (e) {
list.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
}
};
// ── Create ──────────────────────────────
sb.register('_twfCreate', async function() {
var teamId = _getTeamId();
if (!teamId) return;
var name = prompt('Workflow name:');
if (!name) return;
try {
var wf = await API.createTeamWorkflow(teamId, { name: name });
UI.toast('Workflow created', 'success');
_twfOpen(wf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
});
// ── Open Detail ─────────────────────────
async function _twfOpen(id) {
var teamId = _getTeamId();
var list = document.getElementById('twfList');
var detail = document.getElementById('twfDetail');
try {
var wf = await API.getTeamWorkflow(teamId, id);
_currentWf = wf;
list.style.display = 'none';
detail.style.display = '';
document.getElementById('twfDetailName').textContent = wf.name;
document.getElementById('twfDetailVersion').textContent = 'v' + (wf.version || 1);
document.getElementById('twfDetailStatus').textContent = wf.is_active ? 'Active' : 'Inactive';
document.getElementById('twfDetailStatus').className = 'badge ' + (wf.is_active ? 'badge-ok' : 'badge-warn');
document.getElementById('twfEditName').value = wf.name;
document.getElementById('twfEditSlug').value = wf.slug;
document.getElementById('twfEditDesc').value = wf.description || '';
document.getElementById('twfEditEntry').value = wf.entry_mode || 'team_only';
document.getElementById('twfEditActive').checked = wf.is_active;
// Public URL (v0.31.2: use team slug for clean URLs)
var scope = _teamSlug || wf.team_id || teamId;
var base = location.origin + (window.__BASE__ || '');
document.getElementById('twfPublicUrl').textContent =
wf.entry_mode === 'public_link'
? base + '/w/' + scope + '/' + wf.slug
: 'Enable "Public Link" entry mode to generate a URL';
// Preload persona cache
await _twfLoadPersonaSelect('');
// Load stages
await _twfLoadStages(id);
_twfWireEvents();
} catch (e) {
UI.toast('Failed to load workflow: ' + e.message, 'error');
}
}
sb.register('_twfOpen', function(id) { return _twfOpen(id); });
// ── Stage List ──────────────────────────
async function _twfLoadStages(wfId) {
var teamId = _getTeamId();
var el = document.getElementById('twfStageList');
if (!el) return;
try {
var resp = await API.listTeamWorkflowStages(teamId, wfId);
var stages = resp.data || resp || [];
if (stages.length === 0) {
el.innerHTML = '<div class="empty-hint">No stages — add one below</div>';
return;
}
var html = '';
stages.forEach(function(s, i) {
var personaLabel = '';
if (s.persona_id) {
var p = _personaCache.find(function(x) { return x.id === s.persona_id; });
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
'<span class="badge">persona</span>';
}
var modeLabel = '';
if (s.stage_mode === 'review') {
modeLabel = '<span class="badge badge-warn">review</span>';
} else if (s.stage_mode && s.stage_mode !== 'chat_only') {
modeLabel = '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>';
}
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
'<span class="wf-stage-grip">⠿</span>' +
'<span class="wf-stage-ord">' + i + '</span>' +
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
personaLabel + modeLabel +
'<span class="badge">' + (s.history_mode || 'full') + '</span>' +
'<button class="btn-small" data-action="_twfEditStage" data-args=\'' + JSON.stringify([s.id]) + '\'>Edit</button>' +
'<button class="btn-small btn-danger" data-action="_twfDeleteStage" data-args=\'' + JSON.stringify([s.id]) + '\'>×</button>' +
'</div>';
});
el.innerHTML = html;
_twfWireStageDnD(el, wfId);
} catch (e) {
el.innerHTML = '<div class="empty-hint">Failed: ' + esc(e.message) + '</div>';
}
}
// ── Stage DnD ───────────────────────────
function _twfWireStageDnD(container, wfId) {
var dragSrc = null;
var teamId = _getTeamId();
container.querySelectorAll('.wf-stage-row').forEach(function(row) {
row.addEventListener('dragstart', function(e) {
dragSrc = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', row.dataset.id);
});
row.addEventListener('dragend', function() {
row.classList.remove('dragging');
container.querySelectorAll('.wf-stage-row').forEach(function(r) {
r.classList.remove('drag-over');
});
dragSrc = null;
});
row.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (row !== dragSrc) row.classList.add('drag-over');
});
row.addEventListener('dragleave', function() {
row.classList.remove('drag-over');
});
row.addEventListener('drop', function(e) {
e.preventDefault();
row.classList.remove('drag-over');
if (!dragSrc || dragSrc === row) return;
var rows = Array.from(container.querySelectorAll('.wf-stage-row'));
var fromIdx = rows.indexOf(dragSrc);
var toIdx = rows.indexOf(row);
if (fromIdx < toIdx) {
row.parentNode.insertBefore(dragSrc, row.nextSibling);
} else {
row.parentNode.insertBefore(dragSrc, row);
}
var orderedIDs = Array.from(container.querySelectorAll('.wf-stage-row')).map(function(r) {
return r.dataset.id;
});
API.reorderTeamWorkflowStages(teamId, wfId, orderedIDs).then(function() {
UI.toast('Stages reordered', 'success');
_twfLoadStages(wfId);
}).catch(function(err) {
UI.toast('Reorder failed: ' + (err.message || err), 'error');
_twfLoadStages(wfId);
});
});
});
}
// ── Wire Events ─────────────────────────
var _twfWired = false;
function _twfWireEvents() {
if (_twfWired) return;
_twfWired = true;
document.getElementById('twfBackBtn').onclick = function() {
_currentWf = null;
_editingStageId = null;
_personaCache = [];
_twfWired = false;
UI.loadTeamWorkflows(_getTeamId());
};
document.getElementById('twfSaveBtn').onclick = async function() {
if (!_currentWf) return;
var teamId = _getTeamId();
try {
await API.updateTeamWorkflow(teamId, _currentWf.id, {
name: document.getElementById('twfEditName').value.trim(),
description: document.getElementById('twfEditDesc').value.trim(),
entry_mode: document.getElementById('twfEditEntry').value,
is_active: document.getElementById('twfEditActive').checked,
});
UI.toast('Saved', 'success');
_twfOpen(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('twfPublishBtn').onclick = async function() {
if (!_currentWf) return;
var teamId = _getTeamId();
try {
var ver = await API.publishTeamWorkflow(teamId, _currentWf.id);
UI.toast('Published v' + ver.version_number, 'success');
_twfOpen(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('twfDeleteBtn').onclick = async function() {
if (!_currentWf) return;
if (!confirm('Delete workflow "' + _currentWf.name + '"? This cannot be undone.')) return;
var teamId = _getTeamId();
try {
await API.deleteTeamWorkflow(teamId, _currentWf.id);
UI.toast('Deleted', 'success');
_currentWf = null;
_personaCache = [];
_twfWired = false;
UI.loadTeamWorkflows(teamId);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('twfAddStageBtn').onclick = async function() {
_editingStageId = null;
document.getElementById('twfStageName').value = '';
document.getElementById('twfStageHistory').value = 'full';
document.getElementById('twfStagePersona').value = '';
document.getElementById('twfStageMode').value = 'chat_only';
document.getElementById('twfStageForm').value = '';
document.getElementById('twfFormFields').innerHTML = '';
_twfUpdateFormBuilderVisibility();
await _twfLoadPersonaSelect('');
document.getElementById('twfStageEditor').style.display = '';
};
document.getElementById('twfCancelStageBtn').onclick = function() {
document.getElementById('twfStageEditor').style.display = 'none';
_editingStageId = null;
};
document.getElementById('twfSaveStageBtn').onclick = async function() {
if (!_currentWf) return;
var teamId = _getTeamId();
var name = document.getElementById('twfStageName').value.trim();
if (!name) { UI.toast('Stage name required', 'error'); return; }
var stageMode = document.getElementById('twfStageMode').value;
var formTemplate = null;
if (stageMode === 'form_only' || stageMode === 'form_chat') {
var jsonToggle = document.getElementById('twfFormJsonToggle');
if (jsonToggle && jsonToggle.checked) {
var formRaw = document.getElementById('twfStageForm').value.trim();
if (formRaw) {
try { formTemplate = JSON.parse(formRaw); }
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
}
} else {
formTemplate = _twfBuildFormTemplateFromUI();
}
if (!formTemplate || !formTemplate.fields || formTemplate.fields.length === 0) {
UI.toast('Form modes require at least one form field', 'error');
return;
}
} else {
var formRaw = document.getElementById('twfStageForm').value.trim();
if (formRaw) {
try { formTemplate = JSON.parse(formRaw); }
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
}
}
var personaId = document.getElementById('twfStagePersona').value || null;
var data = {
name: name,
history_mode: document.getElementById('twfStageHistory').value,
stage_mode: stageMode,
persona_id: personaId,
form_template: formTemplate,
};
try {
if (_editingStageId) {
await API.updateTeamWorkflowStage(teamId, _currentWf.id, _editingStageId, data);
} else {
data.ordinal = document.querySelectorAll('#twfStageList .wf-stage-row').length;
await API.createTeamWorkflowStage(teamId, _currentWf.id, data);
}
UI.toast('Stage saved', 'success');
document.getElementById('twfStageEditor').style.display = 'none';
_editingStageId = null;
await _twfLoadStages(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
}
// ── Edit / Delete Stage ─────────────────
sb.register('_twfEditStage', async function(stageId) {
if (!_currentWf) return;
var teamId = _getTeamId();
try {
var resp = await API.listTeamWorkflowStages(teamId, _currentWf.id);
var stages = resp.data || resp || [];
var stage = stages.find(function(s) { return s.id === stageId; });
if (!stage) return;
_editingStageId = stageId;
document.getElementById('twfStageName').value = stage.name;
document.getElementById('twfStageHistory').value = stage.history_mode || 'full';
document.getElementById('twfStageMode').value = stage.stage_mode || 'chat_only';
await _twfLoadPersonaSelect(stage.persona_id || '');
var tpl = stage.form_template;
document.getElementById('twfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
_twfPopulateFormBuilder(tpl);
_twfUpdateFormBuilderVisibility();
document.getElementById('twfStageEditor').style.display = '';
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
});
sb.register('_twfDeleteStage', async function(stageId) {
if (!_currentWf) return;
if (!confirm('Delete this stage?')) return;
var teamId = _getTeamId();
try {
await API.deleteTeamWorkflowStage(teamId, _currentWf.id, stageId);
UI.toast('Stage deleted', 'success');
await _twfLoadStages(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
});
// ── Form Builder Helpers ────────────────
var FIELD_TYPES = ['text', 'email', 'number', 'date', 'textarea', 'select', 'checkbox', 'file'];
function _twfUpdateFormBuilderVisibility() {
var mode = document.getElementById('twfStageMode').value;
var wrap = document.getElementById('twfFormBuilderWrap');
if (wrap) wrap.style.display = (mode === 'form_only' || mode === 'form_chat') ? '' : 'none';
}
function _twfCreateFieldRow(field) {
field = field || { key: '', type: 'text', label: '', required: false };
var row = document.createElement('div');
row.className = 'wf-field-row';
row.innerHTML =
'<input type="text" class="wf-fb-key" value="' + esc(field.key || '') + '" placeholder="key" style="width:80px">' +
'<select class="wf-fb-type" style="width:90px">' +
FIELD_TYPES.map(function(t) {
return '<option value="' + t + '"' + (t === field.type ? ' selected' : '') + '>' + t + '</option>';
}).join('') +
'</select>' +
'<input type="text" class="wf-fb-label" value="' + esc(field.label || '') + '" placeholder="Label" style="flex:1">' +
'<label class="toggle-label" style="font-size:12px;white-space:nowrap"><input type="checkbox" class="wf-fb-required"' +
(field.required ? ' checked' : '') + '><span class="toggle-track"></span><span>Req</span></label>' +
'<button class="btn-small btn-danger wf-fb-del" style="padding:2px 6px">×</button>';
row.querySelector('.wf-fb-del').onclick = function() {
row.remove();
_twfSyncFormJSON();
};
row.querySelector('.wf-fb-type').onchange = function() {
var optWrap = row.querySelector('.wf-fb-options');
if (this.value === 'select') {
if (!optWrap) {
optWrap = document.createElement('div');
optWrap.className = 'wf-fb-options';
optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px';
optWrap.innerHTML = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
row.appendChild(optWrap);
}
optWrap.style.display = '';
} else if (optWrap) {
optWrap.style.display = 'none';
}
_twfSyncFormJSON();
};
if (field.type === 'select') {
var optWrap = document.createElement('div');
optWrap.className = 'wf-fb-options';
optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px';
optWrap.innerHTML = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
row.appendChild(optWrap);
}
row.querySelectorAll('input, select').forEach(function(el) {
el.addEventListener('change', _twfSyncFormJSON);
el.addEventListener('input', _twfSyncFormJSON);
});
return row;
}
function _twfPopulateFormBuilder(tpl) {
var container = document.getElementById('twfFormFields');
if (!container) return;
container.innerHTML = '';
if (!tpl || !tpl.fields || !Array.isArray(tpl.fields)) return;
tpl.fields.forEach(function(f) {
if (typeof f === 'string') {
f = { key: f, type: 'text', label: f, required: false };
}
container.appendChild(_twfCreateFieldRow(f));
});
}
function _twfBuildFormTemplateFromUI() {
var container = document.getElementById('twfFormFields');
if (!container) return null;
var rows = container.querySelectorAll('.wf-field-row');
if (rows.length === 0) return null;
var fields = [];
rows.forEach(function(row) {
var key = row.querySelector('.wf-fb-key').value.trim();
var type = row.querySelector('.wf-fb-type').value;
var label = row.querySelector('.wf-fb-label').value.trim();
var required = row.querySelector('.wf-fb-required').checked;
if (!key) return;
var field = { key: key, type: type, label: label || key, required: required };
if (type === 'select') {
var optsInput = row.querySelector('.wf-fb-opts-input');
if (optsInput && optsInput.value.trim()) {
field.options = optsInput.value.split(',').map(function(s) {
s = s.trim();
return { value: s, label: s };
}).filter(function(o) { return o.value; });
}
}
fields.push(field);
});
return fields.length > 0 ? { fields: fields } : null;
}
function _twfSyncFormJSON() {
var jsonToggle = document.getElementById('twfFormJsonToggle');
if (!jsonToggle) return;
var tpl = _twfBuildFormTemplateFromUI();
document.getElementById('twfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
}
function _twfWireFormBuilder() {
var modeSelect = document.getElementById('twfStageMode');
if (modeSelect) {
modeSelect.onchange = _twfUpdateFormBuilderVisibility;
}
var addBtn = document.getElementById('twfAddFieldBtn');
if (addBtn) {
addBtn.onclick = function() {
var container = document.getElementById('twfFormFields');
if (container) {
container.appendChild(_twfCreateFieldRow());
_twfSyncFormJSON();
}
};
}
var jsonToggle = document.getElementById('twfFormJsonToggle');
if (jsonToggle) {
jsonToggle.onchange = function() {
var textarea = document.getElementById('twfStageForm');
if (this.checked) {
_twfSyncFormJSON();
textarea.style.display = '';
} else {
var raw = textarea.value.trim();
if (raw) {
try {
var tpl = JSON.parse(raw);
_twfPopulateFormBuilder(tpl);
} catch (e) {
UI.toast('Invalid JSON — fix before switching to visual mode', 'error');
this.checked = true;
return;
}
}
textarea.style.display = 'none';
}
};
}
}
})();