diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab3cff..30a38ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,12 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su description ("Standard 40h (08:00-16:30)") instead of typed from memory. `demos/crm.prg`'s `DEALS.STAGE` is constrained to a literal `LOOKUP` list matching its own seeded vocabulary exactly, exercising the other lookup kind in a real, working demo. +- **Assistant wizard support** (#62). The New-table and Modify-structure wizards gain a + per-column "lookup (optional)" field accepting `TABLE.COLUMN [DISPLAY COLUMN]` or a quoted + list, so declaring a `LOOKUP` no longer requires dropping into raw W3Script syntax. This + closes out the v1.3.0 lookup-columns milestone (#58–#62): the language, storage, resolver, + BROWSE/REPLACE enforcement, field-bound forms, two demo apps, and the GUI wizards all now + agree on one constraint, declared once, on the column. ## [1.2.0] — 2026-07-09 — TIME columns, WEEK()/DATEADD(), BROWSE cell validation, Overtime demo diff --git a/README.md b/README.md index 2beb9d5..cf2fbb3 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,8 @@ build indexes, search, reindex, pack the database, design and run reports, run p table structure. Every click generates a real W3Script command that echoes into the terminal — watch it to learn the language. Wizards (New table, Filter, Sort, Sum/Average, Modify structure, report designer, …) open in the main area and show a live preview of the command they will run. +New table and Modify structure both offer an optional per-column "lookup" field — `LOOKUP` +without typing raw syntax. --- diff --git a/src/ui/wizards/ModStructWizard.ts b/src/ui/wizards/ModStructWizard.ts index 43d7577..6f10efa 100644 --- a/src/ui/wizards/ModStructWizard.ts +++ b/src/ui/wizards/ModStructWizard.ts @@ -1,4 +1,5 @@ import { WizardShell } from './WizardShell'; +import { lookupClause } from './TableWizard'; import type { ColInfo } from '../../shared/types'; const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -17,6 +18,7 @@ interface Row { origType: string; // W3Script type at load time name: HTMLInputElement; type: HTMLSelectElement; + lookup: HTMLInputElement; drop: HTMLInputElement; // checkbox: mark for deletion } @@ -47,15 +49,20 @@ export function openModStructWizard( if (seen.has(newName.toUpperCase())) return { cmds: [], err: `Duplicate column: ${newName}` }; seen.add(newName.toUpperCase()); const newType = r.type.value; + const lk = lookupClause(r.lookup.value, newName); + if (lk.err) return { cmds: [], err: lk.err }; if (!r.origName) { // brand new column - cmds.push(`ALTER TABLE ${table} ADD ${newName} ${newType}`); + cmds.push(`ALTER TABLE ${table} ADD ${newName} ${newType}${lk.clause}`); continue; } if (newName.toUpperCase() !== r.origName.toUpperCase()) { cmds.push(`ALTER TABLE ${table} RENAME ${r.origName} TO ${newName}`); } - if (newType !== r.origType) { - cmds.push(`ALTER TABLE ${table} ALTER ${newName} ${newType}`); + // A retype OR a newly-typed lookup both go through ALTER … ALTER; the + // clause rides along either way. A blank lookup input never emits or + // removes anything (no lookup-removal path — YAGNI, noted in the PR). + if (newType !== r.origType || lk.clause) { + cmds.push(`ALTER TABLE ${table} ALTER ${newName} ${newType}${lk.clause}`); } } return { cmds, err: '' }; @@ -82,15 +89,20 @@ export function openModStructWizard( if (t === startType) o.selected = true; type.appendChild(o); } + const lookup = document.createElement('input'); + lookup.type = 'text'; lookup.className = 'wz-col-lookup'; + lookup.placeholder = 'lookup (optional)'; + lookup.title = 'Legal values: TABLE.COLUMN [DISPLAY COLUMN] — or a literal list: "Lead","Won"'; + lookup.style.minWidth = '180px'; const drop = document.createElement('input'); drop.type = 'checkbox'; drop.title = 'drop this column'; const dropLabel = document.createElement('label'); dropLabel.append(drop, document.createTextNode(' drop')); if (!col) dropLabel.style.visibility = 'hidden'; // new rows can't be "dropped" - wrap.append(name, type, dropLabel); + wrap.append(name, type, lookup, dropLabel); colsWrap.appendChild(wrap); - rows.push({ origName: col?.name ?? '', origType: startType, name, type, drop }); - for (const el of [name, type, drop]) el.addEventListener('input', update); + rows.push({ origName: col?.name ?? '', origType: startType, name, type, lookup, drop }); + for (const el of [name, type, lookup, drop]) el.addEventListener('input', update); drop.addEventListener('change', update); }; diff --git a/src/ui/wizards/TableWizard.ts b/src/ui/wizards/TableWizard.ts index 12978c1..a03a595 100644 --- a/src/ui/wizards/TableWizard.ts +++ b/src/ui/wizards/TableWizard.ts @@ -5,7 +5,23 @@ const TYPES = ['CHAR', 'NUM', 'INT', 'DATE', 'TIME', 'LOGICAL', 'MEMO'] as const const NEEDS_LEN = new Set(['CHAR', 'NUM']); const OPTIONAL_LEN = new Set(['TIME']); -interface ColRow { name: HTMLInputElement; type: HTMLSelectElement; len: HTMLInputElement; } +interface ColRow { name: HTMLInputElement; type: HTMLSelectElement; len: HTMLInputElement; lookup: HTMLInputElement; } + +/** Turn the wizard's lookup text into a LOOKUP clause, or an error. + Accepts `TABLE.COL`, `TABLE.COL DISPLAY COL`, or a quoted list `"a","b"`. */ +export function lookupClause(raw: string, colName: string): { clause: string; err: string } { + const v = raw.trim(); + if (!v) return { clause: '', err: '' }; + if (v.includes('"')) { + if (!/^"[^"]+"(\s*,\s*"[^"]+")*$/.test(v)) { + return { clause: '', err: `Lookup list for ${colName}: use quoted values, e.g. "Lead","Won"` }; + } + return { clause: ` LOOKUP (${v})`, err: '' }; // values keep their case + } + const m = v.match(/^([A-Za-z_]\w*)\.([A-Za-z_]\w*)(\s+DISPLAY\s+[A-Za-z_]\w*)?$/i); + if (!m) return { clause: '', err: `Lookup for ${colName}: use TABLE.COLUMN [DISPLAY COLUMN] or "a","b"` }; + return { clause: ` LOOKUP ${v.toUpperCase()}`, err: '' }; +} export function openTableWizard(run: (cmd: string) => void, onClose: () => void): void { let shell: WizardShell; @@ -27,6 +43,8 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void) if (!n) continue; // blank rows are skipped if (!NAME_RE.test(n)) return { cmd: null, err: `Invalid column name: ${n}` }; const t = r.type.value; + const lk = lookupClause(r.lookup.value, n); + if (lk.err) return { cmd: null, err: lk.err }; if (t === 'NUM') { // Accept a plain width ("8") or a precision,scale pair ("8,2"). const raw = r.len.value.trim(); @@ -34,25 +52,25 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void) if (!m) return { cmd: null, err: `Length required for ${n} (NUM) — e.g. 8 or 8,2` }; const p = parseInt(m[1], 10); if (!p || p < 1) return { cmd: null, err: `Length required for ${n} (NUM)` }; - if (m[2] === undefined) { cols.push(`${n} NUM(${p})`); continue; } + if (m[2] === undefined) { cols.push(`${n} NUM(${p})${lk.clause}`); continue; } const s = parseInt(m[2], 10); if (s >= p) return { cmd: null, err: `Scale must be smaller than precision for ${n} (NUM)` }; - cols.push(`${n} NUM(${p},${s})`); + cols.push(`${n} NUM(${p},${s})${lk.clause}`); } else if (NEEDS_LEN.has(t)) { const len = parseInt(r.len.value, 10); if (!len || len < 1) return { cmd: null, err: `Length required for ${n} (${t})` }; - cols.push(`${n} ${t}(${len})`); + cols.push(`${n} ${t}(${len})${lk.clause}`); } else if (OPTIONAL_LEN.has(t)) { const raw = r.len.value.trim(); if (raw) { const len = parseInt(raw, 10); if (!len || len < 1) return { cmd: null, err: `Invalid granularity for ${n} (${t})` }; - cols.push(`${n} ${t}(${len})`); + cols.push(`${n} ${t}(${len})${lk.clause}`); } else { - cols.push(`${n} ${t}`); + cols.push(`${n} ${t}${lk.clause}`); } } else { - cols.push(`${n} ${t}`); + cols.push(`${n} ${t}${lk.clause}`); } } if (!cols.length) return { cmd: null, err: 'At least one column.' }; @@ -78,15 +96,20 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void) } const len = document.createElement('input'); len.type = 'text'; len.className = 'wz-col-len'; len.placeholder = 'len'; len.title = 'CHAR: length · NUM: width or precision,scale (8,2) · TIME: minute granularity'; len.style.minWidth = '56px'; len.style.width = '56px'; - row.append(name, type, len); + const lookup = document.createElement('input'); + lookup.type = 'text'; lookup.className = 'wz-col-lookup'; + lookup.placeholder = 'lookup (optional)'; + lookup.title = 'Legal values: TABLE.COLUMN [DISPLAY COLUMN] — or a literal list: "Lead","Won"'; + lookup.style.minWidth = '180px'; + row.append(name, type, len, lookup); colsWrap.appendChild(row); - rows.push({ name, type, len }); - for (const el of [name, type, len]) el.addEventListener('input', update); + rows.push({ name, type, len, lookup }); + for (const el of [name, type, len, lookup]) el.addEventListener('input', update); }; shell = new WizardShell( 'New table', - 'Define columns; blank rows are ignored. CHAR needs a length, NUM a width or precision,scale (8 or 8,2); TIME takes an optional minute-granularity (e.g. 15).', + 'Define columns; blank rows are ignored. CHAR needs a length, NUM a width or precision,scale (8 or 8,2); TIME takes an optional minute-granularity (e.g. 15). Lookup constrains a column to legal values: TABLE.COLUMN [DISPLAY COLUMN] or "a","b".', { okLabel: 'Create table', onOk: () => { const { cmd } = buildCommand(); if (cmd) { run(cmd); shell.close(); } diff --git a/tests/assistant.spec.ts b/tests/assistant.spec.ts index e69f106..a9399ce 100644 --- a/tests/assistant.spec.ts +++ b/tests/assistant.spec.ts @@ -204,6 +204,47 @@ test.describe('Assistant wizards — table', () => { const numbered = lines.filter(l => /^\s*\d+\s+\w+/.test(l) && !/record/i.test(l)); expect(numbered).toHaveLength(1); }); + + test('New table wizard emits a LOOKUP clause and BROWSE honours it', async ({ page }) => { + await boot(page); + await page.locator('#terminal-input').fill('USE DATABASE ASSISTDEMO'); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + await page.locator('#terminal-input').fill('DROP TABLE wiz_lookup'); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + + await clickAction(page, 'New table…'); + await expect(page.locator('#wizard-view')).toBeVisible({ timeout: 5000 }); + + await page.locator('#wz-table-name').fill('wiz_lookup'); + await page.locator('.wz-col-name').first().fill('STAGE'); + await page.locator('.wz-col-type').first().selectOption('CHAR'); + await page.locator('.wz-col-len').first().fill('12'); + await page.locator('.wz-col-lookup').first().fill('"Lead","Won"'); + + // live preview shows the exact clause + await expect(page.locator('.wz-preview')) + .toContainText('CREATE TABLE wiz_lookup (STAGE CHAR(12) LOOKUP ("Lead","Won"))'); + + await page.locator('#wizard-view button', { hasText: 'Create table' }).click(); + await expect(page.locator('#terminal-output')).toContainText('Table created: WIZ_LOOKUP', { timeout: 5000 }); + + // The created table's grid editor is a dropdown with exactly the list. + await page.locator('#terminal-input').fill('APPEND RECORD'); + await page.locator('#terminal-input').press('Enter'); + await page.waitForTimeout(400); + await clickAction(page, 'Browse'); + await expect(page.locator('#grid-view')).toBeVisible({ timeout: 5000 }); + const td = page.locator('#grid-tbody td[data-ri="0"][data-ci="0"]'); + await td.dblclick(); + const sel = td.locator('select.cell-ed'); + await expect(sel).toBeVisible(); + await expect(sel).toBeInViewport(); + await expect(sel.locator('option')).toHaveText(['', 'Lead', 'Won']); + await page.keyboard.press('Escape'); // leave edit + await page.keyboard.press('Escape'); // leave grid + }); }); test.describe('Assistant wizards — filter / index / search', () => {