diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc601d..1bdfffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,17 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su warning rather than locking the column. BROWSE renders a lookup column as a dropdown — `DISPLAY` labels shown while editing, the stored code shown once committed, matching `LIST`/report output. - **Forms (`@ SAY GET`) do not honor `LOOKUP` yet** — that ships in #59, which is when the - full feature is documented in the README command reference. +- **Field-bound `@ SAY GET`** (#59). `@ r,c SAY "…" GET ` now binds directly to the + active table's column when one matches — dBASE III's actual behavior — instead of only + ever collecting into a memory variable. Fields take precedence over a memory variable of + the same name (why the `m_` prefix convention exists). A field-bound `GET` needs a current + record and prefills from it; a lookup column renders the same picker BROWSE does. `READ`'s + submit validates every field-bound value (declared type + lookup membership) before + writing any of them — a rejection sends a new `form-error` message that keeps the form + open with the bad fields outlined, rather than silently discarding the valid ones. + Writes target the row captured at `GET` time, mirroring how `grid-edit` already writes by + rowid. This is the PR that promotes `LOOKUP` to the README command reference in full — + both BROWSE and forms now declare, enforce, and render it end to end. ## [1.2.0] — 2026-07-09 — TIME columns, WEEK()/DATEADD(), BROWSE cell validation, Overtime demo diff --git a/CLAUDE.md b/CLAUDE.md index a31b516..3ebabc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,7 +197,7 @@ WebBase-III supports **unlimited work areas** (no DOS 10-area limit). Cross-area Declared types are recorded per `(database, table, column)` in `server/ColumnMetaStore.ts`, because SQLite only keeps a storage affinity: `TIME`/`DATE`/`CHAR` are all `TEXT`, `LOGICAL`/`INT` are both `INTEGER`, and a `NUM(p,s)` qualifier is lost entirely. -#### `LOOKUP` column qualifier (BROWSE/REPLACE done; forms in progress — v1.3.0) +#### `LOOKUP` column qualifier (v1.3.0) Any column may add a `LOOKUP` clause after its type, constraining it to a set of legal values — a WebBase-III extension with no dBASE III ancestor (dBASE III+ only had @@ -220,9 +220,18 @@ matching `LIST`/report output — only the edit-mode dropdown shows `DISPLAY` la `REPLACE` and `grid-edit` both enforce membership, re-resolving fresh at write time (not trusting a client-held list) so a value that just became legal or just stopped being legal is judged correctly; an unresolvable lookup degrades to free entry with a warning rather -than locking the column. **Forms (`@ SAY GET`) do not honor `LOOKUP` yet** — that lands in -a follow-up PR (#59, field-bound `GET`) before this section is promoted to the README -command reference. +than locking the column. + +`@ r,c SAY "…" GET ` binds to the active table's column when one matches — dBASE III's +actual behavior, and why the field takes precedence over a memory variable of the same name +(the `m_` prefix convention exists for this reason). A field-bound `GET` requires a current +record (`APPEND RECORD` first — `** Error: GET : no current record` otherwise), +prefills from the record, and renders a picker when the column has a resolvable lookup. +`READ`'s submit is all-or-nothing across every field-bound `GET` in the form: every value is +validated (declared type + lookup membership) before any is written, and a rejection sends a +`form-error` message that keeps the form open with the offending fields outlined — Escape +still writes nothing. Writes target the rowid captured at `GET` time (`fetchCurrentRow`), +mirroring `grid-edit`, so pointer motion between `GET` and submit can't retarget the write. #### Cell validation (`BROWSE`) diff --git a/README.md b/README.md index ffaf3e1..2beb9d5 100644 --- a/README.md +++ b/README.md @@ -266,13 +266,20 @@ value that just became legal, or just stopped being legal, is judged correctly stale cached list). A lookup that can't be resolved (source table dropped, empty, or over 1000 distinct values) degrades to free entry with a warning instead of locking the column. +Forms pick it up too: `@ r,c SAY "…" GET ` binds to the active table's column when +one matches — a field takes precedence over a memory variable of the same name, which is +why programs use an `m_` prefix for scratch variables. A field-bound `GET` needs a current +record (`APPEND RECORD` first) and prefills from it; if the column has a lookup, the form +renders the same picker BROWSE does. `READ` validates every field-bound value before +writing any of them — a rejection keeps the form open with the bad field outlined instead +of silently dropping the others. + > **Deviation from dBASE III:** `LOOKUP` has no dBASE III ancestor — dBASE III+ only offered > `PICTURE "@M a,b,c"`, a literal value list cycled with the spacebar, with no table-driven > form and no display label. This is a WebBase-III extension, in the same spirit as -> unlimited work areas and `alias.field` dot notation. -> -> Forms (`@ SAY GET`) don't offer a `LOOKUP` picker yet — only BROWSE and `REPLACE` enforce -> it today. +> unlimited work areas and `alias.field` dot notation. Field-bound `GET` *is* authentic +> dBASE III behavior — WebBase-III's own memory-variable-only forms were the deviation, +> now corrected. > **CSV format (`COPY TO` / `APPEND FROM`):** Unlike dBASE III's headerless, > positional `DELIMITED`/`SDF` formats, WebBase-III uses modern **header-based CSV** @@ -348,7 +355,7 @@ stale cached list). A lookup that can't be resolved (source table dropped, empty | `? [, ...]` | Evaluate expression(s) and print the result (numbers right-justified; bare `?` prints a blank line; `??` also accepted) | | `STORE TO ` | Assign a variable | | `INPUT "prompt" TO ` | Collect keyboard input | -| `@ r,c SAY "text" GET ` | Define a form field | +| `@ r,c SAY "text" GET ` | Define a form field; a name matching a column of the active table binds that column (lookup columns render a picker) | | `READ` | Display the form and wait for submit | ### Control flow diff --git a/server/Session.ts b/server/Session.ts index ac38137..acb4f2d 100644 --- a/server/Session.ts +++ b/server/Session.ts @@ -18,6 +18,11 @@ export class Session { // Whether pendingContinuation was captured while a program (DO ) was // running — its resumption must re-enter program scope (e.g. silent STORE). private pendingFromProgram = false; + // The field list of the form currently awaiting submit. form-submit resolves + // write targets from THIS, never from the client's message — a forged + // form-submit cannot redirect a write to an arbitrary column (same reasoning + // as grid-edit's authoritative re-check). + private pendingFormFields: import('../src/shared/types.js').FormField[] | null = null; private dirty = false; @@ -48,12 +53,48 @@ export class Session { break; case 'form-submit': { - // Always store what the form collected. A bare `INPUT "…" TO var` at the - // REPL leaves no continuation (there is no following statement), and - // gating the assignment on one silently discarded the typed value. (#50) + const fields = this.pendingFormFields ?? []; + type FieldTarget = Extract, { kind: 'field' }>; + const fieldByName = new Map(); + for (const f of fields) { + if (f.target?.kind === 'field') fieldByName.set(f.varName, f.target); + } + // Variables first — and always. A bare `INPUT "…" TO var` at the REPL + // leaves no continuation, and gating the assignment on one silently + // discarded the typed value (#50). Keys that are not field targets of + // THIS form are variables, whatever the client claims. for (const [k, v] of Object.entries(msg.values)) { - this.executor.setVar(k, v); + if (!fieldByName.has(k)) this.executor.setVar(k, v); + } + // Field writes are all-or-nothing: validate every value (declared type + // + freshly-resolved lookup membership) before writing any. + const errors: { varName: string; message: string }[] = []; + const writes: { table: string; column: string; rowid: number; value: string }[] = []; + for (const [varName, t] of fieldByName) { + const value = msg.values[varName] ?? ''; + let meta = columnMetaStore.getColumnType(t.db, t.table, t.column); + if (meta?.lookup) { + const options = await resolveLookup(this.bridge, meta.lookup); + meta = { ...meta, options: options ?? undefined }; + } + const err = validateCellValue(t.column, value, meta); + if (err) errors.push({ varName, message: err }); + else writes.push({ table: t.table, column: t.column, rowid: t.rowid, value }); } + if (errors.length) { + // Keep pendingFormFields AND pendingContinuation intact: the client + // keeps the form open and resubmits corrected values. + this.send({ type: 'form-error', errors }); + break; + } + this.pendingFormFields = null; + for (const w of writes) { + await this.bridge.exec( + `UPDATE ${q(w.table)} SET ${q(w.column)} = ? WHERE rowid = ?`, + [w.value, w.rowid] + ); + } + this.send({ type: 'view-terminal' }); if (this.pendingContinuation !== null) { const cont = this.pendingContinuation; const fromProgram = this.pendingFromProgram; @@ -66,7 +107,6 @@ export class Session { if (fromProgram) this.executor.exitProgram(); } } else { - this.send({ type: 'view-terminal' }); this.sendStatus(); } break; @@ -164,6 +204,7 @@ export class Session { } case 'grid-exit': + this.pendingFormFields = null; this.send({ type: 'view-terminal' }); if (this.pendingContinuation) { const cont = this.pendingContinuation; @@ -187,6 +228,7 @@ export class Session { if (this.pendingContinuation !== null) { const wasProgram = this.pendingFromProgram; this.pendingContinuation = null; + this.pendingFormFields = null; this.pendingFromProgram = false; this.executor.resetProgramDepth(); if (wasProgram) { @@ -309,6 +351,7 @@ export class Session { if (result.action === 'FORM_READY' && result.formFields) { this.pendingContinuation = result.continuation ?? null; this.pendingFromProgram = this.executor.isInProgram(); + this.pendingFormFields = result.formFields; this.send({ type: 'form-open', fields: result.formFields }); return true; } diff --git a/src/interpreter/Executor.ts b/src/interpreter/Executor.ts index fdac08d..f15283a 100644 --- a/src/interpreter/Executor.ts +++ b/src/interpreter/Executor.ts @@ -557,8 +557,39 @@ export class Executor implements IndexCommandsHost { const row = Number(this.evalExpr(rowE)); const col = Number(this.evalExpr(colE)); const text = String(this.evalExpr(textE)); - this.pendingForm.push({ row, col, label: text, varName }); - return { output: [] }; + const out: OutputLine[] = []; + + // Field binding: a GET whose name matches a column of the active table edits + // the current record (dBASE III behavior — fields shadow memory variables; + // this is why the m_ prefix convention exists). Capturing the rowid here means + // form-submit writes by rowid, like grid-edit — pointer motion between here + // and submit cannot retarget the write. + if (this.area.table) { + const cols = await this.db.getStructure(this.area.table); + const match = cols.find(c => c.name.toUpperCase() === varName.toUpperCase()); + if (match) { + const cur = await this.fetchCurrentRow(); + if (!cur) throw new Error(`GET ${match.name}: no current record`); + const field: FormField = { + row, col, label: text, varName: match.name, + target: { + kind: 'field', column: match.name, table: this.area.table, + db: this.area.db ?? '', rowid: Number(cur._rowid), + }, + value: String(cur[match.name] ?? ''), + }; + const info = this.columnMetaStore?.getColumnType(this.metaDb, this.area.table, match.name); + if (info?.lookup) { + const options = await resolveLookup(this.db, info.lookup); + if (options) field.options = options; + else out.push({ text: `** Warning: lookup for ${match.name} could not be resolved — free entry`, cls: 'warn' }); + } + this.pendingForm.push(field); + return { output: out }; + } + } + this.pendingForm.push({ row, col, label: text, varName, target: { kind: 'var' }, value: '' }); + return { output: out }; } private doRead(): ExecResult { diff --git a/src/shared/types.ts b/src/shared/types.ts index 9801959..47f524d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -13,7 +13,16 @@ export interface FormField { row: number; col: number; label: string; + /** The submit key. For a field-bound GET this is the column name. */ varName: string; + /** What form-submit writes. Absent (legacy INPUT/@SAY paths) means 'var'. */ + target?: + | { kind: 'var' } + | { kind: 'field'; column: string; table: string; db: string; rowid: number }; + /** Prefill. Field GETs carry the record's value; var GETs stay '' (unchanged UX). */ + value?: string; + /** Resolved lookup options — render a