diff --git a/tables/js/context.js b/tables/js/context.js index 6f9d6c8..296be1e 100644 --- a/tables/js/context.js +++ b/tables/js/context.js @@ -84,6 +84,22 @@ throw new Error('Spec table.yaml missing columns[]'); } + // Optional row schema from /form.yaml — same JSON Schema + // the form-mode renderer uses. Phase 2 derives per-cell editor + // widgets from it (text/number/date/select/checkbox). + // Best-effort: a directory with only table.yaml still renders + // as a sortable/filterable table; cells fall back to plain + // text inputs without per-property hints. + let rowSchema = null; + try { + const formSpec = await readYaml(dir, 'form.yaml'); + if (formSpec && formSpec.schema) { + rowSchema = formSpec.schema; + } + } catch (_) { + // form.yaml missing or unreadable; carry on without it. + } + // Rows are every *.yaml in EXCEPT the spec // (table.yaml) and the row-edit form (form.yaml). They live // in the same directory by design — copying the directory @@ -95,6 +111,7 @@ description: spec.description, columns: spec.columns, defaults: spec.defaults, + rowSchema: rowSchema, rows: rows }; } diff --git a/tables/js/editor.js b/tables/js/editor.js index 83bfb79..f28d2a2 100644 --- a/tables/js/editor.js +++ b/tables/js/editor.js @@ -189,45 +189,47 @@ const col = colAt(c); if (!row || !col) return; - const currentText = (initial != null) - ? String(initial) - : (effectiveCellValue(row, col) == null ? '' : String(effectiveCellValue(row, col))); + const propSchema = propertySchemaFor(col); - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'zddc-table__cell-input'; - input.value = currentText; - input.setAttribute('aria-label', 'Edit ' + (col.title || col.field)); + // Complex-type cells (nested object, generic array, oneOf) + // can't be inline-edited cleanly — punt to the row's form + // editor in a side panel / new page. Phase 2 ships the + // navigation; Phase 5 may add a side-panel mount. + if (isComplexSchema(propSchema)) { + navigateToRowForm(row); + return; + } - // Replace the cell's text content with the input. We don't - // wipe innerHTML — preserves any error-marker spans Phase 2 - // adds — but wrap the input in a way that overlays the text. - // For now: stash the original text in dataset, swap in input. + const currentValue = effectiveCellValue(row, col); + const widget = makeWidget(propSchema, col, initial != null ? initial : currentValue); + const inputEl = widget.element; + inputEl.classList.add('zddc-table__cell-input'); + inputEl.setAttribute('aria-label', 'Edit ' + (col.title || col.field)); + + // Replace the cell's text content with the editor widget. + // Stash the original text in dataset so cancel can restore it + // verbatim without re-running the formatCell logic. cell.setAttribute('data-display', cell.textContent || ''); cell.textContent = ''; - cell.appendChild(input); - input.focus(); - // If user pressed Enter/F2, position cursor at end. If they - // started typing a printable char, that char already replaced - // the value; cursor is at end naturally. - try { input.setSelectionRange(input.value.length, input.value.length); } - catch (_) { /* type=text supports it; defensive */ } + cell.appendChild(inputEl); + widget.focus(); app.state.editing = true; function commit() { if (!app.state.editing) return; - const newValue = input.value; + const newValue = widget.getValue(); const oldRaw = app.modules.util.resolveField(row.data, col.field); - const oldStr = oldRaw == null ? '' : String(oldRaw); - if (newValue === oldStr) { - // No change — clear any draft entry for this field - // so we don't show a "dirty" badge for a no-op edit. + // Compare by JSON-string equality so number 42 == "42" + // entered into a number input doesn't false-positive as + // a change. resolveField already returns the raw typed + // value from row.data. + if (sameValue(oldRaw, newValue)) { clearDraftField(rowKey(row), col.field); } else { - setDraft(rowKey(row), col.field, coerceForSchema(newValue, col)); + setDraft(rowKey(row), col.field, newValue); } - tearDown(coerceForSchema(newValue, col)); + tearDown(newValue); } function cancel() { @@ -235,9 +237,9 @@ } function tearDown(displayValue) { - input.removeEventListener('keydown', onKey); - input.removeEventListener('blur', onBlur); - const display = (displayValue != null) + inputEl.removeEventListener('keydown', onKey); + inputEl.removeEventListener('blur', onBlur); + const display = (displayValue !== undefined && displayValue !== null) ? renderableText(displayValue, col) : (cell.getAttribute('data-display') || ''); cell.removeAttribute('data-display'); @@ -275,26 +277,242 @@ } } - input.addEventListener('keydown', onKey); - input.addEventListener('blur', onBlur); - } - - function coerceForSchema(text, col) { - // Phase 1 stores raw strings as drafts. Phase 2 will type-coerce - // here based on the row schema (integer→Number, boolean→bool, - // etc.). Until then, also handle the obvious case so number - // columns don't display "42" as a string in the table. - if (col.format === 'number' || col.format === 'integer') { - const n = Number(text); - if (!Number.isNaN(n) && text.trim() !== '') return n; - } - return text; + inputEl.addEventListener('keydown', onKey); + inputEl.addEventListener('blur', onBlur); } function renderableText(value, col) { return app.modules.util.formatCell(value, col.format); } + // --- Schema → editor widget factory -------------------------------- + + function propertySchemaFor(col) { + // Walk the row schema for this column's field. Returns null + // when no schema is present (best-effort: cells fall back to + // plain text editors). Supports a single dot-separated path + // — `properties.a.properties.b` for `field: "a.b"` — to mirror + // the existing util.resolveField conventions. + const ctx = app.context || {}; + if (!ctx.rowSchema) return null; + const parts = String(col.field || '').split('.').filter(Boolean); + let s = ctx.rowSchema; + for (let i = 0; i < parts.length; i++) { + if (!s || !s.properties || !s.properties[parts[i]]) return null; + s = s.properties[parts[i]]; + } + return s; + } + + function isComplexSchema(s) { + if (!s) return false; + if (Array.isArray(s.oneOf) && s.oneOf.length > 0) return true; + if (Array.isArray(s.anyOf) && s.anyOf.length > 0) return true; + if (Array.isArray(s.allOf) && s.allOf.length > 0) return true; + if (s.type === 'object') return true; + if (s.type === 'array') { + // Multi-select-friendly arrays (string-enum + uniqueItems) + // get inline editing; everything else is complex. + const items = s.items || {}; + const isMultiSelect = items.type === 'string' + && Array.isArray(items.enum) && items.enum.length > 0 + && s.uniqueItems === true; + return !isMultiSelect; + } + return false; + } + + function makeWidget(propSchema, col, initialValue) { + // Prefers explicit JSON Schema hints; falls back to column-spec + // hints (col.format / col.enum) for tables without a form.yaml; + // defaults to a plain text input. + const s = propSchema || {}; + const colHint = col || {}; + + // Boolean → checkbox. + if (s.type === 'boolean') { + return widgetCheckbox(initialValue); + } + + // Enum (string with explicit choices) → select dropdown. + const enumChoices = (Array.isArray(s.enum) && s.enum) + || (Array.isArray(colHint.enum) && colHint.enum) + || null; + if (enumChoices) { + return widgetSelect(enumChoices, initialValue); + } + + // Multi-select (array of string-enum with uniqueItems). + if (s.type === 'array' + && s.items && s.items.type === 'string' + && Array.isArray(s.items.enum) && s.uniqueItems === true) { + return widgetMultiSelect(s.items.enum, initialValue); + } + + // Number / integer → number input with min/max/step. + if (s.type === 'number' || s.type === 'integer' + || colHint.format === 'number' || colHint.format === 'integer') { + return widgetNumber(s, initialValue); + } + + // Date / date-time / email — typed inputs the browser can + // help validate. + const fmt = s.format || colHint.format; + if (fmt === 'date') return widgetTyped('date', initialValue); + if (fmt === 'date-time') return widgetTyped('datetime-local', initialValue); + if (fmt === 'email') return widgetTyped('email', initialValue); + + // Long text → textarea (still inline; Phase 5 may add expand). + if (s.type === 'string' && Number(s.maxLength) > 200) { + return widgetTextarea(initialValue); + } + + // Default: plain text input. + return widgetText(initialValue); + } + + function widgetText(initial) { + const el = document.createElement('input'); + el.type = 'text'; + el.value = stringify(initial); + return { + element: el, + getValue: () => el.value, + focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} } + }; + } + + function widgetTextarea(initial) { + const el = document.createElement('textarea'); + el.rows = 1; + el.value = stringify(initial); + return { + element: el, + getValue: () => el.value, + focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} } + }; + } + + function widgetTyped(htmlType, initial) { + const el = document.createElement('input'); + el.type = htmlType; + el.value = stringify(initial); + return { + element: el, + getValue: () => el.value, + focus: () => el.focus() + }; + } + + function widgetNumber(s, initial) { + const el = document.createElement('input'); + el.type = 'number'; + if (s.minimum != null) el.min = String(s.minimum); + if (s.maximum != null) el.max = String(s.maximum); + if (s.type === 'integer') el.step = '1'; + else if (s.multipleOf != null) el.step = String(s.multipleOf); + el.value = (initial == null || initial === '') ? '' : String(initial); + return { + element: el, + getValue: () => { + const v = el.value; + if (v === '') return null; + const n = Number(v); + return Number.isNaN(n) ? v : n; + }, + focus: () => el.focus() + }; + } + + function widgetCheckbox(initial) { + const el = document.createElement('input'); + el.type = 'checkbox'; + el.checked = initial === true || initial === 'true'; + return { + element: el, + getValue: () => el.checked, + focus: () => el.focus() + }; + } + + function widgetSelect(choices, initial) { + const el = document.createElement('select'); + // Empty option lets the cell go back to "unset" without typing. + const empty = document.createElement('option'); + empty.value = ''; + empty.textContent = '—'; + el.appendChild(empty); + for (let i = 0; i < choices.length; i++) { + const opt = document.createElement('option'); + opt.value = String(choices[i]); + opt.textContent = String(choices[i]); + el.appendChild(opt); + } + el.value = initial == null ? '' : String(initial); + return { + element: el, + getValue: () => (el.value === '' ? null : el.value), + focus: () => el.focus() + }; + } + + function widgetMultiSelect(choices, initial) { + const el = document.createElement('select'); + el.multiple = true; + el.size = Math.min(6, choices.length); + const initialSet = {}; + const initArr = Array.isArray(initial) ? initial : []; + for (let i = 0; i < initArr.length; i++) initialSet[String(initArr[i])] = true; + for (let i = 0; i < choices.length; i++) { + const opt = document.createElement('option'); + opt.value = String(choices[i]); + opt.textContent = String(choices[i]); + if (initialSet[opt.value]) opt.selected = true; + el.appendChild(opt); + } + return { + element: el, + getValue: () => { + const out = []; + for (let i = 0; i < el.options.length; i++) { + if (el.options[i].selected) out.push(el.options[i].value); + } + return out; + }, + focus: () => el.focus() + }; + } + + function stringify(v) { + if (v == null) return ''; + if (typeof v === 'object') { + try { return JSON.stringify(v); } catch (_) { return String(v); } + } + return String(v); + } + + function sameValue(a, b) { + if (a === b) return true; + if (a == null && b == null) return true; + if (a == null || b == null) return false; + if (typeof a === 'object' || typeof b === 'object') { + try { return JSON.stringify(a) === JSON.stringify(b); } + catch (_) { return false; } + } + // Loose-string compare so number 42 == "42" from a text input. + return String(a) === String(b); + } + + function navigateToRowForm(row) { + // Complex-type cells punt to the row's full form editor. + // The url field on each context row already points at + // /.yaml.html — the form-mode re-edit URL. + if (!row || !row.url) return; + const nav = (window.tablesApp && window.tablesApp.navigateTo) + || function (u) { window.location.assign(u); }; + nav(row.url); + } + // --- Keyboard nav ------------------------------------------------- function moveSelection(dir) { diff --git a/tests/tables.spec.js b/tests/tables.spec.js index 2decf27..7fdc39b 100644 --- a/tests/tables.spec.js +++ b/tests/tables.spec.js @@ -340,4 +340,217 @@ test.describe('tables/ — directory-of-YAML table view', () => { await expect(page.locator('#table-root tbody tr')).toHaveCount(0); await expect(page.locator('#table-empty')).toBeHidden(); }); + + // --- Phase 2: schema-driven cell editor widgets ----------------------- + + // A small JSON Schema covering the inline-editable types the + // factory recognises: string, integer with min/max, boolean, + // string-enum, format:date, array+uniqueItems. + const ROW_SCHEMA = { + type: 'object', + properties: { + id: { type: 'string' }, + title: { type: 'string' }, + party: { type: 'string', enum: ['Acme', 'Beta', 'Gamma'] }, + dueDate: { type: 'string', format: 'date' }, + status: { type: 'string', enum: ['pending', 'submitted', 'accepted'] }, + priority: { type: 'integer', minimum: 1, maximum: 5 }, + done: { type: 'boolean' }, + tags: { + type: 'array', + uniqueItems: true, + items: { type: 'string', enum: ['blue', 'green', 'red'] }, + }, + // A nested-object cell — should punt to navigation rather + // than mount an inline editor. + owner: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string', format: 'email' }, + }, + }, + }, + }; + + const SCHEMA_COLUMNS = [ + { field: 'id', title: 'ID', width: '6em' }, + { field: 'title', title: 'Title' }, + { field: 'party', title: 'Party' }, + { field: 'dueDate', title: 'Due', format: 'date' }, + { field: 'status', title: 'Status' }, + { field: 'priority', title: 'Priority' }, + { field: 'done', title: 'Done' }, + { field: 'tags', title: 'Tags' }, + { field: 'owner', title: 'Owner' }, + ]; + + function makeSchemaRow(over) { + return { + url: `/Working/MDL/${over.id || 'D-001'}.yaml.html`, + data: Object.assign({ + id: 'D-001', title: 'Sample', party: 'Acme', dueDate: '2026-05-12', + status: 'pending', priority: 3, done: false, tags: ['blue'], + owner: { name: 'Casey', email: 'c@example.com' }, + }, over.data || {}), + editable: true, + }; + } + + const SCHEMA_ROWS = [makeSchemaRow({ id: 'D-001' }), makeSchemaRow({ id: 'D-002' })]; + + function colIdx(field) { + return SCHEMA_COLUMNS.findIndex(c => c.field === field); + } + + test('Phase 2: enum column edits via select dropdown', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, + rows: SCHEMA_ROWS, + rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const partyCell = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('party')); + await partyCell.dblclick(); + const select = partyCell.locator('select.zddc-table__cell-input'); + await expect(select).toBeVisible(); + // Empty placeholder + 3 enum options. + await expect(select.locator('option')).toHaveCount(4); + await select.selectOption('Beta'); + await page.keyboard.press('Enter'); + await expect(partyCell).toContainText('Beta'); + }); + + test('Phase 2: integer column gives a number input with min/max', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, + rows: SCHEMA_ROWS, + rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const cell = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('priority')); + await cell.dblclick(); + const input = cell.locator('input.zddc-table__cell-input'); + await expect(input).toHaveAttribute('type', 'number'); + await expect(input).toHaveAttribute('min', '1'); + await expect(input).toHaveAttribute('max', '5'); + await expect(input).toHaveAttribute('step', '1'); + + await input.fill('4'); + await page.keyboard.press('Enter'); + await expect(cell).toContainText('4'); + + // Draft holds a Number, not a string. + const draftType = await page.evaluate(() => { + const drafts = window.tablesApp.state.drafts; + const rowId = Object.keys(drafts)[0]; + return typeof drafts[rowId].priority; + }); + expect(draftType).toBe('number'); + }); + + test('Phase 2: boolean column gives a checkbox', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, + rows: SCHEMA_ROWS, + rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const cell = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('done')); + await cell.dblclick(); + const cb = cell.locator('input.zddc-table__cell-input'); + await expect(cb).toHaveAttribute('type', 'checkbox'); + // Toggle via Space (the keyboard contract a screen-reader user + // would use). Avoids the click+blur race that Playwright's + // .check() helper hits on a focused-checkbox-inside-grid-cell. + await page.keyboard.press('Space'); + await page.keyboard.press('Enter'); + + const draftValue = await page.evaluate(() => { + const drafts = window.tablesApp.state.drafts; + const rowId = Object.keys(drafts)[0]; + return drafts[rowId].done; + }); + expect(draftValue).toBe(true); + }); + + test('Phase 2: format:date column gives a date input', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, + rows: SCHEMA_ROWS, + rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const cell = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('dueDate')); + await cell.dblclick(); + const input = cell.locator('input.zddc-table__cell-input'); + await expect(input).toHaveAttribute('type', 'date'); + await expect(input).toHaveValue('2026-05-12'); + }); + + test('Phase 2: multi-select enum-array column gives a multi-select', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, + rows: SCHEMA_ROWS, + rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const cell = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('tags')); + await cell.dblclick(); + const select = cell.locator('select.zddc-table__cell-input'); + await expect(select).toBeVisible(); + await expect(select).toHaveAttribute('multiple', ''); + }); + + test('Phase 2: complex (object) column navigates to the row form on edit', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, + rows: SCHEMA_ROWS, + rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + // Stub navigation seam — see how the editor punts to the + // form for inline-uneditable types. + await page.evaluate(() => { + window.__navTarget = null; + window.tablesApp.navigateTo = url => { window.__navTarget = url; }; + }); + + const cell = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('owner')); + await cell.dblclick(); + // No inline editor mounted. + await expect(cell.locator('.zddc-table__cell-input')).toHaveCount(0); + const target = await page.evaluate(() => window.__navTarget); + expect(target).toContain('.yaml.html'); + }); + + test('Phase 2: no rowSchema → falls back to plain text editor', async ({ page }) => { + await loadTableWithContext(page, { + // No rowSchema in the context — same as a directory with + // table.yaml but no form.yaml. + columns: SCHEMA_COLUMNS, + rows: SCHEMA_ROWS, + }); + await page.waitForSelector('#table-root tbody tr'); + + const cell = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('party')); + await cell.dblclick(); + // Default text input — even for a column that COULD have been + // an enum dropdown if the schema had been provided. + const input = cell.locator('input.zddc-table__cell-input'); + await expect(input).toHaveAttribute('type', 'text'); + }); }); diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 28e8378..8c32dfe 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -891,7 +891,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-09 14:15:35 · e6d9966-dirty + v0.0.17-alpha · 2026-05-09 15:17:08 · 08ce8a1-dirty
@@ -2068,6 +2068,22 @@ body.help-open .app-header { throw new Error('Spec table.yaml missing columns[]'); } + // Optional row schema from /form.yaml — same JSON Schema + // the form-mode renderer uses. Phase 2 derives per-cell editor + // widgets from it (text/number/date/select/checkbox). + // Best-effort: a directory with only table.yaml still renders + // as a sortable/filterable table; cells fall back to plain + // text inputs without per-property hints. + let rowSchema = null; + try { + const formSpec = await readYaml(dir, 'form.yaml'); + if (formSpec && formSpec.schema) { + rowSchema = formSpec.schema; + } + } catch (_) { + // form.yaml missing or unreadable; carry on without it. + } + // Rows are every *.yaml in EXCEPT the spec // (table.yaml) and the row-edit form (form.yaml). They live // in the same directory by design — copying the directory @@ -2079,6 +2095,7 @@ body.help-open .app-header { description: spec.description, columns: spec.columns, defaults: spec.defaults, + rowSchema: rowSchema, rows: rows }; } @@ -2687,45 +2704,47 @@ body.help-open .app-header { const col = colAt(c); if (!row || !col) return; - const currentText = (initial != null) - ? String(initial) - : (effectiveCellValue(row, col) == null ? '' : String(effectiveCellValue(row, col))); + const propSchema = propertySchemaFor(col); - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'zddc-table__cell-input'; - input.value = currentText; - input.setAttribute('aria-label', 'Edit ' + (col.title || col.field)); + // Complex-type cells (nested object, generic array, oneOf) + // can't be inline-edited cleanly — punt to the row's form + // editor in a side panel / new page. Phase 2 ships the + // navigation; Phase 5 may add a side-panel mount. + if (isComplexSchema(propSchema)) { + navigateToRowForm(row); + return; + } - // Replace the cell's text content with the input. We don't - // wipe innerHTML — preserves any error-marker spans Phase 2 - // adds — but wrap the input in a way that overlays the text. - // For now: stash the original text in dataset, swap in input. + const currentValue = effectiveCellValue(row, col); + const widget = makeWidget(propSchema, col, initial != null ? initial : currentValue); + const inputEl = widget.element; + inputEl.classList.add('zddc-table__cell-input'); + inputEl.setAttribute('aria-label', 'Edit ' + (col.title || col.field)); + + // Replace the cell's text content with the editor widget. + // Stash the original text in dataset so cancel can restore it + // verbatim without re-running the formatCell logic. cell.setAttribute('data-display', cell.textContent || ''); cell.textContent = ''; - cell.appendChild(input); - input.focus(); - // If user pressed Enter/F2, position cursor at end. If they - // started typing a printable char, that char already replaced - // the value; cursor is at end naturally. - try { input.setSelectionRange(input.value.length, input.value.length); } - catch (_) { /* type=text supports it; defensive */ } + cell.appendChild(inputEl); + widget.focus(); app.state.editing = true; function commit() { if (!app.state.editing) return; - const newValue = input.value; + const newValue = widget.getValue(); const oldRaw = app.modules.util.resolveField(row.data, col.field); - const oldStr = oldRaw == null ? '' : String(oldRaw); - if (newValue === oldStr) { - // No change — clear any draft entry for this field - // so we don't show a "dirty" badge for a no-op edit. + // Compare by JSON-string equality so number 42 == "42" + // entered into a number input doesn't false-positive as + // a change. resolveField already returns the raw typed + // value from row.data. + if (sameValue(oldRaw, newValue)) { clearDraftField(rowKey(row), col.field); } else { - setDraft(rowKey(row), col.field, coerceForSchema(newValue, col)); + setDraft(rowKey(row), col.field, newValue); } - tearDown(coerceForSchema(newValue, col)); + tearDown(newValue); } function cancel() { @@ -2733,9 +2752,9 @@ body.help-open .app-header { } function tearDown(displayValue) { - input.removeEventListener('keydown', onKey); - input.removeEventListener('blur', onBlur); - const display = (displayValue != null) + inputEl.removeEventListener('keydown', onKey); + inputEl.removeEventListener('blur', onBlur); + const display = (displayValue !== undefined && displayValue !== null) ? renderableText(displayValue, col) : (cell.getAttribute('data-display') || ''); cell.removeAttribute('data-display'); @@ -2773,26 +2792,242 @@ body.help-open .app-header { } } - input.addEventListener('keydown', onKey); - input.addEventListener('blur', onBlur); - } - - function coerceForSchema(text, col) { - // Phase 1 stores raw strings as drafts. Phase 2 will type-coerce - // here based on the row schema (integer→Number, boolean→bool, - // etc.). Until then, also handle the obvious case so number - // columns don't display "42" as a string in the table. - if (col.format === 'number' || col.format === 'integer') { - const n = Number(text); - if (!Number.isNaN(n) && text.trim() !== '') return n; - } - return text; + inputEl.addEventListener('keydown', onKey); + inputEl.addEventListener('blur', onBlur); } function renderableText(value, col) { return app.modules.util.formatCell(value, col.format); } + // --- Schema → editor widget factory -------------------------------- + + function propertySchemaFor(col) { + // Walk the row schema for this column's field. Returns null + // when no schema is present (best-effort: cells fall back to + // plain text editors). Supports a single dot-separated path + // — `properties.a.properties.b` for `field: "a.b"` — to mirror + // the existing util.resolveField conventions. + const ctx = app.context || {}; + if (!ctx.rowSchema) return null; + const parts = String(col.field || '').split('.').filter(Boolean); + let s = ctx.rowSchema; + for (let i = 0; i < parts.length; i++) { + if (!s || !s.properties || !s.properties[parts[i]]) return null; + s = s.properties[parts[i]]; + } + return s; + } + + function isComplexSchema(s) { + if (!s) return false; + if (Array.isArray(s.oneOf) && s.oneOf.length > 0) return true; + if (Array.isArray(s.anyOf) && s.anyOf.length > 0) return true; + if (Array.isArray(s.allOf) && s.allOf.length > 0) return true; + if (s.type === 'object') return true; + if (s.type === 'array') { + // Multi-select-friendly arrays (string-enum + uniqueItems) + // get inline editing; everything else is complex. + const items = s.items || {}; + const isMultiSelect = items.type === 'string' + && Array.isArray(items.enum) && items.enum.length > 0 + && s.uniqueItems === true; + return !isMultiSelect; + } + return false; + } + + function makeWidget(propSchema, col, initialValue) { + // Prefers explicit JSON Schema hints; falls back to column-spec + // hints (col.format / col.enum) for tables without a form.yaml; + // defaults to a plain text input. + const s = propSchema || {}; + const colHint = col || {}; + + // Boolean → checkbox. + if (s.type === 'boolean') { + return widgetCheckbox(initialValue); + } + + // Enum (string with explicit choices) → select dropdown. + const enumChoices = (Array.isArray(s.enum) && s.enum) + || (Array.isArray(colHint.enum) && colHint.enum) + || null; + if (enumChoices) { + return widgetSelect(enumChoices, initialValue); + } + + // Multi-select (array of string-enum with uniqueItems). + if (s.type === 'array' + && s.items && s.items.type === 'string' + && Array.isArray(s.items.enum) && s.uniqueItems === true) { + return widgetMultiSelect(s.items.enum, initialValue); + } + + // Number / integer → number input with min/max/step. + if (s.type === 'number' || s.type === 'integer' + || colHint.format === 'number' || colHint.format === 'integer') { + return widgetNumber(s, initialValue); + } + + // Date / date-time / email — typed inputs the browser can + // help validate. + const fmt = s.format || colHint.format; + if (fmt === 'date') return widgetTyped('date', initialValue); + if (fmt === 'date-time') return widgetTyped('datetime-local', initialValue); + if (fmt === 'email') return widgetTyped('email', initialValue); + + // Long text → textarea (still inline; Phase 5 may add expand). + if (s.type === 'string' && Number(s.maxLength) > 200) { + return widgetTextarea(initialValue); + } + + // Default: plain text input. + return widgetText(initialValue); + } + + function widgetText(initial) { + const el = document.createElement('input'); + el.type = 'text'; + el.value = stringify(initial); + return { + element: el, + getValue: () => el.value, + focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} } + }; + } + + function widgetTextarea(initial) { + const el = document.createElement('textarea'); + el.rows = 1; + el.value = stringify(initial); + return { + element: el, + getValue: () => el.value, + focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} } + }; + } + + function widgetTyped(htmlType, initial) { + const el = document.createElement('input'); + el.type = htmlType; + el.value = stringify(initial); + return { + element: el, + getValue: () => el.value, + focus: () => el.focus() + }; + } + + function widgetNumber(s, initial) { + const el = document.createElement('input'); + el.type = 'number'; + if (s.minimum != null) el.min = String(s.minimum); + if (s.maximum != null) el.max = String(s.maximum); + if (s.type === 'integer') el.step = '1'; + else if (s.multipleOf != null) el.step = String(s.multipleOf); + el.value = (initial == null || initial === '') ? '' : String(initial); + return { + element: el, + getValue: () => { + const v = el.value; + if (v === '') return null; + const n = Number(v); + return Number.isNaN(n) ? v : n; + }, + focus: () => el.focus() + }; + } + + function widgetCheckbox(initial) { + const el = document.createElement('input'); + el.type = 'checkbox'; + el.checked = initial === true || initial === 'true'; + return { + element: el, + getValue: () => el.checked, + focus: () => el.focus() + }; + } + + function widgetSelect(choices, initial) { + const el = document.createElement('select'); + // Empty option lets the cell go back to "unset" without typing. + const empty = document.createElement('option'); + empty.value = ''; + empty.textContent = '—'; + el.appendChild(empty); + for (let i = 0; i < choices.length; i++) { + const opt = document.createElement('option'); + opt.value = String(choices[i]); + opt.textContent = String(choices[i]); + el.appendChild(opt); + } + el.value = initial == null ? '' : String(initial); + return { + element: el, + getValue: () => (el.value === '' ? null : el.value), + focus: () => el.focus() + }; + } + + function widgetMultiSelect(choices, initial) { + const el = document.createElement('select'); + el.multiple = true; + el.size = Math.min(6, choices.length); + const initialSet = {}; + const initArr = Array.isArray(initial) ? initial : []; + for (let i = 0; i < initArr.length; i++) initialSet[String(initArr[i])] = true; + for (let i = 0; i < choices.length; i++) { + const opt = document.createElement('option'); + opt.value = String(choices[i]); + opt.textContent = String(choices[i]); + if (initialSet[opt.value]) opt.selected = true; + el.appendChild(opt); + } + return { + element: el, + getValue: () => { + const out = []; + for (let i = 0; i < el.options.length; i++) { + if (el.options[i].selected) out.push(el.options[i].value); + } + return out; + }, + focus: () => el.focus() + }; + } + + function stringify(v) { + if (v == null) return ''; + if (typeof v === 'object') { + try { return JSON.stringify(v); } catch (_) { return String(v); } + } + return String(v); + } + + function sameValue(a, b) { + if (a === b) return true; + if (a == null && b == null) return true; + if (a == null || b == null) return false; + if (typeof a === 'object' || typeof b === 'object') { + try { return JSON.stringify(a) === JSON.stringify(b); } + catch (_) { return false; } + } + // Loose-string compare so number 42 == "42" from a text input. + return String(a) === String(b); + } + + function navigateToRowForm(row) { + // Complex-type cells punt to the row's full form editor. + // The url field on each context row already points at + // /.yaml.html — the form-mode re-edit URL. + if (!row || !row.url) return; + const nav = (window.tablesApp && window.tablesApp.navigateTo) + || function (u) { window.location.assign(u); }; + nav(row.url); + } + // --- Keyboard nav ------------------------------------------------- function moveSelection(dir) {