Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
79189ef2a4 | ||
|
|
d717ecd0c2 | ||
|
|
60ddb475d7 | ||
|
|
e926649787 | ||
|
|
48d45ac7f8 | ||
|
|
7fd8ae4629 | ||
|
|
eec3438de9 | ||
|
|
200ee8931d | ||
|
|
10122312cb | ||
|
|
ce1de3d5b4 | ||
|
|
fbbaa0ac5e | ||
|
|
a4ad4cba28 | ||
|
|
5538e0c575 | ||
|
|
11b6c23000 | ||
|
|
ba21ff00bc | ||
|
|
65a96922f6 | ||
|
|
92bc72f51b | ||
|
|
fdaa249f44 | ||
|
|
9c51fff0ef | ||
|
|
5299e0ba06 | ||
|
|
25077e9ded | ||
|
|
d2dd46bfdf | ||
|
|
8de9978213 | ||
|
|
293636a5f8 | ||
|
|
1e8a261ada | ||
|
|
f1ae501d49 | ||
|
|
9d740f9f47 | ||
|
|
76dd4bf69a | ||
|
|
fed8c7344e | ||
|
|
a770f3695c | ||
|
|
7abd260f98 | ||
|
|
fbd77a5334 | ||
|
|
50282f2ddb | ||
|
|
d61308578c | ||
|
|
171974bd41 | ||
|
|
b5f228351b |
No files matched your search
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Emits the "Verifying the download" section appended to every release body
|
||||
# by the "Upload assets to release" step of release.yaml.
|
||||
# Keep this text in sync with the docs page:
|
||||
# docs.datacontroller.io/docs/downloads.md
|
||||
set -euo pipefail
|
||||
cat <<'EOF'
|
||||
|
||||
## Verifying the download
|
||||
|
||||
Every asset on this release is covered by the `SHA256SUMS` file. To verify a download, fetch `SHA256SUMS` from the release assets alongside the files you downloaded, then run:
|
||||
|
||||
```
|
||||
sha256sum --check SHA256SUMS --ignore-missing
|
||||
```
|
||||
|
||||
The command reports `OK` for each asset that matches. `--ignore-missing` lets you check just the files you downloaded rather than all of them.
|
||||
|
||||
What this protects: a matching checksum confirms the asset is byte-for-byte the file this pipeline uploaded, guarding against corrupted or tampered downloads (mirrors, proxies, interrupted transfers). It is not a signature: the hashes travel in the same release as the files, so a compromise of the forge itself could alter both. Treat it as an integrity check, not a trust anchor.
|
||||
EOF
|
||||
@@ -15,6 +15,22 @@ jobs:
|
||||
with:
|
||||
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
|
||||
run: |
|
||||
apt-get update
|
||||
@@ -107,6 +123,15 @@ jobs:
|
||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||
npm ci
|
||||
|
||||
- name: Build frontend for web streaming
|
||||
# The server-ci target streams client/dist as the 'clickme' web app (see
|
||||
# CONTRIBUTING.md), so the production build has to exist before the mock
|
||||
# services are deployed. Without it the deploy logs
|
||||
# "webSourcePath: .../client/dist ... doesn't exist" and streams no frontend.
|
||||
run: |
|
||||
cd client
|
||||
npm run build
|
||||
|
||||
- name: Setup and start SASjs server
|
||||
run: |
|
||||
npm i -g pm2
|
||||
@@ -117,8 +142,27 @@ jobs:
|
||||
echo NODE_PATH=node >> .env
|
||||
echo CORS=enable >> .env
|
||||
echo WHITELIST=http://localhost:4200 >> .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
|
||||
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
|
||||
run: |
|
||||
@@ -154,7 +198,7 @@ jobs:
|
||||
# Start frontend and run cypress
|
||||
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
|
||||
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.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/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"
|
||||
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,cypress/e2e/ddl-export.cy.ts,cypress/e2e/row-cell-limits.cy.ts"
|
||||
|
||||
- name: Zip Cypress videos
|
||||
if: always()
|
||||
|
||||
@@ -33,10 +33,29 @@ jobs:
|
||||
echo NODE_PATH=node >> .env
|
||||
echo CORS=enable >> .env
|
||||
echo WHITELIST=http://localhost:4200 >> .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
|
||||
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||
unzip linux.zip
|
||||
pm2 start api-linux --wait-ready
|
||||
# `pm2 start --wait-ready` can return while the app is still
|
||||
# launching (it does not always wait for the ready signal), and the
|
||||
# next step hits :5000 immediately. Wait for the server to answer,
|
||||
# and dump its log if it never does.
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||
echo "SASjs server answered on attempt ${i}"
|
||||
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
|
||||
run: echo "$NPMRC" > client/.npmrc
|
||||
|
||||
@@ -117,8 +117,27 @@ jobs:
|
||||
echo NODE_PATH=node >> .env
|
||||
echo CORS=enable >> .env
|
||||
echo WHITELIST=http://localhost:4200 >> .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
|
||||
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
|
||||
run: |
|
||||
@@ -338,23 +357,43 @@ jobs:
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
|
||||
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
|
||||
|
||||
# Update body (also confirms the token has contents:write on this repo)
|
||||
# Generate SHA-256 checksums over the release assets, so downloads can
|
||||
# be verified. The file uses basename-only paths so that a plain
|
||||
# "sha256sum -c SHA256SUMS" works in the folder the assets were
|
||||
# downloaded to (the upload below stores them under their basename).
|
||||
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
|
||||
|
||||
# Update body: keep the existing notes, add a verification section
|
||||
# ahead of the installation footer. The section text lives in a
|
||||
# dedicated script below (.gitea/scripts/verify-section.sh) so this
|
||||
# YAML scalar stays clean.
|
||||
VERIFY_SECTION=$(./.gitea/scripts/verify-section.sh)
|
||||
NEW_BODY=$(jq -n --arg body "$RELEASE_BODY" --arg verify "$VERIFY_SECTION" \
|
||||
'$body + "\n" + $verify + "\n\nFor installation instructions, please visit https://docs.datacontroller.io/"')
|
||||
curl -k --fail-with-body -sS -X PATCH \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "$(jq -n --arg body "$RELEASE_BODY"$'\n\nFor installation instructions, please visit https://docs.datacontroller.io/' \
|
||||
'{draft:false, body:$body}')" \
|
||||
--data "$(jq -n --arg body "$NEW_BODY" '{draft:false, body:$body}')" \
|
||||
"$BASE/releases/$RELEASE_ID"
|
||||
|
||||
# Upload assets
|
||||
URL="$BASE/releases/$RELEASE_ID/assets"
|
||||
for f in 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; do
|
||||
for f in "${ASSETS[@]}"; do
|
||||
echo "Uploading $f ..."
|
||||
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
|
||||
done
|
||||
echo "Uploading SHA256SUMS ..."
|
||||
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
|
||||
@@ -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
|
||||
@@ -1,3 +1,66 @@
|
||||
# [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** correct server-ci webSourcePath and build frontend before mock deploy ([a4ad4cb](https://git.datacontroller.io/dc/dc/commit/a4ad4cba2861fe76f7ee5ea72ccf8c6e61c888a5))
|
||||
* **client:** parse iOS browsers in parseUserAgent ([eec3438](https://git.datacontroller.io/dc/dc/commit/eec3438de9735e0feb74c95d0b28128af6677477))
|
||||
* **diagnostics:** give the startup service a body for browser_info ([5538e0c](https://git.datacontroller.io/dc/dc/commit/5538e0c5752553a7c61903c62e4fdc82b6206826))
|
||||
* **diagnostics:** treat _debug=128 as debug on ([8de9978](https://git.datacontroller.io/dc/dc/commit/8de99782134e37c5e9c26c3117b083f49fa65189))
|
||||
* **loader:** abort when a post edit hook routes to an unregistered table ([171974b](https://git.datacontroller.io/dc/dc/commit/171974bd412be3efafd792e6c61c23f2fb442ace))
|
||||
* **mocks:** keep the mock hook reader inside the Drive ([7fd8ae4](https://git.datacontroller.io/dc/dc/commit/7fd8ae462993f0a1a8a034b5938d309d63920043))
|
||||
* **sas:** drop the duplicated abort in the target re-registration check ([48d45ac](https://git.datacontroller.io/dc/dc/commit/48d45ac7f8c32e39ff2d0e1ceac50252a49ebadd))
|
||||
* **sidebar:** drop the query string from the sub-page label ([d2dd46b](https://git.datacontroller.io/dc/dc/commit/d2dd46bfdfddd1695d1b3915d20ce79330cf275b))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **client:** report the browser and both versions in browser_info ([a770f36](https://git.datacontroller.io/dc/dc/commit/a770f3695ceac441f48527f606843602c39fcc13))
|
||||
* **client:** send session_info with every service request ([50282f2](https://git.datacontroller.io/dc/dc/commit/50282f2ddb1250e048c55c04b395b428167142f7))
|
||||
* **filters:** make the applied-filter panel expandable ([fdaa249](https://git.datacontroller.io/dc/dc/commit/fdaa249f447b3a2c87da4b2aa1e5978c09f53ff8))
|
||||
* **mocks:** emulate the %mpeinit diagnostics dump ([200ee89](https://git.datacontroller.io/dc/dc/commit/200ee8931d3952624434567b5b95c9e7e53c6508))
|
||||
* **mocks:** run pre/post edit hook programs in the JS mock services ([b5f2283](https://git.datacontroller.io/dc/dc/commit/b5f228351bca18cbc8a3032e60846d05192668f6))
|
||||
* **mpeinit:** dump session_info to the log when debug is on ([fbd77a5](https://git.datacontroller.io/dc/dc/commit/fbd77a533417b1c8549a339b94785f7af0a30ca8))
|
||||
* **release:** publish SHA256SUMS and verify instructions with each release ([f1ae501](https://git.datacontroller.io/dc/dc/commit/f1ae501d49b510a1870f3682bb7d83de7446e061))
|
||||
|
||||
# [7.15.0](https://git.datacontroller.io/dc/dc/compare/v7.14.2...v7.15.0) (2026-09-23)
|
||||
|
||||
|
||||
|
||||
+7
-8
@@ -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 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
|
||||
|
||||
[SheetJS Pro Version](https://www.npmjs.com/package/sheetjs)
|
||||
@@ -48,15 +50,12 @@ npm i
|
||||
|
||||
[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
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// 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 {}
|
||||
//
|
||||
// The client sends `browser_info` (and `browser_url_vars`, when the page URL
|
||||
// has parameters) with the startup service and with the services that execute
|
||||
// customer-provided code - hook scripts and dynamic cell dropdown programs.
|
||||
// Other services do not receive the tables.
|
||||
//
|
||||
// Payloads are keyed by the _program value in the request URL, so the
|
||||
// assertions can tell one service's payload from another's. Asserted on the
|
||||
// adapter interface, which is the only place the payload is observable end
|
||||
// to end.
|
||||
//
|
||||
// Each test starts from a plain visit of the app root rather than assuming
|
||||
// the previous test's session: Cypress clears cookies between tests, the
|
||||
// SASjs Server session drops, and the app lands back on the evaluation
|
||||
// agreement card. The acceptance is guarded, so it is a no-op when the
|
||||
// session survived.
|
||||
const hostUrl = Cypress.env('hosturl')
|
||||
|
||||
/** The services the app sends diagnostics to (see DIAGNOSTICS_SERVICES in
|
||||
* client/src/app/services/sas.service.ts). */
|
||||
const DIAGNOSTICS_SERVICES = [
|
||||
'public/startupservice',
|
||||
'editors/getdata',
|
||||
'editors/getdynamiccolvals',
|
||||
'editors/stagedata',
|
||||
'editors/loadfile',
|
||||
'editors/restore',
|
||||
'auditors/postdata'
|
||||
]
|
||||
|
||||
/** Boots the app at the root, accepting the evaluation agreement if shown. */
|
||||
const bootApp = () => {
|
||||
cy.visit(hostUrl, { timeout: 60000 })
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Captures each service's payload, keyed by the _program URL parameter. */
|
||||
const capturePrograms = (): Record<string, string> => {
|
||||
const programs: Record<string, string> = {}
|
||||
cy.intercept('**/stp/execute/**', (req) => {
|
||||
// The handler must never throw: an exception inside an intercept handler
|
||||
// aborts the request, which breaks the app under test. A lone '%' in the
|
||||
// URL would make decodeURIComponent throw, hence the guarded parse.
|
||||
let program = req.url
|
||||
try {
|
||||
program = decodeURIComponent(
|
||||
(req.url.match(/_program=([^&]+)/) || [])[1] || ''
|
||||
)
|
||||
} catch (e) {
|
||||
program = (req.url.match(/_program=([^&]+)/) || [])[1] || req.url
|
||||
}
|
||||
if (typeof req.body === 'string') {
|
||||
programs[program] = req.body
|
||||
} else {
|
||||
programs[program] = JSON.stringify(req.body)
|
||||
}
|
||||
})
|
||||
return programs
|
||||
}
|
||||
|
||||
context('browser_info input table: ', function () {
|
||||
it('1 | services that run no customer code do not receive it', () => {
|
||||
const programs = capturePrograms()
|
||||
bootApp()
|
||||
|
||||
// The viewer route fires public/viewlibs and a usernav service - plain
|
||||
// data services with no hook scripts and no customer includes. Taken
|
||||
// from a fresh boot, before anything caches the library tree.
|
||||
cy.visit(`${hostUrl}/#/view/data`, { timeout: 60000 })
|
||||
cy.wait(8000)
|
||||
|
||||
cy.then(() => {
|
||||
const captured = Object.keys(programs)
|
||||
// The startup service is a diagnostics service by design - it runs on
|
||||
// every boot, which is the point of it - so it is excluded from this
|
||||
// sweep. On a real estate it would otherwise appear here carrying
|
||||
// browser_info and fail the test.
|
||||
const plain = captured.filter(
|
||||
(program) => !DIAGNOSTICS_SERVICES.some((s) => program.includes(s))
|
||||
)
|
||||
expect(
|
||||
plain.length,
|
||||
`non-diagnostics service calls captured (${captured.join(', ')})`
|
||||
).to.be.greaterThan(0)
|
||||
|
||||
plain.forEach((program) => {
|
||||
expect(
|
||||
programs[program],
|
||||
`${program} carries no browser_info`
|
||||
).not.to.contain('browser_info')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('2 | hook-running services receive it', () => {
|
||||
const programs = capturePrograms()
|
||||
bootApp()
|
||||
|
||||
// Opening the editor directly makes the app call editors/getdata, which
|
||||
// runs the pre-edit hook and so receives the diagnostics tables.
|
||||
cy.visit(`${hostUrl}/#/editor/DC_JSLIB.MPE_X_TEST`, { timeout: 60000 })
|
||||
cy.get('#hotTable', { timeout: 60000 }).should('exist')
|
||||
cy.wait(8000)
|
||||
|
||||
cy.then(() => {
|
||||
const captured = Object.keys(programs)
|
||||
expect(
|
||||
captured.length,
|
||||
`service calls captured (${captured.join(', ')})`
|
||||
).to.be.greaterThan(0)
|
||||
|
||||
// The startup service is the other diagnostics recipient, but it is not
|
||||
// reachable from a mock estate: `checkSasjsDeploy` finds makedata in
|
||||
// services/admin and routes to the deploy screen instead, so the app
|
||||
// never calls it on boot. Its null-payload path is covered by the unit
|
||||
// spec (src/app/services/sas.service.spec.ts).
|
||||
const getdata = captured.find((p) => p.includes('editors/getdata'))
|
||||
expect(getdata, 'editors/getdata captured').to.not.be.undefined
|
||||
expect(
|
||||
programs[getdata!],
|
||||
'getdata payload carries browser_info'
|
||||
).to.contain('browser_info')
|
||||
// TIMEZONE is a column of browser_info and of no other input table, so
|
||||
// this shows the row itself arrived, not just the table name.
|
||||
expect(
|
||||
programs[getdata!],
|
||||
'browser_info row content in the payload'
|
||||
).to.contain('timezone')
|
||||
})
|
||||
})
|
||||
|
||||
it('3 | browser_url_vars carries the URL parameters as name/value pairs', () => {
|
||||
const programs = capturePrograms()
|
||||
bootApp()
|
||||
|
||||
// The labels parameter is read by the editor from the hash query string,
|
||||
// so the diagnostics tables must carry it as a name/value row.
|
||||
cy.visit(`${hostUrl}/#/editor/DC_JSLIB.MPE_X_TEST?labels=true`, {
|
||||
timeout: 60000
|
||||
})
|
||||
cy.get('#hotTable', { timeout: 60000 }).should('exist')
|
||||
cy.wait(8000)
|
||||
|
||||
cy.then(() => {
|
||||
const getdata = Object.keys(programs).find((p) =>
|
||||
p.includes('editors/getdata')
|
||||
)
|
||||
expect(getdata, 'editors/getdata captured').to.not.be.undefined
|
||||
expect(
|
||||
programs[getdata!],
|
||||
'getdata payload carries browser_url_vars'
|
||||
).to.contain('browser_url_vars')
|
||||
// the labels parameter, as a name in the name/value table
|
||||
expect(programs[getdata!], 'labels parameter row').to.contain('labels')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,257 @@
|
||||
export {}
|
||||
//
|
||||
// The applied-filter panel shows the clause that is in force. A long clause
|
||||
// does not fit the collapsed single line, so the panel carries a chevron that
|
||||
// expands it to show the clause in full - in the viewer and in the editor.
|
||||
//
|
||||
// The chevron is only rendered when the clause actually overflows, so a short
|
||||
// filter looks exactly as it did before.
|
||||
|
||||
const hostUrl = Cypress.env('hosturl')
|
||||
const appLocation = Cypress.env('appLocation')
|
||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||
|
||||
context('applied filter panel: ', function () {
|
||||
this.beforeAll(() => {
|
||||
cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout })
|
||||
cy.loginAndUpdateValidKey(true)
|
||||
})
|
||||
|
||||
// the panel behaviour depends on how much room the clause has, so pin a
|
||||
// laptop-sized window rather than the wider CI viewport
|
||||
this.beforeEach(() => {
|
||||
cy.viewport(1280, 800)
|
||||
bootApp()
|
||||
})
|
||||
|
||||
it('1 | without a filter the panel is not rendered', () => {
|
||||
openTable('DC_JSLIB.MPE_X_TEST')
|
||||
|
||||
cy.get('.infoBar').should('not.exist')
|
||||
|
||||
cy.screenshot('filter-panel-1-no-filter', { capture: 'viewport' })
|
||||
})
|
||||
|
||||
it('2 | a short filter is shown in full, with no chevron', () => {
|
||||
openTable('DC_JSLIB.MPE_X_TEST')
|
||||
|
||||
openFilterPopup()
|
||||
setFilterVariable('SOME_DROPDOWN')
|
||||
setFilterValue('Option 1')
|
||||
submitFilter()
|
||||
|
||||
cy.get('.infoBar-text').should('contain.text', "SOME_DROPDOWN = 'Option 1'")
|
||||
// the clause fits the collapsed line, so there is nothing to expand
|
||||
assertClauseFitsCollapsedLine()
|
||||
cy.get('.infoBar-toggle').should('not.exist')
|
||||
|
||||
cy.screenshot('filter-panel-2-filter', { capture: 'viewport' })
|
||||
})
|
||||
|
||||
it('3 | a long filter is collapsed to a chevron and expands to the full clause', () => {
|
||||
openTable('DC_JSLIB.MPE_X_TEST')
|
||||
|
||||
// SOME_CHAR holds a very long free-text value, so an IN over every value
|
||||
// produces a clause of several hundred characters
|
||||
openFilterPopup()
|
||||
setFilterVariable('SOME_CHAR')
|
||||
setFilterOperator('IN')
|
||||
chooseAllFilterValues()
|
||||
submitFilter()
|
||||
|
||||
cy.get('.infoBar-text').should('contain.text', 'SOME_CHAR IN')
|
||||
assertClauseOverflowsCollapsedLine()
|
||||
cy.get('.infoBar-toggle')
|
||||
.should('exist')
|
||||
.and('have.attr', 'aria-expanded', 'false')
|
||||
|
||||
cy.screenshot('filter-panel-3-big-filter-collapsed', {
|
||||
capture: 'viewport'
|
||||
})
|
||||
|
||||
// expand: the whole clause is rendered, on several lines, unclipped
|
||||
cy.get('.infoBar-toggle').click()
|
||||
cy.get('.infoBar').should('have.class', 'expanded')
|
||||
cy.get('.infoBar-toggle').should('have.attr', 'aria-expanded', 'true')
|
||||
assertClauseFullyVisible()
|
||||
assertClauseSpansSeveralLines()
|
||||
|
||||
cy.screenshot('filter-panel-3-big-filter-expanded', { capture: 'viewport' })
|
||||
|
||||
// collapse again
|
||||
cy.get('.infoBar-toggle').click()
|
||||
cy.get('.infoBar').should('not.have.class', 'expanded')
|
||||
assertClauseOverflowsCollapsedLine()
|
||||
})
|
||||
|
||||
it('4 | the editor panel collapses and expands in the same way', () => {
|
||||
openTable('DC_JSLIB.MPE_X_TEST')
|
||||
|
||||
openFilterPopup()
|
||||
setFilterVariable('SOME_CHAR')
|
||||
setFilterOperator('IN')
|
||||
chooseAllFilterValues()
|
||||
submitFilter()
|
||||
|
||||
// the editor shows the filter the viewer just stored
|
||||
cy.url().then((url) => {
|
||||
const filterId = url.split('/').pop()
|
||||
cy.visit(
|
||||
`${hostUrl}${appLocation}/#/editor/DC_JSLIB.MPE_X_TEST/${filterId}`,
|
||||
{ timeout: longerCommandTimeout }
|
||||
)
|
||||
})
|
||||
|
||||
// wait for the editor itself - .editor-title/.btnCtrl are editor-only, so
|
||||
// this cannot pass on the viewer the visit came from
|
||||
cy.get('.editor-title', { timeout: longerCommandTimeout }).should('exist')
|
||||
cy.get('.btnCtrl', { timeout: longerCommandTimeout }).should('exist')
|
||||
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
||||
'contain.text',
|
||||
'SOME_CHAR IN'
|
||||
)
|
||||
assertClauseOverflowsCollapsedLine()
|
||||
|
||||
cy.screenshot('filter-panel-4-editor-collapsed', { capture: 'viewport' })
|
||||
|
||||
cy.get('.infoBar-toggle').click()
|
||||
cy.get('.infoBar').should('have.class', 'expanded')
|
||||
assertClauseFullyVisible()
|
||||
assertClauseSpansSeveralLines()
|
||||
|
||||
cy.screenshot('filter-panel-4-editor-expanded', { capture: 'viewport' })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 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 in the viewer and waits for its grid to render. */
|
||||
const openTable = (libMem: string) => {
|
||||
cy.visit(`${hostUrl}${appLocation}/#/view/data/${libMem}`, {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
cy.get('.filterSide', { timeout: longerCommandTimeout }).should('exist')
|
||||
cy.wait(2500)
|
||||
}
|
||||
|
||||
const openFilterPopup = () => {
|
||||
cy.get('.filterSide', { timeout: longerCommandTimeout }).click()
|
||||
cy.get('clr-dropdown-menu', { timeout: longerCommandTimeout })
|
||||
.contains('Filter')
|
||||
.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)
|
||||
}
|
||||
|
||||
/** IN/NOT IN take their values from a modal of checkboxes - take them all. */
|
||||
const chooseAllFilterValues = () => {
|
||||
cy.contains('.filter-modal button', 'Choose values').click()
|
||||
cy.get('.in-values-modal', { timeout: longerCommandTimeout }).should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.get('.in-values-modal input[type=checkbox]').then(($checkboxes: any) => {
|
||||
for (let i = 0; i < $checkboxes.length; i++) {
|
||||
cy.get('.in-values-modal input[type=checkbox]')
|
||||
.eq(i)
|
||||
.click({ force: true })
|
||||
}
|
||||
})
|
||||
cy.contains('.in-values-modal button', 'Apply').click()
|
||||
cy.wait(500)
|
||||
}
|
||||
|
||||
const submitFilter = () => {
|
||||
cy.contains('.filter-modal button', 'Ok').click()
|
||||
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
||||
cy.wait(2500)
|
||||
}
|
||||
|
||||
/** The clause is wider than the collapsed line, so it is clipped. */
|
||||
const assertClauseOverflowsCollapsedLine = () => {
|
||||
cy.get('.infoBar-text').then(($text: any) => {
|
||||
const el = $text[0] as HTMLElement
|
||||
expect(
|
||||
el.scrollWidth,
|
||||
'the clause is wider than the collapsed line'
|
||||
).to.be.greaterThan(el.clientWidth)
|
||||
expect(
|
||||
el.ownerDocument.defaultView!.getComputedStyle(el).whiteSpace,
|
||||
'collapsed panel does not wrap'
|
||||
).to.equal('nowrap')
|
||||
})
|
||||
}
|
||||
|
||||
/** The clause fits the collapsed line, so nothing is hidden. */
|
||||
const assertClauseFitsCollapsedLine = () => {
|
||||
cy.get('.infoBar-text').then(($text: any) => {
|
||||
const el = $text[0] as HTMLElement
|
||||
expect(el.scrollWidth, 'the clause fits the collapsed line').to.be.at.most(
|
||||
el.clientWidth
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Expanded, the whole clause is rendered and nothing is clipped. */
|
||||
const assertClauseFullyVisible = () => {
|
||||
cy.get('.infoBar-text').then(($text: any) => {
|
||||
const el = $text[0] as HTMLElement
|
||||
const style = el.ownerDocument.defaultView!.getComputedStyle(el)
|
||||
expect(style.whiteSpace, 'expanded panel wraps').to.equal('normal')
|
||||
expect(el.scrollWidth, 'expanded panel clips nothing').to.be.at.most(
|
||||
el.clientWidth
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Expanded, the clause occupies several lines rather than one. */
|
||||
const assertClauseSpansSeveralLines = () => {
|
||||
cy.get('.infoBar-text').then(($text: any) => {
|
||||
const el = $text[0] as HTMLElement
|
||||
const lineHeight =
|
||||
parseFloat(
|
||||
el.ownerDocument.defaultView!.getComputedStyle(el).lineHeight
|
||||
) || 20
|
||||
expect(
|
||||
el.clientHeight / lineHeight,
|
||||
'the clause spans several lines'
|
||||
).to.be.greaterThan(3)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Marks this file as an ES module (rather than a global script) so its
|
||||
// top-level consts don't collide, under the TS type-checker, with the same
|
||||
// names declared in other spec files.
|
||||
//
|
||||
// RUN ORDER MATTERS, and build.yaml reflects it: this spec must run after
|
||||
// csv-limited.cy.ts (which asserts the free tier and so needs the first slot,
|
||||
// before any spec applies a licence key) and before every spec that submits a
|
||||
// changeset. Test 2 reads the approval queue, which is paged oldest first, and
|
||||
// a changeset submitted by an earlier spec pushes this one off the first page.
|
||||
export {}
|
||||
|
||||
const hostUrl = Cypress.env('hosturl')
|
||||
const appLocation = Cypress.env('appLocation')
|
||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||
|
||||
context('hook program tests: ', function () {
|
||||
this.beforeAll(() => {
|
||||
cy.loginAndUpdateValidKey(true)
|
||||
})
|
||||
|
||||
this.beforeEach(() => {
|
||||
cy.visit(hostUrl + appLocation)
|
||||
|
||||
visitPage('home')
|
||||
})
|
||||
|
||||
// TESTDATA.DEMO_MIRROR is an empty mirror of TESTDATA.DEMO_ORDERS, registered
|
||||
// with a PRE_EDIT_HOOK and a POST_EDIT_HOOK (services/hooks/demo_mirror_*.js).
|
||||
// The mock services run those hooks the way the SAS backend %includes the
|
||||
// real hook programs - see dcMockUtils.mockHookSource.
|
||||
|
||||
it('1 | PRE_EDIT_HOOK: an empty mirror displays the live rows of its target table', () => {
|
||||
openTableFromTree('testdata', 'demo_mirror')
|
||||
|
||||
// The mirror holds no rows of its own: everything on screen comes from the
|
||||
// pre-edit hook, which loads TESTDATA.DEMO_ORDERS.
|
||||
cy.get('#hotTable .ht_master tbody tr', {
|
||||
timeout: longerCommandTimeout
|
||||
}).should('have.length', 4)
|
||||
|
||||
cy.get('#hotTable .ht_master tbody tr')
|
||||
.first()
|
||||
.should('contain.text', 'Acme Corp')
|
||||
|
||||
cy.get('#hotTable .ht_master tbody tr')
|
||||
.last()
|
||||
.should('contain.text', 'Delta Systems')
|
||||
|
||||
// The grid is validated against the MIRROR's own rule set (AMOUNT <= 2000),
|
||||
// not the real table's (AMOUNT <= 100000) - that is the point of routing
|
||||
// edits through a mirror.
|
||||
clickOnEdit(() => {
|
||||
editCell(0, 'AMOUNT', '5000')
|
||||
})
|
||||
|
||||
cy.get('#hotTable .ht_master tbody tr')
|
||||
.first()
|
||||
.find('td.htInvalid')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('2 | POST_EDIT_HOOK: a change submitted against the mirror is raised against the target', () => {
|
||||
openTableFromTree('testdata', 'demo_mirror')
|
||||
|
||||
clickOnEdit(() => {
|
||||
editCell(0, 'AMOUNT', '750')
|
||||
})
|
||||
|
||||
submitTable(() => {
|
||||
cy.get('textarea.submit-reason', { timeout: longerCommandTimeout }).type(
|
||||
'hook routing test'
|
||||
)
|
||||
submitTableMessage()
|
||||
})
|
||||
|
||||
// A successful submit sends the app to the staged-data page; wait for that
|
||||
// before navigating away, or the app's own redirect wins the race.
|
||||
cy.url({ timeout: longerCommandTimeout }).should('include', '/stage/')
|
||||
|
||||
// The post-edit hook re-points the changeset at TESTDATA.DEMO_ORDERS, so the
|
||||
// approval queue names the real table rather than the mirror. Search the
|
||||
// whole grid rather than one row: the queue also holds changesets from
|
||||
// earlier specs in the same run, so position is not a stable anchor.
|
||||
visitPage('review/approve')
|
||||
|
||||
cy.get('clr-datagrid clr-dg-row', { timeout: longerCommandTimeout }).should(
|
||||
($rows) => {
|
||||
const texts = Array.from($rows).map((row: any) =>
|
||||
row.innerText.trim().replace(/\s+/g, ' ')
|
||||
)
|
||||
|
||||
expect(
|
||||
texts.some((text) => text.includes('TESTDATA.DEMO_ORDERS')),
|
||||
`no approval row names TESTDATA.DEMO_ORDERS - rows: ${texts
|
||||
.slice(0, 5)
|
||||
.join(' | ')
|
||||
.slice(0, 400)}`
|
||||
).to.be.true
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── helpers (declared at the bottom, per the other specs) ───────────────────
|
||||
|
||||
const visitPage = (url: string) => {
|
||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||
}
|
||||
|
||||
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 viyaLib
|
||||
|
||||
for (let node of treeNodes) {
|
||||
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
|
||||
viyaLib = node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cy.get(viyaLib).within(() => {
|
||||
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')
|
||||
}
|
||||
|
||||
const clickOnEdit = (callback?: any) => {
|
||||
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
||||
.click()
|
||||
.then(() => {
|
||||
if (callback) callback()
|
||||
})
|
||||
}
|
||||
|
||||
// Edits a single cell, locating the column by its header label. Handsontable
|
||||
// renders the frozen header in a separate clone pane (.ht_clone_top) - the
|
||||
// header row inside .ht_master is kept visibility:hidden - so the labels are
|
||||
// read from the clone.
|
||||
const editCell = (rowIndex: number, colName: string, value: string) => {
|
||||
cy.get('#hotTable .ht_clone_top .htCore thead th', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then((headers: any) => {
|
||||
let colIndex = -1
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
if (
|
||||
String(headers[i].innerText || '')
|
||||
.trim()
|
||||
.toUpperCase() === colName.toUpperCase()
|
||||
) {
|
||||
colIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(colIndex, `column ${colName} present`).to.be.greaterThan(-1)
|
||||
|
||||
cy.get('#hotTable .ht_master tbody tr')
|
||||
.eq(rowIndex)
|
||||
.then((row: any) => {
|
||||
cy.get(row[0].childNodes[colIndex])
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused()
|
||||
.clear()
|
||||
.type(value + '{enter}')
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const submitTable = (callback?: any) => {
|
||||
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
||||
.click()
|
||||
.then(() => {
|
||||
if (callback) callback()
|
||||
})
|
||||
}
|
||||
|
||||
const submitTableMessage = (callback?: any) => {
|
||||
cy.get('.modal-footer .btn.btn-sm.btn-success-outline', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.click()
|
||||
.then(() => {
|
||||
if (callback) callback()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
// The EDIT screen refuses a selection that is too large - DC_MAXOBS_WEBEDIT
|
||||
// rows, or DC_MAXCELLS_WEBEDIT cells (rows x columns), whichever is reached
|
||||
// first - and the VIEW screen caps at DC_MAXOBS_WEBVIEW rows.
|
||||
//
|
||||
// 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,400 cells), under the row limit
|
||||
// but over the CELL limit
|
||||
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 refuses a table over the row limit', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
|
||||
openEdit()
|
||||
|
||||
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
|
||||
'contain.text',
|
||||
'Table is too big (1000 rows) - please filter and try again!'
|
||||
)
|
||||
})
|
||||
|
||||
it('3 | EDIT refuses a table over the cell limit', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_wide')
|
||||
openEdit()
|
||||
|
||||
// 200 rows x 1002 columns (the 1001 stored columns plus the housekeeping
|
||||
// delete column) = 200,400 cells, over the 200,000 cell limit, while the
|
||||
// row count is under the 250 row limit.
|
||||
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
|
||||
'contain.text',
|
||||
'Selection is too wide (200 rows x 1002 cols) - please filter and try again!'
|
||||
)
|
||||
})
|
||||
|
||||
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 18 columns is 126 cells, so
|
||||
// at 110 the TESTDATA.MPE_X_NEW edit must be refused, 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 17 x 6 = 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('.abortMsg', { timeout: longerCommandTimeout }).should(
|
||||
'contain.text',
|
||||
'Selection is too wide (7 rows x 18 cols) - please filter and try again!'
|
||||
)
|
||||
})
|
||||
|
||||
// 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()
|
||||
|
||||
// 126 cells under a 200000 cell limit: the editor loads, no abort.
|
||||
cy.get('#hotTable .ht_master .htCore tbody tr', {
|
||||
timeout: longerCommandTimeout
|
||||
}).should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('5 | the filter picker still lists the variables after the abort', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
|
||||
openEdit()
|
||||
|
||||
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
|
||||
'contain.text',
|
||||
'Table is too big (1000 rows) - please filter and try again!'
|
||||
)
|
||||
|
||||
closeAbortModal()
|
||||
|
||||
// The abort returns no table, and the picker's variable list normally comes
|
||||
// from the table's own columns - so without the public/getcols fallback the
|
||||
// "please filter and try again" advice has nothing to pick from. Every
|
||||
// column of MPE_X_SEARCH must be on offer.
|
||||
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'
|
||||
])
|
||||
})
|
||||
|
||||
// and the suggestion list is usable, not just populated
|
||||
setFilterVariable('SITE_NAME')
|
||||
|
||||
// Submitting is what exercises TYPE. The picker keys its operator set and
|
||||
// its value quoting on it, and the fallback list never passes through
|
||||
// mergeColsRules, so without TYPE on this payload a character value would be
|
||||
// submitted unquoted and the backend's %mp_filtercheck would reject it.
|
||||
setFilterValue('Bristol')
|
||||
submitFilter()
|
||||
|
||||
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
||||
'contain.text',
|
||||
"SITE_NAME = 'Bristol'"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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 abort modal offers Close, and the filter button sits behind it. */
|
||||
const closeAbortModal = () => {
|
||||
cy.get('app-info-modal', { timeout: longerCommandTimeout })
|
||||
.contains('button', 'Close')
|
||||
.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')
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const check = (cwd) => {
|
||||
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;',
|
||||
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@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;handsontable@18.1.1;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) => {
|
||||
if (error) {
|
||||
|
||||
Generated
+271
-469
File diff suppressed because it is too large.
Load diff
+14
-14
@@ -41,21 +41,21 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.3.27",
|
||||
"@angular/animations": "20.3.33",
|
||||
"@angular/cdk": "^20.2.14",
|
||||
"@angular/common": "^20.3.27",
|
||||
"@angular/compiler": "^20.3.27",
|
||||
"@angular/core": "^20.3.27",
|
||||
"@angular/forms": "^20.3.27",
|
||||
"@angular/platform-browser": "^20.3.27",
|
||||
"@angular/platform-browser-dynamic": "^20.3.27",
|
||||
"@angular/router": "^20.3.27",
|
||||
"@angular/common": "20.3.33",
|
||||
"@angular/compiler": "20.3.33",
|
||||
"@angular/core": "20.3.33",
|
||||
"@angular/forms": "20.3.33",
|
||||
"@angular/platform-browser": "20.3.33",
|
||||
"@angular/platform-browser-dynamic": "20.3.33",
|
||||
"@angular/router": "20.3.33",
|
||||
"@cds/core": "^6.15.1",
|
||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||
"@clr/icons": "^13.0.2",
|
||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||
"@handsontable/angular-wrapper": "18.0.0",
|
||||
"@sasjs/adapter": "^4.18.0",
|
||||
"@handsontable/angular-wrapper": "18.1.1",
|
||||
"@sasjs/adapter": "4.19.1",
|
||||
"@sasjs/utils": "^3.6.0",
|
||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||
"@types/d3-graphviz": "^2.6.7",
|
||||
@@ -69,7 +69,7 @@
|
||||
"d3-graphviz": "^5.0.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"fs-extra": "^7.0.1",
|
||||
"handsontable": "18.0.0",
|
||||
"handsontable": "18.1.1",
|
||||
"https-browserify": "1.0.0",
|
||||
"hyperformula": "^2.5.0",
|
||||
"iconv-lite": "^0.5.0",
|
||||
@@ -99,8 +99,8 @@
|
||||
"@angular-eslint/eslint-plugin-template": "19.8.1",
|
||||
"@angular-eslint/schematics": "19.8.1",
|
||||
"@angular-eslint/template-parser": "19.8.1",
|
||||
"@angular/cli": "^20.3.32",
|
||||
"@angular/compiler-cli": "^20.3.27",
|
||||
"@angular/cli": "20.3.37",
|
||||
"@angular/compiler-cli": "20.3.33",
|
||||
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||
"@compodoc/compodoc": "^2.0.0",
|
||||
"@cypress/webpack-preprocessor": "^5.17.1",
|
||||
@@ -148,7 +148,7 @@
|
||||
"ajv": "8.18.0",
|
||||
"uuid": "11.1.1",
|
||||
"lighthouse": "13.4.0",
|
||||
"fast-uri": "3.1.7",
|
||||
"fast-uri": "3.1.8",
|
||||
"readdir-glob": {
|
||||
"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] .htContextMenu 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 {
|
||||
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td.htSubmenu .htItemWrapper::after {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
@@ -12,14 +11,6 @@
|
||||
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 {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
|
||||
@@ -318,6 +318,7 @@
|
||||
class="startup-check"
|
||||
[class.startup-check--error]="step.status === 'error'"
|
||||
[class.startup-check--done]="step.status === 'done'"
|
||||
[class.startup-check--grid]="step.details?.length"
|
||||
>
|
||||
@switch (step.status) {
|
||||
@case ('pending') {
|
||||
@@ -346,6 +347,18 @@
|
||||
@if (step.detail) {
|
||||
<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>
|
||||
|
||||
@@ -20,6 +20,8 @@ import { DcAdapterSettings } from './models/DcAdapterSettings'
|
||||
import { AppStoreService } from './services/app-store.service'
|
||||
import { LicenceService } from './services/licence.service'
|
||||
import {
|
||||
getSasjsTagDetails,
|
||||
SASJS_TAG_STEP,
|
||||
StartupCheckService,
|
||||
StartupStep
|
||||
} from './services/startup-check.service'
|
||||
@@ -190,40 +192,45 @@ export class AppComponent {
|
||||
* Parses adapter settings that are found in the <sasjs> tag inside index.html
|
||||
*/
|
||||
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')
|
||||
|
||||
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) =>
|
||||
sasjsElement.getAttribute(attribute) || undefined
|
||||
sasjsElement?.getAttribute(attribute) || undefined
|
||||
|
||||
const dcAdapterSettings = {
|
||||
serverUrl: getAppAttribute('serverUrl') || '',
|
||||
appLoc: getAppAttribute('appLoc') || '',
|
||||
serverType: getAppAttribute('serverType'),
|
||||
loginMechanism: getAppAttribute('loginMechanism') || '',
|
||||
adminGroup: getAppAttribute('adminGroup') || '',
|
||||
dcPath: getAppAttribute('dcPath') || '',
|
||||
debug: getAppAttribute('debug') === 'true' || false,
|
||||
useComputeApi: this.parseComputeApi(getAppAttribute('useComputeApi')),
|
||||
runAsTask: getAppAttribute('runAsTask') === 'true' || false,
|
||||
contextName: getAppAttribute('contextName') || '',
|
||||
hotLicenceKey: getAppAttribute('hotLicenceKey') || ''
|
||||
contextName: getAppAttribute('contextName') || ''
|
||||
}
|
||||
|
||||
this.dcAdapterSettings = 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { DeployComponent } from './deploy.component'
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', component: DeployComponent },
|
||||
{ path: 'manualdeploy', component: DeployComponent }
|
||||
]
|
||||
const routes: Routes = [{ path: '', component: DeployComponent }]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
|
||||
@@ -29,20 +29,6 @@
|
||||
></label
|
||||
>
|
||||
</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>
|
||||
}
|
||||
@@ -52,29 +38,17 @@
|
||||
@if (step > 0) {
|
||||
@switch (true) {
|
||||
@case (sasJsConfig.serverType === ServerType.SasViya) {
|
||||
@if (autodeploy) {
|
||||
<div class="autodeploy-section card">
|
||||
<app-automatic-deploy
|
||||
[sasJs]="sasJs"
|
||||
[sasJsConfig]="sasJsConfig"
|
||||
[dcAdapterSettings]="dcAdapterSettings"
|
||||
[appLoc]="appLoc"
|
||||
[dcPath]="dcPath"
|
||||
[selectedAdminGroup]="selectedAdminGroup"
|
||||
(onNavigateToHome)="onNavigateToHome()"
|
||||
></app-automatic-deploy>
|
||||
</div>
|
||||
}
|
||||
@if (!autodeploy) {
|
||||
<div id="mainbody" class="card">
|
||||
<app-manual-deploy
|
||||
[sasJs]="sasJs"
|
||||
[sasJsConfig]="sasJsConfig"
|
||||
[dcAdapterSettings]="dcAdapterSettings"
|
||||
(onNavigateToHome)="onNavigateToHome()"
|
||||
></app-manual-deploy>
|
||||
</div>
|
||||
}
|
||||
<div class="autodeploy-section card">
|
||||
<app-automatic-deploy
|
||||
[sasJs]="sasJs"
|
||||
[sasJsConfig]="sasJsConfig"
|
||||
[dcAdapterSettings]="dcAdapterSettings"
|
||||
[appLoc]="appLoc"
|
||||
[dcPath]="dcPath"
|
||||
[selectedAdminGroup]="selectedAdminGroup"
|
||||
(onNavigateToHome)="onNavigateToHome()"
|
||||
></app-automatic-deploy>
|
||||
</div>
|
||||
}
|
||||
@case (sasJsConfig.serverType === ServerType.Sasjs) {
|
||||
<div class="autodeploy-section card">
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Component, OnInit, ViewEncapsulation } from '@angular/core'
|
||||
import { SasService } from '../services/sas.service'
|
||||
import { SASjsConfig } from '@sasjs/adapter'
|
||||
import { Router } from '@angular/router'
|
||||
import { LoggerService } from '../services/logger.service'
|
||||
import { ServerType } from '@sasjs/utils/types/serverType'
|
||||
import { AppStoreService } from '../services/app-store.service'
|
||||
import { DcAdapterSettings } from '../models/DcAdapterSettings'
|
||||
@@ -19,15 +17,11 @@ import { DcAdapterSettings } from '../models/DcAdapterSettings'
|
||||
})
|
||||
export class DeployComponent implements OnInit {
|
||||
public step: number = 0
|
||||
public adminGroups: any = []
|
||||
|
||||
public appLoc: string = ''
|
||||
public dcPath: string = ''
|
||||
public selectedAdminGroup: string = ''
|
||||
|
||||
public autodeploy: boolean = true
|
||||
public jsonFile: any = null
|
||||
|
||||
public sasJs: any
|
||||
public sasJsConfig: SASjsConfig = new SASjsConfig()
|
||||
|
||||
@@ -37,16 +31,10 @@ export class DeployComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
private appStoreService: AppStoreService,
|
||||
private sasService: SasService,
|
||||
private loggerService: LoggerService,
|
||||
private router: Router
|
||||
private sasService: SasService
|
||||
) {
|
||||
this.dcAdapterSettings = this.appStoreService.getDcAdapterSettings()
|
||||
|
||||
if (this.router.url.includes('manualdeploy')) {
|
||||
this.autodeploy = false
|
||||
}
|
||||
|
||||
this.sasJs = this.sasService.getSasjsInstance()
|
||||
this.sasJsConfig = this.sasService.getSasjsConfig()
|
||||
this.appLoc = this.dcAdapterSettings?.appLoc || ''
|
||||
@@ -61,54 +49,16 @@ export class DeployComponent implements OnInit {
|
||||
*/
|
||||
public setDeployDefaults() {
|
||||
this.dcPath = this.dcAdapterSettings?.dcPath || ''
|
||||
this.selectedAdminGroup = this.dcAdapterSettings?.adminGroup || ''
|
||||
if (!this.selectedAdminGroup) {
|
||||
this.selectedAdminGroup = 'SASAdministrators'
|
||||
}
|
||||
this.selectedAdminGroup = 'SASAdministrators'
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepting terms of service shows next screen
|
||||
*/
|
||||
public termsAgreeChange() {
|
||||
if (!this.autodeploy) {
|
||||
this.getAdminGroups()
|
||||
}
|
||||
|
||||
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() {
|
||||
window.open(location.href.split('#')[0], '_blank')
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { DeployComponent } from './deploy.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 { ClarityModule } from '@clr/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
@@ -12,7 +11,6 @@ import { DeployRoutingModule } from './deploy-routing.module'
|
||||
declarations: [
|
||||
DeployComponent,
|
||||
AutomaticComponent,
|
||||
ManualComponent,
|
||||
SasjsConfiguratorComponent
|
||||
],
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -369,11 +369,30 @@
|
||||
</div>
|
||||
}
|
||||
@if (!['', ' '].includes(queryText)) {
|
||||
<div class="clr-col-md-12 infoBar">
|
||||
<span
|
||||
<div
|
||||
#infoBar
|
||||
class="clr-col-md-12 infoBar"
|
||||
[class.expanded]="filterExpanded"
|
||||
>
|
||||
<span class="infoBar-text"
|
||||
>FILTER :
|
||||
<b>{{ queryText }}</b>
|
||||
</span>
|
||||
@if (filterOverflows) {
|
||||
<button
|
||||
type="button"
|
||||
class="infoBar-toggle"
|
||||
[attr.aria-expanded]="filterExpanded"
|
||||
[attr.aria-label]="
|
||||
filterExpanded
|
||||
? 'Collapse the filter clause'
|
||||
: 'Expand the filter clause'
|
||||
"
|
||||
(click)="toggleFilterPanel()"
|
||||
>
|
||||
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AfterViewChecked,
|
||||
AfterViewInit,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
@@ -112,7 +113,9 @@ import { ParseResult } from '../models/ParseResult.interface'
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
export class EditorComponent
|
||||
implements OnInit, AfterViewInit, AfterViewChecked, OnDestroy
|
||||
{
|
||||
@ViewChildren('uploadStater')
|
||||
uploadStaterCompList: QueryList<UploadStaterComponent> = new QueryList()
|
||||
@ViewChildren('queryFilter')
|
||||
@@ -525,6 +528,19 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
public tableData: Array<any> = []
|
||||
public queryText = ''
|
||||
public queryTextSaved = ''
|
||||
|
||||
/**
|
||||
* The applied-filter panel is collapsed to a single line by default. The
|
||||
* chevron that expands it is only useful when the clause does not fit that
|
||||
* line, which depends on the rendered width - so it is measured from the DOM
|
||||
* rather than guessed from the length of the text.
|
||||
*/
|
||||
public filterExpanded = false
|
||||
public filterOverflows = false
|
||||
|
||||
@ViewChild('infoBar') infoBar?: ElementRef<HTMLElement>
|
||||
|
||||
private filterMeasuredKey = ''
|
||||
public showApprovers = false
|
||||
public pkDups = false
|
||||
public validationDone = 0
|
||||
@@ -1190,7 +1206,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.cdf.detectChanges()
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3547,6 +3572,48 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewChecked() {
|
||||
this.scheduleFilterOverflowCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures whether the applied-filter clause fits the collapsed single line,
|
||||
* and shows or hides the chevron accordingly. The measurement is deferred
|
||||
* because `filterOverflows` and `filterExpanded` are bound in this view -
|
||||
* setting them inside the change-detection cycle would raise
|
||||
* ExpressionChangedAfterItHasBeenChecked.
|
||||
*/
|
||||
private scheduleFilterOverflowCheck() {
|
||||
const el = this.infoBar?.nativeElement
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = el.querySelector('.infoBar-text') as HTMLElement | null
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
// re-measure only when the clause, the available width, or the state changes
|
||||
const key = `${this.queryText}|${text.clientWidth}|${this.filterExpanded}`
|
||||
if (key === this.filterMeasuredKey) {
|
||||
return
|
||||
}
|
||||
this.filterMeasuredKey = key
|
||||
|
||||
setTimeout(() => {
|
||||
// while expanded the clause wraps, so there is nothing to measure
|
||||
if (this.filterExpanded) {
|
||||
return
|
||||
}
|
||||
this.filterOverflows = text.scrollWidth > text.clientWidth
|
||||
})
|
||||
}
|
||||
|
||||
public toggleFilterPanel() {
|
||||
this.filterExpanded = !this.filterExpanded
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
// Fix ARIA accessibility issues after table initialization
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -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
|
||||
* any table bigger than that cap outright. See initSetup's own
|
||||
* 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', () => {
|
||||
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')
|
||||
document.body.appendChild(container)
|
||||
const colNames = ['PK', 'A', 'B']
|
||||
@@ -440,7 +445,7 @@ describe('maxRows must never be smaller than the actual loaded row count', () =>
|
||||
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)', () => {
|
||||
|
||||
@@ -287,9 +287,13 @@ describe('syncOverwrittenCommentForCell resolves the correct visual cell on a so
|
||||
* trigger it, independent of what it actually changes. editTable(),
|
||||
* cancelEdit(), and every other place editor.component.ts calls
|
||||
* updateSettings() after the grid could already be sorted (e.g. the user
|
||||
* sorted while still read-only, then clicked Edit) hits this. The fix
|
||||
* (updateSettingsSortSafe) clears the sort before the call and restores it
|
||||
* immediately afterward.
|
||||
* sorted while still read-only, then clicked Edit) hits this.
|
||||
*
|
||||
* 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', () => {
|
||||
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')
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
hot.updateSettings({}, false)
|
||||
hot.render()
|
||||
|
||||
expect(formulaHardColValues(hot)).toContain('#REF!')
|
||||
expect(formulaHardColValues(hot)).not.toContain('#REF!')
|
||||
|
||||
hot.destroy()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { SASjsConfig } from '@sasjs/adapter'
|
||||
|
||||
export interface DcAdapterSettings extends Partial<SASjsConfig> {
|
||||
adminGroup: string
|
||||
dcPath: string
|
||||
hotLicenceKey: string
|
||||
}
|
||||
@@ -8,13 +8,20 @@ import * as encoding from 'text-encoding'
|
||||
import { Router } from '@angular/router'
|
||||
import { Globvar } from '../models/sas/public-startupservice.model'
|
||||
import { freeTierConfig } from '../free-tier.config'
|
||||
import { AppStoreService } from './app-store.service'
|
||||
import { HelperService } from './helper.service'
|
||||
import { LicenceState } from '../models/LicenceState'
|
||||
import { decodeLicenceFeatures as decodeFeatures } from './utils/decodeLicenceFeatures'
|
||||
import { LoggerService } from './logger.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({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -43,7 +50,7 @@ export class LicenceService {
|
||||
|
||||
private freeTierLicenceData: LicenseKeyData = {
|
||||
demo: true,
|
||||
hot_license_key: this.appStoreService.getDcAdapterSettings()?.hotLicenceKey,
|
||||
hot_license_key: DEFAULT_HOT_LICENCE_KEY,
|
||||
users_allowed: this._licenceState.users_allowed || 1,
|
||||
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
|
||||
site_id: '',
|
||||
@@ -63,7 +70,6 @@ export class LicenceService {
|
||||
|
||||
constructor(
|
||||
private loggerService: LoggerService,
|
||||
private appStoreService: AppStoreService,
|
||||
private eventService: EventService,
|
||||
private sasService: SasService,
|
||||
private helperService: HelperService,
|
||||
@@ -79,8 +85,7 @@ export class LicenceService {
|
||||
}
|
||||
|
||||
public async activation(startupservice: any) {
|
||||
this.freeTierLicenceData.hot_license_key =
|
||||
this.appStoreService.getDcAdapterSettings()?.hotLicenceKey
|
||||
this.freeTierLicenceData.hot_license_key = DEFAULT_HOT_LICENCE_KEY
|
||||
|
||||
await this.setStartupserviceRules(startupservice)
|
||||
|
||||
@@ -225,8 +230,7 @@ export class LicenceService {
|
||||
? licenseData.hot_license_key
|
||||
: this.hot_license_key.value
|
||||
|
||||
if (!hotLicenceKey)
|
||||
hotLicenceKey = this.appStoreService.getDcAdapterSettings()?.hotLicenceKey
|
||||
if (!hotLicenceKey) hotLicenceKey = DEFAULT_HOT_LICENCE_KEY
|
||||
|
||||
this.hot_license_key.next(hotLicenceKey)
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { SasStoreService } from './sas-store.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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -295,6 +295,68 @@ export class SasStoreService {
|
||||
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) {
|
||||
let obj = { TABLE: tableId }
|
||||
let table = { SASControlTable: [obj] }
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { DEBUG_STATE_KEY, SasService } from './sas.service'
|
||||
|
||||
/**
|
||||
* SasService's constructor is a plain 8-argument constructor with an empty
|
||||
* body, and `request()` touches only a handful of collaborators, so this
|
||||
* stubs exactly that surface - no TestBed/DI needed, same precedent as
|
||||
* app.service.spec.ts and va-filter.service.spec.ts.
|
||||
*
|
||||
* The SASjs adapter is not built by the constructor either (sasServiceInit
|
||||
* does that), so it is assigned directly - and its `request` spy is where the
|
||||
* outgoing body is observable, which is the whole point of these tests.
|
||||
*/
|
||||
const buildService = () => {
|
||||
const sasjsAdapter: any = {
|
||||
request: jasmine.createSpy('request').and.resolveTo({}),
|
||||
getSasRequests: () => [],
|
||||
setDebugState: jasmine.createSpy('setDebugState')
|
||||
}
|
||||
const loggerService: any = {
|
||||
logRequestData: jasmine.createSpy('logRequestData')
|
||||
}
|
||||
const userService: any = { user: undefined }
|
||||
const eventService: any = {
|
||||
showAbortModal: jasmine.createSpy('showAbortModal'),
|
||||
startupDataLoaded: jasmine.createSpy('startupDataLoaded')
|
||||
}
|
||||
const router: any = { navigateByUrl: jasmine.createSpy('navigateByUrl') }
|
||||
|
||||
const service = new SasService(
|
||||
{} as any, // appStoreService
|
||||
userService,
|
||||
eventService,
|
||||
{} as any, // sasjsService
|
||||
{} as any, // sasViyaService
|
||||
loggerService,
|
||||
{} as any, // startupCheckService
|
||||
router
|
||||
)
|
||||
;(service as any).sasjsAdapter = sasjsAdapter
|
||||
|
||||
return { service, sasjsAdapter }
|
||||
}
|
||||
|
||||
/** The body the adapter was asked to send for the most recent request. */
|
||||
const sentBody = (sasjsAdapter: any) =>
|
||||
sasjsAdapter.request.calls.mostRecent().args[1]
|
||||
|
||||
describe('SasService diagnostics payload', () => {
|
||||
it('gives the startup service a body, so browser_info can ride on it', async () => {
|
||||
const { service, sasjsAdapter } = buildService()
|
||||
|
||||
// Both startup service call sites pass null - there is nothing to send -
|
||||
// and the service is the one whose log a support ticket is read from.
|
||||
await service.request('public/startupservice', null)
|
||||
|
||||
const body = sentBody(sasjsAdapter)
|
||||
expect(body).toBeTruthy()
|
||||
expect(body.browser_info.length).toBe(1)
|
||||
expect(body.browser_info[0].user_agent).toBe(navigator.userAgent)
|
||||
expect('timezone' in body.browser_info[0]).toBe(true)
|
||||
expect('tz_offset' in body.browser_info[0]).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a non-diagnostics service payload alone', async () => {
|
||||
const { service, sasjsAdapter } = buildService()
|
||||
|
||||
await service.request('public/viewlibs', null)
|
||||
|
||||
expect(sentBody(sasjsAdapter)).toBeNull()
|
||||
})
|
||||
|
||||
it('adds browser_info to a body that already carries content', async () => {
|
||||
const { service, sasjsAdapter } = buildService()
|
||||
|
||||
await service.request('editors/getdata', { table: 'MPE_X_TEST' })
|
||||
|
||||
const body = sentBody(sasjsAdapter)
|
||||
expect(body.table).toBe('MPE_X_TEST')
|
||||
expect(body.browser_info.length).toBe(1)
|
||||
})
|
||||
|
||||
it('does not overwrite a browser_info the caller supplied', async () => {
|
||||
const { service, sasjsAdapter } = buildService()
|
||||
const supplied = [{ url: 'supplied by the caller' }]
|
||||
|
||||
await service.request('editors/getdata', { browser_info: 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)
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,33 @@ import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapper
|
||||
import { SasViyaService } from './sas-viya.service'
|
||||
import { ViyaApiFolder } from '../viya-api-explorer/models/viya-api-folder.model'
|
||||
import { ViyaApiFolderMembers } from '../viya-api-explorer/models/viya-api-folder-content.model'
|
||||
import { parseUserAgent } from '../shared/utils/parse-user-agent'
|
||||
import { VERSION } from '../../environments/version'
|
||||
|
||||
/**
|
||||
* Services that receive the `browser_info` / `browser_url_vars` diagnostics
|
||||
* tables: the startup service (so every session logs its context once) and
|
||||
* every service that executes customer-provided code - hook scripts via
|
||||
* %mpe_runhook (getdata, stagedata, loadfile, restore, postdata) and the
|
||||
* dynamic cell dropdown programs (getdynamiccolvals). Other services never
|
||||
* see the tables, so the payload stays off the high-frequency calls.
|
||||
*/
|
||||
const DIAGNOSTICS_SERVICES = [
|
||||
'services/public/startupservice',
|
||||
'services/editors/getdata',
|
||||
'services/editors/getdynamiccolvals',
|
||||
'services/editors/stagedata',
|
||||
'services/editors/loadfile',
|
||||
'services/editors/restore',
|
||||
'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({
|
||||
providedIn: 'root'
|
||||
@@ -69,6 +96,13 @@ export class SasService {
|
||||
|
||||
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) {
|
||||
case ServerType.SasViya: {
|
||||
this.checkViyaDeploy(this.dcAdapterSettings.appLoc || '')
|
||||
@@ -118,6 +152,50 @@ export class SasService {
|
||||
|
||||
if (!wrapperOptions) wrapperOptions = {}
|
||||
|
||||
// Support diagnostics: tell the backend where the request came from.
|
||||
// Sent only to the services that can act on it - the startup service
|
||||
// (so every session logs its context once) and the services that
|
||||
// %include customer-provided code (hook scripts, dynamic cell dropdown
|
||||
// programs). This is the URL of this page - the Data Controller iframe -
|
||||
// not the document that embeds it, so an embedded report can be
|
||||
// distinguished. The browser_url_vars table carries the URL parameters
|
||||
// as name/value pairs, which is easier for a SAS developer to read than
|
||||
// parsing the url string.
|
||||
// The startup service is called with a null payload - there is nothing to
|
||||
// send - so give it one to carry the diagnostics. It is the service whose
|
||||
// log a support ticket is read from, and without this it never receives
|
||||
// them.
|
||||
if (
|
||||
DIAGNOSTICS_SERVICES.includes(url) &&
|
||||
(data === null || data === undefined)
|
||||
) {
|
||||
data = {}
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object' && !data.browser_info) {
|
||||
if (DIAGNOSTICS_SERVICES.includes(url)) {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const ua = parseUserAgent(navigator.userAgent)
|
||||
data.browser_info = [
|
||||
{
|
||||
url: window.location.href,
|
||||
referrer: document.referrer,
|
||||
timezone: tz || '',
|
||||
tz_offset: new Date().getTimezoneOffset(),
|
||||
locale: navigator.language || '',
|
||||
dc_version: VERSION.semverString,
|
||||
adapter_version: VERSION.adapterVersion,
|
||||
browser: ua.browser,
|
||||
browser_version: ua.browserVersion,
|
||||
platform: ua.platform,
|
||||
user_agent: navigator.userAgent || ''
|
||||
}
|
||||
]
|
||||
const urlVars = this.collectBrowserUrlVars()
|
||||
if (urlVars.length) data.browser_url_vars = urlVars
|
||||
}
|
||||
}
|
||||
|
||||
// If debug is on it will print what is going inside the adapter
|
||||
this.loggerService.logRequestData(url, data)
|
||||
|
||||
@@ -263,6 +341,34 @@ export class SasService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the page URL parameters as name/value pairs for the
|
||||
* `browser_url_vars` table. Parameters appear both in the search string
|
||||
* (before the hash) and in the hash query string (Angular routes carry
|
||||
* them after the `#`), so both are read - search first, and the first
|
||||
* occurrence of a name wins, so the search string value beats the hash
|
||||
* value on a collision.
|
||||
*/
|
||||
private collectBrowserUrlVars(): Array<{ name: string; value: string }> {
|
||||
const vars: { name: string; value: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const collect = (search: string) => {
|
||||
new URLSearchParams(search).forEach((value, name) => {
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name)
|
||||
vars.push({ name, value })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (window.location.search) collect(window.location.search.slice(1))
|
||||
const hashQuery = window.location.hash.split('?')[1]
|
||||
if (hashQuery) collect(hashQuery)
|
||||
|
||||
return vars
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to the backend, using the adapter upload function.
|
||||
*
|
||||
@@ -711,6 +817,7 @@ export class SasService {
|
||||
}
|
||||
|
||||
public setDebugState(state: boolean) {
|
||||
localStorage.setItem(DEBUG_STATE_KEY, String(state))
|
||||
this.sasjsAdapter.setDebugState(state)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
/** A name/value pair shown under a startup step. */
|
||||
export interface StartupStepDetail {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface StartupStep {
|
||||
label: string
|
||||
status: StartupStepStatus
|
||||
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' })
|
||||
export class StartupCheckService {
|
||||
private steps: StartupStep[] = [
|
||||
{ label: 'Checking app location', 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])
|
||||
@@ -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[]) {
|
||||
this.steps = steps
|
||||
this.steps$.next([...this.steps])
|
||||
|
||||
@@ -553,11 +553,27 @@ describe('DC Validator', () => {
|
||||
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', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
validatorOf(someNumRule).call(
|
||||
fakeCellProps(true),
|
||||
'not a number',
|
||||
(valid: boolean) => {
|
||||
@@ -570,7 +586,7 @@ describe('DC Validator', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
validatorOf(someNumRule).call(
|
||||
fakeCellProps(true),
|
||||
null,
|
||||
(valid: boolean) => {
|
||||
@@ -583,7 +599,7 @@ describe('DC Validator', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
validatorOf(someNumRule).call(
|
||||
fakeCellProps(false),
|
||||
'not a number',
|
||||
(valid: boolean) => {
|
||||
@@ -596,7 +612,7 @@ describe('DC Validator', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
validatorOf(someNumRule).call(
|
||||
fakeCellProps(false),
|
||||
2,
|
||||
(valid: boolean) => {
|
||||
@@ -609,7 +625,7 @@ describe('DC Validator', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')
|
||||
|
||||
pkRule!.validator!.call(
|
||||
validatorOf(pkRule).call(
|
||||
fakeCellProps(true),
|
||||
'not a number',
|
||||
(valid: boolean) => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SidebarComponent } from './sidebar.component'
|
||||
|
||||
describe('SidebarComponent', () => {
|
||||
// getSubPage is a pure read of the router's current URL, and the only other
|
||||
// dependencies are the two SasService calls in the constructor - so the
|
||||
// class can be constructed directly, without a TestBed.
|
||||
const buildComponent = (routerUrl: string) =>
|
||||
new SidebarComponent(
|
||||
{ url: routerUrl } as any,
|
||||
{} as any,
|
||||
{
|
||||
getSasjsConfig: () => ({}),
|
||||
getServerType: () => 'SASVIYA'
|
||||
} as any
|
||||
)
|
||||
|
||||
describe('getSubPage', () => {
|
||||
it('returns the sub-page segment of a plain route', () => {
|
||||
expect(buildComponent('/home/tables').getSubPage()).toEqual('tables')
|
||||
})
|
||||
|
||||
it('excludes the query string - a VA report embed always adds one', () => {
|
||||
expect(buildComponent('/home/tables?embed=va').getSubPage()).toEqual(
|
||||
'tables'
|
||||
)
|
||||
})
|
||||
|
||||
it('excludes the query string on the viewer route', () => {
|
||||
expect(buildComponent('/view/data?labels=true').getSubPage()).toEqual(
|
||||
'data'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -67,7 +67,10 @@ export class SidebarComponent implements OnInit {
|
||||
}
|
||||
|
||||
public getSubPage() {
|
||||
let url = this._router.url.split('/')
|
||||
// `Router.url` carries the query string, so take the path segment only -
|
||||
// otherwise any route with a parameter renders it as part of the label
|
||||
// (a VA report embed always adds one, e.g. `?embed=va`).
|
||||
let url = this._router.url.split('?')[0].split('/')
|
||||
|
||||
return url[2]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { parseUserAgent } from './parse-user-agent'
|
||||
|
||||
/**
|
||||
* Table-driven: the point of the parser is the token ORDER, and the only way
|
||||
* to pin an order is to feed it real user agent strings that carry several
|
||||
* tokens at once. Every case below is a verbatim UA from the product named
|
||||
* (the iOS ones are the ones that used to fall through to Safari).
|
||||
*/
|
||||
describe('parseUserAgent', () => {
|
||||
const cases: Array<{
|
||||
label: string
|
||||
ua: string
|
||||
browser: string
|
||||
version: string
|
||||
platform: string
|
||||
}> = [
|
||||
{
|
||||
label: 'desktop Edge (Edg)',
|
||||
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.2592.87',
|
||||
browser: 'Edge',
|
||||
version: '126.0.2592.87',
|
||||
platform: 'Windows'
|
||||
},
|
||||
{
|
||||
label: 'desktop Edge (legacy EdgeHTML)',
|
||||
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/18.17763',
|
||||
browser: 'Edge',
|
||||
version: '18.17763',
|
||||
platform: 'Windows'
|
||||
},
|
||||
{
|
||||
label: 'desktop Opera (OPR)',
|
||||
ua: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 OPR/111.0.0.0',
|
||||
browser: 'Opera',
|
||||
version: '111.0.0.0',
|
||||
platform: 'Linux'
|
||||
},
|
||||
{
|
||||
label: 'desktop Firefox',
|
||||
ua: 'Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0',
|
||||
browser: 'Firefox',
|
||||
version: '140.0',
|
||||
platform: 'Linux'
|
||||
},
|
||||
{
|
||||
label: 'desktop Chrome',
|
||||
ua: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||||
browser: 'Chrome',
|
||||
version: '126.0.0.0',
|
||||
platform: 'Linux'
|
||||
},
|
||||
{
|
||||
label: 'desktop Safari',
|
||||
ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
|
||||
browser: 'Safari',
|
||||
version: '17.5',
|
||||
platform: 'macOS'
|
||||
},
|
||||
{
|
||||
label: 'Android Chrome',
|
||||
ua: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36',
|
||||
browser: 'Chrome',
|
||||
version: '126.0.0.0',
|
||||
platform: 'Android'
|
||||
},
|
||||
{
|
||||
label: 'iOS Chrome (CriOS) - not Safari',
|
||||
ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.153 Mobile/15E148 Safari/604.1',
|
||||
browser: 'Chrome',
|
||||
version: '126.0.6478.153',
|
||||
platform: 'iOS'
|
||||
},
|
||||
{
|
||||
label: 'iOS Firefox (FxiOS) - not Safari',
|
||||
ua: 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/127.0 Mobile/15E148 Safari/605.1.15',
|
||||
browser: 'Firefox',
|
||||
version: '127.0',
|
||||
platform: 'iOS'
|
||||
},
|
||||
{
|
||||
label: 'iOS Edge (EdgiOS) - not Safari',
|
||||
ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/126.0.2592.86 Version/17.0 Mobile/15E148 Safari/604.1',
|
||||
browser: 'Edge',
|
||||
version: '126.0.2592.86',
|
||||
platform: 'iOS'
|
||||
},
|
||||
{
|
||||
label: 'iOS Safari',
|
||||
ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
|
||||
browser: 'Safari',
|
||||
version: '17.5',
|
||||
platform: 'iOS'
|
||||
},
|
||||
{
|
||||
label: 'a non-browser client',
|
||||
ua: 'curl/8.5.0',
|
||||
browser: 'Unknown',
|
||||
version: '',
|
||||
platform: 'Unknown'
|
||||
},
|
||||
{
|
||||
label: 'an empty user agent',
|
||||
ua: '',
|
||||
browser: 'Unknown',
|
||||
version: '',
|
||||
platform: 'Unknown'
|
||||
}
|
||||
]
|
||||
|
||||
cases.forEach((c) => {
|
||||
it(`reports ${c.label} as ${c.browser} ${c.version || '(no version)'} on ${c.platform}`, () => {
|
||||
expect(parseUserAgent(c.ua)).toEqual({
|
||||
browser: c.browser,
|
||||
browserVersion: c.version,
|
||||
platform: c.platform
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Summarises `navigator.userAgent` into the three things worth logging about a
|
||||
* browser: its family, its version, and the platform it is running on.
|
||||
*
|
||||
* The raw user agent is kept alongside these - it is what a support ticket
|
||||
* actually needs - but a hook is far more likely to want to branch on a name
|
||||
* than to write its own user agent parsing.
|
||||
*
|
||||
* Order matters: Edge and Opera both claim to be Chrome, and Chrome claims to
|
||||
* be Safari, so the most specific token has to be tested first. The mobile
|
||||
* tokens are the sharpest case - an iOS browser carries its own token
|
||||
* (`CriOS`, `FxiOS`, `EdgiOS`) *and* `Safari/`, so without the mobile token
|
||||
* being tested first every iPhone and iPad client was reported as Safari with
|
||||
* an empty version.
|
||||
*/
|
||||
export interface UserAgentSummary {
|
||||
browser: string
|
||||
browserVersion: string
|
||||
platform: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Most specific token first. Each entry carries the version pattern for the
|
||||
* same token, so the family and its version can never be read from different
|
||||
* products.
|
||||
*/
|
||||
const BROWSER_TOKENS: Array<{ name: string; token: RegExp; version: RegExp }> =
|
||||
[
|
||||
{
|
||||
name: 'Edge',
|
||||
token: /(?:EdgiOS|Edg[A-Z]?|Edge)\//,
|
||||
version: /(?:EdgiOS|Edg[A-Z]?|Edge)\/([\d.]+)/
|
||||
},
|
||||
{
|
||||
name: 'Opera',
|
||||
token: /(?:OPiOS|OPR)\//,
|
||||
version: /(?:OPiOS|OPR)\/([\d.]+)/
|
||||
},
|
||||
{
|
||||
name: 'Firefox',
|
||||
token: /(?:FxiOS|Firefox)\//,
|
||||
version: /(?:FxiOS|Firefox)\/([\d.]+)/
|
||||
},
|
||||
{
|
||||
name: 'Chrome',
|
||||
token: /(?:CriOS|Chrome)\//,
|
||||
version: /(?:CriOS|Chrome)\/([\d.]+)/
|
||||
},
|
||||
// Safari is the fallback token: everything else that reaches here is
|
||||
// WebKit wearing another product's name.
|
||||
{ name: 'Safari', token: /Safari\//, version: /Version\/([\d.]+)/ }
|
||||
]
|
||||
|
||||
export function parseUserAgent(userAgent: string): UserAgentSummary {
|
||||
const ua = userAgent || ''
|
||||
const match = (re: RegExp) => (ua.match(re) || [])[1] || ''
|
||||
|
||||
let browser = 'Unknown'
|
||||
let browserVersion = ''
|
||||
|
||||
for (const entry of BROWSER_TOKENS) {
|
||||
if (entry.token.test(ua)) {
|
||||
browser = entry.name
|
||||
browserVersion = match(entry.version)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let platform = 'Unknown'
|
||||
if (/Android/.test(ua)) platform = 'Android'
|
||||
else if (/iPhone|iPad|iPod/.test(ua)) platform = 'iOS'
|
||||
else if (/Windows/.test(ua)) platform = 'Windows'
|
||||
else if (/Mac OS X/.test(ua)) platform = 'macOS'
|
||||
else if (/Linux/.test(ua)) platform = 'Linux'
|
||||
|
||||
return { browser, browserVersion, platform }
|
||||
}
|
||||
@@ -1404,10 +1404,17 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.filter = true
|
||||
this.cdf.detectChanges()
|
||||
|
||||
this.sasStoreService.setQueryVariables(
|
||||
this.filterLibds,
|
||||
viewboxTable.hotTable.cols
|
||||
)
|
||||
// Table columns when we have them (the normal, cheapest source); when the
|
||||
// view was aborted by the row limit there is no loaded table to read them
|
||||
// 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
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-action">
|
||||
Export DC Library DDL
|
||||
<button (click)="openDdlExport()" class="btn btn-info btn-sm">
|
||||
EXPORT
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,3 +312,40 @@
|
||||
</button>
|
||||
</div>
|
||||
</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
|
||||
dcLib: string = globals.dcLib
|
||||
targetLibref: string = globals.dcLib
|
||||
ddlModal: boolean = false
|
||||
ddlFlavour: string = 'SAS'
|
||||
ddlSchema: string = ''
|
||||
|
||||
licenceState = this.licenceService.licenceState
|
||||
settings: AppSettings
|
||||
@@ -92,6 +95,32 @@ export class SystemComponent implements OnInit {
|
||||
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() {
|
||||
this.refreshingDataCatalog = true
|
||||
|
||||
|
||||
@@ -499,10 +499,29 @@
|
||||
!['', ' '].includes(queryText) &&
|
||||
!abortActive
|
||||
) {
|
||||
<div class="clr-col-md-12 infoBar">
|
||||
<span
|
||||
<div
|
||||
#infoBar
|
||||
class="clr-col-md-12 infoBar"
|
||||
[class.expanded]="filterExpanded"
|
||||
>
|
||||
<span class="infoBar-text"
|
||||
>FILTER : <b>{{ queryText }}</b></span
|
||||
>
|
||||
@if (filterOverflows) {
|
||||
<button
|
||||
type="button"
|
||||
class="infoBar-toggle"
|
||||
[attr.aria-expanded]="filterExpanded"
|
||||
[attr.aria-label]="
|
||||
filterExpanded
|
||||
? 'Collapse the filter clause'
|
||||
: 'Expand the filter clause'
|
||||
"
|
||||
(click)="toggleFilterPanel()"
|
||||
>
|
||||
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
AfterContentInit,
|
||||
ChangeDetectorRef,
|
||||
AfterViewInit,
|
||||
AfterViewChecked,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
ViewChildren,
|
||||
QueryList,
|
||||
@@ -58,7 +60,7 @@ import { buildColInfoHtml } from '../shared/utils/col-info-html'
|
||||
standalone: false
|
||||
})
|
||||
export class ViewerComponent
|
||||
implements AfterContentInit, AfterViewInit, OnDestroy
|
||||
implements AfterContentInit, AfterViewInit, AfterViewChecked, OnDestroy
|
||||
{
|
||||
@ViewChildren('queryFilter')
|
||||
queryFilterCompList: QueryList<QueryComponent> = new QueryList()
|
||||
@@ -96,6 +98,19 @@ export class ViewerComponent
|
||||
public libTab!: string
|
||||
public queryText: string = ''
|
||||
public webQueryText: string = ''
|
||||
|
||||
/**
|
||||
* The applied-filter panel is collapsed to a single line by default. The
|
||||
* chevron that expands it is only useful when the clause does not fit that
|
||||
* line, which depends on the rendered width - so it is measured from the DOM
|
||||
* rather than guessed from the length of the text.
|
||||
*/
|
||||
public filterExpanded = false
|
||||
public filterOverflows = false
|
||||
|
||||
@ViewChild('infoBar') infoBar?: ElementRef<HTMLElement>
|
||||
|
||||
private filterMeasuredKey = ''
|
||||
public submitLoading!: boolean
|
||||
public queryErr: boolean = false
|
||||
public queryErrMessage!: string
|
||||
@@ -1445,6 +1460,48 @@ export class ViewerComponent
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewChecked() {
|
||||
this.scheduleFilterOverflowCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures whether the applied-filter clause fits the collapsed single line,
|
||||
* and shows or hides the chevron accordingly. The measurement is deferred
|
||||
* because `filterOverflows` and `filterExpanded` are bound in this view -
|
||||
* setting them inside the change-detection cycle would raise
|
||||
* ExpressionChangedAfterItHasBeenChecked.
|
||||
*/
|
||||
private scheduleFilterOverflowCheck() {
|
||||
const el = this.infoBar?.nativeElement
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = el.querySelector('.infoBar-text') as HTMLElement | null
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
// re-measure only when the clause, the available width, or the state changes
|
||||
const key = `${this.queryText}|${text.clientWidth}|${this.filterExpanded}`
|
||||
if (key === this.filterMeasuredKey) {
|
||||
return
|
||||
}
|
||||
this.filterMeasuredKey = key
|
||||
|
||||
setTimeout(() => {
|
||||
// while expanded the clause wraps, so there is nothing to measure
|
||||
if (this.filterExpanded) {
|
||||
return
|
||||
}
|
||||
this.filterOverflows = text.scrollWidth > text.clientWidth
|
||||
})
|
||||
}
|
||||
|
||||
public toggleFilterPanel() {
|
||||
this.filterExpanded = !this.filterExpanded
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
// Fix ARIA accessibility issues after table initialization
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -42,11 +42,13 @@
|
||||
* debug: Whether to enable debug on startup
|
||||
|
||||
## 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)
|
||||
* useComputeApi: Connection method (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
|
||||
@@ -58,9 +60,6 @@
|
||||
useComputeApi="null"
|
||||
runAsTask="true"
|
||||
contextName="SAS Job Execution compute context"
|
||||
adminGroup="SASAdministrators"
|
||||
dcPath="/tmp/dc"
|
||||
hotLicenceKey="non-commercial-and-evaluation"
|
||||
>
|
||||
</sasjs>
|
||||
|
||||
|
||||
+86
-38
@@ -120,30 +120,6 @@ app-editor {
|
||||
|
||||
.infoBar {
|
||||
margin-top: 14px;
|
||||
background: #495967;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 3px;
|
||||
font-size: 16px;
|
||||
|
||||
height: 30px;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
span {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
height: unset;
|
||||
white-space: normal;
|
||||
|
||||
span {
|
||||
width: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pkHeader {
|
||||
@@ -1228,11 +1204,6 @@ app-viewer {
|
||||
|
||||
.infoBar {
|
||||
margin-top: 10px;
|
||||
background: #495967;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 3px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.filterSide {
|
||||
@@ -1693,6 +1664,13 @@ app-requests-modal {
|
||||
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 {
|
||||
color: var(--clr-nav-link-color, #8c8c8c) !important;
|
||||
}
|
||||
@@ -2318,15 +2296,6 @@ app-alerts {
|
||||
}
|
||||
// ALERTS.COMPONENT END
|
||||
|
||||
// MANUAL-DEPLOY.COMPONENT
|
||||
app-manual-deploy {
|
||||
.clear-memory-button {
|
||||
right: 10px;
|
||||
top: 2px;
|
||||
}
|
||||
}
|
||||
// MANUAL-DEPLOY.COMPONENT END
|
||||
|
||||
// LINEAGE.COMPONENT
|
||||
app-lineage {
|
||||
.toggle-switch input[type='checkbox']:checked + label:before {
|
||||
@@ -4284,6 +4253,30 @@ body[cds-theme='light'] {
|
||||
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 {
|
||||
color: #999;
|
||||
}
|
||||
@@ -5448,3 +5441,58 @@ body[cds-theme='dark'] {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* The applied-filter panel. Collapsed to a single line by default so that a
|
||||
long filter does not take over the header; the chevron - rendered only when
|
||||
the clause does not fit that line - expands the panel to show it in full. */
|
||||
.infoBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: #495967;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 3px 8px;
|
||||
font-size: 16px;
|
||||
/* the panel shows a query, so set it in the monospace face the app already
|
||||
uses for code-like content (SAS logs, cell text) */
|
||||
font-family: 'Lucida Console', Monaco, monospace;
|
||||
|
||||
.infoBar-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
align-items: flex-start;
|
||||
|
||||
.infoBar-text {
|
||||
overflow: visible;
|
||||
white-space: normal;
|
||||
text-overflow: clip;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.infoBar-toggle {
|
||||
flex: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
|
||||
clr-icon {
|
||||
transition: transform 0.15s ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded .infoBar-toggle clr-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dcfrontend",
|
||||
"version": "7.15.0",
|
||||
"version": "7.17.0",
|
||||
"description": "Data Controller",
|
||||
"devDependencies": {
|
||||
"@nogoo9/gitleaks": "8.30.1-post.2",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"streamConfig": {
|
||||
"streamWeb": true,
|
||||
"streamWebFolder": "web9",
|
||||
"webSourcePath": "`../../../../client/dist",
|
||||
"webSourcePath": "../../client/dist",
|
||||
"assetPaths": [],
|
||||
"streamServiceName": "clickme"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
var_scope: 'DC',
|
||||
var_name: 'DC_MAXOBS_WEBEDIT',
|
||||
var_value: '100',
|
||||
var_value: '250',
|
||||
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,
|
||||
@@ -2201,6 +2224,37 @@ tables.MPE_TABLES.push({
|
||||
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,400 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
|
||||
// Each entry: { name, type (N/C), length, format, label, notnull (bool) }
|
||||
const schema = {
|
||||
@@ -4976,6 +5030,31 @@ const schema = {
|
||||
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 ───────────
|
||||
// These tables hold the DQ-rule demo columns (HARDSELECT, READONLY, HIDDEN,
|
||||
// ROUND, NUMBER_FORMAT, HARDREGEX, SOFTREGEX) and the formula demo columns
|
||||
@@ -5259,12 +5338,40 @@ const testTables = {
|
||||
STATUS: 1,
|
||||
LAST_REVIEWED: 23621
|
||||
}
|
||||
]
|
||||
],
|
||||
// Hook demo pair: DEMO_MIRROR is an EMPTY mirror of DEMO_ORDERS, registered
|
||||
// with pre/post edit hooks (services/hooks/demo_mirror_*.js). The pre-edit
|
||||
// hook shows the live rows of DEMO_ORDERS; the post-edit hook routes a
|
||||
// changeset submitted against the mirror to DEMO_ORDERS.
|
||||
DEMO_ORDERS: [
|
||||
{ ORDER_ID: 1001, CUSTOMER: 'Acme Corp', REGION: 'EU', AMOUNT: 450, STATUS: 'NEW' },
|
||||
{ ORDER_ID: 1002, CUSTOMER: 'Borealis Ltd', REGION: 'EU', AMOUNT: 980.5, STATUS: 'OPEN' },
|
||||
{ ORDER_ID: 1003, CUSTOMER: 'Cobalt GmbH', REGION: 'EU', AMOUNT: 620, STATUS: 'CLOSED' },
|
||||
{ ORDER_ID: 1004, CUSTOMER: 'Delta Systems', REGION: 'US', AMOUNT: 4500, STATUS: 'NEW' }
|
||||
],
|
||||
DEMO_MIRROR: []
|
||||
}
|
||||
|
||||
// Schemas for the TESTDATA tables, matching the old inline getdata.js payload
|
||||
// that the Cypress editor specs were calibrated against.
|
||||
const testSchema = {
|
||||
// Hook demo pair (see DEMO_ORDERS / DEMO_MIRROR in testTables). The mirror
|
||||
// has the same shape as the real table, so the pre-edit hook can hand its rows
|
||||
// straight to the grid.
|
||||
DEMO_ORDERS: [
|
||||
{ name: 'ORDER_ID', type: 'N', length: 8, format: '', label: 'Order Id', notnull: true },
|
||||
{ name: 'CUSTOMER', type: 'C', length: 20, format: '', label: 'Customer', notnull: false },
|
||||
{ name: 'REGION', type: 'C', length: 8, format: '', label: 'Region', notnull: false },
|
||||
{ name: 'AMOUNT', type: 'N', length: 8, format: '', label: 'Amount', notnull: false },
|
||||
{ name: 'STATUS', type: 'C', length: 12, format: '', label: 'Status', notnull: false }
|
||||
],
|
||||
DEMO_MIRROR: [
|
||||
{ name: 'ORDER_ID', type: 'N', length: 8, format: '', label: 'Order Id', notnull: true },
|
||||
{ name: 'CUSTOMER', type: 'C', length: 20, format: '', label: 'Customer', notnull: false },
|
||||
{ name: 'REGION', type: 'C', length: 8, format: '', label: 'Region', notnull: false },
|
||||
{ name: 'AMOUNT', type: 'N', length: 8, format: '', label: 'Amount', notnull: false },
|
||||
{ name: 'STATUS', type: 'C', length: 12, format: '', label: 'Status', notnull: false }
|
||||
],
|
||||
MPE_X_NEW: [
|
||||
{
|
||||
name: 'PRIMARY_KEY_FIELD',
|
||||
@@ -5631,6 +5738,28 @@ tables.MPE_TABLES.push(
|
||||
loadtype: 'UPDATE',
|
||||
buskey: 'ID',
|
||||
notes: 'Compact rule demo table (eight columns)'
|
||||
},
|
||||
{
|
||||
tx_from: 0,
|
||||
tx_to: 127490111999,
|
||||
libref: testDataLibref,
|
||||
dsn: 'DEMO_ORDERS',
|
||||
num_of_approvals_required: 1,
|
||||
loadtype: 'UPDATE',
|
||||
buskey: 'ORDER_ID',
|
||||
notes: 'Hook demo - the real table the mirror points at'
|
||||
},
|
||||
{
|
||||
tx_from: 0,
|
||||
tx_to: 127490111999,
|
||||
libref: testDataLibref,
|
||||
dsn: 'DEMO_MIRROR',
|
||||
num_of_approvals_required: 1,
|
||||
loadtype: 'UPDATE',
|
||||
buskey: 'ORDER_ID',
|
||||
notes: 'Hook demo - an empty mirror whose hooks show and route DEMO_ORDERS',
|
||||
pre_edit_hook: 'services/hooks/demo_mirror_preedit',
|
||||
post_edit_hook: 'services/hooks/demo_mirror_postedit'
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5854,6 +5983,22 @@ const testValidations = [
|
||||
base_col: 'STATUS',
|
||||
rule_type: 'SOFTSELECT',
|
||||
rule_value: testDataLibref + '.DEMO_01.STATUS'
|
||||
},
|
||||
// Hook demo: the mirror carries its OWN rule set, separate from the real
|
||||
// table's - which is the whole point of routing edits through a mirror.
|
||||
{
|
||||
base_lib: testDataLibref,
|
||||
base_ds: 'DEMO_MIRROR',
|
||||
base_col: 'AMOUNT',
|
||||
rule_type: 'MAXVAL',
|
||||
rule_value: '2000'
|
||||
},
|
||||
{
|
||||
base_lib: testDataLibref,
|
||||
base_ds: 'DEMO_ORDERS',
|
||||
base_col: 'AMOUNT',
|
||||
rule_type: 'MAXVAL',
|
||||
rule_value: '100000'
|
||||
}
|
||||
]
|
||||
for (const v of testValidations) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Provides:
|
||||
* - fetchTable / fetchRaw / parseCsv : emulate SAS %webout(FETCH)
|
||||
* - webOutOpen / webOutObj / webOutClose : emulate SAS %webout(OPEN/OBJ/CLOSE)
|
||||
* - mpeinit : emulate the %mpeinit debug dump
|
||||
* - formatSasValue : apply a SAS format to a numeric value
|
||||
* - parseFormattedToSas : convert an ISO string back to a SAS numeric
|
||||
* - getDdType : derive DATE/DATETIME/TIME/N/C from a column format
|
||||
@@ -147,14 +148,63 @@ function _parseCsvLine(line) {
|
||||
// ─── SAS format <-> ISO conversions ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derives a DDTYPE (DATE, DATETIME, TIME, N, C) from a SAS column format string.
|
||||
* Column metadata, mirroring %mp_getcols.sas.
|
||||
*
|
||||
* The SAS macro runs proc contents and derives, per column: NAME (upcased),
|
||||
* TYPE (C/N), LENGTH, LABEL (defaulting to the name), VARNUM, FMTNAME, FORMAT
|
||||
* and DDTYPE - CHARACTER, NUMERIC, DATETIME, DATE or TIME. The date list is
|
||||
* the macro's (YYMMDD, MMDDYY, DDMMYY, MONYY, E8601DA, B8601DA, E8601DT,
|
||||
* NLDATM, NLDATE), so a column types the same way here as it does in the
|
||||
* service that shares this code.
|
||||
*
|
||||
* @param {Array} schemaCols the table's columns as the mock data stores them
|
||||
* ({name, type, length, format, label}), the equivalent of the proc contents
|
||||
* output the macro reads
|
||||
* @returns {Array} one entry per column, in the macro's shape and order
|
||||
*/
|
||||
function mpGetcols(schemaCols) {
|
||||
return (schemaCols || []).map((col, index) => {
|
||||
const name = String(col.name || '').toUpperCase()
|
||||
const isChar = String(col.type || '').toUpperCase() === 'C'
|
||||
const fmtname = String(col.format || '')
|
||||
.replace(/[\d.]+$/, '')
|
||||
.toUpperCase()
|
||||
|
||||
let ddtype
|
||||
if (isChar) ddtype = 'CHARACTER'
|
||||
else if (fmtname.startsWith('DATETIME') || fmtname.startsWith('E8601DT')
|
||||
|| fmtname.startsWith('NLDATM')) ddtype = 'DATETIME'
|
||||
else if (fmtname.startsWith('DATE') || fmtname.startsWith('DDMMYY')
|
||||
|| fmtname.startsWith('MMDDYY') || fmtname.startsWith('YYMMDD')
|
||||
|| fmtname.startsWith('E8601DA') || fmtname.startsWith('B8601DA')
|
||||
|| fmtname.startsWith('MONYY') || fmtname.startsWith('NLDATE')) ddtype = 'DATE'
|
||||
else if (fmtname.startsWith('TIME')) ddtype = 'TIME'
|
||||
else ddtype = 'NUMERIC'
|
||||
|
||||
return {
|
||||
NAME: name,
|
||||
TYPE: isChar ? 'C' : 'N',
|
||||
LENGTH: Number(col.length) || 0,
|
||||
LABEL: col.label || col.name,
|
||||
VARNUM: index + 1,
|
||||
FORMAT: String(col.format || ''),
|
||||
FMTNAME: fmtname,
|
||||
DDTYPE: ddtype
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a DDTYPE (DATE, DATETIME, TIME, N, C) from a SAS column format
|
||||
* string, in the shape the client expects. Delegates to mpGetcols so the
|
||||
* inference is the macro's, rather than a second format list that can drift
|
||||
* away from the service's.
|
||||
*/
|
||||
function getDdType(col) {
|
||||
const fmt = (col.format || '').toLowerCase()
|
||||
if (fmt.startsWith('datetime') || fmt.startsWith('e8601dt')) return 'DATETIME'
|
||||
if (fmt.startsWith('date') || fmt.startsWith('yymmdd') || fmt.startsWith('ddmmyy') || fmt.startsWith('mmddyy')) return 'DATE'
|
||||
if (fmt.startsWith('time')) return 'TIME'
|
||||
return col.type === 'N' ? 'N' : 'C'
|
||||
const ddtype = mpGetcols([col])[0].DDTYPE
|
||||
if (ddtype === 'CHARACTER') return 'C'
|
||||
if (ddtype === 'NUMERIC') return 'N'
|
||||
return ddtype
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,6 +386,44 @@ function loadTableSchema(dataDir, tableName) {
|
||||
return (data && data.columns) ? data.columns : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a hook program name to the source of its mock implementation.
|
||||
*
|
||||
* MPE_TABLES.pre_edit_hook / post_edit_hook (and the SOFTSELECT_HOOK /
|
||||
* HARDSELECT_HOOK rule_value in MPE_VALIDATIONS) hold a hook program: either a
|
||||
* physical .sas path, or a path relative to the appLoc (e.g.
|
||||
* 'services/hooks/mytable_postedit'). The real backend %includes the program
|
||||
* into the running service, so the hook shares the service's macro variables
|
||||
* and work datasets and can modify them.
|
||||
*
|
||||
* The mock mirrors that: this returns the source of the hook's .js counterpart,
|
||||
* which the calling service eval()s in its OWN scope so the hook can read and
|
||||
* modify the service's variables. Returns null when the hook has no mock
|
||||
* implementation, so callers can fall back to an inline emulation or ignore it.
|
||||
*
|
||||
* Requires nodePath, driveRoot and appLoc in the caller's scope.
|
||||
*/
|
||||
function mockHookSource(hookValue) {
|
||||
if (!hookValue) return null
|
||||
let hook = String(hookValue).trim()
|
||||
if (!hook) return null
|
||||
// Reduce to the services-relative form, dropping any drive path or appLoc
|
||||
// prefix the config may carry.
|
||||
const idx = hook.indexOf('services/')
|
||||
if (idx > -1) hook = hook.slice(idx)
|
||||
// A physical hook is a .sas program on the server; its mock is a .js file
|
||||
// sitting in the same place on the Drive.
|
||||
hook = hook.replace(/\.sas$/i, '')
|
||||
const hookFile = nodePath.resolve(driveRoot, 'files', appLoc, hook + '.js')
|
||||
// The hook value is hand-edited mock config, and a value carrying '..'
|
||||
// segments would otherwise resolve outside the Drive and be eval()'d
|
||||
// in-service. Keep it inside the Drive's files tree.
|
||||
const filesRoot = nodePath.resolve(driveRoot, 'files')
|
||||
if (!hookFile.startsWith(filesRoot + nodePath.sep)) return null
|
||||
if (!fs.existsSync(hookFile)) return null
|
||||
return fs.readFileSync(hookFile, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesises NOTNULL dqrules from the table schema, mirroring the
|
||||
* dictionary.columns union in getdata.sas:
|
||||
@@ -1063,6 +1151,61 @@ function loadDcSettings(driveRoot, appLoc) {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the DC-scope options out of MPE_CONFIG, mirroring the loop in the real
|
||||
* backend's generated settings.sas: only rows that are active (var_active=1)
|
||||
* and current (now < tx_to) count, and the latest tx_from wins. Returns a map
|
||||
* of var_name -> trimmed var_value.
|
||||
*
|
||||
* An option that is absent, or switched off, is simply missing from the map so
|
||||
* the caller's own default applies - which is what the real service's
|
||||
* %symexist fallback does (a switched-off option means "use the code default",
|
||||
* not "no limit").
|
||||
*
|
||||
* Requires nodePath, fs and the caller's driveRoot/appLoc/dataDir.
|
||||
*/
|
||||
function loadDcConfig(driveRoot, appLoc, dataDir) {
|
||||
const out = {}
|
||||
try {
|
||||
const configFile = nodePath.resolve(
|
||||
dataDir || nodePath.resolve(driveRoot, 'files', appLoc, 'data', 'DC_JSLIB'),
|
||||
'mpe_config.json'
|
||||
)
|
||||
const config = JSON.parse(fs.readFileSync(configFile, {encoding:'utf8'}).toString())
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const latest = {}
|
||||
for (const row of (config.rows || [])) {
|
||||
// colVal, not row.var_name: a row that has been through the app's
|
||||
// own edit and approve flow comes back from the adapter with
|
||||
// upper-case keys, and a direct property read would skip it - which
|
||||
// silently drops the option back to its default.
|
||||
if (colVal(row, 'VAR_SCOPE') !== 'DC') continue
|
||||
if (Number(colVal(row, 'VAR_ACTIVE')) !== 1) continue
|
||||
if (Number(colVal(row, 'TX_TO')) <= now) continue
|
||||
const name = String(colVal(row, 'VAR_NAME') || '').trim()
|
||||
if (!name) continue
|
||||
const from = Number(colVal(row, 'TX_FROM')) || 0
|
||||
if (latest[name] === undefined || from > latest[name]) {
|
||||
const val = colVal(row, 'VAR_VALUE')
|
||||
latest[name] = from
|
||||
out[name] = String(val == null ? '' : val).trim()
|
||||
}
|
||||
}
|
||||
} catch(err) { /* no MPE_CONFIG - every option falls back to its default */ }
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A numeric DC option out of a loadDcConfig() map, or the service's own default
|
||||
* when the option is absent, blank, or not a number.
|
||||
*/
|
||||
function dcConfigNumber(cfg, name, fallback) {
|
||||
const raw = cfg ? cfg[name] : undefined
|
||||
if (raw === undefined || raw === '') return fallback
|
||||
const num = Number(raw)
|
||||
return isNaN(num) ? fallback : num
|
||||
}
|
||||
|
||||
// ─── %webout(OPEN/OBJ/CLOSE) emulation ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1176,3 +1319,73 @@ function webOutSend(tables) {
|
||||
}
|
||||
webOutClose()
|
||||
}
|
||||
|
||||
// ─── %mpeinit() emulation ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The `_debug` values that mean "debug on", mirroring the test in mpeinit.sas.
|
||||
*
|
||||
* 131 is what the adapter sends - SASjsApiClient.executeJob() puts it on every
|
||||
* server-mode request, and on the multipart form when the payload goes out as
|
||||
* FormData. 128 is what arrives on the Viya WEB JES path when runAsTask is
|
||||
* enabled, and 2477 / 'fields,log,trace' are the SASjs debug values.
|
||||
*/
|
||||
const MPEINIT_DEBUG_VALUES = ['2477', 'fields,log,trace', '131', '128']
|
||||
|
||||
/* Mirrors the &mpeinit=1 guard in the SAS macro: dump once per execution. */
|
||||
let _mpeinitDone = false
|
||||
|
||||
/**
|
||||
* Dumps the browser_url_vars and browser_info input tables, mirroring the
|
||||
* debug block of %mpeinit (sas/sasjs/macros/mpeinit.sas).
|
||||
*
|
||||
* The SAS macro reads work.browser_url_vars (name/value pairs taken from the
|
||||
* query string) and work.browser_info (the browser fingerprint). Both arrive
|
||||
* here as input tables - see fetchRaw() - and are dumped with console.log,
|
||||
* which SASjs Server returns in the request's log (after its
|
||||
* SASJS_LOGS_SEPARATOR marker), so the dump shows up in Data Controller's
|
||||
* SAS Log tab exactly as the SAS version does.
|
||||
*
|
||||
* Called at the bottom of this file, so the services that eval() it dump the
|
||||
* tables at startup - the same place %mpeinit() runs in the SAS services.
|
||||
* That covers every diagnostics-carrying service (startup, editors, auditors);
|
||||
* the mocks that do not load this file (lineage, metanav, usernav, a few
|
||||
* public ones) have no equivalent startup step in the SAS app either.
|
||||
*
|
||||
* Difference from the SAS version: the header line is printed once per table,
|
||||
* where SAS repeats it for every row (its PUTLOG sits inside the SET loop).
|
||||
*/
|
||||
function mpeinit() {
|
||||
if (_mpeinitDone) return
|
||||
|
||||
const debugValue = typeof _debug === 'undefined' ? '' : _debug
|
||||
if (MPEINIT_DEBUG_VALUES.indexOf(String(debugValue)) < 0) return
|
||||
|
||||
_mpeinitDone = true
|
||||
|
||||
const urlVars = fetchRaw('browser_url_vars')
|
||||
if (urlVars === null) {
|
||||
console.log('NOTE: mpeinit: no work.browser_url_vars on this request')
|
||||
} else {
|
||||
console.log('NOTE: mpeinit: work.browser_url_vars:')
|
||||
parseCsv(urlVars).forEach(row => {
|
||||
console.log(
|
||||
'name=' + colVal(row, 'name') + ' value=' + colVal(row, 'value')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const browserInfo = fetchRaw('browser_info')
|
||||
if (browserInfo === null) {
|
||||
console.log('NOTE: mpeinit: no work.browser_info on this request')
|
||||
} else {
|
||||
console.log('NOTE: mpeinit: work.browser_info:')
|
||||
parseCsv(browserInfo).forEach(row => {
|
||||
console.log(Object.keys(row).map(col => col + '=' + row[col]).join(' '))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* Every service that eval()s this file runs %mpeinit() at startup, the same
|
||||
place the SAS services call it. */
|
||||
mpeinit()
|
||||
@@ -191,6 +191,9 @@ if (tableData && tableData.rows && tableData.columns) {
|
||||
// ─── Build response ──────────────────────────────────────────────────────────
|
||||
|
||||
let response
|
||||
// Set when the row or cell limit is exceeded - mirrors the %mp_abort in
|
||||
// getdata.sas, and is emitted as sasjsAbort below.
|
||||
let abortMsg = ''
|
||||
|
||||
if (tableData && tableData.columns && tableData.rows) {
|
||||
// Exclude temporal columns (TX_FROM/TX_TO etc.) from the editor view
|
||||
@@ -204,8 +207,8 @@ if (tableData && tableData.columns && tableData.rows) {
|
||||
if (tableReg.var_busto) excludeCols.add(tableReg.var_busto.toUpperCase())
|
||||
}
|
||||
|
||||
const visibleColumns = tableData.columns.filter(c => !excludeCols.has(c.name.toUpperCase()))
|
||||
const visibleRows = tableData.rows
|
||||
let visibleColumns = tableData.columns.filter(c => !excludeCols.has(c.name.toUpperCase()))
|
||||
let visibleRows = tableData.rows
|
||||
.filter(r => filterPredicate(r))
|
||||
.map(r => {
|
||||
const copy = {}
|
||||
@@ -215,6 +218,56 @@ if (tableData && tableData.columns && tableData.rows) {
|
||||
return copy
|
||||
})
|
||||
|
||||
// ─── PRE_EDIT_HOOK ───────────────────────────────────────────────────────
|
||||
// Mirrors getdata.sas: %mpe_runhook(PRE_EDIT_HOOK) runs after the filter has
|
||||
// been applied and the rows sorted, with the data in work.OUT, and may
|
||||
// replace it. The mock eval()s the hook's .js counterpart in this scope, so
|
||||
// the hook may reassign visibleRows / visibleColumns - e.g. to show the live
|
||||
// rows of another table behind an empty mirror.
|
||||
if (tableReg && tableReg.pre_edit_hook) {
|
||||
const hookSrc = mockHookSource(tableReg.pre_edit_hook)
|
||||
if (hookSrc) {
|
||||
try {
|
||||
eval(hookSrc)
|
||||
} catch (err) {
|
||||
console.log('Error running pre_edit_hook ' + tableReg.pre_edit_hook + ': ' + err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Row and cell limits ─────────────────────────────────────────────────
|
||||
// Mirrors getdata.sas: DC_MAXOBS_WEBEDIT rows, or DC_MAXCELLS_WEBEDIT
|
||||
// cells (rows x columns), whichever is reached first. Both are read from
|
||||
// MPE_CONFIG, the way the real service picks them up from the settings
|
||||
// program it generates - so editing the option in DC changes what the mock
|
||||
// allows, and an option that is absent (or switched off, var_active=0)
|
||||
// falls back to the shipped default. The check runs after the
|
||||
// PRE_EDIT_HOOK, as the single check in getdata.sas does - a hook may
|
||||
// replace the data with a larger table. The cell count includes the
|
||||
// housekeeping delete column, which is what getdata.sas counts.
|
||||
// MPE_CONFIG lives in the control library, not in the table's own library,
|
||||
// so this reads mpeDataDir - the same directory the MPE_TABLES lookup above
|
||||
// uses. Passing the requested table's dataDir made the read fail for every
|
||||
// non-control libref, and the catch then fell back to the shipped defaults.
|
||||
const dcCfg = loadDcConfig(driveRoot, appLoc, mpeDataDir)
|
||||
const maxObsWebEdit = dcConfigNumber(dcCfg, 'DC_MAXOBS_WEBEDIT', 250)
|
||||
const maxCellsWebEdit = dcConfigNumber(dcCfg, 'DC_MAXCELLS_WEBEDIT', 200000)
|
||||
const editColCount = visibleColumns.length + 1
|
||||
const cellCount = visibleRows.length * editColCount
|
||||
if (visibleRows.length > maxObsWebEdit) {
|
||||
abortMsg =
|
||||
'Table is too big (' +
|
||||
visibleRows.length +
|
||||
' rows) - please filter and try again!'
|
||||
} else if (cellCount > maxCellsWebEdit) {
|
||||
abortMsg =
|
||||
'Selection is too wide (' +
|
||||
visibleRows.length +
|
||||
' rows x ' +
|
||||
editColCount +
|
||||
' cols) - please filter and try again!'
|
||||
}
|
||||
|
||||
// Augment rows: add delete flag, normalise keys, convert temporal values to ISO
|
||||
const colLookup = {}
|
||||
for (const col of visibleColumns) {
|
||||
@@ -247,22 +300,25 @@ if (tableData && tableData.columns && tableData.rows) {
|
||||
else if (ddtype === 'TIME') tmVars.push(col.name)
|
||||
}
|
||||
|
||||
// Build cols array
|
||||
const cols = visibleColumns.map(col => {
|
||||
const ddtype = getDdType(col)
|
||||
const fmtname = col.format
|
||||
? col.format.replace(/[\d.]+$/, '').replace('datetime', 'DATETIME').replace('date', 'DATE').replace('time', 'TIME').replace('best', 'BEST').replace('E8601DT', 'DATETIME')
|
||||
: ' '
|
||||
// Build cols array. Every column of the table, as getdata.sas emits them:
|
||||
// the temporal columns are dropped from the data (the rows below) but not
|
||||
// from the cols payload, so a filter can still be built on them. mpGetcols
|
||||
// is the JS counterpart of %mp_getcols.sas - the macro getdata.sas itself
|
||||
// calls to type these columns - so the editor sees the same DDTYPE the
|
||||
// service would give it.
|
||||
const cols = mpGetcols(tableData.columns).map((c, i) => {
|
||||
const raw = tableData.columns[i]
|
||||
const ddtype = c.DDTYPE === 'CHARACTER' ? 'C' : c.DDTYPE === 'NUMERIC' ? 'N' : c.DDTYPE
|
||||
let coltype
|
||||
if (ddtype === 'DATE') coltype = `{"data":"${col.name}","type":"date"}`
|
||||
else if (ddtype === 'DATETIME') coltype = `{"data":"${col.name}","type":"datetime"}`
|
||||
else if (ddtype === 'TIME') coltype = `{"data":"${col.name}","type":"time"}`
|
||||
else if (ddtype === 'N') coltype = `{"data":"${col.name}","type":"numeric","format":"0"}`
|
||||
else coltype = `{"data":"${col.name}"}`
|
||||
if (ddtype === 'DATE') coltype = `{"data":"${raw.name}","type":"date"}`
|
||||
else if (ddtype === 'DATETIME') coltype = `{"data":"${raw.name}","type":"datetime"}`
|
||||
else if (ddtype === 'TIME') coltype = `{"data":"${raw.name}","type":"time"}`
|
||||
else if (ddtype === 'N') coltype = `{"data":"${raw.name}","type":"numeric","format":"0"}`
|
||||
else coltype = `{"data":"${raw.name}"}`
|
||||
return {
|
||||
NAME: col.name,
|
||||
LABEL: col.label || col.name,
|
||||
FMTNAME: fmtname || ' ',
|
||||
NAME: c.NAME,
|
||||
LABEL: c.LABEL,
|
||||
FMTNAME: c.FMTNAME || ' ',
|
||||
DDTYPE: ddtype,
|
||||
CLS_RULE: 'READ',
|
||||
MEMLABEL: ' ',
|
||||
@@ -337,15 +393,20 @@ if (tableData && tableData.columns && tableData.rows) {
|
||||
}
|
||||
|
||||
webOutOpen()
|
||||
webOutObj(response.approvers, 'approvers')
|
||||
webOutObj(response.cols, 'cols')
|
||||
webOutObj(response.dqdata, 'dqdata')
|
||||
webOutObj(response.dqrules, 'dqrules')
|
||||
webOutObj(response.dsmeta, 'dsmeta')
|
||||
webOutObj(response.maxvarlengths, 'maxvarlengths')
|
||||
webOutObj(response.query, 'query')
|
||||
webOutObj(response.sasdata, 'sasdata', response.$sasdata)
|
||||
webOutObj(response.sasparams, 'sasparams')
|
||||
webOutObj(response.versions, 'versions')
|
||||
webOutObj(response.xl_rules, 'xl_rules')
|
||||
if (abortMsg) {
|
||||
webOutObj([{ MSG: abortMsg, MAC: 'getdata' }], 'sasjsAbort')
|
||||
webOutObj([{ STATUS: 'ERROR' }], 'sasparams')
|
||||
} else {
|
||||
webOutObj(response.approvers, 'approvers')
|
||||
webOutObj(response.cols, 'cols')
|
||||
webOutObj(response.dqdata, 'dqdata')
|
||||
webOutObj(response.dqrules, 'dqrules')
|
||||
webOutObj(response.dsmeta, 'dsmeta')
|
||||
webOutObj(response.maxvarlengths, 'maxvarlengths')
|
||||
webOutObj(response.query, 'query')
|
||||
webOutObj(response.sasdata, 'sasdata', response.$sasdata)
|
||||
webOutObj(response.sasparams, 'sasparams')
|
||||
webOutObj(response.versions, 'versions')
|
||||
webOutObj(response.xl_rules, 'xl_rules')
|
||||
}
|
||||
webOutClose()
|
||||
@@ -59,8 +59,8 @@ for (let i = 1; i <= 10; i++) {
|
||||
|
||||
let libds = ''
|
||||
if (typeof table !== 'undefined' && table) libds = String(table).toUpperCase()
|
||||
const libref = libds.split('.')[0] || dcLibref
|
||||
const dsn = libds.split('.')[1] || ''
|
||||
let libref = libds.split('.')[0] || dcLibref
|
||||
let dsn = libds.split('.')[1] || ''
|
||||
|
||||
// ─── Validate (mirrors the mp_abort checks in loadfile.sas) ─────────────────
|
||||
|
||||
@@ -167,6 +167,29 @@ if (!msg) {
|
||||
}
|
||||
|
||||
if (!msg) {
|
||||
// ─── POST_EDIT_HOOK ──────────────────────────────────────────────────
|
||||
// Mirrors mpe_loader.sas: the hook runs before the MPE_SUBMIT record is
|
||||
// written, while LIBREF / DS are still ordinary macro variables, so it
|
||||
// can re-point the changeset at a different table. The mock eval()s the
|
||||
// hook's .js counterpart in this scope so it can reassign libref / dsn.
|
||||
const hookTables = mpeLoadTableData('MPE_TABLES')
|
||||
const hookReg = (hookTables && hookTables.rows)
|
||||
? hookTables.rows.find(r => r.libref === libref && r.dsn === dsn)
|
||||
: null
|
||||
if (hookReg && hookReg.post_edit_hook) {
|
||||
const hookSrc = mockHookSource(hookReg.post_edit_hook)
|
||||
if (hookSrc) {
|
||||
try {
|
||||
eval(hookSrc)
|
||||
} catch (err) {
|
||||
console.log(
|
||||
'post_edit_hook ' + hookReg.post_edit_hook + ' failed: ' + err.message
|
||||
)
|
||||
}
|
||||
libds = libref + '.' + dsn
|
||||
}
|
||||
}
|
||||
|
||||
tableId = makeTableId()
|
||||
stageSubmission({
|
||||
dataDir: libDataDir(libref),
|
||||
|
||||
@@ -20,8 +20,8 @@ const _sctRow = fetchTable('SASControlTable')[0] || {}
|
||||
let action = _sctRow.ACTION || 'LOAD'
|
||||
let message = _sctRow.MESSAGE || ''
|
||||
let libds = _sctRow.LIBDS || ''
|
||||
const libref = libds.split('.')[0] || dcLibref
|
||||
const dsn = libds.split('.')[1] || ''
|
||||
let libref = libds.split('.')[0] || dcLibref
|
||||
let dsn = libds.split('.')[1] || ''
|
||||
|
||||
// The base table lives in its own library (DC_JSLIB for control tables,
|
||||
// TESTDATA for demo user tables); the staging dir stays in the control lib.
|
||||
@@ -56,6 +56,31 @@ if (schema.length > 0) {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── POST_EDIT_HOOK ──────────────────────────────────────────────────────────
|
||||
// Mirrors mpe_loader.sas: %mpe_runhook(POST_EDIT_HOOK) runs before the MPE_SUBMIT
|
||||
// record is written, while LIBREF / DS are still ordinary macro variables - which
|
||||
// is what lets a hook re-point a changeset at a different table. The mock
|
||||
// eval()s the hook's .js counterpart in this scope so it can reassign
|
||||
// libref / dsn / libds; the submit record then names the new base table, exactly
|
||||
// as the SAS hook does via call symputx.
|
||||
const hookTables = makeTableLoader(mpeDataDir)('MPE_TABLES')
|
||||
const hookReg = (hookTables && hookTables.rows)
|
||||
? hookTables.rows.find(r => r.libref === libref && r.dsn === dsn)
|
||||
: null
|
||||
if (hookReg && hookReg.post_edit_hook) {
|
||||
const hookSrc = mockHookSource(hookReg.post_edit_hook)
|
||||
if (hookSrc) {
|
||||
try {
|
||||
eval(hookSrc)
|
||||
} catch (err) {
|
||||
console.log(
|
||||
'post_edit_hook ' + hookReg.post_edit_hook + ' failed: ' + err.message
|
||||
)
|
||||
}
|
||||
libds = libref + '.' + dsn
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stage the data ──────────────────────────────────────────────────────────
|
||||
// One subfolder per submission (DSID) under the staging directory, so that the
|
||||
// staged data can sit alongside related artifacts (submit/approval logs,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Mock of a POST_EDIT_HOOK program
|
||||
//
|
||||
// Registered against TESTDATA.DEMO_MIRROR (MPE_TABLES.post_edit_hook).
|
||||
//
|
||||
// This file is eval'd by editors/stagedata.js and editors/loadfile.js
|
||||
// (mirroring how mpe_loader.sas %includes the hook program via %mpe_runhook),
|
||||
// so it shares the caller's scope: libref, dsn, libds and every dcMockUtils
|
||||
// function are available directly.
|
||||
//
|
||||
// The real hook runs in mpe_loader BEFORE the submit record is written, while
|
||||
// LIBREF / DS are still ordinary macro variables - which is what lets it
|
||||
// re-point a changeset at a different table:
|
||||
//
|
||||
// data _null_;
|
||||
// call symputx('libref','TESTDATA');
|
||||
// call symputx('ds','DEMO_ORDERS');
|
||||
// run;
|
||||
//
|
||||
// (call symputx reaches the calling service's variable because LIBREF/DS are
|
||||
// not declared %local in mpe_loader; a %let creates a new variable in the
|
||||
// hook's own scope and is discarded.)
|
||||
//
|
||||
// The mock's equivalent is to reassign libref / dsn, so the MPE_SUBMIT record
|
||||
// names the real table and the approval is raised against it rather than
|
||||
// against the empty mirror.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
libref = 'TESTDATA'
|
||||
dsn = 'DEMO_ORDERS'
|
||||
console.log('[HOOK DEBUG] demo_mirror_postedit: routing changeset to ' + libref + '.' + dsn)
|
||||
@@ -0,0 +1,35 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Mock of a PRE_EDIT_HOOK program
|
||||
//
|
||||
// Registered against TESTDATA.DEMO_MIRROR (MPE_TABLES.pre_edit_hook), which is
|
||||
// an EMPTY mirror of TESTDATA.DEMO_ORDERS.
|
||||
//
|
||||
// This file is eval'd by editors/getdata.js (mirroring how getdata.sas
|
||||
// %includes the hook program via %mpe_runhook), so it shares the caller's
|
||||
// scope: visibleRows, visibleColumns, tableReg, requestedLibref and every
|
||||
// dcMockUtils function (loadTableAnyLib, makeTableLoader, libDataDir, ...) are
|
||||
// available directly.
|
||||
//
|
||||
// The real hook is a .sas program which runs after the filter has been applied
|
||||
// and the table sorted, with the editor data in work.OUT:
|
||||
//
|
||||
// data work.out;
|
||||
// set TESTDATA.DEMO_ORDERS;
|
||||
// run;
|
||||
//
|
||||
// The mock's equivalent is to replace visibleRows / visibleColumns with the
|
||||
// rows and columns of the real table, so an empty mirror still shows live data.
|
||||
// (A real hook that must honour the user's filter has to re-apply it - the
|
||||
// filter has already been applied to the empty mirror by the time it runs.)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const realTable = loadTableAnyLib('DEMO_ORDERS')
|
||||
if (realTable && realTable.data) {
|
||||
visibleRows = realTable.data.rows.map((row) => ({ ...row }))
|
||||
visibleColumns = realTable.data.columns
|
||||
console.log(
|
||||
'[HOOK DEBUG] demo_mirror_preedit: loaded ' +
|
||||
visibleRows.length +
|
||||
' rows from DEMO_ORDERS'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
const nodePath = require('path')
|
||||
|
||||
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
|
||||
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
|
||||
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
|
||||
const dcLibref = 'DC_JSLIB'
|
||||
|
||||
// Load shared DC mock utilities
|
||||
eval(fs.readFileSync(nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'), 'utf8'))
|
||||
|
||||
// ─── Parse input ──────────────────────────────────────────────────────────────
|
||||
// getcols.sas reads %mf_getvalue(work.iwant,libds). The adapter serialises the
|
||||
// input table as CSV, which fetchTable parses - so the row is `_iwantRow` (the
|
||||
// runtime already owns the name `iwant`).
|
||||
|
||||
const _iwantRow = fetchTable('iwant')[0] || {}
|
||||
const libds = String(_iwantRow.libds || _iwantRow.LIBDS || '').trim()
|
||||
|
||||
let libref = dcLibref
|
||||
let table = ''
|
||||
if (libds) {
|
||||
const parts = libds.split('.')
|
||||
if (parts.length >= 2) {
|
||||
libref = parts[0].trim()
|
||||
table = parts[1].trim()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Load the table schema ────────────────────────────────────────────────────
|
||||
// Same resolution as getdata.js: the requested libref first, then any libref
|
||||
// (a LIBDS that doesn't match the data folder still resolves).
|
||||
|
||||
let tableData = table ? makeTableLoader(libDataDir(libref))(table) : null
|
||||
if (!tableData) {
|
||||
// Fall back to searching every libref if the table isn't in the requested
|
||||
// libref (e.g. a LIBDS that doesn't match the actual data folder), and take
|
||||
// the libref that actually held it for the registry lookup below.
|
||||
const found = loadTableAnyLib(table)
|
||||
if (found) {
|
||||
tableData = found.data
|
||||
libref = found.libref
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Build the column list ────────────────────────────────────────────────────
|
||||
// Every column, as getdata.sas emits them: the temporal columns are dropped from
|
||||
// the data, not from the cols payload, so a filter can still be built on them -
|
||||
// which is exactly what "please filter and try again" wants to do.
|
||||
|
||||
let cols = []
|
||||
|
||||
if (tableData && tableData.columns) {
|
||||
// mpGetcols, then the mapping getcols.sas applies on top of %mp_getcols:
|
||||
// DDTYPE in the client's vocabulary, and TYPE as the SAS C/N it has always
|
||||
// been. The client maps TYPE to its own num/char vocabulary in
|
||||
// sas-store.getCols, because the picker keys its operator set and its value
|
||||
// quoting on it and this list does not pass through mergeColsRules.
|
||||
cols = mpGetcols(tableData.columns)
|
||||
.map(c => {
|
||||
const ddtype = c.DDTYPE === 'CHARACTER' ? 'C' : c.DDTYPE === 'NUMERIC' ? 'N' : c.DDTYPE
|
||||
return {
|
||||
NAME: c.NAME,
|
||||
LABEL: c.LABEL,
|
||||
FORMAT: c.FORMAT || ' ',
|
||||
FMTNAME: c.FMTNAME || ' ',
|
||||
DDTYPE: ddtype,
|
||||
TYPE: ddtype === 'C' ? 'C' : 'N',
|
||||
CLS_RULE: 'READ',
|
||||
MEMLABEL: ' ',
|
||||
DESC: ' ',
|
||||
LONGDESC: ' ',
|
||||
COLTYPE: ' '
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.NAME.localeCompare(b.NAME))
|
||||
}
|
||||
|
||||
webOutOpen()
|
||||
webOutObj(cols, 'cols')
|
||||
webOutClose()
|
||||
@@ -8,9 +8,15 @@ const mpeDataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', dcLibref
|
||||
|
||||
eval(fs.readFileSync(nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'), 'utf8'))
|
||||
|
||||
// Row cap for the viewer - mirrors DC_MAXOBS_WEBVIEW in viewdata.sas, and the
|
||||
// Row cap for the viewer - mirrors DC_MAXOBS_WEBVIEW in viewdata.sas, read from
|
||||
// MPE_CONFIG the way the real service reads it from the settings program (an
|
||||
// absent or switched-off option falls back to the shipped default). Also the
|
||||
// MAXROWS value reported in sasparams.
|
||||
const maxObsWebView = 500
|
||||
const maxObsWebView = dcConfigNumber(
|
||||
loadDcConfig(driveRoot, appLoc, mpeDataDir),
|
||||
'DC_MAXOBS_WEBVIEW',
|
||||
2000
|
||||
)
|
||||
|
||||
// ─── Parse input ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -95,16 +101,15 @@ let vars = {}
|
||||
let nobs = 0
|
||||
|
||||
if (tableData && tableData.columns) {
|
||||
cols = tableData.columns.map(col => {
|
||||
const ddtype = getDdType(col)
|
||||
const fmtname = col.format
|
||||
? col.format.replace(/[\d.]+$/, '').replace('datetime', 'DATETIME').replace('date', 'DATE').replace('time', 'TIME').replace('best', 'BEST').replace('E8601DT', 'DATETIME')
|
||||
: ' '
|
||||
const formatStr = col.type === 'N' ? (col.format || '8.') : ('$' + col.length + '.')
|
||||
// mpGetcols, the JS counterpart of %mp_getcols.sas - the macro viewdata.sas
|
||||
// calls for its own column list.
|
||||
cols = mpGetcols(tableData.columns).map(c => {
|
||||
const ddtype = c.DDTYPE === 'CHARACTER' ? 'C' : c.DDTYPE === 'NUMERIC' ? 'N' : c.DDTYPE
|
||||
const formatStr = c.TYPE === 'N' ? (c.FORMAT || '8.') : ('$' + c.LENGTH + '.')
|
||||
return {
|
||||
NAME: col.name, TYPE: col.type === 'N' ? 'N' : 'C', LENGTH: col.length,
|
||||
FORMAT: formatStr, FMTNAME: fmtname || ' ', DDTYPE: ddtype,
|
||||
LABEL: col.label || col.name
|
||||
NAME: c.NAME, TYPE: c.TYPE, LENGTH: c.LENGTH,
|
||||
FORMAT: formatStr, FMTNAME: c.FMTNAME || ' ', DDTYPE: ddtype,
|
||||
LABEL: c.LABEL
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Generated
+201
-400
@@ -6,7 +6,7 @@
|
||||
"": {
|
||||
"name": "dc-sas",
|
||||
"dependencies": {
|
||||
"@sasjs/cli": "4.20.4",
|
||||
"@sasjs/cli": "4.20.5",
|
||||
"@sasjs/core": "5.2.8"
|
||||
}
|
||||
},
|
||||
@@ -153,49 +153,14 @@
|
||||
"lodash.isnil": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
"run-parallel": "^1.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.stat": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.walk": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
"fastq": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/adapter": {
|
||||
"version": "4.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.19.0.tgz",
|
||||
"integrity": "sha512-AhSldduDFPAuhRsXdzeiwMqEm0lm6dnh2yVxDSUmxxw8V9YShmeGPmEzvU8n2Cl4VAyrhc+Ietc5YWFYC03G+Q==",
|
||||
"version": "4.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.19.1.tgz",
|
||||
"integrity": "sha512-IpN6UPSdqVb5cAWEBt+jG/i0rhgCubdyp5kxdouuX3ifL313oJW2ZPVUNUvs0+ZGzsXWcGDTNoEzzTrGKiqJlQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@sasjs/utils": "^3.6.0",
|
||||
"axios": "1.18.1",
|
||||
"axios": "1.20.0",
|
||||
"axios-cookiejar-support": "5.0.5",
|
||||
"form-data": "4.0.6",
|
||||
"https": "1.0.0",
|
||||
@@ -203,14 +168,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/cli": {
|
||||
"version": "4.20.4",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.20.4.tgz",
|
||||
"integrity": "sha512-hKE002Dm9AzWU62qpRyYpjQv8BIXG951Z0ToNdG9J1wvvJNYH7h6rGv7kdQMbgvXff/KOQvVdnGJZ023SrG+Jw==",
|
||||
"version": "4.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.20.5.tgz",
|
||||
"integrity": "sha512-/HSpueCFS+SvXas9FwfP8qoD9skhdTDE5r7dT9Xlrs7HGCBjxDYvSiw6tW0GIjfw3/49Yf8TCVDFBsISang1hg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@sasjs/adapter": "^4.19.0",
|
||||
"@sasjs/core": "5.2.9",
|
||||
"@sasjs/lint": "2.5.0",
|
||||
"@sasjs/adapter": "4.19.1",
|
||||
"@sasjs/core": "5.2.10",
|
||||
"@sasjs/lint": "4.1.0",
|
||||
"@sasjs/utils": "3.6.2",
|
||||
"chalk": "4.1.2",
|
||||
"dotenv": "17.4.2",
|
||||
@@ -236,11 +201,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/cli/node_modules/@sasjs/core": {
|
||||
"version": "5.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-5.2.9.tgz",
|
||||
"integrity": "sha512-R5wtJF0ANHchaURqTF2t1vRXrhUJ+uDfj21ewzdOq4Lp14CWW22+yYeRQooY4QOpyHUyFDjReA5q6SoJt2Tz1g==",
|
||||
"version": "5.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-5.2.10.tgz",
|
||||
"integrity": "sha512-Lk/ENHVuXaeAl8M0+Mjh1rcBSdFt4tIM6OPxPPmk78ZD0gYFqAOUZreqIoV9EN+VwPDqYsUPDqXzh0crW6j8OA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sasjs/cli/node_modules/shelljs": {
|
||||
"version": "0.8.5",
|
||||
"resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz",
|
||||
"integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"glob": "^7.0.0",
|
||||
"interpret": "^1.0.0",
|
||||
"rechoir": "^0.6.2"
|
||||
},
|
||||
"bin": {
|
||||
"shjs": "bin/shjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/core": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-5.2.8.tgz",
|
||||
@@ -248,14 +230,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sasjs/lint": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-2.5.0.tgz",
|
||||
"integrity": "sha512-bVUtYnKhCigXylCCvQ6dsAIywpPKkX3BXatMqOWjDVw9R30jfj4M3/qn05fcR45L42Y7e5Tuzlt+LYs+9k95Yg==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-4.1.0.tgz",
|
||||
"integrity": "sha512-Vpg1s0/zdPMFJ0hRXNXk53HhE3kfRr5gFRS0kvrse+bxgcbqih1qqVW7yZ2krhSCYylp9z13eBXLhXbRrj70VQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sasjs/utils": "3.6.2",
|
||||
"ignore": "7.0.8"
|
||||
"ignore": "7.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/utils": {
|
||||
@@ -390,13 +372,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
|
||||
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
|
||||
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"form-data": "^4.0.6",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
@@ -420,6 +402,12 @@
|
||||
"tough-cookie": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -451,16 +439,14 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
@@ -646,26 +632,18 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-2.15.0.tgz",
|
||||
"integrity": "sha512-vlcSGgdYS26mPf7qNi+dCisbhiyDnrN1zaRbw3CSuc2wGOMEGGPsp46PdRG5gqXwgtJfjxDkxRNAgRPr1B77vQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
@@ -840,66 +818,6 @@
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.3",
|
||||
"get-stream": "^6.0.0",
|
||||
"human-signals": "^2.1.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"merge-stream": "^2.0.0",
|
||||
"npm-run-path": "^4.0.1",
|
||||
"onetime": "^5.1.2",
|
||||
"signal-exit": "^3.0.3",
|
||||
"strip-final-newline": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
"@nodelib/fs.walk": "^1.2.3",
|
||||
"glob-parent": "^5.1.2",
|
||||
"merge2": "^1.3.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
|
||||
"integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/find/-/find-0.3.0.tgz",
|
||||
@@ -910,9 +828,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
||||
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -959,6 +877,12 @@
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -1014,28 +938,25 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
@@ -1184,15 +1105,6 @@
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/human-signals": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -1226,20 +1138,55 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "7.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz",
|
||||
"integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==",
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz",
|
||||
"integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/interpret": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz",
|
||||
"integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz",
|
||||
"integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||
@@ -1255,15 +1202,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
@@ -1273,18 +1211,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-interactive": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
|
||||
@@ -1294,33 +1220,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-unicode-supported": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
||||
@@ -1345,12 +1250,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-base64": {
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.3.tgz",
|
||||
@@ -1532,34 +1431,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
@@ -1590,6 +1461,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1630,24 +1513,21 @@
|
||||
"is-wsl": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-path": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.27",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz",
|
||||
"integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/onetime": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||
@@ -1744,33 +1624,27 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
@@ -1820,26 +1694,6 @@
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@@ -1854,6 +1708,17 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/rechoir": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz",
|
||||
"integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==",
|
||||
"dependencies": {
|
||||
"resolve": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -1869,6 +1734,27 @@
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"is-core-module": "^2.16.1",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"resolve": "bin/resolve"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
|
||||
@@ -1882,45 +1768,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||