Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4ec4ec629 |
No files matched your search
@@ -1,11 +1,10 @@
|
|||||||
# Testing
|
# 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
|
## Commands
|
||||||
|
|
||||||
- Full deploy + test cycle: `npm run 4gl` (compiles, deploys to the 4gl target, runs makedata) then `sasjs test -t 4gl`.
|
- 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 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`).
|
- 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`).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -20,16 +20,6 @@ if ! "$gitleaks_bin" protect --staged --redact; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
## Avoid large commits
|
||||||
# https://www.backblaze.com/blog/how-many-bytes-are-in-a-megabyte-really/
|
# https://www.backblaze.com/blog/how-many-bytes-are-in-a-megabyte-really/
|
||||||
size_limit=$((2 * 1024 * 1024)) # 2MB
|
size_limit=$((2 * 1024 * 1024)) # 2MB
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -3,8 +3,6 @@ run-name: Running Lint Check and Licence checker on Pull Request
|
|||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# the server the mocked services and app tests run against - bump deliberately
|
|
||||||
SASJS_SERVER_VERSION: '3.6.2'
|
|
||||||
NODE_VERSION: '24.15.0'
|
NODE_VERSION: '24.15.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -13,11 +11,6 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
@@ -29,8 +22,9 @@ jobs:
|
|||||||
# credential must fail the build, not just a commit.
|
# credential must fail the build, not just a commit.
|
||||||
# Uses the same @nogoo9/gitleaks binary the pre-commit hook pins - the
|
# Uses the same @nogoo9/gitleaks binary the pre-commit hook pins - the
|
||||||
# gitleaks-action requires a license key for organization repos.
|
# gitleaks-action requires a license key for organization repos.
|
||||||
# Runs before the registry config is written, so the scan cannot see
|
# Runs before client/.npmrc is written below, because that file is
|
||||||
# the token this job injects.
|
# 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
|
# --no-git scans the working tree, which is what a pull request gate
|
||||||
# is about.
|
# is about.
|
||||||
run: |
|
run: |
|
||||||
@@ -44,11 +38,7 @@ jobs:
|
|||||||
apt install -y ./google-chrome*.deb
|
apt install -y ./google-chrome*.deb
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: echo "$NPMRC" >> client/.npmrc
|
||||||
# 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"
|
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
@@ -61,13 +51,7 @@ jobs:
|
|||||||
gpg --batch --yes --passphrase-fd 0 \
|
gpg --batch --yes --passphrase-fd 0 \
|
||||||
--output ./libraries/sheet-crypto.tgz \
|
--output ./libraries/sheet-crypto.tgz \
|
||||||
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||||
# dependency hooks stay off (client/.npmrc); the client's own
|
npm ci
|
||||||
# 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
|
|
||||||
|
|
||||||
- name: Check audit
|
- name: Check audit
|
||||||
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
|
# 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
|
cd ../client
|
||||||
npm audit --omit=dev
|
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
|
- name: Lint check
|
||||||
run: npm run 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
|
- name: Licence checker
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
@@ -109,38 +75,11 @@ jobs:
|
|||||||
cd client
|
cd client
|
||||||
npm run test:headless
|
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
|
- name: Production Build
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
npm run build
|
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:
|
Build-and-test-development:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: Build-and-ng-test
|
needs: Build-and-ng-test
|
||||||
@@ -152,24 +91,14 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
# the secret arrives through the environment, not interpolated into
|
touch client/.npmrc
|
||||||
# this script: a quote or $( in the value would otherwise break the
|
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||||
# step or execute
|
|
||||||
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
|
||||||
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
|
||||||
env:
|
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -192,13 +121,7 @@ jobs:
|
|||||||
cd client
|
cd client
|
||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
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
|
npm ci
|
||||||
# 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
|
|
||||||
|
|
||||||
- name: Build frontend for web streaming
|
- name: Build frontend for web streaming
|
||||||
# The server-ci target streams client/dist as the 'clickme' web app (see
|
# The server-ci target streams client/dist as the 'clickme' web app (see
|
||||||
@@ -209,21 +132,11 @@ jobs:
|
|||||||
cd client
|
cd client
|
||||||
npm run build
|
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
|
- name: Setup and start SASjs server
|
||||||
run: |
|
run: |
|
||||||
npm i -g pm2
|
npm i -g pm2
|
||||||
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
|
unzip linux.zip
|
||||||
touch .env
|
touch .env
|
||||||
echo RUN_TIMES=js >> .env
|
echo RUN_TIMES=js >> .env
|
||||||
echo NODE_PATH=node >> .env
|
echo NODE_PATH=node >> .env
|
||||||
@@ -235,10 +148,11 @@ jobs:
|
|||||||
# in a throwaway container, so opt in explicitly.
|
# in a throwaway container, so opt in explicitly.
|
||||||
echo RUN_AS=root >> .env
|
echo RUN_AS=root >> .env
|
||||||
cat .env
|
cat .env
|
||||||
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
|
pm2 start api-linux --wait-ready
|
||||||
# `pm2 start` returns before the app is listening, and the next step
|
# `pm2 start --wait-ready` can return while the app is still
|
||||||
# hits :5000 immediately. Wait for the server to answer, and dump its
|
# launching (it does not always wait for the ready signal), and the
|
||||||
# log if it never does.
|
# 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
|
for i in $(seq 1 60); do
|
||||||
if curl -sf -o /dev/null http://localhost:5000/; then
|
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||||
echo "SASjs server answered on attempt ${i}"
|
echo "SASjs server answered on attempt ${i}"
|
||||||
@@ -248,7 +162,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "SASjs server did not come up within 120s"
|
echo "SASjs server did not come up within 120s"
|
||||||
pm2 list
|
pm2 list
|
||||||
pm2 logs sasjs-server --lines 100 --nostream
|
pm2 logs api-linux --lines 100 --nostream
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Deploy mocked services
|
- 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
|
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
|
||||||
|
|
||||||
- name: Prepare and run frontend and cypress
|
- 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
|
timeout-minutes: 50
|
||||||
run: |
|
run: |
|
||||||
cd ./client
|
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
|
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='"username".*' --replacement='"username":"'${{ secrets.CYPRESS_USERNAME_SASJS }}'",' ./cypress.env.json
|
||||||
replace-in-files --regex='"password".*' --replacement='"password":"'${{ secrets.CYPRESS_PWD_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
|
# Start frontend and run cypress
|
||||||
# timeout 2700: SIGTERM after 45 min so Cypress can flush video/screenshots
|
# 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)
|
# 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
|
- name: Zip Cypress videos
|
||||||
# Only on a failure: a green run has nothing to watch, and zipping and
|
if: always()
|
||||||
# uploading every run's videos is the part of the video cost that lands
|
|
||||||
# on the clock.
|
|
||||||
if: failure()
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./client/cypress/videos
|
mkdir -p ./client/cypress/videos
|
||||||
zip -r cypress-videos ./client/cypress/videos
|
zip -r cypress-videos ./client/cypress/videos
|
||||||
|
|
||||||
- name: Add cypress videos artifacts
|
- name: Add cypress videos artifacts
|
||||||
if: failure()
|
if: always()
|
||||||
# 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.
|
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: cypress-videos.zip
|
name: cypress-videos.zip
|
||||||
path: 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"
|
|
||||||
@@ -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}"
|
|
||||||
@@ -3,8 +3,6 @@ run-name: Running Lighthouse Performance and Accessibility Checks on Pull Reques
|
|||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# the server the mocked services and app tests run against - bump deliberately
|
|
||||||
SASJS_SERVER_VERSION: '3.6.2'
|
|
||||||
NODE_VERSION: '24.15.0'
|
NODE_VERSION: '24.15.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -14,12 +12,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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 }}
|
- name: Use Node.js ${{ env.NODE_VERSION }}
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -34,18 +26,6 @@ jobs:
|
|||||||
- name: Install global packages
|
- name: Install global packages
|
||||||
run: npm i -g pm2 @sasjs/cli wait-on
|
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
|
- name: Setup and start SASjs server
|
||||||
run: |
|
run: |
|
||||||
touch .env
|
touch .env
|
||||||
@@ -59,10 +39,13 @@ jobs:
|
|||||||
# in a throwaway container, so opt in explicitly.
|
# in a throwaway container, so opt in explicitly.
|
||||||
echo RUN_AS=root >> .env
|
echo RUN_AS=root >> .env
|
||||||
cat .env
|
cat .env
|
||||||
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
# `pm2 start` returns before the app is listening, and the next step
|
unzip linux.zip
|
||||||
# hits :5000 immediately. Wait for the server to answer, and dump its
|
pm2 start api-linux --wait-ready
|
||||||
# log if it never does.
|
# `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
|
for i in $(seq 1 60); do
|
||||||
if curl -sf -o /dev/null http://localhost:5000/; then
|
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||||
echo "SASjs server answered on attempt ${i}"
|
echo "SASjs server answered on attempt ${i}"
|
||||||
@@ -72,15 +55,11 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "SASjs server did not come up within 120s"
|
echo "SASjs server did not come up within 120s"
|
||||||
pm2 list
|
pm2 list
|
||||||
pm2 logs sasjs-server --lines 100 --nostream
|
pm2 logs api-linux --lines 100 --nostream
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: echo "$NPMRC" > client/.npmrc
|
||||||
# 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"
|
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
@@ -94,9 +73,6 @@ jobs:
|
|||||||
--output ./libraries/sheet-crypto.tgz \
|
--output ./libraries/sheet-crypto.tgz \
|
||||||
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
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
|
npm install -g replace-in-files-cli
|
||||||
|
|
||||||
- name: Update appLoc in index.html
|
- name: Update appLoc in index.html
|
||||||
@@ -133,8 +109,3 @@ jobs:
|
|||||||
name: Lighthouse results
|
name: Lighthouse results
|
||||||
path: client/lighthouse-reports
|
path: client/lighthouse-reports
|
||||||
include-hidden-files: true
|
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"
|
|
||||||
@@ -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
@@ -6,8 +6,6 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# the server the mocked services and app tests run against - bump deliberately
|
|
||||||
SASJS_SERVER_VERSION: '3.6.2'
|
|
||||||
NODE_VERSION: '24.5.0'
|
NODE_VERSION: '24.5.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -18,24 +16,14 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
# the secret arrives through the environment, not interpolated into
|
touch client/.npmrc
|
||||||
# this script: a quote or $( in the value would otherwise break the
|
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||||
# step or execute
|
|
||||||
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
|
||||||
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
|
||||||
env:
|
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
|
||||||
|
|
||||||
- name: Install Chrome for Angular tests
|
- name: Install Chrome for Angular tests
|
||||||
run: |
|
run: |
|
||||||
@@ -55,9 +43,6 @@ jobs:
|
|||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
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
|
- name: Check audit
|
||||||
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
|
# 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: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
npm run postinstall
|
npm run postinstall
|
||||||
npm run check:archiver
|
|
||||||
npm run build
|
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:
|
Build-and-test-development:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: Build-production-and-ng-test
|
needs: Build-production-and-ng-test
|
||||||
@@ -96,23 +75,14 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
# the secret arrives through the environment, not interpolated into
|
touch client/.npmrc
|
||||||
# this script: a quote or $( in the value would otherwise break the
|
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||||
# step or execute
|
|
||||||
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
|
||||||
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
|
||||||
env:
|
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -136,35 +106,12 @@ jobs:
|
|||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
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
|
- name: Setup and start SASjs server
|
||||||
run: |
|
run: |
|
||||||
npm i -g pm2
|
npm i -g pm2
|
||||||
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
|
unzip linux.zip
|
||||||
touch .env
|
touch .env
|
||||||
echo RUN_TIMES=js >> .env
|
echo RUN_TIMES=js >> .env
|
||||||
echo NODE_PATH=node >> .env
|
echo NODE_PATH=node >> .env
|
||||||
@@ -176,10 +123,11 @@ jobs:
|
|||||||
# in a throwaway container, so opt in explicitly.
|
# in a throwaway container, so opt in explicitly.
|
||||||
echo RUN_AS=root >> .env
|
echo RUN_AS=root >> .env
|
||||||
cat .env
|
cat .env
|
||||||
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
|
pm2 start api-linux --wait-ready
|
||||||
# `pm2 start` returns before the app is listening, and the next step
|
# `pm2 start --wait-ready` can return while the app is still
|
||||||
# hits :5000 immediately. Wait for the server to answer, and dump its
|
# launching (it does not always wait for the ready signal), and the
|
||||||
# log if it never does.
|
# 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
|
for i in $(seq 1 60); do
|
||||||
if curl -sf -o /dev/null http://localhost:5000/; then
|
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||||
echo "SASjs server answered on attempt ${i}"
|
echo "SASjs server answered on attempt ${i}"
|
||||||
@@ -189,7 +137,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "SASjs server did not come up within 120s"
|
echo "SASjs server did not come up within 120s"
|
||||||
pm2 list
|
pm2 list
|
||||||
pm2 logs sasjs-server --lines 100 --nostream
|
pm2 logs api-linux --lines 100 --nostream
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Deploy mocked services
|
- name: Deploy mocked services
|
||||||
@@ -216,9 +164,6 @@ jobs:
|
|||||||
replace-in-files --regex='"password".*' --replacement='"password":"'${{ secrets.CYPRESS_PWD_SASJS }}'" ' ./cypress.env.json
|
replace-in-files --regex='"password".*' --replacement='"password":"'${{ secrets.CYPRESS_PWD_SASJS }}'" ' ./cypress.env.json
|
||||||
cat ./cypress.env.json
|
cat ./cypress.env.json
|
||||||
npm run postinstall
|
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
|
# Prepare index.html to SASJS local
|
||||||
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
|
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
|
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/dc"' ./src/index.html
|
||||||
@@ -241,11 +186,6 @@ jobs:
|
|||||||
name: cypress-videos.zip
|
name: cypress-videos.zip
|
||||||
path: 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:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [Build-production-and-ng-test, Build-and-test-development]
|
needs: [Build-production-and-ng-test, Build-and-test-development]
|
||||||
@@ -266,19 +206,14 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
echo "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
echo "$NPMRC" > client/.npmrc
|
||||||
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
|
echo "legacy-peer-deps=true" >> client/.npmrc
|
||||||
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
@@ -296,9 +231,6 @@ jobs:
|
|||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
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 i webpack
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
@@ -339,11 +271,6 @@ jobs:
|
|||||||
|
|
||||||
semantic-release
|
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
|
- name: Frontend Build
|
||||||
description: Must be created AFTER the release as the version (git tag) is used in the interface
|
description: Must be created AFTER the release as the version (git tag) is used in the interface
|
||||||
run: |
|
run: |
|
||||||
@@ -404,6 +331,19 @@ jobs:
|
|||||||
cd ../client/dist
|
cd ../client/dist
|
||||||
zip -r ../../frontend.zip .
|
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
|
- name: Upload assets to release
|
||||||
env:
|
env:
|
||||||
# Use the same ephemeral per-job Gitea Actions token that the
|
# Use the same ephemeral per-job Gitea Actions token that the
|
||||||
@@ -418,7 +358,7 @@ jobs:
|
|||||||
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
|
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
|
||||||
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
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_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
|
||||||
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
|
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
|
||||||
|
|
||||||
@@ -452,7 +392,7 @@ jobs:
|
|||||||
VERIFY_SECTION=$(./.gitea/scripts/verify-section.sh)
|
VERIFY_SECTION=$(./.gitea/scripts/verify-section.sh)
|
||||||
NEW_BODY=$(jq -nr --arg body "$RELEASE_BODY" --arg verify "$VERIFY_SECTION" \
|
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/"')
|
'$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 "$AUTH_HEADER" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
--data "$(jq -n --arg body "$NEW_BODY" '{draft:false, body:$body}')" \
|
--data "$(jq -n --arg body "$NEW_BODY" '{draft:false, body:$body}')" \
|
||||||
@@ -462,37 +402,10 @@ jobs:
|
|||||||
URL="$BASE/releases/$RELEASE_ID/assets"
|
URL="$BASE/releases/$RELEASE_ID/assets"
|
||||||
for f in "${ASSETS[@]}"; do
|
for f in "${ASSETS[@]}"; do
|
||||||
echo "Uploading $f ..."
|
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
|
done
|
||||||
echo "Uploading SHA256SUMS ..."
|
echo "Uploading SHA256SUMS ..."
|
||||||
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
|
curl -k --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"
|
|
||||||
|
|
||||||
- name: Publish the demo site for this release
|
- name: Publish the demo site for this release
|
||||||
# The tag is created by this job's semantic-release step, so it carries
|
# 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}"
|
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
|
||||||
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
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}"
|
echo "Dispatching Sites for ${TAG}"
|
||||||
|
|
||||||
# the dispatch endpoint resolves a bare name as a branch; a tag needs
|
# the dispatch endpoint resolves a bare name as a branch; a tag needs
|
||||||
# the refs/tags/ prefix
|
# the refs/tags/ prefix
|
||||||
curl --fail-with-body -sS -X POST \
|
curl -k --fail-with-body -sS -X POST \
|
||||||
-H "$AUTH_HEADER" \
|
-H "$AUTH_HEADER" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
|
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
|
||||||
"$BASE/actions/workflows/sites.yaml/dispatches"
|
"$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"
|
|
||||||
@@ -10,8 +10,6 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# the server the mocked services and app tests run against - bump deliberately
|
|
||||||
SASJS_SERVER_VERSION: '3.6.2'
|
|
||||||
NODE_VERSION: '24.15.0'
|
NODE_VERSION: '24.15.0'
|
||||||
APP_LOC: /Public/app/dc
|
APP_LOC: /Public/app/dc
|
||||||
SURFER_HOST: https://demo.datacontroller.io
|
SURFER_HOST: https://demo.datacontroller.io
|
||||||
@@ -38,23 +36,14 @@ jobs:
|
|||||||
# The tag is what the client's build stamp describes (src/version.ts
|
# 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.
|
# runs git-describe), so the demo build needs the tags in the clone.
|
||||||
fetch-depth: 0
|
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
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
# the secret arrives through the environment, not interpolated into
|
touch client/.npmrc
|
||||||
# this script: a quote or $( in the value would otherwise break the
|
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||||
# step or execute
|
|
||||||
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
|
||||||
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
|
||||||
env:
|
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -62,9 +51,6 @@ jobs:
|
|||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
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
|
- name: Build frontend
|
||||||
# The site serves this build. `sasjs build` rewrites its index.html to
|
# The site serves this build. `sasjs build` rewrites its index.html to
|
||||||
@@ -80,29 +66,18 @@ jobs:
|
|||||||
cd sas
|
cd sas
|
||||||
npm ci
|
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
|
- name: Start SASjs server
|
||||||
# Needed only to run makedata: the drive the site ships with is captured
|
# Needed only to run makedata: the drive the site ships with is captured
|
||||||
# from a real server (see the snapshot step below).
|
# from a real server (see the snapshot step below).
|
||||||
run: |
|
run: |
|
||||||
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
|
unzip -q linux.zip
|
||||||
touch .env
|
touch .env
|
||||||
echo RUN_TIMES=js >> .env
|
echo RUN_TIMES=js >> .env
|
||||||
echo NODE_PATH=node >> .env
|
echo NODE_PATH=node >> .env
|
||||||
echo HTTP_PORT=5000 >> .env
|
|
||||||
# The server refuses to run as root unless RUN_AS names an account
|
# The server refuses to run as root unless RUN_AS names an account
|
||||||
echo RUN_AS=root >> .env
|
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
|
for i in $(seq 1 60); do
|
||||||
if curl -sf -o /dev/null http://localhost:5000/; then
|
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||||
echo "SASjs server answered on attempt ${i}"
|
echo "SASjs server answered on attempt ${i}"
|
||||||
@@ -205,8 +180,3 @@ jobs:
|
|||||||
--message "$MESSAGE" \
|
--message "$MESSAGE" \
|
||||||
"${SITE_ARGS[@]}" \
|
"${SITE_ARGS[@]}" \
|
||||||
sas/mocks/sasjsbuild/mock
|
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"
|
|
||||||
@@ -20,6 +20,7 @@ sasjsbuild
|
|||||||
sasjsresults
|
sasjsresults
|
||||||
.env*
|
.env*
|
||||||
.sasjsrc
|
.sasjsrc
|
||||||
|
client/.npmrc
|
||||||
*~
|
*~
|
||||||
.lighthouseci
|
.lighthouseci
|
||||||
audit.log
|
audit.log
|
||||||
|
|||||||
@@ -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:
|
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`.
|
- **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.
|
- **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).
|
||||||
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.
|
|
||||||
|
|
||||||
## No external assets
|
## 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/`.
|
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
|
## Code comments and test names
|
||||||
|
|
||||||
|
|||||||
-164
@@ -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)
|
# [7.18.0](https://git.datacontroller.io/dc/dc/compare/v7.17.0...v7.18.0) (2026-10-19)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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/`.
|
- `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/`.
|
- `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
|
## Roles
|
||||||
|
|
||||||
|
|||||||
+2
-25
@@ -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)
|
[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
|
To auth for SheetJS Pro version you need to use their private registry
|
||||||
the repo - `client/.npmrc` is tracked and holds policy only (see below). Put the registry auth in
|
Add `client/.npmrc` file with following content:
|
||||||
your user config, `~/.npmrc`:
|
|
||||||
```
|
```
|
||||||
@sheet:registry=https://pylon.sheetjs.com:54111/
|
@sheet:registry=https://pylon.sheetjs.com:54111/
|
||||||
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
|
//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
|
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||||
|
|
||||||
npm i
|
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)
|
[Handsontable](https://www.npmjs.com/package/handsontable)
|
||||||
|
|
||||||
The licence key comes from the backend licence: the decrypted licence carries `hot_license_key`,
|
The licence key comes from the backend licence: the decrypted licence carries `hot_license_key`,
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import fs from 'fs'
|
|
||||||
import { defineConfig } from 'cypress'
|
import { defineConfig } from 'cypress'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -35,65 +34,7 @@ export default defineConfig({
|
|||||||
|
|
||||||
e2e: {
|
e2e: {
|
||||||
video: true,
|
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) {
|
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)
|
// Pin the browser locale so locale-formatted cells (intl-date/time/datetime)
|
||||||
// render deterministically regardless of the runner's system locale.
|
// render deterministically regardless of the runner's system locale.
|
||||||
on('before:browser:launch', (browser, launchOptions) => {
|
on('before:browser:launch', (browser, launchOptions) => {
|
||||||
|
|||||||
@@ -93,29 +93,8 @@ context('derived variables: ', function () {
|
|||||||
'not.have.class',
|
'not.have.class',
|
||||||
'dc-derived-cell'
|
'dc-derived-cell'
|
||||||
)
|
)
|
||||||
// The computed block opens at whichever derived column comes first in the
|
// REGION is the first derived column, so it opens the computed block
|
||||||
// grid - MPE_DERIVED order, so LINE_VALUE here, not REGION. Assert the
|
headerCell('REGION').should('have.class', 'dc-derived-first')
|
||||||
// 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
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// the hover text carries the label, which is what says what the column is:
|
// 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
|
// the configured VAR_LABEL where there is one, otherwise the generated
|
||||||
|
|||||||
@@ -7,123 +7,41 @@ const serverType = Cypress.env('serverType')
|
|||||||
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
||||||
const downloadsFolder = Cypress.config('downloadsFolder')
|
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'
|
import { deleteDownloadsFolder } from '../util/deleteDownloadFolder'
|
||||||
|
|
||||||
context('download files test: ', function () {
|
context('download files test: ', function () {
|
||||||
this.beforeAll(() => {
|
this.beforeAll(() => {
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||||
/* the estate ships no licence, so it runs on the free tier - and the free
|
cy.loginAndUpdateValidKey()
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.beforeEach(() => {
|
this.beforeEach(() => {
|
||||||
cy.visit(hostUrl + appLocation)
|
cy.visit(hostUrl + appLocation)
|
||||||
|
cy.get('input.username').type(username)
|
||||||
// beforeAll already logged in. Typing into the login form unconditionally
|
cy.get('input.password').type(password)
|
||||||
// failed with "the center of this element is hidden from view" whenever the
|
cy.get('.login-group button').click()
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
visitPage('home')
|
visitPage('home')
|
||||||
})
|
})
|
||||||
|
|
||||||
this.afterEach(() => {
|
this.afterEach(() => {
|
||||||
deleteDownloadsFolder()
|
deleteDownloadsFolder()
|
||||||
|
|
||||||
if (generatedExcel) {
|
|
||||||
cy.task('deleteFile', 'cypress/fixtures/excels/' + generatedExcel)
|
|
||||||
generatedExcel = ''
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('1 | downloads audit file', (done) => {
|
it('1 | downloads audit file', (done) => {
|
||||||
/* The approve grid renders a download button per table that is waiting for
|
visitPage('approve/toapprove')
|
||||||
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'
|
|
||||||
|
|
||||||
cy.task('makeUniqueExcel', {
|
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
||||||
source: 'cypress/fixtures/excels/regular_excel.xlsx',
|
.should('not.exist')
|
||||||
dest: 'cypress/fixtures/excels/' + generatedExcel
|
.then(() => {
|
||||||
})
|
cy.get('.btn.btn-success')
|
||||||
|
.should('be.visible')
|
||||||
|
.then((buttons) => {
|
||||||
|
buttons[0].click()
|
||||||
|
const id = buttons[0].id
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
checkForFileDownloaded(id, 'zip', () => done())
|
||||||
|
})
|
||||||
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())
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -154,7 +72,7 @@ context('download files test: ', function () {
|
|||||||
|
|
||||||
openDownloadModal(() => {
|
openDownloadModal(() => {
|
||||||
cy.get('select')
|
cy.get('select')
|
||||||
.select('EXCEL')
|
.select('Excel')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
||||||
button.trigger('click')
|
button.trigger('click')
|
||||||
@@ -174,7 +92,7 @@ context('download files test: ', function () {
|
|||||||
|
|
||||||
openDownloadModal(() => {
|
openDownloadModal(() => {
|
||||||
cy.get('select')
|
cy.get('select')
|
||||||
.select('SAS')
|
.select('SAS Datalines')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
||||||
button.trigger('click')
|
button.trigger('click')
|
||||||
@@ -194,7 +112,7 @@ context('download files test: ', function () {
|
|||||||
|
|
||||||
openDownloadModal(() => {
|
openDownloadModal(() => {
|
||||||
cy.get('select')
|
cy.get('select')
|
||||||
.select('SAS_DDL')
|
.select('SAS DDL')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
||||||
button.trigger('click')
|
button.trigger('click')
|
||||||
@@ -214,7 +132,7 @@ context('download files test: ', function () {
|
|||||||
|
|
||||||
openDownloadModal(() => {
|
openDownloadModal(() => {
|
||||||
cy.get('select')
|
cy.get('select')
|
||||||
.select('TSQL_DDL')
|
.select('TSQL DDL')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
||||||
button.trigger('click')
|
button.trigger('click')
|
||||||
@@ -234,7 +152,7 @@ context('download files test: ', function () {
|
|||||||
|
|
||||||
openDownloadModal(() => {
|
openDownloadModal(() => {
|
||||||
cy.get('select')
|
cy.get('select')
|
||||||
.select('PGSQL_DDL')
|
.select('PGSQL DDL')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
||||||
button.trigger('click')
|
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(() => {
|
this.afterEach(() => {
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||||
})
|
})
|
||||||
@@ -300,23 +198,18 @@ const checkForFileDownloaded = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openDownloadModal = (callback?: any) => {
|
const openDownloadModal = (callback?: any) => {
|
||||||
/* filterSide is the viewer's options dropdown; it no longer carries a
|
cy.get('.btn.btn-sm.btn-outline.filterSide.dropdown-toggle')
|
||||||
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
|
|
||||||
})
|
|
||||||
.click()
|
.click()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
/* the click handler is on the clrDropdownItem element, not on the span
|
cy.get('clr-dropdown-menu button').then((buttons) => {
|
||||||
holding the text, and the whole label is matched: more than one entry
|
for (let button of buttons) {
|
||||||
may mention Download, and an ambiguous cy.contains fails outright */
|
if (button.innerText.toLowerCase().includes('download')) {
|
||||||
cy.contains('clr-dropdown-menu [clrDropdownItem]', /^\s*Download\s*$/, {
|
button.click()
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).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')
|
|
||||||
}
|
|
||||||
@@ -6,90 +6,44 @@ const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
|||||||
|
|
||||||
// SAS Visual Analytics data-driven content embed mode (`?embed=va`):
|
// SAS Visual Analytics data-driven content embed mode (`?embed=va`):
|
||||||
// - chrome is hidden (same as embed=true),
|
// - chrome is hidden (same as embed=true),
|
||||||
// - the editor opens READ-ONLY (the user clicks Edit, as in normal use),
|
// - the editor opens in edit mode immediately (not read-only),
|
||||||
// - the Filter button is hidden - VA filters replace the filter builder,
|
// - the only action button is a single Submit below the grid (stubbed in v1),
|
||||||
// - the Add Row / Add Record buttons are hidden.
|
// - the top button bar and Add Record are hidden.
|
||||||
//
|
//
|
||||||
// The detailed PK+label data-merge logic is covered by the VaMessagingService
|
// 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
|
// unit spec; here we only assert the deterministic UI layout and that pushing a
|
||||||
// postMessage does not break the page, and (case 3) that a `va_parmap` URL
|
// VA postMessage does not break the page.
|
||||||
// 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)
|
|
||||||
|
|
||||||
context('embed=va (VA data-driven content) tests: ', function () {
|
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(() => {
|
this.beforeAll(() => {
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||||
cy.loginAndUpdateValidKey()
|
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(() => {
|
this.beforeEach(() => {
|
||||||
cy.visit(hostUrl + appLocation)
|
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')
|
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', () => {
|
openEditorInVaMode('mpe_x_test', () => {
|
||||||
// Chrome hidden (same as embed=true)
|
// Chrome hidden (same as embed=true)
|
||||||
cy.get('header.app-header').should('not.exist')
|
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('#hotTable', { timeout: longerCommandTimeout }).should('exist')
|
||||||
cy.get('.btnCtrl button').contains('Edit').should('exist')
|
cy.get('.btnCtrl button').contains('Edit').should('not.exist')
|
||||||
|
|
||||||
// The filter builder is replaced by VA filters
|
|
||||||
cy.get('.btnCtrl button').contains('Filter').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 button.btn-outline-danger').should('not.exist') // Cancel
|
||||||
cy.get('.btnCtrl').contains('Add Row').should('not.exist')
|
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')
|
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)
|
// Editor still loads in VA mode after the message (chrome hidden, grid up)
|
||||||
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
|
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
|
||||||
cy.get('header.app-header').should('not.exist')
|
cy.get('header.app-header').should('not.exist')
|
||||||
cy.get('.btnCtrl button').contains('Edit').should('exist')
|
cy.get('button').contains('Submit').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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
// 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.
|
// 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) => {
|
||||||
const openEditorInVaMode = (
|
|
||||||
tablename: string,
|
|
||||||
callback?: any,
|
|
||||||
extraParams?: string
|
|
||||||
) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, tablename)
|
openTableFromTree(libraryToOpenIncludes, tablename)
|
||||||
|
|
||||||
cy.get('#hotTable', { timeout: longerCommandTimeout })
|
cy.get('#hotTable', { timeout: longerCommandTimeout })
|
||||||
@@ -498,13 +84,7 @@ const openEditorInVaMode = (
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
cy.url().then((url) => {
|
cy.url().then((url) => {
|
||||||
const separator = url.includes('?') ? '&' : '?'
|
const separator = url.includes('?') ? '&' : '?'
|
||||||
const extra = extraParams ? `&${extraParams}` : ''
|
cy.visit(`${url}${separator}embed=va`)
|
||||||
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.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
||||||
'not.exist'
|
'not.exist'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(() => {
|
this.afterEach(() => {
|
||||||
colorLog(`TEST END -------------`, '#3498DB')
|
colorLog(`TEST END -------------`, '#3498DB')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
// Large files break Cypress
|
||||||
|
|
||||||
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
||||||
|
|||||||
@@ -1,51 +1,35 @@
|
|||||||
export {}
|
const username = Cypress.env('username')
|
||||||
|
const password = Cypress.env('password')
|
||||||
const hostUrl = Cypress.env('hosturl')
|
const hostUrl = Cypress.env('hosturl')
|
||||||
const appLocation = Cypress.env('appLocation')
|
const appLocation = Cypress.env('appLocation')
|
||||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||||
|
const serverType = Cypress.env('serverType')
|
||||||
|
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
||||||
|
const fixturePath = 'excels_general/'
|
||||||
|
|
||||||
context('metanav tests: ', function () {
|
context('metanav tests: ', function () {
|
||||||
this.beforeAll(() => {
|
this.beforeAll(() => {
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||||
cy.loginAndUpdateValidKey(true)
|
cy.loginAndUpdateValidKey()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.beforeEach(() => {
|
this.beforeEach(() => {
|
||||||
// beforeAll has already logged in. Typing into the login form here failed
|
cy.visit(hostUrl + appLocation)
|
||||||
// once the session existed - the form is not on screen, so Cypress reported
|
cy.get('input.username').type(username)
|
||||||
// "the center of this element is hidden from view" and the whole suite was
|
cy.get('input.password').type(password)
|
||||||
// skipped behind that hook.
|
cy.get('.login-group button').click()
|
||||||
|
|
||||||
visitPage('view/metadata')
|
visitPage('view/metadata')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('1 | Opens metadata object', () => {
|
it('1 | Opens metadata object', (done) => {
|
||||||
openFirstMetadataType()
|
openFirstMetadataFromTree(() => {
|
||||||
|
// BLOCKER
|
||||||
/**
|
// For unkown reasons, .clr-accordion-header-button always null although it is present on the page.
|
||||||
* Query the accordion at document level. The panel renders in the content
|
cy.get('.clr-accordion-header-button').then((panelNodes: any) => {
|
||||||
* area, while `.within(<tree node>)` scopes every following `cy.get` to
|
panelNodes[0].querySelector('button').click()
|
||||||
* 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')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.afterEach(() => {
|
this.afterEach(() => {
|
||||||
@@ -53,16 +37,23 @@ context('metanav tests: ', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const openFirstMetadataType = () => {
|
const openFirstMetadataFromTree = (callback?: any) => {
|
||||||
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
||||||
// The metadata types are the tree nodes after the search box; the search box
|
.should('not.exist')
|
||||||
// is its own node (`.search-node`), so selecting by position would tie the
|
.then(() => {
|
||||||
// spec to that detail. The <p> is the element that owns the click handler.
|
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||||
cy.get('.nav-tree clr-tree > clr-tree-node:not(.search-node) p', {
|
timeout: longerCommandTimeout
|
||||||
timeout: longerCommandTimeout
|
}).then((treeNodes: any) => {
|
||||||
})
|
let firstMetaNode
|
||||||
.first()
|
|
||||||
.click()
|
firstMetaNode = treeNodes[1]
|
||||||
|
|
||||||
|
cy.get(firstMetaNode).within(() => {
|
||||||
|
cy.get('.clr-treenode-content').click()
|
||||||
|
callback()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const visitPage = (url: string) => {
|
const visitPage = (url: string) => {
|
||||||
|
|||||||
Generated
+49
-73
@@ -22,7 +22,7 @@
|
|||||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||||
"@handsontable/angular-wrapper": "18.1.2",
|
"@handsontable/angular-wrapper": "18.1.2",
|
||||||
"@sasjs/adapter": "5.1.2",
|
"@sasjs/adapter": "5.1.2",
|
||||||
"@sasjs/utils": "4.3.1",
|
"@sasjs/utils": "4.0.0",
|
||||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||||
"@types/d3-graphviz": "^2.6.7",
|
"@types/d3-graphviz": "^2.6.7",
|
||||||
"@types/text-encoding": "0.0.35",
|
"@types/text-encoding": "0.0.35",
|
||||||
@@ -82,7 +82,6 @@
|
|||||||
"@types/randombytes": "^2.0.3",
|
"@types/randombytes": "^2.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "8.65.0",
|
"@typescript-eslint/eslint-plugin": "8.65.0",
|
||||||
"@typescript-eslint/parser": "8.65.0",
|
"@typescript-eslint/parser": "8.65.0",
|
||||||
"archiver": "8.0.0",
|
|
||||||
"core-js": "^2.5.4",
|
"core-js": "^2.5.4",
|
||||||
"cypress": "^15.19.0",
|
"cypress": "^15.19.0",
|
||||||
"cypress-file-upload": "^5.0.8",
|
"cypress-file-upload": "^5.0.8",
|
||||||
@@ -5209,12 +5208,6 @@
|
|||||||
"node": ">=18.0.0"
|
"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": {
|
"node_modules/@istanbuljs/schema": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||||
@@ -8729,9 +8722,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/utils": {
|
"node_modules/@sasjs/utils": {
|
||||||
"version": "4.3.1",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.0.tgz",
|
||||||
"integrity": "sha512-P98zjjfrhVJ7/ubKTWTn8Oiyb4/a+SIfVQOIa9oMXZrsRsZ5RTLj40sl6jmv1iCxbsqfziOrnNc0Yu6gx5Qp1A==",
|
"integrity": "sha512-lARoCnRM4gSVf8ShoP5elHi7W28K/14MK037dQ0ABgMlP3+r6OuhAZYwK/a70tED47tKxyIoCAKLdP6jrG0ewQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fast-csv/format": "5.0.7",
|
"@fast-csv/format": "5.0.7",
|
||||||
@@ -9884,9 +9877,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.72.1",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz",
|
||||||
"integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==",
|
"integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -9953,17 +9946,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.72.1",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz",
|
||||||
"integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==",
|
"integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.72.1",
|
"@typescript-eslint/scope-manager": "8.72.0",
|
||||||
"@typescript-eslint/types": "8.72.1",
|
"@typescript-eslint/types": "8.72.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.72.1"
|
"@typescript-eslint/typescript-estree": "8.72.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.72.1",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
|
||||||
"integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==",
|
"integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.72.1",
|
"@typescript-eslint/tsconfig-utils": "^8.72.0",
|
||||||
"@typescript-eslint/types": "^8.72.1",
|
"@typescript-eslint/types": "^8.72.0",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -10001,15 +9994,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.72.1",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz",
|
||||||
"integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==",
|
"integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.72.1",
|
"@typescript-eslint/types": "8.72.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.72.1"
|
"@typescript-eslint/visitor-keys": "8.72.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.72.1",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz",
|
||||||
"integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==",
|
"integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -10038,17 +10031,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.72.1",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz",
|
||||||
"integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==",
|
"integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.72.1",
|
"@typescript-eslint/project-service": "8.72.0",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.72.1",
|
"@typescript-eslint/tsconfig-utils": "8.72.0",
|
||||||
"@typescript-eslint/types": "8.72.1",
|
"@typescript-eslint/types": "8.72.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.72.1",
|
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@@ -10067,14 +10060,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.72.1",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz",
|
||||||
"integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==",
|
"integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.72.1",
|
"@typescript-eslint/types": "8.72.0",
|
||||||
"eslint-visitor-keys": "^5.0.0"
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -15253,9 +15246,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.2",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
||||||
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
|
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -19287,7 +19280,6 @@
|
|||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^5.0.8"
|
"brace-expansion": "^5.0.8"
|
||||||
@@ -19609,9 +19601,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mocha/node_modules/argparse": {
|
"node_modules/mocha/node_modules/argparse": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.3.tgz",
|
||||||
"integrity": "sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==",
|
"integrity": "sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -22277,22 +22269,6 @@
|
|||||||
"url": "https://github.com/sponsors/yqnn"
|
"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": {
|
"node_modules/readdir-scoped-modules": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz",
|
||||||
@@ -25195,9 +25171,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vis-data": {
|
"node_modules/vis-data": {
|
||||||
"version": "8.0.6",
|
"version": "8.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.5.tgz",
|
||||||
"integrity": "sha512-6NjsyqWL18ithgkpPqfYGvwx7JlrxgO0MkMJdZus4eovFl6ebUjNcn/UEzgm5NWvCAz4psUgkvcdLyAXFxeeDg==",
|
"integrity": "sha512-tRQKcTGaclo60rTY1j4v7BgD9v5jfD3wl+JgR1q6I4AI7go3QH5OjMCLIuZPpL2sf4IRMlwyPlRLnK4m+Phjsw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "(Apache-2.0 OR MIT)",
|
"license": "(Apache-2.0 OR MIT)",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -25230,9 +25206,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vis-util": {
|
"node_modules/vis-util": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/vis-util/-/vis-util-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vis-util/-/vis-util-6.0.2.tgz",
|
||||||
"integrity": "sha512-2ar+MN4JYYfhRSh+sds7o0Sg7/i/mNMVXv32DaMg5I4N1AJjTaHvXqNtHcISXl4WyaVkJ6twiCfqStudO2TYFg==",
|
"integrity": "sha512-sYawqWllCqwmTPLurRgPChmfRppbWI/XoBWdGNM6N2s4XuVR8VO8gtz/v9AvrAAOfZxel/U5UBNOwyhFn0qb7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "(Apache-2.0 OR MIT)",
|
"license": "(Apache-2.0 OR MIT)",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
|
|||||||
+32
-37
@@ -3,44 +3,41 @@
|
|||||||
"description": "DataController Client",
|
"description": "DataController Client",
|
||||||
"angular-cli": {},
|
"angular-cli": {},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
|
"start": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng serve",
|
||||||
"audit:prod": "npm audit --omit=dev",
|
"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",
|
"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-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",
|
"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": "compodoc -p tsconfig.doc.json --name 'Data Controller Client'",
|
||||||
"compodoc:build-and-serve": "compodoc -p tsconfig.doc.json -s --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'",
|
"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",
|
"lighthouse": "lhci autorun",
|
||||||
"lint": "npm run lint:fix",
|
"ng": "ng"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -59,7 +56,7 @@
|
|||||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||||
"@handsontable/angular-wrapper": "18.1.2",
|
"@handsontable/angular-wrapper": "18.1.2",
|
||||||
"@sasjs/adapter": "5.1.2",
|
"@sasjs/adapter": "5.1.2",
|
||||||
"@sasjs/utils": "4.3.1",
|
"@sasjs/utils": "4.0.0",
|
||||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||||
"@types/d3-graphviz": "^2.6.7",
|
"@types/d3-graphviz": "^2.6.7",
|
||||||
"@types/text-encoding": "0.0.35",
|
"@types/text-encoding": "0.0.35",
|
||||||
@@ -119,7 +116,6 @@
|
|||||||
"@types/randombytes": "^2.0.3",
|
"@types/randombytes": "^2.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "8.65.0",
|
"@typescript-eslint/eslint-plugin": "8.65.0",
|
||||||
"@typescript-eslint/parser": "8.65.0",
|
"@typescript-eslint/parser": "8.65.0",
|
||||||
"archiver": "8.0.0",
|
|
||||||
"core-js": "^2.5.4",
|
"core-js": "^2.5.4",
|
||||||
"cypress": "^15.19.0",
|
"cypress": "^15.19.0",
|
||||||
"cypress-file-upload": "^5.0.8",
|
"cypress-file-upload": "^5.0.8",
|
||||||
@@ -153,12 +149,11 @@
|
|||||||
"lighthouse": "13.4.0",
|
"lighthouse": "13.4.0",
|
||||||
"fast-uri": "4.2.3",
|
"fast-uri": "4.2.3",
|
||||||
"readdir-glob": {
|
"readdir-glob": {
|
||||||
"brace-expansion": "5.0.12",
|
"brace-expansion": "^5.0.9"
|
||||||
"minimatch": "11.0.3"
|
|
||||||
},
|
},
|
||||||
"exceljs": {
|
"exceljs": {
|
||||||
"archiver": "8.0.0",
|
"archiver": "^8.0.0",
|
||||||
"unzipper": "0.12.5"
|
"unzipper": "^0.12.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { QueryClause } from './models/TableData'
|
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
|
* 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.
|
* Defaults to 'live'; toggled by the Auto-apply checkbox in the editor.
|
||||||
*/
|
*/
|
||||||
vaApplyMode: 'live' | 'confirm'
|
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
|
rootParam: string
|
||||||
dcLib: string
|
dcLib: string
|
||||||
xlmaps: XLMapListItem[]
|
xlmaps: XLMapListItem[]
|
||||||
@@ -93,8 +77,6 @@ export const globals: {
|
|||||||
} = {
|
} = {
|
||||||
embed: false,
|
embed: false,
|
||||||
vaApplyMode: 'live',
|
vaApplyMode: 'live',
|
||||||
vaParamMap: new Map(),
|
|
||||||
vaParmapProblems: [],
|
|
||||||
rootParam: <string>'',
|
rootParam: <string>'',
|
||||||
dcLib: '',
|
dcLib: '',
|
||||||
xlmaps: [],
|
xlmaps: [],
|
||||||
|
|||||||
@@ -235,7 +235,7 @@
|
|||||||
@if (!errTop) {
|
@if (!errTop) {
|
||||||
<app-alerts></app-alerts>
|
<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-excel-password-modal></app-excel-password-modal>
|
||||||
|
|
||||||
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
|
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import '@clr/icons'
|
|||||||
import '@clr/icons/shapes/all-shapes'
|
import '@clr/icons/shapes/all-shapes'
|
||||||
import { globals } from './_globals'
|
import { globals } from './_globals'
|
||||||
import { parseEmbedParam } from './shared/utils/parse-embed-param'
|
import { parseEmbedParam } from './shared/utils/parse-embed-param'
|
||||||
import { parseVaParmap } from './shared/utils/parse-va-parmap'
|
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { EventService } from './services/event.service'
|
import { EventService } from './services/event.service'
|
||||||
import { AppService } from './services/app.service'
|
import { AppService } from './services/app.service'
|
||||||
@@ -68,7 +67,7 @@ export class AppComponent {
|
|||||||
public licenceProblem = this.licenceService.licenceProblem
|
public licenceProblem = this.licenceService.licenceProblem
|
||||||
public appOverCapacity: boolean = false
|
public appOverCapacity: boolean = false
|
||||||
public appActive: boolean | null = null
|
public appActive: boolean | null = null
|
||||||
public sasLogsModal: boolean = false
|
public requestsModal: boolean = false
|
||||||
public showRegistration: boolean = true
|
public showRegistration: boolean = true
|
||||||
public startupDataLoaded: boolean = false
|
public startupDataLoaded: boolean = false
|
||||||
public startupSteps: StartupStep[] = []
|
public startupSteps: StartupStep[] = []
|
||||||
@@ -164,24 +163,9 @@ export class AppComponent {
|
|||||||
globals.embed = embed
|
globals.embed = embed
|
||||||
this.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.subscribeToShowAbortModal()
|
||||||
this.subscribeToSasLogsModal()
|
this.subscribeToRequestsModal()
|
||||||
this.subscribeToStartupData()
|
this.subscribeToStartupData()
|
||||||
this.subscribeToAppActive()
|
this.subscribeToAppActive()
|
||||||
this.subscribeToDemoLimitModal()
|
this.subscribeToDemoLimitModal()
|
||||||
@@ -353,9 +337,9 @@ export class AppComponent {
|
|||||||
/**
|
/**
|
||||||
* Opens requests modal when requested from event service
|
* Opens requests modal when requested from event service
|
||||||
*/
|
*/
|
||||||
public subscribeToSasLogsModal() {
|
public subscribeToRequestsModal() {
|
||||||
this.eventService.onSasLogsModalOpen.subscribe((value: boolean) => {
|
this.eventService.onRequestsModalOpen.subscribe((value: boolean) => {
|
||||||
this.sasLogsModal = true
|
this.requestsModal = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { LicensingGuard } from './routes/licensing.guard'
|
|||||||
import { UsernavRouteComponent } from './routes/usernav-route/usernav-route.component'
|
import { UsernavRouteComponent } from './routes/usernav-route/usernav-route.component'
|
||||||
import { AppService } from './services/app.service'
|
import { AppService } from './services/app.service'
|
||||||
import { InfoModalComponent } from './shared/abort-modal/info-modal.component'
|
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 { DirectivesModule } from './directives/directives.module'
|
||||||
import { ViyaApiExplorerComponent } from './viya-api-explorer/viya-api-explorer.component'
|
import { ViyaApiExplorerComponent } from './viya-api-explorer/viya-api-explorer.component'
|
||||||
import { NgxJsonViewerModule } from 'ngx-json-viewer'
|
import { NgxJsonViewerModule } from 'ngx-json-viewer'
|
||||||
@@ -32,7 +32,7 @@ import { AppSettingsService } from './services/app-settings.service'
|
|||||||
ReviewRouteComponent,
|
ReviewRouteComponent,
|
||||||
ReviewRouteComponent,
|
ReviewRouteComponent,
|
||||||
UsernavRouteComponent,
|
UsernavRouteComponent,
|
||||||
SasLogsModalComponent,
|
RequestsModalComponent,
|
||||||
InfoModalComponent,
|
InfoModalComponent,
|
||||||
ViyaApiExplorerComponent
|
ViyaApiExplorerComponent
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,36 +1,7 @@
|
|||||||
<main class="content-area position-relative">
|
<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">
|
<div class="clr-row">
|
||||||
<!-- T&C section -->
|
<!-- T&C section -->
|
||||||
@if (!isEmbedded && step === 0) {
|
@if (step === 0) {
|
||||||
<div id="TCS" class="card">
|
<div id="TCS" class="card">
|
||||||
<div class="card-header">Terms and Conditions</div>
|
<div class="card-header">Terms and Conditions</div>
|
||||||
<div class="card-block">
|
<div class="card-block">
|
||||||
@@ -64,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- T&C section end -->
|
<!-- T&C section end -->
|
||||||
|
|
||||||
@if (!isEmbedded && step > 0) {
|
@if (step > 0) {
|
||||||
@switch (true) {
|
@switch (true) {
|
||||||
@case (sasJsConfig.serverType === ServerType.SasViya) {
|
@case (sasJsConfig.serverType === ServerType.SasViya) {
|
||||||
<div class="autodeploy-section card">
|
<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/`)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -4,8 +4,6 @@ import { SASjsConfig } from '@sasjs/adapter'
|
|||||||
import { ServerType } from '@sasjs/utils/types/serverType'
|
import { ServerType } from '@sasjs/utils/types/serverType'
|
||||||
import { AppStoreService } from '../services/app-store.service'
|
import { AppStoreService } from '../services/app-store.service'
|
||||||
import { DcAdapterSettings } from '../models/DcAdapterSettings'
|
import { DcAdapterSettings } from '../models/DcAdapterSettings'
|
||||||
import { globals } from '../_globals'
|
|
||||||
import { standaloneUrl } from './standalone-url'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-deploy',
|
selector: 'app-deploy',
|
||||||
@@ -46,20 +44,6 @@ export class DeployComponent implements OnInit {
|
|||||||
this.setDeployDefaults()
|
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
|
* Setting default values used for deploy request
|
||||||
*/
|
*/
|
||||||
@@ -76,8 +60,6 @@ export class DeployComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onNavigateToHome() {
|
public onNavigateToHome() {
|
||||||
// Same helper as deployUrl: splitting on '#' left the query string in
|
window.open(location.href.split('#')[0], '_blank')
|
||||||
// place, so a tab opened from `?embed=va#/deploy` stayed embedded.
|
|
||||||
window.open(standaloneUrl(window.location.href), '_blank')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,20 @@
|
|||||||
@if (autodeployDone) {
|
@if (autodeployDone) {
|
||||||
<p class="m-0 align-self-start">Done</p>
|
<p class="m-0 align-self-start">Done</p>
|
||||||
<hr class="w-100" />
|
<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) {
|
@if (autoDeployStatus.runMakeData !== null) {
|
||||||
<div class="deploy-status-row">
|
<div class="deploy-status-row">
|
||||||
@if (autoDeployStatus.runMakeData) {
|
@if (autoDeployStatus.runMakeData) {
|
||||||
@@ -19,13 +33,16 @@
|
|||||||
@if (autoDeployStatus.runMakeData === false) {
|
@if (autoDeployStatus.runMakeData === false) {
|
||||||
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
||||||
}
|
}
|
||||||
<p>Create control library</p>
|
<p>Create database</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<hr class="w-100" />
|
<hr class="w-100" />
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
|
<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>
|
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
||||||
}
|
}
|
||||||
LAUNCH
|
LAUNCH
|
||||||
@@ -48,10 +65,10 @@
|
|||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="openSasLogsModal()"
|
(click)="openSasRequestsModal()"
|
||||||
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
|
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
|
||||||
>
|
>
|
||||||
SAS Logs
|
SAS Requests
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -61,9 +78,9 @@
|
|||||||
|
|
||||||
<h4 class="text-center my-15">Viya Deploy</h4>
|
<h4 class="text-center my-15">Viya Deploy</h4>
|
||||||
<p class="deploy-intro">
|
<p class="deploy-intro">
|
||||||
Configure Data Controller for your Viya environment. The frontend and services
|
Configure and deploy Data Controller to your Viya environment. Deployment
|
||||||
are already in place; this runs the one-off job that creates the control
|
creates the backend SAS services and the control library used by the
|
||||||
library and its configuration data, then removes itself.
|
application.
|
||||||
</p>
|
</p>
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
@@ -231,8 +248,19 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Runs the one-off makedata job. This is the screen's only action: the
|
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
|
||||||
frontend and services are already deployed. -->
|
|
||||||
|
<!-- <clr-checkbox-wrapper>
|
||||||
|
<input
|
||||||
|
clrCheckbox
|
||||||
|
[(ngModel)]="recreateDatabase"
|
||||||
|
(click)="recreateDatabaseClicked($event)"
|
||||||
|
type="checkbox"
|
||||||
|
checked
|
||||||
|
/>
|
||||||
|
<label>Recreate database</label>
|
||||||
|
</clr-checkbox-wrapper> -->
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -241,3 +269,53 @@
|
|||||||
>
|
>
|
||||||
Deploy
|
Deploy
|
||||||
</button>
|
</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 selectedComputeContext: string = ''
|
||||||
public makeDataResponse: string = ''
|
public makeDataResponse: string = ''
|
||||||
|
public jsonFile: any = null
|
||||||
public autodeploying: boolean = false
|
public autodeploying: boolean = false
|
||||||
public autodeployDone: 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 createDatabaseLoading: boolean = false
|
||||||
public adminGroupsLoading: boolean = false
|
public adminGroupsLoading: boolean = false
|
||||||
public currentUserInfoLoading: boolean = false
|
public currentUserInfoLoading: boolean = false
|
||||||
@@ -77,14 +86,18 @@ export class AutomaticComponent implements OnInit {
|
|||||||
public currentUserInfo: ViyaApiCurrentUser | null = null
|
public currentUserInfo: ViyaApiCurrentUser | null = null
|
||||||
public computeContexts: ComputeContextItem[] = []
|
public computeContexts: ComputeContextItem[] = []
|
||||||
|
|
||||||
/**
|
/** autoDeployStatus
|
||||||
* Status of the one step this screen runs: `makedata`, which creates the
|
* This object presents the status for two steps that we have for deploy.
|
||||||
* control library and its configuration data and then deletes itself.
|
* `deployServicePack` - Creating services based on `viya.json`
|
||||||
* `null` means it has not run yet, `false` that it failed.
|
* `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: {
|
public autoDeployStatus: {
|
||||||
|
deployServicePack: any
|
||||||
runMakeData: any
|
runMakeData: any
|
||||||
} = {
|
} = {
|
||||||
|
deployServicePack: null,
|
||||||
runMakeData: null
|
runMakeData: null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,15 +420,61 @@ export class AutomaticComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the deployment. The control library and its configuration data are
|
* Executes sas.json file to deploy the backend
|
||||||
* created by `makedata`, which self-deletes once it has run; the frontend and
|
* Method will first try to run the `auto deploy`
|
||||||
* services arrived with the sasjs deploy, so there is nothing else to deploy
|
* If that fails the rest of the code is ignored.
|
||||||
* from here.
|
* 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
|
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 serverUrl = this.sasjsConfig.serverUrl
|
||||||
let appLoc = this.sasjsConfig.appLoc
|
let appLoc = this.sasjsConfig.appLoc
|
||||||
const execPath = this.sasService.getExecutionPath()
|
const execPath = this.sasService.getExecutionPath()
|
||||||
let contextname = `&_contextname=${encodeURIComponent(params.contextName)}`
|
let contextname = `&_contextname=${params.contextName}`
|
||||||
let admin = `&admin=${encodeURIComponent(params.admin)}`
|
let admin = `&admin=${params.admin}`
|
||||||
let dcPath = `&dcpath=${encodeURIComponent(params.dcPath)}`
|
let dcPath = `&dcpath=${params.dcPath}`
|
||||||
// Debug is ALWAYS enabled for makedata — it runs only once during
|
// Debug is ALWAYS enabled for makedata — it runs only once during
|
||||||
// deployment and the full log is needed to diagnose any issues.
|
// deployment and the full log is needed to diagnose any issues.
|
||||||
let debug = '&_debug=131'
|
let debug = '&_debug=131'
|
||||||
@@ -649,8 +708,24 @@ export class AutomaticComponent implements OnInit {
|
|||||||
this.deployService.downloadFile(content, filename, extension)
|
this.deployService.downloadFile(content, filename, extension)
|
||||||
}
|
}
|
||||||
|
|
||||||
public openSasLogsModal() {
|
public async onJsonFileChange(event: any) {
|
||||||
this.eventService.openSasLogsModal()
|
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() {
|
public navigateToHome() {
|
||||||
|
|||||||
-88
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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>-->
|
<!--<hr>-->
|
||||||
<!--{{ validationDone }}-->
|
<!--{{ 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">
|
<div class="hot-wrapper clr-flex-1">
|
||||||
<hot-table
|
<hot-table
|
||||||
#hotInstance
|
#hotInstance
|
||||||
|
|||||||
@@ -27,15 +27,3 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
opacity: 0.7;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -73,7 +73,6 @@ import {
|
|||||||
} from '../shared/dc-validator/utils/autoEscapedCellTracker'
|
} from '../shared/dc-validator/utils/autoEscapedCellTracker'
|
||||||
import { HyperFormula } from 'hyperformula'
|
import { HyperFormula } from 'hyperformula'
|
||||||
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
|
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 { getDisplayColHeaders } from '../shared/utils/display-col-headers'
|
||||||
import { buildColInfoHtml } from '../shared/utils/col-info-html'
|
import { buildColInfoHtml } from '../shared/utils/col-info-html'
|
||||||
import { globals } from '../_globals'
|
import { globals } from '../_globals'
|
||||||
@@ -108,7 +107,6 @@ import { VaFilterService } from '../services/va-filter.service'
|
|||||||
import { UploadFileResponse } from '../models/UploadFile'
|
import { UploadFileResponse } from '../models/UploadFile'
|
||||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||||
import { ParseResult } from '../models/ParseResult.interface'
|
import { ParseResult } from '../models/ParseResult.interface'
|
||||||
import { observeGridContainerResize } from './utils/refreshGridAfterContainerResize'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-editor',
|
selector: 'app-editor',
|
||||||
@@ -487,46 +485,6 @@ export class EditorComponent
|
|||||||
get isVaEmbed() {
|
get isVaEmbed() {
|
||||||
return globals.embed === 'va'
|
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
|
* 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
|
* 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()
|
const msg = this.vaMessaging.latestMessage()
|
||||||
if (!msg) return false
|
if (!msg) return false
|
||||||
|
|
||||||
const clauses = this.vaFilter.buildInitialClauses(msg, this.vaParamMap)
|
const clauses = this.vaFilter.buildInitialClauses(msg)
|
||||||
if (clauses.length === 0) return false
|
if (clauses.length === 0) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// A `va_parmap` entry is a column the report author typed, so this
|
const res: any = await this.sasStoreService.saveQuery(libds, clauses)
|
||||||
// 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 id = res?.result?.[0]?.FILTER_RK
|
const id = res?.result?.[0]?.FILTER_RK
|
||||||
const table = res?.result?.[0]?.FILTER_TABLE
|
const table = res?.result?.[0]?.FILTER_TABLE
|
||||||
if (id === undefined || table === undefined) return false
|
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
|
* Re-run height+render when the grid container settles.
|
||||||
* lives in editor/utils/refreshGridAfterContainerResize.ts so the wiring is
|
|
||||||
* covered by a spec.
|
|
||||||
*/
|
*/
|
||||||
private observeGridResize() {
|
private observeGridResize() {
|
||||||
|
const el = this.hotInstance?.rootElement?.parentElement
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
this.gridResizeObserver?.disconnect()
|
this.gridResizeObserver?.disconnect()
|
||||||
this.gridResizeObserver = observeGridContainerResize(
|
this.gridResizeObserver = new ResizeObserver(() => {
|
||||||
this.hotInstance,
|
requestAnimationFrame(() => {
|
||||||
this.hotInstance?.rootElement?.parentElement
|
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).
|
// rules must stay index-aligned with headerColumns).
|
||||||
this.headerColumns.push(EDIT_STATUS_COLUMN_NAME)
|
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 !== '') {
|
if (response.data.sasparams[0].DTVARS !== '') {
|
||||||
this.dateHeaders = response.data.sasparams[0].DTVARS.split(' ')
|
this.dateHeaders = response.data.sasparams[0].DTVARS.split(' ')
|
||||||
}
|
}
|
||||||
@@ -4188,8 +4156,9 @@ export class EditorComponent
|
|||||||
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
|
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
|
||||||
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
|
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
|
||||||
|
|
||||||
// Note: this.headerColumns and this.columnHeader contains same data
|
// columnHeader is a copy of headerColumns taken above, once the Delete?
|
||||||
// need to resolve redundancy
|
// 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
|
// default schema - includes NOTNULL defaults from DQ rules
|
||||||
for (let i = 0; i < this.headerColumns.length; i++) {
|
for (let i = 0; i < this.headerColumns.length; i++) {
|
||||||
@@ -4548,9 +4517,6 @@ export class EditorComponent
|
|||||||
// hot.getPlugin('manualColumnFreeze').freezeColumn(0);
|
// hot.getPlugin('manualColumnFreeze').freezeColumn(0);
|
||||||
|
|
||||||
this.queryText = response.data.sasparams[0].FILTER_TEXT
|
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
|
this.readOnlyFields = response.data.sasparams[0].PKCNT
|
||||||
|
|
||||||
hot.addHook(
|
hot.addHook(
|
||||||
@@ -5093,53 +5059,7 @@ export class EditorComponent
|
|||||||
|
|
||||||
this.updateVaFilterText(msg)
|
this.updateVaFilterText(msg)
|
||||||
|
|
||||||
// A `va_parmap` entry naming a column this table does not have is skipped by
|
const clauses = this.vaFilter.buildClauses(msg, labelToVar, this.cols)
|
||||||
// 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 signature = this.vaFilter.signature(clauses)
|
const signature = this.vaFilter.signature(clauses)
|
||||||
|
|
||||||
if (signature === this.vaMessaging.filterSignature) {
|
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">
|
<p cds-text="secondary regular" class="mb-20">
|
||||||
File size: <strong>{{ selectedFile.sizeMB }} MB</strong>
|
File size: <strong>{{ selectedFile.sizeMB }} MB</strong>
|
||||||
</p>
|
</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">
|
<p cds-text="secondary regular" class="mb-15">
|
||||||
Paste or type the list of datasets to upload:
|
Paste or type the list of datasets to upload:
|
||||||
</p>
|
</p>
|
||||||
@@ -198,26 +188,18 @@
|
|||||||
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
|
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
|
||||||
@if (!activeParsedDataset) {
|
@if (!activeParsedDataset) {
|
||||||
<div class="no-table-selected pointer-events-none">
|
<div class="no-table-selected pointer-events-none">
|
||||||
@if (isFileBusy) {
|
@if (fileLoadingState !== FileLoadingState.parsed) {
|
||||||
<clr-icon
|
<clr-icon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
shape="process-on-vm"
|
shape="process-on-vm"
|
||||||
size="40"
|
size="40"
|
||||||
class="is-info icon-dc-fill"
|
class="is-info icon-dc-fill"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
<p
|
<p class="text-center color-gray mt-10" cds-text="section">
|
||||||
class="text-center color-gray mt-10"
|
|
||||||
cds-text="section"
|
|
||||||
id="file-loading-stage"
|
|
||||||
>
|
|
||||||
{{ fileLoadingState }}...
|
{{ fileLoadingState }}...
|
||||||
</p>
|
</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
|
<clr-icon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
shape="warning-standard"
|
shape="warning-standard"
|
||||||
|
|||||||
@@ -40,13 +40,10 @@ import XLSX from 'xlsx'
|
|||||||
|
|
||||||
enum FileLoadingState {
|
enum FileLoadingState {
|
||||||
reading = 'Reading the file',
|
reading = 'Reading the file',
|
||||||
scanning = 'Searching for the data in the file',
|
parsing = 'Searching for the data in the file',
|
||||||
ready = 'File ready to load',
|
parsed = 'Searching for the data finished',
|
||||||
fetching = 'Fetching the dataset details',
|
|
||||||
extracting = 'Extracting the data',
|
|
||||||
extracted = 'Extracting the data finished',
|
|
||||||
/**
|
/**
|
||||||
* Default value
|
* Defualt value
|
||||||
*/
|
*/
|
||||||
notSelected = 'File not selected'
|
notSelected = 'File not selected'
|
||||||
}
|
}
|
||||||
@@ -68,19 +65,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
public workbookInterval: any
|
public workbookInterval: any
|
||||||
public fileLoadingState: FileLoadingState = FileLoadingState.notSelected
|
public fileLoadingState: FileLoadingState = FileLoadingState.notSelected
|
||||||
|
|
||||||
/**
|
public FileLoadingState = FileLoadingState
|
||||||
* 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 hotTableLicenseKey: string | undefined = undefined
|
public hotTableLicenseKey: string | undefined = undefined
|
||||||
public hotTableMaxRows =
|
public hotTableMaxRows =
|
||||||
@@ -297,13 +282,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
|
|
||||||
// Read the excel file to be ready
|
// Read the excel file to be ready
|
||||||
this.spreadsheetService.xlsxReadFile(this.selectedFile!).then((wb) => {
|
this.spreadsheetService.xlsxReadFile(this.selectedFile!).then((wb) => {
|
||||||
|
this.fileLoadingState = FileLoadingState.parsing
|
||||||
this.selectedFile!.workbook = wb
|
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() {
|
onDiscardFile() {
|
||||||
this.selectedFile = null
|
this.selectedFile = null
|
||||||
this.userInputDatasets = ''
|
this.userInputDatasets = ''
|
||||||
this.fileLoadingState = FileLoadingState.notSelected
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onStartParsingFile() {
|
async onStartParsingFile() {
|
||||||
this.uploadLoading = true
|
this.uploadLoading = true
|
||||||
this.fileLoadingState = FileLoadingState.fetching
|
|
||||||
|
|
||||||
const datasetFetchingPromises: Promise<
|
const datasetFetchingPromises: Promise<
|
||||||
EditorsGetDataServiceResponse | undefined
|
EditorsGetDataServiceResponse | undefined
|
||||||
@@ -399,7 +377,6 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
datasetObjects = this.buildDatasetsObjects(datasets)
|
datasetObjects = this.buildDatasetsObjects(datasets)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Error building dataset objects', err)
|
console.warn('Error building dataset objects', err)
|
||||||
this.fileLoadingState = FileLoadingState.ready
|
|
||||||
this.eventService.showInfoModal('Upload error', (err as Error).message)
|
this.eventService.showInfoModal('Upload error', (err as Error).message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -414,12 +391,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.workbookLoaded().then((workbook) => {
|
this.workbookLoaded().then((workbook) => {
|
||||||
this.fileLoadingState = FileLoadingState.extracting
|
|
||||||
|
|
||||||
const parsingPromises: Promise<void>[] = []
|
|
||||||
|
|
||||||
for (let parsedDataset of this.parsedDatasets) {
|
for (let parsedDataset of this.parsedDatasets) {
|
||||||
const parsePromise = this.spreadsheetService
|
this.spreadsheetService
|
||||||
.parseExcelFile({
|
.parseExcelFile({
|
||||||
file: this.selectedFile!,
|
file: this.selectedFile!,
|
||||||
workbook: workbook,
|
workbook: workbook,
|
||||||
@@ -434,6 +407,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
xlRules: parsedDataset.datasetInfo.xlRules
|
xlRules: parsedDataset.datasetInfo.xlRules
|
||||||
})
|
})
|
||||||
.then((parseResult: ParseResult | undefined) => {
|
.then((parseResult: ParseResult | undefined) => {
|
||||||
|
this.fileLoadingState = FileLoadingState.parsed
|
||||||
|
|
||||||
if (parseResult && parseResult.data) {
|
if (parseResult && parseResult.data) {
|
||||||
let datasource: any[] = []
|
let datasource: any[] = []
|
||||||
|
|
||||||
@@ -463,15 +438,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
parsedDataset.includeInSubmission = false
|
parsedDataset.includeInSubmission = false
|
||||||
parsedDataset.parsingTable = 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.selectedFile = null
|
||||||
this.userInputDatasets = ''
|
this.userInputDatasets = ''
|
||||||
this.submitReasonMessage = ''
|
this.submitReasonMessage = ''
|
||||||
this.fileLoadingState = FileLoadingState.notSelected
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initHot() {
|
initHot() {
|
||||||
@@ -708,7 +674,6 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
}
|
}
|
||||||
if (!this.hotInstanceUserDataset) {
|
if (!this.hotInstanceUserDataset) {
|
||||||
console.warn('hotInstanceUserDataset not ready after waiting')
|
console.warn('hotInstanceUserDataset not ready after waiting')
|
||||||
this.fileLoadingState = FileLoadingState.ready
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -781,10 +746,6 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
this.dynamicCellValidations()
|
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) {
|
onParsedDatasetClick(parsedDataset: ParsedDataset) {
|
||||||
|
|||||||
@@ -26,4 +26,22 @@ export class DeployService {
|
|||||||
document.body.removeChild(downloadLink)
|
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 = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ export class EventService {
|
|||||||
@Output() onStartupDataLoaded: EventEmitter<any> = new EventEmitter()
|
@Output() onStartupDataLoaded: EventEmitter<any> = new EventEmitter()
|
||||||
@Output()
|
@Output()
|
||||||
onShowAbortModal: EventEmitter<InfoModal> = new EventEmitter<InfoModal>()
|
onShowAbortModal: EventEmitter<InfoModal> = new EventEmitter<InfoModal>()
|
||||||
@Output() onSasLogsModalOpen: EventEmitter<boolean> = new EventEmitter()
|
@Output() onRequestsModalOpen: EventEmitter<boolean> = new EventEmitter()
|
||||||
@Output() onDemoLimitModalShow: EventEmitter<string> =
|
@Output() onDemoLimitModalShow: EventEmitter<string> =
|
||||||
new EventEmitter<string>()
|
new EventEmitter<string>()
|
||||||
|
|
||||||
@@ -78,8 +78,8 @@ export class EventService {
|
|||||||
this.onShowAbortModal.emit(infoModalObject)
|
this.onShowAbortModal.emit(infoModalObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
public openSasLogsModal() {
|
public openRequestsModal() {
|
||||||
this.onSasLogsModalOpen.emit(true)
|
this.onRequestsModalOpen.emit(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
public catchResponseError(sasService: string | null, err: any) {
|
public catchResponseError(sasService: string | null, err: any) {
|
||||||
|
|||||||
@@ -116,53 +116,6 @@ describe('SasStoreService', () => {
|
|||||||
expect(seen.length).toBe(0)
|
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { HelperService } from './helper.service'
|
|||||||
import { QueryClause } from '../models/TableData'
|
import { QueryClause } from '../models/TableData'
|
||||||
import { globals } from '../_globals'
|
import { globals } from '../_globals'
|
||||||
import { FilterClause, FilterGroup, FilterQuery } from '../models/FilterQuery'
|
import { FilterClause, FilterGroup, FilterQuery } from '../models/FilterQuery'
|
||||||
import { RequestWrapperOptions } from '../models/request-wrapper/RequestWrapperOptions'
|
|
||||||
import {
|
import {
|
||||||
$DataFormats,
|
$DataFormats,
|
||||||
EditorsGetDataSASResponse,
|
EditorsGetDataSASResponse,
|
||||||
@@ -396,30 +395,13 @@ export class SasStoreService {
|
|||||||
).adapterResponse
|
).adapterResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async saveQuery(libds: string, filterQuery: QueryClause[]) {
|
||||||
* 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
|
|
||||||
) {
|
|
||||||
let tables = {
|
let tables = {
|
||||||
iwant: [{ filter_table: libds }],
|
iwant: [{ filter_table: libds }],
|
||||||
filterquery: filterQuery
|
filterquery: filterQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await this.sasService.request(
|
const res = await this.sasService.request('public/validatefilter', tables)
|
||||||
'public/validatefilter',
|
|
||||||
tables,
|
|
||||||
undefined,
|
|
||||||
wrapperOptions
|
|
||||||
)
|
|
||||||
|
|
||||||
this.filter.next(res)
|
this.filter.next(res)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { VaFilterService } from './va-filter.service'
|
import { VaFilterService } from './va-filter.service'
|
||||||
import { VaParmapTarget } from '../shared/utils/parse-va-parmap'
|
|
||||||
import { VaMessage } from './va-messaging.service'
|
import { VaMessage } from './va-messaging.service'
|
||||||
import { HelperService } from './helper.service'
|
import { HelperService } from './helper.service'
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ function msg(parameters: any[]): VaMessage {
|
|||||||
|
|
||||||
describe('VaFilterService', () => {
|
describe('VaFilterService', () => {
|
||||||
let service: VaFilterService
|
let service: VaFilterService
|
||||||
let helperService: HelperService
|
|
||||||
|
|
||||||
// DC column metadata used by the metadata path. cellValidation[0] is the
|
// DC column metadata used by the metadata path. cellValidation[0] is the
|
||||||
// delete column and must be skipped by buildLabelToVarMap.
|
// delete column and must be skipped by buildLabelToVarMap.
|
||||||
@@ -44,8 +42,7 @@ describe('VaFilterService', () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
helperService = new HelperService({} as any)
|
service = new VaFilterService(new HelperService({} as any))
|
||||||
service = new VaFilterService(helperService)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const labelToVar = () =>
|
const labelToVar = () =>
|
||||||
@@ -131,41 +128,6 @@ describe('VaFilterService', () => {
|
|||||||
expect(build([{ label: 'SOME_DATE', value: '2025/03/31' }])).toHaveSize(1)
|
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', () => {
|
it('resolves ambiguous slash dates day-first (never US), disambiguating by value', () => {
|
||||||
const iso = build([{ label: 'SOME_DATE', value: '2025-03-31' }])[0]
|
const iso = build([{ label: 'SOME_DATE', value: '2025-03-31' }])[0]
|
||||||
.RAW_VALUE
|
.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)', () => {
|
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', () => {
|
it('uses the VA label as the column and infers kinds by value shape', () => {
|
||||||
const clauses = service.buildInitialClauses(
|
const clauses = service.buildInitialClauses(
|
||||||
msg([
|
msg([
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import { Injectable } from '@angular/core'
|
|||||||
import { HelperService } from './helper.service'
|
import { HelperService } from './helper.service'
|
||||||
import { VaMessage } from './va-messaging.service'
|
import { VaMessage } from './va-messaging.service'
|
||||||
import { QueryClause } from '../models/TableData'
|
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. */
|
/** SAS data-type kind used to format a VA filter value into a DC RAW_VALUE. */
|
||||||
type VaColumnKind = 'char' | 'numeric' | 'time' | 'date' | 'datetime'
|
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
|
* Translates VA `parameters` into DC filter clauses. Each parameter is matched
|
||||||
* to a DC column by label; a single value becomes `col = value`, multiple
|
* 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.
|
* 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(
|
buildClauses(
|
||||||
msg: VaMessage,
|
msg: VaMessage,
|
||||||
labelToVar: Map<string, string>,
|
labelToVar: Map<string, string>,
|
||||||
cols: any[],
|
cols: any[]
|
||||||
paramMap?: Map<string, VaParmapTarget>
|
|
||||||
): QueryClause[] {
|
): QueryClause[] {
|
||||||
const clauses: QueryClause[] = []
|
const clauses: QueryClause[] = []
|
||||||
for (const param of msg.parameters || []) {
|
for (const param of msg.parameters || []) {
|
||||||
@@ -81,194 +67,60 @@ export class VaFilterService {
|
|||||||
.toString()
|
.toString()
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
// An explicit URL mapping wins over the label->column lookup. The mapped
|
// A VA param whose label isn't a DC column (beyond case, which is handled)
|
||||||
// column has to exist in THIS table, else the entry does not apply here
|
// simply doesn't map — skip it.
|
||||||
// (and the same-name fallback would be the behaviour it exists to
|
const varName = labelToVar.get(key)
|
||||||
// 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) ?? '')
|
|
||||||
if (!varName) continue
|
if (!varName) continue
|
||||||
|
|
||||||
const col = mappedCol ?? this.findCol(varName, cols)
|
const col = this.findCol(varName, cols)
|
||||||
const kind = this.columnKind(col)
|
const kind = this.columnKind(col)
|
||||||
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
|
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
|
||||||
const built = this.buildClauseValueSet(
|
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
|
||||||
param,
|
// Format/validate every value for the column's SAS type. Numeric columns
|
||||||
kind,
|
// must receive a finite number (untrusted postMessage input is
|
||||||
order,
|
// interpolated into a SAS WHERE clause, so a non-numeric value here would
|
||||||
this.mappedTarget(param, paramMap)?.transform
|
// be an injection vector); temporal -> SAS internal number; char ->
|
||||||
)
|
// escaped. Invalid values are dropped.
|
||||||
if (!built) continue
|
const formatted = rawValues
|
||||||
clauses.push({
|
.filter((v) => v !== undefined && v !== null && v !== '')
|
||||||
GROUP_LOGIC: 'AND',
|
.map((v) => this.formatValueByKind(v, kind, order))
|
||||||
SUBGROUP_LOGIC: 'AND',
|
.filter((v): v is string => v !== null)
|
||||||
SUBGROUP_ID: 0,
|
if (formatted.length === 0) continue
|
||||||
VARIABLE_NM: varName,
|
clauses.push(this.clause(varName, formatted))
|
||||||
OPERATOR_NM: built.operator,
|
|
||||||
RAW_VALUE: built.rawValue
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return clauses
|
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
|
* 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
|
* 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
|
* the SAS kind from the value shape, so the clause matches what the metadata
|
||||||
* build later produces (no reconcile reload).
|
* 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(
|
buildInitialClauses(msg: VaMessage): QueryClause[] {
|
||||||
msg: VaMessage,
|
|
||||||
paramMap?: Map<string, VaParmapTarget>
|
|
||||||
): QueryClause[] {
|
|
||||||
const clauses: QueryClause[] = []
|
const clauses: QueryClause[] = []
|
||||||
for (const param of msg.parameters || []) {
|
for (const param of msg.parameters || []) {
|
||||||
if (!param || param.value === undefined || param.value === null) continue
|
if (!param || param.value === undefined || param.value === null) continue
|
||||||
const target = this.mappedTarget(param, paramMap)
|
// The DC column name is the VA `label`; `name` is VA's internal id (pr###).
|
||||||
// Without a mapping the DC column name is the VA `label`; `name` is VA's
|
const varName = (param.label ?? param.name ?? '').toString().trim()
|
||||||
// internal id (pr###). A `va_parmap` entry wins over both.
|
|
||||||
const varName = (target?.column ?? param.label ?? param.name ?? '')
|
|
||||||
.toString()
|
|
||||||
.trim()
|
|
||||||
if (!varName) continue
|
if (!varName) continue
|
||||||
|
|
||||||
const rawValues = (
|
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
|
||||||
Array.isArray(param.value) ? param.value : [param.value]
|
|
||||||
) as any[]
|
|
||||||
const nonEmpty = rawValues.filter(
|
const nonEmpty = rawValues.filter(
|
||||||
(v) => v !== undefined && v !== null && v !== ''
|
(v) => v !== undefined && v !== null && v !== ''
|
||||||
)
|
)
|
||||||
if (nonEmpty.length === 0) continue
|
if (nonEmpty.length === 0) continue
|
||||||
|
|
||||||
// A transform only ever produces dates, so it fixes the kind: inferring it
|
const formatted = nonEmpty
|
||||||
// from the value would see a formatted date either way, but with the
|
.map((v) =>
|
||||||
// transform the column is known to be temporal.
|
this.formatValueByKind(
|
||||||
const kind = target?.transform
|
v,
|
||||||
? ('date' as VaColumnKind)
|
this.inferKindFromValue(String(v).trim(), param.dataType)
|
||||||
: this.inferKindFromValue(String(nonEmpty[0]).trim(), param.dataType)
|
)
|
||||||
const built = this.buildClauseValueSet(
|
)
|
||||||
param,
|
.filter((v): v is string => v !== null)
|
||||||
kind,
|
if (formatted.length === 0) continue
|
||||||
undefined,
|
clauses.push(this.clause(varName, formatted))
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return clauses
|
return clauses
|
||||||
}
|
}
|
||||||
@@ -289,254 +141,16 @@ export class VaFilterService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private clause(varName: string, formatted: string[]): QueryClause {
|
||||||
* Transform mappings in this message that cannot be applied, one plain-English
|
return {
|
||||||
* line each: the column is not temporal, or the value is not a date the browser
|
GROUP_LOGIC: 'AND',
|
||||||
* can parse. `buildClauseValueSet` skips those parameters, so this is what stops
|
SUBGROUP_LOGIC: 'AND',
|
||||||
* the misconfiguration reading as a filter that simply matched nothing.
|
SUBGROUP_ID: 0,
|
||||||
*
|
VARIABLE_NM: varName,
|
||||||
* A missing column is left to `unresolvedMappings`, which already reports it.
|
OPERATOR_NM: formatted.length === 1 ? '=' : 'IN',
|
||||||
*/
|
RAW_VALUE:
|
||||||
unsupportedTransforms(
|
formatted.length === 1 ? formatted[0] : `(${formatted.join(',')})`
|
||||||
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.`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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). */
|
/** 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)
|
return this.parseMonthNameDate(value) ?? this.parseNumericDate(value, order)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962, 01AUG62) -> local Date, or null. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
private parseMonthNameDate(value: string): Date | null {
|
private parseMonthNameDate(value: string): Date | null {
|
||||||
const monthByName = (name: string): number =>
|
const m = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
|
||||||
VaFilterService.SAS_MONTHS.indexOf(
|
if (!m) return null
|
||||||
name.replace(/\./g, '').slice(0, 3).toUpperCase()
|
const [, day, monthAbbr, year] = m
|
||||||
)
|
const monthIndex = VaFilterService.SAS_MONTHS.indexOf(
|
||||||
|
monthAbbr.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)
|
|
||||||
if (monthIndex < 0) return null
|
if (monthIndex < 0) return null
|
||||||
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
|
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { VaMessagingService, VaMessage } from './va-messaging.service'
|
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).
|
// Sample message taken from the official SAS DDC docs (brush column included).
|
||||||
const SAMPLE = {
|
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', () => {
|
describe('dataColumns', () => {
|
||||||
it('excludes brush columns and keeps original indices', () => {
|
it('excludes brush columns and keeps original indices', () => {
|
||||||
const msg = service.parseMessage({ data: SAMPLE } as MessageEvent)!
|
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). */
|
/** DC variable names that should be visible (the rest are hidden). */
|
||||||
visibleColumns: string[] | null = null
|
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). */
|
/** True once the pre-bootstrap early buffer has been drained (drain-once). */
|
||||||
private earlyDrained = false
|
private earlyDrained = false
|
||||||
|
|
||||||
@@ -207,22 +187,6 @@ export class VaMessagingService {
|
|||||||
if (typeof data.resultName !== 'string') return null
|
if (typeof data.resultName !== 'string') return null
|
||||||
if (!Array.isArray(data.columns) || !Array.isArray(data.data)) 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 {
|
return {
|
||||||
version: data.version,
|
version: data.version,
|
||||||
resultName: data.resultName,
|
resultName: data.resultName,
|
||||||
@@ -230,7 +194,7 @@ export class VaMessagingService {
|
|||||||
availableRowCount: data.availableRowCount,
|
availableRowCount: data.availableRowCount,
|
||||||
data: data.data,
|
data: data.data,
|
||||||
columns: data.columns,
|
columns: data.columns,
|
||||||
parameters
|
parameters: Array.isArray(data.parameters) ? data.parameters : []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
(click)="openSasLogsModal()"
|
(click)="openRequestsModal()"
|
||||||
>
|
>
|
||||||
Open logs modal
|
Open requests modal
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ export class InfoModalComponent implements OnInit {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
openSasLogsModal() {
|
openRequestsModal() {
|
||||||
this.eventService.openSasLogsModal()
|
this.eventService.openRequestsModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
reload() {
|
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 Handsontable from 'handsontable'
|
||||||
import { MenuItemConfig } from 'handsontable/plugins/contextMenu'
|
import { MenuItemConfig } from 'handsontable/plugins/contextMenu'
|
||||||
import { downloadTablePdf, TablePdfInput } from './pdf-export.util'
|
|
||||||
|
|
||||||
export type HotExportFormat = 'csv' | 'xlsx' | 'pdf'
|
export type HotExportFormat = 'csv' | 'xlsx'
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cell types whose values must be exported as raw stored strings rather than
|
* 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.
|
* from any server-side (SAS) download.
|
||||||
*
|
*
|
||||||
* @param hot Live Handsontable instance.
|
* @param hot Live Handsontable instance.
|
||||||
* @param format `'csv'`, `'xlsx'` or `'pdf'`.
|
* @param format `'csv'` or `'xlsx'`.
|
||||||
* @param filename File name (without extension).
|
* @param filename File name (without extension).
|
||||||
* @param skipLeadingCols Number of leading UI/housekeeping columns to exclude
|
* @param skipLeadingCols Number of leading UI/housekeeping columns to exclude
|
||||||
* (e.g. the editor's `Delete?` column at index 0).
|
* (e.g. the editor's `Delete?` column at index 0).
|
||||||
@@ -222,11 +36,6 @@ export async function exportGrid(
|
|||||||
filename: string,
|
filename: string,
|
||||||
skipLeadingCols = 0
|
skipLeadingCols = 0
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (format === 'pdf') {
|
|
||||||
exportGridPdf(hot, filename, skipLeadingCols)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugin = hot.getPlugin('exportFile')
|
const plugin = hot.getPlugin('exportFile')
|
||||||
|
|
||||||
const opts: Record<string, any> = {
|
const opts: Record<string, any> = {
|
||||||
@@ -236,20 +45,26 @@ export async function exportGrid(
|
|||||||
|
|
||||||
// Selection-aware, contiguous range (visual indexes). Hidden columns are
|
// Selection-aware, contiguous range (visual indexes). Hidden columns are
|
||||||
// excluded automatically (exportHiddenColumns defaults to false).
|
// excluded automatically (exportHiddenColumns defaults to false).
|
||||||
const range = getExportRange(hot, skipLeadingCols)
|
const sel = hot.getSelectedRangeLast()
|
||||||
if (isRangeEmpty(range)) return
|
const lastRow = hot.countRows() - 1
|
||||||
// "Whole grid" means the export starts at visual column 0 - not at
|
const lastCol = hot.countCols() - 1
|
||||||
// `skipLeadingCols`, or a caller that skips a leading housekeeping column
|
|
||||||
// would look like a whole-grid export and the plugin would put that column
|
// Mirror HOT's own export item: only honor a selection that spans more than
|
||||||
// back in. Leaving `range` unset lets the plugin export the whole grid
|
// one cell. A right-click places a single-cell cursor, and the corner click
|
||||||
// exactly as its own menu item does; it is explicit whenever it is narrower.
|
// is select-all (negative coords) — both mean "export the whole table".
|
||||||
const isWholeGrid =
|
// sel.from/to.row/col are typed nullable (CellCoords allows an unset state),
|
||||||
range.top === 0 &&
|
// but a range returned by getSelectedRangeLast() always has real coordinates.
|
||||||
range.left === 0 &&
|
const isCornerSelectAll = !!sel && sel.from.row! < 0 && sel.from.col! < 0
|
||||||
range.bottom === hot.countRows() - 1 &&
|
if (sel && !sel.isSingleCell() && !isCornerSelectAll) {
|
||||||
range.right === hot.countCols() - 1
|
const top = Math.max(0, Math.min(sel.from.row!, sel.to.row!))
|
||||||
if (!isWholeGrid) {
|
const left = Math.max(0, Math.min(sel.from.col!, sel.to.col!))
|
||||||
opts['range'] = [range.top, range.left, range.bottom, range.right]
|
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') {
|
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
|
* exports the given grid via {@link exportGrid}. Add it to any HOT context-menu
|
||||||
* `items` map under the `export_file` key.
|
* `items` map under the `export_file` key.
|
||||||
*
|
*
|
||||||
@@ -351,11 +166,6 @@ export function buildExportMenuItem(
|
|||||||
key: 'export_file:xlsx',
|
key: 'export_file:xlsx',
|
||||||
name: 'Excel (.xlsx)',
|
name: 'Excel (.xlsx)',
|
||||||
callback: makeCb('xlsx')
|
callback: makeCb('xlsx')
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'export_file:pdf',
|
|
||||||
name: 'PDF (.pdf)',
|
|
||||||
callback: makeCb('pdf')
|
|
||||||
}
|
}
|
||||||
] as any
|
] 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)
|
|
||||||
}
|
|
||||||
+27
-46
@@ -2,10 +2,10 @@
|
|||||||
[clrModalOpen]="opened"
|
[clrModalOpen]="opened"
|
||||||
(clrModalOpenChange)="modalOpenChange($event)"
|
(clrModalOpenChange)="modalOpenChange($event)"
|
||||||
clrModalSize="xl"
|
clrModalSize="xl"
|
||||||
class="sas-logs-modal"
|
class="requests-modal"
|
||||||
>
|
>
|
||||||
<h3 class="modal-title">
|
<h3 class="modal-title">
|
||||||
SAS Logs
|
Request History
|
||||||
<p class="mt-0 d-inline-block">App Location: {{ sasjsConfig.appLoc }}</p>
|
<p class="mt-0 d-inline-block">App Location: {{ sasjsConfig.appLoc }}</p>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="modal-body h-70vh">
|
<div class="modal-body h-70vh">
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<clr-stack-label>
|
<clr-stack-label>
|
||||||
{{ programLog.serviceLink }}
|
{{ programLog.serviceLink }}
|
||||||
<span class="ml-10">
|
<span class="ml-10">
|
||||||
@if (programLog.warnings?.length! > 0) {
|
@if (programLog.logWarnings?.length! > 0) {
|
||||||
<span>
|
<span>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
class="mb-5 color-green"
|
class="mb-5 color-green"
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
></clr-icon>
|
></clr-icon>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@if (programLog.errors?.length! > 0) {
|
@if (programLog.logErrors?.length! > 0) {
|
||||||
<span>
|
<span>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
class="mb-5 color-red"
|
class="mb-5 color-red"
|
||||||
@@ -49,70 +49,51 @@
|
|||||||
<ng-template [(clrIfActive)]="sasLogActive">
|
<ng-template [(clrIfActive)]="sasLogActive">
|
||||||
<clr-tab-content>
|
<clr-tab-content>
|
||||||
<clr-tree>
|
<clr-tree>
|
||||||
@if (programLog.errors?.length) {
|
@if (programLog.logErrors) {
|
||||||
<clr-tree-node class="err-links">
|
<clr-tree-node class="err-links">
|
||||||
<clr-icon
|
<clr-icon
|
||||||
class="color-red"
|
class="color-red"
|
||||||
shape="exclamation-triangle"
|
shape="exclamation-triangle"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
Errors ({{ programLog.errors!.length }})
|
Errors ({{ programLog.logErrors.length! }})
|
||||||
@for (issue of programLog.errors; track $index) {
|
@for (
|
||||||
|
err of programLog.logErrors || [];
|
||||||
|
track err
|
||||||
|
) {
|
||||||
<clr-tree-node
|
<clr-tree-node
|
||||||
(click)="goToLogLine(issue, 'request_' + i)"
|
(click)="
|
||||||
|
goToLogLine(err, 'request_' + i, 'error')
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<button class="clr-treenode-link">
|
<button class="clr-treenode-link">
|
||||||
@if (issue.code) {
|
{{ err }}
|
||||||
<span class="issue-code">{{
|
|
||||||
issue.code
|
|
||||||
}}</span>
|
|
||||||
}
|
|
||||||
{{ issue.message }}
|
|
||||||
</button>
|
</button>
|
||||||
</clr-tree-node>
|
</clr-tree-node>
|
||||||
}
|
}
|
||||||
</clr-tree-node>
|
</clr-tree-node>
|
||||||
}
|
}
|
||||||
@if (programLog.warnings?.length) {
|
@if (programLog.logWarnings) {
|
||||||
<clr-tree-node class="err-links">
|
<clr-tree-node class="err-links">
|
||||||
<clr-icon
|
<clr-icon
|
||||||
class="color-green"
|
class="color-green"
|
||||||
shape="exclamation-circle"
|
shape="exclamation-circle"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
Warnings ({{ programLog.warnings!.length }})
|
Warnings ({{ programLog.logWarnings.length! }})
|
||||||
@for (
|
@for (
|
||||||
issue of programLog.warnings;
|
warning of programLog.logWarnings || [];
|
||||||
track $index
|
track warning
|
||||||
) {
|
) {
|
||||||
<clr-tree-node
|
<clr-tree-node
|
||||||
(click)="goToLogLine(issue, 'request_' + i)"
|
(click)="
|
||||||
|
goToLogLine(
|
||||||
|
warning,
|
||||||
|
'request_' + i,
|
||||||
|
'warning'
|
||||||
|
)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<button class="clr-treenode-link">
|
<button class="clr-treenode-link">
|
||||||
@if (issue.code) {
|
{{ warning }}
|
||||||
<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 }}
|
|
||||||
</button>
|
</button>
|
||||||
</clr-tree-node>
|
</clr-tree-node>
|
||||||
}
|
}
|
||||||
@@ -122,7 +103,7 @@
|
|||||||
@if (serverType !== 'SASVIYA') {
|
@if (serverType !== 'SASVIYA') {
|
||||||
<div
|
<div
|
||||||
class="log-wrapper saslog"
|
class="log-wrapper saslog"
|
||||||
[innerHTML]="programLog.logHtml"
|
[innerHTML]="programLog.logFile"
|
||||||
></div>
|
></div>
|
||||||
}
|
}
|
||||||
@if (serverType === 'SASVIYA') {
|
@if (serverType === 'SASVIYA') {
|
||||||
File renamed without changes.
@@ -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-wrapper>
|
||||||
</clr-toggle-container>
|
</clr-toggle-container>
|
||||||
</div>
|
</div>
|
||||||
<a (click)="openSasLogsModal()" clrDropdownItem>
|
<a (click)="openRequestsModal()" clrDropdownItem>
|
||||||
<span>SAS Logs</span>
|
<span>SAS Requests</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@if (!isDeployPage) {
|
@if (!isDeployPage) {
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export class HeaderActions implements OnInit, OnDestroy {
|
|||||||
this.eventService.toggleDarkMode(value)
|
this.eventService.toggleDarkMode(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
openSasLogsModal() {
|
openRequestsModal() {
|
||||||
this.eventService.openSasLogsModal()
|
this.eventService.openRequestsModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
onDebugModeChange(dropdownItem?: any): void {
|
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="TSQL_DDL">DDL (TSQL Flavour)</option>
|
||||||
<option value="EXCEL">Excel (.xlsx)</option>
|
<option value="EXCEL">Excel (.xlsx)</option>
|
||||||
<option value="MARKDOWN">Markdown (.md)</option>
|
<option value="MARKDOWN">Markdown (.md)</option>
|
||||||
<option value="PDF">PDF report (.pdf)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</clr-select-container>
|
</clr-select-container>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+8
-19
@@ -298,7 +298,7 @@ app-editor {
|
|||||||
|
|
||||||
// APP.COMPONENT
|
// APP.COMPONENT
|
||||||
my-app {
|
my-app {
|
||||||
app-sas-logs-modal {
|
app-requests-modal {
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1566,18 +1566,11 @@ body[cds-theme='light'] {
|
|||||||
}
|
}
|
||||||
// USER.COMPONENT END
|
// USER.COMPONENT END
|
||||||
|
|
||||||
// SAS-LOGS-MODAL.COMPONENT
|
// REQUESTS-MODAL.COMPONENT
|
||||||
app-sas-logs-modal {
|
app-requests-modal {
|
||||||
--line-height: 16px;
|
--line-height: 16px;
|
||||||
--line-count: 1;
|
|
||||||
--line-top: 0px;
|
--line-top: 0px;
|
||||||
|
|
||||||
.issue-code {
|
|
||||||
margin-right: 4px;
|
|
||||||
font-family: monospace;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-wrapper.saslog {
|
.log-wrapper.saslog {
|
||||||
position: relative;
|
position: relative;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
@@ -1598,7 +1591,7 @@ app-sas-logs-modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.temp-height-setter {
|
.temp-height-setter {
|
||||||
height: calc(var(--line-height) * var(--line-count));
|
height: var(--line-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-position-setter {
|
.line-position-setter {
|
||||||
@@ -1616,11 +1609,7 @@ app-sas-logs-modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.warning-highlight {
|
.warning-highlight {
|
||||||
background-color: rgba(242, 175, 0, 0.25);
|
background-color: rgba(255, 255, 0, 0.2);
|
||||||
}
|
|
||||||
|
|
||||||
.note-highlight {
|
|
||||||
background-color: rgba(0, 120, 210, 0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeOut {
|
@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;
|
display: block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sas-logs-modal .modal-content {
|
.requests-modal .modal-content {
|
||||||
padding: 20px 10px 5px 10px;
|
padding: 20px 10px 5px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The SAS log pane is `white-space: pre`, so it never wraps. The stock `xl`
|
/* 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
|
width is a fixed 57.6rem, which forced left/right scrolling to read a log
|
||||||
line. */
|
line. */
|
||||||
.sas-logs-modal .modal-dialog {
|
.requests-modal .modal-dialog {
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dcfrontend",
|
"name": "dcfrontend",
|
||||||
"version": "7.21.0",
|
"version": "7.18.0",
|
||||||
"description": "Data Controller",
|
"description": "Data Controller",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nogoo9/gitleaks": "8.30.1-post.2",
|
"@nogoo9/gitleaks": "8.30.1-post.2",
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"got": "11.8.6"
|
"got": "11.8.6"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"install": "cd client && npm i && cd ../sas && npm i",
|
||||||
"build-frontend": "cd client && npm run build",
|
"build-frontend": "cd client && npm run build",
|
||||||
"lint": "cd client && npm run lint",
|
"lint": "cd client && npm run lint",
|
||||||
"lint:fix": "cd client && npm run lint:fix",
|
"lint:fix": "cd client && npm run lint:fix",
|
||||||
|
|||||||
+2
-37
@@ -4,47 +4,12 @@
|
|||||||
"hasDoxygenHeader": true,
|
"hasDoxygenHeader": true,
|
||||||
"noSpacesInFileNames": true,
|
"noSpacesInFileNames": true,
|
||||||
"lowerCaseFileNames": true,
|
"lowerCaseFileNames": true,
|
||||||
"maxLineLength": 80,
|
"maxLineLength": 180,
|
||||||
"noTabIndentation": true,
|
"noTabIndentation": true,
|
||||||
"indentationMultiple": 2,
|
"indentationMultiple": 2,
|
||||||
"hasMacroNameInMend": true,
|
"hasMacroNameInMend": true,
|
||||||
"noNestedMacros": true,
|
"noNestedMacros": true,
|
||||||
"hasMacroParentheses": true,
|
"hasMacroParentheses": true,
|
||||||
"strictMacroDefinition": true,
|
"strictMacroDefinition": true,
|
||||||
"noSingleAsteriskComments": true,
|
"ignoreList":["sasjs/scratch"]
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"fromjs": [
|
"fromjs": [
|
||||||
{
|
{
|
||||||
"ADMIN": "all-users",
|
"ADMIN": "AllUsers",
|
||||||
"DCPATH": "/tmp/dcdata",
|
"DCPATH": "/tmp/dcdata",
|
||||||
"OUTFMT": "JSON"
|
"OUTFMT": "JSON"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,10 @@
|
|||||||
// The rules mirror services/hooks/mpe_derived_postedit.sas, in its order:
|
// 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
|
// normalise the incoming rows, reject a duplicate name and a bad field, then
|
||||||
// check the dependency graph that the resulting configuration would have (the
|
// check the dependency graph that the resulting configuration would have (the
|
||||||
// incoming rows plus the current rows they do not replace). The field rules
|
// incoming rows plus the current rows they do not replace). As on the server,
|
||||||
// are core's mp_derivedvarcheck, which the server hook calls and the read-time
|
// the collision between VAR_NM and a physical column is left to the read-time
|
||||||
// engine runs too - this file spells them out a second time, so a change there
|
// engine, which runs with the target library assigned - the hook does not have
|
||||||
// has to be mirrored here. As on the server, the collision between VAR_NM and
|
// it here either.
|
||||||
// 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() {
|
;(function mpeDerivedPostedit() {
|
||||||
@@ -85,45 +83,9 @@
|
|||||||
seen.add(key)
|
seen.add(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Field level checks ────────────────────────────────────────────────────
|
// ─── Field level checks, the rules the engine applies at read time ─────────
|
||||||
// Two passes, in the order the SAS hook runs them. The first is the fields
|
// C0 controls and DEL: what a single-line label cannot carry
|
||||||
// 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
|
|
||||||
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/
|
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
|
// true when the value carries a character outside the allowed set
|
||||||
const hasInvalidChar = (value, chars) =>
|
const hasInvalidChar = (value, chars) =>
|
||||||
@@ -131,6 +93,8 @@
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (isDeleted(row)) continue
|
if (isDeleted(row)) continue
|
||||||
|
const libref = field(row, 'LIBREF')
|
||||||
|
const dsn = field(row, 'DSN')
|
||||||
const name = field(row, 'VAR_NM')
|
const name = field(row, 'VAR_NM')
|
||||||
const type = field(row, 'VAR_TYPE')
|
const type = field(row, 'VAR_TYPE')
|
||||||
const len = Number(colVal(row, 'VAR_LEN'))
|
const len = Number(colVal(row, 'VAR_LEN'))
|
||||||
@@ -139,16 +103,19 @@
|
|||||||
const logic = strip(colVal(row, 'VAR_LOGIC'))
|
const logic = strip(colVal(row, 'VAR_LOGIC'))
|
||||||
|
|
||||||
let reason = ''
|
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))
|
else if (hasInvalidChar(name, NAME_CHARS))
|
||||||
reason = 'VAR_NM must be a valid SAS name'
|
reason = 'VAR_NM must be a valid SAS name'
|
||||||
else if ('0123456789'.includes(name[0]))
|
else if ('0123456789'.includes(name[0]))
|
||||||
reason = 'VAR_NM must not start with a digit'
|
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 (name === DELETE_MARK) reason = 'VAR_NM is reserved'
|
||||||
else if (type !== 'C' && type !== 'N')
|
else if (type !== 'C' && type !== 'N')
|
||||||
reason = 'VAR_TYPE must be C or 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)
|
else if (!Number.isInteger(len) || len < 1)
|
||||||
reason = 'VAR_LEN must be a positive integer'
|
reason = 'VAR_LEN must be a positive integer'
|
||||||
else if (type === 'C' && len > 32767)
|
else if (type === 'C' && len > 32767)
|
||||||
@@ -165,24 +132,20 @@
|
|||||||
reason = 'VAR_FMT must be a format name, without ; & % or /* */'
|
reason = 'VAR_FMT must be a format name, without ; & % or /* */'
|
||||||
else if (hasInvalidChar(fmt, FMT_CHARS))
|
else if (hasInvalidChar(fmt, FMT_CHARS))
|
||||||
reason = 'VAR_FMT must be a format name'
|
reason = 'VAR_FMT must be a format name'
|
||||||
// free text, in any script - what is refused is a control character,
|
// free text, in any script - what is refused is anything that cannot be
|
||||||
// and a value the engine would cut to 256
|
// a single-line column label, and a value the engine would cut to 256
|
||||||
else if (CONTROL_CHARS.test(flatten(label)))
|
else if (CONTROL_CHARS.test(label))
|
||||||
reason = 'VAR_LABEL must be plain text - no control characters'
|
reason =
|
||||||
|
'VAR_LABEL must be plain text - no line breaks or control characters'
|
||||||
else if ([...label].length > 256)
|
else if ([...label].length > 256)
|
||||||
reason = 'VAR_LABEL exceeds 256 characters'
|
reason = 'VAR_LABEL exceeds 256 characters'
|
||||||
else if (!logic) reason = 'VAR_LOGIC is required'
|
else if (!logic) reason = 'VAR_LOGIC is required'
|
||||||
else if (logic.length > 4000)
|
|
||||||
reason = 'VAR_LOGIC exceeds 4000 characters'
|
|
||||||
else if (logic.includes(';'))
|
else if (logic.includes(';'))
|
||||||
reason = 'VAR_LOGIC must be a single expression - remove the ;'
|
reason = 'VAR_LOGIC must be a single expression - remove the ;'
|
||||||
else if (logic.includes('&') || logic.includes('%'))
|
else if (logic.includes('&') || logic.includes('%'))
|
||||||
reason = 'VAR_LOGIC must not contain macro triggers'
|
reason = 'VAR_LOGIC must not contain macro triggers'
|
||||||
else if (logic.includes('/*') || logic.includes('*/'))
|
else if (logic.includes('/*') || logic.includes('*/'))
|
||||||
reason = 'VAR_LOGIC must not contain comments'
|
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}`)
|
if (reason) return reject(`MPE_DERIVED: ${name}: ${reason}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
let mimeType = `application/octet-stream`
|
let mimeType = `application/octet-stream`
|
||||||
let filename = `dc-download`
|
let filename = `dc-download`
|
||||||
|
|
||||||
/* typeof, not a truthiness test: if the service is called without a ds in scope
|
if (libref) filename = `${libref}.ddl`
|
||||||
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`)
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFile(_SASJS_WEBOUT_HEADERS, `Content-type: ${mimeType} \n Content-Disposition: attachment;filename=\"${filename}\"`, function (err) {
|
fs.writeFile(_SASJS_WEBOUT_HEADERS, `Content-type: ${mimeType} \n Content-Disposition: attachment;filename=\"${filename}\"`, function (err) {
|
||||||
if (err) throw err;
|
if (err) throw err;
|
||||||
|
|||||||
@@ -55,14 +55,6 @@ switch((type || '').toUpperCase()) {
|
|||||||
mimeType = 'application/octet-stream'
|
mimeType = 'application/octet-stream'
|
||||||
filename += '.md'
|
filename += '.md'
|
||||||
break
|
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:
|
default:
|
||||||
mimeType = 'application/octet-stream'
|
mimeType = 'application/octet-stream'
|
||||||
filename += '.sas'
|
filename += '.sas'
|
||||||
|
|||||||
Generated
+62
-27
@@ -6,8 +6,8 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "dc-sas",
|
"name": "dc-sas",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/cli": "6.5.0",
|
"@sasjs/cli": "6.2.0",
|
||||||
"@sasjs/core": "7.1.0"
|
"@sasjs/core": "6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
@@ -165,15 +165,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/cli": {
|
"node_modules/@sasjs/cli": {
|
||||||
"version": "6.5.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-6.2.0.tgz",
|
||||||
"integrity": "sha512-1I7B0iKd+16ByIGPToGfODU3S1bxO/3VCGpmu5alql/HlXdxu75djdpuzCovbsSeWm+M4FrsrBKDZTc9hhBrLA==",
|
"integrity": "sha512-zovAOCmyPqcm+gooAZgwvgGH8n5cas3sY0lA80+hyFoFtmZ8VjW/bh19LM8OVwheT9dBCRCx4fnHwjy5XGpzvQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/adapter": "^5.1.1",
|
"@sasjs/adapter": "^5.1.1",
|
||||||
"@sasjs/core": "^6.1.1",
|
"@sasjs/core": "^6.0.0",
|
||||||
"@sasjs/lint": "4.9.0",
|
"@sasjs/utils": "^4.1.0",
|
||||||
"@sasjs/utils": "^4.1.2",
|
|
||||||
"axios": "1.20.0",
|
"axios": "1.20.0",
|
||||||
"chalk": "4.1.2",
|
"chalk": "4.1.2",
|
||||||
"dotenv": "18.0.8",
|
"dotenv": "18.0.8",
|
||||||
@@ -195,18 +194,15 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"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": {
|
"node_modules/@sasjs/cli/node_modules/@sasjs/utils": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.1.0.tgz",
|
||||||
"integrity": "sha512-5QLBVlCqiiDW/GoxiHUCyT+8GJjJA/S/TX0DS/t4O4HC1Im/QpQzxlS86QkjLAQBBHovYPyh+v4pgdh+IYwaEQ==",
|
"integrity": "sha512-aC5pOugg6/2jKQR7ZaYsLtbsFy/iVacsrN3/Kzh6BouJUszuVrr13HMrQUSKcWL+rJzxYof7ymmmCl+vE6YwAw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fast-csv/format": "5.0.7",
|
"@fast-csv/format": "5.0.7",
|
||||||
@@ -239,24 +235,62 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/core": {
|
"node_modules/@sasjs/core": {
|
||||||
"version": "7.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-6.1.0.tgz",
|
||||||
"integrity": "sha512-teTXr60gS28UBHnmfoJHchMI1k/eqdgDAnGJ7SWXWi2idQwTM+svNJlOrOk63mBDgOPbRvlnrqMPEXm52au8Yg==",
|
"integrity": "sha512-U0Ekq/TXVMquYV+AG6urBQSUgqUvV37yduufy68QQVgDj3PJiLUJDcOXKOGbhWGD+Nv877VuPCviGYqrqbjXvg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/lint": {
|
"node_modules/@sasjs/lint": {
|
||||||
"version": "4.9.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-4.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-4.3.0.tgz",
|
||||||
"integrity": "sha512-QEBo19bWUdKIaE62Pk8eb35Q9EjHlifyOmbH+voItX6muCqHTX88SOzLD4MYtFVuagm/cnGplIVcKvUZzO+bSw==",
|
"integrity": "sha512-65cjy4IjA4PgSlicOruF0DI9N2HO3LJqtgphsZCg8g7N/StE2FWLJLJbD1N4OHxN6htrUfj5SEawJJiHo5T9Yw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/utils": "^4.0.0",
|
"@sasjs/utils": "4.0.0",
|
||||||
"ignore": "7.0.11"
|
"ignore": "7.0.11"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"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": {
|
"node_modules/@sasjs/utils": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.1.tgz",
|
||||||
@@ -791,9 +825,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.2",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
||||||
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
|
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -1072,6 +1106,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz",
|
||||||
"integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==",
|
"integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -3,7 +3,6 @@
|
|||||||
"description": "dc-sas",
|
"description": "dc-sas",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"cpfavicon": "cp sasjs/utils/favicon.ico ../client/dist/favicon.ico",
|
"cpfavicon": "cp sasjs/utils/favicon.ico ../client/dist/favicon.ico",
|
||||||
"lint:sas": "sasjs lint",
|
|
||||||
"build": "cd ../client && npm run build",
|
"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",
|
"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",
|
"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,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/cli": "6.5.0",
|
"@sasjs/cli": "6.2.0",
|
||||||
"@sasjs/core": "7.1.0"
|
"@sasjs/core": "6.1.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"nanoid": "3.3.18"
|
"nanoid": "3.3.18"
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ insert into &syslast set
|
|||||||
,group_name="DC Demo Group"
|
,group_name="DC Demo Group"
|
||||||
,group_desc="Custom Group for Data Controller Purposes"
|
,group_desc="Custom Group for Data Controller Purposes"
|
||||||
,user_name="mobeen@saspw"
|
,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;
|
proc sql; drop table backup;
|
||||||
*/
|
*/
|
||||||
@@ -15,4 +15,4 @@ proc sql;
|
|||||||
);
|
);
|
||||||
|
|
||||||
/* ALSO - make sure there is a DIRECT libname assigned in the
|
/* 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),
|
DD_SENSITIVITY char(64),
|
||||||
TX_TO float not null format=datetime19.,
|
TX_TO float not null format=datetime19.,
|
||||||
constraint pk
|
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',
|
modate num format=DATETIME. label='Date Modified',
|
||||||
nobs num label='Number of Physical (Actual, inc. deleted) Observations',
|
nobs num label='Number of Physical (Actual, inc. deleted) Observations',
|
||||||
constraint pk
|
constraint pk
|
||||||
primary key(libref,dsn,tx_to));
|
primary key(libref,dsn,tx_to));
|
||||||
@@ -13,4 +13,4 @@ quit;
|
|||||||
proc sql;
|
proc sql;
|
||||||
alter table dc.mpe_config add var_active num;
|
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";
|
,base_col="DSN";
|
||||||
,rule_value="services/validations/mpe_tables.dsn"
|
,rule_value="services/validations/mpe_tables.dsn"
|
||||||
,rule_active=1
|
,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
|
BREAKING CHANGE - deprecate MPE_APPROVALS and MPE_REVIEW (now MPE_SUBMIT does
|
||||||
both)
|
both)
|
||||||
|
|
||||||
|
<h4> SAS Macros </h4>
|
||||||
|
@li mm_assigndirectlib.sas
|
||||||
|
|
||||||
**/
|
**/
|
||||||
|
|
||||||
%let dclib=YOURDCLIB;
|
%let dclib=YOURDCLIB;
|
||||||
|
|||||||
@@ -80,4 +80,4 @@ create table &dclib..mpe_xlmap_info(
|
|||||||
,loadtype='UPDATE'
|
,loadtype='UPDATE'
|
||||||
,buskey='LOAD_REF XLMAP_ID XLMAP_RANGE_ID ROW_NO COL_NO'
|
,buskey='LOAD_REF XLMAP_ID XLMAP_RANGE_ID ROW_NO COL_NO'
|
||||||
,notes='Docs: https://docs.datacontroller.io/complex-excel-uploads'
|
,notes='Docs: https://docs.datacontroller.io/complex-excel-uploads'
|
||||||
;
|
;
|
||||||
@@ -21,4 +21,4 @@ insert into &dclib..mpe_config set
|
|||||||
,var_value="YES"
|
,var_value="YES"
|
||||||
,var_active=1
|
,var_active=1
|
||||||
,var_desc='Setting to NO will prevent each request being logged to the'
|
,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',
|
modified num format=DATETIME. label='Date Modified',
|
||||||
level num label='Library Concatenation Level',
|
level num label='Library Concatenation Level',
|
||||||
constraint pk_mpe_datastatus_OBJS
|
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
|
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.
|
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.
|
Each block below uses %do/%end on BOTH branches. A single-statement
|
||||||
A single-statement branch is rejected by the Viya compute engine with
|
%then/%else in open code (outside a %macro) is rejected by the Viya compute
|
||||||
"Expected %DO not found", so it must not be shortened back, and the macro
|
engine with "Expected %DO not found", so it must not be shortened back.
|
||||||
wrapper is what keeps the conditional out of open code.
|
|
||||||
|
|
||||||
**/
|
**/
|
||||||
|
|
||||||
@@ -48,7 +47,6 @@ proc sql noprint;
|
|||||||
where var_name='DC_MAXCELLS_WEBEDIT' and var_active=0 and &now lt tx_to;
|
where var_name='DC_MAXCELLS_WEBEDIT' and var_active=0 and &now lt tx_to;
|
||||||
quit;
|
quit;
|
||||||
|
|
||||||
%macro apply_view_limit();
|
|
||||||
%if &off_view=0 %then %do;
|
%if &off_view=0 %then %do;
|
||||||
proc sql noprint;
|
proc sql noprint;
|
||||||
update &dclib..mpe_config
|
update &dclib..mpe_config
|
||||||
@@ -69,10 +67,7 @@ quit;
|
|||||||
%else %do;
|
%else %do;
|
||||||
%put NOTE: DC_MAXOBS_WEBVIEW is switched off (var_active=0) - not changed;
|
%put NOTE: DC_MAXOBS_WEBVIEW is switched off (var_active=0) - not changed;
|
||||||
%end;
|
%end;
|
||||||
%mend apply_view_limit;
|
|
||||||
%apply_view_limit()
|
|
||||||
|
|
||||||
%macro apply_edit_limit();
|
|
||||||
%if &off_edit=0 %then %do;
|
%if &off_edit=0 %then %do;
|
||||||
proc sql noprint;
|
proc sql noprint;
|
||||||
update &dclib..mpe_config
|
update &dclib..mpe_config
|
||||||
@@ -95,10 +90,7 @@ quit;
|
|||||||
%else %do;
|
%else %do;
|
||||||
%put NOTE: DC_MAXOBS_WEBEDIT is switched off (var_active=0) - not changed;
|
%put NOTE: DC_MAXOBS_WEBEDIT is switched off (var_active=0) - not changed;
|
||||||
%end;
|
%end;
|
||||||
%mend apply_edit_limit;
|
|
||||||
%apply_edit_limit()
|
|
||||||
|
|
||||||
%macro apply_cell_limit();
|
|
||||||
%if &off_cells=0 %then %do;
|
%if &off_cells=0 %then %do;
|
||||||
proc sql noprint;
|
proc sql noprint;
|
||||||
update &dclib..mpe_config
|
update &dclib..mpe_config
|
||||||
@@ -106,7 +98,7 @@ quit;
|
|||||||
where var_name='DC_MAXCELLS_WEBEDIT' and var_active=1 and &now lt tx_to;
|
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
|
/* 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 expensive long before it is tall - 250 rows of a 1000 column
|
||||||
table is a quarter of a million cells. */
|
table is a quarter of a million cells. */
|
||||||
insert into &dclib..mpe_config
|
insert into &dclib..mpe_config
|
||||||
@@ -125,5 +117,3 @@ quit;
|
|||||||
%else %do;
|
%else %do;
|
||||||
%put NOTE: DC_MAXCELLS_WEBEDIT is switched off (var_active=0) - not changed;
|
%put NOTE: DC_MAXCELLS_WEBEDIT is switched off (var_active=0) - not changed;
|
||||||
%end;
|
%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
|
MPE_DERIVED rows added before this script runs will be lost when the table
|
||||||
is created, so run it before configuring any derived variables.
|
is created, so run it before configuring any derived variables.
|
||||||
|
|
||||||
@sasjslint {"maxLineLength": 99}
|
|
||||||
|
|
||||||
**/
|
**/
|
||||||
|
|
||||||
%let dclib=YOURDCLIB;
|
%let dclib=YOURDCLIB;
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ Areas for optimisation
|
|||||||
@li mf_fmtdttm.sas
|
@li mf_fmtdttm.sas
|
||||||
@li mf_getattrn.sas
|
@li mf_getattrn.sas
|
||||||
@li mf_getengine.sas
|
@li mf_getengine.sas
|
||||||
@li mf_getquotedstr.sas
|
|
||||||
@li mf_getschema.sas
|
@li mf_getschema.sas
|
||||||
@li mf_getuniquefileref.sas
|
@li mf_getuniquefileref.sas
|
||||||
@li mf_getuniquename.sas
|
@li mf_getuniquename.sas
|
||||||
@@ -123,8 +122,6 @@ Areas for optimisation
|
|||||||
@warning multitemporal loads (bitemporal for multiple points in business time)
|
@warning multitemporal loads (bitemporal for multiple points in business time)
|
||||||
are in experimental stage
|
are in experimental stage
|
||||||
|
|
||||||
@sasjslint {"noBannedStrings": false, "maxLineLength": 87}
|
|
||||||
|
|
||||||
**/
|
**/
|
||||||
|
|
||||||
%macro bitemporal_dataloader(
|
%macro bitemporal_dataloader(
|
||||||
@@ -1452,7 +1449,7 @@ run;
|
|||||||
data work.mp_storediffs;
|
data work.mp_storediffs;
|
||||||
set work.mp_storediffs;
|
set work.mp_storediffs;
|
||||||
if MOVE_TYPE="M" and IS_PK=0 and IS_DIFF=0 then delete;
|
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;
|
run;
|
||||||
proc append base=&outds_audit data=work.mp_storediffs;
|
proc append base=&outds_audit data=work.mp_storediffs;
|
||||||
run;
|
run;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
@li bitemporal_closeouts.sas
|
|
||||||
@li bitemporal_dataloader.sas
|
@li bitemporal_dataloader.sas
|
||||||
@li dc_assignlib.sas
|
@li dc_assignlib.sas
|
||||||
@li mf_getattrn.sas
|
@li mf_getattrn.sas
|
||||||
@@ -1350,6 +1349,44 @@ run;
|
|||||||
proc sql; insert into results set test="TEST&testnum",result="&result";
|
proc sql; insert into results set test="TEST&testnum",result="&result";
|
||||||
%end;
|
%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 */
|
/* test 31 - bitemporal closeouts with embedded speechmarks in PK */
|
||||||
%let testnum=31;
|
%let testnum=31;
|
||||||
%if &test=ALL or &test=&testnum %then %do;
|
%if &test=ALL or &test=&testnum %then %do;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
|
@li mf_getuniquename.sas
|
||||||
@li mf_getuser.sas
|
@li mf_getuser.sas
|
||||||
@li mp_assertdsobs.sas
|
@li mp_assertdsobs.sas
|
||||||
@li mpe_getgroups.sas
|
@li mpe_getgroups.sas
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
@li mf_getattrn.sas
|
@li mf_getattrn.sas
|
||||||
|
@li mf_existds.sas
|
||||||
@li mf_getuser.sas
|
@li mf_getuser.sas
|
||||||
|
|
||||||
@version 9.4
|
@version 9.4
|
||||||
@@ -226,4 +227,4 @@ run;
|
|||||||
|
|
||||||
filename __out clear;
|
filename __out clear;
|
||||||
|
|
||||||
%mend mpe_alerts ;
|
%mend mpe_alerts ;
|
||||||
@@ -1,83 +1,92 @@
|
|||||||
/**
|
/**
|
||||||
@file
|
@file
|
||||||
@brief Asserts that a service returned a sasjsAbort object
|
@brief Asserts that a service returned a sasjsAbort object
|
||||||
@details Reads the child's status row and records a PASS when the child was
|
@details Reads the child's response as text and records a PASS when it carries
|
||||||
canceled, which is what happens when a service rejects its input and calls
|
a sasjsAbort object, which is what mp_abort leaves in the webout when a service
|
||||||
mp_abort. When msg is supplied the ERRMSG value must match it, so the
|
rejects its input. When msg is supplied the MSG value inside that object must
|
||||||
assertion proves the service aborted for the expected reason rather than for
|
match it, so the assertion proves the service aborted for the expected reason
|
||||||
any reason at all.
|
rather than for any reason at all.
|
||||||
|
|
||||||
The message comes from the status row rather than from the response text: core
|
The response is scanned a line at a time and only the first MSG that follows
|
||||||
publishes STATE and ERRMSG per call, ERRMSG holds the whole message including
|
the sasjsAbort marker is taken: a single 32767 buffer both truncated longer
|
||||||
quotes and anything longer than a single record, and reading it needs no
|
responses and, with a leading `.*`, returned the last MSG in the text rather
|
||||||
parsing of the response.
|
than the abort's own.
|
||||||
|
|
||||||
Two things are handled here rather than in core:
|
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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
%mpe_execute(&_program,
|
%mpe_execute(&_program,
|
||||||
inputfiles=&f1:sascontroltable,
|
inputfiles=&f1:sascontroltable,
|
||||||
|
outref=web1,
|
||||||
outstatus=work.ab1,
|
outstatus=work.ab1,
|
||||||
failfast=0
|
failfast=0
|
||||||
)
|
)
|
||||||
%mpe_assert_abort(
|
%mpe_assert_abort(
|
||||||
desc=An invalid libds aborts the service,
|
desc=An invalid libds aborts the service,
|
||||||
outstatus=work.ab1,
|
outref=web1,
|
||||||
msg=Invalid libds
|
msg=Invalid libds
|
||||||
)
|
)
|
||||||
|
|
||||||
@param [in] desc The description recorded in the test results
|
@param [in] desc The description recorded in the test results
|
||||||
@param [in] outstatus The dataset given to %mpe_execute, holding STATE and
|
@param [in] outref The fileref given to %mx_execute, holding the response
|
||||||
ERRMSG for the child call
|
@param [in] msg= (0) Expected MSG value in the sasjsAbort object. 0 accepts
|
||||||
@param [in] msg= (0) Expected ERRMSG value. 0 accepts any abort message.
|
any abort message.
|
||||||
@param [out] outds= (work.test_results) Results dataset
|
@param [out] outds= (work.test_results) Results dataset
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
@li mp_assert.sas
|
@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;
|
%local ok seen abortseen;
|
||||||
%let ok=0;
|
%let ok=0;
|
||||||
%let seen=;
|
%let seen=;
|
||||||
%let abortseen=0;
|
%let abortseen=0;
|
||||||
|
|
||||||
%if %sysfunc(exist(&outstatus)) ne 1 %then %do;
|
%if %sysfunc(fileref(&outref))>0 %then %do;
|
||||||
%put %str(WARN)ING: &sysmacroname: &outstatus does not exist - the child left no status row;
|
%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;
|
%end;
|
||||||
%else %do;
|
%else %do;
|
||||||
data _null_;
|
data _null_;
|
||||||
length got $32767;
|
length line $32767 got $256;
|
||||||
set &outstatus;
|
retain prxid 0 inabort 0 found 0 abortseen 0;
|
||||||
if state='canceled' then call symputx('abortseen',1,'l');
|
if _n_=1 then prxid=prxparse('/"MSG" *: *"([^"]*)"/');
|
||||||
got=errmsg;
|
infile &outref end=eof lrecl=32767;
|
||||||
/* mp_abort appends cats(msg,'\n\nLog Extract:\n',logmsg). Those newlines
|
do while(not eof);
|
||||||
reach here as characters, as an escaped pair, or as a doubled pair - which
|
input;
|
||||||
one depends on the producer - so all three are tried */
|
line=_infile_;
|
||||||
cut=index(got,'0A0A'x||'Log Extract:');
|
if index(line,'sasjsAbort')>0 then do;
|
||||||
if cut=0 then cut=index(got,'\n\nLog Extract:');
|
abortseen=1;
|
||||||
if cut=0 then cut=index(got,'\\n\\nLog Extract:');
|
inabort=1;
|
||||||
if cut>1 then got=substr(got,1,cut-1);
|
end;
|
||||||
call symputx('seen',got,'l');
|
/* first MSG at or after the abort marker - stops at the first found */
|
||||||
run;
|
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;
|
%end;
|
||||||
|
|
||||||
%if &abortseen=1 %then %do;
|
%if &abortseen=1 %then %do;
|
||||||
%if "%superq(msg)"="0" %then %let ok=1;
|
%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;
|
%end;
|
||||||
%if "%superq(seen)" ne "" %then %put NOTE: &sysmacroname: sasjsAbort MSG=&seen;
|
%if "&seen" ne "" %then %put NOTE: &sysmacroname: sasjsAbort MSG=&seen;
|
||||||
|
|
||||||
%mp_assert(
|
%mp_assert(
|
||||||
iftrue=(&ok=1),
|
iftrue=(&ok=1),
|
||||||
|
|||||||
@@ -2,18 +2,25 @@
|
|||||||
@file
|
@file
|
||||||
@brief testing the mpe_assert_abort macro
|
@brief testing the mpe_assert_abort macro
|
||||||
|
|
||||||
@details The macro decides PASS/FAIL from the child's status row - STATE says
|
@details The macro decides PASS/FAIL by scanning the child's response text, so
|
||||||
whether the child was canceled, ERRMSG holds the reason - so the cases can be
|
the cases that matter can be built as files instead of services: an abort with
|
||||||
built as datasets instead of services: an abort with the expected message, an
|
the expected message, an abort with a different one, no abort at all, and a
|
||||||
abort with a different one, a completed call, a status dataset that does not
|
fileref the child never wrote to.
|
||||||
exist, a message past the old 256 character width, quotes, backslashes, and
|
|
||||||
the log extract that mp_abort appends to the message.
|
|
||||||
|
|
||||||
Each case asserts on the PASS/FAIL the macro recorded, so a regression in the
|
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
|
scan fails here rather than silently passing an abort assertion in a service
|
||||||
service test.
|
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>
|
<h4> SAS Macros </h4>
|
||||||
|
@li mf_getuniquefileref.sas
|
||||||
@li mp_assert.sas
|
@li mp_assert.sas
|
||||||
@li mpe_assert_abort.sas
|
@li mpe_assert_abort.sas
|
||||||
|
|
||||||
@@ -22,206 +29,98 @@
|
|||||||
and may not be re-distributed or re-sold without the express permission of
|
and may not be re-distributed or re-sold without the express permission of
|
||||||
4GL Apps Ltd.
|
4GL Apps Ltd.
|
||||||
|
|
||||||
@sasjslint {"maxLineLength": 83}
|
|
||||||
|
|
||||||
**/
|
**/
|
||||||
|
|
||||||
/** a status row as %mpe_execute leaves it: the job state and the message */
|
%let rc_any=work.assert_rc1;
|
||||||
%macro mk_status(ds, state, msg);
|
%let rc_other=work.assert_rc2;
|
||||||
/* the values come from the symbol table rather than from quoted text: a
|
%let rc_none=work.assert_rc3;
|
||||||
message holding double quotes would otherwise end the literal early */
|
%let rc_first=work.assert_rc4;
|
||||||
data &ds;
|
%let rc_split=work.assert_rc5;
|
||||||
length state $20 uri $256 errmsg $32767 platform $10;
|
%let rc_long=work.assert_rc6;
|
||||||
state=symget('state');
|
%let rc_noref=work.assert_rc7;
|
||||||
uri='';
|
|
||||||
errmsg=symget('msg');
|
|
||||||
platform='SASJS';
|
|
||||||
run;
|
|
||||||
%mend mk_status;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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(
|
%mpe_assert_abort(
|
||||||
desc=an abort with the expected MSG,
|
desc=an abort with the expected MSG,
|
||||||
outstatus=work.st1,
|
outref=&f1,
|
||||||
msg=Invalid libds,
|
msg=Invalid libds,
|
||||||
outds=work.rc1
|
outds=&rc_any
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test 2 - an abort for a different reason is a FAIL
|
* 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(
|
%mpe_assert_abort(
|
||||||
desc=an abort with a different MSG,
|
desc=an abort with a different MSG,
|
||||||
outstatus=work.st2,
|
outref=&f2,
|
||||||
msg=Invalid filter,
|
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, )
|
%let f3=%mf_getuniquefileref();
|
||||||
|
|
||||||
%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);
|
|
||||||
data _null_;
|
data _null_;
|
||||||
set &ds;
|
file &f3 termstr=crlf;
|
||||||
call symputx("&var",test_result,'g');
|
put '{"sasparams": [{"STATUS": "SUCCESS"}]}';
|
||||||
|
put '{"MSG": "Invalid libds"}';
|
||||||
run;
|
run;
|
||||||
%mend getres;
|
|
||||||
|
|
||||||
%getres(work.rc1, res_any)
|
%mpe_assert_abort(
|
||||||
%getres(work.rc2, res_other)
|
desc=a response with no sasjsAbort object,
|
||||||
%getres(work.rc3, res_none)
|
outref=&f3,
|
||||||
%getres(work.rc4, res_noref)
|
msg=0,
|
||||||
%getres(work.rc5, res_long)
|
outds=&rc_none
|
||||||
%getres(work.rc6, res_quotes)
|
)
|
||||||
%getres(work.rc7, res_logmark)
|
|
||||||
%getres(work.rc8, res_marker)
|
/**
|
||||||
%getres(work.rc9, res_nomsg)
|
* Test 4 - a fileref with no response written records a FAIL instead of aborting the step
|
||||||
%getres(work.rc10, res_bslash)
|
*/
|
||||||
%getres(work.rc11, res_doubled)
|
%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(
|
%mp_assert(
|
||||||
iftrue=("&res_any"="PASS"),
|
iftrue=("&res_any"="PASS"),
|
||||||
@@ -237,54 +136,15 @@ run;
|
|||||||
|
|
||||||
%mp_assert(
|
%mp_assert(
|
||||||
iftrue=("&res_none"="FAIL"),
|
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
|
outds=work.test_results
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
%mp_assert(
|
%mp_assert(
|
||||||
iftrue=("&res_noref"="FAIL"),
|
iftrue=("&res_noref"="FAIL"),
|
||||||
desc=%str(Checking a missing status dataset fails the assertion, not the step),
|
desc=%str(Checking an unassigned outref 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),
|
|
||||||
outds=work.test_results
|
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
Reference in new issue
Block a user