Commit Graph
1701 Commits
Author SHA1 Message Date
allan 3f7d9a1101 Merge pull request 'fix(embed): read a month-name date from VA as a date, not a character value' (#446) from fix/444-va-word-date into release/7.22
Sites / deploy (push) Successful in 4m41s
Build / Build-and-ng-test (pull_request) Successful in 5m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m1s
Build / Build-and-test-development (pull_request) Successful in 28m43s
Reviewed-on: #446
2026-11-03 15:17:05 +00:00
dc 5aba6bfc2e test(embed): track the held-out spec, and keep its comments self-contained
Build / Build-and-ng-test (pull_request) Successful in 5m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m8s
Build / Build-and-test-development (pull_request) Successful in 31m4s
Review follow-up on the browser harness.

The exclusion of va-ddc-host now names the issue that tracks re-enabling it
(dc/dc#449), so the coverage gate cannot report a rotted exclusion as accounted
for. The comment also states the ordering constraint the spec carries: it clears
the DC_JSLIB filter tables per case, and every spec in that job shares one mock
estate, so it has to run last or be given its own.

The spec's comments and test name describe the behaviour and the harness, with
no ticket numbers, per AGENTS.md. The va-filter docstring says what the inference
is: a guess from the value's shape alone, which can disagree with the column in
either direction, and which costs the deferred call rather than the page because
the attempt suppresses the abort modal and falls back to the unfiltered load.
2026-11-03 14:19:20 +00:00
allan f24a02c47e Merge pull request 'feat(editor): convert CASE rule values on entry' (#447) from feat/372-case-transform-on-entry into release/7.22
Sites / deploy (push) Successful in 3m47s
Build / Build-and-ng-test (pull_request) Successful in 5m5s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m19s
Build / Build-and-test-development (pull_request) Canceled after 25m39s
Reviewed-on: #447
2026-11-03 13:44:27 +00:00
dc 45995dbfab test(embed): drive the VA deferred load through a sandboxed host page
Build / Build-and-ng-test (pull_request) Successful in 6m37s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m58s
Build / Build-and-test-development (pull_request) Successful in 35m9s
issue 444 was a month-name date from VA becoming a character literal, and it
only reproduced on a report refresh: the deferred initial load reads VA's
buffered message, which has no column metadata, so the SAS kind comes from the
value's shape. Posting a message into a running page exercises the other path -
it arrives after getdata, metadata in hand, so a date is formatted correctly
whatever the client does - which is why the unit spec could pin the fix but not
show the failure a report author sees.

The new spec frames the built app in sasjs/server's /ddc-host page, which mounts
it with VA's sandbox attribute set and, with timing=early, replays the message
from the moment the frame is created. That is the request and apply path a
refresh takes, with mock services instead of a VA estate.

Where the assertion sits: the mock public/validatefilter stores what it is sent,
so the clause is read back from the drive (mpe_filtersource.json) rather than
inferred from a request payload. The store is emptied per case because
mpFilterStore reuses an existing FILTER_RK for a clause it has already seen, and
a leftover row would answer for this run. The host page's own log is the second
witness: the message is captured before the frame finishes loading.

Three cases in one run - the WORDDATE shape from the issue, a DATE9 date that
always worked, and a character parameter. Against the unfixed service the first
stores 'August 02, 1971' and fails, while both controls pass; with the fix all
three store the expected literal. So the guard goes red for the defect and only
the defect.

The spec runs last, needs no SAS, and takes about a minute against the mock
estate the other specs already use.
2026-11-03 13:34:00 +00:00
dc c4eef498b2 feat(editor): convert CASE rule values on entry
Build / Build-and-ng-test (pull_request) Successful in 5m7s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m1s
Build / Build-and-test-development (pull_request) Successful in 28m39s
Where a column carries a CASE=UPCASE or CASE=LOWCASE rule in
MPE_VALIDATIONS, the EDIT grid treated it as validation only: a value in
the other case was flagged after typing, so the user had to retype it or
accept a red cell and fix it later.

The pending value is now converted before it commits, driven by the rule
that already exists rather than a hard-coded column list:

- DcValidator.getCaseTransform(col) maps a CASE rule on the column to
  'upper'/'lower' (the two rule values dq-validation.ts acts on).
- The existing beforeChange hook applies it alongside ROUND, so an edit, a
  paste range and a fill-down all take the same path, the cursor and
  selection stay put, and undo keeps its history.
- applyCaseTransform / isCaseTransformSkipped live in a small pure module
  so the conversion and the source exclusions are testable directly.

Values are not converted when they are not user entry: loadData (the grid
re-sending its dataset on every updateSettings({data})), UndoRedo.undo /
redo, and the Revert menu item. Converting those would rewrite values the
service supplied, or hand back something other than the value the user
asked to restore, and would leave untouched rows looking modified.

A CASE-bearing column is marked in the header ('Aa' marker plus a tooltip)
so the user can tell the conversion came from the grid.

The CASE validation itself is unchanged: values from an API, staged data
or an Excel import are still validated as before.
2026-11-03 13:25:06 +00:00
allan a87f55a4da Merge pull request 'feat(viewer): download a server-side PDF report' (#442) from feat/432-pdf-report into release/7.22
Sites / deploy (push) Successful in 4m40s
Build / Build-and-ng-test (pull_request) Successful in 6m34s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m37s
Build / Build-and-test-development (pull_request) Canceled after 15m58s
Reviewed-on: #442
2026-11-03 13:07:33 +00:00
allan ed57bf9cba Merge branch 'release/7.22' into feat/432-pdf-report
Build / Build-and-ng-test (pull_request) Successful in 5m8s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m38s
Build / Build-and-test-development (pull_request) Successful in 31m34s
2026-11-03 13:07:22 +00:00
dc 93f840ce00 refactor(report): compose the PDF footer in the service
Build / Build-and-ng-test (pull_request) Successful in 6m28s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m47s
Build / Build-and-test-development (pull_request) Successful in 32m1s
The footer is a %let and two %if inside the PDF branch of the service, which
already runs inside a macro, so the mpe_pdffootnote wrapper added a file and a
call boundary for a value that is built once, in one place.

Behaviour is unchanged: the same filter wording, session user, timestamp and
the truncation notice when the cap bites, still driven by pdf_maxobs so the
printed row count and the printed cap cannot disagree. A blank obs count is
treated as no truncation, which was the one real edge the macro guarded.

getrawdata.test.sas loses test 5 with it - those assertions existed because
the text was composed by a macro the test could call. With the composition
inline there is no seam to assert on, because a PDF cannot be read back as
text in SAS. The service-level PDF assertion (signature present) still covers
the branch, and the footer is checked from the downloaded file: page 1 of the
artefact reads "No filter | sysadmin | 03NOV2025 13:48 Page 1 of 20" on SAS 9,
and the same request on Viya returns a 20 page report.
2026-11-03 12:50:41 +00:00
dc 30cefcf2dc fix(embed): read a month-name date from VA as a date, not a character value
Build / Build-and-ng-test (pull_request) Successful in 6m33s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m48s
Build / Build-and-test-development (pull_request) Successful in 35m4s
2026-11-03 12:42:33 +00:00
allan 1d9108109d Merge pull request 'feat(admin): browse to the DC location, and check it is writeable' (#440) from feat/dirlist-browse into release/7.22
Sites / deploy (push) Successful in 4m14s
Build / Build-and-ng-test (pull_request) Successful in 6m32s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m29s
Build / Build-and-test-development (pull_request) Successful in 32m15s
Reviewed-on: #440
2026-11-03 12:39:29 +00:00
dc 5eb8492742 fix(deploy): gate the browse actions on the settings file
Build / Build-and-ng-test (pull_request) Successful in 6m32s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m47s
Build / Build-and-test-development (pull_request) Successful in 28m29s
2026-11-03 12:22:16 +00:00
dc f3b14a98d1 chore(deps): @sasjs/core 7.1.0 -> 7.1.3
Build / Build-and-ng-test (pull_request) Successful in 5m3s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m42s
Build / Build-and-test-development (pull_request) Successful in 31m58s
7.1.3 carries sasjs/core#510: mv_getjobresult now returns a binary webout
from a job result instead of writing its own "not registered" text, so the
PDF download can be asserted on Viya as well as on SAS 9.

The only other change in the range is 7.1.2's filter validation message
(sasjs/core#511), which this project does not call.
2026-11-03 12:01:46 +00:00
dc addb4a56ea fix(deploy): read a URL-supplied ACTION before defaulting, and show locked folders
Two real findings from review.

ACTION: the default was assigned with %let before the value was read, so a
URL-supplied ACTION was overwritten and the parameter could never take effect.
The incoming value is now captured first with symget, guarded by symexist so a
missing name does not write to the log, and the default is assigned afterwards.
%global was tried first and is wrong here: declaring a name that already exists
as a local in the including scope does nothing, while the symputx that follows
writes the global - so &action kept resolving to the local and no action ran,
which is what broke the makedata test.

Separately: on the SASjs Server STP endpoint a query parameter is not delivered
to the service at all - a request with ?action=DIRLIST&parent=/tmp reaches the
program with no fromjs and no symexist('action'), and fails on WORK.FROMJS
before ACTION is considered. The configurator's link is therefore legacy on
that target; the adapter path (the Angular deploy screen) is the one that
works, and the capture above is what makes a parameter effective if it does
arrive.

Folder type: the picker compared FILE_OR_FOLDER to 'folder' case sensitively
while mpe_dirlist selects on upcase, so an uppercased value emptied the list.
The compare is now lowercase, and entries mp_dirlist listed but could not open
are surfaced - rendered without the drill-in and Use buttons, with the service
message beside them - instead of being filtered out after the macro's header
promised they would be visible. Two specs cover both, including one that
renders a locked row.
2026-11-03 11:59:24 +00:00
dc 68e66e25a0 test(macros): cover mpe_dirlist and mpe_writecheck directly
The service tests exercise the actions through a child session; these call the
macros in place, so the guard and the answers are covered without a request:

  mpe_dirlist      7 assertions - iftrue=0 does nothing, one level, files
                   excluded, three levels, the depth cap, the maxobs cap with
                   TRUNCATED=Y, and a blank parent returning a root path
  mpe_writecheck   5 assertions - iftrue=0 does nothing, a writable folder, a
                   file, a missing path with a reason, and the probe leaving an
                   existing file that already had its old name alone

They live in sasjs/macros beside the macros they test, which works: a test file
in a declared macro folder compiles to sasjsbuild/tests/macros/ and sasjs test
runs it from there.

Two traps, both hit before this passed:

- these macros write JSON to _webout, so the test has to point _webout at a
  temp file while they run and put it back before the term program writes the
  results - the first attempt redirected without restoring and the runner
  reported "No webout was returned by job";
- descriptions in mp_assert must not contain a comma: an unquoted comma splits
  the parameter list and the assertion fails to compile with "All positional
  parameters must precede keyword parameters", which names the call rather than
  the comma.
2026-11-03 11:59:24 +00:00
dc 74b8ec9c48 refactor(makedata): inline the two single-step helpers, and fix keepme scope
check_outfmt and check_keepme were macros around one data step each, so they are
now those data steps, in the body where they run.  Nothing else about them
changes: the &inds trick replaces the `%if &exist` guard, so the step still
touches work.fromjs only when the table is there.

That inlining exposed a real bug, found by running the KEEPME path rather than
by reading it.  keepme was initialised with %let inside the body, so it was
local to makedata_body, while the request set the global with symputx(...,'G').
self_destruct, called from the body, resolves the nearest scope first - the
local 0 - so a caller asking to keep the program had it deleted anyway.  It is
now declared %global and initialised at file scope, before the body exists.

Verified on the estate: KEEPME=1 leaves the program in place (a follow-up
request still finds it), a normal run deletes it (the next request reports that
the program does not exist), the suite is 3/3 with 16/16 assertions, and the
seed returns STATUS SUCCESS.
2026-11-03 11:59:24 +00:00
dc 201f356dc9 refactor(makedata): three guarded invocations, no dispatch branch
Each action is now its own macro that takes `iftrue=` and returns immediately
when its action was not requested:

  %mpe_dirlist(iftrue= (&action=DIRLIST))
  %mpe_writecheck(iftrue= (&action=WRITECHECK))
  %makedata_body(iftrue= (&action=MAKEDATA))

so the service reads as a list rather than a branch, and there is no open-code
%if left in it.  mpe_adminsetup splits into mpe_dirlist and mpe_writecheck
along the action boundary it already had.

The four helper macros move back to file scope, above the body.  With the
dispatch branch gone nothing is nested any more, so the noNestedMacros
exception comes off both files - the last reason for it was the branch I just
removed.
2026-11-03 11:59:24 +00:00
dc a94375f9a9 refactor(makedata): only the dispatch needs a macro
mp_abort takes its own condition, so the two refusals are two calls in open
code rather than branches inside a wrapper macro:

  %mp_abort(iftrue= (&action=MISSING) ,mac=&_program ,msg=...)
  %mp_abort(iftrue= (&action=BAD)     ,mac=&_program ,msg=...)

check_action was never needed either - it wrapped a single data step whose only
condition is a data-step IF on &exist.

That leaves one branch in the file, the two-way dispatch between the browse
actions and the seeding body, which is what noMacroIfInOpenCode actually
requires to be inside a macro.
2026-11-03 11:59:24 +00:00
dc 205d042bbe docs(deploy): the window has no admin gate, and why
Replaces the risk-register framing with the assessment: there is no admin group
until makedata creates it, the caller is the administrator doing the deployment,
the configurator in the same window is more powerful than these two actions, and
a successful run deletes the service anyway.

Filed as deploy-window-authorization.md so the next review finds the reasoning
instead of re-raising the finding.
2026-11-03 11:59:24 +00:00
dc e84cfaaabd fix(deploy): ACTION cannot seed by default, and the probe stops overwriting
Five defects from review, all mine.

ACTION is no longer defaulted.  `%let action=MAKEDATA;` meant a caller with no
input table at all ran the seeding body - so a URL that never mentioned ACTION
would create the control library.  The default is MISSING, which is refused,
and a URL parameter is whitelisted through the same check with symget() so it
is never resolved as macro text.

The write probe no longer has a predictable name.  fopen with 'O' truncates, so
a probe called .dcprobe_<pid> destroyed any pre-existing file of that name in
the folder being tested.  It now carries the pid and a random suffix, checks
fileexist before use, and gives up rather than reuse a name.

DIRLIST returns folders and locked entries only.  The old filter dropped 'file'
and passed every other value - blanks and unknown types - to the picker as a
folder.  The filter is now an explicit whitelist, and the header describes what
is actually emitted.

A request with no input table is refused, covered by a test: that is the case
the default used to swallow.

ngOnDestroy is gone from the client component - it reset a flag on a component
being torn down.

The deploy-window exposure is recorded in .agents/docs/, with why the admin
group cannot be checked there: %mpeinit() reads &mpeadmins from the control
library that makedata creates, so before it runs there is no group to check -
which is also why the previous dirlist could never serve this screen.
2026-11-03 11:59:24 +00:00
dc 4424171b2f revert: keep the helpers nested inside makedata_body
Reverts the hoist. The helpers read better next to the body that calls them,
and the body runs once per deployment, so the nesting costs nothing at run
time - the only cost is the noNestedMacros exception in the two headers, which
is the narrower trade than four macros defined far from their only caller.
2026-11-03 11:59:24 +00:00
dc 7be063441f refactor(makedata): keep the helper macros at file scope
Wrapping the body in a macro had demoted four existing helpers - check_outfmt,
check_keepme, emit_response and self_destruct - to nested definitions, which
needed a noNestedMacros exception in the file header.  They were at file scope
before, so hoisting them back is undoing that demotion rather than promoting
anything new, and the exception goes away.

No behaviour change: macro variable lookup is dynamic, so the helpers still
resolve the body's variables when the body calls them.
2026-11-03 11:59:24 +00:00
dc 6eeab2ed16 feat(deploy): pick and validate the DC location from the deploy screens
The deploy screens asked for a physical folder the deployment user can write
to, as a single text box, and found out whether it worked only by running the
whole deployment.  They now get a picker and a Validate button, backed by the
makedata actions that already answer those two questions.

A new app-dc-location component renders the path as an editable input - the
picker is a convenience over it, never a replacement, because a folder the
deployment user cannot open is not listed and a customer may want a path that
does not exist yet.  Browse lists one level at a time (ACTION=DIRLIST, DEPTH 1,
MAXOBS 500), with Up, a "Use this folder" for where you are standing, and a per
row Use.  Validate calls ACTION=WRITECHECK and reports the verdict inline -
writeable, or the reason it is not - instead of leaving it to the deployment to
fail.

Both screens use it: the Viya setup screen (which runs makedata through the
adapter) and the Sasjs configurator (which opens makedata as a URL).  The
mkdata calls now send ACTION=MAKEDATA, since the service requires it.

Details worth knowing:

- every value read from the service is trimmed: SAS has no zero length
  character value, so a blank field arrives as a single space and reads as a
  value to any `||` or `@if`.
- the listing's level 0 row is the folder being listed, not a folder to drill
  into - except when the parent is blank, where the level 0 rows are the
  platform roots.
- failures are shown inline rather than as abort modals: the two requests pass
  suppressErrorAbortModal/suppressSuccessAbortModal, because a refused path is
  an expected answer here, not a crash.
- parentOf handles a drive root (C:\) and a unix root without inventing an
  empty path.

Frontend tests: 14 new specs (38 in the deploy folder, all passing), including
a template spec for the browser panel and one for the verdict line, both
mutation checked.
2026-11-03 11:59:24 +00:00
dc 442c595c2b feat(admin): find and test a writeable location during deploy
The deploy screen needs to find and test a writeable folder before the control
library exists, so these questions cannot be answered by an admin-gated
service: %mpeinit() reads &DC_LIBREF..mpe_config, which makedata itself
creates.  That is why the previous dirlist could not run in the window it
existed for.

They are answered by the service that already owns that window instead.
makedata now requires an ACTION column:

  MAKEDATA     create the configuration tables (what it always did)
  DIRLIST      the folders below PARENT, DEPTH levels deep, MAXOBS capped
  WRITECHECK   does PATH exist, is it a folder, can we write in it

so a deployment has one service to run and the same one to remove - makedata
already self destructs after a successful run, and now nothing is left behind
that can enumerate the filesystem.

ACTION is not defaulted: a caller that does not say what it wants is refused
rather than having a control library seeded for it.  The payloads under
deploy/ and the configurator's link are updated in the same change, so the
installer and the seeding scripts keep working.

The work is in mpe_browse, called from both server and viya makedata:
mp_dirlist does the listing (a blank parent is the platform roots), and
mp_validatecol(ISPATH) shape checks whatever is submitted.  Folders are
returned a level at a time so a parent with tens of thousands of subfolders is
never transferred in full, and the finite depth is what makes a symlink loop
harmless.

The body of makedata is now a macro of its own, because the branch cannot be
open code - noMacroIfInOpenCode keeps the file loadable on every 9.4
maintenance level.  Its four helper macros are unchanged.

Input is the adapter table as before, never macro text: a value is refused if
it carries a macro trigger, a quote or a statement separator, and only the
three known ACTION values are ever put into a macro variable.

sas9 is not included: its makedata is the metadata STP driven by the
configurator page through URL parameters, not by the adapter, and the sas9
configurator takes a typed path rather than a picker.  That is a change of
input mechanism, not of this feature, so it is left for its own ticket.
2026-11-03 11:59:24 +00:00
allan 8ed0febeaa Merge pull request 'fix(deps): bump @sasjs/core to 7.1.3 so a filter abort names the clause' (#445) from fix/core-7.1.3 into release/7.22
Sites / deploy (push) Successful in 4m10s
Build / Build-and-ng-test (pull_request) Successful in 5m9s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m6s
Build / Build-and-test-development (pull_request) Successful in 28m46s
Reviewed-on: #445
2026-11-03 11:57:33 +00:00
dc c7c181518c fix(deps): bump @sasjs/core to 7.1.3 so a filter abort names the clause
Build / Build-and-ng-test (pull_request) Successful in 5m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m33s
Build / Build-and-test-development (pull_request) Successful in 34m58s
2026-11-03 11:26:15 +00:00
dc 0f729d7101 fix(report): treat a blank PDF footer parameter as its default
Build / Build-and-ng-test (pull_request) Successful in 5m5s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m47s
Build / Build-and-test-development (pull_request) Successful in 28m54s
A parameter named without a value arrives blank, and a blank reached the
footer's numeric comparisons as nothing to evaluate:

    %if &filter eq 0                     /* blank filter */
    %if %eval(&nobs) gt %eval(&maxobs)   /* blank obs count */

That raised %EVAL arithmetic errors, stopped mpe_pdffootnote and failed the
whole download instead of producing a report.  Both are now normalised to
their defaults first, maxobs included.

The footer's user= is taken from mf_getuser() at the top of the service - it
is deliberately not an input, so a caller cannot assert who ran the report -
and it is passed through %superq because a display name may hold a comma,
which the macro call would otherwise read as a parameter separator.

Test 5 gains a case for the blank call.
2026-11-03 08:47:41 +00:00
allan d0bd3f06f9 Merge pull request 'fix: support long active directory names in group member lookup (#441)' (#443) from fix/441-long-group-names into release/7.22
Sites / deploy (push) Successful in 3m54s
Build / Build-and-ng-test (pull_request) Successful in 5m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m3s
Build / Build-and-test-development (pull_request) Successful in 28m32s
Reviewed-on: #443
2026-11-03 07:39:20 +00:00
dc 196d6c931d fix(getgroupmembers): widen membername to $256 for long names
Build / Build-and-ng-test (pull_request) Successful in 5m3s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m40s
Build / Build-and-test-development (pull_request) Successful in 28m43s
mv_getgroupmembers declared the member name at $43, and a longer name was
truncated - or, on Viya, stopped the step with "Multiple lengths were
specified".

membername is declared at $256 in the same shape the macro now returns it: one
width for every group, so the tables the caller combines are never of differing
lengths, and a long active directory name is kept whole.
2026-11-03 01:00:39 +00:00
dc 67ca129c4d feat(viewer): download a server-side PDF report
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m0s
Build / Build-and-test-development (pull_request) Successful in 28m53s
Adds PDF to getrawdata: the staged, filtered dataset is rendered by
mp_ds2pdf and streamed with contenttype=PDF, so the Download modal gains
a typeset report - landscape, SAS formats and labels applied, headers
repeated on every page, 'Page n of m', and the filter, user and timestamp
in the footer.

The report is capped at 2000 observations, about 75 landscape pages, and
the footer states the truncation when it bites.  The cap is one macro
variable driving both maxobs= and the footer text, so the two cannot
disagree, and the footer itself is composed by mpe_pdffootnote so the
truncation wording can be asserted without reading text out of a PDF.

@sasjs/core goes 7.0.3 -> 7.1.0 for the macro; the bump also brings
mp_validatecol ISPATH, two mp_dirlist guards and an mx_execute test.

On Viya the report renders but the download does not arrive: mp_streamfile
fails ahead of the PDF branch inside mp_binarycopy (RECFM=N with lrecl=1
needs 256), which is upstream of this change and left alone here.
2026-11-02 23:08:06 +00:00
allan 45db0785fb Merge pull request 'fix(multi-dataset): a sheet that failed to extract is not a clean finish' (#437) from fix/multi-dataset-extraction-failures into release/7.22
Sites / deploy (push) Successful in 4m13s
Build / Build-and-ng-test (pull_request) Successful in 5m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m29s
Build / Build-and-test-development (pull_request) Successful in 31m10s
Reviewed-on: #437
2026-11-02 15:57:23 +00:00
allan a13d509bc9 Merge pull request 'fix(client): drop the synthetic window resize dispatches' (#439) from fix/434-resize-dispatches into release/7.22
Sites / deploy (push) Successful in 4m11s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m37s
Build / Build-and-ng-test (pull_request) Successful in 6m31s
Build / Build-and-test-development (pull_request) Successful in 35m11s
Reviewed-on: #439
2026-11-02 15:28:10 +00:00
dc 312964864e fix(multi-dataset): a sheet that failed to extract is not a clean finish
Build / Build-and-ng-test (pull_request) Successful in 5m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m54s
Build / Build-and-test-development (pull_request) Successful in 35m45s
The stage that reports what the file is doing is meant to belong to the whole file, and it finished at "Extracting the data finished" whatever happened. A sheet whose parse rejected had already blanked its datasource and dropped itself from the submission in the per-sheet catch, so the user was told the file had extracted while a table was silently missing from the submission.

`allSettled` was already the right primitive, but its results said nothing: the catch swallowed the error, so every promise fulfilled. The catch now names the sheet on the component, the stage ends at a new `extractionFailed` state when anything was left out, and the pane lists what was left out and why.

The two reasons a sheet contributes nothing are kept apart, because they are not the same thing to the reader: a sheet the sheet utility could not read is a "could not extract" problem in red, while a sheet it parsed and found no rows in - which arrives as the rejection "No relevant data found" - is reported as "no rows found" without calling the file corrupt. Both leave the sheet out of the submission, and either one stops the stage reporting a clean finish.

The row derivation sits inside the try with the parse. Outside it, a throw on a row shape the sheet utility did not expect escaped as a rejection that `allSettled` swallows - the dataset stayed in the submission with `parsingTable` true and the stage reported a clean finish over a sheet that had been dropped.

The messages live in the no-table-selected pane, which is where the user is once extraction has finished; the file pane they were first put in unmounts as soon as the datasets are fetched, so they would never have been read there. The stage line keeps one id per state rather than two of the same: `file-loading-stage` in the file pane while the datasets are fetched, `file-extraction-stage` in the overlay while the sheets are extracted, and the Cypress spec asks for both.

The Cypress spec asks for both stage ids, because the element that carries the stage changes state, and asserts that a sheet left out of the submission is named in the pane - under whichever reason the sheet utility reported, since only the utility decides which of the two it is.

The spec builds the component through the test injector rather than with seven positional arguments, so a constructor that gains or reorders a dependency fails there (`NG0201: No provider found for ElementRef`) instead of binding a stub to the wrong collaborator and passing.
2026-11-02 14:49:52 +00:00
dc 85cb01b8c3 docs(client): the deferred measure is about Angular's binding, not an animation
Lighthouse Checks / lighthouse (pull_request) Successful in 21m10s
Build / Build-and-ng-test (pull_request) Successful in 6m41s
Build / Build-and-test-development (pull_request) Successful in 31m38s
The comment claimed the sidebar animates open/closed. It does not: the
collapse is a plain margin-left on clr-vertical-nav.closed (styles.scss)
and margin is not in any transition there - Clarity's width transition on
.clr-vertical-nav does not apply, because the width never changes. What
does need the deferral is that Angular applies the class binding after the
event handler returns, so a synchronous measurement reads the pre-toggle
layout.

The spec now says what it covers: the scheduling contract, not whether a
measurement lands before or after a transition.
2026-11-02 13:34:27 +00:00
dc 590b841f24 fix(client): cancel a queued dropdown reposition on destroy
Build / Build-and-ng-test (pull_request) Successful in 6m28s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m36s
Build / Build-and-test-development (pull_request) Successful in 35m27s
Unsubscribing stops future layout notifications but leaves a timer that
one already scheduled, so a sidebar toggle immediately followed by destroy
still ran calculateInputPosition() against a torn-down view. The timer id
is now held and cleared in ngOnDestroy, and the spec covers that window -
it fails if the clear is removed.

Also drops the EventService.dispatchEvent stub from the viewboxes spec,
which advertised a method this branch deletes.
2026-11-02 12:30:07 +00:00
allan 74e0eb0524 Merge pull request 'fix(client): one grid-scoped ARIA pass, driven by the render hooks instead of a timer' (#436) from fix/435-grid-aria-scope into release/7.22
Sites / deploy (push) Successful in 4m45s
Build / Build-and-ng-test (pull_request) Successful in 6m34s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m11s
Build / Build-and-test-development (pull_request) Successful in 31m18s
Reviewed-on: #436
2026-11-02 10:56:51 +00:00
dc 0587f71273 test(client): drive the autocomplete reposition on the fake clock
Build / Build-and-ng-test (pull_request) Successful in 6m31s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m12s
Build / Build-and-test-development (pull_request) Successful in 35m26s
The specs gated their assertions on a real setTimeout(..., 10) plus done,
which is wall-clock timing: a loaded runner can assert before the
component's own timer fires, and the negative case would then pass
vacuously. fakeAsync + tick order the assertion on the scheduler instead.

Also drops the stale half of the viewbox resize comment - the shared tail
no longer notifies listeners, it just persists and re-measures.
2026-11-02 10:26:35 +00:00
dc e40525e278 refactor(client): remove EventService.dispatchEvent, now that nothing calls it
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
The synthetic resize dispatch was its only caller. Nothing else used it,
and a generic window-event dispatcher on a service invites exactly the
coupling this change removes.

The sidebar spec now asserts the behaviour directly - no resize event
reaches window listeners on a toggle - and fails when a dispatch is put
back, rather than asserting on the removed API.
2026-11-02 09:51:20 +00:00
dc 2535baa5f7 fix(client): drop the synthetic window resize dispatches
Build / Build-and-ng-test (pull_request) Canceled after 5m15s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 4m8s
A sidebar toggle and the viewbox data/resize paths dispatched a synthetic
window resize so other components would notice the layout moved. Two
thirds of that was unnecessary: Handsontable 18 attaches its own
ResizeObserver to the grid's parent, so a container change re-measures the
grid with no event at all (measured in Chromium: view.getLastSize().width
tracks a container resize 600 -> 380 -> 700 without any dispatch).

The one real consumer was the autocomplete dropdown, which follows its
input under the new layout. It now subscribes to
EventService.onSidebarToggle and re-runs calculateInputPosition(), and
unsubscribes on destroy; the window:resize listener stays for real
viewport changes.

Removed: sidebar.component.ts:53, viewboxes.component.ts:281, :705 and
:1571. The viewbox resize tail already re-measures each instance through
refreshTableAfterResize, and a data load renders the grid itself.
2026-11-02 09:48:48 +00:00
allan f82f21dc3b Merge pull request 'docs(install): drop the root install script this repo no longer has' (#438) from docs/install-section-current into release/7.22
Sites / deploy (push) Successful in 4m46s
Build / Build-and-ng-test (pull_request) Successful in 6m32s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m38s
Build / Build-and-test-development (pull_request) Successful in 31m15s
Reviewed-on: #438
2026-11-02 09:39:17 +00:00
dc cb30469e79 fix(client): keep the ARIA pass on the viewbox and multi-dataset grids too
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m28s
Build / Build-and-test-development (pull_request) Successful in 31m24s
Scoping the pass to one grid left the page's other grids uncorrected: the
viewboxes each render their own Handsontable (shared/viewboxes), and the
multi-dataset preview renders two. Nothing called the pass for them, so
they kept role=treegrid and aria-rowcount=-1 - the failures this change
exists to remove. The old document-wide sweep happened to cover them.

observeGridAria(hot) runs the pass and keeps it running from the grid's
own afterRender, for grids whose component has no render hook of its own.
It defers by one microtask: Handsontable writes the row/column counts
after the render that follows a data load, so a pass inside the hook
still sees the previous frame.

Wired into getViewboxHotInstance (every viewbox that hands out an
instance) and into both multi-dataset instances.
2026-11-02 08:41:32 +00:00
allan 1b06857213 Merge pull request 'fix(editor): a container resize must not clear an active sort' (#433) from fix/editor-resize-keeps-sort into release/7.22
Sites / deploy (push) Successful in 4m7s
Build / Build-and-ng-test (pull_request) Successful in 5m39s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m25s
Build / Build-and-test-development (pull_request) Successful in 31m18s
Reviewed-on: #433
2026-11-02 08:37:30 +00:00
dc e9a2abd2e2 docs(install): drop the root install script this repo no longer has
Build / Build-and-ng-test (pull_request) Successful in 6m31s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m4s
Build / Build-and-test-development (pull_request) Successful in 35m52s
The root `package.json` carries no install or postinstall script, so the
guidance to rely on "the composite install script ... which installs both
subprojects" sends the reader after something that does not exist, and
`client/.npmrc` told them the same thing in a comment.

The install section also contradicted itself: it opened by telling the reader
to write the SheetJS registry token into `client/.npmrc`, and closed by
explaining that `client/.npmrc` is a tracked policy file that must not carry a
token (the pre-commit hook rejects one). The token goes in the user config,
which is what the first block now says.

The install sequence itself is unchanged - root `npm ci`, then `client` and
`sas` explicitly - because that is what the repo actually needs.
2026-11-02 08:33:24 +00:00
dc 46c6aec37a fix(client): restrict the table pass to Handsontable's own tables, add the viewer fallback
Build / Build-and-ng-test (pull_request) Successful in 6m27s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m58s
Build / Build-and-test-development (pull_request) Successful in 35m24s
Review follow-up. The table pass matched every table inside the grid root,
so a table a cell renderer owns picked up role=table and scope=col - which
contradicted the reason the wrapper loop avoids [role="treegrid"]. It now
matches table.htCore only (every HOT table carries that class, one per
view), and header cells are read with :scope > thead > tr > th so a
renderer's nested table is not touched. The separate aria-rowcount=-1 pass
is gone: the wrapper strip already removes that attribute from the grid's
own element, and matching it anywhere else was the same overreach.

The viewer also gets the editor's fallback chain, so a pass that runs
before hotInstance is assigned still finds the grid through
hotTableComponent.
2026-11-02 02:47:48 +00:00
dc 32d3d49b59 fix(client): stop synthesising aria-labels; name the filter modal's submit in the test
Build / Build-and-ng-test (pull_request) Successful in 6m31s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m12s
Build / Build-and-test-development (pull_request) Successful in 35m23s
Scoping the ARIA pass to the grid exposed a dependency the document-wide
sweep had been hiding: the sweep used to write aria-label from an
element's text, so cypress/e2e/full-table-search.cy.ts could select the
filter modal's submit button as aria-label="Ok" - a label only the sweep
ever wrote. Once the sweep stopped reaching the modal, that selector
matched nothing and the dev job failed on it.

An element's text already is its accessible name, so the synthesis is
gone from the pass, and the spec now selects the button by its text the
way a user would.
2026-11-02 02:02:31 +00:00
dc ba4ddb06d3 test(editor): cover the resize observer wiring, not just the helper
Build / Build-and-ng-test (pull_request) Successful in 6m28s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m1s
Build / Build-and-test-development (pull_request) Successful in 31m39s
The helper-only spec passes even if the observer goes back to calling
updateSettings() - which is the regression the helper exists to prevent.
Move the observer into refreshGridAfterContainerResize.ts so a spec can
drive a real container resize through it, and assert that the resize
reaches refreshDimensions() without any settings call. Verified by
re-injecting the old wiring: the spec fails with 'Expected spy
updateSettings not to have been called', and passes once it is removed.

Also corrects the helper's claim about what refreshDimensions() measures:
it compares rootElement.getBoundingClientRect() with the last measured
size, and both grids take their height from a viewport-derived calc(), so
a resize that matters is a width or viewport change.
2026-11-01 23:27:41 +00:00
dc f282d1a44b fix(client): strip only this grid's own wrapper, and pin the re-strip after a data load
Lighthouse Checks / lighthouse (pull_request) Successful in 19m24s
Build / Build-and-ng-test (pull_request) Successful in 5m44s
Build / Build-and-test-development (pull_request) Failing after 32m17s
The sweep matched [role="treegrid"] inside the root, which would also
catch a treegrid a cell renderer put in a cell. The grid's own wrapper is
the root itself, so target that plus the overlay wrappers Handsontable
renders, and leave nested widgets alone.

Adds a live-grid spec: Handsontable writes role=treegrid/aria-rowcount
again on loadData, so the pass has to run after renders, not once.
2026-11-01 23:24:52 +00:00
dc bf1e7d6b15 fix(client): scope the ARIA pass to each grid and drive it from the render hooks
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
The editor and viewer each ran fixAriaAccessibility() over the whole
document on every render, on a 3-second interval and from a
MutationObserver on document.body, and each call scheduled four further
copies of itself. The sweep removes the role/aria attributes that its own
observer watches, so it re-armed itself.

Measured with axe-core against Handsontable 18: the default markup fails
aria-required-children (critical) because the treegrid wrapper has no row
children, and disabling the tags with ariaTags:false only trades that for
empty-table-header while leaving the placeholder semantics missing. The
existing attribute sweep is therefore the right end state - it just has to
run against one grid's root, not the document.

Both components now call one shared helper with their own
hotInstance.rootElement, and the interval, the body observer and the four
deferred re-runs are gone; the afterRender/afterChange hooks and the init
calls are the only triggers.
2026-11-01 22:41:23 +00:00
dc 5bd7b5d724 fix(editor): a container resize must not clear an active sort
Build / Build-and-ng-test (pull_request) Successful in 6m37s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 5m53s
The grid's container ResizeObserver (added in c7ba025 to replace the
afterRender window-resize dispatch of #246) refreshed by calling
updateSettingsSortSafe({ height }), and that wrapper's whole reason for
existing is that any updateSettings() while a multi-column sort is active
corrupts formula references into #REF!. It works around that by clearing the
sort, applying the settings and re-applying the sort - so every sidebar
toggle, modal open/close or window resize re-sorted the user's data, and an
open cell editor (anchored to visual coordinates) could end up writing to a
different row than the one being typed into.

refreshGridAfterContainerResize() replaces it with hot.refreshDimensions():
it compares the element's box with the last known size, re-measures the
overlays and redraws only when the size actually moved, touching no settings.
The decision lives in its own module so the spec can pin it - a real
Handsontable instance with an active sort and a formula column asserts that
the resize path calls refreshDimensions() and does not call updateSettings(),
clearSort() or sort(), that the sort config is unchanged, and that no cell
degrades to #REF!.

This is the narrow, independent piece of #246; the wider removal of the
remaining synthetic window resize dispatches (sidebar, viewboxes) is analysed
on the issue and is not part of this change.
2026-11-01 22:25:22 +00:00
allan 663088ad16 Merge pull request 'feat(viewer): export the grid as PDF from the context menu' (#430) from feat/238-pdf-export into release/7.22
Sites / deploy (push) Successful in 4m8s
Build / Build-and-ng-test (pull_request) Successful in 5m54s
Lighthouse Checks / lighthouse (pull_request) Successful in 19m8s
Build / Build-and-test-development (pull_request) Successful in 35m52s
Reviewed-on: #430
2026-11-01 22:19:24 +00:00
allan 0d8fa59e81 Merge pull request 'feat(logs): read the logs modal with the shared log parser' (#431) from feat/sas-logs-modal into release/7.22
Sites / deploy (push) Canceled after 0s
Build / Build-and-ng-test (pull_request) Successful in 5m39s
Lighthouse Checks / lighthouse (pull_request) Failing after 21m22s
Build / Build-and-test-development (pull_request) Successful in 31m37s
Reviewed-on: #431
2026-11-01 22:18:15 +00:00