{% extends "base.html" %} {% block title %}Form Editor — {{ template.name }}{% endblock %} {% block extra_css %} {% endblock %} {% block content %}
Back
{{ template.name }}
Form Editor · {{ template.frequency|title }}
All changes saved Preview
Basic
{% for t, ic, lb in [ ('text', 'bi-input-cursor-text', 'Text Input'), ('textarea', 'bi-text-left', 'Text Area'), ('number', 'bi-hash', 'Number'), ('date', 'bi-calendar3', 'Date'), ('email', 'bi-envelope', 'Email'), ] %}
{{ lb }}
{% endfor %}
Choice
{% for t, ic, lb in [ ('checkbox', 'bi-check-square', 'Checkbox'), ('checkbox_group', 'bi-ui-checks', 'Checkbox Group'), ('radio', 'bi-ui-radios', 'Radio Group'), ('select', 'bi-menu-button-wide', 'Dropdown'), ] %}
{{ lb }}
{% endfor %}
Media & Other
{% for t, ic, lb in [ ('image', 'bi-image', 'Image Upload'), ('signature', 'bi-pen', 'Signature'), ('rating', 'bi-star', 'Rating (1–5)'), ('section', 'bi-dash-lg', 'Section Header'), ('table', 'bi-table', 'Table'), ] %}
{{ lb }}
{% endfor %}
Text & Actions
{% for t, ic, lb in [ ('label', 'bi-type', 'Label / Text'), ('button_submit', 'bi-send-fill', 'Submit Button'), ('button_print', 'bi-printer-fill', 'Print Button'), ('button_email', 'bi-envelope-fill', 'Email Button'), ] %}
{{ lb }}
{% endfor %}

Drag fields onto the canvas

Fields snap to a 12-column grid.

{# Schema is injected via a typed script tag — tojson escapes sequences so this is safe. The JS init reads window.__FORM_SCHEMA__ directly. #}

Select a field to edit its properties.

{% endblock %} {% block extra_js %} sequences) const raw = JSON.parse(document.getElementById('schema-data').textContent || '[]'); if (Array.isArray(raw) && raw.length) { fields = raw.map(f => ({ ...f, col: f.col ?? 1, row: f.row ?? 1, colSpan: f.colSpan ?? (DEF_SIZE[f.type]?.[0] ?? 6), rowSpan: f.rowSpan ?? (DEF_SIZE[f.type]?.[1] ?? 2), // table back-compat table_cols: f.type === 'table' ? (f.table_cols ?? 3) : f.table_cols, table_rows: f.type === 'table' ? (f.table_rows ?? 3) : f.table_rows, col_headers: f.type === 'table' ? (f.col_headers ?? ['Column 1','Column 2','Column 3']) : f.col_headers, // label back-compat text_content: f.type === 'label' ? (f.text_content ?? 'Label text') : f.text_content, font_size: f.type === 'label' ? (f.font_size ?? 'normal') : f.font_size, font_weight: f.type === 'label' ? (f.font_weight ?? 'normal') : f.font_weight, // button back-compat btn_label: f.type.startsWith('button_') ? (f.btn_label ?? ({button_submit:'Submit Form',button_print:'Print Form',button_email:'Email Form'}[f.type]||'Button') ) : f.btn_label, })); renderAll(); } })(); // ═══════════════════════════════════════════════════════════════════════════ // FIELD FACTORY // ═══════════════════════════════════════════════════════════════════════════ function mkField(type, col, row) { const [cs, rs] = DEF_SIZE[type] ?? [6, 2]; const isTable = type === 'table'; return { id: 'f_' + Date.now() + '_' + (Math.random() * 9999 | 0), type, label: LABELS[type] ?? 'Field', placeholder: '', required: false, help_text: '', options: ['radio','checkbox_group','select'].includes(type) ? ['Option 1','Option 2'] : [], table_cols: isTable ? 3 : undefined, table_rows: isTable ? 3 : undefined, col_headers: isTable ? ['Column 1','Column 2','Column 3'] : undefined, // label: free-text content text_content: type === 'label' ? 'Label text' : undefined, // buttons: configurable label text btn_label: type.startsWith('button_') ? { button_submit:'Submit Form', button_print:'Print Form', button_email:'Email Form' }[type] : undefined, col: Math.max(1, Math.min(COLS - cs + 1, col ?? 1)), row: Math.max(1, row ?? 1), colSpan: cs, rowSpan: rs, order: fields.length, }; } // ═══════════════════════════════════════════════════════════════════════════ // RENDER — diff-patch cards on the grid surface // ═══════════════════════════════════════════════════════════════════════════ function renderAll() { document.getElementById('gridHint').style.display = fields.length ? 'none' : ''; const existing = {}; surface.querySelectorAll('.fcard').forEach(el => { existing[el.dataset.id] = el; }); fields.forEach(f => { let card = existing[f.id]; if (card) { card.style.cssText = cardCss(f); updateHead(card, f); updateBody(card, f); delete existing[f.id]; } else { card = buildCard(f); surface.appendChild(card); } card.classList.toggle('sel', f.id === selectedId); }); Object.values(existing).forEach(el => el.remove()); growSurface(); } function buildCard(f) { const card = document.createElement('div'); card.className = 'fcard'; card.dataset.id = f.id; card.style.cssText = cardCss(f); card.innerHTML = `
${esc(f.type.replace('_',' '))} ${esc(f.label)}${f.required ? ' *':''}
${mkPreview(f)}
`; // click to select card.addEventListener('mousedown', e => { if (e.target.closest('.fact') || e.target.closest('.resize-h')) return; selectField(f.id); }); // action buttons card.querySelector('[data-act="dup"]').addEventListener('click', e => { e.stopPropagation(); dupField(f.id); }); card.querySelector('[data-act="del"]').addEventListener('click', e => { e.stopPropagation(); delField(f.id); }); // move via header card.querySelector('.fcard-head').addEventListener('mousedown', e => { if (e.target.closest('.fact')) return; e.preventDefault(); selectField(f.id); const rect = surface.getBoundingClientRect(); const cRect = card.getBoundingClientRect(); moveState = { id: f.id, offX: e.clientX - cRect.left, offY: e.clientY - cRect.top, surfLeft: rect.left, surfTop: rect.top, }; card.classList.add('moving'); showPreview(f); }); // resize card.querySelector('.resize-h').addEventListener('mousedown', e => { e.preventDefault(); e.stopPropagation(); selectField(f.id); resizeState = { id: f.id, startX: e.clientX, startY: e.clientY, origCS: f.colSpan, origRS: f.rowSpan, }; showPreview(f); }); return card; } function updateHead(card, f) { const badge = card.querySelector('.fcard-badge'); const lbl = card.querySelector('.fcard-lbl'); if (badge) badge.textContent = f.type.replace('_',' '); if (lbl) lbl.innerHTML = esc(f.label) + (f.required ? ' *' : ''); } function updateBody(card, f) { const body = card.querySelector('.fcard-body'); if (body) body.innerHTML = mkPreview(f); } function mkPreview(f) { const ph = f.placeholder ? `placeholder="${esc(f.placeholder)}"` : ''; const lbl = f.type !== 'section' ? `` : ''; switch (f.type) { case 'text': return `${lbl}`; case 'textarea': return `${lbl}`; case 'number': return `${lbl}`; case 'date': return `${lbl}`; case 'email': return `${lbl}`; case 'checkbox': return `
`; case 'checkbox_group': return lbl + (f.options||[]).map(o => `
`).join(''); case 'radio': return lbl + (f.options||[]).map(o => `
`).join(''); case 'select': return `${lbl}`; case 'image': return `${lbl}
Upload image
`; case 'signature': return `${lbl}
Sign here…
`; case 'rating': return `${lbl}
★★★★★
`; case 'section': return `
${esc(f.label)}
`; case 'label': { const fsMap = {small:'.72rem', normal:'.84rem', large:'1rem', 'x-large':'1.2rem'}; const fs = fsMap[f.font_size||'normal'] || '.84rem'; const fw = f.font_weight || 'normal'; return `
${esc(f.text_content || 'Label text')}
`; } case 'button_submit': return ``; case 'button_print': return ``; case 'button_email': return ``; case 'table': { const tcols = f.col_headers || ['Col 1','Col 2','Col 3']; const trows = f.table_rows || 3; const hdrs = tcols.map(h => `${esc(h)}`).join(''); const cells = tcols.map(() => `—`).join(''); const rows = Array(trows).fill(`${cells}`).join(''); return `${lbl}
${hdrs}${rows}
`; } default: return `${lbl}`; } } // ═══════════════════════════════════════════════════════════════════════════ // DROP PREVIEW (blue ghost box on the grid) // ═══════════════════════════════════════════════════════════════════════════ function showPreview(f) { positionPreview(f.col, f.row, f.colSpan, f.rowSpan); preview.style.display = ''; } function hidePreview() { preview.style.display = 'none'; } function positionPreview(col, row, cs, rs) { const { x, y } = cellPx(col, row); preview.style.left = x + 'px'; preview.style.top = y + 'px'; preview.style.width = (cs * CELL_W + (cs-1)*GAP) + 'px'; preview.style.height = (rs * CELL_H + (rs-1)*GAP) + 'px'; } // ═══════════════════════════════════════════════════════════════════════════ // SELECT + PROPERTIES // ═══════════════════════════════════════════════════════════════════════════ function selectField(id) { selectedId = id; surface.querySelectorAll('.fcard').forEach(c => { c.classList.toggle('sel', c.dataset.id === id); }); renderProperties(); } function renderProperties() { const empty = document.getElementById('propsEmpty'); const content = document.getElementById('propsContent'); if (!selectedId) { empty.style.display=''; content.style.display='none'; return; } const f = fields.find(f => f.id === selectedId); if (!f) return; empty.style.display = 'none'; content.style.display = ''; const hasOpts = ['radio','checkbox_group','select'].includes(f.type); const isSec = f.type === 'section'; const isLabel = f.type === 'label'; const isBtn = f.type.startsWith('button_'); const isSimple = isLabel || isBtn; // no label/placeholder/required row content.innerHTML = ` ${!isSimple ? `
Field
` : ''} ${!isSimple && !['checkbox','section','image','signature','rating','table'].includes(f.type) ? `
` : ''} ${!isSimple && !isSec ? `
` : ''}
Size & Position
${hasOpts ? `
Options
` : ''} ${f.type === 'label' ? `
Content
` : ''} ${f.type.startsWith('button_') ? `
Button
` : ''} ${f.type === 'table' ? `
Table Structure
Column Headers
` : ''} `; } function setProp(key, val) { const f = fields.find(f => f.id === selectedId); if (!f) return; f[key] = val; markDirty(); const card = surface.querySelector(`[data-id="${selectedId}"]`); if (card) { updateHead(card, f); updateBody(card, f); } } function setSizeProp(key, val, min, max) { const f = fields.find(f => f.id === selectedId); if (!f) return; const n = Math.max(min, Math.min(max, parseInt(val) || min)); f[key] = n; markDirty(); const card = surface.querySelector(`[data-id="${selectedId}"]`); if (card) { card.style.cssText = cardCss(f); } growSurface(); } function setOpt(i, v) { const f = fields.find(f => f.id === selectedId); if (!f) return; f.options[i] = v; markDirty(); const card = surface.querySelector(`[data-id="${selectedId}"]`); if (card) updateBody(card, f); } function addOpt() { const f = fields.find(f => f.id === selectedId); if (!f) return; f.options.push('Option ' + (f.options.length + 1)); markDirty(); renderProperties(); const card = surface.querySelector(`[data-id="${selectedId}"]`); if (card) updateBody(card, f); } function rmOpt(i) { const f = fields.find(f => f.id === selectedId); if (!f) return; f.options.splice(i, 1); markDirty(); renderProperties(); const card = surface.querySelector(`[data-id="${selectedId}"]`); if (card) updateBody(card, f); } // ── Table helpers ─────────────────────────────────────────────────────────── function setTableRows(n) { const f = fields.find(f => f.id === selectedId); if (!f) return; f.table_rows = Math.max(1, Math.min(30, n)); markDirty(); const card = surface.querySelector(`[data-id="${selectedId}"]`); if (card) updateBody(card, f); } function setColHeader(i, v) { const f = fields.find(f => f.id === selectedId); if (!f) return; f.col_headers[i] = v; f.table_cols = f.col_headers.length; markDirty(); const card = surface.querySelector(`[data-id="${selectedId}"]`); if (card) updateBody(card, f); } function addTableCol() { const f = fields.find(f => f.id === selectedId); if (!f) return; f.col_headers = f.col_headers || []; f.col_headers.push('Column ' + (f.col_headers.length + 1)); f.table_cols = f.col_headers.length; markDirty(); renderProperties(); const card = surface.querySelector(`[data-id="${selectedId}"]`); if (card) updateBody(card, f); } function rmTableCol(i) { const f = fields.find(f => f.id === selectedId); if (!f || f.col_headers.length <= 1) return; f.col_headers.splice(i, 1); f.table_cols = f.col_headers.length; markDirty(); renderProperties(); const card = surface.querySelector(`[data-id="${selectedId}"]`); if (card) updateBody(card, f); } // ═══════════════════════════════════════════════════════════════════════════ // FIELD OPERATIONS // ═══════════════════════════════════════════════════════════════════════════ function addField(type, col, row) { const f = mkField(type, col, row); fields.push(f); selectedId = f.id; renderAll(); renderProperties(); markDirty(); } function delField(id) { fields = fields.filter(f => f.id !== id); if (selectedId === id) { selectedId = null; renderProperties(); } renderAll(); markDirty(); } function dupField(id) { const src = fields.find(f => f.id === id); if (!src) return; const clone = JSON.parse(JSON.stringify(src)); clone.id = 'f_' + Date.now() + '_' + (Math.random()*9999|0); clone.row = src.row + src.rowSpan + 1; fields.push(clone); selectedId = clone.id; renderAll(); renderProperties(); markDirty(); } // ═══════════════════════════════════════════════════════════════════════════ // MOUSE MOVE + RESIZE // ═══════════════════════════════════════════════════════════════════════════ document.addEventListener('mousemove', e => { // move ghost label if (ghostEl.style.display !== 'none') { ghostEl.style.left = (e.clientX + 12) + 'px'; ghostEl.style.top = (e.clientY + 10) + 'px'; } if (moveState) { const f = fields.find(f => f.id === moveState.id); if (!f) return; const x = e.clientX - moveState.surfLeft - moveState.offX; const y = e.clientY - moveState.surfTop - moveState.offY; const { col, row } = pxCell(x, y); const newCol = Math.max(1, Math.min(COLS - f.colSpan + 1, col)); const newRow = Math.max(1, row); if (newCol !== f.col || newRow !== f.row) { f.col = newCol; f.row = newRow; const card = surface.querySelector(`[data-id="${f.id}"]`); if (card) card.style.cssText = cardCss(f); positionPreview(f.col, f.row, f.colSpan, f.rowSpan); growSurface(); markDirty(); } } if (resizeState) { const f = fields.find(f => f.id === resizeState.id); if (!f) return; const dCol = Math.round((e.clientX - resizeState.startX) / (CELL_W + GAP)); const dRow = Math.round((e.clientY - resizeState.startY) / (CELL_H + GAP)); const newCS = Math.max(1, Math.min(COLS - f.col + 1, resizeState.origCS + dCol)); const newRS = Math.max(1, resizeState.origRS + dRow); if (newCS !== f.colSpan || newRS !== f.rowSpan) { f.colSpan = newCS; f.rowSpan = newRS; const card = surface.querySelector(`[data-id="${f.id}"]`); if (card) card.style.cssText = cardCss(f); positionPreview(f.col, f.row, f.colSpan, f.rowSpan); growSurface(); markDirty(); // live-update size inputs in props panel const inputs = document.querySelectorAll('#propsContent .size-grid input'); if (inputs[0]) inputs[0].value = newCS; if (inputs[1]) inputs[1].value = newRS; } } }); document.addEventListener('mouseup', () => { if (moveState) { surface.querySelector(`[data-id="${moveState.id}"]`)?.classList.remove('moving'); hidePreview(); renderProperties(); moveState = null; } if (resizeState) { hidePreview(); renderProperties(); resizeState = null; } }); // ═══════════════════════════════════════════════════════════════════════════ // PALETTE DRAG → CANVAS (HTML5 drag API) // ═══════════════════════════════════════════════════════════════════════════ document.querySelectorAll('.pal-item').forEach(item => { item.addEventListener('dragstart', e => { palDragType = item.dataset.ftype; e.dataTransfer.setData('ftype', item.dataset.ftype); e.dataTransfer.setData('flabel', item.dataset.flabel); ghostEl.textContent = '+ ' + item.dataset.flabel; ghostEl.style.display = 'block'; e.dataTransfer.effectAllowed = 'copy'; item.classList.add('dragging-src'); }); item.addEventListener('dragend', () => { ghostEl.style.display = 'none'; item.classList.remove('dragging-src'); }); }); document.addEventListener('dragover', e => { ghostEl.style.left = (e.clientX + 12) + 'px'; ghostEl.style.top = (e.clientY + 10) + 'px'; }); surface.addEventListener('dragover', e => { e.preventDefault(); surface.classList.add('drop-active'); const type = palDragType || e.dataTransfer.getData('ftype'); if (!type) return; const rect = surface.getBoundingClientRect(); const { col, row } = pxCell(e.clientX - rect.left, e.clientY - rect.top); const [cs, rs] = DEF_SIZE[type] ?? [6, 2]; const safeCol = Math.max(1, Math.min(COLS - cs + 1, col)); positionPreview(safeCol, Math.max(1, row), cs, rs); preview.style.display = ''; }); surface.addEventListener('dragleave', e => { if (!surface.contains(e.relatedTarget)) { surface.classList.remove('drop-active'); hidePreview(); } }); surface.addEventListener('drop', e => { e.preventDefault(); surface.classList.remove('drop-active'); hidePreview(); const type = e.dataTransfer.getData('ftype') || palDragType; palDragType = null; if (!type) return; const rect = surface.getBoundingClientRect(); const { col, row } = pxCell(e.clientX - rect.left, e.clientY - rect.top); const [cs] = DEF_SIZE[type] ?? [6, 2]; addField(type, Math.max(1, Math.min(COLS - cs + 1, col)), Math.max(1, row)); }); // deselect on bare canvas click surface.addEventListener('mousedown', e => { if (e.target === surface || e.target.closest('.grid-hint')) { selectedId = null; surface.querySelectorAll('.fcard').forEach(c => c.classList.remove('sel')); renderProperties(); } }); // ═══════════════════════════════════════════════════════════════════════════ // SAVE // ═══════════════════════════════════════════════════════════════════════════ let saveTimer = null; function markDirty() { setInd('saving', '● Unsaved'); clearTimeout(saveTimer); saveTimer = setTimeout(() => saveSchema(true), 2500); } function setInd(cls, txt) { const el = document.getElementById('saveInd'); el.className = 'save-ind ' + cls; el.textContent = txt; } async function saveSchema(silent=false) { clearTimeout(saveTimer); if (!silent) setInd('saving', 'Saving…'); fields.forEach((f, i) => f.order = i); try { const r = await fetch(SAVE_URL, { method: 'POST', headers: { 'Content-Type':'application/json', 'X-CSRFToken': CSRF_TOKEN }, body: JSON.stringify({ fields }), }); const d = await r.json(); setInd(d.success ? 'saved' : 'error', d.success ? `✓ Saved — ${d.field_count} field${d.field_count!==1?'s':''}` : '✗ Save failed'); } catch { setInd('error', '✗ Network error'); } } // ═══════════════════════════════════════════════════════════════════════════ // KEYBOARD // ═══════════════════════════════════════════════════════════════════════════ document.addEventListener('keydown', e => { if ((e.ctrlKey||e.metaKey) && e.key==='s') { e.preventDefault(); saveSchema(); } if ((e.key==='Delete'||e.key==='Backspace') && selectedId && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)) { delField(selectedId); } if (e.key==='Escape') { selectedId = null; surface.querySelectorAll('.fcard').forEach(c => c.classList.remove('sel')); renderProperties(); } }); // ═══════════════════════════════════════════════════════════════════════════ // UTIL // ═══════════════════════════════════════════════════════════════════════════ function esc(s) { return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } {% endblock %}