Merge feat/m1-kernel-cli (e058942) into feat/m1-related-records
Brings in the DATE_TIME work (local-zone display and input, --utc, process input exit codes). The record view now formats DATE_TIME through displayDateTime (the viewer's zone, --utc for the raw instant, server-zoned text kept), and so do the child lists of table get --associations and table children; create/update keep the base's conversion through the form session, including the --parent and --upload paths. process.ts keeps the base's value formatting with the unified safeFileName/saveDownload. Exports, TRY-IT sections and parity STATUS merged; this branch's findings renumbered to 16 and 17; matrix regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
21 files changed
+413
-42
No files matched your search
@@ -195,13 +195,27 @@ qqq table download fieldLab 1 blobValue -o copy.txt #
|
||||
qqq table get person 1 --help-content # table, section and field help inline (this sample app declares none; qqq help show for help alone)
|
||||
```
|
||||
|
||||
DATE_TIME values are typed and shown in your zone (the `TZ` environment) and sent as UTC instants, as the web
|
||||
UI does; `--utc` shows the raw instants. A time skipped by a spring-forward change moves forward by the gap; a
|
||||
time repeated by a fall-back change means its first occurrence. In filters, a date alone on a DATE_TIME field
|
||||
means local midnight. Fields the server formats in a declared zone (fixed or per-record) keep its text.
|
||||
|
||||
```sh
|
||||
TZ=America/Chicago qqq table create fieldLab --set name=tz --set dateTimeValue=2026-10-31T14:30 # stored 19:30Z
|
||||
qqq table get fieldLab 4 # Date Time Value 2026-10-31 14:30:00 CDT
|
||||
qqq --utc table get fieldLab 4 # Date Time Value 2026-10-31T19:30:00Z
|
||||
TZ=Asia/Tokyo qqq table get fieldLab 4 # 2026-11-01 04:30:00 GMT+9
|
||||
qqq table query fieldLab -w "dateTimeValue BETWEEN 2026-10-31,2026-10-31T23:59" --print-filter
|
||||
qqq process run fieldLab.bulkEdit -r 4 -v bulkEditEnabledFields=dateTimeValue -v dateTimeValue=2026-10-31T09:15 -v name=tz --validate
|
||||
```
|
||||
|
||||
Filter values can be expressions: `now()`, `now(-7d)`, `thisPeriod(month)`, `lastPeriod(week)`,
|
||||
`startOf(quarter,-1)`, `me()`, `${variable.x}`; quote values with commas: `city IN "St. Louis, MO",Chicago`.
|
||||
|
||||
Where M1 stands: `docs/parity/feature-matrix.md` has the CLI status of every Next UI acceptance row. Not yet in
|
||||
the CLI: bulk load (the sample server cannot accept uploads, see `docs/server-findings.md` #10), saved views,
|
||||
column statistics, joins, variants, audits, jobs on a durable-jobs server,
|
||||
TABLE_BASED sign-in, DATE_TIME local-to-UTC conversion.
|
||||
TABLE_BASED sign-in.
|
||||
|
||||
## Reporting what you find
|
||||
|
||||
|
||||
@@ -12,15 +12,15 @@ The TUI column fills in during M3.
|
||||
| HLP | 6 | 1 | 0 | 4 | 1 |
|
||||
| INT | 14 | 5 | 0 | 0 | 9 |
|
||||
| NAV | 40 | 9 | 3 | 1 | 27 |
|
||||
| PRC | 60 | 34 | 12 | 2 | 12 |
|
||||
| PRC | 60 | 35 | 11 | 2 | 12 |
|
||||
| PRF | 5 | 4 | 1 | 0 | 0 |
|
||||
| QRY | 70 | 27 | 20 | 0 | 23 |
|
||||
| REC | 65 | 29 | 12 | 13 | 11 |
|
||||
| QRY | 70 | 28 | 19 | 0 | 23 |
|
||||
| REC | 65 | 31 | 10 | 13 | 11 |
|
||||
| REL | 9 | 6 | 0 | 3 | 0 |
|
||||
| RPT | 20 | 1 | 19 | 0 | 0 |
|
||||
| SEC | 43 | 17 | 14 | 0 | 12 |
|
||||
| WID | 69 | 29 | 27 | 0 | 13 |
|
||||
| **all** | 401 | 162 | 108 | 23 | 108 |
|
||||
| **all** | 401 | 166 | 104 | 23 | 108 |
|
||||
|
||||
## Rows
|
||||
|
||||
@@ -128,7 +128,7 @@ The TUI column fills in during M3.
|
||||
| PRC-061 | Typed bulk load default values, searchable value mapping and the grouped, searchable Add Fields menu (Material | todo | todo | |
|
||||
| PRC-062 | Composite INPUT_FIELD metadata editors share process validation and multipart submission | n/a | todo | composite editors |
|
||||
| PRC-063 | INPUT_FIELD autofocus and trimmed Enter values on subsequent process screens | n/a | todo | |
|
||||
| PRC-064 | DATE_TIME process inputs display local time and submit UTC instants | todo | todo | DATE_TIME local to UTC |
|
||||
| PRC-064 | DATE_TIME process inputs display local time and submit UTC instants | pass | todo | process values typed local, sent UTC; screens show the local zone |
|
||||
| PRC-065 | Configured Google Drive shared-folder selection and process submission | n/a | n/a | Google Drive |
|
||||
| PRC-066 | INPUT_FIELD case behaviors preserve caret and submitted process values | pass | todo | field behaviors applied before submit |
|
||||
| QRY-090 | Column menu sorts, pins, opens a field filter and hides a column | n/a | todo | saved views and column UI |
|
||||
@@ -148,7 +148,7 @@ The TUI column fills in during M3.
|
||||
| QRY-010 | Text operators match Material labels and backend semantics | pass | todo | all operators and shorthand |
|
||||
| QRY-011 | Number operators including ranges and lists | pass | todo | BETWEEN, IN lists |
|
||||
| QRY-012 | Date operators and relative date expressions | pass | todo | now(), now(-7d), thisPeriod(), startOf() |
|
||||
| QRY-013 | Date-time operators convert local input to UTC and support relative values | todo | todo | DATE_TIME local to UTC conversion |
|
||||
| QRY-013 | Date-time operators convert local input to UTC and support relative values | pass | todo | --where/--group DATE_TIME values local to UTC (a date is local midnight); expressions as before |
|
||||
| QRY-014 | Boolean, blob and long-text operators | pass | todo | IS_BLANK, TRUE, FALSE |
|
||||
| QRY-015 | Possible-value selectors for table and enum sources show labels and send ids | todo | todo | filters take ids; labels in filters todo |
|
||||
| QRY-016 | AND/OR and nested groups combine as declared | pass | todo | --group with parentheses |
|
||||
@@ -227,7 +227,7 @@ The TUI column fills in during M3.
|
||||
| REC-015 | INTEGER, LONG and DECIMAL editors keep exact values | pass | todo | |
|
||||
| REC-016 | BOOLEAN editor applies the metadata default and saves true/false | pass | todo | defaults applied |
|
||||
| REC-017 | DATE and TIME editors round-trip values and clearing | pass | todo | |
|
||||
| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | todo | todo | DATE_TIME local to UTC |
|
||||
| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | pass | todo | typed and shown in TZ, stored UTC, --utc for raw; DST edges tested |
|
||||
| REC-019 | Zoned DATE_TIME display behaviors (fixed zone and per-record zone) | n/a | todo | display zones |
|
||||
| REC-020 | HTML field edits in the rich-text editor and renders sanitized | todo | todo | raw HTML; $EDITOR todo |
|
||||
| REC-021 | PASSWORD is masked on read and never overwritten by the mask | pass | todo | server masks |
|
||||
@@ -243,7 +243,7 @@ The TUI column fills in during M3.
|
||||
| REC-031 | CODE_EDITOR adornment: code view with formatting and a code editor on forms | partial | todo | raw code on view; no code editor (unit-tested) |
|
||||
| REC-032 | RENDER_HTML adornment renders sanitized HTML | partial | todo | HTML shown as text (unit-tested) |
|
||||
| REC-033 | REVEAL adornment masks, reveals and copies the value | partial | todo | masked until --reveal; no copy (unit-tested; none in the sample) |
|
||||
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | partial | todo | name plus qqq table download via the field route (unit-tested); live only for BLOB values, server finding 15 |
|
||||
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | partial | todo | name plus qqq table download via the field route (unit-tested); live only for BLOB values, server finding 16 |
|
||||
| REC-035 | FILE_UPLOAD adornment formats, replacing and removing a stored file | partial | todo | create/update --upload field=path as multipart, replace and remove verified on a BLOB; FILE_UPLOAD formats not in the sample |
|
||||
| REC-036 | TOOLTIP adornment shows its text on the value | partial | todo | tooltip text in parentheses after the value (unit-tested) |
|
||||
| REC-037 | WIDGET adornment renders the field value as the named widget | partial | todo | value rendered through the widget render model as text (unit-tested) |
|
||||
@@ -274,7 +274,7 @@ The TUI column fills in during M3.
|
||||
| REC-060 | Material table and field form adjusters and field rules operate against the real QQQ sample server | todo | todo | adjusters wired, no sample to test |
|
||||
| REC-064 | Record sections render qualified fields from readable exposed joins | todo | todo | joins |
|
||||
| REC-063 | Record view, edit and copy load from the table variant selected on the query screen | todo | todo | variants |
|
||||
| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | todo | todo | |
|
||||
| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | pass | todo | an unchanged wall-clock time keeps the original instant and its precision |
|
||||
| SEC-001 | Table hidden from a user (DenyBehavior.HIDDEN): absent from navigation, direct link loads nothing, backend ref | pass | todo | |
|
||||
| SEC-002 | Table disabled for a user (DenyBehavior.DISABLED): listed, but list/record/create/edit links explain the denia | todo | todo | disabled tables listed with a reason |
|
||||
| SEC-003 | Insert permission denied: create not offered, create link refused, backend writes nothing | pass | todo | |
|
||||
|
||||
@@ -19,5 +19,6 @@ the sample app and MOCK authentication. Each is a candidate issue for `QRun-IO/q
|
||||
| 12 | `POST /table/{t}/export` answers 500 `INTERNAL_ERROR` for an unsupported `format` (`Unsupported report format: pdf.`) and for an unknown name in `fieldNames` (`Field name bogus was not found on the Person table.`). Both are request errors. | `qqq api exportTable --path tableName=person --body-json '{"format":"pdf"}'`; same with `"fieldNames":["bogus"]`. | `qqq table export` checks the format and the field names before sending (exit 2). A 400 with `fieldErrors` would be right. |
|
||||
| 13 | `POST /processes/{p}/{uuid}/cancel` answers `200 {}` for a run that declares no cancel step, and the run can still be stepped afterwards (its state is kept). A malformed UUID answers 500 `INTERNAL_ERROR` (`IllegalArgumentException`) instead of 400. | init `sleepInteractive`, cancel, then step `screen0`: it runs. `POST …/nonexistent-uuid/cancel`. | `qqq process run` treats 200 as cancelled and stops; nothing to do for the 500. A cancelled run should refuse further steps (409 or 404). |
|
||||
| 14 | The `isStepBack=true` query parameter of `stepProcess` (going back to `backStep`) is described in the `ProcessStepComplete.backStep` schema text but is not a declared parameter of the operation, so a spec-driven client cannot know it exists. | Spec: `stepProcess` lists only `tableVariant`. | The kernel sends `isStepBack=true` as the Next UI does. Declare the parameter. |
|
||||
| 15 | `downloadRecordFieldFile` on a BLOB field without adornments answers 500 `NullPointerException (Cannot invoke "java.util.List.stream()" because the return value of "QFieldMetaData.getAdornments()" is null)`. | `qqq table create fieldLab --set name=x --upload blobValue=./a.txt`, then `qqq api downloadRecordFieldFile --path tableName=fieldLab --path primaryKey=<id> --path fieldName=blobValue --path filename=a.txt -o out`. | `qqq table download` uses the route only for FILE_DOWNLOAD fields and decodes the record's base64 BLOB value otherwise. The route should treat no adornments as none (or answer 404/400). |
|
||||
| 16 | The sample app declares no field adornments other than LINK, no hidden fields or sections, no help content, no exposed joins, no composite or aliased associations and no table the mock user may not read, so CHIP, REVEAL, RENDER_HTML, CODE_EDITOR, ERROR, FILE_DOWNLOAD, WIDGET, TOOLTIP, help display, denied child reads and composite associations can only be checked with unit tests against the client. | `GET /metaData/table/{t}` for every sample table. | None needed in the client; a `fieldLab`-style adornment table in the sample (one field per adornment, a hidden section, help on a field, section and table, a composite association) would make these rows checkable live. |
|
||||
| 15 | A step that fails on missing input answers `ERROR` with only `error` (`Error message: Unable to get Cities by state. Missing required input.`), no `userFacingError`, and the field is not marked required in the step metadata, so a client cannot tell an input problem from a crash or ask for the field first. | `qqq process run placeAddressPick --no-prompt`. | The CLI exits 6 (validation) when the message says required input is missing or the error is user-facing, 1 otherwise. Mark the fields required, or return `userFacingError` (or a 400 with `fieldErrors`). |
|
||||
| 16 | `downloadRecordFieldFile` on a BLOB field without adornments answers 500 `NullPointerException (Cannot invoke "java.util.List.stream()" because the return value of "QFieldMetaData.getAdornments()" is null)`. | `qqq table create fieldLab --set name=x --upload blobValue=./a.txt`, then `qqq api downloadRecordFieldFile --path tableName=fieldLab --path primaryKey=<id> --path fieldName=blobValue --path filename=a.txt -o out`. | `qqq table download` uses the route only for FILE_DOWNLOAD fields and decodes the record's base64 BLOB value otherwise. The route should treat no adornments as none (or answer 404/400). |
|
||||
| 17 | The sample app declares no field adornments other than LINK, no hidden fields or sections, no help content, no exposed joins, no composite or aliased associations and no table the mock user may not read, so CHIP, REVEAL, RENDER_HTML, CODE_EDITOR, ERROR, FILE_DOWNLOAD, WIDGET, TOOLTIP, help display, denied child reads and composite associations can only be checked with unit tests against the client. | `GET /metaData/table/{t}` for every sample table. | None needed in the client; a `fieldLab`-style adornment table in the sample (one field per adornment, a hidden section, help on a field, section and table, a composite association) would make these rows checkable live. |
|
||||
@@ -7,7 +7,7 @@ import { mkdirSync, readFileSync } from 'node:fs'
|
||||
import { basename, join } from 'node:path'
|
||||
import type { Command } from 'commander'
|
||||
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
|
||||
import { BACK, FormInputError, FormSession, ProcessCancelledError, ProcessError, blocksToText, checkInputRecords, downloadProcessFile, formatTable, helpText, isMissingValue, processHelpText, runProcess, safeFileName, saveDownload, screenToText, summaryLines, type FieldMeta, type ProcessMeta, type RunOutcome, type ScreenBlock } from '@qrun-io/qqq-core'
|
||||
import { BACK, displayDateTime, formatValue, FormInputError, FormSession, ProcessCancelledError, ProcessError, blocksToText, checkInputRecords, downloadProcessFile, formatTable, helpText, isMissingValue, processHelpText, runProcess, safeFileName, saveDownload, screenToText, summaryLines, type FieldMeta, type ProcessMeta, type RunOutcome, type ScreenBlock } from '@qrun-io/qqq-core'
|
||||
import { UsageError, type Context } from '../context.js'
|
||||
import { ask, promptForm } from '../prompt.js'
|
||||
import { authenticatedFetch } from '../session.js'
|
||||
@@ -122,7 +122,7 @@ export function registerProcess(program: Command, ctx: () => Context): void {
|
||||
stepTimeoutMillis: Number(options.timeout),
|
||||
always: Boolean(options.interactive),
|
||||
signal: abort.signal,
|
||||
screen: { recordLimit: Number(options.recordLimit) },
|
||||
screen: { recordLimit: Number(options.recordLimit), format: (field, value, shown) => (field.type === 'DATE_TIME' ? displayDateTime(value, shown, c.dateTimeDisplay) : formatValue(field, value)) },
|
||||
onProgress: (e) => {
|
||||
if (options.quiet) return
|
||||
if (e.phase === 'init') c.note(`${meta.label ?? name}: starting`)
|
||||
@@ -144,6 +144,8 @@ export function registerProcess(program: Command, ctx: () => Context): void {
|
||||
const answer = await ask('Validate fully before running, or skip validation? [v/S] ', { signal: abort.signal })
|
||||
out.doFullValidation = /^v/i.test(answer) ? 'true' : 'false'
|
||||
given.doFullValidation = out.doFullValidation
|
||||
} else if (given.doFullValidation === undefined && values.validationSummary === undefined && review.supportsFullValidation && !canPrompt) {
|
||||
throw new UsageError(`Step "${step.label ?? step.name}" is a validation review: pass --validate or --skip-validation (or run on a terminal to be asked).`)
|
||||
} else if (values.validationSummary !== undefined && canPrompt && !c.options.yes) {
|
||||
const answer = await ask('Submit? [Y/n] ', { signal: abort.signal })
|
||||
if (/^n/i.test(answer)) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import type { Command } from 'commander'
|
||||
import {
|
||||
associationGroups, associationLabel, associationsOf, childListFields, fetchChildren, fileDownload, safeFileName, saveDownload, findAssociation, recordView, relationshipValues, tableViewHelp,
|
||||
type RecordLike, type TableMetaLike, type WidgetMetaLike,
|
||||
displayDateTime, type DisplayOptions, type RecordLike, type TableMetaLike, type WidgetMetaLike,
|
||||
} from '@qrun-io/qqq-core'
|
||||
import { UsageError, type Context } from '../context.js'
|
||||
import { authenticatedFetch } from '../session.js'
|
||||
@@ -55,7 +55,7 @@ export async function showRecord(c: Context, table: string, primaryKey: string,
|
||||
const record = await getRecord(c, table, primaryKey, Boolean(options.associations)) as unknown as RecordLike
|
||||
const widgets = (metaData.widgets ?? {}) as Record<string, WidgetMetaLike>
|
||||
const tableNames = Object.keys(metaData.tables ?? {})
|
||||
const sections = recordView(meta, record, { reveal: options.reveal, tableNames, widgets, helpContent: options.helpContent, tableName: table, primaryKey, empty: c.format === 'table' ? '--' : '' })
|
||||
const sections = recordView(meta, record, { reveal: options.reveal, tableNames, widgets, helpContent: options.helpContent, tableName: table, primaryKey, empty: c.format === 'table' ? '--' : '', dateTime: c.dateTimeDisplay })
|
||||
const rows: Json[] = []
|
||||
for (const section of sections) {
|
||||
if (options.helpContent && section.help) rows.push({ section: section.label, field: '(help)', value: section.help })
|
||||
@@ -105,7 +105,7 @@ export async function showRecord(c: Context, table: string, primaryKey: string,
|
||||
const relationship = relationshipValues(group.association, parentTable, group.parent, childTable)
|
||||
const binding = 'error' in relationship ? [] : Object.keys(relationship.values)
|
||||
const fields = childListFields(childTable, binding)
|
||||
const childRows = group.records.map((child) => Object.fromEntries(fields.map((f) => [f, valueOf(child, f)])))
|
||||
const childRows = group.records.map((child) => Object.fromEntries(fields.map((f) => [f, valueOf(child, f, childTable, c.dateTimeDisplay)])))
|
||||
process.stdout.write(`\n${indent}${title} (${group.records.length})\n`)
|
||||
if (childRows.length) c.print({ data: group.records, rows: childRows, columns: fields, headers: fields.map((f) => childTable.fields[f]?.label ?? f) })
|
||||
else process.stdout.write(`${indent} none\n`)
|
||||
@@ -113,7 +113,8 @@ export async function showRecord(c: Context, table: string, primaryKey: string,
|
||||
}
|
||||
}
|
||||
|
||||
function valueOf(record: RecordLike, field: string): string {
|
||||
function valueOf(record: RecordLike, field: string, table?: TableMetaLike, display?: DisplayOptions): string {
|
||||
if (table?.fields[field]?.type === 'DATE_TIME') return displayDateTime(record.values[field], record.displayValues?.[field], display)
|
||||
const value = record.displayValues?.[field] ?? record.values[field]
|
||||
return value === null || value === undefined ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
}
|
||||
@@ -171,7 +172,7 @@ export function registerRelated(program: Command, ctx: () => Context): void {
|
||||
const label = associationLabel(meta, association, { widgets, childTable })
|
||||
c.print({
|
||||
data: { association: association.name, table: childTable.name, count, skip: first, records },
|
||||
rows: records.map((r) => Object.fromEntries(fields.map((f) => [f, valueOf(r, f)]))),
|
||||
rows: records.map((r) => Object.fromEntries(fields.map((f) => [f, valueOf(r, f, childTable, c.dateTimeDisplay)]))),
|
||||
columns: fields,
|
||||
headers: fields.map((f) => childTable.fields[f]?.label ?? f),
|
||||
footer: `${label} of ${parent.recordLabel ?? `${meta.label} ${primaryKey}`}: ${records.length ? `${first + 1}-${first + records.length}` : 'none'} of ${count ?? '?'}${!options.all && count !== undefined && first + records.length < count ? `; next: --skip ${first + records.length}` : ''}`,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { displayDateTime, type DisplayOptions } from '@qrun-io/qqq-core'
|
||||
import { UsageError } from '../context.js'
|
||||
|
||||
export type Json = Record<string, unknown>
|
||||
@@ -29,11 +30,15 @@ export function readJson(source: string, flag: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
/** A record's value as text: its display value when the server sent one. */
|
||||
export function valueText(record: Json, field: string): string {
|
||||
const display = (record.displayValues as Json | undefined)?.[field]
|
||||
/**
|
||||
* A record's value as text: its display value when the server sent one. With the table's
|
||||
* metadata, DATE_TIME values are shown in the local zone (or raw with `display.utc`).
|
||||
*/
|
||||
export function valueText(record: Json, field: string, meta?: Json, display?: DisplayOptions): string {
|
||||
const shown = (record.displayValues as Json | undefined)?.[field]
|
||||
const raw = (record.values as Json | undefined)?.[field]
|
||||
const value = display ?? raw
|
||||
if ((((meta?.fields ?? {}) as Record<string, Json>)[field])?.type === 'DATE_TIME') return displayDateTime(raw, shown, display)
|
||||
const value = shown ?? raw
|
||||
if (value === null || value === undefined) return ''
|
||||
return typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { basename } from 'node:path'
|
||||
import type { Command } from 'commander'
|
||||
import { AssociationError, FormInputError, FormSession, buildFilter, combineWithQuickSearch, copyRecordValues, findRecordByKey, gotoKeys, isGotoKey, parentLinkValues, printFilter, quickSearchFields, quickSearchFilter, relatedRecordTarget, safeFileName, saveDownload, type FieldMeta, type QueryFilter, type RecordLike, type TableMetaLike } from '@qrun-io/qqq-core'
|
||||
import { AssociationError, FormInputError, FormSession, buildFilter, combineWithQuickSearch, copyRecordValues, filterDateTimesToUtc, findRecordByKey, gotoKeys, isGotoKey, parentLinkValues, printFilter, quickSearchFields, quickSearchFilter, relatedRecordTarget, safeFileName, saveDownload, type FieldMeta, type QueryFilter, type RecordLike, type TableMetaLike } from '@qrun-io/qqq-core'
|
||||
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
|
||||
import { promptForm } from '../prompt.js'
|
||||
import { authenticatedFetch } from '../session.js'
|
||||
@@ -33,8 +33,9 @@ async function tableMeta(c: Context, table: string): Promise<Json> {
|
||||
}
|
||||
|
||||
function filterFrom(options: QueryOptions, meta: Json, columns?: string[]): QueryFilter {
|
||||
// --where / --group values on DATE_TIME fields are local times (or dates: local midnight), sent as UTC instants; --filter-json is sent as given
|
||||
const filter = options.filterJson ? readJson(options.filterJson, '--filter-json') as QueryFilter
|
||||
: buildFilter({ where: options.where, group: options.group, or: options.or, order: options.order, knownFields: Object.keys((meta.fields ?? {}) as Json) })
|
||||
: filterDateTimesToUtc(buildFilter({ where: options.where, group: options.group, or: options.or, order: options.order, knownFields: Object.keys((meta.fields ?? {}) as Json) }), (meta.fields ?? {}) as Record<string, FieldMeta>)
|
||||
if (options.search === undefined) return filter
|
||||
// quick search as the web UI does it: CONTAINS on each shown string field, ORed, ANDed onto the filter
|
||||
const fields = quickSearchFields(meta, columns)
|
||||
@@ -128,7 +129,7 @@ export function registerTable(program: Command, ctx: () => Context): void {
|
||||
skip += limit
|
||||
}
|
||||
const fields = columnsFrom(c, options) ?? defaultFields(meta)
|
||||
const rows = records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r, f)])))
|
||||
const rows = records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r, f, meta, c.dateTimeDisplay)])))
|
||||
c.print({ data: records, rows, columns: fields, headers: labelsFor(meta, fields), footer: `(${records.length} record${records.length === 1 ? '' : 's'}${filter.criteria?.length || filter.subFilters?.length ? `; filter: ${printFilter(filter)}` : ''}${!options.all && records.length === limit ? '; more may exist: --limit, --skip or --all' : ''})` })
|
||||
})
|
||||
|
||||
@@ -301,7 +302,7 @@ export function registerTable(program: Command, ctx: () => Context): void {
|
||||
const copied = copyRecordValues(meta, sourceRecord)
|
||||
const given = parsePairs(options.set, '--set')
|
||||
const session = await fillForm(c, meta, name, given, { interactive: false }, copied, [String(meta.primaryKeyField)])
|
||||
for (const [field, label] of Object.entries((sourceRecord.displayValues ?? {}) as Json)) if (copied[field] !== undefined && given[field] === undefined && session.values[field] === copied[field]) session.displayValues[field] = String(label)
|
||||
for (const [field, label] of Object.entries((sourceRecord.displayValues ?? {}) as Json)) if (((meta.fields as Record<string, Json>)[field])?.type !== 'DATE_TIME' && copied[field] !== undefined && given[field] === undefined && session.values[field] === copied[field]) session.displayValues[field] = String(label)
|
||||
if (options.interactive) await promptForm(session, { skipSet: false })
|
||||
const problems = session.validate()
|
||||
if (Object.keys(problems).length) validationError(session, problems)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import { UiApiError, unwrap, type UiApiClient } from '@qrun-io/qqq-ui-api-client'
|
||||
import { FilterSyntaxError, OUTPUT_FORMATS, UnsafeFileNameError, applyQuery, callOperation, defaultFormat, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
|
||||
import { FilterSyntaxError, OUTPUT_FORMATS, ProcessError, UnsafeFileNameError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
|
||||
import { authenticatedFetch, clientFor, selectTarget, target, type Target } from './session.js'
|
||||
|
||||
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
|
||||
@@ -26,6 +26,7 @@ export interface GlobalOptions {
|
||||
yes?: boolean
|
||||
trace?: boolean
|
||||
noPager?: boolean
|
||||
utc?: boolean
|
||||
}
|
||||
|
||||
export class Context {
|
||||
@@ -63,6 +64,11 @@ export class Context {
|
||||
return defaultFormat(Boolean(process.stdout.isTTY))
|
||||
}
|
||||
|
||||
/** How DATE_TIME values are shown: in the TZ zone, or raw UTC instants with --utc. */
|
||||
get dateTimeDisplay(): DisplayOptions {
|
||||
return { utc: Boolean(this.options.utc), timeZone: defaultTimeZone() }
|
||||
}
|
||||
|
||||
/** `--fields a,b,c` as a list, or undefined. */
|
||||
get fields(): string[] | undefined {
|
||||
return this.options.fields ? this.options.fields.split(',').map((f) => f.trim()).filter(Boolean) : undefined
|
||||
@@ -135,6 +141,7 @@ export function describeError(error: unknown, trace = false): { code: number; te
|
||||
: error.status >= 500 ? EXIT.unexpected : EXIT.error
|
||||
return { code, text: lines.join('\n') }
|
||||
}
|
||||
if (error instanceof ProcessError) return { code: error.isInputError ? EXIT.validation : EXIT.error, text: `Error: ${error.message}` }
|
||||
if (error instanceof TypeError && /fetch failed|ECONNREFUSED|ENOTFOUND|network/i.test(String((error as Error & { cause?: unknown }).cause ?? error.message))) {
|
||||
return { code: EXIT.transient, text: `Error: cannot reach the server (${String((error as Error & { cause?: { message?: string } }).cause?.message ?? error.message)})` }
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ program
|
||||
.option('-Q, --query <jmespath>', 'reshape the output with a JMESPath expression, as `aws --query`')
|
||||
.option('--fields <names>', 'comma-separated columns to show in table, text and csv output')
|
||||
.option('-y, --yes', 'answer yes to confirmations (delete, cancel)')
|
||||
.option('--utc', 'show DATE_TIME values as raw UTC instants instead of in the local zone (TZ)')
|
||||
.option('--trace', 'print stack traces for unexpected errors')
|
||||
.exitOverride()
|
||||
.configureOutput({ writeErr: (text) => process.stderr.write(text) })
|
||||
|
||||
@@ -73,7 +73,7 @@ async function promptField(rl: Interface, out: NodeJS.WritableStream, session: F
|
||||
return
|
||||
}
|
||||
const current = state.displayValue ?? (isMissingValue(state.value) ? '' : String(state.value))
|
||||
let hint = field.type.toLowerCase().replace('_', ' ')
|
||||
let hint = field.type === 'DATE_TIME' ? `local date time YYYY-MM-DDTHH:MM, ${session.timeZone}` : field.type.toLowerCase().replace('_', ' ')
|
||||
if (session.hasChoices(field.name)) {
|
||||
const choices = await session.options(field.name, '', 12)
|
||||
if (choices.length) {
|
||||
|
||||
@@ -11,6 +11,7 @@ what it produces; the TUI draws it.
|
||||
| `forms/fields` | field metadata helpers: contingent-field dependencies and the DISABLE_UNTIL_SET rule, behaviors, validation that mirrors the server's rules |
|
||||
| `forms/possible-values` | possible-value search with dependency values; resolution of typed text by id, exact label or unique partial |
|
||||
| `forms/adjuster` | form adjusters (`onLoad`, `onChange`; 404 means none) and how their output is applied |
|
||||
| `forms/datetime` | DATE_TIME between people and the server: local wall-clock text to UTC instants in a zone (DST gaps forward, repeated hours first), instants shown in the zone, server zoned display kept, filter criteria converted |
|
||||
| `forms/form-session` | one form being filled: values, display values, clearing dependents, adjusters, validation, submission |
|
||||
| `process/runner` | init, step until complete, poll asynchronous jobs, apply metadata adjustments, ask a provider for each step's input, go back (`BACK`), cancel on an abort signal (`POST …/cancel`), check min/max input records |
|
||||
| `process/screen` | each step's components as neutral screen blocks: help, HTML as text, view pairs, process records (paged), validation review, summary lines, downloads (`GET /download`), widgets |
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* DATE_TIME values between people and the server. The server stores and sends UTC instants
|
||||
* (`2026-10-31T19:30:00Z`); people type and read wall-clock times in their own zone. Typed local
|
||||
* times (`2026-10-31T14:30`, `2026-10-31 14:30:15`) become UTC instants; instants are shown in the
|
||||
* zone (the TZ environment, or an explicit IANA zone). Daylight-saving edges follow what a clock
|
||||
* does: a time skipped by a spring-forward gap moves forward by the gap, and a time repeated by a
|
||||
* fall-back hour means its first occurrence (the earlier instant), the same choices JavaScript's
|
||||
* `Date` makes. No terminal API is used here.
|
||||
*/
|
||||
|
||||
import type { FieldMeta, FieldValues } from './fields.js'
|
||||
import type { QueryFilter } from '../filter/grammar.js'
|
||||
|
||||
/** An ISO instant with a zone designator (Z or an offset). */
|
||||
const ZONED_ISO = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i
|
||||
|
||||
/** A local wall-clock date and time without a zone. */
|
||||
const LOCAL = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(\.\d{1,9})?)?$/
|
||||
|
||||
/** A date alone. */
|
||||
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/
|
||||
|
||||
/** A display value the server already formatted in a declared zone: `2026-10-31 2:30:00 PM CDT`. */
|
||||
const SERVER_ZONED = /^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}(:\d{2})? [AP]M \S+$/i
|
||||
|
||||
/** The zone in effect: the TZ environment as the runtime resolved it, else UTC. */
|
||||
export function defaultTimeZone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||
} catch {
|
||||
return 'UTC'
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a zone name is one this runtime knows. */
|
||||
export function isTimeZone(zone: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: zone })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const formats = new Map<string, Intl.DateTimeFormat>()
|
||||
function wallFormat(timeZone: string): Intl.DateTimeFormat {
|
||||
let format = formats.get(timeZone)
|
||||
if (!format) {
|
||||
format = new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short' })
|
||||
formats.set(timeZone, format)
|
||||
}
|
||||
return format
|
||||
}
|
||||
|
||||
interface Wall { year: number; month: number; day: number; hour: number; minute: number; second: number; zoneName: string }
|
||||
|
||||
/** The wall clock of an instant in a zone. */
|
||||
function wallOf(ms: number, timeZone: string): Wall {
|
||||
const parts = Object.fromEntries(wallFormat(timeZone).formatToParts(new Date(ms)).map((p) => [p.type, p.value]))
|
||||
return { year: Number(parts.year), month: Number(parts.month), day: Number(parts.day), hour: Number(parts.hour) % 24, minute: Number(parts.minute), second: Number(parts.second), zoneName: String(parts.timeZoneName ?? '') }
|
||||
}
|
||||
|
||||
/** The zone's offset from UTC at an instant, in milliseconds (positive east of Greenwich). */
|
||||
export function zoneOffset(ms: number, timeZone: string): number {
|
||||
const w = wallOf(ms, timeZone)
|
||||
const whole = Math.floor(ms / 1000) * 1000
|
||||
return Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second) - whole
|
||||
}
|
||||
|
||||
/** The instant a wall-clock time names in a zone (DST gaps move forward; repeated hours take the first). */
|
||||
export function wallTimeToInstant(year: number, month: number, day: number, hour: number, minute: number, second: number, millis: number, timeZone: string): number {
|
||||
const wall = Date.UTC(year, month - 1, day, hour, minute, second, millis)
|
||||
const day1 = 86_400_000
|
||||
const offsets = [...new Set([zoneOffset(wall - day1, timeZone), zoneOffset(wall, timeZone), zoneOffset(wall + day1, timeZone)])]
|
||||
const valid = offsets.map((o) => wall - o).filter((t) => zoneOffset(t, timeZone) === wall - t).sort((a, b) => a - b)
|
||||
if (valid.length) return valid[0]!
|
||||
// a gap: read the time with the offset in force before it, as a clock moved forward would
|
||||
return wall - zoneOffset(wall - day1, timeZone)
|
||||
}
|
||||
|
||||
/** Formats epoch milliseconds as a UTC instant, keeping fractional seconds only when present. */
|
||||
function isoInstant(ms: number, fraction?: string): string {
|
||||
const base = new Date(Math.floor(ms / 1000) * 1000).toISOString().replace(/\.000Z$/, '')
|
||||
return `${base}${fraction ?? ''}Z`
|
||||
}
|
||||
|
||||
export interface ToUtcOptions {
|
||||
timeZone?: string
|
||||
/**
|
||||
* The value before editing: when the typed text names the same wall-clock time, the original
|
||||
* instant comes back unchanged (its offset in a repeated hour, and precision finer than seconds).
|
||||
*/
|
||||
original?: unknown
|
||||
/** A date alone (`2026-10-31`) means local midnight (filters); otherwise it is not converted. */
|
||||
dateMeansMidnight?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts what a person typed for a DATE_TIME to the UTC instant the server takes. Zoned instants
|
||||
* pass through; local wall-clock times are read in the zone; anything else is returned unchanged
|
||||
* for validation to report.
|
||||
*/
|
||||
export function localToUtc(text: string, options: ToUtcOptions = {}): string {
|
||||
const value = text.trim()
|
||||
if (!value) return value
|
||||
if (ZONED_ISO.test(value)) return value.replace(' ', 'T')
|
||||
const timeZone = options.timeZone ?? defaultTimeZone()
|
||||
let match = LOCAL.exec(value)
|
||||
if (!match && options.dateMeansMidnight) {
|
||||
const d = DATE_ONLY.exec(value)
|
||||
if (d) match = [value, d[1]!, d[2]!, d[3]!, '00', '00', '00', ''] as unknown as RegExpExecArray
|
||||
}
|
||||
if (!match) return value
|
||||
const [, y, mo, d, h, mi, s, fraction] = match
|
||||
if (isInstant(options.original) && utcToLocalInput(String(options.original), timeZone) === `${y}-${mo}-${d}T${h}:${mi}:${s ?? '00'}` && !fraction) return String(options.original)
|
||||
const ms = wallTimeToInstant(Number(y), Number(mo), Number(d), Number(h), Number(mi), Number(s ?? 0), 0, timeZone)
|
||||
return isoInstant(ms, fraction || undefined)
|
||||
}
|
||||
|
||||
/** Whether a value is a zoned ISO instant. */
|
||||
export function isInstant(value: unknown): value is string {
|
||||
return typeof value === 'string' && ZONED_ISO.test(value.trim()) && !Number.isNaN(Date.parse(value.trim().replace(' ', 'T')))
|
||||
}
|
||||
|
||||
/** An instant as local `YYYY-MM-DDTHH:MM:SS` in the zone (what an editor pre-fills). */
|
||||
export function utcToLocalInput(instant: string, timeZone: string = defaultTimeZone()): string {
|
||||
if (!isInstant(instant)) return instant
|
||||
const w = wallOf(Date.parse(instant.trim().replace(' ', 'T')), timeZone)
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${w.year}-${p(w.month)}-${p(w.day)}T${p(w.hour)}:${p(w.minute)}:${p(w.second)}`
|
||||
}
|
||||
|
||||
/** An instant for people: `2026-10-31 14:30:00 CDT` in the zone; other values unchanged. */
|
||||
export function utcToLocal(instant: string, timeZone: string = defaultTimeZone()): string {
|
||||
if (!isInstant(instant)) return instant
|
||||
const w = wallOf(Date.parse(instant.trim().replace(' ', 'T')), timeZone)
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${w.year}-${p(w.month)}-${p(w.day)} ${p(w.hour)}:${p(w.minute)}:${p(w.second)} ${w.zoneName}`
|
||||
}
|
||||
|
||||
export interface DisplayOptions {
|
||||
timeZone?: string
|
||||
/** Show the raw UTC instant (`--utc`). */
|
||||
utc?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The text to show for a DATE_TIME value: the server's display value when it is formatted in a
|
||||
* declared zone (a field with a fixed or per-record zone), else the instant in the viewer's zone,
|
||||
* else the raw value (`utc`).
|
||||
*/
|
||||
export function displayDateTime(value: unknown, displayValue?: unknown, options: DisplayOptions = {}): string {
|
||||
if (value === null || value === undefined || value === '') return displayValue === null || displayValue === undefined ? '' : String(displayValue)
|
||||
if (options.utc) return String(value)
|
||||
if (typeof displayValue === 'string' && SERVER_ZONED.test(displayValue.trim())) return displayValue.trim()
|
||||
return isInstant(value) ? utcToLocal(value, options.timeZone) : String(displayValue ?? value)
|
||||
}
|
||||
|
||||
/** Converts the DATE_TIME values among `values` (by field type) to UTC instants; returns a new object. */
|
||||
export function dateTimesToUtc(fields: Iterable<FieldMeta>, values: FieldValues, options: Omit<ToUtcOptions, 'original'> & { originals?: FieldValues } = {}): FieldValues {
|
||||
const out = { ...values }
|
||||
for (const field of fields) {
|
||||
if (field.type !== 'DATE_TIME') continue
|
||||
const v = out[field.name]
|
||||
if (typeof v === 'string') out[field.name] = localToUtc(v, { ...options, original: options.originals?.[field.name] })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the values of criteria on DATE_TIME fields (local times and dates) to UTC instants,
|
||||
* in place through sub-filters. Expression values (`now(-7d)`, `thisPeriod(month)`) are left to the server.
|
||||
*/
|
||||
export function filterDateTimesToUtc<F extends Pick<QueryFilter, 'criteria'> & { subFilters?: unknown[] }>(filter: F, fields: Record<string, Pick<FieldMeta, 'type'>>, timeZone: string = defaultTimeZone()): F {
|
||||
for (const criterion of filter.criteria ?? []) {
|
||||
if (fields[criterion.fieldName]?.type !== 'DATE_TIME') continue
|
||||
criterion.values = (criterion.values ?? []).map((v) => (typeof v === 'string' ? localToUtc(v, { timeZone, dateMeansMidnight: true }) : v)) as typeof criterion.values
|
||||
}
|
||||
for (const sub of filter.subFilters ?? []) filterDateTimesToUtc(sub as F, fields, timeZone)
|
||||
return filter
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export function validateValue(field: FieldMeta, raw: unknown): string | undefine
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(text) || Number.isNaN(Date.parse(text))) return 'must be a date, YYYY-MM-DD'
|
||||
break
|
||||
case 'DATE_TIME':
|
||||
if (Number.isNaN(Date.parse(text))) return 'must be a date and time, YYYY-MM-DDTHH:MM'
|
||||
if (!/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/i.test(text) || Number.isNaN(Date.parse(text.replace(' ', 'T')))) return 'must be a date and time, YYYY-MM-DDTHH:MM (local time) or an ISO instant'
|
||||
break
|
||||
case 'TIME':
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/.test(text)) return 'must be a time, HH:MM'
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { applyAdjustment, fieldAdjusterIdentifier, hasTableOnLoadAdjuster, runFormAdjuster, type FormAdjusterOutput } from './adjuster.js'
|
||||
import { applyBehaviors, dependentsOf, dependencyValues, getDependencies, isContingentlyDisabled, isMissingValue, missingDependencies, validateValues, type FieldMeta, type FieldValues, type PossibleValue } from './fields.js'
|
||||
import { defaultTimeZone, isInstant, localToUtc, utcToLocal } from './datetime.js'
|
||||
import { resolvePossibleValue, searchPossibleValues, type FormScope } from './possible-values.js'
|
||||
|
||||
export interface FormSessionOptions {
|
||||
@@ -25,6 +26,8 @@ export interface FormSessionOptions {
|
||||
tableMeta?: { supplementalMetaData?: Record<string, unknown> }
|
||||
/** Fields never offered for input (the primary key on create). */
|
||||
exclude?: string[]
|
||||
/** The zone DATE_TIME values are typed and shown in (default: the TZ environment). */
|
||||
timeZone?: string
|
||||
}
|
||||
|
||||
export interface FieldState {
|
||||
@@ -56,6 +59,9 @@ export class FormSession {
|
||||
private readonly fetchImpl: typeof fetch
|
||||
private readonly tableMeta?: FormSessionOptions['tableMeta']
|
||||
private readonly exclude: Set<string>
|
||||
/** The zone typed DATE_TIME values are read in. */
|
||||
readonly timeZone: string
|
||||
private readonly originals: FieldValues
|
||||
|
||||
constructor(options: FormSessionOptions) {
|
||||
this.baseUrl = options.baseUrl
|
||||
@@ -66,7 +72,11 @@ export class FormSession {
|
||||
this.order = options.fields.map((f) => f.name)
|
||||
this.fields = Object.fromEntries(options.fields.map((f) => [f.name, f]))
|
||||
this.values = { ...(options.initialValues ?? {}) }
|
||||
this.originals = { ...(options.initialValues ?? {}) }
|
||||
this.timeZone = options.timeZone ?? defaultTimeZone()
|
||||
for (const f of options.fields) if (this.values[f.name] === undefined && f.defaultValue !== undefined && f.defaultValue !== null) this.values[f.name] = f.defaultValue
|
||||
// DATE_TIME instants are shown in the local zone
|
||||
for (const f of options.fields) if (f.type === 'DATE_TIME' && isInstant(this.values[f.name])) this.displayValues[f.name] = utcToLocal(String(this.values[f.name]), this.timeZone)
|
||||
}
|
||||
|
||||
/** Runs the table's on-load adjuster when it declares one. */
|
||||
@@ -163,6 +173,12 @@ export class FormSession {
|
||||
if (isContingentlyDisabled(field, this.values)) throw new FormInputError(`${field.label}: ${this.state(name).disabledBecause}`)
|
||||
let value: unknown = input
|
||||
if (typeof input === 'string') value = applyBehaviors(field, input)
|
||||
if (field.type === 'DATE_TIME' && typeof value === 'string' && !isMissingValue(value)) {
|
||||
// typed local time to the UTC instant the server stores; an unchanged wall-clock time keeps the original instant
|
||||
value = localToUtc(value, { timeZone: this.timeZone, original: this.originals[name] })
|
||||
if (isInstant(value)) this.displayValues[name] = utcToLocal(String(value), this.timeZone)
|
||||
else delete this.displayValues[name]
|
||||
}
|
||||
if (this.hasChoices(name) && typeof value === 'string' && !isMissingValue(value)) {
|
||||
const resolved = await this.resolveChoice(name, value)
|
||||
value = resolved.id
|
||||
|
||||
@@ -23,6 +23,7 @@ export * from './records/variants.js'
|
||||
export * from './metadata/navigation.js'
|
||||
export * from './help/help-content.js'
|
||||
export * from './process/screen.js'
|
||||
export * from './forms/datetime.js'
|
||||
export * from './records/associations.js'
|
||||
export * from './records/record-view.js'
|
||||
export * from './render/files.js'
|
||||
@@ -77,8 +77,19 @@ export interface RunnerOptions {
|
||||
export const BACK: unique symbol = Symbol('qqq.process.back')
|
||||
|
||||
export class ProcessError extends Error {
|
||||
constructor(message: string, readonly processUUID?: string) {
|
||||
/** The server marked the message as meant for the user (userFacingError). */
|
||||
readonly userFacing: boolean
|
||||
constructor(message: string, readonly processUUID?: string, options: { userFacing?: boolean } = {}) {
|
||||
super(message)
|
||||
this.userFacing = options.userFacing ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the failure is about the input (exit 6 in the CLI): a user-facing message, or the
|
||||
* server's "Missing required input" for inputs its metadata does not mark required.
|
||||
*/
|
||||
get isInputError(): boolean {
|
||||
return this.userFacing || /missing required input|is required/i.test(this.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +242,7 @@ export async function runProcess(options: RunnerOptions): Promise<RunOutcome> {
|
||||
checkAbort()
|
||||
current = (await callOperation(baseUrl, 'getProcessStatus', { path: { processName: meta.name, processUUID: current.processUUID, jobUUID: jobUUID! }, query }, fetchImpl)).data as StepResponse
|
||||
}
|
||||
if (current.type === 'ERROR') throw new ProcessError(current.userFacingError ?? current.error ?? 'the process failed', current.processUUID)
|
||||
if (current.type === 'ERROR') throw new ProcessError(current.userFacingError ?? current.error ?? 'the process failed', current.processUUID, { userFacing: Boolean(current.userFacingError) })
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@ export interface ScreenOptions {
|
||||
/** Records previewed on a validation review screen; default 10 (0: none). */
|
||||
previewLimit?: number
|
||||
pageSize?: number
|
||||
/** Formats a value for display (DATE_TIME in the local zone, etc.); default {@link formatValue}. */
|
||||
format?: (field: FieldMeta, value: unknown) => string
|
||||
/** Formats a value for display (DATE_TIME in the local zone, etc.), given the server's display value when there is one; default {@link formatValue}. */
|
||||
format?: (field: FieldMeta, value: unknown, displayValue?: unknown) => string
|
||||
tableVariant?: string
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ export function processHelpText(meta: { helpContents?: Record<string, ScreenStep
|
||||
return Object.values(meta.helpContents ?? {}).flatMap((entries) => helpText(entries))
|
||||
}
|
||||
|
||||
function recordTable(fields: FieldMeta[], records: Array<Record<string, unknown>>, format: (f: FieldMeta, v: unknown) => string): { columns: string[]; headers: string[]; rows: Row[] } {
|
||||
function recordTable(fields: FieldMeta[], records: Array<Record<string, unknown>>, format: (f: FieldMeta, v: unknown, d?: unknown) => string): { columns: string[]; headers: string[]; rows: Row[] } {
|
||||
const shown = fields.filter((f) => !f.isHidden)
|
||||
const columns = shown.length ? shown.map((f) => f.name) : Object.keys((records[0]?.values ?? {}) as Record<string, unknown>).slice(0, 8)
|
||||
const byName = Object.fromEntries(shown.map((f) => [f.name, f]))
|
||||
@@ -166,8 +166,8 @@ function recordTable(fields: FieldMeta[], records: Array<Record<string, unknown>
|
||||
const display = (r.displayValues ?? {}) as Record<string, unknown>
|
||||
return Object.fromEntries(columns.map((c) => {
|
||||
const field = byName[c] ?? { name: c, label: c, type: 'STRING' }
|
||||
// the server's display value wins, except for DATE_TIME, which the client shows in the local zone
|
||||
const text = field.type !== 'DATE_TIME' && display[c] !== undefined && display[c] !== null ? String(display[c]) : format(field, values[c])
|
||||
// the server's display value wins, except for DATE_TIME, which the formatter shows in the local zone (or the server's declared zone)
|
||||
const text = field.type !== 'DATE_TIME' && display[c] !== undefined && display[c] !== null ? String(display[c]) : format(field, values[c], display[c])
|
||||
return [c, text]
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import type { FieldMeta } from '../forms/fields.js'
|
||||
import { blocksToText, htmlToText, widgetToBlocks } from '../widgets/render-model.js'
|
||||
import { displayDateTime, type DisplayOptions } from '../forms/datetime.js'
|
||||
import type { RecordLike, SectionMeta, TableMetaLike } from './associations.js'
|
||||
|
||||
export interface HelpContent {
|
||||
@@ -164,6 +165,8 @@ export interface ValueOptions {
|
||||
widgets?: Record<string, unknown>
|
||||
/** Text placeholder for empty values (default empty). */
|
||||
empty?: string
|
||||
/** DATE_TIME display: the viewer's zone (default), another zone, or the raw UTC instant (`--utc`). */
|
||||
dateTime?: DisplayOptions
|
||||
}
|
||||
|
||||
/** A field value rendered for a terminal. */
|
||||
@@ -243,6 +246,8 @@ function valueTextWithoutTooltip(field: FieldMeta, record: RecordLike, options:
|
||||
|
||||
if (value === null || value === undefined || value === '') return { text: empty }
|
||||
|
||||
if (field.type === 'DATE_TIME') return { text: displayDateTime(raw, display, options.dateTime) }
|
||||
|
||||
if (field.type === 'BLOB' && typeof raw === 'string') {
|
||||
const bytes = Math.floor(raw.replace(/=+$/, '').length * 3 / 4)
|
||||
const hint = options.tableName ? ` (qqq table download ${options.tableName} ${options.primaryKey ?? ''} ${field.name})`.replace(' ', ' ') : ''
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { dateTimesToUtc, displayDateTime, filterDateTimesToUtc, localToUtc, utcToLocal, utcToLocalInput, wallTimeToInstant } from '../forms/datetime.js'
|
||||
import type { QueryFilter } from '../filter/grammar.js'
|
||||
|
||||
const CHI = 'America/Chicago'
|
||||
|
||||
test('local wall-clock times become UTC instants in the given zone', () => {
|
||||
assert.equal(localToUtc('2026-10-31T14:30', { timeZone: CHI }), '2026-10-31T19:30:00Z')
|
||||
assert.equal(localToUtc('2026-10-31 14:30:15', { timeZone: CHI }), '2026-10-31T19:30:15Z')
|
||||
assert.equal(localToUtc('2026-01-15T09:00', { timeZone: CHI }), '2026-01-15T15:00:00Z')
|
||||
assert.equal(localToUtc('2026-07-01T12:00', { timeZone: 'Asia/Kolkata' }), '2026-07-01T06:30:00Z')
|
||||
assert.equal(localToUtc('2026-07-01T12:00', { timeZone: 'UTC' }), '2026-07-01T12:00:00Z')
|
||||
assert.equal(localToUtc('2026-07-01T12:00:00.123456', { timeZone: 'UTC' }), '2026-07-01T12:00:00.123456Z')
|
||||
})
|
||||
|
||||
test('zoned instants pass through and unparseable text is left for validation', () => {
|
||||
assert.equal(localToUtc('2026-10-31T19:30:00Z', { timeZone: CHI }), '2026-10-31T19:30:00Z')
|
||||
assert.equal(localToUtc('2026-10-31T14:30:00-05:00', { timeZone: 'UTC' }), '2026-10-31T14:30:00-05:00')
|
||||
assert.equal(localToUtc('next tuesday', { timeZone: CHI }), 'next tuesday')
|
||||
assert.equal(localToUtc('2026-10-31', { timeZone: CHI }), '2026-10-31')
|
||||
assert.equal(localToUtc('2026-10-31', { timeZone: CHI, dateMeansMidnight: true }), '2026-10-31T05:00:00Z')
|
||||
})
|
||||
|
||||
test('spring forward: a time in the gap moves forward by the gap (Chicago, 2026-03-08 02:00 to 03:00)', () => {
|
||||
assert.equal(localToUtc('2026-03-08T01:59', { timeZone: CHI }), '2026-03-08T07:59:00Z')
|
||||
assert.equal(localToUtc('2026-03-08T02:30', { timeZone: CHI }), '2026-03-08T08:30:00Z')
|
||||
assert.equal(utcToLocal('2026-03-08T08:30:00Z', CHI), '2026-03-08 03:30:00 CDT')
|
||||
assert.equal(localToUtc('2026-03-08T03:00', { timeZone: CHI }), '2026-03-08T08:00:00Z')
|
||||
})
|
||||
|
||||
test('fall back: a repeated time means its first occurrence (Chicago, 2026-11-01 01:00 to 02:00 twice)', () => {
|
||||
assert.equal(localToUtc('2026-11-01T01:30', { timeZone: CHI }), '2026-11-01T06:30:00Z')
|
||||
assert.equal(utcToLocal('2026-11-01T06:30:00Z', CHI), '2026-11-01 01:30:00 CDT')
|
||||
assert.equal(utcToLocal('2026-11-01T07:30:00Z', CHI), '2026-11-01 01:30:00 CST')
|
||||
assert.equal(localToUtc('2026-11-01T02:30', { timeZone: CHI }), '2026-11-01T08:30:00Z')
|
||||
// the second 01:30 survives an edit that leaves the wall-clock text alone
|
||||
assert.equal(localToUtc('2026-11-01T01:30:00', { timeZone: CHI, original: '2026-11-01T07:30:00Z' }), '2026-11-01T07:30:00Z')
|
||||
})
|
||||
|
||||
test('southern hemisphere and Europe: Sydney and London DST edges', () => {
|
||||
// Sydney leaves DST 2026-04-05 03:00 -> 02:00; enters 2026-10-04 02:00 -> 03:00
|
||||
assert.equal(localToUtc('2026-04-05T02:30', { timeZone: 'Australia/Sydney' }), '2026-04-04T15:30:00Z')
|
||||
assert.equal(localToUtc('2026-10-04T02:30', { timeZone: 'Australia/Sydney' }), '2026-10-03T16:30:00Z')
|
||||
// London enters BST 2026-03-29 01:00 -> 02:00
|
||||
assert.equal(localToUtc('2026-03-29T01:30', { timeZone: 'Europe/London' }), '2026-03-29T01:30:00Z')
|
||||
assert.equal(localToUtc('2026-03-29T00:30', { timeZone: 'Europe/London' }), '2026-03-29T00:30:00Z')
|
||||
assert.equal(localToUtc('2026-03-29T02:30', { timeZone: 'Europe/London' }), '2026-03-29T01:30:00Z')
|
||||
})
|
||||
|
||||
test('unchanged instants keep their precision through an edit', () => {
|
||||
const original = '2026-10-31T22:41:52.344419Z'
|
||||
const local = utcToLocalInput(original, CHI)
|
||||
assert.equal(local, '2026-10-31T17:41:52')
|
||||
assert.equal(localToUtc(local, { timeZone: CHI, original }), original)
|
||||
assert.equal(localToUtc('2026-10-31T17:42', { timeZone: CHI, original }), '2026-10-31T22:42:00Z')
|
||||
})
|
||||
|
||||
test('display: local zone, the server zoned display when given, raw with utc', () => {
|
||||
assert.equal(displayDateTime('2026-10-31T19:30:00Z', '2026-10-31T19:30:00Z', { timeZone: CHI }), '2026-10-31 14:30:00 CDT')
|
||||
assert.equal(displayDateTime('2026-10-31T19:30:00Z', '2026-10-31 3:30:00 PM EDT', { timeZone: CHI }), '2026-10-31 3:30:00 PM EDT')
|
||||
assert.equal(displayDateTime('2026-10-31T19:30:00Z', undefined, { timeZone: CHI, utc: true }), '2026-10-31T19:30:00Z')
|
||||
assert.equal(displayDateTime(null, undefined, { timeZone: CHI }), '')
|
||||
assert.match(displayDateTime('2026-12-01T12:00:00Z', undefined, { timeZone: 'Europe/London' }), /^2026-12-01 12:00:00 GMT/)
|
||||
})
|
||||
|
||||
test('only DATE_TIME fields and DATE_TIME criteria are converted', () => {
|
||||
const fields = [{ name: 'when', label: 'When', type: 'DATE_TIME' }, { name: 'day', label: 'Day', type: 'DATE' }, { name: 'note', label: 'Note', type: 'STRING' }]
|
||||
assert.deepEqual(dateTimesToUtc(fields, { when: '2026-10-31T14:30', day: '2026-10-31', note: '2026-10-31T14:30' }, { timeZone: CHI }),
|
||||
{ when: '2026-10-31T19:30:00Z', day: '2026-10-31', note: '2026-10-31T14:30' })
|
||||
const filter: QueryFilter = {
|
||||
criteria: [{ fieldName: 'when', operator: 'BETWEEN', values: ['2026-10-31', '2026-10-31T23:59'] }, { fieldName: 'day', operator: 'EQUALS', values: ['2026-10-31'] }],
|
||||
booleanOperator: 'AND',
|
||||
subFilters: [{ criteria: [{ fieldName: 'when', operator: 'GREATER_THAN', values: [{ type: 'NowWithOffset', operator: 'MINUS', amount: 7, timeUnit: 'DAYS' } as never] }], booleanOperator: 'AND' }],
|
||||
} as QueryFilter
|
||||
filterDateTimesToUtc(filter, Object.fromEntries(fields.map((f) => [f.name, f])), CHI)
|
||||
assert.deepEqual(filter.criteria[0]!.values, ['2026-10-31T05:00:00Z', '2026-11-01T04:59:00Z'])
|
||||
assert.deepEqual(filter.criteria[1]!.values, ['2026-10-31'])
|
||||
assert.equal(typeof (filter.subFilters![0] as QueryFilter).criteria[0]!.values[0], 'object')
|
||||
})
|
||||
|
||||
test('wallTimeToInstant agrees with Date for the host zone across a year', () => {
|
||||
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
const expected = new Date(2026, month - 1, 15, 10, 20, 0).getTime()
|
||||
assert.equal(wallTimeToInstant(2026, month, 15, 10, 20, 0, 0, zone), expected)
|
||||
}
|
||||
})
|
||||
|
||||
test('a form session reads typed DATE_TIME values as local time and keeps unchanged instants', async () => {
|
||||
const { FormSession } = await import('../forms/form-session.js')
|
||||
const noFetch: typeof fetch = async () => new Response('{}', { status: 404 })
|
||||
const session = new FormSession({
|
||||
baseUrl: 'http://x', fetch: noFetch, scope: { process: 'p' }, timeZone: CHI,
|
||||
fields: [{ name: 'when', label: 'When', type: 'DATE_TIME' }, { name: 'other', label: 'Other', type: 'DATE_TIME' }],
|
||||
initialValues: { when: '2026-10-31T22:41:52.344419Z' },
|
||||
})
|
||||
assert.equal(session.displayValues.when, '2026-10-31 17:41:52 CDT')
|
||||
await session.set('when', '2026-10-31T17:41:52')
|
||||
assert.equal(session.values.when, '2026-10-31T22:41:52.344419Z')
|
||||
await session.set('other', '2026-11-01T01:30')
|
||||
assert.equal(session.values.other, '2026-11-01T06:30:00Z')
|
||||
assert.equal(session.displayValues.other, '2026-11-01 01:30:00 CDT')
|
||||
await session.set('other', 'soon')
|
||||
assert.deepEqual(session.validate(), { other: 'must be a date and time, YYYY-MM-DDTHH:MM (local time) or an ISO instant' })
|
||||
})
|
||||
@@ -89,3 +89,11 @@ test('help content: view roles first, then entries without roles; HTML and MARKD
|
||||
assert.deepEqual(tableViewHelp(table), [{ slot: 'header', text: 'table help' }])
|
||||
assert.equal(recordView(table, rec({ a: 1 }))[0]!.help, undefined, 'help only when asked')
|
||||
})
|
||||
|
||||
test('DATE_TIME values show in the viewer\'s zone, or as the UTC instant with utc', () => {
|
||||
const f = field('when', { type: 'DATE_TIME' })
|
||||
const r = rec({ when: '2026-10-31T22:33:01Z' }, { when: '2026-10-31T22:33:01Z' })
|
||||
assert.match(valueText(f, r, { dateTime: { timeZone: 'Asia/Tokyo' } }).text, /^2026-11-01 07:33:01 /)
|
||||
assert.equal(valueText(f, r, { dateTime: { utc: true } }).text, '2026-10-31T22:33:01Z')
|
||||
assert.equal(valueText(f, rec({ when: null }), { empty: '--' }).text, '--')
|
||||
})
|
||||
@@ -42,11 +42,11 @@ STATUS = {
|
||||
'PRC-040': (X, 'no reload'), 'PRC-041': (P, 'UNAUTHENTICATED exits 3 with the sign-in hint'), 'PRC-042': (P, 'server-enforced'), 'PRC-043': (P, '-v values seed every step'),
|
||||
'PRC-044': (X, 'scanner screens are interactive'), 'PRC-045': (P, 'the runner follows nextStep whatever the flow'), 'PRC-046': (T, 'saved bulk load profiles'), 'PRC-047': (P, 'completes on init'),
|
||||
'PRC-048': (P, 'searchProcessPossibleValues'), 'PRC-049': (P, 'v1 routes only'), 'PRC-050': (X, 'Material-specific'), 'PRC-051': (P, 'dependent choices narrow after each answer'),
|
||||
'PRC-058': (T, ''), 'PRC-059': (T, ''), 'PRC-060': (T, ''), 'PRC-061': (T, ''), 'PRC-062': (X, 'composite editors'), 'PRC-063': (X, ''), 'PRC-064': (T, 'DATE_TIME local to UTC'), 'PRC-065': (X, 'Google Drive'), 'PRC-066': (P, 'field behaviors applied before submit'),
|
||||
'PRC-058': (T, ''), 'PRC-059': (T, ''), 'PRC-060': (T, ''), 'PRC-061': (T, ''), 'PRC-062': (X, 'composite editors'), 'PRC-063': (X, ''), 'PRC-064': (P, 'process values typed local, sent UTC; screens show the local zone'), 'PRC-065': (X, 'Google Drive'), 'PRC-066': (P, 'field behaviors applied before submit'),
|
||||
# QRY
|
||||
'QRY-001': (P, 'default order by primary key'), 'QRY-002': (P, '--limit, --skip, --all'), 'QRY-003': (P, '--order, -field for descending'), 'QRY-004': (T, '--fields per call; no persistence'),
|
||||
'QRY-005': (X, 'grid presentation'), 'QRY-006': (X, ''), 'QRY-007': (P, 'error with code, status and trace id'), 'QRY-010': (P, 'all operators and shorthand'), 'QRY-011': (P, 'BETWEEN, IN lists'),
|
||||
'QRY-012': (P, 'now(), now(-7d), thisPeriod(), startOf()'), 'QRY-013': (T, 'DATE_TIME local to UTC conversion'), 'QRY-014': (P, 'IS_BLANK, TRUE, FALSE'), 'QRY-015': (T, 'filters take ids; labels in filters todo'),
|
||||
'QRY-012': (P, 'now(), now(-7d), thisPeriod(), startOf()'), 'QRY-013': (P, '--where/--group DATE_TIME values local to UTC (a date is local midnight); expressions as before'), 'QRY-014': (P, 'IS_BLANK, TRUE, FALSE'), 'QRY-015': (T, 'filters take ids; labels in filters todo'),
|
||||
'QRY-016': (P, '--group with parentheses'), 'QRY-017': (P, '--print-filter gives the JSON the web links accept'), 'QRY-018': (P, 'any operator in --where'), 'QRY-019': (P, '--search on query, count and export: CONTAINS over the shown text fields, ORed, ANDed with the filter'),
|
||||
'QRY-020': (T, 'joins'), 'QRY-021': (T, 'joins'), 'QRY-022': (T, 'column statistics'), 'QRY-023': (T, ''), 'QRY-030': (P, '-r ids, --filter-json, --limit'), 'QRY-031': (T, 'bulk edit'), 'QRY-032': (T, 'bulk delete'),
|
||||
'QRY-033': (P, 'process run --filter-json with limit'), 'QRY-034': (T, 'process list --table'), 'QRY-035': (P, 'server refuses; exit 3'), 'QRY-040': (P, 'table export: filtered (--where, --group, --search), sorted (--order), --fields in column order, --limit, --out -'), 'QRY-041': (P, 'csv, xlsx, json, tsv; format from --out extension; --no-header; bad format or field exit 2 before the request'),
|
||||
@@ -60,11 +60,11 @@ STATUS = {
|
||||
# REC
|
||||
'REC-001': (P, 'table get by section and tier'), 'REC-002': (N, 'empty values show --; hidden sections and fields are dropped by the kernel record view (unit-tested; the sample has none)'), 'REC-003': (P, 'exit 5'), 'REC-004': (X, 'tabs'), 'REC-005': (P, 'validated locally before any request'), 'REC-006': (P, ''), 'REC-007': (P, ''), 'REC-008': (X, 'guard'),
|
||||
'REC-009': (P, 'update sends only changed fields'), 'REC-010': (P, 'non-editable fields excluded'), 'REC-011': (P, ''), 'REC-012': (P, 'confirm unless --yes'), 'REC-013': (P, 'qqq table copy <t> <pk> [--set f=v] [-i] [--dry-run]: primary key, non-editable and file fields dropped'), 'REC-014': (P, ''), 'REC-015': (P, ''), 'REC-016': (P, 'defaults applied'),
|
||||
'REC-017': (P, ''), 'REC-018': (T, 'DATE_TIME local to UTC'), 'REC-019': (X, 'display zones'), 'REC-020': (T, 'raw HTML; $EDITOR todo'), 'REC-021': (P, 'server masks'), 'REC-022': (T, 'blob upload and download'), 'REC-023': (P, 'server'),
|
||||
'REC-017': (P, ''), 'REC-018': (P, 'typed and shown in TZ, stored UTC, --utc for raw; DST edges tested'), 'REC-019': (X, 'display zones'), 'REC-020': (T, 'raw HTML; $EDITOR todo'), 'REC-021': (P, 'server masks'), 'REC-022': (T, 'blob upload and download'), 'REC-023': (P, 'server'),
|
||||
'REC-024': (P, 'maxLength checked locally'), 'REC-025': (P, 'behaviors'), 'REC-026': (P, 'ranges checked locally'), 'REC-027': (P, 'server message'), 'REC-028': (P, 'record links name table and key, --follow opens them; URL links show the URL'), 'REC-029': (N, 'chip as [value]; colors and icons not shown (unit-tested; none in the sample)'), 'REC-030': (X, ''), 'REC-031': (N, 'raw code on view; no code editor (unit-tested)'), 'REC-032': (N, 'HTML shown as text (unit-tested)'),
|
||||
'REC-033': (N, 'masked until --reveal; no copy (unit-tested; none in the sample)'), 'REC-034': (N, 'name plus qqq table download via the field route (unit-tested); live only for BLOB values, server finding 15'), 'REC-035': (N, 'create/update --upload field=path as multipart, replace and remove verified on a BLOB; FILE_UPLOAD formats not in the sample'), 'REC-036': (N, 'tooltip text in parentheses after the value (unit-tested)'), 'REC-037': (N, 'value rendered through the widget render model as text (unit-tested)'), 'REC-038': (N, 'value marked with ! (unit-tested)'), 'REC-039': (N, 'table get --help-content: view-screen roles, HTML/MARKDOWN as text (unit-tested; the sample has no help); qqq help show lists help alone'), 'REC-040': (N, 'section help with --help-content (unit-tested)'), 'REC-041': (N, 'table helpContent and helpContents slots with --help-content (unit-tested)'), 'REC-042': (T, 'audits'), 'REC-043': (T, ''),
|
||||
'REC-033': (N, 'masked until --reveal; no copy (unit-tested; none in the sample)'), 'REC-034': (N, 'name plus qqq table download via the field route (unit-tested); live only for BLOB values, server finding 16'), 'REC-035': (N, 'create/update --upload field=path as multipart, replace and remove verified on a BLOB; FILE_UPLOAD formats not in the sample'), 'REC-036': (N, 'tooltip text in parentheses after the value (unit-tested)'), 'REC-037': (N, 'value rendered through the widget render model as text (unit-tested)'), 'REC-038': (N, 'value marked with ! (unit-tested)'), 'REC-039': (N, 'table get --help-content: view-screen roles, HTML/MARKDOWN as text (unit-tested; the sample has no help); qqq help show lists help alone'), 'REC-040': (N, 'section help with --help-content (unit-tested)'), 'REC-041': (N, 'table helpContent and helpContents slots with --help-content (unit-tested)'), 'REC-042': (T, 'audits'), 'REC-043': (T, ''),
|
||||
'REC-044': (P, 'recordLabel'), 'REC-045': (P, 'labels resolved, defaults looked up'), 'REC-046': (P, 'server'), 'REC-047': (X, ''), 'REC-048': (P, 'fieldErrors by field'), 'REC-049': (X, ''), 'REC-050': (P, ''), 'REC-051': (P, ''), 'REC-052': (P, 'contingent fields'),
|
||||
'REC-053': (T, 'developer view'), 'REC-054': (T, ''), 'REC-055': (X, ''), 'REC-056': (X, ''), 'REC-057': (X, ''), 'REC-058': (X, ''), 'REC-059': (X, ''), 'REC-060': (T, 'adjusters wired, no sample to test'), 'REC-061': (T, ''), 'REC-062': (P, 'inline enums'), 'REC-063': (T, 'variants'), 'REC-064': (T, 'joins'), 'REC-065': (T, ''),
|
||||
'REC-053': (T, 'developer view'), 'REC-054': (T, ''), 'REC-055': (X, ''), 'REC-056': (X, ''), 'REC-057': (X, ''), 'REC-058': (X, ''), 'REC-059': (X, ''), 'REC-060': (T, 'adjusters wired, no sample to test'), 'REC-061': (T, ''), 'REC-062': (P, 'inline enums'), 'REC-063': (T, 'variants'), 'REC-064': (T, 'joins'), 'REC-065': (P, 'an unchanged wall-clock time keeps the original instant and its precision'),
|
||||
# SEC
|
||||
'SEC-001': (P, ''), 'SEC-002': (T, 'disabled tables listed with a reason'), 'SEC-003': (P, ''), 'SEC-004': (P, ''), 'SEC-005': (P, ''), 'SEC-006': (T, 'copy'), 'SEC-007': (P, ''), 'SEC-008': (T, ''), 'SEC-009': (P, 'widget get shows the refusal'), 'SEC-010': (P, ''),
|
||||
'SEC-011': (T, 'saved views and reports'), 'SEC-012': (P, 'server'), 'SEC-013': (P, 'server'), 'SEC-014': (P, ''), 'SEC-015': (P, ''), 'SEC-020': (P, 'qqq login on a MOCK server'), 'SEC-021': (P, 'qqq logout calls POST /logout'), 'SEC-022': (P, 'exit 3 with the sign-in hint'),
|
||||
|
||||
Reference in new issue
Block a user