The panel shows a query, so set it in the monospace face the app already uses
for code-like content (SAS logs, cell text) rather than the UI font.
The spec now also captures the editor's panel - collapsed and expanded - since
the editor is where the clause was previously clamped with an ellipsis.
A filter clause is shown in a panel above the grid. The panel was clamped to a
single line - in the editor with an ellipsis, and in the viewer by letting the
clause run the full width of the table - so a long or complex filter could not
be read on screen.
The panel is now collapsed to one line and carries a chevron that expands it to
show the whole clause, wrapped, and collapses it again. The chevron is rendered
only when the clause does not fit the collapsed line, so a short filter looks
exactly as it did before. Whether it fits is measured from the DOM rather than
guessed from the length of the text, since it depends on the rendered width; the
measurement is deferred out of the change-detection cycle, and re-run when the
clause, the available width, or the state changes.
This replaces the editor's hover-only reveal with a real control, which is a
button carrying aria-expanded and an accessible label, and gives the viewer the
same affordance. Both panels share the styling, which was previously duplicated
between the two component blocks.
Tested with `client/cypress/e2e/filter-panel.cy.ts`, at a laptop-sized viewport,
covering the three states:
1. no filter - the panel is not rendered
2. a short filter - shown in full, with no chevron
3. a long filter (an IN over every value of a free-text column, 646 characters)
- collapsed with a chevron, expands to the whole clause over several lines,
and collapses again
4. the editor panel behaves the same way for the same clause
The diagnostics tables now go only where they can be read: the startup
service, and the services that %include customer-provided code - the
hook scripts (getdata, stagedata, loadfile, restore, postdata) and the
dynamic cell dropdown programs (getdynamiccolvals). Every other service
is spared the payload, so the high-frequency calls stay lean.
The page's URL parameters now also travel as a browser_url_vars table,
one row per parameter (name, value), taken from both the search string
and the hash query string - easier for a SAS developer than parsing the
browser_info url column. The table is sent only when the URL has at
least one parameter, and only to the same services as browser_info.
mpeinit's debug dump covers browser_url_vars alongside browser_info.
The spec asserts both directions on the adapter interface: the viewer's
data services carry no browser_info, getdata does, and a ?labels=true
visit arrives as a browser_url_vars row. Each test boots from the app
root with a guarded evaluation-agreement acceptance, because Cypress
clears cookies between tests and the SASjs Server session drops (the
same flake hook-programs.cy.ts hits on this estate).
Adds dc_version, adapter_version, browser, browser_version, platform and the
raw user_agent to the browser_info row, so a support ticket can be read off
the log and a hook can branch on the browser without parsing the user agent
itself. The summary comes from a new parse-user-agent util, following the
existing shared/utils convention.
Also adds a Cypress spec asserting the table reaches the adapter interface,
which is the only place the payload is observable end to end.
The JS mock services carried MPE_TABLES.pre_edit_hook / post_edit_hook values
but never acted on them, so a table configured with hooks behaved differently
in the mock than in production.
Add mockHookSource() to dcMockUtils (resolving a hook program name to the
source of its .js counterpart on the Drive) and run the hook where the SAS
backend does:
- editors/getdata runs the pre-edit hook after filtering, letting it reassign
visibleRows / visibleColumns (the work.OUT contract) - so an empty mirror can
display the live rows of the table it points at
- editors/stagedata and editors/loadfile run the post-edit hook before the
MPE_SUBMIT row is written, letting it reassign libref / dsn (the call
symputx contract) - so a changeset submitted against a mirror is raised
against the real table
Seed a demo pair in makedata (TESTDATA.DEMO_ORDERS plus an empty
TESTDATA.DEMO_MIRROR with both hooks) and cover it with a Cypress spec.
DEMO_01 gains a GRADE column carrying MINVAL .A with MAXVAL .C, so the demo table
can show a range written in special missings: .B is inside it and .D is outside.
The clip's range-rule scene is rebuilt around the order - the same special missing
fails AMOUNT (MINVAL 1, below every number), passes SCORE (MAXVAL 100, below the
ceiling), and in GRADE .B is taken while .D is refused.
The captions from the previous take claimed a range rule steps aside for a
missing value, which the engine no longer does, so they are corrected along with
the scene.
DEMO_01 no longer carries a NOTNULL rule for ID: it is the table's buskey, and
Data Controller applies NOT NULL to a primary key by itself, so the demo now
shows the behaviour rather than a rule that duplicates it.
The clip's range-rule scene showed a special missing being rejected by MINVAL.
It now shows the missing accepted in both AMOUNT (MINVAL 1) and SCORE (MAXVAL
100), and a real number out of range - 0, then 200 - rejected.
Two captions were also wrong and are corrected: the period in a special missing
is optional (the demo's own row 2 holds .a), and ID is NOT NULL because it is the
primary key.
Every beat now says what it shows and why the rule behaves as it does, so the
colour of a cell never has to be inferred. The spec appends a caption track as
it runs - a label, the text, and a wall-clock stamp per beat - and the encoder
turns consecutive entries into subtitle cues, calibrated against the one thing
measurable in the video (the amber SOFTREGEX warning cell appearing).
The timestamp is taken inside a cy.then(): Cypress evaluates a command's
arguments when the command is queued, so Date.now() passed to cy.writeFile
directly gives every mark the same value - the moment the spec body ran.
Two beats also gained room so their caption can be read: the opening grid hold,
and the DIFF hold before the staged screen. A mark with empty text clears the
caption while the clip moves between screens.
The clip reloaded the app halfway through, because the second half began with a
cy.visit - which reads as the table refreshing for no reason. It is now a single
session: the table is opened once, every rule scene is played on row 1 and each
value is put back, the real change is submitted, and the review screens are
reached through the app's own navigation. Nothing reloads.
Because the rule scenes are put back first, the DIFF carries only the two cells
the clip is about, and the spec asserts that: RATING and LAST_REVIEWED carry the
changed-cell marker and the other cells do not.
The staged-data screen gets a beat of its own (view staged data, held long
enough to read), since that is the row the approval acts on - and its assertion
checks text that only exists on that screen, because asserting 'Staged Data'
would have been satisfied by the button that was just clicked.
Also shortens the correction beats, which do not need a study pause.
The clip hovered a changed DIFF cell with trigger('mouseover') and then asserted
contain.text on the tooltip. Clarity reveals a tooltip through CSS :hover, which
a synthetic event does not activate, so the tooltip stayed visibility: hidden
while the assertion passed anyway - the text is in the DOM either way. The
recording therefore showed no previous value at all.
hoverDiffCell now moves the real mouse (cypress-real-events, already a
dependency) and asserts the tooltip's computed visibility and opacity alongside
its text, so a take that does not actually display it fails. The first real
mouse move after another action can be swallowed, hence the repeat.
The holds are cut again as well - the recording is 82s where it was 101s - since
the finished clip is played at 1.43x on the way out.
A SOFTSELECT/HARDSELECT whose rule value is a library.member.column reference
takes its list from that column, and getdata.sas builds it with cats() and then
orders it by the column itself. The mock sent the raw stored value instead, so a
numeric special missing reached the client in its period form (".a") rather than
as the bare letter cats() produces, and it sat in row order rather than below
every number. It now does both, so a dropdown sourced from a column reads the
way the real one does.
DEMO_01 gains a STATUS column carrying a SOFTSELECT over its own column, with
one row holding a special missing, so the dropdown can be demonstrated listing
that missing alongside the ordinary values.
The clip spec records that beat, hovers both changed cells on the review screen
so the value each replaced is on screen, and cuts the waits that were only
letting a page or a modal settle.
The mock's APPROVE_TABLE branch called loadTableData(), which is not defined in
that service's scope - the loader it defines is mpeLoadTableData(). Every ACCEPT
therefore failed with "No webout was returned by job", and it went unnoticed
because the demo clip stopped at the DIFF. With the loader corrected, ACCEPT
applies the load to the base table, writes the MPE_REVIEW / MPE_SUBMIT /
MPE_DATALOADS entries and lands on the history page.
DEMO_01 row 2 now carries a special missing (RATING .a) so a demo can show one
special missing changing into another - the DIFF compares the two rather than
treating either as a blank.
The clip spec gains the two beats the companion post describes: the approver
opens the submission from the approvals list, switches the DIFF between
formatted and unformatted (15JAN2026 / 24121 on the date9. column), accepts,
and the history shows the change APPROVED. Both DIFF beats scroll the table
right, because the changed columns sit off the edge of a 1280-wide frame. The
key entered in the NOTNULL scene is now a value that is not already a key, since
the editor checks the keys before it checks the rules and would report the
duplicate instead of the invalid value.
A special missing is NULL as far as a SAS NOT NULL (or primary key)
constraint is concerned - an insert carrying one is rejected with an
integrity constraint error (_NM0001_ / _PK0001_) - but the frontend
NOTNULL rule passed it, so the editor accepted a value the target table
refuses. getdata merges a physical NOT NULL constraint into a NOTNULL
rule, so the mismatch was reachable on any table with the constraint.
The rule now fails a special missing on a numeric column. A single
letter stays valid on a character column, where there is no special
missing concept.
Also updates the DEMO_01 clip script: the NOTNULL scene now shows a
blank AND a special missing both rejected, with a number accepted.
The recording spec paused for a fixed couple of seconds after each edit and
trusted that the rule engine had finished. It has not: the engine flags a cell
(htInvalid) shortly after the edit commits, so a blind pause can land before
the red appears. In the first take the blanked NOTNULL key never showed as
invalid on screen, which is the whole point of that scene.
Every beat now asserts the expected state - flagged for the rejections
(AB, 1a, a blank key, a special missing below MINVAL), unflagged for the
accepted ones - and only then holds. The hold therefore always rests on the
settled outcome, and the clip is self-checking: if the engine stops flagging
one of these states the recording fails instead of quietly showing the wrong
thing.
Verified against the app: blanking ID gives the cell htInvalid; AB and 1a in
RATING are flagged; a single letter in RATING, a special missing in ID and a
special missing under MAXVAL all settle clean.
The mock's DIFF service normalised numeric values with Number(), so a SAS
special missing (".b") became NaN and was emitted as null - the value vanished
from the DIFF screen even though the staged data held it.
Real Data Controller writes the DIFF with missing=STRING, whose format maps ._
and .a-.z to a string and leaves a bare "." as null (see the bart format in
mp_jsonout.sas). The mock now mirrors that, so a special missing survives into
the DIFF. Special missings are numeric-only, so the guard sits in the numeric
branch of normVal.
Also adds cypress/clips/special-missings-clip.cy.ts - the recording script for
the companion demo clip. It lives outside cypress/e2e so the default spec
pattern does not pick it up in CI.
The three-row siphonophore result is narrower than the grid viewport, so
Handsontable sizes the columns wider than it (scrollWidth 1562 vs
clientWidth 1340): a horizontal scrollbar appears and NOTES - the column the
match is actually in - is clipped. The cell text is in the DOM either way, so
the beat passed without it, but the demo read as three arbitrary rows.
scrollGridTo() sets scrollLeft on #hotTable .wtHolder, asserts the value
landed, and the beat asserts the full NOTES text and scrolls back for the
following beats. It is a plain spec step - CI runs it, no recording flag and
no cy.wait.
Also lets a recording size the browser window: the capture is the window's
content area, so RECORD_WINDOW_SIZE is passed through as --window-size for a
real Chromium browser (Electron ignores it). That is what makes a 16:9 pane
possible: (W-450)/(H-96) = 16/9 at 1920x923 of content, i.e. 1920x1010 of
window, for a 1470x827 pane and the repo's 1600x900 viewport at ~0.90 zoom.
Inert unless the env var is set, so CI is unaffected. dc-cypress updated with
the window-sizing recipe, the clipped-column scroll and the frame
mean/variance scan used to find the cut point.
Seven more search beats (Halcyon across two columns, multi-word site and
species, partial expedition code, exact numeric matches on DEPTH_M and the
last primary key), all with counts verified against the mock service first.
The walkthrough now takes 38s in CI against 28s.
A single-row result renders "(1 row, 9 cols)", not "(1 rows, ...)", so
assertRowCount picks its unit from the count it is given.
demoPause adds a recording-only 1.5s linger at each row count (0 unless the
env var is set, so CI still runs the file with no waits), and the dc-cypress
skill now records how the demo capture actually works: a fixed 1280x720
window containing the runner, with the app zoomed to fit the AUT pane -
matching the viewport aspect to that pane (~1.33) fills it, and the crop
rect plus the cut points are measured from the frames.
Opening the table once and making every variation a search in place - rather
than one it() per variation, each paying for the beforeEach navigation again -
matches how a user works, makes the recorded walkthrough one continuous take,
and cuts the spec from 67s to 26s.
The no-match steps (wrong case, partial number, value not present) are now
wrapped in a negativeStep() guard driven by --env skipNegative=true, so the
recording still never shows an empty result while CI (no env) runs every step.
The walkthrough ends inside the filter: clearing the search leaves the filter
applied, which is asserted rather than assumed.
Three of the cases legitimately match nothing - they pin the case sensitivity
of the character search, the exact-match semantics of the numeric search, and
the "no data found" handling. They are worth keeping as regression coverage
but they make a poor demo video.
They now use an `itNegative` alias that resolves to `it.skip` when the spec is
run with `--env skipNegative=true`, so the walkthrough recording only ever
shows searches that return rows:
npx cypress run --browser electron \
--spec cypress/e2e/full-table-search.cy.ts --env skipNegative=true
CI runs the file with no env, so all nine cases still run there. The positive
half of the old case-sensitivity case is split out as its own test (3.1) so the
recording keeps it, and the "no data found" assertion is extracted into
assertNoData().
The viewer's search box searches every column of a table - character columns
by case-sensitive CONTAINS and numeric columns by exact equality - but against
the JS mock backend the box did nothing, because the viewdata mock ignored
SEARCHTYPE/SEARCHVAL (and FILTER_RK) entirely.
Mock changes (sas/mocks/sasjs/services/public/viewdata.js):
- apply the stored filter first via mpeFilterMaster, mirroring
%mpe_filtermaster(VIEW,...) in viewdata.sas, and return its WHERE text as
sasparams.FILTER_TEXT (previously hard-coded blank, so the viewer's info bar
never showed a clause and the filter was never applied to the rows)
- mirror %mp_searchdata: CHAR = case-sensitive CONTAINS across every character
column, NUM = exact equality across every numeric column, only CHAR/NUM
trigger a search, search values stripped of % & ; " like the SAS service
- cap the rows the way the real service does (MAXROWS 500; NOBS is the uncapped
filtered count for a normal view, the capped match count for a search)
- a search with no matches now returns no rows, so the client shows its
"No data found with given conditions" panel; the single-empty-row fallback
stays for the normal-view-empty-table case
Mock data (sas/mocks/sasjs/services/admin/makedata.js):
- new MPE_X_SEARCH demo table: 1000 deterministic, obviously fictional
deep-sea survey rows, registered in MPE_TABLES. Seeded so a partial search
hits a value buried in the middle of a long text column, a value spanning two
columns, a repeated exact numeric, and rows that deliberately do not match.
New spec (client/cypress/e2e/full-table-search.cy.ts):
- seven tests covering open/full view, partial character search, case
sensitivity, numeric exact match, no match, search within a filter, and
clearing the search. No artificial waits - state assertions only.
- added to the Cypress spec list in .gitea/workflows/build.yaml
Also adds the dc-cypress skill (.agents/skills/dc-cypress) covering the mock
backend setup, the spec conventions, the selectors that actually work in the
viewer filter modal, and the fixed 1280x720 video capture.
multiColumnSorting's sort() replaces the entire sort state on every call rather than accumulating, so the restore loop after updateSettings()/editTable()/cancelEdit() silently dropped every sorted column but the last. Pass the captured configs as a single array instead. Adds a Karma integration test proving the loop
loses columns, and a Cypress test verifying cancelEdit() keeps a real shift-click multi-column sort intact end-to-end.
String.replace interpreted $&/$`/$'/$<digit> specially in DC.ORIG_VALUE/DC.USER_NAME substitutions, corrupting formulas whose cell data happened to contain a literal $. Separately, afterChange indexed dataSource with Handsontable's visual row instead of translating to physical (as beforeChange already does), desyncing EDIT_STATUS/overwritten-comment writes on a sorted grid. Investigating that surfaced a third, unrelated Handsontable bug: updateSettings() corrupts formula cell references whenever a sort is active, regardless of what it changes - fixed by clearing the sort around every updateSettings() call.
Rewrite stage.cy.ts to follow the same pattern as excel.cy.ts:
open MPE_X_TEST, attach regular_excel.xlsx, submit to create staged
data, then test the formatted/unformatted toggle on the stage page.
The date assertion is now dynamic: reads the raw SOME_DATE value
from the unformatted view and computes the expected SAS date9.
formatted value, rather than hardcoding 12FEB1960.
Remove the fixture stageSubmission code from makedata.js — staged
data is created at runtime by stagedata.js, not by makedata.
getchangeinfo.js: use SAS datetime19. format (DDMMMYYYY:HH:MM:SS)
for SUBMITTED_ON_DTTM and REVIEWED_ON_DTTM, matching the real SAS
output. Use SUBMIT_STATUS_CD field name (not REVIEW_STATUS_ID)
to match the MPE_SUBMIT column name and getchangeinfo.sas output.
stage.cy.ts: add login step in beforeEach (the beforeAll logs out
after uploading the license, so each test needs to re-login).
Use loginAndUpdateValidKey(true) to force license upload.
- editor.cy.ts: assert DC.USER_NAME is non-empty instead of hardcoded
'sasdemo' (CI runs as a different OS user)
- editor.cy.ts: use =SOME_BESTNUM * 0 + 1936 for a deterministic formula
result regardless of the column's actual value
- excel.cy.ts: expect computed value '2' instead of formula text '=1+1'
(no XL_RULE=FORMULA configured on that column)
- excel-multi-load.cy.ts: use DC996664 libref to match the actual fixture
sheet names
- makedata.js: add labels to DC_JSLIB.MPE_X_TEST schema (SOME_CHAR,
SOME_DATE, SOME_DATETIME, SOME_TIME) for the viewer-labels tests
- makedata.js: register DC996664 libref with MPE_X_TEST/MPE_TABLES/
MPE_VALIDATIONS/MPE_ALERTS and copy table files for multi-load fixtures
- makedata.js: change SOME_SHORTNUM HARDREGEX from [1-5] to [0-2]
- loadfile.js: exclude VAR_TXFROM/VAR_TXTO/VAR_PROCESSED columns from the
missing-cols check for TXTEMPORAL tables (computed at load time)
- InfoModal.ts: add _PROGRAM to AbortDetails
- info-modal.component.html: display _PROGRAM in the abort modal
- 4 call sites: pass abortRes._PROGRAM when building AbortDetails
- licensing.cy.ts: call proceed() unconditionally via cy.then() instead of conditionally chaining .then() after cy.wait(), so the command queue stays open across the async gap and enqueued commands always run
- combinedLicenceKey.ts: replace new Response(stream).arrayBuffer() with an explicit streamToArrayBuffer helper for broader browser support
- remove debug-license.cy.ts, an untracked throwaway repro for the promise-handling question
- Tests 7-10 only acted when already on the licensing page, silently
no-oping otherwise; now they navigate to licensing/update explicitly
when needed, chained via .then() to avoid Cypress conflating a raw
async function's promise with the preceding cy.visit()/cy.wait()
- Replace Response(readable).arrayBuffer() with a direct
ReadableStream reader loop in generateCombinedKey - the Response-based
read never settled under Cypress, likely due to its fetch/Response
patching for network interception
- Accept a single pasted/uploaded combined licence key (base64+gzip of
licence+activation key), auto-detected in either paste mode, alongside
the legacy two-field format
- Warn and block applying a key generated for the wrong protocol before
it ever reaches the backend
- Decode both the legacy positional features string and a new named
features object, so already-issued keys keep working unchanged
- Show a live "Key Details" preview (validity, users, site IDs, enabled
features) as a key is typed, pasted, or uploaded
- Fix cypress test setup broken by the paste-format default change, and
an unreliable FileReader-based base64 conversion in the test helpers
saveTable() only resolved a live formula's computed value for character columns - coerceNumericRow (beforePaste/beforeAutofill) deliberately leaves a non-numeric, formula-looking value alone rather than forcing it to NaN, so a numeric column can hold a live formula too. Its raw `=...` text was reaching the backend, which a numeric SAS field can't parse.
saveTable() only resolved computed values for HARDFORMULA/SOFTFORMULA
columns - a formula typed, pasted, or "Apply as formula"'d into any
other character column still had its raw `=...` text submitted,
since HyperFormula recomputes the display without ever mutating the
underlying dataSource string.
Only backend-sourced `=`-led values are auto-escaped now; anything the
user types or pastes into a character column evaluates as a real
formula, and "Apply as formula" lets a user promote an auto-escaped
cell explicitly. Formulas are enabled on every table instead of only
ones with formula rules.
- Paste a formula using column names (e.g. "=A_COL * B_COL") into any
cell and have it translated to that row's cell references
("=B4 * C4") so HyperFormula can evaluate it - covers both a
grid-level paste and pasting directly into an open cell editor
- Only applies on tables that already have formulas enabled (an
existing HARDFORMULA/SOFTFORMULA column); otherwise the pasted text
is left untouched rather than becoming inert translated text
- New substituteColumnReferences() reuses parseFormulaRule's
boundary-matching helpers but deliberately skips DC.* variable
substitution, which only makes sense for admin-defined rule values
A key generated for one connection type (http/https) always fails to
decrypt on the other, surfacing only as a generic "invalid key" error
after a round trip to the backend. Detects the mismatch client-side
from the raw key text before the user submits, and disables the apply
button while it's showing.
Adds right, left, bottom, and bottom-left-corner resize handles
alongside the existing bottom-right corner, and raises resize handle
z-index above Handsontable's frozen header clones so clicks reach the
handle instead of the table underneath it.
Handsontable's Menu widget closes on any mouseup inside an item and
unconditionally preventDefault()s contextmenu, even for the info item,
which has no callback and exists purely to show read-only column details.
That made its text impossible to select or right-click-copy. Marked the
item isCommand: false and stop mousedown/mouseup/contextmenu/selectstart
from bubbling past its rendered content to the menu's own listeners.
- Formulas' HyperFormula sync resolves a dotted `data` key differently
than getDataAtRowProp/datamap.get() - dataDotNotation: false is needed
for the renamed dc.row_status column to work as a live cell reference
- saveTable() now submits the live computed value for a formula cell, not
the raw '=...' string still sitting in dataSource