Merge branch 'feat/m1-kernel-cli' into feat/m1-cli-parity
# Conflicts: # docs/parity/feature-matrix.md # docs/server-findings.md # scripts/parity-matrix.py
This commit is contained in:
27 files changed
+1325
-189
No files matched your search
@@ -7,37 +7,37 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
|
||||
## Summary (CLI column)
|
||||
|
||||
| Family | rows | pass | todo | partial | n/a | blocked |
|
||||
| Family | rows | pass | todo | partial | blocked | n/a |
|
||||
|---|---|---|---|---|---|---|
|
||||
| HLP | 6 | 1 | 0 | 4 | 1 | 0 |
|
||||
| INT | 14 | 5 | 0 | 0 | 9 | 0 |
|
||||
| NAV | 40 | 9 | 3 | 1 | 27 | 0 |
|
||||
| PRC | 60 | 47 | 0 | 1 | 12 | 0 |
|
||||
| HLP | 6 | 1 | 0 | 4 | 0 | 1 |
|
||||
| INT | 14 | 5 | 0 | 0 | 0 | 9 |
|
||||
| NAV | 40 | 9 | 3 | 1 | 0 | 27 |
|
||||
| PRC | 60 | 47 | 0 | 1 | 0 | 12 |
|
||||
| PRF | 5 | 4 | 1 | 0 | 0 | 0 |
|
||||
| QRY | 70 | 33 | 3 | 13 | 21 | 0 |
|
||||
| REC | 65 | 32 | 3 | 19 | 11 | 0 |
|
||||
| QRY | 70 | 33 | 3 | 13 | 0 | 21 |
|
||||
| REC | 65 | 32 | 3 | 19 | 0 | 11 |
|
||||
| REL | 9 | 6 | 0 | 3 | 0 | 0 |
|
||||
| RPT | 20 | 1 | 19 | 0 | 0 | 0 |
|
||||
| SEC | 43 | 21 | 10 | 0 | 12 | 0 |
|
||||
| WID | 69 | 29 | 27 | 0 | 13 | 0 |
|
||||
| **all** | 401 | 188 | 66 | 41 | 106 | 0 |
|
||||
| SEC | 43 | 21 | 10 | 0 | 0 | 12 |
|
||||
| WID | 69 | 29 | 27 | 0 | 0 | 13 |
|
||||
| **all** | 401 | 188 | 66 | 41 | 0 | 106 |
|
||||
|
||||
## Summary (TUI column)
|
||||
|
||||
| Family | rows | pass | todo | partial | n/a | blocked |
|
||||
| Family | rows | pass | todo | partial | blocked | n/a |
|
||||
|---|---|---|---|---|---|---|
|
||||
| HLP | 6 | 1 | 4 | 1 | 0 | 0 |
|
||||
| INT | 14 | 9 | 0 | 1 | 4 | 0 |
|
||||
| NAV | 40 | 17 | 0 | 6 | 17 | 0 |
|
||||
| PRC | 60 | 38 | 2 | 7 | 13 | 0 |
|
||||
| HLP | 6 | 2 | 4 | 0 | 0 | 0 |
|
||||
| INT | 14 | 10 | 0 | 0 | 0 | 4 |
|
||||
| NAV | 40 | 19 | 0 | 1 | 1 | 19 |
|
||||
| PRC | 60 | 44 | 1 | 1 | 1 | 13 |
|
||||
| PRF | 5 | 4 | 0 | 1 | 0 | 0 |
|
||||
| QRY | 70 | 41 | 8 | 13 | 8 | 0 |
|
||||
| REC | 65 | 44 | 3 | 14 | 4 | 0 |
|
||||
| REL | 9 | 6 | 0 | 3 | 0 | 0 |
|
||||
| QRY | 70 | 59 | 0 | 1 | 1 | 9 |
|
||||
| REC | 65 | 58 | 1 | 0 | 0 | 6 |
|
||||
| REL | 9 | 9 | 0 | 0 | 0 | 0 |
|
||||
| RPT | 20 | 0 | 20 | 0 | 0 | 0 |
|
||||
| SEC | 43 | 7 | 23 | 1 | 12 | 0 |
|
||||
| WID | 69 | 13 | 43 | 2 | 11 | 0 |
|
||||
| **all** | 401 | 180 | 103 | 49 | 69 | 0 |
|
||||
| SEC | 43 | 7 | 23 | 1 | 0 | 12 |
|
||||
| WID | 69 | 15 | 43 | 0 | 0 | 11 |
|
||||
| **all** | 401 | 227 | 92 | 5 | 3 | 74 |
|
||||
|
||||
## Rows
|
||||
|
||||
@@ -47,14 +47,14 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| NAV-002 | Sidebar lists every permitted top-level app once, by label, in backend order, with nested apps only under thei | n/a | pass | browser shell; TUI: Apps tree, backend order |
|
||||
| NAV-003 | Apps nested deeper than two levels render as nested sidebar groups; deep links expand every enclosing group | n/a | pass | browser shell; TUI: nested apps in the tree |
|
||||
| NAV-004 | Sidebar click navigation to app homes, tables and processes with active-entry highlighting and collapse/expand | n/a | pass | browser shell; TUI: Enter opens; current entry marked; arrows expand and collapse |
|
||||
| NAV-005 | Declared icons (QIcon name, color, path) render on navigation surfaces | n/a | partial | browser shell; TUI: markers for processes and reports; icons not drawn |
|
||||
| NAV-005 | Declared icons (QIcon name, color, path) render on navigation surfaces | n/a | n/a | browser shell; TUI: icon glyphs are a browser icon font; the TUI marks processes and reports and shows the icon name on app cards |
|
||||
| NAV-006 | Hidden tables and processes never appear in navigation; direct links to hidden objects the user may not access | pass | pass | hidden tables and processes are not listed; direct use is refused by the server; TUI: hidden tables and processes are not listed |
|
||||
| NAV-007 | A hidden table the user may access opens by direct link (hidden is not denied) | pass | pass | qqq table get on a hidden table the user may read; TUI: :table query and :record open hidden tables the user may read |
|
||||
| NAV-008 | App home sections list processes (Actions), reports (Reports) and tables (Data) by label with icons, and each | pass | pass | qqq apps show <app>: sections of processes, reports, tables and child apps, then widgets; TUI: app home: sections with tables, processes, reports |
|
||||
| NAV-009 | App home table entries show the backend record count | pass | pass | qqq apps show counts each table with TABLE_COUNT (--no-counts skips); TUI: record counts beside tables |
|
||||
| NAV-010 | App homes of apps with child apps, only processes, or nothing render without errors | n/a | pass | app home rendering |
|
||||
| NAV-011 | A dashboard app (widgets only) opens from navigation and shows one labelled container per permitted declared w | todo | pass | widgets per app; TUI: dashboard apps draw their widgets |
|
||||
| NAV-012 | Branding: sidebar logo (Material parity), app name, favicon from the icon, accent color, and partial branding | n/a | partial | browser shell; TUI: app name in the title bar and Home; no logo or banners in a terminal |
|
||||
| NAV-012 | Branding: sidebar logo (Material parity), app name, favicon from the icon, accent color, and partial branding | n/a | n/a | browser shell; TUI: logo, favicon and accent images are browser-only; the app name is in the title bar and on Home |
|
||||
| NAV-013 | Branding banners render in their slots (top of site, top of body, side nav under logo) with text or sanitized | n/a | n/a | browser shell |
|
||||
| NAV-014 | Breadcrumbs show the full app hierarchy for tables, processes, records, create and edit pages, and each crumb | n/a | pass | browser shell; TUI: title-bar path: apps, table, record or form |
|
||||
| NAV-015 | Document title names the page, its enclosing apps and the application (Material parity) | n/a | n/a | browser shell |
|
||||
@@ -67,12 +67,12 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| NAV-022 | Unknown deeper or top-level paths return the 404 page with a working home link | n/a | n/a | browser shell |
|
||||
| NAV-023 | Record lookup by key: /app/{table}/key?{field}={value} opens the single matching record | pass | pass | qqq table get <t> --key field=value: one match shows the record, none exits 5, several exit 2; TUI: g: primary key or a configured unique key (kernel gotoKeys) |
|
||||
| NAV-024 | Command palette (Ctrl+K and '.') lists navigable apps, tables and processes by label with type and app context | todo | pass | shell completion covers names; a palette is the TUI; TUI: F2, /, ., Ctrl+K palette with type and app context |
|
||||
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records by lab | pass | partial | qqq search; TUI: palette: recents and navigation targets; no separate search page |
|
||||
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records by lab | pass | pass | qqq search; TUI: the palette (F2, /, ., Ctrl+K) jumps to navigation targets and recently viewed records; live |
|
||||
| NAV-026 | Mobile navigation drawer opens from the header, navigates and closes | n/a | n/a | browser shell |
|
||||
| NAV-027 | Developer page reports the backend metadata counts and the running framework versions | pass | partial | qqq whoami: apiVersion, features, counts; TUI: F9 Connection: versions, features, extensions, metadata generation |
|
||||
| NAV-027 | Developer page reports the backend metadata counts and the running framework versions | pass | pass | qqq whoami: apiVersion, features, counts; TUI: F9 › Connection: UI API version, features, extensions, metadata generation and age, client build; live |
|
||||
| NAV-028 | Reports in the app tree are listed under their app (sidebar and app home Reports group) and open the report pa | partial | partial | qqq report list (reports with their app); the sample app has no reports to verify; TUI: reports listed in the tree, Home and palette; run through :report run |
|
||||
| NAV-029 | A user who may access no apps sees the Dashboard entry and the no-apps message (Material NoApps parity) | n/a | n/a | browser shell |
|
||||
| NAV-030 | Global search (header box, '/' dialog, search page) lists the records a backend record search (POST /qqq/v1/se | pass | partial | qqq search over POST /search; TUI: palette calls POST /search when the server lists recordSearch; the sample answers provider NONE |
|
||||
| NAV-030 | Global search (header box, '/' dialog, search page) lists the records a backend record search (POST /qqq/v1/se | pass | blocked | qqq search over POST /search; TUI: the palette calls POST /search; the sample lists recordSearch with no provider (server finding 21) |
|
||||
| NAV-031 | Record search respects table read permissions and record security locks, in the UI and the API | pass | pass | server-enforced; refusals shown; TUI: server-enforced; the palette shows only what the server returns |
|
||||
| NAV-032 | Without searchable tables (the backend declares no searchFields) global search stays local and never calls a s | n/a | n/a | browser shell |
|
||||
| NAV-033 | Login branding before sign-in: logo, app name, accent color, favicon and title from GET /qqq/v1/metaData/authe | n/a | n/a | browser shell |
|
||||
@@ -101,18 +101,18 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| PRC-011 | Process possible-value fields | pass | pass | labels or ids, prompts list choices; TUI: picker |
|
||||
| PRC-012 | VIEW_FORM labels and backend values | pass | pass | label: value on every step; TUI: summary shows view fields |
|
||||
| PRC-013 | HTML component renders the step html value, sanitized | pass | pass | {step}.html as text (tags stripped); TUI: HTML component as text from the kernel screen |
|
||||
| PRC-014 | DOWNLOAD_FORM delivers the generated file | pass | partial | GET /download/{file} saved to --out-dir, else the download directory; no sample process offers one, verified by tests; TUI: w saves the DOWNLOAD_FORM file; no sample process offers one |
|
||||
| PRC-014 | DOWNLOAD_FORM delivers the generated file | pass | pass | GET /download/{file} saved to --out-dir, else the download directory; no sample process offers one, verified by tests; TUI: w saves the DOWNLOAD_FORM file through the save dialog (unit-tested only) |
|
||||
| PRC-015 | Step help content for process screens | pass | pass | step helpContents shown on the screen, process help at start and in describe; none in the sample; TUI: step and process help in the screen |
|
||||
| PRC-016 | Back to the backend back step with entered values | pass | partial | b at a prompt when the step reports backStep (isStepBack=true); verified live: bulk insert -i back from the mapping to the upload and from the review to the mapping; TUI: F4 Back resolves the runner BACK; no sample process declares a back step |
|
||||
| PRC-016 | Back to the backend back step with entered values | pass | pass | b at a prompt when the step reports backStep (isStepBack=true); verified live: bulk insert -i back from the mapping to the upload and from the review to the mapping; TUI: F4 goes back to the backend back step with the entered values (isStepBack; unit-tested only) |
|
||||
| PRC-017 | Dynamic step list from processMetaDataAdjustment.updatedFrontendStepList | pass | pass | updatedFrontendStepList followed; TUI: kernel runner |
|
||||
| PRC-018 | Updated field metadata from processMetaDataAdjustment.updatedFields | pass | pass | updatedFields applied; TUI: kernel runner |
|
||||
| PRC-019 | Async job progress in the live job view (message, N / M, determinate progress bar, elapsed) then continue | pass | pass | message, N/M, elapsed on stderr; TUI: bar, N/M, message, elapsed |
|
||||
| PRC-020 | Cancel from a screen and mid-job runs the cancel step and leaves | pass | partial | Ctrl-C during a job or a prompt calls POST …/cancel, exit 130; TUI: Esc asks, then POST cancel; the sample keeps the run |
|
||||
| PRC-020 | Cancel from a screen and mid-job runs the cancel step and leaves | pass | blocked | Ctrl-C during a job or a prompt calls POST …/cancel, exit 130; TUI: Esc asks, then POST cancel; the sample accepts the cancel and keeps the run (server finding 13) |
|
||||
| PRC-021 | Step errors: user-facing messages directly, internal errors behind a detail toggle, Close | pass | pass | userFacingError shown; TUI: failure message shown; F5 re-runs |
|
||||
| PRC-022 | Retry after an error restarts with the original record selection | n/a | pass | retry is re-running the command; TUI: F5 or r runs again with the same records |
|
||||
| PRC-023 | Completion: Next/Submit labels, Return on the last screen or when noMoreSteps | n/a | pass | screen labels; TUI: Ctrl+S continues; the last screen closes with Enter or Esc |
|
||||
| PRC-024 | Linear stepper and step position | n/a | pass | stepper; step names printed; TUI: step list with marks |
|
||||
| PRC-025 | RECORD_LIST paging and page size over process records | pass | partial | GET …/records paged into a table, --record-limit; TUI: RECORD_LIST shows the first page of records; no paging inside the step |
|
||||
| PRC-025 | RECORD_LIST paging and page size over process records | pass | pass | GET …/records paged into a table, --record-limit; TUI: RECORD_LIST pages with n / p through getProcessRecords (unit-tested only) |
|
||||
| PRC-026 | Validation review: input count, validate-or-skip choice, Next/Submit label, record preview | pass | pass | count and preview; asks validate or skip on a terminal, --validate / --skip-validation; Submit? after validation; TUI: validate or skip, then run |
|
||||
| PRC-027 | Full validation summary lines (OK, WARNING, ERROR) with record links | pass | pass | status, count, message lines; TUI: summary lines colored by status |
|
||||
| PRC-028 | Process summary results after execution and persisted effects | pass | pass | |
|
||||
@@ -122,8 +122,8 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| PRC-032 | Bulk load mapping validation and Back through mapping and upload | pass | n/a | mapping checked before submit (unmapped field, unknown column, layout, WIDE repetition, key fields, repeated header); Back from mapping to upload and from review to mapping verified live with -i; TUI: bulk load parked |
|
||||
| PRC-033 | Bulk edit with a file matched by key fields | pass | n/a | qqq table bulk-edit <t> --file x.csv --key id [--mapping]: rows matched by the key, value mapping; verified live on person; TUI: bulk load parked |
|
||||
| PRC-034 | Upload instructions HTML with downloadable template | pass | n/a | upload instructions printed as text; --template [flat/tall/wide] [--out] saves the data: templates; verified live; TUI: bulk load parked for the TUI |
|
||||
| PRC-035 | Named WIDGET fetched with the process UUID and values | pass | partial | named widget with the process UUID and values, kernel render model; none in the sample, verified by tests; TUI: named WIDGET blocks drawn from the kernel screen; no sample step uses one |
|
||||
| PRC-036 | Named composite WIDGET seeded from process values | pass | partial | seeded from the process value of the widget name; TUI: composite widget blocks drawn as text |
|
||||
| PRC-035 | Named WIDGET fetched with the process UUID and values | pass | pass | named widget with the process UUID and values, kernel render model; none in the sample, verified by tests; TUI: named WIDGET fetched with the process UUID and values, drawn from the kernel screen (unit-tested only) |
|
||||
| PRC-036 | Named composite WIDGET seeded from process values | pass | pass | seeded from the process value of the widget name; TUI: composite widget blocks seeded from process values drawn as text (kernel screen; unit-tested only) |
|
||||
| PRC-037 | Ad hoc composite WIDGET: interpolation, conditional blocks, action buttons, submit-on-enter inputs | partial | partial | ad hoc blocks rendered with interpolation; buttons and inputs not offered; TUI: ad hoc widget blocks drawn; no action buttons |
|
||||
| PRC-038 | GOOGLE_DRIVE_SELECT_FOLDER screen keeps its other components working | n/a | n/a | Google Drive is web-only |
|
||||
| PRC-039 | Google Drive folder selection through the Google Picker | n/a | n/a | Google Drive is web-only |
|
||||
@@ -131,7 +131,7 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| PRC-041 | Expired session mid-process returns to sign-in | pass | pass | UNAUTHENTICATED exits 3 with the sign-in hint; TUI: a 401 shows the error; :signout and F9 sign in again |
|
||||
| PRC-042 | Process permission enforcement | pass | pass | server-enforced; TUI: hidden processes are not offered; the server refuses the rest |
|
||||
| PRC-043 | Default process values from the link (defaultProcessValues) | pass | pass | -v values seed every step; TUI: :process run P -v name=value seeds the values |
|
||||
| PRC-044 | Scanner-format screens: no heading or action bar, input blocks submit | n/a | todo | scanner screens are interactive; TUI: scanner-format screens |
|
||||
| PRC-044 | Scanner-format screens: no heading or action bar, input blocks submit | n/a | pass | scanner screens are interactive; TUI: scanner-format steps: no heading or step list, Enter in the input submits (unit-tested only; no sample scanner process) |
|
||||
| PRC-045 | State-machine step flow: repeated screens, no linear stepper | pass | pass | the runner follows nextStep whatever the flow; TUI: the runner follows nextStep whatever the flow |
|
||||
| PRC-046 | Saved bulk load profiles: save, choose for a later load, delete | pass | n/a | qqq bulk-profile list/show/save/rename/delete <t> [--edit], bulk-insert --saved <name> and --save-profile; unit-tested and stub-server CLI tests: the sample has no saved-profile processes; TUI: bulk load parked for the TUI |
|
||||
| PRC-047 | A run with no frontend screens completes with a completion screen | pass | pass | completes on init |
|
||||
@@ -148,13 +148,13 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| PRC-064 | DATE_TIME process inputs display local time and submit UTC instants | pass | pass | process values typed local, sent UTC; screens show the local zone; TUI: step DATE_TIME inputs edited in the local zone, sent as UTC (kernel datetime) |
|
||||
| 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 | pass | field behaviors applied before submit; TUI: field behaviors via FormSession |
|
||||
| QRY-090 | Column menu sorts, pins, opens a field filter and hides a column | n/a | partial | saved views and column UI; TUI: s sorts, v hides and orders columns, F6 filters; no pinning |
|
||||
| QRY-090 | Column menu sorts, pins, opens a field filter and hides a column | n/a | pass | saved views and column UI; TUI: s sorts, v hides, orders, sizes and pins columns, F6 filters; live on person |
|
||||
| QRY-091 | Column menu copies page or full query values in backend sort order | n/a | n/a | saved views and column UI; TUI: clipboard copy of column values |
|
||||
| QRY-092 | Column chooser searches, groups, counts and marks changed layouts | n/a | partial | saved views and column UI; TUI: v lists every column with on/off; no grouping or counts |
|
||||
| QRY-092 | Column chooser searches, groups, counts and marks changed layouts | n/a | pass | saved views and column UI; TUI: v: / searches, columns grouped by table and join with shown counts, * marks changes; live |
|
||||
| QRY-093 | Last saved view and unsaved edits survive reopening and reload | n/a | pass | no screen state to keep: views are opened by name; TUI: view settings (columns, widths, sort) persist per table |
|
||||
| QRY-094 | Counted quick views render and open their saved-view route | partial | todo | saved-view list counts quick views with doCount (--counts counts all); TUI: counted quick views |
|
||||
| QRY-094 | Counted quick views render and open their saved-view route | partial | pass | saved-view list counts quick views with doCount (--counts counts all); TUI: doCount views show their record counts in F9 Saved views (unit-tested only; no sample saved views) |
|
||||
| QRY-095 | A new query defaults to fifty rows per page | n/a | n/a | saved views and column UI; TUI: page size follows the terminal height |
|
||||
| QRY-096 | A saved view reconciles removed fields and warns the user | partial | partial | opening a view drops removed fields with Material's warnings on stderr; TUI: viewToQuery warnings flashed |
|
||||
| QRY-096 | A saved view reconciles removed fields and warns the user | partial | pass | opening a view drops removed fields with Material's warnings on stderr; TUI: opening a view drops removed fields and flashes the kernel warnings (unit-tested only) |
|
||||
| QRY-001 | Record list renders metadata labels and all records in the default primary-key-descending order | pass | pass | default order by primary key |
|
||||
| QRY-002 | Pagination with Material page sizes and URL state | pass | pass | --limit, --skip, --all; TUI: PgUp/PgDn, Ctrl+F/B, Home/End across pages |
|
||||
| QRY-003 | Column header sort cycles ascending, descending and default; sort kept in the URL | pass | pass | --order, -field for descending; TUI: s cycles asc, desc, default |
|
||||
@@ -165,30 +165,30 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| QRY-010 | Text operators match Material labels and backend semantics | pass | pass | all operators and shorthand; TUI: operators by field type in the filter dialog |
|
||||
| QRY-011 | Number operators including ranges and lists | pass | pass | BETWEEN, IN lists |
|
||||
| QRY-012 | Date operators and relative date expressions | pass | pass | now(), now(-7d), thisPeriod(), startOf(); TUI: date presets: now(-7d), thisPeriod(month) … |
|
||||
| QRY-013 | Date-time operators convert local input to UTC and support relative values | pass | partial | --where/--group DATE_TIME values local to UTC (a date is local midnight); expressions as before; TUI: date-time criteria take expressions; typed local times are sent as typed |
|
||||
| QRY-013 | Date-time operators convert local input to UTC and support relative values | pass | pass | --where/--group DATE_TIME values local to UTC (a date is local midnight); expressions as before; TUI: DATE_TIME criteria typed in the local zone go out as UTC instants; expressions pass through (unit-tested) |
|
||||
| QRY-014 | Boolean, blob and long-text operators | pass | pass | IS_BLANK, TRUE, FALSE; TUI: TRUE/FALSE/EQUALS on booleans, blank tests, text operators on long text |
|
||||
| QRY-015 | Possible-value selectors for table and enum sources show labels and send ids | todo | pass | filters take ids; labels in filters todo; TUI: labels in the picker, ids sent; typed labels resolved |
|
||||
| QRY-016 | AND/OR and nested groups combine as declared | pass | pass | --group with parentheses; TUI: AND/OR and groups, guided and advanced |
|
||||
| QRY-017 | Filters live in the URL: reload, back/forward and Material-style JSON links | pass | partial | --print-filter gives the JSON the web links accept; TUI: the grammar and the CLI command are shown and copied; no URL |
|
||||
| QRY-017 | Filters live in the URL: reload, back/forward and Material-style JSON links | pass | n/a | --print-filter gives the JSON the web links accept; TUI: URL state is browser-only; the TUI shows the grammar and the CLI command, and qqq ui accepts the command as a route |
|
||||
| QRY-018 | Backend-only operators from links render and filter correctly | pass | pass | any operator in --where; TUI: More operators… offers every operator |
|
||||
| QRY-019 | Quick search combines with the advanced filter | pass | pass | --search on query, count and export: CONTAINS over the shown text fields, ORed, ANDed with the filter; TUI: q quick search ANDed onto the filter (kernel) |
|
||||
| QRY-020 | One-side exposed join: labelled columns, filters and sorts match SQL | partial | todo | --join and table.field in --fields/--where/--order, labelled "Join: Field"; the sample exposes no joins, verified through an association join (person to pet); TUI: exposed joins in the grid |
|
||||
| QRY-021 | Many-side exposed join repeats rows and reports the distinct count | pass | todo | a many-side join repeats rows; count and --count add the distinct count (person --join pet: 21 rows, 16 distinct); TUI: exposed joins in the grid |
|
||||
| QRY-022 | Column statistics aggregate the filtered rows | partial | partial | qqq table stats <t> <field> [-w …] through the columnStats process; the sample has neither QUERY_STATS nor the process, so --local (same figures from the matching rows) is what was verified; TUI: S runs kernel column statistics; the sample has no QUERY_STATS table |
|
||||
| QRY-020 | One-side exposed join: labelled columns, filters and sorts match SQL | partial | pass | --join and table.field in --fields/--where/--order, labelled "Join: Field"; the sample exposes no joins, verified through an association join (person to pet); TUI: joined columns and filters, labelled "Join: Field", joins planned by the kernel (unit-tested only; the sample exposes no joins) |
|
||||
| QRY-021 | Many-side exposed join repeats rows and reports the distinct count | pass | pass | a many-side join repeats rows; count and --count add the distinct count (person --join pet: 21 rows, 16 distinct); TUI: many-side join: rows repeat and the status shows the distinct count (unit-tested only) |
|
||||
| QRY-022 | Column statistics aggregate the filtered rows | partial | pass | qqq table stats <t> <field> [-w …] through the columnStats process; the sample has neither QUERY_STATS nor the process, so --local (same figures from the matching rows) is what was verified; TUI: S: kernel column statistics of the column under the cursor with the filter (unit-tested only; no sample QUERY_STATS table) |
|
||||
| QRY-023 | Tables without QUERY_STATS offer no column statistics | pass | pass | without QUERY_STATS (or the process) table stats exits 2 with the reason; --local is an explicit opt-in; TUI: the refusal says why |
|
||||
| QRY-030 | Selection menu: this page, full query result, first N, clear | pass | partial | -r ids, --filter-json, --limit; TUI: Space, Ctrl+A for the page; no full-result or first-N selection |
|
||||
| QRY-030 | Selection menu: this page, full query result, first N, clear | pass | pass | -r ids, --filter-json, --limit; TUI: Ctrl+A: this page, the full result, the first N, clear (unit-tested) |
|
||||
| QRY-031 | Bulk Edit launches with the checked record ids | pass | pass | table bulk-edit -r ids; verified live |
|
||||
| QRY-032 | Bulk Delete of the full query result sends the whole filter | pass | partial | table bulk-delete -w ... (or --filter-json, --all-records) sends the whole filter; verified live; TUI: bulk delete on the selection; without a selection the process gets the filter |
|
||||
| QRY-033 | First-N selection under a filter launches with that limit | pass | todo | process run --filter-json with limit; TUI: first-N selection |
|
||||
| QRY-032 | Bulk Delete of the full query result sends the whole filter | pass | pass | table bulk-delete -w ... (or --filter-json, --all-records) sends the whole filter; verified live; TUI: with the full result selected, bulk delete gets the whole filter (unit-tested) |
|
||||
| QRY-033 | First-N selection under a filter launches with that limit | pass | pass | process run --filter-json with limit; TUI: run a table process on the first N records of the result (filter with a limit; unit-tested only) |
|
||||
| QRY-034 | Actions menu offers bulk load, bulk processes and table processes | pass | partial | qqq table actions <t>: bulk load, bulk edit/delete and the table processes with the command for each; verified live; TUI: F9 and b list table processes; bulk load parked |
|
||||
| QRY-035 | Viewers get no create, bulk or process actions and the backend refuses them | pass | pass | server refuses; exit 3; TUI: F-keys and entries hidden without permission; the server refuses |
|
||||
| QRY-040 | CSV export has visible columns in order and exactly the filtered, sorted rows | pass | pass | table export: filtered (--where, --group, --search), sorted (--order), --fields in column order, --limit, --out -; TUI: x: visible columns, filter and sort, file dialog |
|
||||
| QRY-041 | XLSX and JSON exports carry the same rows | pass | pass | csv, xlsx, json, tsv; format from --out extension; --no-header; bad format or field exit 2 before the request |
|
||||
| QRY-042 | Nothing to export disables the formats | n/a | pass | nothing to disable; TUI: an empty result exports an empty file; the server decides |
|
||||
| QRY-043 | Backend refuses exports of unreadable tables | pass | pass | server refuses; TUI: server refuses; the message is shown |
|
||||
| QRY-050 | Your saved views are listed and open on their own route | partial | partial | qqq saved-view list <t>, open <t> <view>, table query --view; the sample has no saved views; verified with node tests and a local stub server; TUI: F9 Saved views lists and opens (kernel); no sample table has saved views |
|
||||
| QRY-051 | Saving a new view stores filter, sort, columns and page size | partial | partial | saved-view save --name: filter, sort, columns and page size as the Material RecordQueryView; TUI: save the current query as a view; unverified live |
|
||||
| QRY-052 | Update, rename and delete your view | partial | partial | saved-view save --replace, rename, delete (asks; --yes); TUI: delete from F9; no rename |
|
||||
| QRY-050 | Your saved views are listed and open on their own route | partial | pass | qqq saved-view list <t>, open <t> <view>, table query --view; the sample has no saved views; verified with node tests and a local stub server; TUI: F9 › Saved views lists and opens views (kernel; unit-tested only) |
|
||||
| QRY-051 | Saving a new view stores filter, sort, columns and page size | partial | pass | saved-view save --name: filter, sort, columns and page size as the Material RecordQueryView; TUI: save the current filter, sort, columns and page size as a view (unit-tested only) |
|
||||
| QRY-052 | Update, rename and delete your view | partial | pass | saved-view save --replace, rename, delete (asks; --yes); TUI: rename, update with the current query, delete (unit-tested only) |
|
||||
| QRY-053 | Duplicate names and unknown views are reported without changes | partial | pass | a name in use is refused before any change; an unknown view exits 2 with the list; TUI: server messages shown |
|
||||
| QRY-054 | A view shared with you opens read-only and the backend refuses changes | partial | pass | views shared with you are listed as shared by their owner; changes to them are the server's to refuse, its message shown; TUI: server refuses changes |
|
||||
| QRY-060 | Variant table asks for a variant, queries with it and remembers it | partial | pass | table variants; --variant on query, count, get, copy, export, writes and process run; a terminal asks; not remembered between commands; no variant table in the sample; TUI: the variant prompt before the first query; tableVariant on query and count (test) |
|
||||
@@ -202,30 +202,30 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| QRY-068 | Default grid columns follow the table sections (section order, each field once), not the metadata field order | pass | pass | default columns follow sections by tier |
|
||||
| QRY-069 | Pagination numbers are locale formatted in the shown range and the total | n/a | pass | locale formatting; TUI: counts with en-US separators |
|
||||
| QRY-070 | Query screen keyboard shortcuts n (new record), r (refresh the query), f (open the filter builder) | n/a | pass | shortcuts; TUI: n r f s and the F-keys; off with keyboard.singleKeyShortcuts |
|
||||
| QRY-071 | Processes launched from a variant table run with the chosen variant (tableVariant on init and every step) and | partial | partial | process run --variant: tableVariant on init, every step and status; sent live, no variant table to observe; TUI: processes from a variant grid get tableVariant through the runner; no sample variant table |
|
||||
| QRY-080 | Basic mode displays default quick filters from table metadata and applies their criteria | n/a | todo | quick filters UI; TUI: basic-mode quick filters |
|
||||
| QRY-081 | Add quick filter accepts metadata fields and remembers a chosen field | n/a | todo | TUI: quick filter fields |
|
||||
| QRY-071 | Processes launched from a variant table run with the chosen variant (tableVariant on init and every step) and | partial | pass | process run --variant: tableVariant on init, every step and status; sent live, no variant table to observe; TUI: processes from a variant grid carry tableVariant through the runner (unit-tested only) |
|
||||
| QRY-080 | Basic mode displays default quick filters from table metadata and applies their criteria | n/a | pass | quick filters UI; TUI: the filter dialog starts with the quick filter fields (defaultQuickFilterFieldNames, else T1); live on person |
|
||||
| QRY-081 | Add quick filter accepts metadata fields and remembers a chosen field | n/a | pass | TUI: add a quick filter field; kept per table in the local view settings |
|
||||
| QRY-082 | Basic mode cannot simplify repeated or nested advanced conditions and explains why | n/a | n/a | TUI: the guided builder shows every condition; no basic mode |
|
||||
| QRY-083 | Advanced query shows and removes individual conditions (Next inline editor) | n/a | pass | TUI: guided builder edits and removes conditions |
|
||||
| QRY-084 | Sort picker changes field and direction; clearing filters requires confirmation and keeps the sort | n/a | pass | TUI: sort in the builder; c clears |
|
||||
| QRY-085 | Weekday criteria use the metadata-enabled date function and ISO weekday values | todo | todo | weekday criteria; TUI: weekday criteria |
|
||||
| QRY-085 | Weekday criteria use the metadata-enabled date function and ISO weekday values | todo | blocked | weekday criteria; TUI: weekday(field) IN days sent as the WeekdayOf fieldFunction; the sample H2 backend fails it (server finding 24, qqq#1355) |
|
||||
| QRY-086 | Relative date presets expose their evaluated date and filter records | pass | pass | expressions |
|
||||
| QRY-087 | Bulk pasted values become a multi-value filter | pass | pass | IN lists; quoted values; TUI: IN takes a pasted comma list |
|
||||
| QRY-088 | Instance query help accompanies the bulk paste dialog | n/a | todo | TUI: bulk paste help |
|
||||
| QRY-088 | Instance query help accompanies the bulk paste dialog | n/a | pass | TUI: paste values split on commas, tabs, semicolons, new lines; the instance bulkAddFilterValues help shown (unit-tested only; the sample has no such help) |
|
||||
| QRY-089 | String filter input honors field case behavior from v1 metadata | pass | pass | behaviors; TUI: behaviors applied to typed values |
|
||||
| QRY-008 | Phone card list: readable records, touch-sized selection, query selections and paging | n/a | n/a | phone layout |
|
||||
| QRY-009 | Phone filter sheet fits the viewport, scrolls inside and manages focus | n/a | n/a | phone layout |
|
||||
| QRY-024 | Column configuration is reachable and usable by touch on phones and tablets | n/a | n/a | phone layout |
|
||||
| QRY-036 | Bulk actions launch from a phone card selection | n/a | n/a | phone layout |
|
||||
| QRY-055 | Saved views and export menus fit the phone and are touch-sized | n/a | n/a | phone layout |
|
||||
| REL-001 | Possible-value cells show the related record label and link to it | pass | partial | display values; table get shows the target (Avery Sample → person 1), --links lists them, --follow opens one; TUI: labels with links in the record view; grid cells show labels without links |
|
||||
| REL-001 | Possible-value cells show the related record label and link to it | pass | pass | display values; table get shows the target (Avery Sample → person 1), --links lists them, --follow opens one; TUI: labels with ↗ links in the record view; l opens a grid cell's linked record; live |
|
||||
| REL-002 | Parent lists exactly its own children; View All opens the filtered child list | pass | pass | table get --associations lists exactly the record's children; table children pages a list with its count (View All); TUI: child lists; F9 Query all opens the filtered list |
|
||||
| REL-003 | Adding a child from its parent links it and shows it immediately | pass | pass | table create <child> --parent table=pk; table get --associations shows it at once; TUI: F7 on a child list prefills the parent |
|
||||
| REL-004 | Three-level associations: each pet shows only its own notes | pass | partial | person > pets > notes: each pet lists only its own notes; TUI: one level shown; open a child for its own children |
|
||||
| REL-004 | Three-level associations: each pet shows only its own notes | pass | pass | person > pets > notes: each pet lists only its own notes; TUI: child lists three levels deep (a pet's notes under the pet); live on person 1 |
|
||||
| REL-005 | Editing and deleting a child leaves no stale parent, list or unexpected rows | pass | pass | records are never cached: update or delete a child, the next get/children shows the current rows; TUI: the parent reloads on return |
|
||||
| REL-006 | Denied child reads show an unavailable panel without data or actions | partial | pass | unreadable child tables shown as unavailable without data; table children shows the server message; the sample has no denied child table (unit-tested); TUI: unavailable panel (kernel associationGroups) |
|
||||
| REL-007 | Aliased and composite associations bind to their widgets and list the right members | partial | pass | composite joins and widget-section labels in the kernel, unit-tested; the sample declares none; TUI: kernel associationGroups binds aliased associations to their widgets |
|
||||
| REL-008 | Adding through a composite association stores every join value | partial | partial | --parent sets every join value of a composite association, unit-tested; the sample declares none; TUI: create child prefills every join value; composite untested live |
|
||||
| REL-008 | Adding through a composite association stores every join value | partial | pass | --parent sets every join value of a composite association, unit-tested; the sample declares none; TUI: a new child through a composite association gets every join value (unit-tested only) |
|
||||
| REL-009 | Deleting a parent removes associated children and leaves other parents intact | pass | pass | table delete of a parent: its pets are gone, other parents keep theirs; TUI: server cascade; the list reloads |
|
||||
| REC-001 | Record view shows the record label, tiered sections and backend-formatted values | pass | pass | table get by section and tier; TUI: sections by tier, two columns when they fit |
|
||||
| REC-002 | Empty values render a placeholder; hidden fields and hidden sections are never shown | partial | pass | empty values show --; hidden sections and fields are dropped by the kernel record view (unit-tested; the sample has none); TUI: hidden fields and sections are left out; empty values blank |
|
||||
@@ -240,13 +240,13 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| REC-011 | Edit of a missing record shows the load failure instead of a form | pass | pass | TUI: the load failure shows instead of the form |
|
||||
| REC-012 | Delete asks for confirmation, removes the record and returns to the list without errors | pass | pass | confirm unless --yes; TUI: F8 asks; the grid moves on to the next record |
|
||||
| REC-013 | Copy prefills a new record from the source and saves it as a separate record | pass | pass | qqq table copy <t> <pk> [--set f=v] [-i] [--dry-run]: primary key, non-editable and file fields dropped; TUI: c: copyRecordValues |
|
||||
| REC-014 | STRING and TEXT editors round-trip single and multi-line text | pass | partial | TUI: TEXT single line, $EDITOR on Ctrl+E |
|
||||
| REC-014 | STRING and TEXT editors round-trip single and multi-line text | pass | pass | TUI: single-line text inline; multi-line through $EDITOR (Ctrl+E) round-trips |
|
||||
| REC-015 | INTEGER, LONG and DECIMAL editors keep exact values | pass | pass | |
|
||||
| REC-016 | BOOLEAN editor applies the metadata default and saves true/false | pass | pass | defaults applied; TUI: Space toggles |
|
||||
| REC-017 | DATE and TIME editors round-trip values and clearing | pass | pass | |
|
||||
| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | pass | pass | typed and shown in TZ, stored UTC, --utc for raw; DST edges tested; TUI: local zone in and out (kernel datetime) |
|
||||
| REC-019 | Zoned DATE_TIME display behaviors (fixed zone and per-record zone) | n/a | partial | display zones; TUI: kernel displayDateTime; fixed and per-record zones as the server formats them |
|
||||
| REC-020 | HTML field edits in the rich-text editor and renders sanitized | todo | partial | raw HTML; $EDITOR todo; TUI: HTML edited as text or in $EDITOR |
|
||||
| REC-019 | Zoned DATE_TIME display behaviors (fixed zone and per-record zone) | n/a | pass | display zones; TUI: DATE_TIME through the kernel displayDateTime: server-zoned display values as sent, else the viewer's zone; live on Field Lab |
|
||||
| REC-020 | HTML field edits in the rich-text editor and renders sanitized | todo | n/a | raw HTML; $EDITOR todo; TUI: the rich-text editor is a browser component; HTML is edited as source in $EDITOR and shown as text |
|
||||
| REC-021 | PASSWORD is masked on read and never overwritten by the mask | pass | pass | server masks; TUI: masked |
|
||||
| REC-022 | BLOB upload and download without a download adornment | pass | pass | table create/update --upload and table download (base64 BLOB value) verified on fieldLab; default name into the download directory; TUI: BLOB upload through the file dialog and save from the record (Field Lab, live) |
|
||||
| REC-023 | Dynamic default values (user id, create day, modify day, explicit NONE) | pass | pass | server; TUI: defaults applied by FormSession and the server |
|
||||
@@ -255,21 +255,21 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| REC-026 | Numeric range policies: reject, exclusive reject, inclusive clip and exclusive clip | pass | pass | ranges checked locally; TUI: local range check and server policy |
|
||||
| REC-027 | Unique keys are enforced with the backend message on create and edit | pass | pass | server message; TUI: server message shown |
|
||||
| REC-028 | LINK adornment: record links and URL links | pass | pass | record links name table and key, --follow opens them; URL links show the URL; TUI: links marked, Enter follows (kernel record-view) |
|
||||
| REC-029 | CHIP adornment colors and icons by value | partial | partial | chip as [value]; colors and icons not shown (unit-tested; none in the sample); TUI: kernel valueText renders CHIP as text |
|
||||
| REC-029 | CHIP adornment colors and icons by value | partial | pass | chip as [value]; colors and icons not shown (unit-tested; none in the sample); TUI: CHIP values bracketed and colored by the adornment color (unit-tested only) |
|
||||
| REC-030 | SIZE adornment sets the query grid column width | n/a | pass | TUI: saved widths per column; SIZE as text width |
|
||||
| REC-031 | CODE_EDITOR adornment: code view with formatting and a code editor on forms | partial | partial | raw code on view; no code editor (unit-tested); TUI: code shown as text; $EDITOR to edit |
|
||||
| REC-032 | RENDER_HTML adornment renders sanitized HTML | partial | partial | HTML shown as text (unit-tested); TUI: HTML shown as text |
|
||||
| REC-031 | CODE_EDITOR adornment: code view with formatting and a code editor on forms | partial | n/a | raw code on view; no code editor (unit-tested); TUI: the code editor is a browser component; code is shown as text and edited in $EDITOR |
|
||||
| REC-032 | RENDER_HTML adornment renders sanitized HTML | partial | pass | HTML shown as text (unit-tested); TUI: RENDER_HTML shown as text with the markup stripped (kernel htmlToText) |
|
||||
| REC-033 | REVEAL adornment masks, reveals and copies the value | partial | pass | masked until --reveal in the record view and in query and child-list cells; no copy (unit-tested; none in the sample); TUI: masked; Enter reveals |
|
||||
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | partial | pass | name plus qqq table download via the field route (unit-tested); live only for BLOB values, server finding 16; TUI: Enter saves through the save dialog |
|
||||
| REC-035 | FILE_UPLOAD adornment formats, replacing and removing a stored file | partial | pass | create/update --upload field=path as multipart, replace and remove verified on a BLOB; FILE_UPLOAD formats not in the sample; TUI: FILE_UPLOAD fields choose a file; Backspace clears |
|
||||
| REC-036 | TOOLTIP adornment shows its text on the value | partial | pass | tooltip text in parentheses after the value (unit-tested); TUI: tooltip text appended by valueText |
|
||||
| REC-037 | WIDGET adornment renders the field value as the named widget | partial | partial | value rendered through the widget render model as text (unit-tested); TUI: WIDGET adornment as text |
|
||||
| REC-037 | WIDGET adornment renders the field value as the named widget | partial | pass | value rendered through the widget render model as text (unit-tested); TUI: a WIDGET-adorned field's value drawn as its widget (unit-tested only) |
|
||||
| REC-038 | ERROR adornment highlights the value | partial | pass | value marked with ! (unit-tested); TUI: marked by valueText |
|
||||
| REC-039 | Field help content follows screen roles and formats | partial | partial | table get --help-content: view-screen roles, HTML/MARKDOWN as text (unit-tested; the sample has no help); qqq help show lists help alone; TUI: F1 shows field help; no sample content |
|
||||
| REC-040 | Section help content on the record view | partial | partial | section help with --help-content (unit-tested); TUI: section help via F1; no sample content |
|
||||
| REC-039 | Field help content follows screen roles and formats | partial | pass | table get --help-content: view-screen roles, HTML/MARKDOWN as text (unit-tested; the sample has no help); qqq help show lists help alone; TUI: field help by screen role in F1 and in the form's field hint (unit-tested only) |
|
||||
| REC-040 | Section help content on the record view | partial | pass | section help with --help-content (unit-tested); TUI: section help under the section heading and in F1 (unit-tested only) |
|
||||
| REC-041 | Table help content slots are delivered in table metadata | partial | pass | table helpContent and helpContents slots with --help-content (unit-tested); TUI: table help in F1 |
|
||||
| REC-042 | Audit history lists who changed what, newest first | partial | partial | qqq audit list <t> <pk> [--asc]: entries newest first with field changes; the sample audits no table, verified with node tests and a local stub server; TUI: A opens the audit history (kernel); the sample has no audit source |
|
||||
| REC-043 | Audit history without process permission reads the audit table | partial | partial | without the audit process the audit table is queried with its auditTable and auditDetail joins (node tests); TUI: kernel reads the audit table when there is no process; unverified live |
|
||||
| REC-042 | Audit history lists who changed what, newest first | partial | pass | qqq audit list <t> <pk> [--asc]: entries newest first with field changes; the sample audits no table, verified with node tests and a local stub server; TUI: A: audits newest first, who, when, what, changes (unit-tested only; the sample has no audit source) |
|
||||
| REC-043 | Audit history without process permission reads the audit table | partial | pass | without the audit process the audit table is queried with its auditTable and auditDetail joins (node tests); TUI: without the audit process the kernel reads the audit table (kernel-tested; the TUI uses it) |
|
||||
| REC-044 | Record label format is used for the record's title and dialogs | pass | pass | recordLabel |
|
||||
| REC-045 | Possible-value editor shows stored labels, looks up defaults and selects by keyboard | pass | pass | labels resolved, defaults looked up; TUI: picker by keyboard |
|
||||
| REC-046 | Read-only persona sees no write actions and the backend refuses writes | pass | pass | server; TUI: write keys hidden without permission; server refuses |
|
||||
@@ -281,16 +281,16 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| REC-056 | Record view hash links: #audit, #/launchProcess={process} and #{section} anchors | n/a | n/a | TUI: hash links |
|
||||
| REC-057 | Create presets from links: #/createChild= opens a child create dialog over the record; /create#/defaultValues= | n/a | pass | TUI: :table create T --set parent=… and F7 on a child list |
|
||||
| REC-052 | Dependent possible values: a field whose filter uses ${input.otherField} offers only the choices matching the | pass | pass | contingent fields; TUI: contingent fields wait and narrow |
|
||||
| REC-053 | Table developer view shows the API Docs & Playground section for the table's application APIs | partial | partial | qqq table apis <t> and table api-doc <t> <api> <version> (saved as JSON); no playground; the sample exposes no application APIs (verified: none listed, an unknown API exits 5); TUI: D on a grid lists the table APIs and their OpenAPI documents; the sample has none |
|
||||
| REC-054 | Record developer view manages associated scripts: versions, new versions, test runs, logs, docs and script cre | partial | partial | qqq script list/show [--revision]/store --file/--editor/logs, and table developer <t> <pk> with the developer panels (verified live on person 1); no test runs; the sample has no associated scripts, so scripts are covered by node tests; TUI: D on a record: panels live; scripts, versions, logs (kernel-tested) |
|
||||
| REC-053 | Table developer view shows the API Docs & Playground section for the table's application APIs | partial | pass | qqq table apis <t> and table api-doc <t> <api> <version> (saved as JSON); no playground; the sample exposes no application APIs (verified: none listed, an unknown API exits 5); TUI: D on a grid: the table's APIs and their OpenAPI documents, view or save (unit-tested only) |
|
||||
| REC-054 | Record developer view manages associated scripts: versions, new versions, test runs, logs, docs and script cre | partial | pass | qqq script list/show [--revision]/store --file/--editor/logs, and table developer <t> <pk> with the developer panels (verified live on person 1); no test runs; the sample has no associated scripts, so scripts are covered by node tests; TUI: D on a record: panels (live), scripts, revisions, code, logs, store a new version (unit-tested) |
|
||||
| REC-055 | Record view keyboard shortcuts n, e, c, d, a (Material) and their help text | n/a | pass | TUI: n e c d; F-keys |
|
||||
| REC-058 | Record view on phones and tablets: the title stays readable, the header controls wrap onto their own row, and | n/a | n/a | TUI: phone layout |
|
||||
| REC-059 | Record forms on phones and tablets: every editor type fits without sideways scrolling, controls are touch-size | n/a | n/a | TUI: phone layout |
|
||||
| REC-061 | An editable dynamicForm widget on a record form validates its fields and persists them in the declared JSON re | todo | todo | TUI: dynamicForm widget editing |
|
||||
| REC-062 | Inline enum possible values filter by label prefix without a lookup request; CHIP adornments style options and | pass | pass | inline enums; TUI: inline enums filter locally |
|
||||
| REC-060 | Material table and field form adjusters and field rules operate against the real QQQ sample server | todo | partial | adjusters wired, no sample to test; TUI: adjusters wired through FormSession; no sample adjuster |
|
||||
| REC-064 | Record sections render qualified fields from readable exposed joins | partial | todo | table get --join adds joined fields (queryJoins); verified with an association join; TUI: exposed-join fields in sections |
|
||||
| REC-063 | Record view, edit and copy load from the table variant selected on the query screen | partial | todo | get, copy and update -i read with --variant; v1 writes ignore it (server finding #19); TUI: record view and forms with the grid variant |
|
||||
| REC-060 | Material table and field form adjusters and field rules operate against the real QQQ sample server | todo | pass | adjusters wired, no sample to test; TUI: form adjusters run on change through FormSession (unit-tested only; the sample has none) |
|
||||
| REC-064 | Record sections render qualified fields from readable exposed joins | partial | pass | table get --join adds joined fields (queryJoins); verified with an association join; TUI: section fields of readable exposed joins read through a joined query (unit-tested only) |
|
||||
| REC-063 | Record view, edit and copy load from the table variant selected on the query screen | partial | pass | get, copy and update -i read with --variant; v1 writes ignore it (server finding #19); TUI: records and forms opened from a variant grid read with tableVariant (unit-tested only; writes ignore it, finding 19) |
|
||||
| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | pass | pass | an unchanged wall-clock time keeps the original instant and its precision; TUI: DATE_TIME edited in local zone, unchanged instants kept |
|
||||
| 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 |
|
||||
@@ -346,7 +346,7 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| INT-010 | Failed request and recovery: a failed list request shows an error with retry; retry loads the data | pass | pass | exit 4 and a message; rerun; TUI: F5 retries |
|
||||
| INT-011 | Delayed save: the submit control is disabled while saving and double submission creates one record | n/a | n/a | browser accessibility and layout |
|
||||
| INT-012 | Stale state: changes and deletions made elsewhere are shown after refresh/navigation, not stale cache | pass | pass | no client cache of records; TUI: F5 and on return |
|
||||
| INT-013 | Empty state: a query with no results explains it and offers the create action | pass | partial | (no records); TUI: no records message; F7 offered |
|
||||
| INT-013 | Empty state: a query with no results explains it and offers the create action | pass | pass | (no records); TUI: no records explains it and offers F7 when the user may create; live |
|
||||
| INT-014 | Backend unavailable at sign-in: the login page explains the failure and recovers with Try again | pass | pass | cannot reach the server; TUI: sign-in screen shows the failure |
|
||||
| SEC-050 | Optional QA capture (Jam, qbit-qa-capture qa-capture extension): Jam loads only for enabled users; Report a bu | n/a | n/a | browser security |
|
||||
| WID-001 | alert widget: severity from alertType, sanitized HTML and bullet list | pass | pass | rendered as text |
|
||||
@@ -386,7 +386,7 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| WID-031 | rowBuilder widget without association binding shows its rows read-only | todo | todo | developer and editor widgets |
|
||||
| WID-032 | scriptViewer widget lists script revisions and shows the selected revision's files | todo | todo | developer and editor widgets |
|
||||
| WID-033 | quickSightChart against a real AWS QuickSight account and dashboard | n/a | todo | QuickSight |
|
||||
| WID-040 | dashboard renders app widgets in declared order sized by gridColumns (twelfths) | todo | partial | widgets per app; TUI: declared order, v reorders; one column |
|
||||
| WID-040 | dashboard renders app widgets in declared order sized by gridColumns (twelfths) | todo | pass | widgets per app; TUI: widgets in declared order, laid out side by side by gridColumns on wide terminals, v reorders; live |
|
||||
| WID-041 | widget chrome: card vs plain (isCard=false), sublabel, metadata and payload footer HTML | todo | todo | footer text |
|
||||
| WID-042 | header icons (topLeftInsideCard/topRightInsideCard) with metadata colors | n/a | n/a | card chrome |
|
||||
| WID-043 | widget label tooltip from metadata on hover and keyboard focus | n/a | n/a | card chrome |
|
||||
@@ -406,7 +406,7 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| WID-057 | composite renders every block type with its values and styles | todo | todo | every block type |
|
||||
| WID-058 | composite layouts and slot links/tooltips | n/a | n/a | card chrome |
|
||||
| WID-059 | unsupported block type shows a contained warning | pass | todo | rendered as text |
|
||||
| WID-060 | record-view section widgets receive the record context (id, tableName) | pass | partial | --input id=…; TUI: record section widgets get the id |
|
||||
| WID-060 | record-view section widgets receive the record context (id, tableName) | pass | pass | --input id=…; TUI: record section widgets get the record id and table (unit-tested) |
|
||||
| WID-061 | process widget for a persona without process permission is contained | pass | todo | rendered as text |
|
||||
| WID-062 | custom component whose bundle cannot load shows a contained error | n/a | n/a | card chrome |
|
||||
| WID-063 | alerts with hideWidget or without HTML render nothing | pass | todo | |
|
||||
@@ -439,7 +439,7 @@ The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
| WID-074 | Standalone composite scalar controls and metadata editors | todo | todo | developer and editor widgets |
|
||||
| WID-075 | Standalone DATE_TIME blocks localize seeded instants without losing seconds | todo | todo | developer and editor widgets |
|
||||
| HLP-001 | App and widget help | partial | todo | qqq help show app/widget/instance; the sample carries no help content |
|
||||
| HLP-002 | Table, field and section help | partial | partial | qqq help show table <t> [field]: table, section and field help; no sample content; TUI: F1 shows table and field help; the sample has none |
|
||||
| HLP-002 | Table, field and section help | partial | pass | qqq help show table <t> [field]: table, section and field help; no sample content; TUI: table, field and section help by screen role in F1 (unit-tested only; the sample has none) |
|
||||
| HLP-003 | Query columns and table action help | partial | todo | field help is the column help; table action help slots print with their slot name; no sample content |
|
||||
| HLP-004 | Process, step and record action help | partial | todo | qqq help show process <p>: process, step, HELP_TEXT and step field help; no sample content |
|
||||
| HLP-005 | Help search | pass | pass | qqq help-search; TUI: type in F1 |
|
||||
|
||||
@@ -28,4 +28,5 @@ the sample app and MOCK authentication. Filed on 2026-10-31 (James's OK); the `I
|
||||
| 21 | `GET /version` lists the `recordSearch` feature, but `POST /search` on the sample answers `{"results":[],"tables":[],"provider":{"status":"NONE"}}` for any text: the feature is advertised with no provider behind it, so a client cannot tell "nothing matched" from "this server cannot search" without reading `provider.status`. | `qqq api searchRecords --body-json '{"query":"avery"}'` | The TUI palette reads `provider.status` and says the server has no record search. Either list `recordSearch` only when a provider is configured, or document `provider.status` as the signal. | [qqq#1343](https://github.com/QRun-IO/qqq/issues/1343) |
|
||||
| 22 | The `multiStatistics` widget payload names its list `statisticList` (singular) inside `statisticsGroupData`, while other statistics payloads and the kernel's first reading used `statisticsList`; the OpenAPI document does not type the group, so a client can only guess. | `qqq widget get SampleMultiStatisticsWidget --output json` | The kernel render model accepts both names. Type `MultiStatisticsData` group items in the spec. | [qqq#1353](https://github.com/QRun-IO/qqq/issues/1353) |
|
||||
| 23 | The realtime SSE writer (`RealtimeStreamSpecV1.handleOutput`) serialises event data with `JsonUtils.toJson`, whose mapper is `NON_EMPTY`, so every empty array is dropped from every event while the HTTP JSON endpoints keep them (`ALWAYS`). Root cause of finding 18; also hits an empty `fields` on `record.*` events. | Subscribe to `/realtime/stream`; emit an event whose data has an empty list; the key is missing. | The kernel treats a missing array in an event as empty. Fixed in qbit-collaboration#52 for presence; core should serialise with `ALWAYS`. | [qqq#1354](https://github.com/QRun-IO/qqq/issues/1354) |
|
||||
| 24 | A JSON `null` in a process step's `values` answers 500 `QRuntimeException (Non-serializable value in param map under key [values][birthDate]: Null)`. Bulk edit clears a switched-on field by sending it empty, and `null` is the natural JSON for "no value". | `qqq api stepProcess` on `person.bulkEdit` step `edit` with `values={"birthDate":null,"bulkEditEnabledFields":"birthDate"}`. | The kernel sends `""` for a cleared field, as the Next UI does (`bulkEditValues`). Treat `null` as no value, or answer 400. | |
|
||||
| 24 | A weekday criterion (`fieldFunction` `WeekdayOfDate`, as the web UI sends for "day of week is any of") fails on the sample with 500 `INTERNAL_ERROR` (`Error executing query`): the RDBMS backend renders it as MySQL `WEEKDAY(col) + 1`, and H2 has no `WEEKDAY` function (`JdbcSQLSyntaxErrorException: Function "WEEKDAY" not found`). | `POST /qqq/v1/table/person/query` with `{"filter":{"criteria":[{"fieldName":"birthDate","operator":"IN","values":["1"],"fieldFunction":{"fieldName":"birthDate","functionTypeIdentifierName":"WeekdayOfDate"}}],"booleanOperator":"AND"}}`; `qqq ui`, `:filter weekday(birthDate) IN mon`. | The TUI sends the criterion as the web UI does and shows the server error in the grid. The backend needs a per-dialect weekday expression (H2 `ISO_DAY_OF_WEEK`, MySQL `WEEKDAY()+1`). | [qqq#1355](https://github.com/QRun-IO/qqq/issues/1355) |
|
||||
| 25 | A JSON `null` in a process step's `values` answers 500 `QRuntimeException (Non-serializable value in param map under key [values][birthDate]: Null)`. Bulk edit clears a switched-on field by sending it empty, and `null` is the natural JSON for "no value". | `qqq api stepProcess` on `person.bulkEdit` step `edit` with `values={"birthDate":null,"bulkEditEnabledFields":"birthDate"}`. | The kernel sends `""` for a cleared field, as the Next UI does (`bulkEditValues`). Treat `null` as no value, or answer 400. | [qqq#1356](https://github.com/QRun-IO/qqq/issues/1356) |
|
||||
@@ -57,6 +57,41 @@ export interface Criterion {
|
||||
fieldName: string
|
||||
operator: Operator
|
||||
values: FilterValue[]
|
||||
/** A backend function applied to the field before comparing (`weekday(birthDate) IN 1,7`). */
|
||||
fieldFunction?: FieldFunction
|
||||
}
|
||||
|
||||
/** A backend field function (FieldFunction): `functionTypeIdentifierName` applied to `fieldName`. */
|
||||
export interface FieldFunction {
|
||||
fieldName: string
|
||||
functionTypeIdentifierName: string
|
||||
arguments?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const WEEKDAY_OF_DATE = 'WeekdayOfDate'
|
||||
export const WEEKDAY_OF_DATE_TIME = 'WeekdayOfDateTime'
|
||||
const DAY_NUMBER: Record<string, string> = { mon: '1', tue: '2', wed: '3', thu: '4', fri: '5', sat: '6', sun: '7' }
|
||||
|
||||
/** ISO weekday numbers from typed values: 1-7 or day names (monday, mon, …). */
|
||||
export function weekdayValues(values: FilterValue[]): string[] {
|
||||
return values.map((v) => {
|
||||
const text = String(v).trim().toLowerCase()
|
||||
if (/^[1-7]$/.test(text)) return text
|
||||
const n = DAY_NUMBER[text.slice(0, 3)]
|
||||
if (!n) throw new FilterSyntaxError(`Not a weekday: ${String(v)} (use 1-7, Monday 1, or day names)`)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
/** Uses WeekdayOfDateTime for weekday criteria on DATE_TIME fields (the grammar cannot know the type). */
|
||||
export function applyFieldTypes<F extends { criteria: Criterion[]; subFilters?: F[] }>(filter: F, fieldType: (name: string) => string | undefined, dateTimeArguments?: Record<string, unknown>): F {
|
||||
for (const c of filter.criteria) {
|
||||
if (c.fieldFunction && /^WeekdayOf/.test(c.fieldFunction.functionTypeIdentifierName) && fieldType(c.fieldName) === 'DATE_TIME') {
|
||||
c.fieldFunction = { ...c.fieldFunction, functionTypeIdentifierName: WEEKDAY_OF_DATE_TIME, ...(dateTimeArguments ? { arguments: dateTimeArguments } : {}) }
|
||||
}
|
||||
}
|
||||
for (const sub of filter.subFilters ?? []) applyFieldTypes(sub, fieldType, dateTimeArguments)
|
||||
return filter
|
||||
}
|
||||
|
||||
export interface OrderBy {
|
||||
@@ -146,9 +181,11 @@ function splitValues(text: string): string[] {
|
||||
* @param knownFields - When given, the field must be one of them.
|
||||
*/
|
||||
export function parseCriterion(text: string, knownFields?: Iterable<string>): Criterion {
|
||||
const match = /^\s*([A-Za-z0-9_.]+)\s+(\S+)\s*(.*)$/s.exec(text)
|
||||
const match = /^\s*((?:weekday\()?[A-Za-z0-9_.]+\)?)\s+(\S+)\s*(.*)$/s.exec(text)
|
||||
if (!match) throw new FilterSyntaxError(`A criterion is "field OPERATOR value": ${text.trim()}`)
|
||||
const [, fieldName, rawOperator, rest] = match as unknown as [string, string, string, string]
|
||||
const [, rawField, rawOperator, rest] = match as unknown as [string, string, string, string]
|
||||
const weekday = /^weekday\(([A-Za-z0-9_.]+)\)$/.exec(rawField)
|
||||
const fieldName = weekday ? weekday[1]! : rawField
|
||||
const operator = SHORTHAND[rawOperator] ?? rawOperator.toUpperCase() as Operator
|
||||
if (!OPERATORS.includes(operator)) throw new FilterSyntaxError(`Unknown operator "${rawOperator}"; use one of ${OPERATORS.join(', ')} or = != < <= > >= ~`)
|
||||
if (knownFields) {
|
||||
@@ -161,14 +198,19 @@ export function parseCriterion(text: string, knownFields?: Iterable<string>): Cr
|
||||
return { fieldName, operator, values: [] }
|
||||
}
|
||||
if (trimmed === '') throw new FilterSyntaxError(`${operator} needs a value: ${text.trim()}`)
|
||||
const values = (LIST_OPERATORS.has(operator) ? splitValues(trimmed) : [trimmed]).map(parseValue)
|
||||
let values = (LIST_OPERATORS.has(operator) ? splitValues(trimmed) : [trimmed]).map(parseValue)
|
||||
if (weekday) {
|
||||
values = weekdayValues(values)
|
||||
return { fieldName, operator, values, fieldFunction: { fieldName, functionTypeIdentifierName: WEEKDAY_OF_DATE } }
|
||||
}
|
||||
if ((operator === 'BETWEEN' || operator === 'NOT_BETWEEN') && values.length !== 2) throw new FilterSyntaxError(`${operator} needs two values: ${text.trim()}`)
|
||||
return { fieldName, operator, values }
|
||||
}
|
||||
|
||||
/** Prints one criterion. */
|
||||
export function printCriterion(c: Criterion): string {
|
||||
return c.values.length ? `${c.fieldName} ${c.operator} ${c.values.map(printValue).join(',')}` : `${c.fieldName} ${c.operator}`
|
||||
const field = c.fieldFunction && /^WeekdayOf/.test(c.fieldFunction.functionTypeIdentifierName) ? `weekday(${c.fieldName})` : c.fieldName
|
||||
return c.values.length ? `${field} ${c.operator} ${c.values.map(printValue).join(',')}` : `${field} ${c.operator}`
|
||||
}
|
||||
|
||||
type Token = { kind: '(' | ')' | 'AND' | 'OR' } | { kind: 'criterion'; text: string }
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface ResultCursorOptions {
|
||||
total?: number
|
||||
/** Extra query parameters (tableVariant). */
|
||||
query?: Record<string, string | undefined>
|
||||
/** Joins (QueryJoin) sent with query and count; with a many-side join the count includes the distinct count. */
|
||||
joins?: unknown[]
|
||||
includeDistinct?: boolean
|
||||
}
|
||||
|
||||
export class ResultCursor {
|
||||
@@ -46,6 +49,10 @@ export class ResultCursor {
|
||||
private readonly baseUrl: string
|
||||
private readonly fetchImpl: typeof fetch
|
||||
private readonly query?: Record<string, string | undefined>
|
||||
private readonly joins?: unknown[]
|
||||
private readonly includeDistinct: boolean
|
||||
/** The distinct count of the base records (with a many-side join), when the server sent it. */
|
||||
distinct?: number
|
||||
private pages = new Map<number, QueryRecord[]>()
|
||||
|
||||
constructor(options: ResultCursorOptions) {
|
||||
@@ -58,6 +65,8 @@ export class ResultCursor {
|
||||
this.index = options.index ?? 0
|
||||
this.total = options.total
|
||||
this.query = options.query
|
||||
this.joins = options.joins?.length ? options.joins : undefined
|
||||
this.includeDistinct = Boolean(options.includeDistinct)
|
||||
}
|
||||
|
||||
/** The page number holding an index. */
|
||||
@@ -71,8 +80,11 @@ export class ResultCursor {
|
||||
|
||||
/** Counts the matching records (also done by the first page load when unknown). */
|
||||
async count(): Promise<number> {
|
||||
const result = await callOperation(this.baseUrl, 'countTable', { path: { tableName: this.table }, query: this.query, json: { filter: this.wireFilter() } }, this.fetchImpl)
|
||||
this.total = Number((result.data as { count?: number }).count ?? 0)
|
||||
const query = this.includeDistinct ? { ...(this.query ?? {}), includeDistinct: 'true' } : this.query
|
||||
const result = await callOperation(this.baseUrl, 'countTable', { path: { tableName: this.table }, query, json: { filter: this.wireFilter(), ...(this.joins ? { joins: this.joins } : {}) } }, this.fetchImpl)
|
||||
const data = result.data as { count?: number; distinctCount?: number }
|
||||
this.total = Number(data.count ?? 0)
|
||||
this.distinct = data.distinctCount !== undefined && data.distinctCount !== null ? Number(data.distinctCount) : undefined
|
||||
return this.total
|
||||
}
|
||||
|
||||
@@ -81,7 +93,7 @@ export class ResultCursor {
|
||||
if (!options.refresh && this.pages.has(page)) return this.pages.get(page)!
|
||||
const filter = { ...this.wireFilter(), skip: page * this.pageSize, limit: this.pageSize }
|
||||
const [result] = await Promise.all([
|
||||
callOperation(this.baseUrl, 'queryTable', { path: { tableName: this.table }, query: this.query, json: { filter } }, this.fetchImpl),
|
||||
callOperation(this.baseUrl, 'queryTable', { path: { tableName: this.table }, query: this.query, json: { filter, ...(this.joins ? { joins: this.joins } : {}) } }, this.fetchImpl),
|
||||
this.total === undefined || options.refresh ? this.count() : Promise.resolve(this.total),
|
||||
])
|
||||
const records = ((result.data as { records?: QueryRecord[] }).records ?? [])
|
||||
|
||||
@@ -67,3 +67,13 @@ test('buildFilter assembles where, group, or, order, skip and limit', () => {
|
||||
assert.equal(only.criteria.length, 1)
|
||||
assert.equal(only.subFilters?.length, 1)
|
||||
})
|
||||
|
||||
test('weekday criteria: weekday(field) with ISO numbers or day names, printed back; DATE_TIME gets its function', async () => {
|
||||
const { applyFieldTypes, parseFilter: pf, printFilter: pr } = await import('../filter/grammar.js')
|
||||
const f = pf('weekday(birthDate) IN sat,sun AND weekday(createDate) NOT_IN 1')
|
||||
assert.deepEqual(f.criteria[0], { fieldName: 'birthDate', operator: 'IN', values: ['6', '7'], fieldFunction: { fieldName: 'birthDate', functionTypeIdentifierName: 'WeekdayOfDate' } })
|
||||
applyFieldTypes(f, (n) => (n === 'createDate' ? 'DATE_TIME' : 'DATE'), { timeZoneId: 'America/Chicago' })
|
||||
assert.deepEqual(f.criteria[1]!.fieldFunction, { fieldName: 'createDate', functionTypeIdentifierName: 'WeekdayOfDateTime', arguments: { timeZoneId: 'America/Chicago' } })
|
||||
assert.equal(pr(f), 'weekday(birthDate) IN 6,7 AND weekday(createDate) NOT_IN 1')
|
||||
assert.throws(() => pf('weekday(birthDate) IN funday'), /Not a weekday/)
|
||||
})
|
||||
@@ -49,3 +49,20 @@ test('after a delete the cursor shows the next record and reloads pages', async
|
||||
assert.equal(cursor.total, 4)
|
||||
assert.equal(cursor.index, 3)
|
||||
})
|
||||
|
||||
test('joins go with query and count; a many-side join asks for the distinct count', async () => {
|
||||
const seen: Array<{ path: string; query: string; body: { joins?: unknown } }> = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const request = new Request(input as RequestInfo, init)
|
||||
const url = new URL(request.url)
|
||||
seen.push({ path: url.pathname, query: url.search, body: await request.json() as { joins?: unknown } })
|
||||
const json = (d: unknown) => new Response(JSON.stringify(d), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
return url.pathname.endsWith('/count') ? json({ count: 21, distinctCount: 16 }) : json({ records: [] })
|
||||
}
|
||||
const cursor = new ResultCursor({ baseUrl: 'http://h', fetch: fetchImpl, table: 'person', filter: { criteria: [], booleanOperator: 'AND' }, pageSize: 10, joins: [{ joinTable: 'pet', select: true, type: 'LEFT' }], includeDistinct: true })
|
||||
await cursor.page(0)
|
||||
assert.equal(cursor.total, 21)
|
||||
assert.equal(cursor.distinct, 16)
|
||||
assert.ok(seen.every((s) => JSON.stringify(s.body.joins) === '[{"joinTable":"pet","select":true,"type":"LEFT"}]'))
|
||||
assert.equal(seen.find((s) => s.path.endsWith('/count'))!.query, '?includeDistinct=true')
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { useKeyboard, useTerminalDimensions } from '@opentui/react'
|
||||
import { useKeyboard, usePaste, useTerminalDimensions } from '@opentui/react'
|
||||
import { Component, useEffect, useReducer, type ReactNode } from 'react'
|
||||
import type { AppTreeNode } from './client'
|
||||
import { presenceText, usePresence } from './presence'
|
||||
@@ -141,6 +141,7 @@ export function App({ store }: { store: AppStore }) {
|
||||
if (width !== store.width || height !== store.height) store.resize(width, height)
|
||||
}, [width, height, store])
|
||||
useKeyboard((key) => store.handleKey(key))
|
||||
usePaste((event) => store.handlePaste(new TextDecoder().decode(event.bytes)))
|
||||
const t = theme()
|
||||
const w = Math.max(20, width)
|
||||
const h = Math.max(8, height)
|
||||
|
||||
@@ -140,8 +140,11 @@ export class TuiClient {
|
||||
return Number((await this.data<{ count?: number }>('countTable', { path: { tableName: table }, json: { filter } })).count ?? 0)
|
||||
}
|
||||
|
||||
async record(table: string, primaryKey: unknown, associations = false): Promise<QueryRecord & { associatedRecords?: Record<string, QueryRecord[]> }> {
|
||||
const data = await this.data<Json>('getRecord', { path: { tableName: table, primaryKey: String(primaryKey) }, query: associations ? { includeAssociations: 'true' } : undefined })
|
||||
async record(table: string, primaryKey: unknown, associations = false, tableVariant?: string): Promise<QueryRecord & { associatedRecords?: Record<string, QueryRecord[]> }> {
|
||||
const query: Record<string, string> = {}
|
||||
if (associations) query.includeAssociations = 'true'
|
||||
if (tableVariant) query.tableVariant = tableVariant
|
||||
const data = await this.data<Json>('getRecord', { path: { tableName: table, primaryKey: String(primaryKey) }, query: Object.keys(query).length ? query : undefined })
|
||||
return (data.record ?? data) as QueryRecord
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
|
||||
import { runCli } from '@qrun-io/qqq-cli/program'
|
||||
import { THEMES } from './theme'
|
||||
import { applyLiveFix, checkEnvironment, formatReport, buildFilter, readFilePreferences, startDirForSave, startDirForUpload, writeFilePreferences, hasPin, readConfig, readRecents, removePin, setPin, writeIdleMinutes, writeTheme, readIdleMinutes, type QueryFilter } from '@qrun-io/qqq-core'
|
||||
import { helpEntryText, helpText, processHelpText, selectHelpContent, tableViewHelp, applyLiveFix, checkEnvironment, formatReport, buildFilter, readFilePreferences, startDirForSave, startDirForUpload, writeFilePreferences, hasPin, readConfig, readRecents, removePin, setPin, writeIdleMinutes, writeTheme, readIdleMinutes, type QueryFilter } from '@qrun-io/qqq-core'
|
||||
import type { AppTreeNode } from './client'
|
||||
import { openApp, openForm, openProcess, openRecord, openTable } from './nav'
|
||||
import { ChoiceOverlay, PromptOverlay } from './overlays/basic'
|
||||
import { CommandLineOverlay, HelpOverlay, LockOverlay, MenuOverlay, PaletteOverlay, type PaletteItem } from './overlays/center'
|
||||
import { chooseFolder } from './overlays/files-helpers'
|
||||
import { OutputScreen } from './screens/output'
|
||||
import { openReport } from './screens/report'
|
||||
import { TableApisScreen } from './screens/developer'
|
||||
import { actionsOverlay } from './overlays/actions'
|
||||
import { SignInScreen } from './screens/signin'
|
||||
@@ -75,13 +76,30 @@ export class Commands implements CommandsLike {
|
||||
})
|
||||
s.open(overlay)
|
||||
if (topic?.kind === 'table' && s.client) {
|
||||
// table, section and field help for this screen's roles (kernel record-view helpers)
|
||||
const roles = screen?.kind === 'form' ? ['INSERT_SCREEN', 'EDIT_SCREEN', 'WRITE_SCREENS', 'ALL_SCREENS'] : screen?.kind === 'table' ? ['QUERY_SCREEN', 'READ_SCREENS', 'ALL_SCREENS'] : ['VIEW_SCREEN', 'READ_SCREENS', 'ALL_SCREENS']
|
||||
s.client.tableMeta(topic.name).then((m) => {
|
||||
for (const h of m.helpContents ?? []) if (h.contentAsText) topicHelp.push(h.contentAsText)
|
||||
for (const f of Object.values(m.fields)) for (const h of f.helpContents ?? []) if (h.contentAsText) topicHelp.push(`${f.label}: ${h.contentAsText}`)
|
||||
for (const h of tableViewHelp(m as never, roles)) topicHelp.push(h.slot === 'table' ? h.text : `${h.slot}: ${h.text}`)
|
||||
for (const sec of m.sections ?? []) { const t = helpEntryText(selectHelpContent((sec as { helpContents?: unknown }).helpContents, roles)); if (t) topicHelp.push(`${sec.label}: ${t}`) }
|
||||
for (const f of Object.values(m.fields)) { const t = helpEntryText(selectHelpContent(f.helpContents, roles)); if (t) topicHelp.push(`${f.label}: ${t}`) }
|
||||
if (!topicHelp.length) topicHelp.push(`${m.label}: no help content on this server. Fields: ${Object.values(m.fields).map((f) => f.label).join(', ')}.`)
|
||||
s.bump()
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
if (topic?.kind === 'process' && s.client) {
|
||||
s.client.processMeta(topic.name).then((p) => {
|
||||
for (const t of processHelpText(p as never)) topicHelp.push(t)
|
||||
for (const step of p.frontendSteps ?? []) for (const t of helpText((step as { helpContents?: never }).helpContents)) topicHelp.push(`${step.label ?? step.name}: ${t}`)
|
||||
if (!topicHelp.length) topicHelp.push(`${p.label ?? p.name}: no help content on this server.`)
|
||||
s.bump()
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
if (topic?.kind === 'app') {
|
||||
const app = (s.meta?.apps as Record<string, { label?: string; helpContents?: unknown; widgets?: string[] }> | undefined)?.[topic.name]
|
||||
const t = helpEntryText(selectHelpContent(app?.helpContents))
|
||||
if (t) topicHelp.push(t)
|
||||
for (const w of app?.widgets ?? []) { const wt = helpEntryText(selectHelpContent((s.meta?.widgets as Record<string, { helpContents?: unknown; label?: string }> | undefined)?.[w]?.helpContents)); if (wt) topicHelp.push(`${(s.meta?.widgets as Record<string, { label?: string }>)[w]?.label ?? w}: ${wt}`) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a metadata object found in the index. */
|
||||
@@ -90,7 +108,7 @@ export class Commands implements CommandsLike {
|
||||
if (kind === 'table') openTable(s, name)
|
||||
else if (kind === 'process') openProcess(s, name)
|
||||
else if (kind === 'app') openApp(s, name)
|
||||
else if (kind === 'report') this.commandLine(`report run ${name} --format csv`)
|
||||
else if (kind === 'report') openReport(s, name)
|
||||
else if (kind === 'widget') void this.run(`widget get ${name}`)
|
||||
}
|
||||
|
||||
@@ -255,6 +273,9 @@ export class Commands implements CommandsLike {
|
||||
case 'connection': return this.connection()
|
||||
case 'open': return rest[0] ? openTable(s, rest[0]) : fail('open <table>')
|
||||
case 'app': return rest[0] ? openApp(s, rest[0]) : fail('app <name>')
|
||||
case 'report':
|
||||
if (rest[0] === 'run' && rest[1] && !rest.includes('--format') && !rest.includes('--out')) return openReport(s, rest[1])
|
||||
break
|
||||
case 'record': return rest[0] && rest[1] ? openRecord(s, rest[0], rest[1]) : fail('record <table> <primaryKey>')
|
||||
case 'table':
|
||||
if (this.tableCommand(rest)) return
|
||||
|
||||
@@ -16,10 +16,14 @@ export class LineEditor {
|
||||
cursor: number
|
||||
masked: boolean
|
||||
|
||||
constructor(value = '', options: { masked?: boolean } = {}) {
|
||||
/** What a pasted line break becomes (a space in text fields; ', ' where values are lists). */
|
||||
newline: string
|
||||
|
||||
constructor(value = '', options: { masked?: boolean; newline?: string } = {}) {
|
||||
this.value = value
|
||||
this.cursor = value.length
|
||||
this.masked = Boolean(options.masked)
|
||||
this.newline = options.newline ?? ' '
|
||||
}
|
||||
|
||||
set(value: string): void {
|
||||
@@ -28,7 +32,7 @@ export class LineEditor {
|
||||
}
|
||||
|
||||
insert(text: string): void {
|
||||
const clean = text.replace(/[\r\n]+/g, ' ')
|
||||
const clean = text.replace(/\r\n|\r|\n/g, this.newline).replace(/\t/g, this.newline === ' ' ? ' ' : this.newline)
|
||||
this.value = this.value.slice(0, this.cursor) + clean + this.value.slice(this.cursor)
|
||||
this.cursor += clean.length
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ export function openTable(store: AppStore, table: string, filter?: QueryFilter):
|
||||
store.push(new TableScreen(store, table, filter))
|
||||
}
|
||||
|
||||
export function openRecord(store: AppStore, table: string, primaryKey: string | undefined, options: { cursor?: ResultCursor; label?: string } = {}): void {
|
||||
export function openRecord(store: AppStore, table: string, primaryKey: string | undefined, options: { cursor?: ResultCursor; label?: string; tableVariant?: string } = {}): void {
|
||||
if (!known(store, 'tables', table)) return
|
||||
store.push(new RecordScreen(store, table, primaryKey, options.cursor, options.label))
|
||||
store.push(new RecordScreen(store, table, primaryKey, options.cursor, options.label, options.tableVariant))
|
||||
}
|
||||
|
||||
/** After a create: the new record replaces the form. */
|
||||
@@ -58,6 +58,6 @@ export function openNode(store: AppStore, node: AppTreeNode): void {
|
||||
if (node.type === 'TABLE') openTable(store, node.name)
|
||||
else if (node.type === 'PROCESS') openProcess(store, node.name)
|
||||
else if (node.type === 'APP') openApp(store, node.name)
|
||||
else if (node.type === 'REPORT') store.commands().commandLine(`report run ${node.name} --format csv`)
|
||||
else if (node.type === 'REPORT') void import('./screens/report').then((r) => r.openReport(store, node.name))
|
||||
else store.flash(`${node.type} ${node.label} has no terminal screen yet`)
|
||||
}
|
||||
@@ -103,9 +103,12 @@ export function EditorLine(props: { editor: LineEditor; width: number; focused?:
|
||||
export class PromptOverlay implements Overlay {
|
||||
readonly id = overlayId('prompt')
|
||||
readonly editor: LineEditor
|
||||
constructor(private store: AppStore, readonly title: string, readonly hint: string, private onSubmit: (value: string) => void, initial = '', options: { masked?: boolean } = {}) {
|
||||
constructor(private store: AppStore, readonly title: string, readonly hint: string, private onSubmit: (value: string) => void, initial = '', options: { masked?: boolean; newline?: string } = {}) {
|
||||
this.editor = new LineEditor(initial, options)
|
||||
}
|
||||
paste(text: string): void {
|
||||
this.editor.insert(text)
|
||||
}
|
||||
onKey(key: Key): void {
|
||||
if (key.name === 'escape') this.store.closeOverlay(this)
|
||||
else if (key.name === 'return') {
|
||||
|
||||
@@ -348,6 +348,10 @@ export class CommandLineOverlay implements Overlay {
|
||||
this.editor = new LineEditor(initial)
|
||||
}
|
||||
|
||||
paste(text: string): void {
|
||||
this.editor.insert(text)
|
||||
}
|
||||
|
||||
onKey(key: Key): void {
|
||||
if (this.search) {
|
||||
if (key.name === 'escape') {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
FilterSyntaxError, LIST_OPERATORS, OPERATORS, UNARY_OPERATORS, applyCompletion, completeFilter, describeFilter, expressionCandidates, locateFilterError,
|
||||
FilterSyntaxError, LIST_OPERATORS, OPERATORS, UNARY_OPERATORS, WEEKDAY_OF_DATE, WEEKDAY_OF_DATE_TIME, applyFieldTypes, applyCompletion, completeFilter, describeFilter, expressionCandidates, locateFilterError,
|
||||
operatorsForField, parseFilter, parseValue, printCriterion, printFilter, printValue, resolvePossibleValue, searchPossibleValues,
|
||||
type Completion, type CompletionResult, type Criterion, type FilterValue, type Operator, type QueryFilter,
|
||||
} from '@qrun-io/qqq-core'
|
||||
@@ -26,12 +26,15 @@ import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { Overlay } from '../types'
|
||||
import { errorText, pad, shellQuote, trunc } from '../util/text'
|
||||
import { readViewSettings, writeViewSettings } from '@qrun-io/qqq-core'
|
||||
import { ChoiceOverlay, EditorLine, PromptOverlay, overlayId, wrap } from './basic'
|
||||
import { Frame, Line } from './frame'
|
||||
|
||||
type Sort = { field: string; asc: boolean } | undefined
|
||||
|
||||
type GuidedRow =
|
||||
| { kind: 'quick'; field: string }
|
||||
| { kind: 'addQuick' }
|
||||
| { kind: 'crit'; group: number; index: number }
|
||||
| { kind: 'group'; group: number }
|
||||
| { kind: 'add'; group: number }
|
||||
@@ -39,6 +42,25 @@ type GuidedRow =
|
||||
| { kind: 'join' }
|
||||
| { kind: 'sort' }
|
||||
|
||||
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
const PASTE = '\u0000paste'
|
||||
|
||||
/** Splits pasted text on commas, tabs, semicolons and new lines (quoted values keep their commas). */
|
||||
export function splitPasted(text: string): string[] {
|
||||
const out: string[] = []
|
||||
let cur = ''
|
||||
let quoted = false
|
||||
for (const ch of text) {
|
||||
if (ch === '"') quoted = !quoted
|
||||
else if (!quoted && /[,;\t\n\r]/.test(ch)) {
|
||||
out.push(cur)
|
||||
cur = ''
|
||||
} else cur += ch
|
||||
}
|
||||
out.push(cur)
|
||||
return out.map((x) => x.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function clone(f: QueryFilter): QueryFilter {
|
||||
return JSON.parse(JSON.stringify(f)) as QueryFilter
|
||||
}
|
||||
@@ -92,8 +114,35 @@ export class FilterOverlay implements Overlay {
|
||||
return this.filter.subFilters ?? []
|
||||
}
|
||||
|
||||
/** Quick filter fields: the table's defaults (defaultQuickFilterFieldNames, else T1 fields) plus the ones added here (kept per table). */
|
||||
quickFields(): string[] {
|
||||
const md = ((this.meta.supplementalMetaData as Record<string, unknown> | undefined)?.materialDashboard ?? {}) as { defaultQuickFilterFieldNames?: string[] }
|
||||
const defaults = md.defaultQuickFilterFieldNames?.length ? md.defaultQuickFilterFieldNames : (this.meta.sections ?? []).filter((x) => x.tier === 'T1').flatMap((x) => x.fieldNames ?? [])
|
||||
const added = (readViewSettings(`table:${this.meta.name}`).quickFilterFields as string[] | undefined) ?? []
|
||||
return [...new Set([...defaults, ...added])].filter((n) => this.meta.fields[n] && !this.meta.fields[n]!.isHidden && operatorsForField(this.meta.fields[n]).length)
|
||||
}
|
||||
|
||||
/** The quick filter's starting comparison, as the web UI picks it. */
|
||||
quickOperator(f: FieldMetaData): Operator {
|
||||
if (f.possibleValueSourceName) return 'IN'
|
||||
if (['INTEGER', 'LONG', 'DECIMAL', 'DATE', 'DATE_TIME', 'TIME'].includes(f.type)) return 'EQUALS'
|
||||
if (f.type === 'BOOLEAN') return 'TRUE'
|
||||
return 'CONTAINS'
|
||||
}
|
||||
|
||||
editQuick(name: string): void {
|
||||
const f = this.meta.fields[name]!
|
||||
const at = this.filter.criteria.findIndex((c) => c.fieldName === name && !c.fieldFunction)
|
||||
const current = at >= 0 ? this.filter.criteria[at]! : undefined
|
||||
this.buildCriterion({ fieldName: name, operator: current?.operator ?? this.quickOperator(f), values: current?.values }, (c) => {
|
||||
if (at >= 0) this.filter.criteria[at] = c
|
||||
else this.filter.criteria.push(c)
|
||||
this.store.bump()
|
||||
})
|
||||
}
|
||||
|
||||
rows(): GuidedRow[] {
|
||||
const out: GuidedRow[] = []
|
||||
const out: GuidedRow[] = [...this.quickFields().map((field) => ({ kind: 'quick' as const, field })), { kind: 'addQuick' }]
|
||||
this.filter.criteria.forEach((_, i) => out.push({ kind: 'crit', group: -1, index: i }))
|
||||
out.push({ kind: 'add', group: -1 })
|
||||
this.groups().forEach((g, gi) => {
|
||||
@@ -115,6 +164,9 @@ export class FilterOverlay implements Overlay {
|
||||
|
||||
critText(c: Criterion): string {
|
||||
const f = this.field(c.fieldName)
|
||||
if (c.fieldFunction && /^WeekdayOf/.test(c.fieldFunction.functionTypeIdentifierName)) {
|
||||
return `${f?.label ?? c.fieldName}: day ${c.operator === 'NOT_IN' ? 'is none of' : 'is any of'} ${c.values.map((v) => DAYS[Number(v) - 1] ?? String(v)).join(', ')}`
|
||||
}
|
||||
const op = c.operator.toLowerCase().replace(/_/g, ' ')
|
||||
return `${f?.label ?? c.fieldName} ${op}${c.values.length ? ` ${this.valueText(c)}` : ''}`
|
||||
}
|
||||
@@ -130,7 +182,16 @@ export class FilterOverlay implements Overlay {
|
||||
if (start.operator && operatorsForField(f).includes(start.operator)) return next(start.operator)
|
||||
const ops = operatorsForField(f)
|
||||
const more = OPERATORS.filter((o) => !ops.includes(o))
|
||||
const all = (pickAll: boolean) => this.store.open(new ChoiceOverlay<Operator | ''>(this.store, `${f.label}: which comparison?`, [...(pickAll ? OPERATORS : ops).map((o) => ({ label: o.toLowerCase().replace(/_/g, ' '), value: o as Operator | '', detail: o })), ...(!pickAll && more.length ? [{ label: 'More operators…', value: '' as const, detail: `${more.length} for other field types` }] : [])], (o) => (o ? next(o) : all(true))))
|
||||
// weekday criteria (the instance's weekdayCriteriaSettings, on unless it says enabled: false)
|
||||
const weekdays = (f.type === 'DATE' || f.type === 'DATE_TIME') && this.weekdayEnabled() ? ['WEEKDAY_IN', 'WEEKDAY_NOT_IN'] : []
|
||||
const all = (pickAll: boolean) => this.store.open(new ChoiceOverlay<string>(this.store, `${f.label}: which comparison?`, [
|
||||
...(pickAll ? OPERATORS : ops).map((o) => ({ label: o.toLowerCase().replace(/_/g, ' '), value: o as string, detail: o })),
|
||||
...weekdays.map((w) => ({ label: w === 'WEEKDAY_IN' ? 'day of the week is any of' : 'day of the week is none of', value: w, detail: w === 'WEEKDAY_IN' ? 'weekday IN' : 'weekday NOT_IN' })),
|
||||
...(!pickAll && more.length ? [{ label: 'More operators…', value: '', detail: `${more.length} for other field types` }] : []),
|
||||
], (o) => {
|
||||
if (o === 'WEEKDAY_IN' || o === 'WEEKDAY_NOT_IN') return this.pickWeekdays(f, o === 'WEEKDAY_IN' ? 'IN' : 'NOT_IN', done)
|
||||
return o ? next(o as Operator) : all(true)
|
||||
}))
|
||||
all(false)
|
||||
}
|
||||
pickOp((op) => {
|
||||
@@ -149,6 +210,7 @@ export class FilterOverlay implements Overlay {
|
||||
const term = new LineEditor('')
|
||||
const overlay = new ChoiceOverlay<string | null>(this.store, `${f.label}${list ? ` (${acc.length} chosen; Esc when done)` : ''}`, [], (id) => {
|
||||
if (id === null) return done(acc)
|
||||
if (id === PASTE) return this.pasteValues(f, true, (vals) => done([...acc, ...vals]))
|
||||
const next = list ? [...acc, id] : [id]
|
||||
if (list) pick(next)
|
||||
else done(next)
|
||||
@@ -156,7 +218,7 @@ export class FilterOverlay implements Overlay {
|
||||
void term
|
||||
const load = (search: string) => {
|
||||
searchPossibleValues(this.store.client!.server, this.store.client!.fetch, { table: this.meta.name }, f.name, { searchTerm: search, useCase: 'FILTER', limit: 100 }).then((opts) => {
|
||||
overlay.choices.splice(0, overlay.choices.length, ...(list && acc.length ? [{ label: `Done (${acc.length} chosen)`, value: null }] : []), ...opts.map((o) => {
|
||||
overlay.choices.splice(0, overlay.choices.length, ...(list && acc.length ? [{ label: `Done (${acc.length} chosen)`, value: null }] : []), ...(list && !search ? [{ label: 'Paste a list of labels or ids…', value: PASTE, detail: 'bulk add' }] : []), ...opts.map((o) => {
|
||||
this.labels.set(`${f.name}\u0000${String(o.id)}`, o.label)
|
||||
return { label: o.label, value: String(o.id), detail: String(o.id) }
|
||||
}))
|
||||
@@ -187,6 +249,10 @@ export class FilterOverlay implements Overlay {
|
||||
}
|
||||
}, initial))
|
||||
const initial = current.map(printValue).join(',')
|
||||
if (list && !between) {
|
||||
// a list: paste or type values separated by commas, tabs, semicolons or new lines
|
||||
return this.pasteValues(f, false, (vals) => done(vals), initial)
|
||||
}
|
||||
if (isDate && !list) {
|
||||
const presets = expressionCandidates(type).map((c) => ({ label: c.detail ?? c.value, value: c.value, detail: c.value }))
|
||||
this.store.open(new ChoiceOverlay(this.store, `${f.label}: when?`, [{ label: 'Type a date or an expression…', value: '', detail: 'YYYY-MM-DD' }, ...presets], (v) => (v ? done([parseValue(v)]) : prompt(initial))))
|
||||
@@ -195,7 +261,63 @@ export class FilterOverlay implements Overlay {
|
||||
prompt(initial)
|
||||
}
|
||||
|
||||
weekdayEnabled(): boolean {
|
||||
const sup = (this.store.meta as { supplementalInstanceMetaData?: { uiSettings?: Record<string, unknown>; materialDashboard?: Record<string, unknown> } } | undefined)?.supplementalInstanceMetaData
|
||||
const settings = (sup?.uiSettings?.weekdayCriteriaSettings ?? sup?.materialDashboard?.weekdayCriteriaSettings) as { enabled?: boolean; dateTimeFieldFunctionArguments?: Record<string, unknown> } | undefined
|
||||
return settings?.enabled !== false
|
||||
}
|
||||
|
||||
/** Day-of-week criteria: a multi-pick of Monday … Sunday (ISO numbers), with the WeekdayOf function. */
|
||||
pickWeekdays(f: FieldMetaData, op: Operator, done: (c: Criterion) => void, acc: string[] = []): void {
|
||||
const choices = [...(acc.length ? [{ label: `Done (${acc.map((d) => DAYS[Number(d) - 1]).join(', ')})`, value: '' }] : []), ...DAYS.map((d, i) => ({ label: d, value: String(i + 1), detail: acc.includes(String(i + 1)) ? 'chosen' : '' }))]
|
||||
this.store.open(new ChoiceOverlay(this.store, `${f.label}: day of the week ${op === 'IN' ? 'is any of' : 'is none of'}`, choices, (v) => {
|
||||
if (!v) return done({ fieldName: f.name, operator: op, values: acc, fieldFunction: { fieldName: f.name, functionTypeIdentifierName: f.type === 'DATE_TIME' ? WEEKDAY_OF_DATE_TIME : WEEKDAY_OF_DATE } })
|
||||
this.pickWeekdays(f, op, done, acc.includes(v) ? acc.filter((x) => x !== v) : [...acc, v].sort())
|
||||
}, 'Enter adds or removes a day', 0, () => { if (acc.length) done({ fieldName: f.name, operator: op, values: acc, fieldFunction: { fieldName: f.name, functionTypeIdentifierName: f.type === 'DATE_TIME' ? WEEKDAY_OF_DATE_TIME : WEEKDAY_OF_DATE } }) }))
|
||||
}
|
||||
|
||||
/** The instance's help for bulk-added filter values (help slots bulkAddFilterValues…). */
|
||||
pasteHelp(pvs: boolean): string {
|
||||
const help = (this.store.meta as { helpContents?: Record<string, Array<{ contentAsText?: string; content?: string }>> } | undefined)?.helpContents?.[pvs ? 'bulkAddFilterValuesPossibleValueSource' : 'bulkAddFilterValues']
|
||||
return help?.map((h) => h.contentAsText ?? h.content ?? '').filter(Boolean).join(' ') ?? ''
|
||||
}
|
||||
|
||||
/** Bulk paste: values separated by commas, tabs, semicolons or new lines; labels resolved for choice fields. */
|
||||
pasteValues(f: FieldMetaData, pvs: boolean, done: (values: FilterValue[]) => void, initial = ''): void {
|
||||
const help = this.pasteHelp(pvs)
|
||||
this.store.open(new PromptOverlay(this.store, `${f.label}: values`, `Type or paste values separated by commas, tabs, semicolons or new lines${pvs ? ' (labels or ids)' : ''}; duplicates are dropped. Expressions work for dates.${help ? ` ${help}` : ''}`, (text) => {
|
||||
const parts = [...new Set(splitPasted(text))]
|
||||
if (!parts.length) return
|
||||
if (!pvs) {
|
||||
try {
|
||||
return done(parts.map((p) => parseValue(p)))
|
||||
} catch (e) {
|
||||
return this.store.flash(errorText(e), 'error')
|
||||
}
|
||||
}
|
||||
const c = this.store.client!
|
||||
Promise.all(parts.map((p) => resolvePossibleValue(c.server, c.fetch, { table: this.meta.name }, f.name, p, { fieldType: f.type }).then((r) => ({ p, option: r.option }))))
|
||||
.then((rs) => {
|
||||
const missing = rs.filter((r) => !r.option).map((r) => r.p)
|
||||
for (const r of rs) if (r.option) this.labels.set(`${f.name}\u0000${String(r.option.id)}`, r.option.label)
|
||||
if (missing.length) this.store.flash(`not added (no match): ${missing.join(', ')}`, 'error')
|
||||
done(rs.filter((r) => r.option).map((r) => String(r.option!.id)))
|
||||
this.store.bump()
|
||||
}).catch((e) => this.store.flash(errorText(e), 'error'))
|
||||
}, initial, { newline: ', ' }))
|
||||
}
|
||||
|
||||
editRow(r: GuidedRow): void {
|
||||
if (r.kind === 'quick') return this.editQuick(r.field)
|
||||
if (r.kind === 'addQuick') {
|
||||
this.store.open(new ChoiceOverlay(this.store, 'Add a quick filter field', this.fields().filter((f) => !this.quickFields().includes(f.name)).map((f) => ({ label: f.label, value: f.name, detail: f.name })), (name) => {
|
||||
const key = `table:${this.meta.name}`
|
||||
const v = readViewSettings(key)
|
||||
writeViewSettings(key, { ...v, quickFilterFields: [...((v.quickFilterFields as string[] | undefined) ?? []), name] })
|
||||
this.editQuick(name)
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (r.kind === 'crit') {
|
||||
const g = this.groupOf(r.group)
|
||||
const c = g.criteria[r.index]!
|
||||
@@ -231,6 +353,13 @@ export class FilterOverlay implements Overlay {
|
||||
}
|
||||
|
||||
removeRow(r: GuidedRow): void {
|
||||
if (r.kind === 'quick') {
|
||||
this.filter.criteria = this.filter.criteria.filter((c) => c.fieldName !== r.field || c.fieldFunction)
|
||||
const key = `table:${this.meta.name}`
|
||||
const v = readViewSettings(key)
|
||||
writeViewSettings(key, { ...v, quickFilterFields: ((v.quickFilterFields as string[] | undefined) ?? []).filter((n) => n !== r.field) })
|
||||
return
|
||||
}
|
||||
if (r.kind === 'crit') this.groupOf(r.group).criteria.splice(r.index, 1)
|
||||
else if (r.kind === 'group') this.filter.subFilters = this.groups().filter((_, i) => i !== r.group)
|
||||
else if (r.kind === 'sort') this.sort = undefined
|
||||
@@ -323,10 +452,25 @@ export class FilterOverlay implements Overlay {
|
||||
return copy
|
||||
}
|
||||
|
||||
/** Weekday functions follow the field type (WeekdayOfDateTime on DATE_TIME, with the instance's arguments). */
|
||||
typed(f: QueryFilter): QueryFilter {
|
||||
const sup = (this.store.meta as { supplementalInstanceMetaData?: { uiSettings?: Record<string, unknown> } } | undefined)?.supplementalInstanceMetaData
|
||||
const args = (sup?.uiSettings?.weekdayCriteriaSettings as { dateTimeFieldFunctionArguments?: Record<string, unknown> } | undefined)?.dateTimeFieldFunctionArguments
|
||||
return applyFieldTypes(f, (n) => this.meta.fields[n]?.type, args)
|
||||
}
|
||||
|
||||
paste(text: string): void {
|
||||
if (this.mode === 'advanced') {
|
||||
this.text.insert(text)
|
||||
this.complete()
|
||||
}
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
if (this.mode === 'advanced') {
|
||||
const parsed = this.parse()
|
||||
if (!parsed) return
|
||||
const parsed0 = this.parse()
|
||||
if (!parsed0) return
|
||||
const parsed = this.typed(parsed0)
|
||||
this.store.filterHistory.add(this.text.value)
|
||||
this.store.run(this.resolveLabels(parsed), (resolved) => {
|
||||
this.store.closeOverlay(this)
|
||||
@@ -334,7 +478,7 @@ export class FilterOverlay implements Overlay {
|
||||
})
|
||||
return
|
||||
}
|
||||
const f = this.normalized()
|
||||
const f = this.typed(this.normalized())
|
||||
if (printFilter(f)) this.store.filterHistory.add(printFilter(f))
|
||||
this.store.closeOverlay(this)
|
||||
this.onApply(f, this.sort ?? null)
|
||||
@@ -363,7 +507,7 @@ export class FilterOverlay implements Overlay {
|
||||
else if (key.name === 'home') this.row = 0
|
||||
else if (key.name === 'end') this.row = rows.length - 1
|
||||
else if (key.name === 'return') {
|
||||
if (r && (r.kind === 'add' || r.kind === 'addGroup' || r.kind === 'sort' || r.kind === 'join' || r.kind === 'group')) this.editRow(r)
|
||||
if (r && (r.kind === 'add' || r.kind === 'addGroup' || r.kind === 'sort' || r.kind === 'join' || r.kind === 'group' || r.kind === 'quick' || r.kind === 'addQuick')) this.editRow(r)
|
||||
else this.apply()
|
||||
} else if (ch === 'e' || key.name === 'right' || key.name === 'space') {
|
||||
if (r) this.editRow(r)
|
||||
@@ -446,7 +590,15 @@ export class FilterOverlay implements Overlay {
|
||||
const sel = top + i === this.row
|
||||
let text = ''
|
||||
let fg = t.fg
|
||||
if (r.kind === 'crit') {
|
||||
if (r.kind === 'quick') {
|
||||
const f = this.field(r.field)!
|
||||
const c = this.filter.criteria.find((x) => x.fieldName === r.field && !x.fieldFunction)
|
||||
text = ` quick ${pad(f.label, 22)} ${c ? this.critText(c).slice(f.label.length + 1) : '(any)'}`
|
||||
fg = c ? t.fg : t.dim
|
||||
} else if (r.kind === 'addQuick') {
|
||||
text = ' quick + add a quick filter field (remembered for this table)'
|
||||
fg = t.acc
|
||||
} else if (r.kind === 'crit') {
|
||||
const c = this.groupOf(r.group).criteria[r.index]!
|
||||
const join = r.index > 0 ? this.groupOf(r.group).booleanOperator.padEnd(4) : ' '
|
||||
text = `${r.group >= 0 ? ' ' : ''}${join}${this.critText(c)}`
|
||||
|
||||
@@ -11,7 +11,9 @@ import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { Overlay } from '../types'
|
||||
import { pad } from '../util/text'
|
||||
import { ChoiceOverlay, PromptOverlay, overlayId } from './basic'
|
||||
import { ChoiceOverlay, EditorLine, PromptOverlay, overlayId } from './basic'
|
||||
import { LineEditor } from '../line-editor'
|
||||
import { defaultColumns } from '../screens/table'
|
||||
import { Frame, Line } from './frame'
|
||||
|
||||
/**
|
||||
@@ -24,8 +26,13 @@ export class GridViewSettingsOverlay implements Overlay {
|
||||
order: string[]
|
||||
visible: Set<string>
|
||||
row = 0
|
||||
/** `/` searches the column list (Esc ends the search). */
|
||||
readonly search = new LineEditor('')
|
||||
searching = false
|
||||
readonly defaults: string[]
|
||||
|
||||
constructor(private store: AppStore, readonly meta: TableMetaData, readonly key: string, current: string[], private onDone: () => void) {
|
||||
this.defaults = defaultColumns(meta)
|
||||
this.view = readViewSettings(key)
|
||||
const all = Object.values(meta.fields).filter((f) => !f.isHidden).map((f) => f.name)
|
||||
this.order = [...current, ...all.filter((n) => !current.includes(n))]
|
||||
@@ -37,6 +44,7 @@ export class GridViewSettingsOverlay implements Overlay {
|
||||
const sort = this.view.sort as { field: string; asc: boolean } | undefined
|
||||
return [
|
||||
{ label: 'Sort', value: sort ? `${this.meta.fields[sort.field]?.label ?? sort.field} ${sort.asc ? 'ascending' : 'descending'}` : 'default order', toggle: () => this.chooseSort() },
|
||||
{ label: 'Pinned columns', value: `${Number(this.view.pinned ?? 0)} (stay left while scrolling; +/- on this line)`, toggle: () => { this.view.pinned = (Number(this.view.pinned ?? 0) + 1) % 4 } },
|
||||
{ label: 'Width use', value: this.view.fit === 'fill' ? 'fill the terminal width' : 'fit to content', toggle: () => { this.view.fit = this.view.fit === 'fill' ? 'content' : 'fill' } },
|
||||
{ label: 'Zebra stripes', value: (this.view.zebra ?? t.zebraByDefault) ? 'on' : 'off', toggle: () => { this.view.zebra = !(this.view.zebra ?? t.zebraByDefault) } },
|
||||
{ label: 'Header rule', value: (this.view.headerRule ?? true) ? 'on' : 'off', toggle: () => { this.view.headerRule = !(this.view.headerRule ?? true) } },
|
||||
@@ -62,7 +70,30 @@ export class GridViewSettingsOverlay implements Overlay {
|
||||
this.onDone()
|
||||
}
|
||||
|
||||
/** Column indexes shown for the search text (all when empty). */
|
||||
matching(): number[] {
|
||||
const needle = this.search.value.toLowerCase()
|
||||
return this.order.map((n, i) => ({ n, i })).filter(({ n }) => !needle || n.toLowerCase().includes(needle) || (this.meta.fields[n]?.label ?? '').toLowerCase().includes(needle)).map(({ i }) => i)
|
||||
}
|
||||
|
||||
onKey(key: Key): void {
|
||||
if (this.searching) {
|
||||
if (key.name === 'escape' || key.name === 'return') {
|
||||
this.searching = false
|
||||
const first = this.matching()[0]
|
||||
if (first !== undefined) this.row = first
|
||||
return
|
||||
}
|
||||
if (this.search.handle(key)) {
|
||||
const first = this.matching()[0]
|
||||
if (first !== undefined) this.row = first
|
||||
}
|
||||
return
|
||||
}
|
||||
if (charOf(key) === '/') {
|
||||
this.searching = true
|
||||
return
|
||||
}
|
||||
const opts = this.options()
|
||||
const total = this.order.length + opts.length
|
||||
const ch = charOf(key)
|
||||
@@ -122,21 +153,37 @@ export class GridViewSettingsOverlay implements Overlay {
|
||||
const width = Math.min(sw - 4, 80)
|
||||
const inner = width - 2
|
||||
const opts = this.options()
|
||||
const items = [
|
||||
...this.order.map((n, i) => ({ text: `${this.visible.has(n) ? g.check : g.uncheck} ${pad(this.meta.fields[n]!.label, 28)} ${this.view.widths?.[n] ? `width ${this.view.widths[n]}` : 'auto width'}`, idx: i })),
|
||||
const match = new Set(this.matching())
|
||||
const changed = (n: string, i: number) => this.visible.has(n) !== this.defaults.includes(n) || (this.visible.has(n) && this.defaults.indexOf(n) !== this.order.filter((x) => this.visible.has(x)).indexOf(n)) || Boolean(this.view.widths?.[n])
|
||||
const groupOf = (n: string) => (n.includes('.') ? (this.meta.fields[n]?.label.split(':')[0] ?? n.split('.')[0]!) : 'Fields')
|
||||
const items: Array<{ text: string; idx: number; heading?: boolean }> = []
|
||||
let lastGroup = ''
|
||||
this.order.forEach((n, i) => {
|
||||
if (!match.has(i)) return
|
||||
const group = groupOf(n)
|
||||
if (group !== lastGroup) {
|
||||
items.push({ text: `${group} (${this.order.filter((x) => groupOf(x) === group && this.visible.has(x)).length} of ${this.order.filter((x) => groupOf(x) === group).length} shown)`, idx: -1 - items.length, heading: true })
|
||||
lastGroup = group
|
||||
}
|
||||
items.push({ text: `${changed(n, i) ? '*' : ' '}${this.visible.has(n) ? g.check : g.uncheck} ${pad(this.meta.fields[n]!.label, 30)} ${this.view.widths?.[n] ? `width ${this.view.widths[n]}` : 'auto width'}`, idx: i })
|
||||
})
|
||||
items.push(
|
||||
...opts.map((o, i) => ({ text: ` ${pad(o.label, 28)} ${o.value}`, idx: this.order.length + i })),
|
||||
]
|
||||
const rows = Math.max(5, Math.min(items.length, sh - 10))
|
||||
const top = Math.max(0, Math.min(this.row - Math.floor(rows / 2), items.length - rows))
|
||||
)
|
||||
const rows = Math.max(5, Math.min(items.length, sh - 11))
|
||||
const selAt = Math.max(0, items.findIndex((it) => it.idx === this.row))
|
||||
const top = Math.max(0, Math.min(selAt - Math.floor(rows / 2), items.length - rows))
|
||||
return (
|
||||
<Frame title={`View settings · ${this.meta.label}`} width={width} height={rows + 5} screenWidth={sw} screenHeight={sh}>
|
||||
<Frame title={`View settings · ${this.meta.label} · ${this.visible.size} of ${this.order.length} columns shown`} width={width} height={rows + 5} screenWidth={sw} screenHeight={sh}>
|
||||
{this.searching || this.search.value ? <box paddingLeft={1}><EditorLine editor={this.search} width={inner - 2} focused={this.searching} placeholder="search columns" /></box> : null}
|
||||
{items.slice(top, top + rows).map((it) => {
|
||||
const sel = it.idx === this.row
|
||||
if (it.heading) return <text flexShrink={0} wrapMode="none" key={it.idx} fg={t.acc}>{pad(` ${it.text}`, inner)}</text>
|
||||
const isOpt = it.idx >= this.order.length
|
||||
return <text flexShrink={0} wrapMode="none" key={it.idx} fg={sel ? t.selFg : isOpt ? t.acc : t.fg} bg={sel ? t.selBg : undefined}>{pad(` ${it.text}`, inner)}</text>
|
||||
})}
|
||||
<Line text="" width={inner} />
|
||||
<Line text=" Space on/off (on Sort: choose) · Shift+↑↓ or +/- move · ←→ width · w width · a auto" width={inner} fg={t.dim} />
|
||||
<Line text=" Space on/off · / search · Shift+↑↓ or +/- move · ←→ width · w width · a auto · * changed from the default" width={inner} fg={t.dim} />
|
||||
<Line text=" r reset to defaults · Enter saves (kept for this table) · Esc cancels" width={inner} fg={t.dim} />
|
||||
</Frame>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { readViewSettings, widgetToBlocks, writeViewSettings, type RenderBlock }
|
||||
import type { AppTreeNode } from '../client'
|
||||
import type { Key } from '../keys'
|
||||
import { openApp, openProcess, openTable } from '../nav'
|
||||
import { openReport } from './report'
|
||||
import { ChoiceOverlay } from '../overlays/basic'
|
||||
import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
@@ -118,25 +119,52 @@ export class AppHomeScreen implements Screen {
|
||||
const meta = this.store.meta
|
||||
const rows: Row[] = []
|
||||
const width = this.width
|
||||
for (const w of this.widgetOrder()) {
|
||||
// widgets in rows of twelfths (gridColumns), side by side when the terminal is wide enough
|
||||
const order = this.widgetOrder()
|
||||
const gridOf = (w: string) => Math.max(1, Math.min(12, Number((meta?.widgets as Record<string, { gridColumns?: number }> | undefined)?.[w]?.gridColumns ?? 12)))
|
||||
const widgetRows = (w: string, colWidth: number): Row[] => {
|
||||
const data = this.widgets.get(w)
|
||||
const label = (meta?.widgets as Record<string, { label?: string }> | undefined)?.[w]?.label ?? w
|
||||
// widget headings are selectable (Enter reloads it), so the cursor starts at the top and only visible widgets load
|
||||
const reload = () => { this.widgets.delete(w); this.started.delete(`w:${w}`); this.build(); this.store.bump() }
|
||||
const out: Row[] = []
|
||||
if (!data) {
|
||||
this.widgetTask(w, rows.length)
|
||||
rows.push({ text: label, fg: t.acc, bold: true, key: `w:${w}`, action: reload })
|
||||
rows.push({ text: ' loading…', fg: t.dim })
|
||||
out.push({ text: label, fg: t.acc, bold: true, key: `w:${w}`, action: reload })
|
||||
out.push({ text: ' loading…', fg: t.dim })
|
||||
} else if ('error' in data) {
|
||||
rows.push({ text: label, fg: t.acc, bold: true, key: `w:${w}`, action: reload })
|
||||
rows.push({ text: ` ${data.error}`, fg: t.err })
|
||||
out.push({ text: label, fg: t.acc, bold: true, key: `w:${w}`, action: reload })
|
||||
out.push({ text: ` ${data.error}`, fg: t.err })
|
||||
} else {
|
||||
const heading = data[0]?.kind === 'heading' ? data[0] : undefined
|
||||
rows.push({ text: heading ? heading.text : label, fg: t.acc, bold: true, key: `w:${w}`, action: reload })
|
||||
if (heading?.sub) rows.push({ text: ` ${heading.sub}`, fg: t.dim })
|
||||
rows.push(...blockLines(heading ? data.slice(1) : data, width - 4).map((l) => ({ ...l, text: ' ' + l.text })))
|
||||
out.push({ text: heading ? heading.text : label, fg: t.acc, bold: true, key: `w:${w}`, action: reload })
|
||||
if (heading?.sub) out.push({ text: ` ${heading.sub}`, fg: t.dim })
|
||||
out.push(...blockLines(heading ? data.slice(1) : data, colWidth - 4).map((l) => ({ ...l, text: ' ' + l.text })))
|
||||
}
|
||||
rows.push({ text: g.rule.repeat(Math.min(width - 2, 80)), fg: t.border })
|
||||
return out
|
||||
}
|
||||
const sideBySide = width >= 120
|
||||
let i = 0
|
||||
while (i < order.length) {
|
||||
const group: string[] = []
|
||||
let used = 0
|
||||
while (i < order.length && (group.length === 0 || (sideBySide && used + gridOf(order[i]!) <= 12))) {
|
||||
used += gridOf(order[i]!)
|
||||
group.push(order[i]!)
|
||||
i++
|
||||
}
|
||||
for (const w of group) if (!this.widgets.get(w)) this.widgetTask(w, rows.length)
|
||||
if (group.length === 1 || !sideBySide) {
|
||||
for (const w of group) rows.push(...widgetRows(w, width))
|
||||
} else {
|
||||
const widths = group.map((w) => Math.floor(((width - 2) * gridOf(w)) / Math.max(12, used)))
|
||||
const parts = group.map((w, k) => widgetRows(w, widths[k]!))
|
||||
const n = Math.max(...parts.map((p) => p.length))
|
||||
for (let r = 0; r < n; r++) {
|
||||
const first = parts.find((p) => p[r]?.action)?.[r]
|
||||
rows.push({ text: parts.map((p, k) => pad(p[r]?.text ?? '', widths[k]! - 1) + ' ').join(''), fg: parts.some((p) => p[r]?.bold) ? t.acc : t.fg, bold: parts.some((p) => p[r]?.bold), ...(first ? { key: first.key, action: first.action } : {}) })
|
||||
}
|
||||
}
|
||||
rows.push({ text: g.rule.repeat(Math.min(width - 2, 120)), fg: t.border })
|
||||
}
|
||||
const sections = this.app?.sections?.length ? this.app.sections : [{ name: this.appName, label: this.title(), tables: this.app?.children?.filter((c) => c.type === 'TABLE').map((c) => c.name), processes: this.app?.children?.filter((c) => c.type === 'PROCESS').map((c) => c.name) }]
|
||||
for (const s of sections) {
|
||||
@@ -148,7 +176,7 @@ export class AppHomeScreen implements Screen {
|
||||
rows.push({ text: ` ${pad(label, 34)} ${typeof c === 'number' ? num(c) + ' records' : c ?? '…'}`, key: `t:${name}`, action: () => openTable(this.store, name) })
|
||||
}
|
||||
for (const name of s.processes ?? []) rows.push({ text: ` ${g.process} ${meta?.processes?.[name]?.label ?? name}`, key: `p:${name}`, action: () => openProcess(this.store, name) })
|
||||
for (const name of s.reports ?? []) rows.push({ text: ` ${g.report} ${(meta?.reports as Record<string, { label?: string }> | undefined)?.[name]?.label ?? name} (run with :report run ${name})`, key: `r:${name}`, action: () => this.store.commands().commandLine(`report run ${name} --format csv`) })
|
||||
for (const name of s.reports ?? []) rows.push({ text: ` ${g.report} ${(meta?.reports as Record<string, { label?: string }> | undefined)?.[name]?.label ?? name} `, key: `r:${name}`, action: () => openReport(this.store, name) })
|
||||
}
|
||||
const listed = new Set(sections.flatMap((s) => [...(s.tables ?? []), ...(s.processes ?? []), ...(s.reports ?? [])]))
|
||||
const others = (this.app?.children ?? []).filter((c) => !listed.has(c.name))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { FormInputError, FormSession, ResultCursor, copyRecordValues, dateTimesToUtc, formatSize, defaultTimeZone, isInstant, isMissingValue, utcToLocalInput, validateValue, type FieldMeta, type FieldValues } from '@qrun-io/qqq-core'
|
||||
import { FormInputError, FormSession, ResultCursor, copyRecordValues, dateTimesToUtc, formatSize, helpEntryText, selectHelpContent, defaultTimeZone, isInstant, isMissingValue, utcToLocalInput, validateValue, type FieldMeta, type FieldValues } from '@qrun-io/qqq-core'
|
||||
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { basename } from 'node:path'
|
||||
@@ -76,7 +76,7 @@ export class FormEditor {
|
||||
/** Files chosen for BLOB and FILE_UPLOAD fields (sent as multipart parts). */
|
||||
files = new Map<string, { name: string; bytes: Uint8Array; path: string }>()
|
||||
|
||||
constructor(private store: AppStore, readonly session: FormSession, readonly order: string[], readonly options: { bulkEdit?: boolean; readOnly?: string[] } = {}) {
|
||||
constructor(private store: AppStore, readonly session: FormSession, readonly order: string[], readonly options: { bulkEdit?: boolean; readOnly?: string[]; submitOnEnter?: () => void } = {}) {
|
||||
this.initial = { ...session.values }
|
||||
for (const name of order) {
|
||||
const f = session.fields[name]
|
||||
@@ -182,6 +182,13 @@ export class FormEditor {
|
||||
}, initial))
|
||||
}
|
||||
|
||||
/** Pasted text goes into the text field that has focus. */
|
||||
paste(text: string): void {
|
||||
const name = this.current
|
||||
const ed = name ? this.editors.get(name) : undefined
|
||||
if (ed) ed.insert(text)
|
||||
}
|
||||
|
||||
/** Handles a key; returns true when the form used it. Ctrl+S and Esc are left to the owner. */
|
||||
onKey(key: Key): boolean {
|
||||
const name = this.current
|
||||
@@ -249,6 +256,11 @@ export class FormEditor {
|
||||
return true
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
// scanner screens: Enter in an input submits the step (hand-held stations)
|
||||
if (this.options.submitOnEnter) {
|
||||
void this.commit(name).then(() => this.options.submitOnEnter!())
|
||||
return true
|
||||
}
|
||||
this.move(1)
|
||||
return true
|
||||
}
|
||||
@@ -290,7 +302,8 @@ export class FormEditor {
|
||||
const pickGlyph = (this.session.hasChoices(name) || isFileField(f)) && !disabled ? ' ▾' : ''
|
||||
editor = <text flexShrink={0} wrapMode="none" fg={disabled ? t.dim : sel ? t.selFg : t.inputFg} bg={disabled ? undefined : sel ? t.selBg : t.inputBg}>{pad(trunc(shown, editorWidth - pickGlyph.length) + pickGlyph, editorWidth)}</text>
|
||||
}
|
||||
const msg = error ? `! ${error}` : state.disabledBecause ? state.disabledBecause : idx === this.index ? (isFileField(f) ? 'Enter chooses a file · Backspace clears' : this.session.hasChoices(name) ? 'Enter or type to choose · Backspace clears' : hintFor(f)) : ''
|
||||
const fieldHelp = idx === this.index ? helpEntryText(selectHelpContent(f.helpContents, ['INSERT_SCREEN', 'EDIT_SCREEN', 'WRITE_SCREENS', 'ALL_SCREENS'])) : ''
|
||||
const msg = error ? `! ${error}` : state.disabledBecause ? state.disabledBecause : fieldHelp ? fieldHelp : idx === this.index ? (isFileField(f) ? 'Enter chooses a file · Backspace clears' : this.session.hasChoices(name) ? 'Enter or type to choose · Backspace clears' : hintFor(f)) : ''
|
||||
return (
|
||||
<box key={name} flexDirection="row" height={1}>
|
||||
<text flexShrink={0} wrapMode="none" fg={sel ? t.acc2 : disabled ? t.dim : t.fg}>{`${sel ? g.pointer : ' '}${bulk}${pad(f.label, labelWidth - 1)}${required} `}</text>
|
||||
@@ -315,6 +328,8 @@ export interface FormOptions {
|
||||
initial?: FieldValues
|
||||
/** The result the record came from (edit from a grid): saving returns there. */
|
||||
cursor?: ResultCursor
|
||||
/** The grid's table variant: the record is read with it (v1 writes ignore it, server finding #19). */
|
||||
tableVariant?: string
|
||||
}
|
||||
|
||||
/** Create, edit or copy a table record. Ctrl+S saves; Esc cancels and asks when there are unsaved changes. */
|
||||
@@ -339,7 +354,7 @@ export class FormScreen implements Screen {
|
||||
let initial: FieldValues = { ...(this.options.initial ?? {}) }
|
||||
const display: Record<string, string> = {}
|
||||
if (this.options.mode !== 'create' && this.options.primaryKey !== undefined) {
|
||||
const rec = await client.record(this.tableName, this.options.primaryKey)
|
||||
const rec = await client.record(this.tableName, this.options.primaryKey, false, this.options.tableVariant)
|
||||
initial = this.options.mode === 'copy' ? copyRecordValues(meta, rec) as FieldValues : { ...rec.values }
|
||||
Object.assign(display, rec.displayValues ?? {})
|
||||
}
|
||||
@@ -375,6 +390,10 @@ export class FormScreen implements Screen {
|
||||
return `${verb} ${this.meta?.label ?? this.tableName}${this.options.mode === 'edit' ? ` ${this.options.primaryKey}` : ''}`
|
||||
}
|
||||
|
||||
helpTopic() {
|
||||
return { kind: 'table' as const, name: this.tableName }
|
||||
}
|
||||
|
||||
presenceScope(): string | undefined {
|
||||
return this.options.mode === 'edit' ? `record:${this.tableName}:${this.options.primaryKey}` : undefined
|
||||
}
|
||||
@@ -383,6 +402,10 @@ export class FormScreen implements Screen {
|
||||
return true
|
||||
}
|
||||
|
||||
paste(text: string): void {
|
||||
this.editor?.paste(text)
|
||||
}
|
||||
|
||||
dirty(): boolean {
|
||||
return this.editor?.dirty() ?? false
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { BACK, FormSession, dateTimesToUtc, defaultTimeZone, isInstant, utcToLocalInput, ProcessError, checkInputRecords, downloadProcessFile, runProcess, safeFileName, saveDownload, screenToText, type FieldMeta, type FieldValues, type ProcessMeta, type ProcessStep, type QueryFilter, type ScreenBlock } from '@qrun-io/qqq-core'
|
||||
import { BACK, FormSession, fetchProcessRecords, dateTimesToUtc, defaultTimeZone, isInstant, utcToLocalInput, ProcessError, checkInputRecords, downloadProcessFile, runProcess, safeFileName, saveDownload, screenToText, type FieldMeta, type FieldValues, type ProcessMeta, type ProcessStep, type QueryFilter, type ScreenBlock } from '@qrun-io/qqq-core'
|
||||
import { saveFile } from '../overlays/files-helpers'
|
||||
import { blockLines, tableLines } from '../widgets'
|
||||
import type { Key } from '../keys'
|
||||
@@ -23,6 +23,8 @@ class Cancelled extends Error {
|
||||
}
|
||||
|
||||
export interface ProcessOptions {
|
||||
/** Reports: save the DOWNLOAD_FORM file as soon as the run finishes. */
|
||||
autoDownload?: boolean
|
||||
/** The table variant the records come from (sent on init, every step and status). */
|
||||
tableVariant?: string
|
||||
recordIds?: string[]
|
||||
@@ -58,6 +60,8 @@ export class ProcessScreen implements Screen {
|
||||
/** The current step's screen (help, HTML, view values, records, widgets, downloads) as text lines, from the kernel. */
|
||||
screenLines: string[] = []
|
||||
backStep?: string
|
||||
/** A scanner-format step: no heading or action bar; Enter in an input submits. */
|
||||
scanner = false
|
||||
downloads: Array<{ fileName: string; query: Record<string, string> }> = []
|
||||
/** Files chosen in a step's form, handed to the kernel runner (sent with that step). */
|
||||
private stepFiles: Record<string, { name: string; bytes: Uint8Array }> = {}
|
||||
@@ -165,6 +169,7 @@ export class ProcessScreen implements Screen {
|
||||
this.phase = 'done'
|
||||
this.finished = Date.now()
|
||||
this.note('complete')
|
||||
if (this.options.autoDownload && this.downloads.length) this.saveDownloadFile()
|
||||
this.store.bump()
|
||||
}).catch((e) => {
|
||||
if (e instanceof Cancelled || this.cancelled) {
|
||||
@@ -180,6 +185,35 @@ export class ProcessScreen implements Screen {
|
||||
|
||||
/** Turns the kernel's screen blocks into lines (tables aligned, widgets drawn) and notes downloads. */
|
||||
showScreen(blocks: ScreenBlock[]): void {
|
||||
this.blocks = blocks
|
||||
this.recordsSkip = 0
|
||||
this.recordsPage = 0
|
||||
this.renderScreen()
|
||||
}
|
||||
|
||||
/** The current step's blocks, and the RECORD_LIST page shown (n / p page through the run's records). */
|
||||
blocks: ScreenBlock[] = []
|
||||
recordsSkip = 0
|
||||
recordsPage = 0
|
||||
|
||||
pageRecords(direction: 1 | -1): void {
|
||||
const block = this.blocks.find((b): b is Extract<ScreenBlock, { kind: 'records' }> => b.kind === 'records')
|
||||
if (!block || !this.processUUID) return this.store.flash('this step has no record list')
|
||||
const size = Math.max(1, this.recordsPage || block.rows.length || 50)
|
||||
this.recordsPage = size
|
||||
const skip = Math.max(0, this.recordsSkip + direction * size)
|
||||
if (skip >= block.total) return this.store.flash('last page of records')
|
||||
const c = this.store.client!
|
||||
this.store.run(fetchProcessRecords(c.server, c.fetch, this.processName, this.processUUID, skip, size, this.options.tableVariant), (page) => {
|
||||
this.recordsSkip = skip
|
||||
block.rows = page.records.map((r) => Object.fromEntries(block.columns.map((col) => [col, cell(r as never, col)])))
|
||||
block.total = page.totalRecords
|
||||
this.renderScreen()
|
||||
})
|
||||
}
|
||||
|
||||
renderScreen(): void {
|
||||
const blocks = this.blocks
|
||||
const width = 100
|
||||
this.downloads = blocks.filter((b): b is Extract<ScreenBlock, { kind: 'download' }> => b.kind === 'download').map((b) => ({ fileName: b.fileName, query: b.query }))
|
||||
const visible = blocks.filter((b) => b.kind !== 'form' && b.kind !== 'review' && b.kind !== 'summary')
|
||||
@@ -188,6 +222,8 @@ export class ProcessScreen implements Screen {
|
||||
widget: (w) => blockLines(w, width).map((l) => l.text).join('\n'),
|
||||
})
|
||||
this.screenLines = text ? text.split('\n') : []
|
||||
const rec = blocks.find((b) => b.kind === 'records') as Extract<ScreenBlock, { kind: 'records' }> | undefined
|
||||
if (rec && rec.total > rec.rows.length) this.screenLines.unshift(`records ${this.recordsSkip + 1}-${this.recordsSkip + rec.rows.length} of ${rec.total} · n next page · p previous page`)
|
||||
this.store.bump()
|
||||
}
|
||||
|
||||
@@ -217,7 +253,8 @@ export class ProcessScreen implements Screen {
|
||||
const local = { ...values }
|
||||
for (const f of fields) if (f.type === 'DATE_TIME' && isInstant(local[f.name])) local[f.name] = utcToLocalInput(String(local[f.name]), defaultTimeZone())
|
||||
const session = new FormSession({ baseUrl: client.server, fetch: this.fetchFor(), scope: { process: this.processName }, fields, initialValues: local })
|
||||
this.editor = new FormEditor(this.store, session, session.promptOrder(fields.map((f) => f.name)), { bulkEdit: types.includes('BULK_EDIT_FORM') })
|
||||
this.scanner = String(step.format ?? '').toLowerCase() === 'scanner'
|
||||
this.editor = new FormEditor(this.store, session, session.promptOrder(fields.map((f) => f.name)), { bulkEdit: types.includes('BULK_EDIT_FORM'), ...(this.scanner ? { submitOnEnter: () => this.submit() } : {}) })
|
||||
this.phase = 'form'
|
||||
} else {
|
||||
// nothing to ask (a view step that is not the last): continue
|
||||
@@ -293,6 +330,10 @@ export class ProcessScreen implements Screen {
|
||||
pending.resolve(BACK as unknown as FieldValues)
|
||||
return true
|
||||
}
|
||||
if ((charOf(key) === 'n' || charOf(key) === 'p') && this.phase !== 'form' && this.blocks.some((b) => b.kind === 'records')) {
|
||||
this.pageRecords(charOf(key) === 'n' ? 1 : -1)
|
||||
return true
|
||||
}
|
||||
if (charOf(key) === 'w' && this.downloads.length && this.phase !== 'form') {
|
||||
this.saveDownloadFile()
|
||||
return true
|
||||
@@ -405,8 +446,11 @@ export class ProcessScreen implements Screen {
|
||||
lines.push({ text: 'Cancelled', fg: t.warn, bold: true })
|
||||
lines.push({ text: 'Esc closes · F5 or r runs it again', fg: t.dim })
|
||||
}
|
||||
body.push(<text flexShrink={0} wrapMode="none" key="stepper" fg={t.dim}>{pad(` ${trunc(stepper, inner - 1)}`, inner)}</text>)
|
||||
body.push(<text flexShrink={0} wrapMode="none" key="sp">{pad('', inner)}</text>)
|
||||
const scannerForm = this.scanner && this.phase === 'form'
|
||||
if (!scannerForm) {
|
||||
body.push(<text flexShrink={0} wrapMode="none" key="stepper" fg={t.dim}>{pad(` ${trunc(stepper, inner - 1)}`, inner)}</text>)
|
||||
body.push(<text flexShrink={0} wrapMode="none" key="sp">{pad('', inner)}</text>)
|
||||
}
|
||||
// the step's screen from the kernel (help, HTML, view values, records, widgets, downloads), else the step's help text
|
||||
const screen = this.screenLines.length ? this.screenLines : help.flatMap((h) => wrap(h, inner - 2))
|
||||
const maxScreen = this.phase === 'form' ? Math.max(0, Math.min(screen.length, Math.floor(height / 3))) : Math.max(0, Math.min(screen.length, height - 12))
|
||||
@@ -414,7 +458,10 @@ export class ProcessScreen implements Screen {
|
||||
if (screen.length > maxScreen) body.push(<text flexShrink={0} wrapMode="none" key="hmore" fg={t.dim}>{pad(` … ${screen.length - maxScreen} more lines`, inner)}</text>)
|
||||
if (this.downloads.length && this.phase !== 'form') body.push(<text flexShrink={0} wrapMode="none" key="dl" fg={t.acc2}>{pad(` w saves ${this.downloads[0]!.fileName}`, inner)}</text>)
|
||||
let used = body.length
|
||||
if (this.phase === 'form' && this.editor) {
|
||||
if (scannerForm && this.editor) {
|
||||
const formHeight = Math.max(3, height - 2 - used)
|
||||
body.push(<box key="form" flexDirection="column" height={formHeight}>{this.editor.render(inner, formHeight, focused)}</box>)
|
||||
} else if (this.phase === 'form' && this.editor) {
|
||||
body.push(<text flexShrink={0} wrapMode="none" key="title" fg={t.acc}><strong>{pad(` ${this.step?.label ?? this.step?.name ?? ''}${this.editor.options.bulkEdit ? ' (Ctrl+T marks a field to apply)' : ''}`, inner)}</strong></text>)
|
||||
used++
|
||||
const formHeight = Math.max(3, height - 2 - used - 1)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { ResultCursor, associationGroups, childFilter, childListFields, formatSize, readViewSettings, relatedRecordTarget, saveDownload, valueText, widgetToBlocks, writeViewSettings, type AssociationGroup, type FieldMeta, type TagEntry, type QueryRecord, type RenderBlock, type TableMetaLike } from '@qrun-io/qqq-core'
|
||||
import { ResultCursor, associationGroups, childFilter, childListFields, exposedJoins, formatSize, helpEntryText, selectHelpContent, planJoins, readViewSettings, withJoinFields, relatedRecordTarget, saveDownload, valueText, widgetToBlocks, writeViewSettings, type AssociationGroup, type FieldMeta, type TagEntry, type QueryRecord, type RenderBlock, type TableMetaLike } from '@qrun-io/qqq-core'
|
||||
import { saveFile } from '../overlays/files-helpers'
|
||||
import { actionsOverlay } from '../overlays/actions'
|
||||
import { TagOverlay, collaborationOf } from '../overlays/tags'
|
||||
@@ -53,6 +53,18 @@ export function saveBlob(store: AppStore, record: QueryRecord, table: string, pk
|
||||
})
|
||||
}
|
||||
|
||||
/** A CHIP value's color: the adornment's color for the value mapped onto the theme (success, warning, error, info), else the accent. */
|
||||
export function chipColor(f: FieldMetaData, rec: QueryRecord): string {
|
||||
const t = theme()
|
||||
const values = (f.adornments?.find((a) => a.type === 'CHIP')?.values ?? {}) as Record<string, unknown>
|
||||
const raw = String(rec.values?.[f.name] ?? '')
|
||||
const color = String(values[`color.${raw}`] ?? values.color ?? '').toLowerCase()
|
||||
if (/success|green/.test(color)) return t.ok
|
||||
if (/warn|yellow|orange/.test(color)) return t.warn
|
||||
if (/error|red|danger/.test(color)) return t.err
|
||||
return t.acc2
|
||||
}
|
||||
|
||||
/** Sections in tier order (T1, then T2, then T3, then the rest), hidden ones left out. */
|
||||
export function orderedSections(meta: TableMetaData) {
|
||||
const rank = (tier?: string) => (tier === 'T1' ? 0 : tier === 'T2' ? 1 : tier === 'T3' ? 2 : 3)
|
||||
@@ -76,7 +88,7 @@ export class RecordScreen implements Screen {
|
||||
width = 100
|
||||
private generation = 0
|
||||
|
||||
constructor(private store: AppStore, readonly tableName: string, public primaryKey: string | undefined, readonly cursor?: ResultCursor, private label?: string) {
|
||||
constructor(private store: AppStore, readonly tableName: string, public primaryKey: string | undefined, readonly cursor?: ResultCursor, private label?: string, readonly tableVariant?: string) {
|
||||
this.id = `record:${tableName}:${Date.now()}`
|
||||
this.load()
|
||||
}
|
||||
@@ -104,8 +116,20 @@ export class RecordScreen implements Screen {
|
||||
this.meta ??= await client.tableMeta(this.tableName)
|
||||
if (this.cursor && this.primaryKey === undefined) this.primaryKey = String(this.cursor.keyOf(await this.cursor.current()) ?? '')
|
||||
if (this.primaryKey === undefined || this.primaryKey === '') throw new Error('no record')
|
||||
const record = await client.record(this.tableName, this.primaryKey, Boolean(this.meta.associations?.length))
|
||||
for (const a of this.meta.associations ?? []) if (!this.childMeta.has(a.associatedTableName)) this.childMeta.set(a.associatedTableName, await client.tableMeta(a.associatedTableName))
|
||||
const record = await client.record(this.tableName, this.primaryKey, Boolean(this.meta.associations?.length), this.tableVariant)
|
||||
await this.loadJoinedFields(record)
|
||||
// child tables three levels down (pets, then each pet's notes), for nested child lists
|
||||
let level: TableMetaData[] = [this.meta]
|
||||
for (let depth = 0; depth < 3 && level.length; depth++) {
|
||||
const next: TableMetaData[] = []
|
||||
for (const m of level) for (const a of m.associations ?? []) {
|
||||
if (this.childMeta.has(a.associatedTableName)) continue
|
||||
const cm = await client.tableMeta(a.associatedTableName)
|
||||
this.childMeta.set(a.associatedTableName, cm)
|
||||
next.push(cm)
|
||||
}
|
||||
level = next
|
||||
}
|
||||
return record
|
||||
}
|
||||
work().then((record) => {
|
||||
@@ -126,6 +150,30 @@ export class RecordScreen implements Screen {
|
||||
})
|
||||
}
|
||||
|
||||
/** Metadata with exposed-join fields under qualified names (sections may list `joinTable.field`). */
|
||||
fullMeta?: TableMetaData
|
||||
|
||||
/** Section fields from readable exposed joins: read through a query with those joins for this record. */
|
||||
async loadJoinedFields(record: QueryRecord): Promise<void> {
|
||||
const meta = this.meta!
|
||||
const qualified = [...new Set((meta.sections ?? []).flatMap((x) => x.fieldNames ?? []).filter((n) => n.includes('.') && !meta.fields[n]))]
|
||||
const joins = exposedJoins(meta as never, this.store.meta?.tables as never)
|
||||
const full = withJoinFields(meta as never, joins) as unknown as TableMetaData
|
||||
for (const j of joins) for (const [n, f] of Object.entries(j.tableMeta.fields as Record<string, FieldMetaData>)) if (full.fields[`${j.table}.${n}`]) full.fields[`${j.table}.${n}`]!.label = `${j.label}: ${f.label}`
|
||||
this.fullMeta = full
|
||||
const wanted = qualified.filter((n) => full.fields[n])
|
||||
if (!wanted.length) return
|
||||
const plan = planJoins(joins, { columns: wanted })
|
||||
const filter = { criteria: [{ fieldName: meta.primaryKeyField, operator: 'EQUALS', values: [String(this.primaryKey)] }], booleanOperator: 'AND', limit: 1 }
|
||||
const rows = ((await this.store.client!.data<{ records?: QueryRecord[] }>('queryTable', { path: { tableName: this.tableName }, query: this.tableVariant ? { tableVariant: this.tableVariant } : undefined, json: { filter, joins: plan.joins } })).records ?? [])
|
||||
const row = rows[0]
|
||||
if (!row) return
|
||||
for (const n of wanted) {
|
||||
record.values[n] = row.values?.[n]
|
||||
if (row.displayValues?.[n] !== undefined) (record.displayValues ??= {})[n] = row.displayValues[n]!
|
||||
}
|
||||
}
|
||||
|
||||
loadWidgets(): void {
|
||||
for (const s of this.meta?.sections ?? []) {
|
||||
if (!s.widgetName) continue
|
||||
@@ -181,7 +229,7 @@ export class RecordScreen implements Screen {
|
||||
}
|
||||
const inner = this.width - 2
|
||||
const view = this.view()
|
||||
const labelWidth = view.labelWidth ?? Math.min(28, Math.max(8, ...Object.values(meta.fields).map((f) => f.label.length + 1)))
|
||||
const labelWidth = view.labelWidth ?? Math.min(28, Math.max(8, ...Object.values({ ...meta.fields, ...Object.fromEntries((meta.sections ?? []).flatMap((x) => x.fieldNames ?? []).filter((n) => this.fullMeta?.fields[n]).map((n) => [n, this.fullMeta!.fields[n]!])) }).map((f) => f.label.length + 1)))
|
||||
rows.push({ text: `${rec.recordLabel ?? this.primaryKey}${this.cursor ? ` (${this.cursor.describe()})` : ''}`, fg: t.fg, bold: true })
|
||||
if (this.tags) rows.push({ text: `Tags: ${this.tags.length ? this.tags.map((x) => x.label ?? x.name).join(' · ') : '(none)'} t edits`, fg: t.acc2, key: 'tags', action: () => this.openTags() })
|
||||
for (const s of orderedSections(meta)) {
|
||||
@@ -194,15 +242,20 @@ export class RecordScreen implements Screen {
|
||||
else rows.push(...blockLines(w.filter((b) => b.kind !== 'heading'), inner - 2).map((l) => ({ ...l, text: ' ' + l.text })))
|
||||
continue
|
||||
}
|
||||
const fields = (s.fieldNames ?? []).map((n) => meta.fields[n]).filter((f): f is FieldMetaData => Boolean(f) && !f!.isHidden)
|
||||
const fields = (s.fieldNames ?? []).map((n) => meta.fields[n] ?? this.fullMeta?.fields[n]).filter((f): f is FieldMetaData => Boolean(f) && !f!.isHidden)
|
||||
if (!fields.length) continue
|
||||
rows.push({ text: '' })
|
||||
rows.push({ text: `${s.label}${s.tier ? ` (${s.tier})` : ''} ${g.rule.repeat(Math.max(0, Math.min(60, inner - s.label.length - 8)))}`, fg: t.acc, bold: true })
|
||||
// section help for the view screen (helpContents by role)
|
||||
const sectionHelp = helpEntryText(selectHelpContent((s as { helpContents?: unknown }).helpContents))
|
||||
if (sectionHelp) rows.push({ text: ` ${trunc(sectionHelp, inner - 4)}`, fg: t.dim })
|
||||
const tableNames = Object.keys(this.store.meta?.tables ?? {})
|
||||
const valueOf = (f: FieldMetaData) => f.type === 'BLOB' && !f.adornments?.some((a) => a.type === 'FILE_DOWNLOAD') ? blobText(rec, f.name) : valueText(f as FieldMeta, rec as never, { reveal: this.revealed.has(f.name), tableNames, tableName: this.tableName, primaryKey: this.primaryKey, widgets: this.store.meta?.widgets as never }).text
|
||||
// the value column is sized once per section (first record shown) and kept while stepping records
|
||||
const frozenKey = `${s.name}|${inner}`
|
||||
if (!this.sectionWidths.has(frozenKey)) this.sectionWidths.set(frozenKey, Math.max(...fields.map((f) => valueOf(f).length + (linkTarget(this.store, f) ? 2 : 0)), 12))
|
||||
// from the field set (type widths), and the first record's values; kept while stepping
|
||||
const typeWidth = (f: FieldMetaData) => (f.type === 'DATE_TIME' ? 30 : f.type === 'DATE' ? 10 : f.type === 'TIME' ? 8 : f.type === 'BOOLEAN' ? 5 : ['INTEGER', 'LONG', 'DECIMAL'].includes(f.type) && !f.possibleValueSourceName ? 14 : Math.min(40, Number((f as { maxLength?: number }).maxLength ?? 24)))
|
||||
if (!this.sectionWidths.has(frozenKey)) this.sectionWidths.set(frozenKey, Math.max(...fields.map((f) => Math.max(typeWidth(f), valueOf(f).length) + (linkTarget(this.store, f) ? 2 : 0)), 12))
|
||||
const longest = this.sectionWidths.get(frozenKey)!
|
||||
const colWidth = Math.min(60, labelWidth + 2 + longest)
|
||||
const two = (view.recordColumns ?? (inner >= 2 * colWidth + 6 ? 2 : 1)) === 2 && inner >= 2 * Math.min(colWidth, 30) + 6
|
||||
@@ -211,15 +264,23 @@ export class RecordScreen implements Screen {
|
||||
const link = v.link ? ` ${g.link}` : v.file || isBlob(rec, f) ? ' [file: Enter saves]' : v.masked ? ' [Enter reveals]' : ''
|
||||
return `${pad(f.label, labelWidth)} ${trunc(valueOf(f) + link, (two ? colWidth : Math.min(inner - 4, 100)) - labelWidth - 2)}`
|
||||
}
|
||||
for (let i = 0; i < fields.length; i += two ? 2 : 1) {
|
||||
const pair = two ? fields.slice(i, i + 2) : [fields[i]!]
|
||||
// WIDGET-adorned fields: the value is a widget payload, drawn as blocks under its label
|
||||
const widgetFields = fields.filter((f) => f.adornments?.some((a) => a.type === 'WIDGET') && rec.values?.[f.name] && typeof rec.values[f.name] === 'object')
|
||||
for (const f of widgetFields) {
|
||||
rows.push({ text: ` ${f.label}`, fg: t.fg, bold: true })
|
||||
rows.push(...blockLines(widgetToBlocks(rec.values![f.name]), inner - 6).map((l) => ({ ...l, text: ` ${l.text}` })))
|
||||
}
|
||||
const plainFields = fields.filter((f) => !widgetFields.includes(f))
|
||||
for (let i = 0; i < plainFields.length; i += two ? 2 : 1) {
|
||||
const pair = two ? plainFields.slice(i, i + 2) : [plainFields[i]!]
|
||||
const links = pair.filter((f) => { const v = valueText(f as FieldMeta, rec as never, { tableNames }); return Boolean(v.link || v.file || v.masked || isBlob(rec, f)) })
|
||||
const text = ' ' + (two ? pad(fieldText(pair[0]!), colWidth) + ' ' + (pair[1] ? fieldText(pair[1]) : '') : fieldText(pair[0]!))
|
||||
rows.push({ text, key: `f:${pair[0]!.name}`, action: () => this.follow(links) })
|
||||
const chip = pair.some((f) => valueText(f as FieldMeta, rec as never, {}).adornment === 'CHIP' && cell(rec, f.name))
|
||||
rows.push({ text, key: `f:${pair[0]!.name}`, action: () => this.follow(links), ...(chip ? { fg: chipColor(pair.find((f) => valueText(f as FieldMeta, rec as never, {}).adornment === 'CHIP')!, rec) } : {}) })
|
||||
}
|
||||
}
|
||||
const tables: Record<string, TableMetaLike | undefined> = Object.fromEntries([...this.childMeta].map(([k, v]) => [k, v as unknown as TableMetaLike]))
|
||||
this.groups = associationGroups(rec as never, meta as unknown as TableMetaLike, tables, { widgets: this.store.meta?.widgets as never, maxDepth: 1 })
|
||||
this.groups = associationGroups(rec as never, meta as unknown as TableMetaLike, tables, { widgets: this.store.meta?.widgets as never, maxDepth: 3 })
|
||||
this.groups.forEach((group, gi) => this.childRows(group, gi, rows, inner))
|
||||
this.list.setRows(rows)
|
||||
// back from a child record: land on the child that was viewed last (it may have stepped)
|
||||
@@ -245,7 +306,10 @@ export class RecordScreen implements Screen {
|
||||
const child = this.childMeta.get(group.table)
|
||||
const records = group.records as QueryRecord[]
|
||||
rows.push({ text: '' })
|
||||
rows.push({ text: `${group.label} (${records.length}) ${g.rule.repeat(Math.max(0, Math.min(60, inner - group.label.length - 8)))}`, fg: t.acc, bold: true, key: `c:${gi}`, action: () => this.newChild(group) })
|
||||
const indent = ' '.repeat(Math.max(0, group.depth - 1))
|
||||
const heading = group.depth > 1 ? group.title : group.label
|
||||
if (group.depth > 1 && !records.length) return // empty grandchild lists stay out of the way
|
||||
rows.push({ text: `${indent}${heading} (${records.length}) ${g.rule.repeat(Math.max(0, Math.min(60, inner - heading.length - 8 - indent.length)))}`, fg: t.acc, bold: true, key: `c:${gi}`, action: () => this.newChild(group) })
|
||||
if (group.unavailable || !child) {
|
||||
rows.push({ text: ` ${group.unavailable ?? 'no metadata'}`, fg: t.dim })
|
||||
return
|
||||
@@ -257,9 +321,9 @@ export class RecordScreen implements Screen {
|
||||
this.childWidths.set(wkey, names.map((n) => Math.min(30, Math.max(child.fields[n]!.label.length, ...records.map((r) => fieldCell(r, child.fields[n]!).length)))))
|
||||
}
|
||||
const widths = this.childWidths.get(wkey)!
|
||||
rows.push({ text: ' ' + names.map((n, i) => pad(child.fields[n]!.label, widths[i]!)).join(' '), fg: t.dim })
|
||||
rows.push({ text: `${indent} ` + names.map((n, i) => pad(child.fields[n]!.label, widths[i]!)).join(' '), fg: t.dim })
|
||||
records.forEach((r, idx) => {
|
||||
rows.push({ text: ' ' + names.map((n, i) => pad(fieldCell(r, child.fields[n]!), widths[i]!)).join(' '), key: `c:${gi}:${idx}`, action: () => this.openChild(group, idx) })
|
||||
rows.push({ text: `${indent} ` + names.map((n, i) => pad(fieldCell(r, child.fields[n]!), widths[i]!)).join(' '), key: `c:${gi}:${idx}`, action: () => this.openChild(group, idx) })
|
||||
})
|
||||
if (!records.length) rows.push({ text: ' (none) · Enter on the heading or F7 adds one', fg: t.dim })
|
||||
}
|
||||
@@ -267,7 +331,8 @@ export class RecordScreen implements Screen {
|
||||
/** The filter selecting this record's children of a group (kernel childFilter). */
|
||||
childFilterOf(group: AssociationGroup) {
|
||||
const child = this.childMeta.get(group.table)!
|
||||
const f = childFilter(group.association, this.meta as unknown as TableMetaLike, this.record as never, child as unknown as TableMetaLike)
|
||||
const parentMeta = group.parentTable === this.tableName ? this.meta : this.childMeta.get(group.parentTable)
|
||||
const f = childFilter(group.association, parentMeta as unknown as TableMetaLike, group.parent as never, child as unknown as TableMetaLike)
|
||||
return { criteria: f.criteria, booleanOperator: f.booleanOperator, ...(f.subFilters ? { subFilters: f.subFilters } : {}) }
|
||||
}
|
||||
|
||||
@@ -283,7 +348,7 @@ export class RecordScreen implements Screen {
|
||||
}
|
||||
|
||||
newChild(group: AssociationGroup): void {
|
||||
const values = Object.fromEntries((group.association.join?.joinOns ?? []).map((j) => [j.rightField, this.record?.values?.[j.leftField]]))
|
||||
const values = Object.fromEntries((group.association.join?.joinOns ?? []).map((j) => [j.rightField, (group.parent as QueryRecord).values?.[j.leftField]]))
|
||||
openForm(this.store, group.table, { mode: 'create', initial: values })
|
||||
}
|
||||
|
||||
@@ -387,7 +452,7 @@ export class RecordScreen implements Screen {
|
||||
return true
|
||||
}
|
||||
switch (key.name) {
|
||||
case 'f4': if (this.primaryKey !== undefined) openForm(this.store, this.tableName, { mode: 'edit', primaryKey: this.primaryKey }); return true
|
||||
case 'f4': if (this.primaryKey !== undefined) openForm(this.store, this.tableName, { mode: 'edit', primaryKey: this.primaryKey, tableVariant: this.tableVariant }); return true
|
||||
case 'f7': {
|
||||
const a = this.currentAssociation()
|
||||
if (a) this.newChild(a)
|
||||
@@ -401,7 +466,7 @@ export class RecordScreen implements Screen {
|
||||
if (!this.store.singleKeys) return false
|
||||
switch (ch) {
|
||||
case 'e': return this.onKey({ name: 'f4', sequence: '' })
|
||||
case 'c': if (this.primaryKey !== undefined) openForm(this.store, this.tableName, { mode: 'copy', primaryKey: this.primaryKey }); return true
|
||||
case 'c': if (this.primaryKey !== undefined) openForm(this.store, this.tableName, { mode: 'copy', primaryKey: this.primaryKey, tableVariant: this.tableVariant }); return true
|
||||
case 'd': this.deleteRecord(); return true
|
||||
case 'n': openForm(this.store, this.tableName, { mode: 'create' }); return true
|
||||
case 'r': this.refresh(); return true
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { searchPossibleValues, type FieldValues } from '@qrun-io/qqq-core'
|
||||
import { openProcess } from '../nav'
|
||||
import { ChoiceOverlay } from '../overlays/basic'
|
||||
import { saveFile } from '../overlays/files-helpers'
|
||||
import type { AppStore } from '../store'
|
||||
import { errorText, num } from '../util/text'
|
||||
|
||||
interface ReportMeta {
|
||||
name: string
|
||||
label?: string
|
||||
processName?: string
|
||||
hasPermission?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_FORMATS = ['XLSX', 'CSV', 'JSON']
|
||||
|
||||
/** The formats a report can be written in: the server's reportFormat possible values, else XLSX, CSV, JSON. */
|
||||
async function formats(store: AppStore): Promise<Array<{ id: string; label: string }>> {
|
||||
try {
|
||||
const c = store.client!
|
||||
const r = await c.data<{ options?: Array<{ id: unknown; label?: string }> }>('searchPossibleValues', { path: { possibleValueSourceName: 'reportFormat' }, json: { searchTerm: '' } })
|
||||
const list = (r.options ?? []).map((o) => ({ id: String(o.id), label: String(o.label ?? o.id) }))
|
||||
if (list.length) return list
|
||||
} catch {
|
||||
// fall back
|
||||
}
|
||||
void searchPossibleValues
|
||||
return DEFAULT_FORMATS.map((f) => ({ id: f, label: f }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a report, as the web UI does: a report with a process runs through the process screens (its
|
||||
* input fields, then its file, saved with w or automatically); the output format is asked first
|
||||
* unless the process asks for it on a screen; a report without a process streams from
|
||||
* GET /reports/{name} into the save dialog.
|
||||
*/
|
||||
export function openReport(store: AppStore, name: string, values: FieldValues = {}): void {
|
||||
const meta = (store.meta?.reports as Record<string, ReportMeta> | undefined)?.[name]
|
||||
if (!meta) return store.flash(`no report named "${name}"`, 'error')
|
||||
if (meta.hasPermission === false) return store.flash(`you may not run ${meta.label ?? name}`, 'error')
|
||||
const c = store.client!
|
||||
const chooseFormat = (then: (format: string) => void) => {
|
||||
store.run(formats(store), (list) => store.open(new ChoiceOverlay(store, `${meta.label ?? name}: format`, list.map((f) => ({ label: f.label, value: f.id })), then)))
|
||||
}
|
||||
if (meta.processName) {
|
||||
const processName = meta.processName
|
||||
store.run(c.processMeta(processName), (pm) => {
|
||||
const asksFormat = (pm.frontendSteps ?? []).some((s) => (s.formFields ?? []).some((f) => f.name === 'reportFormat'))
|
||||
if (asksFormat) return openProcess(store, processName, { values: { ...values }, autoDownload: true })
|
||||
chooseFormat((format) => openProcess(store, processName, { values: { ...values, reportFormat: format }, autoDownload: true }))
|
||||
})
|
||||
return
|
||||
}
|
||||
chooseFormat((format) => {
|
||||
store.run(c.call('runReport', { path: { reportName: name }, query: { format: format.toLowerCase() } }), (r) => {
|
||||
const bytes = r.bytes ?? new TextEncoder().encode(typeof r.data === 'string' ? r.data : JSON.stringify(r.data ?? ''))
|
||||
saveFile(store, `Save ${meta.label ?? name}`, `${name}.${format.toLowerCase()}`, (path) => {
|
||||
try {
|
||||
writeFileSync(path, bytes)
|
||||
store.message(`Wrote ${path} (${num(bytes.length)} bytes).`)
|
||||
} catch (e) {
|
||||
store.error(errorText(e))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { ResultCursor, buildViewJson, columnStatsAvailability, combineWithQuickSearch, deleteSavedView, exposedJoins, findRecordByKey, gotoKeys, listSavedViews, listTableVariants, printFilter, quickSearchFields, quickSearchFilter, runColumnStats, savedViewProcesses, storeSavedView, usesVariants, variantLabel, variantParam, viewToQuery, type TableVariant, readViewSettings, type QueryFilter, type QueryRecord, type ViewSettings } from '@qrun-io/qqq-core'
|
||||
import { ResultCursor, buildViewJson, filterDateTimesToUtc, relatedRecordTarget, planJoins, withJoinFields, type ExposedJoin, columnStatsAvailability, combineWithQuickSearch, deleteSavedView, exposedJoins, findRecordByKey, gotoKeys, listSavedViews, listTableVariants, printFilter, quickSearchFields, quickSearchFilter, runColumnStats, savedViewProcesses, storeSavedView, usesVariants, variantLabel, variantParam, viewToQuery, type TableVariant, readViewSettings, type QueryFilter, type QueryRecord, type ViewSettings } from '@qrun-io/qqq-core'
|
||||
import type { FieldMetaData, TableMetaData } from '../client'
|
||||
import type { Key } from '../keys'
|
||||
import { charOf } from '../keys'
|
||||
@@ -107,6 +107,8 @@ export class TableScreen implements Screen {
|
||||
colCursor = 0
|
||||
leftCol = 0
|
||||
selection = new Set<string>()
|
||||
/** Selection beyond checked rows: the whole result (the filter), or its first N records. */
|
||||
selectAll?: { kind: 'all' } | { kind: 'first'; n: number }
|
||||
rows: QueryRecord[] = []
|
||||
rowsPage = -1
|
||||
loading = false
|
||||
@@ -115,6 +117,9 @@ export class TableScreen implements Screen {
|
||||
pageSize = 20
|
||||
view: ViewSettings = {}
|
||||
private generation = 0
|
||||
/** Exposed joins the user may read, and the metadata with their fields under qualified names (labels "Join: Field"). */
|
||||
joins: ExposedJoin[] = []
|
||||
fullMeta?: TableMetaData
|
||||
/** Quick search text (CONTAINS on the visible string columns, ANDed onto the filter). */
|
||||
quick = ''
|
||||
/** The table variant, for tables that need one. */
|
||||
@@ -132,6 +137,13 @@ export class TableScreen implements Screen {
|
||||
if (!this.sort && savedSort) this.sort = savedSort
|
||||
store.run(store.client!.tableMeta(tableName), (meta) => {
|
||||
this.meta = meta
|
||||
this.joins = exposedJoins(meta as never, store.meta?.tables as never)
|
||||
const joined = withJoinFields(meta as never, this.joins) as unknown as TableMetaData
|
||||
for (const j of this.joins) for (const [n, f] of Object.entries(j.tableMeta.fields as Record<string, FieldMetaData>)) {
|
||||
const q = joined.fields[`${j.table}.${n}`]
|
||||
if (q) q.label = `${j.label}: ${f.label}`
|
||||
}
|
||||
this.fullMeta = joined
|
||||
if (usesVariants(meta as never)) return this.chooseVariant(options.index ?? 0)
|
||||
this.newCursor(options.index ?? 0)
|
||||
this.store.remember({ kind: 'table', table: tableName, label: meta.label })
|
||||
@@ -161,10 +173,22 @@ export class TableScreen implements Screen {
|
||||
|
||||
fullFilter(): QueryFilter {
|
||||
const quick = this.meta && this.quick.trim() ? quickSearchFilter(this.quick, quickSearchFields(this.meta as never, this.fields().map((f) => f.name))) : undefined
|
||||
const base = combineWithQuickSearch({ ...this.filter }, quick)
|
||||
const base = combineWithQuickSearch(JSON.parse(JSON.stringify(this.filter)) as QueryFilter, quick)
|
||||
// DATE_TIME criteria typed in the local zone go out as UTC instants (expressions stay for the server)
|
||||
if (this.fullMeta ?? this.meta) filterDateTimesToUtc(base, (this.fullMeta ?? this.meta)!.fields as never)
|
||||
return { ...base, ...(this.sort ? { orderBys: [{ fieldName: this.sort.field, isAscending: this.sort.asc }] } : {}) }
|
||||
}
|
||||
|
||||
/** Opens the record a possible-value cell links to (the column under the cursor). */
|
||||
followCell(): void {
|
||||
const f = this.fields()[this.colCursor]
|
||||
const rec = this.currentRecord
|
||||
if (!f || !rec) return
|
||||
const target = relatedRecordTarget(f as never, rec as never, Object.keys(this.store.meta?.tables ?? {}))
|
||||
if (!target) return this.store.flash(`${f.label} does not link to a record`)
|
||||
openRecord(this.store, target.tableName, target.primaryKey)
|
||||
}
|
||||
|
||||
quickSearch(): void {
|
||||
this.store.open(new PromptOverlay(this.store, `Quick search ${this.title()}`, 'Text matched (contains) in the visible text columns, together with the filter; empty clears.', (text) => {
|
||||
this.quick = text.trim()
|
||||
@@ -192,13 +216,15 @@ export class TableScreen implements Screen {
|
||||
const procs = savedViewProcesses(meta as never, this.store.meta as never)
|
||||
if (!procs.query) return this.store.message('Saved views are not available for this table.', 'info', 'Saved views')
|
||||
this.store.run(listSavedViews(c.server, c.fetch, procs, this.tableName), (views) => {
|
||||
type Pick = { kind: 'open' | 'delete'; v: (typeof views)[number] } | { kind: 'save' }
|
||||
type Pick = { kind: 'open' | 'delete' | 'rename' | 'update'; v: (typeof views)[number] } | { kind: 'save' }
|
||||
const choices: Array<{ label: string; value: Pick; detail?: string }> = [
|
||||
...views.map((v) => ({ label: v.label, value: { kind: 'open' as const, v }, detail: v.type ?? '' })),
|
||||
...(procs.store ? [{ label: '+ Save the current query as a view…', value: { kind: 'save' as const } }] : []),
|
||||
...(procs.store ? views.map((v) => ({ label: `Rename "${v.label}"…`, value: { kind: 'rename' as const, v } })) : []),
|
||||
...(procs.store ? views.map((v) => ({ label: `Update "${v.label}" with the current query`, value: { kind: 'update' as const, v } })) : []),
|
||||
...(procs.delete ? views.map((v) => ({ label: `Delete "${v.label}"`, value: { kind: 'delete' as const, v } })) : []),
|
||||
]
|
||||
this.store.open(new ChoiceOverlay<Pick>(this.store, `Saved views · ${this.title()}`, choices, (choice) => {
|
||||
const overlay = new ChoiceOverlay<Pick>(this.store, `Saved views · ${this.title()}`, choices, (choice) => {
|
||||
if (choice.kind === 'open') {
|
||||
const q = viewToQuery(meta as never, exposedJoins(meta as never), choice.v.view)
|
||||
this.view = { ...this.view, columns: q.columns.filter((n) => meta.fields[n]) }
|
||||
@@ -209,11 +235,30 @@ export class TableScreen implements Screen {
|
||||
if (!label.trim()) return
|
||||
this.store.run(storeSavedView(c.server, c.fetch, procs, { table: this.tableName, label: label.trim(), view: buildViewJson({ filter: this.fullFilter(), columns: this.fields().map((f) => f.name), allColumns: Object.keys(meta.fields), rowsPerPage: this.pageSize }) }), (v) => this.store.flash(`saved view ${v.label}`))
|
||||
}))
|
||||
} else if (choice.kind === 'rename') {
|
||||
const v = choice.v
|
||||
this.store.open(new PromptOverlay(this.store, `Rename "${v.label}"`, 'The new name.', (label) => {
|
||||
if (label.trim()) this.store.run(storeSavedView(c.server, c.fetch, procs, { table: this.tableName, label: label.trim(), view: v.view, id: v.id }), (x) => this.store.flash(`renamed to ${x.label}`))
|
||||
}, v.label))
|
||||
} else if (choice.kind === 'update') {
|
||||
const v = choice.v
|
||||
this.store.run(storeSavedView(c.server, c.fetch, procs, { table: this.tableName, label: v.label, id: v.id, view: buildViewJson({ filter: this.fullFilter(), columns: this.fields().map((f) => f.name), allColumns: Object.keys(meta.fields), rowsPerPage: this.pageSize, base: v.view }) }), (x) => this.store.flash(`updated ${x.label}`))
|
||||
} else if (choice.kind === 'delete') {
|
||||
const v = choice.v
|
||||
this.store.confirm(`Delete the saved view "${v.label}"?`, () => this.store.run(deleteSavedView(c.server, c.fetch, procs, v.id), () => this.store.flash(`deleted view ${v.label}`)))
|
||||
}
|
||||
}))
|
||||
})
|
||||
this.store.open(overlay)
|
||||
// counted quick views (doCount): their record counts load after the list shows
|
||||
for (const v of views.filter((x) => x.doCount)) {
|
||||
const q = viewToQuery(meta as never, exposedJoins(meta as never), v.view)
|
||||
c.data<{ count?: number }>('countTable', { path: { tableName: this.tableName }, query: this.variant ? { tableVariant: variantParam(this.variant) } : undefined, json: { filter: q.filter } })
|
||||
.then((r) => {
|
||||
const choice = overlay.choices.find((x) => x.value.kind === 'open' && x.value.v.id === v.id)
|
||||
if (choice) choice.detail = `${num(r.count ?? 0)} records`
|
||||
this.store.bump()
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -231,7 +276,14 @@ export class TableScreen implements Screen {
|
||||
newCursor(index = 0): void {
|
||||
if (!this.meta) return
|
||||
if (usesVariants(this.meta as never) && !this.variant) return
|
||||
this.cursor = new ResultCursor({ baseUrl: this.store.client!.server, fetch: this.store.client!.fetch, table: this.tableName, filter: this.fullFilter(), pageSize: this.pageSize, primaryKeyField: this.meta.primaryKeyField, index, query: this.variant ? { tableVariant: variantParam(this.variant) } : undefined })
|
||||
// joins the columns, filter and sort need (exposed joins), selected when a column shows them
|
||||
let plan: { joins: unknown[]; includeDistinct: boolean } = { joins: [], includeDistinct: false }
|
||||
try {
|
||||
plan = planJoins(this.joins, { columns: this.fields().map((f) => f.name), filter: this.fullFilter() })
|
||||
} catch (e) {
|
||||
this.error = errorText(e)
|
||||
}
|
||||
this.cursor = new ResultCursor({ baseUrl: this.store.client!.server, fetch: this.store.client!.fetch, table: this.tableName, filter: this.fullFilter(), pageSize: this.pageSize, primaryKeyField: this.meta.primaryKeyField, index, query: this.variant ? { tableVariant: variantParam(this.variant) } : undefined, joins: plan.joins, includeDistinct: plan.includeDistinct })
|
||||
this.rowsPage = -1
|
||||
this.load()
|
||||
}
|
||||
@@ -257,6 +309,9 @@ export class TableScreen implements Screen {
|
||||
if (gen !== this.generation) return
|
||||
this.loading = false
|
||||
this.error = errorText(e)
|
||||
// never leave the previous result on screen under a filter that failed
|
||||
this.rows = []
|
||||
this.rowsPage = -1
|
||||
this.store.bump()
|
||||
})
|
||||
}
|
||||
@@ -275,9 +330,10 @@ export class TableScreen implements Screen {
|
||||
}
|
||||
|
||||
fields(): FieldMetaData[] {
|
||||
if (!this.meta) return []
|
||||
const names = (this.view.columns?.length ? this.view.columns : defaultColumns(this.meta)).filter((n) => this.meta!.fields[n])
|
||||
return names.map((n) => this.meta!.fields[n]!)
|
||||
const meta = this.fullMeta ?? this.meta
|
||||
if (!meta || !this.meta) return []
|
||||
const names = (this.view.columns?.length ? this.view.columns : defaultColumns(this.meta)).filter((n) => meta.fields[n])
|
||||
return names.map((n) => meta.fields[n]!)
|
||||
}
|
||||
|
||||
moveTo(index: number): void {
|
||||
@@ -313,7 +369,7 @@ export class TableScreen implements Screen {
|
||||
const rec = this.currentRecord
|
||||
const key = this.keyOf(rec)
|
||||
if (key === undefined || !this.cursor) return this.store.flash('no record under the cursor')
|
||||
openRecord(this.store, this.tableName, key, { cursor: this.cursor, label: rec?.recordLabel })
|
||||
openRecord(this.store, this.tableName, key, { cursor: this.cursor, label: rec?.recordLabel, tableVariant: variantParam(this.variant) })
|
||||
}
|
||||
|
||||
deleteCurrent(): void {
|
||||
@@ -338,20 +394,59 @@ export class TableScreen implements Screen {
|
||||
.sort((a, b) => Number(Boolean(a.parked)) - Number(Boolean(b.parked)) || a.label.localeCompare(b.label))
|
||||
}
|
||||
|
||||
/** The selection menu: this page, the full result, the first N, or clear. */
|
||||
selectionMenu(): void {
|
||||
type M = 'page' | 'all' | 'first' | 'clear'
|
||||
const total = this.cursor?.total
|
||||
this.store.open(new ChoiceOverlay<M>(this.store, 'Select', [
|
||||
{ label: 'This page', value: 'page', detail: `${this.rows.length} rows` },
|
||||
{ label: 'The full query result', value: 'all', detail: total !== undefined ? `${num(total)} records (the filter is sent)` : 'the filter is sent' },
|
||||
{ label: 'The first N records…', value: 'first' },
|
||||
{ label: 'Clear the selection', value: 'clear' },
|
||||
], (m) => {
|
||||
if (m === 'page') for (const r of this.rows) { const k = this.keyOf(r); if (k !== undefined) this.selection.add(k) }
|
||||
else if (m === 'all') { this.selection.clear(); this.selectAll = { kind: 'all' } }
|
||||
else if (m === 'clear') { this.selection.clear(); this.selectAll = undefined }
|
||||
else this.store.open(new PromptOverlay(this.store, 'Select the first N records', 'In the current sort order.', (v) => {
|
||||
const n = Number(v)
|
||||
if (!Number.isInteger(n) || n < 1) return this.store.flash('a whole number above 0', 'error')
|
||||
this.selection.clear()
|
||||
this.selectAll = { kind: 'first', n }
|
||||
this.store.bump()
|
||||
}, '10'))
|
||||
}))
|
||||
}
|
||||
|
||||
/** Runs a table process: on the selection, else on the cursor record, the whole result, or its first N records. */
|
||||
runProcess(name: string): void {
|
||||
const ids = [...this.selection]
|
||||
const tableVariant = variantParam(this.variant)
|
||||
if (ids.length) openProcess(this.store, name, { recordIds: ids, tableVariant })
|
||||
else {
|
||||
const key = this.keyOf(this.currentRecord)
|
||||
openProcess(this.store, name, key !== undefined ? { recordIds: [key], tableVariant } : { filter: this.fullFilter(), tableVariant })
|
||||
}
|
||||
if (this.selectAll?.kind === 'all') return openProcess(this.store, name, { filter: this.fullFilter(), tableVariant })
|
||||
if (this.selectAll?.kind === 'first') return openProcess(this.store, name, { filter: { ...this.fullFilter(), limit: this.selectAll.n }, tableVariant })
|
||||
if (ids.length) return openProcess(this.store, name, { recordIds: ids, tableVariant })
|
||||
const key = this.keyOf(this.currentRecord)
|
||||
const total = this.cursor?.total
|
||||
type Scope = 'cursor' | 'all' | 'first'
|
||||
const choices: Array<{ label: string; value: Scope; detail?: string }> = [
|
||||
...(key !== undefined ? [{ label: `The record under the cursor (${key})`, value: 'cursor' as const }] : []),
|
||||
{ label: `Every record of the result${total !== undefined ? ` (${num(total)})` : ''}`, value: 'all', detail: 'the filter is sent' },
|
||||
{ label: 'The first N records of the result…', value: 'first', detail: 'filter with a limit' },
|
||||
]
|
||||
this.store.open(new ChoiceOverlay<Scope>(this.store, `Run ${this.store.meta?.processes?.[name]?.label ?? name} on`, choices, (scope) => {
|
||||
if (scope === 'cursor') openProcess(this.store, name, { recordIds: [key!], tableVariant })
|
||||
else if (scope === 'all') openProcess(this.store, name, { filter: this.fullFilter(), tableVariant })
|
||||
else this.store.open(new PromptOverlay(this.store, 'How many records?', 'The first N records of the result, in its sort order.', (n) => {
|
||||
const limit = Number(n)
|
||||
if (!Number.isInteger(limit) || limit < 1) return this.store.flash('a whole number above 0', 'error')
|
||||
openProcess(this.store, name, { filter: { ...this.fullFilter(), limit }, tableVariant })
|
||||
}, '10'))
|
||||
}))
|
||||
}
|
||||
|
||||
bulkActions(): void {
|
||||
const procs = this.tableProcesses()
|
||||
if (!procs.length) return this.store.flash('this table has no processes')
|
||||
const what = this.selection.size ? `${this.selection.size} selected` : 'the record under the cursor'
|
||||
const what = this.selectAll?.kind === 'all' ? 'the full result' : this.selectAll?.kind === 'first' ? `the first ${this.selectAll.n}` : this.selection.size ? `${this.selection.size} selected` : 'the record under the cursor'
|
||||
this.store.open(new ChoiceOverlay(this.store, `Run on ${what}`, procs.map((p) => ({ label: p.label, value: p.name, detail: p.name, disabled: p.parked })), (name) => this.runProcess(name)))
|
||||
}
|
||||
|
||||
@@ -384,17 +479,20 @@ export class TableScreen implements Screen {
|
||||
|
||||
filterDialog(): void {
|
||||
if (!this.meta) return
|
||||
this.store.open(new FilterOverlay(this.store, this.meta, this.filter, this.sort, (filter, sort) => this.setFilter(filter, sort)))
|
||||
this.store.open(new FilterOverlay(this.store, this.fullMeta ?? this.meta, this.filter, this.sort, (filter, sort) => this.setFilter(filter, sort)))
|
||||
}
|
||||
|
||||
viewSettings(): Overlay | undefined {
|
||||
if (!this.meta) return undefined
|
||||
return new GridViewSettingsOverlay(this.store, this.meta, `table:${this.tableName}`, this.fields().map((f) => f.name), () => {
|
||||
return new GridViewSettingsOverlay(this.store, this.fullMeta ?? this.meta, `table:${this.tableName}`, this.fields().map((f) => f.name), () => {
|
||||
const before = this.fields().map((f) => f.name).join(',')
|
||||
this.view = readViewSettings(`table:${this.tableName}`)
|
||||
const sort = this.view.sort as { field: string; asc: boolean } | undefined
|
||||
if (JSON.stringify(sort) !== JSON.stringify(this.sort)) {
|
||||
this.sort = sort
|
||||
this.newCursor(0)
|
||||
} else if (this.joins.length && before !== this.fields().map((f) => f.name).join(',')) {
|
||||
this.newCursor(this.cursor?.index ?? 0)
|
||||
}
|
||||
this.store.bump()
|
||||
})
|
||||
@@ -422,7 +520,7 @@ export class TableScreen implements Screen {
|
||||
case 'return': case 'f3': this.openCurrent(); return true
|
||||
case 'f4': {
|
||||
const key2 = this.keyOf(this.currentRecord)
|
||||
if (key2 !== undefined) openForm(this.store, this.tableName, { mode: 'edit', primaryKey: key2, cursor: c })
|
||||
if (key2 !== undefined) openForm(this.store, this.tableName, { mode: 'edit', primaryKey: key2, cursor: c, tableVariant: variantParam(this.variant) })
|
||||
return true
|
||||
}
|
||||
case 'f6': this.filterDialog(); return true
|
||||
@@ -438,8 +536,9 @@ export class TableScreen implements Screen {
|
||||
return true
|
||||
}
|
||||
case 'escape':
|
||||
if (this.selection.size) {
|
||||
if (this.selection.size || this.selectAll) {
|
||||
this.selection.clear()
|
||||
this.selectAll = undefined
|
||||
return true
|
||||
}
|
||||
if (this.quick) {
|
||||
@@ -451,7 +550,7 @@ export class TableScreen implements Screen {
|
||||
default: break
|
||||
}
|
||||
if (key.ctrl && key.name === 'a') {
|
||||
for (const r of this.rows) { const k = this.keyOf(r); if (k !== undefined) this.selection.add(k) }
|
||||
this.selectionMenu()
|
||||
return true
|
||||
}
|
||||
if (!single) return false
|
||||
@@ -471,6 +570,7 @@ export class TableScreen implements Screen {
|
||||
case 'b': this.bulkActions(); return true
|
||||
case 'a': actionsOverlay(this.store, this); return true
|
||||
case 'q': this.quickSearch(); return true
|
||||
case 'l': this.followCell(); return true
|
||||
case 'S': this.columnStats(); return true
|
||||
case 'D': this.store.push(new TableApisScreen(this.store, this.tableName)); return true
|
||||
default: return false
|
||||
@@ -517,7 +617,7 @@ export class TableScreen implements Screen {
|
||||
const inner = width - 2
|
||||
const comfortable = this.view.density === 'comfortable'
|
||||
const headerRule = this.view.headerRule ?? true
|
||||
const chrome = 1 + (headerRule ? 1 : 0) + 2 + (this.selection.size ? 1 : 0)
|
||||
const chrome = 1 + (headerRule ? 1 : 0) + 2 + (this.selection.size || this.selectAll ? 1 : 0)
|
||||
const bodyLines = Math.max(1, height - 2 - chrome)
|
||||
const pageSize = Math.max(1, comfortable ? Math.ceil(bodyLines / 2) : bodyLines)
|
||||
if (this.cursor && pageSize !== this.cursor.pageSize) {
|
||||
@@ -539,8 +639,15 @@ export class TableScreen implements Screen {
|
||||
const cols = this.frozen.cols
|
||||
this.colCursor = Math.min(this.colCursor, Math.max(0, cols.length - 1))
|
||||
if (this.colCursor < this.leftCol) this.leftCol = this.colCursor
|
||||
while (this.leftCol < this.colCursor && !visibleFrom(cols, this.leftCol, inner - 2).some((c) => c.name === cols[this.colCursor]!.name)) this.leftCol++
|
||||
const shown = visibleFrom(cols, this.leftCol, inner - 2).map((c) => ({ ...c }))
|
||||
const pinnedCount = Math.max(0, Math.min(Number(this.view.pinned ?? 0), cols.length - 1))
|
||||
const pinnedW = cols.slice(0, pinnedCount).reduce((a, c) => a + c.width + 1, 0)
|
||||
if (this.colCursor >= pinnedCount) while (this.leftCol < this.colCursor && !visibleFrom(cols, Math.max(this.leftCol, pinnedCount), inner - 2 - pinnedW).some((c) => c.name === cols[this.colCursor]!.name)) this.leftCol++
|
||||
// pinned columns stay on the left while the rest scroll
|
||||
const pinned = Math.max(0, Math.min(Number(this.view.pinned ?? 0), cols.length - 1))
|
||||
const pinnedCols = cols.slice(0, pinned)
|
||||
const pinnedWidth = pinnedCols.reduce((a, c) => a + c.width + 1, 0)
|
||||
if (this.leftCol < pinned) this.leftCol = pinned
|
||||
const shown = [...pinnedCols, ...visibleFrom(cols, Math.max(this.leftCol, pinned), inner - 2 - pinnedWidth)].map((c) => ({ ...c }))
|
||||
if (this.view.fit === 'fill' && shown.length) {
|
||||
// fill: spread what is left of the width over the visible columns
|
||||
const spare = inner - 2 - shown.reduce((a, c) => a + c.width + 1, 0)
|
||||
@@ -579,13 +686,14 @@ export class TableScreen implements Screen {
|
||||
for (let i = used; i < bodyLines; i++) {
|
||||
lines.push(<text flexShrink={0} wrapMode="none" key={`e${i}`} fg={this.error ? t.err : t.dim}>{pad(i === 0 && !this.rows.length ? (this.error ? ` ${this.error}` : this.loading || !this.meta ? ' loading…' : ' (no records match)') : '', inner)}</text>)
|
||||
}
|
||||
if (this.selection.size) {
|
||||
lines.push(<text flexShrink={0} wrapMode="none" key="bulk" fg={t.selFg} bg={t.acc}>{pad(` ${this.selection.size} selected · b run a process on them · Ctrl+A select page · Space toggle · Esc clear`, inner)}</text>)
|
||||
if (this.selection.size || this.selectAll) {
|
||||
const what = this.selectAll?.kind === 'all' ? `all ${this.cursor?.total !== undefined ? num(this.cursor.total) : ''} records of the result` : this.selectAll?.kind === 'first' ? `the first ${this.selectAll.n} records` : `${this.selection.size} selected`
|
||||
lines.push(<text flexShrink={0} wrapMode="none" key="bulk" fg={t.selFg} bg={t.acc}>{pad(` ${what} · b run a process on them · Ctrl+A select menu · Space toggle · Esc clear`, inner)}</text>)
|
||||
}
|
||||
const c = this.cursor
|
||||
const filterText = `${printFilter(this.filter) || 'no filter'}${this.quick ? ` · quick "${this.quick}"` : ''}${this.variant ? ` · ${variantLabel(this.meta as never)} ${this.variant.name ?? this.variant.id}` : ''}`
|
||||
lines.push(<text flexShrink={0} wrapMode="none" key="f" fg={t.dim}>{pad(` ${filterText}`, inner)}</text>)
|
||||
const status = c ? `${c.total === undefined ? '…' : num(c.total)} records · row ${num(c.index + 1)} · page ${this.rowsPage + 1}/${c.pageCount}${this.sort ? ` · sort ${this.sort.field} ${this.sort.asc ? 'asc' : 'desc'}` : ''} · col ${this.colCursor + 1}/${cols.length}${this.fetchMs !== undefined ? ` · ${this.fetchMs.toFixed(0)} ms` : ''}${this.loading ? ' · loading' : ''}` : 'loading…'
|
||||
const status = c ? `${c.total === undefined ? '…' : num(c.total)} ${c.distinct !== undefined ? `rows (${num(c.distinct)} distinct)` : 'records'} · row ${num(c.index + 1)} · page ${this.rowsPage + 1}/${c.pageCount}${this.sort ? ` · sort ${this.sort.field} ${this.sort.asc ? 'asc' : 'desc'}` : ''} · col ${this.colCursor + 1}/${cols.length}${this.fetchMs !== undefined ? ` · ${this.fetchMs.toFixed(0)} ms` : ''}${this.loading ? ' · loading' : ''}` : 'loading…'
|
||||
const hint = this.store.singleKeys ? ' · a actions · s sort · f filter · q search · S stats · v view · x export · Space select' : ''
|
||||
lines.push(<text flexShrink={0} wrapMode="none" key="s" fg={t.dim}>{pad(` ${status}${hint}`, inner)}</text>)
|
||||
return <Panel title={this.title()} width={width} height={height} focused={focused}>{lines}</Panel>
|
||||
|
||||
@@ -264,6 +264,19 @@ export class AppStore {
|
||||
this.bump()
|
||||
}
|
||||
|
||||
/** Bracketed paste: to the top dialog, else the screen, when it takes text. */
|
||||
handlePaste(text: string): void {
|
||||
this.lastInput = Date.now()
|
||||
const top = (this.lock ?? this.overlays[this.overlays.length - 1]) as { paste?: (t: string) => void } | undefined
|
||||
const target = top ?? (this.screen as { paste?: (t: string) => void } | undefined)
|
||||
try {
|
||||
target?.paste?.(text)
|
||||
} catch (e) {
|
||||
this.error(e)
|
||||
}
|
||||
this.bump()
|
||||
}
|
||||
|
||||
private dispatch(key: Key): void {
|
||||
if (this.lock) {
|
||||
this.lock.onKey(key)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeAll, expect, test } from 'bun:test'
|
||||
import { KeyCodes } from '@opentui/core/testing'
|
||||
import type { TableScreen } from '../src/screens/table'
|
||||
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
|
||||
|
||||
let h: Harness | undefined
|
||||
beforeAll(() => isolate())
|
||||
afterEach(() => {
|
||||
h?.store.dispose()
|
||||
h?.setup.renderer.destroy()
|
||||
h = undefined
|
||||
})
|
||||
|
||||
test('exposed joins: a joined column sends the join, labels it "Join: Field", reports the distinct count', async () => {
|
||||
h = await startTui(120, 30)
|
||||
await command(h, 'table query order')
|
||||
await waitFor(h, (x) => x.includes('2 records'))
|
||||
const grid = h.store.screen as TableScreen
|
||||
grid.view = { columns: ['id', 'number', 'line.sku'] }
|
||||
grid.newCursor(0)
|
||||
const f = await waitFor(h, (x) => x.includes('3 rows (2 distinct)'))
|
||||
expect(f).toContain('Line: SKU')
|
||||
expect(f).toContain('A-2')
|
||||
const q = h.log.requests.filter((r) => r.path === '/table/order/query').at(-1)!.body as { joins: unknown }
|
||||
expect(q.joins).toEqual([{ joinTable: 'line', select: true, type: 'LEFT', joinName: 'orderJoinLine' }])
|
||||
})
|
||||
|
||||
test('record sections show fields of readable exposed joins', async () => {
|
||||
h = await startTui(120, 30)
|
||||
await command(h, 'table get order 1')
|
||||
const f = await waitFor(h, (x) => x.includes('First line') && x.includes('A-1'))
|
||||
expect(f).toContain('Line: SKU')
|
||||
})
|
||||
|
||||
test('counted quick views show their counts; opening one applies its filter', async () => {
|
||||
h = await startTui(120, 30)
|
||||
await command(h, 'table query order')
|
||||
await waitFor(h, (x) => x.includes('2 records'))
|
||||
;(h.store.screen as TableScreen).savedViews()
|
||||
await waitFor(h, (x) => x.includes('Recent orders') && x.includes('1 records'))
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, (x) => x.includes('number EQUALS SO-2') && x.includes('1 records'))
|
||||
})
|
||||
|
||||
test('quick filters: the T1 fields are offered first in the filter dialog and set a criterion', async () => {
|
||||
h = await startTui(120, 34)
|
||||
await command(h, 'table query order')
|
||||
await waitFor(h, (x) => x.includes('2 records'))
|
||||
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F6))
|
||||
let f = await waitFor(h, (x) => x.includes('quick Number'))
|
||||
expect(f).toContain('quick Placed')
|
||||
await press(h, () => h!.setup.mockInput.pressArrow('down'))
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
f = await waitFor(h, (x) => x.includes('Number contains'))
|
||||
await press(h, () => h!.setup.mockInput.typeText('SO-1'))
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
f = await waitFor(h, (x) => x.includes('number CONTAINS SO-1'))
|
||||
})
|
||||
|
||||
test('weekday criteria through the grammar: weekday(placed) IN sat,sun goes out with WeekdayOfDate', async () => {
|
||||
h = await startTui(120, 30)
|
||||
await command(h, 'table query order')
|
||||
await waitFor(h, (x) => x.includes('2 records'))
|
||||
await command(h, 'filter weekday(placed) IN sat,sun')
|
||||
await waitFor(h, (x) => x.includes('weekday(placed) IN 6,7'))
|
||||
const q = h.log.requests.filter((r) => r.path === '/table/order/query').at(-1)!.body as { filter: { criteria: Array<{ fieldFunction?: unknown }> } }
|
||||
expect(q.filter.criteria[0]!.fieldFunction).toEqual({ fieldName: 'placed', functionTypeIdentifierName: 'WeekdayOfDate' })
|
||||
})
|
||||
|
||||
test('first N: a table process runs on the first N records of the result (filter with a limit)', async () => {
|
||||
h = await startTui(120, 30)
|
||||
await command(h, 'table query person -w "lastName = Odd"')
|
||||
await waitFor(h, (x) => x.includes('15 records'))
|
||||
;(h.store.screen as TableScreen).runProcess('sleeper')
|
||||
await waitFor(h, (x) => x.includes('first N records'))
|
||||
await press(h, () => h!.setup.mockInput.pressArrow('down'))
|
||||
await press(h, () => h!.setup.mockInput.pressArrow('down'))
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, (x) => x.includes('How many records?'))
|
||||
await press(h, () => h!.setup.mockInput.pressKey('u', { ctrl: true }))
|
||||
await press(h, () => h!.setup.mockInput.typeText('3'))
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, (x) => x.includes('Millis'))
|
||||
const init = h.log.requests.find((r) => r.path === '/processes/sleeper/init')!.body as { filterJSON: { limit: number; criteria: unknown[] } }
|
||||
expect(init.filterJSON.limit).toBe(3)
|
||||
expect(init.filterJSON.criteria).toEqual([{ fieldName: 'lastName', operator: 'EQUALS', values: ['Odd'] }])
|
||||
})
|
||||
|
||||
test('scanner screens: no heading or step list, and Enter in the input submits the step', async () => {
|
||||
h = await startTui(120, 30)
|
||||
await command(h, 'process run scanner')
|
||||
const f = await waitFor(h, (x) => x.includes('Barcode'))
|
||||
expect(f).not.toContain('Ctrl+S continues')
|
||||
await press(h, () => h!.setup.mockInput.typeText('4006381333931'))
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, (x) => x.includes('scanned 4006381333931'), 5000)
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeAll, expect, test } from 'bun:test'
|
||||
import { KeyCodes } from '@opentui/core/testing'
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { writeFilePreferences } from '@qrun-io/qqq-core'
|
||||
import type { TableScreen } from '../src/screens/table'
|
||||
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
|
||||
|
||||
let h: Harness | undefined
|
||||
beforeAll(() => isolate())
|
||||
afterEach(() => {
|
||||
h?.store.dispose()
|
||||
h?.setup.renderer.destroy()
|
||||
h = undefined
|
||||
})
|
||||
|
||||
const key = (k: string, mods?: { ctrl?: boolean; shift?: boolean }) => press(h!, () => h!.setup.mockInput.pressKey(k, mods))
|
||||
const type = (t: string) => press(h!, () => h!.setup.mockInput.typeText(t))
|
||||
const enter = () => press(h!, () => h!.setup.mockInput.pressEnter())
|
||||
const esc = () => press(h!, () => h!.setup.mockInput.pressEscape())
|
||||
|
||||
test('record view: CHIP value, WIDGET-adorned field drawn as a widget, section help, a section widget with the record context', async () => {
|
||||
h = await startTui(130, 44)
|
||||
await command(h, 'table get acc 1')
|
||||
const f = await waitFor(h, (x) => x.includes('in the payload') && x.includes('record 1 of acc'))
|
||||
expect(f).toContain('[Active]')
|
||||
expect(f).toContain('Main section help.')
|
||||
const widget = h.log.requests.find((r) => r.path === '/widget/ctxWidget')!
|
||||
expect((widget.body as { id: string }).id).toBe('1')
|
||||
})
|
||||
|
||||
test('F1 shows table help for the view screen and field help for the edit screen', async () => {
|
||||
h = await startTui(130, 44)
|
||||
await command(h, 'table get acc 1')
|
||||
await waitFor(h, (x) => x.includes('Alpha'))
|
||||
let f = await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F1))
|
||||
f = await waitFor(h, (x) => x.includes('Acc table help for viewers.') && x.includes('Main: Main section help.'))
|
||||
expect(f).not.toContain('Name help when editing.')
|
||||
await esc()
|
||||
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F4))
|
||||
f = await waitFor(h, (x) => x.includes('Edit Acc 1'))
|
||||
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F1))
|
||||
await waitFor(h, (x) => x.includes('Name: Name help when editing.'))
|
||||
})
|
||||
|
||||
test('a form adjuster runs on change and its values land in the form', async () => {
|
||||
h = await startTui(130, 44)
|
||||
await command(h, 'table update acc 1')
|
||||
await waitFor(h, (x) => x.includes('Edit Acc 1'))
|
||||
await key('u', { ctrl: true })
|
||||
await type('Beta')
|
||||
await press(h, () => h!.setup.mockInput.pressTab())
|
||||
await waitFor(h, (x) => x.includes('changed by adjuster for Beta'))
|
||||
})
|
||||
|
||||
test('audits: newest first, who, when, what, and the field changes', async () => {
|
||||
h = await startTui(130, 44)
|
||||
await command(h, 'table get acc 1')
|
||||
await waitFor(h, (x) => x.includes('Alpha'))
|
||||
await key('A')
|
||||
const f = await waitFor(h, (x) => x.includes('2 of 2 audit entries'))
|
||||
expect(f.indexOf('bob')).toBeLessThan(f.indexOf('alice'))
|
||||
expect(f).toContain('name: Alfa → Alpha')
|
||||
})
|
||||
|
||||
test('column statistics of the column under the cursor', async () => {
|
||||
h = await startTui(130, 44)
|
||||
await command(h, 'table query acc')
|
||||
await waitFor(h, (x) => x.includes('1 records'))
|
||||
await press(h, () => h!.setup.mockInput.pressArrow('right'))
|
||||
await key('S')
|
||||
const f = await waitFor(h, (x) => x.includes('Column statistics · Name'))
|
||||
expect(f).toContain('Distinct: 1')
|
||||
expect(f).toContain('Alpha ×1')
|
||||
})
|
||||
|
||||
test('developer: panels, a script revision, its logs; table APIs and an OpenAPI document', async () => {
|
||||
h = await startTui(130, 44)
|
||||
await command(h, 'table get acc 1')
|
||||
await waitFor(h, (x) => x.includes('Alpha'))
|
||||
await key('D')
|
||||
await waitFor(h, (x) => x.includes('Panel One') && x.includes('codeRef: acc-script') && x.includes('v2 second'))
|
||||
const grid = h.store.screen as unknown as { list: { rows: Array<{ key?: string }>; cursor: number } }
|
||||
grid.list.cursor = grid.list.rows.findIndex((r) => r.key === 'r:51')
|
||||
await enter()
|
||||
await waitFor(h, (x) => x.includes('Show the run logs'))
|
||||
await press(h, () => h!.setup.mockInput.pressArrow('down'))
|
||||
await enter()
|
||||
await waitFor(h, (x) => x.includes('log line one'))
|
||||
await esc()
|
||||
await esc()
|
||||
await esc()
|
||||
await command(h, 'table query acc')
|
||||
await waitFor(h, (x) => x.includes('1 records'))
|
||||
await key('D')
|
||||
await waitFor(h, (x) => x.includes('Public API (pub)'))
|
||||
await press(h, () => h!.setup.mockInput.pressArrow('down'))
|
||||
await enter()
|
||||
await waitFor(h, (x) => x.includes('View it here'))
|
||||
await enter()
|
||||
await waitFor(h, (x) => x.includes('"openapi": "3.0.0"'))
|
||||
})
|
||||
|
||||
test('process: named widget with the process UUID, RECORD_LIST paging, back step, DOWNLOAD_FORM saved', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-dl-'))
|
||||
writeFilePreferences({ downloadDir: dir, downloadMode: 'silent' })
|
||||
try {
|
||||
h = await startTui(130, 44)
|
||||
await command(h, 'process run wizard')
|
||||
await waitFor(h, (x) => x.includes('One') && x.includes('▶A'))
|
||||
await key('s', { ctrl: true })
|
||||
let f = await waitFor(h, (x) => x.includes('process w1') && x.includes('records 1-100 of 120'))
|
||||
expect(f).toContain('row 1 ')
|
||||
;(h.store.screen as unknown as { phase: string }).phase = 'review'
|
||||
await key('n')
|
||||
f = await waitFor(h, (x) => x.includes('records 101-120 of 120'))
|
||||
;(h.store.screen as unknown as { phase: string }).phase = 'form'
|
||||
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F4))
|
||||
f = await waitFor(h, (x) => x.includes('▶A') && !x.includes('process w1'))
|
||||
expect(h.log.requests.some((r) => r.path === '/processes/wizard/w1/step/one')).toBe(true)
|
||||
await key('s', { ctrl: true })
|
||||
await waitFor(h, (x) => x.includes('records 1-100 of 120'))
|
||||
await key('s', { ctrl: true })
|
||||
f = await waitFor(h, (x) => x.includes('Finished in') && x.includes('w saves report.csv'))
|
||||
await key('w')
|
||||
await waitFor(h, (x) => x.includes('Saved'))
|
||||
expect(readFileSync(join(dir, 'report.csv'), 'utf8')).toBe('a,b\n1,2\n')
|
||||
} finally {
|
||||
writeFilePreferences({ downloadDir: undefined, downloadMode: undefined })
|
||||
}
|
||||
})
|
||||
|
||||
test('grid: whole-result and first-N selection, pinned columns, DATE_TIME filter in UTC, a cell link', async () => {
|
||||
h = await startTui(130, 34)
|
||||
await command(h, 'table query person')
|
||||
await waitFor(h, (x) => x.includes('30 records'))
|
||||
const grid = h.store.screen as TableScreen
|
||||
await key('a', { ctrl: true })
|
||||
await press(h, () => h!.setup.mockInput.pressArrow('down'))
|
||||
await enter()
|
||||
await waitFor(h, (x) => x.includes('all 30 records of the result'))
|
||||
await esc()
|
||||
grid.view = { ...grid.view, pinned: 1 }
|
||||
for (let i = 0; i < 3; i++) await press(h, () => h!.setup.mockInput.pressArrow('right'))
|
||||
const f = await press(h, () => undefined)
|
||||
expect(f.split('\n').find((l) => l.includes('First Name'))).toContain('Id')
|
||||
await command(h, 'table query acc -w "when > 2026-10-31T17:00"')
|
||||
await waitFor(h, (x) => x.includes('1 records'))
|
||||
const q = h.log.requests.filter((r) => r.path === '/table/acc/query').at(-1)!.body as { filter: { criteria: Array<{ values: string[] }> } }
|
||||
expect(q.filter.criteria[0]!.values[0]).toMatch(/Z$/)
|
||||
for (let i = 0; i < 4; i++) await press(h, () => h!.setup.mockInput.pressArrow('right'))
|
||||
await key('l')
|
||||
await waitFor(h, (x) => x.includes('First3') && h!.store.screen?.kind === 'record')
|
||||
})
|
||||
|
||||
test('create child through a composite association fills every join value', async () => {
|
||||
h = await startTui(130, 44)
|
||||
await command(h, 'table get acc 1')
|
||||
await waitFor(h, (x) => x.includes('Part (0)'))
|
||||
const rec = h.store.screen as unknown as { groups: unknown[]; newChild(g: unknown): void }
|
||||
rec.newChild(rec.groups[0])
|
||||
const f = await waitFor(h, (x) => x.includes('New Part'))
|
||||
expect(f).toContain('Alpha')
|
||||
const form = h.store.screen as unknown as { editor: { session: { values: Record<string, unknown> } } }
|
||||
expect(form.editor.session.values).toMatchObject({ accId: 1, accName: 'Alpha' })
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* An acceptance-style table for features the sample server lacks: CHIP, WIDGET and LINK
|
||||
* adornments, help by role, a form adjuster, audits, column statistics, developer scripts,
|
||||
* table APIs, a record section widget, a composite association; and a process with a back step,
|
||||
* a DOWNLOAD_FORM, a named widget and a RECORD_LIST.
|
||||
*/
|
||||
|
||||
type J = Record<string, unknown>
|
||||
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } })
|
||||
|
||||
export const accMeta = {
|
||||
name: 'acc', label: 'Acc', primaryKeyField: 'id', insertPermission: true, editPermission: true, deletePermission: true, capabilities: ['TABLE_QUERY', 'QUERY_STATS'],
|
||||
helpContents: { overview: [{ contentAsText: 'Acc table help for viewers.', roles: ['VIEW_SCREEN'] }] },
|
||||
fields: {
|
||||
id: { name: 'id', label: 'Id', type: 'INTEGER', isEditable: false },
|
||||
name: { name: 'name', label: 'Name', type: 'STRING', helpContents: [{ contentAsText: 'Name help when editing.', roles: ['EDIT_SCREEN'] }], supplementalMetaData: { materialDashboard: { formAdjusterIdentifier: 'acc.name' } } },
|
||||
status: { name: 'status', label: 'Status', type: 'STRING', adornments: [{ type: 'CHIP', values: { 'color.Active': 'success' } }] },
|
||||
payload: { name: 'payload', label: 'Payload', type: 'STRING', adornments: [{ type: 'WIDGET', values: { widgetName: 'payloadWidget' } }] },
|
||||
when: { name: 'when', label: 'When', type: 'DATE_TIME' },
|
||||
personId: { name: 'personId', label: 'Person', type: 'INTEGER', possibleValueSourceName: 'person' },
|
||||
},
|
||||
sections: [
|
||||
{ name: 's', label: 'Main', tier: 'T1', fieldNames: ['id', 'name', 'status', 'when', 'personId'], helpContents: [{ contentAsText: 'Main section help.', roles: ['VIEW_SCREEN'] }] },
|
||||
{ name: 'w', label: 'Widget field', tier: 'T2', fieldNames: ['payload'] },
|
||||
{ name: 'sw', label: 'Context widget', tier: 'T2', widgetName: 'ctxWidget' },
|
||||
],
|
||||
associations: [{ name: 'parts', associatedTableName: 'part', join: { name: 'accPart', type: 'ONE_TO_MANY', joinOns: [{ leftField: 'id', rightField: 'accId' }, { leftField: 'name', rightField: 'accName' }] } }],
|
||||
clientCapabilities: { audits: { available: true, processName: 'GetAuditsForRecord' } },
|
||||
}
|
||||
|
||||
const partMeta = { name: 'part', label: 'Part', primaryKeyField: 'id', insertPermission: true, fields: { id: { name: 'id', label: 'Id', type: 'INTEGER', isEditable: false }, accId: { name: 'accId', label: 'Acc', type: 'INTEGER' }, accName: { name: 'accName', label: 'Acc name', type: 'STRING' }, title: { name: 'title', label: 'Title', type: 'STRING' } }, sections: [{ name: 's', label: 'Part', tier: 'T1', fieldNames: ['id', 'accId', 'accName', 'title'] }] }
|
||||
|
||||
export const ACC_RECORD = { values: { id: 1, name: 'Alpha', status: 'Active', payload: { type: 'statistics', count: 42, countContext: 'in the payload' }, when: '2026-10-31T22:00:00Z', personId: 3 }, displayValues: { personId: 'Casey Sample' }, recordLabel: 'Alpha', associatedRecords: { parts: [] } }
|
||||
|
||||
/** Handles acc routes; undefined when the request is not one of them. */
|
||||
export async function accRoute(path: string, method: string, body: unknown, url: URL): Promise<Response | undefined> {
|
||||
const b = (body ?? {}) as J
|
||||
if (path === '/metaData/table/acc') return json(accMeta)
|
||||
if (path === '/metaData/table/part') return json(partMeta)
|
||||
if (path === '/table/acc/count') return json({ count: 1 })
|
||||
if (path === '/table/acc/query') return json({ records: [ACC_RECORD] })
|
||||
if (path === '/table/acc/1' && method === 'GET') return json({ record: ACC_RECORD })
|
||||
if (path === '/table/acc/formAdjuster/acc.name/onChange') return json({ updatedFieldValues: { status: `changed by adjuster for ${String(b.newValue)}` } })
|
||||
if (path === '/widget/ctxWidget') return json({ type: 'statistics', label: 'Context', count: 1, countContext: `record ${String(b.id)} of ${String(b.tableName ?? 'acc')}` })
|
||||
if (path === '/processes/GetAuditsForRecord/init') {
|
||||
return json({ type: 'COMPLETE', processUUID: 'a', values: { audits: [
|
||||
{ values: { id: 2, timestamp: '2026-10-31T23:00:00Z', auditUserId: 'bob', message: 'Record was Edited', 'auditDetail.id': 9, 'auditDetail.fieldName': 'name', 'auditDetail.oldValue': 'Alfa', 'auditDetail.newValue': 'Alpha' } },
|
||||
{ values: { id: 1, timestamp: '2026-10-30T10:00:00Z', auditUserId: 'alice', message: 'Record was Inserted' } },
|
||||
], distinctCount: 2 } })
|
||||
}
|
||||
if (path === '/processes/columnStats/init') return json({ type: 'COMPLETE', processUUID: 'cs', values: { statsFields: [{ name: 'count', label: 'Count' }, { name: 'countDistinct', label: 'Distinct' }], statsRecord: { values: { count: 1, countDistinct: 1 } }, valueCounts: [{ values: { name: 'Alpha', count: 1 } }] } })
|
||||
if (path === '/table/acc/1/developer') return json({ record: ACC_RECORD, associatedScripts: [{ associatedScript: { fieldName: 'codeRef' }, script: { values: { id: 5, name: 'acc-script' } }, scriptRevisions: [{ values: { id: 51, sequenceNo: 2, commitMessage: 'second', author: 'bob' } }], testInputFields: [], testOutputFields: [] }] })
|
||||
if (path === '/table/acc/1/developer/panels') return json({ panels: [{ id: 'p', title: 'Panel One', order: 1, blocks: [{ type: 'TEXT', text: 'panel text' }] }] })
|
||||
if (path === '/table/acc/1/developer/associatedScript/codeRef/51/logs') return json({ scriptLogRecords: [{ values: { id: 1, startTimestamp: 's', endTimestamp: 'e', hadError: false, scriptLogLine: [{ values: { timestamp: 't1', text: 'log line one' } }] } }] })
|
||||
if (path === '/table/scriptRevisionFile/query') return json({ records: [{ values: { fileName: 'main.js', contents: 'return 42' } }] })
|
||||
if (path === '/table/acc/apis') return json({ apis: [{ name: 'pub', label: 'Public API', path: '/api/pub', currentVersion: 'v1', versions: [{ version: 'v1' }] }] })
|
||||
if (path === '/table/acc/apis/pub/v1/openapi') return json({ openapi: '3.0.0', info: { title: 'Public API' } })
|
||||
if (path === '/metaData/process/wizard') {
|
||||
return json({ name: 'wizard', label: 'Wizard', frontendSteps: [
|
||||
{ name: 'one', label: 'One', components: [{ type: 'EDIT_FORM' }], formFields: [{ name: 'a', label: 'A', type: 'STRING' }] },
|
||||
{ name: 'two', label: 'Two', components: [{ type: 'WIDGET', values: { widgetName: 'stepWidget' } }, { type: 'RECORD_LIST' }, { type: 'EDIT_FORM' }], formFields: [{ name: 'b', label: 'B', type: 'STRING' }], recordListFields: [{ name: 'id', label: 'Id', type: 'INTEGER' }, { name: 'name', label: 'Name', type: 'STRING' }] },
|
||||
{ name: 'done', label: 'Done', components: [{ type: 'DOWNLOAD_FORM' }, { type: 'VIEW_FORM' }], viewFields: [{ name: 'result', label: 'Result', type: 'STRING' }] },
|
||||
] })
|
||||
}
|
||||
if (path === '/processes/wizard/init') return json({ type: 'COMPLETE', processUUID: 'w1', nextStep: 'one', values: {} })
|
||||
if (path === '/processes/wizard/w1/step/one') {
|
||||
// going back (isStepBack) shows step one again; otherwise on to two
|
||||
if (url.searchParams.get('isStepBack') === 'true') return json({ type: 'COMPLETE', processUUID: 'w1', nextStep: 'one', values: { a: 'x' } })
|
||||
return json({ type: 'COMPLETE', processUUID: 'w1', nextStep: 'two', backStep: 'one', values: { a: 'x' } })
|
||||
}
|
||||
if (path === '/processes/wizard/w1/step/two') {
|
||||
if (url.searchParams.get('isStepBack') === 'true') return json({ type: 'COMPLETE', processUUID: 'w1', nextStep: 'one', values: { a: 'x' } })
|
||||
return json({ type: 'COMPLETE', processUUID: 'w1', nextStep: 'done', values: { result: 'all done', downloadFileName: 'report.csv', serverFilePath: '/tmp/report.csv' } })
|
||||
}
|
||||
if (path === '/processes/wizard/w1/records') {
|
||||
const skip = Number(url.searchParams.get('skip') ?? 0)
|
||||
const limit = Number(url.searchParams.get('limit') ?? 50)
|
||||
return json({ records: Array.from({ length: Math.max(0, Math.min(limit, 120 - skip)) }, (_, i) => ({ values: { id: skip + i + 1, name: `row ${skip + i + 1}` } })), totalRecords: 120 })
|
||||
}
|
||||
if (path === '/widget/stepWidget') return json({ type: 'statistics', label: 'Step widget', count: 7, countContext: `process ${String(b.processUUID ?? '')}` })
|
||||
if (path === '/download/report.csv') return new Response('a,b\n1,2\n', { status: 200, headers: { 'content-type': 'text/csv' } })
|
||||
return undefined
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
/** A fake UI API server for in-memory TUI tests: a person table with pets, a contingent address form, a process. */
|
||||
|
||||
import { accRoute } from './fake-acc'
|
||||
|
||||
type J = Record<string, unknown>
|
||||
|
||||
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } })
|
||||
@@ -49,6 +51,16 @@ const docMeta = {
|
||||
sections: [{ name: 's', label: 'Doc', tier: 'T1', fieldNames: ['id', 'title', 'content'] }],
|
||||
}
|
||||
|
||||
/** An order table with an exposed many-side join to its lines, saved views, T1 quick filters and a joined section field. */
|
||||
const orderMeta = {
|
||||
name: 'order', label: 'Order', primaryKeyField: 'id', insertPermission: true, editPermission: true, deletePermission: true,
|
||||
fields: { id: { name: 'id', label: 'Id', type: 'INTEGER' }, number: { name: 'number', label: 'Number', type: 'STRING' }, placed: { name: 'placed', label: 'Placed', type: 'DATE' } },
|
||||
sections: [{ name: 's', label: 'Order', tier: 'T1', fieldNames: ['id', 'number', 'placed'] }, { name: 'l', label: 'First line', tier: 'T2', fieldNames: ['line.sku'] }],
|
||||
exposedJoins: [{ label: 'Line', isMany: true, joinTable: { name: 'line', label: 'Order Line', primaryKeyField: 'id', fields: { id: { name: 'id', label: 'Id', type: 'INTEGER' }, sku: { name: 'sku', label: 'SKU', type: 'STRING' } } }, joinPath: [{ name: 'orderJoinLine', leftTable: 'order', rightTable: 'line' }] }],
|
||||
clientCapabilities: { savedViews: { available: true, queryProcessName: 'querySavedView', storeProcessName: 'storeSavedView', deleteProcessName: 'deleteSavedView' } },
|
||||
}
|
||||
const ORDERS = [{ id: 1, number: 'SO-1', placed: '2026-10-31' }, { id: 2, number: 'SO-2', placed: '2026-11-01' }]
|
||||
|
||||
const wideMeta = {
|
||||
name: 'wide', label: 'Wide', primaryKeyField: 'id', insertPermission: true, editPermission: true, deletePermission: true,
|
||||
fields: { id: { name: 'id', label: 'Id', type: 'INTEGER' }, name: { name: 'name', label: 'Name', type: 'STRING' }, note: { name: 'note', label: 'Note', type: 'STRING' } },
|
||||
@@ -73,20 +85,44 @@ export function fakeFetch(log: FakeLog = { requests: [] }, bigMeta?: unknown): t
|
||||
else if (type.includes('form')) body = Object.fromEntries((await request.formData()).entries())
|
||||
}
|
||||
log.requests.push({ method: request.method, path, body })
|
||||
const acc = await accRoute(path, request.method, body, url)
|
||||
if (acc) return acc
|
||||
if (path === '/version') return json({ apiVersion: '1.8.0', features: ['metaData', 'tables', 'processes'], extensions: [] })
|
||||
if (path === '/metaData' && bigMeta) return json(bigMeta)
|
||||
if (path === '/metaData') {
|
||||
return json({
|
||||
apps: { people: { name: 'people', label: 'People', children: [{ type: 'TABLE', name: 'person', label: 'Person' }, { type: 'TABLE', name: 'address', label: 'Address' }, { type: 'PROCESS', name: 'sleeper', label: 'Sleeper' }] } },
|
||||
appTree: [{ type: 'APP', name: 'people', label: 'People', children: [{ type: 'TABLE', name: 'person', label: 'Person' }, { type: 'TABLE', name: 'address', label: 'Address' }, { type: 'PROCESS', name: 'sleeper', label: 'Sleeper' }] }],
|
||||
tables: { person: { name: 'person', label: 'Person', insertPermission: true }, address: { name: 'address', label: 'Address', insertPermission: true }, wide: { name: 'wide', label: 'Wide' }, vt: { name: 'vt', label: 'Variant Table' }, doc: { name: 'doc', label: 'Doc', insertPermission: true } },
|
||||
processes: { sleeper: { name: 'sleeper', label: 'Sleeper' } },
|
||||
widgets: {}, branding: { appName: 'Fake' },
|
||||
tables: { person: { name: 'person', label: 'Person', insertPermission: true }, address: { name: 'address', label: 'Address', insertPermission: true }, wide: { name: 'wide', label: 'Wide' }, order: { name: 'order', label: 'Order' }, acc: { name: 'acc', label: 'Acc', insertPermission: true }, part: { name: 'part', label: 'Part', insertPermission: true }, scriptRevisionFile: { name: 'scriptRevisionFile', label: 'Files' }, line: { name: 'line', label: 'Order Line' }, vt: { name: 'vt', label: 'Variant Table' }, doc: { name: 'doc', label: 'Doc', insertPermission: true } },
|
||||
processes: { sleeper: { name: 'sleeper', label: 'Sleeper', tableName: 'person' }, scanner: { name: 'scanner', label: 'Scanner' }, wizard: { name: 'wizard', label: 'Wizard' }, GetAuditsForRecord: { name: 'GetAuditsForRecord', label: 'Audits', isHidden: true }, columnStats: { name: 'columnStats', label: 'Stats', isHidden: true }, querySavedView: { name: 'querySavedView', label: 'q', isHidden: true } },
|
||||
widgets: { ctxWidget: { name: 'ctxWidget', label: 'Context' }, stepWidget: { name: 'stepWidget', label: 'Step widget' }, payloadWidget: { name: 'payloadWidget', label: 'Payload' } }, branding: { appName: 'Fake' },
|
||||
})
|
||||
}
|
||||
if (path === '/metaData/table/person') return json(personMeta)
|
||||
if (path === '/metaData/table/wide') return json(wideMeta)
|
||||
if (path === '/metaData/table/doc') return json(docMeta)
|
||||
if (path === '/metaData/table/order') return json(orderMeta)
|
||||
if (path === '/table/order/count') {
|
||||
const crit = (((body as J).filter ?? {}) as { criteria?: Array<{ fieldName: string; values: string[] }> }).criteria ?? []
|
||||
const n = ORDERS.filter((o) => crit.every((c) => String((o as J)[c.fieldName]) === String(c.values[0]))).length
|
||||
return json(JSON.stringify((body as J).joins ?? null).includes('line') ? { count: 3, distinctCount: 2 } : { count: n })
|
||||
}
|
||||
if (path === '/table/order/query') {
|
||||
const joined = JSON.stringify((body as J).joins ?? null).includes('line')
|
||||
const f = ((body as J).filter ?? {}) as { criteria?: Array<{ fieldName: string; values: string[] }> }
|
||||
let rows = joined ? [{ ...ORDERS[0], 'line.sku': 'A-1' }, { ...ORDERS[0], 'line.sku': 'A-2' }, { ...ORDERS[1], 'line.sku': 'B-1' }] : ORDERS.map((o) => ({ ...o }))
|
||||
for (const c of f.criteria ?? []) rows = rows.filter((r) => String((r as J)[c.fieldName]) === String(c.values[0]))
|
||||
return json({ records: rows.map((r) => ({ values: r, displayValues: {}, recordLabel: String(r.number) })) })
|
||||
}
|
||||
if (path === '/table/order/1' && request.method === 'GET') return json({ record: { values: ORDERS[0], displayValues: {}, recordLabel: 'SO-1' } })
|
||||
if (path === '/processes/querySavedView/init') return json({ type: 'COMPLETE', processUUID: 'sv', values: { savedViewList: [{ values: { id: 7, label: 'Recent orders', tableName: 'order', doCount: true, viewJson: JSON.stringify({ queryFilter: { criteria: [{ fieldName: 'number', operator: 'EQUALS', values: ['SO-2'] }], booleanOperator: 'AND' } }) } }] } })
|
||||
if (path === '/processes/scanner/init') return json({ type: 'COMPLETE', processUUID: 'sc', nextStep: 'scan', values: {} })
|
||||
if (path === '/processes/scanner/sc/step/scan') return json({ type: 'COMPLETE', processUUID: 'sc', nextStep: 'done', values: { message: `scanned ${JSON.parse(String((body as J).values ?? '{}')).barcode}` } })
|
||||
if (path === '/metaData/process/scanner') return json({ name: 'scanner', label: 'Scanner', frontendSteps: [{ name: 'scan', label: 'Scan', format: 'scanner', components: [{ type: 'EDIT_FORM' }], formFields: [{ name: 'barcode', label: 'Barcode', type: 'STRING', isRequired: true }] }, { name: 'done', label: 'Done', components: [{ type: 'VIEW_FORM' }], viewFields: [{ name: 'message', label: 'Message', type: 'STRING' }] }] })
|
||||
if (path === '/processes/sleeper/init' && (body as J)?.filterJSON) {
|
||||
log.requests.at(-1)!.body = { filterJSON: JSON.parse(String((body as J).filterJSON)) }
|
||||
return json({ type: 'COMPLETE', processUUID: 'p1', nextStep: 'input', values: {} })
|
||||
}
|
||||
if (path === '/metaData/table/vt') return json({ ...wideMeta, name: 'vt', label: 'Variant Table', usesVariants: true, variantTableLabel: 'Client' })
|
||||
if (path === '/table/vt/variants') return json({ variants: [{ id: '1', type: 'client', name: 'Acme' }, { id: '2', type: 'client', name: 'Globex' }] })
|
||||
if (path === '/table/vt/count') return json({ count: url.searchParams.get('tableVariant') ? 3 : 0 })
|
||||
|
||||
@@ -12,7 +12,8 @@ SRC = sys.argv[1]
|
||||
OUT = sys.argv[2] if len(sys.argv) > 2 else 'docs/parity/feature-matrix.md'
|
||||
|
||||
# id -> (cli, note) ; family default is todo
|
||||
P, T, N, X, B = 'pass', 'todo', 'partial', 'n/a', 'blocked' # T is the default; B (blocked) notes cite a server finding
|
||||
P, T, N, X = 'pass', 'todo', 'partial', 'n/a' # T is the default and the common case
|
||||
B = 'blocked' # waits on a server finding or issue, named in the note
|
||||
STATUS = {
|
||||
# NAV: the CLI has no shell; the TUI covers these in M3
|
||||
**{f'NAV-{i:03d}': (X, 'browser shell') for i in (1,2,3,4,5,12,13,14,15,16,18,19,20,21,22,26,29,32,33,34,35,36,37,38,55,56)},
|
||||
@@ -184,6 +185,69 @@ TUI.update({
|
||||
'REC-063': (T, 'record view and forms with the grid variant'), 'REC-064': (T, 'exposed-join fields in sections'), 'REC-065': (P, 'DATE_TIME edited in local zone, unchanged instants kept'),
|
||||
})
|
||||
|
||||
# Batch: the 13 priority todos.
|
||||
TUI.update({
|
||||
'PRC-044': (P, 'scanner-format steps: no heading or step list, Enter in the input submits (unit-tested only; no sample scanner process)'),
|
||||
'QRY-094': (P, 'doCount views show their record counts in F9 Saved views (unit-tested only; no sample saved views)'),
|
||||
'QRY-020': (P, 'joined columns and filters, labelled "Join: Field", joins planned by the kernel (unit-tested only; the sample exposes no joins)'),
|
||||
'QRY-021': (P, 'many-side join: rows repeat and the status shows the distinct count (unit-tested only)'),
|
||||
'REC-064': (P, 'section fields of readable exposed joins read through a joined query (unit-tested only)'),
|
||||
'QRY-033': (P, 'run a table process on the first N records of the result (filter with a limit; unit-tested only)'),
|
||||
'QRY-080': (P, 'the filter dialog starts with the quick filter fields (defaultQuickFilterFieldNames, else T1); live on person'),
|
||||
'QRY-081': (P, 'add a quick filter field; kept per table in the local view settings'),
|
||||
'QRY-085': (B, 'weekday(field) IN days sent as the WeekdayOf fieldFunction; the sample H2 backend fails it (server finding 24, qqq#1355)'),
|
||||
'QRY-088': (P, 'paste values split on commas, tabs, semicolons, new lines; the instance bulkAddFilterValues help shown (unit-tested only; the sample has no such help)'),
|
||||
'REC-063': (P, 'records and forms opened from a variant grid read with tableVariant (unit-tested only; writes ignore it, finding 19)'),
|
||||
})
|
||||
|
||||
# Batch: the partials.
|
||||
TUI.update({
|
||||
'NAV-005': (X, 'icon glyphs are a browser icon font; the TUI marks processes and reports and shows the icon name on app cards'),
|
||||
'NAV-012': (X, 'logo, favicon and accent images are browser-only; the app name is in the title bar and on Home'),
|
||||
'NAV-025': (P, 'the palette (F2, /, ., Ctrl+K) jumps to navigation targets and recently viewed records; live'),
|
||||
'NAV-027': (P, 'F9 › Connection: UI API version, features, extensions, metadata generation and age, client build; live'),
|
||||
'NAV-030': (B, 'the palette calls POST /search; the sample lists recordSearch with no provider (server finding 21)'),
|
||||
'PRC-014': (P, 'w saves the DOWNLOAD_FORM file through the save dialog (unit-tested only)'),
|
||||
'PRC-016': (P, 'F4 goes back to the backend back step with the entered values (isStepBack; unit-tested only)'),
|
||||
'PRC-020': (B, 'Esc asks, then POST cancel; the sample accepts the cancel and keeps the run (server finding 13)'),
|
||||
'PRC-025': (P, 'RECORD_LIST pages with n / p through getProcessRecords (unit-tested only)'),
|
||||
'PRC-035': (P, 'named WIDGET fetched with the process UUID and values, drawn from the kernel screen (unit-tested only)'),
|
||||
'PRC-036': (P, 'composite widget blocks seeded from process values drawn as text (kernel screen; unit-tested only)'),
|
||||
'QRY-013': (P, 'DATE_TIME criteria typed in the local zone go out as UTC instants; expressions pass through (unit-tested)'),
|
||||
'QRY-017': (X, 'URL state is browser-only; the TUI shows the grammar and the CLI command, and qqq ui accepts the command as a route'),
|
||||
'QRY-022': (P, 'S: kernel column statistics of the column under the cursor with the filter (unit-tested only; no sample QUERY_STATS table)'),
|
||||
'QRY-030': (P, 'Ctrl+A: this page, the full result, the first N, clear (unit-tested)'),
|
||||
'QRY-032': (P, 'with the full result selected, bulk delete gets the whole filter (unit-tested)'),
|
||||
'QRY-050': (P, 'F9 › Saved views lists and opens views (kernel; unit-tested only)'),
|
||||
'QRY-051': (P, 'save the current filter, sort, columns and page size as a view (unit-tested only)'),
|
||||
'QRY-052': (P, 'rename, update with the current query, delete (unit-tested only)'),
|
||||
'QRY-071': (P, 'processes from a variant grid carry tableVariant through the runner (unit-tested only)'),
|
||||
'QRY-090': (P, 's sorts, v hides, orders, sizes and pins columns, F6 filters; live on person'),
|
||||
'QRY-092': (P, 'v: / searches, columns grouped by table and join with shown counts, * marks changes; live'),
|
||||
'QRY-096': (P, 'opening a view drops removed fields and flashes the kernel warnings (unit-tested only)'),
|
||||
'REL-001': (P, 'labels with ↗ links in the record view; l opens a grid cell\'s linked record; live'),
|
||||
'REL-004': (P, 'child lists three levels deep (a pet\'s notes under the pet); live on person 1'),
|
||||
'REL-008': (P, 'a new child through a composite association gets every join value (unit-tested only)'),
|
||||
'REC-014': (P, 'single-line text inline; multi-line through $EDITOR (Ctrl+E) round-trips'),
|
||||
'REC-019': (P, 'DATE_TIME through the kernel displayDateTime: server-zoned display values as sent, else the viewer\'s zone; live on Field Lab'),
|
||||
'REC-020': (X, 'the rich-text editor is a browser component; HTML is edited as source in $EDITOR and shown as text'),
|
||||
'REC-029': (P, 'CHIP values bracketed and colored by the adornment color (unit-tested only)'),
|
||||
'REC-031': (X, 'the code editor is a browser component; code is shown as text and edited in $EDITOR'),
|
||||
'REC-032': (P, 'RENDER_HTML shown as text with the markup stripped (kernel htmlToText)'),
|
||||
'REC-037': (P, 'a WIDGET-adorned field\'s value drawn as its widget (unit-tested only)'),
|
||||
'REC-039': (P, 'field help by screen role in F1 and in the form\'s field hint (unit-tested only)'),
|
||||
'REC-040': (P, 'section help under the section heading and in F1 (unit-tested only)'),
|
||||
'REC-042': (P, 'A: audits newest first, who, when, what, changes (unit-tested only; the sample has no audit source)'),
|
||||
'REC-043': (P, 'without the audit process the kernel reads the audit table (kernel-tested; the TUI uses it)'),
|
||||
'REC-053': (P, 'D on a grid: the table\'s APIs and their OpenAPI documents, view or save (unit-tested only)'),
|
||||
'REC-054': (P, 'D on a record: panels (live), scripts, revisions, code, logs, store a new version (unit-tested)'),
|
||||
'REC-060': (P, 'form adjusters run on change through FormSession (unit-tested only; the sample has none)'),
|
||||
'INT-013': (P, 'no records explains it and offers F7 when the user may create; live'),
|
||||
'WID-040': (P, 'widgets in declared order, laid out side by side by gridColumns on wide terminals, v reorders; live'),
|
||||
'WID-060': (P, 'record section widgets get the record id and table (unit-tested)'),
|
||||
'HLP-002': (P, 'table, field and section help by screen role in F1 (unit-tested only; the sample has none)'),
|
||||
})
|
||||
|
||||
rows = []
|
||||
for line in open(SRC, encoding='utf-8', errors='replace'):
|
||||
m = re.match(r'^\|\s*([A-Z]{3}-\d{3})\s*\|\s*(.*?)\s*\|', line)
|
||||
@@ -211,19 +275,19 @@ for rid, feature in rows:
|
||||
both = note if not tnote else (f'{note}; TUI: {tnote}' if note else f'TUI: {tnote}')
|
||||
lines.append(f'| {rid} | {feature[:110].replace("|", "/")} | {cli} | {tui} | {both.replace("|", "/")} |')
|
||||
out.append('## Summary (CLI column)'); out.append('')
|
||||
out.append('| Family | rows | pass | todo | partial | n/a | blocked |'); out.append('|---|---|---|---|---|---|---|')
|
||||
out.append('| Family | rows | pass | todo | partial | blocked | n/a |'); out.append('|---|---|---|---|---|---|---|')
|
||||
total = collections.Counter()
|
||||
for fam in sorted(fam_counts):
|
||||
c = fam_counts[fam]; total.update(c)
|
||||
out.append(f'| {fam} | {sum(c.values())} | {c[P]} | {c[T]} | {c[N]} | {c[X]} | {c[B]} |')
|
||||
out.append(f'| **all** | {sum(total.values())} | {total[P]} | {total[T]} | {total[N]} | {total[X]} | {total[B]} |'); out.append('')
|
||||
out.append(f'| {fam} | {sum(c.values())} | {c[P]} | {c[T]} | {c[N]} | {c[B]} | {c[X]} |')
|
||||
out.append(f'| **all** | {sum(total.values())} | {total[P]} | {total[T]} | {total[N]} | {total[B]} | {total[X]} |'); out.append('')
|
||||
out.append('## Summary (TUI column)'); out.append('')
|
||||
out.append('| Family | rows | pass | todo | partial | n/a | blocked |'); out.append('|---|---|---|---|---|---|---|')
|
||||
out.append('| Family | rows | pass | todo | partial | blocked | n/a |'); out.append('|---|---|---|---|---|---|---|')
|
||||
ttotal = collections.Counter()
|
||||
for fam in sorted(tui_counts):
|
||||
c = tui_counts[fam]; ttotal.update(c)
|
||||
out.append(f'| {fam} | {sum(c.values())} | {c[P]} | {c[T]} | {c[N]} | {c[X]} | {c[B]} |')
|
||||
out.append(f'| **all** | {sum(ttotal.values())} | {ttotal[P]} | {ttotal[T]} | {ttotal[N]} | {ttotal[X]} | {ttotal[B]} |'); out.append('')
|
||||
out.append(f'| {fam} | {sum(c.values())} | {c[P]} | {c[T]} | {c[N]} | {c[B]} | {c[X]} |')
|
||||
out.append(f'| **all** | {sum(ttotal.values())} | {ttotal[P]} | {ttotal[T]} | {ttotal[N]} | {ttotal[B]} | {ttotal[X]} |'); out.append('')
|
||||
out.append('## Rows'); out.append(''); out.append('| ID | Feature | CLI | TUI | Note |'); out.append('|---|---|---|---|---|')
|
||||
out.extend(lines); out.append('')
|
||||
open(OUT, 'w', encoding='utf-8').write('\n'.join(out))
|
||||
|
||||
Reference in new issue
Block a user