Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'refactor/forms-decompose' (#293) from refactor/forms-decompose into main

scott 948ee2f4 f29f98de

+664 -470
+152
src/forms/ai-chat-panel.ts
··· 1 + /** 2 + * AI chat panel logic for forms — context extraction and message sending. 3 + * 4 + * Extracted from main.ts following the same pattern as sheets/ai-chat-panel.ts. 5 + */ 6 + 7 + import type { FormSchema } from './form-builder.js'; 8 + import { executeFormAction } from './ai-form-actions.js'; 9 + import { escapeHtml } from '../lib/ai-chat.js'; 10 + import { 11 + isConfigured, buildSystemMessage, streamChat, 12 + appendMessage, appendStreamingBubble, renderMarkdown, 13 + appendActionCard, 14 + type ChatMessage, 15 + } from '../lib/ai-chat.js'; 16 + import { splitResponse, isFormAction } from '../lib/ai-actions.js'; 17 + 18 + // ── Types ─────────────────────────────────────────────────── 19 + 20 + export interface ChatPanelDeps { 21 + getForm: () => FormSchema; 22 + setForm: (form: FormSchema) => void; 23 + syncToYjs: () => void; 24 + renderBuilder: () => void; 25 + chatUI: { 26 + input: HTMLTextAreaElement; 27 + settingsPanel: HTMLElement; 28 + endpointInput: HTMLInputElement; 29 + messageList: HTMLElement; 30 + sendBtn: HTMLElement; 31 + stopBtn: HTMLElement; 32 + contextToggle: HTMLInputElement; 33 + actionsToggle: HTMLInputElement; 34 + }; 35 + chatState: { 36 + messages: ChatMessage[]; 37 + loading: boolean; 38 + error: string | null; 39 + abortController: AbortController | null; 40 + }; 41 + chatWiring: { getConfig: () => ReturnType<typeof import('../lib/ai-chat.js').loadConfig> }; 42 + titleInput: HTMLInputElement; 43 + } 44 + 45 + // ── Form context extraction ───────────────────────────────── 46 + 47 + export function getFormContextText(form: FormSchema): string { 48 + const lines: string[] = []; 49 + lines.push(`Title: ${form.title}`); 50 + if (form.description) lines.push(`Description: ${form.description}`); 51 + form.questions.forEach((q, i) => { 52 + let line = `${i + 1}. [${q.type}] "${q.label}"`; 53 + if (q.required) line += ' (required)'; 54 + if (q.options.length > 0) line += ` \u2014 options: ${q.options.map(o => o.label).join(', ')}`; 55 + lines.push(line); 56 + }); 57 + return lines.join('\n'); 58 + } 59 + 60 + // ── Send chat message ─────────────────────────────────────── 61 + 62 + export async function sendChatMessage(deps: ChatPanelDeps): Promise<void> { 63 + const text = deps.chatUI.input.value.trim(); 64 + if (!text || deps.chatState.loading) return; 65 + 66 + const cfg = deps.chatWiring.getConfig(); 67 + if (!isConfigured(cfg)) { 68 + deps.chatUI.settingsPanel.style.display = ''; 69 + deps.chatUI.endpointInput.focus(); 70 + return; 71 + } 72 + 73 + const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 74 + deps.chatState.messages.push(userMsg); 75 + appendMessage(deps.chatUI.messageList, userMsg); 76 + 77 + deps.chatUI.input.value = ''; 78 + deps.chatUI.input.style.height = ''; 79 + deps.chatUI.sendBtn.style.display = 'none'; 80 + deps.chatUI.stopBtn.style.display = ''; 81 + deps.chatState.loading = true; 82 + deps.chatState.error = null; 83 + 84 + const title = deps.titleInput.value.trim() || 'Untitled Form'; 85 + const includeContext = deps.chatUI.contextToggle.checked; 86 + const actionsEnabled = deps.chatUI.actionsToggle.checked; 87 + const contextText = includeContext ? getFormContextText(deps.getForm()) : ''; 88 + 89 + const systemPrompt = buildSystemMessage(title, contextText, { 90 + editorType: 'form', 91 + actionsEnabled, 92 + }); 93 + 94 + const formDeps = { 95 + getForm: deps.getForm, 96 + setForm: deps.setForm, 97 + syncToYjs: deps.syncToYjs, 98 + render: deps.renderBuilder, 99 + }; 100 + 101 + const abortController = new AbortController(); 102 + deps.chatState.abortController = abortController; 103 + const bubble = appendStreamingBubble(deps.chatUI.messageList); 104 + let fullText = ''; 105 + 106 + await streamChat( 107 + cfg, 108 + deps.chatState.messages, 109 + systemPrompt, 110 + { 111 + onChunk(chunk) { 112 + fullText += chunk; 113 + bubble.update(renderMarkdown(fullText)); 114 + }, 115 + onDone(doneText) { 116 + if (doneText) { 117 + deps.chatState.messages.push({ role: 'assistant', content: doneText, ts: Date.now() }); 118 + 119 + if (actionsEnabled) { 120 + const { displayText, actions } = splitResponse(doneText); 121 + if (actions.length > 0) { 122 + bubble.update(renderMarkdown(displayText)); 123 + for (const action of actions) { 124 + if (!isFormAction(action)) continue; 125 + appendActionCard(deps.chatUI.messageList, action, { 126 + onApply: (a) => { 127 + const result = executeFormAction(a as Parameters<typeof executeFormAction>[0], formDeps); 128 + if (!result.success && result.error) { 129 + appendMessage(deps.chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 130 + } 131 + }, 132 + onDismiss: () => {}, 133 + }); 134 + } 135 + } 136 + } 137 + } 138 + }, 139 + onError(err) { 140 + deps.chatState.error = err; 141 + bubble.el.classList.add('ai-chat-bubble--error'); 142 + bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 143 + }, 144 + }, 145 + abortController.signal, 146 + ); 147 + 148 + deps.chatState.loading = false; 149 + deps.chatState.abortController = null; 150 + deps.chatUI.sendBtn.style.display = ''; 151 + deps.chatUI.stopBtn.style.display = 'none'; 152 + }
+58 -470
src/forms/main.ts
··· 6 6 */ 7 7 8 8 import * as Y from 'yjs'; 9 - import { importKey, encryptString, decryptString } from '../lib/crypto.js'; 9 + import { importKey } from '../lib/crypto.js'; 10 10 import { EncryptedProvider } from '../lib/provider.js'; 11 11 import { setupTooltips } from '../lib/tooltips.js'; 12 - import { 13 - createForm, 14 - addQuestion, 15 - removeQuestion, 16 - updateQuestion, 17 - moveQuestion, 18 - addOption, 19 - setTargetSheet, 20 - validateSubmission, 21 - questionCount, 22 - type FormSchema, 23 - type QuestionType, 24 - type Question, 25 - } from './form-builder.js'; 26 - import { 27 - createChatSidebar, createChatState, loadConfig, isConfigured, 28 - buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 29 - renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 30 - type ChatMessage, 31 - } from '../lib/ai-chat.js'; 32 - import { splitResponse, isFormAction } from '../lib/ai-actions.js'; 33 - import { executeFormAction } from './ai-form-actions.js'; 34 - import { 35 - createConditionalState, 36 - addRule, 37 - getVisibleQuestions, 38 - type ConditionalLogicState, 39 - } from './conditional-logic.js'; 40 - import { 41 - createResponse, 42 - createPipelineConfig, 43 - responseToRow, 44 - pipelineHeaders, 45 - type FormResponse, 46 - } from './responses.js'; 12 + import { createForm, setTargetSheet, type FormSchema } from './form-builder.js'; 13 + import { createConditionalState, type ConditionalLogicState } from './conditional-logic.js'; 14 + import { createChatSidebar, createChatState, loadConfig, initChatWiring } from '../lib/ai-chat.js'; 15 + import { createCommandPalette } from '../command-palette.js'; 16 + import { renderBuilderQuestions, showAddQuestionDialog } from './render-builder.js'; 17 + import { renderPreview } from './render-preview.js'; 18 + import { renderResponses } from './render-responses.js'; 19 + import { sendChatMessage, type ChatPanelDeps } from './ai-chat-panel.js'; 47 20 48 21 // --- URL parsing --- 49 22 const url = new URL(window.location.href); ··· 72 45 73 46 // --- State --- 74 47 let form: FormSchema = createForm('Untitled Form'); 75 - let condLogic = createConditionalState(); 48 + let condLogic: ConditionalLogicState = createConditionalState(); 76 49 let mode: 'builder' | 'preview' | 'responses' = 'builder'; 77 50 78 51 // --- DOM refs --- ··· 102 75 } 103 76 } 104 77 105 - // --- Question type definitions --- 106 - const QUESTION_TYPES: Array<{ type: QuestionType; label: string; icon: string }> = [ 107 - { type: 'short_text', label: 'Short Text', icon: 'Aa' }, 108 - { type: 'long_text', label: 'Long Text', icon: '¶' }, 109 - { type: 'number', label: 'Number', icon: '#' }, 110 - { type: 'email', label: 'Email', icon: '@' }, 111 - { type: 'single_choice', label: 'Single Choice', icon: '○' }, 112 - { type: 'multiple_choice', label: 'Multiple Choice', icon: '☐' }, 113 - { type: 'dropdown', label: 'Dropdown', icon: '▾' }, 114 - { type: 'date', label: 'Date', icon: '📅' }, 115 - { type: 'rating', label: 'Rating', icon: '★' }, 116 - { type: 'scale', label: 'Scale', icon: '⊞' }, 117 - ]; 78 + // --- Shared deps for extracted modules --- 79 + const builderDeps = { 80 + getForm: () => form, 81 + setForm: (f: FormSchema) => { form = f; }, 82 + syncToYjs: syncFormToYjs, 83 + }; 118 84 119 - // --- Render builder --- 85 + // --- Mode rendering --- 120 86 function renderBuilder() { 121 87 toolbar.style.display = ''; 122 88 questionsContainer.style.display = ''; ··· 126 92 titleInput.value = form.title; 127 93 descInput.value = form.description; 128 94 129 - questionsContainer.innerHTML = ''; 130 - 131 - if (form.questions.length === 0) { 132 - questionsContainer.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--color-text-secondary)">No questions yet. Click "+ Add Question" to get started.</div>'; 133 - return; 134 - } 135 - 136 - for (let i = 0; i < form.questions.length; i++) { 137 - const q = form.questions[i]!; 138 - const el = document.createElement('div'); 139 - el.className = 'form-question-card'; 140 - el.dataset.questionId = q.id; 141 - 142 - const typeLabel = QUESTION_TYPES.find(t => t.type === q.type)?.label ?? q.type; 143 - const isChoice = ['single_choice', 'multiple_choice', 'dropdown'].includes(q.type); 144 - 145 - let optionsHtml = ''; 146 - if (isChoice) { 147 - optionsHtml = '<div class="form-question-options">'; 148 - for (const opt of q.options) { 149 - optionsHtml += `<div class="form-option-row"><input type="text" value="${escapeHtml(opt.label)}" data-option-id="${opt.id}" class="form-option-input" placeholder="Option label"><button class="form-option-remove" data-option-id="${opt.id}" title="Remove">✕</button></div>`; 150 - } 151 - optionsHtml += `<button class="form-add-option" data-question-id="${q.id}">+ Add option</button></div>`; 152 - } 153 - 154 - el.innerHTML = ` 155 - <div class="form-question-header"> 156 - <span class="form-question-number">${i + 1}</span> 157 - <span class="form-question-type-badge">${typeLabel}</span> 158 - <span style="flex:1"></span> 159 - <label class="form-question-required-label"><input type="checkbox" class="form-question-required" ${q.required ? 'checked' : ''}> Required</label> 160 - <button class="form-question-move-up" title="Move up" ${i === 0 ? 'disabled' : ''}>↑</button> 161 - <button class="form-question-move-down" title="Move down" ${i === form.questions.length - 1 ? 'disabled' : ''}>↓</button> 162 - <button class="form-question-delete" title="Delete">✕</button> 163 - </div> 164 - <input type="text" class="form-question-label" value="${escapeHtml(q.label)}" placeholder="Question text"> 165 - <input type="text" class="form-question-desc" value="${escapeHtml(q.description)}" placeholder="Description (optional)"> 166 - ${optionsHtml} 167 - `; 168 - 169 - // Wire events 170 - el.querySelector('.form-question-label')!.addEventListener('change', (e) => { 171 - form = updateQuestion(form, q.id, { label: (e.target as HTMLInputElement).value }); 172 - syncFormToYjs(); 173 - }); 174 - el.querySelector('.form-question-desc')!.addEventListener('change', (e) => { 175 - form = updateQuestion(form, q.id, { description: (e.target as HTMLInputElement).value }); 176 - syncFormToYjs(); 177 - }); 178 - el.querySelector('.form-question-required')!.addEventListener('change', (e) => { 179 - form = updateQuestion(form, q.id, { required: (e.target as HTMLInputElement).checked }); 180 - syncFormToYjs(); 181 - }); 182 - el.querySelector('.form-question-delete')!.addEventListener('click', () => { 183 - form = removeQuestion(form, q.id); 184 - syncFormToYjs(); 185 - renderBuilder(); 186 - }); 187 - el.querySelector('.form-question-move-up')?.addEventListener('click', () => { 188 - form = moveQuestion(form, q.id, i - 1); 189 - syncFormToYjs(); 190 - renderBuilder(); 191 - }); 192 - el.querySelector('.form-question-move-down')?.addEventListener('click', () => { 193 - form = moveQuestion(form, q.id, i + 1); 194 - syncFormToYjs(); 195 - renderBuilder(); 196 - }); 197 - 198 - // Option events 199 - if (isChoice) { 200 - el.querySelector('.form-add-option')?.addEventListener('click', () => { 201 - form = addOption(form, q.id, `Option ${q.options.length + 1}`); 202 - syncFormToYjs(); 203 - renderBuilder(); 204 - }); 205 - el.querySelectorAll('.form-option-input').forEach(input => { 206 - input.addEventListener('change', (e) => { 207 - const optId = (e.target as HTMLElement).dataset.optionId; 208 - const newLabel = (e.target as HTMLInputElement).value; 209 - const updatedOptions = q.options.map(o => o.id === optId ? { ...o, label: newLabel } : o); 210 - form = updateQuestion(form, q.id, { options: updatedOptions }); 211 - syncFormToYjs(); 212 - }); 213 - }); 214 - el.querySelectorAll('.form-option-remove').forEach(btn => { 215 - btn.addEventListener('click', (e) => { 216 - const optId = (e.target as HTMLElement).dataset.optionId; 217 - const updatedOptions = q.options.filter(o => o.id !== optId); 218 - form = updateQuestion(form, q.id, { options: updatedOptions }); 219 - syncFormToYjs(); 220 - renderBuilder(); 221 - }); 222 - }); 223 - } 224 - 225 - questionsContainer.appendChild(el); 226 - } 227 - } 228 - 229 - // --- Add question dialog --- 230 - function showAddQuestionDialog() { 231 - if (document.querySelector('.add-question-overlay')) return; 232 - const overlay = document.createElement('div'); 233 - overlay.className = 'sheet-dialog-overlay add-question-overlay'; 234 - 235 - overlay.innerHTML = ` 236 - <div class="sheet-dialog" style="max-width:400px"> 237 - <h3>Add Question</h3> 238 - <div class="form-question-type-grid"> 239 - ${QUESTION_TYPES.map(t => `<button class="form-type-btn" data-type="${t.type}"><span class="form-type-icon">${t.icon}</span><span>${t.label}</span></button>`).join('')} 240 - </div> 241 - <div class="sheet-dialog-actions"> 242 - <button id="add-q-cancel">Cancel</button> 243 - </div> 244 - </div> 245 - `; 246 - document.body.appendChild(overlay); 247 - 248 - overlay.querySelector('#add-q-cancel')!.addEventListener('click', () => overlay.remove()); 249 - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 250 - 251 - overlay.querySelectorAll('.form-type-btn').forEach(btn => { 252 - btn.addEventListener('click', () => { 253 - const type = (btn as HTMLElement).dataset.type as QuestionType; 254 - const defaultOpts = ['single_choice', 'multiple_choice', 'dropdown'].includes(type) 255 - ? { options: [{ id: `opt-${Date.now()}-1`, label: 'Option 1' }, { id: `opt-${Date.now()}-2`, label: 'Option 2' }] } 256 - : {}; 257 - form = addQuestion(form, type, '', defaultOpts); 258 - syncFormToYjs(); 259 - overlay.remove(); 260 - renderBuilder(); 261 - }); 262 - }); 95 + renderBuilderQuestions(questionsContainer, builderDeps); 263 96 } 264 97 265 - // --- Preview mode --- 266 - function renderPreview() { 98 + function showPreview() { 267 99 toolbar.style.display = ''; 268 100 questionsContainer.style.display = 'none'; 269 101 previewPane.style.display = ''; 270 102 responsesPane.style.display = 'none'; 271 103 272 - const answers = new Map<string, unknown>(); 273 - const visibleIds = getVisibleQuestions(form.questions.map(q => q.id), condLogic, answers); 274 - 275 - previewPane.innerHTML = ` 276 - <div class="form-preview-container"> 277 - <h2>${escapeHtml(form.title)}</h2> 278 - ${form.description ? `<p class="form-preview-desc">${escapeHtml(form.description)}</p>` : ''} 279 - <div class="form-preview-questions" id="preview-questions"></div> 280 - <div class="form-preview-actions"> 281 - <button class="btn-primary" id="preview-submit">Submit</button> 282 - </div> 283 - </div> 284 - `; 285 - 286 - const questionsEl = previewPane.querySelector('#preview-questions')!; 287 - 288 - for (const q of form.questions) { 289 - if (!visibleIds.includes(q.id)) continue; 290 - 291 - const qEl = document.createElement('div'); 292 - qEl.className = 'form-preview-question'; 293 - 294 - let inputHtml = ''; 295 - switch (q.type) { 296 - case 'short_text': 297 - case 'email': 298 - case 'url': 299 - inputHtml = `<input type="${q.type === 'email' ? 'email' : q.type === 'url' ? 'url' : 'text'}" class="form-preview-input" data-qid="${q.id}" placeholder="Your answer">`; 300 - break; 301 - case 'long_text': 302 - inputHtml = `<textarea class="form-preview-textarea" data-qid="${q.id}" rows="3" placeholder="Your answer"></textarea>`; 303 - break; 304 - case 'number': 305 - inputHtml = `<input type="number" class="form-preview-input" data-qid="${q.id}" placeholder="0">`; 306 - break; 307 - case 'date': 308 - inputHtml = `<input type="date" class="form-preview-input" data-qid="${q.id}">`; 309 - break; 310 - case 'single_choice': 311 - inputHtml = q.options.map(o => `<label class="form-preview-radio"><input type="radio" name="q-${q.id}" value="${o.id}" data-qid="${q.id}"> ${escapeHtml(o.label)}</label>`).join(''); 312 - break; 313 - case 'multiple_choice': 314 - inputHtml = q.options.map(o => `<label class="form-preview-checkbox"><input type="checkbox" value="${o.id}" data-qid="${q.id}"> ${escapeHtml(o.label)}</label>`).join(''); 315 - break; 316 - case 'dropdown': 317 - inputHtml = `<select class="form-preview-select" data-qid="${q.id}"><option value="">Select...</option>${q.options.map(o => `<option value="${o.id}">${escapeHtml(o.label)}</option>`).join('')}</select>`; 318 - break; 319 - case 'rating': 320 - inputHtml = `<div class="form-preview-rating" data-qid="${q.id}">${[1, 2, 3, 4, 5].map(n => `<button class="form-rating-star" data-value="${n}">★</button>`).join('')}</div>`; 321 - break; 322 - case 'scale': { 323 - const min = q.scaleMin ?? 1; 324 - const max = q.scaleMax ?? 10; 325 - inputHtml = `<div class="form-preview-scale" data-qid="${q.id}">${Array.from({ length: max - min + 1 }, (_, i) => `<button class="form-scale-btn" data-value="${min + i}">${min + i}</button>`).join('')}</div>`; 326 - break; 327 - } 328 - } 329 - 330 - qEl.innerHTML = ` 331 - <label class="form-preview-label">${escapeHtml(q.label)}${q.required ? ' <span class="form-required-mark">*</span>' : ''}</label> 332 - ${q.description ? `<p class="form-preview-hint">${escapeHtml(q.description)}</p>` : ''} 333 - ${inputHtml} 334 - <div class="form-preview-error" data-error-qid="${q.id}"></div> 335 - `; 336 - questionsEl.appendChild(qEl); 337 - } 338 - 339 - // Rating star / scale button click handlers 340 - previewPane.querySelectorAll('.form-preview-rating, .form-preview-scale').forEach(container => { 341 - container.addEventListener('click', (e) => { 342 - const btn = (e.target as HTMLElement).closest('[data-value]') as HTMLElement | null; 343 - if (!btn) return; 344 - // Mark selected: remove active from siblings, add to clicked 345 - container.querySelectorAll('[data-value]').forEach(b => b.classList.remove('active')); 346 - btn.classList.add('active'); 347 - (container as HTMLElement).dataset.selectedValue = btn.dataset.value!; 348 - }); 349 - }); 350 - 351 - // Submit handler 352 - previewPane.querySelector('#preview-submit')!.addEventListener('click', () => { 353 - const formAnswers = new Map<string, unknown>(); 354 - 355 - // Collect answers 356 - previewPane.querySelectorAll('[data-qid]').forEach(el => { 357 - const qid = (el as HTMLElement).dataset.qid!; 358 - if (el instanceof HTMLInputElement) { 359 - if (el.type === 'radio') { 360 - if (el.checked) formAnswers.set(qid, el.value); 361 - } else if (el.type === 'checkbox') { 362 - const existing = (formAnswers.get(qid) as string[]) || []; 363 - if (el.checked) existing.push(el.value); 364 - formAnswers.set(qid, existing); 365 - } else { 366 - formAnswers.set(qid, el.value); 367 - } 368 - } else if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) { 369 - formAnswers.set(qid, el.value); 370 - } else if (el instanceof HTMLElement && (el.classList.contains('form-preview-rating') || el.classList.contains('form-preview-scale'))) { 371 - // Rating stars and scale buttons store selection in data-selected-value 372 - const val = el.dataset.selectedValue; 373 - if (val) formAnswers.set(qid, Number(val)); 374 - } 375 - }); 376 - 377 - const errors = validateSubmission(form, formAnswers); 378 - // Show errors 379 - previewPane.querySelectorAll('.form-preview-error').forEach(el => { 380 - const qid = (el as HTMLElement).dataset.errorQid!; 381 - (el as HTMLElement).textContent = errors.get(qid) ?? ''; 382 - }); 383 - 384 - if (errors.size === 0) { 385 - const response = createResponse(form.id, formAnswers); 386 - // Convert Map to plain object for JSON serialization (Map serializes as {}) 387 - const serializable = { ...response, answers: Object.fromEntries(response.answers) }; 388 - yResponses.push([JSON.stringify(serializable)]); 389 - previewPane.innerHTML = '<div style="text-align:center;padding:3rem"><h2>Response submitted!</h2><p>Your response has been recorded.</p></div>'; 390 - } 104 + renderPreview(previewPane, { 105 + getForm: () => form, 106 + getCondLogic: () => condLogic, 107 + yResponses, 391 108 }); 392 109 } 393 110 394 - // --- Responses view --- 395 - function renderResponses() { 111 + function showResponses() { 396 112 toolbar.style.display = ''; 397 113 questionsContainer.style.display = 'none'; 398 114 previewPane.style.display = 'none'; 399 115 responsesPane.style.display = ''; 400 116 401 - const responses: FormResponse[] = []; 402 - for (let i = 0; i < yResponses.length; i++) { 403 - try { 404 - const r = JSON.parse(yResponses.get(i) as string); 405 - r.answers = new Map(Object.entries(r.answers)); 406 - responses.push(r); 407 - } catch { /* skip invalid */ } 408 - } 409 - 410 - if (responses.length === 0) { 411 - responsesPane.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--color-text-secondary)">No responses yet.</div>'; 412 - return; 413 - } 414 - 415 - const config = createPipelineConfig( 416 - form.id, 417 - form.targetSheetId ?? '', 418 - form.questions.map(q => q.id), 419 - form.questions.map(q => q.label || 'Untitled'), 420 - ); 421 - 422 - const headers = pipelineHeaders(config); 423 - const rows = responses.map(r => responseToRow(r, config)); 424 - 425 - let tableHtml = '<table class="pivot-table"><thead><tr>'; 426 - for (const h of headers) tableHtml += `<th>${escapeHtml(String(h))}</th>`; 427 - tableHtml += '</tr></thead><tbody>'; 428 - for (const row of rows) { 429 - tableHtml += '<tr>'; 430 - for (const cell of row) tableHtml += `<td>${escapeHtml(String(cell ?? ''))}</td>`; 431 - tableHtml += '</tr>'; 432 - } 433 - tableHtml += '</tbody></table>'; 434 - 435 - responsesPane.innerHTML = ` 436 - <div style="padding:var(--space-md)"> 437 - <h3>${responses.length} Response${responses.length === 1 ? '' : 's'}</h3> 438 - <div style="overflow-x:auto">${tableHtml}</div> 439 - </div> 440 - `; 117 + renderResponses(responsesPane, { 118 + getForm: () => form, 119 + yResponses, 120 + }); 441 121 } 442 122 443 123 // --- Toolbar events --- 444 - document.getElementById('btn-add-question')!.addEventListener('click', showAddQuestionDialog); 124 + document.getElementById('btn-add-question')!.addEventListener('click', () => { 125 + showAddQuestionDialog(builderDeps, renderBuilder); 126 + }); 445 127 446 128 document.getElementById('btn-preview')!.addEventListener('click', () => { 447 129 mode = mode === 'preview' ? 'builder' : 'preview'; 448 - if (mode === 'preview') renderPreview(); 130 + if (mode === 'preview') showPreview(); 449 131 else renderBuilder(); 450 132 }); 451 133 452 134 document.getElementById('btn-responses')!.addEventListener('click', () => { 453 135 mode = mode === 'responses' ? 'builder' : 'responses'; 454 - if (mode === 'responses') renderResponses(); 136 + if (mode === 'responses') showResponses(); 455 137 else renderBuilder(); 456 138 }); 457 139 ··· 479 161 if (mode === 'builder') renderBuilder(); 480 162 }); 481 163 482 - // ── AI Chat Panel ──────────────────────────────────────────────────────── 483 - 164 + // --- AI Chat Panel --- 484 165 const chatUI = createChatSidebar(); 485 166 document.getElementById('main-content')!.appendChild(chatUI.container); 486 167 487 168 const chatState = createChatState(); 488 169 170 + const chatDeps: ChatPanelDeps = { 171 + getForm: () => form, 172 + setForm: (f: FormSchema) => { form = f; }, 173 + syncToYjs: syncFormToYjs, 174 + renderBuilder, 175 + chatUI, 176 + chatState, 177 + chatWiring: null!, // assigned below after initChatWiring 178 + titleInput, 179 + }; 180 + 489 181 const chatWiring = initChatWiring({ 490 182 chatUI, 491 183 chatState, 492 184 chatConfig: loadConfig(), 493 185 toggleBtn: document.getElementById('btn-ai-chat')!, 494 186 editorType: 'form', 495 - onSend: sendChatMessage, 187 + onSend: () => sendChatMessage(chatDeps), 496 188 }); 497 189 498 - function getFormContextText(): string { 499 - const lines: string[] = []; 500 - lines.push(`Title: ${form.title}`); 501 - if (form.description) lines.push(`Description: ${form.description}`); 502 - form.questions.forEach((q, i) => { 503 - let line = `${i + 1}. [${q.type}] "${q.label}"`; 504 - if (q.required) line += ' (required)'; 505 - if (q.options.length > 0) line += ` — options: ${q.options.map(o => o.label).join(', ')}`; 506 - lines.push(line); 507 - }); 508 - return lines.join('\n'); 509 - } 510 - 511 - async function sendChatMessage(): Promise<void> { 512 - const text = chatUI.input.value.trim(); 513 - if (!text || chatState.loading) return; 514 - 515 - const cfg = chatWiring.getConfig(); 516 - if (!isConfigured(cfg)) { 517 - chatUI.settingsPanel.style.display = ''; 518 - chatUI.endpointInput.focus(); 519 - return; 520 - } 521 - 522 - const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 523 - chatState.messages.push(userMsg); 524 - appendMessage(chatUI.messageList, userMsg); 525 - 526 - chatUI.input.value = ''; 527 - chatUI.input.style.height = ''; 528 - chatUI.sendBtn.style.display = 'none'; 529 - chatUI.stopBtn.style.display = ''; 530 - chatState.loading = true; 531 - chatState.error = null; 532 - 533 - const title = titleInput.value.trim() || 'Untitled Form'; 534 - const includeContext = chatUI.contextToggle.checked; 535 - const actionsEnabled = chatUI.actionsToggle.checked; 536 - const contextText = includeContext ? getFormContextText() : ''; 537 - 538 - const systemPrompt = buildSystemMessage(title, contextText, { 539 - editorType: 'form', 540 - actionsEnabled, 541 - }); 542 - 543 - const formDeps = { 544 - getForm: () => form, 545 - setForm: (f: FormSchema) => { form = f; }, 546 - syncToYjs: syncFormToYjs, 547 - render: renderBuilder, 548 - }; 549 - 550 - const abortController = new AbortController(); 551 - chatState.abortController = abortController; 552 - const bubble = appendStreamingBubble(chatUI.messageList); 553 - let fullText = ''; 554 - 555 - await streamChat( 556 - cfg, 557 - chatState.messages, 558 - systemPrompt, 559 - { 560 - onChunk(chunk) { 561 - fullText += chunk; 562 - bubble.update(renderMarkdown(fullText)); 563 - }, 564 - onDone(doneText) { 565 - if (doneText) { 566 - chatState.messages.push({ role: 'assistant', content: doneText, ts: Date.now() }); 567 - 568 - if (actionsEnabled) { 569 - const { displayText, actions } = splitResponse(doneText); 570 - if (actions.length > 0) { 571 - bubble.update(renderMarkdown(displayText)); 572 - for (const action of actions) { 573 - if (!isFormAction(action)) continue; 574 - appendActionCard(chatUI.messageList, action, { 575 - onApply: (a) => { 576 - const result = executeFormAction(a as Parameters<typeof executeFormAction>[0], formDeps); 577 - if (!result.success && result.error) { 578 - appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 579 - } 580 - }, 581 - onDismiss: () => {}, 582 - }); 583 - } 584 - } 585 - } 586 - } 587 - }, 588 - onError(err) { 589 - chatState.error = err; 590 - bubble.el.classList.add('ai-chat-bubble--error'); 591 - bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 592 - }, 593 - }, 594 - abortController.signal, 595 - ); 190 + chatDeps.chatWiring = chatWiring; 596 191 597 - chatState.loading = false; 598 - chatState.abortController = null; 599 - chatUI.sendBtn.style.display = ''; 600 - chatUI.stopBtn.style.display = 'none'; 601 - } 192 + // --- Command Palette --- 193 + createCommandPalette({ 194 + actions: [ 195 + { id: 'back', label: 'Back to Documents', category: 'action', icon: '\u2190', action: () => { window.location.href = '/'; } }, 196 + { id: 'new-form', label: 'New Form', category: 'action', icon: '\u2637', action: () => { window.open('/', '_blank'); } }, 197 + { id: 'add-question', label: 'Add Question', category: 'action', icon: '+', action: () => { document.getElementById('btn-add-question')?.click(); } }, 198 + { id: 'preview', label: 'Preview Form', category: 'action', icon: '\u25b7', action: () => { document.getElementById('btn-preview')?.click(); } }, 199 + { id: 'responses', label: 'View Responses', category: 'action', icon: '\u25a6', action: () => { document.getElementById('btn-responses')?.click(); } }, 200 + { id: 'settings', label: 'Form Settings', category: 'action', icon: '\u2699', action: () => { document.getElementById('btn-settings')?.click(); } }, 201 + ], 202 + }); 602 203 603 204 // --- Initialize --- 604 205 async function init() { ··· 619 220 renderBuilder(); 620 221 } 621 222 } 622 - 623 - // --- Command Palette --- 624 - import { createCommandPalette } from '../command-palette.js'; 625 - createCommandPalette({ 626 - actions: [ 627 - { id: 'back', label: 'Back to Documents', category: 'action', icon: '\u2190', action: () => { window.location.href = '/'; } }, 628 - { id: 'new-form', label: 'New Form', category: 'action', icon: '\u2637', action: () => { window.open('/', '_blank'); } }, 629 - { id: 'add-question', label: 'Add Question', category: 'action', icon: '+', action: () => { document.getElementById('btn-add-question')?.click(); } }, 630 - { id: 'preview', label: 'Preview Form', category: 'action', icon: '\u25b7', action: () => { document.getElementById('btn-preview')?.click(); } }, 631 - { id: 'responses', label: 'View Responses', category: 'action', icon: '\u25a6', action: () => { document.getElementById('btn-responses')?.click(); } }, 632 - { id: 'settings', label: 'Form Settings', category: 'action', icon: '\u2699', action: () => { document.getElementById('btn-settings')?.click(); } }, 633 - ], 634 - }); 635 223 636 224 init();
+35
src/forms/question-types.ts
··· 1 + /** 2 + * Question type definitions — display labels and icons for the type picker. 3 + */ 4 + 5 + import type { QuestionType } from './form-builder.js'; 6 + 7 + export interface QuestionTypeInfo { 8 + type: QuestionType; 9 + label: string; 10 + icon: string; 11 + } 12 + 13 + export const QUESTION_TYPES: QuestionTypeInfo[] = [ 14 + { type: 'short_text', label: 'Short Text', icon: 'Aa' }, 15 + { type: 'long_text', label: 'Long Text', icon: '\u00B6' }, 16 + { type: 'number', label: 'Number', icon: '#' }, 17 + { type: 'email', label: 'Email', icon: '@' }, 18 + { type: 'single_choice', label: 'Single Choice', icon: '\u25CB' }, 19 + { type: 'multiple_choice', label: 'Multiple Choice', icon: '\u2610' }, 20 + { type: 'dropdown', label: 'Dropdown', icon: '\u25BE' }, 21 + { type: 'date', label: 'Date', icon: '\uD83D\uDCC5' }, 22 + { type: 'rating', label: 'Rating', icon: '\u2605' }, 23 + { type: 'scale', label: 'Scale', icon: '\u229E' }, 24 + ]; 25 + 26 + /** Question types that use an options list */ 27 + export const CHOICE_TYPES: QuestionType[] = ['single_choice', 'multiple_choice', 'dropdown']; 28 + 29 + export function isChoiceType(type: QuestionType): boolean { 30 + return CHOICE_TYPES.includes(type); 31 + } 32 + 33 + export function labelForType(type: QuestionType): string { 34 + return QUESTION_TYPES.find(t => t.type === type)?.label ?? type; 35 + }
+196
src/forms/render-builder.ts
··· 1 + /** 2 + * Form Builder Renderer — renders the question editor UI. 3 + * 4 + * Pure rendering module: builds DOM for question cards and the add-question dialog. 5 + * Delegates state mutations back to main via the BuilderDeps interface. 6 + */ 7 + 8 + import type { FormSchema, QuestionType } from './form-builder.js'; 9 + import { 10 + addQuestion, 11 + removeQuestion, 12 + updateQuestion, 13 + moveQuestion, 14 + addOption, 15 + } from './form-builder.js'; 16 + import { QUESTION_TYPES, isChoiceType, labelForType } from './question-types.js'; 17 + import { escapeHtml } from '../lib/ai-chat.js'; 18 + 19 + export interface BuilderDeps { 20 + getForm: () => FormSchema; 21 + setForm: (form: FormSchema) => void; 22 + syncToYjs: () => void; 23 + } 24 + 25 + /** 26 + * Render the builder question list into the given container. 27 + */ 28 + export function renderBuilderQuestions( 29 + container: HTMLElement, 30 + deps: BuilderDeps, 31 + ): void { 32 + const form = deps.getForm(); 33 + container.innerHTML = ''; 34 + 35 + if (form.questions.length === 0) { 36 + container.innerHTML = 37 + '<div style="text-align:center;padding:2rem;color:var(--color-text-secondary)">No questions yet. Click "+ Add Question" to get started.</div>'; 38 + return; 39 + } 40 + 41 + for (let i = 0; i < form.questions.length; i++) { 42 + const q = form.questions[i]!; 43 + const el = createQuestionCard(q, i, form.questions.length, deps); 44 + container.appendChild(el); 45 + } 46 + } 47 + 48 + function createQuestionCard( 49 + q: FormSchema['questions'][number], 50 + index: number, 51 + total: number, 52 + deps: BuilderDeps, 53 + ): HTMLElement { 54 + const el = document.createElement('div'); 55 + el.className = 'form-question-card'; 56 + el.dataset.questionId = q.id; 57 + 58 + const typeLabel = labelForType(q.type); 59 + const isChoice = isChoiceType(q.type); 60 + 61 + let optionsHtml = ''; 62 + if (isChoice) { 63 + optionsHtml = '<div class="form-question-options">'; 64 + for (const opt of q.options) { 65 + optionsHtml += `<div class="form-option-row"><input type="text" value="${escapeHtml(opt.label)}" data-option-id="${opt.id}" class="form-option-input" placeholder="Option label"><button class="form-option-remove" data-option-id="${opt.id}" title="Remove">\u2715</button></div>`; 66 + } 67 + optionsHtml += `<button class="form-add-option" data-question-id="${q.id}">+ Add option</button></div>`; 68 + } 69 + 70 + el.innerHTML = ` 71 + <div class="form-question-header"> 72 + <span class="form-question-number">${index + 1}</span> 73 + <span class="form-question-type-badge">${typeLabel}</span> 74 + <span style="flex:1"></span> 75 + <label class="form-question-required-label"><input type="checkbox" class="form-question-required" ${q.required ? 'checked' : ''}> Required</label> 76 + <button class="form-question-move-up" title="Move up" ${index === 0 ? 'disabled' : ''}>\u2191</button> 77 + <button class="form-question-move-down" title="Move down" ${index === total - 1 ? 'disabled' : ''}>\u2193</button> 78 + <button class="form-question-delete" title="Delete">\u2715</button> 79 + </div> 80 + <input type="text" class="form-question-label" value="${escapeHtml(q.label)}" placeholder="Question text"> 81 + <input type="text" class="form-question-desc" value="${escapeHtml(q.description)}" placeholder="Description (optional)"> 82 + ${optionsHtml} 83 + `; 84 + 85 + wireQuestionEvents(el, q, index, deps); 86 + if (isChoice) wireOptionEvents(el, q, deps); 87 + 88 + return el; 89 + } 90 + 91 + function wireQuestionEvents( 92 + el: HTMLElement, 93 + q: FormSchema['questions'][number], 94 + index: number, 95 + deps: BuilderDeps, 96 + ): void { 97 + const rerender = () => renderBuilderQuestions(el.parentElement!, deps); 98 + 99 + el.querySelector('.form-question-label')!.addEventListener('change', (e) => { 100 + deps.setForm(updateQuestion(deps.getForm(), q.id, { label: (e.target as HTMLInputElement).value })); 101 + deps.syncToYjs(); 102 + }); 103 + el.querySelector('.form-question-desc')!.addEventListener('change', (e) => { 104 + deps.setForm(updateQuestion(deps.getForm(), q.id, { description: (e.target as HTMLInputElement).value })); 105 + deps.syncToYjs(); 106 + }); 107 + el.querySelector('.form-question-required')!.addEventListener('change', (e) => { 108 + deps.setForm(updateQuestion(deps.getForm(), q.id, { required: (e.target as HTMLInputElement).checked })); 109 + deps.syncToYjs(); 110 + }); 111 + el.querySelector('.form-question-delete')!.addEventListener('click', () => { 112 + deps.setForm(removeQuestion(deps.getForm(), q.id)); 113 + deps.syncToYjs(); 114 + rerender(); 115 + }); 116 + el.querySelector('.form-question-move-up')?.addEventListener('click', () => { 117 + deps.setForm(moveQuestion(deps.getForm(), q.id, index - 1)); 118 + deps.syncToYjs(); 119 + rerender(); 120 + }); 121 + el.querySelector('.form-question-move-down')?.addEventListener('click', () => { 122 + deps.setForm(moveQuestion(deps.getForm(), q.id, index + 1)); 123 + deps.syncToYjs(); 124 + rerender(); 125 + }); 126 + } 127 + 128 + function wireOptionEvents( 129 + el: HTMLElement, 130 + q: FormSchema['questions'][number], 131 + deps: BuilderDeps, 132 + ): void { 133 + const rerender = () => renderBuilderQuestions(el.parentElement!, deps); 134 + 135 + el.querySelector('.form-add-option')?.addEventListener('click', () => { 136 + deps.setForm(addOption(deps.getForm(), q.id, `Option ${q.options.length + 1}`)); 137 + deps.syncToYjs(); 138 + rerender(); 139 + }); 140 + el.querySelectorAll('.form-option-input').forEach(input => { 141 + input.addEventListener('change', (e) => { 142 + const optId = (e.target as HTMLElement).dataset.optionId; 143 + const newLabel = (e.target as HTMLInputElement).value; 144 + const updatedOptions = q.options.map(o => o.id === optId ? { ...o, label: newLabel } : o); 145 + deps.setForm(updateQuestion(deps.getForm(), q.id, { options: updatedOptions })); 146 + deps.syncToYjs(); 147 + }); 148 + }); 149 + el.querySelectorAll('.form-option-remove').forEach(btn => { 150 + btn.addEventListener('click', (e) => { 151 + const optId = (e.target as HTMLElement).dataset.optionId; 152 + const updatedOptions = q.options.filter(o => o.id !== optId); 153 + deps.setForm(updateQuestion(deps.getForm(), q.id, { options: updatedOptions })); 154 + deps.syncToYjs(); 155 + rerender(); 156 + }); 157 + }); 158 + } 159 + 160 + /** 161 + * Show the add-question type picker dialog. 162 + */ 163 + export function showAddQuestionDialog(deps: BuilderDeps, onAdded: () => void): void { 164 + if (document.querySelector('.add-question-overlay')) return; 165 + const overlay = document.createElement('div'); 166 + overlay.className = 'sheet-dialog-overlay add-question-overlay'; 167 + 168 + overlay.innerHTML = ` 169 + <div class="sheet-dialog" style="max-width:400px"> 170 + <h3>Add Question</h3> 171 + <div class="form-question-type-grid"> 172 + ${QUESTION_TYPES.map(t => `<button class="form-type-btn" data-type="${t.type}"><span class="form-type-icon">${t.icon}</span><span>${t.label}</span></button>`).join('')} 173 + </div> 174 + <div class="sheet-dialog-actions"> 175 + <button id="add-q-cancel">Cancel</button> 176 + </div> 177 + </div> 178 + `; 179 + document.body.appendChild(overlay); 180 + 181 + overlay.querySelector('#add-q-cancel')!.addEventListener('click', () => overlay.remove()); 182 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 183 + 184 + overlay.querySelectorAll('.form-type-btn').forEach(btn => { 185 + btn.addEventListener('click', () => { 186 + const type = (btn as HTMLElement).dataset.type as QuestionType; 187 + const defaultOpts = isChoiceType(type) 188 + ? { options: [{ id: `opt-${Date.now()}-1`, label: 'Option 1' }, { id: `opt-${Date.now()}-2`, label: 'Option 2' }] } 189 + : {}; 190 + deps.setForm(addQuestion(deps.getForm(), type, '', defaultOpts)); 191 + deps.syncToYjs(); 192 + overlay.remove(); 193 + onAdded(); 194 + }); 195 + }); 196 + }
+150
src/forms/render-preview.ts
··· 1 + /** 2 + * Form Preview Renderer — renders the live preview and handles submission. 3 + * 4 + * Pure rendering module: builds preview DOM, collects answers, validates, submits. 5 + * Delegates state access and Yjs writes back via the PreviewDeps interface. 6 + */ 7 + 8 + import * as Y from 'yjs'; 9 + import type { FormSchema } from './form-builder.js'; 10 + import { validateSubmission } from './form-builder.js'; 11 + import { getVisibleQuestions, type ConditionalLogicState } from './conditional-logic.js'; 12 + import { createResponse, type FormResponse } from './responses.js'; 13 + import { escapeHtml } from '../lib/ai-chat.js'; 14 + 15 + export interface PreviewDeps { 16 + getForm: () => FormSchema; 17 + getCondLogic: () => ConditionalLogicState; 18 + yResponses: Y.Array<string>; 19 + } 20 + 21 + /** 22 + * Render the form preview into the given pane element. 23 + */ 24 + export function renderPreview(pane: HTMLElement, deps: PreviewDeps): void { 25 + const form = deps.getForm(); 26 + const condLogic = deps.getCondLogic(); 27 + const answers = new Map<string, unknown>(); 28 + const visibleIds = getVisibleQuestions(form.questions.map(q => q.id), condLogic, answers); 29 + 30 + pane.innerHTML = ` 31 + <div class="form-preview-container"> 32 + <h2>${escapeHtml(form.title)}</h2> 33 + ${form.description ? `<p class="form-preview-desc">${escapeHtml(form.description)}</p>` : ''} 34 + <div class="form-preview-questions" id="preview-questions"></div> 35 + <div class="form-preview-actions"> 36 + <button class="btn-primary" id="preview-submit">Submit</button> 37 + </div> 38 + </div> 39 + `; 40 + 41 + const questionsEl = pane.querySelector('#preview-questions')!; 42 + 43 + for (const q of form.questions) { 44 + if (!visibleIds.includes(q.id)) continue; 45 + 46 + const qEl = document.createElement('div'); 47 + qEl.className = 'form-preview-question'; 48 + 49 + const inputHtml = renderQuestionInput(q); 50 + 51 + qEl.innerHTML = ` 52 + <label class="form-preview-label">${escapeHtml(q.label)}${q.required ? ' <span class="form-required-mark">*</span>' : ''}</label> 53 + ${q.description ? `<p class="form-preview-hint">${escapeHtml(q.description)}</p>` : ''} 54 + ${inputHtml} 55 + <div class="form-preview-error" data-error-qid="${q.id}"></div> 56 + `; 57 + questionsEl.appendChild(qEl); 58 + } 59 + 60 + wireRatingAndScaleButtons(pane); 61 + wireSubmitHandler(pane, deps); 62 + } 63 + 64 + function renderQuestionInput(q: FormSchema['questions'][number]): string { 65 + switch (q.type) { 66 + case 'short_text': 67 + case 'email': 68 + case 'url': 69 + return `<input type="${q.type === 'email' ? 'email' : q.type === 'url' ? 'url' : 'text'}" class="form-preview-input" data-qid="${q.id}" placeholder="Your answer">`; 70 + case 'long_text': 71 + return `<textarea class="form-preview-textarea" data-qid="${q.id}" rows="3" placeholder="Your answer"></textarea>`; 72 + case 'number': 73 + return `<input type="number" class="form-preview-input" data-qid="${q.id}" placeholder="0">`; 74 + case 'date': 75 + return `<input type="date" class="form-preview-input" data-qid="${q.id}">`; 76 + case 'single_choice': 77 + return q.options.map(o => `<label class="form-preview-radio"><input type="radio" name="q-${q.id}" value="${o.id}" data-qid="${q.id}"> ${escapeHtml(o.label)}</label>`).join(''); 78 + case 'multiple_choice': 79 + return q.options.map(o => `<label class="form-preview-checkbox"><input type="checkbox" value="${o.id}" data-qid="${q.id}"> ${escapeHtml(o.label)}</label>`).join(''); 80 + case 'dropdown': 81 + return `<select class="form-preview-select" data-qid="${q.id}"><option value="">Select...</option>${q.options.map(o => `<option value="${o.id}">${escapeHtml(o.label)}</option>`).join('')}</select>`; 82 + case 'rating': 83 + return `<div class="form-preview-rating" data-qid="${q.id}">${[1, 2, 3, 4, 5].map(n => `<button class="form-rating-star" data-value="${n}">\u2605</button>`).join('')}</div>`; 84 + case 'scale': { 85 + const min = q.scaleMin ?? 1; 86 + const max = q.scaleMax ?? 10; 87 + return `<div class="form-preview-scale" data-qid="${q.id}">${Array.from({ length: max - min + 1 }, (_, i) => `<button class="form-scale-btn" data-value="${min + i}">${min + i}</button>`).join('')}</div>`; 88 + } 89 + default: 90 + return ''; 91 + } 92 + } 93 + 94 + function wireRatingAndScaleButtons(pane: HTMLElement): void { 95 + pane.querySelectorAll('.form-preview-rating, .form-preview-scale').forEach(container => { 96 + container.addEventListener('click', (e) => { 97 + const btn = (e.target as HTMLElement).closest('[data-value]') as HTMLElement | null; 98 + if (!btn) return; 99 + container.querySelectorAll('[data-value]').forEach(b => b.classList.remove('active')); 100 + btn.classList.add('active'); 101 + (container as HTMLElement).dataset.selectedValue = btn.dataset.value!; 102 + }); 103 + }); 104 + } 105 + 106 + function collectAnswers(pane: HTMLElement): Map<string, unknown> { 107 + const formAnswers = new Map<string, unknown>(); 108 + 109 + pane.querySelectorAll('[data-qid]').forEach(el => { 110 + const qid = (el as HTMLElement).dataset.qid!; 111 + if (el instanceof HTMLInputElement) { 112 + if (el.type === 'radio') { 113 + if (el.checked) formAnswers.set(qid, el.value); 114 + } else if (el.type === 'checkbox') { 115 + const existing = (formAnswers.get(qid) as string[]) || []; 116 + if (el.checked) existing.push(el.value); 117 + formAnswers.set(qid, existing); 118 + } else { 119 + formAnswers.set(qid, el.value); 120 + } 121 + } else if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) { 122 + formAnswers.set(qid, el.value); 123 + } else if (el instanceof HTMLElement && (el.classList.contains('form-preview-rating') || el.classList.contains('form-preview-scale'))) { 124 + const val = el.dataset.selectedValue; 125 + if (val) formAnswers.set(qid, Number(val)); 126 + } 127 + }); 128 + 129 + return formAnswers; 130 + } 131 + 132 + function wireSubmitHandler(pane: HTMLElement, deps: PreviewDeps): void { 133 + pane.querySelector('#preview-submit')!.addEventListener('click', () => { 134 + const form = deps.getForm(); 135 + const formAnswers = collectAnswers(pane); 136 + 137 + const errors = validateSubmission(form, formAnswers); 138 + pane.querySelectorAll('.form-preview-error').forEach(el => { 139 + const qid = (el as HTMLElement).dataset.errorQid!; 140 + (el as HTMLElement).textContent = errors.get(qid) ?? ''; 141 + }); 142 + 143 + if (errors.size === 0) { 144 + const response = createResponse(form.id, formAnswers); 145 + const serializable = { ...response, answers: Object.fromEntries(response.answers) }; 146 + deps.yResponses.push([JSON.stringify(serializable)]); 147 + pane.innerHTML = '<div style="text-align:center;padding:3rem"><h2>Response submitted!</h2><p>Your response has been recorded.</p></div>'; 148 + } 149 + }); 150 + }
+73
src/forms/render-responses.ts
··· 1 + /** 2 + * Form Responses Renderer — renders the responses table view. 3 + * 4 + * Pure rendering module: parses stored responses and builds a summary table. 5 + */ 6 + 7 + import * as Y from 'yjs'; 8 + import type { FormSchema } from './form-builder.js'; 9 + import { 10 + createPipelineConfig, 11 + responseToRow, 12 + pipelineHeaders, 13 + type FormResponse, 14 + } from './responses.js'; 15 + import { escapeHtml } from '../lib/ai-chat.js'; 16 + 17 + export interface ResponsesDeps { 18 + getForm: () => FormSchema; 19 + yResponses: Y.Array<string>; 20 + } 21 + 22 + /** 23 + * Render the responses table into the given pane element. 24 + */ 25 + export function renderResponses(pane: HTMLElement, deps: ResponsesDeps): void { 26 + const form = deps.getForm(); 27 + const responses = parseResponses(deps.yResponses); 28 + 29 + if (responses.length === 0) { 30 + pane.innerHTML = 31 + '<div style="text-align:center;padding:2rem;color:var(--color-text-secondary)">No responses yet.</div>'; 32 + return; 33 + } 34 + 35 + const config = createPipelineConfig( 36 + form.id, 37 + form.targetSheetId ?? '', 38 + form.questions.map(q => q.id), 39 + form.questions.map(q => q.label || 'Untitled'), 40 + ); 41 + 42 + const headers = pipelineHeaders(config); 43 + const rows = responses.map(r => responseToRow(r, config)); 44 + 45 + let tableHtml = '<table class="pivot-table"><thead><tr>'; 46 + for (const h of headers) tableHtml += `<th>${escapeHtml(String(h))}</th>`; 47 + tableHtml += '</tr></thead><tbody>'; 48 + for (const row of rows) { 49 + tableHtml += '<tr>'; 50 + for (const cell of row) tableHtml += `<td>${escapeHtml(String(cell ?? ''))}</td>`; 51 + tableHtml += '</tr>'; 52 + } 53 + tableHtml += '</tbody></table>'; 54 + 55 + pane.innerHTML = ` 56 + <div style="padding:var(--space-md)"> 57 + <h3>${responses.length} Response${responses.length === 1 ? '' : 's'}</h3> 58 + <div style="overflow-x:auto">${tableHtml}</div> 59 + </div> 60 + `; 61 + } 62 + 63 + function parseResponses(yResponses: Y.Array<string>): FormResponse[] { 64 + const responses: FormResponse[] = []; 65 + for (let i = 0; i < yResponses.length; i++) { 66 + try { 67 + const r = JSON.parse(yResponses.get(i) as string); 68 + r.answers = new Map(Object.entries(r.answers)); 69 + responses.push(r); 70 + } catch { /* skip invalid */ } 71 + } 72 + return responses; 73 + }