Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
428e70a4c1 | ||
|
|
94e040c006 | ||
|
|
cdc0ab2a91 | ||
|
|
8449ab3987 | ||
|
|
405b28dccc | ||
|
|
559221fad4 | ||
|
|
999e16b8fc | ||
|
|
ce5b1aa961 | ||
|
|
f74100a818 | ||
|
|
a4a0f9cb2f | ||
|
|
e6a5cce475 | ||
|
|
97b4cd4d61 | ||
|
|
eeffa92f9e | ||
|
|
dd25be8e75 | ||
|
|
2e40749052 | ||
|
|
fc687f8643 | ||
|
|
c97e8866db | ||
|
|
ad061946c3 | ||
|
|
914a1606bb | ||
|
|
c803773d3c | ||
|
|
b8eea5063c | ||
|
|
0a172b0c37 | ||
|
|
888873421c | ||
|
|
af1813f829 | ||
|
|
27686d3aad | ||
|
|
d84a1d50f4 | ||
|
|
cf7b21a8b6 | ||
|
|
60589c78b6 | ||
|
|
8e2cce4150 | ||
|
|
f7e753863b | ||
|
|
febfea3e5e | ||
|
|
3580d6e9eb | ||
|
|
bc9e3ae50f | ||
|
|
330448c496 | ||
|
|
00257f9160 | ||
|
|
e51953ca8a | ||
|
|
aa91daf9f7 | ||
|
|
00df87f189 | ||
|
|
65699bdd39 | ||
|
|
459ff2da37 | ||
|
|
c0ea810766 | ||
|
|
62e43034e4 | ||
|
|
80e8911af8 | ||
|
|
bd3c220af2 | ||
|
|
5e8910f238 | ||
|
|
221dd8a15e | ||
|
|
e9216b0895 | ||
|
|
37d81c902a | ||
|
|
689b5b6709 | ||
|
|
10157c1159 | ||
|
|
541a61ba08 | ||
|
|
ad50acf647 | ||
|
|
cd1ce4a7e2 | ||
|
|
7e3045a21b | ||
|
|
88010c3980 | ||
|
|
c5808a16eb | ||
|
|
0011f7e933 | ||
|
|
4ed1e0684d | ||
|
|
2623a3fb4b | ||
|
|
388f418bf4 | ||
|
|
f83dc359e2 | ||
|
|
411f6c3354 | ||
|
|
f042ca82ea | ||
|
|
15a1f34d52 | ||
|
|
6486c80ed4 | ||
|
|
dd482c4069 | ||
|
|
f58925b299 | ||
|
|
25ffa71add | ||
|
|
424b111737 | ||
|
|
849153d4a2 | ||
|
|
bc9e0996ec | ||
|
|
66039c91d7 | ||
|
|
abb1ecbcab | ||
|
|
337ea8e5e0 | ||
|
|
709acfad73 | ||
|
|
42355f17bc | ||
|
|
6ef2eec011 | ||
|
|
1ef3e0a97b | ||
|
|
56b6620ef4 | ||
|
|
fd272eec89 | ||
|
|
c463a4e03c | ||
|
|
23015ac41f | ||
|
|
a8f62bdfe2 | ||
|
|
355c46a7b6 | ||
|
|
661ee19cfa | ||
|
|
619fe9f9b9 | ||
|
|
7830b7bcfa | ||
|
|
f8f65681e1 | ||
|
|
7fb981daa9 | ||
|
|
469229aecb | ||
|
|
6c7c8ebf74 | ||
|
|
468bdf5ab3 | ||
|
|
e0da9f1098 | ||
|
|
cfe6647ca8 | ||
|
|
cdfb3d01aa | ||
|
|
e80ca2af4d | ||
|
|
81ccf758e9 | ||
|
|
7efbf31ea5 | ||
|
|
9b3cf50b9c | ||
|
|
e929ca047f | ||
|
|
d95ffdfb97 | ||
|
|
87e794f769 | ||
|
|
f182179403 | ||
|
|
803bb1ad76 | ||
|
|
1863076bc9 | ||
|
|
36e4e88eb1 | ||
|
|
1e224763ba | ||
|
|
059cdf176a | ||
|
|
45dce7b11b | ||
|
|
ddb1671e37 | ||
|
|
da30266278 | ||
|
|
fdcd8fa4b3 | ||
|
|
220408538b | ||
|
|
4bcee706ff | ||
|
|
ded8bd13b3 | ||
|
|
f2b1428ec3 | ||
|
|
ea300f73e7 | ||
|
|
359b570b85 | ||
|
|
d626cb8f2d | ||
|
|
0a52d20c5f | ||
|
|
a03638667f | ||
|
|
638c34a070 | ||
|
|
9aef2f4f03 | ||
|
|
9aea59dfef | ||
|
|
6513d09f05 | ||
|
|
356308e13f | ||
|
|
46bacdd6d9 | ||
|
|
582523dc87 | ||
|
|
105052b948 | ||
|
|
d0bc5ae297 | ||
|
|
31aa859153 | ||
|
|
a141e38da4 | ||
|
|
e3ec1da55c | ||
|
|
9c294c0ab5 | ||
|
|
fed43cb07e | ||
|
|
a20a5a627c | ||
|
|
f5aade5976 | ||
|
|
867faf8dd8 | ||
|
|
609f2a743c | ||
|
|
16e9b31e65 | ||
|
|
8096772119 | ||
|
|
36e168971c | ||
|
|
0d58be975d | ||
|
|
fe2388718a | ||
|
|
05b91cb43e | ||
|
|
072a133702 | ||
|
|
116c37b379 | ||
|
|
1fb4818d21 | ||
|
|
78e88a4112 | ||
|
|
bca1b9363f | ||
|
|
1b13120839 | ||
|
|
6cad23fce9 | ||
|
|
5fc0ff1ee3 | ||
|
|
5b70c000d7 | ||
|
|
d7cadc0b7b | ||
|
|
9914790777 | ||
|
|
e0ee99c178 | ||
|
|
5d183d1350 | ||
|
|
fa7a468fd3 | ||
|
|
d14395a0f8 | ||
|
|
f411b7d269 | ||
|
|
be8f445ae1 | ||
|
|
293022d46b | ||
|
|
60ec470404 | ||
|
|
ee3c8787ef | ||
|
|
5f443b0b16 | ||
|
|
3e8f200a1a | ||
|
|
03b67c44c7 | ||
|
|
4d462d310f | ||
|
|
1f1028a2a5 | ||
|
|
a07728be2b | ||
|
|
7d9df71fdb | ||
|
|
75b4acf1b1 | ||
|
|
73d25b1220 | ||
|
|
c4021a42ff | ||
|
|
698dea6d6b | ||
|
|
489819b051 | ||
|
|
0b7cf6a724 | ||
|
|
62f55db170 | ||
|
|
752d944c20 | ||
|
|
50f09c8db8 | ||
|
|
fb36379520 | ||
|
|
6e70d203d1 | ||
|
|
3ec53076e7 | ||
|
|
bb8d007e2d | ||
|
|
a128b609f8 | ||
|
|
37abc3adb7 | ||
|
|
ab36ff8671 | ||
|
|
56ed429183 | ||
|
|
6b5bf3e6be | ||
|
|
033ffd3025 | ||
|
|
ac97252b02 | ||
|
|
821bf9d1ca | ||
|
|
74060c0585 | ||
|
|
0ba7450b65 | ||
|
|
347dbb53b0 | ||
|
|
2dc2885c3f | ||
|
|
8806504967 | ||
|
|
aa07401766 | ||
|
|
4166679dbf | ||
|
|
688550a28d | ||
|
|
bd4ac50a69 | ||
|
|
7799a300df | ||
|
|
85c89ca2a4 | ||
|
|
96914f1cbd | ||
|
|
3445107d9a | ||
|
|
ad2da45389 | ||
|
|
de71b4456e | ||
|
|
40f61331aa | ||
|
|
0dd55c9796 | ||
|
|
04ce1861e7 | ||
|
|
7ac620cc22 | ||
|
|
f38d0f80ca | ||
|
|
9de56f8a0b | ||
|
|
7df4cb29cc | ||
|
|
66c233602f | ||
|
|
5585ae481e | ||
|
|
34691dceec | ||
|
|
422f766cd4 | ||
|
|
af31c36b67 | ||
|
|
c5a8de38b3 | ||
|
|
ccd468b335 | ||
|
|
88a38db877 | ||
|
|
4c198c6e31 | ||
|
|
561d7fc3ed | ||
|
|
9a047ad42e | ||
|
|
616a2928e7 | ||
|
|
ee31782fbf | ||
|
|
ace4a66701 | ||
|
|
7c9ce9a712 | ||
|
|
1b5c4b3ecd | ||
|
|
5f5d8681b9 | ||
|
|
9f70c56b58 | ||
|
|
5af8e6fc35 | ||
|
|
6a1128a463 | ||
|
|
2d5de9a324 | ||
|
|
12beeff695 | ||
|
|
1055a0bc4a | ||
|
|
d2de3ce88a | ||
|
|
be50cb5d08 | ||
|
|
ee5d7c678e | ||
|
|
cee1a954ee | ||
|
|
5f6569b9e5 | ||
|
|
813bce6856 | ||
|
|
4d9ad38df1 | ||
|
|
8bae21230e | ||
|
|
27ddcfb575 | ||
|
|
aa6d50e1ea | ||
|
|
0b010f2c65 | ||
|
|
52803225df | ||
|
|
3ede98fb18 | ||
|
|
9ef813d439 | ||
|
|
33d043ea92 | ||
|
|
d3dec682aa | ||
|
|
05ad5544e2 | ||
|
|
8e82a7c857 | ||
|
|
11ae41d626 | ||
|
|
b4fae88ae1 | ||
|
|
b0fd354ecd | ||
|
|
779006b0bf | ||
|
|
6f65bb691f | ||
|
|
3d21a0134c | ||
|
|
a51d9e5574 | ||
|
|
026930d3e4 | ||
|
|
05d2d1e08e | ||
|
|
cc01f540c2 | ||
|
|
ece67d37ba | ||
|
|
8f98ebe326 | ||
|
|
be79f3a1ea | ||
|
|
72a7963972 | ||
|
|
a5f61d4a73 | ||
|
|
f710d871ff | ||
|
|
d6e0662b16 | ||
|
|
f45b3259c7 | ||
|
|
b53f951307 | ||
|
|
9f8c71eb47 | ||
|
|
19fb032f0d | ||
|
|
662d00032c | ||
|
|
b26f5571b6 | ||
|
|
2b8a2fa103 | ||
|
|
56025cc052 | ||
|
|
a05554e67a | ||
|
|
402ec346b9 | ||
|
|
e48bf80ab4 | ||
|
|
dd958ac4e7 | ||
|
|
8fdb468ad3 | ||
|
|
af52e4a1bf | ||
|
|
b24eb731ce | ||
|
|
d81967e6a1 | ||
|
|
c81db17588 | ||
|
|
3c10d915a5 | ||
|
|
3ce006d6b4 | ||
|
|
cc4cbd52e4 | ||
|
|
1f4c4a4d82 | ||
|
|
75c1dccfd2 | ||
|
|
7094bfdf7c | ||
|
|
fead3d5b26 | ||
|
|
cd5bae7518 | ||
|
|
78d436f847 | ||
|
|
0d3d47500c | ||
|
|
768d33c618 | ||
|
|
6ffbdbadf9 | ||
|
|
b0e47fc4ed | ||
|
|
5693b4488b | ||
|
|
6522fcb28b | ||
|
|
7cecf79515 | ||
|
|
e90bcde512 | ||
|
|
9c32b1b1da | ||
|
|
9f8fc6f2eb | ||
|
|
b034fe5335 | ||
|
|
90af6b8515 | ||
|
|
99ff732d67 | ||
|
|
d5d28e92c8 | ||
|
|
93ef9228aa | ||
|
|
bcad05e032 | ||
|
|
dbc3044a0f | ||
|
|
1fc7b55e3b | ||
|
|
bcb8fde986 | ||
|
|
a20ce96744 | ||
|
|
55fd16d53c | ||
|
|
1385741f2d | ||
|
|
05126ae73a | ||
|
|
3a6a216b9d | ||
|
|
edac9a987a | ||
|
|
8169ef253d | ||
|
|
f2535ce335 | ||
|
|
831f0b27a9 | ||
|
|
f08c4c231f | ||
|
|
b505e45c6f | ||
|
|
3de6655f26 | ||
|
|
6e5093858b | ||
|
|
b6b00ab96e | ||
|
|
e49e8e6710 | ||
|
|
1354fced46 | ||
|
|
a0c9349e08 | ||
|
|
644a7eb33d | ||
|
|
c019224e96 | ||
|
|
05f3f49e0c |
No files matched your search
@@ -1,10 +1,11 @@
|
|||||||
# Testing
|
# Testing
|
||||||
|
|
||||||
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory.
|
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.
|
||||||
|
|
||||||
## 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`).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/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.
|
||||||
|
#
|
||||||
|
# Specs added later are appended rather than inserted, so the sequence that was
|
||||||
|
# known green keeps its shape. metanav is deliberately absent - see
|
||||||
|
# https://git.datacontroller.io/dc/dc/issues/395.
|
||||||
|
#
|
||||||
|
# download is held out for a different reason - its download button is covered by
|
||||||
|
# a fixed-position element, see
|
||||||
|
# https://git.datacontroller.io/dc/dc/issues/398.
|
||||||
|
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"
|
||||||
|
|
||||||
|
# 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="metanav download"
|
||||||
|
|
||||||
|
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
|
||||||
@@ -15,6 +15,22 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Scan for secrets (gitleaks)
|
||||||
|
# The local pre-commit hook is suppressed entirely when .npmrc sets
|
||||||
|
# ignore-scripts=true (the prepare script that would activate the
|
||||||
|
# hooks never runs), so CI is the enforcement point: a leaked
|
||||||
|
# credential must fail the build, not just a commit.
|
||||||
|
# Uses the same @nogoo9/gitleaks binary the pre-commit hook pins - the
|
||||||
|
# gitleaks-action requires a license key for organization repos.
|
||||||
|
# Runs before client/.npmrc is written below, because that file is
|
||||||
|
# created from the NPMRC secret and a later scan would flag the
|
||||||
|
# registry token this job injects itself.
|
||||||
|
# --no-git scans the working tree, which is what a pull request gate
|
||||||
|
# is about.
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
node_modules/.bin/gitleaks detect --no-git --source . --redact --no-banner -v
|
||||||
|
|
||||||
- name: Install Google Chrome
|
- name: Install Google Chrome
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
@@ -59,11 +75,33 @@ 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.
|
||||||
|
|
||||||
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
|
||||||
@@ -126,8 +164,28 @@ jobs:
|
|||||||
echo NODE_PATH=node >> .env
|
echo NODE_PATH=node >> .env
|
||||||
echo CORS=enable >> .env
|
echo CORS=enable >> .env
|
||||||
echo WHITELIST=http://localhost:4200 >> .env
|
echo WHITELIST=http://localhost:4200 >> .env
|
||||||
|
echo HTTP_PORT=5000 >> .env
|
||||||
|
# The server refuses to run as root unless RUN_AS names an account
|
||||||
|
# (a security change in the recent releases). This job runs as root
|
||||||
|
# in a throwaway container, so opt in explicitly.
|
||||||
|
echo RUN_AS=root >> .env
|
||||||
cat .env
|
cat .env
|
||||||
pm2 start api-linux --wait-ready
|
pm2 start api-linux --wait-ready
|
||||||
|
# `pm2 start --wait-ready` can return while the app is still
|
||||||
|
# launching (it does not always wait for the ready signal), and the
|
||||||
|
# next step hits :5000 immediately. Wait for the server to answer,
|
||||||
|
# and dump its log if it never does.
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||||
|
echo "SASjs server answered on attempt ${i}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "SASjs server did not come up within 120s"
|
||||||
|
pm2 list
|
||||||
|
pm2 logs api-linux --lines 100 --nostream
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Deploy mocked services
|
- name: Deploy mocked services
|
||||||
run: |
|
run: |
|
||||||
@@ -146,9 +204,20 @@ 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
|
||||||
timeout-minutes: 35
|
# One job, one SASjs server, one ng serve, one Chrome: four of each on one
|
||||||
|
# runner starved the app under test and every failure in that run was a
|
||||||
|
# timeout, not an assertion.
|
||||||
|
timeout-minutes: 50
|
||||||
run: |
|
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
|
||||||
@@ -161,18 +230,24 @@ jobs:
|
|||||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||||
cat ./cypress.config.ts
|
cat ./cypress.config.ts
|
||||||
# Start frontend and run cypress
|
# Start frontend and run cypress
|
||||||
# timeout 1800: SIGTERM after 30 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 1800 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"
|
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"
|
||||||
|
|
||||||
- name: Zip Cypress videos
|
- name: Zip Cypress videos
|
||||||
if: always()
|
# Only on a failure: a green run has nothing to watch, and zipping and
|
||||||
|
# uploading every run's videos is the part of the video cost that lands
|
||||||
|
# on the clock.
|
||||||
|
if: failure()
|
||||||
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: always()
|
if: failure()
|
||||||
|
# v3, not v4: this runner reports "GHESNotSupportedError:
|
||||||
|
# @actions/artifact v2.0.0+, upload-artifact@v4+ ... not supported on
|
||||||
|
# GHES", so the current major cannot upload here at all.
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: cypress-videos.zip
|
name: cypress-videos.zip
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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" >> client/.npmrc
|
||||||
|
echo "legacy-peer-deps=true" >> client/.npmrc
|
||||||
|
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
|
||||||
|
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}"
|
||||||
|
|
||||||
|
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}"
|
||||||
@@ -33,10 +33,30 @@ jobs:
|
|||||||
echo NODE_PATH=node >> .env
|
echo NODE_PATH=node >> .env
|
||||||
echo CORS=enable >> .env
|
echo CORS=enable >> .env
|
||||||
echo WHITELIST=http://localhost:4200 >> .env
|
echo WHITELIST=http://localhost:4200 >> .env
|
||||||
|
echo HTTP_PORT=5000 >> .env
|
||||||
|
# The server refuses to run as root unless RUN_AS names an account
|
||||||
|
# (a security change in the recent releases). This job runs as root
|
||||||
|
# in a throwaway container, so opt in explicitly.
|
||||||
|
echo RUN_AS=root >> .env
|
||||||
cat .env
|
cat .env
|
||||||
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
unzip linux.zip
|
unzip linux.zip
|
||||||
pm2 start api-linux --wait-ready
|
pm2 start api-linux --wait-ready
|
||||||
|
# `pm2 start --wait-ready` can return while the app is still
|
||||||
|
# launching (it does not always wait for the ready signal), and the
|
||||||
|
# next step hits :5000 immediately. Wait for the server to answer,
|
||||||
|
# and dump its log if it never does.
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||||
|
echo "SASjs server answered on attempt ${i}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "SASjs server did not come up within 120s"
|
||||||
|
pm2 list
|
||||||
|
pm2 logs api-linux --lines 100 --nostream
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: echo "$NPMRC" > client/.npmrc
|
run: echo "$NPMRC" > client/.npmrc
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Write .npmrc file
|
||||||
|
run: |
|
||||||
|
printf '%s\n' "$NPMRC" >> client/.npmrc
|
||||||
|
echo "legacy-peer-deps=true" >> client/.npmrc
|
||||||
|
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
|
||||||
|
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"
|
||||||
@@ -107,6 +107,16 @@ jobs:
|
|||||||
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
|
||||||
|
|
||||||
|
- 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: Setup and start SASjs server
|
- name: Setup and start SASjs server
|
||||||
run: |
|
run: |
|
||||||
npm i -g pm2
|
npm i -g pm2
|
||||||
@@ -117,8 +127,28 @@ jobs:
|
|||||||
echo NODE_PATH=node >> .env
|
echo NODE_PATH=node >> .env
|
||||||
echo CORS=enable >> .env
|
echo CORS=enable >> .env
|
||||||
echo WHITELIST=http://localhost:4200 >> .env
|
echo WHITELIST=http://localhost:4200 >> .env
|
||||||
|
echo HTTP_PORT=5000 >> .env
|
||||||
|
# The server refuses to run as root unless RUN_AS names an account
|
||||||
|
# (a security change in the recent releases). This job runs as root
|
||||||
|
# in a throwaway container, so opt in explicitly.
|
||||||
|
echo RUN_AS=root >> .env
|
||||||
cat .env
|
cat .env
|
||||||
pm2 start api-linux --wait-ready
|
pm2 start api-linux --wait-ready
|
||||||
|
# `pm2 start --wait-ready` can return while the app is still
|
||||||
|
# launching (it does not always wait for the ready signal), and the
|
||||||
|
# next step hits :5000 immediately. Wait for the server to answer,
|
||||||
|
# and dump its log if it never does.
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||||
|
echo "SASjs server answered on attempt ${i}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "SASjs server did not come up within 120s"
|
||||||
|
pm2 list
|
||||||
|
pm2 logs api-linux --lines 100 --nostream
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Deploy mocked services
|
- name: Deploy mocked services
|
||||||
run: |
|
run: |
|
||||||
@@ -174,11 +204,15 @@ jobs:
|
|||||||
# and its permissions are derived from this block.
|
# and its permissions are derived from this block.
|
||||||
# contents: write -> push CHANGELOG.md / package.json commit + tag, and
|
# contents: write -> push CHANGELOG.md / package.json commit + tag, and
|
||||||
# create the Release via the Gitea API.
|
# create the Release via the Gitea API.
|
||||||
|
# actions: write -> dispatch the Sites workflow for the new tag (that
|
||||||
|
# route is scoped to the actions unit, which contents
|
||||||
|
# does not grant).
|
||||||
# The TSDOC_TOKEN and CODE_DATACONTROLLER_IO secrets continue to be used
|
# The TSDOC_TOKEN and CODE_DATACONTROLLER_IO secrets continue to be used
|
||||||
# because they target *different* servers (webdoc.datacontroller.io and
|
# because they target *different* servers (webdoc.datacontroller.io and
|
||||||
# code.datacontroller.io), not the Gitea API.
|
# code.datacontroller.io), not the Gitea API.
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -247,6 +281,11 @@ 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: |
|
||||||
@@ -307,19 +346,6 @@ 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
|
||||||
@@ -334,7 +360,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 -k --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest")
|
RELEASE_JSON=$(curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/tags/${RELEASE_TAG}")
|
||||||
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')
|
||||||
|
|
||||||
@@ -361,10 +387,14 @@ jobs:
|
|||||||
# ahead of the installation footer. The section text lives in a
|
# ahead of the installation footer. The section text lives in a
|
||||||
# dedicated script below (.gitea/scripts/verify-section.sh) so this
|
# dedicated script below (.gitea/scripts/verify-section.sh) so this
|
||||||
# YAML scalar stays clean.
|
# YAML scalar stays clean.
|
||||||
|
# `jq -r` is required: without it jq emits the result as a JSON string
|
||||||
|
# literal (quoted, with \n escapes), and the PATCH below then encodes
|
||||||
|
# that literal a second time, so the release body renders as one
|
||||||
|
# escaped line instead of markdown.
|
||||||
VERIFY_SECTION=$(./.gitea/scripts/verify-section.sh)
|
VERIFY_SECTION=$(./.gitea/scripts/verify-section.sh)
|
||||||
NEW_BODY=$(jq -n --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 -k --fail-with-body -sS -X PATCH \
|
curl --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}')" \
|
||||||
@@ -374,7 +404,58 @@ 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 -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
|
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
|
||||||
done
|
done
|
||||||
echo "Uploading SHA256SUMS ..."
|
echo "Uploading SHA256SUMS ..."
|
||||||
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
|
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
|
||||||
|
|
||||||
|
- name: Publish the code documentation
|
||||||
|
# Typedoc (webdoc) and the SAS docs (code) are published by docs.yaml,
|
||||||
|
# not inline here. Two reasons: the v6 `surfer put --token` flow is gone
|
||||||
|
# in Surfer 7, which takes a Cloudron username plus an app password; and
|
||||||
|
# a dispatch means a stale or failed docs deploy can be republished
|
||||||
|
# without re-running this job, which semantic-release refuses once the
|
||||||
|
# tag exists.
|
||||||
|
#
|
||||||
|
# This runs AFTER the release assets are uploaded: the documentation is
|
||||||
|
# a side channel, and a docs problem must not cost a cut release its
|
||||||
|
# artefacts.
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
|
||||||
|
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||||
|
|
||||||
|
TAG="${RELEASE_TAG}"
|
||||||
|
echo "Dispatching code documentation for ${TAG}"
|
||||||
|
|
||||||
|
curl --fail-with-body -sS -X POST \
|
||||||
|
-H "$AUTH_HEADER" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
|
||||||
|
"$BASE/actions/workflows/docs.yaml/dispatches"
|
||||||
|
|
||||||
|
- name: Publish the demo site for this release
|
||||||
|
# The tag is created by this job's semantic-release step, so it carries
|
||||||
|
# the Actions user as its author - and the forge suppresses ref events
|
||||||
|
# from that user, which means `on: push: tags` in sites.yaml never fires.
|
||||||
|
# Dispatch it instead: the API call is authenticated, and the run it
|
||||||
|
# starts checks out the tag the same way.
|
||||||
|
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 Sites for ${TAG}"
|
||||||
|
|
||||||
|
# the dispatch endpoint resolves a bare name as a branch; a tag needs
|
||||||
|
# the refs/tags/ prefix
|
||||||
|
curl --fail-with-body -sS -X POST \
|
||||||
|
-H "$AUTH_HEADER" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
|
||||||
|
"$BASE/actions/workflows/sites.yaml/dispatches"
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
name: Sites
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'release/**'
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '24.15.0'
|
||||||
|
APP_LOC: /Public/app/dc
|
||||||
|
SURFER_HOST: https://demo.datacontroller.io
|
||||||
|
# A release branch publishes the staging site (a named site on the instance,
|
||||||
|
# served at its alias domain); a release tag publishes the demo, which is the
|
||||||
|
# default site and so needs no site name.
|
||||||
|
STAGING_SITE: staging
|
||||||
|
STAGING_DOMAIN: staging.datacontroller.io
|
||||||
|
|
||||||
|
# One deploy per target site: a newer push supersedes the one in flight, and two
|
||||||
|
# release branches (which share the staging site) cannot interleave their swaps.
|
||||||
|
# The demo and staging deploys are independent, so they do not queue.
|
||||||
|
concurrency:
|
||||||
|
group: surfer-${{ startsWith(gitea.ref, 'refs/tags/') && 'demo' || 'staging' }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# The tag is what the client's build stamp describes (src/version.ts
|
||||||
|
# runs git-describe), so the demo build needs the tags in the clone.
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Write .npmrc file
|
||||||
|
run: |
|
||||||
|
touch client/.npmrc
|
||||||
|
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||||
|
|
||||||
|
- name: Install client dependencies
|
||||||
|
run: |
|
||||||
|
cd client
|
||||||
|
# Decrypt and Install sheet
|
||||||
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
# The site serves this build. `sasjs build` rewrites its index.html to
|
||||||
|
# point the adapter at the in-browser backend.
|
||||||
|
run: |
|
||||||
|
cd client
|
||||||
|
npm run postinstall
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Install the SASjs CLI
|
||||||
|
# Pinned in sas/package.json - the browser build needs CLI >= 6.1.0
|
||||||
|
run: |
|
||||||
|
cd sas
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Start SASjs server
|
||||||
|
# Needed only to run makedata: the drive the site ships with is captured
|
||||||
|
# from a real server (see the snapshot step below).
|
||||||
|
run: |
|
||||||
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
|
unzip -q linux.zip
|
||||||
|
touch .env
|
||||||
|
echo RUN_TIMES=js >> .env
|
||||||
|
echo NODE_PATH=node >> .env
|
||||||
|
echo HTTP_PORT=5000 >> .env
|
||||||
|
# The server refuses to run as root unless RUN_AS names an account
|
||||||
|
echo RUN_AS=root >> .env
|
||||||
|
nohup ./api-linux > server.log 2>&1 &
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||||
|
echo "SASjs server answered on attempt ${i}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "SASjs server did not come up within 120s"
|
||||||
|
cat server.log
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Deploy the mocked services and seed the database
|
||||||
|
run: |
|
||||||
|
cd sas/mocks/sasjs
|
||||||
|
# Start from a clean appLoc so the snapshot below is this build alone,
|
||||||
|
# not a merge with whatever a previous run left on the drive.
|
||||||
|
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=$APP_LOC"
|
||||||
|
../../node_modules/.bin/sasjs cbd -t server-ci
|
||||||
|
# makedata replies with HTML (it is normally called as a URL redirect),
|
||||||
|
# which the CLI reports as "invalid Json string" - that exit is
|
||||||
|
# expected. A real failure must not be published as an empty site, so
|
||||||
|
# the drive is checked before the snapshot is taken.
|
||||||
|
../../node_modules/.bin/sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
|
||||||
|
|
||||||
|
DATA_DIR="../../../sasjs_root/drive/files$APP_LOC/data"
|
||||||
|
SEEDED=$(find "$DATA_DIR" -name '*.json' 2>/dev/null | wc -l)
|
||||||
|
echo "makedata seeded $SEEDED table(s) under $DATA_DIR"
|
||||||
|
if [ -z "$(find "$DATA_DIR" -name 'mpe_tables.json' 2>/dev/null | head -1)" ] || [ "$SEEDED" -lt 10 ]; then
|
||||||
|
echo "makedata did not seed the drive - refusing to publish a data-less site"
|
||||||
|
find ../../../sasjs_root/drive/files -maxdepth 3 2>/dev/null | head -20
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build the browser-hosted site
|
||||||
|
run: |
|
||||||
|
# The published sites ship a working licence so they are usable beyond
|
||||||
|
# the free tier. The pair is committed in sas/mocks/mock-licence.json
|
||||||
|
# rather than minted here, so every deploy of both sites carries the
|
||||||
|
# same key; the CI estate stays unlicensed because that file is outside
|
||||||
|
# the deployed sync folder, so the specs still exercise the free tier.
|
||||||
|
# The pair must round trip through the same WebCrypto the app uses,
|
||||||
|
# because a committed pair can drift (one half regenerated, a value
|
||||||
|
# truncated on copy) and nothing else would notice before publishing.
|
||||||
|
# Identical values in the two fields is the HTTP-format marker the app
|
||||||
|
# refuses in a secure context, so refuse to publish one.
|
||||||
|
node -e "
|
||||||
|
const d = require('./sas/mocks/mock-licence.json')
|
||||||
|
const b = (s) => Buffer.from(String(s || ''), 'base64')
|
||||||
|
if (!d.licenceKey || !d.activationKey || d.licenceKey === d.activationKey) {
|
||||||
|
console.error('sas/mocks/mock-licence.json must hold an HTTPS-format key pair')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
crypto.subtle
|
||||||
|
.importKey('pkcs8', b(d.activationKey), { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt'])
|
||||||
|
.then((key) => crypto.subtle.decrypt({ name: 'RSA-OAEP', hash: 'SHA-256' }, key, b(d.licenceKey)))
|
||||||
|
.then((plain) => {
|
||||||
|
const data = JSON.parse(new TextDecoder().decode(plain))
|
||||||
|
console.log('licence pair round trips: site', data.site_id, 'until', data.valid_until)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('sas/mocks/mock-licence.json does not decrypt:', e.message || e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
"
|
||||||
|
LICENCE_DIR="sasjs_root/drive/files$APP_LOC/mock-storage"
|
||||||
|
mkdir -p "$LICENCE_DIR"
|
||||||
|
cp sas/mocks/mock-licence.json "$LICENCE_DIR/licence.json"
|
||||||
|
|
||||||
|
# DC's data files exist only after makedata has run, so the drive is
|
||||||
|
# captured first and shipped as the site's starting drive. services/web9
|
||||||
|
# is the streamed frontend, which is already the site itself.
|
||||||
|
node sas/mocks/snapshot.js sasjs_root/drive "$APP_LOC" sas/mocks/sasjsbuild/mock-snapshot.json services/web9
|
||||||
|
cd sas/mocks/sasjs
|
||||||
|
../../node_modules/.bin/sasjs b -t server-ci
|
||||||
|
|
||||||
|
- name: Publish to Surfer
|
||||||
|
env:
|
||||||
|
SURFER_USER: allan
|
||||||
|
SURFER_PASSWORD: ${{ secrets.DEMO_DATACONTROLLER_IO }}
|
||||||
|
REF: ${{ gitea.ref }}
|
||||||
|
MESSAGE: Build from ${{ gitea.ref_name }} @ ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
# `deploy` uploads the folder as an archive and swaps the site in one
|
||||||
|
# step, so a failed upload leaves the current site serving.
|
||||||
|
if [ -z "$REF" ]; then
|
||||||
|
echo "REF is empty - cannot tell a branch push from a tag push"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SITE_ARGS=()
|
||||||
|
case "$REF" in
|
||||||
|
refs/tags/*) ;;
|
||||||
|
*) SITE_ARGS=(--site-name "$STAGING_SITE" --domain "$STAGING_DOMAIN") ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
npx --yes @cloudron/surfer@7.2.0 deploy \
|
||||||
|
--server "$SURFER_HOST" \
|
||||||
|
--username "$SURFER_USER" \
|
||||||
|
--password "$SURFER_PASSWORD" \
|
||||||
|
--message "$MESSAGE" \
|
||||||
|
"${SITE_ARGS[@]}" \
|
||||||
|
sas/mocks/sasjsbuild/mock
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# sas/mocks/mock-licence.json holds a licence key pair for the mock estate: the
|
||||||
|
# activation key is RSA key material the browser demo decrypts with, not a
|
||||||
|
# credential for any service.
|
||||||
|
sas/mocks/mock-licence.json:generic-api-key:2
|
||||||
|
sas/mocks/mock-licence.json:generic-api-key:3
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Hooks for the pre-commit framework - https://pre-commit.com
|
||||||
|
#
|
||||||
|
# The repo also ships native hooks in .git-hooks/, covering the secret scan, a
|
||||||
|
# 2MB commit size limit, a prettier check and Conventional Commits. But .npmrc
|
||||||
|
# sets ignore-scripts=true, so the `prepare` script that points git at them
|
||||||
|
# never runs on `npm i` - a fresh clone commits unchecked until the one-time
|
||||||
|
# command in CONTRIBUTING.md is run by hand.
|
||||||
|
#
|
||||||
|
# This file is the standard route instead: install the framework once with
|
||||||
|
# `pre-commit install`, and the secret scan runs on every commit. The hook is
|
||||||
|
# pinned to an exact gitleaks release tag.
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/gitleaks/gitleaks
|
||||||
|
rev: v8.30.1
|
||||||
|
hooks:
|
||||||
|
- id: gitleaks
|
||||||
@@ -32,6 +32,8 @@ Never consider a change complete until the relevant linters pass on the files yo
|
|||||||
|
|
||||||
- **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 `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).
|
- **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).
|
||||||
|
`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.
|
||||||
|
|
||||||
## No external assets
|
## No external assets
|
||||||
|
|
||||||
@@ -41,7 +43,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, `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, `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.
|
||||||
|
|
||||||
## Code comments and test names
|
## Code comments and test names
|
||||||
|
|
||||||
|
|||||||
+226
@@ -1,3 +1,229 @@
|
|||||||
|
# [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)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **deps:** bump @sasjs/core to 5.4.0 ([19fb032](https://git.datacontroller.io/dc/dc/commit/19fb032f0dbdd612651ca4ff3b55d1aefa4850d5))
|
||||||
|
* **deps:** pin @sasjs/utils and drop the unused jsrsasign ([05d2d1e](https://git.datacontroller.io/dc/dc/commit/05d2d1e08e2c3082f0dd5aa4629d91907c0401fc))
|
||||||
|
* **editor:** own the delete-record column client-side ([56025cc](https://git.datacontroller.io/dc/dc/commit/56025cc0524d34d3589992e9f1059c13fb5581ed))
|
||||||
|
* **editor:** surface the reserved-column refusal on every getdata path ([b26f557](https://git.datacontroller.io/dc/dc/commit/b26f5571b66b65166c1158817f2df3027ae45cdc))
|
||||||
|
* **filters:** do not turn a fractional temporal value into a literal ([f710d87](https://git.datacontroller.io/dc/dc/commit/f710d871fffc0fa451fd8cc4d037a26414725654))
|
||||||
|
* **filters:** fix the dynamic where clause deref and map its temporal values ([ece67d3](https://git.datacontroller.io/dc/dc/commit/ece67d37ba2c4e2037dea626b9730a692388f025))
|
||||||
|
* **filters:** keep the range field readable and cover a BETWEEN in the rewrite ([be79f3a](https://git.datacontroller.io/dc/dc/commit/be79f3a1ea5e4c4a157396d276f2c6654f615d39))
|
||||||
|
* **filters:** map an IN entry to its unformatted value before the literal ([8f98ebe](https://git.datacontroller.io/dc/dc/commit/8f98ebe326978b8da58d8ac895ec946e562b5de4))
|
||||||
|
* **filters:** stop the temporal rewrite corrupting a typed value, and bind the range dropdown to the unformatted value ([a5f61d4](https://git.datacontroller.io/dc/dc/commit/a5f61d4a73051237b671695f67122c237770c95a))
|
||||||
|
* **filters:** use the unformatted value in every IN branch ([cc01f54](https://git.datacontroller.io/dc/dc/commit/cc01f540c29d3d5f7dd86943b6eed82b332ce868))
|
||||||
|
* **makedata:** shorten the DC_MAXCELLS_WEBEDIT description to fit var_desc ([a51d9e5](https://git.datacontroller.io/dc/dc/commit/a51d9e5574383e5d1e1943c95261fbce63e8788d))
|
||||||
|
* **multi-dataset:** surface the reserved-column guard, use the name constant ([2b8a2fa](https://git.datacontroller.io/dc/dc/commit/2b8a2fa1039da3ab888af8c3ffae7e1b848b250a))
|
||||||
|
* **release:** emit the release body as raw text, not a JSON string ([a05554e](https://git.datacontroller.io/dc/dc/commit/a05554e67a8d44e32e2d9f5e4a959a9d0592be5e))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **filters:** show dates as date literals in the filter query ([662d000](https://git.datacontroller.io/dc/dc/commit/662d00032c27634bbc2d82c392e7875a2e174eff))
|
||||||
|
|
||||||
|
# [7.17.0](https://git.datacontroller.io/dc/dc/compare/v7.16.0...v7.17.0) (2026-10-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **client:** do not publish an empty getcols fallback to the picker ([9f8fc6f](https://git.datacontroller.io/dc/dc/commit/9f8fc6f2eb83c78ea9cb220bb74059bab39e66ad))
|
||||||
|
* **client:** map the getcols fallback onto the picker vocabulary ([7cecf79](https://git.datacontroller.io/dc/dc/commit/7cecf795153138d6f2ad1f87f7bdd12b5d861341))
|
||||||
|
* **client:** pin the dependency entries this release changes ([b034fe5](https://git.datacontroller.io/dc/dc/commit/b034fe5335c41ec0289e459270839ee7caa8e126))
|
||||||
|
* **deploy:** apply the same identity-call fix to the automatic deploy ([5693b44](https://git.datacontroller.io/dc/dc/commit/5693b4488b0328022c69dd34f98662d660612578))
|
||||||
|
* **deploy:** disable the manual deploy actions without an admin group ([6ffbdba](https://git.datacontroller.io/dc/dc/commit/6ffbdbadf9804e0128694ed1c2bdb8f957743ad9))
|
||||||
|
* **deploy:** disable the manual deploy file inputs while admin groups load ([7094bfd](https://git.datacontroller.io/dc/dc/commit/7094bfdf7cdab4c70e5f1a7763a8a8aeb8cc61a7))
|
||||||
|
* **deploy:** guard the manual deploy form and Viya identity calls ([6522fcb](https://git.datacontroller.io/dc/dc/commit/6522fcb28b67b910b2784b93251f591bbc79d294))
|
||||||
|
* **deploy:** never bake an empty admin group into the deploy script ([b0e47fc](https://git.datacontroller.io/dc/dc/commit/b0e47fc4ed4e36878de6505e678667b14d19c5e6))
|
||||||
|
* **deps:** pin shelljs to clear the braces advisory in the sas audit ([6e50938](https://git.datacontroller.io/dc/dc/commit/6e5093858be106a1c3545a16119d246f69aac3da))
|
||||||
|
* **editor:** check the EDIT limits once, and skip switched-off options ([f08c4c2](https://git.datacontroller.io/dc/dc/commit/f08c4c231f019979a27fe71fe73798752c015710))
|
||||||
|
* **filters:** carry TYPE on the fallback list, and drop the temporal exclusion ([bcb8fde](https://git.datacontroller.io/dc/dc/commit/bcb8fde9868b8e1ded1590e204c34054f62d8250))
|
||||||
|
* **filters:** source the picker variables from public/getcols when the table is not returned ([3a6a216](https://git.datacontroller.io/dc/dc/commit/3a6a216b9d4dfc462e05031039c47002a9156130))
|
||||||
|
* **getcols:** use %global and %mp_getcols, and merge the base branch ([1385741](https://git.datacontroller.io/dc/dc/commit/1385741f2d303c1432f1f0ba637dd3cb39500a71))
|
||||||
|
* **getcols:** widen TYPE before assigning the client vocabulary ([bcad05e](https://git.datacontroller.io/dc/dc/commit/bcad05e03291fc3e2b72a4465629e1e330d70c8a))
|
||||||
|
* **getdata:** apply row level security to selectbox source values ([768d33c](https://git.datacontroller.io/dc/dc/commit/768d33c618a7ea453c3c19723f342a170ec576ea)), closes [#340](https://git.datacontroller.io/dc/dc/issues/340)
|
||||||
|
* **mocks:** read MPE_CONFIG correctly, and pin the limits to it ([05126ae](https://git.datacontroller.io/dc/dc/commit/05126ae73a1ab2461066eb701b7475674b96f1ec))
|
||||||
|
* repair the SAS suite for the row/cell limits and default the manual deploy admin group ([99ff732](https://git.datacontroller.io/dc/dc/commit/99ff732d67425b01a8a8f5e3e7c98b3e6d604323))
|
||||||
|
* **requests-modal:** widen the dialog so log lines need less scrolling ([3c10d91](https://git.datacontroller.io/dc/dc/commit/3c10d915a5134247aff0e987dd4d5c83c197e4c5))
|
||||||
|
* **sas:** keep format and label in the public getcols response ([9c32b1b](https://git.datacontroller.io/dc/dc/commit/9c32b1b1dae00e4b7bfd4acad94cbed831819d84))
|
||||||
|
* **sas:** keep the SAS C/N vocabulary in the public getcols response ([e90bcde](https://git.datacontroller.io/dc/dc/commit/e90bcde51218c9e5f562f7aaa65506761b21d6bd))
|
||||||
|
* **sas:** run the row/cell limit migration as a Viya job ([90af6b8](https://git.datacontroller.io/dc/dc/commit/90af6b851542a97f79172b655070cda559386a5c))
|
||||||
|
* **startup:** stack the SASjs tag detail so the dump is readable ([1f4c4a4](https://git.datacontroller.io/dc/dc/commit/1f4c4a4d8297568f46bcc16ea208673fedad345b))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **debug:** keep the debug toggle state across a page refresh ([3ce006d](https://git.datacontroller.io/dc/dc/commit/3ce006d6b469b4ece2bc769f0a32a1792eb1e774))
|
||||||
|
* **editor:** raise the row limits and add a cell limit to the EDIT screen ([3de6655](https://git.datacontroller.io/dc/dc/commit/3de6655f26b7e895f60266e9931b46cfc0053975))
|
||||||
|
* **mocks:** read the DC_MAX* options from MPE_CONFIG, and mock the DDL export ([edac9a9](https://git.datacontroller.io/dc/dc/commit/edac9a987aad3e4217641eed43f697f61c4bf739))
|
||||||
|
* **startup:** call out a missing SASjs tag on the boot screen ([b24eb73](https://git.datacontroller.io/dc/dc/commit/b24eb731ce089acf496bed1e11a5e34208c1bd11))
|
||||||
|
* **startup:** dump the SASjs tag contents in the boot screen checks ([75c1dcc](https://git.datacontroller.io/dc/dc/commit/75c1dccfd23557ab321cd7abd1508f20d817c7b8))
|
||||||
|
* **system:** export the DC library DDL from the system screen ([05f3f49](https://git.datacontroller.io/dc/dc/commit/05f3f49e0c4d7005fa1f93852d73f2368395ab28)), closes [#179](https://git.datacontroller.io/dc/dc/issues/179)
|
||||||
|
|
||||||
# [7.16.0](https://git.datacontroller.io/dc/dc/compare/v7.15.0...v7.16.0) (2026-09-28)
|
# [7.16.0](https://git.datacontroller.io/dc/dc/compare/v7.15.0...v7.16.0) (2026-09-28)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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, 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, dc-estate-testing for deploying to and testing on the shared estate, plus npx-installed sasjs/ sas skills).
|
||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
|
|
||||||
|
|||||||
+13
-9
@@ -19,6 +19,8 @@ git config core.hooksPath ./.git-hooks
|
|||||||
|
|
||||||
The pre-commit hook requires the gitleaks binary provided by the root `@nogoo9/gitleaks` devDependency, so make sure `npm i` has run in the repo root before your first commit.
|
The pre-commit hook requires the gitleaks binary provided by the root `@nogoo9/gitleaks` devDependency, so make sure `npm i` has run in the repo root before your first commit.
|
||||||
|
|
||||||
|
The standard [pre-commit](https://pre-commit.com) route is available as well, and does not depend on the lifecycle scripts above: install it once with `pre-commit install` and the secret scan in [`.pre-commit-config.yaml`](./.pre-commit-config.yaml) runs on every commit. The same scan also runs in CI, so a leaked credential fails the build regardless of which local hooks are active.
|
||||||
|
|
||||||
## Dependencies that requires licences
|
## Dependencies that requires licences
|
||||||
|
|
||||||
[SheetJS Pro Version](https://www.npmjs.com/package/sheetjs)
|
[SheetJS Pro Version](https://www.npmjs.com/package/sheetjs)
|
||||||
@@ -48,15 +50,12 @@ npm i
|
|||||||
|
|
||||||
[Handsontable](https://www.npmjs.com/package/handsontable)
|
[Handsontable](https://www.npmjs.com/package/handsontable)
|
||||||
|
|
||||||
Licence should be inserted in the `client/src/index.html` file:
|
The licence key comes from the backend licence: the decrypted licence carries `hot_license_key`,
|
||||||
|
which is published on the `hot_license_key` subject and pushed into every Handsontable instance.
|
||||||
|
Until a licence is loaded the client falls back to the non-commercial key
|
||||||
|
(`DEFAULT_HOT_LICENCE_KEY` in `client/src/app/services/licence.service.ts`), so there is nothing to
|
||||||
|
configure in `index.html` for this.
|
||||||
|
|
||||||
```
|
|
||||||
<sasjs
|
|
||||||
...
|
|
||||||
hotLicenceKey="LICENCE-GOES-HERE"
|
|
||||||
>
|
|
||||||
</sasjs>
|
|
||||||
```
|
|
||||||
## Development
|
## Development
|
||||||
Update `client/src/index.html` so that it points to your SAS9, SASVIYA or SASJS backend.
|
Update `client/src/index.html` so that it points to your SAS9, SASVIYA or SASJS backend.
|
||||||
Be aware that VIYA can be configured in such way that it would not work with cross origin frontend.
|
Be aware that VIYA can be configured in such way that it would not work with cross origin frontend.
|
||||||
@@ -87,6 +86,7 @@ RUN_TIMES=js
|
|||||||
NODE_PATH=node
|
NODE_PATH=node
|
||||||
CORS=enable
|
CORS=enable
|
||||||
WHITELIST=http://localhost:4200
|
WHITELIST=http://localhost:4200
|
||||||
|
HTTP_PORT=5000
|
||||||
```
|
```
|
||||||
|
|
||||||
`RUN_TIMES=js` means the server executes `.js` files as Stored Programs (no SAS binary needed). `CORS=enable` plus `WHITELIST` allows the Angular dev server on port 4200 to call the mock services on port 5000. Start the server:
|
`RUN_TIMES=js` means the server executes `.js` files as Stored Programs (no SAS binary needed). `CORS=enable` plus `WHITELIST` allows the Angular dev server on port 4200 to call the mock services on port 5000. Start the server:
|
||||||
@@ -95,7 +95,7 @@ WHITELIST=http://localhost:4200
|
|||||||
./api-linux # or api-macos / api-win.exe depending on your platform
|
./api-linux # or api-macos / api-win.exe depending on your platform
|
||||||
```
|
```
|
||||||
|
|
||||||
The server runs in desktop mode (no auth) on `http://localhost:5000` by default.
|
`HTTP_PORT` is not optional in SASjs Server 3.x: `PORT` and `PROTOCOL` are deprecated, and a server started without `HTTP_PORT` or `HTTPS_PORT` exits with "no listener is enabled". With it set, the server runs in desktop mode (no auth) on `http://localhost:5000`.
|
||||||
|
|
||||||
### 2. Deploy the mock services and streaming frontend
|
### 2. Deploy the mock services and streaming frontend
|
||||||
|
|
||||||
@@ -134,6 +134,10 @@ There is also a SAS 9 mock variant under `sas/mocks/sas9/` (target `sas9-mocks`)
|
|||||||
|
|
||||||
After changing mock services, re-run `sasjs cbd -t server-ci` from `sas/mocks/sasjs/`. After changing the frontend, rebuild (`cd client && npm run build`) then re-deploy so the streaming app picks up the new `client/dist/`. If you are using `ng serve` (step 3), only the mock services need re-deploying; the frontend hot-reloads automatically.
|
After changing mock services, re-run `sasjs cbd -t server-ci` from `sas/mocks/sasjs/`. After changing the frontend, rebuild (`cd client && npm run build`) then re-deploy so the streaming app picks up the new `client/dist/`. If you are using `ng serve` (step 3), only the mock services need re-deploying; the frontend hot-reloads automatically.
|
||||||
|
|
||||||
|
### Demo and staging sites
|
||||||
|
|
||||||
|
A push to `release/**` publishes [staging.datacontroller.io](https://staging.datacontroller.io); a release tag (`v*`, created by the release job after its tests pass) publishes [demo.datacontroller.io](https://demo.datacontroller.io) - workflow file `.gitea/workflows/sites.yaml`, using the `DEMO_DATACONTROLLER_IO` Surfer app password (user `allan`). Both are sites on one multi-site Surfer instance; the demo is its default site. The job builds the mocks as a browser-hosted site (SASjs CLI >= 6.1, `streamConfig.mockWeb`), so the services run in a service worker and the drive lives in the visitor's IndexedDB - there is no server. It runs `makedata` on a throwaway SASjs Server first and ships that drive as `sasjsbuild/mock-snapshot.json`; without it the site opens with no data. The estate carries no licence key, so CI runs on the free tier until a key is registered through the licensing screen (`admin/registerkey`) - the Cypress setup does that for the specs that need one. The Sites job writes a licence into the published drive, so demo and staging are fully usable. The pair lives in `sas/mocks/mock-licence.json`: the two values are one RSA key and are regenerated together, and the `.gitleaksignore` entries for it are line-scoped, so inserting a line above the pair re-fires the scanner rather than revealing a new secret.
|
||||||
|
|
||||||
## GUI Elements
|
## GUI Elements
|
||||||
|
|
||||||
For documentation on the Clarity Design System, including a list of components and example usage, see [our website](https://vmware.github.io/clarity).
|
For documentation on the Clarity Design System, including a list of components and example usage, see [our website](https://vmware.github.io/clarity).
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from 'fs'
|
||||||
import { defineConfig } from 'cypress'
|
import { defineConfig } from 'cypress'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -34,7 +35,28 @@ 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)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 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) => {
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ context('special missings clip (DEMO_01)', function () {
|
|||||||
this.beforeEach(() => {
|
this.beforeEach(() => {
|
||||||
cy.visit(hostUrl + appLocation)
|
cy.visit(hostUrl + appLocation)
|
||||||
|
|
||||||
// The mock estate carries a valid licence key (mock-storage/licence.json),
|
// Assumes the estate is already activated (the licensing spec registers a
|
||||||
// so the app activates directly with no free-tier banner in shot.
|
// valid key); otherwise a licensing page would sit in shot.
|
||||||
cy.get('.nav-tree', { timeout: longerCommandTimeout }).should('exist')
|
cy.get('.nav-tree', { timeout: longerCommandTimeout }).should('exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
|||||||
// 2. Ordinary columns (e.g. SOME_DROPDOWN) still get their rule via the
|
// 2. Ordinary columns (e.g. SOME_DROPDOWN) still get their rule via the
|
||||||
// new per-row cols[].COLTYPE path — test 2 proves the migration didn't
|
// new per-row cols[].COLTYPE path — test 2 proves the migration didn't
|
||||||
// silently drop real columns' rules either.
|
// silently drop real columns' rules either.
|
||||||
// Column positions below come from COLHEADERS in
|
// Column positions below come from the mock's COLHEADERS
|
||||||
// sas/mocks/sasjs/services/editors/getdata.js:
|
// (sas/mocks/sasjs/services/editors/getdata.js):
|
||||||
// "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,..."
|
// "PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,..."
|
||||||
// — childNodes[0] on a body <tr> is the row-header <th>, so childNodes[N+1]
|
// — the client-owned delete column is prepended by the editor itself (see
|
||||||
// is the Nth (0-indexed) column in that list.
|
// deleteRecordColumn.ts), so on screen it is still column 0. childNodes[0] on
|
||||||
|
// a body <tr> is the row-header <th>, so childNodes[N+1] is the Nth
|
||||||
|
// (0-indexed) column in that list.
|
||||||
context('coltype vertical-array regression tests: ', function () {
|
context('coltype vertical-array regression tests: ', function () {
|
||||||
this.beforeAll(() => {
|
this.beforeAll(() => {
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
export {}
|
||||||
|
|
||||||
|
const hostUrl = Cypress.env('hosturl')
|
||||||
|
const appLocation = Cypress.env('appLocation')
|
||||||
|
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||||
|
|
||||||
|
context('Export DC Library DDL: ', function () {
|
||||||
|
this.beforeAll(() => {
|
||||||
|
cy.loginAndUpdateValidKey(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.beforeEach(() => {
|
||||||
|
cy.visit(hostUrl + appLocation)
|
||||||
|
visitPage('home')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1 | the admin action opens the flavour chooser and exports the SAS flavour by default', () => {
|
||||||
|
visitPage('system')
|
||||||
|
|
||||||
|
cy.get('.admin-action', { timeout: longerCommandTimeout })
|
||||||
|
.contains('Export DC Library DDL')
|
||||||
|
.should('exist')
|
||||||
|
cy.get('.admin-action').contains('button', 'EXPORT').should('exist')
|
||||||
|
|
||||||
|
cy.window().then((win) => {
|
||||||
|
cy.stub(win, 'open').as('open')
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.admin-action').contains('button', 'EXPORT').click()
|
||||||
|
|
||||||
|
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
|
||||||
|
|
||||||
|
ddlModal().should('be.visible')
|
||||||
|
ddlModal().within(() => {
|
||||||
|
cy.get('select').should('have.value', 'SAS')
|
||||||
|
// the schema box only applies to the DB flavours
|
||||||
|
cy.contains('label', 'Schema (optional)').should('not.exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
ddlModal().contains('button', 'Export').click()
|
||||||
|
|
||||||
|
cy.get('@open').should(
|
||||||
|
'have.been.calledWithMatch',
|
||||||
|
/\/services\/admin\/exportdb&flavour=SAS$/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2 | the PGSQL flavour reveals the optional schema box and passes it to the service', () => {
|
||||||
|
visitPage('system')
|
||||||
|
|
||||||
|
cy.window().then((win) => {
|
||||||
|
cy.stub(win, 'open').as('open')
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.admin-action').contains('button', 'EXPORT').click()
|
||||||
|
|
||||||
|
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
|
||||||
|
|
||||||
|
ddlModal().within(() => {
|
||||||
|
cy.get('select').select('PGSQL')
|
||||||
|
cy.contains('label', 'Schema (optional)').should('be.visible')
|
||||||
|
cy.get('input[type="text"]').clear().type('DC')
|
||||||
|
})
|
||||||
|
|
||||||
|
ddlModal().contains('button', 'Export').click()
|
||||||
|
|
||||||
|
cy.get('@open').should(
|
||||||
|
'have.been.calledWithMatch',
|
||||||
|
/\/services\/admin\/exportdb&flavour=PGSQL&schema=DC$/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3 | the TSQL flavour exports without a schema when the box is left empty', () => {
|
||||||
|
visitPage('system')
|
||||||
|
|
||||||
|
cy.window().then((win) => {
|
||||||
|
cy.stub(win, 'open').as('open')
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.admin-action').contains('button', 'EXPORT').click()
|
||||||
|
|
||||||
|
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
|
||||||
|
|
||||||
|
ddlModal().within(() => {
|
||||||
|
cy.get('select').select('TSQL')
|
||||||
|
cy.contains('label', 'Schema (optional)').should('be.visible')
|
||||||
|
})
|
||||||
|
|
||||||
|
ddlModal().contains('button', 'Export').click()
|
||||||
|
|
||||||
|
cy.get('@open').should(
|
||||||
|
'have.been.calledWithMatch',
|
||||||
|
/\/services\/admin\/exportdb&flavour=TSQL$/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4 | the SAS export streams the DC library DDL', () => {
|
||||||
|
visitPage('system')
|
||||||
|
|
||||||
|
cy.window().then((win) => {
|
||||||
|
cy.stub(win, 'open').as('open')
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.admin-action').contains('button', 'EXPORT').click()
|
||||||
|
|
||||||
|
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
|
||||||
|
|
||||||
|
ddlModal().contains('button', 'Export').click()
|
||||||
|
|
||||||
|
// the URL the action opens is served by the backend - against the mocks
|
||||||
|
// that is services/admin/exportdb.js, which renders the library as basic
|
||||||
|
// DDL per flavour
|
||||||
|
cy.get('@open').then((open: any) => {
|
||||||
|
const url = open.getCall(0).args[0]
|
||||||
|
expect(url).to.match(/\/services\/admin\/exportdb&flavour=SAS$/)
|
||||||
|
|
||||||
|
cy.request(url).then((res) => {
|
||||||
|
expect(res.status).to.eq(200)
|
||||||
|
expect(res.headers['content-disposition']).to.contain('DC_JSLIB.ddl')
|
||||||
|
expect(res.body).to.contain('create table DC_JSLIB.MPE_CONFIG(')
|
||||||
|
expect(res.body).to.contain('VAR_NAME char(32)')
|
||||||
|
expect(res.body).to.contain('create table DC_JSLIB.MPE_X_TEST(')
|
||||||
|
// the SAS flavour has no schema statement and no unique index
|
||||||
|
expect(res.body).not.to.contain('CREATE SCHEMA')
|
||||||
|
expect(res.body).not.to.contain('UNIQUE INDEX')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('5 | the PGSQL export honours the schema and quotes its identifiers', () => {
|
||||||
|
visitPage('system')
|
||||||
|
|
||||||
|
cy.window().then((win) => {
|
||||||
|
cy.stub(win, 'open').as('open')
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.admin-action').contains('button', 'EXPORT').click()
|
||||||
|
|
||||||
|
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
|
||||||
|
|
||||||
|
ddlModal().within(() => {
|
||||||
|
cy.get('select').select('PGSQL')
|
||||||
|
cy.get('input[type="text"]').clear().type('DC')
|
||||||
|
})
|
||||||
|
ddlModal().contains('button', 'Export').click()
|
||||||
|
|
||||||
|
cy.get('@open').then((open: any) => {
|
||||||
|
const url = open.getCall(0).args[0]
|
||||||
|
expect(url).to.match(/&flavour=PGSQL&schema=DC$/)
|
||||||
|
|
||||||
|
cy.request(url).then((res) => {
|
||||||
|
expect(res.status).to.eq(200)
|
||||||
|
expect(res.body).to.contain('CREATE SCHEMA DC;')
|
||||||
|
expect(res.body).to.contain('CREATE TABLE DC.MPE_CONFIG (')
|
||||||
|
expect(res.body).to.contain('"VAR_NAME" VARCHAR(32)')
|
||||||
|
expect(res.body).to.contain('CREATE UNIQUE INDEX "MPE_CONFIG_pk"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const visitPage = (url: string) => {
|
||||||
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
export {}
|
||||||
|
//
|
||||||
|
// Derived (computed) variables are configured per table in MPE_DERIVED and
|
||||||
|
// created at read time, ahead of the filter, so that they can be filtered on.
|
||||||
|
//
|
||||||
|
// TESTDATA.DERIVED_DEMO carries two of them, neither of which is a physical
|
||||||
|
// column of the table:
|
||||||
|
// REGION ifc(STATE in ('CA','OR','WA'),'West','East')
|
||||||
|
// LINE_VALUE QTY * PRICE
|
||||||
|
//
|
||||||
|
// The mock implements the derivation in getdata.js / getcolvals.js (see
|
||||||
|
// mpeDerived in dcMockUtils.js), so the EDIT screen can be exercised without a
|
||||||
|
// SAS server. The real evaluation is done by SAS.
|
||||||
|
|
||||||
|
const hostUrl = Cypress.env('hosturl')
|
||||||
|
const appLocation = Cypress.env('appLocation')
|
||||||
|
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||||
|
|
||||||
|
context('derived variables: ', function () {
|
||||||
|
this.beforeAll(() => {
|
||||||
|
cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout })
|
||||||
|
cy.loginAndUpdateValidKey(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.beforeEach(() => {
|
||||||
|
cy.viewport(1440, 900)
|
||||||
|
bootApp()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1 | derived columns are shown in the EDIT grid with computed values', () => {
|
||||||
|
openEditor('TESTDATA.DERIVED_DEMO')
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(0, 'REGION').should('have.text', 'West')
|
||||||
|
getCellByHeaderAndRow(1, 'REGION').should('have.text', 'East')
|
||||||
|
getCellByHeaderAndRow(0, 'LINE_VALUE').should('have.text', '21')
|
||||||
|
getCellByHeaderAndRow(1, 'LINE_VALUE').should('have.text', '99')
|
||||||
|
// REGION_LABEL is built on REGION, so it only resolves if the derived
|
||||||
|
// statements run in VARIABLE_ORDER sequence
|
||||||
|
getCellByHeaderAndRow(0, 'REGION_LABEL').should('have.text', 'West-CA')
|
||||||
|
getCellByHeaderAndRow(1, 'REGION_LABEL').should('have.text', 'East-NY')
|
||||||
|
|
||||||
|
cy.screenshot('derived-1-grid', { capture: 'viewport' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2 | filters on a character derived column', () => {
|
||||||
|
openEditor('TESTDATA.DERIVED_DEMO')
|
||||||
|
|
||||||
|
openFilterPopup()
|
||||||
|
setFilterVariable('REGION')
|
||||||
|
setFilterValue('West')
|
||||||
|
submitFilter()
|
||||||
|
|
||||||
|
cy.get('.infoBar-text').should('contain.text', "REGION = 'West'")
|
||||||
|
cy.get('.ht_master .htCore tbody tr', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should('have.length', 2)
|
||||||
|
getCellByHeaderAndRow(1, 'REGION').should('have.text', 'West')
|
||||||
|
|
||||||
|
cy.screenshot('derived-2-filter-region', { capture: 'viewport' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3 | filters on a numeric derived column', () => {
|
||||||
|
openEditor('TESTDATA.DERIVED_DEMO')
|
||||||
|
|
||||||
|
openFilterPopup()
|
||||||
|
setFilterVariable('LINE_VALUE')
|
||||||
|
setFilterOperator('>')
|
||||||
|
setFilterValue('50')
|
||||||
|
submitFilter()
|
||||||
|
|
||||||
|
cy.get('.ht_master .htCore tbody tr', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should('have.length', 1)
|
||||||
|
getCellByHeaderAndRow(0, 'LINE_VALUE').should('have.text', '99')
|
||||||
|
|
||||||
|
cy.screenshot('derived-3-filter-value', { capture: 'viewport' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4 | a derived column is marked read-only, and opens no editor', () => {
|
||||||
|
openEditor('TESTDATA.DERIVED_DEMO')
|
||||||
|
|
||||||
|
// a derived column is read-only AND computed, so the grid marks it apart
|
||||||
|
// from an ordinary read-only column - its own header/cell class, no
|
||||||
|
// readonlyCell dimming (editor.component.ts, afterGetColHeader /
|
||||||
|
// afterGetCellMeta). STATE is the control: same grid, a physical column.
|
||||||
|
headerCell('REGION').should('have.class', 'dc-derived-header')
|
||||||
|
headerCell('REGION').should('not.have.class', 'readonlyCell')
|
||||||
|
headerCell('STATE')
|
||||||
|
.should('not.have.class', 'dc-derived-header')
|
||||||
|
.and('not.have.class', 'readonlyCell')
|
||||||
|
getCellByHeaderAndRow(0, 'REGION').should('have.class', 'dc-derived-cell')
|
||||||
|
getCellByHeaderAndRow(0, 'STATE').should(
|
||||||
|
'not.have.class',
|
||||||
|
'dc-derived-cell'
|
||||||
|
)
|
||||||
|
// The computed block opens at whichever derived column comes first in the
|
||||||
|
// grid - MPE_DERIVED order, so LINE_VALUE here, not REGION. Assert the
|
||||||
|
// invariant rather than a name: exactly one derived header carries the
|
||||||
|
// marker, and it is the first one.
|
||||||
|
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
|
||||||
|
const derived = [...$ths].filter((th) =>
|
||||||
|
th.classList.contains('dc-derived-header')
|
||||||
|
)
|
||||||
|
expect(derived.length, 'derived headers').to.be.greaterThan(0)
|
||||||
|
expect(
|
||||||
|
derived[0].classList.contains('dc-derived-first'),
|
||||||
|
'the first derived header opens the block'
|
||||||
|
).to.be.true
|
||||||
|
derived
|
||||||
|
.slice(1)
|
||||||
|
.forEach(
|
||||||
|
(th) =>
|
||||||
|
expect(
|
||||||
|
th.classList.contains('dc-derived-first'),
|
||||||
|
'only the first derived header carries the marker'
|
||||||
|
).to.be.false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// name=expression
|
||||||
|
headerCell('REGION_LABEL')
|
||||||
|
.should('have.attr', 'title')
|
||||||
|
.and('contain', 'Region and state')
|
||||||
|
headerCell('REGION')
|
||||||
|
.should('have.attr', 'title')
|
||||||
|
.and('contain', 'REGION=ifc')
|
||||||
|
|
||||||
|
enterEditMode()
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(0, 'REGION').dblclick({ force: true })
|
||||||
|
cy.get('.handsontableInput').should('not.exist')
|
||||||
|
getCellByHeaderAndRow(0, 'REGION').should('have.text', 'West')
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The positive control for the case above: same screen, same edit mode, a
|
||||||
|
* physical column - so its silence is about the column, not the screen.
|
||||||
|
*/
|
||||||
|
it('5 | a physical column in the same grid does accept an edit', () => {
|
||||||
|
openEditor('TESTDATA.DERIVED_DEMO')
|
||||||
|
enterEditMode()
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(0, 'STATE').dblclick({ force: true })
|
||||||
|
cy.get('.handsontableInput').should('exist')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cypress clears cookies between tests, which drops the SASjs Server session
|
||||||
|
* and lands the app back on the evaluation agreement card. Accept it when it is
|
||||||
|
* shown - a no-op when the session survived.
|
||||||
|
*/
|
||||||
|
const bootApp = () => {
|
||||||
|
cy.visit(hostUrl, { timeout: longerCommandTimeout })
|
||||||
|
cy.get('body').then(($body: any) => {
|
||||||
|
if ($body.find('#TCS input[type="checkbox"]').length) {
|
||||||
|
cy.get('#TCS input[type="checkbox"]').check({ force: true })
|
||||||
|
cy.wait(4000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens a table straight in the EDIT screen and waits for the grid. */
|
||||||
|
const openEditor = (libMem: string) => {
|
||||||
|
cy.visit(`${hostUrl}${appLocation}/#/editor/${libMem}`, {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
|
cy.get('.ht_clone_top .htCore thead tr th', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should('exist')
|
||||||
|
cy.wait(2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The EDIT screen opens read-only; this is the button that starts an edit. */
|
||||||
|
const enterEditMode = () => {
|
||||||
|
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
||||||
|
.click()
|
||||||
|
.then(() => cy.wait(2500))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The grid header cell for a column, by its rendered text. */
|
||||||
|
const headerCell = (headerText: string) =>
|
||||||
|
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
|
||||||
|
const th = [...$ths].find(
|
||||||
|
(candidate) => (candidate as HTMLElement).innerText.trim() === headerText
|
||||||
|
)
|
||||||
|
if (!th) throw new Error(`header not found: ${headerText}`)
|
||||||
|
return cy.wrap(th)
|
||||||
|
})
|
||||||
|
|
||||||
|
const openFilterPopup = () => {
|
||||||
|
cy.contains('button.btnView', 'Filter', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).click()
|
||||||
|
cy.get('.filter-modal', { timeout: longerCommandTimeout }).should(
|
||||||
|
'be.visible'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The soft-select inputs drop a transparent .overlay click-catcher over the
|
||||||
|
* modal while their suggestion list is open, so they need force.
|
||||||
|
*/
|
||||||
|
const setFilterVariable = (column: string) => {
|
||||||
|
cy.get('#vals_var_id0_0').clear({ force: true }).type(column, { force: true })
|
||||||
|
cy.get('#datalist_vals_var_id0_0 option').contains(column).click({
|
||||||
|
force: true
|
||||||
|
})
|
||||||
|
cy.get('#vals_var_id0_0').trigger('keyup', { key: 'Escape', force: true })
|
||||||
|
cy.wait(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setFilterValue = (value: string) => {
|
||||||
|
cy.get('#vals_0_0').clear({ force: true }).type(value, { force: true })
|
||||||
|
cy.get('#vals_0_0').trigger('keyup', { key: 'Escape', force: true })
|
||||||
|
cy.wait(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setFilterOperator = (operator: string) => {
|
||||||
|
cy.get('.filter-modal .operator-col select').first().select(operator)
|
||||||
|
cy.wait(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitFilter = () => {
|
||||||
|
cy.contains('.filter-modal button', 'Ok').click()
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
||||||
|
cy.wait(2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
|
||||||
|
return cy
|
||||||
|
.get('.ht_clone_top .htCore thead tr th')
|
||||||
|
.should(($ths) => {
|
||||||
|
const texts = [...$ths].map((th) => (th as HTMLElement).innerText.trim())
|
||||||
|
expect(texts).to.include(headerText)
|
||||||
|
})
|
||||||
|
.then(($ths) => {
|
||||||
|
const index = [...$ths].findIndex(
|
||||||
|
(th) => (th as HTMLElement).innerText.trim() === headerText
|
||||||
|
)
|
||||||
|
|
||||||
|
return cy
|
||||||
|
.get('.ht_master tbody tr')
|
||||||
|
.then((rows: any) => rows[rowIndex].childNodes[index])
|
||||||
|
.then((cell) => cy.get(cell))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -17,9 +17,20 @@ context('download files test: ', function () {
|
|||||||
|
|
||||||
this.beforeEach(() => {
|
this.beforeEach(() => {
|
||||||
cy.visit(hostUrl + appLocation)
|
cy.visit(hostUrl + appLocation)
|
||||||
cy.get('input.username').type(username)
|
|
||||||
cy.get('input.password').type(password)
|
// beforeAll already logged in. Typing into the login form unconditionally
|
||||||
cy.get('.login-group button').click()
|
// failed with "the center of this element is hidden from view" whenever the
|
||||||
|
// session was live and the form was not on screen, so only fill it when it
|
||||||
|
// is actually shown.
|
||||||
|
cy.get('body').then(($body) => {
|
||||||
|
const usernameInput = $body.find('input.username')[0]
|
||||||
|
|
||||||
|
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
|
||||||
|
cy.get('input.username').type(username)
|
||||||
|
cy.get('input.password').type(password)
|
||||||
|
cy.get('.login-group button').click()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
visitPage('home')
|
visitPage('home')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,44 +6,90 @@ 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 in edit mode immediately (not read-only),
|
// - the editor opens READ-ONLY (the user clicks Edit, as in normal use),
|
||||||
// - the only action button is a single Submit below the grid (stubbed in v1),
|
// - the Filter button is hidden - VA filters replace the filter builder,
|
||||||
// - the top button bar and Add Record are hidden.
|
// - the Add Row / Add Record buttons 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 only assert the deterministic UI layout and that pushing a
|
// unit spec; here we assert the deterministic UI layout, that pushing a VA
|
||||||
// VA postMessage does not break the page.
|
// postMessage does not break the page, and (case 3) that a `va_parmap` URL
|
||||||
|
// mapping filters the column it names end to end.
|
||||||
|
/**
|
||||||
|
* Assert on the row count the editor shows. The assertion runs inside a Cy
|
||||||
|
* `should`, so it retries until the grid has loaded: reading the text once would
|
||||||
|
* capture the count from before a filter is applied.
|
||||||
|
*/
|
||||||
|
const parseRows = (text: string): number => {
|
||||||
|
const match = /\(([\d,]+) rows?/.exec(text)
|
||||||
|
return match ? Number(match[1].replace(/,/g, '')) : NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectRows = (check: (rows: number) => void) =>
|
||||||
|
cy.get('h4', { timeout: longerCommandTimeout }).should(($h4) => {
|
||||||
|
check(parseRows($h4.text()))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Assert the grid shrank, without pinning a fixture-specific number. */
|
||||||
|
const expectFewerRowsThan = (baseline: number) =>
|
||||||
|
expectRows((rows) => {
|
||||||
|
expect(rows, 'filtered row count').to.be.lessThan(baseline)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Read the unfiltered row count from the app itself, retrying until it loads. */
|
||||||
|
const readRowCount = (): Cypress.Chainable<number> =>
|
||||||
|
expectRows((rows) => {
|
||||||
|
expect(rows, 'row count').to.be.greaterThan(0)
|
||||||
|
})
|
||||||
|
.get('h4')
|
||||||
|
.invoke('text')
|
||||||
|
.then(parseRows)
|
||||||
|
|
||||||
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, editable, single bottom Submit', () => {
|
it('1 | opens the editor in VA mode: chrome hidden, read-only, no Filter button', () => {
|
||||||
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 and in edit mode (read-only Filter/Edit/Upload bar absent)
|
// Grid present, read-only: the user enters edit mode with Edit, as usual
|
||||||
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
|
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
|
||||||
cy.get('.btnCtrl button').contains('Edit').should('not.exist')
|
cy.get('.btnCtrl button').contains('Edit').should('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')
|
||||||
|
|
||||||
// Top edit-mode buttons hidden in VA mode
|
// Not in edit mode, so no edit-mode controls
|
||||||
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
|
// Bottom Add Record hidden in VA mode
|
||||||
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')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -69,14 +115,382 @@ 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('button').contains('Submit').should('exist')
|
cy.get('.btnCtrl button').contains('Edit').should('exist')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('3 | va_parmap filters a column from a parameter that is not its name (tab 1)', () => {
|
||||||
|
// Two DC instances over one table (one per report tab), each filtering the
|
||||||
|
// same DC column from its own report parameter. Without the URL mapping the
|
||||||
|
// parameter label is not a column name, so nothing would filter at all. The
|
||||||
|
// mapping here is written in lower case against an upper-case label and
|
||||||
|
// column, which pins the case-insensitivity of both sides end to end.
|
||||||
|
openEditorInVaMode(
|
||||||
|
'mpe_x_test',
|
||||||
|
() => {
|
||||||
|
pushVaMessage({
|
||||||
|
name: 'pr1',
|
||||||
|
label: 'DROPDOWN_TAB1',
|
||||||
|
dataType: 'string',
|
||||||
|
value: 'Option 2'
|
||||||
|
})
|
||||||
|
// The applied filter names the MAPPED column, and the grid shrinks to
|
||||||
|
// the rows matching that value (Option 2 occurs twice in this table).
|
||||||
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'SOME_DROPDOWN'
|
||||||
|
)
|
||||||
|
cy.get('.infoBar-text').should('contain.text', "'Option 2'")
|
||||||
|
expectFewerRowsThan(unfilteredRows)
|
||||||
|
},
|
||||||
|
'va_parmap=dropdown_tab1-some_dropdown'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4 | the other tab maps its own parameter to the same column', () => {
|
||||||
|
// Same table, same column, a different parameter label and value (Option 1
|
||||||
|
// occurs four times) - the per-instance URL mapping is what keeps the two
|
||||||
|
// tabs independent.
|
||||||
|
openEditorInVaMode(
|
||||||
|
'mpe_x_test',
|
||||||
|
() => {
|
||||||
|
pushVaMessage({
|
||||||
|
name: 'pr1',
|
||||||
|
label: 'DROPDOWN_TAB2',
|
||||||
|
dataType: 'string',
|
||||||
|
value: 'Option 1'
|
||||||
|
})
|
||||||
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'SOME_DROPDOWN'
|
||||||
|
)
|
||||||
|
cy.get('.infoBar-text').should('contain.text', "'Option 1'")
|
||||||
|
expectFewerRowsThan(unfilteredRows)
|
||||||
|
},
|
||||||
|
'va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('5 | warns in the grid when a mapping names a column the table lacks', () => {
|
||||||
|
// Written by hand in the report's URL, so a typo must be visible to the
|
||||||
|
// report author: the parameter is skipped, and the reason is shown rather
|
||||||
|
// than leaving an unfiltered grid that looks like a working filter.
|
||||||
|
openEditorInVaMode(
|
||||||
|
'mpe_x_test',
|
||||||
|
() => {
|
||||||
|
pushVaMessage({
|
||||||
|
name: 'pr1',
|
||||||
|
label: 'DROPDOWN_TAB1',
|
||||||
|
dataType: 'string',
|
||||||
|
value: 'Option 2'
|
||||||
|
})
|
||||||
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'VA parameter mapping problem'
|
||||||
|
)
|
||||||
|
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
|
||||||
|
cy.get('.va-parmap-warning').should('contain.text', 'NOT_A_COLUMN')
|
||||||
|
// VA mode hides the app header, so the banner must paint inside the
|
||||||
|
// viewport on its own - assert the real box, not just its presence.
|
||||||
|
cy.get('.va-parmap-warning').should(($alert) => {
|
||||||
|
const rect = $alert[0].getBoundingClientRect()
|
||||||
|
expect(rect.width, 'banner width').to.be.greaterThan(100)
|
||||||
|
expect(rect.height, 'banner height').to.be.greaterThan(20)
|
||||||
|
expect(rect.top, 'banner top').to.be.at.least(0)
|
||||||
|
expect(rect.bottom, 'banner bottom').to.be.at.most(
|
||||||
|
Cypress.config('viewportHeight')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
cy.screenshot('va-parmap-banner', { capture: 'viewport' })
|
||||||
|
// Nothing was filtered: the row count matches the unfiltered baseline.
|
||||||
|
expectRows((rows) => expect(rows).to.equal(unfilteredRows))
|
||||||
|
},
|
||||||
|
'va_parmap=DROPDOWN_TAB1-NOT_A_COLUMN'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('6 | warns straight away when a mapping entry cannot be parsed', () => {
|
||||||
|
// No separator, so the entry never becomes a mapping - reported before any
|
||||||
|
// VA message arrives.
|
||||||
|
openEditorInVaMode(
|
||||||
|
'mpe_x_test',
|
||||||
|
() => {
|
||||||
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'it needs a "-" between the VA parameter and the column name'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
'va_parmap=DROPDOWN_TAB1'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('7 | warns when a mapping names a parameter VA is not sending', () => {
|
||||||
|
// Parameter-side typo: the mapping never applies, so without the banner this
|
||||||
|
// is indistinguishable from a working filter over every row.
|
||||||
|
openEditorInVaMode(
|
||||||
|
'mpe_x_test',
|
||||||
|
() => {
|
||||||
|
pushVaMessage({
|
||||||
|
name: 'pr1',
|
||||||
|
label: 'DROPDOWN_TAB1',
|
||||||
|
dataType: 'string',
|
||||||
|
value: 'Option 2'
|
||||||
|
})
|
||||||
|
// One message only tells us this object's parameter set is still being
|
||||||
|
// discovered; the warning waits for a message that introduces no new name.
|
||||||
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
||||||
|
'not.exist'
|
||||||
|
)
|
||||||
|
pushVaMessage({
|
||||||
|
name: 'pr1',
|
||||||
|
label: 'DROPDOWN_TAB1',
|
||||||
|
dataType: 'string',
|
||||||
|
value: 'Option 2'
|
||||||
|
})
|
||||||
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'names VA parameter "dropdown_tab_1", which VA is not sending'
|
||||||
|
)
|
||||||
|
expectRows((rows) => expect(rows).to.equal(unfilteredRows))
|
||||||
|
},
|
||||||
|
'va_parmap=DROPDOWN_TAB_1-SOME_DROPDOWN'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('8 | applies a mapping from the buffered message on the very first load', () => {
|
||||||
|
// VA posts once on iframe load, before the app bootstraps, so the first
|
||||||
|
// getdata is already filtered from that buffered message - with no column
|
||||||
|
// metadata available, the mapping is what names the column.
|
||||||
|
visitWithBufferedMessage(
|
||||||
|
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN',
|
||||||
|
{
|
||||||
|
name: 'pr1',
|
||||||
|
label: 'DROPDOWN_TAB1',
|
||||||
|
dataType: 'string',
|
||||||
|
value: 'Option 2'
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
"SOME_DROPDOWN = 'Option 2'"
|
||||||
|
)
|
||||||
|
expectFewerRowsThan(unfilteredRows)
|
||||||
|
cy.url().should('match', /\/editor\/[^/]+\/\d+/)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('9 | a bad mapping on the first load still ends at the banner', () => {
|
||||||
|
// The deferred first load cannot check the column against metadata, so its
|
||||||
|
// saveQuery may fail and fall back to an unfiltered load - the banner is
|
||||||
|
// what stops that reading as a working filter.
|
||||||
|
visitWithBufferedMessage(
|
||||||
|
'va_parmap=DROPDOWN_TAB1-NOT_A_COLUMN',
|
||||||
|
{
|
||||||
|
name: 'pr1',
|
||||||
|
label: 'DROPDOWN_TAB1',
|
||||||
|
dataType: 'string',
|
||||||
|
value: 'Option 2'
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'NOT_A_COLUMN'
|
||||||
|
)
|
||||||
|
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
|
||||||
|
// Deliberately no row-count assertion: this path cannot validate the
|
||||||
|
// column (no metadata yet), so the outcome depends on the platform - on
|
||||||
|
// SAS, mp_filtercheck aborts the save and the load falls back to
|
||||||
|
// unfiltered; the JS mock stores the filter and returns 0 rows. The
|
||||||
|
// banner is the part that must hold either way, because both outcomes
|
||||||
|
// are otherwise indistinguishable from a working filter.
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('10 | warns when two parameters filter the same column (an empty grid)', () => {
|
||||||
|
// Clauses are AND-ed, so two different values on one column produce no rows
|
||||||
|
// at all - the last silent-empty-grid path. Reported rather than rewritten,
|
||||||
|
// so the author decides whether to use one parameter carrying both values.
|
||||||
|
openEditorInVaMode(
|
||||||
|
'mpe_x_test',
|
||||||
|
() => {
|
||||||
|
pushVaMessage(
|
||||||
|
{
|
||||||
|
name: 'pr1',
|
||||||
|
label: 'DROPDOWN_TAB1',
|
||||||
|
dataType: 'string',
|
||||||
|
value: 'Option 1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pr2',
|
||||||
|
label: 'DROPDOWN_TAB2',
|
||||||
|
dataType: 'string',
|
||||||
|
value: 'Option 2'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'combined with AND'
|
||||||
|
)
|
||||||
|
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
|
||||||
|
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB2')
|
||||||
|
// The consequence: rows must match both clauses, and the two values
|
||||||
|
// differ, so none can match.
|
||||||
|
expectRows((rows) => expect(rows, 'matching rows').to.equal(0))
|
||||||
|
},
|
||||||
|
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN&va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('12 | a transform adds the previous quarter to the filter', () => {
|
||||||
|
// ADD(QUARTER_START,-1) is the reported requirement: one clause carrying the
|
||||||
|
// selected date and the same point one quarter earlier. 2026-10-01 = 24380,
|
||||||
|
// 2026-07-01 = 24288 in SAS days; nothing in the fixture matches either, so
|
||||||
|
// the assertion is on the clause rather than the row count.
|
||||||
|
openEditorInVaMode(
|
||||||
|
'mpe_x_test',
|
||||||
|
() => {
|
||||||
|
pushVaMessage({
|
||||||
|
name: 'pr1',
|
||||||
|
label: 'SELECTED_DATE',
|
||||||
|
dataType: 'string',
|
||||||
|
value: '01OCT2026'
|
||||||
|
})
|
||||||
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'SOME_DATE IN ('
|
||||||
|
)
|
||||||
|
cy.get('.infoBar-text').should('contain.text', '24380')
|
||||||
|
cy.get('.infoBar-text').should('contain.text', '24288')
|
||||||
|
cy.get('h4').should('contain.text', '(0 rows')
|
||||||
|
},
|
||||||
|
'va_parmap=SELECTED_DATE-SOME_DATE:ADD(QUARTER_START,-1)'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('13 | warns when a transform lands on a column it cannot apply to', () => {
|
||||||
|
openEditorInVaMode(
|
||||||
|
'mpe_x_test',
|
||||||
|
() => {
|
||||||
|
pushVaMessage({
|
||||||
|
name: 'pr1',
|
||||||
|
label: 'SELECTED_DATE',
|
||||||
|
dataType: 'string',
|
||||||
|
value: 'x'
|
||||||
|
})
|
||||||
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'is not a date or datetime column'
|
||||||
|
)
|
||||||
|
cy.get('h4').should('contain.text', '(7 rows')
|
||||||
|
},
|
||||||
|
'va_parmap=SELECTED_DATE-SOME_CHAR:ADD(QUARTER_START,-1)'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('11 | logs a parse-time problem once, however many messages arrive', () => {
|
||||||
|
// The parse problem is logged at app init; the editor's own loop must not log
|
||||||
|
// it again. The second entry is valid-but-missing, so the loop definitely runs
|
||||||
|
// and logs its own line - which is what makes this a real assertion.
|
||||||
|
cy.visit(
|
||||||
|
`${hostUrl}${appLocation}/index.html#/editor/DC_JSLIB.MPE_X_TEST?embed=va&va_parmap=DROPDOWN_TAB1&va_parmap=DROPDOWN_TAB2-NOT_A_COLUMN`,
|
||||||
|
{
|
||||||
|
onBeforeLoad(win: any) {
|
||||||
|
win.__warns = []
|
||||||
|
const original = win.console.warn.bind(win.console)
|
||||||
|
win.console.warn = (...args: any[]) => {
|
||||||
|
win.__warns.push(args.join(' '))
|
||||||
|
original(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
||||||
|
'not.exist'
|
||||||
|
)
|
||||||
|
pushVaMessage({
|
||||||
|
name: 'pr2',
|
||||||
|
label: 'DROPDOWN_TAB2',
|
||||||
|
dataType: 'string',
|
||||||
|
value: 'Option 2'
|
||||||
|
})
|
||||||
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'NOT_A_COLUMN'
|
||||||
|
)
|
||||||
|
cy.window().then((win: any) => {
|
||||||
|
const warns: string[] = win.__warns || []
|
||||||
|
const parseLines = warns.filter((w) =>
|
||||||
|
w.includes('va_parmap "DROPDOWN_TAB1"')
|
||||||
|
)
|
||||||
|
const runtimeLines = warns.filter((w) => w.includes('NOT_A_COLUMN'))
|
||||||
|
expect(parseLines.length, warns.join(' | ')).to.eq(1)
|
||||||
|
expect(runtimeLines.length, warns.join(' | ')).to.eq(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
const openEditorInVaMode = (tablename: string, callback?: any) => {
|
// `extraParams` is appended to that query string (e.g. va_parmap entries).
|
||||||
|
const openEditorInVaMode = (
|
||||||
|
tablename: string,
|
||||||
|
callback?: any,
|
||||||
|
extraParams?: string
|
||||||
|
) => {
|
||||||
openTableFromTree(libraryToOpenIncludes, tablename)
|
openTableFromTree(libraryToOpenIncludes, tablename)
|
||||||
|
|
||||||
cy.get('#hotTable', { timeout: longerCommandTimeout })
|
cy.get('#hotTable', { timeout: longerCommandTimeout })
|
||||||
@@ -84,7 +498,13 @@ const openEditorInVaMode = (tablename: string, callback?: any) => {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
cy.url().then((url) => {
|
cy.url().then((url) => {
|
||||||
const separator = url.includes('?') ? '&' : '?'
|
const separator = url.includes('?') ? '&' : '?'
|
||||||
cy.visit(`${url}${separator}embed=va`)
|
const extra = extraParams ? `&${extraParams}` : ''
|
||||||
|
cy.visit(`${url}${separator}embed=va${extra}`)
|
||||||
|
// A cy.visit that differs from the current URL only in the hash is a
|
||||||
|
// same-document navigation, so the app is not re-bootstrapped and the
|
||||||
|
// embed parameter (read once at app init) never takes effect. Reload to
|
||||||
|
// bootstrap it, as the VA iframe load does.
|
||||||
|
cy.reload()
|
||||||
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
||||||
'not.exist'
|
'not.exist'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -399,6 +399,26 @@ context('excel tests: ', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('25 | Keeps decimals when the upload preview becomes a manual edit', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
// Entering manual edit mode discards the source file but keeps the values
|
||||||
|
// parsed from it. SOME_NUM holds 0.901045951946194 in the first data row
|
||||||
|
// of the fixture, so a truncated or zeroed cell fails the assertion.
|
||||||
|
attachExcelFile('regular_excel.xlsx', () => {
|
||||||
|
getFirstRowCells().then((previewCells) => {
|
||||||
|
expect(previewCells).to.include(decimalSomeNum)
|
||||||
|
|
||||||
|
cy.get('.btn-upload-preview.btn-primary').click()
|
||||||
|
cy.get('.modal-footer .btn-success-outline').click()
|
||||||
|
|
||||||
|
getFirstRowCells().then((editCells) => {
|
||||||
|
expect(editCells).to.include(decimalSomeNum)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Large files break Cypress
|
// Large files break Cypress
|
||||||
|
|
||||||
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
||||||
@@ -476,6 +496,19 @@ const attachExcelFile = (excelFilename: string, callback?: any) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decimalSomeNum = '0.901045951946194'
|
||||||
|
|
||||||
|
// The row's cell texts, without depending on column positions: the preview
|
||||||
|
// grid renders row-status and delete cells in an order that varies.
|
||||||
|
const getFirstRowCells = () =>
|
||||||
|
cy
|
||||||
|
.get('#hotTable', { timeout: longerCommandTimeout })
|
||||||
|
.find('div.ht_master.handsontable tbody tr')
|
||||||
|
.eq(0)
|
||||||
|
.then(($row) =>
|
||||||
|
Array.from($row[0].children).map((cell: any) => cell.innerText.trim())
|
||||||
|
)
|
||||||
|
|
||||||
const submitExcel = (callback?: any) => {
|
const submitExcel = (callback?: any) => {
|
||||||
cy.get('.buttonBar button.preview-submit', { timeout: longerCommandTimeout })
|
cy.get('.buttonBar button.preview-submit', { timeout: longerCommandTimeout })
|
||||||
.click()
|
.click()
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ context('filtering tests: ', function () {
|
|||||||
this.beforeAll(() => {
|
this.beforeAll(() => {
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout })
|
cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout })
|
||||||
cy.loginAndUpdateValidKey()
|
cy.loginAndUpdateValidKey()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The evaluation agreement is per-origin localStorage, so a fresh browser
|
||||||
|
* profile lands on it and the nav tree never renders. Guarded, so it is a
|
||||||
|
* no-op once an earlier spec in the run has accepted it.
|
||||||
|
*/
|
||||||
|
cy.get('body').then(($body) => {
|
||||||
|
if ($body.find('#TCS input[type="checkbox"]').length) {
|
||||||
|
cy.get('#TCS input[type="checkbox"]').check({ force: true })
|
||||||
|
cy.wait(4000)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
this.beforeEach(() => {
|
this.beforeEach(() => {
|
||||||
@@ -51,9 +63,13 @@ context('filtering tests: ', function () {
|
|||||||
|
|
||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_TIME', '00:00:42', 'time', () => {
|
setFilterWithValue('SOME_TIME', '00:00:42', 'time', () => {
|
||||||
checkInfoBarIncludes(`(( SOME_TIME = 42 ))`, (includes: boolean) => {
|
checkInfoBarIncludes(
|
||||||
if (includes) done()
|
`(( SOME_TIME = '00:00:42't ))`,
|
||||||
})
|
(includes: boolean) => {
|
||||||
|
if (!includes) return
|
||||||
|
assertGridRowCount(3, () => done())
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -63,9 +79,13 @@ context('filtering tests: ', function () {
|
|||||||
|
|
||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_TIME', '42', 'value', () => {
|
setFilterWithValue('SOME_TIME', '42', 'value', () => {
|
||||||
checkInfoBarIncludes(`(( SOME_TIME = 42 ))`, (includes: boolean) => {
|
checkInfoBarIncludes(
|
||||||
if (includes) done()
|
`(( SOME_TIME = '00:00:42't ))`,
|
||||||
})
|
(includes: boolean) => {
|
||||||
|
if (!includes) return
|
||||||
|
assertGridRowCount(3, () => done())
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}, false)
|
}, false)
|
||||||
})
|
})
|
||||||
@@ -75,9 +95,13 @@ context('filtering tests: ', function () {
|
|||||||
|
|
||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_DATE', '12/02/1960', 'date', () => {
|
setFilterWithValue('SOME_DATE', '12/02/1960', 'date', () => {
|
||||||
checkInfoBarIncludes(`(( SOME_DATE = 42 ))`, (includes: boolean) => {
|
checkInfoBarIncludes(
|
||||||
if (includes) done()
|
`(( SOME_DATE = '12FEB1960'd ))`,
|
||||||
})
|
(includes: boolean) => {
|
||||||
|
if (!includes) return
|
||||||
|
assertGridRowCount(5, () => done())
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -87,9 +111,13 @@ context('filtering tests: ', function () {
|
|||||||
|
|
||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_DATE', '42', 'value', () => {
|
setFilterWithValue('SOME_DATE', '42', 'value', () => {
|
||||||
checkInfoBarIncludes(`(( SOME_DATE = 42 ))`, (includes: boolean) => {
|
checkInfoBarIncludes(
|
||||||
if (includes) done()
|
`(( SOME_DATE = '12FEB1960'd ))`,
|
||||||
})
|
(includes: boolean) => {
|
||||||
|
if (!includes) return
|
||||||
|
assertGridRowCount(5, () => done())
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}, false)
|
}, false)
|
||||||
})
|
})
|
||||||
@@ -104,9 +132,10 @@ context('filtering tests: ', function () {
|
|||||||
'datetime',
|
'datetime',
|
||||||
() => {
|
() => {
|
||||||
checkInfoBarIncludes(
|
checkInfoBarIncludes(
|
||||||
`(( SOME_DATETIME = 42 ))`,
|
`(( SOME_DATETIME = '01JAN1960:00:00:42'dt ))`,
|
||||||
(includes: boolean) => {
|
(includes: boolean) => {
|
||||||
if (includes) done()
|
if (!includes) return
|
||||||
|
assertGridRowCount(5, () => done())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,9 +149,10 @@ context('filtering tests: ', function () {
|
|||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_DATETIME', '42', 'value', () => {
|
setFilterWithValue('SOME_DATETIME', '42', 'value', () => {
|
||||||
checkInfoBarIncludes(
|
checkInfoBarIncludes(
|
||||||
`(( SOME_DATETIME = 42 ))`,
|
`(( SOME_DATETIME = '01JAN1960:00:00:42'dt ))`,
|
||||||
(includes: boolean) => {
|
(includes: boolean) => {
|
||||||
if (includes) done()
|
if (!includes) return
|
||||||
|
assertGridRowCount(5, () => done())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -134,9 +164,13 @@ context('filtering tests: ', function () {
|
|||||||
|
|
||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_DATE', '', 'in', () => {
|
setFilterWithValue('SOME_DATE', '', 'in', () => {
|
||||||
checkInfoBarIncludes(`(( SOME_DATE IN (42) ))`, (includes: boolean) => {
|
checkInfoBarIncludes(
|
||||||
if (includes) done()
|
`(( SOME_DATE IN ('12FEB1960'd) ))`,
|
||||||
})
|
(includes: boolean) => {
|
||||||
|
if (!includes) return
|
||||||
|
assertGridRowCount(5, () => done())
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -156,6 +190,34 @@ context('filtering tests: ', function () {
|
|||||||
// })
|
// })
|
||||||
// })
|
// })
|
||||||
// })
|
// })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With the pickers off, a temporal BETWEEN takes both of its values from a
|
||||||
|
* soft-select over the column's values, so the value is chosen from that list
|
||||||
|
* rather than typed. The range dropdown binds the formatted label, as the
|
||||||
|
* single-value dropdown's list does, so the field reads the date and the
|
||||||
|
* clause composition maps it back to the internal day / second count before
|
||||||
|
* the literal is built - a display string that reached sasTemporalLiteral as
|
||||||
|
* a non-number would come back null and be passed through to RAW_VALUE
|
||||||
|
* unquoted, which mp_filtercheck rejects on a numeric column and aborts the
|
||||||
|
* save. The test asserts the applied clause and the row count, so it pins
|
||||||
|
* the mapping end to end.
|
||||||
|
*/
|
||||||
|
it('7 | Non picker - filter date field BETWEEN from the value list', (done) => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
openFilterPopup(() => {
|
||||||
|
setTemporalRangeFromValueList('SOME_DATE', '12FEB1960', () => {
|
||||||
|
checkInfoBarIncludes(
|
||||||
|
`BETWEEN '12FEB1960'd AND '12FEB1960'd`,
|
||||||
|
(includes: boolean) => {
|
||||||
|
if (!includes) return
|
||||||
|
assertGridRowCount(5, () => done())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const checkInfoBarIncludes = (text: string, callback: any) => {
|
const checkInfoBarIncludes = (text: string, callback: any) => {
|
||||||
@@ -166,6 +228,21 @@ const checkInfoBarIncludes = (text: string, callback: any) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The temporal filter tests assert the applied filter still selects the same
|
||||||
|
* rows as the internal value did - a readable clause that filtered differently
|
||||||
|
* would be a worse bug than the one being fixed.
|
||||||
|
*/
|
||||||
|
const assertGridRowCount = (expected: number, callback: any) => {
|
||||||
|
cy.get('#hotTable .ht_master tbody tr', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
|
.should('have.length', expected)
|
||||||
|
.then(() => {
|
||||||
|
if (callback) callback()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const openFilterPopup = (
|
const openFilterPopup = (
|
||||||
callback?: any,
|
callback?: any,
|
||||||
usePickers: boolean = true,
|
usePickers: boolean = true,
|
||||||
@@ -331,6 +408,54 @@ const setFilterWithValue = (
|
|||||||
if (callback) callback()
|
if (callback) callback()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives a temporal BETWEEN with the pickers off, choosing both values from the
|
||||||
|
* range soft-select's list rather than typing them - typing bypasses the
|
||||||
|
* binding this test exists to pin.
|
||||||
|
*/
|
||||||
|
const setTemporalRangeFromValueList = (
|
||||||
|
variableValue: string,
|
||||||
|
formatted: string,
|
||||||
|
callback?: any
|
||||||
|
) => {
|
||||||
|
cy.wait(600)
|
||||||
|
|
||||||
|
cy.focused().type(variableValue)
|
||||||
|
cy.wait(100)
|
||||||
|
cy.get('.variable-col .autocomplete-wrapper', { withinSubject: null })
|
||||||
|
.first()
|
||||||
|
.trigger('keydown', { key: 'ArrowDown' })
|
||||||
|
cy.get('.variable-col .autocomplete-wrapper', {
|
||||||
|
withinSubject: null
|
||||||
|
}).trigger('keydown', { key: 'Enter' })
|
||||||
|
cy.focused().tab()
|
||||||
|
cy.wait(100)
|
||||||
|
|
||||||
|
cy.focused().select('BETWEEN').trigger('change')
|
||||||
|
cy.wait(400)
|
||||||
|
|
||||||
|
const chooseFromList = (range: 'start' | 'end') => {
|
||||||
|
const inputId = `vals-${range}_0_0`
|
||||||
|
|
||||||
|
cy.get(`#${inputId}`)
|
||||||
|
.clear({ force: true })
|
||||||
|
.type(formatted, { force: true })
|
||||||
|
cy.get(`#datalist_${inputId} option`)
|
||||||
|
.contains(formatted)
|
||||||
|
.click({ force: true })
|
||||||
|
cy.get(`#${inputId}`).trigger('keyup', { key: 'Escape', force: true })
|
||||||
|
cy.wait(300)
|
||||||
|
}
|
||||||
|
|
||||||
|
chooseFromList('start')
|
||||||
|
chooseFromList('end')
|
||||||
|
|
||||||
|
cy.contains('.filter-modal button', 'Ok').click()
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
||||||
|
|
||||||
|
if (callback) callback()
|
||||||
|
}
|
||||||
|
|
||||||
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
||||||
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
||||||
.should('not.exist')
|
.should('not.exist')
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ context('metanav tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.beforeEach(() => {
|
this.beforeEach(() => {
|
||||||
cy.visit(hostUrl + appLocation)
|
// beforeAll has already logged in. Typing into the login form here failed
|
||||||
cy.get('input.username').type(username)
|
// once the session existed - the form is not on screen, so Cypress reported
|
||||||
cy.get('input.password').type(password)
|
// "the center of this element is hidden from view" and the whole suite was
|
||||||
cy.get('.login-group button').click()
|
// skipped behind that hook.
|
||||||
|
|
||||||
visitPage('view/metadata')
|
visitPage('view/metadata')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
// The EDIT screen is bounded by DC_MAXOBS_WEBEDIT rows and DC_MAXCELLS_WEBEDIT
|
||||||
|
// cells (rows x columns), whichever is reached first, and the VIEW screen caps
|
||||||
|
// at DC_MAXOBS_WEBVIEW rows.
|
||||||
|
//
|
||||||
|
// A selection over an EDIT limit is served TRUNCATED - the grid carries the
|
||||||
|
// first rows that fit, and a warning triangle against the table name says why.
|
||||||
|
// This spec pins those limits against the mock, which mirrors the shipped
|
||||||
|
// defaults (250 rows / 200000 cells for EDIT, 2000 rows for VIEW). It uses
|
||||||
|
// two existing fixtures rather than new ones:
|
||||||
|
// MPE_X_SEARCH - 1000 rows x 9 cols, so it trips the ROW limit
|
||||||
|
// MPE_X_WIDE - 200 rows x 1001 cols (200,200 cells), under the row limit
|
||||||
|
// but over the CELL limit, so the row cap is 199
|
||||||
|
export {}
|
||||||
|
|
||||||
|
const hostUrl = Cypress.env('hosturl')
|
||||||
|
const appLocation = Cypress.env('appLocation')
|
||||||
|
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||||
|
const serverType = Cypress.env('serverType')
|
||||||
|
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
||||||
|
|
||||||
|
context('EDIT row and cell limits, VIEW row limit: ', function () {
|
||||||
|
this.beforeAll(() => {
|
||||||
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||||
|
// forceLicenceKey=true: applies a valid licence rather than relying on an
|
||||||
|
// earlier spec in the run having done so (see commands.ts).
|
||||||
|
cy.loginAndUpdateValidKey(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.beforeEach(() => {
|
||||||
|
cy.visit(hostUrl + appLocation)
|
||||||
|
visitPage('home')
|
||||||
|
visitPage('view/data')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1 | VIEW renders up to the raised row cap, past the old 500', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
|
||||||
|
|
||||||
|
// Neither the header nor the cell text can pin the cap: the header reports
|
||||||
|
// the table's true row count whether or not the grid is capped, and the
|
||||||
|
// fixture's own numeric columns carry values above 1500 (DEPTH_M reaches
|
||||||
|
// 4293, SAMPLE_COUNT 4210), so a text match can hit fixture data.
|
||||||
|
//
|
||||||
|
// The PK can pin it. The first td of a .ht_master row is the PK - the row
|
||||||
|
// header, where present, is a th - and MPE_X_SEARCH holds PK 1001-2000, so
|
||||||
|
// the range check below is what stops a mis-selected column passing. With
|
||||||
|
// the old 500 row cap the grid stopped at PK 1500, so a rendered PK above
|
||||||
|
// 1500 can only appear if the cap was raised.
|
||||||
|
scrollGridToBottom()
|
||||||
|
cy.get('#hotTable .ht_master .htCore tbody tr', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should(($rows) => {
|
||||||
|
const pks = $rows
|
||||||
|
.toArray()
|
||||||
|
.map((row) => row.querySelectorAll('td')[0])
|
||||||
|
.filter((td) => !!td)
|
||||||
|
.map((td) => parseInt((td.textContent || '').trim(), 10))
|
||||||
|
.filter((pk) => !isNaN(pk))
|
||||||
|
|
||||||
|
expect(pks.length).to.be.greaterThan(0)
|
||||||
|
pks.forEach((pk) => expect(pk).to.be.within(1001, 2000))
|
||||||
|
expect(Math.max(...pks)).to.be.greaterThan(1500)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2 | EDIT serves a table over the row limit, truncated, with a warning', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
|
||||||
|
openEdit()
|
||||||
|
|
||||||
|
// 1000 rows x 9 columns is over the 250 row limit, so the grid carries the
|
||||||
|
// first 250 rows and the header says how many it is showing.
|
||||||
|
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'250 rows'
|
||||||
|
)
|
||||||
|
cy.get('#hotTable .ht_master .htCore tbody tr', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should('have.length.at.least', 1)
|
||||||
|
|
||||||
|
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
|
||||||
|
.should('be.visible')
|
||||||
|
.realHover()
|
||||||
|
cy.get('.editor-title clr-tooltip-content', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
|
.should('be.visible')
|
||||||
|
.should('contain.text', 'DC_MAXOBS_WEBEDIT')
|
||||||
|
.should(($el) => expect($el.css('opacity')).to.eq('1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3 | EDIT serves a table over the cell limit, truncated, with a warning', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_wide')
|
||||||
|
openEdit()
|
||||||
|
|
||||||
|
// 200 rows x 1001 stored columns = 200,200 cells, over the 200,000 cell
|
||||||
|
// limit, while the row count is under the 250 row limit - so the cell
|
||||||
|
// limit sets the cap: floor(200000 / 1001) = 199 rows.
|
||||||
|
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'199 rows'
|
||||||
|
)
|
||||||
|
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
|
||||||
|
.should('be.visible')
|
||||||
|
.realHover()
|
||||||
|
cy.get('.editor-title clr-tooltip-content', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
|
.should('be.visible')
|
||||||
|
.should('contain.text', 'DC_MAXCELLS_WEBEDIT')
|
||||||
|
.should(($el) => expect($el.css('opacity')).to.eq('1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4 | the EDIT limits follow MPE_CONFIG, not the shipped defaults', () => {
|
||||||
|
// Two things at once. The seeded config carries the shipped defaults (250
|
||||||
|
// rows / 200000 cells), which are the numbers the mock used to hard-code -
|
||||||
|
// so nothing above would notice if the mock stopped reading MPE_CONFIG at
|
||||||
|
// all. And MPE_CONFIG lives in the control library, so reading it from the
|
||||||
|
// table's own library would fail for this table in particular.
|
||||||
|
//
|
||||||
|
// The cell limit is the one to move: 7 rows x 17 columns is 119 cells, so
|
||||||
|
// at 110 the TESTDATA.MPE_X_NEW edit must be truncated to
|
||||||
|
// floor(110 / 17) = 6 rows, where a hard-coded 200000 - or a config read
|
||||||
|
// that fails and falls back to it - lets it through. 110 is still above
|
||||||
|
// MPE_CONFIG's own 6 x 17 = 102, so the option can be put back afterwards.
|
||||||
|
setConfigValue('DC_MAXCELLS_WEBEDIT', '110', () => {
|
||||||
|
visitPage('view/data/TESTDATA.MPE_X_NEW')
|
||||||
|
closeAbortModalIfPresent()
|
||||||
|
openEdit()
|
||||||
|
|
||||||
|
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'6 rows'
|
||||||
|
)
|
||||||
|
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
|
||||||
|
.should('be.visible')
|
||||||
|
.realHover()
|
||||||
|
cy.get('.editor-title clr-tooltip-content', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
|
.should('be.visible')
|
||||||
|
.should('contain.text', 'DC_MAXCELLS_WEBEDIT')
|
||||||
|
.should(($el) => expect($el.css('opacity')).to.eq('1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Put the option back, so the rest of the estate sees the shipped default.
|
||||||
|
setConfigValue('DC_MAXCELLS_WEBEDIT', '200000', () => {
|
||||||
|
visitPage('view/data/TESTDATA.MPE_X_NEW')
|
||||||
|
closeAbortModalIfPresent()
|
||||||
|
openEdit()
|
||||||
|
|
||||||
|
// 119 cells under a 200000 cell limit: all 7 rows load, no warning.
|
||||||
|
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'7 rows'
|
||||||
|
)
|
||||||
|
cy.get('.truncation-warning').should('not.exist')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('5 | the warning says to filter, and filtering narrows the grid', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
|
||||||
|
openEdit()
|
||||||
|
|
||||||
|
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
|
||||||
|
.should('be.visible')
|
||||||
|
.realHover()
|
||||||
|
cy.get('.editor-title clr-tooltip-content', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
|
.should('be.visible')
|
||||||
|
.should('contain.text', 'Filter the table to work with the rest')
|
||||||
|
.should(($el) => expect($el.css('opacity')).to.eq('1'))
|
||||||
|
|
||||||
|
// The advice has to be actionable: the picker must offer the table's
|
||||||
|
// columns and the filter must reload the grid.
|
||||||
|
openFilterPopup()
|
||||||
|
|
||||||
|
filterVariables().should(($options) => {
|
||||||
|
const names = $options
|
||||||
|
.toArray()
|
||||||
|
.map((o) => (o.textContent || '').trim())
|
||||||
|
.filter((n) => n.length > 0)
|
||||||
|
|
||||||
|
expect(names, 'the picker offers the table columns').to.include.members([
|
||||||
|
'PRIMARY_KEY_FIELD',
|
||||||
|
'SITE_NAME',
|
||||||
|
'VESSEL',
|
||||||
|
'SAMPLE_COUNT',
|
||||||
|
'DEPTH_M',
|
||||||
|
'SPECIES',
|
||||||
|
'CRUISE_DATE',
|
||||||
|
'EXPEDITION_ID',
|
||||||
|
'NOTES'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
setFilterVariable('SITE_NAME')
|
||||||
|
// 100 of the 1000 rows: under the limit, so nothing is truncated.
|
||||||
|
setFilterValue('Nereid Trench')
|
||||||
|
submitFilter()
|
||||||
|
|
||||||
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
"SITE_NAME = 'Nereid Trench'"
|
||||||
|
)
|
||||||
|
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
'100 rows'
|
||||||
|
)
|
||||||
|
cy.get('.truncation-warning').should('not.exist')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const visitPage = (url: string) => {
|
||||||
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The viewer's options dropdown holds the Edit item; it only renders for a
|
||||||
|
// table that is registered in MPE_TABLES (tableEditExists()).
|
||||||
|
const openEdit = () => {
|
||||||
|
cy.get('.filterSide', { timeout: longerCommandTimeout }).click()
|
||||||
|
cy.contains('[clrDropdownItem]', 'Edit', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The EDIT screen's Filter button opens the same .filter-modal the viewer uses.
|
||||||
|
* It only renders once the editor is in its read-only (non-editing) state,
|
||||||
|
* which is where a failed EDIT load leaves it.
|
||||||
|
*/
|
||||||
|
const openFilterPopup = () => {
|
||||||
|
cy.contains('button.btnView', 'Filter', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).click()
|
||||||
|
cy.get('.filter-modal', { timeout: longerCommandTimeout }).should(
|
||||||
|
'be.visible'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picker's variable suggestion list. */
|
||||||
|
const filterVariables = () =>
|
||||||
|
cy.get('#datalist_vals_var_id0_0 option', { timeout: longerCommandTimeout })
|
||||||
|
|
||||||
|
/** The soft-select drops a transparent overlay over the modal, so it needs force. */
|
||||||
|
const setFilterVariable = (column: string) => {
|
||||||
|
cy.get('#vals_var_id0_0').clear({ force: true }).type(column, { force: true })
|
||||||
|
cy.get('#datalist_vals_var_id0_0 option').contains(column).click({
|
||||||
|
force: true
|
||||||
|
})
|
||||||
|
cy.get('#vals_var_id0_0').trigger('keyup', { key: 'Escape', force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The value input of the same soft-select row. */
|
||||||
|
const setFilterValue = (value: string) => {
|
||||||
|
cy.get('#vals_0_0').clear({ force: true }).type(value, { force: true })
|
||||||
|
cy.get('#vals_0_0').trigger('keyup', { key: 'Escape', force: true })
|
||||||
|
cy.wait(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitFilter = () => {
|
||||||
|
cy.contains('.filter-modal button', 'Ok').click()
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
||||||
|
cy.wait(2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a DC option in MPE_CONFIG through the editor and approves it, then hands
|
||||||
|
* back to the callback. The same route licensing.cy.ts uses for the licence
|
||||||
|
* keys: the config table needs an approval like any other.
|
||||||
|
*/
|
||||||
|
const setConfigValue = (
|
||||||
|
varName: string,
|
||||||
|
varValue: string,
|
||||||
|
callback: () => void
|
||||||
|
) => {
|
||||||
|
reloadApp()
|
||||||
|
// straight to the config table's editor, so the flow does not depend on
|
||||||
|
// whichever view mode the previous step left behind
|
||||||
|
visitPage('editor/DC_JSLIB.MPE_CONFIG')
|
||||||
|
closeAbortModalIfPresent()
|
||||||
|
clickOnEdit()
|
||||||
|
|
||||||
|
editTableField([{ varName, varValue }], () => {
|
||||||
|
submitTable(() => {
|
||||||
|
cy.wait(2000)
|
||||||
|
approveTable(() => {
|
||||||
|
reloadApp()
|
||||||
|
callback()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A full page load. A visit that only changes the hash does not reload the
|
||||||
|
* app, so an open modal - the abort message from the step just gone, say -
|
||||||
|
* would still be covering the page.
|
||||||
|
*/
|
||||||
|
const reloadApp = () => {
|
||||||
|
cy.visit(hostUrl + appLocation)
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC puts abort messages up as modals over whatever page is showing, and they
|
||||||
|
* outlive a hash-only navigation, so clear one before driving the page.
|
||||||
|
*/
|
||||||
|
const closeAbortModalIfPresent = () => {
|
||||||
|
cy.get('body').then(($body) => {
|
||||||
|
if ($body.find('app-info-modal').length > 0) {
|
||||||
|
cy.get('app-info-modal')
|
||||||
|
.contains('button', 'Close')
|
||||||
|
.click({ force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The viewer's Edit button. The options dropdown (.filterSide) that openEdit()
|
||||||
|
* uses is not rendered for every table, so the config table is opened the way
|
||||||
|
* licensing.cy.ts opens it.
|
||||||
|
*/
|
||||||
|
const clickOnEdit = (callback?: () => void) => {
|
||||||
|
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
if (callback) callback()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Types a new value into the row whose cell carries varName. */
|
||||||
|
const editTableField = (
|
||||||
|
edits: { varName: string; varValue: string }[],
|
||||||
|
callback: () => void
|
||||||
|
) => {
|
||||||
|
cy.get('#hotTable .ht_master .htCore tbody td', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(($tds: any) => {
|
||||||
|
const nodes = $tds.toArray()
|
||||||
|
|
||||||
|
for (const edit of edits) {
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
// exact match on the cell that holds the option name; the cell after it
|
||||||
|
// is the value
|
||||||
|
if ((nodes[i].textContent || '').trim() === edit.varName) {
|
||||||
|
cy.wrap(nodes[i + 1])
|
||||||
|
.dblclick()
|
||||||
|
.then(() => {
|
||||||
|
cy.focused()
|
||||||
|
.type('{selectall}')
|
||||||
|
.type(edit.varValue)
|
||||||
|
.type('{enter}')
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callback()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitTable = (callback: () => void) => {
|
||||||
|
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
cy.get(".modal.ng-star-inserted button[type='submit']").click()
|
||||||
|
callback()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const approveTable = (callback: () => void) => {
|
||||||
|
cy.get('button', { timeout: longerCommandTimeout })
|
||||||
|
.should('contain', 'Approve')
|
||||||
|
.then((allButtons: any) => {
|
||||||
|
for (const approvalButton of allButtons) {
|
||||||
|
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
||||||
|
approvalButton.click()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.get('button#acceptBtn', { timeout: longerCommandTimeout })
|
||||||
|
.should('exist')
|
||||||
|
.should('not.be.disabled')
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
cy.get('app-history', { timeout: longerCommandTimeout }).should(
|
||||||
|
'exist'
|
||||||
|
)
|
||||||
|
callback()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handsontable virtualises rows, so the last row is only in the DOM once the
|
||||||
|
// grid has been scrolled to the bottom. The scroller is the .wtHolder inside
|
||||||
|
// the .ht_master pane.
|
||||||
|
const scrollGridToBottom = () => {
|
||||||
|
cy.get('#hotTable .ht_master .wtHolder', { timeout: longerCommandTimeout })
|
||||||
|
.scrollTo('bottom', { duration: 0 })
|
||||||
|
.trigger('scroll')
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
||||||
|
.should('not.exist')
|
||||||
|
.then(() => {
|
||||||
|
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then((treeNodes: any) => {
|
||||||
|
let targetLib
|
||||||
|
|
||||||
|
for (let node of treeNodes) {
|
||||||
|
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
|
||||||
|
targetLib = node
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.get(targetLib).within(() => {
|
||||||
|
// Small settle wait: right after a fresh visit the tree component can
|
||||||
|
// still be re-rendering, causing this node to be found then swapped
|
||||||
|
// out mid-click.
|
||||||
|
cy.wait(300)
|
||||||
|
|
||||||
|
cy.get(
|
||||||
|
'.clr-tree-node-content-container .clr-treenode-content p'
|
||||||
|
).click()
|
||||||
|
|
||||||
|
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
||||||
|
for (let innerNode of innerNodes) {
|
||||||
|
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
||||||
|
innerNode.click()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should('exist')
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Marks this file as an ES module so its top-level consts don't collide with
|
||||||
|
// other spec files under the TS type-checker.
|
||||||
|
export {}
|
||||||
|
//
|
||||||
|
// When the startup service fails without a usable response, the error modal
|
||||||
|
// offers a button that runs the same service in a new browser window, where the
|
||||||
|
// SAS log is returned instead. This spec forces a startup failure against the
|
||||||
|
// mock backend and checks the button and the URL it opens.
|
||||||
|
const hostUrl = Cypress.env('hosturl')
|
||||||
|
|
||||||
|
const STARTUP_PROGRAM = '/Public/app/dc/services/public/startupservice'
|
||||||
|
|
||||||
|
/** The body a Viya Compute task returns when the run outlives the
|
||||||
|
* execution-task timeout: a bare string, so the adapter has no webout and no
|
||||||
|
* log to report. */
|
||||||
|
const rawJobError = [
|
||||||
|
'Job error',
|
||||||
|
'The Compute service could not execute the task c74a707a-8b88-46a5-8f28-9a7694ad5e13 because the context is not reusable.',
|
||||||
|
'path: /compute/tasks',
|
||||||
|
'correlator: dc1a9fda-8f65-4f32-b4d4-2a70e02179b1;1bcb6a4c-8a16-4053-a790-292544c25a03'
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
context('startupservice launcher: ', function () {
|
||||||
|
it('1 | runs the startupservice in a new window when its response is malformed', () => {
|
||||||
|
// Report an empty admin folder so the app loads the startup service: on a
|
||||||
|
// freshly seeded mock estate makedata is still present, which sends the app
|
||||||
|
// to the deploy screen instead.
|
||||||
|
cy.intercept('GET', '**/drive/folder**', (req) => {
|
||||||
|
// Guarded parse: a lone '%' in the URL would make decodeURIComponent
|
||||||
|
// throw, and an exception here aborts the request.
|
||||||
|
let url = req.url
|
||||||
|
try {
|
||||||
|
url = decodeURIComponent(req.url)
|
||||||
|
} catch (e) {
|
||||||
|
url = req.url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('services/admin')) {
|
||||||
|
req.reply({ files: [], folders: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.continue()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.intercept('**/stp/execute/**', (req) => {
|
||||||
|
if (req.url.includes('public/startupservice')) {
|
||||||
|
// The adapter cannot use a bare string as a response payload, so the
|
||||||
|
// startup request fails with nothing to show.
|
||||||
|
req.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(rawJobError)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.continue()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The launcher must not actually open a tab under test.
|
||||||
|
cy.visit(hostUrl, {
|
||||||
|
timeout: 60000,
|
||||||
|
onBeforeLoad(win) {
|
||||||
|
cy.stub(win, 'open').as('windowOpen')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.clr-abort-modal', { timeout: 60000 }).should('be.visible')
|
||||||
|
cy.get('.clr-abort-modal').last().should('contain.text', 'startupservice')
|
||||||
|
cy.get('.clr-abort-modal').last().contains('button', 'Show log').click()
|
||||||
|
|
||||||
|
cy.get('@windowOpen').should('have.been.calledOnce')
|
||||||
|
cy.get('@windowOpen').then((stub: any) => {
|
||||||
|
const url = stub.firstCall.args[0]
|
||||||
|
|
||||||
|
expect(url).to.contain(STARTUP_PROGRAM)
|
||||||
|
expect(url).to.contain('_debug=131')
|
||||||
|
// The browser route is what makes the log available: no execution task.
|
||||||
|
expect(url).not.to.contain('_executionTasks')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -79,14 +79,34 @@ Cypress.Commands.add(
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
cy.wait(2000)
|
cy.wait(2000)
|
||||||
|
|
||||||
if ($body.find('.nav-tree').length > 0) {
|
/**
|
||||||
|
* A key that is already working needs no update. Ask the loaded
|
||||||
|
* page, not the tree: a free tier estate renders the nav tree as
|
||||||
|
* well, so the tree cannot tell a licensed estate from an
|
||||||
|
* unlicensed one - the licence banner is what does. Reading the
|
||||||
|
* body again here also matters on an estate that shows a login
|
||||||
|
* form, where the body captured above has no tree yet.
|
||||||
|
*/
|
||||||
|
cy.get('body').then(($loaded) => {
|
||||||
|
const licenceProblem = $loaded.find('#demo-banner').length > 0
|
||||||
|
|
||||||
|
if (!licenceProblem && $loaded.find('.nav-tree').length > 0) {
|
||||||
|
return logout(() => {
|
||||||
|
return
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If licence key is already working, then skip rest of the function
|
* An estate with no key is left as it is unless the caller asked
|
||||||
|
* for a licensed one - the free tier specs are written against
|
||||||
|
* that state and must not have a key planted under them.
|
||||||
*/
|
*/
|
||||||
return logout(() => {
|
if (!forceLicenceKey) {
|
||||||
return
|
return logout(() => {
|
||||||
})
|
return
|
||||||
} else {
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const keyData = {
|
const keyData = {
|
||||||
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
|
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
|
||||||
number_of_users: 10,
|
number_of_users: 10,
|
||||||
@@ -106,27 +126,21 @@ Cypress.Commands.add(
|
|||||||
cy.wait(20000)
|
cy.wait(20000)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!forceLicenceKey) {
|
return updateLicenseKeyQuick(keysGen, () => {
|
||||||
return logout(() => {
|
cy.wait(25000)
|
||||||
return
|
return acceptTermsIfPresented((result: boolean) => {
|
||||||
})
|
if (result) {
|
||||||
} else {
|
cy.wait(20000)
|
||||||
return updateLicenseKeyQuick(keysGen, () => {
|
}
|
||||||
cy.wait(25000)
|
return logout(() => {
|
||||||
return acceptTermsIfPresented((result: boolean) => {
|
return
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
return logout(() => {
|
|
||||||
return
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const check = (cwd) => {
|
|||||||
onlyAllow:
|
onlyAllow:
|
||||||
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;BlueOak-1.0.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
|
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;BlueOak-1.0.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
|
||||||
excludePackages:
|
excludePackages:
|
||||||
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
|
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;@handsontable/angular-wrapper@18.1.1;@handsontable/angular-wrapper@18.1.2;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;handsontable@18.1.1;handsontable@18.1.2;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
|
||||||
},
|
},
|
||||||
(error, json) => {
|
(error, json) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
Generated
+1119
-1689
File diff suppressed because it is too large.
Load diff
+18
-17
@@ -37,26 +37,28 @@
|
|||||||
"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'",
|
||||||
"lighthouse": "lhci autorun",
|
"lighthouse": "lhci autorun",
|
||||||
"ng": "ng"
|
"ng": "ng",
|
||||||
|
"test:tz-west": "TZ=America/New_York npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI --include='**/va-*.spec.ts'",
|
||||||
|
"test:tz-east": "TZ=Pacific/Auckland npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI --include='**/va-*.spec.ts'"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^20.3.27",
|
"@angular/animations": "20.3.34",
|
||||||
"@angular/cdk": "^20.2.14",
|
"@angular/cdk": "^20.2.14",
|
||||||
"@angular/common": "^20.3.27",
|
"@angular/common": "20.3.34",
|
||||||
"@angular/compiler": "^20.3.27",
|
"@angular/compiler": "20.3.34",
|
||||||
"@angular/core": "^20.3.27",
|
"@angular/core": "20.3.34",
|
||||||
"@angular/forms": "^20.3.27",
|
"@angular/forms": "20.3.34",
|
||||||
"@angular/platform-browser": "^20.3.27",
|
"@angular/platform-browser": "20.3.34",
|
||||||
"@angular/platform-browser-dynamic": "^20.3.27",
|
"@angular/platform-browser-dynamic": "20.3.34",
|
||||||
"@angular/router": "^20.3.27",
|
"@angular/router": "20.3.34",
|
||||||
"@cds/core": "^6.15.1",
|
"@cds/core": "^6.15.1",
|
||||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||||
"@clr/icons": "^13.0.2",
|
"@clr/icons": "^13.0.2",
|
||||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||||
"@handsontable/angular-wrapper": "18.0.0",
|
"@handsontable/angular-wrapper": "18.1.2",
|
||||||
"@sasjs/adapter": "^4.18.0",
|
"@sasjs/adapter": "5.1.2",
|
||||||
"@sasjs/utils": "^3.6.0",
|
"@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",
|
||||||
@@ -69,12 +71,11 @@
|
|||||||
"d3-graphviz": "^5.0.2",
|
"d3-graphviz": "^5.0.2",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"fs-extra": "^7.0.1",
|
"fs-extra": "^7.0.1",
|
||||||
"handsontable": "18.0.0",
|
"handsontable": "18.1.2",
|
||||||
"https-browserify": "1.0.0",
|
"https-browserify": "1.0.0",
|
||||||
"hyperformula": "^2.5.0",
|
"hyperformula": "^2.5.0",
|
||||||
"iconv-lite": "^0.5.0",
|
"iconv-lite": "^0.5.0",
|
||||||
"jquery-datetimepicker": "^2.5.21",
|
"jquery-datetimepicker": "^2.5.21",
|
||||||
"jsrsasign": "11.1.1",
|
|
||||||
"marked": "^5.0.0",
|
"marked": "^5.0.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"ngx-clipboard": "^16.0.0",
|
"ngx-clipboard": "^16.0.0",
|
||||||
@@ -99,8 +100,8 @@
|
|||||||
"@angular-eslint/eslint-plugin-template": "19.8.1",
|
"@angular-eslint/eslint-plugin-template": "19.8.1",
|
||||||
"@angular-eslint/schematics": "19.8.1",
|
"@angular-eslint/schematics": "19.8.1",
|
||||||
"@angular-eslint/template-parser": "19.8.1",
|
"@angular-eslint/template-parser": "19.8.1",
|
||||||
"@angular/cli": "^20.3.32",
|
"@angular/cli": "20.3.37",
|
||||||
"@angular/compiler-cli": "^20.3.27",
|
"@angular/compiler-cli": "20.3.34",
|
||||||
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||||
"@compodoc/compodoc": "^2.0.0",
|
"@compodoc/compodoc": "^2.0.0",
|
||||||
"@cypress/webpack-preprocessor": "^5.17.1",
|
"@cypress/webpack-preprocessor": "^5.17.1",
|
||||||
@@ -148,7 +149,7 @@
|
|||||||
"ajv": "8.18.0",
|
"ajv": "8.18.0",
|
||||||
"uuid": "11.1.1",
|
"uuid": "11.1.1",
|
||||||
"lighthouse": "13.4.0",
|
"lighthouse": "13.4.0",
|
||||||
"fast-uri": "3.1.7",
|
"fast-uri": "4.2.3",
|
||||||
"readdir-glob": {
|
"readdir-glob": {
|
||||||
"brace-expansion": "^5.0.9"
|
"brace-expansion": "^5.0.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
|
|
||||||
[class*=ht-theme-classic] .htDropdownMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
[class*=ht-theme-classic] .htDropdownMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
||||||
[class*=ht-theme-classic] .htContextMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
[class*=ht-theme-classic] .htContextMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
||||||
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td.htSubmenu .htItemWrapper::after {
|
||||||
[class*=ht-theme-classic] .pika-single .pika-next {
|
|
||||||
width: var(--ht-icon-size);
|
width: var(--ht-icon-size);
|
||||||
height: var(--ht-icon-size);
|
height: var(--ht-icon-size);
|
||||||
-webkit-mask-size: contain;
|
-webkit-mask-size: contain;
|
||||||
@@ -12,14 +11,6 @@
|
|||||||
background-color: currentColor;
|
background-color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
[class*=ht-theme-classic] .pika-single .pika-prev {
|
|
||||||
width: var(--ht-icon-size);
|
|
||||||
height: var(--ht-icon-size);
|
|
||||||
-webkit-mask-size: contain;
|
|
||||||
-webkit-mask-image: url("./assets/hot-icons/arrow-left.svg");
|
|
||||||
background-color: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
[class*=ht-theme-classic] .ht-page-size-section__select-wrapper::after {
|
[class*=ht-theme-classic] .ht-page-size-section__select-wrapper::after {
|
||||||
width: var(--ht-icon-size);
|
width: var(--ht-icon-size);
|
||||||
height: var(--ht-icon-size);
|
height: var(--ht-icon-size);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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
|
||||||
@@ -61,6 +62,21 @@ 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[]
|
||||||
@@ -77,6 +93,8 @@ export const globals: {
|
|||||||
} = {
|
} = {
|
||||||
embed: false,
|
embed: false,
|
||||||
vaApplyMode: 'live',
|
vaApplyMode: 'live',
|
||||||
|
vaParamMap: new Map(),
|
||||||
|
vaParmapProblems: [],
|
||||||
rootParam: <string>'',
|
rootParam: <string>'',
|
||||||
dcLib: '',
|
dcLib: '',
|
||||||
xlmaps: [],
|
xlmaps: [],
|
||||||
|
|||||||
@@ -318,6 +318,7 @@
|
|||||||
class="startup-check"
|
class="startup-check"
|
||||||
[class.startup-check--error]="step.status === 'error'"
|
[class.startup-check--error]="step.status === 'error'"
|
||||||
[class.startup-check--done]="step.status === 'done'"
|
[class.startup-check--done]="step.status === 'done'"
|
||||||
|
[class.startup-check--grid]="step.details?.length"
|
||||||
>
|
>
|
||||||
@switch (step.status) {
|
@switch (step.status) {
|
||||||
@case ('pending') {
|
@case ('pending') {
|
||||||
@@ -346,6 +347,18 @@
|
|||||||
@if (step.detail) {
|
@if (step.detail) {
|
||||||
<span class="startup-check__detail">{{ step.detail }}</span>
|
<span class="startup-check__detail">{{ step.detail }}</span>
|
||||||
}
|
}
|
||||||
|
@if (step.details?.length) {
|
||||||
|
<div class="startup-check__grid">
|
||||||
|
@for (detail of step.details; track detail.name) {
|
||||||
|
<span class="startup-check__grid-name">{{
|
||||||
|
detail.name
|
||||||
|
}}</span>
|
||||||
|
<span class="startup-check__grid-value">{{
|
||||||
|
detail.value
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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'
|
||||||
@@ -20,6 +21,8 @@ import { DcAdapterSettings } from './models/DcAdapterSettings'
|
|||||||
import { AppStoreService } from './services/app-store.service'
|
import { AppStoreService } from './services/app-store.service'
|
||||||
import { LicenceService } from './services/licence.service'
|
import { LicenceService } from './services/licence.service'
|
||||||
import {
|
import {
|
||||||
|
getSasjsTagDetails,
|
||||||
|
SASJS_TAG_STEP,
|
||||||
StartupCheckService,
|
StartupCheckService,
|
||||||
StartupStep
|
StartupStep
|
||||||
} from './services/startup-check.service'
|
} from './services/startup-check.service'
|
||||||
@@ -161,6 +164,21 @@ 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.subscribeToRequestsModal()
|
this.subscribeToRequestsModal()
|
||||||
@@ -190,40 +208,45 @@ export class AppComponent {
|
|||||||
* Parses adapter settings that are found in the <sasjs> tag inside index.html
|
* Parses adapter settings that are found in the <sasjs> tag inside index.html
|
||||||
*/
|
*/
|
||||||
private parseDcAdapterSettings() {
|
private parseDcAdapterSettings() {
|
||||||
|
/* The <sasjs> tag is a mandatory part of index.html - it carries serverUrl,
|
||||||
|
serverType and the rest of the adapter config - so there is no config to
|
||||||
|
fall back on when it is absent. A missing one is reported on the boot
|
||||||
|
screen below rather than handled with an abort modal. */
|
||||||
const sasjsElement = document.querySelector('sasjs')
|
const sasjsElement = document.querySelector('sasjs')
|
||||||
|
|
||||||
if (!sasjsElement) {
|
|
||||||
this.licenceService.deactivateApp()
|
|
||||||
setTimeout(() => {
|
|
||||||
this.eventService.showAbortModal(
|
|
||||||
null,
|
|
||||||
"Please make sure 'SASJS' tag with config attributes is added to index.html",
|
|
||||||
null,
|
|
||||||
'SASjs Config not found'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAppAttribute = (attribute: string) =>
|
const getAppAttribute = (attribute: string) =>
|
||||||
sasjsElement.getAttribute(attribute) || undefined
|
sasjsElement?.getAttribute(attribute) || undefined
|
||||||
|
|
||||||
const dcAdapterSettings = {
|
const dcAdapterSettings = {
|
||||||
serverUrl: getAppAttribute('serverUrl') || '',
|
serverUrl: getAppAttribute('serverUrl') || '',
|
||||||
appLoc: getAppAttribute('appLoc') || '',
|
appLoc: getAppAttribute('appLoc') || '',
|
||||||
serverType: getAppAttribute('serverType'),
|
serverType: getAppAttribute('serverType'),
|
||||||
loginMechanism: getAppAttribute('loginMechanism') || '',
|
loginMechanism: getAppAttribute('loginMechanism') || '',
|
||||||
adminGroup: getAppAttribute('adminGroup') || '',
|
|
||||||
dcPath: getAppAttribute('dcPath') || '',
|
dcPath: getAppAttribute('dcPath') || '',
|
||||||
debug: getAppAttribute('debug') === 'true' || false,
|
debug: getAppAttribute('debug') === 'true' || false,
|
||||||
useComputeApi: this.parseComputeApi(getAppAttribute('useComputeApi')),
|
useComputeApi: this.parseComputeApi(getAppAttribute('useComputeApi')),
|
||||||
runAsTask: getAppAttribute('runAsTask') === 'true' || false,
|
runAsTask: getAppAttribute('runAsTask') === 'true' || false,
|
||||||
contextName: getAppAttribute('contextName') || '',
|
contextName: getAppAttribute('contextName') || ''
|
||||||
hotLicenceKey: getAppAttribute('hotLicenceKey') || ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dcAdapterSettings = dcAdapterSettings as any
|
this.dcAdapterSettings = dcAdapterSettings as any
|
||||||
this.appStoreService.setDcAdapterSettings(dcAdapterSettings as any)
|
this.appStoreService.setDcAdapterSettings(dcAdapterSettings as any)
|
||||||
|
|
||||||
|
/* Show the <sasjs> tag contents on the boot screen, so the effective
|
||||||
|
configuration (serverType, debug, useComputeApi, ...) is visible without
|
||||||
|
having to read the page source. The tag is mandatory - without it the
|
||||||
|
adapter has no serverUrl or serverType - so a missing one is called out
|
||||||
|
plainly rather than silently leaving the step pending. */
|
||||||
|
if (sasjsElement) {
|
||||||
|
this.startupCheckService.setTagDetails(getSasjsTagDetails(sasjsElement))
|
||||||
|
} else {
|
||||||
|
this.startupCheckService.updateStep(
|
||||||
|
SASJS_TAG_STEP,
|
||||||
|
'error',
|
||||||
|
'SASJS TAG MISSING!!'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
this.appService.sasServiceInit()
|
this.appService.sasServiceInit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,9 +330,12 @@ export class AppComponent {
|
|||||||
*/
|
*/
|
||||||
public subscribeToShowAbortModal() {
|
public subscribeToShowAbortModal() {
|
||||||
this.eventService.onShowAbortModal.subscribe((sasjsAbort: InfoModal) => {
|
this.eventService.onShowAbortModal.subscribe((sasjsAbort: InfoModal) => {
|
||||||
let abortId = this.sasjsAborts.length + 1
|
/* One message at a time. Pushing to the array stacked a second modal on
|
||||||
sasjsAbort.id = abortId
|
top of the one already open - two dismissals for one failure, and the
|
||||||
this.sasjsAborts.push(sasjsAbort)
|
lower one was often a duplicate of the upper. Replacing keeps the
|
||||||
|
newest message, which is the one the user has to act on. */
|
||||||
|
sasjsAbort.id = Date.now()
|
||||||
|
this.sasjsAborts = [sasjsAbort]
|
||||||
this.cdr.detectChanges() //Changes were not triggered while hot is focused
|
this.cdr.detectChanges() //Changes were not triggered while hot is focused
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { NgModule } from '@angular/core'
|
|||||||
import { RouterModule, Routes } from '@angular/router'
|
import { RouterModule, Routes } from '@angular/router'
|
||||||
import { DeployComponent } from './deploy.component'
|
import { DeployComponent } from './deploy.component'
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [{ path: '', component: DeployComponent }]
|
||||||
{ path: '', component: DeployComponent },
|
|
||||||
{ path: 'manualdeploy', component: DeployComponent }
|
|
||||||
]
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [RouterModule.forChild(routes)],
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
|||||||
@@ -1,7 +1,36 @@
|
|||||||
<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 (step === 0) {
|
@if (!isEmbedded && 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">
|
||||||
@@ -29,52 +58,26 @@
|
|||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
</clr-checkbox-wrapper>
|
</clr-checkbox-wrapper>
|
||||||
<!-- <hr />
|
|
||||||
<div class="clr-checkbox-wrapper">
|
|
||||||
<input
|
|
||||||
[(ngModel)]="autodeploy"
|
|
||||||
type="checkbox"
|
|
||||||
id="checkbox2"
|
|
||||||
class="clr-checkbox"
|
|
||||||
checked
|
|
||||||
/>
|
|
||||||
<label for="checkbox2"
|
|
||||||
>Autodeploy
|
|
||||||
{{ !jsonFile ? '(json file is not available)' : '' }}</label
|
|
||||||
>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- T&C section end -->
|
<!-- T&C section end -->
|
||||||
|
|
||||||
@if (step > 0) {
|
@if (!isEmbedded && step > 0) {
|
||||||
@switch (true) {
|
@switch (true) {
|
||||||
@case (sasJsConfig.serverType === ServerType.SasViya) {
|
@case (sasJsConfig.serverType === ServerType.SasViya) {
|
||||||
@if (autodeploy) {
|
<div class="autodeploy-section card">
|
||||||
<div class="autodeploy-section card">
|
<app-automatic-deploy
|
||||||
<app-automatic-deploy
|
[sasJs]="sasJs"
|
||||||
[sasJs]="sasJs"
|
[sasJsConfig]="sasJsConfig"
|
||||||
[sasJsConfig]="sasJsConfig"
|
[dcAdapterSettings]="dcAdapterSettings"
|
||||||
[dcAdapterSettings]="dcAdapterSettings"
|
[appLoc]="appLoc"
|
||||||
[appLoc]="appLoc"
|
[dcPath]="dcPath"
|
||||||
[dcPath]="dcPath"
|
[selectedAdminGroup]="selectedAdminGroup"
|
||||||
[selectedAdminGroup]="selectedAdminGroup"
|
(onNavigateToHome)="onNavigateToHome()"
|
||||||
(onNavigateToHome)="onNavigateToHome()"
|
></app-automatic-deploy>
|
||||||
></app-automatic-deploy>
|
</div>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (!autodeploy) {
|
|
||||||
<div id="mainbody" class="card">
|
|
||||||
<app-manual-deploy
|
|
||||||
[sasJs]="sasJs"
|
|
||||||
[sasJsConfig]="sasJsConfig"
|
|
||||||
[dcAdapterSettings]="dcAdapterSettings"
|
|
||||||
(onNavigateToHome)="onNavigateToHome()"
|
|
||||||
></app-manual-deploy>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@case (sasJsConfig.serverType === ServerType.Sasjs) {
|
@case (sasJsConfig.serverType === ServerType.Sasjs) {
|
||||||
<div class="autodeploy-section card">
|
<div class="autodeploy-section card">
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { ServerType } from '@sasjs/utils/types/serverType'
|
||||||
|
import { DeployComponent } from './deploy.component'
|
||||||
|
import { standaloneUrl } from './standalone-url'
|
||||||
|
import { globals } from '../_globals'
|
||||||
|
import { AppStoreService } from '../services/app-store.service'
|
||||||
|
import { SasService } from '../services/sas.service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An embedded instance cannot deploy itself: the deployment needs an
|
||||||
|
* authenticated, standalone session, and the person looking at a report object
|
||||||
|
* is not the person who installs the app. The component therefore withholds the
|
||||||
|
* wizard and names the state instead.
|
||||||
|
*
|
||||||
|
* These cases render the template, so they fail if the `!isEmbedded` guards in
|
||||||
|
* deploy.component.html are dropped - asserting the getter alone would not.
|
||||||
|
*/
|
||||||
|
describe('DeployComponent embed guard', () => {
|
||||||
|
let fixture: ComponentFixture<DeployComponent>
|
||||||
|
let component: DeployComponent
|
||||||
|
|
||||||
|
const originalEmbed = globals.embed
|
||||||
|
|
||||||
|
const configure = () =>
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [DeployComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AppStoreService,
|
||||||
|
useValue: {
|
||||||
|
getDcAdapterSettings: () => ({
|
||||||
|
appLoc: '/Public/app/dc',
|
||||||
|
dcPath: '/tmp/dc'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SasService,
|
||||||
|
useValue: {
|
||||||
|
getSasjsInstance: () => ({}),
|
||||||
|
getSasjsConfig: () => ({ serverType: ServerType.Sasjs })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// The wizard renders child components this spec does not exercise.
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
fixture = TestBed.createComponent(DeployComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
const tcsSection = () =>
|
||||||
|
fixture.nativeElement.querySelector('#TCS') as HTMLElement | null
|
||||||
|
const notConfigured = () =>
|
||||||
|
fixture.nativeElement.querySelector('#not-configured') as HTMLElement | null
|
||||||
|
const deploySection = () =>
|
||||||
|
fixture.nativeElement.querySelector(
|
||||||
|
'.autodeploy-section'
|
||||||
|
) as HTMLElement | null
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globals.embed = originalEmbed
|
||||||
|
TestBed.resetTestingModule()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers the wizard when running in its own tab', () => {
|
||||||
|
globals.embed = false
|
||||||
|
configure()
|
||||||
|
render()
|
||||||
|
|
||||||
|
expect(tcsSection()).toBeTruthy()
|
||||||
|
expect(notConfigured()).toBeNull()
|
||||||
|
expect(component.isEmbedded).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces the wizard with the notice for a VA embed', () => {
|
||||||
|
globals.embed = 'va'
|
||||||
|
configure()
|
||||||
|
render()
|
||||||
|
|
||||||
|
expect(tcsSection()).toBeNull()
|
||||||
|
expect(notConfigured()).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces the wizard with the notice for a plain embed too', () => {
|
||||||
|
globals.embed = true
|
||||||
|
configure()
|
||||||
|
render()
|
||||||
|
|
||||||
|
expect(tcsSection()).toBeNull()
|
||||||
|
expect(notConfigured()).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the standalone URL in the embedded notice', () => {
|
||||||
|
globals.embed = 'va'
|
||||||
|
configure()
|
||||||
|
render()
|
||||||
|
|
||||||
|
const link = notConfigured()!.querySelector('a') as HTMLAnchorElement
|
||||||
|
|
||||||
|
expect(link.getAttribute('href')).toBe(component.deployUrl)
|
||||||
|
expect(link.textContent).toBe(component.deployUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('withholds the deploy section after the terms are accepted, when embedded', () => {
|
||||||
|
globals.embed = 'va'
|
||||||
|
configure()
|
||||||
|
render()
|
||||||
|
|
||||||
|
component.step = 1
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(deploySection()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the deploy section after the terms are accepted, standalone', () => {
|
||||||
|
globals.embed = false
|
||||||
|
configure()
|
||||||
|
render()
|
||||||
|
|
||||||
|
component.step = 1
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(deploySection()).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DeployComponent onNavigateToHome', () => {
|
||||||
|
const originalEmbed = globals.embed
|
||||||
|
const originalHref = window.location.href
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globals.embed = originalEmbed
|
||||||
|
history.replaceState(null, '', originalHref)
|
||||||
|
TestBed.resetTestingModule()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the standalone URL, merging an embed tab into the app URL', () => {
|
||||||
|
// Karma serves the runner with its own query, so the app URL is set here
|
||||||
|
// rather than read from window.location: the assertion has to see the
|
||||||
|
// app's query survive the embed parameters being dropped. The shape is
|
||||||
|
// the real SAS 9 one - the app's `_program` query in the search part,
|
||||||
|
// the embed switches in the hash query.
|
||||||
|
history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/deploy-tc?_program=/Public/app/dc&_debug=131#/editor?embed=va&va_parmap=A-B'
|
||||||
|
)
|
||||||
|
const open = spyOn(window, 'open')
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [DeployComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AppStoreService,
|
||||||
|
useValue: { getDcAdapterSettings: () => ({ appLoc: '', dcPath: '' }) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SasService,
|
||||||
|
useValue: {
|
||||||
|
getSasjsInstance: () => ({}),
|
||||||
|
getSasjsConfig: () => ({ serverType: ServerType.Sasjs })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
})
|
||||||
|
const fixture = TestBed.createComponent(DeployComponent)
|
||||||
|
|
||||||
|
fixture.componentInstance.onNavigateToHome()
|
||||||
|
|
||||||
|
const opened = open.calls.mostRecent().args[0] as string
|
||||||
|
expect(opened).toBe(
|
||||||
|
window.location.origin + '/deploy-tc?_program=/Public/app/dc&_debug=131'
|
||||||
|
)
|
||||||
|
expect(opened).not.toContain('#')
|
||||||
|
expect(opened).not.toContain('embed=')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('standaloneUrl', () => {
|
||||||
|
const origin = window.location.origin
|
||||||
|
|
||||||
|
it('keeps the path of a plain URL', () => {
|
||||||
|
expect(standaloneUrl(`${origin}/SASJobExecution/dc`)).toBe(
|
||||||
|
`${origin}/SASJobExecution/dc`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a query the app needs, and drops the hash route', () => {
|
||||||
|
expect(standaloneUrl(`${origin}/path?x=1#/deploy`)).toBe(
|
||||||
|
`${origin}/path?x=1`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps `_program=`, which is how SAS 9 and Viya address the app', () => {
|
||||||
|
// The whole app URL is the query here: /SASJobExecution/?_program=... is
|
||||||
|
// the standalone app, so dropping the query would open a blank executor.
|
||||||
|
const sas9 =
|
||||||
|
`${origin}/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice` +
|
||||||
|
'&_debug=131'
|
||||||
|
expect(standaloneUrl(sas9)).toBe(sas9)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a bare embed query', () => {
|
||||||
|
expect(standaloneUrl(`${origin}/app?embed=va`)).toBe(`${origin}/app`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the va_parmap entries along with the embed switch', () => {
|
||||||
|
expect(
|
||||||
|
standaloneUrl(
|
||||||
|
`${origin}/app?embed=va&va_parmap=A-B&va_parmap=C-D#/editor/x`
|
||||||
|
)
|
||||||
|
).toBe(`${origin}/app`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a query parameter that merely starts with va_', () => {
|
||||||
|
expect(standaloneUrl(`${origin}/app?embed=va&va_rowlimit=100`)).toBe(
|
||||||
|
`${origin}/app?va_rowlimit=100`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves a relative URL against the current origin', () => {
|
||||||
|
expect(standaloneUrl('/app/#/deploy')).toBe(`${origin}/app/`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Component, OnInit, ViewEncapsulation } from '@angular/core'
|
import { Component, OnInit, ViewEncapsulation } from '@angular/core'
|
||||||
import { SasService } from '../services/sas.service'
|
import { SasService } from '../services/sas.service'
|
||||||
import { SASjsConfig } from '@sasjs/adapter'
|
import { SASjsConfig } from '@sasjs/adapter'
|
||||||
import { Router } from '@angular/router'
|
|
||||||
import { LoggerService } from '../services/logger.service'
|
|
||||||
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',
|
||||||
@@ -19,15 +19,11 @@ import { DcAdapterSettings } from '../models/DcAdapterSettings'
|
|||||||
})
|
})
|
||||||
export class DeployComponent implements OnInit {
|
export class DeployComponent implements OnInit {
|
||||||
public step: number = 0
|
public step: number = 0
|
||||||
public adminGroups: any = []
|
|
||||||
|
|
||||||
public appLoc: string = ''
|
public appLoc: string = ''
|
||||||
public dcPath: string = ''
|
public dcPath: string = ''
|
||||||
public selectedAdminGroup: string = ''
|
public selectedAdminGroup: string = ''
|
||||||
|
|
||||||
public autodeploy: boolean = true
|
|
||||||
public jsonFile: any = null
|
|
||||||
|
|
||||||
public sasJs: any
|
public sasJs: any
|
||||||
public sasJsConfig: SASjsConfig = new SASjsConfig()
|
public sasJsConfig: SASjsConfig = new SASjsConfig()
|
||||||
|
|
||||||
@@ -37,16 +33,10 @@ export class DeployComponent implements OnInit {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private appStoreService: AppStoreService,
|
private appStoreService: AppStoreService,
|
||||||
private sasService: SasService,
|
private sasService: SasService
|
||||||
private loggerService: LoggerService,
|
|
||||||
private router: Router
|
|
||||||
) {
|
) {
|
||||||
this.dcAdapterSettings = this.appStoreService.getDcAdapterSettings()
|
this.dcAdapterSettings = this.appStoreService.getDcAdapterSettings()
|
||||||
|
|
||||||
if (this.router.url.includes('manualdeploy')) {
|
|
||||||
this.autodeploy = false
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sasJs = this.sasService.getSasjsInstance()
|
this.sasJs = this.sasService.getSasjsInstance()
|
||||||
this.sasJsConfig = this.sasService.getSasjsConfig()
|
this.sasJsConfig = this.sasService.getSasjsConfig()
|
||||||
this.appLoc = this.dcAdapterSettings?.appLoc || ''
|
this.appLoc = this.dcAdapterSettings?.appLoc || ''
|
||||||
@@ -56,60 +46,38 @@ 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
|
||||||
*/
|
*/
|
||||||
public setDeployDefaults() {
|
public setDeployDefaults() {
|
||||||
this.dcPath = this.dcAdapterSettings?.dcPath || ''
|
this.dcPath = this.dcAdapterSettings?.dcPath || ''
|
||||||
this.selectedAdminGroup = this.dcAdapterSettings?.adminGroup || ''
|
this.selectedAdminGroup = 'SASAdministrators'
|
||||||
if (!this.selectedAdminGroup) {
|
|
||||||
this.selectedAdminGroup = 'SASAdministrators'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accepting terms of service shows next screen
|
* Accepting terms of service shows next screen
|
||||||
*/
|
*/
|
||||||
public termsAgreeChange() {
|
public termsAgreeChange() {
|
||||||
if (!this.autodeploy) {
|
|
||||||
this.getAdminGroups()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.step++
|
this.step++
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches admin groups from VIYA to be selected for a backend deploy
|
|
||||||
*/
|
|
||||||
public getAdminGroups() {
|
|
||||||
fetch(
|
|
||||||
this.sasJsConfig.serverUrl + '/identities/groups?sortBy=name&limit=5000',
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then((res: any) => {
|
|
||||||
return res.text()
|
|
||||||
})
|
|
||||||
.then((res: any) => {
|
|
||||||
let jsonRes
|
|
||||||
|
|
||||||
try {
|
|
||||||
jsonRes = JSON.parse(res)
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jsonRes) {
|
|
||||||
this.adminGroups = jsonRes.items
|
|
||||||
this.selectedAdminGroup = this.adminGroups[0].id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public onNavigateToHome() {
|
public onNavigateToHome() {
|
||||||
window.open(location.href.split('#')[0], '_blank')
|
// Same helper as deployUrl: splitting on '#' left the query string in
|
||||||
|
// place, so a tab opened from `?embed=va#/deploy` stayed embedded.
|
||||||
|
window.open(standaloneUrl(window.location.href), '_blank')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ import { NgModule } from '@angular/core'
|
|||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { DeployComponent } from './deploy.component'
|
import { DeployComponent } from './deploy.component'
|
||||||
import { AutomaticComponent } from './sections/automatic/automatic.component'
|
import { AutomaticComponent } from './sections/automatic/automatic.component'
|
||||||
import { ManualComponent } from './sections/manual/manual.component'
|
|
||||||
import { SasjsConfiguratorComponent } from './sections/sasjs-configurator/sasjs-configurator.component'
|
import { SasjsConfiguratorComponent } from './sections/sasjs-configurator/sasjs-configurator.component'
|
||||||
import { ClarityModule } from '@clr/angular'
|
import { ClarityModule } from '@clr/angular'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
@@ -12,7 +11,6 @@ import { DeployRoutingModule } from './deploy-routing.module'
|
|||||||
declarations: [
|
declarations: [
|
||||||
DeployComponent,
|
DeployComponent,
|
||||||
AutomaticComponent,
|
AutomaticComponent,
|
||||||
ManualComponent,
|
|
||||||
SasjsConfiguratorComponent
|
SasjsConfiguratorComponent
|
||||||
],
|
],
|
||||||
imports: [CommonModule, FormsModule, ClarityModule, DeployRoutingModule]
|
imports: [CommonModule, FormsModule, ClarityModule, DeployRoutingModule]
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
<div class="card-header position-relative">
|
|
||||||
Configurator
|
|
||||||
<p class="d-inline-block ml-10 mb-10 mt-0">App Location: {{ appLoc }}</p>
|
|
||||||
<button
|
|
||||||
(click)="deleteKeys()"
|
|
||||||
class="btn btn-primary clear-memory-button position-absolute"
|
|
||||||
>
|
|
||||||
Clear memory
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-block">
|
|
||||||
<!-- <div class="card-title">
|
|
||||||
Client Details
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
@if (needsLogin) {
|
|
||||||
<div id="loginForm" class="d-none">
|
|
||||||
<p class="mb-10">Please log in first</p>
|
|
||||||
<label for="username" class="clr-control-label">Username</label>
|
|
||||||
<div class="mb-10 clr-control-container">
|
|
||||||
<div class="clr-input-wrapper">
|
|
||||||
<input type="text" id="username" class="clr-input" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label for="password" class="clr-control-label">Password</label>
|
|
||||||
<div class="mb-10 clr-control-container">
|
|
||||||
<div class="clr-input-wrapper">
|
|
||||||
<input type="password" id="password" class="clr-input" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (!needsLogin) {
|
|
||||||
<form>
|
|
||||||
<div class="clr-form-control">
|
|
||||||
<label for="select-full" class="clr-control-label">Admin group</label>
|
|
||||||
<div class="clr-control-container">
|
|
||||||
<div class="clr-select-wrapper">
|
|
||||||
<select
|
|
||||||
[(ngModel)]="selectedAdminGroup"
|
|
||||||
[ngModelOptions]="{ standalone: true }"
|
|
||||||
name="selectedAdminGroup"
|
|
||||||
id="adminGroupsSelect"
|
|
||||||
class="clr-select"
|
|
||||||
>
|
|
||||||
@for (adminGroup of adminGroups; track adminGroup) {
|
|
||||||
<option [value]="adminGroup.id">
|
|
||||||
{{ adminGroup.name }}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="clr-form-control">
|
|
||||||
<div [class.hidden]="contextsLoading">
|
|
||||||
<label for="select-full" class="clr-control-label">Context</label>
|
|
||||||
<div class="clr-control-container">
|
|
||||||
<div class="clr-select-wrapper">
|
|
||||||
<select
|
|
||||||
[(ngModel)]="selectedContext"
|
|
||||||
[ngModelOptions]="{ standalone: true }"
|
|
||||||
name="selectedContext"
|
|
||||||
class="clr-select"
|
|
||||||
>
|
|
||||||
@for (context of allContexts; track context) {
|
|
||||||
<option [value]="context.name">
|
|
||||||
{{ context.name }}
|
|
||||||
@if ((context.attributes | json) != '{}') {
|
|
||||||
<span>( {{ context.attributes.sysUserId }} )</span>
|
|
||||||
}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
(click)="executableContext()"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-icon"
|
|
||||||
id="contexts-btn"
|
|
||||||
>
|
|
||||||
<clr-icon shape="play"></clr-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div [class.hidden]="!contextsLoading" class="d-flex">
|
|
||||||
<span class="spinner spinner-inline mr-10">
|
|
||||||
Loading contexts...
|
|
||||||
</span>
|
|
||||||
<span> Loading contexts... </span>
|
|
||||||
</div>
|
|
||||||
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
|
|
||||||
<div class="mb-10 clr-control-container">
|
|
||||||
<div class="clr-input-wrapper">
|
|
||||||
<input
|
|
||||||
[(ngModel)]="dcPath"
|
|
||||||
[ngModelOptions]="{ standalone: true }"
|
|
||||||
(focusout)="saveDcPath()"
|
|
||||||
name="dcPath"
|
|
||||||
type="text"
|
|
||||||
id="dcloc"
|
|
||||||
class="clr-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-10">
|
|
||||||
Select JSON file to upload (json build file preloaded):
|
|
||||||
</p>
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
(click)="clearUploadInput($event)"
|
|
||||||
(change)="onJsonFileChange($event)"
|
|
||||||
/>
|
|
||||||
<!-- <button *ngIf="downloadFileBtn" (click)="downloadSasPrecodeFile()" style="width: 40px; min-width: 0;" class="btn btn-sm btn-icon">
|
|
||||||
<clr-icon shape="download"></clr-icon>
|
|
||||||
</button> -->
|
|
||||||
</div>
|
|
||||||
<div class="mt-20 d-flex align-items-center">
|
|
||||||
<button
|
|
||||||
(click)="executeJson()"
|
|
||||||
class="btn btn-primary"
|
|
||||||
[clrLoading]="isSubmittingJson"
|
|
||||||
id=""
|
|
||||||
class="mt-0"
|
|
||||||
>
|
|
||||||
SUBMIT JSON
|
|
||||||
</button>
|
|
||||||
@if (isJsonSubmitted) {
|
|
||||||
<span>JSON Submitted Successfully</span>
|
|
||||||
}
|
|
||||||
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
|
|
||||||
Loading...
|
|
||||||
</span> -->
|
|
||||||
</div>
|
|
||||||
<p class="mt-10">Select SAS file to upload:</p>
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
(click)="clearUploadInput($event)"
|
|
||||||
(change)="onSasFileChange($event); downloadFileBtn = true"
|
|
||||||
/>
|
|
||||||
@if (downloadFileBtn) {
|
|
||||||
<button
|
|
||||||
(click)="downloadSasPrecodeFile()"
|
|
||||||
class="btn btn-sm btn-icon min-w-0 w-40"
|
|
||||||
>
|
|
||||||
<clr-icon shape="download"></clr-icon>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="mt-20 d-flex align-items-center">
|
|
||||||
<button
|
|
||||||
(click)="executeSAS()"
|
|
||||||
class="btn btn-primary"
|
|
||||||
[clrLoading]="executingScript"
|
|
||||||
id="deploy"
|
|
||||||
class="mt-0"
|
|
||||||
>
|
|
||||||
SUBMIT
|
|
||||||
</button>
|
|
||||||
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
|
|
||||||
Loading...
|
|
||||||
</span> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (jobLog.length > 0) {
|
|
||||||
<p class="mb-0 mt-10">File execute completed</p>
|
|
||||||
<hr />
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
(click)="downloadFile(jobLog, 'execute-script-log', 'txt')"
|
|
||||||
class="btn btn-primary mt-0 mr-20"
|
|
||||||
>
|
|
||||||
Download log
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
[clrLoading]="createDatabaseLoading"
|
|
||||||
(click)="createDatabase()"
|
|
||||||
class="btn btn-primary mt-10"
|
|
||||||
>
|
|
||||||
Create Database
|
|
||||||
</button>
|
|
||||||
@if (makeDataResponse.length > 0) {
|
|
||||||
<p class="mb-0 mt-10">Create Database Completed</p>
|
|
||||||
<hr />
|
|
||||||
@if (makeDataResponse.length > 0) {
|
|
||||||
<div class="log-wrapper">
|
|
||||||
{{ makeDataResponse }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<button (click)="navigateToHome()" class="btn btn-primary mt-15">
|
|
||||||
Let's get started
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
|
|
||||||
class="btn btn-primary mt-15"
|
|
||||||
>
|
|
||||||
Download log
|
|
||||||
</button>
|
|
||||||
<button (click)="validateDeploy()" class="btn btn-primary mt-15">
|
|
||||||
Validate
|
|
||||||
</button>
|
|
||||||
@if (validationState !== 'none' || isValidating) {
|
|
||||||
<div class="validation-bar">
|
|
||||||
@if (isValidating) {
|
|
||||||
<span class="spinner spinner-inline mr-10">
|
|
||||||
Validating deploy...
|
|
||||||
</span>
|
|
||||||
<span> Validating deploy... </span>
|
|
||||||
}
|
|
||||||
@if (!isValidating && validationState === 'error') {
|
|
||||||
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
|
|
||||||
<span> Validation failed </span>
|
|
||||||
}
|
|
||||||
@if (!isValidating && validationState === 'success') {
|
|
||||||
<clr-icon shape="check-circle" class="is-success"></clr-icon>
|
|
||||||
<span> Validation succeeded </span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
Whitespace-only changes.
@@ -1,90 +0,0 @@
|
|||||||
import { ManualComponent } from './manual.component'
|
|
||||||
|
|
||||||
// validateDeploy() is fire-and-forget (doesn't return its own promise
|
|
||||||
// chain), so tests need to wait for it to settle some other way. A
|
|
||||||
// macrotask boundary guarantees every already-queued microtask (regardless
|
|
||||||
// of how many .then() hops validateDeploy's chain has) has run first.
|
|
||||||
const flushPromiseChain = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* validateDeploy() only touches sasService.request, eventService.showInfoModal
|
|
||||||
* and loggerService.log - these stubs cover exactly that surface, not the
|
|
||||||
* full real services (no TestBed/DI needed, same plain-instantiation-with-
|
|
||||||
* stubs precedent as app.service.spec.ts).
|
|
||||||
*/
|
|
||||||
const buildDeps = () => {
|
|
||||||
const sasService: any = {
|
|
||||||
request: jasmine.createSpy('request')
|
|
||||||
}
|
|
||||||
const eventService: any = {
|
|
||||||
showInfoModal: jasmine.createSpy('showInfoModal')
|
|
||||||
}
|
|
||||||
const loggerService: any = {
|
|
||||||
log: jasmine.createSpy('log')
|
|
||||||
}
|
|
||||||
const deployService: any = {}
|
|
||||||
|
|
||||||
return { sasService, eventService, loggerService, deployService }
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildManualComponent = (deps: ReturnType<typeof buildDeps>) =>
|
|
||||||
new ManualComponent(
|
|
||||||
deps.sasService,
|
|
||||||
deps.eventService,
|
|
||||||
deps.loggerService,
|
|
||||||
deps.deployService
|
|
||||||
)
|
|
||||||
|
|
||||||
describe('ManualComponent - validateDeploy', () => {
|
|
||||||
it('sets validationState to success when saslibs is present on a normal object response', async () => {
|
|
||||||
const deps = buildDeps()
|
|
||||||
deps.sasService.request.and.resolveTo({
|
|
||||||
adapterResponse: { saslibs: { SOME_LIB: ['SOME_TABLE'] } }
|
|
||||||
})
|
|
||||||
|
|
||||||
const component = buildManualComponent(deps)
|
|
||||||
component.validateDeploy()
|
|
||||||
await flushPromiseChain()
|
|
||||||
|
|
||||||
expect(component.validationState).toBe('success')
|
|
||||||
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets validationState to error with no modal when saslibs is missing from an otherwise normal object response', async () => {
|
|
||||||
const deps = buildDeps()
|
|
||||||
deps.sasService.request.and.resolveTo({
|
|
||||||
adapterResponse: { SYSSITE: 'SITE1' }
|
|
||||||
// saslibs deliberately omitted
|
|
||||||
})
|
|
||||||
|
|
||||||
const component = buildManualComponent(deps)
|
|
||||||
component.validateDeploy()
|
|
||||||
await flushPromiseChain()
|
|
||||||
|
|
||||||
expect(component.validationState).toBe('error')
|
|
||||||
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Same underlying bug as app.service.spec.ts's equivalent test: a
|
|
||||||
// misconfigured Viya computeTasks deployment returns a raw "Job error"
|
|
||||||
// text body, which the adapter resolves as adapterResponse verbatim.
|
|
||||||
it('sets validationState to error and shows the real response text when adapterResponse is a raw string, not an object', async () => {
|
|
||||||
const deps = buildDeps()
|
|
||||||
const rawJobError = [
|
|
||||||
'Job error',
|
|
||||||
'The Compute service could not execute the task c74a707a-8b88-46a5-8f28-9a7694ad5e13 because the context 340cd3eb-72ae is not reusable. Please provide a reusable context or supply the ID of an existing session in the task request.',
|
|
||||||
'path: /compute/tasks',
|
|
||||||
'correlator: dc1a9fda-8f65-4f32-b4d4-2a70e02179b1;1bcb6a4c-8a16-4053-a790-292544c25a03'
|
|
||||||
].join('\n')
|
|
||||||
deps.sasService.request.and.resolveTo({ adapterResponse: rawJobError })
|
|
||||||
|
|
||||||
const component = buildManualComponent(deps)
|
|
||||||
component.validateDeploy()
|
|
||||||
await flushPromiseChain()
|
|
||||||
|
|
||||||
expect(component.validationState).toBe('error')
|
|
||||||
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
|
|
||||||
const [, message] = deps.eventService.showInfoModal.calls.mostRecent().args
|
|
||||||
expect(message).toContain(rawJobError)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
Input,
|
|
||||||
OnInit,
|
|
||||||
Output,
|
|
||||||
EventEmitter,
|
|
||||||
ViewEncapsulation
|
|
||||||
} from '@angular/core'
|
|
||||||
import SASjs, { SASjsConfig } from '@sasjs/adapter'
|
|
||||||
import { DcAdapterSettings } from 'src/app/models/DcAdapterSettings'
|
|
||||||
import { RequestWrapperResponse } from 'src/app/models/request-wrapper/RequestWrapperResponse'
|
|
||||||
import { DeployService } from 'src/app/services/deploy.service'
|
|
||||||
import { EventService } from 'src/app/services/event.service'
|
|
||||||
import { LoggerService } from 'src/app/services/logger.service'
|
|
||||||
import { SasService } from 'src/app/services/sas.service'
|
|
||||||
import { getMalformedAdapterResponseMessage } from 'src/app/shared/utils/get-malformed-adapter-response-message'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-manual-deploy',
|
|
||||||
templateUrl: './manual.component.html',
|
|
||||||
styleUrls: ['./manual.component.scss'],
|
|
||||||
encapsulation: ViewEncapsulation.None,
|
|
||||||
standalone: false
|
|
||||||
})
|
|
||||||
export class ManualComponent implements OnInit {
|
|
||||||
@Input() sasJs!: SASjs
|
|
||||||
@Input() sasJsConfig: SASjsConfig = new SASjsConfig()
|
|
||||||
@Input() dcAdapterSettings: DcAdapterSettings | undefined
|
|
||||||
|
|
||||||
@Output() onNavigateToHome: EventEmitter<any> = new EventEmitter<any>()
|
|
||||||
|
|
||||||
public needsLogin: boolean = false
|
|
||||||
public adminGroups: any = []
|
|
||||||
public allContexts: any = []
|
|
||||||
|
|
||||||
public appLoc: string = ''
|
|
||||||
public dcPath: string = ''
|
|
||||||
public selectedAdminGroup: string = ''
|
|
||||||
public selectedContext: string = ''
|
|
||||||
public jobLog: string = ''
|
|
||||||
public makeDataResponse: string = ''
|
|
||||||
public linesOfCode: any = []
|
|
||||||
public fileName: string = ''
|
|
||||||
public preloadedFile: boolean = true
|
|
||||||
public executeSASEnabled: boolean = false
|
|
||||||
|
|
||||||
public contextsLoading: boolean = false
|
|
||||||
public createDatabaseLoading: boolean = false
|
|
||||||
public executingScript: boolean = false
|
|
||||||
public downloadFileBtn: boolean = false
|
|
||||||
public isValidating: boolean = false
|
|
||||||
public jsonFile: any = null
|
|
||||||
public isSubmittingJson: boolean = false
|
|
||||||
public isJsonSubmitted: boolean = false
|
|
||||||
public validationState: string = 'none'
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private sasService: SasService,
|
|
||||||
private eventService: EventService,
|
|
||||||
private loggerService: LoggerService,
|
|
||||||
private deployService: DeployService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
ngOnInit(): void {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FIXME: Remove
|
|
||||||
*/
|
|
||||||
public async executableContext() {
|
|
||||||
// getExecutableContexts now need AuthConfig parameter which we don't have on web
|
|
||||||
// this.contextsLoading = true
|
|
||||||
// let contexts = await this.sasJs.getExecutableContexts()
|
|
||||||
// this.allContexts = contexts
|
|
||||||
// if (contexts.length === 0) {
|
|
||||||
// this.selectedContext = ''
|
|
||||||
// } else {
|
|
||||||
// this.selectedContext = this.allContexts[0].name
|
|
||||||
// }
|
|
||||||
// this.contextsLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes sas.json file attached to the input
|
|
||||||
*/
|
|
||||||
public clearUploadInput(event: Event) {
|
|
||||||
this.deployService.clearUploadInput(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads attached SAS file to be sent to sas for execution (backend deploy)
|
|
||||||
*/
|
|
||||||
public onSasFileChange(event: any) {
|
|
||||||
this.preloadedFile = false
|
|
||||||
|
|
||||||
let file = event.target.files[0]
|
|
||||||
this.fileName = file.name
|
|
||||||
|
|
||||||
let fileReader = new FileReader()
|
|
||||||
|
|
||||||
fileReader.onload = () => {
|
|
||||||
if (fileReader.result) {
|
|
||||||
this.linesOfCode = (fileReader.result as string).split('\n')
|
|
||||||
this.linesOfCode = this.linesOfCode.filter((el: string) => {
|
|
||||||
return el !== '' && el !== null
|
|
||||||
})
|
|
||||||
|
|
||||||
this.executeSASEnabled = true
|
|
||||||
|
|
||||||
this.addPrecodeLines()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileReader.readAsText(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads attached JSON file to be sent to sas for execution (backend deploy)
|
|
||||||
*/
|
|
||||||
public async onJsonFileChange(event: any) {
|
|
||||||
let file = event.target.files[0]
|
|
||||||
|
|
||||||
this.jsonFile = await this.deployService.readFile(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appending precode lines to the attached sas or json file for backend deploy
|
|
||||||
*/
|
|
||||||
public addPrecodeLines() {
|
|
||||||
let headerLines = [
|
|
||||||
`%let context=${this.selectedContext};`,
|
|
||||||
`%let appLoc=${this.appLoc};`,
|
|
||||||
`%let admin=${this.selectedAdminGroup};`,
|
|
||||||
`%let dcpath=${this.dcPath};`
|
|
||||||
]
|
|
||||||
|
|
||||||
this.linesOfCode.unshift(...headerLines)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloadng file with precode included
|
|
||||||
*/
|
|
||||||
public downloadSasPrecodeFile() {
|
|
||||||
let linesAsText = this.linesOfCode.join('\n')
|
|
||||||
let filename = this.fileName.split('.')[0]
|
|
||||||
|
|
||||||
this.downloadFile(linesAsText, filename, 'sas')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used for downloading log and repsonse as a file
|
|
||||||
*/
|
|
||||||
public downloadFile(
|
|
||||||
content: any,
|
|
||||||
filename: string,
|
|
||||||
extension: string = 'txt'
|
|
||||||
) {
|
|
||||||
this.deployService.downloadFile(content, filename, extension)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saving dcpath to localstorage
|
|
||||||
* FIXME: maybe it'snot necessary
|
|
||||||
*/
|
|
||||||
public saveDcPath() {
|
|
||||||
localStorage.setItem('deploy_dc_loc', this.dcPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send sas.json to be executed (deploying backend)
|
|
||||||
*/
|
|
||||||
public async executeJson() {
|
|
||||||
this.isSubmittingJson = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
let uploadJsonFile = await this.sasJs.deployServicePack(
|
|
||||||
this.jsonFile,
|
|
||||||
this.dcAdapterSettings?.appLoc || '',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
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.eventService.showInfoModal(
|
|
||||||
'Deploy error',
|
|
||||||
`Exception: \n ${textEx !== '' ? textEx : ex}`
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isSubmittingJson = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send sas file to be executed (deploying backend)
|
|
||||||
*/
|
|
||||||
public async executeSAS() {
|
|
||||||
this.executingScript = true
|
|
||||||
this.jobLog = ''
|
|
||||||
this.makeDataResponse = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
let executeResponse = await this.sasJs.executeScript({
|
|
||||||
fileName: this.fileName,
|
|
||||||
linesOfCode: this.linesOfCode,
|
|
||||||
contextName: this.selectedContext
|
|
||||||
})
|
|
||||||
|
|
||||||
this.loggerService.log(executeResponse)
|
|
||||||
|
|
||||||
if (typeof executeResponse.log === 'string') {
|
|
||||||
executeResponse.log = JSON.parse(executeResponse.log)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (executeResponse.jobStatus === 'error') {
|
|
||||||
alert('Error!')
|
|
||||||
} else {
|
|
||||||
this.jobLog = executeResponse.log.items
|
|
||||||
? executeResponse.log.items.map((i: any) => i.line).join('\n')
|
|
||||||
: JSON.stringify(executeResponse.log)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.executingScript = false
|
|
||||||
} catch (err) {
|
|
||||||
this.executingScript = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Running makedata service
|
|
||||||
* @param newTab open and run in new tab
|
|
||||||
*/
|
|
||||||
public createDatabase(newTab: boolean = true) {
|
|
||||||
if (newTab) {
|
|
||||||
// Debug is ALWAYS enabled for makedata — it runs only once during
|
|
||||||
// deployment and the full log is needed to diagnose any issues.
|
|
||||||
const _debug = '&_debug=131'
|
|
||||||
let url =
|
|
||||||
this.sasService.getSasjsConfig().serverUrl +
|
|
||||||
'/SASJobExecution/?_program=' +
|
|
||||||
this.dcAdapterSettings?.appLoc ||
|
|
||||||
'' +
|
|
||||||
'/admin/makedata' +
|
|
||||||
'&ADMIN=' +
|
|
||||||
this.selectedAdminGroup +
|
|
||||||
'&DCPATH=' +
|
|
||||||
this.dcPath +
|
|
||||||
_debug
|
|
||||||
|
|
||||||
window.open(url, '_blank')
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.createDatabaseLoading = true
|
|
||||||
|
|
||||||
let data = {
|
|
||||||
fromjs: [
|
|
||||||
{
|
|
||||||
ADMIN: this.selectedAdminGroup,
|
|
||||||
DCPATH: this.dcPath
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We are overriding default `sasjsConfig` object fields with this object fields.
|
|
||||||
* Here we want to run this request using original WEB method.
|
|
||||||
* contextName: null is the MUST field for it.
|
|
||||||
*/
|
|
||||||
let overrideConfig = {
|
|
||||||
useComputeApi: null,
|
|
||||||
contextName: this.sasJsConfig.contextName,
|
|
||||||
debug: true
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sasJs
|
|
||||||
.request(`services/admin/makedata`, data, overrideConfig, () => {
|
|
||||||
this.sasService.shouldLogin.next(true)
|
|
||||||
})
|
|
||||||
.then((res: any) => {
|
|
||||||
try {
|
|
||||||
this.makeDataResponse = JSON.stringify(res)
|
|
||||||
} catch {
|
|
||||||
this.makeDataResponse = res
|
|
||||||
}
|
|
||||||
|
|
||||||
this.createDatabaseLoading = false
|
|
||||||
})
|
|
||||||
.catch((err: any) => {
|
|
||||||
this.createDatabaseLoading = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.makeDataResponse = JSON.stringify(err)
|
|
||||||
} catch {
|
|
||||||
this.makeDataResponse = err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public navigateToHome() {
|
|
||||||
this.onNavigateToHome.emit()
|
|
||||||
}
|
|
||||||
|
|
||||||
public validateDeploy() {
|
|
||||||
this.isValidating = true
|
|
||||||
|
|
||||||
this.sasService
|
|
||||||
.request('public/startupservice', null)
|
|
||||||
.then((res: RequestWrapperResponse) => {
|
|
||||||
this.loggerService.log(res.adapterResponse)
|
|
||||||
|
|
||||||
const malformedMessage = getMalformedAdapterResponseMessage(
|
|
||||||
res.adapterResponse
|
|
||||||
)
|
|
||||||
if (malformedMessage) {
|
|
||||||
this.validationState = 'error'
|
|
||||||
this.eventService.showInfoModal('Error', malformedMessage)
|
|
||||||
} else if (res.adapterResponse.saslibs) {
|
|
||||||
this.validationState = 'success'
|
|
||||||
} else {
|
|
||||||
this.validationState = 'error'
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isValidating = false
|
|
||||||
})
|
|
||||||
.catch((err: any) => {
|
|
||||||
this.isValidating = false
|
|
||||||
this.validationState = 'error'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public deleteKeys() {
|
|
||||||
localStorage.removeItem('deploy_dc_loc')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
@@ -245,6 +245,25 @@
|
|||||||
}
|
}
|
||||||
</clr-tooltip>
|
</clr-tooltip>
|
||||||
|
|
||||||
|
@if (truncationNote) {
|
||||||
|
<clr-tooltip>
|
||||||
|
<clr-icon
|
||||||
|
clrTooltipTrigger
|
||||||
|
shape="warning"
|
||||||
|
aria-label="The selection was truncated"
|
||||||
|
class="color-orange truncation-warning"
|
||||||
|
size="20"
|
||||||
|
></clr-icon>
|
||||||
|
<clr-tooltip-content
|
||||||
|
clrPosition="right"
|
||||||
|
clrSize="lg"
|
||||||
|
*clrIfOpen
|
||||||
|
>
|
||||||
|
{{ truncationNote }}
|
||||||
|
</clr-tooltip-content>
|
||||||
|
</clr-tooltip>
|
||||||
|
}
|
||||||
|
|
||||||
@if (dataSource) {
|
@if (dataSource) {
|
||||||
@if (!zeroFilterRows) {
|
@if (!zeroFilterRows) {
|
||||||
({{ dataSource.length | thousandSeparator: ',' }}
|
({{ dataSource.length | thousandSeparator: ',' }}
|
||||||
@@ -440,6 +459,43 @@
|
|||||||
<!--<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,3 +27,15 @@
|
|||||||
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,6 +73,7 @@ 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'
|
||||||
@@ -89,6 +90,11 @@ import { withoutEditStatus } from './utils/withoutEditStatus'
|
|||||||
import { getEditStatusSymbol } from './utils/getEditStatusSymbol'
|
import { getEditStatusSymbol } from './utils/getEditStatusSymbol'
|
||||||
import { normalizeSortConfig } from './utils/normalizeSortConfig'
|
import { normalizeSortConfig } from './utils/normalizeSortConfig'
|
||||||
import { EDIT_STATUS_COLUMN_NAME } from '../shared/dc-validator/utils/editStatusColumnRule'
|
import { EDIT_STATUS_COLUMN_NAME } from '../shared/dc-validator/utils/editStatusColumnRule'
|
||||||
|
import { DELETE_RECORD_COLUMN_NAME } from '../shared/dc-validator/utils/deleteRecordColumnRule'
|
||||||
|
import {
|
||||||
|
prepareDeleteRecordColumn,
|
||||||
|
seedDeleteRecordColumn
|
||||||
|
} from '../shared/dc-validator/utils/deleteRecordColumn'
|
||||||
import {
|
import {
|
||||||
errorRenderer,
|
errorRenderer,
|
||||||
noSpinnerRenderer,
|
noSpinnerRenderer,
|
||||||
@@ -155,6 +161,9 @@ export class EditorComponent
|
|||||||
dsmeta: DSMeta[] = []
|
dsmeta: DSMeta[] = []
|
||||||
versions: Version[] = []
|
versions: Version[] = []
|
||||||
dsNote = ''
|
dsNote = ''
|
||||||
|
/** Set when getdata served a truncated selection - shown as a warning
|
||||||
|
* against the table name, with the reason in a tooltip. */
|
||||||
|
truncationNote = ''
|
||||||
|
|
||||||
viewboxes = false
|
viewboxes = false
|
||||||
|
|
||||||
@@ -477,6 +486,46 @@ 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
|
||||||
@@ -567,6 +616,8 @@ export class EditorComponent
|
|||||||
public hasBaseDropZoneOver = false
|
public hasBaseDropZoneOver = false
|
||||||
public hasAnotherDropZoneOver = false
|
public hasAnotherDropZoneOver = false
|
||||||
public headerPks: string[] = []
|
public headerPks: string[] = []
|
||||||
|
/** Derived (computed) columns from MPE_DERIVED - read-only, and marked as such */
|
||||||
|
public derivedVars: string[] = []
|
||||||
public columnLevelSecurityFlag = false
|
public columnLevelSecurityFlag = false
|
||||||
public dateTimeHeaders: string[] = []
|
public dateTimeHeaders: string[] = []
|
||||||
public timeHeaders: string[] = []
|
public timeHeaders: string[] = []
|
||||||
@@ -1081,6 +1132,20 @@ export class EditorComponent
|
|||||||
return colRules?.readOnly
|
return colRules?.readOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A derived (computed) column - configured in MPE_DERIVED, created at read
|
||||||
|
* time. Read-only, and styled apart from an ordinary read-only column so the
|
||||||
|
* user can see that there is nothing to edit rather than that they may not.
|
||||||
|
*/
|
||||||
|
isDerivedCol(col: string | number) {
|
||||||
|
const name =
|
||||||
|
typeof col === 'number'
|
||||||
|
? (this.hotInstance?.colToProp(col) as string) || this.columnHeader[col]
|
||||||
|
: col
|
||||||
|
|
||||||
|
return !!name && this.derivedVars.includes(name.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
isColHeader(col: string) {
|
isColHeader(col: string) {
|
||||||
return this.headerArray.indexOf(col.toUpperCase()) > -1
|
return this.headerArray.indexOf(col.toUpperCase()) > -1
|
||||||
}
|
}
|
||||||
@@ -1206,7 +1271,16 @@ export class EditorComponent
|
|||||||
this.cdf.detectChanges()
|
this.cdf.detectChanges()
|
||||||
|
|
||||||
this.submitLoading = false
|
this.submitLoading = false
|
||||||
this.sasStoreService.setQueryVariables(this.libds, this.cols)
|
// The columns that came with the table are the cheapest source for the
|
||||||
|
// picker. When the table was not returned - the row/cell limits abort the
|
||||||
|
// request before the data arrives - that list is empty, so the store
|
||||||
|
// falls back to public/getcols rather than leaving the picker with
|
||||||
|
// nothing to pick from.
|
||||||
|
this.sasStoreService
|
||||||
|
.setQueryVariablesFromTable(this.libds, this.cols)
|
||||||
|
.catch((err: any) =>
|
||||||
|
this.eventService.catchResponseError('public/getcols', err)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1346,8 +1420,14 @@ export class EditorComponent
|
|||||||
colSpecs.TYPE === 'num' &&
|
colSpecs.TYPE === 'num' &&
|
||||||
!colSpecs.DDTYPE.includes('TIME') &&
|
!colSpecs.DDTYPE.includes('TIME') &&
|
||||||
!colSpecs.DDTYPE.includes('DATE')
|
!colSpecs.DDTYPE.includes('DATE')
|
||||||
)
|
) {
|
||||||
row[colKey] = parseInt(row[colKey])
|
const numericValue = parseFloat(row[colKey])
|
||||||
|
|
||||||
|
// A cell the file parser could not read as a number stays as it
|
||||||
|
// was: the editor's numeric validation then flags it as text,
|
||||||
|
// where a NaN would render as an empty cell.
|
||||||
|
if (!Number.isNaN(numericValue)) row[colKey] = numericValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2734,17 +2814,24 @@ export class EditorComponent
|
|||||||
this.disableSubmit = true
|
this.disableSubmit = true
|
||||||
this.submit = false
|
this.submit = false
|
||||||
|
|
||||||
const errorText =
|
/* sasService has already shown the SAS message when it rejected
|
||||||
typeof err.adapterRespnse === 'string'
|
(the abort, or the adapter's error), so reporting it again here
|
||||||
? err.adapterRespnse
|
stacked a second modal on top of the first - and read
|
||||||
: JSON.stringify(err.adapterRespnse)
|
'undefined' because it looked for a property that does not
|
||||||
|
exist. Only speak up when nothing was shown. */
|
||||||
|
if (!err?.errorShown) {
|
||||||
|
const errorText =
|
||||||
|
typeof err?.adapterResponse?.error === 'string'
|
||||||
|
? err.adapterResponse.error
|
||||||
|
: JSON.stringify(err?.adapterResponse ?? err)
|
||||||
|
|
||||||
this.eventService.showAbortModal(
|
this.eventService.showAbortModal(
|
||||||
'editors/stagedata',
|
'editors/stagedata',
|
||||||
`Submit request failed\n${errorText}`,
|
`Submit request failed\n${errorText}`,
|
||||||
null,
|
null,
|
||||||
'Submit error'
|
'Submit error'
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -3510,14 +3597,24 @@ export class EditorComponent
|
|||||||
// A synchronous throw inside initSetup (called from the .then()
|
// A synchronous throw inside initSetup (called from the .then()
|
||||||
// above) lands here too, not just a network failure - log it, or
|
// above) lands here too, not just a network failure - log it, or
|
||||||
// the table just renders hidden with no clue why.
|
// the table just renders hidden with no clue why.
|
||||||
// eslint-disable-next-line no-console
|
this.handleGetdataFailure(err)
|
||||||
console.error('editors/getdata failed:', err)
|
|
||||||
this.getdataError = true
|
|
||||||
this.tableTrue = true
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the page into its getdata error state. Shared by the callService
|
||||||
|
* .catch and initSetup's own retry branch: a throw from the retry runs on a
|
||||||
|
* bare setTimeout, outside that promise chain, so it would otherwise never
|
||||||
|
* reach the .catch and the user would be left on the loading spinner.
|
||||||
|
*/
|
||||||
|
private handleGetdataFailure(err: any) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('editors/getdata failed:', err)
|
||||||
|
this.getdataError = true
|
||||||
|
this.tableTrue = true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VA mode, initial (unfiltered) route only: briefly wait for VA's buffered
|
* VA mode, initial (unfiltered) route only: briefly wait for VA's buffered
|
||||||
* first message and, if it already carries a filter, save that filter and
|
* first message and, if it already carries a filter, save that filter and
|
||||||
@@ -3546,7 +3643,7 @@ 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)
|
const clauses = this.vaFilter.buildInitialClauses(msg, this.vaParamMap)
|
||||||
if (clauses.length === 0) return false
|
if (clauses.length === 0) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3866,13 +3963,28 @@ export class EditorComponent
|
|||||||
|
|
||||||
this.hotInstance = this.hotTableComponent?.hotInstance!
|
this.hotInstance = this.hotTableComponent?.hotInstance!
|
||||||
if (!this.hotInstance) {
|
if (!this.hotInstance) {
|
||||||
// Retry init, don't permanently abort
|
// Retry init, don't permanently abort. A throw from the retry runs on a
|
||||||
|
// bare setTimeout rather than in the caller's promise chain, so it would
|
||||||
|
// never reach that .catch - surface it through the same handler here.
|
||||||
if (attempt < 60) {
|
if (attempt < 60) {
|
||||||
setTimeout(() => this.initSetup(response, attempt + 1), 50)
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
this.initSetup(response, attempt + 1)
|
||||||
|
} catch (err) {
|
||||||
|
this.handleGetdataFailure(err)
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The delete column is client-owned (see deleteRecordColumnRule.ts):
|
||||||
|
// this asserts getdata did not send it, then prepends it to COLHEADERS so
|
||||||
|
// it is the grid's first column. A throw here lands in the caller's
|
||||||
|
// .catch - or, on a retry, the branch above - which sets getdataError and
|
||||||
|
// shows the error state.
|
||||||
|
prepareDeleteRecordColumn(response)
|
||||||
|
|
||||||
this.cols = response.data.cols
|
this.cols = response.data.cols
|
||||||
this.dsmeta = response.data.dsmeta
|
this.dsmeta = response.data.dsmeta
|
||||||
this.versions = response.data.versions || []
|
this.versions = response.data.versions || []
|
||||||
@@ -3891,6 +4003,14 @@ export class EditorComponent
|
|||||||
this.dsNote = ''
|
this.dsNote = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getdata serves a selection over DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT
|
||||||
|
// truncated to the first rows within the limit, and carries the reason
|
||||||
|
// here. Empty means the whole selection is in the grid - and a blank
|
||||||
|
// character arrives as a single blank (mp_jsonout cannot emit a zero-length
|
||||||
|
// string, since SAS has none), so trim it: whitespace is "no note", not a
|
||||||
|
// warning with an empty tooltip.
|
||||||
|
this.truncationNote = (response.data.sasparams[0].TRUNC_MSG || '').trim()
|
||||||
|
|
||||||
const hot = this.hotInstance
|
const hot = this.hotInstance
|
||||||
|
|
||||||
const approvers: Approver[] = response.data.approvers
|
const approvers: Approver[] = response.data.approvers
|
||||||
@@ -3911,6 +4031,12 @@ export class EditorComponent
|
|||||||
|
|
||||||
this.headerColumns = response.data.sasparams[0].COLHEADERS.split(',')
|
this.headerColumns = response.data.sasparams[0].COLHEADERS.split(',')
|
||||||
this.headerPks = response.data.sasparams[0].PK.split(' ')
|
this.headerPks = response.data.sasparams[0].PK.split(' ')
|
||||||
|
// Derived (computed) columns, named by getdata from MPE_DERIVED - read-only
|
||||||
|
// through a READONLY DQ rule, and marked in the grid so they read as
|
||||||
|
// computed rather than merely non-editable.
|
||||||
|
this.derivedVars = (response.data.sasparams[0].DERIVEDVARS || '')
|
||||||
|
.split(' ')
|
||||||
|
.filter((col: string) => !!col)
|
||||||
this.columnLevelSecurityFlag = !!response.data.sasparams[0].CLS_FLAG
|
this.columnLevelSecurityFlag = !!response.data.sasparams[0].CLS_FLAG
|
||||||
|
|
||||||
if (this.columnLevelSecurityFlag)
|
if (this.columnLevelSecurityFlag)
|
||||||
@@ -3922,9 +4048,9 @@ export class EditorComponent
|
|||||||
|
|
||||||
this.checkEmptyRowWhenFilter()
|
this.checkEmptyRowWhenFilter()
|
||||||
|
|
||||||
if (this.headerColumns.indexOf('_____DELETE__THIS__RECORD_____') !== -1) {
|
if (this.headerColumns.indexOf(DELETE_RECORD_COLUMN_NAME) !== -1) {
|
||||||
this.headerColumns[
|
this.headerColumns[
|
||||||
this.headerColumns.indexOf('_____DELETE__THIS__RECORD_____')
|
this.headerColumns.indexOf(DELETE_RECORD_COLUMN_NAME)
|
||||||
] = 'Delete?'
|
] = 'Delete?'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3989,6 +4115,10 @@ export class EditorComponent
|
|||||||
row[EDIT_STATUS_COLUMN_NAME] = 'U'
|
row[EDIT_STATUS_COLUMN_NAME] = 'U'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The delete flag is client-owned too (see deleteRecordColumnRule.ts) -
|
||||||
|
// getdata no longer sends the column, so every row starts unmarked.
|
||||||
|
seedDeleteRecordColumn(this.dataSource)
|
||||||
|
|
||||||
// Seed HARDFORMULA/SOFTFORMULA columns with their computed formula
|
// Seed HARDFORMULA/SOFTFORMULA columns with their computed formula
|
||||||
// string per row. dataSourceUnchanged isn't populated yet this early in
|
// string per row. dataSourceUnchanged isn't populated yet this early in
|
||||||
// a fresh load (only the excel-preview/add-row flows set it) - at this
|
// a fresh load (only the excel-preview/add-row flows set it) - at this
|
||||||
@@ -4281,9 +4411,37 @@ export class EditorComponent
|
|||||||
// header columns styling - primary keys
|
// header columns styling - primary keys
|
||||||
const isPKCol = column && this.isColPk(column)
|
const isPKCol = column && this.isColPk(column)
|
||||||
const isReadonlyCol = column && this.isReadonlyCol(column)
|
const isReadonlyCol = column && this.isReadonlyCol(column)
|
||||||
|
const isDerivedCol = column && this.isDerivedCol(column)
|
||||||
|
|
||||||
if (isPKCol) th.classList.add('primaryKeyHeaderStyle')
|
if (isPKCol) th.classList.add('primaryKeyHeaderStyle')
|
||||||
if (isReadonlyCol && !isPKCol) th.classList.add('readonlyCell')
|
if (isReadonlyCol && !isPKCol && !isDerivedCol) {
|
||||||
|
th.classList.add('readonlyCell')
|
||||||
|
}
|
||||||
|
|
||||||
|
// a derived column is computed, not merely locked - give it its own
|
||||||
|
// hue and an 'fx' marker rather than the read-only dimming
|
||||||
|
if (isDerivedCol) {
|
||||||
|
th.classList.add('dc-derived-header')
|
||||||
|
// the label says what the column is - for a derived variable that
|
||||||
|
// is the configured VAR_LABEL, or the generated name=expression
|
||||||
|
// when the administrator left it blank. A label that merely
|
||||||
|
// repeats the name adds nothing, so it falls back to the rule.
|
||||||
|
const derivedLabel = (this.cols || [])
|
||||||
|
.filter((c: any) => c.NAME === column)
|
||||||
|
.map((c: any) => String(c.LABEL || '').trim())
|
||||||
|
.find(
|
||||||
|
(l: string) =>
|
||||||
|
l.length > 0 &&
|
||||||
|
l.toUpperCase() !== String(column).toUpperCase()
|
||||||
|
)
|
||||||
|
th.title = derivedLabel
|
||||||
|
? `${derivedLabel} - derived variable, computed at read time and cannot be edited`
|
||||||
|
: 'Derived variable - computed at read time and cannot be edited'
|
||||||
|
const firstDerivedCol = this.columnHeader.findIndex(
|
||||||
|
(name: string) => this.isDerivedCol(name)
|
||||||
|
)
|
||||||
|
if (col === firstDerivedCol) th.classList.add('dc-derived-first')
|
||||||
|
}
|
||||||
|
|
||||||
// Remove header arrow from Delete column
|
// Remove header arrow from Delete column
|
||||||
if (col === 0) {
|
if (col === 0) {
|
||||||
@@ -4299,6 +4457,7 @@ export class EditorComponent
|
|||||||
cellProperties: Handsontable.CellProperties
|
cellProperties: Handsontable.CellProperties
|
||||||
) => {
|
) => {
|
||||||
const isReadonlyCol = col && this.isReadonlyCol(col)
|
const isReadonlyCol = col && this.isReadonlyCol(col)
|
||||||
|
const isDerivedCol = col && this.isDerivedCol(col)
|
||||||
|
|
||||||
// Check if this cell should be marked as invalid due to duplicate primary key values
|
// Check if this cell should be marked as invalid due to duplicate primary key values
|
||||||
// Only applies to primary key columns (col 1 through readOnlyFields)
|
// Only applies to primary key columns (col 1 through readOnlyFields)
|
||||||
@@ -4322,7 +4481,26 @@ export class EditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add readonlyCell class for readonly columns to maintain original styling
|
// Add readonlyCell class for readonly columns to maintain original styling
|
||||||
if (isReadonlyCol && !classes.includes('readonlyCell')) {
|
if (isDerivedCol) {
|
||||||
|
// a derived cell carries its own tint; the read-only dimming would
|
||||||
|
// wash it out, and "greyed out" is a different message from
|
||||||
|
// "computed here"
|
||||||
|
classes = classes.filter((c: string) => c !== 'readonlyCell')
|
||||||
|
if (!classes.includes('dc-derived-cell')) {
|
||||||
|
classes.push('dc-derived-cell')
|
||||||
|
}
|
||||||
|
// derived columns are appended after the physical ones - mark the
|
||||||
|
// start of the block so it reads as one group
|
||||||
|
const firstDerivedCol = this.columnHeader.findIndex(
|
||||||
|
(name: string) => this.isDerivedCol(name)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
col === firstDerivedCol &&
|
||||||
|
!classes.includes('dc-derived-first')
|
||||||
|
) {
|
||||||
|
classes.push('dc-derived-first')
|
||||||
|
}
|
||||||
|
} else if (isReadonlyCol && !classes.includes('readonlyCell')) {
|
||||||
classes.push('readonlyCell')
|
classes.push('readonlyCell')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4911,7 +5089,53 @@ export class EditorComponent
|
|||||||
|
|
||||||
this.updateVaFilterText(msg)
|
this.updateVaFilterText(msg)
|
||||||
|
|
||||||
const clauses = this.vaFilter.buildClauses(msg, labelToVar, this.cols)
|
// A `va_parmap` entry naming a column this table does not have is skipped by
|
||||||
|
// buildClauses. Report it (banner + console, once per distinct set) so the
|
||||||
|
// report author sees a misconfiguration instead of an unfiltered grid.
|
||||||
|
this.vaUnresolvedMappings = this.vaFilter.unresolvedMappings(
|
||||||
|
msg,
|
||||||
|
this.vaParamMap,
|
||||||
|
this.cols,
|
||||||
|
this.libds
|
||||||
|
)
|
||||||
|
// A key naming no parameter VA has ever sent is a typo on the parameter
|
||||||
|
// side: the mapping never applies. Only judged once parameters are actually
|
||||||
|
// arriving, since VA sends just the parameters its query uses.
|
||||||
|
this.vaUnmatchedMappings = this.vaFilter.unmatchedMappings(
|
||||||
|
this.vaParamMap,
|
||||||
|
this.vaMessaging.seenParamKeys,
|
||||||
|
this.vaMessaging.lastMessageParamsStable
|
||||||
|
)
|
||||||
|
// Two parameters filtering one column are AND-ed, so differing values leave
|
||||||
|
// an empty grid. Reported, not rewritten: turning that AND into an OR is a
|
||||||
|
// filter-semantics decision, taken in buildClauses and not in a diagnostic.
|
||||||
|
this.vaSameColumnMappings = this.vaFilter.sameColumnMappings(
|
||||||
|
msg,
|
||||||
|
this.vaParamMap,
|
||||||
|
this.cols
|
||||||
|
)
|
||||||
|
// A transform that cannot run here (char column, value that is not a date) is
|
||||||
|
// skipped by the clause builder, so the banner has to say why.
|
||||||
|
this.vaTransformProblems = this.vaFilter.unsupportedTransforms(
|
||||||
|
msg,
|
||||||
|
this.vaParamMap,
|
||||||
|
this.cols
|
||||||
|
)
|
||||||
|
const warnings = this.vaRuntimeWarnings.join(' | ')
|
||||||
|
if (warnings !== this.vaWarningsReported) {
|
||||||
|
this.vaWarningsReported = warnings
|
||||||
|
for (const warning of this.vaRuntimeWarnings) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('Data Controller: ' + warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clauses = this.vaFilter.buildClauses(
|
||||||
|
msg,
|
||||||
|
labelToVar,
|
||||||
|
this.cols,
|
||||||
|
this.vaParamMap
|
||||||
|
)
|
||||||
const signature = this.vaFilter.signature(clauses)
|
const signature = this.vaFilter.signature(clauses)
|
||||||
|
|
||||||
if (signature === this.vaMessaging.filterSignature) {
|
if (signature === this.vaMessaging.filterSignature) {
|
||||||
|
|||||||
@@ -417,9 +417,14 @@ describe('formulas plugin must not be disabled/re-enabled around a table load',
|
|||||||
* capping maxRows at editor_rows_allowed alone makes HyperFormula reject
|
* capping maxRows at editor_rows_allowed alone makes HyperFormula reject
|
||||||
* any table bigger than that cap outright. See initSetup's own
|
* any table bigger than that cap outright. See initSetup's own
|
||||||
* Math.max(dataSource.length, editor_rows_allowed) fix.
|
* Math.max(dataSource.length, editor_rows_allowed) fix.
|
||||||
|
*
|
||||||
|
* Handsontable 18.1 fixed the engine coupling upstream (#10672): the grid's
|
||||||
|
* maxRows/maxCols no longer limit the HyperFormula engine, so a capped
|
||||||
|
* maxRows no longer rejects a larger existing dataset. DC's own guard is
|
||||||
|
* still in place; the first test below now pins the fixed behaviour.
|
||||||
*/
|
*/
|
||||||
describe('maxRows must never be smaller than the actual loaded row count', () => {
|
describe('maxRows must never be smaller than the actual loaded row count', () => {
|
||||||
it('throws when maxRows is capped below the real row count (proves the mechanism)', () => {
|
it('no longer throws when maxRows is capped below the real row count (HOT 18.1 #10672)', () => {
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
document.body.appendChild(container)
|
document.body.appendChild(container)
|
||||||
const colNames = ['PK', 'A', 'B']
|
const colNames = ['PK', 'A', 'B']
|
||||||
@@ -440,7 +445,7 @@ describe('maxRows must never be smaller than the actual loaded row count', () =>
|
|||||||
error = e
|
error = e
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(error?.message).toEqual('Sheet size limit exceeded')
|
expect(error).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not throw when maxRows is never let below the actual row count (the fix)', () => {
|
it('does not throw when maxRows is never let below the actual row count (the fix)', () => {
|
||||||
|
|||||||
@@ -287,9 +287,13 @@ describe('syncOverwrittenCommentForCell resolves the correct visual cell on a so
|
|||||||
* trigger it, independent of what it actually changes. editTable(),
|
* trigger it, independent of what it actually changes. editTable(),
|
||||||
* cancelEdit(), and every other place editor.component.ts calls
|
* cancelEdit(), and every other place editor.component.ts calls
|
||||||
* updateSettings() after the grid could already be sorted (e.g. the user
|
* updateSettings() after the grid could already be sorted (e.g. the user
|
||||||
* sorted while still read-only, then clicked Edit) hits this. The fix
|
* sorted while still read-only, then clicked Edit) hits this.
|
||||||
* (updateSettingsSortSafe) clears the sort before the call and restores it
|
*
|
||||||
* immediately afterward.
|
* Fixed upstream in Handsontable 18.1 (the formulas plugin now keeps its
|
||||||
|
* index translations across updateSettings), so the corruption no longer
|
||||||
|
* occurs. DC's own guard (updateSettingsSortSafe - clear the sort, call
|
||||||
|
* updateSettings, restore the sort) is still in place and still correct;
|
||||||
|
* this test now pins the fixed behaviour so a future regression shows up.
|
||||||
*/
|
*/
|
||||||
describe('updateSettings must not run while a sort is active - it corrupts formula cell references', () => {
|
describe('updateSettings must not run while a sort is active - it corrupts formula cell references', () => {
|
||||||
const buildSortedGrid = (): Handsontable => {
|
const buildSortedGrid = (): Handsontable => {
|
||||||
@@ -340,13 +344,13 @@ describe('updateSettings must not run while a sort is active - it corrupts formu
|
|||||||
hot.getDataAtRowProp(r, 'FORMULA_HARD_COL')
|
hot.getDataAtRowProp(r, 'FORMULA_HARD_COL')
|
||||||
)
|
)
|
||||||
|
|
||||||
it('demonstrates the failure mode: updateSettings while sorted corrupts formula cells', () => {
|
it('no longer corrupts formula cells when updateSettings runs while sorted (HOT 18.1)', () => {
|
||||||
const hot = buildSortedGrid()
|
const hot = buildSortedGrid()
|
||||||
|
|
||||||
hot.updateSettings({}, false)
|
hot.updateSettings({}, false)
|
||||||
hot.render()
|
hot.render()
|
||||||
|
|
||||||
expect(formulaHardColValues(hot)).toContain('#REF!')
|
expect(formulaHardColValues(hot)).not.toContain('#REF!')
|
||||||
|
|
||||||
hot.destroy()
|
hot.destroy()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { SASjsConfig } from '@sasjs/adapter'
|
import { SASjsConfig } from '@sasjs/adapter'
|
||||||
|
|
||||||
export interface DcAdapterSettings extends Partial<SASjsConfig> {
|
export interface DcAdapterSettings extends Partial<SASjsConfig> {
|
||||||
adminGroup: string
|
|
||||||
dcPath: string
|
dcPath: string
|
||||||
hotLicenceKey: string
|
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,15 @@ export interface SASParam {
|
|||||||
PKCNT: number
|
PKCNT: number
|
||||||
RK_FLAG: number
|
RK_FLAG: number
|
||||||
TMVARS: string
|
TMVARS: string
|
||||||
|
/** 1 when getdata cut the selection down to the row / cell limit */
|
||||||
|
TRUNCATED?: number
|
||||||
|
/** Reason shown in the tooltip when TRUNCATED is 1, else empty */
|
||||||
|
TRUNC_MSG?: string
|
||||||
|
/**
|
||||||
|
* Space separated list of the derived (computed) columns from MPE_DERIVED,
|
||||||
|
* upper case. Empty when the table has none.
|
||||||
|
*/
|
||||||
|
DERIVEDVARS?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExcelRule {
|
export interface ExcelRule {
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import {
|
|||||||
import { globals } from '../_globals'
|
import { globals } from '../_globals'
|
||||||
import { EditorsGetDataServiceResponse } from '../models/sas/editors-getdata.model'
|
import { EditorsGetDataServiceResponse } from '../models/sas/editors-getdata.model'
|
||||||
import { DcValidator } from '../shared/dc-validator/dc-validator'
|
import { DcValidator } from '../shared/dc-validator/dc-validator'
|
||||||
|
import {
|
||||||
|
prepareDeleteRecordColumn,
|
||||||
|
seedDeleteRecordColumn
|
||||||
|
} from '../shared/dc-validator/utils/deleteRecordColumn'
|
||||||
|
import { DELETE_RECORD_COLUMN_NAME } from '../shared/dc-validator/utils/deleteRecordColumnRule'
|
||||||
import { ExcelRule } from '../models/TableData'
|
import { ExcelRule } from '../models/TableData'
|
||||||
import { HotTableInterface } from '../models/HotTable.interface'
|
import { HotTableInterface } from '../models/HotTable.interface'
|
||||||
import { Col } from '../shared/dc-validator/models/col.model'
|
import { Col } from '../shared/dc-validator/models/col.model'
|
||||||
@@ -360,7 +365,21 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
|
|
||||||
this.uploadLoading = false
|
this.uploadLoading = false
|
||||||
|
|
||||||
const datasetObjects = this.buildDatasetsObjects(datasets)
|
// buildDatasetsObjects throws when a getdata response carries the
|
||||||
|
// reserved delete-record column (see deleteRecordColumn.ts). This chain
|
||||||
|
// has no .catch, so an uncaught throw here would become an unhandled
|
||||||
|
// rejection that aborts the loop with nothing shown. Surface it on
|
||||||
|
// screen, the way the editor flow refuses the same response, rather
|
||||||
|
// than leaving the user on an empty table selection with the file
|
||||||
|
// still attached.
|
||||||
|
let datasetObjects: DatasetsObject[] = []
|
||||||
|
try {
|
||||||
|
datasetObjects = this.buildDatasetsObjects(datasets)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Error building dataset objects', err)
|
||||||
|
this.eventService.showInfoModal('Upload error', (err as Error).message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
datasetObjects.forEach((datasetObject) => {
|
datasetObjects.forEach((datasetObject) => {
|
||||||
this.parsedDatasets.push({
|
this.parsedDatasets.push({
|
||||||
@@ -849,6 +868,21 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
return this.sasStoreService
|
return this.sasStoreService
|
||||||
.callService(myParams, 'SASControlTable', 'editors/getdata', libds)
|
.callService(myParams, 'SASControlTable', 'editors/getdata', libds)
|
||||||
.then((res: EditorsGetDataServiceResponse) => {
|
.then((res: EditorsGetDataServiceResponse) => {
|
||||||
|
// getdata serves a selection over DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT
|
||||||
|
// truncated, rather than aborting the way it used to. This flow compares
|
||||||
|
// the whole table against the upload, so a partial read would compute
|
||||||
|
// the changes against the wrong starting state - refuse the table
|
||||||
|
// instead of submitting differences nobody asked for.
|
||||||
|
const sasParams = res?.data?.sasparams?.[0]
|
||||||
|
|
||||||
|
if (sasParams && Number(sasParams.TRUNCATED) === 1) {
|
||||||
|
this.eventService.showInfoModal(
|
||||||
|
'Upload error',
|
||||||
|
`${libds} holds more than the EDIT limit allows (${sasParams.TRUNC_MSG}), so it cannot be compared with the upload. Raise DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT, or submit the table without the multi-table upload.`
|
||||||
|
)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
@@ -1074,6 +1108,12 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
|
|
||||||
for (let response of responses) {
|
for (let response of responses) {
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
|
// The delete column is client-owned (see deleteRecordColumnRule.ts):
|
||||||
|
// asserts getdata did not send it, then prepends it to COLHEADERS so
|
||||||
|
// it is index 0 for everything below.
|
||||||
|
prepareDeleteRecordColumn(response)
|
||||||
|
seedDeleteRecordColumn(response.data.sasdata)
|
||||||
|
|
||||||
const datasetObject: DatasetsObject = {
|
const datasetObject: DatasetsObject = {
|
||||||
...response,
|
...response,
|
||||||
hotTable: {
|
hotTable: {
|
||||||
@@ -1097,14 +1137,10 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
datasetObject.headerPks = response.data.sasparams[0].PK.split(' ')
|
datasetObject.headerPks = response.data.sasparams[0].PK.split(' ')
|
||||||
|
|
||||||
if (
|
if (
|
||||||
datasetObject.headerColumns.indexOf(
|
datasetObject.headerColumns.indexOf(DELETE_RECORD_COLUMN_NAME) !== -1
|
||||||
'_____DELETE__THIS__RECORD_____'
|
|
||||||
) !== -1
|
|
||||||
) {
|
) {
|
||||||
datasetObject.headerColumns[
|
datasetObject.headerColumns[
|
||||||
datasetObject.headerColumns.indexOf(
|
datasetObject.headerColumns.indexOf(DELETE_RECORD_COLUMN_NAME)
|
||||||
'_____DELETE__THIS__RECORD_____'
|
|
||||||
)
|
|
||||||
] = 'Delete?'
|
] = 'Delete?'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { QueryComponent } from './query.component'
|
||||||
|
import { HelperService } from '../services/helper.service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dynamicWhereClauseCreator builds the prefilter that getcolvals sends when the
|
||||||
|
* dynamic where clause is on, and it composes a temporal value into the same
|
||||||
|
* SAS literal the applied filter carries. The value it prefilters with comes
|
||||||
|
* from the *other* clauses in the query, so the clause under test sits at index
|
||||||
|
* 1 and index 0 is skipped.
|
||||||
|
*/
|
||||||
|
describe('QueryComponent - dynamic where clause', () => {
|
||||||
|
const buildComponent = (elements: any[]) => {
|
||||||
|
const component = new QueryComponent(
|
||||||
|
{} as any,
|
||||||
|
{ catchResponseError: () => undefined } as any,
|
||||||
|
new HelperService(null as any),
|
||||||
|
{ detectChanges: () => undefined } as any
|
||||||
|
)
|
||||||
|
|
||||||
|
component.clauses = {
|
||||||
|
queryObj: [{ clauseLogic: 'AND', elements }],
|
||||||
|
groupLogic: 'AND'
|
||||||
|
} as any
|
||||||
|
|
||||||
|
return component
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporalClause = (over: any = {}) => ({
|
||||||
|
variable: 'SOME_DATE',
|
||||||
|
operator: '=',
|
||||||
|
value: '',
|
||||||
|
values: [],
|
||||||
|
type: 'num',
|
||||||
|
ddtype: 'DATE',
|
||||||
|
valueVariable: false,
|
||||||
|
...over
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends an IN entry as the literal for its unformatted value', () => {
|
||||||
|
const component = buildComponent([
|
||||||
|
temporalClause(),
|
||||||
|
temporalClause({
|
||||||
|
operator: 'IN',
|
||||||
|
value: [{ checked: true, val: '12FEB1960' }],
|
||||||
|
values: [{ formatted: '12FEB1960', unformatted: 42 }]
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
const table = component.dynamicWhereClauseCreator(0, 0)
|
||||||
|
|
||||||
|
expect(table.length).toBe(1)
|
||||||
|
expect(table[0].RAW_VALUE).toBe("('12FEB1960'd)")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps both ends of a BETWEEN picked from the value list', () => {
|
||||||
|
const component = buildComponent([
|
||||||
|
temporalClause(),
|
||||||
|
temporalClause({
|
||||||
|
operator: 'BETWEEN',
|
||||||
|
value: ['12FEB1960', '13FEB1960'],
|
||||||
|
values: [
|
||||||
|
{ formatted: '12FEB1960', unformatted: 42 },
|
||||||
|
{ formatted: '13FEB1960', unformatted: 43 }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
const table = component.dynamicWhereClauseCreator(0, 0)
|
||||||
|
|
||||||
|
expect(table.length).toBe(1)
|
||||||
|
expect(table[0].RAW_VALUE).toBe("'12FEB1960'd AND '13FEB1960'd")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves an already-unformatted BETWEEN alone', () => {
|
||||||
|
const component = buildComponent([
|
||||||
|
temporalClause(),
|
||||||
|
temporalClause({
|
||||||
|
operator: 'BETWEEN',
|
||||||
|
value: [42, 43],
|
||||||
|
values: [
|
||||||
|
{ formatted: '12FEB1960', unformatted: 42 },
|
||||||
|
{ formatted: '13FEB1960', unformatted: 43 }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
const table = component.dynamicWhereClauseCreator(0, 0)
|
||||||
|
|
||||||
|
expect(table[0].RAW_VALUE).toBe("'12FEB1960'd AND '13FEB1960'd")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits the unformatted value in the non-literal IN fallback', () => {
|
||||||
|
const component = buildComponent([
|
||||||
|
temporalClause(),
|
||||||
|
{
|
||||||
|
variable: 'SOME_CHAR',
|
||||||
|
operator: 'IN',
|
||||||
|
value: [{ checked: true, val: 'ABC ' }],
|
||||||
|
values: [{ formatted: 'ABC ', unformatted: 'ABC' }],
|
||||||
|
type: 'char',
|
||||||
|
ddtype: 'CHAR',
|
||||||
|
valueVariable: false
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const table = component.dynamicWhereClauseCreator(0, 0)
|
||||||
|
|
||||||
|
expect(table[0].RAW_VALUE).toBe("('ABC')")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -425,35 +425,57 @@ export class QueryComponent
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
let rawValue = ''
|
let rawValue = ''
|
||||||
const operator =
|
const element = this.clauses.queryObj[groupInd].elements[clauseInd]
|
||||||
this.clauses.queryObj[groupInd].elements[clauseInd].operator
|
const operator = element.operator
|
||||||
const variable =
|
const variable = element.variable
|
||||||
this.clauses.queryObj[groupInd].elements[clauseInd].variable
|
/* a temporal value is sent as a SAS literal (eg '20APR2042'd), the
|
||||||
|
same way the applied filter carries it */
|
||||||
|
const temporalValue = (value: any) =>
|
||||||
|
element.valueVariable
|
||||||
|
? null
|
||||||
|
: this.helperService.sasTemporalLiteral(value, element.ddtype)
|
||||||
|
/* the dropdowns and the IN modal hold the *formatted* value (the
|
||||||
|
soft-select option value, and setINValues), so a picked value is
|
||||||
|
mapped back to the unformatted one the filter needs */
|
||||||
|
const unformattedValue = (value: any) =>
|
||||||
|
(element.values || []).find((v: any) => v.formatted === value)
|
||||||
|
?.unformatted ?? value
|
||||||
|
|
||||||
if (operator === 'BETWEEN') {
|
if (operator === 'BETWEEN') {
|
||||||
rawValue = `${this.clauses.queryObj[groupInd].elements[clauseInd].value[0]} AND ${this.clauses.queryObj[groupInd][clauseInd].value[1]}`
|
const from = unformattedValue(element.value[0])
|
||||||
|
const to = unformattedValue(element.value[1])
|
||||||
|
rawValue = `${temporalValue(from) ?? from} AND ${
|
||||||
|
temporalValue(to) ?? to
|
||||||
|
}`
|
||||||
} else if (operator === 'IN' || operator === 'NOT IN') {
|
} else if (operator === 'IN' || operator === 'NOT IN') {
|
||||||
this.clauses.queryObj[groupInd][clauseInd].value.forEach(
|
element.value.forEach((item: any) => {
|
||||||
(element: any) => {
|
if (item.checked) {
|
||||||
if (element.checked) {
|
/* map once, and use the unformatted value in every branch - the
|
||||||
let value = element.val
|
literal is built from it, and so are the fallbacks, so a
|
||||||
|
formatted label can never reach RAW_VALUE */
|
||||||
|
const value = unformattedValue(item.val)
|
||||||
|
const literal = temporalValue(value)
|
||||||
|
|
||||||
if (typeof value === 'string' && !isSpecialMissing(value)) {
|
if (literal) {
|
||||||
if (rawValue.length > 0) {
|
rawValue += (rawValue.length > 0 ? ',' : '(') + literal
|
||||||
rawValue += `,'${value}'`
|
} else if (
|
||||||
} else {
|
typeof value === 'string' &&
|
||||||
rawValue = `('${value}'`
|
!isSpecialMissing(value)
|
||||||
}
|
) {
|
||||||
|
if (rawValue.length > 0) {
|
||||||
|
rawValue += `,'${value}'`
|
||||||
} else {
|
} else {
|
||||||
if (rawValue.length > 0) {
|
rawValue = `('${value}'`
|
||||||
rawValue += `,${value}`
|
}
|
||||||
} else {
|
} else {
|
||||||
rawValue = `(${value}`
|
if (rawValue.length > 0) {
|
||||||
}
|
rawValue += `,${value}`
|
||||||
|
} else {
|
||||||
|
rawValue = `(${value}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
if (rawValue.length > 0) {
|
if (rawValue.length > 0) {
|
||||||
rawValue += ')'
|
rawValue += ')'
|
||||||
@@ -470,11 +492,12 @@ export class QueryComponent
|
|||||||
rawValue = '.'
|
rawValue = '.'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (
|
const literal = temporalValue(element.value)
|
||||||
this.clauses.queryObj[groupInd].elements[clauseInd].type ===
|
|
||||||
'char'
|
if (literal) {
|
||||||
) {
|
rawValue = literal
|
||||||
rawValue = `'${this.clauses.queryObj[groupInd].elements[clauseInd].value}'`
|
} else if (element.type === 'char') {
|
||||||
|
rawValue = `'${element.value}'`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,9 +170,11 @@ describe('AppService - startup retry', () => {
|
|||||||
|
|
||||||
expect(deps.sasService.request).toHaveBeenCalledTimes(1)
|
expect(deps.sasService.request).toHaveBeenCalledTimes(1)
|
||||||
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
|
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
|
||||||
const [, message] = deps.eventService.showInfoModal.calls.mostRecent().args
|
const [, message, sasService] =
|
||||||
|
deps.eventService.showInfoModal.calls.mostRecent().args
|
||||||
expect(message).toContain(rawJobError)
|
expect(message).toContain(rawJobError)
|
||||||
expect(message).not.toContain('Globvars, Sasdatasets, Saslibs, XLMaps')
|
expect(message).not.toContain('Globvars, Sasdatasets, Saslibs, XLMaps')
|
||||||
|
expect(sasService).toBe('public/startupservice')
|
||||||
expect(deps.licenceService.isAppActivated.value).toBeFalse()
|
expect(deps.licenceService.isAppActivated.value).toBeFalse()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -107,7 +107,11 @@ export class AppService {
|
|||||||
malformedMessage
|
malformedMessage
|
||||||
)
|
)
|
||||||
this.startupCheckService.updateStep(2, 'error', 'Malformed response')
|
this.startupCheckService.updateStep(2, 'error', 'Malformed response')
|
||||||
this.eventService.showInfoModal('Error', malformedMessage)
|
this.eventService.showInfoModal(
|
||||||
|
'Error',
|
||||||
|
malformedMessage,
|
||||||
|
'public/startupservice'
|
||||||
|
)
|
||||||
this.licenceService.isAppActivated.next(false)
|
this.licenceService.isAppActivated.next(false)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -139,7 +143,8 @@ export class AppService {
|
|||||||
)
|
)
|
||||||
this.eventService.showInfoModal(
|
this.eventService.showInfoModal(
|
||||||
'Error',
|
'Error',
|
||||||
`${missingProps.join(', ')} are not present in the startupservice`
|
`${missingProps.join(', ')} are not present in the startupservice`,
|
||||||
|
'public/startupservice'
|
||||||
)
|
)
|
||||||
this.licenceService.isAppActivated.next(false)
|
this.licenceService.isAppActivated.next(false)
|
||||||
|
|
||||||
@@ -221,7 +226,8 @@ export class AppService {
|
|||||||
this.startupCheckService.updateStep(2, 'error', 'Request failed')
|
this.startupCheckService.updateStep(2, 'error', 'Request failed')
|
||||||
this.eventService.showInfoModal(
|
this.eventService.showInfoModal(
|
||||||
'Error',
|
'Error',
|
||||||
'There is an issue with startupservice response'
|
'There is an issue with startupservice response',
|
||||||
|
'public/startupservice'
|
||||||
)
|
)
|
||||||
this.licenceService.isAppActivated.next(false)
|
this.licenceService.isAppActivated.next(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,11 +42,15 @@ export class EventService {
|
|||||||
this.onDemoLimitModalShow.emit(featureName)
|
this.onDemoLimitModalShow.emit(featureName)
|
||||||
}
|
}
|
||||||
|
|
||||||
public showInfoModal(modalTitle: string = 'Info', message: string) {
|
public showInfoModal(
|
||||||
|
modalTitle: string = 'Info',
|
||||||
|
message: string,
|
||||||
|
sasService: string | null = null
|
||||||
|
) {
|
||||||
let infoModal: InfoModal = {
|
let infoModal: InfoModal = {
|
||||||
modalTitle,
|
modalTitle,
|
||||||
message,
|
message,
|
||||||
sasService: '',
|
sasService,
|
||||||
details: null
|
details: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,3 +46,72 @@ describe('HelperService - convertArrayValues', () => {
|
|||||||
expect(result.length).toBe(3)
|
expect(result.length).toBe(3)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('HelperService - sasTemporalLiteral', () => {
|
||||||
|
let service: HelperService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new HelperService(null as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a SAS date value as a date literal', () => {
|
||||||
|
expect(service.sasTemporalLiteral(42, 'DATE')).toBe("'12FEB1960'd")
|
||||||
|
expect(service.sasTemporalLiteral(0, 'DATE')).toBe("'01JAN1960'd")
|
||||||
|
expect(service.sasTemporalLiteral(34322, 'DATE')).toBe("'20DEC2053'd")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a date before 1960', () => {
|
||||||
|
expect(service.sasTemporalLiteral(-1, 'DATE')).toBe("'31DEC1959'd")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a SAS datetime value as a datetime literal', () => {
|
||||||
|
expect(service.sasTemporalLiteral(42, 'DATETIME')).toBe(
|
||||||
|
"'01JAN1960:00:00:42'dt"
|
||||||
|
)
|
||||||
|
expect(service.sasTemporalLiteral(86400, 'DATETIME')).toBe(
|
||||||
|
"'02JAN1960:00:00:00'dt"
|
||||||
|
)
|
||||||
|
expect(service.sasTemporalLiteral(31536001, 'DATETIME')).toBe(
|
||||||
|
"'31DEC1960:00:00:01'dt"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rounds double noise onto the second', () => {
|
||||||
|
expect(service.sasTemporalLiteral(1.9999999999999, 'TIME')).toBe(
|
||||||
|
"'00:00:02't"
|
||||||
|
)
|
||||||
|
expect(service.sasTemporalLiteral(42.000000000000004, 'DATETIME')).toBe(
|
||||||
|
"'01JAN1960:00:00:42'dt"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null rather than round a genuinely fractional value', () => {
|
||||||
|
expect(service.sasTemporalLiteral(42.5, 'DATETIME')).toBeNull()
|
||||||
|
expect(service.sasTemporalLiteral(42.5, 'TIME')).toBeNull()
|
||||||
|
expect(service.sasTemporalLiteral(42.3, 'TIME')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a SAS time value as a time literal', () => {
|
||||||
|
expect(service.sasTemporalLiteral(0, 'TIME')).toBe("'00:00:00't")
|
||||||
|
expect(service.sasTemporalLiteral(42, 'TIME')).toBe("'00:00:42't")
|
||||||
|
expect(service.sasTemporalLiteral(3661, 'TIME')).toBe("'01:01:01't")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a value that arrives as a string', () => {
|
||||||
|
expect(service.sasTemporalLiteral('42', 'DATE')).toBe("'12FEB1960'd")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the value cannot be a literal', () => {
|
||||||
|
// not temporal
|
||||||
|
expect(service.sasTemporalLiteral(42, 'N')).toBeNull()
|
||||||
|
expect(service.sasTemporalLiteral(42, 'C')).toBeNull()
|
||||||
|
expect(service.sasTemporalLiteral(42, '')).toBeNull()
|
||||||
|
// not a number
|
||||||
|
expect(service.sasTemporalLiteral('12FEB1960', 'DATE')).toBeNull()
|
||||||
|
expect(service.sasTemporalLiteral('', 'DATE')).toBeNull()
|
||||||
|
expect(service.sasTemporalLiteral(null, 'DATE')).toBeNull()
|
||||||
|
expect(service.sasTemporalLiteral(undefined, 'DATE')).toBeNull()
|
||||||
|
// a fractional number of days is not a SAS date
|
||||||
|
expect(service.sasTemporalLiteral(42.5, 'DATE')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,6 +5,22 @@ import { SasService } from './sas.service'
|
|||||||
|
|
||||||
const librariesToShow = 50
|
const librariesToShow = 50
|
||||||
|
|
||||||
|
/** Month names used by a SAS date literal, eg 20APR2042. */
|
||||||
|
const sasMonths = [
|
||||||
|
'JAN',
|
||||||
|
'FEB',
|
||||||
|
'MAR',
|
||||||
|
'APR',
|
||||||
|
'MAY',
|
||||||
|
'JUN',
|
||||||
|
'JUL',
|
||||||
|
'AUG',
|
||||||
|
'SEP',
|
||||||
|
'OCT',
|
||||||
|
'NOV',
|
||||||
|
'DEC'
|
||||||
|
]
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
@@ -226,6 +242,70 @@ export class HelperService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a SAS internal date / datetime / time value as the SAS literal a
|
||||||
|
* WHERE clause can carry, eg:
|
||||||
|
* 34322 (DATE) -> '20DEC2053'd
|
||||||
|
* 42 (DATETIME) -> '01JAN1960:00:00:42'dt
|
||||||
|
* 42 (TIME) -> '00:00:42't
|
||||||
|
*
|
||||||
|
* The value is days since 1960-01-01 for a date, and seconds since
|
||||||
|
* 1960-01-01 (datetime) or since midnight (time). The date part is built
|
||||||
|
* arithmetically in UTC, so the day cannot shift with the browser timezone.
|
||||||
|
*
|
||||||
|
* SAS temporal values are whole days and whole seconds, so the value is
|
||||||
|
* rounded to seconds first - that kills the noise a double picks up on the
|
||||||
|
* way here, where 1.9999999999999 is simply the second 2. A value that is
|
||||||
|
* genuinely fractional does not land on a second, and rounding it would
|
||||||
|
* silently widen the filter, so it returns null instead.
|
||||||
|
*
|
||||||
|
* Returns null when the value is not a finite number, is not a whole day or
|
||||||
|
* second, or the ddtype is not temporal - callers then send the raw value.
|
||||||
|
*/
|
||||||
|
public sasTemporalLiteral(value: any, ddtype: string): string | null {
|
||||||
|
const kind = (ddtype ?? '').toString().toUpperCase()
|
||||||
|
if (kind !== 'DATE' && kind !== 'DATETIME' && kind !== 'TIME') return null
|
||||||
|
if (value === null || value === undefined || value === '') return null
|
||||||
|
|
||||||
|
const num = typeof value === 'number' ? value : Number(String(value).trim())
|
||||||
|
if (!isFinite(num)) return null
|
||||||
|
|
||||||
|
/* Within the precision of a double, is this a whole second? */
|
||||||
|
const seconds = Math.round(num * 1e8) / 1e8
|
||||||
|
if (!Number.isInteger(seconds)) return null
|
||||||
|
|
||||||
|
if (kind === 'TIME') return `'${this.sasTimeFromSeconds(seconds)}'t`
|
||||||
|
|
||||||
|
const days = kind === 'DATETIME' ? Math.floor(seconds / 86400) : seconds
|
||||||
|
const datePart = this.sasDateFromDays(days)
|
||||||
|
|
||||||
|
if (kind === 'DATE') return `'${datePart}'d`
|
||||||
|
|
||||||
|
return `'${datePart}:${this.sasTimeFromSeconds(seconds - days * 86400)}'dt`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ddMMMyyyy` for a SAS date value (days since 1960-01-01). */
|
||||||
|
private sasDateFromDays(days: number): string {
|
||||||
|
const date = new Date(Date.UTC(1960, 0, 1) + days * 86400000)
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.addLeadingZero(date.getUTCDate()) +
|
||||||
|
sasMonths[date.getUTCMonth()] +
|
||||||
|
date.getUTCFullYear()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `hh:mm:ss` for a SAS time value (whole seconds since midnight). */
|
||||||
|
private sasTimeFromSeconds(seconds: number): string {
|
||||||
|
return (
|
||||||
|
this.addLeadingZero(Math.floor(seconds / 3600)) +
|
||||||
|
':' +
|
||||||
|
this.addLeadingZero(Math.floor((seconds % 3600) / 60)) +
|
||||||
|
':' +
|
||||||
|
this.addLeadingZero(seconds % 60)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
public addLeadingZero(value: string | number): string {
|
public addLeadingZero(value: string | number): string {
|
||||||
if (typeof value !== 'string') value = value.toString()
|
if (typeof value !== 'string') value = value.toString()
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ import * as encoding from 'text-encoding'
|
|||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import { Globvar } from '../models/sas/public-startupservice.model'
|
import { Globvar } from '../models/sas/public-startupservice.model'
|
||||||
import { freeTierConfig } from '../free-tier.config'
|
import { freeTierConfig } from '../free-tier.config'
|
||||||
import { AppStoreService } from './app-store.service'
|
|
||||||
import { HelperService } from './helper.service'
|
import { HelperService } from './helper.service'
|
||||||
import { LicenceState } from '../models/LicenceState'
|
import { LicenceState } from '../models/LicenceState'
|
||||||
import { decodeLicenceFeatures as decodeFeatures } from './utils/decodeLicenceFeatures'
|
import { decodeLicenceFeatures as decodeFeatures } from './utils/decodeLicenceFeatures'
|
||||||
import { LoggerService } from './logger.service'
|
import { LoggerService } from './logger.service'
|
||||||
import { EventService } from './event.service'
|
import { EventService } from './event.service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handsontable licence key used until a licence is loaded from the backend.
|
||||||
|
* The decrypted licence carries the customer's own key (`hot_license_key`), so
|
||||||
|
* this is only the pre-licence / free-tier default - it used to be supplied by
|
||||||
|
* the `<sasjs>` tag.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_HOT_LICENCE_KEY = 'non-commercial-and-evaluation'
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
@@ -43,7 +50,7 @@ export class LicenceService {
|
|||||||
|
|
||||||
private freeTierLicenceData: LicenseKeyData = {
|
private freeTierLicenceData: LicenseKeyData = {
|
||||||
demo: true,
|
demo: true,
|
||||||
hot_license_key: this.appStoreService.getDcAdapterSettings()?.hotLicenceKey,
|
hot_license_key: DEFAULT_HOT_LICENCE_KEY,
|
||||||
users_allowed: this._licenceState.users_allowed || 1,
|
users_allowed: this._licenceState.users_allowed || 1,
|
||||||
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
|
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
|
||||||
site_id: '',
|
site_id: '',
|
||||||
@@ -63,7 +70,6 @@ export class LicenceService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
private appStoreService: AppStoreService,
|
|
||||||
private eventService: EventService,
|
private eventService: EventService,
|
||||||
private sasService: SasService,
|
private sasService: SasService,
|
||||||
private helperService: HelperService,
|
private helperService: HelperService,
|
||||||
@@ -79,8 +85,7 @@ export class LicenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async activation(startupservice: any) {
|
public async activation(startupservice: any) {
|
||||||
this.freeTierLicenceData.hot_license_key =
|
this.freeTierLicenceData.hot_license_key = DEFAULT_HOT_LICENCE_KEY
|
||||||
this.appStoreService.getDcAdapterSettings()?.hotLicenceKey
|
|
||||||
|
|
||||||
await this.setStartupserviceRules(startupservice)
|
await this.setStartupserviceRules(startupservice)
|
||||||
|
|
||||||
@@ -137,7 +142,8 @@ export class LicenceService {
|
|||||||
this.isAppActivated.next(false)
|
this.isAppActivated.next(false)
|
||||||
this.eventService.showInfoModal(
|
this.eventService.showInfoModal(
|
||||||
'Error',
|
'Error',
|
||||||
'Some of the globvars are not present in the startupservice'
|
'Some of the globvars are not present in the startupservice',
|
||||||
|
'public/startupservice'
|
||||||
)
|
)
|
||||||
// this.router.navigateByUrl('/licensing/key?error=missing')
|
// this.router.navigateByUrl('/licensing/key?error=missing')
|
||||||
return
|
return
|
||||||
@@ -225,8 +231,7 @@ export class LicenceService {
|
|||||||
? licenseData.hot_license_key
|
? licenseData.hot_license_key
|
||||||
: this.hot_license_key.value
|
: this.hot_license_key.value
|
||||||
|
|
||||||
if (!hotLicenceKey)
|
if (!hotLicenceKey) hotLicenceKey = DEFAULT_HOT_LICENCE_KEY
|
||||||
hotLicenceKey = this.appStoreService.getDcAdapterSettings()?.hotLicenceKey
|
|
||||||
|
|
||||||
this.hot_license_key.next(hotLicenceKey)
|
this.hot_license_key.next(hotLicenceKey)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { SasStoreService } from './sas-store.service'
|
||||||
|
import { HelperService } from './helper.service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filter picker keys its operator set, its variable highlight and its value
|
||||||
|
* quoting on Col.TYPE being exactly 'num' or 'char'. On the normal path
|
||||||
|
* mergeColsRules derives that from $dataFormats; on the public/getcols fallback
|
||||||
|
* (used when the EDIT/VIEW limits abort the load) the service speaks the SAS
|
||||||
|
* vocabulary (TYPE C/N, DDTYPE separating DATE/DATETIME/TIME), so the store has
|
||||||
|
* to map it. These tests pin both the mapping and the empty-payload guard.
|
||||||
|
*/
|
||||||
|
describe('SasStoreService', () => {
|
||||||
|
const buildStore = (cols: any[]) =>
|
||||||
|
new SasStoreService(
|
||||||
|
{
|
||||||
|
request: () => Promise.resolve({ adapterResponse: { cols } })
|
||||||
|
} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any
|
||||||
|
)
|
||||||
|
|
||||||
|
const collectColumns = (store: SasStoreService) => {
|
||||||
|
const seen: any[] = []
|
||||||
|
store.columns.subscribe((value: any) => seen.push(value))
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getCols', () => {
|
||||||
|
it('maps the SAS C/N TYPE onto the picker vocabulary', async () => {
|
||||||
|
const store = buildStore([
|
||||||
|
{ NAME: 'CHAR_COL', TYPE: 'C', DDTYPE: 'C' },
|
||||||
|
{ NAME: 'NUM_COL', TYPE: 'N', DDTYPE: 'N' },
|
||||||
|
{ NAME: 'DATE_COL', TYPE: 'N', DDTYPE: 'DATE' },
|
||||||
|
{ NAME: 'DATETIME_COL', TYPE: 'N', DDTYPE: 'DATETIME' },
|
||||||
|
{ NAME: 'TIME_COL', TYPE: 'N', DDTYPE: 'TIME' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const cols = await store.getCols('DC.MY_TABLE')
|
||||||
|
|
||||||
|
expect(cols.map((col: any) => col.TYPE)).toEqual([
|
||||||
|
'char',
|
||||||
|
'num',
|
||||||
|
'num',
|
||||||
|
'num',
|
||||||
|
'num'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the rest of the column payload untouched', async () => {
|
||||||
|
const store = buildStore([
|
||||||
|
{
|
||||||
|
NAME: 'CHAR_COL',
|
||||||
|
TYPE: 'C',
|
||||||
|
DDTYPE: 'C',
|
||||||
|
LENGTH: 8,
|
||||||
|
LABEL: 'A label'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const cols = await store.getCols('DC.MY_TABLE')
|
||||||
|
|
||||||
|
expect(cols[0].NAME).toBe('CHAR_COL')
|
||||||
|
expect(cols[0].DDTYPE).toBe('C')
|
||||||
|
expect(cols[0].LENGTH).toBe(8)
|
||||||
|
expect(cols[0].LABEL).toBe('A label')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an empty list rather than throwing when the payload is empty', async () => {
|
||||||
|
const store = buildStore([])
|
||||||
|
|
||||||
|
const cols = await store.getCols('DC.MY_TABLE')
|
||||||
|
|
||||||
|
expect(cols).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setQueryVariablesFromTable', () => {
|
||||||
|
it('publishes the table columns without calling the service', async () => {
|
||||||
|
let called = false
|
||||||
|
const store = new SasStoreService(
|
||||||
|
{
|
||||||
|
request: () => {
|
||||||
|
called = true
|
||||||
|
return Promise.resolve({})
|
||||||
|
}
|
||||||
|
} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any
|
||||||
|
)
|
||||||
|
const seen = collectColumns(store)
|
||||||
|
|
||||||
|
await store.setQueryVariablesFromTable('DC.MY_TABLE', [{ NAME: 'A' }])
|
||||||
|
|
||||||
|
expect(called).toBe(false)
|
||||||
|
expect(seen.length).toBe(1)
|
||||||
|
expect(seen[0].data.cols.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to getcols and publishes the mapped columns', async () => {
|
||||||
|
const store = buildStore([{ NAME: 'A', TYPE: 'C', DDTYPE: 'C' }])
|
||||||
|
const seen = collectColumns(store)
|
||||||
|
|
||||||
|
await store.setQueryVariablesFromTable('DC.MY_TABLE', [])
|
||||||
|
|
||||||
|
expect(seen.length).toBe(1)
|
||||||
|
expect(seen[0].libds).toBe('DC.MY_TABLE')
|
||||||
|
expect(seen[0].data.cols[0].TYPE).toBe('char')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not publish an empty fallback, which the picker cannot render', async () => {
|
||||||
|
const store = buildStore([])
|
||||||
|
const seen = collectColumns(store)
|
||||||
|
|
||||||
|
await store.setQueryVariablesFromTable('DC.MY_TABLE', [])
|
||||||
|
|
||||||
|
expect(seen.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The clause shown in the filter panel is composed by whereClauseCreator, and
|
||||||
|
* its temporal rewrite turns the internal day / second count into a SAS
|
||||||
|
* literal. It may only do that when the composed value really is nothing but
|
||||||
|
* those numbers - a value typed by hand is not, and rewriting the digit runs
|
||||||
|
* inside it produced clauses like '13JAN1960'd/'03JAN1960'd/'14MAY1965'd for
|
||||||
|
* a date typed as 12/02/1960.
|
||||||
|
*/
|
||||||
|
describe('SasStoreService - temporal clause preview', () => {
|
||||||
|
const buildStore = () =>
|
||||||
|
new SasStoreService({} as any, new HelperService(null as any), {} as any)
|
||||||
|
|
||||||
|
const clauseFor = (
|
||||||
|
value: any,
|
||||||
|
operator: string,
|
||||||
|
ddtype: string,
|
||||||
|
values: any[] = []
|
||||||
|
) => ({
|
||||||
|
queryObj: [
|
||||||
|
{
|
||||||
|
clauseLogic: '',
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
variable: 'SOME_DATE',
|
||||||
|
operator,
|
||||||
|
value,
|
||||||
|
type: 'num',
|
||||||
|
ddtype,
|
||||||
|
valueVariable: false,
|
||||||
|
values
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
groupLogic: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a typed temporal value alone when it is not a number', () => {
|
||||||
|
const store = buildStore()
|
||||||
|
|
||||||
|
const result = store.whereClauseCreator(
|
||||||
|
clauseFor('12/02/1960', '=', 'DATE'),
|
||||||
|
'',
|
||||||
|
'lib.ds'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.whereClause).toContain('12/02/1960')
|
||||||
|
expect(result.whereClause).not.toContain("'d")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still shows an internal day count as a date literal', () => {
|
||||||
|
const store = buildStore()
|
||||||
|
|
||||||
|
const result = store.whereClauseCreator(
|
||||||
|
clauseFor('42', '=', 'DATE'),
|
||||||
|
'',
|
||||||
|
'lib.ds'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.whereClause).toContain("'12FEB1960'd")
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A BETWEEN pair is composed as `<from> AND <to>`, so the guard that decides
|
||||||
|
* whether the rewrite may run has to see past the separator - its letters are
|
||||||
|
* not part of the numbers the value is built from.
|
||||||
|
*/
|
||||||
|
it('shows a BETWEEN pair picked from the value list as literals', () => {
|
||||||
|
const store = buildStore()
|
||||||
|
|
||||||
|
const result = store.whereClauseCreator(
|
||||||
|
clauseFor(['12FEB1960', '13FEB1960'], 'BETWEEN', 'DATE', [
|
||||||
|
{ formatted: '12FEB1960', unformatted: '42' },
|
||||||
|
{ formatted: '13FEB1960', unformatted: '43' }
|
||||||
|
]),
|
||||||
|
'',
|
||||||
|
'lib.ds'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.whereClause).toContain(
|
||||||
|
"BETWEEN '12FEB1960'd AND '13FEB1960'd"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createFilterQueryTable builds the RAW_VALUE the backend validates and stores.
|
||||||
|
* The range dropdown binds the formatted label, so a BETWEEN has to map each
|
||||||
|
* end back to its unformatted value before the literal is built - otherwise
|
||||||
|
* sasTemporalLiteral returns null and an unquoted date reaches the backend.
|
||||||
|
*/
|
||||||
|
describe('SasStoreService - temporal RAW_VALUE', () => {
|
||||||
|
const buildStore = () =>
|
||||||
|
new SasStoreService({} as any, new HelperService(null as any), {} as any)
|
||||||
|
|
||||||
|
const betweenQuery = (value: any[], values: any[]) => ({
|
||||||
|
groupLogic: 'AND',
|
||||||
|
filterGroups: [
|
||||||
|
{
|
||||||
|
clauseLogic: 'AND',
|
||||||
|
filterClauses: [
|
||||||
|
{
|
||||||
|
ddtype: 'DATE',
|
||||||
|
invalidClause: false,
|
||||||
|
logic: '',
|
||||||
|
operator: 'BETWEEN',
|
||||||
|
operators: [],
|
||||||
|
type: 'num',
|
||||||
|
value,
|
||||||
|
valueVariable: false,
|
||||||
|
values,
|
||||||
|
variable: 'SOME_DATE'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps a BETWEEN picked from the value list back to the internal value', () => {
|
||||||
|
const store = buildStore()
|
||||||
|
|
||||||
|
const clauses = store.createFilterQueryTable(
|
||||||
|
betweenQuery(
|
||||||
|
['12FEB1960', '13FEB1960'],
|
||||||
|
[
|
||||||
|
{ formatted: '12FEB1960', unformatted: 42 },
|
||||||
|
{ formatted: '13FEB1960', unformatted: 43 }
|
||||||
|
]
|
||||||
|
) as any
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(clauses[0].RAW_VALUE).toBe("'12FEB1960'd AND '13FEB1960'd")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still builds literals when the values are already internal counts', () => {
|
||||||
|
const store = buildStore()
|
||||||
|
|
||||||
|
const clauses = store.createFilterQueryTable(
|
||||||
|
betweenQuery(['42', '43'], []) as any
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(clauses[0].RAW_VALUE).toBe("'12FEB1960'd AND '13FEB1960'd")
|
||||||
|
})
|
||||||
|
|
||||||
|
const inQuery = (value: any[], values: any[]) => ({
|
||||||
|
groupLogic: 'AND',
|
||||||
|
filterGroups: [
|
||||||
|
{
|
||||||
|
clauseLogic: 'AND',
|
||||||
|
filterClauses: [
|
||||||
|
{
|
||||||
|
ddtype: 'DATE',
|
||||||
|
invalidClause: false,
|
||||||
|
logic: '',
|
||||||
|
operator: 'IN',
|
||||||
|
operators: [],
|
||||||
|
type: 'num',
|
||||||
|
value,
|
||||||
|
valueVariable: false,
|
||||||
|
values,
|
||||||
|
variable: 'SOME_DATE'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps an IN entry picked from the value list back to the internal value', () => {
|
||||||
|
const store = buildStore()
|
||||||
|
|
||||||
|
const clauses = store.createFilterQueryTable(
|
||||||
|
inQuery(
|
||||||
|
[{ checked: true, val: '12FEB1960' }],
|
||||||
|
[{ formatted: '12FEB1960', unformatted: 42 }]
|
||||||
|
) as any
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(clauses[0].RAW_VALUE).toBe("('12FEB1960'd)")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes an already-unformatted IN entry through unchanged', () => {
|
||||||
|
const store = buildStore()
|
||||||
|
|
||||||
|
const clauses = store.createFilterQueryTable(
|
||||||
|
inQuery(
|
||||||
|
[{ checked: true, val: 42 }],
|
||||||
|
[{ formatted: '12FEB1960', unformatted: 42 }]
|
||||||
|
) as any
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(clauses[0].RAW_VALUE).toBe("('12FEB1960'd)")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -295,6 +295,68 @@ export class SasStoreService {
|
|||||||
this.columns.next(columnsData)
|
this.columns.next(columnsData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column metadata for the filter picker, read from the service.
|
||||||
|
*
|
||||||
|
* The picker's variable list normally comes from the columns that arrived
|
||||||
|
* with the table (see setQueryVariablesFromTable). This is the fallback for
|
||||||
|
* when there is no table - public/getcols reads the metadata only.
|
||||||
|
*
|
||||||
|
* public/getcols speaks the SAS vocabulary: TYPE is C/N and DDTYPE separates
|
||||||
|
* DATE, DATETIME and TIME. The picker keys its operator set (query.component),
|
||||||
|
* its variable highlight and its value quoting (sas-store) on the client
|
||||||
|
* vocabulary instead - TYPE is exactly 'num' or 'char' - which
|
||||||
|
* mergeColsRules derives from $dataFormats on the normal path. That pass
|
||||||
|
* never runs for this fallback, so map TYPE here, with the same rule: the
|
||||||
|
* date and time types are numeric.
|
||||||
|
*/
|
||||||
|
public async getCols(libds: string) {
|
||||||
|
const tables = { iwant: [{ libds: libds }] }
|
||||||
|
|
||||||
|
const res = await this.sasService.request('public/getcols', tables)
|
||||||
|
|
||||||
|
const cols = res.adapterResponse.cols || []
|
||||||
|
|
||||||
|
return cols.map((col: any) => ({
|
||||||
|
...col,
|
||||||
|
TYPE:
|
||||||
|
['DATE', 'DATETIME', 'TIME'].includes(col.DDTYPE) || col.TYPE === 'N'
|
||||||
|
? 'num'
|
||||||
|
: 'char'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feeds the filter picker its variable list.
|
||||||
|
*
|
||||||
|
* `cols` is the column metadata that came back with the table, which costs
|
||||||
|
* nothing extra and is the normal path. When the table was not returned - the
|
||||||
|
* EDIT/VIEW row and cell limits abort the request before the data arrives -
|
||||||
|
* that list is empty, which is what makes "please filter and try again"
|
||||||
|
* unactionable: the picker has nothing to offer. Fall back to
|
||||||
|
* public/getcols, which reads the column metadata without the rows.
|
||||||
|
*/
|
||||||
|
public async setQueryVariablesFromTable(libds: string, cols: any[]) {
|
||||||
|
if (cols && cols.length > 0) {
|
||||||
|
this.setQueryVariables(libds, cols)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = await this.getCols(libds)
|
||||||
|
|
||||||
|
/*
|
||||||
|
public/getcols emits an empty cols table when the dataset has no
|
||||||
|
metadata. Publishing that would push an empty array into the filter
|
||||||
|
picker, whose subscriber reads cols[0].NAME - the throw is caught, but
|
||||||
|
the picker is left half-updated (this.cols assigned, the filter.cols
|
||||||
|
assignment below the throwing line never run). There is nothing to
|
||||||
|
offer, so leave the picker as it was.
|
||||||
|
*/
|
||||||
|
if (!fallback || fallback.length === 0) return
|
||||||
|
|
||||||
|
this.setQueryVariables(libds, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
public async getChangeInfo(tableId: any) {
|
public async getChangeInfo(tableId: any) {
|
||||||
let obj = { TABLE: tableId }
|
let obj = { TABLE: tableId }
|
||||||
let table = { SASControlTable: [obj] }
|
let table = { SASControlTable: [obj] }
|
||||||
@@ -534,6 +596,27 @@ export class SasStoreService {
|
|||||||
}
|
}
|
||||||
oper = this.checkOperator(oper, value, type).operator
|
oper = this.checkOperator(oper, value, type).operator
|
||||||
|
|
||||||
|
/* A temporal value is shown as a SAS literal (eg '20DEC2053'd) rather
|
||||||
|
than the internal day / second count. Only rewrite when the composed
|
||||||
|
value is nothing but the numbers the clause is built from - a single
|
||||||
|
value, a BETWEEN pair or an IN list. The BETWEEN separator is
|
||||||
|
removed before the test because ' AND ' is not part of that character
|
||||||
|
set; a typed value that is not a number, such as a date entered by
|
||||||
|
hand, would otherwise have every digit run inside it rewritten into
|
||||||
|
an unrelated literal. */
|
||||||
|
if (
|
||||||
|
!isValueVariable &&
|
||||||
|
['DATE', 'DATETIME', 'TIME'].includes(query.ddtype) &&
|
||||||
|
/^[-0-9.,()\s]+$/.test(String(value).replace(/\s+AND\s+/gi, ' '))
|
||||||
|
) {
|
||||||
|
value = String(value).replace(
|
||||||
|
/-?\d+(?:\.\d+)?/g,
|
||||||
|
(match) =>
|
||||||
|
this.helperService.sasTemporalLiteral(match, query.ddtype) ??
|
||||||
|
match
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'char' && oper !== 'IN' && oper !== 'NOT IN') {
|
if (type === 'char' && oper !== 'IN' && oper !== 'NOT IN') {
|
||||||
if (typeof value === 'undefined') {
|
if (typeof value === 'undefined') {
|
||||||
value = ''
|
value = ''
|
||||||
@@ -600,17 +683,53 @@ export class SasStoreService {
|
|||||||
filterQuery.filterGroups.forEach(
|
filterQuery.filterGroups.forEach(
|
||||||
(filterGroup: FilterGroup, groupIndex: number) => {
|
(filterGroup: FilterGroup, groupIndex: number) => {
|
||||||
filterGroup.filterClauses.forEach((filterClause: FilterClause) => {
|
filterGroup.filterClauses.forEach((filterClause: FilterClause) => {
|
||||||
|
/* A temporal value is sent as a SAS literal (eg '20APR2042'd) rather
|
||||||
|
than the internal day / second count, so the resolved WHERE clause
|
||||||
|
- and the FILTER_TEXT shown above the grid - carries a date. */
|
||||||
|
const temporalValue = (value: any) =>
|
||||||
|
filterClause.valueVariable
|
||||||
|
? null
|
||||||
|
: this.helperService.sasTemporalLiteral(
|
||||||
|
value,
|
||||||
|
filterClause.ddtype
|
||||||
|
)
|
||||||
|
|
||||||
|
/* The range dropdown binds the formatted label, as the value list
|
||||||
|
and the IN modal do, so each end of a BETWEEN is mapped back to
|
||||||
|
its unformatted value before the literal is built. This is the
|
||||||
|
same lookup whereClauseCreator applies for the preview - without
|
||||||
|
it the formatted string reaches sasTemporalLiteral, comes back
|
||||||
|
null, and RAW_VALUE carries an unquoted date that the backend
|
||||||
|
rejects on a numeric column. */
|
||||||
|
const unformattedValue = (value: any) => {
|
||||||
|
const match = (filterClause.values || []).find(
|
||||||
|
(entry: any) => entry.formatted === value
|
||||||
|
)
|
||||||
|
return match ? match.unformatted : value
|
||||||
|
}
|
||||||
|
|
||||||
let rawValue = ''
|
let rawValue = ''
|
||||||
if (filterClause.operator === 'BETWEEN') {
|
if (filterClause.operator === 'BETWEEN') {
|
||||||
rawValue = `${filterClause.value[0]} AND ${filterClause.value[1]}`
|
const from = unformattedValue(filterClause.value[0])
|
||||||
|
const to = unformattedValue(filterClause.value[1])
|
||||||
|
rawValue = `${temporalValue(from) ?? from} AND ${
|
||||||
|
temporalValue(to) ?? to
|
||||||
|
}`
|
||||||
} else if (
|
} else if (
|
||||||
filterClause.operator === 'IN' ||
|
filterClause.operator === 'IN' ||
|
||||||
filterClause.operator === 'NOT IN'
|
filterClause.operator === 'NOT IN'
|
||||||
) {
|
) {
|
||||||
filterClause.value.forEach((element: any) => {
|
filterClause.value.forEach((element: any) => {
|
||||||
if (element.checked) {
|
if (element.checked) {
|
||||||
const value = element.val
|
/* Mapped like the BETWEEN ends - whereClauseCreator normally
|
||||||
if (
|
rewrites element.val to the unformatted count before submit,
|
||||||
|
so this is a pass-through for the usual path and a guard for
|
||||||
|
any path that reaches here without the preview having run. */
|
||||||
|
const value = unformattedValue(element.val)
|
||||||
|
const literal = temporalValue(value)
|
||||||
|
if (literal) {
|
||||||
|
rawValue += (rawValue.length > 0 ? ',' : '(') + literal
|
||||||
|
} else if (
|
||||||
typeof value === 'string' &&
|
typeof value === 'string' &&
|
||||||
(!isSpecialMissing(value) || filterClause.type === 'char')
|
(!isSpecialMissing(value) || filterClause.type === 'char')
|
||||||
) {
|
) {
|
||||||
@@ -638,7 +757,13 @@ export class SasStoreService {
|
|||||||
rawValue = '.'
|
rawValue = '.'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (filterClause.type === 'char' && !filterClause.valueVariable) {
|
const literal = temporalValue(filterClause.value)
|
||||||
|
if (literal) {
|
||||||
|
rawValue = literal
|
||||||
|
} else if (
|
||||||
|
filterClause.type === 'char' &&
|
||||||
|
!filterClause.valueVariable
|
||||||
|
) {
|
||||||
rawValue = `'${filterClause.value.replace(/'/g, "''")}'`
|
rawValue = `'${filterClause.value.replace(/'/g, "''")}'`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SasService } from './sas.service'
|
import { DEBUG_STATE_KEY, SasService } from './sas.service'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SasService's constructor is a plain 8-argument constructor with an empty
|
* SasService's constructor is a plain 8-argument constructor with an empty
|
||||||
@@ -13,7 +13,13 @@ import { SasService } from './sas.service'
|
|||||||
const buildService = () => {
|
const buildService = () => {
|
||||||
const sasjsAdapter: any = {
|
const sasjsAdapter: any = {
|
||||||
request: jasmine.createSpy('request').and.resolveTo({}),
|
request: jasmine.createSpy('request').and.resolveTo({}),
|
||||||
getSasRequests: () => []
|
getSasRequests: () => [],
|
||||||
|
setDebugState: jasmine.createSpy('setDebugState'),
|
||||||
|
getSasjsConfig: () => ({
|
||||||
|
serverUrl: 'https://sas.example.com',
|
||||||
|
appLoc: '/Public/app/dc',
|
||||||
|
serverType: 'SASVIYA'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const loggerService: any = {
|
const loggerService: any = {
|
||||||
logRequestData: jasmine.createSpy('logRequestData')
|
logRequestData: jasmine.createSpy('logRequestData')
|
||||||
@@ -87,3 +93,115 @@ describe('SasService diagnostics payload', () => {
|
|||||||
expect(sentBody(sasjsAdapter).browser_info).toBe(supplied)
|
expect(sentBody(sasjsAdapter).browser_info).toBe(supplied)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `<sasjs>` tag is re-read on every load, so the debug toggle has to survive
|
||||||
|
* a refresh on its own.
|
||||||
|
*/
|
||||||
|
describe('SasService debug state', () => {
|
||||||
|
/** Builds a service whose init can actually run (real adapter, stub deps). */
|
||||||
|
const initService = (tagDebug = false) => {
|
||||||
|
const appStoreService: any = {
|
||||||
|
getDcAdapterSettings: () => ({
|
||||||
|
serverUrl: '',
|
||||||
|
appLoc: '/Public/app/dc',
|
||||||
|
serverType: 'SAS9',
|
||||||
|
loginMechanism: '',
|
||||||
|
dcPath: '',
|
||||||
|
debug: tagDebug,
|
||||||
|
useComputeApi: null,
|
||||||
|
runAsTask: true,
|
||||||
|
contextName: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = new SasService(
|
||||||
|
appStoreService,
|
||||||
|
{ user: undefined } as any,
|
||||||
|
{ showInfoModal: jasmine.createSpy('showInfoModal') } as any,
|
||||||
|
{ setup: jasmine.createSpy('setup') } as any,
|
||||||
|
{ setup: jasmine.createSpy('setup') } as any,
|
||||||
|
{ logRequestData: jasmine.createSpy('logRequestData') } as any,
|
||||||
|
{ updateStep: jasmine.createSpy('updateStep') } as any,
|
||||||
|
{ navigateByUrl: jasmine.createSpy('navigateByUrl') } as any
|
||||||
|
)
|
||||||
|
|
||||||
|
service.sasServiceInit()
|
||||||
|
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.removeItem(DEBUG_STATE_KEY))
|
||||||
|
afterEach(() => localStorage.removeItem(DEBUG_STATE_KEY))
|
||||||
|
|
||||||
|
it('stores the state when the toggle changes', () => {
|
||||||
|
const { service, sasjsAdapter } = buildService()
|
||||||
|
|
||||||
|
service.setDebugState(true)
|
||||||
|
|
||||||
|
expect(localStorage.getItem(DEBUG_STATE_KEY)).toBe('true')
|
||||||
|
expect(sasjsAdapter.setDebugState).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores a switch back off, rather than clearing it', () => {
|
||||||
|
const { service } = buildService()
|
||||||
|
|
||||||
|
service.setDebugState(true)
|
||||||
|
service.setDebugState(false)
|
||||||
|
|
||||||
|
expect(localStorage.getItem(DEBUG_STATE_KEY)).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reinstates the stored state on the next load', () => {
|
||||||
|
localStorage.setItem(DEBUG_STATE_KEY, 'true')
|
||||||
|
|
||||||
|
expect(initService().getSasjsConfig().debug).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the tag value alone when nothing is stored', () => {
|
||||||
|
expect(initService(true).getSasjsConfig().debug).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reinstates an off state over a tag that turns debug on', () => {
|
||||||
|
localStorage.setItem(DEBUG_STATE_KEY, 'false')
|
||||||
|
|
||||||
|
expect(initService(true).getSasjsConfig().debug).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL shape the deploy configurator already uses for makedata: the service
|
||||||
|
* is run by the JES / STP web app in a browser tab, so the SAS log comes back
|
||||||
|
* as the page body instead of through the adapter.
|
||||||
|
*/
|
||||||
|
describe('SasService openServiceInNewWindow', () => {
|
||||||
|
it('opens the service URL built from the live adapter config', () => {
|
||||||
|
const { service } = buildService()
|
||||||
|
spyOn(service, 'getExecutionPath').and.returnValue('/SASJobExecution')
|
||||||
|
const openSpy = spyOn(window, 'open')
|
||||||
|
|
||||||
|
service.openServiceInNewWindow('public/startupservice', {
|
||||||
|
_contextname: 'ctx'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(
|
||||||
|
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_contextname=ctx&_debug=131',
|
||||||
|
'_blank',
|
||||||
|
'noopener'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens without parameters when none are given', () => {
|
||||||
|
const { service } = buildService()
|
||||||
|
spyOn(service, 'getExecutionPath').and.returnValue('/SASJobExecution')
|
||||||
|
const openSpy = spyOn(window, 'open')
|
||||||
|
|
||||||
|
service.openServiceInNewWindow('public/startupservice')
|
||||||
|
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(
|
||||||
|
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_debug=131',
|
||||||
|
'_blank',
|
||||||
|
'noopener'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -21,6 +21,7 @@ import { SasViyaService } from './sas-viya.service'
|
|||||||
import { ViyaApiFolder } from '../viya-api-explorer/models/viya-api-folder.model'
|
import { ViyaApiFolder } from '../viya-api-explorer/models/viya-api-folder.model'
|
||||||
import { ViyaApiFolderMembers } from '../viya-api-explorer/models/viya-api-folder-content.model'
|
import { ViyaApiFolderMembers } from '../viya-api-explorer/models/viya-api-folder-content.model'
|
||||||
import { parseUserAgent } from '../shared/utils/parse-user-agent'
|
import { parseUserAgent } from '../shared/utils/parse-user-agent'
|
||||||
|
import { buildServiceUrl } from '../shared/utils/service-url'
|
||||||
import { VERSION } from '../../environments/version'
|
import { VERSION } from '../../environments/version'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +42,13 @@ const DIAGNOSTICS_SERVICES = [
|
|||||||
'services/auditors/postdata'
|
'services/auditors/postdata'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the debug toggle state is kept. The `<sasjs>` tag in index.html is
|
||||||
|
* re-read on every load, so without this a refresh would drop the state the user
|
||||||
|
* set from the UI.
|
||||||
|
*/
|
||||||
|
export const DEBUG_STATE_KEY = 'dc-debug-state'
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
@@ -89,6 +97,13 @@ export class SasService {
|
|||||||
|
|
||||||
this.sasjsAdapter = new SASjs(this.dcAdapterSettings)
|
this.sasjsAdapter = new SASjs(this.dcAdapterSettings)
|
||||||
|
|
||||||
|
/* The tag resets debug on every load, so reinstate the state the user last
|
||||||
|
chose from the UI. */
|
||||||
|
const storedDebugState = localStorage.getItem(DEBUG_STATE_KEY)
|
||||||
|
if (storedDebugState !== null) {
|
||||||
|
this.sasjsAdapter.setDebugState(storedDebugState === 'true')
|
||||||
|
}
|
||||||
|
|
||||||
switch (this.dcAdapterSettings.serverType) {
|
switch (this.dcAdapterSettings.serverType) {
|
||||||
case ServerType.SasViya: {
|
case ServerType.SasViya: {
|
||||||
this.checkViyaDeploy(this.dcAdapterSettings.appLoc || '')
|
this.checkViyaDeploy(this.dcAdapterSettings.appLoc || '')
|
||||||
@@ -271,7 +286,10 @@ export class SasService {
|
|||||||
adapterResponse: {
|
adapterResponse: {
|
||||||
error: abortMsg
|
error: abortMsg
|
||||||
},
|
},
|
||||||
log: sasRequest?.logFile
|
log: sasRequest?.logFile,
|
||||||
|
/* the modal above already told the user: a caller that also
|
||||||
|
reports the failure would stack a second one on top */
|
||||||
|
errorShown: !wrapperOptions?.suppressSuccessAbortModal
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +332,8 @@ export class SasService {
|
|||||||
adapterResponse: {
|
adapterResponse: {
|
||||||
error: errorMessage
|
error: errorMessage
|
||||||
},
|
},
|
||||||
log: sasRequest?.logFile
|
log: sasRequest?.logFile,
|
||||||
|
errorShown: !wrapperOptions?.suppressErrorAbortModal
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,6 +822,7 @@ export class SasService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setDebugState(state: boolean) {
|
public setDebugState(state: boolean) {
|
||||||
|
localStorage.setItem(DEBUG_STATE_KEY, String(state))
|
||||||
this.sasjsAdapter.setDebugState(state)
|
this.sasjsAdapter.setDebugState(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,6 +847,36 @@ export class SasService {
|
|||||||
return this.sasjsAdapter
|
return this.sasjsAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a SAS service in a new browser window, bypassing the adapter.
|
||||||
|
*
|
||||||
|
* The adapter's request path can hide the backend log when a service fails:
|
||||||
|
* on Viya a long startup service may be submitted as an execution task and
|
||||||
|
* time out before it finishes, returning neither webout nor log. A browser
|
||||||
|
* request to the JES / STP web app runs the same `_program` with no task and
|
||||||
|
* returns the SAS log (`_debug=131`), so this is the diagnostic path offered
|
||||||
|
* when a failed request leaves nothing usable to display.
|
||||||
|
*/
|
||||||
|
public openServiceInNewWindow(
|
||||||
|
service: string,
|
||||||
|
params: { [name: string]: string | number | null | undefined } = {}
|
||||||
|
) {
|
||||||
|
const config = this.getSasjsConfig()
|
||||||
|
|
||||||
|
const url = buildServiceUrl({
|
||||||
|
serverUrl: config.serverUrl || '',
|
||||||
|
appLoc: config.appLoc || '',
|
||||||
|
executionPath: this.getExecutionPath() || '',
|
||||||
|
service,
|
||||||
|
params
|
||||||
|
})
|
||||||
|
|
||||||
|
// noopener: the SAS log page is on a different origin (Viya's serverUrl is
|
||||||
|
// not the app's), and without it the opened window keeps a handle on
|
||||||
|
// window.opener and can navigate this tab.
|
||||||
|
window.open(url, '_blank', 'noopener')
|
||||||
|
}
|
||||||
|
|
||||||
public getServerType(): string {
|
public getServerType(): string {
|
||||||
const sasjsConfig = this.sasjsAdapter.getSasjsConfig()
|
const sasjsConfig = this.sasjsAdapter.getSasjsConfig()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
getSasjsTagDetails,
|
||||||
|
SASJS_TAG_STEP,
|
||||||
|
StartupCheckService
|
||||||
|
} from './startup-check.service'
|
||||||
|
|
||||||
|
describe('StartupCheckService', () => {
|
||||||
|
let service: StartupCheckService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new StartupCheckService()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes a SASjs tag step after the startup checks', () => {
|
||||||
|
const steps = service.steps$.value
|
||||||
|
|
||||||
|
expect(steps.length).toBe(SASJS_TAG_STEP + 1)
|
||||||
|
expect(steps[SASJS_TAG_STEP].label).toBe('SASjs tag')
|
||||||
|
expect(steps[SASJS_TAG_STEP].status).toBe('pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the tag contents against the SASjs tag step', () => {
|
||||||
|
service.setTagDetails([{ name: 'serverType', value: 'SASVIYA' }])
|
||||||
|
|
||||||
|
expect(service.steps$.value[SASJS_TAG_STEP]).toEqual({
|
||||||
|
label: 'SASjs tag',
|
||||||
|
status: 'done',
|
||||||
|
details: [{ name: 'serverType', value: 'SASVIYA' }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags a missing tag on the SASjs tag step', () => {
|
||||||
|
service.updateStep(SASJS_TAG_STEP, 'error', 'SASJS TAG MISSING!!')
|
||||||
|
|
||||||
|
expect(service.steps$.value[SASJS_TAG_STEP]).toEqual({
|
||||||
|
label: 'SASjs tag',
|
||||||
|
status: 'error',
|
||||||
|
detail: 'SASJS TAG MISSING!!'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSasjsTagDetails', () => {
|
||||||
|
const tagWith = (attributes: Record<string, string>) => {
|
||||||
|
const element = document.createElement('sasjs')
|
||||||
|
|
||||||
|
Object.entries(attributes).forEach(([name, value]) =>
|
||||||
|
element.setAttribute(name, value)
|
||||||
|
)
|
||||||
|
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reads the tag attributes as name/value pairs', () => {
|
||||||
|
const details = getSasjsTagDetails(
|
||||||
|
tagWith({ serverType: 'SASVIYA', debug: 'false', useComputeApi: 'true' })
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(details).toEqual([
|
||||||
|
{ name: 'serverType', value: 'SASVIYA' },
|
||||||
|
{ name: 'debug', value: 'false' },
|
||||||
|
{ name: 'useComputeApi', value: 'true' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps attributes the parser does not know about', () => {
|
||||||
|
expect(getSasjsTagDetails(tagWith({ serverTyp: 'SASJS' }))).toEqual([
|
||||||
|
{ name: 'servertyp', value: 'SASJS' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows every attribute, including the admin group and the licence key', () => {
|
||||||
|
const details = getSasjsTagDetails(
|
||||||
|
tagWith({
|
||||||
|
serverType: 'SASJS',
|
||||||
|
adminGroup: 'SASAdministrators',
|
||||||
|
hotLicenceKey: 'non-commercial-and-evaluation'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(details).toEqual([
|
||||||
|
{ name: 'serverType', value: 'SASJS' },
|
||||||
|
{ name: 'adminGroup', value: 'SASAdministrators' },
|
||||||
|
{ name: 'hotLicenceKey', value: 'non-commercial-and-evaluation' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags attributes that carry no value', () => {
|
||||||
|
expect(getSasjsTagDetails(tagWith({ runAsTask: '' }))).toEqual([
|
||||||
|
{ name: 'runAsTask', value: '(empty)' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,18 +3,64 @@ import { BehaviorSubject } from 'rxjs'
|
|||||||
|
|
||||||
export type StartupStepStatus = 'pending' | 'in_progress' | 'done' | 'error'
|
export type StartupStepStatus = 'pending' | 'in_progress' | 'done' | 'error'
|
||||||
|
|
||||||
|
/** A name/value pair shown under a startup step. */
|
||||||
|
export interface StartupStepDetail {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface StartupStep {
|
export interface StartupStep {
|
||||||
label: string
|
label: string
|
||||||
status: StartupStepStatus
|
status: StartupStepStatus
|
||||||
detail?: string
|
detail?: string
|
||||||
|
/** Rendered as a grid under the label. */
|
||||||
|
details?: StartupStepDetail[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the `<sasjs>` tag dump within the startup steps. It is appended
|
||||||
|
* after the three startup checks so the indices the startup flow already drives
|
||||||
|
* (sas.service / app.service) are unchanged.
|
||||||
|
*/
|
||||||
|
export const SASJS_TAG_STEP = 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical spelling of the `<sasjs>` tag attributes. HTML lower-cases attribute
|
||||||
|
* names in the DOM, so they are mapped back for display.
|
||||||
|
*/
|
||||||
|
const TAG_ATTRIBUTE_NAMES: Record<string, string> = {
|
||||||
|
serverurl: 'serverUrl',
|
||||||
|
apploc: 'appLoc',
|
||||||
|
servertype: 'serverType',
|
||||||
|
loginmechanism: 'loginMechanism',
|
||||||
|
admingroup: 'adminGroup',
|
||||||
|
dcpath: 'dcPath',
|
||||||
|
debug: 'debug',
|
||||||
|
usecomputeapi: 'useComputeApi',
|
||||||
|
runastask: 'runAsTask',
|
||||||
|
contextname: 'contextName',
|
||||||
|
hotlicencekey: 'hotLicenceKey'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the attributes of the `<sasjs>` tag for display in the startup checks.
|
||||||
|
* Every attribute is shown - including ones the parser does not know about, so a
|
||||||
|
* misspelt one is visible and a wrong value is exactly the misconfiguration this
|
||||||
|
* dump exists to expose.
|
||||||
|
*/
|
||||||
|
export const getSasjsTagDetails = (element: Element): StartupStepDetail[] =>
|
||||||
|
Array.from(element.attributes).map((attribute) => ({
|
||||||
|
name: TAG_ATTRIBUTE_NAMES[attribute.name.toLowerCase()] || attribute.name,
|
||||||
|
value: attribute.value || '(empty)'
|
||||||
|
}))
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class StartupCheckService {
|
export class StartupCheckService {
|
||||||
private steps: StartupStep[] = [
|
private steps: StartupStep[] = [
|
||||||
{ label: 'Checking app location', status: 'pending' },
|
{ label: 'Checking app location', status: 'pending' },
|
||||||
{ label: 'Checking Viya deploy', status: 'pending' },
|
{ label: 'Checking Viya deploy', status: 'pending' },
|
||||||
{ label: 'Loading startup service', status: 'pending' }
|
{ label: 'Loading startup service', status: 'pending' },
|
||||||
|
{ label: 'SASjs tag', status: 'pending' }
|
||||||
]
|
]
|
||||||
|
|
||||||
public steps$ = new BehaviorSubject<StartupStep[]>([...this.steps])
|
public steps$ = new BehaviorSubject<StartupStep[]>([...this.steps])
|
||||||
@@ -30,6 +76,16 @@ export class StartupCheckService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shows the `<sasjs>` tag contents on the boot screen. */
|
||||||
|
public setTagDetails(details: StartupStepDetail[]) {
|
||||||
|
this.steps[SASJS_TAG_STEP] = {
|
||||||
|
...this.steps[SASJS_TAG_STEP],
|
||||||
|
status: 'done',
|
||||||
|
details
|
||||||
|
}
|
||||||
|
this.steps$.next([...this.steps])
|
||||||
|
}
|
||||||
|
|
||||||
public setSteps(steps: StartupStep[]) {
|
public setSteps(steps: StartupStep[]) {
|
||||||
this.steps = steps
|
this.steps = steps
|
||||||
this.steps$.next([...this.steps])
|
this.steps$.next([...this.steps])
|
||||||
|
|||||||
@@ -1,27 +1,7 @@
|
|||||||
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'
|
||||||
/**
|
|
||||||
* Minimal stand-in for HelperService.convertJsDateToSasDate using the real
|
|
||||||
* epoch math, so date/datetime assertions use the actual SAS internal values
|
|
||||||
* without pulling in HelperService's dependency graph (SasService etc.).
|
|
||||||
*/
|
|
||||||
class HelperServiceStub {
|
|
||||||
convertJsDateToSasDate(jsDate: Date, unit = 'days'): number {
|
|
||||||
const ms = Date.UTC(
|
|
||||||
jsDate.getFullYear(),
|
|
||||||
jsDate.getMonth(),
|
|
||||||
jsDate.getDate(),
|
|
||||||
jsDate.getHours(),
|
|
||||||
jsDate.getMinutes(),
|
|
||||||
jsDate.getSeconds()
|
|
||||||
)
|
|
||||||
const sasMs = ms + 315619200000
|
|
||||||
return unit === 'days'
|
|
||||||
? Math.floor(Math.abs(sasMs / 86400000))
|
|
||||||
: sasMs / 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function msg(parameters: any[]): VaMessage {
|
function msg(parameters: any[]): VaMessage {
|
||||||
return {
|
return {
|
||||||
@@ -35,6 +15,7 @@ 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.
|
||||||
@@ -63,7 +44,8 @@ describe('VaFilterService', () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new VaFilterService(new HelperServiceStub() as any)
|
helperService = new HelperService({} as any)
|
||||||
|
service = new VaFilterService(helperService)
|
||||||
})
|
})
|
||||||
|
|
||||||
const labelToVar = () =>
|
const labelToVar = () =>
|
||||||
@@ -109,28 +91,30 @@ describe('VaFilterService', () => {
|
|||||||
expect(build([{ label: 'SOME_NUM', value: 'abc' }])).toEqual([])
|
expect(build([{ label: 'SOME_NUM', value: 'abc' }])).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('converts temporal values to SAS internal numbers', () => {
|
it('renders temporal values as SAS literals', () => {
|
||||||
expect(
|
expect(
|
||||||
build([{ label: 'SOME_TIME', value: '0:00:02' }])[0].RAW_VALUE
|
build([{ label: 'SOME_TIME', value: '0:00:02' }])[0].RAW_VALUE
|
||||||
).toBe('2')
|
).toBe("'00:00:02't")
|
||||||
expect(
|
expect(
|
||||||
build([{ label: 'SOME_DATE', value: '01JUL1960' }])[0].RAW_VALUE
|
build([{ label: 'SOME_DATE', value: '01JUL1960' }])[0].RAW_VALUE
|
||||||
).toBe('182')
|
).toBe("'01JUL1960'd")
|
||||||
expect(
|
expect(
|
||||||
build([{ label: 'SOME_DATETIME', value: '01JAN1960:00:06:05' }])[0]
|
build([{ label: 'SOME_DATETIME', value: '01JAN1960:00:06:05' }])[0]
|
||||||
.RAW_VALUE
|
.RAW_VALUE
|
||||||
).toBe('365')
|
).toBe("'01JAN1960:00:06:05'dt")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('passes an already-internal (bare number) temporal value through raw', () => {
|
it('renders an already-internal (bare number) temporal value as a literal', () => {
|
||||||
// VA sends the raw SAS internal value when the data item has no format.
|
// VA sends the raw SAS internal value when the data item has no format.
|
||||||
expect(build([{ label: 'SOME_DATE', value: '182' }])[0].RAW_VALUE).toBe(
|
expect(build([{ label: 'SOME_DATE', value: '182' }])[0].RAW_VALUE).toBe(
|
||||||
'182'
|
"'01JUL1960'd"
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
build([{ label: 'SOME_DATETIME', value: '365' }])[0].RAW_VALUE
|
build([{ label: 'SOME_DATETIME', value: '365' }])[0].RAW_VALUE
|
||||||
).toBe('365')
|
).toBe("'01JAN1960:00:06:05'dt")
|
||||||
expect(build([{ label: 'SOME_TIME', value: '2' }])[0].RAW_VALUE).toBe('2')
|
expect(build([{ label: 'SOME_TIME', value: '2' }])[0].RAW_VALUE).toBe(
|
||||||
|
"'00:00:02't"
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('parses ISO date shapes identically to DATE9 (VA sends no format)', () => {
|
it('parses ISO date shapes identically to DATE9 (VA sends no format)', () => {
|
||||||
@@ -147,6 +131,41 @@ 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
|
||||||
@@ -218,7 +237,694 @@ 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([
|
||||||
@@ -232,9 +938,9 @@ describe('VaFilterService', () => {
|
|||||||
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
|
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
|
||||||
['SOME_CHAR', "'x'"],
|
['SOME_CHAR', "'x'"],
|
||||||
['SOME_NUM', '10'],
|
['SOME_NUM', '10'],
|
||||||
['SOME_TIME', '2'],
|
['SOME_TIME', "'00:00:02't"],
|
||||||
['SOME_DATE', '182'],
|
['SOME_DATE', "'01JUL1960'd"],
|
||||||
['SOME_DATETIME', '365']
|
['SOME_DATETIME', "'01JAN1960:00:06:05'dt"]
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -247,9 +953,9 @@ describe('VaFilterService', () => {
|
|||||||
])
|
])
|
||||||
)
|
)
|
||||||
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
|
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
|
||||||
['SOME_DATE', '182'],
|
['SOME_DATE', "'01JUL1960'd"],
|
||||||
['SOME_DATE2', '182'],
|
['SOME_DATE2', "'01JUL1960'd"],
|
||||||
['SOME_DTTM', '365']
|
['SOME_DTTM', "'01JAN1960:00:06:05'dt"]
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ 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'
|
||||||
@@ -54,11 +60,19 @@ 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 || []) {
|
||||||
@@ -67,60 +81,194 @@ export class VaFilterService {
|
|||||||
.toString()
|
.toString()
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
// A VA param whose label isn't a DC column (beyond case, which is handled)
|
// An explicit URL mapping wins over the label->column lookup. The mapped
|
||||||
// simply doesn't map — skip it.
|
// column has to exist in THIS table, else the entry does not apply here
|
||||||
const varName = labelToVar.get(key)
|
// (and the same-name fallback would be the behaviour it exists to
|
||||||
|
// override).
|
||||||
|
const mapped = this.mappedColumn(param, paramMap)
|
||||||
|
const mappedCol = mapped ? this.findCol(mapped, cols) : undefined
|
||||||
|
if (mapped && !mappedCol) continue
|
||||||
|
// The table's own case, so the clause matches what the metadata build
|
||||||
|
// produces for a same-name match.
|
||||||
|
const varName: string = mappedCol
|
||||||
|
? mappedCol.NAME
|
||||||
|
: (labelToVar.get(key) ?? '')
|
||||||
if (!varName) continue
|
if (!varName) continue
|
||||||
|
|
||||||
const col = this.findCol(varName, cols)
|
const col = mappedCol ?? 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 rawValues = Array.isArray(param.value) ? param.value : [param.value]
|
const built = this.buildClauseValueSet(
|
||||||
// Format/validate every value for the column's SAS type. Numeric columns
|
param,
|
||||||
// must receive a finite number (untrusted postMessage input is
|
kind,
|
||||||
// interpolated into a SAS WHERE clause, so a non-numeric value here would
|
order,
|
||||||
// be an injection vector); temporal -> SAS internal number; char ->
|
this.mappedTarget(param, paramMap)?.transform
|
||||||
// escaped. Invalid values are dropped.
|
)
|
||||||
const formatted = rawValues
|
if (!built) continue
|
||||||
.filter((v) => v !== undefined && v !== null && v !== '')
|
clauses.push({
|
||||||
.map((v) => this.formatValueByKind(v, kind, order))
|
GROUP_LOGIC: 'AND',
|
||||||
.filter((v): v is string => v !== null)
|
SUBGROUP_LOGIC: 'AND',
|
||||||
if (formatted.length === 0) continue
|
SUBGROUP_ID: 0,
|
||||||
clauses.push(this.clause(varName, formatted))
|
VARIABLE_NM: varName,
|
||||||
|
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(msg: VaMessage): QueryClause[] {
|
buildInitialClauses(
|
||||||
|
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
|
||||||
// The DC column name is the VA `label`; `name` is VA's internal id (pr###).
|
const target = this.mappedTarget(param, paramMap)
|
||||||
const varName = (param.label ?? param.name ?? '').toString().trim()
|
// Without a mapping the DC column name is the VA `label`; `name` is VA's
|
||||||
|
// internal id (pr###). A `va_parmap` entry wins over both.
|
||||||
|
const varName = (target?.column ?? param.label ?? param.name ?? '')
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
if (!varName) continue
|
if (!varName) continue
|
||||||
|
|
||||||
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
|
const rawValues = (
|
||||||
|
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
|
||||||
|
|
||||||
const formatted = nonEmpty
|
// A transform only ever produces dates, so it fixes the kind: inferring it
|
||||||
.map((v) =>
|
// from the value would see a formatted date either way, but with the
|
||||||
this.formatValueByKind(
|
// transform the column is known to be temporal.
|
||||||
v,
|
const kind = target?.transform
|
||||||
this.inferKindFromValue(String(v).trim(), param.dataType)
|
? ('date' as VaColumnKind)
|
||||||
)
|
: this.inferKindFromValue(String(nonEmpty[0]).trim(), param.dataType)
|
||||||
)
|
const built = this.buildClauseValueSet(
|
||||||
.filter((v): v is string => v !== null)
|
param,
|
||||||
if (formatted.length === 0) continue
|
kind,
|
||||||
clauses.push(this.clause(varName, formatted))
|
undefined,
|
||||||
|
target?.transform
|
||||||
|
)
|
||||||
|
if (!built) continue
|
||||||
|
clauses.push({
|
||||||
|
GROUP_LOGIC: 'AND',
|
||||||
|
SUBGROUP_LOGIC: 'AND',
|
||||||
|
SUBGROUP_ID: 0,
|
||||||
|
VARIABLE_NM: varName,
|
||||||
|
OPERATOR_NM: built.operator,
|
||||||
|
RAW_VALUE: built.rawValue
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return clauses
|
return clauses
|
||||||
}
|
}
|
||||||
@@ -141,16 +289,254 @@ export class VaFilterService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private clause(varName: string, formatted: string[]): QueryClause {
|
/**
|
||||||
return {
|
* Transform mappings in this message that cannot be applied, one plain-English
|
||||||
GROUP_LOGIC: 'AND',
|
* line each: the column is not temporal, or the value is not a date the browser
|
||||||
SUBGROUP_LOGIC: 'AND',
|
* can parse. `buildClauseValueSet` skips those parameters, so this is what stops
|
||||||
SUBGROUP_ID: 0,
|
* the misconfiguration reading as a filter that simply matched nothing.
|
||||||
VARIABLE_NM: varName,
|
*
|
||||||
OPERATOR_NM: formatted.length === 1 ? '=' : 'IN',
|
* A missing column is left to `unresolvedMappings`, which already reports it.
|
||||||
RAW_VALUE:
|
*/
|
||||||
formatted.length === 1 ? formatted[0] : `(${formatted.join(',')})`
|
unsupportedTransforms(
|
||||||
|
msg: VaMessage,
|
||||||
|
paramMap: Map<string, VaParmapTarget> | undefined,
|
||||||
|
cols: any[]
|
||||||
|
): string[] {
|
||||||
|
if (!paramMap || paramMap.size === 0) return []
|
||||||
|
if (!cols || cols.length === 0) return []
|
||||||
|
const problems = new Set<string>()
|
||||||
|
for (const param of msg.parameters || []) {
|
||||||
|
const target = this.mappedTarget(param, paramMap)
|
||||||
|
if (!target?.transform) continue
|
||||||
|
const transform = target.transform
|
||||||
|
const col = this.findCol(target.column, cols)
|
||||||
|
if (!col) continue
|
||||||
|
const kind = this.columnKind(col)
|
||||||
|
const paramName = (param.label ?? param.name ?? '').toString().trim()
|
||||||
|
const spec =
|
||||||
|
target.transform.op === 'ADD'
|
||||||
|
? `ADD(${target.transform.anchor},${target.transform.offset})`
|
||||||
|
: `RANGE(${target.transform.period},${target.transform.offset})`
|
||||||
|
if (kind !== 'date' && kind !== 'datetime') {
|
||||||
|
problems.add(
|
||||||
|
`VA parameter "${paramName}" maps to ${target.column}:${spec}, but ${target.column} is not a date or datetime column - the filter was ignored.`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
|
||||||
|
const sent = (
|
||||||
|
Array.isArray(param.value) ? param.value : [param.value]
|
||||||
|
).filter((v: any) => v !== undefined && v !== null && v !== '')
|
||||||
|
const usable = sent.filter((v: any) => {
|
||||||
|
const date = this.valueAsDate(v, kind, order)
|
||||||
|
if (!date) return false
|
||||||
|
// Same test the clause builder applies, so this count matches what it uses.
|
||||||
|
return applyVaTransform(date, transform, kind === 'datetime').every(
|
||||||
|
(resolved) => {
|
||||||
|
const sasDate = this.helperService.convertJsDateToSasDate(
|
||||||
|
resolved,
|
||||||
|
kind === 'datetime' ? 'seconds' : 'days'
|
||||||
|
)
|
||||||
|
return Number.isFinite(Number(sasDate))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (usable.length === 0) {
|
||||||
|
problems.add(
|
||||||
|
`VA parameter "${paramName}" maps to ${target.column}:${spec}, but its value was not a date - the filter was ignored.`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// One BETWEEN can only express one period. A multi-select would have to
|
||||||
|
// become an OR of ranges, which the clause model does not carry, so say so
|
||||||
|
// rather than building a BETWEEN from whichever value happened to parse.
|
||||||
|
// Counted on what VA sent: a second value that does not parse must not make
|
||||||
|
// the single-value case look clean.
|
||||||
|
if (isRangeTransform(target.transform) && sent.length > 1) {
|
||||||
|
problems.add(
|
||||||
|
`VA parameter "${paramName}" maps to ${target.column}:${spec}, which is a range, but the parameter has ${sent.length} values - a range needs one value, so the filter was ignored.`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (usable.length < sent.length) {
|
||||||
|
problems.add(
|
||||||
|
`VA parameter "${paramName}" maps to ${target.column}:${spec}, but ${sent.length - usable.length} of its ${sent.length} values was not a date - the filter covers only the rest.`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
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). */
|
||||||
@@ -223,15 +609,18 @@ export class VaFilterService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats one VA filter value into a DC RAW_VALUE for the column kind, or null
|
* Formats one VA filter value into a DC RAW_VALUE for the column kind, or null
|
||||||
* when invalid (dropped). Matches DC's native filters, which send the NUMERIC
|
* when invalid (dropped). Matches DC's native filters, which send a SAS
|
||||||
* INTERNAL value (not SAS literals — those error out in the backend):
|
* literal for a temporal column and the raw value otherwise:
|
||||||
* - numeric -> finite number, raw (else null — injection guard)
|
* - numeric -> finite number, raw (else null - injection guard)
|
||||||
* - time -> seconds since midnight (VA: "0:00:02" -> 2)
|
* - time -> seconds since midnight, as a SAS time literal
|
||||||
* - date -> days since 1960-01-01 (VA: "01JUL1960" -> 182)
|
* (VA: "0:00:02" -> '00:00:02't)
|
||||||
* - datetime -> seconds since 1960-01-01 (VA: "01JAN1960:00:06:05" -> 365)
|
* - date -> days since 1960-01-01, as a SAS date literal
|
||||||
|
* (VA: "01JUL1960" -> '01JUL1960'd)
|
||||||
|
* - datetime -> seconds since 1960-01-01, as a SAS datetime literal
|
||||||
|
* (VA: "01JAN1960:00:06:05" -> '01JAN1960:00:06:05'dt)
|
||||||
* - char -> single-quote escaped
|
* - char -> single-quote escaped
|
||||||
* Temporal strings are regex-parsed (digits/letters/colons only), so a value
|
* Temporal strings are regex-parsed (digits/letters/colons only), so a value
|
||||||
* either yields a finite number or is dropped — also the injection guard.
|
* either yields a finite number or is dropped - also the injection guard.
|
||||||
* Date/datetime reuse HelperService.convertJsDateToSasDate so the value is
|
* Date/datetime reuse HelperService.convertJsDateToSasDate so the value is
|
||||||
* byte-identical to what DC's own pickers produce.
|
* byte-identical to what DC's own pickers produce.
|
||||||
*
|
*
|
||||||
@@ -239,7 +628,7 @@ export class VaFilterService {
|
|||||||
* itself (VA sends it unformatted when the data item carries no format / is
|
* itself (VA sends it unformatted when the data item carries no format / is
|
||||||
* used as a measure), so it is passed through raw. This is unambiguous because
|
* used as a measure), so it is passed through raw. This is unambiguous because
|
||||||
* the only date formats in use (DATE9, ISO) always contain a letter or a
|
* the only date formats in use (DATE9, ISO) always contain a letter or a
|
||||||
* separator — a plain integer is never a formatted date.
|
* separator - a plain integer is never a formatted date.
|
||||||
*/
|
*/
|
||||||
private formatValueByKind(
|
private formatValueByKind(
|
||||||
value: any,
|
value: any,
|
||||||
@@ -254,23 +643,32 @@ export class VaFilterService {
|
|||||||
return Number.isFinite(num) ? `${num}` : null
|
return Number.isFinite(num) ? `${num}` : null
|
||||||
}
|
}
|
||||||
case 'time': {
|
case 'time': {
|
||||||
if (this.isBareNumber(s)) return `${Number(s)}`
|
const secs = this.isBareNumber(s) ? Number(s) : this.timeToSeconds(s)
|
||||||
const secs = this.timeToSeconds(s)
|
return secs === null
|
||||||
return secs === null ? null : `${secs}`
|
? null
|
||||||
|
: this.helperService.sasTemporalLiteral(secs, 'TIME')
|
||||||
}
|
}
|
||||||
case 'date': {
|
case 'date': {
|
||||||
if (this.isBareNumber(s)) return `${Number(s)}`
|
let days: number | null = null
|
||||||
const d = this.parseDate(s, order)
|
if (this.isBareNumber(s)) days = Number(s)
|
||||||
return d
|
else {
|
||||||
? `${this.helperService.convertJsDateToSasDate(d, 'days')}`
|
const d = this.parseDate(s, order)
|
||||||
: null
|
if (d) days = this.helperService.convertJsDateToSasDate(d, 'days')
|
||||||
|
}
|
||||||
|
return days === null
|
||||||
|
? null
|
||||||
|
: this.helperService.sasTemporalLiteral(days, 'DATE')
|
||||||
}
|
}
|
||||||
case 'datetime': {
|
case 'datetime': {
|
||||||
if (this.isBareNumber(s)) return `${Number(s)}`
|
let secs: number | null = null
|
||||||
const d = this.parseDatetime(s, order)
|
if (this.isBareNumber(s)) secs = Number(s)
|
||||||
return d
|
else {
|
||||||
? `${this.helperService.convertJsDateToSasDate(d, 'seconds')}`
|
const d = this.parseDatetime(s, order)
|
||||||
: null
|
if (d) secs = this.helperService.convertJsDateToSasDate(d, 'seconds')
|
||||||
|
}
|
||||||
|
return secs === null
|
||||||
|
? null
|
||||||
|
: this.helperService.sasTemporalLiteral(secs, 'DATETIME')
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return `'${s.replace(/'/g, "''")}'`
|
return `'${s.replace(/'/g, "''")}'`
|
||||||
@@ -309,14 +707,42 @@ 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 m = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
|
const monthByName = (name: string): number =>
|
||||||
if (!m) return null
|
VaFilterService.SAS_MONTHS.indexOf(
|
||||||
const [, day, monthAbbr, year] = m
|
name.replace(/\./g, '').slice(0, 3).toUpperCase()
|
||||||
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,4 +1,6 @@
|
|||||||
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 = {
|
||||||
@@ -51,6 +53,57 @@ 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,6 +82,26 @@ 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
|
||||||
|
|
||||||
@@ -187,6 +207,22 @@ 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,
|
||||||
@@ -194,7 +230,7 @@ export class VaMessagingService {
|
|||||||
availableRowCount: data.availableRowCount,
|
availableRowCount: data.availableRowCount,
|
||||||
data: data.data,
|
data: data.data,
|
||||||
columns: data.columns,
|
columns: data.columns,
|
||||||
parameters: Array.isArray(data.parameters) ? data.parameters : []
|
parameters
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,16 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
@if (showServiceLauncher(data.sasService)) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
(click)="openServiceInNewWindow()"
|
||||||
|
>
|
||||||
|
Show log
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
@if (showConfiguratorButton(data.sasService)) {
|
@if (showConfiguratorButton(data.sasService)) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { InfoModalComponent } from './info-modal.component'
|
||||||
|
|
||||||
|
describe('InfoModalComponent', () => {
|
||||||
|
let component: InfoModalComponent
|
||||||
|
let sasService: any
|
||||||
|
|
||||||
|
const buildComponent = (sasServiceConfig: any = {}) => {
|
||||||
|
sasService = {
|
||||||
|
getSasjsConfig: () => sasServiceConfig,
|
||||||
|
openServiceInNewWindow: jasmine.createSpy('openServiceInNewWindow')
|
||||||
|
}
|
||||||
|
|
||||||
|
component = new InfoModalComponent(
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
sasService,
|
||||||
|
{} as any
|
||||||
|
)
|
||||||
|
component.data = {
|
||||||
|
modalTitle: 'Error',
|
||||||
|
message: 'startupservice failed',
|
||||||
|
sasService: 'public/startupservice',
|
||||||
|
details: null
|
||||||
|
}
|
||||||
|
component.ngOnInit()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('showServiceLauncher', () => {
|
||||||
|
beforeEach(() => buildComponent())
|
||||||
|
|
||||||
|
it('is shown for the startupservice, on every server type', () => {
|
||||||
|
expect(component.showServiceLauncher('public/startupservice')).toBeTrue()
|
||||||
|
expect(
|
||||||
|
component.showServiceLauncher('services/public/startupservice')
|
||||||
|
).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not shown for other services or a missing service', () => {
|
||||||
|
expect(component.showServiceLauncher('public/getdata')).toBeFalse()
|
||||||
|
expect(component.showServiceLauncher('')).toBeFalse()
|
||||||
|
expect(component.showServiceLauncher(null)).toBeFalse()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('openServiceInNewWindow', () => {
|
||||||
|
it('runs the startupservice in a new window', () => {
|
||||||
|
buildComponent({ serverType: 'SASVIYA' })
|
||||||
|
|
||||||
|
component.openServiceInNewWindow()
|
||||||
|
|
||||||
|
expect(sasService.openServiceInNewWindow).toHaveBeenCalledWith(
|
||||||
|
'public/startupservice',
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes the configured Viya context so the log comes from it', () => {
|
||||||
|
buildComponent({
|
||||||
|
serverType: 'SASVIYA',
|
||||||
|
contextName: 'SAS Job Execution compute context'
|
||||||
|
})
|
||||||
|
|
||||||
|
component.openServiceInNewWindow()
|
||||||
|
|
||||||
|
expect(sasService.openServiceInNewWindow).toHaveBeenCalledWith(
|
||||||
|
'public/startupservice',
|
||||||
|
{ _contextname: 'SAS Job Execution compute context' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -71,6 +71,34 @@ export class InfoModalComponent implements OnInit {
|
|||||||
this.onConfirmModalClick.emit()
|
this.onConfirmModalClick.emit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the `Show log` button.
|
||||||
|
*
|
||||||
|
* The startupservice failure modal is a dead end otherwise: the adapter
|
||||||
|
* returned no log, so the only way to see the backend reason is to run the
|
||||||
|
* service through the JES / STP web app in a browser tab. Offered on every
|
||||||
|
* server type - SAS 9, Viya and SASjs Server all accept the `_program` URL.
|
||||||
|
*/
|
||||||
|
showServiceLauncher(sasService: string | null) {
|
||||||
|
return !!sasService?.includes('startupservice')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the startupservice in a new browser window so the SAS log is
|
||||||
|
* available even when the adapter response carried none (e.g. a Viya
|
||||||
|
* execution task that timed out).
|
||||||
|
*/
|
||||||
|
openServiceInNewWindow() {
|
||||||
|
const sasjsConfig = this.sasService.getSasjsConfig()
|
||||||
|
const params: { [name: string]: string } = {}
|
||||||
|
|
||||||
|
if (sasjsConfig.contextName) {
|
||||||
|
params['_contextname'] = sasjsConfig.contextName
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sasService.openServiceInNewWindow(this.data.sasService || '', params)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only on SAS9, opening a backend configurator/deploy page
|
* Only on SAS9, opening a backend configurator/deploy page
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -61,13 +61,13 @@ export class DcValidator {
|
|||||||
// injected separately — see its own doc comment for why.
|
// injected separately — see its own doc comment for why.
|
||||||
//
|
//
|
||||||
// COLHEADERS is the authoritative grid order and the only safe key to
|
// COLHEADERS is the authoritative grid order and the only safe key to
|
||||||
// build against: it is the variable list of the *output* dataset, where
|
// build against: it is the variable list of the *output* dataset, and the
|
||||||
// getdata.sas hoists the delete column and the PK to the front ("order
|
// editor prepends the client-owned delete column to it (see
|
||||||
// delete var and pk fields at start of table"), whereas cols[].VARNUM
|
// editor.component.ts's initSetup) before this constructor runs, whereas
|
||||||
// describes the *source* table and cols[] itself can arrive sorted by
|
// cols[].VARNUM describes the *source* table and cols[] itself can arrive
|
||||||
// NAME. Those orders diverge whenever the PK isn't already the source's
|
// sorted by NAME. Those orders diverge whenever the PK isn't already the
|
||||||
// first column, so rules are keyed by name and emitted in COLHEADERS
|
// source's first column, so rules are keyed by name and emitted in
|
||||||
// order — never paired positionally against cols[] or VARNUM.
|
// COLHEADERS order — never paired positionally against cols[] or VARNUM.
|
||||||
//
|
//
|
||||||
// A column with no parseable COLTYPE falls back to a plain text rule
|
// A column with no parseable COLTYPE falls back to a plain text rule
|
||||||
// rather than being dropped: getRules() is consumed positionally against
|
// rather than being dropped: getRules() is consumed positionally against
|
||||||
|
|||||||
@@ -553,11 +553,27 @@ describe('DC Validator', () => {
|
|||||||
example_dqData
|
example_dqData
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// HOT 18.1 tightened `ColumnSettings['validator']` to the real
|
||||||
|
// `string | RegExp | function` union (18.0 erased it behind an index
|
||||||
|
// signature), so narrow it the way the production code does before
|
||||||
|
// invoking it.
|
||||||
|
const validatorOf = (rule: ReturnType<DcValidator['getRule']>) => {
|
||||||
|
const validator = rule?.validator
|
||||||
|
if (typeof validator !== 'function') {
|
||||||
|
throw new Error('expected the rule to carry a function validator')
|
||||||
|
}
|
||||||
|
return validator as (
|
||||||
|
this: any,
|
||||||
|
value: any,
|
||||||
|
callback: (valid: boolean) => void
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
it('exempts an invalid non-PK cell on a delete-marked row', () => {
|
it('exempts an invalid non-PK cell on a delete-marked row', () => {
|
||||||
const dcValidator = buildValidator()
|
const dcValidator = buildValidator()
|
||||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||||
|
|
||||||
someNumRule!.validator!.call(
|
validatorOf(someNumRule).call(
|
||||||
fakeCellProps(true),
|
fakeCellProps(true),
|
||||||
'not a number',
|
'not a number',
|
||||||
(valid: boolean) => {
|
(valid: boolean) => {
|
||||||
@@ -570,7 +586,7 @@ describe('DC Validator', () => {
|
|||||||
const dcValidator = buildValidator()
|
const dcValidator = buildValidator()
|
||||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||||
|
|
||||||
someNumRule!.validator!.call(
|
validatorOf(someNumRule).call(
|
||||||
fakeCellProps(true),
|
fakeCellProps(true),
|
||||||
null,
|
null,
|
||||||
(valid: boolean) => {
|
(valid: boolean) => {
|
||||||
@@ -583,7 +599,7 @@ describe('DC Validator', () => {
|
|||||||
const dcValidator = buildValidator()
|
const dcValidator = buildValidator()
|
||||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||||
|
|
||||||
someNumRule!.validator!.call(
|
validatorOf(someNumRule).call(
|
||||||
fakeCellProps(false),
|
fakeCellProps(false),
|
||||||
'not a number',
|
'not a number',
|
||||||
(valid: boolean) => {
|
(valid: boolean) => {
|
||||||
@@ -596,7 +612,7 @@ describe('DC Validator', () => {
|
|||||||
const dcValidator = buildValidator()
|
const dcValidator = buildValidator()
|
||||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||||
|
|
||||||
someNumRule!.validator!.call(
|
validatorOf(someNumRule).call(
|
||||||
fakeCellProps(false),
|
fakeCellProps(false),
|
||||||
2,
|
2,
|
||||||
(valid: boolean) => {
|
(valid: boolean) => {
|
||||||
@@ -609,7 +625,7 @@ describe('DC Validator', () => {
|
|||||||
const dcValidator = buildValidator()
|
const dcValidator = buildValidator()
|
||||||
const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')
|
const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')
|
||||||
|
|
||||||
pkRule!.validator!.call(
|
validatorOf(pkRule).call(
|
||||||
fakeCellProps(true),
|
fakeCellProps(true),
|
||||||
'not a number',
|
'not a number',
|
||||||
(valid: boolean) => {
|
(valid: boolean) => {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
prepareDeleteRecordColumn,
|
||||||
|
seedDeleteRecordColumn
|
||||||
|
} from './deleteRecordColumn'
|
||||||
|
import { DELETE_RECORD_COLUMN_NAME } from './deleteRecordColumnRule'
|
||||||
|
|
||||||
|
const response = (overrides: any = {}) => ({
|
||||||
|
data: {
|
||||||
|
sasparams: [{ COLHEADERS: 'PRIMARY_KEY_FIELD,SOME_CHAR' }],
|
||||||
|
cols: [],
|
||||||
|
$sasdata: { vars: {} },
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('prepareDeleteRecordColumn', () => {
|
||||||
|
it('prepends the delete column to COLHEADERS', () => {
|
||||||
|
const res = response()
|
||||||
|
|
||||||
|
prepareDeleteRecordColumn(res)
|
||||||
|
|
||||||
|
expect(res.data.sasparams[0].COLHEADERS).toEqual(
|
||||||
|
`${DELETE_RECORD_COLUMN_NAME},PRIMARY_KEY_FIELD,SOME_CHAR`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when COLHEADERS already carries the delete column', () => {
|
||||||
|
const res = response({
|
||||||
|
sasparams: [{ COLHEADERS: `${DELETE_RECORD_COLUMN_NAME},SOME_CHAR` }]
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => prepareDeleteRecordColumn(res)).toThrowError(/reserved column/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when cols carries the delete column', () => {
|
||||||
|
const res = response({ cols: [{ NAME: DELETE_RECORD_COLUMN_NAME }] })
|
||||||
|
|
||||||
|
expect(() => prepareDeleteRecordColumn(res)).toThrowError(/reserved column/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when $sasdata vars carries the delete column', () => {
|
||||||
|
const res = response({
|
||||||
|
$sasdata: { vars: { [DELETE_RECORD_COLUMN_NAME]: {} } }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => prepareDeleteRecordColumn(res)).toThrowError(/reserved column/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves COLHEADERS untouched when it throws', () => {
|
||||||
|
const colHeaders = `${DELETE_RECORD_COLUMN_NAME},SOME_CHAR`
|
||||||
|
const res = response({ sasparams: [{ COLHEADERS: colHeaders }] })
|
||||||
|
|
||||||
|
expect(() => prepareDeleteRecordColumn(res)).toThrowError()
|
||||||
|
expect(res.data.sasparams[0].COLHEADERS).toEqual(colHeaders)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('seedDeleteRecordColumn', () => {
|
||||||
|
it("sets every row's delete flag to 'No'", () => {
|
||||||
|
const rows: any[] = [{ PRIMARY_KEY_FIELD: 1 }, { PRIMARY_KEY_FIELD: 2 }]
|
||||||
|
|
||||||
|
seedDeleteRecordColumn(rows)
|
||||||
|
|
||||||
|
expect(rows[0][DELETE_RECORD_COLUMN_NAME]).toEqual('No')
|
||||||
|
expect(rows[1][DELETE_RECORD_COLUMN_NAME]).toEqual('No')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overwrites a flag a row already carried', () => {
|
||||||
|
const rows: any[] = [{ [DELETE_RECORD_COLUMN_NAME]: 'Yes' }]
|
||||||
|
|
||||||
|
seedDeleteRecordColumn(rows)
|
||||||
|
|
||||||
|
expect(rows[0][DELETE_RECORD_COLUMN_NAME]).toEqual('No')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Col } from '../models/col.model'
|
||||||
|
import { DELETE_RECORD_COLUMN_NAME } from './deleteRecordColumnRule'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parts of a getdata response this module touches. Declared structurally
|
||||||
|
* so the editor and the multi-dataset loader can both use it without either
|
||||||
|
* owning the other's response type.
|
||||||
|
*/
|
||||||
|
interface DeleteColumnResponse {
|
||||||
|
data: {
|
||||||
|
sasparams: { COLHEADERS: string }[]
|
||||||
|
cols?: Col[]
|
||||||
|
$sasdata?: { vars?: Record<string, unknown> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that getdata did NOT send the client-owned delete column, then
|
||||||
|
* prepends it to COLHEADERS so the grid's first column is the delete flag.
|
||||||
|
*
|
||||||
|
* `_____DELETE__THIS__RECORD_____` is created by the editor itself (see
|
||||||
|
* deleteRecordColumnRule.ts) and getdata must never emit it. Its presence in a
|
||||||
|
* response can only mean a real table column of that name, or a hook that
|
||||||
|
* re-added it - either way it would shadow the delete flag, so refuse the load
|
||||||
|
* rather than render a table whose delete column cannot be trusted. Callers
|
||||||
|
* run this inside a promise chain whose `.catch` surfaces the error (the
|
||||||
|
* editor sets `getdataError`).
|
||||||
|
*
|
||||||
|
* Call it BEFORE constructing a DcValidator or deriving headerArray: rules,
|
||||||
|
* `columnHeader` and `headerArray` are all keyed off COLHEADERS order, and the
|
||||||
|
* delete column is always index 0.
|
||||||
|
*/
|
||||||
|
export const prepareDeleteRecordColumn = (response: DeleteColumnResponse) => {
|
||||||
|
const sasparams = response.data.sasparams[0]
|
||||||
|
|
||||||
|
const sentByBackend =
|
||||||
|
sasparams.COLHEADERS.split(',').includes(DELETE_RECORD_COLUMN_NAME) ||
|
||||||
|
(response.data.cols || []).some(
|
||||||
|
(col) => col.NAME === DELETE_RECORD_COLUMN_NAME
|
||||||
|
) ||
|
||||||
|
Object.prototype.hasOwnProperty.call(
|
||||||
|
response.data.$sasdata?.vars || {},
|
||||||
|
DELETE_RECORD_COLUMN_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
if (sentByBackend) {
|
||||||
|
throw new Error(
|
||||||
|
`getdata returned the reserved column ${DELETE_RECORD_COLUMN_NAME}. ` +
|
||||||
|
'That name is created by the editor itself and must not exist in the target table.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sasparams.COLHEADERS = `${DELETE_RECORD_COLUMN_NAME},${sasparams.COLHEADERS}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds every row's delete flag to 'No' - getdata no longer sends the column,
|
||||||
|
* so the editor owns the value too (see deleteRecordColumnRule.ts).
|
||||||
|
*/
|
||||||
|
export const seedDeleteRecordColumn = (rows: any[]) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
row[DELETE_RECORD_COLUMN_NAME] = 'No'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
import { DcValidation } from '../models/dc-validation.model'
|
import { DcValidation } from '../models/dc-validation.model'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `_____DELETE__THIS__RECORD_____` is a client-side UI concept (the delete
|
* `_____DELETE__THIS__RECORD_____` is a purely client-owned column - the
|
||||||
* checkbox column) that getdata.sas synthesizes into vars3/vars4 for display
|
* delete checkbox column. getdata.sas does NOT emit it: the editor adds the
|
||||||
* purposes only — it's never a real column of the underlying dataset, so
|
* name to COLHEADERS itself (see editor.component.ts's initSetup) and seeds
|
||||||
* `%mp_getcols` (which builds `cols1`, the left side of the join into
|
* every row to 'No'. A response that carries it is refused, because the name
|
||||||
* `cols`) never has a row for it, and its rule can no longer travel via
|
* can then only come from a real table column of that name (or a hook that
|
||||||
* `cols[].COLTYPE` (see issue #253's vertical-array fix). The rule is fixed
|
* re-added it), which would silently shadow the delete flag.
|
||||||
* and data-independent, so it's injected directly here instead.
|
*
|
||||||
|
* Its rule can never travel via `cols[].COLTYPE` - `%mp_getcols` (which
|
||||||
|
* builds `cols1`, the left side of the join into `cols`) has no row for it -
|
||||||
|
* so it is injected directly, here and in DcValidator's constructor.
|
||||||
*/
|
*/
|
||||||
|
export const DELETE_RECORD_COLUMN_NAME = '_____DELETE__THIS__RECORD_____'
|
||||||
|
|
||||||
export const DELETE_RECORD_COLUMN_RULE: DcValidation = {
|
export const DELETE_RECORD_COLUMN_RULE: DcValidation = {
|
||||||
data: '_____DELETE__THIS__RECORD_____',
|
data: DELETE_RECORD_COLUMN_NAME,
|
||||||
type: 'dropdown',
|
type: 'dropdown',
|
||||||
source: ['No', 'Yes']
|
source: ['No', 'Yes']
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { buildServiceUrl } from './service-url'
|
||||||
|
|
||||||
|
describe('buildServiceUrl', () => {
|
||||||
|
const viyaConfig = {
|
||||||
|
serverUrl: 'https://sas.example.com',
|
||||||
|
appLoc: '/Public/app/dc',
|
||||||
|
executionPath: '/SASJobExecution',
|
||||||
|
service: 'public/startupservice'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('builds the Viya JES URL for a service, with the log enabled', () => {
|
||||||
|
expect(buildServiceUrl(viyaConfig)).toBe(
|
||||||
|
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves serverUrl empty on SAS 9 so the browser resolves the origin', () => {
|
||||||
|
expect(
|
||||||
|
buildServiceUrl({
|
||||||
|
...viyaConfig,
|
||||||
|
serverUrl: '',
|
||||||
|
executionPath: '/SASStoredProcess/do'
|
||||||
|
})
|
||||||
|
).toBe(
|
||||||
|
'/SASStoredProcess/do/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds the SASjs Server URL from its execution path', () => {
|
||||||
|
expect(
|
||||||
|
buildServiceUrl({
|
||||||
|
...viyaConfig,
|
||||||
|
executionPath: '/SASjsApi/stp/execute'
|
||||||
|
})
|
||||||
|
).toBe(
|
||||||
|
'https://sas.example.com/SASjsApi/stp/execute/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends encoded parameters before the debug flag', () => {
|
||||||
|
expect(
|
||||||
|
buildServiceUrl({
|
||||||
|
...viyaConfig,
|
||||||
|
params: { _contextname: 'SAS Job Execution compute context' }
|
||||||
|
})
|
||||||
|
).toBe(
|
||||||
|
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_contextname=SAS%20Job%20Execution%20compute%20context&_debug=131'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips parameters with no value', () => {
|
||||||
|
expect(
|
||||||
|
buildServiceUrl({
|
||||||
|
...viyaConfig,
|
||||||
|
params: { _contextname: undefined, admin: null, dcpath: 'sas' }
|
||||||
|
})
|
||||||
|
).toBe(
|
||||||
|
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&dcpath=sas&_debug=131'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits the debug flag when debug is off', () => {
|
||||||
|
expect(buildServiceUrl({ ...viyaConfig, debug: false })).toBe(
|
||||||
|
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a service with a leading slash', () => {
|
||||||
|
expect(
|
||||||
|
buildServiceUrl({ ...viyaConfig, service: '/public/startupservice' })
|
||||||
|
).toBe(
|
||||||
|
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts the services/ prefix the request wrapper reports', () => {
|
||||||
|
expect(
|
||||||
|
buildServiceUrl({
|
||||||
|
...viyaConfig,
|
||||||
|
service: 'services/public/startupservice'
|
||||||
|
})
|
||||||
|
).toBe(
|
||||||
|
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Builds the URL that runs a SAS service directly in a browser window.
|
||||||
|
*
|
||||||
|
* The adapter submits requests from JavaScript, and on Viya that can route the
|
||||||
|
* run through the Compute service (`_executionTasks=true`), where the SAS log
|
||||||
|
* is not returned to the caller and the request can time out before the
|
||||||
|
* service finishes. A browser request to the JES / STP web app runs the same
|
||||||
|
* `_program` without any task involved, and `_debug=131` returns the SAS log
|
||||||
|
* as the page body - which is what makes it useful when a service fails.
|
||||||
|
*
|
||||||
|
* `serverUrl` is left empty on SAS 9 when the app is served from the SAS
|
||||||
|
* server itself; the browser then resolves the path against the current
|
||||||
|
* origin.
|
||||||
|
*/
|
||||||
|
export interface ServiceUrlOptions {
|
||||||
|
serverUrl: string
|
||||||
|
appLoc: string
|
||||||
|
executionPath: string
|
||||||
|
service: string
|
||||||
|
params?: { [name: string]: string | number | null | undefined }
|
||||||
|
debug?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildServiceUrl = ({
|
||||||
|
serverUrl,
|
||||||
|
appLoc,
|
||||||
|
executionPath,
|
||||||
|
service,
|
||||||
|
params = {},
|
||||||
|
debug = true
|
||||||
|
}: ServiceUrlOptions): string => {
|
||||||
|
// Callers pass the service as the app knows it: `public/getdata` in the
|
||||||
|
// request path, but the request wrapper reports `services/public/getdata`
|
||||||
|
// on a failed request. Both resolve to the same program.
|
||||||
|
const servicePath = service.replace(/^\/+/, '').replace(/^services\//, '')
|
||||||
|
const paramString = Object.entries(params)
|
||||||
|
.filter(([, value]) => value !== null && value !== undefined)
|
||||||
|
.map(
|
||||||
|
([name, value]) =>
|
||||||
|
`&${encodeURIComponent(name)}=${encodeURIComponent(String(value))}`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
const debugParam = debug ? '&_debug=131' : ''
|
||||||
|
|
||||||
|
return `${serverUrl}${executionPath}/?_program=${appLoc}/services/${servicePath}${paramString}${debugParam}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* 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]
|
||||||
|
}
|
||||||
@@ -1404,10 +1404,17 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.filter = true
|
this.filter = true
|
||||||
this.cdf.detectChanges()
|
this.cdf.detectChanges()
|
||||||
|
|
||||||
this.sasStoreService.setQueryVariables(
|
// Table columns when we have them (the normal, cheapest source); when the
|
||||||
this.filterLibds,
|
// view was aborted by the row limit there is no loaded table to read them
|
||||||
viewboxTable.hotTable.cols
|
// from, so the store falls back to public/getcols.
|
||||||
)
|
const cols =
|
||||||
|
viewboxTable && viewboxTable.hotTable ? viewboxTable.hotTable.cols : []
|
||||||
|
|
||||||
|
this.sasStoreService
|
||||||
|
.setQueryVariablesFromTable(this.filterLibds, cols)
|
||||||
|
.catch((err: any) =>
|
||||||
|
this.eventService.catchResponseError('public/getcols', err)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -286,6 +286,12 @@
|
|||||||
UPDATE
|
UPDATE
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="admin-action">
|
||||||
|
Export DC Library DDL
|
||||||
|
<button (click)="openDdlExport()" class="btn btn-info btn-sm">
|
||||||
|
EXPORT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,3 +312,40 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</clr-modal>
|
</clr-modal>
|
||||||
|
|
||||||
|
<clr-modal [(clrModalOpen)]="ddlModal">
|
||||||
|
<h3 class="modal-title">Export DC Library DDL</h3>
|
||||||
|
<div class="modal-body">
|
||||||
|
<clr-select-container>
|
||||||
|
<label>Flavour</label>
|
||||||
|
<select [(ngModel)]="ddlFlavour" clrSelect>
|
||||||
|
<option value="SAS">SAS</option>
|
||||||
|
<option value="PGSQL">PGSQL</option>
|
||||||
|
<option value="TSQL">TSQL</option>
|
||||||
|
</select>
|
||||||
|
</clr-select-container>
|
||||||
|
@if (ddlFlavour !== 'SAS') {
|
||||||
|
<clr-input-container>
|
||||||
|
<label>Schema (optional)</label>
|
||||||
|
<input
|
||||||
|
clrInput
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="ddlSchema"
|
||||||
|
placeholder="e.g. DC"
|
||||||
|
/>
|
||||||
|
</clr-input-container>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline"
|
||||||
|
(click)="ddlModal = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" (click)="exportDdl()">
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</clr-modal>
|
||||||
@@ -42,6 +42,9 @@ export class SystemComponent implements OnInit {
|
|||||||
Infinity = Infinity
|
Infinity = Infinity
|
||||||
dcLib: string = globals.dcLib
|
dcLib: string = globals.dcLib
|
||||||
targetLibref: string = globals.dcLib
|
targetLibref: string = globals.dcLib
|
||||||
|
ddlModal: boolean = false
|
||||||
|
ddlFlavour: string = 'SAS'
|
||||||
|
ddlSchema: string = ''
|
||||||
|
|
||||||
licenceState = this.licenceService.licenceState
|
licenceState = this.licenceService.licenceState
|
||||||
settings: AppSettings
|
settings: AppSettings
|
||||||
@@ -92,6 +95,32 @@ export class SystemComponent implements OnInit {
|
|||||||
window.open(downUrl)
|
window.open(downUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openDdlExport() {
|
||||||
|
this.ddlFlavour = 'SAS'
|
||||||
|
this.ddlSchema = ''
|
||||||
|
this.ddlModal = true
|
||||||
|
}
|
||||||
|
|
||||||
|
exportDdl() {
|
||||||
|
let sasjsConfig = this.sasService.getSasjsConfig()
|
||||||
|
let storage = sasjsConfig.serverUrl
|
||||||
|
let metaData = sasjsConfig.appLoc
|
||||||
|
let path = this.sasService.getExecutionPath()
|
||||||
|
let downUrl =
|
||||||
|
storage +
|
||||||
|
path +
|
||||||
|
'/?_program=' +
|
||||||
|
metaData +
|
||||||
|
'/services/admin/exportdb&flavour=' +
|
||||||
|
encodeURIComponent(this.ddlFlavour)
|
||||||
|
let schema = this.ddlSchema.trim()
|
||||||
|
if (this.ddlFlavour !== 'SAS' && schema) {
|
||||||
|
downUrl += '&schema=' + encodeURIComponent(schema)
|
||||||
|
}
|
||||||
|
window.open(downUrl)
|
||||||
|
this.ddlModal = false
|
||||||
|
}
|
||||||
|
|
||||||
refreshDataCatalog() {
|
refreshDataCatalog() {
|
||||||
this.refreshingDataCatalog = true
|
this.refreshingDataCatalog = true
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,13 @@
|
|||||||
* debug: Whether to enable debug on startup
|
* debug: Whether to enable debug on startup
|
||||||
|
|
||||||
## Viya only settings ##
|
## Viya only settings ##
|
||||||
* adminGroup: This group will have unrestricted access (Viya Only)
|
|
||||||
* dcPath: The physical location in which to deploy the staging area (Viya only)
|
* dcPath: The physical location in which to deploy the staging area (Viya only)
|
||||||
* useComputeApi: Connection method (Viya only)
|
* useComputeApi: Connection method (Viya only)
|
||||||
* contextName: Compute Context (Viya only)
|
* contextName: Compute Context (Viya only)
|
||||||
|
|
||||||
|
The admin group is not configured here - it is a DC setting (DC_ADMIN_GROUP),
|
||||||
|
shown on the System screen.
|
||||||
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<sasjs
|
<sasjs
|
||||||
@@ -58,8 +60,6 @@
|
|||||||
useComputeApi="null"
|
useComputeApi="null"
|
||||||
runAsTask="true"
|
runAsTask="true"
|
||||||
contextName="SAS Job Execution compute context"
|
contextName="SAS Job Execution compute context"
|
||||||
adminGroup="SASAdministrators"
|
|
||||||
hotLicenceKey="non-commercial-and-evaluation"
|
|
||||||
>
|
>
|
||||||
</sasjs>
|
</sasjs>
|
||||||
|
|
||||||
|
|||||||
+82
-9
@@ -1664,6 +1664,13 @@ app-requests-modal {
|
|||||||
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`
|
||||||
|
width is a fixed 57.6rem, which forced left/right scrolling to read a log
|
||||||
|
line. */
|
||||||
|
.requests-modal .modal-dialog {
|
||||||
|
width: 90vw;
|
||||||
|
}
|
||||||
|
|
||||||
.work-tables-dropdown button {
|
.work-tables-dropdown button {
|
||||||
color: var(--clr-nav-link-color, #8c8c8c) !important;
|
color: var(--clr-nav-link-color, #8c8c8c) !important;
|
||||||
}
|
}
|
||||||
@@ -2289,15 +2296,6 @@ app-alerts {
|
|||||||
}
|
}
|
||||||
// ALERTS.COMPONENT END
|
// ALERTS.COMPONENT END
|
||||||
|
|
||||||
// MANUAL-DEPLOY.COMPONENT
|
|
||||||
app-manual-deploy {
|
|
||||||
.clear-memory-button {
|
|
||||||
right: 10px;
|
|
||||||
top: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// MANUAL-DEPLOY.COMPONENT END
|
|
||||||
|
|
||||||
// LINEAGE.COMPONENT
|
// LINEAGE.COMPONENT
|
||||||
app-lineage {
|
app-lineage {
|
||||||
.toggle-switch input[type='checkbox']:checked + label:before {
|
.toggle-switch input[type='checkbox']:checked + label:before {
|
||||||
@@ -4255,6 +4253,30 @@ body[cds-theme='light'] {
|
|||||||
color: #c21d00;
|
color: #c21d00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The SASjs tag dump drops onto its own line and reads as a name/value grid. */
|
||||||
|
&--grid {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
flex-basis: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 1px 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid-name {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid-value {
|
||||||
|
color: #ccc;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
&__icon--pending {
|
&__icon--pending {
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
@@ -4997,6 +5019,57 @@ body[cds-theme='dark'] {
|
|||||||
border: 1px solid var(--cds-alias-status-warning) !important;
|
border: 1px solid var(--cds-alias-status-warning) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Derived (computed) columns: read-only, but computed at read time rather than
|
||||||
|
locked by policy. Tinted and marked with an 'fx' glyph instead of the
|
||||||
|
read-only dimming, so the two do not read as the same thing. Blue is the
|
||||||
|
info family - the primary key green and the warning ochre keep their meaning. */
|
||||||
|
.handsontable th.dc-derived-header {
|
||||||
|
background: #d3e5f2 !important;
|
||||||
|
box-shadow: inset 0 -2px 0 #1a6ea3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handsontable th.dc-derived-header div::after {
|
||||||
|
content: 'fx';
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handsontable td.dc-derived-cell {
|
||||||
|
background: #eef5fa !important;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the first derived column starts the computed block */
|
||||||
|
.handsontable th.dc-derived-first {
|
||||||
|
border-left: 2px solid #1a6ea3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handsontable td.dc-derived-cell.dc-derived-first {
|
||||||
|
box-shadow: inset 2px 0 0 #1a6ea3;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[cds-theme='dark'] {
|
||||||
|
.handsontable th.dc-derived-header {
|
||||||
|
background: #2b4a5c !important;
|
||||||
|
box-shadow: inset 0 -2px 0 #4b9cc9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handsontable td.dc-derived-cell {
|
||||||
|
background: #24343d !important;
|
||||||
|
color: #dbe7ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handsontable th.dc-derived-first {
|
||||||
|
border-left-color: #4b9cc9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handsontable td.dc-derived-cell.dc-derived-first {
|
||||||
|
box-shadow: inset 2px 0 0 #4b9cc9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.handsontable .numericListbox {
|
.handsontable .numericListbox {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dcfrontend",
|
"name": "dcfrontend",
|
||||||
"version": "7.16.0",
|
"version": "7.21.0",
|
||||||
"description": "Data Controller",
|
"description": "Data Controller",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nogoo9/gitleaks": "8.30.1-post.2",
|
"@nogoo9/gitleaks": "8.30.1-post.2",
|
||||||
|
|||||||
@@ -11,5 +11,6 @@
|
|||||||
"noNestedMacros": true,
|
"noNestedMacros": true,
|
||||||
"hasMacroParentheses": true,
|
"hasMacroParentheses": true,
|
||||||
"strictMacroDefinition": true,
|
"strictMacroDefinition": true,
|
||||||
|
"ignoredMacros": ["webout", "mpeinit", "mpeterm"],
|
||||||
"ignoreList":["sasjs/scratch"]
|
"ignoreList":["sasjs/scratch"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"fromjs": [
|
||||||
|
{
|
||||||
|
"ADMIN": "all-users",
|
||||||
|
"DCPATH": "/tmp/dcdata",
|
||||||
|
"OUTFMT": "JSON",
|
||||||
|
"KEEPME": "Y"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"fromjs": [
|
"fromjs": [
|
||||||
{
|
{
|
||||||
"ADMIN": "AllUsers",
|
"ADMIN": "all-users",
|
||||||
"DCPATH": "/tmp/dcdata",
|
"DCPATH": "/tmp/dcdata",
|
||||||
"OUTFMT": "JSON"
|
"OUTFMT": "JSON"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"licenceKey": "G4H/DNTdEMkmCIxOrYkRYTQ1P4sWg0gs8Zk9rakxFNYDZcLyEPMJJo82YJQ/S24P2E/YPxaWsDZXgOrWKfeCDS7NrLcwsoFNa8KRF2WTlRgSMiG4sjBRX8zNEIvszj5DAq4LEgOUUAdfctNfpUcygbxT1dhqy6gU3hBu/xRuXGd36rhxQwfahaKkfaCg/rFplkrvqnjoJ6622BxWP/HG1dxZUE7HNJyT4tNaXzlC9U6u/aaJJ66+V+9511BwjM9ss4nyHuHoogVE1yrzLMlL94P3QoM1hwkGHnEHmFzg3zHbQG1f3pUs9YzK0ytsFKU7AK948gJrGbf37Zqa+Mz7EZf6XzqqXx0zQ4y9zAhLV9s4QG8JdhrByhfG5kefrs4L/BpgKq3ElgYL5EMkrLJUrlqWYI2ouxJuIGCQz41VCWXJ2S7pd39ljifc8Zj+t2Mn5EEkRVsrbdxjCkyoZOQ1ALofa1+auUI26Rm5rwEgrfhEocDmphJpO2F8lZgwDxbepSE6v/vEV/JQWBQTyrX10W+qOEu8OhvZrYJo+eIJMaO7q1pjfdrn3XhoH+BeB3Sy1sevGLXQZC+CzOtcvajetGGCDAabtTLc49ouWPUiUOAWPE3uYymsMoRZgpLIMxaUM86hoK0r9T6wSaP0bddK754sXN1jWwIDAHSGz5WO1ko=",
|
||||||
|
"activationKey": "MIIJRAIBADANBgkqhkiG9w0BAQEFAASCCS4wggkqAgEAAoICAQD2w8Ge6tMI82vaQ7x5ZdlwnTJycn0UFZoSgRwObaM0NC5wp9Y8lwkVzN8FUhg2oYSTpvyJpix+VGRj0AlNBwFAVcJG9VDPYh2LE3Us9ghRfkzFUiM2PqorHnzL5DwpailRtmFHoDQPGwwSmKncTo2QHql/K6tWe7g3Nv9uqQNks283hvDQ6PVGU4TaFTSRbjGX4j+JAR651J6wI6nY+9kNxOBwnL/yKTnJGmL8oqZ+oC45quZzgRW4dLIeTODcB4IZe2ruxZ9tvXpSuAESiOv5bBZyMJEKdKwsRdt6vk+bezkZBCzPq+ly892Ncq7x5u4/8dGcGxRxxp5TnIIx3hFoiXZ7svXJ7st6uHuKkuwYq+ebWh6bcvQAwH2wCP40twMjQgmU8nHHo6+pulxu1wVZBxtoyM4m3Lu0ZncTi6mF0duiPvpkNNAZzKOF98aUq3hxYp5PP28qrtWuPsgSdTNWJwnV2R288yAPx4WLUAGB9h889d/m67HUiKadmfG4oyUnChCU/mqeJ/5nQkK1n76ezRPGsTcMZIv2mmPLShA/j38lGMjR/3pzM2Ha6UFjJKpurpeDiyt5pUdmRBpQRoBUx/PHnbXBsO9PBXa0mjdq3UFbgb5iBD1Fz2YClZ/h0d4YlYUMSeharDrLfR15g3fGnzxrkR03zYeHkxMUi5vYHQIDAQABAoICAFxUg5pcH8lOF1FMHfXIY5MM5mbHKsC8knERCPfnEvVB/wxc6D60fow/I0Vg4blzNuFuTjiMykIcfpGQMiK1l8TEMdSODoYCHq6+gY3ootUnLj94i79MtCw9FJHPnKkcVUiDNqPvr/rk8l/06j2J0NMXffEc3FeYJCXYuLtlluTzdI4wueNY/wkoz+EvpQMzWXAMkhxHpIEAqr2aIn+TQDQ0MYxBQlqwDTSPYHFJxIVFlvYx2VdnecKSO2E1/76kOowhTzH3FL+hcWcI/TG7tJuXU1C3llV0sUVBgC1NjwjMkuEm3EN5/ffqgj/eJNJTR1r6tqSzxzMJD42WVmqlPhqEmmRE1yeaio6Ulm5EJLREYEY4/1EotdIO6PCXF1Xeo+10VJLcwi19vzMlz7vFB6vSJzJNYOaiwm4rgmF12+nNt18cHoFvegGPAoCJurUhXU+iV6LlKYB8J/+kpITleZwm4dEaa6EWxgFf4nZ4N+qRVqk4JfC+rN3DQgbPcII+z7axAb8xutq5rvzskGAmKcTEagMG+pnk9DI+0m1C2J6+mC/YruVlpSlPB7Ecu/OF++vJoSCLuDXVuxMTQ3tQXoicOG/S8d1aa3nq4fvLIgZTOZoffUvNvjCyy/Dlv6vaDVCjgqt2kFZVSZv3C6ttyrNVBSjhucASGTq/7q82JKVrAoIBAQD7e7SYqDM8SAjOfpRAHCbIVVSaHw1tul16B6JAF67OIs1uD//Y+maL4GHlpFxnib0tg6z3+sG10NMUhK7elB6SkJ8voT+Ztg/+on659vmaqwTlkGjgks+8DmIw1b0ZbXuIqhnkOeSsll2V9NWItIrbzNwvhtMEQsOtFvmrLlKQvx9Bo3lGmbfdU1/9XpwW4wiA5ONcFCl1xllX9nItB+ActPoAeQgnuejWAISQkITDTOFcGu0AjMjJ66HUlY/WDbf7wY/s2aMbOiVx7B6YzQnK+lZdjjNfoaL8nfN8Z8ZzJFAgmTQ9ztZCJ8fLTfZDsYD6XZnVEIG401+6de0Ea4MHAoIBAQD7Mlr++keNDxYy3k6kwTXDOSKMDfXIDverFy/tiNorcVU5nIpuhjowl4HIQVAhzlXhATp938LM/PvJIeu9HtgsY9BFNL52fr7iGEWknqZQVE+8Um35tzV3vtPH9NVXHIYmcRCAQQJ5a6oVQRJKAM3E1+htM2AeZKMaQSCC6VHGBXJG9nEltdMKTet09pd26Z5y/YIfTqbIekyjRomGdInyWBkpJLJ/uROVb9OGnsYWadDktn7y9+oH+VZoJxW5QwXV0OL7wMzymlrKhhBNMziJRBk4rVztclYzUShpwE8Xfjt5WYkOoieXtmkdpF7TMvRYy6cdflh39l8s7THq4U67AoIBAQDPShR+kIZK9gVsUCHbBllhYBSwRVBIVgmfAJPYyKyp9T3nrwBw1VWZjoajR2tzXLG2xobYkLUDLPIQQ53wkZ7xqIvOMte917gvZ7mVh+IOf1E1+Ga6VcktdZ2yKzwHrZrZ93wIB0BakkbpyDoKyOZ8bsw/0J43MOEh14JMe45LCX62GA1rCGHp6UOCEW4P32uu8vwFdgkGr0wNmXJbc9ZjUzmPEjSKqk8ltp+OmdEVOlW4GempPmJzLeri07UP95bjyZTc4jfzxWYAFWTTBo8/5EE0NHZcKVuzZm7A6LlHR3NUq3PITqmFNk0VY1nOSo3J0UVvvn9u3axWgcWwkv4ZAoIBAQD5GaB/RfZ6fcGHxEi8vRTxedE2jglz0NPuCFpw9ctUVVksfn4QyG8gDL6YtZwfShyDutIkTSdCXIXbmRXLP7Ejo/+/AWT24Q2t7F/ztZg8BL/lguCoR/RvwP8HRVXiwtiNeoXgiZdIjWnnFdZrKIzlbS6CS/Y/bZEI0hjJeVKsSm5ezLi7qqmTiF28aWUeeOG2KotFPJvMJuuk5qgue5boilC5KjkQy5oOEQz0bLMHOvv6MreFfi/IpIv5UPkL9NjuhezxtuoGkucB+xxjUi34gV15hQGoPkAZN5wZ8qj8JSQZXJ67sjYNDskCqiOW0Orau2oHp2lX3SvYw4dC4xdNAoIBAQDSXF8OnWu08smGDs4S57yfqe59N0ULIBDOZdEQcLXRaDeAoOwQj8szUf0pycXUZLvXxv29iF61FJg70iGGCp3lW+9Oe0P+dGsKvzphRGsQPZy4h/qZDoQ9dE667ejI4aDb/C1GaOZ8TT1QfpQcVKbgLjdC19IGQPhFljGM1wMQojU5lp4/iKmA3J3ahOuV4dvEUr6xkqzq+PhH405XdURXsVc1J6qYX2wQXRoB0oDsrCbqidZi14DaeE8UhZRA6m9haUOb/3Y+OkCs2QIW4MNZ+zg8XtHjiUhMxAaJ6zdEFltWPoVFsVfgoecquAHb0uDJJRz/+RfR0AiG0XG0NW+C"
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ function makeRows(n) {
|
|||||||
const rows = []
|
const rows = []
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
rows.push({
|
rows.push({
|
||||||
_____DELETE__THIS__RECORD_____: "No",
|
|
||||||
PRIMARY_KEY_FIELD: i,
|
PRIMARY_KEY_FIELD: i,
|
||||||
SOME_CHAR: "dummy data row " + i,
|
SOME_CHAR: "dummy data row " + i,
|
||||||
SOME_DROPDOWN: dropdowns[i % dropdowns.length],
|
SOME_DROPDOWN: dropdowns[i % dropdowns.length],
|
||||||
@@ -241,7 +240,6 @@ const data = {
|
|||||||
{ ODS_TABLE: "ENGINEHOST", NAME: "File Size (bytes)", VALUE: "1048576" }
|
{ ODS_TABLE: "ENGINEHOST", NAME: "File Size (bytes)", VALUE: "1048576" }
|
||||||
],
|
],
|
||||||
maxvarlengths: [
|
maxvarlengths: [
|
||||||
{ NAME: "_____DELETE__THIS__RECORD_____", MAXLEN: 3 },
|
|
||||||
{ NAME: "PRIMARY_KEY_FIELD", MAXLEN: 4 },
|
{ NAME: "PRIMARY_KEY_FIELD", MAXLEN: 4 },
|
||||||
{ NAME: "some_char", MAXLEN: 591 },
|
{ NAME: "some_char", MAXLEN: 591 },
|
||||||
{ NAME: "some_dropdown", MAXLEN: 8 },
|
{ NAME: "some_dropdown", MAXLEN: 8 },
|
||||||
@@ -261,7 +259,6 @@ const data = {
|
|||||||
sasdata: makeRows(100),
|
sasdata: makeRows(100),
|
||||||
$sasdata: {
|
$sasdata: {
|
||||||
vars: {
|
vars: {
|
||||||
_____DELETE__THIS__RECORD_____: { format: "$3.", label: "_____DELETE__THIS__RECORD_____", length: "3", type: "char" },
|
|
||||||
PRIMARY_KEY_FIELD: { format: "best.", label: "PRIMARY_KEY_FIELD", length: "8", type: "num" },
|
PRIMARY_KEY_FIELD: { format: "best.", label: "PRIMARY_KEY_FIELD", length: "8", type: "num" },
|
||||||
SOME_CHAR: { format: "$32767.", label: "SOME_CHAR", length: "32767", type: "char" },
|
SOME_CHAR: { format: "$32767.", label: "SOME_CHAR", length: "32767", type: "char" },
|
||||||
SOME_DROPDOWN: { format: "$128.", label: "SOME_DROPDOWN", length: "128", type: "char" },
|
SOME_DROPDOWN: { format: "$128.", label: "SOME_DROPDOWN", length: "128", type: "char" },
|
||||||
@@ -280,14 +277,14 @@ const data = {
|
|||||||
},
|
},
|
||||||
sasparams: [
|
sasparams: [
|
||||||
{
|
{
|
||||||
COLHEADERS: "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,SOME_HARDSELECT,SOME_NUM,SOME_DATE,SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM,READONLY_COL,HIDDEN_COL,ROUND_COL,NUMFMT_COL",
|
COLHEADERS: "PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,SOME_HARDSELECT,SOME_NUM,SOME_DATE,SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM,READONLY_COL,HIDDEN_COL,ROUND_COL,NUMFMT_COL",
|
||||||
FILTER_TEXT: "",
|
FILTER_TEXT: "",
|
||||||
PKCNT: 1,
|
PKCNT: 1,
|
||||||
PK: "PRIMARY_KEY_FIELD",
|
PK: "PRIMARY_KEY_FIELD",
|
||||||
DTVARS: " SOME_DATE",
|
DTVARS: " SOME_DATE",
|
||||||
DTTMVARS: " SOME_DATETIME",
|
DTTMVARS: " SOME_DATETIME",
|
||||||
TMVARS: " SOME_TIME",
|
TMVARS: " SOME_TIME",
|
||||||
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"PRIMARY_KEY_FIELD\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_CHAR\"},{\"data\":\"SOME_DROPDOWN\"},{\"data\":\"SOME_HARDSELECT\"},{\"data\":\"SOME_NUM\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_DATE\",\"type\":\"date\"},{\"data\":\"SOME_DATETIME\",\"type\":\"datetime\"},{\"data\":\"SOME_TIME\",\"type\":\"time\"},{\"data\":\"SOME_SHORTNUM\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_BESTNUM\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"READONLY_COL\"},{\"data\":\"HIDDEN_COL\"},{\"data\":\"ROUND_COL\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"NUMFMT_COL\",\"type\":\"numeric\",\"format\":\"0\"}",
|
COLTYPE: "{\"data\":\"PRIMARY_KEY_FIELD\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_CHAR\"},{\"data\":\"SOME_DROPDOWN\"},{\"data\":\"SOME_HARDSELECT\"},{\"data\":\"SOME_NUM\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_DATE\",\"type\":\"date\"},{\"data\":\"SOME_DATETIME\",\"type\":\"datetime\"},{\"data\":\"SOME_TIME\",\"type\":\"time\"},{\"data\":\"SOME_SHORTNUM\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_BESTNUM\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"READONLY_COL\"},{\"data\":\"HIDDEN_COL\"},{\"data\":\"ROUND_COL\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"NUMFMT_COL\",\"type\":\"numeric\",\"format\":\"0\"}",
|
||||||
LOADTYPE: "UPDATE",
|
LOADTYPE: "UPDATE",
|
||||||
RK_FLAG: 0,
|
RK_FLAG: 0,
|
||||||
CLS_FLAG: 0
|
CLS_FLAG: 0
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"licenceKey": "invalid",
|
"licenceKey": "",
|
||||||
"activationKey": "invalid"
|
"activationKey": ""
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,9 @@
|
|||||||
"streamWebFolder": "web9",
|
"streamWebFolder": "web9",
|
||||||
"webSourcePath": "../../client/dist",
|
"webSourcePath": "../../client/dist",
|
||||||
"assetPaths": [],
|
"assetPaths": [],
|
||||||
"streamServiceName": "clickme"
|
"streamServiceName": "clickme",
|
||||||
|
"mockWeb": true,
|
||||||
|
"mockWebFolder": "sasjsbuild/mock"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
const nodePath = require('path')
|
||||||
|
|
||||||
|
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
|
||||||
|
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
|
||||||
|
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
|
||||||
|
|
||||||
|
eval(fs.readFileSync(nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'), 'utf8'))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock of services/admin/exportdb.sas - exports the DC library as DDL.
|
||||||
|
*
|
||||||
|
* The real service streams mp_ds2ddl output for every table in &DC_LIBREF, in
|
||||||
|
* the requested flavour, and mp_lib2inserts appends the rows for the SAS and
|
||||||
|
* PGSQL flavours. This mock emits the DDL only - a basic, readable rendering
|
||||||
|
* per flavour, enough to exercise the System screen's Export DC Library DDL
|
||||||
|
* action end to end (the download headers, the filename, the flavour and the
|
||||||
|
* optional schema). It deliberately does not append INSERTs: the real service
|
||||||
|
* does, but for a mock that would mean serialising the whole library's rows
|
||||||
|
* (MPE_X_WIDE alone is 200,000 cells) for no extra test value.
|
||||||
|
*
|
||||||
|
* Params arrive from the URL the client opens:
|
||||||
|
* <serverUrl>/SASjsApi/stp/execute/?_program=<appLoc>/services/admin/exportdb
|
||||||
|
* &flavour=SAS|PGSQL|TSQL[&schema=<name>]
|
||||||
|
* The schema box only applies to the DB flavours, and the client omits the
|
||||||
|
* param entirely when it is blank, so both must be read defensively - an
|
||||||
|
* unset URL param is an undeclared const here, not an empty string.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ddlFlavour = (typeof flavour === 'undefined' || !flavour)
|
||||||
|
? 'SAS'
|
||||||
|
: String(flavour).toUpperCase()
|
||||||
|
|
||||||
|
const dcConf = loadDcSettings(driveRoot, appLoc)
|
||||||
|
const libref = dcConf.dcLibref
|
||||||
|
const dataDir = dcConf.dataDir
|
||||||
|
|
||||||
|
// if no schema param, the DC libref is used (as in exportdb.sas)
|
||||||
|
const ddlSchema = (typeof schema === 'undefined' || !schema)
|
||||||
|
? libref
|
||||||
|
: String(schema)
|
||||||
|
|
||||||
|
// ─── Column types, per flavour ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function colType(col, flavour) {
|
||||||
|
const isChar = String(col.type).toUpperCase() === 'C'
|
||||||
|
const fmt = String(col.format || '').toUpperCase()
|
||||||
|
const len = Number(col.length) || (isChar ? 1 : 8)
|
||||||
|
|
||||||
|
if (flavour === 'SAS') {
|
||||||
|
return isChar ? `char(${len})` : 'num'
|
||||||
|
}
|
||||||
|
|
||||||
|
// PGSQL and TSQL share the mapping; only the quoting differs. The one type
|
||||||
|
// that must not be shared is the datetime: T-SQL's TIMESTAMP is a deprecated
|
||||||
|
// synonym for rowversion - a binary row version, not a datetime - so it maps
|
||||||
|
// to DATETIME2 there. DOUBLE PRECISION is a T-SQL synonym for float, so it
|
||||||
|
// stays valid on both.
|
||||||
|
if (isChar) return `VARCHAR(${len})`
|
||||||
|
if (fmt.startsWith('DATETIME'))
|
||||||
|
return flavour === 'TSQL' ? 'DATETIME2' : 'TIMESTAMP'
|
||||||
|
if (fmt.startsWith('DATE')) return 'DATE'
|
||||||
|
if (fmt.startsWith('TIME')) return 'TIME'
|
||||||
|
return 'DOUBLE PRECISION'
|
||||||
|
}
|
||||||
|
|
||||||
|
function colRef(name, flavour) {
|
||||||
|
if (flavour === 'PGSQL') return `"${name}"`
|
||||||
|
if (flavour === 'TSQL') return `[${name}]`
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableRef(table, flavour) {
|
||||||
|
if (flavour === 'TSQL') return `[${ddlSchema}].[${table}]`
|
||||||
|
return `${ddlSchema}.${table}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Buskeys (for the unique index), from MPE_TABLES ─────────────────────────
|
||||||
|
|
||||||
|
const buskeys = {}
|
||||||
|
try {
|
||||||
|
const reg = makeTableLoader(dataDir)('MPE_TABLES')
|
||||||
|
for (const row of ((reg && reg.rows) || [])) {
|
||||||
|
const dsn = String(colVal(row, 'DSN') || '').trim().toUpperCase()
|
||||||
|
const buskey = String(colVal(row, 'BUSKEY') || '').trim()
|
||||||
|
if (dsn && buskey) buskeys[dsn] = buskey
|
||||||
|
}
|
||||||
|
} catch(err) { /* no MPE_TABLES - the DDL is still emitted, without indexes */ }
|
||||||
|
|
||||||
|
// ─── Build the DDL ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const lines = []
|
||||||
|
const stamp = new Date().toISOString().replace('T', ':').substring(0, 19)
|
||||||
|
lines.push(`/* DDL generated by the Data Controller mock on ${stamp} */`)
|
||||||
|
|
||||||
|
if (ddlFlavour === 'PGSQL') {
|
||||||
|
lines.push(`CREATE SCHEMA ${ddlSchema};`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let tables = []
|
||||||
|
try {
|
||||||
|
tables = fs.readdirSync(dataDir)
|
||||||
|
.filter(f => f.endsWith('.json'))
|
||||||
|
.map(f => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(nodePath.resolve(dataDir, f), {encoding:'utf8'}).toString())
|
||||||
|
} catch(e) { return null }
|
||||||
|
})
|
||||||
|
.filter(t => t && t.name && Array.isArray(t.columns))
|
||||||
|
.sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
||||||
|
} catch(err) { /* nothing to export */ }
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
const tableName = String(table.name).toUpperCase()
|
||||||
|
const cols = table.columns.filter(c => String(c.name || '').trim() !== '')
|
||||||
|
if (cols.length === 0) continue
|
||||||
|
|
||||||
|
if (ddlFlavour === 'SAS') {
|
||||||
|
lines.push(`/* SAS flavour DDL for ${libref}.${tableName} */`)
|
||||||
|
lines.push(`create table ${tableRef(tableName, ddlFlavour)}(`)
|
||||||
|
} else if (ddlFlavour === 'PGSQL') {
|
||||||
|
lines.push(`/* Postgres Flavour DDL for ${libref}.${tableName} */`)
|
||||||
|
lines.push(`CREATE TABLE ${tableRef(tableName, ddlFlavour)} (`)
|
||||||
|
} else {
|
||||||
|
lines.push(`/* TSQL Flavour DDL for ${libref}.${tableName} */`)
|
||||||
|
lines.push(`create table ${tableRef(tableName, ddlFlavour)}(`)
|
||||||
|
}
|
||||||
|
|
||||||
|
cols.forEach((col, idx) => {
|
||||||
|
const name = colRef(String(col.name).toUpperCase(), ddlFlavour)
|
||||||
|
const prefix = idx === 0 ? ' ' : ' ,'
|
||||||
|
lines.push(`${prefix}${name} ${colType(col, ddlFlavour)}`)
|
||||||
|
})
|
||||||
|
lines.push(');')
|
||||||
|
|
||||||
|
// a unique index on the buskey, where MPE_TABLES declares one - SAS has no
|
||||||
|
// equivalent in mp_ds2ddl output, so it is skipped for that flavour
|
||||||
|
const buskey = buskeys[tableName]
|
||||||
|
if (buskey && ddlFlavour !== 'SAS') {
|
||||||
|
const keys = buskey.split(/[\s,]+/).filter(Boolean)
|
||||||
|
.map(k => colRef(k.toUpperCase(), ddlFlavour))
|
||||||
|
lines.push(
|
||||||
|
`CREATE UNIQUE INDEX ${colRef(tableName + '_pk', ddlFlavour)} ON ` +
|
||||||
|
`${tableRef(tableName, ddlFlavour)}(\n ${keys.join('\n ,')}\n);`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stream it back as a download, as mp_streamfile does ─────────────────────
|
||||||
|
|
||||||
|
const filename = `${libref}.ddl`
|
||||||
|
fs.writeFile(
|
||||||
|
_SASJS_WEBOUT_HEADERS,
|
||||||
|
`Content-type: text/plain \n Content-Disposition: attachment;filename="${filename}"`,
|
||||||
|
function (err) {
|
||||||
|
if (err) throw err
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_webout = lines.join('\n') + '\n'
|
||||||
@@ -76,9 +76,32 @@ const tables = {
|
|||||||
tx_to: 253717919999,
|
tx_to: 253717919999,
|
||||||
var_scope: 'DC',
|
var_scope: 'DC',
|
||||||
var_name: 'DC_MAXOBS_WEBEDIT',
|
var_name: 'DC_MAXOBS_WEBEDIT',
|
||||||
var_value: '100',
|
var_value: '250',
|
||||||
var_active: 1,
|
var_active: 1,
|
||||||
var_desc: 'Maximum observations for editing in the EDIT screen.'
|
var_desc:
|
||||||
|
'Maximum observations for editing in the EDIT screen. The ' +
|
||||||
|
'DC_MAXCELLS_WEBEDIT limit applies at the same time, whichever is ' +
|
||||||
|
'reached first.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
tx_to: 253717919999,
|
||||||
|
var_scope: 'DC',
|
||||||
|
var_name: 'DC_MAXCELLS_WEBEDIT',
|
||||||
|
var_value: '200000',
|
||||||
|
var_active: 1,
|
||||||
|
var_desc:
|
||||||
|
'Maximum CELLS (rows x columns) for editing in the EDIT screen - a ' +
|
||||||
|
'wide table is expensive long before it is tall.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
tx_to: 253717919999,
|
||||||
|
var_scope: 'DC',
|
||||||
|
var_name: 'DC_MAXOBS_WEBVIEW',
|
||||||
|
var_value: '2000',
|
||||||
|
var_active: 1,
|
||||||
|
var_desc: 'Maximum observations for the VIEW screen.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tx_from: 0,
|
tx_from: 0,
|
||||||
@@ -258,6 +281,53 @@ const tables = {
|
|||||||
DD_SENSITIVITY: 'Low'
|
DD_SENSITIVITY: 'Low'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
// Derived (computed) variables. Values are display-time only - the logic is
|
||||||
|
// applied by mpe_derived in the mock's getdata/getcolvals, and must use the
|
||||||
|
// expression subset that compileSasExpression supports (the real evaluation
|
||||||
|
// is done by SAS).
|
||||||
|
MPE_DERIVED: [
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
tx_to: 253717919999,
|
||||||
|
libref: 'TESTDATA',
|
||||||
|
dsn: 'DERIVED_DEMO',
|
||||||
|
var_nm: 'REGION',
|
||||||
|
var_deps: 'STATE',
|
||||||
|
var_type: 'C',
|
||||||
|
var_len: 8,
|
||||||
|
var_fmt: '',
|
||||||
|
var_logic: "ifc(STATE in ('CA','OR','WA'),'West','East')",
|
||||||
|
active: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
tx_to: 253717919999,
|
||||||
|
libref: 'TESTDATA',
|
||||||
|
dsn: 'DERIVED_DEMO',
|
||||||
|
var_nm: 'LINE_VALUE',
|
||||||
|
var_deps: 'QTY PRICE',
|
||||||
|
var_type: 'N',
|
||||||
|
var_len: 8,
|
||||||
|
var_fmt: 'dollar12.2',
|
||||||
|
var_logic: 'QTY * PRICE',
|
||||||
|
active: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// built on REGION, so it proves the dependency order is applied
|
||||||
|
tx_from: 0,
|
||||||
|
tx_to: 253717919999,
|
||||||
|
libref: 'TESTDATA',
|
||||||
|
dsn: 'DERIVED_DEMO',
|
||||||
|
var_nm: 'REGION_LABEL',
|
||||||
|
var_deps: 'REGION STATE',
|
||||||
|
var_type: 'C',
|
||||||
|
var_len: 14,
|
||||||
|
var_fmt: '',
|
||||||
|
var_label: 'Region and state',
|
||||||
|
var_logic: "cats(REGION,'-',STATE)",
|
||||||
|
active: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
MPE_DATALOADS: [],
|
MPE_DATALOADS: [],
|
||||||
MPE_EMAILS: [],
|
MPE_EMAILS: [],
|
||||||
MPE_EXCEL_CONFIG: [],
|
MPE_EXCEL_CONFIG: [],
|
||||||
@@ -1089,6 +1159,26 @@ const tables = {
|
|||||||
selectbox_value: 'FORMULA',
|
selectbox_value: 'FORMULA',
|
||||||
selectbox_order: 1,
|
selectbox_order: 1,
|
||||||
ver_to_dttm: 127490111999
|
ver_to_dttm: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selectbox_rk: 73,
|
||||||
|
ver_from_dttm: 0,
|
||||||
|
select_lib: dcLibref,
|
||||||
|
select_ds: 'MPE_DERIVED',
|
||||||
|
base_column: 'VAR_TYPE',
|
||||||
|
selectbox_value: 'C',
|
||||||
|
selectbox_order: 1,
|
||||||
|
ver_to_dttm: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selectbox_rk: 74,
|
||||||
|
ver_from_dttm: 0,
|
||||||
|
select_lib: dcLibref,
|
||||||
|
select_ds: 'MPE_DERIVED',
|
||||||
|
base_column: 'VAR_TYPE',
|
||||||
|
selectbox_value: 'N',
|
||||||
|
selectbox_order: 2,
|
||||||
|
ver_to_dttm: 127490111999
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
MPE_SUBMIT: [],
|
MPE_SUBMIT: [],
|
||||||
@@ -1301,6 +1391,19 @@ const tables = {
|
|||||||
'RLS_SCOPE RLS_GROUP RLS_LIBREF RLS_TABLE RLS_GROUP_LOGIC RLS_SUBGROUP_LOGIC RLS_SUBGROUP_ID RLS_VARIABLE_NM RLS_OPERATOR_NM RLS_RAW_VALUE',
|
'RLS_SCOPE RLS_GROUP RLS_LIBREF RLS_TABLE RLS_GROUP_LOGIC RLS_SUBGROUP_LOGIC RLS_SUBGROUP_ID RLS_VARIABLE_NM RLS_OPERATOR_NM RLS_RAW_VALUE',
|
||||||
post_edit_hook: 'services/hooks/mpe_row_level_security_postedit'
|
post_edit_hook: 'services/hooks/mpe_row_level_security_postedit'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
tx_to: 127490111999,
|
||||||
|
libref: dcLibref,
|
||||||
|
dsn: 'MPE_DERIVED',
|
||||||
|
num_of_approvals_required: 1,
|
||||||
|
loadtype: 'TXTEMPORAL',
|
||||||
|
buskey: 'LIBREF DSN VAR_NM',
|
||||||
|
notes: 'Derived (computed) variables for the EDIT screen',
|
||||||
|
var_txfrom: 'TX_FROM',
|
||||||
|
var_txto: 'TX_TO',
|
||||||
|
post_edit_hook: 'services/hooks/mpe_derived_postedit'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tx_from: 0,
|
tx_from: 0,
|
||||||
tx_to: 127490111999,
|
tx_to: 127490111999,
|
||||||
@@ -1932,6 +2035,206 @@ const tables = {
|
|||||||
rule_value: 'services/validations/mpe_x_test.some_num',
|
rule_value: 'services/validations/mpe_x_test.some_num',
|
||||||
rule_active: 1,
|
rule_active: 1,
|
||||||
tx_to: 127490111999
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'LIBREF',
|
||||||
|
rule_type: 'NOTNULL',
|
||||||
|
rule_value: ' ',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'LIBREF',
|
||||||
|
rule_type: 'CASE',
|
||||||
|
rule_value: 'UPCASE',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'DSN',
|
||||||
|
rule_type: 'NOTNULL',
|
||||||
|
rule_value: ' ',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'DSN',
|
||||||
|
rule_type: 'CASE',
|
||||||
|
rule_value: 'UPCASE',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_NM',
|
||||||
|
rule_type: 'NOTNULL',
|
||||||
|
rule_value: ' ',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_NM',
|
||||||
|
rule_type: 'CASE',
|
||||||
|
rule_value: 'UPCASE',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_NM',
|
||||||
|
rule_type: 'HARDREGEX',
|
||||||
|
rule_value: '^[A-Za-z_][A-Za-z0-9_]*$',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_DEPS',
|
||||||
|
rule_type: 'CASE',
|
||||||
|
rule_value: 'UPCASE',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_DEPS',
|
||||||
|
rule_type: 'HARDREGEX',
|
||||||
|
rule_value: '^[A-Za-z0-9_ ]*$',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_TYPE',
|
||||||
|
rule_type: 'NOTNULL',
|
||||||
|
rule_value: ' ',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_TYPE',
|
||||||
|
rule_type: 'CASE',
|
||||||
|
rule_value: 'UPCASE',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_TYPE',
|
||||||
|
rule_type: 'HARDSELECT',
|
||||||
|
rule_value: ' ',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_LEN',
|
||||||
|
rule_type: 'NOTNULL',
|
||||||
|
rule_value: ' ',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_LEN',
|
||||||
|
rule_type: 'MINVAL',
|
||||||
|
rule_value: '1',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_LEN',
|
||||||
|
rule_type: 'MAXVAL',
|
||||||
|
rule_value: '32767',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_FMT',
|
||||||
|
rule_type: 'CASE',
|
||||||
|
rule_value: 'UPCASE',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_FMT',
|
||||||
|
rule_type: 'HARDREGEX',
|
||||||
|
rule_value: '^(\$?[A-Za-z][A-Za-z0-9_]*(\.[0-9]+)?\.?|\$?[0-9]+(\.[0-9]+)?\.?)?$',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_LOGIC',
|
||||||
|
rule_type: 'NOTNULL',
|
||||||
|
rule_value: ' ',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_LABEL',
|
||||||
|
rule_type: 'HARDREGEX',
|
||||||
|
rule_value: '^[^\\x00-\\x1F\\x7F]*$',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
base_lib: dcLibref,
|
||||||
|
base_ds: 'MPE_DERIVED',
|
||||||
|
base_col: 'VAR_LOGIC',
|
||||||
|
rule_type: 'HARDREGEX',
|
||||||
|
rule_value: '^(?!.*(/\\*|\\*/))[^;&%]*$',
|
||||||
|
rule_active: 1,
|
||||||
|
tx_to: 127490111999
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
MPE_X_TEST: [
|
MPE_X_TEST: [
|
||||||
@@ -2201,6 +2504,37 @@ tables.MPE_TABLES.push({
|
|||||||
notes: 'Full table search demo table'
|
notes: 'Full table search demo table'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── MPE_X_WIDE: wide table for the EDIT cell limit ─────────────────────────
|
||||||
|
// 200 rows x 1001 columns (ROW_ID + 500 single-character + 500 numeric) is
|
||||||
|
// 200,200 cells - over DC_MAXCELLS_WEBEDIT (200000) while staying under
|
||||||
|
// DC_MAXOBS_WEBEDIT (250), so it exercises the CELL limit on its own and the
|
||||||
|
// row limit spec can use MPE_X_SEARCH (1000 rows) instead. Values are one
|
||||||
|
// character / one digit on purpose: the point is width, and small values keep
|
||||||
|
// the seeded JSON near 1MB rather than the 25MB the real WIDEBOY fixture (20
|
||||||
|
// character fields) would take.
|
||||||
|
tables.MPE_X_WIDE = []
|
||||||
|
for (let n = 0; n < 200; n++) {
|
||||||
|
const wideRow = { ROW_ID: n + 1 }
|
||||||
|
for (let c = 1; c <= 500; c++) {
|
||||||
|
wideRow['CHAR' + c] = String.fromCharCode(65 + ((n + c) % 26))
|
||||||
|
}
|
||||||
|
for (let c = 1; c <= 500; c++) {
|
||||||
|
wideRow['NUM' + c] = (n + c) % 10
|
||||||
|
}
|
||||||
|
tables.MPE_X_WIDE.push(wideRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables.MPE_TABLES.push({
|
||||||
|
tx_from: 0,
|
||||||
|
tx_to: 127490111999,
|
||||||
|
libref: dcLibref,
|
||||||
|
dsn: 'MPE_X_WIDE',
|
||||||
|
num_of_approvals_required: 1,
|
||||||
|
loadtype: 'UPDATE',
|
||||||
|
buskey: 'ROW_ID CHAR1 NUM1',
|
||||||
|
notes: 'Wide table fixture for the EDIT cell limit'
|
||||||
|
})
|
||||||
|
|
||||||
// Column metadata for each table, extracted from mpe_makedatamodel.sas
|
// Column metadata for each table, extracted from mpe_makedatamodel.sas
|
||||||
// Each entry: { name, type (N/C), length, format, label, notnull (bool) }
|
// Each entry: { name, type (N/C), length, format, label, notnull (bool) }
|
||||||
const schema = {
|
const schema = {
|
||||||
@@ -3208,6 +3542,20 @@ const schema = {
|
|||||||
notnull: false
|
notnull: false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
MPE_DERIVED: [
|
||||||
|
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
||||||
|
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: '', notnull: false },
|
||||||
|
{ name: 'DSN', type: 'C', length: 32, format: '', label: '', notnull: false },
|
||||||
|
{ name: 'VAR_NM', type: 'C', length: 32, format: '', label: '', notnull: false },
|
||||||
|
{ name: 'VAR_DEPS', type: 'C', length: 4000, format: '', label: '', notnull: false },
|
||||||
|
{ name: 'VAR_TYPE', type: 'C', length: 1, format: '', label: '', notnull: false },
|
||||||
|
{ name: 'VAR_LEN', type: 'N', length: 8, format: '', label: '', notnull: false },
|
||||||
|
{ name: 'VAR_FMT', type: 'C', length: 32, format: '', label: '', notnull: false },
|
||||||
|
{ name: 'VAR_LABEL', type: 'C', length: 256, format: '', label: '', notnull: false },
|
||||||
|
{ name: 'VAR_LOGIC', type: 'C', length: 4000, format: '', label: '', notnull: false },
|
||||||
|
{ name: 'ACTIVE', type: 'N', length: 8, format: '', label: '', notnull: false },
|
||||||
|
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true }
|
||||||
|
],
|
||||||
MPE_DATALOADS: [
|
MPE_DATALOADS: [
|
||||||
{
|
{
|
||||||
name: 'LIBREF',
|
name: 'LIBREF',
|
||||||
@@ -4976,6 +5324,31 @@ const schema = {
|
|||||||
MPE_MAXKEYVALUES: []
|
MPE_MAXKEYVALUES: []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Columns for MPE_X_WIDE - built rather than spelled out, there are 1001.
|
||||||
|
schema.MPE_X_WIDE = [
|
||||||
|
{ name: 'ROW_ID', type: 'N', length: 8, format: '', label: '', notnull: true }
|
||||||
|
]
|
||||||
|
for (let c = 1; c <= 500; c++) {
|
||||||
|
schema.MPE_X_WIDE.push({
|
||||||
|
name: 'CHAR' + c,
|
||||||
|
type: 'C',
|
||||||
|
length: 1,
|
||||||
|
format: '',
|
||||||
|
label: '',
|
||||||
|
notnull: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (let c = 1; c <= 500; c++) {
|
||||||
|
schema.MPE_X_WIDE.push({
|
||||||
|
name: 'NUM' + c,
|
||||||
|
type: 'N',
|
||||||
|
length: 8,
|
||||||
|
format: '',
|
||||||
|
label: '',
|
||||||
|
notnull: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── TESTDATA library: demo user tables for the Cypress E2E suite ───────────
|
// ─── TESTDATA library: demo user tables for the Cypress E2E suite ───────────
|
||||||
// These tables hold the DQ-rule demo columns (HARDSELECT, READONLY, HIDDEN,
|
// These tables hold the DQ-rule demo columns (HARDSELECT, READONLY, HIDDEN,
|
||||||
// ROUND, NUMBER_FORMAT, HARDREGEX, SOFTREGEX) and the formula demo columns
|
// ROUND, NUMBER_FORMAT, HARDREGEX, SOFTREGEX) and the formula demo columns
|
||||||
@@ -5270,7 +5643,15 @@ const testTables = {
|
|||||||
{ ORDER_ID: 1003, CUSTOMER: 'Cobalt GmbH', REGION: 'EU', AMOUNT: 620, STATUS: 'CLOSED' },
|
{ ORDER_ID: 1003, CUSTOMER: 'Cobalt GmbH', REGION: 'EU', AMOUNT: 620, STATUS: 'CLOSED' },
|
||||||
{ ORDER_ID: 1004, CUSTOMER: 'Delta Systems', REGION: 'US', AMOUNT: 4500, STATUS: 'NEW' }
|
{ ORDER_ID: 1004, CUSTOMER: 'Delta Systems', REGION: 'US', AMOUNT: 4500, STATUS: 'NEW' }
|
||||||
],
|
],
|
||||||
DEMO_MIRROR: []
|
DEMO_MIRROR: [],
|
||||||
|
// Derived variable demo - REGION and LINE_VALUE are not columns of this
|
||||||
|
// table, they are computed at read time from MPE_DERIVED rows.
|
||||||
|
DERIVED_DEMO: [
|
||||||
|
{ ID: 1, STATE: 'CA', QTY: 2, PRICE: 10.5 },
|
||||||
|
{ ID: 2, STATE: 'NY', QTY: 1, PRICE: 99 },
|
||||||
|
{ ID: 3, STATE: 'OR', QTY: 5, PRICE: 3 },
|
||||||
|
{ ID: 4, STATE: 'TX', QTY: 3, PRICE: 7.25 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schemas for the TESTDATA tables, matching the old inline getdata.js payload
|
// Schemas for the TESTDATA tables, matching the old inline getdata.js payload
|
||||||
@@ -5612,6 +5993,12 @@ const testSchema = {
|
|||||||
label: 'Last reviewed',
|
label: 'Last reviewed',
|
||||||
notnull: false
|
notnull: false
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
DERIVED_DEMO: [
|
||||||
|
{ name: 'ID', type: 'N', length: 8, format: '', label: 'ID', notnull: true },
|
||||||
|
{ name: 'STATE', type: 'C', length: 2, format: '', label: 'State', notnull: false },
|
||||||
|
{ name: 'QTY', type: 'N', length: 8, format: '', label: 'Quantity', notnull: false },
|
||||||
|
{ name: 'PRICE', type: 'N', length: 8, format: 'dollar12.2', label: 'Price', notnull: false }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5660,6 +6047,16 @@ tables.MPE_TABLES.push(
|
|||||||
buskey: 'ID',
|
buskey: 'ID',
|
||||||
notes: 'Compact rule demo table (eight columns)'
|
notes: 'Compact rule demo table (eight columns)'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tx_from: 0,
|
||||||
|
tx_to: 127490111999,
|
||||||
|
libref: testDataLibref,
|
||||||
|
dsn: 'DERIVED_DEMO',
|
||||||
|
num_of_approvals_required: 1,
|
||||||
|
loadtype: 'UPDATE',
|
||||||
|
buskey: 'ID',
|
||||||
|
notes: 'Derived variable demo table'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tx_from: 0,
|
tx_from: 0,
|
||||||
tx_to: 127490111999,
|
tx_to: 127490111999,
|
||||||
|
|||||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user