Compare commits

..
Author SHA1 Message Date
dc e4ec4ec629 fix(editor): assign columnHeader before the grid renders
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m53s
Build / Build-and-test-development (pull_request) Failing after 33m28s
The derived-block marker (dc-derived-first) landed on the wrong column, so the
computed columns did not read as a group and derived-variables.cy.ts failed on
the header assertion.

initSetup reversed the order it needs: hot.updateSettings() installs
afterGetColHeader / afterGetCellMeta at line ~4167 and renders, but
this.columnHeader was only assigned at line ~4506 - after that render. The
hooks therefore resolved visual column indexes against the previous table's
list, so 'is this the first derived column' compared an index in one array
against an index in another. The header name check still matched, which is why
dc-derived-header appeared while its sibling class did not.

columnHeader is now taken from headerColumns once, where that list is final
(the Delete? rename and the EDIT_STATUS append have been applied), and the
later divergent copy is gone - it never carried EDIT_STATUS, so it could not
have matched the grid anyway. Comments at both sites record why the order
matters.
2026-10-27 21:42:53 +00:00
227 changed files with 1780 additions and 8661 deletions

No files matched your search

+1 -2
View File
@@ -1,11 +1,10 @@
# Testing
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory. For a deploy-and-run on the shared state see the `dc-estate-testing` skill (`.agents/skills/dc-estate-testing/SKILL.md`), which covers the credentials, the `makedata` seed the suite needs and the estate's group name.
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory.
## Commands
- Full deploy + test cycle: `npm run 4gl` (compiles, deploys to the 4gl target, runs makedata) then `sasjs test -t 4gl`.
- The shared estate under its `server` target: `npm run server` (compile, deploy, seed) then `sasjs test -t server`.
- Run tests only (no rebuild/redeploy): `sasjs test -t 4gl`.
- Run a subset of tests: `sasjs test -t 4gl SOMESTRING` — the positional argument filters to matching tests (e.g. `sasjs test -t 4gl stagedata.test.3`).
-79
View File
@@ -1,79 +0,0 @@
---
name: dc-estate-testing
description: >
Use when deploying Data Controller to a shared SASjs Server estate (sas.4gl.io) or running the SAS
test suite against it. Covers the target and credential setup, the makedata seed the suite needs on
a fresh appLoc and the group it must name, how to read the results, and how to deploy the frontend
so the streamed app is the build you just made.
---
# Deploying and testing on the shared SASjs Server estate
The whole cycle is two npm scripts plus `sasjs test`. Everything below explains what those scripts do and the steps that go wrong.
cd sas && npm ci # macro dependencies
cd ../client && npm ci && npm run build # frontend only - see "Deploying the frontend"
cd ../sas && npm run server # cpfavicon + sasjs cbd -t server + makedata
npx sasjs test -t server
`npm run server` is `sasjs cbd -t server && npm run serverdata`, and the seed in the second half is not optional. `sasjs cbd` on its own deploys the services and the test programs but leaves the appLoc with no Data Controller configuration, and `testsetup` reads `<appLoc>/services/public/settings.sas` through the drive API. `makedata` is what creates that file, so without the seed `testsetup` gets a 404, aborts, and 59 of the 64 test programs fail to complete on setup alone - which reads like a broken deployment rather than a missing seed.
## The targets
`server` is the shared estate: `serverType: SASJS` on https://sas.4gl.io, `appLoc` `/Public/app/dc`, a real SAS 9 binary behind SASjs Server (`GET /SASjsApi/info` reports `mode: server`, `runTimes: ["sas"]`, `localLoginEnabled: true`). `4gl` is the same estate with a slightly different config, hence the older `npm run 4gl` / `4glmakedata` pair.
A run against `server` exercises the SASjs Server branch of every macro and the SAS 9 backend, and nothing else: no Viya branch, no SAS 9 STP branch. Say that when reporting, rather than implying the matrix is covered.
To deploy your own branch without replacing the app everyone else uses, add a target that copies `server` and changes `appLoc` and `streamServiceName` (for example `/Public/app/<you>/dc`), keep it out of the commit, and pass it with `-t`. `sasjs cbd` overwrites the whole appLoc it is pointed at.
## Credentials
The CLI logs in with a registered client, not a username and password: `.env.<target>` (gitignored, mode 600) needs `CLIENT`, `ACCESS_TOKEN` and `REFRESH_TOKEN`. With `CLIENT` alone it prints a `/SASjsLogon?client_id=...` URL and waits for an authorization code, which cannot be answered headless.
Mint the pair from a local login, with the password read from a file rather than argv:
1. `GET /` with a cookie jar - it sets `XSRF-TOKEN`, which every POST must echo in an `X-XSRF-TOKEN` header.
2. `POST /SASLogon/login` with `{"username","password"}`.
3. `GET /SASjsApi/client` on the same session - the registered client ids.
4. `POST /SASLogon/authorize` with `{"clientId"}` -> `{"code"}`.
5. `POST /SASjsApi/auth/token` with `{"clientId","code"}` -> `accessToken` / `refreshToken`.
An unmatched `/SASjsApi/...` path answers `200` with the Swagger page, so check the content type; HTML means the route does not exist. Delete the token file when the run is over.
## The makedata seed
`npm run serverdata` sends `deploy/makeDataServer.json`: one `fromjs` row with `ADMIN`, `DCPATH` and `OUTFMT`. Two things about it are worth knowing before you trust its result.
- **`ADMIN` must name a group the calling account is actually in.** The shipped value is `all-users`, the SASjs Server group that every logged-in user is a member of, which is what the shared estate needs. A name the account is not in - a Viya-style group such as `AllUsers`, or one estate's own group carried into another - still answers `SUCCESS` from the seed, and then every later `getdata`/`stagedata` call aborts with `<user> is not authorised to edit <lib>.<ds> in the <lib>.MPE_SECURITY table`. Check what the account belongs to with a call to `services/public/getgroups` before changing the value.
- **Each run creates a new library.** The response carries `STATUS`, `DCPATH` and `LIBREF` (for example `DC621835`), and the LIBREF is clock-derived, so a libref from an earlier run answers `dataset <lib>.<table> does not exist` - take the current one from the response.
- **`KEEPME` controls whether the seed can be repeated.** Without it `makedata` deletes itself and the configurator after a successful run (deliberately, so it cannot be executed twice over real data), and a second seed fails with `The Program at (<appLoc>/services/admin/makedata) does not exist` until you run `sasjs cbd` again. A harness that reseeds between runs sets `KEEPME` in the input row.
- **Seed once per deploy, not before every test run.** The suite builds its own `dctest` library from the seeded control tables; reseeding in between is wasted work, and there is no need to run `makedata` and `sasjs test` in the opposite order.
## Reading the results
Results land in `sas/sasjsresults/`: `testResults.json` (the counts worth quoting: `tests_with_results`, `tests_that_pass`, `failed_to_complete`, `completed_with_failures`), `testResults.csv` (one row per assertion, lower-case headers `test_target`, `test_loc`, `sasjs_test_id`, `test_suite_result`, `test_description`, `test_url`), and one SAS log per program under `logs/`. The CLI's closing lines are not the source of truth when they disagree with the JSON.
A current green run is 64 of 64 test programs with 241 assertions passing and nothing failing to complete.
`ERROR` lines in the per-program logs are expected and are not a failed run: the specs that assert a refusal get `ERROR: Execution canceled by an %ABORT CANCEL statement`, and the ones that feed bad payloads get the rejection message they are asserting on (`Invalid libds:...`, `Invalid libref:...`). Judge the run by the JSON counts, then by whether the errors you find are the ones the tests asked for.
## Deploying the frontend
The streamed web app is a separate artefact and `sasjs cbd` only streams it if `client/dist` exists:
1. `client/libraries/sheet-crypto.tgz.gpg` is an encrypted dependency (`file:libraries/sheet-crypto.tgz` in `client/package.json`) that is not on the public registry. Decrypt it with the passphrase the maintainer holds: `printf '%s' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 --output ./libraries/sheet-crypto.tgz --decrypt ./libraries/sheet-crypto.tgz.gpg`.
2. `client/.npmrc` is gitignored and needs `legacy-peer-deps=true` (the committed lock is written with it) plus the `@sheet` registry lines.
3. `cd client && npm ci && npm run build`.
4. The build carries the repo default appLoc, so point it at the deployment before deploying: rewrite `apploc="/Public/app/dc"` in `dist/index.html` to the target appLoc.
5. `cd ../sas && npx sasjs cbd -t <target>` - with `client/dist` present the log says `Copying web app files` and `Web app is available at <url>`.
Without step 1 the deploy prints `ERROR An error has occurred when compiling web app services ... webSourcePath: .../client/dist doesn't exist` and then deploys the services anyway, so the deploy looks successful while the app streams nothing. The AppStream URL is session-protected: an anonymous `curl` gets a `401`, so verify it with a logged-in cookie jar and fetch the `main.<hash>.js` the page names.
Never commit `client/.npmrc` or the decrypted tarball; both are ignored, and `git status` staying clean is the check.
## Reading a failed run
- `ERROR An error occurred while executing the request. Job location: <appLoc>/tests/...` means the response was not JSON, so the program broke before it could write results. Open `sasjsresults/logs/<suite>.log` and read the first `ERROR`; a single compile break cascades into hundreds of `ERROR 180-322: Statement is not valid` lines and the tail is noise.
- `WARNING: Apparent symbolic reference TESTLOC not resolved` in `testsetup.log`, next to a `404 Not Found` for `services/public/settings.sas`, is the missing seed described above, not a macro problem.
- `sasjs test` runs the DEPLOYED program, so after editing a service, hook or test, run `sasjs cbd` again before testing or you will debug code that is no longer in the tree.
- A deployment can succeed and still be missing its web app, its data or its configuration - check the thing you changed rather than the exit code.
-10
View File
@@ -20,16 +20,6 @@ if ! "$gitleaks_bin" protect --staged --redact; then
exit 1
fi
# Every tracked .npmrc is a policy file - root, sas/ and client/ - so a registry
# credential pasted into any of them is one `git add -A` from being committed.
# Each staged one is checked, not just the client.
for npmrc in $(git diff --cached --name-only --diff-filter=ACMR | grep -E '(^|/)\.npmrc$'); do
if git show ":$npmrc" | grep -Eq '(_authToken|_auth[^a-z]|_password|:_auth)'; then
echo "ERROR: $npmrc holds a registry credential - keep it in ~/.npmrc instead"
exit 1
fi
done
## Avoid large commits
# https://www.backblaze.com/blog/how-many-bytes-are-in-a-megabyte-really/
size_limit=$((2 * 1024 * 1024)) # 2MB
-99
View File
@@ -1,99 +0,0 @@
#!/usr/bin/env bash
# Fail a client job before it starts when the runner cannot hold it.
#
# Every client job here compiles the frontend from cold: `ng build
# --configuration production` peaks at 3.8 GB resident on a 4 vCPU box
# (measured with no .angular/cache present, which is what a fresh runner
# has), and the Cypress and Lighthouse jobs add a dev server and a browser on
# top of that. On a 4 GB runner the kernel OOM killer takes the build part way
# through, which surfaces as `exitcode '137'` at `ng build`, or as Karma
# disconnecting mid-suite ("no message in 30000 ms"). Both read as a flaky
# test rather than as an undersized box, and both cost a whole CI slot.
#
# 8 GB is the minimum supported runner size: it leaves roughly 2x headroom
# over that peak. This check runs before the installs, so an undersized runner
# fails in seconds with the numbers in the log.
#
# The floor is 7 GB rather than 8192 MB because an 8 GB box does not report 8
# GB: the fleet's report 7751 MB total, the rest going to firmware and the
# kernel. A 4 GB box reports under 4000 MB, so 7 GB separates the two sizes
# with room for a box that reserves a little more than ours do.
#
# What this does not catch is oversubscription: two client jobs landing on one
# box. A box that passes the floor can still OOM a build when another job is
# resident, and MemAvailable at start is the only sign of it - that is reported
# as a warning, not a failure, because a busy box is transient where a small
# box is not. Keep one client job per runner.
#
# Usage: check-runner-memory.sh [min_total_mb]
# or: MIN_TOTAL_MB=4096 check-runner-memory.sh
set -euo pipefail
MIN_TOTAL_MB="${1:-${MIN_TOTAL_MB:-7000}}"
# A typo here (8GB, a stray flag, an empty variable) would otherwise reach the
# integer test under `set -e` and abort with "integer expression expected".
case "$MIN_TOTAL_MB" in
''|*[!0-9]*)
echo "usage: check-runner-memory.sh [min_total_mb] - got '$MIN_TOTAL_MB'" >&2
exit 2
;;
esac
meminfo_field() {
awk -v key="$1:" '$1 == key { print $2 }' /proc/meminfo
}
total_kb=$(meminfo_field MemTotal || true)
avail_kb=$(meminfo_field MemAvailable || true)
cpus=$(nproc 2>/dev/null || echo '?')
if [ -z "$total_kb" ]; then
echo "runner memory: /proc/meminfo has no MemTotal - cannot measure, continuing"
exit 0
fi
total_mb=$(( total_kb / 1024 ))
avail_mb=$(( ${avail_kb:-0} / 1024 ))
# Older kernels have no MemAvailable; report that rather than 0, which would
# read as a permanently exhausted runner.
if [ -n "$avail_kb" ]; then
avail_display="${avail_mb} MB available"
else
avail_display="available unknown"
fi
# A container usually sees the host's RAM, but may carry its own lower ceiling
# (cgroup v2 memory.max, v1 memory.limit_in_bytes). That ceiling is what the
# build is actually killed against, so it counts as the runner's size.
limit_mb=""
for f in /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory/memory.limit_in_bytes; do
[ -r "$f" ] || continue
v=$(cat "$f")
case "$v" in
''|max|*[!0-9]*) continue ;;
esac
# An unlimited v1 cgroup reports a sentinel near 2^63 bytes.
[ "$v" -lt 100000000000 ] || continue
candidate=$(( v / 1024 / 1024 ))
if [ -z "$limit_mb" ] || [ "$candidate" -lt "$limit_mb" ]; then
limit_mb="$candidate"
fi
done
echo "runner memory: ${total_mb} MB total, ${avail_display}, ${cpus} vCPU${limit_mb:+, ${limit_mb} MB cgroup limit}"
effective_mb=$total_mb
if [ -n "$limit_mb" ] && [ "$limit_mb" -lt "$effective_mb" ]; then
effective_mb=$limit_mb
fi
if [ "$effective_mb" -lt "$MIN_TOTAL_MB" ]; then
echo "::error::runner has ${effective_mb} MB of memory, a client job needs at least ${MIN_TOTAL_MB} MB (8 GB is the supported minimum; a cold production build alone peaks at about 3.8 GB, and the Cypress and Lighthouse jobs add a dev server and a browser to that)."
exit 1
fi
# Not a gate - a second job landing on the same box shows up here first.
if [ -n "$avail_kb" ] && [ "$avail_mb" -lt 5000 ]; then
echo "::warning::only ${avail_mb} MB is available on this runner; another job may be running on the same box"
fi
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bash
# The Cypress specs CI runs, and the order they run in.
#
# ORDER MATTERS. These specs share one mock estate that is seeded once per job,
# so several of them depend on the state they find:
#
# - csv-limited asserts the free-tier "Locked Feature" modal, which an earlier
# spec can remove by planting a valid licence. It has always run first.
# - hook-programs asserts that an edit raises an approval row against its
# target table; it runs early, before other specs have added rows to the
# approval tables.
# - metanav only reads the metadata tree, and applies a licence key so it can
# run on its own; it goes last so that key cannot change another spec.
#
# Specs added later are appended rather than inserted, so the sequence that was
# known green keeps its shape.
#
# download was held out while its selectors were stale - see
# https://git.datacontroller.io/dc/dc/issues/398. Its first case uploads a fixture
# to mpe_x_test and submits it, so it creates the approval it downloads instead of
# depending on what earlier specs leave behind. That approval stays: the CI job
# deletes the appLoc and re-runs makedata before the specs, so nothing accumulates
# between runs, but a hand-run against a reused estate leaves one pending row per
# run. It is appended because a spec added later goes on the end rather than
# reordering the sequence that was known green.
set -euo pipefail
SPECS="csv-limited hook-programs liveness editor excel-multi-load excel csv filtering filter-panel licensing viewer-labels full-table-search viewbox stage browser-info ddl-export row-cell-limits derived-variables startupservice-launcher coltype-delete-record submitted-details embed-va download metanav"
# Specs that exist but are held out of CI, each with the reason. Listed here so
# the coverage check below can tell a deliberate exclusion from a forgotten file.
EXCLUDED=""
case "${1:-}" in
all)
# comma separated, the form `cypress run --spec` takes
for name in $SPECS; do printf 'cypress/e2e/%s.cy.ts,' "$name"; done | sed 's/,$//'
;;
coverage)
on_disk=$(cd "$2/client" && ls cypress/e2e/*.cy.ts | xargs -n1 basename | sed 's/\.cy\.ts$//' | sort)
listed=$(printf '%s\n' $SPECS $EXCLUDED | sort)
missing=$(comm -23 <(echo "$on_disk") <(echo "$listed"))
if [ -n "$missing" ]; then
echo "Specs that are neither run nor explicitly excluded:"
echo "$missing"
echo "Add them to SPECS (or EXCLUDED, with a reason) in .gitea/scripts/cypress-specs.sh"
exit 1
fi
echo "All $(echo "$on_disk" | wc -l) specs are accounted for: $(echo $SPECS | wc -w) run, $(echo $EXCLUDED | wc -w) excluded."
;;
*)
echo "usage: $0 {all|coverage <repo-root>}" >&2
exit 2
;;
esac
+19 -127
View File
@@ -3,8 +3,6 @@ run-name: Running Lint Check and Licence checker on Pull Request
on: [pull_request]
env:
# the server the mocked services and app tests run against - bump deliberately
SASJS_SERVER_VERSION: '3.6.2'
NODE_VERSION: '24.15.0'
jobs:
@@ -13,11 +11,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check runner size
# An undersized runner dies inside `ng build` (exitcode 137) or as a
# Karma disconnect twenty minutes in. Fail here instead, in seconds,
# with the measured numbers in the log - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
@@ -29,8 +22,9 @@ jobs:
# credential must fail the build, not just a commit.
# Uses the same @nogoo9/gitleaks binary the pre-commit hook pins - the
# gitleaks-action requires a license key for organization repos.
# Runs before the registry config is written, so the scan cannot see
# the token this job injects.
# Runs before client/.npmrc is written below, because that file is
# created from the NPMRC secret and a later scan would flag the
# registry token this job injects itself.
# --no-git scans the working tree, which is what a pull request gate
# is about.
run: |
@@ -44,11 +38,7 @@ jobs:
apt install -y ./google-chrome*.deb
- name: Write .npmrc file
run: |
# the registry secret stays outside the repo: client/.npmrc is
# tracked now, and one git add -A would commit a live token
echo "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
run: echo "$NPMRC" >> client/.npmrc
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
@@ -61,13 +51,7 @@ jobs:
gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
# dependency hooks stay off (client/.npmrc); the client's own
# generator runs explicitly, because the app imports the file it makes
npm ci --ignore-scripts
npm run postinstall
# cypress downloads its binary in a dependency install hook, which the
# policy skips, so it is installed explicitly here
npx cypress install
npm ci
- name: Check audit
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
@@ -78,27 +62,9 @@ jobs:
cd ../client
npm audit --omit=dev
- name: Check archiver directory walk
# the readdir-glob minimatch override in client/package.json is a major
# bump; archiver's directory() is the only runtime caller of that path,
# so exercise it rather than trusting that the install succeeded
run: |
cd client
npm run check:archiver
- name: Lint check
run: npm run lint:check
- name: SAS lint check
# sas/.sasjslint promotes each rule to "error" as its findings are
# cleared, and only errors change the exit code - so this gate fails on
# a regression in a cleared rule and passes while other rules still
# carry warnings.
run: |
cd sas
npm ci --ignore-scripts
npm run lint:sas
- name: Licence checker
run: |
cd client
@@ -109,38 +75,11 @@ jobs:
cd client
npm run test:headless
- name: Angular Tests in other timezones
# The VA filter clauses are built from local calendar dates, so a
# UTC-only run cannot see a date that slips a day west of UTC or east of
# it. Only the VA specs carry that property, so both scripts run that
# subset rather than the whole suite a second and third time.
run: |
cd client
npm run test:tz-west
npm run test:tz-east
- name: Every Cypress spec is in the CI list
# The spec list used to be spelled out inline in --spec, and five files
# quietly fell out of it - among them the VA embed suite. Fails when a
# spec on disk is not listed.
run: bash .gitea/scripts/cypress-specs.sh coverage .
- name: Production Build
run: |
cd client
npm run build
# Cypress shard 1 of 4. The setup repeats in every shard because each
# one gets its own runner and its own SASjs server; that fixed cost is what
# limits how far sharding helps. The specs it runs come from
# .gitea/scripts/cypress-specs.sh.
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
Build-and-test-development:
runs-on: ubuntu-latest
needs: Build-and-ng-test
@@ -152,24 +91,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check runner size
# Each Cypress job compiles the frontend and runs a browser on top; a
# runner too small for that fails as a flaky-looking disconnect - see
# the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
touch client/.npmrc
echo '${{ secrets.NPMRC}}' > client/.npmrc
- name: Install system dependencies
run: |
@@ -192,13 +121,7 @@ jobs:
cd client
# Decrypt and Install sheet
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
# dependency hooks stay off (client/.npmrc); the client's own
# generator runs explicitly, because the app imports the file it makes
npm ci --ignore-scripts
npm run postinstall
# cypress downloads its binary in a dependency install hook, which the
# policy skips, so it is installed explicitly here
npx cypress install
npm ci
- name: Build frontend for web streaming
# The server-ci target streams client/dist as the 'clickme' web app (see
@@ -209,21 +132,11 @@ jobs:
cd client
npm run build
- name: Cache the npm downloads
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
- name: Setup and start SASjs server
run: |
npm i -g pm2
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip linux.zip
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
@@ -235,10 +148,11 @@ jobs:
# in a throwaway container, so opt in explicitly.
echo RUN_AS=root >> .env
cat .env
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
# `pm2 start` returns before the app is listening, and the next step
# hits :5000 immediately. Wait for the server to answer, and dump its
# log if it never does.
pm2 start api-linux --wait-ready
# `pm2 start --wait-ready` can return while the app is still
# launching (it does not always wait for the ready signal), and the
# next step hits :5000 immediately. Wait for the server to answer,
# and dump its log if it never does.
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
@@ -248,7 +162,7 @@ jobs:
done
echo "SASjs server did not come up within 120s"
pm2 list
pm2 logs sasjs-server --lines 100 --nostream
pm2 logs api-linux --lines 100 --nostream
exit 1
- name: Deploy mocked services
@@ -268,20 +182,9 @@ jobs:
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
- name: Prepare and run frontend and cypress
# One job, one SASjs server, one ng serve, one Chrome: four of each on one
# runner starved the app under test and every failure in that run was a
# timeout, not an assertion.
timeout-minutes: 50
run: |
cd ./client
# The spec list lives in one place, and an empty list is a broken
# workflow rather than a green run with no tests.
SPECS="$(bash ../.gitea/scripts/cypress-specs.sh all)"
if [ -z "$SPECS" ]; then
echo "the spec list resolved to nothing"
exit 1
fi
echo "specs: $SPECS"
mv ./cypress.env.example.json ./cypress.env.json
replace-in-files --regex='"username".*' --replacement='"username":"'${{ secrets.CYPRESS_USERNAME_SASJS }}'",' ./cypress.env.json
replace-in-files --regex='"password".*' --replacement='"password":"'${{ secrets.CYPRESS_PWD_SASJS }}'" ' ./cypress.env.json
@@ -296,28 +199,17 @@ jobs:
# Start frontend and run cypress
# timeout 2700: SIGTERM after 45 min so Cypress can flush video/screenshots
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 2700 npx cypress run --browser chrome --spec "$SPECS"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 2700 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/hook-programs.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/filter-panel.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/full-table-search.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts,cypress/e2e/browser-info.cy.ts,cypress/e2e/ddl-export.cy.ts,cypress/e2e/row-cell-limits.cy.ts,cypress/e2e/derived-variables.cy.ts,cypress/e2e/startupservice-launcher.cy.ts"
- name: Zip Cypress videos
# Only on a failure: a green run has nothing to watch, and zipping and
# uploading every run's videos is the part of the video cost that lands
# on the clock.
if: failure()
if: always()
run: |
mkdir -p ./client/cypress/videos
zip -r cypress-videos ./client/cypress/videos
- name: Add cypress videos artifacts
if: failure()
# v3, not v4: this runner reports "GHESNotSupportedError:
# @actions/artifact v2.0.0+, upload-artifact@v4+ ... not supported on
# GHES", so the current major cannot upload here at all.
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-videos.zip
path: cypress-videos.zip
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
-130
View File
@@ -1,130 +0,0 @@
name: Publish code documentation
run-name: Publishing typedoc and SAS docs
# The code documentation sites are published here rather than inline in the
# release job, so that they can be republished on their own - after a secret
# rotation, or for a release whose artefact upload failed (the release job
# cannot simply be re-run: semantic-release refuses once the tag exists).
on:
workflow_dispatch:
env:
NODE_VERSION: '24.5.0'
jobs:
typedoc:
runs-on: ubuntu-latest
# One deploy per site at a time: `surfer deploy` swaps the site atomically,
# so two runs racing is not corruption, but it is a wasted build and the
# last swap wins.
concurrency:
group: surfer-webdoc
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
shell: bash
env:
NPMRC: ${{ secrets.NPMRC }}
- name: Install dependencies
run: |
cd client
# Decrypt and Install sheet
echo "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
shell: bash
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
- name: Build typedoc
run: |
cd client
npm run compodoc:build
# Deploy tooling is pinned to an exact version: an unpinned `latest` would
# let a breaking or compromised upstream release publish straight to the
# live site. `deploy` (v7) replaces the site atomically, and it takes the
# Cloudron username plus an app password - the v6 `put --token` flow this
# replaces is gone.
#
# webdoc is a NAMED site on the app whose default site is the marketing
# site, so the name and its alias domain are both passed. Surfer picks
# the target only from `--site-name` (it travels as the Surfer-Deployment
# header); without it `deploy` replaces the app's DEFAULT site, which is
# what serves datacontroller.io - so an unnamed deploy published the
# Typedoc over the top of the marketing site instead of to webdoc.
- name: Deploy typedoc
env:
SURFER_PASSWORD: ${{ secrets.WEBDOC_DATACONTROLLER_IO }}
REF_NAME: ${{ gitea.ref_name }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
npx --yes @cloudron/surfer@7.2.0 deploy client/documentation \
--server webdoc.datacontroller.io \
--site-name webdoc \
--domain webdoc.datacontroller.io \
--username allan \
--password "$SURFER_PASSWORD" \
--message "Typedoc from ${REF_NAME} @ ${COMMIT_SHA:0:8}"
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
sas-docs:
runs-on: ubuntu-latest
concurrency:
group: surfer-code-datacontroller
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: |
# `sasjs doc` shells out to doxygen
apt-get update
apt-get install -y doxygen
cd sas
npm ci
CLI_VERSION=$(node -p "require('./package.json').dependencies['@sasjs/cli']")
npm i -g "@sasjs/cli@${CLI_VERSION}"
- name: Build SAS docs
run: |
cd sas
sasjs doc
# No `--site-name` here on purpose: code.datacontroller.io is its own app
# and the SAS docs are its DEFAULT site, so naming one would create a
# separate site and leave the served copy stale. Do not copy the named
# form from the typedoc job above - that app's default site is the
# marketing site, which is why it must not be the target.
- name: Deploy SAS docs
env:
SURFER_PASSWORD: ${{ secrets.CODE_DATACONTROLLER_IO }}
REF_NAME: ${{ gitea.ref_name }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
npx --yes @cloudron/surfer@7.2.0 deploy sas/sasjsbuild/sasdocs \
--server code.datacontroller.io \
--username allan \
--password "$SURFER_PASSWORD" \
--message "SAS docs from ${REF_NAME} @ ${COMMIT_SHA:0:8}"
+9 -38
View File
@@ -3,8 +3,6 @@ run-name: Running Lighthouse Performance and Accessibility Checks on Pull Reques
on: [pull_request]
env:
# the server the mocked services and app tests run against - bump deliberately
SASJS_SERVER_VERSION: '3.6.2'
NODE_VERSION: '24.15.0'
jobs:
@@ -14,12 +12,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check runner size
# This job runs a production build, then a SASjs server, a dev server
# and Chrome. An undersized runner dies inside the build with exitcode
# 137; fail here in seconds instead - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
@@ -34,18 +26,6 @@ jobs:
- name: Install global packages
run: npm i -g pm2 @sasjs/cli wait-on
- name: Cache the npm downloads
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
- name: Setup and start SASjs server
run: |
touch .env
@@ -59,10 +39,13 @@ jobs:
# in a throwaway container, so opt in explicitly.
echo RUN_AS=root >> .env
cat .env
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
# `pm2 start` returns before the app is listening, and the next step
# hits :5000 immediately. Wait for the server to answer, and dump its
# log if it never does.
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip linux.zip
pm2 start api-linux --wait-ready
# `pm2 start --wait-ready` can return while the app is still
# launching (it does not always wait for the ready signal), and the
# next step hits :5000 immediately. Wait for the server to answer,
# and dump its log if it never does.
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
@@ -72,15 +55,11 @@ jobs:
done
echo "SASjs server did not come up within 120s"
pm2 list
pm2 logs sasjs-server --lines 100 --nostream
pm2 logs api-linux --lines 100 --nostream
exit 1
- name: Write .npmrc file
run: |
# the registry secret stays outside the repo: client/.npmrc is
# tracked now, and one git add -A would commit a live token
echo "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
run: echo "$NPMRC" > client/.npmrc
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
@@ -94,9 +73,6 @@ jobs:
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
npm install -g replace-in-files-cli
- name: Update appLoc in index.html
@@ -133,8 +109,3 @@ jobs:
name: Lighthouse results
path: client/lighthouse-reports
include-hidden-files: true
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
-222
View File
@@ -1,222 +0,0 @@
name: Release assets
run-name: Building and uploading the release assets
# Builds the downloadable artefacts for an existing release and uploads them.
#
# The release job cuts the tag before it builds, so a failure anywhere between
# the semantic-release step and the asset upload leaves a published release with
# no files - and that job cannot simply be re-run, because semantic-release
# refuses once the tag exists. This workflow rebuilds the artefacts from the tag
# and attaches them, so the release can be completed without cutting a new one.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and upload (blank = the latest release)'
required: false
default: ''
env:
NODE_VERSION: '24.5.0'
permissions:
contents: write
jobs:
assets:
runs-on: ubuntu-latest
# Two runs uploading the same asset names would race the delete-then-upload
# below.
concurrency:
group: release-assets
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check runner size
# Builds the same frontend as the release jobs; an undersized runner
# fails inside the build - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
shell: bash
env:
NPMRC: ${{ secrets.NPMRC }}
- name: Install system packages
# jq parses the release JSON below, so it is installed before the tag
# resolve.
run: |
apt-get update
apt-get install -y zip jq
- name: Resolve the release tag
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REQUESTED_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
if [ -n "${REQUESTED_TAG}" ]; then
TAG="${REQUESTED_TAG}"
# Fail here rather than after a ten-minute build if the tag is not
# cut yet or has no release.
curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/tags/${TAG}" > /dev/null
else
TAG=$(curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest" | jq -r '.tag_name')
fi
echo "Building assets for ${TAG}"
echo "TAG=${TAG}" >> "$GITHUB_ENV"
- name: Check out the tag
run: |
set -euo pipefail
git fetch --force --tags origin "+refs/tags/${TAG}:refs/tags/${TAG}"
git checkout --force "refs/tags/${TAG}"
- name: Install the SASjs CLI the tag pins
run: |
cd sas
CLI_VERSION=$(node -p "require('./package.json').dependencies['@sasjs/cli']")
npm i -g "@sasjs/cli@${CLI_VERSION}"
- name: Frontend build
run: |
cd client
# Decrypt and Install sheet
echo "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
npm i webpack
npm run build
shell: bash
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
- name: Build SAS9 EBI Release
run: |
cd sas
npm i
sasjs c -t sas9
rm -rf sasjsbuild/tests
sasjs b -t sas9
cp sasjsbuild/mysas9deploy.sas ./demostream_sas9.sas
# remove streamed component and rebuild SAS 9 services
rm -rf sasjsbuild/services/web9
rm sasjsbuild/services/clickme.sas
sasjs b -t sas9
cp sasjsbuild/mysas9deploy.sas ./sas9.sas
- name: Build SASjs Server Release
run: |
cd sas
cp sasjs/utils/favicon.ico ../client/dist/favicon.ico
sasjs c -t server
rm -rf sasjsbuild/tests
server_apploc="/Public/app/dc"
sed -i "s|apploc=\"[^\"]*\"|apploc=\"${server_apploc}\"|g" sasjsbuild/services/web/index.html
sasjs b -t server
cp sasjsbuild/server.json.zip ./sasjs_server.json.zip
- name: Build Viya Release
run: |
cd sas
sasjs c -t viya
rm -rf sasjsbuild/tests
sed -i -e 's/servertype="SASJS"/servertype="SASVIYA"/g' sasjsbuild/services/DC.html
sasjs b -t viya
cp sasjsbuild/viya.sas ./viya.sas
# compile Viya Full deploy (without web)
rm -rf sasjsbuild/services/web
rm sasjsbuild/services/DC.html
sasjs b -t viya
cp sasjsbuild/viya.sas ./viya_noweb.sas
cp sasjsbuild/viya.json ./viya_noweb.json
- name: Zip Frontend (including viya.json for full viya deploy)
run: |
cd sas
cp sasjsbuild/viya.json ../client/dist/viya.json
# Zip from *inside* dist so the archive holds the frontend files at its
# root rather than under a client/dist/ prefix (see #147).
cd ../client/dist
zip -r ../../frontend.zip .
- name: Upload assets to the release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
RELEASE_JSON=$(curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
ASSETS=(frontend.zip
sas/demostream_sas9.sas
sas/viya.sas
sas/sasjs_server.json.zip
sas/sas9.sas
sas/viya_noweb.sas
sas/viya_noweb.json)
: > SHA256SUMS
for f in "${ASSETS[@]}"; do
sum=$(sha256sum "$f" | cut -d' ' -f1)
printf '%s %s\n' "$sum" "${f##*/}" >> SHA256SUMS
done
cat SHA256SUMS
# Replacing the assets means removing the ones this workflow uploads,
# and a second run must not append the verification section twice. An
# asset the release carries from another source is left alone.
OWNED=$(printf '%s\n' "${ASSETS[@]##*/}" SHA256SUMS)
echo "$RELEASE_JSON" | jq -r '.assets[]? | "\(.id) \(.name)"' |
while read -r asset_id asset_name; do
if printf '%s\n' "$OWNED" | grep -qxF -- "$asset_name"; then
echo "Removing previous ${asset_name}"
curl --fail-with-body -sS -X DELETE -H "$AUTH_HEADER" "$BASE/releases/${RELEASE_ID}/assets/${asset_id}"
else
echo "Leaving ${asset_name} in place: this workflow did not upload it"
fi
done
if echo "$RELEASE_BODY" | grep -q "## Verifying the download"; then
echo "Release body already carries the verification section"
else
VERIFY_SECTION=$(./.gitea/scripts/verify-section.sh)
NEW_BODY=$(jq -nr --arg body "$RELEASE_BODY" --arg verify "$VERIFY_SECTION" \
'$body + "\n" + $verify + "\n\nFor installation instructions, please visit https://docs.datacontroller.io/"')
curl --fail-with-body -sS -X PATCH \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg body "$NEW_BODY" '{draft:false, body:$body}')" \
"$BASE/releases/$RELEASE_ID"
fi
URL="$BASE/releases/${RELEASE_ID}/assets"
for f in "${ASSETS[@]}"; do
echo "Uploading $f ..."
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
done
echo "Uploading SHA256SUMS ..."
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
+33 -125
View File
@@ -6,8 +6,6 @@ on:
- main
env:
# the server the mocked services and app tests run against - bump deliberately
SASJS_SERVER_VERSION: '3.6.2'
NODE_VERSION: '24.5.0'
jobs:
@@ -18,24 +16,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check runner size
# A release job that OOMs costs the tag as well as the slot; fail in
# seconds on an undersized runner instead of twenty minutes in - see
# the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
touch client/.npmrc
echo '${{ secrets.NPMRC}}' > client/.npmrc
- name: Install Chrome for Angular tests
run: |
@@ -55,9 +43,6 @@ jobs:
# Decrypt and Install sheet
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
- name: Check audit
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
@@ -77,14 +62,8 @@ jobs:
run: |
cd client
npm run postinstall
npm run check:archiver
npm run build
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
Build-and-test-development:
runs-on: ubuntu-latest
needs: Build-production-and-ng-test
@@ -96,23 +75,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check runner size
# The development build plus a browser: same undersized-runner failure
# as the review jobs, on the path that makes the tag - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
touch client/.npmrc
echo '${{ secrets.NPMRC}}' > client/.npmrc
- name: Install system dependencies
run: |
@@ -136,35 +106,12 @@ jobs:
# Decrypt and Install sheet
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
- name: Build frontend for web streaming
# The server-ci target streams client/dist as the 'clickme' web app, so
# the production build has to exist before the mocked services are
# deployed. Without it `sasjs cbd` reports
# "webSourcePath: .../client/dist ... doesn't exist", streams no
# frontend and the deploy step fails.
run: |
cd client
npm run build
- name: Cache the npm downloads
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
- name: Setup and start SASjs server
run: |
npm i -g pm2
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip linux.zip
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
@@ -176,10 +123,11 @@ jobs:
# in a throwaway container, so opt in explicitly.
echo RUN_AS=root >> .env
cat .env
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
# `pm2 start` returns before the app is listening, and the next step
# hits :5000 immediately. Wait for the server to answer, and dump its
# log if it never does.
pm2 start api-linux --wait-ready
# `pm2 start --wait-ready` can return while the app is still
# launching (it does not always wait for the ready signal), and the
# next step hits :5000 immediately. Wait for the server to answer,
# and dump its log if it never does.
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
@@ -189,7 +137,7 @@ jobs:
done
echo "SASjs server did not come up within 120s"
pm2 list
pm2 logs sasjs-server --lines 100 --nostream
pm2 logs api-linux --lines 100 --nostream
exit 1
- name: Deploy mocked services
@@ -216,9 +164,6 @@ jobs:
replace-in-files --regex='"password".*' --replacement='"password":"'${{ secrets.CYPRESS_PWD_SASJS }}'" ' ./cypress.env.json
cat ./cypress.env.json
npm run postinstall
# cypress downloads its binary in a dependency install hook, which the
# client policy skips
npx cypress install
# Prepare index.html to SASJS local
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/dc"' ./src/index.html
@@ -241,11 +186,6 @@ jobs:
name: cypress-videos.zip
path: cypress-videos.zip
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
release:
runs-on: ubuntu-latest
needs: [Build-production-and-ng-test, Build-and-test-development]
@@ -266,19 +206,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check runner size
# This job builds the frontend twice (webdoc and the release check);
# both die the same way on an undersized runner - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
echo "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
echo "$NPMRC" > client/.npmrc
echo "legacy-peer-deps=true" >> client/.npmrc
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
@@ -296,9 +231,6 @@ jobs:
# Decrypt and Install sheet
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
npm i webpack
npm run build
@@ -339,11 +271,6 @@ jobs:
semantic-release
# The tag this job cuts, for the steps that publish against it: a
# `releases/latest` lookup would pick whichever release landed last
# if two overlap.
echo "RELEASE_TAG=v$(node -p "require('./package.json').version")" >> "$GITHUB_ENV"
- name: Frontend Build
description: Must be created AFTER the release as the version (git tag) is used in the interface
run: |
@@ -404,6 +331,19 @@ jobs:
cd ../client/dist
zip -r ../../frontend.zip .
- name: Release Typedoc
run: |
cd client
npm -g install cloudron-surfer
npm run compodoc:build
surfer put --token ${{ secrets.TSDOC_TOKEN }} --server webdoc.datacontroller.io documentation/* /
- name: Release code.datacontroller.io
run: |
cd sas
sasjs doc
surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* /
- name: Upload assets to release
env:
# Use the same ephemeral per-job Gitea Actions token that the
@@ -418,7 +358,7 @@ jobs:
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
RELEASE_JSON=$(curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/tags/${RELEASE_TAG}")
RELEASE_JSON=$(curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
@@ -452,7 +392,7 @@ jobs:
VERIFY_SECTION=$(./.gitea/scripts/verify-section.sh)
NEW_BODY=$(jq -nr --arg body "$RELEASE_BODY" --arg verify "$VERIFY_SECTION" \
'$body + "\n" + $verify + "\n\nFor installation instructions, please visit https://docs.datacontroller.io/"')
curl --fail-with-body -sS -X PATCH \
curl -k --fail-with-body -sS -X PATCH \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg body "$NEW_BODY" '{draft:false, body:$body}')" \
@@ -462,37 +402,10 @@ jobs:
URL="$BASE/releases/$RELEASE_ID/assets"
for f in "${ASSETS[@]}"; do
echo "Uploading $f ..."
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
done
echo "Uploading SHA256SUMS ..."
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
- name: Publish the code documentation
# Typedoc (webdoc) and the SAS docs (code) are published by docs.yaml,
# not inline here. Two reasons: the v6 `surfer put --token` flow is gone
# in Surfer 7, which takes a Cloudron username plus an app password; and
# a dispatch means a stale or failed docs deploy can be republished
# without re-running this job, which semantic-release refuses once the
# tag exists.
#
# This runs AFTER the release assets are uploaded: the documentation is
# a side channel, and a docs problem must not cost a cut release its
# artefacts.
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
TAG="${RELEASE_TAG}"
echo "Dispatching code documentation for ${TAG}"
curl --fail-with-body -sS -X POST \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
"$BASE/actions/workflows/docs.yaml/dispatches"
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
- name: Publish the demo site for this release
# The tag is created by this job's semantic-release step, so it carries
@@ -507,18 +420,13 @@ jobs:
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
TAG="${RELEASE_TAG}"
TAG=$(curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest" | jq -r '.tag_name')
echo "Dispatching Sites for ${TAG}"
# the dispatch endpoint resolves a bare name as a branch; a tag needs
# the refs/tags/ prefix
curl --fail-with-body -sS -X POST \
curl -k --fail-with-body -sS -X POST \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
"$BASE/actions/workflows/sites.yaml/dispatches"
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
+5 -35
View File
@@ -10,8 +10,6 @@ on:
workflow_dispatch:
env:
# the server the mocked services and app tests run against - bump deliberately
SASJS_SERVER_VERSION: '3.6.2'
NODE_VERSION: '24.15.0'
APP_LOC: /Public/app/dc
SURFER_HOST: https://demo.datacontroller.io
@@ -38,23 +36,14 @@ jobs:
# The tag is what the client's build stamp describes (src/version.ts
# runs git-describe), so the demo build needs the tags in the clone.
fetch-depth: 0
- name: Check runner size
# The demo build is the same cold production build; an undersized
# runner fails inside it - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
touch client/.npmrc
echo '${{ secrets.NPMRC}}' > client/.npmrc
- name: Install client dependencies
run: |
@@ -62,9 +51,6 @@ jobs:
# Decrypt and Install sheet
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
- name: Build frontend
# The site serves this build. `sasjs build` rewrites its index.html to
@@ -80,29 +66,18 @@ jobs:
cd sas
npm ci
- name: Cache the npm downloads
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
- name: Start SASjs server
# Needed only to run makedata: the drive the site ships with is captured
# from a real server (see the snapshot step below).
run: |
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip -q linux.zip
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
echo HTTP_PORT=5000 >> .env
# The server refuses to run as root unless RUN_AS names an account
echo RUN_AS=root >> .env
nohup npx --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }} > server.log 2>&1 &
nohup ./api-linux > server.log 2>&1 &
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
@@ -205,8 +180,3 @@ jobs:
--message "$MESSAGE" \
"${SITE_ARGS[@]}" \
sas/mocks/sasjsbuild/mock
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
+1
View File
@@ -20,6 +20,7 @@ sasjsbuild
sasjsresults
.env*
.sasjsrc
client/.npmrc
*~
.lighthouseci
audit.log
+2 -7
View File
@@ -31,12 +31,7 @@ Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and
Never consider a change complete until the relevant linters pass on the files you touched — do not rely on the user's pre-commit hooks to catch it:
- **Client (TypeScript/HTML/etc.)**: run `npm run lint:check` from the `client/` directory (prettier). Fix any failures with `npm run lint:fix`.
- **SAS**: after creating or modifying any `.sas` files, run `npm run lint:sas` from the `sas/` directory. That script runs the `@sasjs/cli` version pinned in `sas/package.json`, so it matches CI; a global or `npx` `sasjs` may resolve a different `@sasjs/lint` and report a different rule set.
Rules are promoted to `error` in `sas/.sasjslint` one at a time as their findings reach zero, and only errors change the exit code - so the whole project must stay clean of the promoted rules, not just the files you touched. Warnings under rules not yet promoted are the remaining backlog; do not add to them in files you touch, and do not clear other files' findings in the same change unless that rule is the point of the change.
`sas/.sasjslint` carries `ignoredMacros: ["webout", "mpeinit", "mpeterm"]`. `webout` is the linter's own default - `sasjs compile` injects `%webout` into every service - and an explicit list replaces that default, so it has to be restated there. `mpeinit` and `mpeterm` are listed because every service is compiled with a serviceInit program (`sasjs/utils/serviceinitsas9|viya|server.sas`) that declares both macros in its own header, so a service body may call them without declaring them. Add a macro to that list only when it reaches a file the same way - injected by the compiler, through serviceInit, testSetUp or another implicit provider - never to silence a genuinely missing declaration.
`noBannedStrings` flags the literal words `error ` and `warning ` anywhere in a file, comments included. Reword prose; where the text has to stay (a real `%put`, a doxygen `@warning` tag), keep the log output identical with `%put %str(WARN)ING:` rather than a per-file override, and use `@sasjslint {"noBannedStrings": false}` only when the word itself is the content.
Hook scripts (`sasjs/services/hooks/*.sas`) run inside the service that includes them, so the macros they call come from that service's session. They carry a file-local `@sasjslint {"ignoredMacros": [...]}` rather than a macro list of their own, because end users copy and edit these scripts and should not have to maintain a declaration section.
`noMacroIfInOpenCode` is on, so `%if` must sit inside a `%macro` definition. Where a conditional has to stay in a service body, wrap the block in a small macro and call it unconditionally (`%macro build_staged(); ... %end; %mend build_staged; %build_staged()`), or resolve the value in a data step with `call symputx`. Two traps: a `%let` inside such a wrapper is local, so a macro variable that must survive the block has to be written with `call symputx(...,'G')`; and open-code `%if` itself needs SAS 9.4M5, which the code no longer relies on.
- **SAS**: after creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored).
## No external assets
@@ -46,7 +41,7 @@ Data Controller must run entirely locally (offline / on-prem, no internet access
Agent-related content lives in `.agents/`: technical/agent-facing documentation goes in `.agents/docs/` (not `docs/`), and skills in `.agents/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agents/docs/`.
Skills installed via `npx skills add` are tracked in `skills-lock.json` and live under `.agents/skills/`. Repo-local custom skills (e.g. `dc-sas` for Data Controller SAS development, `dc-estate-testing` for estate deploys and test runs, `handsontable`, `hyperformula`) are kept here too. Avoid editing npx-managed skill files directly — use `npx skills update` instead.
Skills installed via `npx skills add` are tracked in `skills-lock.json` and live under `.agents/skills/`. Repo-local custom skills (e.g. `dc-sas` for Data Controller SAS development, `handsontable`, `hyperformula`) are kept here too. Avoid editing npx-managed skill files directly — use `npx skills update` instead.
## Code comments and test names
-164
View File
@@ -1,167 +1,3 @@
# [7.21.0](https://git.datacontroller.io/dc/dc/compare/v7.20.0...v7.21.0) (2026-10-29)
### Bug Fixes
* **client:** parse word-date values from VA parameters ([405b28d](https://git.datacontroller.io/dc/dc/commit/405b28dcccfe5994521bf63f0270d4877f544294))
* **client:** parse word-date values from VA parameters ([ce5b1aa](https://git.datacontroller.io/dc/dc/commit/ce5b1aa96175d0fd1f048fea6d127cbc743804f2))
* **makedata:** name the sasjs server group in the estate seed ([eeffa92](https://git.datacontroller.io/dc/dc/commit/eeffa92f9e973b323171bbefe2ef1cbc27e3aee7))
### Features
* **derived:** apply the core field scan in the MPE_DERIVED hook ([a4a0f9c](https://git.datacontroller.io/dc/dc/commit/a4a0f9cb2f7c0b6252405b71df64aae912d1b075))
# [7.20.0](https://git.datacontroller.io/dc/dc/compare/v7.19.0...v7.20.0) (2026-10-28)
### Bug Fixes
* **ci:** install doxygen for the SAS docs job ([f58925b](https://git.datacontroller.io/dc/dc/commit/f58925b29956f1f753f0a367fdcd0afe7f3f51a0))
* **ci:** keep foreign release assets, install jq first, pin the CLI ([27686d3](https://git.datacontroller.io/dc/dc/commit/27686d3aad5e34ace59ca1b735a17cca1a4025e1))
* **ci:** publish against the tag this job cuts, not releases/latest ([8888734](https://git.datacontroller.io/dc/dc/commit/888873421c3aeaf312d5ec45e3c805317c66976a))
* **ci:** publish docs after the assets, with the webdoc app password ([dd482c4](https://git.datacontroller.io/dc/dc/commit/dd482c4069202a0c6b9983129c19efc09bd160a7))
* **ci:** publish the code documentation with surfer 7 and app passwords ([25ffa71](https://git.datacontroller.io/dc/dc/commit/25ffa71adddba5e334f4a8c166f284d1be498fcd))
* **ci:** publish the typedoc to the webdoc site, not the app default ([c5808a1](https://git.datacontroller.io/dc/dc/commit/c5808a16eba0d29562fcc76f13ac4a33ab8da0b7))
* **ci:** rebuild and upload release assets on demand ([6486c80](https://git.datacontroller.io/dc/dc/commit/6486c80ed4f368bbf116c0ecdecc0e5e5f02075f))
* **ci:** use the lockfile and the pinned CLI in the docs job ([af1813f](https://git.datacontroller.io/dc/dc/commit/af1813f829861df41c89b512c824e8d3ebac7a4b))
* **ci:** verify the forge certificate on the release API calls ([cf7b21a](https://git.datacontroller.io/dc/dc/commit/cf7b21a8b61ec3bf7ebe21968ccf13ade264bc64))
* **cypress:** hold download out too, and retry once in CI ([c97e886](https://git.datacontroller.io/dc/dc/commit/c97e8866db9ee404b2118d24baf53fc7058debef)), closes [#395](https://git.datacontroller.io/dc/dc/issues/395) [#398](https://git.datacontroller.io/dc/dc/issues/398)
* **cypress:** register the deleteFolder task, restore the spec order, hold metanav out ([c803773](https://git.datacontroller.io/dc/dc/commit/c803773d3c36476d8263f01fc3b8344aa3503d44)), closes [#395](https://git.datacontroller.io/dc/dc/issues/395)
* **deploy:** do not offer the deploy wizard inside a report ([80e8911](https://git.datacontroller.io/dc/dc/commit/80e8911af8857fa2c417f028cd59b64a87233b9e))
* **deploy:** keep the app query when opening the standalone URL ([e51953c](https://git.datacontroller.io/dc/dc/commit/e51953ca8a6734e1649bad68cf4ad2057d677334))
* **deploy:** one definition of the standalone URL, and test the guards ([459ff2d](https://git.datacontroller.io/dc/dc/commit/459ff2da371375e996839d69deb40f681c356f1d)), closes [#TCS](https://git.datacontroller.io/dc/dc/issues/TCS)
* **deploy:** strip the embed parameters by name, and scope the timezone runs to the VA specs ([f7e7538](https://git.datacontroller.io/dc/dc/commit/f7e753863b61d8545bd0777d80267661b3c58670))
* **embed:** build quarter ends from the calendar, not minus 24 hours ([7e3045a](https://git.datacontroller.io/dc/dc/commit/7e3045a21b67053b76a81bfe836e31571ccbffbf))
* **embed:** defer the column check until metadata loads, report AND-ed pairs ([2623a3f](https://git.datacontroller.io/dc/dc/commit/2623a3fb4ba3bf19c00636b1717b53d5a2d54f3a))
* **embed:** log a parse-time mapping problem once ([0011f7e](https://git.datacontroller.io/dc/dc/commit/0011f7e933d85be8e33debe3a6ecb9000edaabe3))
* **embed:** make the va_parmap diagnostics say what they mean ([541a61b](https://git.datacontroller.io/dc/dc/commit/541a61ba08bc7256a911c50de7078859cc46c84e))
* **embed:** match a mapping on the parameter label only, and warn only when the values differ ([e9216b0](https://git.datacontroller.io/dc/dc/commit/e9216b08956d5599662259a2b05cc1524c458df5))
* **embed:** never build a clause from a value that did not convert, and say so in the banner ([bd3c220](https://git.datacontroller.io/dc/dc/commit/bd3c220af2678a2a3f8394fe3084ee03c23d0b15))
* **embed:** one parameter maps one column, and report typos on both sides ([f83dc35](https://git.datacontroller.io/dc/dc/commit/f83dc359e258f5d1cb9fc49f0eaab9748995d823))
* **embed:** record the parameter label only, so a name-keyed va_parmap is reported ([aa91daf](https://git.datacontroller.io/dc/dc/commit/aa91daf9f78c00cea486d0f885f8e634366bd1a0))
* **embed:** resolve transform values as local dates and give ranges whole days ([10157c1](https://git.datacontroller.io/dc/dc/commit/10157c11595685efdc206fe7b16a5cafa098f26d))
* **embed:** warn when a va_parmap entry is ignored ([411f6c3](https://git.datacontroller.io/dc/dc/commit/411f6c3354b7fbfec11fef3b174ae1f8066c92ba))
* **makedata:** pair the %else of the keepme block so it parses in open code ([c0ea810](https://git.datacontroller.io/dc/dc/commit/c0ea810766ebfda5ae5358ab14146fbcc07fcc8e))
### Features
* **embed:** map VA parameters to DC columns from the URL ([15a1f34](https://git.datacontroller.io/dc/dc/commit/15a1f34d5263503cac83b4fe4e52482777dc2f1f))
* **embed:** resolve va_parmap values through named date anchors ([88010c3](https://git.datacontroller.io/dc/dc/commit/88010c3980c2ea1c39bbb024ed32d63e495e61f0))
# [7.19.0](https://git.datacontroller.io/dc/dc/compare/v7.18.0...v7.19.0) (2026-10-28)
### Bug Fixes
* address the review findings on the harness and refresh changes ([36e4e88](https://git.datacontroller.io/dc/dc/commit/36e4e88eb134d3b48217d6dd185ea3c03f03fba5))
* **ci:** dispatch the demo deploy with a tag ref and the actions scope ([698dea6](https://git.datacontroller.io/dc/dc/commit/698dea6d6b7a8635313f11acfcc7a4f39a02bc99))
* **ci:** restore the gitleaks ignore entries for the mock licence pair ([0d58be9](https://git.datacontroller.io/dc/dc/commit/0d58be975d8eb2d7f1ab6a648f760151392c7973))
* **client:** open the SAS log page with noopener ([072a133](https://git.datacontroller.io/dc/dc/commit/072a1337020970b5b4ff8aa31719cc5ffec7701e))
* **client:** refuse a truncated table in the multi-table upload ([1fb4818](https://git.datacontroller.io/dc/dc/commit/1fb4818d215f538a0ac51772ab1f261aeb91f7e1))
* **cypress:** apply the licence key when the estate has none ([be8f445](https://git.datacontroller.io/dc/dc/commit/be8f445ae1051e132fb6eb5a69b45264255326b5)), closes [input#file-upload](https://git.datacontroller.io/input/issues/file-upload)
* **dc_execute:** use = comparisons, not IN, in the fail-fast guard ([fed43cb](https://git.datacontroller.io/dc/dc/commit/fed43cb07ef7f2afc685612a124dbc16d7618e21))
* **dc_getlibs:** exclude the DC libref from the dictionary scan ([f5aade5](https://git.datacontroller.io/dc/dc/commit/f5aade59760cc376b0f96eb4c892c8bca82dde14))
* **dc_refreshcatalog:** terminate the generated statements ([1e22476](https://git.datacontroller.io/dc/dc/commit/1e224763ba80500a112812a983801968e3751959))
* **derived:** a fileref with nothing written is a FAIL, not an error ([9b3cf50](https://git.datacontroller.io/dc/dc/commit/9b3cf50b9c3889786605f0f5d9032d84c50f7107))
* **derived:** address five review findings ([ccd468b](https://git.datacontroller.io/dc/dc/commit/ccd468b3358c1418ee6d8e198b088a0cf23b8c24))
* **derived:** carry the placement sequence in mpe_derived ([be50cb5](https://git.datacontroller.io/dc/dc/commit/be50cb5d082e788c6ae106bc1d02e73a8c7b9646))
* **derived:** close four gaps in the MPE_DERIVED hook ([9914790](https://git.datacontroller.io/dc/dc/commit/9914790777f92128c74ecce67e6c67ee48824b45))
* **derived:** close the RLS and search gaps on derived columns ([9ef813d](https://git.datacontroller.io/dc/dc/commit/9ef813d439b48e7fefd35c426e62b99e5943002a))
* **derived:** close the two gaps in the derived/RLS invariant ([422f766](https://git.datacontroller.io/dc/dc/commit/422f766cd43db4ce740e1890fd5df30790eaa7e3))
* **derived:** compare the outref as text, prove the file is empty ([7830b7b](https://git.datacontroller.io/dc/dc/commit/7830b7bcfa9806dde840ce8dd3e6a1887d0b38be))
* **derived:** do not drop a shadowed column from staging or downloads ([2d5de9a](https://git.datacontroller.io/dc/dc/commit/2d5de9a324a07a36edc0a4f74c8fad09fde07cbf))
* **derived:** do not duplicate a shadowed column in the grid payload ([6a1128a](https://git.datacontroller.io/dc/dc/commit/6a1128a46352f77c95dd02df9309f0984a1db2d1))
* **derived:** eight review findings ([561d7fc](https://git.datacontroller.io/dc/dc/commit/561d7fc3ed2a460835619962cf4dcb29d3dc87c7))
* **derived:** enforce the ACTIVE picker pair in the extracted check ([1863076](https://git.datacontroller.io/dc/dc/commit/1863076bc956b4ff94bf7b85b3dde0d6e3b54d7a)), closes [#366](https://git.datacontroller.io/dc/dc/issues/366)
* **derived:** enforce the documented limits, and handle derived filters in VIEW ([d3dec68](https://git.datacontroller.io/dc/dc/commit/d3dec682aafeb143fe983d6546337cb6490f5620))
* **derived:** filtering a format catalog no longer aborts the service ([469229a](https://git.datacontroller.io/dc/dc/commit/469229aecbaad304a835a5f229df111c37ff7c0d))
* **derived:** guard the bitemporal cut-off, coalesce the selectbox seed ([7fb981d](https://git.datacontroller.io/dc/dc/commit/7fb981daa91bce29c2d62c246185d01dcad08d6b))
* **derived:** guard the empty physical column list in the dep check ([5f5d868](https://git.datacontroller.io/dc/dc/commit/5f5d8681b9d78280a5200309917415e135c3ce5b))
* **derived:** hold a derived column read-only whatever the table's security ([5f443b0](https://git.datacontroller.io/dc/dc/commit/5f443b0b16a2abd3c11d96caa7a043911c29ef01))
* **derived:** ignore a re-added name when collecting inactive rows ([6b5bf3e](https://git.datacontroller.io/dc/dc/commit/6b5bf3e6be89b22e5a633c34b9aecbc8c7d48ee2))
* **derived:** ignore inactive rows and VIEW rules in the submit guards ([85c89ca](https://git.datacontroller.io/dc/dc/commit/85c89ca2a4e02112ba496ac36a222f33675544da))
* **derived:** keep targetds for plain datasets, test the empty file ([f8f6568](https://git.datacontroller.io/dc/dc/commit/f8f65681e1489f4ce94d9127d5e4c0c82798e960))
* **derived:** make the model enforce the required columns, and leave VAR_FMT optional ([7efbf31](https://git.datacontroller.io/dc/dc/commit/7efbf31ea5ac234bd8f924fdf89a95d101b3aff1))
* **derived:** only the delete marker is a reserved name ([af31c36](https://git.datacontroller.io/dc/dc/commit/af31c36b670ec63a2a4ba056bf6c4b28f6096e08))
* **derived:** open the statements file with a long record length ([ee31782](https://git.datacontroller.io/dc/dc/commit/ee31782fbff909b540162b1bdfca6f307208331b))
* **derived:** pair the filterds %else with %do/%end ([88a38db](https://git.datacontroller.io/dc/dc/commit/88a38db877dba9b98ccf50f0cc7242674654586b))
* **derived:** raise the VAR_ORDER ceiling to 10000 ([27ddcfb](https://git.datacontroller.io/dc/dc/commit/27ddcfb5759d6eb8f9880adc7c271a408e7741ad))
* **derived:** refuse dependencies on inactive rows, and keep the RLS scope guard coherent ([7799a30](https://git.datacontroller.io/dc/dc/commit/7799a300df82339683eea21e4a89ce7a6471c575))
* **derived:** reject a dependency on a row this submission deletes ([8806504](https://git.datacontroller.io/dc/dc/commit/88065049674c2cc0141eef22704b759f5b708b0f))
* **derived:** repair the validation rows for MPE_DERIVED ([9a047ad](https://git.datacontroller.io/dc/dc/commit/9a047ad42ec86df7d1eaf5058c0edde56016e88f))
* **derived:** review feedback - cover the empty response, assert the last row ([e0da9f1](https://git.datacontroller.io/dc/dc/commit/e0da9f1098db63185538398ca478696cc849da4b))
* **derived:** stop the hooks aborting on their own macro variables ([688550a](https://git.datacontroller.io/dc/dc/commit/688550a28d9794ac6b878926a0e1e2ef8f6e9335))
* **derived:** truncate the derived label on an even quote boundary ([1b5c4b3](https://git.datacontroller.io/dc/dc/commit/1b5c4b3ecd962a28e08fc5671b57dbf7ae0b8d9f))
* **derived:** verify on stripped values, and label with the variable name ([4d9ad38](https://git.datacontroller.io/dc/dc/commit/4d9ad38df1fa89b0d5a12214d703d9c1fe5295f2))
* **editor:** a blank truncation note is not a warning ([f182179](https://git.datacontroller.io/dc/dc/commit/f18217940365f27ae388fae087e6126931e1222c))
* **editor:** keep decimal values when an uploaded file is edited manually ([04ce186](https://git.datacontroller.io/dc/dc/commit/04ce1861e7821063b1be42c22f92f62aed509602))
* **getcolvals:** report an invalid value, not missing inputs ([4bcee70](https://git.datacontroller.io/dc/dc/commit/4bcee706ffaf1929bdd14bb2fb15111174ca3916))
* **getdata.test:** pin the derived filter to a libref, and spell the table as it is ([45dce7b](https://git.datacontroller.io/dc/dc/commit/45dce7b11b7a4399c1d0dc9c3e7a283b1cf59df1))
* **getdata:** clear the globals it declares before use ([359b570](https://git.datacontroller.io/dc/dc/commit/359b570b85aa2f66507e81ea2977243be4590f83))
* **getddl.test:** ask for a table, not a whole library ([059cdf1](https://git.datacontroller.io/dc/dc/commit/059cdf176a7ec556b45ea1b21d880c048eba14ca))
* **getrawdata:** declare mp_ds2md so the Markdown download can run ([661ee19](https://git.datacontroller.io/dc/dc/commit/661ee19cfa5f996bf01f2b11817039f38dbb9985))
* **lint:** clear the sasjs lint warnings on the files 7.19 touched ([355c46a](https://git.datacontroller.io/dc/dc/commit/355c46a7b64994d0146a88657dc5635593249138))
* **makedata:** read KEEPME from every input row, and keep it out of the Viya seed ([d626cb8](https://git.datacontroller.io/dc/dc/commit/d626cb8f2daf94fde7ff21d924b9a2ca4968d983))
* **mocks:** address the audit-pack review findings ([489819b](https://git.datacontroller.io/dc/dc/commit/489819b05111323ae1e34edda22a5446cdbc2d87))
* **mocks:** carry the zip flags into the central directory ([c4021a4](https://git.datacontroller.io/dc/dc/commit/c4021a42ffaed2af80c53aaa1455249190b339b8))
* **mocks:** clear the licence for published sites, not for the estate ([62f55db](https://git.datacontroller.io/dc/dc/commit/62f55db170185900063f7a431cd8f375ebabf930))
* **mocks:** copy the physical column list before deriving ([f411b7d](https://git.datacontroller.io/dc/dc/commit/f411b7d269381e3f7cab2d85b6d8f7d7d50ea4d1))
* **mocks:** derive before filtering in the viewer, and drop the columns ([60ec470](https://git.datacontroller.io/dc/dc/commit/60ec470404e5c279b58ac6a01768dfc83413b197))
* **mocks:** derive the retained key wherever rows are staged ([74060c0](https://git.datacontroller.io/dc/dc/commit/74060c0585fb8b217cb181ea747ad318f5238868))
* **mocks:** drop derived variables before staging ([73d25b1](https://git.datacontroller.io/dc/dc/commit/73d25b1220d574ef2547fbd9e6e3cf1ceda375ee))
* **mocks:** key rk_underlying tables on their business key ([347dbb5](https://git.datacontroller.io/dc/dc/commit/347dbb53b01c47bbd562de14d5ccbae7d96407c6))
* **mocks:** register MPE_DERIVED and seed its field rules ([ee3c878](https://git.datacontroller.io/dc/dc/commit/ee3c8787efef9cd716bdd2bf80f6cb6751461db9))
* **mocks:** scope the derived hook's graph to the submission ([6cad23f](https://git.datacontroller.io/dc/dc/commit/6cad23fce9ac0cf64c247c40f3b7eac875fd4cfb))
* **mocks:** search the physical columns only in the viewer ([293022d](https://git.datacontroller.io/dc/dc/commit/293022d46b27e096fd514cdda49f688612df2f5b))
* **mocks:** stop the mock advertising invalid licence keys ([fb36379](https://git.datacontroller.io/dc/dc/commit/fb36379520028baaf4bc3f08131bfc88deab3ac3))
* **mpe_assert_abort:** drop the unused line variable ([9c294c0](https://git.datacontroller.io/dc/dc/commit/9c294c0ab5ca8fe30a235553a298c1c68a61442f))
* **mpe_assert_abort:** take the abort's own MSG, tolerate a missing response ([0a52d20](https://git.datacontroller.io/dc/dc/commit/0a52d20c5fc27b4adde5fc8ed6f39a58c48f62ff))
* **mpe_execute:** fail fast when the child left no status ([a036386](https://git.datacontroller.io/dc/dc/commit/a03638667f1968c5909f496d954eeaa13f316763))
* **mpe_execute:** no apostrophe in the SAS 9 status note ([2204085](https://git.datacontroller.io/dc/dc/commit/220408538b26016bda45e48d619cdbc3e1cbcc59))
* **mpe_execute:** read an unknown state as unmeasured, not failed ([f2b1428](https://git.datacontroller.io/dc/dc/commit/f2b1428ec3099b2b6e3c6da6627530ef81fab201))
* name the startupservice launcher button "Show log" ([3445107](https://git.datacontroller.io/dc/dc/commit/3445107d9a91642b67e114bcfcd81c3ce47f3f0f))
* offer the launcher on the globvars failure modal too ([ad2da45](https://git.datacontroller.io/dc/dc/commit/ad2da453896a51f2cc7e49cebb6c84f7b47d7395))
* **refresh:** name each catalogue load uniquely in the audit trail ([46bacdd](https://git.datacontroller.io/dc/dc/commit/46bacdd6d9ea814bcd02a1ff8aa3f447baf02f64))
* **refresh:** one audit folder per refresh call ([56b6620](https://git.datacontroller.io/dc/dc/commit/56b6620ef402b288ce78a2a77662b651e310753e))
* **sites:** round-trip the committed licence before publishing ([4d462d3](https://git.datacontroller.io/dc/dc/commit/4d462d310f7df23e5d5229cb0702721b7ad41bbd))
* **test:** getdata test ([da30266](https://git.datacontroller.io/dc/dc/commit/da30266278c4fe27a72810cd5ed647cf3bd823af))
* **tests:** enable closeouts logging ([fdcd8fa](https://git.datacontroller.io/dc/dc/commit/fdcd8fa4b3e7a7a119c662974d70dc9eea80bdae))
* **tests:** read the work location from _SASJS_WORK ([033ffd3](https://git.datacontroller.io/dc/dc/commit/033ffd302502c7530fc44a314364f5cdb3be6ad2))
* **ui:** one error modal at a time ([81ccf75](https://git.datacontroller.io/dc/dc/commit/81ccf758e9dab0c7632ab50bf469e88b8c4c4420))
* **validatefilter:** build targetds without macro triggers in comments ([42355f1](https://git.datacontroller.io/dc/dc/commit/42355f17bc07bf4d44ba858984d83b9ed72cc0ba))
* **validatefilter:** drop the dead nobs assignment, keep targetds precedence ([337ea8e](https://git.datacontroller.io/dc/dc/commit/337ea8e5e021c6f2451fa3ad8396148744118fc9))
### Features
* **dc_getlibs:** dump the library list when debug is on ([582523d](https://git.datacontroller.io/dc/dc/commit/582523dc87a253b71824359224070ccb4746a386))
* **derived:** allow a variable to shadow a physical column ([12beeff](https://git.datacontroller.io/dc/dc/commit/12beeff69518376a042fbbc58332acc40d89d132))
* **derived:** declare VAR_DEPENDENCIES instead of VAR_ORDER ([813bce6](https://git.datacontroller.io/dc/dc/commit/813bce685691ad252043eeab31eb5460de81b756))
* **derived:** optional VAR_LABEL on MPE_DERIVED ([7c9ce9a](https://git.datacontroller.io/dc/dc/commit/7c9ce9a712137e9825a5f2ff52796d765423147b))
* **derived:** pickers, and an ACTIVE default, for MPE_DERIVED ([87e794f](https://git.datacontroller.io/dc/dc/commit/87e794f769fe420f214f43b141f0b41cf88eefee))
* **derived:** report the dependency cycle path ([cee1a95](https://git.datacontroller.io/dc/dc/commit/cee1a954ee97e17f297b7cf43ef0e5cfc6e393a6))
* **derived:** take any text in VAR_LABEL, but not a line break ([78e88a4](https://git.datacontroller.io/dc/dc/commit/78e88a41128b047c0276ccf647f128229152604c))
* **derived:** validate MPE_DERIVED at submit time ([0b010f2](https://git.datacontroller.io/dc/dc/commit/0b010f2c65ddada70d62ea83c706943c6c45756f))
* **derived:** validation rules and a VAR_TYPE dropdown for MPE_DERIVED ([aa6d50e](https://git.datacontroller.io/dc/dc/commit/aa6d50e1ea5fc2fe6be8aa9fa859b92d0a1061c5))
* **derived:** VARIABLE_ORDER, so a derived variable can build on another ([33d043e](https://git.datacontroller.io/dc/dc/commit/33d043ea92f007dca70c95d5363329d4447eeba9))
* **editor:** derived variables in the EDIT screen ([b0fd354](https://git.datacontroller.io/dc/dc/commit/b0fd354ecdbd7bf29196ed983ff3cce691affeed))
* **editor:** mark derived variables as computed in the EDIT grid ([6513d09](https://git.datacontroller.io/dc/dc/commit/6513d09f059de15c5304f99b3f8d898d46ea1be7))
* **editor:** serve a truncated selection with a warning instead of aborting ([34691dc](https://git.datacontroller.io/dc/dc/commit/34691dceecb63f952b23ad8acc5bc93753c6cfee))
* **editor:** show the derived column label in the header tooltip ([cfe6647](https://git.datacontroller.io/dc/dc/commit/cfe6647ca870c45a3d8abc2753ce7f070e3fdf4a))
* **mocks:** add getauditfile and the audit-folder artefacts ([752d944](https://git.datacontroller.io/dc/dc/commit/752d944c2091ef6d2624a15ab2e2e7be35608798))
* **mocks:** implement derived variables in the JS services ([b4fae88](https://git.datacontroller.io/dc/dc/commit/b4fae88ae13658607e4209449577cd42849d3a99))
* **mocks:** validate a submitted MPE_DERIVED row like the hook does ([5d183d1](https://git.datacontroller.io/dc/dc/commit/5d183d135085ea023f32e5b1d4992d997394a8fb))
* run startupservice in a new window from its error modal ([40f6133](https://git.datacontroller.io/dc/dc/commit/40f61331aad6bc8a52c551b924f0d1f1e6426cee))
* **sites:** mint a licence for the published sites ([7d9df71](https://git.datacontroller.io/dc/dc/commit/7d9df71fdb0ab265e745ed3b708038983e855a87))
### Reverts
* **derived:** no shadowing, and no self reference ([5af8e6f](https://git.datacontroller.io/dc/dc/commit/5af8e6fc359221586af74a685159dfd75a40ec45))
* **stagedata.test.4:** back to the MPE_LOADS evidence for now ([31aa859](https://git.datacontroller.io/dc/dc/commit/31aa8591538409fc1a3ef97d7474120094ce202d))
# [7.18.0](https://git.datacontroller.io/dc/dc/compare/v7.17.0...v7.18.0) (2026-10-19)
+1 -1
View File
@@ -8,7 +8,7 @@ This file is the shared glossary and orientation for the repo. When your output
- `client/` - the Angular frontend (TypeScript). Uses [Handsontable](.agents/skills/handsontable/SKILL.md) for the editable grid and [HyperFormula](.agents/skills/hyperformula/SKILL.md) for Excel-formula support. Lint with `npm run lint:check` from `client/`.
- `sas/` - the SAS backend. Services, macros, hooks and tests live under `sas/sasjs/**`; `sas/sasjsbuild/` is generated output (never hand-edit). Lint `.sas` files with `sasjs lint` from `sas/`.
- `.agents/docs/` - technical deep-dives (see below). `.agents/skills/` - task skills (handsontable, hyperformula, dc-sas for Data Controller SAS development, dc-estate-testing for deploying to and testing on the shared estate, plus npx-installed sasjs/ sas skills).
- `.agents/docs/` - technical deep-dives (see below). `.agents/skills/` - task skills (handsontable, hyperformula, dc-sas for Data Controller SAS development, plus npx-installed sasjs/ sas skills).
## Roles
+2 -25
View File
@@ -26,9 +26,8 @@ The standard [pre-commit](https://pre-commit.com) route is available as well, an
[SheetJS Pro Version](https://www.npmjs.com/package/sheetjs)
To auth for SheetJS Pro version you need to use their private registry. Keep that config out of
the repo - `client/.npmrc` is tracked and holds policy only (see below). Put the registry auth in
your user config, `~/.npmrc`:
To auth for SheetJS Pro version you need to use their private registry
Add `client/.npmrc` file with following content:
```
@sheet:registry=https://pylon.sheetjs.com:54111/
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
@@ -47,30 +46,8 @@ echo "YOUR_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--decrypt ./libraries/sheet-crypto.tgz.gpg
npm i
npm run postinstall
```
The root and `sas/` `.npmrc` files set `ignore-scripts=true` too, and the root `package.json`
carries no install script - the root install is `npm ci` plus the subprojects, each run explicitly:
```bash
npm ci # root tooling: gitleaks, prettier, semantic-release
cd client && npm ci && npm run postinstall
cd ../sas && npm ci
```
The client `.npmrc` is now tracked and sets `ignore-scripts=true`, so dependency install hooks do not
run. Two of them matter here: the client's own `postinstall` - which generates
`src/environments/version.ts`, imported throughout the app - and cypress's binary download. Run
`npm run postinstall` after installing (and `npx cypress install` if you are running the Cypress
suite).
Registry credentials do **not** belong in `client/.npmrc`: it is a tracked policy file, and a token
written there is one `git add -A` away from being committed. Keep the token in `~/.npmrc`, npm's
default user config, or point npm at a file outside the repo with
`NPM_CONFIG_USERCONFIG=/path/to/npmrc`. The pre-commit hook rejects a staged `client/.npmrc` that
holds `_authToken`, `_auth` or `_password`.
[Handsontable](https://www.npmjs.com/package/handsontable)
The licence key comes from the backend licence: the decrypted licence carries `hot_license_key`,
-5
View File
@@ -1,5 +0,0 @@
ignore-scripts=true
save-exact=true
# dependency install hooks are skipped: the client's own postinstall generates
# src/environments/version.ts and the hot icons, so run `npm run postinstall`
# after installing - see CONTRIBUTING.md for the full sequence
-59
View File
@@ -1,4 +1,3 @@
import fs from 'fs'
import { defineConfig } from 'cypress'
export default defineConfig({
@@ -35,65 +34,7 @@ export default defineConfig({
e2e: {
video: true,
// One retry in a CI run, none interactively. `editor.cy.ts` test 28
// intermittently fails on "app-loading ... continuously found" - a slow load,
// not an assertion - and a single retry costs a few seconds where a red
// pipeline costs forty minutes. Failures that are real still fail twice, and
// the retried attempt's output is in the run log.
retries: { runMode: 1, openMode: 0 },
setupNodeEvents(on, config) {
// `download.cy.ts` clears the downloads folder before each test through
// `cy.task('deleteFolder')`. That task was registered in
// cypress/plugins/index.js, which Cypress 10+ never loads unless it is
// required from here - so the task had no handler and the spec failed on
// its first hook.
on('task', {
deleteFolder(folderName: string) {
return new Promise((resolve, reject) => {
fs.rm(folderName, { recursive: true, force: true }, (err) =>
err ? reject(err) : resolve(null)
)
})
},
/**
* Writes a copy of a fixture whose primary keys are renumbered from a
* random base, and returns the path to write it to.
*
* An upload that matches what the table already holds produces no
* changes, and a changeset with no changes has no approval waiting - so
* re-running a spec that uploads the same file finds nothing on the
* approve screen. That is not a UI fault, and a retry after a partial
* first attempt hits it every time. New keys make the upload a real
* insert whichever attempt gets there.
*/
makeUniqueExcel({ source, dest }: { source: string; dest: string }) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ExcelJS = require('exceljs')
const workbook = new ExcelJS.Workbook()
const base = 1000000 + Math.floor(Math.random() * 8000000)
return workbook.xlsx.readFile(source).then(() => {
const sheet = workbook.worksheets[0]
let rowNumber = 0
sheet.eachRow((row: any, index: number) => {
if (index === 1) return // header
row.getCell(1).value = base + rowNumber++
})
return workbook.xlsx.writeFile(dest).then(() => dest)
})
},
/** removes one file - deleteFolder would take the source fixture too */
deleteFile(filePath: string) {
return new Promise((resolve) => {
;(fs as any).rm(filePath, { force: true }, () => resolve(null))
})
}
})
// Pin the browser locale so locale-formatted cells (intl-date/time/datetime)
// render deterministically regardless of the runner's system locale.
on('before:browser:launch', (browser, launchOptions) => {
+2 -23
View File
@@ -93,29 +93,8 @@ context('derived variables: ', function () {
'not.have.class',
'dc-derived-cell'
)
// The computed block opens at whichever derived column comes first in the
// grid - MPE_DERIVED order, so LINE_VALUE here, not REGION. Assert the
// invariant rather than a name: exactly one derived header carries the
// marker, and it is the first one.
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
const derived = [...$ths].filter((th) =>
th.classList.contains('dc-derived-header')
)
expect(derived.length, 'derived headers').to.be.greaterThan(0)
expect(
derived[0].classList.contains('dc-derived-first'),
'the first derived header opens the block'
).to.be.true
derived
.slice(1)
.forEach(
(th) =>
expect(
th.classList.contains('dc-derived-first'),
'only the first derived header carries the marker'
).to.be.false
)
})
// REGION is the first derived column, so it opens the computed block
headerCell('REGION').should('have.class', 'dc-derived-first')
// the hover text carries the label, which is what says what the column is:
// the configured VAR_LABEL where there is one, otherwise the generated
+29 -178
View File
@@ -7,123 +7,41 @@ const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
const downloadsFolder = Cypress.config('downloadsFolder')
/* set by test 1 and removed afterwards, since it is written per attempt */
let generatedExcel = ''
import { deleteDownloadsFolder } from '../util/deleteDownloadFolder'
context('download files test: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
/* the estate ships no licence, so it runs on the free tier - and the free
tier disables file upload, which is the whole point of the audit case.
Asking for a licensed estate here means the spec does not depend on
another spec having planted a key first: the key persists in the estate's
config, so whether this case could run depended on the order the specs
were sharded into. */
cy.loginAndUpdateValidKey(true)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
// beforeAll already logged in. Typing into the login form unconditionally
// failed with "the center of this element is hidden from view" whenever the
// session was live and the form was not on screen, so only fill it when it
// is actually shown.
cy.get('body').then(($body) => {
const usernameInput = $body.find('input.username')[0]
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
cy.get('input.username').type(username)
cy.get('input.password').type(password)
cy.get('.login-group button').click()
}
})
cy.get('input.username').type(username)
cy.get('input.password').type(password)
cy.get('.login-group button').click()
visitPage('home')
})
this.afterEach(() => {
deleteDownloadsFolder()
if (generatedExcel) {
cy.task('deleteFile', 'cypress/fixtures/excels/' + generatedExcel)
generatedExcel = ''
}
})
it('1 | downloads audit file', (done) => {
/* The approve grid renders a download button per table that is waiting for
approval, and a freshly seeded estate has none, so create one: upload a
fixture to the test table and submit it from the stage screen. The row is
then found on the approve screen by the table it names, because the stage
route does not carry that table's id.
The submitted row is left behind: CI recreates the appLoc and re-runs
makedata before the specs. On an estate reused by hand the rows accumulate
and the approve grid paginates, so a row pushed past the first page would
not be found - the table name still anchors the assertion, but it cannot
rescue a row that is not on the page. */
/* the fixture is renumbered before every attempt: an upload identical to
what the table already holds produces no changes, and a changeset with no
changes leaves nothing waiting for approval. On a retry that is exactly
what happens - the first attempt's rows are already in the table - so the
keys come from a random base and the upload is always an insert. */
generatedExcel = 'generated_' + Date.now() + '.xlsx'
visitPage('approve/toapprove')
cy.task('makeUniqueExcel', {
source: 'cypress/fixtures/excels/regular_excel.xlsx',
dest: 'cypress/fixtures/excels/' + generatedExcel
})
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.btn.btn-success')
.should('be.visible')
.then((buttons) => {
buttons[0].click()
const id = buttons[0].id
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
attachExcelFile(generatedExcel)
submitExcel()
/* the stage route carries a load reference, not the table id: an upload
lands on /stage/DC20261031-201657.470_601010_4449 rather than on
/stage/<table id>, so taking the id from the URL looked for a button that
could never exist. The row is found by the table it names, and the id the
download is named after is read from the button itself. */
visitPage('review/approve')
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
cy.get('clr-datagrid clr-dg-row', { timeout: longerCommandTimeout }).should(
($rows) => {
const texts = Array.from($rows).map((row: any) =>
row.innerText.replace(/\s+/g, ' ')
)
expect(
texts.some((text: string) => text.includes('MPE_X_TEST')),
`no approval row names MPE_X_TEST - rows: ${texts
.slice(0, 5)
.join(' | ')
.slice(0, 300)}`
).to.be.true
}
)
cy.get('clr-datagrid clr-dg-row')
.filter(':contains("MPE_X_TEST")')
.first()
.find('[aria-label="Download audit file"]')
.should('have.length', 1)
.then(($btn) => {
const tableId = $btn.attr('id') as string
expect(tableId, 'the approve button carries the table id').to.be.a(
'string'
)
cy.wrap($btn).click()
checkForFileDownloaded(tableId, 'zip', () => done())
checkForFileDownloaded(id, 'zip', () => done())
})
})
})
@@ -154,7 +72,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('EXCEL')
.select('Excel')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -174,7 +92,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('SAS')
.select('SAS Datalines')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -194,7 +112,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('SAS_DDL')
.select('SAS DDL')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -214,7 +132,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('TSQL_DDL')
.select('TSQL DDL')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -234,7 +152,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('PGSQL_DDL')
.select('PGSQL DDL')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -247,26 +165,6 @@ context('download files test: ', function () {
})
})
it('8 | downloads viewer PDF report', (done) => {
visitPage('view/data')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
openDownloadModal(() => {
cy.get('select')
.select('PDF')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
const id = button[0].id
checkForFileDownloaded(id, 'pdf', () => done())
})
})
})
})
this.afterEach(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
})
@@ -300,23 +198,18 @@ const checkForFileDownloaded = (
}
const openDownloadModal = (callback?: any) => {
/* filterSide is the viewer's options dropdown; it no longer carries a
dropdown-toggle class - see filter-panel.cy.ts and row-cell-limits.cy.ts.
Its entries are div[clrDropdownItem], not buttons, so select Download by its
text the way filter-panel.cy.ts selects Filter. */
cy.get('.btn.btn-sm.btn-outline.filterSide', {
timeout: longerCommandTimeout
})
cy.get('.btn.btn-sm.btn-outline.filterSide.dropdown-toggle')
.click()
.then(() => {
/* the click handler is on the clrDropdownItem element, not on the span
holding the text, and the whole label is matched: more than one entry
may mention Download, and an ambiguous cy.contains fails outright */
cy.contains('clr-dropdown-menu [clrDropdownItem]', /^\s*Download\s*$/, {
timeout: longerCommandTimeout
}).click()
cy.get('clr-dropdown-menu button').then((buttons) => {
for (let button of buttons) {
if (button.innerText.toLowerCase().includes('download')) {
button.click()
if (callback) callback()
if (callback) callback()
}
}
})
})
}
@@ -355,45 +248,3 @@ const openTableFromTree = (libNameIncludes: string, tablename: string) => {
})
})
}
const fixturePath = 'excels'
/**
* Uploads a fixture to the open table. The editor renders the file input inside
* its upload modal, which the Upload button in the button bar opens, so that
* button is clicked first - by its label, not by position, so a change to the
* bar's button order cannot break this.
*/
const attachExcelFile = (excelFilename: string) => {
cy.get('.buttonBar button', { timeout: longerCommandTimeout })
.contains('Upload')
.click()
cy.get('input[type="file"]#file-upload', { timeout: longerCommandTimeout })
.should('have.length', 1)
.attachFile(`/${fixturePath}/${excelFilename}`)
/* a parsed upload raises the info modal with this title. The modal component
is the same one aborts use, so the title is what distinguishes them: any
other text here - an abort, a validation error - fails the case with its own
message rather than being clicked through. */
cy.get('.clr-abort-modal .modal-title', { timeout: longerCommandTimeout })
.should('be.visible')
.invoke('text')
.should('include', 'Table Found')
cy.get('.clr-abort-modal .modal-footer button').contains('Close').click()
}
const submitExcel = () => {
cy.get('.buttonBar button.preview-submit', {
timeout: longerCommandTimeout
}).click()
/* the URL is already a stage route before the submit lands, so waiting on it
proves nothing; the editor leaving preview mode is the signal that the
submit was accepted */
cy.get('.buttonBar button.preview-submit', {
timeout: longerCommandTimeout
}).should('not.exist')
}
+17 -437
View File
@@ -6,90 +6,44 @@ const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// SAS Visual Analytics data-driven content embed mode (`?embed=va`):
// - chrome is hidden (same as embed=true),
// - the editor opens READ-ONLY (the user clicks Edit, as in normal use),
// - the Filter button is hidden - VA filters replace the filter builder,
// - the Add Row / Add Record buttons are hidden.
// - the editor opens in edit mode immediately (not read-only),
// - the only action button is a single Submit below the grid (stubbed in v1),
// - the top button bar and Add Record are hidden.
//
// The detailed PK+label data-merge logic is covered by the VaMessagingService
// unit spec; here we assert the deterministic UI layout, that pushing a VA
// postMessage does not break the page, and (case 3) that a `va_parmap` URL
// mapping filters the column it names end to end.
/**
* Assert on the row count the editor shows. The assertion runs inside a Cy
* `should`, so it retries until the grid has loaded: reading the text once would
* capture the count from before a filter is applied.
*/
const parseRows = (text: string): number => {
const match = /\(([\d,]+) rows?/.exec(text)
return match ? Number(match[1].replace(/,/g, '')) : NaN
}
const expectRows = (check: (rows: number) => void) =>
cy.get('h4', { timeout: longerCommandTimeout }).should(($h4) => {
check(parseRows($h4.text()))
})
/** Assert the grid shrank, without pinning a fixture-specific number. */
const expectFewerRowsThan = (baseline: number) =>
expectRows((rows) => {
expect(rows, 'filtered row count').to.be.lessThan(baseline)
})
/** Read the unfiltered row count from the app itself, retrying until it loads. */
const readRowCount = (): Cypress.Chainable<number> =>
expectRows((rows) => {
expect(rows, 'row count').to.be.greaterThan(0)
})
.get('h4')
.invoke('text')
.then(parseRows)
// unit spec; here we only assert the deterministic UI layout and that pushing a
// VA postMessage does not break the page.
context('embed=va (VA data-driven content) tests: ', function () {
// How many rows the table has unfiltered. Captured once, from the app itself.
let unfilteredRows = 0
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey()
// Baseline for the relative row-count assertions: open the table with no VA
// message and record what the editor itself reports.
cy.visit(`${hostUrl}${appLocation}/#/editor/DC_JSLIB.MPE_X_TEST`, {
timeout: 60000
})
readRowCount().then((count) => {
unfilteredRows = count
})
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
// A cy.visit that differs from the current URL only in the hash is a
// same-document navigation, so the app is not re-bootstrapped. Reload so
// each test starts from a fresh document (the previous test may have left
// ?embed=va in the URL, which the app reads once at init).
cy.reload()
visitPage('home')
})
it('1 | opens the editor in VA mode: chrome hidden, read-only, no Filter button', () => {
it('1 | opens the editor in VA mode: chrome hidden, editable, single bottom Submit', () => {
openEditorInVaMode('mpe_x_test', () => {
// Chrome hidden (same as embed=true)
cy.get('header.app-header').should('not.exist')
// Grid present, read-only: the user enters edit mode with Edit, as usual
// Grid present and in edit mode (read-only Filter/Edit/Upload bar absent)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('.btnCtrl button').contains('Edit').should('exist')
// The filter builder is replaced by VA filters
cy.get('.btnCtrl button').contains('Edit').should('not.exist')
cy.get('.btnCtrl button').contains('Filter').should('not.exist')
// Not in edit mode, so no edit-mode controls
// Top edit-mode buttons hidden in VA mode
cy.get('.btnCtrl button.btn-outline-danger').should('not.exist') // Cancel
cy.get('.btnCtrl').contains('Add Row').should('not.exist')
cy.get('.btnCtrl').contains('Submit').should('not.exist')
// Bottom Add Record hidden in VA mode
// Bottom Add Record hidden
cy.contains('button', 'Add Record').should('not.exist')
// Exactly one Submit button, below the grid, disabled in v1
cy.get('button').filter(':contains("Submit")').should('have.length', 1)
cy.get('button').contains('Submit').should('be.disabled')
})
})
@@ -115,382 +69,14 @@ context('embed=va (VA data-driven content) tests: ', function () {
// Editor still loads in VA mode after the message (chrome hidden, grid up)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('header.app-header').should('not.exist')
cy.get('.btnCtrl button').contains('Edit').should('exist')
})
})
it('3 | va_parmap filters a column from a parameter that is not its name (tab 1)', () => {
// Two DC instances over one table (one per report tab), each filtering the
// same DC column from its own report parameter. Without the URL mapping the
// parameter label is not a column name, so nothing would filter at all. The
// mapping here is written in lower case against an upper-case label and
// column, which pins the case-insensitivity of both sides end to end.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
// The applied filter names the MAPPED column, and the grid shrinks to
// the rows matching that value (Option 2 occurs twice in this table).
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
'SOME_DROPDOWN'
)
cy.get('.infoBar-text').should('contain.text', "'Option 2'")
expectFewerRowsThan(unfilteredRows)
},
'va_parmap=dropdown_tab1-some_dropdown'
)
})
it('4 | the other tab maps its own parameter to the same column', () => {
// Same table, same column, a different parameter label and value (Option 1
// occurs four times) - the per-instance URL mapping is what keeps the two
// tabs independent.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB2',
dataType: 'string',
value: 'Option 1'
})
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
'SOME_DROPDOWN'
)
cy.get('.infoBar-text').should('contain.text', "'Option 1'")
expectFewerRowsThan(unfilteredRows)
},
'va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
)
})
it('5 | warns in the grid when a mapping names a column the table lacks', () => {
// Written by hand in the report's URL, so a typo must be visible to the
// report author: the parameter is skipped, and the reason is shown rather
// than leaving an unfiltered grid that looks like a working filter.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'VA parameter mapping problem'
)
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
cy.get('.va-parmap-warning').should('contain.text', 'NOT_A_COLUMN')
// VA mode hides the app header, so the banner must paint inside the
// viewport on its own - assert the real box, not just its presence.
cy.get('.va-parmap-warning').should(($alert) => {
const rect = $alert[0].getBoundingClientRect()
expect(rect.width, 'banner width').to.be.greaterThan(100)
expect(rect.height, 'banner height').to.be.greaterThan(20)
expect(rect.top, 'banner top').to.be.at.least(0)
expect(rect.bottom, 'banner bottom').to.be.at.most(
Cypress.config('viewportHeight')
)
})
cy.screenshot('va-parmap-banner', { capture: 'viewport' })
// Nothing was filtered: the row count matches the unfiltered baseline.
expectRows((rows) => expect(rows).to.equal(unfilteredRows))
},
'va_parmap=DROPDOWN_TAB1-NOT_A_COLUMN'
)
})
it('6 | warns straight away when a mapping entry cannot be parsed', () => {
// No separator, so the entry never becomes a mapping - reported before any
// VA message arrives.
openEditorInVaMode(
'mpe_x_test',
() => {
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'it needs a "-" between the VA parameter and the column name'
)
},
'va_parmap=DROPDOWN_TAB1'
)
})
it('7 | warns when a mapping names a parameter VA is not sending', () => {
// Parameter-side typo: the mapping never applies, so without the banner this
// is indistinguishable from a working filter over every row.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
// One message only tells us this object's parameter set is still being
// discovered; the warning waits for a message that introduces no new name.
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'not.exist'
)
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'names VA parameter "dropdown_tab_1", which VA is not sending'
)
expectRows((rows) => expect(rows).to.equal(unfilteredRows))
},
'va_parmap=DROPDOWN_TAB_1-SOME_DROPDOWN'
)
})
it('8 | applies a mapping from the buffered message on the very first load', () => {
// VA posts once on iframe load, before the app bootstraps, so the first
// getdata is already filtered from that buffered message - with no column
// metadata available, the mapping is what names the column.
visitWithBufferedMessage(
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN',
{
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
},
() => {
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
"SOME_DROPDOWN = 'Option 2'"
)
expectFewerRowsThan(unfilteredRows)
cy.url().should('match', /\/editor\/[^/]+\/\d+/)
}
)
})
it('9 | a bad mapping on the first load still ends at the banner', () => {
// The deferred first load cannot check the column against metadata, so its
// saveQuery may fail and fall back to an unfiltered load - the banner is
// what stops that reading as a working filter.
visitWithBufferedMessage(
'va_parmap=DROPDOWN_TAB1-NOT_A_COLUMN',
{
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
},
() => {
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'NOT_A_COLUMN'
)
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
// Deliberately no row-count assertion: this path cannot validate the
// column (no metadata yet), so the outcome depends on the platform - on
// SAS, mp_filtercheck aborts the save and the load falls back to
// unfiltered; the JS mock stores the filter and returns 0 rows. The
// banner is the part that must hold either way, because both outcomes
// are otherwise indistinguishable from a working filter.
}
)
})
it('10 | warns when two parameters filter the same column (an empty grid)', () => {
// Clauses are AND-ed, so two different values on one column produce no rows
// at all - the last silent-empty-grid path. Reported rather than rewritten,
// so the author decides whether to use one parameter carrying both values.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage(
{
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 1'
},
{
name: 'pr2',
label: 'DROPDOWN_TAB2',
dataType: 'string',
value: 'Option 2'
}
)
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'combined with AND'
)
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB2')
// The consequence: rows must match both clauses, and the two values
// differ, so none can match.
expectRows((rows) => expect(rows, 'matching rows').to.equal(0))
},
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN&va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
)
})
it('12 | a transform adds the previous quarter to the filter', () => {
// ADD(QUARTER_START,-1) is the reported requirement: one clause carrying the
// selected date and the same point one quarter earlier. 2026-10-01 = 24380,
// 2026-07-01 = 24288 in SAS days; nothing in the fixture matches either, so
// the assertion is on the clause rather than the row count.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'SELECTED_DATE',
dataType: 'string',
value: '01OCT2026'
})
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
'SOME_DATE IN ('
)
cy.get('.infoBar-text').should('contain.text', '24380')
cy.get('.infoBar-text').should('contain.text', '24288')
cy.get('h4').should('contain.text', '(0 rows')
},
'va_parmap=SELECTED_DATE-SOME_DATE:ADD(QUARTER_START,-1)'
)
})
it('13 | warns when a transform lands on a column it cannot apply to', () => {
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'SELECTED_DATE',
dataType: 'string',
value: 'x'
})
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'is not a date or datetime column'
)
cy.get('h4').should('contain.text', '(7 rows')
},
'va_parmap=SELECTED_DATE-SOME_CHAR:ADD(QUARTER_START,-1)'
)
})
it('11 | logs a parse-time problem once, however many messages arrive', () => {
// The parse problem is logged at app init; the editor's own loop must not log
// it again. The second entry is valid-but-missing, so the loop definitely runs
// and logs its own line - which is what makes this a real assertion.
cy.visit(
`${hostUrl}${appLocation}/index.html#/editor/DC_JSLIB.MPE_X_TEST?embed=va&va_parmap=DROPDOWN_TAB1&va_parmap=DROPDOWN_TAB2-NOT_A_COLUMN`,
{
onBeforeLoad(win: any) {
win.__warns = []
const original = win.console.warn.bind(win.console)
win.console.warn = (...args: any[]) => {
win.__warns.push(args.join(' '))
original(...args)
}
}
}
)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
pushVaMessage({
name: 'pr2',
label: 'DROPDOWN_TAB2',
dataType: 'string',
value: 'Option 2'
})
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'NOT_A_COLUMN'
)
cy.window().then((win: any) => {
const warns: string[] = win.__warns || []
const parseLines = warns.filter((w) =>
w.includes('va_parmap "DROPDOWN_TAB1"')
)
const runtimeLines = warns.filter((w) => w.includes('NOT_A_COLUMN'))
expect(parseLines.length, warns.join(' | ')).to.eq(1)
expect(runtimeLines.length, warns.join(' | ')).to.eq(1)
cy.get('button').contains('Submit').should('exist')
})
})
})
/**
* Loads the editor with a VA message already in the pre-bootstrap buffer, the
* way VA's own postMessage arrives before Angular starts. A distinct pathname is
* used so this is a real document load: a cy.visit that differs only in the hash
* is a same-document navigation and would not re-bootstrap the app.
*/
const visitWithBufferedMessage = (
extraParams: string,
parameter: any,
callback?: any
) => {
cy.visit(
`${hostUrl}${appLocation}/index.html#/editor/DC_JSLIB.MPE_X_TEST?embed=va&${extraParams}`,
{
onBeforeLoad(win: any) {
win.__vaLastMessage = {
t: Date.now(),
origin: win.location.origin,
data: {
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: [parameter]
}
}
}
}
)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
if (callback) callback()
}
/** Pushes one VA data-driven content message carrying the given parameters. */
const pushVaMessage = (...parameters: any[]) => {
cy.window().then((win) => {
win.postMessage(
{
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters
},
'*'
)
})
}
// Opens a table from the tree (which routes to #/editor/LIB.TABLE), then
// re-visits the same route with ?embed=va so the app parses VA embed mode.
// `extraParams` is appended to that query string (e.g. va_parmap entries).
const openEditorInVaMode = (
tablename: string,
callback?: any,
extraParams?: string
) => {
const openEditorInVaMode = (tablename: string, callback?: any) => {
openTableFromTree(libraryToOpenIncludes, tablename)
cy.get('#hotTable', { timeout: longerCommandTimeout })
@@ -498,13 +84,7 @@ const openEditorInVaMode = (
.then(() => {
cy.url().then((url) => {
const separator = url.includes('?') ? '&' : '?'
const extra = extraParams ? `&${extraParams}` : ''
cy.visit(`${url}${separator}embed=va${extra}`)
// A cy.visit that differs from the current URL only in the hash is a
// same-document navigation, so the app is not re-bootstrapped and the
// embed parameter (read once at app init) never takes effect. Reload to
// bootstrap it, as the VA iframe load does.
cy.reload()
cy.visit(`${url}${separator}embed=va`)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
-37
View File
@@ -193,43 +193,6 @@ context('excel multi load tests: ', function () {
})
})
// The file's own status line: size and stage. The stages are momentary, so
// the dataset lookups are held open to make "Fetching the dataset details"
// assertable (and visible in the recording).
it('4 | Shows the file size and the loading stage while the file is processed', () => {
attachExcelFile('multi_load_test_1.xlsx', () => {
cy.contains('File size:')
.should('be.visible')
.invoke('text')
.should('match', /File size:\s*[\d.]+ MB/)
// The sheet scan is quick; from then on the file waits for the user.
cy.get('#file-loading-stage', { timeout: longerCommandTimeout })
.should('be.visible')
.and('contain.text', 'File ready to load')
// The adapter posts to /SASjsApi/stp/execute/?_program=... - a glob on
// 'execute**' does not match the trailing slash, so match on the path.
cy.intercept({ url: /SASjsApi\/stp\/execute/ }, (req) => {
req.on('response', (res) => {
res.setDelay(1500)
})
})
cy.get('#continue-btn').trigger('click')
cy.get('#file-loading-stage', { timeout: longerCommandTimeout })
.should('be.visible')
.and('contain.text', 'Fetching the dataset details')
// Once every sheet has been extracted the stage gives way to the hint.
cy.get('#file-loading-stage', { timeout: longerCommandTimeout }).should(
'not.exist'
)
cy.contains('Please select a dataset on the left').should('be.visible')
})
})
this.afterEach(() => {
colorLog(`TEST END -------------`, '#3498DB')
})
-40
View File
@@ -419,46 +419,6 @@ context('excel tests: ', function () {
})
})
it('26 | Discarding an upload that fails validation clears the red cells', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// The loaded table's row count is what the grid must return to - the
// preview replaces the data source with the parsed file.
cy.get('h4.editor-title', { timeout: longerCommandTimeout })
.invoke('text')
.should('match', /\(\d+ rows?/)
.then((titleBefore) => {
const rowsBefore = titleBefore.match(/\(([\d,]+) rows?/)?.[1]
attachExcelFile('mixed_content_excel.xlsx', () => {
// The preview paints the cells that fail validation red - the state
// the user is looking at when they decide to abandon the file.
cy.get('#hotTable .htInvalid', {
timeout: longerCommandTimeout
}).should('have.length.greaterThan', 0)
cy.get('.buttonBar .btn-upload-preview.btn-outline-danger').click()
cy.get('clr-modal .modal-footer .btn-success-outline').click()
// Back to the loaded table, with no red cells left over. cancelEdit
// re-runs the HOT validation after restoring the data; without that
// the invalid cell meta survives the restore and the grid stays red.
cy.get('h4.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
`(${rowsBefore} rows`
)
cy.get('#hotTable .htInvalid').should('not.exist')
// And the page is back in its normal read-only state, not the
// preview's button bar.
cy.get('.btnCtrl .btn-primary', {
timeout: longerCommandTimeout
}).should('contain.text', 'Edit')
})
})
})
// Large files break Cypress
// it ('? | Uploads Excel with size of 5MB', (done) => {
+36 -45
View File
@@ -1,51 +1,35 @@
export {}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
const fixturePath = 'excels_general/'
context('metanav tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey(true)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
// beforeAll has already logged in. Typing into the login form here failed
// once the session existed - the form is not on screen, so Cypress reported
// "the center of this element is hidden from view" and the whole suite was
// skipped behind that hook.
cy.visit(hostUrl + appLocation)
cy.get('input.username').type(username)
cy.get('input.password').type(password)
cy.get('.login-group button').click()
visitPage('view/metadata')
})
it('1 | Opens metadata object', () => {
openFirstMetadataType()
/**
* Query the accordion at document level. The panel renders in the content
* area, while `.within(<tree node>)` scopes every following `cy.get` to
* that node alone - which finds nothing however long it retries.
*/
cy.get('.clr-accordion-header-button', { timeout: longerCommandTimeout })
.should('have.length.greaterThan', 0)
.first()
// Clarity puts the class on the button itself, so it is the click target.
.click()
// The panel reports its own open state, so the assertion is on the app's
// marker rather than on the content happening to be present.
cy.get('.clr-accordion-panel-open').should('have.length', 1)
cy.get('.clr-accordion-header-button')
.first()
.should('have.attr', 'aria-expanded', 'true')
// Opening the panel loads the object's details: the association tree and
// the attribute grid both come from `metanav/metadetails`.
cy.get('.clr-accordion-inner-content clr-tree-node', {
timeout: longerCommandTimeout
}).should('have.length.greaterThan', 0)
cy.get('clr-dg-row').should('have.length.greaterThan', 0)
cy.get('clr-dg-cell').contains('Name')
it('1 | Opens metadata object', (done) => {
openFirstMetadataFromTree(() => {
// BLOCKER
// For unkown reasons, .clr-accordion-header-button always null although it is present on the page.
cy.get('.clr-accordion-header-button').then((panelNodes: any) => {
panelNodes[0].querySelector('button').click()
})
})
})
this.afterEach(() => {
@@ -53,16 +37,23 @@ context('metanav tests: ', function () {
})
})
const openFirstMetadataType = () => {
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
// The metadata types are the tree nodes after the search box; the search box
// is its own node (`.search-node`), so selecting by position would tie the
// spec to that detail. The <p> is the element that owns the click handler.
cy.get('.nav-tree clr-tree > clr-tree-node:not(.search-node) p', {
timeout: longerCommandTimeout
})
.first()
.click()
const openFirstMetadataFromTree = (callback?: any) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let firstMetaNode
firstMetaNode = treeNodes[1]
cy.get(firstMetaNode).within(() => {
cy.get('.clr-treenode-content').click()
callback()
})
})
})
}
const visitPage = (url: string) => {
+49 -73
View File
@@ -22,7 +22,7 @@
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular-wrapper": "18.1.2",
"@sasjs/adapter": "5.1.2",
"@sasjs/utils": "4.3.1",
"@sasjs/utils": "4.0.0",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
"@types/d3-graphviz": "^2.6.7",
"@types/text-encoding": "0.0.35",
@@ -82,7 +82,6 @@
"@types/randombytes": "^2.0.3",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"archiver": "8.0.0",
"core-js": "^2.5.4",
"cypress": "^15.19.0",
"cypress-file-upload": "^5.0.8",
@@ -5209,12 +5208,6 @@
"node": ">=18.0.0"
}
},
"node_modules/@isaacs/nonrecursive-glob-match": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
"license": "BlueOak-1.0.0"
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
@@ -8729,9 +8722,9 @@
}
},
"node_modules/@sasjs/utils": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.3.1.tgz",
"integrity": "sha512-P98zjjfrhVJ7/ubKTWTn8Oiyb4/a+SIfVQOIa9oMXZrsRsZ5RTLj40sl6jmv1iCxbsqfziOrnNc0Yu6gx5Qp1A==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.0.tgz",
"integrity": "sha512-lARoCnRM4gSVf8ShoP5elHi7W28K/14MK037dQ0ABgMlP3+r6OuhAZYwK/a70tED47tKxyIoCAKLdP6jrG0ewQ==",
"license": "ISC",
"dependencies": {
"@fast-csv/format": "5.0.7",
@@ -9884,9 +9877,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz",
"integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz",
"integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9953,17 +9946,17 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
"integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz",
"integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1"
"@typescript-eslint/scope-manager": "8.72.0",
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/typescript-estree": "8.72.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -9978,15 +9971,15 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz",
"integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
"integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.72.1",
"@typescript-eslint/types": "^8.72.1",
"@typescript-eslint/tsconfig-utils": "^8.72.0",
"@typescript-eslint/types": "^8.72.0",
"debug": "^4.4.3"
},
"engines": {
@@ -10001,15 +9994,15 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz",
"integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz",
"integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1"
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/visitor-keys": "8.72.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -10020,9 +10013,9 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz",
"integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz",
"integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -10038,17 +10031,17 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz",
"integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz",
"integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/project-service": "8.72.1",
"@typescript-eslint/tsconfig-utils": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1",
"@typescript-eslint/project-service": "8.72.0",
"@typescript-eslint/tsconfig-utils": "8.72.0",
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/visitor-keys": "8.72.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -10067,14 +10060,14 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz",
"integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz",
"integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/types": "8.72.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -15253,9 +15246,9 @@
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
@@ -19287,7 +19280,6 @@
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
@@ -19609,9 +19601,9 @@
}
},
"node_modules/mocha/node_modules/argparse": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.4.tgz",
"integrity": "sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.3.tgz",
"integrity": "sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==",
"dev": true,
"funding": [
{
@@ -22277,22 +22269,6 @@
"url": "https://github.com/sponsors/yqnn"
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.3.tgz",
"integrity": "sha512-iny/40PEGkNJQ5/Pv6z3nm6FbRAby32GeZRjCUJk2WnNDbgPSTcdI+DDvLqsgBdD4cD5sMtMheqPPnehCCa8Nw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/nonrecursive-glob-match": "^1.0.3",
"brace-expansion": "^5.0.12"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/readdir-scoped-modules": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz",
@@ -25195,9 +25171,9 @@
}
},
"node_modules/vis-data": {
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.6.tgz",
"integrity": "sha512-6NjsyqWL18ithgkpPqfYGvwx7JlrxgO0MkMJdZus4eovFl6ebUjNcn/UEzgm5NWvCAz4psUgkvcdLyAXFxeeDg==",
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.5.tgz",
"integrity": "sha512-tRQKcTGaclo60rTY1j4v7BgD9v5jfD3wl+JgR1q6I4AI7go3QH5OjMCLIuZPpL2sf4IRMlwyPlRLnK4m+Phjsw==",
"dev": true,
"license": "(Apache-2.0 OR MIT)",
"peer": true,
@@ -25230,9 +25206,9 @@
}
},
"node_modules/vis-util": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vis-util/-/vis-util-6.0.3.tgz",
"integrity": "sha512-2ar+MN4JYYfhRSh+sds7o0Sg7/i/mNMVXv32DaMg5I4N1AJjTaHvXqNtHcISXl4WyaVkJ6twiCfqStudO2TYFg==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/vis-util/-/vis-util-6.0.2.tgz",
"integrity": "sha512-sYawqWllCqwmTPLurRgPChmfRppbWI/XoBWdGNM6N2s4XuVR8VO8gtz/v9AvrAAOfZxel/U5UBNOwyhFn0qb7A==",
"dev": true,
"license": "(Apache-2.0 OR MIT)",
"peer": true,
+32 -37
View File
@@ -3,44 +3,41 @@
"description": "DataController Client",
"angular-cli": {},
"scripts": {
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
"audit:prod": "npm audit --omit=dev",
"start": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng serve",
"prestart": "npm run generate-eula",
"generate-eula": "node ./src/eula.ts",
"license-checker": "node licenseChecker.js",
"prebuild": "node ./src/version.ts && npm run generate-eula && npm run license-checker",
"build": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration production",
"postbuild": "rimraf dist/3rdpartylicenses.txt",
"build-dev": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration development",
"build-watch": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration development --watch",
"check:archiver": "node scripts/check-archiver.mjs",
"sync": "./node_modules/.bin/watch --wait=3 \"echo Account: ${npm_config_account} && npm run deploy_${npm_config_server} --account=${npm_config_account} && notify-send 'Data Controller' 'App is synced!' ; echo 'App is synced!'\" dist",
"deploy_sas9": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/${npm_config_account}/dc/dev",
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
"viyabuild": "cd build; ./viyabuild.sh",
"lint": "npm run lint:fix",
"lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:fix:silent": "npx prettier --log-level silent --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check:silent": "npx prettier --log-level silent --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"test": "npx ng test",
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
"watch": "ng test watch=true",
"pree2e": "webdriver-manager update",
"e2e": "protractor protractor.config.js",
"postinstall": "node ./src/version.ts && npm run add-githook && node ./scripts/strip-clr-base64-fonts.mjs && node ./scripts/gen-hot-icons.mjs",
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
"cypress": "cypress open",
"cy:run": "cypress run",
"audit:prod": "npm audit --omit=dev",
"sasdocs": "sasjs doc && ./sasjs/utils/deploydocs.sh",
"compodoc:build": "compodoc -p tsconfig.doc.json --name 'Data Controller Client'",
"compodoc:build-and-serve": "compodoc -p tsconfig.doc.json -s --name 'Data Controller Client'",
"compodoc:serve": "compodoc -s --name 'Data Controller Client'",
"cy:run": "cypress run",
"cypress": "cypress open",
"deploy_sas9": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/${npm_config_account}/dc/dev",
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
"e2e": "protractor protractor.config.js",
"generate-eula": "node ./src/eula.ts",
"license-checker": "node licenseChecker.js",
"lighthouse": "lhci autorun",
"lint": "npm run lint:fix",
"lint:check": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check:silent": "npx prettier --log-level silent --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:fix:silent": "npx prettier --log-level silent --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"ng": "ng",
"postbuild": "rimraf dist/3rdpartylicenses.txt",
"postinstall": "node ./src/version.ts && npm run add-githook && node ./scripts/strip-clr-base64-fonts.mjs && node ./scripts/gen-hot-icons.mjs",
"prebuild": "node ./src/version.ts && npm run generate-eula && npm run license-checker",
"pree2e": "webdriver-manager update",
"prestart": "npm run generate-eula",
"sasdocs": "sasjs doc && ./sasjs/utils/deploydocs.sh",
"start": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng serve",
"sync": "./node_modules/.bin/watch --wait=3 \"echo Account: ${npm_config_account} && npm run deploy_${npm_config_server} --account=${npm_config_account} && notify-send 'Data Controller' 'App is synced!' ; echo 'App is synced!'\" dist",
"test": "npx ng test",
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
"test:tz-east": "TZ=Pacific/Auckland npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI --include='**/va-*.spec.ts'",
"test:tz-west": "TZ=America/New_York npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI --include='**/va-*.spec.ts'",
"viyabuild": "cd build; ./viyabuild.sh",
"watch": "ng test watch=true"
"ng": "ng"
},
"private": true,
"dependencies": {
@@ -59,7 +56,7 @@
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular-wrapper": "18.1.2",
"@sasjs/adapter": "5.1.2",
"@sasjs/utils": "4.3.1",
"@sasjs/utils": "4.0.0",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
"@types/d3-graphviz": "^2.6.7",
"@types/text-encoding": "0.0.35",
@@ -119,7 +116,6 @@
"@types/randombytes": "^2.0.3",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"archiver": "8.0.0",
"core-js": "^2.5.4",
"cypress": "^15.19.0",
"cypress-file-upload": "^5.0.8",
@@ -153,12 +149,11 @@
"lighthouse": "13.4.0",
"fast-uri": "4.2.3",
"readdir-glob": {
"brace-expansion": "5.0.12",
"minimatch": "11.0.3"
"brace-expansion": "^5.0.9"
},
"exceljs": {
"archiver": "8.0.0",
"unzipper": "0.12.5"
"archiver": "^8.0.0",
"unzipper": "^0.12.5"
}
}
}
-74
View File
@@ -1,74 +0,0 @@
/* Guards the readdir-glob -> minimatch override in client/package.json.
*
* The override forces minimatch 11 where readdir-glob asks for ^10, and
* readdir-glob reaches minimatch on two paths: the directory walk, and the
* pattern/ignore matching behind archiver's glob(). Both are exercised here,
* because a matcher that breaks shows up as files silently skipped rather than
* as an error.
*
* Entries are read from archiver's own 'entry' events, so nothing has to be
* unzipped and the check needs no reader dependency.
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { ZipArchive } from 'archiver'
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-arch-'))
const source = path.join(root, 'source')
const cleanup = () => fs.rmSync(root, { recursive: true, force: true })
const fail = (message) => {
console.error(message)
cleanup()
process.exit(1)
}
fs.mkdirSync(source)
fs.writeFileSync(path.join(source, 'a.txt'), 'hello')
fs.mkdirSync(path.join(source, 'sub'))
fs.writeFileSync(path.join(source, 'sub', 'b.txt'), 'world')
fs.writeFileSync(path.join(source, 'sub', 'c.md'), 'ignored by pattern')
let runs = 0
const archiveWith = (configure) =>
new Promise((resolve, reject) => {
const out = path.join(root, `test-${++runs}.zip`)
const sink = fs.createWriteStream(out)
const archive = new ZipArchive({ zlib: { level: 9 } })
const names = []
archive.on('entry', (entry) => names.push(entry.name))
archive.on('error', reject)
sink.on('error', reject)
sink.on('close', () => resolve(names))
archive.pipe(sink)
configure(archive)
archive.finalize()
})
try {
/* the directory walk: every file, nested directories included */
const walked = await archiveWith((archive) => archive.directory(source, false))
const missing = ['a.txt', 'sub/b.txt'].filter((n) => !walked.includes(n))
if (missing.length) {
fail('directory walk missed: ' + missing.join(', ') + ' (got: ' + walked.join(', ') + ')')
}
/* pattern and ignore matching, which is minimatch used directly */
const globbed = await archiveWith((archive) =>
archive.glob('**/*.txt', { cwd: source, ignore: ['sub/**'] })
)
if (!globbed.includes('a.txt')) {
fail('glob did not match a.txt (got: ' + globbed.join(', ') + ')')
}
if (globbed.some((n) => n.startsWith('sub/'))) {
fail('glob ignore did not exclude sub/ (got: ' + globbed.join(', ') + ')')
}
console.log('archiver walk ok: ' + walked.join(', '))
console.log('archiver glob ok: ' + globbed.join(', '))
cleanup()
} catch (err) {
fail('archiver error: ' + err.message)
}
-18
View File
@@ -1,5 +1,4 @@
import { QueryClause } from './models/TableData'
import { VaParmapTarget } from './shared/utils/parse-va-parmap'
/**
* Filtering cache info, to be reused when filtering modal is re-open
@@ -62,21 +61,6 @@ export const globals: {
* Defaults to 'live'; toggled by the Auto-apply checkbox in the editor.
*/
vaApplyMode: 'live' | 'confirm'
/**
* Explicit VA parameter -> DC column mappings, parsed from the `va_parmap`
* URL parameter (see parseVaParmap). Empty unless configured; entries override
* VA's default same-name (parameter label -> column) resolution, so two DC
* instances over one table can filter the same column from differently-named
* VA parameters.
*/
vaParamMap: Map<string, VaParmapTarget>
/**
* `va_parmap` entries that could not be used at all (not a plain SAS column
* name, or missing a side of the `-`). Surfaced to the report author, because
* these are hand-written in the report's URL and a typo otherwise looks like
* a working, unfiltered view.
*/
vaParmapProblems: string[]
rootParam: string
dcLib: string
xlmaps: XLMapListItem[]
@@ -93,8 +77,6 @@ export const globals: {
} = {
embed: false,
vaApplyMode: 'live',
vaParamMap: new Map(),
vaParmapProblems: [],
rootParam: <string>'',
dcLib: '',
xlmaps: [],
+1 -1
View File
@@ -235,7 +235,7 @@
@if (!errTop) {
<app-alerts></app-alerts>
}
<app-sas-logs-modal [(opened)]="sasLogsModal"></app-sas-logs-modal>
<app-requests-modal [(opened)]="requestsModal"></app-requests-modal>
<app-excel-password-modal></app-excel-password-modal>
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
+5 -21
View File
@@ -12,7 +12,6 @@ import '@clr/icons'
import '@clr/icons/shapes/all-shapes'
import { globals } from './_globals'
import { parseEmbedParam } from './shared/utils/parse-embed-param'
import { parseVaParmap } from './shared/utils/parse-va-parmap'
import moment from 'moment'
import { EventService } from './services/event.service'
import { AppService } from './services/app.service'
@@ -68,7 +67,7 @@ export class AppComponent {
public licenceProblem = this.licenceService.licenceProblem
public appOverCapacity: boolean = false
public appActive: boolean | null = null
public sasLogsModal: boolean = false
public requestsModal: boolean = false
public showRegistration: boolean = true
public startupDataLoaded: boolean = false
public startupSteps: StartupStep[] = []
@@ -164,24 +163,9 @@ export class AppComponent {
globals.embed = embed
this.embed = embed
}
// VA parameter -> DC column mappings for this instance, held on globals
// (like `embed` and `vaApplyMode`) so in-app navigation that rebuilds the
// editor keeps them - e.g. returning from the stage / approve pages.
if (globals.embed === 'va') {
const parmap = parseVaParmap(hashQuery ?? '')
globals.vaParamMap = parmap.map
globals.vaParmapProblems = parmap.problems
// Visible in the browser console as well as the editor banner: the entry
// is hand-written in the report's URL, so a typo has to be findable when
// someone is looking at the report rather than at DC's UI.
for (const problem of parmap.problems) {
// eslint-disable-next-line no-console
console.warn('Data Controller: ' + problem)
}
}
this.subscribeToShowAbortModal()
this.subscribeToSasLogsModal()
this.subscribeToRequestsModal()
this.subscribeToStartupData()
this.subscribeToAppActive()
this.subscribeToDemoLimitModal()
@@ -353,9 +337,9 @@ export class AppComponent {
/**
* Opens requests modal when requested from event service
*/
public subscribeToSasLogsModal() {
this.eventService.onSasLogsModalOpen.subscribe((value: boolean) => {
this.sasLogsModal = true
public subscribeToRequestsModal() {
this.eventService.onRequestsModalOpen.subscribe((value: boolean) => {
this.requestsModal = true
})
}
+2 -2
View File
@@ -19,7 +19,7 @@ import { LicensingGuard } from './routes/licensing.guard'
import { UsernavRouteComponent } from './routes/usernav-route/usernav-route.component'
import { AppService } from './services/app.service'
import { InfoModalComponent } from './shared/abort-modal/info-modal.component'
import { SasLogsModalComponent } from './shared/sas-logs-modal/sas-logs-modal.component'
import { RequestsModalComponent } from './shared/requests-modal/requests-modal.component'
import { DirectivesModule } from './directives/directives.module'
import { ViyaApiExplorerComponent } from './viya-api-explorer/viya-api-explorer.component'
import { NgxJsonViewerModule } from 'ngx-json-viewer'
@@ -32,7 +32,7 @@ import { AppSettingsService } from './services/app-settings.service'
ReviewRouteComponent,
ReviewRouteComponent,
UsernavRouteComponent,
SasLogsModalComponent,
RequestsModalComponent,
InfoModalComponent,
ViyaApiExplorerComponent
],
+2 -31
View File
@@ -1,36 +1,7 @@
<main class="content-area position-relative">
<!-- An embedded instance (a report object) has no report author who can deploy:
showing the wizard there only produces a screen whose buttons cannot work,
because the deployment needs an authenticated, non-embedded session. Name
the state instead, and keep the wizard for standalone use. -->
@if (isEmbedded) {
<div class="clr-row">
<div id="not-configured" class="card">
<div class="card-header">Data Controller is not configured</div>
<div class="card-block">
<div class="card-text">
<p class="mt-0">
This Data Controller instance has not been deployed yet, so it
cannot be shown inside a report.
</p>
<p>
Deployment has to be done once, in its own browser tab, by someone
who can write to the application location:
<a [href]="deployUrl" target="_blank">{{ deployUrl }}</a>
</p>
<p>
When it is configured, reload the report and this object will show
the editor.
</p>
</div>
</div>
</div>
</div>
}
<div class="clr-row">
<!-- T&C section -->
@if (!isEmbedded && step === 0) {
@if (step === 0) {
<div id="TCS" class="card">
<div class="card-header">Terms and Conditions</div>
<div class="card-block">
@@ -64,7 +35,7 @@
</div>
<!-- T&C section end -->
@if (!isEmbedded && step > 0) {
@if (step > 0) {
@switch (true) {
@case (sasJsConfig.serverType === ServerType.SasViya) {
<div class="autodeploy-section card">
@@ -1,229 +0,0 @@
import { NO_ERRORS_SCHEMA } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { ServerType } from '@sasjs/utils/types/serverType'
import { DeployComponent } from './deploy.component'
import { standaloneUrl } from './standalone-url'
import { globals } from '../_globals'
import { AppStoreService } from '../services/app-store.service'
import { SasService } from '../services/sas.service'
/**
* An embedded instance cannot deploy itself: the deployment needs an
* authenticated, standalone session, and the person looking at a report object
* is not the person who installs the app. The component therefore withholds the
* wizard and names the state instead.
*
* These cases render the template, so they fail if the `!isEmbedded` guards in
* deploy.component.html are dropped - asserting the getter alone would not.
*/
describe('DeployComponent embed guard', () => {
let fixture: ComponentFixture<DeployComponent>
let component: DeployComponent
const originalEmbed = globals.embed
const configure = () =>
TestBed.configureTestingModule({
declarations: [DeployComponent],
providers: [
{
provide: AppStoreService,
useValue: {
getDcAdapterSettings: () => ({
appLoc: '/Public/app/dc',
dcPath: '/tmp/dc'
})
}
},
{
provide: SasService,
useValue: {
getSasjsInstance: () => ({}),
getSasjsConfig: () => ({ serverType: ServerType.Sasjs })
}
}
],
// The wizard renders child components this spec does not exercise.
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents()
const render = () => {
fixture = TestBed.createComponent(DeployComponent)
component = fixture.componentInstance
fixture.detectChanges()
}
const tcsSection = () =>
fixture.nativeElement.querySelector('#TCS') as HTMLElement | null
const notConfigured = () =>
fixture.nativeElement.querySelector('#not-configured') as HTMLElement | null
const deploySection = () =>
fixture.nativeElement.querySelector(
'.autodeploy-section'
) as HTMLElement | null
afterEach(() => {
globals.embed = originalEmbed
TestBed.resetTestingModule()
})
it('offers the wizard when running in its own tab', () => {
globals.embed = false
configure()
render()
expect(tcsSection()).toBeTruthy()
expect(notConfigured()).toBeNull()
expect(component.isEmbedded).toBeFalse()
})
it('replaces the wizard with the notice for a VA embed', () => {
globals.embed = 'va'
configure()
render()
expect(tcsSection()).toBeNull()
expect(notConfigured()).toBeTruthy()
})
it('replaces the wizard with the notice for a plain embed too', () => {
globals.embed = true
configure()
render()
expect(tcsSection()).toBeNull()
expect(notConfigured()).toBeTruthy()
})
it('names the standalone URL in the embedded notice', () => {
globals.embed = 'va'
configure()
render()
const link = notConfigured()!.querySelector('a') as HTMLAnchorElement
expect(link.getAttribute('href')).toBe(component.deployUrl)
expect(link.textContent).toBe(component.deployUrl)
})
it('withholds the deploy section after the terms are accepted, when embedded', () => {
globals.embed = 'va'
configure()
render()
component.step = 1
fixture.detectChanges()
expect(deploySection()).toBeNull()
})
it('shows the deploy section after the terms are accepted, standalone', () => {
globals.embed = false
configure()
render()
component.step = 1
fixture.detectChanges()
expect(deploySection()).toBeTruthy()
})
})
describe('DeployComponent onNavigateToHome', () => {
const originalEmbed = globals.embed
const originalHref = window.location.href
afterEach(() => {
globals.embed = originalEmbed
history.replaceState(null, '', originalHref)
TestBed.resetTestingModule()
})
it('opens the standalone URL, merging an embed tab into the app URL', () => {
// Karma serves the runner with its own query, so the app URL is set here
// rather than read from window.location: the assertion has to see the
// app's query survive the embed parameters being dropped. The shape is
// the real SAS 9 one - the app's `_program` query in the search part,
// the embed switches in the hash query.
history.replaceState(
null,
'',
'/deploy-tc?_program=/Public/app/dc&_debug=131#/editor?embed=va&va_parmap=A-B'
)
const open = spyOn(window, 'open')
TestBed.configureTestingModule({
declarations: [DeployComponent],
providers: [
{
provide: AppStoreService,
useValue: { getDcAdapterSettings: () => ({ appLoc: '', dcPath: '' }) }
},
{
provide: SasService,
useValue: {
getSasjsInstance: () => ({}),
getSasjsConfig: () => ({ serverType: ServerType.Sasjs })
}
}
],
schemas: [NO_ERRORS_SCHEMA]
})
const fixture = TestBed.createComponent(DeployComponent)
fixture.componentInstance.onNavigateToHome()
const opened = open.calls.mostRecent().args[0] as string
expect(opened).toBe(
window.location.origin + '/deploy-tc?_program=/Public/app/dc&_debug=131'
)
expect(opened).not.toContain('#')
expect(opened).not.toContain('embed=')
})
})
describe('standaloneUrl', () => {
const origin = window.location.origin
it('keeps the path of a plain URL', () => {
expect(standaloneUrl(`${origin}/SASJobExecution/dc`)).toBe(
`${origin}/SASJobExecution/dc`
)
})
it('keeps a query the app needs, and drops the hash route', () => {
expect(standaloneUrl(`${origin}/path?x=1#/deploy`)).toBe(
`${origin}/path?x=1`
)
})
it('keeps `_program=`, which is how SAS 9 and Viya address the app', () => {
// The whole app URL is the query here: /SASJobExecution/?_program=... is
// the standalone app, so dropping the query would open a blank executor.
const sas9 =
`${origin}/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice` +
'&_debug=131'
expect(standaloneUrl(sas9)).toBe(sas9)
})
it('drops a bare embed query', () => {
expect(standaloneUrl(`${origin}/app?embed=va`)).toBe(`${origin}/app`)
})
it('drops the va_parmap entries along with the embed switch', () => {
expect(
standaloneUrl(
`${origin}/app?embed=va&va_parmap=A-B&va_parmap=C-D#/editor/x`
)
).toBe(`${origin}/app`)
})
it('keeps a query parameter that merely starts with va_', () => {
expect(standaloneUrl(`${origin}/app?embed=va&va_rowlimit=100`)).toBe(
`${origin}/app?va_rowlimit=100`
)
})
it('resolves a relative URL against the current origin', () => {
expect(standaloneUrl('/app/#/deploy')).toBe(`${origin}/app/`)
})
})
+1 -19
View File
@@ -4,8 +4,6 @@ import { SASjsConfig } from '@sasjs/adapter'
import { ServerType } from '@sasjs/utils/types/serverType'
import { AppStoreService } from '../services/app-store.service'
import { DcAdapterSettings } from '../models/DcAdapterSettings'
import { globals } from '../_globals'
import { standaloneUrl } from './standalone-url'
@Component({
selector: 'app-deploy',
@@ -46,20 +44,6 @@ export class DeployComponent implements OnInit {
this.setDeployDefaults()
}
/**
* True when DC is running inside a report rather than in its own tab. The
* deployment needs an authenticated, standalone session, so the wizard is not
* offered here - a report object cannot deploy the app that it is displaying.
*/
get isEmbedded(): boolean {
return !!globals.embed
}
/** The standalone URL an administrator deploys from, for the embedded notice. */
get deployUrl(): string {
return standaloneUrl(window.location.href)
}
/**
* Setting default values used for deploy request
*/
@@ -76,8 +60,6 @@ export class DeployComponent implements OnInit {
}
public onNavigateToHome() {
// Same helper as deployUrl: splitting on '#' left the query string in
// place, so a tab opened from `?embed=va#/deploy` stayed embedded.
window.open(standaloneUrl(window.location.href), '_blank')
window.open(location.href.split('#')[0], '_blank')
}
}
@@ -8,6 +8,20 @@
@if (autodeployDone) {
<p class="m-0 align-self-start">Done</p>
<hr class="w-100" />
@if (autoDeployStatus.deployServicePack !== null) {
<div class="deploy-status-row">
@if (autoDeployStatus.deployServicePack === true) {
<clr-icon
class="deploy-success"
shape="success-standard"
></clr-icon>
}
@if (!autoDeployStatus.deployServicePack === false) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
<p>Deploy SAS Jobs</p>
</div>
}
@if (autoDeployStatus.runMakeData !== null) {
<div class="deploy-status-row">
@if (autoDeployStatus.runMakeData) {
@@ -19,13 +33,16 @@
@if (autoDeployStatus.runMakeData === false) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
<p>Create control library</p>
<p>Create database</p>
</div>
}
<hr class="w-100" />
<div class="buttons">
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
@if (autoDeployStatus.runMakeData === false) {
@if (
autoDeployStatus.deployServicePack === false ||
autoDeployStatus.runMakeData === false
) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
LAUNCH
@@ -48,10 +65,10 @@
Close
</button>
<button
(click)="openSasLogsModal()"
(click)="openSasRequestsModal()"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
>
SAS Logs
SAS Requests
</button>
</div>
}
@@ -61,9 +78,9 @@
<h4 class="text-center my-15">Viya Deploy</h4>
<p class="deploy-intro">
Configure Data Controller for your Viya environment. The frontend and services
are already in place; this runs the one-off job that creates the control
library and its configuration data, then removes itself.
Configure and deploy Data Controller to your Viya environment. Deployment
creates the backend SAS services and the control library used by the
application.
</p>
<hr />
@@ -231,8 +248,19 @@
</div>
}
<!-- Runs the one-off makedata job. This is the screen's only action: the
frontend and services are already deployed. -->
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
<!-- <clr-checkbox-wrapper>
<input
clrCheckbox
[(ngModel)]="recreateDatabase"
(click)="recreateDatabaseClicked($event)"
type="checkbox"
checked
/>
<label>Recreate database</label>
</clr-checkbox-wrapper> -->
<hr />
<button
@@ -241,3 +269,53 @@
>
Deploy
</button>
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
<!-- <button
(click)="executeJson()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
[disabled]="!jsonFile"
>
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
</button> -->
<!-- <button
(click)="uploadJsonAuto.click()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
>
Upload different file to deploy
</button>
<input
#uploadJsonAuto
type="file"
hidden
(click)="clearUploadInput($event)"
(change)="onJsonFileChange($event)"
/> -->
<clr-modal [(clrModalOpen)]="recreateDatabaseModal" [clrModalClosable]="false">
<h3 class="modal-title">Warning</h3>
<div class="modal-body">
This action will recreate the database (if it exists). For an initial
deployment, this is expected. If this is a re-deployment, you will lose any
existing tables in <strong>{{ dcPath }}</strong
>.
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-sm btn-primary"
(click)="recreateDatabaseModal = false; recreateDatabase = false"
>
Cancel
</button>
<button
type="button"
class="btn btn-sm btn-primary"
(click)="recreateDatabaseModal = false; recreateDatabase = true"
>
Ok
</button>
</div>
</clr-modal>
@@ -1,153 +0,0 @@
import { SASjsConfig } from '@sasjs/adapter'
import { AutomaticComponent } from './automatic.component'
/**
* The Viya setup screen has one job: run `makedata` with the selections the
* form collected - the admin group, the DC path, and the compute context the
* job runs in. These tests pin that payload and the new-window URL, because a
* screen whose only action silently stopped calling the service would still
* compile, still render, and still look like it worked.
*/
describe('AutomaticComponent (Viya setup)', () => {
const build = () => {
const requests: any[][] = []
const sasJs: any = {
request: (...args: any[]) => {
requests.push(args)
return Promise.resolve({ result: [{}] })
}
}
const sasService: any = {
getSasjsConfig: () => new SASjsConfig(),
getExecutionPath: () => '/SASJobExecution/',
shouldLogin: { next: () => undefined },
viyaMakedataSuccessfull: () => Promise.resolve(true),
updateFileContent: () => Promise.resolve()
}
const component = new AutomaticComponent(
{
showAbortModal: () => undefined,
showInfoModal: () => undefined
} as any,
{ downloadFile: () => undefined } as any,
sasService,
{} as any,
{ error: () => undefined } as any,
{} as any
)
component.sasJs = sasJs
const config = {
contextName: 'Compute Reusable',
serverUrl: 'https://viya.example.com',
appLoc: '/Users/demo/dc'
} as any
component.sasJsConfig = config
// The new-window URL is built from the adapter config the component holds,
// while the request path reads the @Input one.
component.sasjsConfig = config
component.appLoc = '/Users/demo/dc'
component.dcPath = '/export/dc'
component.selectedAdminGroup = 'viyagroup18'
return { component, requests, sasService }
}
it('posts makedata with the admin group and the DC path', async () => {
const { component, requests } = build()
component.deployInNewWindow = false
await component.createDatabase()
expect(requests[0][0]).toBe('services/admin/makedata')
expect(requests[0][1]).toEqual({
fromjs: [{ ADMIN: 'viyagroup18', DCPATH: '/export/dc' }]
})
})
it('runs the job in the configured context, on the web method', async () => {
const { component, requests } = build()
component.deployInNewWindow = false
await component.createDatabase()
expect(requests[0][2]).toEqual({
useComputeApi: null,
contextName: 'Compute Reusable',
debug: true
})
})
it('prefers the context chosen in the form over the configured one', async () => {
const { component, requests } = build()
component.deployInNewWindow = false
component.computeContexts = [
{ id: 'ctx-1', name: 'Selected Context' } as any
]
component.selectedComputeContext = 'ctx-1'
await component.createDatabase()
expect(requests[0][2].contextName).toBe('Selected Context')
})
it('opens makedata in a new window with the selections as URL parameters', () => {
const { component } = build()
const opened: string[] = []
spyOn(window, 'open').and.callFake((url?: any) => {
opened.push(String(url))
return null
})
spyOn(component as any, 'pollMakedataCompletion')
component.deployInNewWindow = true
component.createDatabase()
expect(opened.length).toBe(1)
expect(opened[0]).toContain('/Users/demo/dc/services/admin/makedata')
expect(opened[0]).toContain('_contextname=Compute%20Reusable')
expect(opened[0]).toContain('admin=viyagroup18')
expect(opened[0]).toContain('dcpath=%2Fexport%2Fdc')
expect(opened[0]).toContain('_debug=131')
})
it('percent-encodes the values that need it in the new-window URL', () => {
// A context name or admin group can hold characters that would otherwise
// split the query string - an ampersand would start a new parameter and a
// space would arrive truncated.
const { component } = build()
const opened: string[] = []
spyOn(window, 'open').and.callFake((url?: any) => {
opened.push(String(url))
return null
})
component.runMakedataInNewWindow({
contextName: 'SAS Job Execution & co',
admin: 'group 1+2',
dcPath: '/export/dc data'
})
expect(opened[0]).toContain('_contextname=SAS%20Job%20Execution%20%26%20co')
expect(opened[0]).toContain('admin=group%201%2B2')
expect(opened[0]).toContain('dcpath=%2Fexport%2Fdc%20data')
// the parameter names themselves are not encoded
expect(opened[0]).toContain('&admin=group%201%2B2')
})
it('starts the job when deploy is pressed', () => {
// The screen's only button calls this; a version where it stopped doing so
// would leave the setup page unable to do anything at all.
const { component } = build()
const spy = spyOn(component, 'createDatabase').and.returnValue(
Promise.resolve() as any
)
component.runAutoDeploy()
expect(spy).toHaveBeenCalled()
})
})
@@ -51,8 +51,17 @@ export class AutomaticComponent implements OnInit {
public selectedComputeContext: string = ''
public makeDataResponse: string = ''
public jsonFile: any = null
public autodeploying: boolean = false
public autodeployDone: boolean = false
public recreateDatabaseModal: boolean = false
public isSubmittingJson: boolean = false
public isJsonSubmitted: boolean = false
/**
* Default was `false` when deploy was done with frontend and backend separately.
* Now we are using only streaming app, so we always want to recreate database (makedata)
*/
public recreateDatabase: boolean = true
public createDatabaseLoading: boolean = false
public adminGroupsLoading: boolean = false
public currentUserInfoLoading: boolean = false
@@ -77,14 +86,18 @@ export class AutomaticComponent implements OnInit {
public currentUserInfo: ViyaApiCurrentUser | null = null
public computeContexts: ComputeContextItem[] = []
/**
* Status of the one step this screen runs: `makedata`, which creates the
* control library and its configuration data and then deletes itself.
* `null` means it has not run yet, `false` that it failed.
/** autoDeployStatus
* This object presents the status for two steps that we have for deploy.
* `deployServicePack` - Creating services based on `viya.json`
* `runMakeData` - Running `makedata` service
* If any of them is `null` or `false` that means step failed
* and will be shown to user on deploy done modal.
*/
public autoDeployStatus: {
deployServicePack: any
runMakeData: any
} = {
deployServicePack: null,
runMakeData: null
}
@@ -407,15 +420,61 @@ export class AutomaticComponent implements OnInit {
}
/**
* Runs the deployment. The control library and its configuration data are
* created by `makedata`, which self-deletes once it has run; the frontend and
* services arrived with the sasjs deploy, so there is nothing else to deploy
* from here.
* Executes sas.json file to deploy the backend
* Method will first try to run the `auto deploy`
* If that fails the rest of the code is ignored.
* If request is successfull, method will continue to try
* to create database if checkbox is toggled on
*/
public runAutoDeploy() {
public async executeJson() {
this.isSubmittingJson = true
try {
let uploadJsonFile = await this.sasJs.deployServicePack(
this.jsonFile,
this.dcAdapterSettings?.appLoc,
undefined,
undefined,
true
)
this.autoDeployStatus.deployServicePack = true
this.isJsonSubmitted = true
} catch (ex: any) {
let textEx = ''
if (typeof ex.message !== 'string') {
textEx = JSON.stringify(ex).replace(/\\/gm, '')
} else {
textEx = ex.message
}
this.autoDeployStatus.deployServicePack = false
this.eventService.showInfoModal(
'Deploy error',
`Exception: \n ${textEx !== '' ? textEx : ex}`
)
this.autodeploying = false
this.autodeployDone = false
return
}
this.isSubmittingJson = false
}
public async runAutoDeploy(executeJson: boolean = false) {
if (!this.deployInNewWindow) this.autodeploying = true
this.createDatabase()
if (executeJson) {
this.executeJson()
}
if (this.recreateDatabase) {
this.createDatabase()
} else {
if (!this.deployInNewWindow) this.autodeployDone = true
}
}
/**
@@ -519,9 +578,9 @@ export class AutomaticComponent implements OnInit {
let serverUrl = this.sasjsConfig.serverUrl
let appLoc = this.sasjsConfig.appLoc
const execPath = this.sasService.getExecutionPath()
let contextname = `&_contextname=${encodeURIComponent(params.contextName)}`
let admin = `&admin=${encodeURIComponent(params.admin)}`
let dcPath = `&dcpath=${encodeURIComponent(params.dcPath)}`
let contextname = `&_contextname=${params.contextName}`
let admin = `&admin=${params.admin}`
let dcPath = `&dcpath=${params.dcPath}`
// Debug is ALWAYS enabled for makedata — it runs only once during
// deployment and the full log is needed to diagnose any issues.
let debug = '&_debug=131'
@@ -649,8 +708,24 @@ export class AutomaticComponent implements OnInit {
this.deployService.downloadFile(content, filename, extension)
}
public openSasLogsModal() {
this.eventService.openSasLogsModal()
public async onJsonFileChange(event: any) {
let file = event.target.files[0]
this.jsonFile = await this.deployService.readFile(file)
}
public recreateDatabaseClicked(event: Event) {
;(<HTMLInputElement>event.target).checked === true
? (this.recreateDatabaseModal = true)
: ''
}
public clearUploadInput(event: Event) {
this.deployService.clearUploadInput(event)
}
public openSasRequestsModal() {
this.eventService.openRequestsModal()
}
public navigateToHome() {
@@ -1,88 +0,0 @@
import { ServerType } from '@sasjs/utils/types/serverType'
import { SasjsConfiguratorComponent } from './sasjs-configurator.component'
/**
* The SAS9 / SASjs Server setup screen cannot deploy anything: it builds the
* one-off `makedata` URL and opens it, with the admin group and the directory
* the control library will live in. These tests pin the URL shape, because a
* wrong parameter name or a lost encode still produces a page that opens and
* merely fails inside SAS, where nobody is looking.
*/
describe('SasjsConfiguratorComponent (SAS9 / SASjs setup)', () => {
const build = (serverType: ServerType) => {
const sasService: any = {
getExecutionPath: () => '/SASStoredProcess/do',
sasjsMakedataChecking: () => Promise.resolve(false),
logout: () => undefined
}
const component = new SasjsConfiguratorComponent(
sasService,
{ getServerInfo: () => ({ subscribe: () => undefined }) } as any,
{ replaceState: () => undefined } as any
)
component.sasJsConfig = {
serverType,
serverUrl: 'https://sas9.example.com',
appLoc: '/User Folders/dc'
} as any
component.dcDirectory = '/tmp/DataController'
component.dcAdminGroup = 'DCADMINS'
const opened: string[] = []
spyOn(window, 'open').and.callFake((url?: any) => {
opened.push(String(url))
return null
})
return { component, opened, sasService }
}
it('opens makedata on SAS9 with the admin group and the directory', () => {
const { component, opened } = build(ServerType.Sas9)
component.makeData()
expect(opened.length).toBe(1)
expect(opened[0]).toContain(
'_program=/User Folders/dc/services/admin/makedata'
)
expect(opened[0]).toContain('admin=DCADMINS')
expect(opened[0]).toContain('path=%2Ftmp%2FDataController')
expect(opened[0]).toContain('_debug=131')
})
it('stays on the page origin for SAS9 rather than the configured server', () => {
const { component, opened } = build(ServerType.Sas9)
component.makeData()
// A SAS9 deployment is reached through the URL the user is already on; the
// configured serverUrl would point the browser at a host it may not reach.
expect(opened[0].startsWith(window.location.origin)).toBe(true)
expect(opened[0]).not.toContain('sas9.example.com')
})
it('keeps the directory readable for SASjs Server', () => {
const { component, opened } = build(ServerType.Sasjs)
component.makeData()
// The SASjs branch does not encode the path, so a plain directory stays as
// written; encoding it there would make makedata look for a folder with %2F
// in its name.
expect(opened[0]).toContain('path=/tmp/DataController')
})
it('checks the SASjs Server deployment before reloading the app', () => {
const { component, sasService } = build(ServerType.Sasjs)
const spy = spyOn(sasService, 'sasjsMakedataChecking').and.returnValue(
Promise.resolve(false)
)
component.makeData()
expect(spy).toHaveBeenCalled()
})
})
-29
View File
@@ -1,29 +0,0 @@
/**
* The URL a Data Controller instance runs at when it is not embedded: the same
* URL, without the parts that make it an embed - the `embed` switch, the
* `va_parmap` entries, and the hash route.
*
* The rest of the query is kept. On SAS 9 and Viya the app is addressed
* entirely through it (`/SASJobExecution/?_program=/.../startupservice`), so
* dropping the query would open a blank executor rather than the app, and any
* other parameter the deployment relies on would go the same way.
*
* Only these two parameters are embed parameters. A later parameter that
* happens to start with `va_` belongs to the app, not the embed, so it stays.
*
* Parsing with URL also handles the relative hrefs the adapter can hand back,
* which plain string splicing cannot.
*/
const EMBED_PARAMS = ['embed', 'va_parmap']
export const standaloneUrl = (href: string): string => {
const url = new URL(href, window.location.origin)
url.hash = ''
for (const key of Array.from(url.searchParams.keys())) {
if (EMBED_PARAMS.includes(key.toLowerCase())) {
url.searchParams.delete(key)
}
}
return url.toString()
}
@@ -459,43 +459,6 @@
<!--<hr>-->
<!--{{ validationDone }}-->
<!-- VA embed: something about a `va_parmap` entry in this report
object's URL needs the report author's attention - it could not be
used, it was applied but AND-ed against another parameter, or it
names a parameter VA never sends. Shown as a banner, not just a
console warning, because
the entries are written by the report author and a typo otherwise
looks like a working filter over the whole table. -->
@if (isVaEmbed && vaMappingWarnings.length) {
<clr-alert
class="va-parmap-warning"
[clrAlertType]="'warning'"
[clrAlertClosable]="false"
>
<clr-alert-item>
<div class="alert-text">
<strong>VA parameter mapping problem</strong>
@for (warning of vaMappingWarnings; track warning) {
<div>{{ warning }}</div>
}
<!-- The reader is the report author, who can only fix this in
the report: name the parameter they edit, and say what a
correct entry looks like, so the message is actionable
without the docs. -->
<div>
Fix: edit the <code>va_parmap</code> entries in the URL of
this Data Controller object in the report. An entry is
<code>parameter-column</code>, optionally
<code>:ADD(anchor,offset)</code> or
<code>:RANGE(period,offset)</code> - for example
<code>va_parmap=SEL_DATE-D_DATE:ADD(QUARTER_START,-1)</code
>.
</div>
</div>
</clr-alert-item>
</clr-alert>
}
<div class="hot-wrapper clr-flex-1">
<hot-table
#hotInstance
@@ -27,15 +27,3 @@
font-style: italic;
opacity: 0.7;
}
/* VA embed: `va_parmap` diagnostic banner. Clarity's alert lays `.alert-title`
and `.alert-text` out on one row, which pushes a long message off the right
edge; this keeps the whole message in the flow and wraps it. */
.va-parmap-warning {
width: 100%;
.alert-text {
white-space: normal;
overflow-wrap: anywhere;
}
}
+32 -112
View File
@@ -73,7 +73,6 @@ import {
} from '../shared/dc-validator/utils/autoEscapedCellTracker'
import { HyperFormula } from 'hyperformula'
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
import { VaParmapTarget } from '../shared/utils/parse-va-parmap'
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
import { buildColInfoHtml } from '../shared/utils/col-info-html'
import { globals } from '../_globals'
@@ -108,7 +107,6 @@ import { VaFilterService } from '../services/va-filter.service'
import { UploadFileResponse } from '../models/UploadFile'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { ParseResult } from '../models/ParseResult.interface'
import { observeGridContainerResize } from './utils/refreshGridAfterContainerResize'
@Component({
selector: 'app-editor',
@@ -487,46 +485,6 @@ export class EditorComponent
get isVaEmbed() {
return globals.embed === 'va'
}
/**
* Explicit VA parameter -> DC column mappings (`va_parmap`) for this instance.
* Parsed once at app init (see parseVaParmap) and held on globals so the
* editor reloads a filter apply performs keep using the same mapping.
*/
get vaParamMap(): Map<string, VaParmapTarget> {
return globals.vaParamMap
}
/** `va_parmap` entries that name a column this table does not have. */
public vaUnresolvedMappings: string[] = []
/** `va_parmap` keys that no VA message has named (a parameter-side typo). */
public vaUnmatchedMappings: string[] = []
/** Parameters in this message that filter the same column (an AND-ed pair). */
public vaSameColumnMappings: string[] = []
/** Transform mappings this message cannot apply (non-temporal column or value). */
public vaTransformProblems: string[] = []
/** The warning text last written to the console, so repeated VA messages log once. */
private vaWarningsReported = ''
/**
* Everything wrong with this instance's `va_parmap` entries, in the report
* author's terms: entries that could not be parsed at all, plus entries that
* name a column the open table does not have. Shown as a banner in the editor
* - a report developer is usually not going to read the browser console.
*/
get vaMappingWarnings(): string[] {
return [...globals.vaParmapProblems, ...this.vaRuntimeWarnings]
}
/**
* The warnings this instance discovers for itself, i.e. everything except the
* parse-time problems - those are logged once at app init (`app.component`),
* so logging them again here would double every line in the console.
*/
get vaRuntimeWarnings(): string[] {
return [
...this.vaUnresolvedMappings,
...this.vaUnmatchedMappings,
...this.vaSameColumnMappings,
...this.vaTransformProblems
]
}
/**
* VA filter mode. Live = auto-apply each VA change; confirm = stage the change
* and wait for the user to click Apply. Backed by globals (toggled by the
@@ -3644,20 +3602,11 @@ export class EditorComponent
const msg = this.vaMessaging.latestMessage()
if (!msg) return false
const clauses = this.vaFilter.buildInitialClauses(msg, this.vaParamMap)
const clauses = this.vaFilter.buildInitialClauses(msg)
if (clauses.length === 0) return false
try {
// A `va_parmap` entry is a column the report author typed, so this
// request can legitimately fail: the table may not have that column, and
// validatefilter aborts on an unknown variable (dc/dc#404). Attempting it
// is still worth it - when the mapping is right the very first getdata is
// already filtered - but the failure must not stop the page. An abort
// modal here covers an editor that is about to load fine; the mapping
// banner names the bad entry once the real metadata arrives.
const res: any = await this.sasStoreService.saveQuery(libds, clauses, {
suppressErrorAbortModal: true
})
const res: any = await this.sasStoreService.saveQuery(libds, clauses)
const id = res?.result?.[0]?.FILTER_RK
const table = res?.result?.[0]?.FILTER_TABLE
if (id === undefined || table === undefined) return false
@@ -3802,16 +3751,22 @@ export class EditorComponent
}
/**
* Re-measure the grid whenever its container settles. The observer itself
* lives in editor/utils/refreshGridAfterContainerResize.ts so the wiring is
* covered by a spec.
* Re-run height+render when the grid container settles.
*/
private observeGridResize() {
const el = this.hotInstance?.rootElement?.parentElement
if (!el) return
this.gridResizeObserver?.disconnect()
this.gridResizeObserver = observeGridContainerResize(
this.hotInstance,
this.hotInstance?.rootElement?.parentElement
)
this.gridResizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
const hot = this.hotInstance
if (!hot || hot.isDestroyed) return
this.updateSettingsSortSafe({ height: this.hotTable.height }, false)
hot.render()
})
})
this.gridResizeObserver.observe(el)
}
/**
@@ -4067,6 +4022,19 @@ export class EditorComponent
// rules must stay index-aligned with headerColumns).
this.headerColumns.push(EDIT_STATUS_COLUMN_NAME)
/**
* columnHeader is the same list, taken once it is final (Delete? renamed,
* EDIT_STATUS appended) - it is the grid's own column order, which is what
* every hook that maps a visual column index to a name needs.
*
* This has to happen HERE, before the updateSettings below installs
* afterGetColHeader / afterGetCellMeta: those fire during the render that
* updateSettings triggers, and reading a columnHeader that was still being
* assigned further down meant they saw the previous table's columns (or
* nothing). That is what put the derived-block marker on the wrong column.
*/
this.columnHeader = this.headerColumns.slice()
if (response.data.sasparams[0].DTVARS !== '') {
this.dateHeaders = response.data.sasparams[0].DTVARS.split(' ')
}
@@ -4188,8 +4156,9 @@ export class EditorComponent
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
// Note: this.headerColumns and this.columnHeader contains same data
// need to resolve redundancy
// columnHeader is a copy of headerColumns taken above, once the Delete?
// rename and the EDIT_STATUS append had been applied - the hooks read it
// for grid-index-to-name mapping, so the two must not drift apart again.
// default schema - includes NOTNULL defaults from DQ rules
for (let i = 0; i < this.headerColumns.length; i++) {
@@ -4548,9 +4517,6 @@ export class EditorComponent
// hot.getPlugin('manualColumnFreeze').freezeColumn(0);
this.queryText = response.data.sasparams[0].FILTER_TEXT
this.columnHeader = response.data.sasparams[0].COLHEADERS.split(',')
// First column is always used to mark records for deletion
this.columnHeader[0] = 'Delete?'
this.readOnlyFields = response.data.sasparams[0].PKCNT
hot.addHook(
@@ -5093,53 +5059,7 @@ export class EditorComponent
this.updateVaFilterText(msg)
// A `va_parmap` entry naming a column this table does not have is skipped by
// buildClauses. Report it (banner + console, once per distinct set) so the
// report author sees a misconfiguration instead of an unfiltered grid.
this.vaUnresolvedMappings = this.vaFilter.unresolvedMappings(
msg,
this.vaParamMap,
this.cols,
this.libds
)
// A key naming no parameter VA has ever sent is a typo on the parameter
// side: the mapping never applies. Only judged once parameters are actually
// arriving, since VA sends just the parameters its query uses.
this.vaUnmatchedMappings = this.vaFilter.unmatchedMappings(
this.vaParamMap,
this.vaMessaging.seenParamKeys,
this.vaMessaging.lastMessageParamsStable
)
// Two parameters filtering one column are AND-ed, so differing values leave
// an empty grid. Reported, not rewritten: turning that AND into an OR is a
// filter-semantics decision, taken in buildClauses and not in a diagnostic.
this.vaSameColumnMappings = this.vaFilter.sameColumnMappings(
msg,
this.vaParamMap,
this.cols
)
// A transform that cannot run here (char column, value that is not a date) is
// skipped by the clause builder, so the banner has to say why.
this.vaTransformProblems = this.vaFilter.unsupportedTransforms(
msg,
this.vaParamMap,
this.cols
)
const warnings = this.vaRuntimeWarnings.join(' | ')
if (warnings !== this.vaWarningsReported) {
this.vaWarningsReported = warnings
for (const warning of this.vaRuntimeWarnings) {
// eslint-disable-next-line no-console
console.warn('Data Controller: ' + warning)
}
}
const clauses = this.vaFilter.buildClauses(
msg,
labelToVar,
this.cols,
this.vaParamMap
)
const clauses = this.vaFilter.buildClauses(msg, labelToVar, this.cols)
const signature = this.vaFilter.signature(clauses)
if (signature === this.vaMessaging.filterSignature) {
@@ -1,134 +0,0 @@
import Handsontable from 'handsontable'
import { HyperFormula } from 'hyperformula'
import {
observeGridContainerResize,
refreshGridAfterContainerResize
} from './refreshGridAfterContainerResize'
/**
* The editor refreshes its grid when the container changes size (sidebar
* toggle, modal open/close). That path must not touch grid settings: any
* `updateSettings()` while a multi-column sort is active corrupts formula
* references into `#REF!` (the bug `updateSettingsSortSafe()` works around),
* and its workaround - clear the sort, apply, re-apply - re-sorts the data
* underneath an open cell editor.
*
* This pins that contract against a real Handsontable instance, using the same
* technique as the other editor integration specs (editor.component.ts has no
* TestBed harness).
*/
describe('refreshGridAfterContainerResize', () => {
let container: HTMLDivElement
let hot: Handsontable
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
hot = new Handsontable(container, {
data: [
[3, 10, '=A1*B1'],
[1, 20, '=A2*B2'],
[2, 30, '=A3*B3']
],
columns: [{ data: 0 }, { data: 1 }, { data: 2, readOnly: true }],
formulas: { engine: HyperFormula, licenseKey: 'gpl-v3' },
multiColumnSorting: true,
licenseKey: 'non-commercial-and-evaluation'
})
})
afterEach(() => {
if (hot && !hot.isDestroyed) hot.destroy()
hot = undefined as any
container.remove()
})
it('re-measures without changing settings, so an active sort survives', () => {
const sorting = hot.getPlugin('multiColumnSorting') as any
sorting.sort([{ column: 0, sortOrder: 'asc' }])
const sortBefore = JSON.parse(JSON.stringify(sorting.getSortConfig()))
const updateSettings = spyOn(hot, 'updateSettings').and.callThrough()
const clearSort = spyOn(sorting, 'clearSort').and.callThrough()
const sort = spyOn(sorting, 'sort').and.callThrough()
const refreshDimensions = spyOn(hot, 'refreshDimensions').and.callThrough()
refreshGridAfterContainerResize(hot)
expect(refreshDimensions).toHaveBeenCalledTimes(1)
expect(updateSettings).not.toHaveBeenCalled()
expect(clearSort).not.toHaveBeenCalled()
expect(sort).not.toHaveBeenCalled()
expect(JSON.parse(JSON.stringify(sorting.getSortConfig()))).toEqual(
sortBefore
)
})
it('leaves formula values intact across a resize', () => {
const valuesBefore = hot.getData()
refreshGridAfterContainerResize(hot)
expect(hot.getData()).toEqual(valuesBefore)
const flat = (hot.getData() as any[][]).flat()
expect(flat.some((value) => String(value).includes('#REF'))).toBeFalse()
})
it('ignores a missing or destroyed instance', () => {
expect(() => refreshGridAfterContainerResize(null)).not.toThrow()
expect(() => refreshGridAfterContainerResize(undefined)).not.toThrow()
hot.destroy()
expect(() => refreshGridAfterContainerResize(hot)).not.toThrow()
})
})
/**
* The observer is what makes the helper fire, and it is the wiring - not the
* helper body - that regressed into `updateSettings()` in the first place. A
* helper-only spec passes even if the observer reapplies settings, so this one
* drives a real container resize through the observer and fails on any
* settings call.
*/
describe('observeGridContainerResize', () => {
it('re-measures on a container resize without touching grid settings', async () => {
const container = document.createElement('div')
container.style.width = '500px'
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [
[3, 10, '=A1*B1'],
[1, 20, '=A2*B2']
],
colHeaders: true,
width: '100%',
height: 300,
licenseKey: undefined
})
const settingsSpy = spyOn(hot, 'updateSettings').and.callThrough()
const refreshSpy = spyOn(hot, 'refreshDimensions').and.callThrough()
const observer = observeGridContainerResize(hot, container)
expect(observer).toBeDefined()
container.style.width = '320px'
await new Promise((resolve) => setTimeout(resolve, 400))
expect(refreshSpy).toHaveBeenCalled()
expect(settingsSpy).not.toHaveBeenCalled()
observer?.disconnect()
hot.destroy()
container.remove()
})
it('returns nothing when the grid or container is missing', () => {
const container = document.createElement('div')
expect(observeGridContainerResize(null, container)).toBeUndefined()
expect(observeGridContainerResize(undefined, undefined)).toBeUndefined()
})
})
@@ -1,53 +0,0 @@
import Handsontable from 'handsontable'
/**
* Re-measures a grid after its container changed size (sidebar toggle, modal
* open/close, window resize).
*
* Deliberately does NOT call `updateSettings()`. Any settings call while a
* multi-column sort is active corrupts formula cell references into `#REF!` -
* that is the bug `updateSettingsSortSafe()` exists for - and the workaround
* (clear the sort, apply, re-apply the sort) re-sorts the data underneath an
* open cell editor, so typing can land on a different row than the one being
* edited. A container resize has no business changing settings at all:
* `refreshDimensions()` measures `rootElement.getBoundingClientRect()` and
* compares it with the last measured size, re-running `adjustElementsSize()`
* and a render only when the box actually moved. Both grids take their height
* from a viewport-derived `calc()` setting (editor.component.ts:203), so a
* container resize that matters to the grid is a width change or a viewport
* change - both of which move that box. It also fires
* `beforeRefreshDimensions` / `afterRefreshDimensions` for anything that needs
* to react.
*/
export function refreshGridAfterContainerResize(
hot: Handsontable | null | undefined
): void {
if (!hot || hot.isDestroyed) return
hot.refreshDimensions()
}
/**
* Watches a grid's container and re-measures the grid whenever its box changes
* (sidebar toggle, modal open/close, window resize).
*
* The observer lives here rather than in the component so the wiring is
* covered by a spec: what matters is not that `refreshDimensions()` is called
* but that a resize never reaches `updateSettings()`.
*
* Returns the observer so the caller can disconnect it on destroy.
*/
export function observeGridContainerResize(
hot: Handsontable | null | undefined,
container: HTMLElement | null | undefined
): ResizeObserver | undefined {
if (!hot || !container || typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(() => {
requestAnimationFrame(() => refreshGridAfterContainerResize(hot))
})
observer.observe(container)
return observer
}
@@ -157,16 +157,6 @@
<p cds-text="secondary regular" class="mb-20">
File size: <strong>{{ selectedFile.sizeMB }} MB</strong>
</p>
<p
cds-text="secondary regular"
class="mb-20 d-flex clr-align-items-center"
id="file-loading-stage"
>
Stage: <strong class="ml-5">{{ fileLoadingState }}</strong>
@if (isFileBusy) {
<clr-spinner class="ml-10" clrSmall></clr-spinner>
}
</p>
<p cds-text="secondary regular" class="mb-15">
Paste or type the list of datasets to upload:
</p>
@@ -198,26 +188,18 @@
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
@if (!activeParsedDataset) {
<div class="no-table-selected pointer-events-none">
@if (isFileBusy) {
@if (fileLoadingState !== FileLoadingState.parsed) {
<clr-icon
aria-hidden="true"
shape="process-on-vm"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p
class="text-center color-gray mt-10"
cds-text="section"
id="file-loading-stage"
>
<p class="text-center color-gray mt-10" cds-text="section">
{{ fileLoadingState }}...
</p>
<p class="text-center color-gray" cds-text="secondary regular">
{{ selectedFile?.name }} - {{ selectedFile?.sizeMB }} MB
</p>
<clr-spinner class="mt-10" clrSmall></clr-spinner>
}
@if (!isFileBusy) {
@if (fileLoadingState === FileLoadingState.parsed) {
<clr-icon
aria-hidden="true"
shape="warning-standard"
@@ -40,13 +40,10 @@ import XLSX from 'xlsx'
enum FileLoadingState {
reading = 'Reading the file',
scanning = 'Searching for the data in the file',
ready = 'File ready to load',
fetching = 'Fetching the dataset details',
extracting = 'Extracting the data',
extracted = 'Extracting the data finished',
parsing = 'Searching for the data in the file',
parsed = 'Searching for the data finished',
/**
* Default value
* Defualt value
*/
notSelected = 'File not selected'
}
@@ -68,19 +65,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
public workbookInterval: any
public fileLoadingState: FileLoadingState = FileLoadingState.notSelected
/**
* True while the file is being worked on. `ready` and `extracted` are idle
* states, so the spinner and the stage line are only shown for the phases
* that actually have work running.
*/
public get isFileBusy(): boolean {
return (
this.fileLoadingState === FileLoadingState.reading ||
this.fileLoadingState === FileLoadingState.scanning ||
this.fileLoadingState === FileLoadingState.fetching ||
this.fileLoadingState === FileLoadingState.extracting
)
}
public FileLoadingState = FileLoadingState
public hotTableLicenseKey: string | undefined = undefined
public hotTableMaxRows =
@@ -297,13 +282,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
// Read the excel file to be ready
this.spreadsheetService.xlsxReadFile(this.selectedFile!).then((wb) => {
this.fileLoadingState = FileLoadingState.parsing
this.selectedFile!.workbook = wb
// The sheet scan runs in parallel and can finish first, so only
// advance the stage if the read is still the phase in progress.
if (this.fileLoadingState === FileLoadingState.reading) {
this.fileLoadingState = FileLoadingState.scanning
}
})
}
@@ -360,12 +340,10 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
onDiscardFile() {
this.selectedFile = null
this.userInputDatasets = ''
this.fileLoadingState = FileLoadingState.notSelected
}
async onStartParsingFile() {
this.uploadLoading = true
this.fileLoadingState = FileLoadingState.fetching
const datasetFetchingPromises: Promise<
EditorsGetDataServiceResponse | undefined
@@ -399,7 +377,6 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
datasetObjects = this.buildDatasetsObjects(datasets)
} catch (err) {
console.warn('Error building dataset objects', err)
this.fileLoadingState = FileLoadingState.ready
this.eventService.showInfoModal('Upload error', (err as Error).message)
return
}
@@ -414,12 +391,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
})
this.workbookLoaded().then((workbook) => {
this.fileLoadingState = FileLoadingState.extracting
const parsingPromises: Promise<void>[] = []
for (let parsedDataset of this.parsedDatasets) {
const parsePromise = this.spreadsheetService
this.spreadsheetService
.parseExcelFile({
file: this.selectedFile!,
workbook: workbook,
@@ -434,6 +407,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
xlRules: parsedDataset.datasetInfo.xlRules
})
.then((parseResult: ParseResult | undefined) => {
this.fileLoadingState = FileLoadingState.parsed
if (parseResult && parseResult.data) {
let datasource: any[] = []
@@ -463,15 +438,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
parsedDataset.includeInSubmission = false
parsedDataset.parsingTable = false
})
parsingPromises.push(parsePromise)
}
// The stage belongs to the whole file, not to the sheet that finished
// last, so it only moves on once every sheet has been extracted.
Promise.allSettled(parsingPromises).then(() => {
this.fileLoadingState = FileLoadingState.extracted
})
})
})
}
@@ -493,7 +460,6 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
this.selectedFile = null
this.userInputDatasets = ''
this.submitReasonMessage = ''
this.fileLoadingState = FileLoadingState.notSelected
}
initHot() {
@@ -708,7 +674,6 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
}
if (!this.hotInstanceUserDataset) {
console.warn('hotInstanceUserDataset not ready after waiting')
this.fileLoadingState = FileLoadingState.ready
return
}
}
@@ -781,10 +746,6 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
}, 100)
}
this.dynamicCellValidations()
// The sheet scan is done; the file waits for the user to confirm the
// dataset list, so no stage is running any more.
this.fileLoadingState = FileLoadingState.ready
}
onParsedDatasetClick(parsedDataset: ParsedDataset) {
+18
View File
@@ -26,4 +26,22 @@ export class DeployService {
document.body.removeChild(downloadLink)
}
}
public readFile(file: File): Promise<any> {
return new Promise((resolve, reject) => {
let JsonFileReader = new FileReader()
JsonFileReader.onload = () => {
if (JsonFileReader.result) {
resolve(JSON.parse(JsonFileReader.result.toString()))
}
}
JsonFileReader.readAsText(file)
})
}
public clearUploadInput(event: Event) {
;(<HTMLInputElement>event.target).value = ''
}
}
+3 -3
View File
@@ -13,7 +13,7 @@ export class EventService {
@Output() onStartupDataLoaded: EventEmitter<any> = new EventEmitter()
@Output()
onShowAbortModal: EventEmitter<InfoModal> = new EventEmitter<InfoModal>()
@Output() onSasLogsModalOpen: EventEmitter<boolean> = new EventEmitter()
@Output() onRequestsModalOpen: EventEmitter<boolean> = new EventEmitter()
@Output() onDemoLimitModalShow: EventEmitter<string> =
new EventEmitter<string>()
@@ -78,8 +78,8 @@ export class EventService {
this.onShowAbortModal.emit(infoModalObject)
}
public openSasLogsModal() {
this.onSasLogsModalOpen.emit(true)
public openRequestsModal() {
this.onRequestsModalOpen.emit(true)
}
public catchResponseError(sasService: string | null, err: any) {
@@ -116,53 +116,6 @@ describe('SasStoreService', () => {
expect(seen.length).toBe(0)
})
})
describe('saveQuery', () => {
const capture = (adapterResponse: any = { result: [] }) => {
const calls: any[][] = []
const store = new SasStoreService(
{
request: (...args: any[]) => {
calls.push(args)
return Promise.resolve({ adapterResponse })
}
} as any,
{} as any,
{} as any
)
return { store, calls }
}
it('posts the filter to public/validatefilter', async () => {
const { store, calls } = capture()
await store.saveQuery('DC.MY_TABLE', [
{ VARIABLE_NM: 'A', OPERATOR_NM: '=', RAW_VALUE: "'x'" } as any
])
expect(calls[0][0]).toBe('public/validatefilter')
expect(calls[0][1].iwant[0].filter_table).toBe('DC.MY_TABLE')
expect(calls[0][1].filterquery.length).toBe(1)
})
it('passes wrapper options through, so a rejected filter can stay quiet', async () => {
// The VA deferred load attempts a filter built from a va_parmap entry the
// table may not have, and must not cover a page that loads fine with an
// abort modal (dc/dc#404).
const { store, calls } = capture()
await store.saveQuery('DC.MY_TABLE', [], {
suppressErrorAbortModal: true
})
expect(calls[0][3]).toEqual({ suppressErrorAbortModal: true })
})
it('sends no wrapper options when the caller gives none', async () => {
const { store, calls } = capture()
await store.saveQuery('DC.MY_TABLE', [])
expect(calls[0][3]).toBeUndefined()
})
})
})
/**
+2 -20
View File
@@ -8,7 +8,6 @@ import { HelperService } from './helper.service'
import { QueryClause } from '../models/TableData'
import { globals } from '../_globals'
import { FilterClause, FilterGroup, FilterQuery } from '../models/FilterQuery'
import { RequestWrapperOptions } from '../models/request-wrapper/RequestWrapperOptions'
import {
$DataFormats,
EditorsGetDataSASResponse,
@@ -396,30 +395,13 @@ export class SasStoreService {
).adapterResponse
}
/**
* Persists a filter and returns its FILTER_RK.
*
* `wrapperOptions` reaches the request wrapper, so a caller that can live with
* a rejected filter (the VA deferred load, whose `va_parmap` entry may name a
* column the table does not have) can suppress the abort modal that would
* otherwise cover a page which is about to load perfectly well.
*/
public async saveQuery(
libds: string,
filterQuery: QueryClause[],
wrapperOptions?: RequestWrapperOptions
) {
public async saveQuery(libds: string, filterQuery: QueryClause[]) {
let tables = {
iwant: [{ filter_table: libds }],
filterquery: filterQuery
}
const res = await this.sasService.request(
'public/validatefilter',
tables,
undefined,
wrapperOptions
)
const res = await this.sasService.request('public/validatefilter', tables)
this.filter.next(res)
@@ -1,5 +1,4 @@
import { VaFilterService } from './va-filter.service'
import { VaParmapTarget } from '../shared/utils/parse-va-parmap'
import { VaMessage } from './va-messaging.service'
import { HelperService } from './helper.service'
@@ -15,7 +14,6 @@ function msg(parameters: any[]): VaMessage {
describe('VaFilterService', () => {
let service: VaFilterService
let helperService: HelperService
// DC column metadata used by the metadata path. cellValidation[0] is the
// delete column and must be skipped by buildLabelToVarMap.
@@ -44,8 +42,7 @@ describe('VaFilterService', () => {
]
beforeEach(() => {
helperService = new HelperService({} as any)
service = new VaFilterService(helperService)
service = new VaFilterService(new HelperService({} as any))
})
const labelToVar = () =>
@@ -131,41 +128,6 @@ describe('VaFilterService', () => {
expect(build([{ label: 'SOME_DATE', value: '2025/03/31' }])).toHaveSize(1)
})
it('parses word dates, in either order and with an optional comma', () => {
const expected = "'01JAN2026'd"
// The reported shape: a date parameter displayed as WORDDATE.
expect(
build([{ label: 'SOME_DATE', value: 'January 1, 2026' }])[0].RAW_VALUE
).toBe(expected)
expect(
build([{ label: 'SOME_DATE', value: 'Jan 1, 2026' }])[0].RAW_VALUE
).toBe(expected)
expect(
build([{ label: 'SOME_DATE', value: 'Jan. 1, 2026' }])[0].RAW_VALUE
).toBe(expected)
expect(
build([{ label: 'SOME_DATE', value: '1 January 2026' }])[0].RAW_VALUE
).toBe(expected)
expect(
build([{ label: 'SOME_DATE', value: '1-Jan-2026' }])[0].RAW_VALUE
).toBe(expected)
})
it('matches a spelled-out month case-insensitively, and keeps DATE9 working', () => {
expect(
build([{ label: 'SOME_DATE', value: 'DECEMBER 31, 2025' }])[0].RAW_VALUE
).toBe("'31DEC2025'd")
expect(
build([{ label: 'SOME_DATE', value: '01AUG1962' }])[0].RAW_VALUE
).toBe("'01AUG1962'd")
})
it('still drops a month-name value that has no day', () => {
// A month and year alone is not a date - accepted shapes always carry a
// day, so this must not silently become the first of the month.
expect(build([{ label: 'SOME_DATE', value: 'January 2026' }])).toEqual([])
})
it('resolves ambiguous slash dates day-first (never US), disambiguating by value', () => {
const iso = build([{ label: 'SOME_DATE', value: '2025-03-31' }])[0]
.RAW_VALUE
@@ -237,694 +199,7 @@ describe('VaFilterService', () => {
})
})
describe('buildClauses with an explicit va_parmap', () => {
const build = (parameters: any[], paramMap: Map<string, VaParmapTarget>) =>
service.buildClauses(msg(parameters), labelToVar(), cols, paramMap)
it('overrides the same-name match, including the value formatting', () => {
// The VA label IS a DC column here (SOME_CHAR), but the mapping points at
// a different one - the numeric column - so the value is formatted numeric.
const map = new Map([['some_char', { column: 'SOME_NUM' }]])
expect(build([{ label: 'SOME_CHAR', value: '10' }], map)).toEqual([
{
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: 'SOME_NUM',
OPERATOR_NM: '=',
RAW_VALUE: '10'
}
])
})
it('lets two differently-named params filter the same column', () => {
// The multi-tab case: one DC instance per report tab, each with its own
// VA parameter, both mapping to the same DC column.
const map = new Map([
['region_tab1', { column: 'SOME_CHAR' }],
['region_tab2', { column: 'SOME_CHAR' }]
])
const clauses = build(
[
{ label: 'REGION_TAB1', value: 'north' },
{ label: 'REGION_TAB2', value: 'south' }
],
map
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_CHAR', "'north'"],
['SOME_CHAR', "'south'"]
])
})
it('matches the parameter label only, not its internal name', () => {
// The internal name is the report's own bookkeeping. Matching it as well
// meant one parameter answered to two keys, so an entry written against the
// name and one against the label silently fought over it.
const map = new Map([['pr273', { column: 'SOME_NUM' }]])
expect(
build([{ name: 'pr273', label: 'Tab_1_Value', value: '7' }], map)
).toEqual([])
// The label still matches, case-insensitively.
expect(
build(
[{ name: 'pr273', label: 'Tab_1_Value', value: 'x' }],
new Map([['tab_1_value', { column: 'SOME_CHAR' }]])
)[0].VARIABLE_NM
).toBe('SOME_CHAR')
})
it('uses the column case from the loaded metadata', () => {
const map = new Map([['region_tab1', { column: 'some_char' }]])
expect(
build([{ label: 'REGION_TAB1', value: 'x' }], map)[0].VARIABLE_NM
).toBe('SOME_CHAR')
})
it('leaves unmapped params on the same-name path', () => {
const map = new Map([['region_tab1', { column: 'SOME_NUM' }]])
const clauses = build(
[
{ label: 'REGION_TAB1', value: '10' },
{ label: 'SOME_CHAR', value: 'x' }
],
map
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_NUM', '10'],
['SOME_CHAR', "'x'"]
])
})
it('skips a mapping whose column is not in this table (no fallback)', () => {
// Falling back to the same-name match would silently apply the filter the
// mapping exists to override, so the param is dropped instead.
const map = new Map([['some_char', { column: 'NOT_IN_THIS_TABLE' }]])
expect(build([{ label: 'SOME_CHAR', value: 'x' }], map)).toEqual([])
})
it('ANDs two parameters that map to the same column - it does not union them', () => {
// What a report gets when one column's two values arrive in two
// parameters: every clause is AND-ed, so the result is the INTERSECTION.
// Two different dates therefore match no rows at all rather than both
// dates' rows - a single parameter carrying both values is the shape that
// ORs, via `IN (...)`.
const map = new Map([
['param_a', { column: 'SOME_DATE' }],
['param_b', { column: 'SOME_DATE' }]
])
const one = build([{ label: 'PARAM_A', value: '01JUL1960' }], map)[0]
const other = build([{ label: 'PARAM_B', value: '01JAN1961' }], map)[0]
const clauses = build(
[
{ label: 'PARAM_A', value: '01JUL1960' },
{ label: 'PARAM_B', value: '01JAN1961' }
],
map
)
expect(one.RAW_VALUE).not.toBe(other.RAW_VALUE)
expect(clauses).toHaveSize(2)
expect(clauses.map((c) => c.RAW_VALUE)).toEqual([
one.RAW_VALUE,
other.RAW_VALUE
])
for (const clause of clauses) {
expect(clause.VARIABLE_NM).toBe('SOME_DATE')
expect(clause.OPERATOR_NM).toBe('=')
expect(clause.GROUP_LOGIC).toBe('AND')
expect(clause.SUBGROUP_LOGIC).toBe('AND')
expect(clause.SUBGROUP_ID).toBe(0)
}
})
it('applies a transform to the clause it builds', () => {
// ADD(QUARTER_START,-1) is the reported requirement: the selected row plus
// the row for the previous quarter, as one IN clause so the AND rule is
// untouched. 2026-10-01 = 24380, 2026-07-01 = 24288 in SAS days.
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
const [clause] = build([{ label: 'SELECTED', value: '01OCT2026' }], map)
expect(clause.VARIABLE_NM).toBe('SOME_DATE')
expect(clause.OPERATOR_NM).toBe('IN')
expect(clause.RAW_VALUE).toBe('(24380,24288)')
})
it('applies a RANGE as BETWEEN', () => {
// RANGE(QUARTER,-1) for a mid-quarter value: 2026-04-01..2026-06-30
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'RANGE' as const,
period: 'QUARTER' as const,
offset: -1
}
}
]
])
const [clause] = build([{ label: 'SELECTED', value: '1962-08-15' }], map)
expect(clause.OPERATOR_NM).toBe('BETWEEN')
expect(clause.RAW_VALUE).toContain(' AND ')
expect(clause.RAW_VALUE.split(' AND ')).toHaveSize(2)
})
it('keeps equality when the value already sits on the anchor', () => {
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: 0
}
}
]
])
const [clause] = build([{ label: 'SELECTED', value: '01OCT2026' }], map)
expect(clause.OPERATOR_NM).toBe('=')
expect(clause.RAW_VALUE).toBe('24380')
})
it('drops a transform on a column it cannot apply to', () => {
// A character column has no dates to shift; the parameter is skipped rather
// than filtered on the raw value the author did not see.
const map = new Map([
[
'selected',
{
column: 'SOME_CHAR',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
expect(build([{ label: 'SELECTED', value: 'x' }], map)).toEqual([])
})
it('adds nothing when the value is unparseable as a date', () => {
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
expect(build([{ label: 'SELECTED', value: 'not a date' }], map)).toEqual(
[]
)
})
it('round-trips a bare SAS day number unchanged', () => {
// VA may send the SAS internal value rather than a formatted string. The
// Date a transform works from is local, so the number must come back out of
// the clause identical - a UTC-built Date shifts a day west of UTC.
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
const [clause] = build([{ label: 'SELECTED', value: 24380 }], map)
expect(clause.RAW_VALUE).toBe('(24380,24288)')
})
it('reports a range mapping fed more than one value', () => {
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'RANGE' as const,
period: 'QUARTER' as const,
offset: -1
}
}
]
])
const problems = service.unsupportedTransforms(
msg([{ label: 'SELECTED', value: ['01APR2026', '01JUL2026'] }]),
map,
[{ NAME: 'SOME_DATE', DDTYPE: 'DATE', FMTNAME: 'DATE9' }]
)
expect(problems).toHaveSize(1)
expect(problems[0]).toContain('is a range')
expect(problems[0]).toContain('2 values')
})
it('refuses a range fed more than one value, rather than picking one', () => {
// Two values, one of which does not parse: building a BETWEEN from the
// survivor would silently filter one period when VA selected two.
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'RANGE' as const,
period: 'QUARTER' as const,
offset: -1
}
}
]
])
expect(
build([{ label: 'SELECTED', value: ['01APR2026', 'not a date'] }], map)
).toEqual([])
})
it('never lets a non-finite date reach the clause', () => {
// convertJsDateToSasDate returning NaN would otherwise put a literal NaN in
// the WHERE clause.
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
spyOn(helperService, 'convertJsDateToSasDate').and.returnValue(NaN as any)
expect(build([{ label: 'SELECTED', value: '01OCT2026' }], map)).toEqual(
[]
)
})
it('reports a partly unparseable value set instead of covering the rest', () => {
// One good value and one that is not a date: the clause still filters on
// the good one, so the author has to be told the other was dropped.
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
const problems = service.unsupportedTransforms(
msg([{ label: 'SELECTED', value: ['01OCT2026', 'not a date'] }]),
map,
cols
)
expect(problems).toHaveSize(1)
expect(problems[0]).toContain('1 of its 2 values was not a date')
})
it('reports a range holding a value that does not parse', () => {
// Counted on what VA sent, not on what parsed: otherwise the second value
// vanishes and the range looks like a clean single-value case.
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'RANGE' as const,
period: 'QUARTER' as const,
offset: -1
}
}
]
])
const problems = service.unsupportedTransforms(
msg([{ label: 'SELECTED', value: ['01APR2026', 'not a date'] }]),
map,
cols
)
expect(problems).toHaveSize(1)
expect(problems[0]).toContain('is a range')
expect(problems[0]).toContain('2 values')
})
it('applies a transform on the deferred initial load too', () => {
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
const [clause] = service.buildInitialClauses(
msg([{ name: 'pr1', label: 'SELECTED', value: '01OCT2026' }]),
map
)
expect(clause.VARIABLE_NM).toBe('SOME_DATE')
expect(clause.RAW_VALUE).toBe('(24380,24288)')
})
it('behaves exactly as before with an empty map', () => {
expect(
service.buildClauses(
msg([{ label: 'SOME_CHAR', value: 'x' }]),
labelToVar(),
cols
)
).toEqual(build([{ label: 'SOME_CHAR', value: 'x' }], new Map()))
})
})
describe('unresolvedMappings (what the report author is warned about)', () => {
const report = (
parameters: any[],
map: Map<string, VaParmapTarget>,
libds?: string
) => service.unresolvedMappings(msg(parameters), map, cols, libds)
it('reports a mapping whose column is not in the loaded table', () => {
expect(
report(
[{ label: 'REGION_TAB1', value: 'x' }],
new Map([['region_tab1', { column: 'NOT_A_COLUMN' }]]),
'DC_JSLIB.MPE_X_TEST'
)
).toEqual([
'VA parameter "REGION_TAB1" is mapped to NOT_A_COLUMN, which is not a column of DC_JSLIB.MPE_X_TEST - the filter was ignored.'
])
})
it('says "this table" when the table name is not supplied', () => {
expect(
report(
[{ label: 'REGION_TAB1', value: 'x' }],
new Map([['region_tab1', { column: 'NOPE' }]])
)[0]
).toContain('is not a column of this table')
})
it('reports nothing when the mapping resolves, or the param is unmapped', () => {
expect(
report(
[{ label: 'REGION_TAB1', value: 'x' }],
new Map([['region_tab1', { column: 'some_char' }]])
)
).toEqual([])
expect(report([{ label: 'SOME_CHAR', value: 'x' }], new Map())).toEqual(
[]
)
// A mapping whose parameter this message does not carry is not an error.
expect(
report([], new Map([['region_tab1', { column: 'NOPE' }]]))
).toEqual([])
})
it('reports each mapping once, not once per message parameter', () => {
const problems = report(
[
{ label: 'REGION_TAB1', value: '1' },
{ name: 'pr99', label: 'REGION_TAB1', value: '2' }
],
new Map([['region_tab1', { column: 'NOPE' }]])
)
expect(problems).toHaveSize(1)
})
it('treats an unloaded column list as "not known yet", not as a bad mapping', () => {
// While the grid is still loading there is no metadata to judge against, so
// reporting would flash the banner on every load.
expect(
service.unresolvedMappings(
msg([{ label: 'REGION_TAB1', value: 'x' }]),
new Map([['region_tab1', { column: 'NOPE' }]]),
[],
'DC_JSLIB.MPE_X_TEST'
)
).toEqual([])
expect(
service.unresolvedMappings(
msg([{ label: 'REGION_TAB1', value: 'x' }]),
new Map([['region_tab1', { column: 'NOPE' }]]),
undefined as any,
'DC_JSLIB.MPE_X_TEST'
)
).toEqual([])
})
})
describe('sameColumnMappings (two parameters, one column)', () => {
it('reports an AND-ed pair, naming both parameters and the column', () => {
const map = new Map([
['dropdown_tab1', { column: 'SOME_DROPDOWN' }],
['dropdown_tab2', { column: 'SOME_DROPDOWN' }]
])
const problems = service.sameColumnMappings(
msg([
{ label: 'DROPDOWN_TAB1', value: 'Option 1' },
{ label: 'DROPDOWN_TAB2', value: 'Option 2' }
]),
map
)
expect(problems).toHaveSize(1)
expect(problems[0]).toContain('"DROPDOWN_TAB1" and "DROPDOWN_TAB2"')
expect(problems[0]).toContain('all filter SOME_DROPDOWN')
expect(problems[0]).toContain('combined with AND')
})
it('says nothing for one parameter per column', () => {
const map = new Map([
['dropdown_tab1', { column: 'SOME_DROPDOWN' }],
['num_tab1', { column: 'SOME_NUM' }]
])
expect(
service.sameColumnMappings(
msg([
{ label: 'DROPDOWN_TAB1', value: 'Option 1' },
{ label: 'NUM_TAB1', value: '1' }
]),
map
)
).toEqual([])
expect(
service.sameColumnMappings(
msg([{ label: 'DROPDOWN_TAB1', value: 'Option 1' }]),
map
)
).toEqual([])
expect(service.sameColumnMappings(msg([]), map)).toEqual([])
})
it('says nothing when the AND-ed parameters carry the same value', () => {
// col = 'x' AND col = 'x' filters exactly as one clause does, so a banner
// claiming "different values will show no rows" would be wrong.
const map = new Map([
['dropdown_tab1', { column: 'SOME_CHAR' }],
['dropdown_tab2', { column: 'SOME_CHAR' }]
])
expect(
service.sameColumnMappings(
msg([
{ label: 'DROPDOWN_TAB1', value: 'x' },
{ label: 'DROPDOWN_TAB2', value: 'x' }
]),
map,
cols
)
).toEqual([])
// One differing value and the intersection can be empty: reported.
const problems = service.sameColumnMappings(
msg([
{ label: 'DROPDOWN_TAB1', value: 'x' },
{ label: 'DROPDOWN_TAB2', value: 'y' }
]),
map,
cols
)
expect(problems).toHaveSize(1)
expect(problems[0]).toContain('"DROPDOWN_TAB1" and "DROPDOWN_TAB2"')
})
it('leaves a column the table does not have to unresolvedMappings', () => {
// One cause, one line: the column is missing, so the AND story is moot.
const map = new Map([
['a', { column: 'NOPE' }],
['b', { column: 'NOPE' }]
])
const cols = [
{ LIBREF: 'DC_JSLIB', TABLE: 'MPE_X_TEST', VARIABLE: 'SOME_CHAR' }
]
expect(
service.sameColumnMappings(
msg([
{ label: 'A', value: '1' },
{ label: 'B', value: '2' }
]),
map,
cols
)
).toEqual([])
// Without metadata the pair is still reported: the AND semantics hold
// whatever the table turns out to be.
expect(
service.sameColumnMappings(
msg([
{ label: 'A', value: '1' },
{ label: 'B', value: '2' }
]),
map
)
).toHaveSize(1)
})
it('reports one line per contended column', () => {
const map = new Map([
['a', { column: 'SOME_DROPDOWN' }],
['b', { column: 'SOME_DROPDOWN' }],
['c', { column: 'SOME_NUM' }],
['d', { column: 'SOME_NUM' }]
])
const problems = service.sameColumnMappings(
msg([
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
{ label: 'C', value: '3' },
{ label: 'D', value: '4' }
]),
map
)
expect(problems).toHaveSize(2)
})
})
describe('unmatchedMappings (parameter-side typo)', () => {
const seen = (...keys: string[]) => new Set(keys)
it('reports a key that no message has named', () => {
expect(
service.unmatchedMappings(
new Map([['dropdown_tab1', { column: 'SOME_CHAR' }]]),
seen('dropdown_tab_1', 'pr1'),
true
)
).toEqual([
'This object\'s va_parmap names VA parameter "dropdown_tab1", which VA is not sending - check the spelling.'
])
})
it('says nothing until a parameter has actually been seen', () => {
// Without evidence that this object receives parameters at all, an absent
// key proves nothing - VA sends only the parameters its query uses.
expect(
service.unmatchedMappings(
new Map([['dropdown_tab1', { column: 'SOME_CHAR' }]]),
seen()
)
).toEqual([])
expect(
service.unmatchedMappings(
new Map([['dropdown_tab1', { column: 'SOME_CHAR' }]]),
undefined
)
).toEqual([])
})
it('says nothing when every key has been seen', () => {
expect(
service.unmatchedMappings(
new Map([['dropdown_tab1', { column: 'SOME_CHAR' }]]),
seen('dropdown_tab1'),
true
)
).toEqual([])
expect(
service.unmatchedMappings(new Map(), seen('anything'), true)
).toEqual([])
})
it('says nothing while the parameter set is still growing', () => {
// A message that introduces a new parameter name means this object's set
// has not settled, so an absent key may arrive in the next message.
expect(
service.unmatchedMappings(
new Map([['dropdown_tab1', { column: 'SOME_CHAR' }]]),
seen('pr1', 'pr2'),
false
)
).toEqual([])
})
})
describe('buildInitialClauses (no metadata, infer kind from value)', () => {
it('uses an explicit mapping instead of the label', () => {
// The deferred first load has no column metadata, so a mapped column is
// used as given and the SAS kind still comes from the value shape.
const map = new Map([
['region_tab1', { column: 'REGION' }],
['amt_tab1', { column: 'AMOUNT' }]
])
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'REGION_TAB1', dataType: 'string', value: 'n' },
{ name: 'pr2', label: 'AMT_TAB1', dataType: 'number', value: '10' }
]),
map
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['REGION', "'n'"],
['AMOUNT', '10']
])
})
it('falls back to the label for params the map does not name', () => {
const map = new Map([['region_tab2', { column: 'REGION' }]])
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'REGION_TAB1', value: 'n' },
{ name: 'pr2', label: 'REGION_TAB2', value: 's' }
]),
map
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['REGION_TAB1', "'n'"],
['REGION', "'s'"]
])
})
it('uses the VA label as the column and infers kinds by value shape', () => {
const clauses = service.buildInitialClauses(
msg([
+47 -461
View File
@@ -2,12 +2,6 @@ import { Injectable } from '@angular/core'
import { HelperService } from './helper.service'
import { VaMessage } from './va-messaging.service'
import { QueryClause } from '../models/TableData'
import { VaParmapTarget } from '../shared/utils/parse-va-parmap'
import {
applyVaTransform,
isRangeTransform,
VaTransform
} from '../shared/utils/va-parmap-transform'
/** SAS data-type kind used to format a VA filter value into a DC RAW_VALUE. */
type VaColumnKind = 'char' | 'numeric' | 'time' | 'date' | 'datetime'
@@ -60,19 +54,11 @@ export class VaFilterService {
* Translates VA `parameters` into DC filter clauses. Each parameter is matched
* to a DC column by label; a single value becomes `col = value`, multiple
* values become `col IN (...)`. Values are formatted for the column's SAS kind.
*
* `paramMap` (from the URL `va_parmap` parameter, see parseVaParmap) overrides
* that same-name resolution for the parameters it names, so two DC instances
* over one table can filter the same column from differently-named VA
* parameters. A mapped column must exist in `cols` - if it does not the
* parameter is skipped rather than falling back to the same-name match, which
* is exactly the behaviour the mapping exists to override.
*/
buildClauses(
msg: VaMessage,
labelToVar: Map<string, string>,
cols: any[],
paramMap?: Map<string, VaParmapTarget>
cols: any[]
): QueryClause[] {
const clauses: QueryClause[] = []
for (const param of msg.parameters || []) {
@@ -81,194 +67,60 @@ export class VaFilterService {
.toString()
.trim()
.toLowerCase()
// An explicit URL mapping wins over the label->column lookup. The mapped
// column has to exist in THIS table, else the entry does not apply here
// (and the same-name fallback would be the behaviour it exists to
// override).
const mapped = this.mappedColumn(param, paramMap)
const mappedCol = mapped ? this.findCol(mapped, cols) : undefined
if (mapped && !mappedCol) continue
// The table's own case, so the clause matches what the metadata build
// produces for a same-name match.
const varName: string = mappedCol
? mappedCol.NAME
: (labelToVar.get(key) ?? '')
// A VA param whose label isn't a DC column (beyond case, which is handled)
// simply doesn't map — skip it.
const varName = labelToVar.get(key)
if (!varName) continue
const col = mappedCol ?? this.findCol(varName, cols)
const col = this.findCol(varName, cols)
const kind = this.columnKind(col)
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
const built = this.buildClauseValueSet(
param,
kind,
order,
this.mappedTarget(param, paramMap)?.transform
)
if (!built) continue
clauses.push({
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: varName,
OPERATOR_NM: built.operator,
RAW_VALUE: built.rawValue
})
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
// Format/validate every value for the column's SAS type. Numeric columns
// must receive a finite number (untrusted postMessage input is
// interpolated into a SAS WHERE clause, so a non-numeric value here would
// be an injection vector); temporal -> SAS internal number; char ->
// escaped. Invalid values are dropped.
const formatted = rawValues
.filter((v) => v !== undefined && v !== null && v !== '')
.map((v) => this.formatValueByKind(v, kind, order))
.filter((v): v is string => v !== null)
if (formatted.length === 0) continue
clauses.push(this.clause(varName, formatted))
}
return clauses
}
/**
* The operator and raw value for one parameter, after any transform.
*
* Values are formatted for the column's SAS kind: numeric columns must receive
* a finite number (untrusted postMessage input is interpolated into a WHERE
* clause, so anything else would be an injection vector); temporal values
* become SAS internal numbers; char values are escaped. Invalid values are
* dropped, and a transform that cannot be applied to the column's kind yields
* nothing at all - the caller reports that separately rather than filtering on
* a value the author did not choose.
*/
private buildClauseValueSet(
param: any,
kind: VaColumnKind,
order: 'YMD' | 'DMY' | 'MDY' | undefined,
transform?: VaTransform
): { operator: '=' | 'IN' | 'BETWEEN'; rawValue: string } | null {
const rawValues = (
Array.isArray(param.value) ? param.value : [param.value]
).filter((v: any) => v !== undefined && v !== null && v !== '')
if (rawValues.length === 0) return null
if (!transform) {
const formatted = rawValues
.map((v: any) => this.formatValueByKind(v, kind, order))
.filter((v: string | null): v is string => v !== null)
if (formatted.length === 0) return null
return formatted.length === 1
? { operator: '=', rawValue: formatted[0] }
: { operator: 'IN', rawValue: `(${formatted.join(',')})` }
}
// Transforms resolve to dates, so only temporal columns can take one.
if (kind !== 'date' && kind !== 'datetime') return null
const unit = kind === 'datetime' ? 'seconds' : 'days'
const values = new Set<string>()
for (const raw of rawValues) {
const resolved = this.valueAsDate(raw, kind, order)
if (!resolved) continue
for (const date of applyVaTransform(
resolved,
transform,
kind === 'datetime'
)) {
// A non-finite result (an Invalid Date reaching the helper) must never be
// interpolated into the clause: `NaN` is not a SAS value. Dropped here,
// and reported by unsupportedTransforms so the parameter is not silently
// filtered on nothing.
const sasDate = this.helperService.convertJsDateToSasDate(date, unit)
if (!Number.isFinite(Number(sasDate))) continue
values.add(`${sasDate}`)
}
}
const formatted = Array.from(values)
if (formatted.length === 0) return null
if (isRangeTransform(transform)) {
// One BETWEEN carries one period, so more than one value has no clause -
// dropping it here (rather than building a BETWEEN from whichever value
// parsed) is what unsupportedTransforms' count warning then describes.
if (rawValues.length > 1) return null
if (formatted.length !== 2) return null
return { operator: 'BETWEEN', rawValue: formatted.join(' AND ') }
}
return formatted.length === 1
? { operator: '=', rawValue: formatted[0] }
: { operator: 'IN', rawValue: `(${formatted.join(',')})` }
}
/**
* A VA value as a local Date, for a transform to work from. Formatted strings
* are parsed by shape; a bare number is already the SAS internal value, so it
* is converted back from days (date) or seconds (datetime) since 1960-01-01.
*
* Built with local calendar construction, matching `parseDate` and
* `HelperService.convertJsDateToSasDate`: a UTC instant read back through local
* accessors lands on the previous day west of UTC, and the round trip to a SAS
* day number would be one out.
*/
private valueAsDate(
value: any,
kind: VaColumnKind,
order?: 'YMD' | 'DMY' | 'MDY'
): Date | null {
const s = String(value).trim()
if (s === '') return null
if (this.isBareNumber(s)) {
const internal = Number(s)
if (kind !== 'datetime') return new Date(1960, 0, 1 + internal)
// Seconds since midnight 1960-01-01, split into whole days and a remainder
// so the constructor normalises the clock time locally.
const days = Math.floor(internal / 86400)
const seconds = internal - days * 86400
return new Date(1960, 0, 1 + days, 0, 0, seconds)
}
return kind === 'datetime'
? this.parseDatetime(s, order)
: this.parseDate(s, order)
}
/**
* Pre-getdata variant used on the deferred initial load, when DC column
* metadata isn't available. Uses the VA `label` as the column name and infers
* the SAS kind from the value shape, so the clause matches what the metadata
* build later produces (no reconcile reload).
*
* `paramMap` overrides the label-as-column rule for the parameters it names,
* so the very first (already filtered) load also honours an explicit mapping.
*/
buildInitialClauses(
msg: VaMessage,
paramMap?: Map<string, VaParmapTarget>
): QueryClause[] {
buildInitialClauses(msg: VaMessage): QueryClause[] {
const clauses: QueryClause[] = []
for (const param of msg.parameters || []) {
if (!param || param.value === undefined || param.value === null) continue
const target = this.mappedTarget(param, paramMap)
// Without a mapping the DC column name is the VA `label`; `name` is VA's
// internal id (pr###). A `va_parmap` entry wins over both.
const varName = (target?.column ?? param.label ?? param.name ?? '')
.toString()
.trim()
// The DC column name is the VA `label`; `name` is VA's internal id (pr###).
const varName = (param.label ?? param.name ?? '').toString().trim()
if (!varName) continue
const rawValues = (
Array.isArray(param.value) ? param.value : [param.value]
) as any[]
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
const nonEmpty = rawValues.filter(
(v) => v !== undefined && v !== null && v !== ''
)
if (nonEmpty.length === 0) continue
// A transform only ever produces dates, so it fixes the kind: inferring it
// from the value would see a formatted date either way, but with the
// transform the column is known to be temporal.
const kind = target?.transform
? ('date' as VaColumnKind)
: this.inferKindFromValue(String(nonEmpty[0]).trim(), param.dataType)
const built = this.buildClauseValueSet(
param,
kind,
undefined,
target?.transform
)
if (!built) continue
clauses.push({
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: varName,
OPERATOR_NM: built.operator,
RAW_VALUE: built.rawValue
})
const formatted = nonEmpty
.map((v) =>
this.formatValueByKind(
v,
this.inferKindFromValue(String(v).trim(), param.dataType)
)
)
.filter((v): v is string => v !== null)
if (formatted.length === 0) continue
clauses.push(this.clause(varName, formatted))
}
return clauses
}
@@ -289,254 +141,16 @@ export class VaFilterService {
)
}
/**
* Transform mappings in this message that cannot be applied, one plain-English
* line each: the column is not temporal, or the value is not a date the browser
* can parse. `buildClauseValueSet` skips those parameters, so this is what stops
* the misconfiguration reading as a filter that simply matched nothing.
*
* A missing column is left to `unresolvedMappings`, which already reports it.
*/
unsupportedTransforms(
msg: VaMessage,
paramMap: Map<string, VaParmapTarget> | undefined,
cols: any[]
): string[] {
if (!paramMap || paramMap.size === 0) return []
if (!cols || cols.length === 0) return []
const problems = new Set<string>()
for (const param of msg.parameters || []) {
const target = this.mappedTarget(param, paramMap)
if (!target?.transform) continue
const transform = target.transform
const col = this.findCol(target.column, cols)
if (!col) continue
const kind = this.columnKind(col)
const paramName = (param.label ?? param.name ?? '').toString().trim()
const spec =
target.transform.op === 'ADD'
? `ADD(${target.transform.anchor},${target.transform.offset})`
: `RANGE(${target.transform.period},${target.transform.offset})`
if (kind !== 'date' && kind !== 'datetime') {
problems.add(
`VA parameter "${paramName}" maps to ${target.column}:${spec}, but ${target.column} is not a date or datetime column - the filter was ignored.`
)
continue
}
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
const sent = (
Array.isArray(param.value) ? param.value : [param.value]
).filter((v: any) => v !== undefined && v !== null && v !== '')
const usable = sent.filter((v: any) => {
const date = this.valueAsDate(v, kind, order)
if (!date) return false
// Same test the clause builder applies, so this count matches what it uses.
return applyVaTransform(date, transform, kind === 'datetime').every(
(resolved) => {
const sasDate = this.helperService.convertJsDateToSasDate(
resolved,
kind === 'datetime' ? 'seconds' : 'days'
)
return Number.isFinite(Number(sasDate))
}
)
})
if (usable.length === 0) {
problems.add(
`VA parameter "${paramName}" maps to ${target.column}:${spec}, but its value was not a date - the filter was ignored.`
)
continue
}
// One BETWEEN can only express one period. A multi-select would have to
// become an OR of ranges, which the clause model does not carry, so say so
// rather than building a BETWEEN from whichever value happened to parse.
// Counted on what VA sent: a second value that does not parse must not make
// the single-value case look clean.
if (isRangeTransform(target.transform) && sent.length > 1) {
problems.add(
`VA parameter "${paramName}" maps to ${target.column}:${spec}, which is a range, but the parameter has ${sent.length} values - a range needs one value, so the filter was ignored.`
)
continue
}
if (usable.length < sent.length) {
problems.add(
`VA parameter "${paramName}" maps to ${target.column}:${spec}, but ${sent.length - usable.length} of its ${sent.length} values was not a date - the filter covers only the rest.`
)
}
private clause(varName: string, formatted: string[]): QueryClause {
return {
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: varName,
OPERATOR_NM: formatted.length === 1 ? '=' : 'IN',
RAW_VALUE:
formatted.length === 1 ? formatted[0] : `(${formatted.join(',')})`
}
return Array.from(problems)
}
/**
* `va_parmap` entries that this message actually uses but that name a column
* the loaded table does not have, one plain-English line each. `buildClauses`
* skips those parameters (deliberately: falling back to the same-name match
* would silently apply the filter the mapping exists to override), so this is
* what makes the misconfiguration visible to the report author - otherwise the
* grid simply shows every row, which looks like a working filter.
*/
unresolvedMappings(
msg: VaMessage,
paramMap: Map<string, VaParmapTarget> | undefined,
cols: any[],
libds?: string
): string[] {
const problems = new Set<string>()
if (!paramMap || paramMap.size === 0) return []
// No metadata yet is not the same as "the table lacks this column": an empty
// `cols` means the grid is still loading, so there is nothing to judge yet.
// Reporting here would flash a misconfiguration banner during every load.
if (!cols || cols.length === 0) return []
for (const param of msg.parameters || []) {
const mapped = this.mappedColumn(param, paramMap)
if (!mapped || this.findCol(mapped, cols)) continue
const paramName = (param.label ?? param.name ?? '').toString().trim()
problems.add(
`VA parameter "${paramName}" is mapped to ${mapped}, which is not a column of ${
libds || 'this table'
} - the filter was ignored.`
)
}
return Array.from(problems)
}
/**
* `va_parmap` keys that no VA message has named, one plain-English line each.
* A key that matches nothing is a parameter-side typo: the mapping never
* applies and the grid stays unfiltered, which looks the same as a working
* filter.
*
* `seenKeys` must be every key observed across messages so far. `paramsStable`
* says the latest message introduced no new parameter name, i.e. this object's
* parameter set has settled - until then a missing name may simply not have
* arrived, because VA sends only the parameters its query uses.
*/
unmatchedMappings(
paramMap: Map<string, VaParmapTarget> | undefined,
seenKeys: Set<string> | undefined,
paramsStable = false
): string[] {
if (!paramMap || paramMap.size === 0) return []
if (!seenKeys || seenKeys.size === 0) return []
if (!paramsStable) return []
const problems: string[] = []
for (const key of Array.from(paramMap.keys())) {
if (!seenKeys.has(key)) {
problems.push(
`This object's va_parmap names VA parameter "${key}", which VA is not sending - check the spelling.`
)
}
}
return problems
}
/**
* Parameters in this message that filter the same column with *different*
* values, one plain-English line per column. Clauses are AND-ed, so two
* parameters on one column are an intersection: with different values the grid
* comes back empty, which looks like a working filter. Two parameters carrying
* the same value intersect cleanly and filter correctly, so they are not
* reported - a banner there would call a working configuration broken.
*
* Reported rather than rewritten - folding them into one `IN (...)` would
* silently turn an AND into an OR, which is a semantics decision, not a
* diagnostic.
*/
sameColumnMappings(
msg: VaMessage,
paramMap: Map<string, VaParmapTarget> | undefined,
cols?: any[]
): string[] {
if (!paramMap || paramMap.size === 0) return []
// column -> the parameters on it, each with the clause value it produces
const byColumn = new Map<string, { name: string; value: string }[]>()
for (const param of msg.parameters || []) {
const mapped = this.mappedColumn(param, paramMap)
if (!mapped) continue
// A column the table lacks is unresolvedMappings' finding; counting it here
// would report one cause as two symptoms.
const col =
cols && cols.length > 0 ? this.findCol(mapped, cols) : undefined
if (cols && cols.length > 0 && !col) continue
// A parameter that produces no clause cannot conflict with anything.
const value = this.valueSignature(
param,
col,
this.mappedTarget(param, paramMap)?.transform
)
if (value === null) continue
const column = mapped.toUpperCase()
const params = byColumn.get(column) || []
const paramName = (param.label ?? '').toString().trim()
if (paramName && !params.some((p) => p.name === paramName)) {
params.push({ name: paramName, value })
}
byColumn.set(column, params)
}
const problems: string[] = []
for (const [column, params] of Array.from(byColumn.entries())) {
if (params.length < 2) continue
// Same value on every parameter: the AND is a no-op, not an empty grid.
const distinct = new Set(params.map((p) => p.value))
if (distinct.size === 1) continue
problems.push(
`VA parameters ${params
.map((p) => `"${p.name}"`)
.join(
' and '
)} all filter ${column}. Parameters are combined with AND, so rows must match all of them - different values will show no rows. Use one parameter carrying both values to match either.`
)
}
return problems
}
/**
* The DC column an explicit URL mapping (`va_parmap`) assigns to this
* parameter, or undefined when the parameter is not mapped. The key is matched
* case-insensitively against the parameter's `label`.
*/
private mappedTarget(
param: any,
paramMap?: Map<string, VaParmapTarget>
): VaParmapTarget | undefined {
if (!paramMap || paramMap.size === 0) return undefined
// The label only. The internal name (pr273) is an implementation detail of
// the report, and matching both made one parameter answer to two keys: a
// mapping written against the name could never be distinguished from a typo
// once the label was also mapped, and the second entry silently lost.
const key = (param.label ?? '').toString().trim().toLowerCase()
if (!key) return undefined
return paramMap.get(key)
}
/**
* The clause value a parameter would filter its column with, as a comparable
* string, or null when it produces no clause at all. Used to tell an AND of two
* identical values (harmless) from an AND of two different ones (empty grid).
*
* Delegates to the clause builder so a transform's derived dates take part in
* the comparison: two parameters adding the same quarter are the same clause.
*/
private valueSignature(
param: any,
col: any,
transform?: VaTransform
): string | null {
const built = this.buildClauseValueSet(
param,
this.columnKind(col),
this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT),
transform
)
return built ? built.rawValue : null
}
/** The mapped column name, for callers that do not need the transform. */
private mappedColumn(
param: any,
paramMap?: Map<string, VaParmapTarget>
): string | undefined {
return this.mappedTarget(param, paramMap)?.column
}
/** The loaded column spec whose NAME matches varName (case-insensitive). */
@@ -707,42 +321,14 @@ export class VaFilterService {
return this.parseMonthNameDate(value) ?? this.parseNumericDate(value, order)
}
/**
* Month-name date shapes -> local Date, or null. Three families:
* - DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962, 01AUG62);
* - word date with the month first, comma optional: "January 1, 2026",
* "Jan 1 2026", "Jan. 1, 2026";
* - word date with the day first: "1 January 2026", "1-Jan-2026".
*
* A date parameter's display format decides which of these the report author
* sees, and VA sends the formatted text, so a report using WORDDATE (or a
* long month name in any order) has to parse here rather than being dropped.
* The month name may be abbreviated or spelled out; only its first three
* letters are matched.
*/
/** DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962, 01AUG62) -> local Date, or null. */
private parseMonthNameDate(value: string): Date | null {
const monthByName = (name: string): number =>
VaFilterService.SAS_MONTHS.indexOf(
name.replace(/\./g, '').slice(0, 3).toUpperCase()
)
const date9 = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
if (date9) {
const [, day, monthAbbr, year] = date9
const monthIndex = monthByName(monthAbbr)
if (monthIndex < 0) return null
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
}
const monthFirst =
/^([A-Za-z]{3,9})\.?[\s-]+(\d{1,2}),?[\s-]+(\d{2}|\d{4})$/.exec(value)
const dayFirst =
/^(\d{1,2})[\s-]+([A-Za-z]{3,9})\.?,?[\s-]+(\d{2}|\d{4})$/.exec(value)
const name = monthFirst ? monthFirst[1] : dayFirst ? dayFirst[2] : null
if (!name) return null
const day = monthFirst ? monthFirst[2] : dayFirst![1]
const year = monthFirst ? monthFirst[3] : dayFirst![3]
const monthIndex = monthByName(name)
const m = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
if (!m) return null
const [, day, monthAbbr, year] = m
const monthIndex = VaFilterService.SAS_MONTHS.indexOf(
monthAbbr.toUpperCase()
)
if (monthIndex < 0) return null
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
}
@@ -1,6 +1,4 @@
import { VaMessagingService, VaMessage } from './va-messaging.service'
import { VaFilterService } from './va-filter.service'
import { HelperService } from './helper.service'
// Sample message taken from the official SAS DDC docs (brush column included).
const SAMPLE = {
@@ -53,57 +51,6 @@ describe('VaMessagingService', () => {
})
})
describe('parameter keys (what the va_parmap typo diagnostic is judged on)', () => {
const PARAMS = [
{ name: 'pr273', label: 'Tab_1_Date', value: '2025-03-31' },
{ name: 'pr274', label: 'Tab 2 Region', value: 'north' }
]
const withParams = () => ({ ...SAMPLE, parameters: PARAMS })
it('records each parameter label once, across messages', () => {
service.parseMessage({ data: withParams() } as MessageEvent)
service.parseMessage({ data: withParams() } as MessageEvent)
expect(Array.from(service.seenParamKeys)).toEqual([
'tab_1_date',
'tab 2 region'
])
// The second message introduced no new label, so the set has settled.
expect(service.lastMessageParamsStable).toBeTrue()
})
it('records the label only, so a mapping written against the internal name is reported', () => {
// `pr273` is VA's own bookkeeping. A va_parmap entry written against it
// never matches a parameter, and recording the name here would make the
// typo diagnostic believe VA is sending it - leaving the author with an
// unfiltered grid and no warning at all.
service.parseMessage({ data: withParams() } as MessageEvent)
service.parseMessage({ data: withParams() } as MessageEvent)
const filter = new VaFilterService(new HelperService({} as any))
const seen = service.seenParamKeys
const stable = service.lastMessageParamsStable
expect(
filter.unmatchedMappings(
new Map([['pr273', { column: 'D_DATE' }]]),
seen,
stable
)
).toEqual([
'This object\'s va_parmap names VA parameter "pr273", which VA is not sending - check the spelling.'
])
// The label is a real key, so the same map written that way is silent.
expect(
filter.unmatchedMappings(
new Map([['tab_1_date', { column: 'D_DATE' }]]),
seen,
stable
)
).toEqual([])
})
})
describe('dataColumns', () => {
it('excludes brush columns and keeps original indices', () => {
const msg = service.parseMessage({ data: SAMPLE } as MessageEvent)!
@@ -82,26 +82,6 @@ export class VaMessagingService {
/** DC variable names that should be visible (the rest are hidden). */
visibleColumns: string[] | null = null
/**
* Lower-cased label of every parameter any VA message has carried so far, kept
* across editor reloads. Lets the editor tell a `va_parmap` key that names no
* parameter in the report (a typo, so the filter would silently never apply)
* from one that is merely absent from the current message - VA only sends the
* parameters its query uses.
*
* Labels only: that is the key a mapping is matched on, and recording the
* internal name as well would make a name-keyed entry - which never applies -
* look like a parameter VA is sending.
*/
seenParamKeys = new Set<string>()
/**
* True when every parameter name in the latest message had already been seen.
* VA sends only the parameters a report object's query uses, and sends them per
* object, so a name that is absent from the first messages may simply not have
* arrived yet. A stable parameter set is what makes absence meaningful.
*/
lastMessageParamsStable = false
/** True once the pre-bootstrap early buffer has been drained (drain-once). */
private earlyDrained = false
@@ -207,22 +187,6 @@ export class VaMessagingService {
if (typeof data.resultName !== 'string') return null
if (!Array.isArray(data.columns) || !Array.isArray(data.data)) return null
const parameters = Array.isArray(data.parameters) ? data.parameters : []
const keys: string[] = []
for (const param of parameters) {
// The label only, which is the key a `va_parmap` entry is matched on. The
// internal name (`pr273`) is the report's own bookkeeping: recording it
// here would let the typo diagnostic below believe a name-keyed entry is
// being sent when no code path can ever apply it.
const key = (param?.label ?? '').toString().trim().toLowerCase()
if (key && !keys.includes(key)) keys.push(key)
}
// Judged before the keys are recorded: a message that introduces a new name
// means the set is still growing, so a missing name proves nothing yet.
this.lastMessageParamsStable =
keys.length > 0 && keys.every((key) => this.seenParamKeys.has(key))
for (const key of keys) this.seenParamKeys.add(key)
return {
version: data.version,
resultName: data.resultName,
@@ -230,7 +194,7 @@ export class VaMessagingService {
availableRowCount: data.availableRowCount,
data: data.data,
columns: data.columns,
parameters
parameters: Array.isArray(data.parameters) ? data.parameters : []
}
}
@@ -52,9 +52,9 @@
<button
type="button"
class="btn btn-primary"
(click)="openSasLogsModal()"
(click)="openRequestsModal()"
>
Open logs modal
Open requests modal
</button>
}
@@ -115,8 +115,8 @@ export class InfoModalComponent implements OnInit {
)
}
openSasLogsModal() {
this.eventService.openSasLogsModal()
openRequestsModal() {
this.eventService.openRequestsModal()
}
reload() {
@@ -1,212 +0,0 @@
import Handsontable from 'handsontable'
import {
buildGridPdfInput,
collectGridTable,
exportGrid,
isRangeEmpty,
getExportRange,
buildExportMenuItem
} from './hot-export.util'
/**
* The grid-reading side of the PDF export, against a real Handsontable
* instance so `getColHeader`, `getSelectedRangeLast` and the hiddenColumns
* plugin behave as they do in the viewer and editor.
*/
describe('hot-export grid extraction', () => {
let container: HTMLDivElement
let hot: Handsontable
const settings = (overrides: Record<string, any> = {}) => ({
data: [
['DELETE?', 'A1', '10.5'],
['', 'A2', '20.25'],
['', 'A3', null]
],
colHeaders: ['Delete?', 'ACCOUNT', 'BALANCE'],
rowHeaders: false,
licenseKey: 'non-commercial-and-evaluation',
...overrides
})
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
if (hot && !hot.isDestroyed) hot.destroy()
hot = undefined as any
container.remove()
})
it('reads headers and every row when nothing is selected', () => {
hot = new Handsontable(container, settings())
hot.render()
const table = collectGridTable(hot)
expect(table.headers).toEqual(['Delete?', 'ACCOUNT', 'BALANCE'])
expect(table.rows.length).toBe(3)
expect(table.rows[0]).toEqual(['DELETE?', 'A1', '10.5'])
expect(table.rows[2][2]).toBeNull()
})
it('drops the leading housekeeping column when asked', () => {
hot = new Handsontable(container, settings())
hot.render()
const table = collectGridTable(hot, 1)
expect(table.headers).toEqual(['ACCOUNT', 'BALANCE'])
expect(table.rows[0]).toEqual(['A1', '10.5'])
})
it('excludes columns hidden in the grid', () => {
hot = new Handsontable(
container,
settings({ hiddenColumns: { columns: [2], indicators: false } })
)
hot.render()
const table = collectGridTable(hot)
expect(table.headers).toEqual(['Delete?', 'ACCOUNT'])
expect(table.rows.map((row) => row.length)).toEqual([2, 2, 2])
})
it('survives a hiddenColumns plugin that cannot report (regression)', () => {
// getPlugin() hands back the instance even when the plugin is off, and its
// isHidden() then dereferences a map that was never created - seen in CI on
// grids with no hiddenColumns setting at all.
hot = new Handsontable(container, settings())
hot.render()
spyOn(hot, 'getPlugin').and.returnValue({
enabled: true,
isHidden: () => {
throw new TypeError(
"Cannot read properties of null (reading 'getValueAtIndex')"
)
}
} as any)
const table = collectGridTable(hot)
expect(table.headers).toEqual(['Delete?', 'ACCOUNT', 'BALANCE'])
expect(table.rows.length).toBe(3)
})
it('limits the export to a multi-cell selection', () => {
hot = new Handsontable(container, settings())
hot.render()
hot.selectCells([[1, 1, 2, 2]])
const range = getExportRange(hot)
const table = collectGridTable(hot)
expect(range).toEqual({ top: 1, left: 1, bottom: 2, right: 2 })
expect(table.headers).toEqual(['ACCOUNT', 'BALANCE'])
expect(table.rows).toEqual([
['A2', '20.25'],
['A3', null]
])
})
it('exports nothing when the selection is inside the skipped columns', () => {
hot = new Handsontable(container, settings())
hot.render()
// Rows 0-2 of column 0 only: the Delete? column, which the caller skips.
hot.selectCells([[0, 0, 2, 0]])
const range = getExportRange(hot, 1)
expect(isRangeEmpty(range)).toBeTrue()
expect(collectGridTable(hot, 1).headers).toEqual([])
expect(buildGridPdfInput(hot, 'WORK.TEST', 1)).toBeNull()
})
it('treats a single-cell cursor as "export everything"', () => {
hot = new Handsontable(container, settings())
hot.render()
hot.selectCell(1, 1)
expect(collectGridTable(hot).rows.length).toBe(3)
})
it('never starts inside the skipped housekeeping columns', () => {
hot = new Handsontable(container, settings())
hot.render()
// A selection that begins in the Delete? column still exports from column 1.
hot.selectCells([[0, 0, 2, 2]])
const range = getExportRange(hot, 1)
expect(range.left).toBe(1)
expect(collectGridTable(hot, 1).headers).toEqual(['ACCOUNT', 'BALANCE'])
})
it('keeps the skipped column out of CSV/Excel exports (regression)', async () => {
// The exportFile plugin is not enabled in these grids, so stand in for it
// and read the options the export passes through.
hot = new Handsontable(container, settings())
hot.render()
const downloadFile = jasmine.createSpy('downloadFile')
const getPlugin = hot.getPlugin.bind(hot)
spyOn(hot, 'getPlugin').and.callFake((name: string) =>
name === 'exportFile' ? ({ downloadFile } as any) : getPlugin(name as any)
)
await exportGrid(hot, 'csv', 'WORK.TEST', 1)
expect(downloadFile).toHaveBeenCalledTimes(1)
const opts = downloadFile.calls.mostRecent().args[1]
// Column 0 is the Delete? column; the export must not start at it, and the
// plugin must not be left to export the whole grid.
expect(opts['range']).toEqual([0, 1, 2, 2])
})
it('stops reading rows at the cap instead of collecting them all', () => {
hot = new Handsontable(container, settings())
hot.render()
const read = spyOn(hot, 'getDataAtCell').and.callThrough()
const table = collectGridTable(hot, 0, 2)
expect(table.rows.length).toBe(2)
expect(read).toHaveBeenCalledTimes(2 * 3) // 2 rows x 3 columns
})
it('still produces a PDF for a grid with no rows', () => {
// The real grids declare their columns, so an empty grid still has a
// schema - which is the case this guards. Without `columns`, HOT reports
// zero columns for `data: []` and there would be no table to describe.
hot = new Handsontable(
container,
settings({
data: [],
columns: [{ data: 0 }, { data: 1 }, { data: 2 }]
})
)
hot.render()
const input = buildGridPdfInput(hot, 'WORK.EMPTY')
expect(input).not.toBeNull()
expect(input!.headers).toEqual(['Delete?', 'ACCOUNT', 'BALANCE'])
expect(input!.rows).toEqual([])
expect(input!.subtitle).toBe('0 rows x 3 columns')
})
it('offers PDF alongside CSV and Excel in the context menu', () => {
const item: any = buildExportMenuItem(() => 'WORK.TEST')
const keys = item.submenu.items.map((sub: any) => sub.key)
const names = item.submenu.items.map((sub: any) => sub.name)
expect(keys).toEqual([
'export_file:csv',
'export_file:xlsx',
'export_file:pdf'
])
expect(names).toContain('PDF (.pdf)')
})
})
@@ -1,193 +1,7 @@
import Handsontable from 'handsontable'
import { MenuItemConfig } from 'handsontable/plugins/contextMenu'
import { downloadTablePdf, TablePdfInput } from './pdf-export.util'
export type HotExportFormat = 'csv' | 'xlsx' | 'pdf'
/**
* Row cap for the PDF export. Unlike CSV/Excel, a PDF is laid out page by page
* in the browser, so an unbounded grid would freeze the tab and produce a
* document nobody reads; the export says so in its subtitle when it truncates.
*/
const MAX_PDF_ROWS = 5000
/**
* Visual (0-based, inclusive) block of the grid to export: honours the current
* selection when there is one, otherwise the whole grid. `left` never reaches
* into the leading housekeeping columns the caller asked to skip, and a
* selection that lies entirely inside those columns yields an empty range
* (`right < left`) rather than being shifted sideways into a column the user
* did not select.
*/
export interface GridRange {
top: number
left: number
bottom: number
right: number
}
/** True when the range covers no column, so there is nothing to export. */
export function isRangeEmpty(range: GridRange): boolean {
return range.right < range.left
}
/**
* Works out which block of cells an export should cover.
*
* HOT's own export item only honours a selection spanning more than one cell:
* a right-click places a single-cell cursor and the corner click is select-all
* (negative coordinates), and both of those mean "export the whole table".
*/
export function getExportRange(
hot: Handsontable,
skipLeadingCols = 0
): GridRange {
const sel = hot.getSelectedRangeLast()
const lastRow = hot.countRows() - 1
const lastCol = hot.countCols() - 1
let top = 0
let left = skipLeadingCols
let bottom = lastRow
let right = lastCol
// sel.from/to.row/col are typed nullable (CellCoords allows an unset state),
// but a range returned by getSelectedRangeLast() always has real coordinates.
const isCornerSelectAll = !!sel && sel.from.row! < 0 && sel.from.col! < 0
if (sel && !sel.isSingleCell() && !isCornerSelectAll) {
top = Math.max(0, Math.min(sel.from.row!, sel.to.row!))
bottom = Math.max(sel.from.row!, sel.to.row!)
const selectedLeft = Math.min(sel.from.col!, sel.to.col!)
const selectedRight = Math.max(sel.from.col!, sel.to.col!)
left = Math.max(skipLeadingCols, selectedLeft)
right = selectedRight
// Selecting only the skipped leading column(s) leaves nothing to export;
// `right` below `left` is the empty range, never a sideways shift.
if (right < left) right = left - 1
}
return { top, left, bottom, right }
}
/**
* Columns hidden in the grid (DQ rules in the viewer, filters elsewhere).
*
* `getPlugin()` returns the plugin instance even when it is not switched on, and
* `isHidden()` reads a map that only exists once the plugin has been enabled -
* so ask the enabled flag first, and treat any remaining failure as "visible"
* rather than losing the whole export.
*/
function isHiddenColumn(hot: Handsontable, visualColumn: number): boolean {
const plugin = hot.getPlugin('hiddenColumns') as any
if (
!plugin ||
plugin.enabled !== true ||
typeof plugin.isHidden !== 'function'
) {
return false
}
try {
return plugin.isHidden(visualColumn) === true
} catch {
return false
}
}
export interface GridTable {
headers: string[]
rows: (string | number | null)[][]
}
/**
* Reads the exportable slice of a grid into plain headers and rows, the same
* way the CSV export sees it: raw cell values (not rendered text), hidden
* columns dropped, leading housekeeping columns excluded.
*
* @param maxRows Stop after this many rows. The limit is applied to the range
* before any cell is read, so a capped export never pays for the rows it
* discards.
*/
export function collectGridTable(
hot: Handsontable,
skipLeadingCols = 0,
maxRows = Infinity
): GridTable {
const range = getExportRange(hot, skipLeadingCols)
const colHeaders = hot.getColHeader()
const headerArray = Array.isArray(colHeaders) ? (colHeaders as string[]) : []
const columns: number[] = []
for (let col = Math.max(0, range.left); col <= range.right; col++) {
if (!isHiddenColumn(hot, col)) columns.push(col)
}
const headers = columns.map((col) => {
const header = headerArray[col]
return header === null || header === undefined ? '' : String(header)
})
const firstRow = Math.max(0, range.top)
const lastRow = Math.min(range.bottom, firstRow + maxRows - 1)
const rows: (string | number | null)[][] = []
for (let row = firstRow; row <= lastRow; row++) {
rows.push(
columns.map(
(col) => hot.getDataAtCell(row, col) as string | number | null
)
)
}
return { headers, rows }
}
/**
* Client-side PDF export of the grid's current view. Same "what you see"
* contract as the CSV/Excel export: the current selection if there is one,
* otherwise the whole grid, hidden columns excluded.
*/
export function exportGridPdf(
hot: Handsontable,
filename: string,
skipLeadingCols = 0
): void {
const input = buildGridPdfInput(hot, filename, skipLeadingCols)
// Without columns there is no table to describe; a grid with no rows still
// makes a page, with its headers and a "0 rows" subtitle, rather than the
// click doing nothing at all.
if (!input) return
downloadTablePdf(input, filename || 'export')
}
/**
* Builds the PDF input for a grid, or `null` when the grid has no exportable
* columns. Split out from {@link exportGridPdf} so the "no rows" case can be
* asserted without touching the browser's download plumbing.
*/
export function buildGridPdfInput(
hot: Handsontable,
filename: string,
skipLeadingCols = 0
): TablePdfInput | null {
const range = getExportRange(hot, skipLeadingCols)
if (isRangeEmpty(range)) return null
const availableRows = Math.max(0, range.bottom - range.top + 1)
// The cap is applied inside collectGridTable so the discarded rows are never
// read; the subtitle still needs the real total.
const { headers, rows } = collectGridTable(hot, skipLeadingCols, MAX_PDF_ROWS)
if (!headers.length) return null
const truncated = availableRows > rows.length
return {
title: filename || 'export',
subtitle:
`${rows.length} row${rows.length === 1 ? '' : 's'} x ` +
`${headers.length} column${headers.length === 1 ? '' : 's'}` +
(truncated ? ` (first ${rows.length} of ${availableRows} rows)` : ''),
headers,
rows
}
}
export type HotExportFormat = 'csv' | 'xlsx'
/**
* Cell types whose values must be exported as raw stored strings rather than
@@ -211,7 +25,7 @@ const RAW_EXPORT_TYPES = new Set([
* from any server-side (SAS) download.
*
* @param hot Live Handsontable instance.
* @param format `'csv'`, `'xlsx'` or `'pdf'`.
* @param format `'csv'` or `'xlsx'`.
* @param filename File name (without extension).
* @param skipLeadingCols Number of leading UI/housekeeping columns to exclude
* (e.g. the editor's `Delete?` column at index 0).
@@ -222,11 +36,6 @@ export async function exportGrid(
filename: string,
skipLeadingCols = 0
): Promise<void> {
if (format === 'pdf') {
exportGridPdf(hot, filename, skipLeadingCols)
return
}
const plugin = hot.getPlugin('exportFile')
const opts: Record<string, any> = {
@@ -236,20 +45,26 @@ export async function exportGrid(
// Selection-aware, contiguous range (visual indexes). Hidden columns are
// excluded automatically (exportHiddenColumns defaults to false).
const range = getExportRange(hot, skipLeadingCols)
if (isRangeEmpty(range)) return
// "Whole grid" means the export starts at visual column 0 - not at
// `skipLeadingCols`, or a caller that skips a leading housekeeping column
// would look like a whole-grid export and the plugin would put that column
// back in. Leaving `range` unset lets the plugin export the whole grid
// exactly as its own menu item does; it is explicit whenever it is narrower.
const isWholeGrid =
range.top === 0 &&
range.left === 0 &&
range.bottom === hot.countRows() - 1 &&
range.right === hot.countCols() - 1
if (!isWholeGrid) {
opts['range'] = [range.top, range.left, range.bottom, range.right]
const sel = hot.getSelectedRangeLast()
const lastRow = hot.countRows() - 1
const lastCol = hot.countCols() - 1
// Mirror HOT's own export item: only honor a selection that spans more than
// one cell. A right-click places a single-cell cursor, and the corner click
// is select-all (negative coords) — both mean "export the whole table".
// sel.from/to.row/col are typed nullable (CellCoords allows an unset state),
// but a range returned by getSelectedRangeLast() always has real coordinates.
const isCornerSelectAll = !!sel && sel.from.row! < 0 && sel.from.col! < 0
if (sel && !sel.isSingleCell() && !isCornerSelectAll) {
const top = Math.max(0, Math.min(sel.from.row!, sel.to.row!))
const left = Math.max(0, Math.min(sel.from.col!, sel.to.col!))
const bottom = Math.max(sel.from.row!, sel.to.row!)
const right = Math.max(sel.from.col!, sel.to.col!)
opts['range'] = [top, Math.max(left, skipLeadingCols), bottom, right]
}
if (!opts['range'] && skipLeadingCols > 0) {
opts['range'] = [0, skipLeadingCols, lastRow, lastCol]
}
if (format === 'csv') {
@@ -311,7 +126,7 @@ export async function exportGrid(
}
/**
* Builds a reusable `export_file` context-menu item (CSV / Excel / PDF submenu) that
* Builds a reusable `export_file` context-menu item (CSV / Excel submenu) that
* exports the given grid via {@link exportGrid}. Add it to any HOT context-menu
* `items` map under the `export_file` key.
*
@@ -351,11 +166,6 @@ export function buildExportMenuItem(
key: 'export_file:xlsx',
name: 'Excel (.xlsx)',
callback: makeCb('xlsx')
},
{
key: 'export_file:pdf',
name: 'PDF (.pdf)',
callback: makeCb('pdf')
}
] as any
}
@@ -1,259 +0,0 @@
import {
createTablePdf,
downloadTablePdf,
TablePdfInput
} from './pdf-export.util'
/** PDF bytes are Latin-1 by construction, so decode them the same way. */
function decode(bytes: Uint8Array): string {
let text = ''
for (const byte of bytes) text += String.fromCharCode(byte)
return text
}
function build(overrides: Partial<TablePdfInput> = {}): string {
return decode(
createTablePdf({
title: 'WORK.CUSTOMERS',
subtitle: '2 rows x 2 columns',
headers: ['ACCOUNT', 'BALANCE'],
rows: [
['A1', '1.25'],
['A2', '2.50']
],
generatedAt: new Date('2026-11-01T19:00:00Z'),
...overrides
})
)
}
/** The `(literal) Tj` operands of a content stream, unescaped enough to measure. */
function literals(pdf: string): string[] {
return Array.from(pdf.matchAll(/\((.*?)\) Tj/g)).map((match) => match[1])
}
describe('downloadTablePdf', () => {
it('releases the blob URL after the download has started, not during it', () => {
// Firefox and Safari read the blob asynchronously, so revoking in the same
// task as the click cancels the download.
jasmine.clock().install()
const click = spyOn(HTMLAnchorElement.prototype, 'click')
spyOn(URL, 'createObjectURL').and.returnValue('blob:test-url')
const revoke = spyOn(URL, 'revokeObjectURL')
downloadTablePdf({ title: 'T', headers: ['A'], rows: [['1']] }, 'export')
expect(click).toHaveBeenCalledTimes(1)
expect(revoke).not.toHaveBeenCalled()
jasmine.clock().tick(1000)
expect(revoke).toHaveBeenCalledWith('blob:test-url')
jasmine.clock().uninstall()
})
})
describe('createTablePdf', () => {
it('produces a structurally valid PDF with a working xref', () => {
const pdf = build()
expect(pdf.startsWith('%PDF-1.4')).toBeTrue()
expect(pdf.trimEnd().endsWith('%%EOF')).toBeTrue()
const startxref = Number(pdf.match(/startxref\n(\d+)/)![1])
expect(pdf.slice(startxref, startxref + 4)).toBe('xref')
// Every object listed in the xref must actually start at the offset given.
const xref = pdf.slice(startxref).split('\n')
const entryCount = Number(xref[1].split(' ')[1])
for (let id = 1; id < entryCount; id++) {
const offset = Number(xref[2 + id].slice(0, 10))
expect(pdf.slice(offset, offset + `${id} 0 obj`.length))
.withContext(`object ${id}`)
.toBe(`${id} 0 obj`)
}
})
it('declares one /Type /Page per page and a matching /Count', () => {
const pdf = build()
const pageObjects = (pdf.match(/\/Type \/Page[^s]/g) || []).length
expect(pdf).toContain('/Count 1')
expect(pageObjects).toBe(1)
})
it('prints the title, subtitle and a generated stamp', () => {
const pdf = build()
expect(literals(pdf)).toContain('WORK.CUSTOMERS')
expect(literals(pdf)).toContain('2 rows x 2 columns')
expect(literals(pdf)).toContain('Generated 2026-11-01 19:00')
})
it('prints every cell value', () => {
const pdf = build()
// BALANCE is numeric, so its values are padded to the column's right edge.
const values = literals(pdf).map((literal) => literal.trim())
expect(values).toEqual(
jasmine.arrayContaining(['A1', 'A2', '1.25', '2.50'])
)
})
it('escapes the PDF string delimiters', () => {
const pdf = build({
headers: ['NOTE'],
rows: [['a (b) c \\ d']]
})
// The literal must carry the backslash escapes, not raw delimiters.
expect(pdf).toContain('(a \\(b\\) c \\\\ d) Tj')
})
it('maps text to WinAnsi where it exists and falls back to ? elsewhere', () => {
const pdf = build({
headers: ['NAME', 'COST', 'OTHER'],
rows: [['M\u00fcller', '\u20ac5', '\u4f8b\u3048']]
})
const values = literals(pdf).join('|')
expect(values).toContain('M\\374ller') // u-umlaut: octal for cp1252 0xFC
expect(values).toContain('\\2005') // euro sign: cp1252 0x80
expect(values).toContain('??') // outside cp1252
})
it('right-aligns numeric columns and left-aligns the rest', () => {
const pdf = build({
headers: ['NAME', 'AMOUNT'],
rows: [
['x', '7.5'],
['long-name', '1234.25']
]
})
const ops = literals(pdf)
// AMOUNT is 7 characters wide (its widest value), so 7.5 is padded to the
// right edge; NAME is left-aligned, so x is unpadded while the widest value
// fills the column.
expect(ops).toContain(' 7.5')
expect(ops).toContain('1234.25')
expect(ops).toContain('x')
expect(ops).toContain('long-name')
})
it('wraps a value too long for its column instead of overflowing it', () => {
const word = 'X'.repeat(140)
const pdf = build({ headers: ['ONECOL'], rows: [[word]] })
const lines = literals(pdf).filter((line) => /^X+$/.test(line))
expect(lines.length).toBeGreaterThan(1)
expect(lines.join('')).toBe(word)
for (const line of lines) expect(line.length).toBeLessThanOrEqual(48)
})
it('keeps a multi-line value on separate lines', () => {
const pdf = build({ headers: ['ADDR'], rows: [['LINE1', 'LINE2']] })
const pdfMulti = build({ headers: ['ADDR'], rows: [['LINE1\nLINE2']] })
expect(literals(pdfMulti)).toEqual(
jasmine.arrayContaining(['LINE1', 'LINE2'])
)
expect(literals(pdfMulti)).not.toEqual(literals(pdf))
})
it('paginates long tables and repeats the column headers on every page', () => {
const rows = Array.from({ length: 400 }, (_, index) => [
`row${index}`,
index
])
const pdf = build({ headers: ['NAME', 'ID'], rows })
const pageCount = Number(pdf.match(/\/Count (\d+)/)![1])
expect(pageCount).toBeGreaterThan(1)
const headerAppearances = literals(pdf).filter(
(op) => op.trim() === 'NAME' || op.trim() === 'ID'
)
expect(headerAppearances.length).toBe(pageCount * 2)
// Footers are numbered from 1 to the total page count.
expect(literals(pdf)).toContain(`Page 1 of ${pageCount}`)
expect(literals(pdf)).toContain(`Page ${pageCount} of ${pageCount}`)
})
it('shrinks the body font rather than truncating headers on a 20-column table', () => {
const columnCount = 20
const headers = Array.from(
{ length: columnCount },
(_, index) => `COLUMN_${String(index + 1).padStart(2, '0')}`
)
const rows = [
Array.from({ length: columnCount }, (_, index) => `value ${index}`)
]
const pdf = build({ headers, rows })
const sizes = Array.from(pdf.matchAll(/\/F1 ([\d.]+) Tf/g)).map((m) =>
Number(m[1])
)
// Every header survives intact, and the page is won back with font size.
expect(literals(pdf)).toContain('COLUMN_01')
expect(literals(pdf)).toContain('COLUMN_20')
expect(Math.min(...sizes)).toBeLessThan(7)
})
it('never places text outside the printable area', () => {
const headers = Array.from({ length: 60 }, (_, index) => `COL_${index}`)
const rows = [Array.from({ length: 60 }, (_, index) => `v${index}`)]
const pdf = build({ headers, rows })
const starts = Array.from(pdf.matchAll(/([\d.]+) [\d.-]+ Td/g)).map((m) =>
Number(m[1])
)
// 842 - 24 margin, plus a point of rounding slack.
expect(Math.max(...starts)).toBeLessThanOrEqual(819)
})
it('says so when the columns cannot fit even at the smallest font', () => {
const columnCount = 300
const headers = Array.from(
{ length: columnCount },
(_, index) => `HEADER_${index}`
)
const rows = [
Array.from({ length: columnCount }, (_, index) => `v${index}`)
]
const pdf = build({ headers, rows })
const note = literals(pdf).find((literal) =>
/^Showing the first \d+ of 300 columns$/.test(literal)
)
expect(note).toBeTruthy()
expect(literals(pdf)).not.toContain('HEADER_299')
})
it('clamps a row taller than the page and marks the omission', () => {
// 20k characters at a 48-character column is ~420 wrapped lines, far more
// than a page holds; the tail must be marked, not drawn off the page.
const value = Array.from({ length: 4000 }, (_, i) => `w${i}`).join(' ')
const pdf = build({ headers: ['LONG'], rows: [[value]] })
const lines = literals(pdf)
expect(lines.some((line) => line.trimEnd().endsWith('...'))).toBeTrue()
// The clamped row must fit the page it starts on, not spill onto its own.
expect(pdf).toContain('/Count 1')
// Nothing is drawn below the bottom margin (24pt) or above the page.
const tops = Array.from(pdf.matchAll(/[\d.]+ ([\d.-]+) Td/g)).map((m) =>
Number(m[1])
)
expect(Math.min(...tops)).toBeGreaterThanOrEqual(24)
expect(Math.max(...tops)).toBeLessThanOrEqual(595 - 24)
})
it('renders an empty table as a single valid page', () => {
const pdf = build({ headers: ['ONLY'], rows: [] })
expect(pdf).toContain('/Count 1')
expect(literals(pdf)).toContain('ONLY')
})
})
@@ -1,533 +0,0 @@
/**
* Zero-dependency PDF writer for tabular data (the VIEW/EDIT grid export).
*
* Implemented directly against the PDF 1.4 spec rather than pulling in a PDF
* library: the output is a plain text table, which needs no font embedding,
* no images and no compression. The base-14 Courier font is used because its
* glyphs are exactly 600/1000 em wide, so column widths are arithmetic on
* character counts instead of a font-metrics table, and the file stays a few
* kilobytes per page.
*
* Scope and limits (deliberate):
* - Text is encoded as WinAnsi (cp1252), the encoding the base-14 fonts cover
* without embedding. Western European characters round-trip; characters
* outside cp1252 are written as `?`.
* - Fonts are not embedded and there is no styling beyond a bold header band
* and row rules, so this is a "what you see" data dump, not a branded
* report. A typeset report belongs on the server (ODS), where SAS formats
* and DC metadata are available.
*/
/** Glyph advance of base-14 Courier, as a fraction of the font size. */
const COURIER_WIDTH = 0.6
const HEADING_SIZE = 10
const MARGIN = 24
/** A4 landscape, in points. */
const PAGE_WIDTH = 842
const PAGE_HEIGHT = 595
const MIN_COL_CHARS = 5
const MAX_COL_CHARS = 48
/** Blank characters kept between columns so values never touch. */
const GUTTER_CHARS = 2
const FOOTER_HEIGHT = 16
/**
* Body font sizes tried in turn when the natural column widths do not fit the
* page. The table keeps its column content (and its headers) intact by going
* smaller first; 5pt is the floor, below which the text stops being readable.
*/
const BODY_FONT_SIZES = [7, 6.5, 6, 5.5, 5]
/**
* Narrowest column once scaling is unavoidable at the smallest font size. Below
* this a header is a fragment and a value is choppped across lines, so the
* export drops trailing columns instead (and says so on the page) rather than
* squeezing them into unreadable slivers.
*/
const MIN_SCALED_COL_CHARS = 4
/** Gutter used once scaling is unavoidable, to buy back space per column. */
const MIN_GUTTER_CHARS = 1
/** Appended to a cell whose value is longer than one page can show. */
const TRUNCATION_MARKER = ' ...'
/** How long the blob URL is kept alive after a click, for slow downloaders. */
const REVOKE_DELAY_MS = 1000
export interface TablePdfInput {
/** Heading shown top-left on page 1, and in the "(continued)" marker after. */
title: string
/** Optional line under the heading, e.g. row/column counts. */
subtitle?: string
headers: string[]
rows: ReadonlyArray<ReadonlyArray<string | number | null | undefined>>
/** Override for the "Generated ..." stamp; defaults to now. */
generatedAt?: Date
}
/** cp1252 code points that differ from Latin-1, which PDF needs as its own byte. */
const WINANSI_EXTRA: Record<number, number> = {
0x20ac: 0x80,
0x201a: 0x82,
0x0192: 0x83,
0x201e: 0x84,
0x2026: 0x85,
0x2020: 0x86,
0x2021: 0x87,
0x02c6: 0x88,
0x2030: 0x89,
0x0160: 0x8a,
0x2039: 0x8b,
0x0152: 0x8c,
0x017d: 0x8e,
0x2018: 0x91,
0x2019: 0x92,
0x201c: 0x93,
0x201d: 0x94,
0x2022: 0x95,
0x2013: 0x96,
0x2014: 0x97,
0x02dc: 0x98,
0x2122: 0x99,
0x0161: 0x9a,
0x203a: 0x9b,
0x0153: 0x9c,
0x017e: 0x9e,
0x0178: 0x9f
}
/**
* Cell values as printable text: nulls become empty, CR/LF is kept as a line
* break (so multi-line values stay multi-line), tabs become spaces and other
* control characters are dropped since they have no glyph.
*/
function sanitise(value: string | number | null | undefined): string {
if (value === null || value === undefined) return ''
return String(value)
.replace(/\r\n?/g, '\n')
.replace(/\t/g, ' ')
.replace(/[\u0000-\u0009\u000b-\u001f]/g, ' ')
}
/**
* Escape text for a PDF literal string: `(`, `)` and `\` are the delimiters,
* and non-printable / non-ASCII bytes are written as three-digit octal of
* their WinAnsi code. Characters outside cp1252 become `?`.
*/
function escapeText(text: string): string {
let out = ''
for (const char of text) {
const code = char.codePointAt(0) as number
let byte: number
if (code >= 0x20 && code <= 0x7e) byte = code
else if (WINANSI_EXTRA[code] !== undefined) byte = WINANSI_EXTRA[code]
else if (code >= 0xa0 && code <= 0xff) byte = code
else byte = 0x3f // '?'
if (byte === 0x28 || byte === 0x29 || byte === 0x5c) {
out += '\\' + String.fromCharCode(byte)
} else if (byte < 0x20 || byte > 0x7e) {
out += '\\' + byte.toString(8).padStart(3, '0')
} else {
out += String.fromCharCode(byte)
}
}
return out
}
function textOp(
x: number,
y: number,
size: number,
text: string,
font = 'F1'
): string {
return `BT /${font} ${size} Tf ${x.toFixed(2)} ${y.toFixed(2)} Td (${escapeText(text)}) Tj ET\n`
}
/** Longest line of a value; multi-line values are measured line by line. */
function longestLine(text: string): number {
return Math.max(...text.split('\n').map((part) => part.length), 1)
}
/**
* Word wrap to `width` characters. Words longer than the column are hard-broken
* rather than allowed to overflow into the neighbouring column.
*/
function wrap(text: string, width: number): string[] {
const lines: string[] = []
for (const paragraph of text.split('\n')) {
let line = ''
for (const word of paragraph.split(' ')) {
if (line === '') {
line = word
} else if (line.length + 1 + word.length <= width) {
line += ' ' + word
} else {
lines.push(line)
line = word
}
while (line.length > width) {
lines.push(line.slice(0, width))
line = line.slice(width)
}
}
lines.push(line)
}
return lines
}
/** A column is right-aligned when every non-missing value reads as a number. */
function isNumericColumn(rows: TablePdfInput['rows'], index: number): boolean {
let seen = 0
for (const row of rows) {
const value = row[index]
if (value === null || value === undefined || value === '') continue
if (typeof value === 'number') {
seen++
continue
}
if (!/^-?\d+(\.\d+)?$/.test(String(value))) return false
seen++
}
return seen > 0
}
/** Characters that fit across the page at a given font size. */
function usableChars(fontSize: number): number {
return Math.floor((PAGE_WIDTH - MARGIN * 2) / (fontSize * COURIER_WIDTH))
}
/** Characters a table occupies, gutters included. */
function tableChars(widths: number[], gutter: number): number {
return (
widths.reduce((total, width) => total + width, 0) + gutter * widths.length
)
}
interface Layout {
fontSize: number
lineHeight: number
charWidth: number
/** Column content widths in characters. */
widths: number[]
gutter: number
/** Columns actually exported; below `headers.length` only for absurd widths. */
keptColumns: number
}
/**
* Fits the table across the page.
*
* The content widths are natural first: as wide as the widest value, bounded.
* When those do not fit, the body font shrinks through {@link BODY_FONT_SIZES}
* rather than the columns being squeezed, which keeps full headers and values
* readable on wide tables. Only at the smallest size are the columns scaled
* down (and the gutter narrowed), and columns stop shrinking at
* {@link MIN_SCALED_COL_CHARS}: past that, trailing columns are dropped and the
* page says how many were kept, so the omission is never silent.
*/
function fitLayout(input: TablePdfInput): Layout {
const naturalWidths = input.headers.map((header, index) => {
let widest = longestLine(sanitise(header))
for (const row of input.rows) {
widest = Math.max(widest, longestLine(sanitise(row[index])))
}
return Math.min(MAX_COL_CHARS, Math.max(MIN_COL_CHARS, widest))
})
const naturalTotal = tableChars(naturalWidths, GUTTER_CHARS)
for (const fontSize of BODY_FONT_SIZES) {
if (naturalTotal <= usableChars(fontSize)) {
return {
fontSize,
lineHeight: fontSize * 1.35,
charWidth: fontSize * COURIER_WIDTH,
widths: naturalWidths,
gutter: GUTTER_CHARS,
keptColumns: naturalWidths.length
}
}
}
// Smallest font, so the only remaining lever is the column widths themselves.
const fontSize = BODY_FONT_SIZES[BODY_FONT_SIZES.length - 1]
const available = usableChars(fontSize)
const gutter = MIN_GUTTER_CHARS
const scaleToFit = (count: number): number[] => {
const natural = naturalWidths.slice(0, count)
const scale =
(available - gutter * count) / natural.reduce((a, b) => a + b, 0)
return natural.map((width) =>
Math.max(MIN_SCALED_COL_CHARS, Math.floor(width * scale))
)
}
let keptColumns = naturalWidths.length
let widths = scaleToFit(keptColumns)
while (keptColumns > 1 && tableChars(widths, gutter) > available) {
keptColumns--
widths = scaleToFit(keptColumns)
}
return {
fontSize,
lineHeight: fontSize * 1.35,
charWidth: fontSize * COURIER_WIDTH,
widths,
gutter,
keptColumns
}
}
interface PagePlan {
/** Content stream operators for the page. */
content: string
}
function paginate(input: TablePdfInput): PagePlan[] {
const layout = fitLayout(input)
const { fontSize, lineHeight, charWidth, widths, gutter, keptColumns } =
layout
// Columns beyond the page are dropped rather than drawn off it; the first
// page says so, so the document never silently omits data.
const headers = input.headers.slice(0, keptColumns)
const rows = input.rows.map((row) => row.slice(0, keptColumns))
const droppedColumns = input.headers.length - keptColumns
const numeric = headers.map((_, index) => isNumericColumn(rows, index))
const tableWidth = tableChars(widths, gutter) * charWidth
const columnsX: number[] = []
let cursor = MARGIN
for (const width of widths) {
columnsX.push(cursor)
cursor += (width + gutter) * charWidth
}
const stamp = (input.generatedAt ?? new Date())
.toISOString()
.replace('T', ' ')
.slice(0, 16)
const headerLines = headers.map((header, index) =>
wrap(sanitise(header), widths[index])
)
const headerLineCount = Math.max(
...headerLines.map((lines) => lines.length),
1
)
const pages: PagePlan[] = []
let ops = ''
let y = 0
const drawHeaderRow = () => {
const bandHeight = headerLineCount * lineHeight
ops +=
`q 0.93 g ${MARGIN.toFixed(2)} ${(y - bandHeight + lineHeight).toFixed(2)} ` +
`${tableWidth.toFixed(2)} ${bandHeight.toFixed(2)} re f Q\n`
for (let line = 0; line < headerLineCount; line++) {
for (let column = 0; column < headers.length; column++) {
const text = headerLines[column][line]
if (!text) continue
const label = numeric[column] ? text.padStart(widths[column]) : text
ops += textOp(columnsX[column], y, fontSize, label, 'F2')
}
y -= lineHeight
}
y -= 3
}
const startPage = (first: boolean) => {
ops = ''
y = PAGE_HEIGHT - MARGIN
if (first) {
ops += textOp(MARGIN, y, HEADING_SIZE, input.title, 'F2')
if (input.subtitle)
ops += textOp(MARGIN, y - 13, fontSize, input.subtitle)
ops += textOp(
MARGIN + tableWidth - 26 * charWidth,
y - 13,
fontSize,
`Generated ${stamp}`
)
if (droppedColumns > 0) {
ops += textOp(
MARGIN,
y - 13 - lineHeight,
fontSize,
`Showing the first ${keptColumns} of ${input.headers.length} columns`
)
y -= fontSize + 4
}
y -= HEADING_SIZE + 20
} else {
ops += textOp(MARGIN, y, fontSize, `${input.title} (continued)`, 'F2')
y -= lineHeight + 4
}
drawHeaderRow()
}
const bottom = MARGIN + FOOTER_HEIGHT
// A single cell can hold thousands of characters, and MAX_COL_CHARS only
// bounds the column: at this font one value can wrap to more lines than a
// page has. Such a row is clamped to the tallest row that fits, with the
// marker on its last line, rather than being drawn through the footer and
// losing its tail without a trace.
const pageOneOverhead =
HEADING_SIZE +
20 +
(droppedColumns > 0 ? fontSize + 4 : 0) +
headerLineCount * lineHeight +
3
// Both margins, the footer and the page-1 header block all come off the
// height a single row may occupy, or an exactly-fitting row would be pushed
// onto a page of its own and leave the first page with only its headers.
const maxRowLines = Math.max(
1,
Math.floor(
(PAGE_HEIGHT - MARGIN * 2 - FOOTER_HEIGHT - pageOneOverhead) / lineHeight
)
)
startPage(true)
for (const row of rows) {
let cells = widths.map((width, index) => wrap(sanitise(row[index]), width))
if (cells.some((lines) => lines.length > maxRowLines)) {
cells = cells.map((lines) => {
if (lines.length <= maxRowLines) return lines
const kept = lines.slice(0, maxRowLines)
const last = kept[kept.length - 1]
kept[kept.length - 1] =
last.length > TRUNCATION_MARKER.length
? last.slice(0, last.length - TRUNCATION_MARKER.length) +
TRUNCATION_MARKER
: TRUNCATION_MARKER.trim()
return kept
})
}
const lineCount = Math.max(...cells.map((lines) => lines.length), 1)
if (y - lineCount * lineHeight < bottom) {
pages.push({ content: ops })
startPage(false)
}
for (let line = 0; line < lineCount; line++) {
for (let column = 0; column < cells.length; column++) {
const text = cells[column][line]
if (!text) continue
const padded = numeric[column] ? text.padStart(widths[column]) : text
ops += textOp(columnsX[column], y, fontSize, padded)
}
y -= lineHeight
}
y -= 1.5
ops +=
`0.88 G 0.4 w ${MARGIN.toFixed(2)} ${y.toFixed(2)} m ` +
`${(MARGIN + tableWidth).toFixed(2)} ${y.toFixed(2)} l S\n`
}
pages.push({ content: ops })
// Footers last, once the total page count is known.
return pages.map((page, index) => {
const footer = `Page ${index + 1} of ${pages.length}`
const x = MARGIN + tableWidth - footer.length * charWidth
return { content: page.content + textOp(x, MARGIN, fontSize, footer) }
})
}
/**
* Builds the PDF bytes for a table. Returns a `Uint8Array` rather than a Blob so
* the output can be asserted in unit tests (Blob is the only browser-specific
* part, and it lives in {@link downloadTablePdf}).
*/
export function createTablePdf(input: TablePdfInput): Uint8Array<ArrayBuffer> {
const pages = paginate(input)
const chunks: Uint8Array[] = []
let length = 0
const write = (text: string) => {
const bytes = new Uint8Array(text.length)
for (let index = 0; index < text.length; index++) {
bytes[index] = text.charCodeAt(index) & 0xff
}
chunks.push(bytes)
length += bytes.length
}
write('%PDF-1.4\n%\u00e2\u00e3\u00cf\u00d3\n')
// Object numbering: 1 catalog, 2 page tree, 3 and 4 the two fonts, then a
// page object + content stream pair per page.
const offsets: number[] = []
const markObject = (id: number) => {
offsets[id] = length
}
markObject(1)
write('1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n')
markObject(2)
const kids = pages.map((_, index) => `${5 + index * 2} 0 R`).join(' ')
write(
`2 0 obj\n<< /Type /Pages /Count ${pages.length} /Kids [${kids}] >>\nendobj\n`
)
markObject(3)
write(
'3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>\nendobj\n'
)
markObject(4)
write(
'4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>\nendobj\n'
)
pages.forEach((page, index) => {
const pageId = 5 + index * 2
const contentId = pageId + 1
markObject(pageId)
write(
`${pageId} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] ` +
`/Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${contentId} 0 R >>\nendobj\n`
)
markObject(contentId)
write(`${contentId} 0 obj\n<< /Length ${page.content.length} >>\nstream\n`)
write(page.content)
write('endstream\nendobj\n')
})
const lastObjectId = 4 + pages.length * 2
const xrefOffset = length
let xref = `xref\n0 ${lastObjectId + 1}\n0000000000 65535 f \n`
for (let id = 1; id <= lastObjectId; id++) {
xref += String(offsets[id]).padStart(10, '0') + ' 00000 n \n'
}
write(xref)
write(
`trailer\n<< /Size ${lastObjectId + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`
)
const bytes = new Uint8Array(length)
let cursor = 0
for (const chunk of chunks) {
bytes.set(chunk, cursor)
cursor += chunk.length
}
return bytes
}
/**
* Builds the PDF and hands it to the browser as a download, mirroring what the
* CSV/Excel export does for the same grid. No new tab and no print dialog, so
* it behaves the same when DC is embedded in an iframe.
*/
export function downloadTablePdf(input: TablePdfInput, filename: string): void {
const blob = new Blob([createTablePdf(input)], { type: 'application/pdf' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${filename || 'export'}.pdf`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// The click only starts the download; Firefox and Safari read the blob
// asynchronously, so revoking in the same task can cancel it and the user
// gets nothing. Release the URL on a later turn instead - the blob is a few
// hundred kilobytes, so holding it briefly costs nothing.
setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS)
}
@@ -2,10 +2,10 @@
[clrModalOpen]="opened"
(clrModalOpenChange)="modalOpenChange($event)"
clrModalSize="xl"
class="sas-logs-modal"
class="requests-modal"
>
<h3 class="modal-title">
SAS Logs
Request History
<p class="mt-0 d-inline-block">App Location: {{ sasjsConfig.appLoc }}</p>
</h3>
<div class="modal-body h-70vh">
@@ -20,7 +20,7 @@
<clr-stack-label>
{{ programLog.serviceLink }}
<span class="ml-10">
@if (programLog.warnings?.length! > 0) {
@if (programLog.logWarnings?.length! > 0) {
<span>
<clr-icon
class="mb-5 color-green"
@@ -28,7 +28,7 @@
></clr-icon>
</span>
}
@if (programLog.errors?.length! > 0) {
@if (programLog.logErrors?.length! > 0) {
<span>
<clr-icon
class="mb-5 color-red"
@@ -49,70 +49,51 @@
<ng-template [(clrIfActive)]="sasLogActive">
<clr-tab-content>
<clr-tree>
@if (programLog.errors?.length) {
@if (programLog.logErrors) {
<clr-tree-node class="err-links">
<clr-icon
class="color-red"
shape="exclamation-triangle"
></clr-icon>
Errors ({{ programLog.errors!.length }})
@for (issue of programLog.errors; track $index) {
Errors ({{ programLog.logErrors.length! }})
@for (
err of programLog.logErrors || [];
track err
) {
<clr-tree-node
(click)="goToLogLine(issue, 'request_' + i)"
(click)="
goToLogLine(err, 'request_' + i, 'error')
"
>
<button class="clr-treenode-link">
@if (issue.code) {
<span class="issue-code">{{
issue.code
}}</span>
}
{{ issue.message }}
{{ err }}
</button>
</clr-tree-node>
}
</clr-tree-node>
}
@if (programLog.warnings?.length) {
@if (programLog.logWarnings) {
<clr-tree-node class="err-links">
<clr-icon
class="color-green"
shape="exclamation-circle"
></clr-icon>
Warnings ({{ programLog.warnings!.length }})
Warnings ({{ programLog.logWarnings.length! }})
@for (
issue of programLog.warnings;
track $index
warning of programLog.logWarnings || [];
track warning
) {
<clr-tree-node
(click)="goToLogLine(issue, 'request_' + i)"
(click)="
goToLogLine(
warning,
'request_' + i,
'warning'
)
"
>
<button class="clr-treenode-link">
@if (issue.code) {
<span class="issue-code">{{
issue.code
}}</span>
}
{{ issue.message }}
</button>
</clr-tree-node>
}
</clr-tree-node>
}
@if (programLog.notes?.length) {
<clr-tree-node class="err-links">
<clr-icon shape="info-circle"></clr-icon>
Notes ({{ programLog.notes!.length }})
@for (issue of programLog.notes; track $index) {
<clr-tree-node
(click)="goToLogLine(issue, 'request_' + i)"
>
<button class="clr-treenode-link">
@if (issue.code) {
<span class="issue-code">{{
issue.code
}}</span>
}
{{ issue.message }}
{{ warning }}
</button>
</clr-tree-node>
}
@@ -122,7 +103,7 @@
@if (serverType !== 'SASVIYA') {
<div
class="log-wrapper saslog"
[innerHTML]="programLog.logHtml"
[innerHTML]="programLog.logFile"
></div>
}
@if (serverType === 'SASVIYA') {
@@ -0,0 +1,278 @@
import {
Component,
EventEmitter,
Input,
OnInit,
Output,
ViewEncapsulation
} from '@angular/core'
import { SASjsRequest } from '@sasjs/adapter'
import moment from 'moment'
import { HelperService } from 'src/app/services/helper.service'
import { LoggerService } from '../../services/logger.service'
import { SasService } from '../../services/sas.service'
import { ServerType } from '@sasjs/utils/types/serverType'
interface SASjsRequestExtended extends SASjsRequest {
parsedTimestamp?: string
logErrors?: string[]
logWarnings?: string[]
selectedTable?: string
}
@Component({
selector: 'app-requests-modal',
templateUrl: './requests-modal.component.html',
styleUrls: ['./requests-modal.component.scss'],
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class RequestsModalComponent implements OnInit {
private _opened: boolean = false
get opened(): boolean {
return this._opened
}
@Input()
set opened(value: boolean) {
this._opened = value
if (value) this.modalOpened()
this.loggerService.log(this.sasjsRequests)
}
@Output() openedChange = new EventEmitter()
public sasLogActive: boolean = true
public sasSourceCodeActive: boolean = false
public sasGeneratedCodeActive: boolean = false
public tablesActive: boolean = false
public sasjsConfig = this.sasService.getSasjsConfig()
public serverType: string
public sasjsRequests: SASjsRequestExtended[] = []
public workTables: any
constructor(
private sasService: SasService,
private loggerService: LoggerService,
private helperService: HelperService
) {
this.sasjsConfig = this.sasService.getSasjsConfig()
this.serverType = this.sasService.getServerType()
}
ngOnInit(): void {}
public parseLogTimestamp(timestamp: any) {
return `${this.formatTimestamp(timestamp)} ${this.timestampFromNow(
timestamp
)}`
}
public cutAppLoc(link: string) {
return link.replace(this.sasjsConfig.appLoc + '/', '')
}
public formatTimestamp(timestamp: any) {
return moment(timestamp).format()
? moment(timestamp).format('dddd, MMMM Do YYYY, h:mm:ss a')
: timestamp
}
public timestampFromNow(timestamp: any) {
return moment(timestamp).format() ? ` (${moment(timestamp).fromNow()})` : ''
}
public modalOpenChange(state: any) {
this.opened = state
this.openedChange.emit(this.opened)
}
public modalOpened() {
this.sasjsRequests = this.sasService.getSasRequests()
for (let request of this.sasjsRequests) {
this.parseErrorsAndWarnings(request)
request.serviceLink = this.cutAppLoc(request.serviceLink)
request.parsedTimestamp = this.parseLogTimestamp(request.timestamp)
}
}
public goToLogLine(
linkingLine: string,
requestStackId: string,
type: string
) {
const logWrapper: HTMLElement | null = document.querySelector(
`#${requestStackId} .log-wrapper.saslog`
)
if (!logWrapper) return
if (this.serverType === 'SASVIYA') {
// For VIYA (textContent approach)
const logContent = logWrapper.textContent || ''
const logLines = logContent.split('\n')
let lineIndex = -1
// Find the matching line index
for (let i = 0; i < logLines.length; i++) {
if (logLines[i].includes(linkingLine)) {
lineIndex = i
break
}
}
if (lineIndex === -1) return
// Create a temporary div to calculate line height
const tempDiv = document.createElement('div')
tempDiv.className = 'temp-line-height-calc'
tempDiv.textContent = 'X'
logWrapper.appendChild(tempDiv)
const lineHeight = tempDiv.clientHeight
logWrapper.removeChild(tempDiv)
// Scroll to the appropriate position
logWrapper.scrollTop = lineHeight * lineIndex
// Create highlighting overlay at that position
const overlay = document.createElement('div')
overlay.className = `line-highlight-overlay ${type === 'error' ? 'error-highlight' : 'warning-highlight'}`
// We are setting the height using a CSS variable to the HTML element
// this does not trigger CSP errors because it's driven by JS
overlay.classList.add('temp-height-setter')
document.documentElement.style.setProperty(
'--line-height',
`${lineHeight}px`
)
overlay.classList.add('line-position-setter')
document.documentElement.style.setProperty(
'--line-top',
`${lineHeight * lineIndex}px`
)
logWrapper.appendChild(overlay)
setTimeout(() => {
if (logWrapper.contains(overlay)) {
logWrapper.removeChild(overlay)
}
}, 3000)
} else {
// For non-VIYA (innerHTML approach)
const allLines: NodeListOf<Element> = document.querySelectorAll(
`#${requestStackId} .log-wrapper.saslog font`
)
for (let line of Array.from(allLines)) {
if (line.textContent?.includes(linkingLine)) {
logWrapper.scrollTop =
(line as HTMLElement).offsetTop - logWrapper.offsetTop
// Use class instead of inline style
line.classList.add('highlighted-line')
setTimeout(() => {
line.classList.remove('highlighted-line')
}, 3000)
break
}
}
}
}
public async parseErrorsAndWarnings(req: any) {
if (!req || !req.logFile || typeof req.logFile !== 'string') return
if (req['logErrors'] !== undefined || req['logWarnings'] !== undefined)
return
let errorLines = []
let warningLines = []
// Create a safe version of the log content
let logLines = req.logFile.split('\n')
let originalLogLines = [...logLines]
for (let i = 0; i < logLines.length; i++) {
if (/<.*>ERROR/gm.test(logLines[i])) {
let errorLine = logLines[i].substring(
logLines[i].indexOf('E'),
logLines[i].length - 1
)
errorLines.push(errorLine)
} else if (/^ERROR/gm.test(logLines[i])) {
errorLines.push(logLines[i])
if (this.serverType !== 'SASVIYA') {
logLines[i] = '<font class="error-line">' + logLines[i] + '</font>'
}
}
if (/<.*>WARNING/gm.test(logLines[i])) {
let warningLine = logLines[i].substring(
logLines[i].indexOf('W'),
logLines[i].length - 1
)
warningLines.push(warningLine)
} else if (/^WARNING/gm.test(logLines[i])) {
warningLines.push(logLines[i])
if (this.serverType !== 'SASVIYA') {
logLines[i] = '<font class="warning-line">' + logLines[i] + '</font>'
}
}
}
this.loggerService.log(warningLines)
// For VIYA, store both versions of the log
if (this.serverType === 'SASVIYA') {
req.originalLogFile = originalLogLines.join('\n')
req.logFileLineMap = {}
// Create a mapping of error/warning lines to their indices
errorLines.forEach((errorLine) => {
for (let i = 0; i < originalLogLines.length; i++) {
if (originalLogLines[i].includes(errorLine)) {
if (!req.logFileLineMap.errors) req.logFileLineMap.errors = {}
req.logFileLineMap.errors[errorLine] = i
break
}
}
})
warningLines.forEach((warningLine) => {
for (let i = 0; i < originalLogLines.length; i++) {
if (originalLogLines[i].includes(warningLine)) {
if (!req.logFileLineMap.warnings) req.logFileLineMap.warnings = {}
req.logFileLineMap.warnings[warningLine] = i
break
}
}
})
}
req.logFile = logLines.join('\n')
req.logErrors = errorLines
req.logWarnings = warningLines
}
downloadLog(logFile: string) {
const timestamp = new Date().valueOf()
this.helperService.downloadTextFile(`logFile-${timestamp}`, logFile)
}
downloadSourceCode(sourceCode: string) {
const timestamp = new Date().valueOf()
this.helperService.downloadTextFile(`sourceCode-${timestamp}`, sourceCode)
}
downloadGeneratedCode(generatedCode: string) {
const timestamp = new Date().valueOf()
this.helperService.downloadTextFile(
`generatedCode-${timestamp}`,
generatedCode
)
}
}
@@ -1,319 +0,0 @@
import { LogIssue } from '@sasjs/utils/logs'
import { SasLogsModalComponent } from './sas-logs-modal.component'
const LOG = [
'1 data want;',
'2 set sashelp.class;',
'3 x = age / 0;',
'4 run;',
'NOTE: There were 19 observations read from the data set SASHELP.CLASS.',
'WARNING: Division by zero detected at line 3 column 11.',
'ERROR: File WORK.MISSING_DS.DATA does not exist.',
'ERROR 22-322: Syntax error, expecting one of the following: ;, CANCEL,',
' RUN, QUIT.',
'NOTE: Invalid data for age in line 42 3-5.'
].join('\n')
const issue = (overrides: Partial<LogIssue>): LogIssue => {
const line = overrides.line ?? 0
return {
line,
endLine: line,
column: 0,
endColumn: 0,
severity: 'error',
message: 'a message',
...overrides
} as LogIssue
}
describe('SasLogsModalComponent', () => {
let component: SasLogsModalComponent
let sasService: any
let helperService: any
const buildComponent = (
requests: any[] = [],
serverType: string = 'SAS9'
): SasLogsModalComponent => {
sasService = {
getSasjsConfig: () => ({ appLoc: '/Public/app' }),
getServerType: () => serverType,
getSasRequests: () => requests
}
helperService = {
downloadTextFile: jasmine.createSpy('downloadTextFile')
}
return new SasLogsModalComponent(
sasService,
{ log: () => {} } as any,
helperService
)
}
beforeEach(() => {
component = buildComponent()
})
describe('analyseLog', () => {
it('groups the errors, the warnings and the notable notes of a log', () => {
const request: any = { logFile: LOG }
component.analyseLog(request)
expect(request.errors.map((i: LogIssue) => i.line)).toEqual([6, 7])
expect(request.warnings.map((i: LogIssue) => i.line)).toEqual([5])
expect(request.notes.map((i: LogIssue) => i.message)).toEqual([
'Invalid data for age in line 42 3-5.'
])
})
it('keeps the message and the code the parser reported', () => {
const request: any = { logFile: LOG }
component.analyseLog(request)
expect(request.errors[0]).toEqual(
issue({
line: 6,
endLine: 6,
column: 0,
endColumn: 'ERROR: File WORK.MISSING_DS.DATA does not exist.'.length,
message: 'File WORK.MISSING_DS.DATA does not exist.'
})
)
expect(request.errors[1].code).toEqual('22-322')
expect(request.errors[1].endLine).toEqual(8)
expect(request.errors[1].message).toEqual(
'Syntax error, expecting one of the following: ;, CANCEL, RUN, QUIT.'
)
})
it('reports nothing for a log with nothing to report', () => {
const request: any = {
logFile: 'NOTE: There were 19 observations read.\n'
}
component.analyseLog(request)
expect(request.errors).toEqual([])
expect(request.warnings).toEqual([])
expect(request.notes).toEqual([])
})
it('reports nothing when the request carries no log at all', () => {
const request: any = { serviceLink: 'services/x' }
component.analyseLog(request)
expect(request.logIssues).toEqual([])
expect(request.errors).toEqual([])
expect(request.logHtml).toEqual('')
})
})
describe('markIssueLines', () => {
it('marks every line an issue occupies, the wrapped ones included', () => {
const request: any = { logFile: LOG }
component.analyseLog(request)
const lines: string[] = request.logHtml.split('\n')
expect(lines[5]).toEqual(
'<font class="warning-line">WARNING: Division by zero detected at line 3 column 11.</font>'
)
expect(lines[6]).toContain('<font class="error-line">ERROR: File WORK')
expect(lines[7]).toContain('<font class="error-line">ERROR 22-322:')
expect(lines[8]).toEqual(
'<font class="error-line"> RUN, QUIT.</font>'
)
})
it('leaves a notable note unmarked, and the other lines untouched', () => {
const request: any = { logFile: LOG }
component.analyseLog(request)
const lines: string[] = request.logHtml.split('\n')
expect(lines[9]).toEqual('NOTE: Invalid data for age in line 42 3-5.')
expect(lines[0]).toEqual('1 data want;')
expect(lines).toHaveSize(10)
})
it('returns the log as it is when it holds no issues', () => {
const log = 'NOTE: There were 19 observations read.'
expect(component.markIssueLines(log, [])).toEqual(log)
})
})
describe('goToLogLine', () => {
let wrapper: HTMLElement
beforeEach(() => {
const stackBlock = document.createElement('div')
stackBlock.id = 'request_0'
wrapper = document.createElement('div')
wrapper.className = 'log-wrapper saslog'
wrapper.textContent = LOG
stackBlock.appendChild(wrapper)
document.body.appendChild(stackBlock)
// The height of a rendered line needs a layout; a fixed value keeps the
// arithmetic under test deterministic.
;(component as any).measureLineHeight = () => 10
})
afterEach(() => wrapper.parentElement?.remove())
it('positions the highlight from the line the parser reported', () => {
component.goToLogLine(issue({ line: 6 }), 'request_0')
expect(
document.documentElement.style.getPropertyValue('--line-top')
).toEqual('60px')
expect(
document.documentElement.style.getPropertyValue('--line-height')
).toEqual('10px')
expect(
document.documentElement.style.getPropertyValue('--line-count')
).toEqual('1')
})
it('does not jump to the first of two identical messages', () => {
const repeated = issue({ line: 7, message: 'a message' })
component.goToLogLine(repeated, 'request_0')
expect(
document.documentElement.style.getPropertyValue('--line-top')
).toEqual('70px')
})
it('covers every line a wrapped message runs to', () => {
component.goToLogLine(issue({ line: 7, endLine: 8 }), 'request_0')
expect(
document.documentElement.style.getPropertyValue('--line-count')
).toEqual('2')
})
it('flashes the colour of the severity it was given', () => {
component.goToLogLine(
issue({ line: 5, severity: 'warning' }),
'request_0'
)
const overlay = wrapper.querySelector('.line-highlight-overlay')
expect(overlay).not.toBeNull()
expect(overlay!.classList).toContain('warning-highlight')
})
it('does nothing when the request has no log on screen', () => {
expect(() =>
component.goToLogLine(issue({ line: 1 }), 'request_9')
).not.toThrow()
})
})
describe('modalOpened', () => {
it('analyses every request and cuts the app location from its link', () => {
component = buildComponent([
{
serviceLink: '/Public/app/services/editors/save',
timestamp: new Date(),
logFile: LOG
}
])
component.modalOpened()
const [request] = component.sasjsRequests as any[]
expect(request.serviceLink).toEqual('services/editors/save')
expect(request.errors).toHaveSize(2)
expect(request.warnings).toHaveSize(1)
expect(request.notes).toHaveSize(1)
})
})
describe('the opened input', () => {
it('analyses the requests when the modal opens', () => {
component = buildComponent([
{
serviceLink: '/Public/app/services/x',
timestamp: new Date(),
logFile: LOG
}
])
component.opened = true
expect(component.opened).toBeTrue()
expect((component.sasjsRequests[0] as any).errors).toHaveSize(2)
})
it('does not re-read the requests when it closes', () => {
const getSasRequests = jasmine
.createSpy('getSasRequests')
.and.returnValue([])
component = buildComponent([])
sasService.getSasRequests = getSasRequests
component.opened = true
getSasRequests.calls.reset()
component.opened = false
expect(component.opened).toBeFalse()
expect(getSasRequests).not.toHaveBeenCalled()
})
it('reports the new state to the parent', () => {
const openedChange = jasmine.createSpy('openedChange')
component.openedChange.subscribe(openedChange)
component.modalOpenChange(false)
expect(openedChange).toHaveBeenCalledWith(false)
})
})
describe('timestamps', () => {
it('formats a known date in full and relative to now', () => {
const date = new Date('2026-03-04T15:06:07Z')
expect(component.formatTimestamp(date)).toContain('March 4th 2026')
expect(component.parseLogTimestamp(date)).toContain('March 4th 2026')
expect(component.timestampFromNow(date)).toContain('ago')
})
it('hands back a timestamp it cannot parse', () => {
expect(component.formatTimestamp('not a date')).toEqual('not a date')
expect(component.timestampFromNow('not a date')).toEqual('')
})
})
describe('downloads', () => {
it('writes the log it was given under a timestamped name', () => {
component.downloadLog('a log')
expect(helperService.downloadTextFile).toHaveBeenCalledWith(
jasmine.stringMatching(/^logFile-\d+$/),
'a log'
)
})
it('writes the source and the generated code under their own names', () => {
component.downloadSourceCode('source')
component.downloadGeneratedCode('generated')
expect(helperService.downloadTextFile).toHaveBeenCalledWith(
jasmine.stringMatching(/^sourceCode-\d+$/),
'source'
)
expect(helperService.downloadTextFile).toHaveBeenCalledWith(
jasmine.stringMatching(/^generatedCode-\d+$/),
'generated'
)
})
})
})
@@ -1,227 +0,0 @@
import {
Component,
EventEmitter,
Input,
OnInit,
Output,
ViewEncapsulation
} from '@angular/core'
import { SASjsRequest } from '@sasjs/adapter'
import { LogIssue, parseLog } from '@sasjs/utils/logs'
import moment from 'moment'
import { HelperService } from 'src/app/services/helper.service'
import { LoggerService } from '../../services/logger.service'
import { SasService } from '../../services/sas.service'
import { ServerType } from '@sasjs/utils/types/serverType'
interface SASjsRequestExtended extends SASjsRequest {
parsedTimestamp?: string
/** Every error, warning and notable note in `logFile`, in log order. */
logIssues?: LogIssue[]
errors?: LogIssue[]
warnings?: LogIssue[]
notes?: LogIssue[]
/** The log with its error and warning lines wrapped in markup, for rendering. */
logHtml?: string
selectedTable?: string
}
@Component({
selector: 'app-sas-logs-modal',
templateUrl: './sas-logs-modal.component.html',
styleUrls: ['./sas-logs-modal.component.scss'],
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class SasLogsModalComponent implements OnInit {
private _opened: boolean = false
get opened(): boolean {
return this._opened
}
@Input()
set opened(value: boolean) {
this._opened = value
if (value) this.modalOpened()
this.loggerService.log(this.sasjsRequests)
}
@Output() openedChange = new EventEmitter()
public sasLogActive: boolean = true
public sasSourceCodeActive: boolean = false
public sasGeneratedCodeActive: boolean = false
public tablesActive: boolean = false
public sasjsConfig = this.sasService.getSasjsConfig()
public serverType: string
public sasjsRequests: SASjsRequestExtended[] = []
public workTables: any
constructor(
private sasService: SasService,
private loggerService: LoggerService,
private helperService: HelperService
) {
this.sasjsConfig = this.sasService.getSasjsConfig()
this.serverType = this.sasService.getServerType()
}
ngOnInit(): void {}
public parseLogTimestamp(timestamp: any) {
return `${this.formatTimestamp(timestamp)} ${this.timestampFromNow(
timestamp
)}`
}
public cutAppLoc(link: string) {
return link.replace(this.sasjsConfig.appLoc + '/', '')
}
public formatTimestamp(timestamp: any) {
const parsed = moment(timestamp)
return parsed.isValid()
? parsed.format('dddd, MMMM Do YYYY, h:mm:ss a')
: timestamp
}
public timestampFromNow(timestamp: any) {
const parsed = moment(timestamp)
return parsed.isValid() ? ` (${parsed.fromNow()})` : ''
}
public modalOpenChange(state: any) {
this.opened = state
this.openedChange.emit(this.opened)
}
public modalOpened() {
this.sasjsRequests = this.sasService.getSasRequests()
for (let request of this.sasjsRequests) {
this.analyseLog(request)
request.serviceLink = this.cutAppLoc(request.serviceLink)
request.parsedTimestamp = this.parseLogTimestamp(request.timestamp)
}
}
/**
* Reads the request's log once and keeps what the template renders: the
* issues SAS wrote, grouped by severity, and - for a log carrying markup -
* the log itself with those lines marked.
*/
public analyseLog(request: SASjsRequestExtended) {
const log = typeof request.logFile === 'string' ? request.logFile : ''
const issues = parseLog(log)
request.logIssues = issues
request.errors = issues.filter((issue) => issue.severity === 'error')
request.warnings = issues.filter((issue) => issue.severity === 'warning')
request.notes = issues.filter((issue) => issue.severity === 'note')
request.logHtml = this.markIssueLines(log, issues)
}
/**
* The log with each line an error or a warning occupies wrapped in a class
* that colours it, so the lists and the log agree about what is wrong and
* where. A wrapped message is marked on every line it runs to, and a notable
* note is left unmarked - it is not a defect.
*/
public markIssueLines(log: string, issues: LogIssue[]): string {
const severityByLine = new Map<number, string>()
for (const issue of issues) {
if (issue.severity === 'note') continue
for (let line = issue.line; line <= issue.endLine; line++) {
severityByLine.set(line, issue.severity)
}
}
if (severityByLine.size === 0) return log
return log
.split('\n')
.map((line, index) => {
const severity = severityByLine.get(index)
return severity ? `<font class="${severity}-line">${line}</font>` : line
})
.join('\n')
}
/**
* Scrolls the log to an issue and flashes its line, using the line number the
* parser reported rather than searching the rendered text - a log repeats its
* messages, so a search lands on the first of them every time.
*/
public goToLogLine(issue: LogIssue, requestStackId: string) {
const logWrapper = document.querySelector(
`#${requestStackId} .log-wrapper.saslog`
) as HTMLElement | null
if (!logWrapper) return
const lineHeight = this.measureLineHeight(logWrapper)
const lineCount = issue.endLine - issue.line + 1
const top = lineHeight * issue.line
logWrapper.scrollTop = top
// The height and position reach the stylesheet as CSS variables, which
// keeps the inline style away from the element and therefore away from CSP.
document.documentElement.style.setProperty(
'--line-height',
`${lineHeight}px`
)
document.documentElement.style.setProperty('--line-count', `${lineCount}`)
document.documentElement.style.setProperty('--line-top', `${top}px`)
const overlay = document.createElement('div')
overlay.className = `line-highlight-overlay ${issue.severity}-highlight`
overlay.classList.add('temp-height-setter')
overlay.classList.add('line-position-setter')
logWrapper.appendChild(overlay)
setTimeout(() => {
if (logWrapper.contains(overlay)) {
logWrapper.removeChild(overlay)
}
}, 3000)
}
/** The height of one rendered log line, measured rather than assumed. */
private measureLineHeight(logWrapper: HTMLElement): number {
const tempDiv = document.createElement('div')
tempDiv.className = 'temp-line-height-calc'
tempDiv.textContent = 'X'
logWrapper.appendChild(tempDiv)
const lineHeight = tempDiv.clientHeight
logWrapper.removeChild(tempDiv)
return lineHeight
}
downloadLog(logFile: string) {
const timestamp = new Date().valueOf()
this.helperService.downloadTextFile(`logFile-${timestamp}`, logFile)
}
downloadSourceCode(sourceCode: string) {
const timestamp = new Date().valueOf()
this.helperService.downloadTextFile(`sourceCode-${timestamp}`, sourceCode)
}
downloadGeneratedCode(generatedCode: string) {
const timestamp = new Date().valueOf()
this.helperService.downloadTextFile(
`generatedCode-${timestamp}`,
generatedCode
)
}
}
@@ -71,8 +71,8 @@
</clr-toggle-wrapper>
</clr-toggle-container>
</div>
<a (click)="openSasLogsModal()" clrDropdownItem>
<span>SAS Logs</span>
<a (click)="openRequestsModal()" clrDropdownItem>
<span>SAS Requests</span>
</a>
@if (!isDeployPage) {
@@ -59,8 +59,8 @@ export class HeaderActions implements OnInit, OnDestroy {
this.eventService.toggleDarkMode(value)
}
openSasLogsModal() {
this.eventService.openSasLogsModal()
openRequestsModal() {
this.eventService.openRequestsModal()
}
onDebugModeChange(dropdownItem?: any): void {
@@ -1,215 +0,0 @@
import { parseVaParmap } from './parse-va-parmap'
describe('parseVaParmap', () => {
it('returns an empty map and no problems when the parameter is absent', () => {
for (const query of ['', 'embed=va', 'embed=va&labels=true']) {
const parsed = parseVaParmap(query)
expect(parsed.map.size).toBe(0)
expect(parsed.problems).toEqual([])
}
})
it('maps a VA parameter label to a DC column', () => {
const { map, problems } = parseVaParmap(
'embed=va&va_parmap=REGION_TAB1-REGION'
)
expect(map.get('region_tab1')?.column).toBe('REGION')
expect(map.size).toBe(1)
expect(problems).toEqual([])
})
it('collects repeated entries, so two parameters can map to one column', () => {
// The multi-tab case: each DC instance filters the same DC column from a
// differently-named VA parameter.
const { map } = parseVaParmap(
'embed=va&va_parmap=REGION_TAB1-REGION&va_parmap=REGION_TAB2-REGION'
)
expect(map.get('region_tab1')?.column).toBe('REGION')
expect(map.get('region_tab2')?.column).toBe('REGION')
expect(map.size).toBe(2)
})
it('is case-insensitive on the key and trims both sides', () => {
const { map } = parseVaParmap('va_parmap=%20Region_Tab1%20-%20region%20')
expect(map.get('region_tab1')?.column).toBe('region')
})
it('decodes a key containing a space (VA labels are free text)', () => {
expect(
parseVaParmap('va_parmap=My+Region-DEPT').map.get('my region')?.column
).toBe('DEPT')
})
it('splits at the LAST hyphen, so a label may contain hyphens', () => {
expect(
parseVaParmap('va_parmap=Sales-2024-DEPT').map.get('sales-2024')?.column
).toBe('DEPT')
})
describe('entries that cannot be used', () => {
it('reports an entry with no separator', () => {
const { map, problems } = parseVaParmap('va_parmap=DEPT')
expect(map.size).toBe(0)
expect(problems).toEqual([
'va_parmap "DEPT" was ignored: it needs a "-" between the VA parameter and the column name.'
])
})
it('reports an entry with no parameter name', () => {
const { map, problems } = parseVaParmap('va_parmap=-DEPT')
expect(map.size).toBe(0)
expect(problems).toEqual([
'va_parmap "-DEPT" was ignored: the VA parameter name is missing.'
])
})
it('reports an entry with no column name', () => {
const { map, problems } = parseVaParmap('va_parmap=REGION_TAB1-')
expect(map.size).toBe(0)
expect(problems).toEqual([
'va_parmap "REGION_TAB1-" was ignored: "" is not a valid SAS column name.'
])
})
it('reports a column that is not a plain SAS name (injection guard)', () => {
// The column is interpolated into a SAS WHERE clause, so anything beyond a
// SAS name is refused at parse time rather than carried into a clause.
for (const entry of [
'A-x;drop',
'A-REGION%20OR%201=1',
"A-RE'GION",
'A-1ABC'
]) {
const { map, problems } = parseVaParmap(`va_parmap=${entry}`)
expect(map.size).toBe(0)
expect(problems.length).toBe(1)
expect(problems[0]).toContain('is not a valid SAS column name')
}
expect(parseVaParmap('va_parmap=A-A_B9').map.get('a')?.column).toBe(
'A_B9'
)
})
it('keeps the usable entries and reports only the bad one', () => {
const { map, problems } = parseVaParmap(
'va_parmap=GOOD-REGION&va_parmap=BROKEN'
)
expect(map.get('good')?.column).toBe('REGION')
expect(problems.length).toBe(1)
expect(problems[0]).toContain('va_parmap "BROKEN"')
})
})
describe('transforms', () => {
it('parses an ADD anchor with an offset', () => {
const { map, problems } = parseVaParmap(
'va_parmap=SELECTED-D_DATE:ADD(QUARTER_START,-1)'
)
expect(problems).toEqual([])
expect(map.get('selected')?.column).toBe('D_DATE')
expect(map.get('selected')?.transform).toEqual({
op: 'ADD',
anchor: 'QUARTER_START',
offset: -1
})
})
it('defaults the offset to zero and accepts lower case', () => {
const { map, problems } = parseVaParmap(
'va_parmap=sel-d_date:add(quarter_start)'
)
expect(problems).toEqual([])
expect(map.get('sel')?.transform).toEqual({
op: 'ADD',
anchor: 'QUARTER_START',
offset: 0
})
})
it('parses a RANGE period', () => {
const { map, problems } = parseVaParmap(
'va_parmap=sel-D_DATE:RANGE(quarter,-1)'
)
expect(problems).toEqual([])
expect(map.get('sel')?.transform).toEqual({
op: 'RANGE',
period: 'QUARTER',
offset: -1
})
})
it('leaves a mapping without a transform as a plain column', () => {
const { map } = parseVaParmap('va_parmap=sel-D_DATE')
expect(map.get('sel')?.column).toBe('D_DATE')
expect(map.get('sel')?.transform).toBeUndefined()
})
it('reports an unknown operation, anchor, period or offset', () => {
const cases: [string, string][] = [
['sel-D_DATE:SHIFT(QUARTER_START,-1)', 'is not a known transform'],
['sel-D_DATE:ADD(FORTNIGHT_START,-1)', 'is not a known anchor'],
['sel-D_DATE:RANGE(DECADE,-1)', 'is not a known period'],
['sel-D_DATE:ADD(QUARTER_START,3.5)', 'is not a whole number'],
['sel-D_DATE:ADD', 'is not a transform']
]
for (const [entry, expected] of cases) {
const { map, problems } = parseVaParmap(`va_parmap=${entry}`)
expect(`${entry}: ${map.size}`).toBe(`${entry}: 0`)
expect(`${entry}: ${problems.length}`).toBe(`${entry}: 1`)
expect(problems[0]).toContain(expected)
}
})
it('keeps the parameter label free to contain a colon', () => {
// The key side is untouched by the transform grammar: only the column side
// is split, so a label with a colon still maps.
const { map, problems } = parseVaParmap(
'va_parmap=Sales:2026-D_DATE:ADD(QUARTER_START,-1)'
)
expect(problems).toEqual([])
expect(map.get('sales:2026')?.column).toBe('D_DATE')
expect(map.get('sales:2026')?.transform).toEqual({
op: 'ADD',
anchor: 'QUARTER_START',
offset: -1
})
})
})
describe('one parameter, one column', () => {
it('keeps the first mapping and reports a conflicting duplicate', () => {
const { map, problems } = parseVaParmap(
'va_parmap=DEPT-REGION&va_parmap=DEPT-OTHER'
)
expect(map.get('dept')?.column).toBe('REGION')
expect(problems).toEqual([
'va_parmap "DEPT-OTHER" was ignored: VA parameter "dept" already maps to REGION, and a parameter can filter only one column.'
])
})
it('treats a duplicate key as a duplicate across case', () => {
const { map, problems } = parseVaParmap(
'va_parmap=Dept-REGION&va_parmap=DEPT-SOMETHING_ELSE'
)
expect(map.get('dept')?.column).toBe('REGION')
expect(problems.length).toBe(1)
})
it('accepts the same mapping written twice (no ambiguity)', () => {
const { map, problems } = parseVaParmap(
'va_parmap=DEPT-REGION&va_parmap=dept-region'
)
expect(map.get('dept')?.column).toBe('REGION')
expect(problems).toEqual([])
})
it('still allows two parameters to map to one column', () => {
// The multi-object case: different parameters, same DC column.
const { map, problems } = parseVaParmap(
'va_parmap=REGION_TAB1-REGION&va_parmap=REGION_TAB2-REGION'
)
expect(map.size).toBe(2)
expect(problems).toEqual([])
})
})
})
@@ -1,192 +0,0 @@
import {
VA_ANCHORS,
VA_PERIODS,
VaAnchor,
VaPeriod,
VaTransform
} from './va-parmap-transform'
/**
* What a VA parameter's mapping resolves to: the DC column, and optionally a
* transform that adds derived dates to the filter (e.g. the previous quarter's
* row alongside the selected one).
*/
export interface VaParmapTarget {
column: string
transform?: VaTransform
}
/** Result of `parseVaParmap` - the usable mappings, and what was ignored. */
export interface VaParmap {
/** Lower-cased VA parameter key -> DC column (and transform, if any). */
map: Map<string, VaParmapTarget>
/** One plain-English line per `va_parmap` entry that was ignored. */
problems: string[]
}
/**
* Parses the `va_parmap` entries out of a hash query string (the part after `?`
* in `window.location.hash`).
*
* Format - one mapping per occurrence, each `<VA parameter key>-<DC column>`:
*
* ?embed=va&va_parmap=REGION_TAB1-REGION&va_parmap=REGION_TAB2-REGION
*
* Repeating the parameter (rather than packing a comma-separated list into one
* value) keeps the grammar unambiguous: VA parameter labels are free text, so
* any list separator we picked could also appear inside a label.
*
* Each entry is split at its LAST `-`. A SAS column name can never contain a
* hyphen, so the final one always delimits the column, and a label that does
* contain hyphens (e.g. `Sales-2024`) still maps correctly.
*
* The key is matched case-insensitively, and trailing/leading blanks are
* trimmed, against a VA parameter's `label`, which is the same key the
* label->column match uses. The internal `name` (e.g. `pr273`) is not a key: it
* is the report's own bookkeeping, so an entry written against it never applies
* and is reported by `unmatchedMappings`.
*
* An entry that cannot be used is reported in `problems` rather than dropped in
* silence: these entries are hand-written in the report's DDC URL, usually by
* the report author, and a typo would otherwise degrade to "no filter" - which
* looks exactly like a working filter over the whole table.
*
* Returns an empty map when the parameter is absent, which leaves the default
* same-name resolution untouched (existing embeds are unaffected).
*
* Modeled on `parseEmbedParam` / `parseLabelsParam`, which read the same hash
* query.
*/
export function parseVaParmap(hashQuery: string): VaParmap {
const map = new Map<string, VaParmapTarget>()
const problems: string[] = []
if (!hashQuery) return { map, problems }
const entries = new URLSearchParams(hashQuery).getAll('va_parmap')
for (const entry of entries) {
// A transform sits at the end, in parentheses: `<param>-<column>:OP(args)`.
// It is lifted off FIRST, because its arguments may contain a hyphen (a
// negative offset) and the key/column split below uses the hyphen.
let body = entry ?? ''
let transformSpec = ''
if (body.trim().endsWith(')')) {
const open = body.lastIndexOf('(')
const colon = open > 0 ? body.lastIndexOf(':', open) : -1
if (colon > 0) {
transformSpec = body.slice(colon + 1).trim()
body = body.slice(0, colon)
}
}
// Last hyphen delimits the DC column (see above).
const separator = body.lastIndexOf('-')
if (separator < 0) {
problems.push(
`va_parmap "${entry}" was ignored: it needs a "-" between the VA parameter and the column name.`
)
continue
}
const key = body.slice(0, separator).trim().toLowerCase()
const column = body.slice(separator + 1).trim()
if (!key) {
problems.push(
`va_parmap "${entry}" was ignored: the VA parameter name is missing.`
)
continue
}
if (column.includes(':')) {
problems.push(
`va_parmap "${entry}" was ignored: "${column}" is not a transform - expected e.g. ADD(QUARTER_START,-1).`
)
continue
}
// The column name is interpolated into a SAS WHERE clause, so only a plain
// SAS name is accepted - anything else is refused rather than mapped.
if (!/^[A-Za-z_][A-Za-z0-9_]{0,31}$/.test(column)) {
problems.push(
`va_parmap "${entry}" was ignored: "${column}" is not a valid SAS column name.`
)
continue
}
const transform = transformSpec
? parseTransform(transformSpec, entry, problems)
: undefined
if (transformSpec && !transform) continue
// One VA parameter filters one column. A second entry for the same parameter
// is ambiguous, so the FIRST one wins and the conflict is reported - the
// alternative (last one wins, silently) would apply a filter the author
// never sees the origin of. Two parameters mapping to the same column is
// fine and is how two report objects filter one column from their own
// parameters.
const existing = map.get(key)
if (existing) {
if (existing.column.toUpperCase() !== column.toUpperCase()) {
problems.push(
`va_parmap "${entry}" was ignored: VA parameter "${key}" already maps to ${existing.column}, and a parameter can filter only one column.`
)
}
continue
}
map.set(key, { column, transform })
}
return { map, problems }
}
/**
* Parses `OP(arg[, arg])`, returning undefined (and pushing a problem line) when
* the operation or one of its arguments is not in the allow-list.
*
* ADD takes an anchor and an optional offset; RANGE takes a period and an
* optional offset. The offset is whole periods, negative for earlier.
*/
function parseTransform(
spec: string,
entry: string,
problems: string[]
): VaTransform | undefined {
const match = /^([A-Za-z]+)\s*\(([^)]*)\)$/.exec(spec)
if (!match) {
problems.push(
`va_parmap "${entry}" was ignored: "${spec}" is not a transform - expected e.g. ADD(QUARTER_START,-1).`
)
return undefined
}
const op = match[1].toUpperCase()
const args = match[2]
.split(',')
.map((arg) => arg.trim())
.filter((arg) => arg.length > 0)
const offsetArg = args[1] === undefined ? '0' : args[1]
if (!/^-?\d+$/.test(offsetArg)) {
problems.push(
`va_parmap "${entry}" was ignored: the offset "${offsetArg}" is not a whole number of periods.`
)
return undefined
}
const offset = Number(offsetArg)
if (op === 'ADD') {
const anchor = (args[0] || '').toUpperCase()
if (!VA_ANCHORS.includes(anchor as VaAnchor)) {
problems.push(
`va_parmap "${entry}" was ignored: "${args[0] || ''}" is not a known anchor (${VA_ANCHORS.join(', ')}).`
)
return undefined
}
return { op: 'ADD', anchor: anchor as VaAnchor, offset }
}
if (op === 'RANGE') {
const period = (args[0] || '').toUpperCase()
if (!VA_PERIODS.includes(period as VaPeriod)) {
problems.push(
`va_parmap "${entry}" was ignored: "${args[0] || ''}" is not a known period (${VA_PERIODS.join(', ')}).`
)
return undefined
}
return { op: 'RANGE', period: period as VaPeriod, offset }
}
problems.push(
`va_parmap "${entry}" was ignored: "${match[1]}" is not a known transform (ADD, RANGE).`
)
return undefined
}
@@ -1,194 +0,0 @@
import {
applyVaTransform,
isRangeTransform,
VA_ANCHORS,
VA_PERIODS,
VaTransform
} from './va-parmap-transform'
/** Local y-m-d, so assertions do not depend on the runner's timezone offset. */
const ymd = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
).padStart(2, '0')}`
const date = (y: number, m: number, d: number) => new Date(y, m - 1, d)
describe('va_parmap transforms', () => {
it('exposes only allow-listed anchors and periods', () => {
expect(VA_ANCHORS).toContain('QUARTER_START')
expect(VA_PERIODS).toEqual(['QUARTER', 'MONTH', 'YEAR'])
})
describe('ADD - the value plus one anchor beside it', () => {
it('adds the previous quarter start, which is the reported requirement', () => {
// intnx('qtr', dt, -1, 'b') for a value on a quarter boundary
const dates = applyVaTransform(date(2026, 10, 1), {
op: 'ADD',
anchor: 'QUARTER_START',
offset: -1
})
expect(dates.map(ymd)).toEqual(['2026-10-01', '2026-07-01'])
})
it('resolves a mid-quarter value to its own value plus the previous quarter start', () => {
const dates = applyVaTransform(date(2026, 8, 15), {
op: 'ADD',
anchor: 'QUARTER_START',
offset: -1
})
expect(dates.map(ymd)).toEqual(['2026-08-15', '2026-04-01'])
})
it('de-duplicates when the value already sits on the anchor', () => {
// 2026-01-01 is a quarter start, so "0 quarters back" adds nothing
const dates = applyVaTransform(date(2026, 1, 1), {
op: 'ADD',
anchor: 'QUARTER_START',
offset: 0
})
expect(dates.map(ymd)).toEqual(['2026-01-01'])
})
it('resolves the other anchors at their boundaries', () => {
const at = date(2026, 8, 15)
expect(
ymd(
applyVaTransform(at, {
op: 'ADD',
anchor: 'QUARTER_END',
offset: 0
})[1]
)
).toBe('2026-09-30')
expect(
ymd(
applyVaTransform(at, { op: 'ADD', anchor: 'MONTH_END', offset: 0 })[1]
)
).toBe('2026-08-31')
expect(
ymd(
applyVaTransform(at, {
op: 'ADD',
anchor: 'YEAR_START',
offset: -1
})[1]
)
).toBe('2025-01-01')
expect(
ymd(
applyVaTransform(at, { op: 'ADD', anchor: 'YEAR_END', offset: 0 })[1]
)
).toBe('2026-12-31')
})
it('handles February and leap years at month end', () => {
expect(
ymd(
applyVaTransform(date(2026, 3, 1), {
op: 'ADD',
anchor: 'MONTH_END',
offset: -1
})[1]
)
).toBe('2026-02-28')
expect(
ymd(
applyVaTransform(date(2024, 3, 15), {
op: 'ADD',
anchor: 'MONTH_END',
offset: -1
})[1]
)
).toBe('2024-02-29')
})
it('crosses a year boundary when the offset takes it out of the year', () => {
const dates = applyVaTransform(date(2027, 1, 1), {
op: 'ADD',
anchor: 'QUARTER_START',
offset: -1
})
expect(dates.map(ymd)).toEqual(['2027-01-01', '2026-10-01'])
})
})
describe('RANGE - a closed period', () => {
it('covers the previous quarter', () => {
const dates = applyVaTransform(date(2026, 8, 15), {
op: 'RANGE',
period: 'QUARTER',
offset: -1
})
expect(dates.map(ymd)).toEqual(['2026-04-01', '2026-06-30'])
})
it('covers the current month and the previous year', () => {
expect(
applyVaTransform(date(2026, 8, 15), {
op: 'RANGE',
period: 'MONTH',
offset: 0
}).map(ymd)
).toEqual(['2026-08-01', '2026-08-31'])
expect(
applyVaTransform(date(2026, 8, 15), {
op: 'RANGE',
period: 'YEAR',
offset: -1
}).map(ymd)
).toEqual(['2025-01-01', '2025-12-31'])
})
it('reports itself as a range so the caller picks BETWEEN', () => {
expect(
isRangeTransform({ op: 'RANGE', period: 'QUARTER', offset: 0 })
).toBe(true)
expect(
isRangeTransform({
op: 'ADD',
anchor: 'QUARTER_START',
offset: 0
} as VaTransform)
).toBe(false)
})
})
describe('time of day', () => {
it('gives a datetime range whole days, not the value clock time', () => {
// A BETWEEN that starts at 13:45 on day one loses the earlier part of that
// day, and one that ends at 13:45 on the last day loses the rest of it.
const value = new Date(2026, 7, 15, 13, 45, 30)
const [start, end] = applyVaTransform(
value,
{ op: 'RANGE', period: 'QUARTER', offset: 0 },
true
)
expect([
start.getHours(),
start.getMinutes(),
start.getSeconds()
]).toEqual([0, 0, 0])
expect([end.getHours(), end.getMinutes(), end.getSeconds()]).toEqual([
23, 59, 59
])
expect(ymd(start)).toBe('2026-07-01')
expect(ymd(end)).toBe('2026-09-30')
})
it('keeps the time for datetime columns and drops it otherwise', () => {
const value = new Date(2026, 9, 1, 13, 45, 30)
const add: VaTransform = {
op: 'ADD',
anchor: 'QUARTER_START',
offset: -1
}
const kept = applyVaTransform(value, add, true)[1]
expect([kept.getHours(), kept.getMinutes(), kept.getSeconds()]).toEqual([
13, 45, 30
])
const dropped = applyVaTransform(value, add, false)[1]
expect([dropped.getHours(), dropped.getMinutes()]).toEqual([0, 0])
})
})
})
@@ -1,151 +0,0 @@
/**
* Anchors and periods a `va_parmap` transform can resolve a report value to.
*
* The names describe the result rather than mirroring SAS `intnx`, because the
* arithmetic runs in the browser: emulating `intnx`'s alignment letters (`b`,
* `e`, `s`, `m`, never mind week alignment and fiscal quarters) would invite
* assumptions we could not honour. Each anchor below is one small function, so
* adding one is a function plus a test row.
*/
export const VA_ANCHORS = [
'QUARTER_START',
'QUARTER_END',
'MONTH_START',
'MONTH_END',
'YEAR_START',
'YEAR_END'
] as const
export type VaAnchor = (typeof VA_ANCHORS)[number]
export const VA_PERIODS = ['QUARTER', 'MONTH', 'YEAR'] as const
export type VaPeriod = (typeof VA_PERIODS)[number]
/**
* A parsed transform: the operation plus its validated arguments.
*
* `ADD` yields the value and one anchor beside it (a list of dates), which is
* how "this period's row plus the previous quarter's" is expressed. `RANGE`
* yields a closed period (two dates) for a BETWEEN.
*/
export type VaTransform =
| { op: 'ADD'; anchor: VaAnchor; offset: number }
| { op: 'RANGE'; period: VaPeriod; offset: number }
/** True when the transform produces a closed range rather than a list. */
export function isRangeTransform(transform: VaTransform): boolean {
return transform.op === 'RANGE'
}
/** Local midnight on the first day of the month `offset` months from `date`. */
function monthStart(date: Date, offset: number): Date {
return new Date(date.getFullYear(), date.getMonth() + offset, 1)
}
/** Local midnight on the last day of the month `offset` months from `date`. */
function monthEnd(date: Date, offset: number): Date {
return new Date(date.getFullYear(), date.getMonth() + offset + 1, 0)
}
/** First day of the quarter `offset` quarters from the one containing `date`. */
function quarterStart(date: Date, offset: number): Date {
const firstMonthOfQuarter = 3 * Math.floor(date.getMonth() / 3)
return new Date(date.getFullYear(), firstMonthOfQuarter + offset * 3, 1)
}
/** Last day of the quarter `offset` quarters from the one containing `date`. */
function quarterEnd(date: Date, offset: number): Date {
// Day 0 of a month is the last day of the month before it: calendar
// construction, so a daylight-saving transition inside the quarter cannot
// shift the result by an hour the way subtracting 24 hours would.
const nextStart = quarterStart(date, offset + 1)
return new Date(nextStart.getFullYear(), nextStart.getMonth(), 0)
}
/** First day of the year `offset` years from the one containing `date`. */
function yearStart(date: Date, offset: number): Date {
return new Date(date.getFullYear() + offset, 0, 1)
}
/** Last day of the year `offset` years from the one containing `date`. */
function yearEnd(date: Date, offset: number): Date {
return new Date(date.getFullYear() + offset, 11, 31)
}
/** Resolve one anchor, keeping the time of day of `date` for datetime columns. */
function resolveAnchor(
anchor: VaAnchor,
date: Date,
offset: number,
keepTime: boolean
): Date {
let resolved: Date
switch (anchor) {
case 'QUARTER_START':
resolved = quarterStart(date, offset)
break
case 'QUARTER_END':
resolved = quarterEnd(date, offset)
break
case 'MONTH_START':
resolved = monthStart(date, offset)
break
case 'MONTH_END':
resolved = monthEnd(date, offset)
break
case 'YEAR_START':
resolved = yearStart(date, offset)
break
default:
resolved = yearEnd(date, offset)
break
}
if (keepTime) {
resolved.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), 0)
}
return resolved
}
/**
* The dates a transform adds to a mapping, in order.
*
* `ADD` returns the report's own value plus the anchor beside it, de-duplicated -
* so a value that already sits on the anchor yields one date, and the clause
* stays an equality rather than an `IN` with a repeat. `RANGE` returns the
* period's first and last day.
*
* `keepTime` carries the time of day across for datetime columns, since a
* DATETIME filter compares to the second; date columns use midnight.
*/
export function applyVaTransform(
value: Date,
transform: VaTransform,
keepTime = false
): Date[] {
if (transform.op === 'RANGE') {
// A range covers whole days: the bounds are the period's own, never the
// value's clock time. Reusing keepTime here would start the range mid-morning
// on day one and end it mid-morning on the last day, dropping the rest of
// both boundary days from a BETWEEN.
const start = resolveAnchor(
(transform.period + '_START') as VaAnchor,
value,
transform.offset,
false
)
const end = resolveAnchor(
(transform.period + '_END') as VaAnchor,
value,
transform.offset,
false
)
if (keepTime) end.setHours(23, 59, 59, 0)
return [start, end]
}
const anchor = resolveAnchor(
transform.anchor,
value,
transform.offset,
keepTime
)
return anchor.getTime() === value.getTime() ? [value] : [value, anchor]
}
@@ -187,7 +187,6 @@
<option value="TSQL_DDL">DDL (TSQL Flavour)</option>
<option value="EXCEL">Excel (.xlsx)</option>
<option value="MARKDOWN">Markdown (.md)</option>
<option value="PDF">PDF report (.pdf)</option>
</select>
</clr-select-container>
</div>
+8 -19
View File
@@ -298,7 +298,7 @@ app-editor {
// APP.COMPONENT
my-app {
app-sas-logs-modal {
app-requests-modal {
z-index: 10000;
}
@@ -1566,18 +1566,11 @@ body[cds-theme='light'] {
}
// USER.COMPONENT END
// SAS-LOGS-MODAL.COMPONENT
app-sas-logs-modal {
// REQUESTS-MODAL.COMPONENT
app-requests-modal {
--line-height: 16px;
--line-count: 1;
--line-top: 0px;
.issue-code {
margin-right: 4px;
font-family: monospace;
font-weight: 600;
}
.log-wrapper.saslog {
position: relative;
white-space: pre;
@@ -1598,7 +1591,7 @@ app-sas-logs-modal {
}
.temp-height-setter {
height: calc(var(--line-height) * var(--line-count));
height: var(--line-height);
}
.line-position-setter {
@@ -1616,11 +1609,7 @@ app-sas-logs-modal {
}
.warning-highlight {
background-color: rgba(242, 175, 0, 0.25);
}
.note-highlight {
background-color: rgba(0, 120, 210, 0.15);
background-color: rgba(255, 255, 0, 0.2);
}
@keyframes fadeOut {
@@ -1667,18 +1656,18 @@ app-sas-logs-modal {
}
}
.sas-logs-modal .modal-header--accessible--accessible .close clr-icon {
.requests-modal .modal-header--accessible--accessible .close clr-icon {
display: block !important;
}
.sas-logs-modal .modal-content {
.requests-modal .modal-content {
padding: 20px 10px 5px 10px;
}
/* The SAS log pane is `white-space: pre`, so it never wraps. The stock `xl`
width is a fixed 57.6rem, which forced left/right scrolling to read a log
line. */
.sas-logs-modal .modal-dialog {
.requests-modal .modal-dialog {
width: 90vw;
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dcfrontend",
"version": "7.21.0",
"version": "7.18.0",
"description": "Data Controller",
"devDependencies": {
"@nogoo9/gitleaks": "8.30.1-post.2",
@@ -15,6 +15,7 @@
"got": "11.8.6"
},
"scripts": {
"install": "cd client && npm i && cd ../sas && npm i",
"build-frontend": "cd client && npm run build",
"lint": "cd client && npm run lint",
"lint:fix": "cd client && npm run lint:fix",
+2 -37
View File
@@ -4,47 +4,12 @@
"hasDoxygenHeader": true,
"noSpacesInFileNames": true,
"lowerCaseFileNames": true,
"maxLineLength": 80,
"maxLineLength": 180,
"noTabIndentation": true,
"indentationMultiple": 2,
"hasMacroNameInMend": true,
"noNestedMacros": true,
"hasMacroParentheses": true,
"strictMacroDefinition": true,
"noSingleAsteriskComments": true,
"noUnusedLibnames": true,
"lineEndings": "lf",
"ignoredMacros": [
"webout",
"mpeinit",
"mpeterm"
],
"ignoreList": [
"sasjs/scratch"
],
"severityLevel": {
"hasDoxygenHeader": "error",
"hasMacroNameInMend": "error",
"hasMacroParentheses": "error",
"indentationMultiple": "error",
"lineEndings": "error",
"lowerCaseFileNames": "error",
"lowerCaseMacroNames": "error",
"maxLineLength": "error",
"minMacroNameLength": "error",
"noBannedStrings": "error",
"noGremlins": "error",
"noNestedCommentsInHeader": "error",
"noNestedMacros": "error",
"noSingleAsteriskComments": "error",
"noSpacesInFileNames": "error",
"noTabIndentation": "error",
"noTrailingSpaces": "error",
"noUndeclaredMacros": "error",
"noUnusedLibnames": "error",
"noUnusedMacros": "error",
"strictMacroDefinition": "error",
"noMacroIfInOpenCode": "error"
},
"noMacroIfInOpenCode": true
"ignoreList":["sasjs/scratch"]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"fromjs": [
{
"ADMIN": "all-users",
"ADMIN": "AllUsers",
"DCPATH": "/tmp/dcdata",
"OUTFMT": "JSON"
}
@@ -13,12 +13,10 @@
// The rules mirror services/hooks/mpe_derived_postedit.sas, in its order:
// normalise the incoming rows, reject a duplicate name and a bad field, then
// check the dependency graph that the resulting configuration would have (the
// incoming rows plus the current rows they do not replace). The field rules
// are core's mp_derivedvarcheck, which the server hook calls and the read-time
// engine runs too - this file spells them out a second time, so a change there
// has to be mirrored here. As on the server, the collision between VAR_NM and
// a physical column is left to the read-time engine, which runs with the target
// library assigned - the hook does not have it here either.
// incoming rows plus the current rows they do not replace). As on the server,
// the collision between VAR_NM and a physical column is left to the read-time
// engine, which runs with the target library assigned - the hook does not have
// it here either.
// ─────────────────────────────────────────────────────────────────────────────
;(function mpeDerivedPostedit() {
@@ -85,45 +83,9 @@
seen.add(key)
}
// ─── Field level checks ────────────────────────────────────────────────────
// Two passes, in the order the SAS hook runs them. The first is the fields
// this hook owns - the control table key, which the loader normalises but
// does not require, and ACTIVE, the picker pair the EDIT screen offers.
for (const row of rows) {
if (isDeleted(row)) continue
const name = field(row, 'VAR_NM')
let reason = ''
if (!field(row, 'LIBREF') || !field(row, 'DSN'))
reason = 'LIBREF and DSN are required'
// ACTIVE is a picker in the EDIT screen (HARDSELECT over the
// MPE_SELECTBOX pair) - the same pair the hook enforces
else if (!['0', '1'].includes(String(colVal(row, 'ACTIVE')).trim()))
reason = 'ACTIVE must be 1 or 0'
if (reason) return reject(`MPE_DERIVED: ${name}: ${reason}`)
}
// The second pass is the rules for the derived variable itself, which are
// core's (mp_derivedvarcheck): the engine runs the same macro at read time,
// so a definition is measured against one rule list wherever it is checked.
// C0 controls and DEL: what a label cannot carry once a line break has been
// flattened to a space, which is what the engine does with it
// ─── Field level checks, the rules the engine applies at read time ─────────
// C0 controls and DEL: what a single-line label cannot carry
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/
const flatten = (v) => v.replace(/[\r\n]+/g, ' ')
const AUTOMATIC_VARS = ['_N_', '_ERROR_', '_IORC_']
// the capabilities refused by name - a function name cannot be assembled at
// run time, so naming them is a control the expression cannot evade
const DENY_FUNCS = new RegExp(
'\\b(DOSUBL|JAVAOBJ|JAVAMETA|SYMGET|SYMPUTX?|SYSGET|GETOPTION' +
'|PATHNAME|FILEEXIST|FOPEN|FREAD|FGET|FCLOSE' +
'|DOPEN|DREAD|DNUM|MOPEN|SLEEP' +
'|FILENAME|FDELETE|FCOPY|DCREATE|DLCREATE)\\s*\\('
)
// quoted literals are data, not calls
const unquoted = (v) =>
v.replace(/'([^']|'')*'/g, ' ').replace(/"([^"]|"")*"/g, ' ')
// true when the value carries a character outside the allowed set
const hasInvalidChar = (value, chars) =>
@@ -131,6 +93,8 @@
for (const row of rows) {
if (isDeleted(row)) continue
const libref = field(row, 'LIBREF')
const dsn = field(row, 'DSN')
const name = field(row, 'VAR_NM')
const type = field(row, 'VAR_TYPE')
const len = Number(colVal(row, 'VAR_LEN'))
@@ -139,16 +103,19 @@
const logic = strip(colVal(row, 'VAR_LOGIC'))
let reason = ''
if (!name) reason = 'VAR_NM is required'
if (!libref || !dsn) reason = 'LIBREF and DSN are required'
else if (!name) reason = 'VAR_NM is required'
else if (hasInvalidChar(name, NAME_CHARS))
reason = 'VAR_NM must be a valid SAS name'
else if ('0123456789'.includes(name[0]))
reason = 'VAR_NM must not start with a digit'
else if (AUTOMATIC_VARS.includes(name))
reason = 'VAR_NM is an automatic variable of the data step'
else if (name === DELETE_MARK) reason = 'VAR_NM is reserved'
else if (type !== 'C' && type !== 'N')
reason = 'VAR_TYPE must be C or N'
// ACTIVE is a picker in the EDIT screen (HARDSELECT over the
// MPE_SELECTBOX pair) - the same pair mpe_derived_check.sas enforces
else if (!['0', '1'].includes(String(colVal(row, 'ACTIVE')).trim()))
reason = 'ACTIVE must be 1 or 0'
else if (!Number.isInteger(len) || len < 1)
reason = 'VAR_LEN must be a positive integer'
else if (type === 'C' && len > 32767)
@@ -165,24 +132,20 @@
reason = 'VAR_FMT must be a format name, without ; & % or /* */'
else if (hasInvalidChar(fmt, FMT_CHARS))
reason = 'VAR_FMT must be a format name'
// free text, in any script - what is refused is a control character,
// and a value the engine would cut to 256
else if (CONTROL_CHARS.test(flatten(label)))
reason = 'VAR_LABEL must be plain text - no control characters'
// free text, in any script - what is refused is anything that cannot be
// a single-line column label, and a value the engine would cut to 256
else if (CONTROL_CHARS.test(label))
reason =
'VAR_LABEL must be plain text - no line breaks or control characters'
else if ([...label].length > 256)
reason = 'VAR_LABEL exceeds 256 characters'
else if (!logic) reason = 'VAR_LOGIC is required'
else if (logic.length > 4000)
reason = 'VAR_LOGIC exceeds 4000 characters'
else if (logic.includes(';'))
reason = 'VAR_LOGIC must be a single expression - remove the ;'
else if (logic.includes('&') || logic.includes('%'))
reason = 'VAR_LOGIC must not contain macro triggers'
else if (logic.includes('/*') || logic.includes('*/'))
reason = 'VAR_LOGIC must not contain comments'
else if (DENY_FUNCS.test(unquoted(logic).toUpperCase()))
reason =
'VAR_LOGIC may not call a function that submits code or reaches the file system, the environment or the macro symbol table'
if (reason) return reject(`MPE_DERIVED: ${name}: ${reason}`)
}
+1 -14
View File
@@ -1,20 +1,7 @@
let mimeType = `application/octet-stream`
let filename = `dc-download`
/* typeof, not a truthiness test: if the service is called without a ds in scope
the read itself throws and the fallback never runs */
if (
typeof libref !== 'undefined' &&
libref &&
typeof ds !== 'undefined' &&
ds
)
filename = `${libref}_${ds}.ddl`
else if (typeof libref !== 'undefined' && libref) {
/* production aborts when ds is missing (getddl.sas:43-45), so the mock does
too: a client that dropped ds would otherwise stay green here */
throw new Error(`Dataset ${libref}..${ds} was not found`)
}
if (libref) filename = `${libref}.ddl`
fs.writeFile(_SASJS_WEBOUT_HEADERS, `Content-type: ${mimeType} \n Content-Disposition: attachment;filename=\"${filename}\"`, function (err) {
if (err) throw err;
@@ -55,14 +55,6 @@ switch((type || '').toUpperCase()) {
mimeType = 'application/octet-stream'
filename += '.md'
break
case 'PDF':
/* the mock layer has no ODS, so it streams the table listing as the
body: enough for the client to check the option, the content type
and the filename, which is what this layer owns. The real report is
laid out by mp_ds2pdf on the SAS side. */
mimeType = 'application/pdf'
filename += '.pdf'
break
default:
mimeType = 'application/octet-stream'
filename += '.sas'
+62 -27
View File
@@ -6,8 +6,8 @@
"": {
"name": "dc-sas",
"dependencies": {
"@sasjs/cli": "6.5.0",
"@sasjs/core": "7.1.0"
"@sasjs/cli": "6.2.0",
"@sasjs/core": "6.1.0"
}
},
"node_modules/@asamuzakjp/css-color": {
@@ -165,15 +165,14 @@
}
},
"node_modules/@sasjs/cli": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-6.5.0.tgz",
"integrity": "sha512-1I7B0iKd+16ByIGPToGfODU3S1bxO/3VCGpmu5alql/HlXdxu75djdpuzCovbsSeWm+M4FrsrBKDZTc9hhBrLA==",
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-6.2.0.tgz",
"integrity": "sha512-zovAOCmyPqcm+gooAZgwvgGH8n5cas3sY0lA80+hyFoFtmZ8VjW/bh19LM8OVwheT9dBCRCx4fnHwjy5XGpzvQ==",
"license": "ISC",
"dependencies": {
"@sasjs/adapter": "^5.1.1",
"@sasjs/core": "^6.1.1",
"@sasjs/lint": "4.9.0",
"@sasjs/utils": "^4.1.2",
"@sasjs/core": "^6.0.0",
"@sasjs/utils": "^4.1.0",
"axios": "1.20.0",
"chalk": "4.1.2",
"dotenv": "18.0.8",
@@ -195,18 +194,15 @@
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"@sasjs/lint": "^4.3.0"
}
},
"node_modules/@sasjs/cli/node_modules/@sasjs/core": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-6.1.4.tgz",
"integrity": "sha512-vIF2LIftxPWg4CvO3i5raEfrHFHY/6S+MqVGpjRR71dgjLRJEYNyPJgGqD6BCZwHv5V6/2nDnBi/BIIMiLXfzA==",
"license": "MIT"
},
"node_modules/@sasjs/cli/node_modules/@sasjs/utils": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.1.2.tgz",
"integrity": "sha512-5QLBVlCqiiDW/GoxiHUCyT+8GJjJA/S/TX0DS/t4O4HC1Im/QpQzxlS86QkjLAQBBHovYPyh+v4pgdh+IYwaEQ==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.1.0.tgz",
"integrity": "sha512-aC5pOugg6/2jKQR7ZaYsLtbsFy/iVacsrN3/Kzh6BouJUszuVrr13HMrQUSKcWL+rJzxYof7ymmmCl+vE6YwAw==",
"license": "ISC",
"dependencies": {
"@fast-csv/format": "5.0.7",
@@ -239,24 +235,62 @@
}
},
"node_modules/@sasjs/core": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-7.1.0.tgz",
"integrity": "sha512-teTXr60gS28UBHnmfoJHchMI1k/eqdgDAnGJ7SWXWi2idQwTM+svNJlOrOk63mBDgOPbRvlnrqMPEXm52au8Yg==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-6.1.0.tgz",
"integrity": "sha512-U0Ekq/TXVMquYV+AG6urBQSUgqUvV37yduufy68QQVgDj3PJiLUJDcOXKOGbhWGD+Nv877VuPCviGYqrqbjXvg==",
"license": "MIT"
},
"node_modules/@sasjs/lint": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-4.9.0.tgz",
"integrity": "sha512-QEBo19bWUdKIaE62Pk8eb35Q9EjHlifyOmbH+voItX6muCqHTX88SOzLD4MYtFVuagm/cnGplIVcKvUZzO+bSw==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-4.3.0.tgz",
"integrity": "sha512-65cjy4IjA4PgSlicOruF0DI9N2HO3LJqtgphsZCg8g7N/StE2FWLJLJbD1N4OHxN6htrUfj5SEawJJiHo5T9Yw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@sasjs/utils": "^4.0.0",
"@sasjs/utils": "4.0.0",
"ignore": "7.0.11"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@sasjs/lint/node_modules/@sasjs/utils": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.0.tgz",
"integrity": "sha512-lARoCnRM4gSVf8ShoP5elHi7W28K/14MK037dQ0ABgMlP3+r6OuhAZYwK/a70tED47tKxyIoCAKLdP6jrG0ewQ==",
"license": "ISC",
"peer": true,
"dependencies": {
"@fast-csv/format": "5.0.7",
"@types/fs-extra": "11.0.4",
"@types/prompts": "2.4.9",
"chalk": "4.1.1",
"consola": "3.4.2",
"fs-extra": "11.4.1",
"jwt-decode": "4.0.0",
"prompts": "2.4.2"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@sasjs/lint/node_modules/chalk": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@sasjs/utils": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.1.tgz",
@@ -791,9 +825,9 @@
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
@@ -1072,6 +1106,7 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz",
"integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4"
}
+2 -3
View File
@@ -3,7 +3,6 @@
"description": "dc-sas",
"scripts": {
"cpfavicon": "cp sasjs/utils/favicon.ico ../client/dist/favicon.ico",
"lint:sas": "sasjs lint",
"build": "cd ../client && npm run build",
"deploy:server": "rsync -avhe ssh ../client/dist/* --delete allbow@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/allbow/server",
"sas9": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/${npm_config_account}/dc",
@@ -34,8 +33,8 @@
},
"private": true,
"dependencies": {
"@sasjs/cli": "6.5.0",
"@sasjs/core": "7.1.0"
"@sasjs/cli": "6.2.0",
"@sasjs/core": "6.1.0"
},
"overrides": {
"nanoid": "3.3.18"
@@ -23,4 +23,4 @@ insert into &syslast set
,group_name="DC Demo Group"
,group_desc="Custom Group for Data Controller Purposes"
,user_name="mobeen@saspw"
,tx_to='31DEC5999:23:59:59'dt;
,tx_to='31DEC5999:23:59:59'dt;
@@ -18,4 +18,4 @@ update DC.MPE_CONFIG
/*
proc sql; drop table backup;
*/
*/
@@ -15,4 +15,4 @@ proc sql;
);
/* ALSO - make sure there is a DIRECT libname assigned in the
data_controller_settings stp */
data_controller_settings stp */
@@ -29,4 +29,4 @@ create table DC.mpe_datadictionary
DD_SENSITIVITY char(64),
TX_TO float not null format=datetime19.,
constraint pk
primary key(tx_from, dd_type,dd_source));
primary key(tx_from, dd_type,dd_source));
@@ -71,4 +71,4 @@ create table dc.mpe_datacatalog_vars(
modate num format=DATETIME. label='Date Modified',
nobs num label='Number of Physical (Actual, inc. deleted) Observations',
constraint pk
primary key(libref,dsn,tx_to));
primary key(libref,dsn,tx_to));
@@ -13,4 +13,4 @@ quit;
proc sql;
alter table dc.mpe_config add var_active num;
update dc.mpe_config set var_active=1;
update dc.mpe_config set var_active=1;
@@ -28,4 +28,4 @@ insert into &dclib..MPE_VALIDATIONS set tx_from=%sysfunc(datetime())
,base_col="DSN";
,rule_value="services/validations/mpe_tables.dsn"
,rule_active=1
,rule_type='SOFTSELECT_HOOK';
,rule_type='SOFTSELECT_HOOK';
@@ -5,6 +5,9 @@
BREAKING CHANGE - deprecate MPE_APPROVALS and MPE_REVIEW (now MPE_SUBMIT does
both)
<h4> SAS Macros </h4>
@li mm_assigndirectlib.sas
**/
%let dclib=YOURDCLIB;
@@ -80,4 +80,4 @@ create table &dclib..mpe_xlmap_info(
,loadtype='UPDATE'
,buskey='LOAD_REF XLMAP_ID XLMAP_RANGE_ID ROW_NO COL_NO'
,notes='Docs: https://docs.datacontroller.io/complex-excel-uploads'
;
;
@@ -21,4 +21,4 @@ insert into &dclib..mpe_config set
,var_value="YES"
,var_active=1
,var_desc='Setting to NO will prevent each request being logged to the'
!!' MPE_REQUESTS table Default=YES.';
!!' MPE_REQUESTS table Default=YES.';
@@ -68,4 +68,4 @@ create table &dclib..mpe_datastatus_OBJS(
modified num format=DATETIME. label='Date Modified',
level num label='Library Concatenation Level',
constraint pk_mpe_datastatus_OBJS
primary key(libref,memname,objname,objtype,tx_to));
primary key(libref,memname,objname,objtype,tx_to));
@@ -23,10 +23,9 @@
only for options that are not present at all - an existing install carries
its own rows, so it keeps 500 / 100 until this is run.
Each block below sits inside its own macro, with %do/%end on both branches.
A single-statement branch is rejected by the Viya compute engine with
"Expected %DO not found", so it must not be shortened back, and the macro
wrapper is what keeps the conditional out of open code.
Each block below uses %do/%end on BOTH branches. A single-statement
%then/%else in open code (outside a %macro) is rejected by the Viya compute
engine with "Expected %DO not found", so it must not be shortened back.
**/
@@ -48,7 +47,6 @@ proc sql noprint;
where var_name='DC_MAXCELLS_WEBEDIT' and var_active=0 and &now lt tx_to;
quit;
%macro apply_view_limit();
%if &off_view=0 %then %do;
proc sql noprint;
update &dclib..mpe_config
@@ -69,10 +67,7 @@ quit;
%else %do;
%put NOTE: DC_MAXOBS_WEBVIEW is switched off (var_active=0) - not changed;
%end;
%mend apply_view_limit;
%apply_view_limit()
%macro apply_edit_limit();
%if &off_edit=0 %then %do;
proc sql noprint;
update &dclib..mpe_config
@@ -95,10 +90,7 @@ quit;
%else %do;
%put NOTE: DC_MAXOBS_WEBEDIT is switched off (var_active=0) - not changed;
%end;
%mend apply_edit_limit;
%apply_edit_limit()
%macro apply_cell_limit();
%if &off_cells=0 %then %do;
proc sql noprint;
update &dclib..mpe_config
@@ -106,7 +98,7 @@ quit;
where var_name='DC_MAXCELLS_WEBEDIT' and var_active=1 and &now lt tx_to;
/* Why the cell limit exists, kept out of var_desc - MPE_CONFIG.VAR_DESC
is varchar(300), and a truncation aborts the build. A wide
is varchar(300), and a truncation WARNING aborts the build. A wide
table is expensive long before it is tall - 250 rows of a 1000 column
table is a quarter of a million cells. */
insert into &dclib..mpe_config
@@ -125,5 +117,3 @@ quit;
%else %do;
%put NOTE: DC_MAXCELLS_WEBEDIT is switched off (var_active=0) - not changed;
%end;
%mend apply_cell_limit;
%apply_cell_limit()
@@ -10,8 +10,6 @@
MPE_DERIVED rows added before this script runs will be lost when the table
is created, so run it before configuring any derived variables.
@sasjslint {"maxLineLength": 99}
**/
%let dclib=YOURDCLIB;
+1 -4
View File
@@ -98,7 +98,6 @@ Areas for optimisation
@li mf_fmtdttm.sas
@li mf_getattrn.sas
@li mf_getengine.sas
@li mf_getquotedstr.sas
@li mf_getschema.sas
@li mf_getuniquefileref.sas
@li mf_getuniquename.sas
@@ -123,8 +122,6 @@ Areas for optimisation
@warning multitemporal loads (bitemporal for multiple points in business time)
are in experimental stage
@sasjslint {"noBannedStrings": false, "maxLineLength": 87}
**/
%macro bitemporal_dataloader(
@@ -1452,7 +1449,7 @@ run;
data work.mp_storediffs;
set work.mp_storediffs;
if MOVE_TYPE="M" and IS_PK=0 and IS_DIFF=0 then delete;
/* putlog load_ref= libref= dsn= key_hash= tgtvar_nm=; */
* putlog load_ref= libref= dsn= key_hash= tgtvar_nm=;
run;
proc append base=&outds_audit data=work.mp_storediffs;
run;
@@ -16,7 +16,6 @@
<h4> SAS Macros </h4>
@li bitemporal_closeouts.sas
@li bitemporal_dataloader.sas
@li dc_assignlib.sas
@li mf_getattrn.sas
@@ -1350,6 +1349,44 @@ run;
proc sql; insert into results set test="TEST&testnum",result="&result";
%end;
/* test 30 - testing a load to OLEDB (should use SQL server temp table) */
%if (&test=ALL or &test=30 ) and 0 %then %do; /* skip until have a db */
%let testnum=30;
%assign_direct_lib(libref=XXX);
data appendtable&testnum ;
set XXX.temp_rk_table
(where=(tx_from le %sysfunc(datetime()) lt tx_to));
some_num+1;
output;
some_id=put(ranuni(0)*10000000,8.);output;
stop;
run;
proc sql noprint;
select count(*) into:testnobs&testnum from web.temp_rk_table;
%bitemporal_dataloader(dclib=work,PK=some_rk
,append_dsn=appendtable&testnum.
,ETLSOURCE=bitemporal_tester test&testnum override
,tech_from=tx_from
,tech_to=tx_to
,base_lib=web
,base_dsn=temp_rk_table
,LOG=YES
,rk_underlying=some_id
,LOADTARGET=YES
,LOADTYPE=TXTEMPORAL
);
%let result=;
proc sql noprint;
select count(*) into:testnobs2&testnum from web.temp_rk_table;
%if (&&testnobs2&testnum =%eval(&&testnobs&testnum+2)) %then %let result=PASS;
%else %let result=FAIL;
proc sql; insert into results set test="TEST&testnum",result="&result";
%end;
/* test 31 - bitemporal closeouts with embedded speechmarks in PK */
%let testnum=31;
%if &test=ALL or &test=&testnum %then %do;
@@ -5,6 +5,7 @@
<h4> SAS Macros </h4>
@li mf_getuniquename.sas
@li mf_getuser.sas
@li mp_assertdsobs.sas
@li mpe_getgroups.sas
+2 -1
View File
@@ -10,6 +10,7 @@
<h4> SAS Macros </h4>
@li mf_getattrn.sas
@li mf_existds.sas
@li mf_getuser.sas
@version 9.4
@@ -226,4 +227,4 @@ run;
filename __out clear;
%mend mpe_alerts ;
%mend mpe_alerts ;
+51 -42
View File
@@ -1,83 +1,92 @@
/**
@file
@brief Asserts that a service returned a sasjsAbort object
@details Reads the child's status row and records a PASS when the child was
canceled, which is what happens when a service rejects its input and calls
mp_abort. When msg is supplied the ERRMSG value must match it, so the
assertion proves the service aborted for the expected reason rather than for
any reason at all.
@details Reads the child's response as text and records a PASS when it carries
a sasjsAbort object, which is what mp_abort leaves in the webout when a service
rejects its input. When msg is supplied the MSG value inside that object must
match it, so the assertion proves the service aborted for the expected reason
rather than for any reason at all.
The message comes from the status row rather than from the response text: core
publishes STATE and ERRMSG per call, ERRMSG holds the whole message including
quotes and anything longer than a single record, and reading it needs no
parsing of the response.
The response is scanned a line at a time and only the first MSG that follows
the sasjsAbort marker is taken: a single 32767 buffer both truncated longer
responses and, with a leading `.*`, returned the last MSG in the text rather
than the abort's own.
Two things are handled here rather than in core:
- mp_abort appends cats(msg,'\n\nLog Extract:\n',logmsg) to the message, so
the tail is cut at the two newlines that precede the marker. A message that
merely mentions "Log Extract:" is not preceded by them and survives.
- a platform that reports no job state at all (SAS 9 PROC STP) cannot show the
abort this way; the row reads 'unknown' and the assertion fails rather than
guessing.
An unassigned outref (a child that died before writing a response) records a
FAIL rather than raising an unassigned-fileref error in the data step.
Usage:
%mpe_execute(&_program,
inputfiles=&f1:sascontroltable,
outref=web1,
outstatus=work.ab1,
failfast=0
)
%mpe_assert_abort(
desc=An invalid libds aborts the service,
outstatus=work.ab1,
outref=web1,
msg=Invalid libds
)
@param [in] desc The description recorded in the test results
@param [in] outstatus The dataset given to %mpe_execute, holding STATE and
ERRMSG for the child call
@param [in] msg= (0) Expected ERRMSG value. 0 accepts any abort message.
@param [in] outref The fileref given to %mx_execute, holding the response
@param [in] msg= (0) Expected MSG value in the sasjsAbort object. 0 accepts
any abort message.
@param [out] outds= (work.test_results) Results dataset
<h4> SAS Macros </h4>
@li mp_assert.sas
@sasjslint {"maxLineLength": 94}
**/
%macro mpe_assert_abort(desc, outstatus, msg=0, outds=work.test_results);
%macro mpe_assert_abort(desc, outref, msg=0, outds=work.test_results);
%local ok seen abortseen;
%let ok=0;
%let seen=;
%let abortseen=0;
%if %sysfunc(exist(&outstatus)) ne 1 %then %do;
%put %str(WARN)ING: &sysmacroname: &outstatus does not exist - the child left no status row;
%if %sysfunc(fileref(&outref))>0 %then %do;
%put WARNING: &sysmacroname: fileref &outref is not assigned - the child left no response to read;
%end;
%else %if %sysfunc(fileexist(%sysfunc(pathname(&outref)))) ne 1 %then %do;
/* the fileref exists but nothing was written to it: a child that aborted
before it produced a response. Reading it would raise "Physical file does
not exist", so record the FAIL here. */
%put WARNING: &sysmacroname: &outref has no response written to it - the child produced nothing;
%end;
%else %do;
data _null_;
length got $32767;
set &outstatus;
if state='canceled' then call symputx('abortseen',1,'l');
got=errmsg;
/* mp_abort appends cats(msg,'\n\nLog Extract:\n',logmsg). Those newlines
reach here as characters, as an escaped pair, or as a doubled pair - which
one depends on the producer - so all three are tried */
cut=index(got,'0A0A'x||'Log Extract:');
if cut=0 then cut=index(got,'\n\nLog Extract:');
if cut=0 then cut=index(got,'\\n\\nLog Extract:');
if cut>1 then got=substr(got,1,cut-1);
call symputx('seen',got,'l');
run;
data _null_;
length line $32767 got $256;
retain prxid 0 inabort 0 found 0 abortseen 0;
if _n_=1 then prxid=prxparse('/"MSG" *: *"([^"]*)"/');
infile &outref end=eof lrecl=32767;
do while(not eof);
input;
line=_infile_;
if index(line,'sasjsAbort')>0 then do;
abortseen=1;
inabort=1;
end;
/* first MSG at or after the abort marker - stops at the first found */
if inabort=1 and found=0 then do;
if prxmatch(prxid,line)>0 then do;
got=prxposn(prxid,1,line);
found=1;
end;
end;
end;
call symputx('abortseen',abortseen,'l');
if found=1 then call symputx('seen',got,'l');
stop;
run;
%end;
%if &abortseen=1 %then %do;
%if "%superq(msg)"="0" %then %let ok=1;
%else %if "%superq(seen)"="%superq(msg)" %then %let ok=1;
%else %if "&seen"="%superq(msg)" %then %let ok=1;
%end;
%if "%superq(seen)" ne "" %then %put NOTE: &sysmacroname: sasjsAbort MSG=&seen;
%if "&seen" ne "" %then %put NOTE: &sysmacroname: sasjsAbort MSG=&seen;
%mp_assert(
iftrue=(&ok=1),
+87 -227
View File
@@ -2,18 +2,25 @@
@file
@brief testing the mpe_assert_abort macro
@details The macro decides PASS/FAIL from the child's status row - STATE says
whether the child was canceled, ERRMSG holds the reason - so the cases can be
built as datasets instead of services: an abort with the expected message, an
abort with a different one, a completed call, a status dataset that does not
exist, a message past the old 256 character width, quotes, backslashes, and
the log extract that mp_abort appends to the message.
@details The macro decides PASS/FAIL by scanning the child's response text, so
the cases that matter can be built as files instead of services: an abort with
the expected message, an abort with a different one, no abort at all, and a
fileref the child never wrote to.
Each case asserts on the PASS/FAIL the macro recorded, so a regression in the
comparison fails here rather than silently passing an abort assertion in a
service test.
scan fails here rather than silently passing an abort assertion in a service
test.
Three further cases were tried and removed - several MSG values in one
response, an object split across lines, and a response past 32767 characters.
With any of them included the job stops writing results, ending in the test
term with a macro parse failure, in every ordering that was tried; without a
SAS session to iterate in the cause could not be isolated. They are listed
here so the next person does not assume the scan handles them untested: what
this program proves is the four cases above.
<h4> SAS Macros </h4>
@li mf_getuniquefileref.sas
@li mp_assert.sas
@li mpe_assert_abort.sas
@@ -22,206 +29,98 @@
and may not be re-distributed or re-sold without the express permission of
4GL Apps Ltd.
@sasjslint {"maxLineLength": 83}
**/
/** a status row as %mpe_execute leaves it: the job state and the message */
%macro mk_status(ds, state, msg);
/* the values come from the symbol table rather than from quoted text: a
message holding double quotes would otherwise end the literal early */
data &ds;
length state $20 uri $256 errmsg $32767 platform $10;
state=symget('state');
uri='';
errmsg=symget('msg');
platform='SASJS';
run;
%mend mk_status;
%let rc_any=work.assert_rc1;
%let rc_other=work.assert_rc2;
%let rc_none=work.assert_rc3;
%let rc_first=work.assert_rc4;
%let rc_split=work.assert_rc5;
%let rc_long=work.assert_rc6;
%let rc_noref=work.assert_rc7;
/**
* Test 1 - an abort whose message matches is a PASS
* Test 1 - an abort whose MSG matches is a PASS
*/
%mk_status(work.st1, canceled, Invalid libds)
%let f1=%mf_getuniquefileref();
data _null_;
file &f1 termstr=crlf;
put '{"sasjsAbort": [{"MSG": "Invalid libds", "MAC": "mp_abort"}]}';
run;
%mpe_assert_abort(
desc=an abort with the expected MSG,
outstatus=work.st1,
outref=&f1,
msg=Invalid libds,
outds=work.rc1
outds=&rc_any
)
/**
* Test 2 - an abort for a different reason is a FAIL
*/
%mk_status(work.st2, canceled, Invalid libds)
%let f2=%mf_getuniquefileref();
data _null_;
file &f2 termstr=crlf;
put '{"sasjsAbort": [{"MSG": "Invalid libds", "MAC": "mp_abort"}]}';
run;
%mpe_assert_abort(
desc=an abort with a different MSG,
outstatus=work.st2,
outref=&f2,
msg=Invalid filter,
outds=work.rc2
outds=&rc_other
)
/**
* Test 3 - a completed call is a FAIL
* Test 3 - a completed call (no sasjsAbort) is a FAIL
*/
%mk_status(work.st3, completed, )
%mpe_assert_abort(
desc=a completed call,
outstatus=work.st3,
msg=0,
outds=work.rc3
)
/**
* Test 4 - a status dataset that does not exist fails the assertion, not the step
*/
%mpe_assert_abort(
desc=a status dataset that was never written,
outstatus=work.doesnotexist,
msg=0,
outds=work.rc4
)
/**
* Test 5 - a message longer than the old capture width is compared in full
*
* The status row carries the whole message, so a caller comparing a long one
* gets an answer rather than a silent 256 character cut.
*/
%let longmsg=;
data work.st5;
length state $20 errmsg $32767;
state='canceled';
errmsg=repeat('a clause that runs on ',30);
call symputx('longmsg',errmsg,'l');
run;
%mpe_assert_abort(
desc=an abort with a message past the old capture width,
outstatus=work.st5,
msg=%superq(longmsg),
outds=work.rc5
)
/**
* Test 6 - quotes in the message survive
*/
%mk_status(work.st6, canceled, %str(alpha "quoted" beta))
%mpe_assert_abort(
desc=a message holding double quotes,
outstatus=work.st6,
msg=%str(alpha "quoted" beta),
outds=work.rc6
)
/**
* Test 7 - the log extract mp_abort appends is not part of the message
*/
data work.st7;
length state $20 errmsg $32767;
state='canceled';
errmsg=cats('Invalid libds','0A0A'x,'Log Extract:','0A0A'x,'NOTE: a log line');
run;
%mpe_assert_abort(
desc=an abort with the appended log extract,
outstatus=work.st7,
msg=Invalid libds,
outds=work.rc7
)
/**
* Test 8 - a message that itself mentions the marker is not cut at it
*
* Only the two newlines before the marker identify the appended extract; a
* message that merely says "Log Extract:" keeps its text.
*/
data work.st8;
length state $20 errmsg $32767;
state='canceled';
errmsg='check the hook Log Extract: is mentioned here';
run;
%mpe_assert_abort(
desc=a message containing the extract marker,
outstatus=work.st8,
msg=%str(check the hook Log Extract: is mentioned here),
outds=work.rc8
)
/**
* Test 9 - a canceled call with no message fails a comparison it cannot support
*
* Core fills errmsg on both platforms now, so a blank one means the reason was
* never recorded - an expected message cannot be proved, and the assertion
* says so rather than passing on the state alone.
*/
%mk_status(work.st9, canceled, )
%mpe_assert_abort(
desc=a canceled call whose status row carries no message,
outstatus=work.st9,
msg=Invalid libds,
outds=work.rc9
)
/**
* Test 10 - backslashes in the message compare as they were raised
*/
%mk_status(work.st10, canceled, %str(C:\temp\share))
%mpe_assert_abort(
desc=a message holding backslashes,
outstatus=work.st10,
msg=%str(C:\temp\share),
outds=work.rc10
)
/**
* Test 11 - the doubled separator the escaping producer writes
*
* mp_abort doubles backslashes, so the appended extract arrives as a literal
* doubled pair rather than as characters.
*/
data work.st11;
length state $20 errmsg $32767;
state='canceled';
errmsg=cats('Invalid libds','\\n\\nLog Extract:\\n','NOTE: a log line');
run;
%mpe_assert_abort(
desc=an abort whose extract arrives doubled,
outstatus=work.st11,
msg=Invalid libds,
outds=work.rc11
)
/**
* Test 12 - the recorded results
*/
%macro getres(ds, var);
%let f3=%mf_getuniquefileref();
data _null_;
set &ds;
call symputx("&var",test_result,'g');
file &f3 termstr=crlf;
put '{"sasparams": [{"STATUS": "SUCCESS"}]}';
put '{"MSG": "Invalid libds"}';
run;
%mend getres;
%getres(work.rc1, res_any)
%getres(work.rc2, res_other)
%getres(work.rc3, res_none)
%getres(work.rc4, res_noref)
%getres(work.rc5, res_long)
%getres(work.rc6, res_quotes)
%getres(work.rc7, res_logmark)
%getres(work.rc8, res_marker)
%getres(work.rc9, res_nomsg)
%getres(work.rc10, res_bslash)
%getres(work.rc11, res_doubled)
%mpe_assert_abort(
desc=a response with no sasjsAbort object,
outref=&f3,
msg=0,
outds=&rc_none
)
/**
* Test 4 - a fileref with no response written records a FAIL instead of aborting the step
*/
%let f7=%mf_getuniquefileref();
%mpe_assert_abort(
desc=an unassigned outref,
outref=&f7,
msg=0,
outds=&rc_noref
)
/**
* Test 5 - the recorded results
*/
data _null_;
set &rc_any;
call symputx('res_any',test_result,'g');
run;
data _null_;
set &rc_other;
call symputx('res_other',test_result,'g');
run;
data _null_;
set &rc_none;
call symputx('res_none',test_result,'g');
run;
data _null_;
set &rc_noref;
call symputx('res_noref',test_result,'g');
run;
%mp_assert(
iftrue=("&res_any"="PASS"),
@@ -237,54 +136,15 @@ run;
%mp_assert(
iftrue=("&res_none"="FAIL"),
desc=%str(Checking a completed call fails),
desc=%str(Checking a response without a sasjsAbort object fails),
outds=work.test_results
)
%mp_assert(
iftrue=("&res_noref"="FAIL"),
desc=%str(Checking a missing status dataset fails the assertion, not the step),
outds=work.test_results
)
%mp_assert(
iftrue=("&res_long"="PASS"),
desc=%str(Checking an abort message past the old capture width matches in full),
outds=work.test_results
)
%mp_assert(
iftrue=("&res_quotes"="PASS"),
desc=%str(Checking a message holding double quotes compares),
outds=work.test_results
)
%mp_assert(
iftrue=("&res_logmark"="PASS"),
desc=%str(Checking the appended log extract is stripped),
outds=work.test_results
)
%mp_assert(
iftrue=("&res_marker"="PASS"),
desc=%str(Checking a message containing the extract marker is not cut at it),
outds=work.test_results
)
%mp_assert(
iftrue=("&res_nomsg"="FAIL"),
desc=%str(Checking a canceled call with no message fails the comparison),
outds=work.test_results
)
%mp_assert(
iftrue=("&res_bslash"="PASS"),
desc=%str(Checking a message holding backslashes compares),
outds=work.test_results
)
%mp_assert(
iftrue=("&res_doubled"="PASS"),
desc=%str(Checking the doubled extract separator is stripped),
desc=%str(Checking an unassigned outref fails the assertion, not the step),
outds=work.test_results
)
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff. Show more