Compare commits
164
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aae36f94d | ||
|
|
f24a02c47e | ||
|
|
c4eef498b2 | ||
|
|
a87f55a4da | ||
|
|
ed57bf9cba | ||
|
|
93f840ce00 | ||
|
|
1d9108109d | ||
|
|
5eb8492742 | ||
|
|
f3b14a98d1 | ||
|
|
addb4a56ea | ||
|
|
68e66e25a0 | ||
|
|
74b8ec9c48 | ||
|
|
201f356dc9 | ||
|
|
a94375f9a9 | ||
|
|
205d042bbe | ||
|
|
e84cfaaabd | ||
|
|
4424171b2f | ||
|
|
7be063441f | ||
|
|
6eeab2ed16 | ||
|
|
442c595c2b | ||
|
|
8ed0febeaa | ||
|
|
c7c181518c | ||
|
|
0f729d7101 | ||
|
|
d0bd3f06f9 | ||
|
|
196d6c931d | ||
|
|
67ca129c4d | ||
|
|
45db0785fb | ||
|
|
a13d509bc9 | ||
|
|
312964864e | ||
|
|
85cb01b8c3 | ||
|
|
590b841f24 | ||
|
|
74e0eb0524 | ||
|
|
0587f71273 | ||
|
|
e40525e278 | ||
|
|
2535baa5f7 | ||
|
|
f82f21dc3b | ||
|
|
cb30469e79 | ||
|
|
1b06857213 | ||
|
|
e9a2abd2e2 | ||
|
|
46c6aec37a | ||
|
|
32d3d49b59 | ||
|
|
ba4ddb06d3 | ||
|
|
f282d1a44b | ||
|
|
bf1e7d6b15 | ||
|
|
5bd7b5d724 | ||
|
|
663088ad16 | ||
|
|
0d8fa59e81 | ||
|
|
b5bb704a4f | ||
|
|
aabc578d5e | ||
|
|
4528d26927 | ||
|
|
10229d5efe | ||
|
|
2c4be5e94b | ||
|
|
f84d4ca7af | ||
|
|
e58e735610 | ||
|
|
440ea9dc0f | ||
|
|
f3d85d49bf | ||
|
|
b1892ebb98 | ||
|
|
ab4f4c9b60 | ||
|
|
2ee3c2241b | ||
|
|
cfa72ff879 | ||
|
|
cf759db3d1 | ||
|
|
45d6c025c9 | ||
|
|
f3bbcc8a9f | ||
|
|
9f2cd24b20 | ||
|
|
0528a229b7 | ||
|
|
90fa2e3b74 | ||
|
|
33efc5ae1e | ||
|
|
e1a33c9ae9 | ||
|
|
0662f6230b | ||
|
|
a232b2ee5a | ||
|
|
9da9f88ed1 | ||
|
|
9071801ac5 | ||
|
|
de5cb029eb | ||
|
|
7da67eb17b | ||
|
|
a15bf99462 | ||
|
|
0b85f7bf1e | ||
|
|
4c09c02998 | ||
|
|
af28a79bd5 | ||
|
|
bf979802fd | ||
|
|
c8c0858295 | ||
|
|
6fc3f0c819 | ||
|
|
ad6e4c6e78 | ||
|
|
02a8590310 | ||
|
|
ea789c4977 | ||
|
|
b927276abe | ||
|
|
dff4bbdc09 | ||
|
|
1455f9d435 | ||
|
|
01893036c2 | ||
|
|
fbf0ed676b | ||
|
|
cface15b92 | ||
|
|
64383571b2 | ||
|
|
86c8a1a76f | ||
|
|
5eb8b0bf46 | ||
|
|
39b12e9a18 | ||
|
|
6e9a2e2ed2 | ||
|
|
c033032e26 | ||
|
|
5a0135ff7e | ||
|
|
effb9de83e | ||
|
|
b9a1d5ac61 | ||
|
|
26c0711bc4 | ||
|
|
761734016d | ||
|
|
787f267adc | ||
|
|
3f569d3dae | ||
|
|
c6a9885437 | ||
|
|
d07151fbf5 | ||
|
|
ec83550a79 | ||
|
|
5ca1937ea4 | ||
|
|
c97082e468 | ||
|
|
786d15ab0f | ||
|
|
af89126acf | ||
|
|
94220048d0 | ||
|
|
aa5c3f887a | ||
|
|
e74a7ba115 | ||
|
|
24b8cbe9dc | ||
|
|
3c57f7ba96 | ||
|
|
1843d79f20 | ||
|
|
4c7e869369 | ||
|
|
d63e4f46c2 | ||
|
|
263e99ed40 | ||
|
|
2df5b7d649 | ||
|
|
11587e2f24 | ||
|
|
4c61ef374b | ||
|
|
700b906d69 | ||
|
|
0ae552ea44 | ||
|
|
b29bc1e7ae | ||
|
|
b47fb2367b | ||
|
|
68b89caf63 | ||
|
|
1c0d636708 | ||
|
|
468d2e9a80 | ||
|
|
7b09ade97c | ||
|
|
f50a0de9ae | ||
|
|
628092d136 | ||
|
|
e68d221354 | ||
|
|
3e4070ae49 | ||
|
|
e2e91039cb | ||
|
|
5d32de9df9 | ||
|
|
847d329d5c | ||
|
|
8b426c27fd | ||
|
|
c1e7314417 | ||
|
|
04eabf249f | ||
|
|
ebfd39582f | ||
|
|
8862798d65 | ||
|
|
dfc5c6ebb8 | ||
|
|
20dbf00fb0 | ||
|
|
7d53ed7b8b | ||
|
|
9d82d7545e | ||
|
|
d67a0e5d5f | ||
|
|
3d2eefdd7b | ||
|
|
e3819e4350 | ||
|
|
59e09e26a5 | ||
|
|
eaa3b3e619 | ||
|
|
d845b9e9b7 | ||
|
|
428e70a4c1 | ||
|
|
94e040c006 | ||
|
|
cdc0ab2a91 | ||
|
|
8449ab3987 | ||
|
|
405b28dccc | ||
|
|
559221fad4 | ||
|
|
999e16b8fc | ||
|
|
ce5b1aa961 | ||
|
|
f74100a818 | ||
|
|
a4a0f9cb2f | ||
|
|
e6a5cce475 | ||
|
|
eeffa92f9e |
No files matched your search
@@ -0,0 +1,32 @@
|
|||||||
|
# What guards the deploy window
|
||||||
|
|
||||||
|
The deploy screens ask two questions about the physical filesystem - what folders sit below this parent, and can the deployment user write to this one. Both are answered by `makedata` (`ACTION=DIRLIST` and `ACTION=WRITECHECK`), through `mpe_dirlist.sas` and `mpe_writecheck.sas`.
|
||||||
|
|
||||||
|
This note records the two-layer answer: a gate that closes the window once the appLoc is configured, and why the window itself has no group check.
|
||||||
|
|
||||||
|
## The gate
|
||||||
|
|
||||||
|
The deploy window is over once the appLoc has `services/public/settings.sas`: `makedata` writes it at the end of a successful run, and a run that failed - or was abandoned - after writing it leaves the file behind. So the service refuses `DIRLIST` and `WRITECHECK` whenever that file is present, however the run that created it ended. On SASjs Server the file is fetched with `ms_getfile` and a probe that cannot answer (anything other than 200 or 404) is treated as configured: an unreachable drive is not evidence that the window is open. Viya checks the same file through `mfv_existfile`.
|
||||||
|
|
||||||
|
The consequence for tests: the browse actions are refused on any appLoc that runs the suite, because every such appLoc is configured. `services/admin/makedata.test.sas` therefore asserts the refusal, and the listing and write-probe behaviour is asserted against the macros directly (`macros/mpe_dirlist.test.sas`, `macros/mpe_writecheck.test.sas`). The macros' own abort paths (a relative path, one carrying a macro trigger) are not asserted anywhere: `mp_abort` cannot be exercised inside the test session.
|
||||||
|
|
||||||
|
## Why there is no group check
|
||||||
|
|
||||||
|
Every other admin service gates on `&mpeadmins`, which comes from `MPE_CONFIG` in the control library - and that library is what `makedata` creates. Before it runs there is no group to check: `%mpeinit()` reads `&DC_LIBREF..mpe_config` and has nothing to read. That is also why the previous `dirlist` service could never serve the deploy screen it was written for.
|
||||||
|
|
||||||
|
The window is not narrower than the configurator's own: on SASjs Server the configurator page, in the same window, lets the caller pick any group they belong to and create the control library - which is full Data Controller admin. `DIRLIST` and `WRITECHECK` are strictly less powerful than that.
|
||||||
|
|
||||||
|
## What the actions do, for the record
|
||||||
|
|
||||||
|
- `DIRLIST` - enumerate the folders below any absolute path the SAS process can open. Folder names only; no file contents, no file listing.
|
||||||
|
- `WRITECHECK` - ask whether a path exists, is a folder, and whether the deployment user can create and delete a file in it. It creates a uniquely named probe file and removes it again.
|
||||||
|
|
||||||
|
Both refuse a value carrying a macro trigger, a quote or a statement separator, and shape check it with `mp_validatecol(ISPATH)` before use. The shape check fails closed: an absent `ISPATH` rule refuses the path rather than accepting it.
|
||||||
|
|
||||||
|
## The assessment
|
||||||
|
|
||||||
|
- **The window is the deployment itself.** `makedata` is deployed, run once by an administrator, and deletes itself on success.
|
||||||
|
- **There is no admin group to check while it runs.** It is created by `makedata`, from the group the deployer picks in the configurator.
|
||||||
|
- **The caller is the administrator.** Reaching these actions means reaching the STPs of a deployment in progress, which is the same caller the configurator is serving in the same window.
|
||||||
|
- **The actions are the weaker half of that window.** The configurator, in the same window, lets the caller pick any group they belong to and create the control library - which is full Data Controller admin. Listing folders and probing a write cannot achieve that.
|
||||||
|
- **The window closes on the settings file, not on survival of the service.** That is what covers a failed or abandoned run.
|
||||||
@@ -44,7 +44,7 @@ An unmatched `/SASjsApi/...` path answers `200` with the Swagger page, so check
|
|||||||
|
|
||||||
`npm run serverdata` sends `deploy/makeDataServer.json`: one `fromjs` row with `ADMIN`, `DCPATH` and `OUTFMT`. Two things about it are worth knowing before you trust its result.
|
`npm run serverdata` sends `deploy/makeDataServer.json`: one `fromjs` row with `ADMIN`, `DCPATH` and `OUTFMT`. Two things about it are worth knowing before you trust its result.
|
||||||
|
|
||||||
- **`ADMIN` must name a group the calling account is actually in.** The shipped value is `AllUsers`, which is not a group on the shared estate - the account belongs to `all-users` (check with a call to `services/public/getgroups`, or read the `MF_GETUSER` the response echoes back). With a group the account is not in, the seed still answers `SUCCESS` and every later `getdata`/`stagedata` call aborts with `<user> is not authorised to edit <lib>.<ds> in the <lib>.MPE_SECURITY table`. Override `ADMIN` to `all-users` for the estate rather than editing the shipped JSON.
|
- **`ADMIN` must name a group the calling account is actually in.** The shipped value is `all-users`, the SASjs Server group that every logged-in user is a member of, which is what the shared estate needs. A name the account is not in - a Viya-style group such as `AllUsers`, or one estate's own group carried into another - still answers `SUCCESS` from the seed, and then every later `getdata`/`stagedata` call aborts with `<user> is not authorised to edit <lib>.<ds> in the <lib>.MPE_SECURITY table`. Check what the account belongs to with a call to `services/public/getgroups` before changing the value.
|
||||||
- **Each run creates a new library.** The response carries `STATUS`, `DCPATH` and `LIBREF` (for example `DC621835`), and the LIBREF is clock-derived, so a libref from an earlier run answers `dataset <lib>.<table> does not exist` - take the current one from the response.
|
- **Each run creates a new library.** The response carries `STATUS`, `DCPATH` and `LIBREF` (for example `DC621835`), and the LIBREF is clock-derived, so a libref from an earlier run answers `dataset <lib>.<table> does not exist` - take the current one from the response.
|
||||||
- **`KEEPME` controls whether the seed can be repeated.** Without it `makedata` deletes itself and the configurator after a successful run (deliberately, so it cannot be executed twice over real data), and a second seed fails with `The Program at (<appLoc>/services/admin/makedata) does not exist` until you run `sasjs cbd` again. A harness that reseeds between runs sets `KEEPME` in the input row.
|
- **`KEEPME` controls whether the seed can be repeated.** Without it `makedata` deletes itself and the configurator after a successful run (deliberately, so it cannot be executed twice over real data), and a second seed fails with `The Program at (<appLoc>/services/admin/makedata) does not exist` until you run `sasjs cbd` again. A harness that reseeds between runs sets `KEEPME` in the input row.
|
||||||
- **Seed once per deploy, not before every test run.** The suite builds its own `dctest` library from the seeded control tables; reseeding in between is wasted work, and there is no need to run `makedata` and `sasjs test` in the opposite order.
|
- **Seed once per deploy, not before every test run.** The suite builds its own `dctest` library from the seeded control tables; reseeding in between is wasted work, and there is no need to run `makedata` and `sasjs test` in the opposite order.
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ if ! "$gitleaks_bin" protect --staged --redact; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Every tracked .npmrc is a policy file - root, sas/ and client/ - so a registry
|
||||||
|
# credential pasted into any of them is one `git add -A` from being committed.
|
||||||
|
# Each staged one is checked, not just the client.
|
||||||
|
for npmrc in $(git diff --cached --name-only --diff-filter=ACMR | grep -E '(^|/)\.npmrc$'); do
|
||||||
|
if git show ":$npmrc" | grep -Eq '(_authToken|_auth[^a-z]|_password|:_auth)'; then
|
||||||
|
echo "ERROR: $npmrc holds a registry credential - keep it in ~/.npmrc instead"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
## Avoid large commits
|
## Avoid large commits
|
||||||
# https://www.backblaze.com/blog/how-many-bytes-are-in-a-megabyte-really/
|
# https://www.backblaze.com/blog/how-many-bytes-are-in-a-megabyte-really/
|
||||||
size_limit=$((2 * 1024 * 1024)) # 2MB
|
size_limit=$((2 * 1024 * 1024)) # 2MB
|
||||||
|
|||||||
Executable
+99
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fail a client job before it starts when the runner cannot hold it.
|
||||||
|
#
|
||||||
|
# Every client job here compiles the frontend from cold: `ng build
|
||||||
|
# --configuration production` peaks at 3.8 GB resident on a 4 vCPU box
|
||||||
|
# (measured with no .angular/cache present, which is what a fresh runner
|
||||||
|
# has), and the Cypress and Lighthouse jobs add a dev server and a browser on
|
||||||
|
# top of that. On a 4 GB runner the kernel OOM killer takes the build part way
|
||||||
|
# through, which surfaces as `exitcode '137'` at `ng build`, or as Karma
|
||||||
|
# disconnecting mid-suite ("no message in 30000 ms"). Both read as a flaky
|
||||||
|
# test rather than as an undersized box, and both cost a whole CI slot.
|
||||||
|
#
|
||||||
|
# 8 GB is the minimum supported runner size: it leaves roughly 2x headroom
|
||||||
|
# over that peak. This check runs before the installs, so an undersized runner
|
||||||
|
# fails in seconds with the numbers in the log.
|
||||||
|
#
|
||||||
|
# The floor is 7 GB rather than 8192 MB because an 8 GB box does not report 8
|
||||||
|
# GB: the fleet's report 7751 MB total, the rest going to firmware and the
|
||||||
|
# kernel. A 4 GB box reports under 4000 MB, so 7 GB separates the two sizes
|
||||||
|
# with room for a box that reserves a little more than ours do.
|
||||||
|
#
|
||||||
|
# What this does not catch is oversubscription: two client jobs landing on one
|
||||||
|
# box. A box that passes the floor can still OOM a build when another job is
|
||||||
|
# resident, and MemAvailable at start is the only sign of it - that is reported
|
||||||
|
# as a warning, not a failure, because a busy box is transient where a small
|
||||||
|
# box is not. Keep one client job per runner.
|
||||||
|
#
|
||||||
|
# Usage: check-runner-memory.sh [min_total_mb]
|
||||||
|
# or: MIN_TOTAL_MB=4096 check-runner-memory.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MIN_TOTAL_MB="${1:-${MIN_TOTAL_MB:-7000}}"
|
||||||
|
|
||||||
|
# A typo here (8GB, a stray flag, an empty variable) would otherwise reach the
|
||||||
|
# integer test under `set -e` and abort with "integer expression expected".
|
||||||
|
case "$MIN_TOTAL_MB" in
|
||||||
|
''|*[!0-9]*)
|
||||||
|
echo "usage: check-runner-memory.sh [min_total_mb] - got '$MIN_TOTAL_MB'" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
meminfo_field() {
|
||||||
|
awk -v key="$1:" '$1 == key { print $2 }' /proc/meminfo
|
||||||
|
}
|
||||||
|
|
||||||
|
total_kb=$(meminfo_field MemTotal || true)
|
||||||
|
avail_kb=$(meminfo_field MemAvailable || true)
|
||||||
|
cpus=$(nproc 2>/dev/null || echo '?')
|
||||||
|
|
||||||
|
if [ -z "$total_kb" ]; then
|
||||||
|
echo "runner memory: /proc/meminfo has no MemTotal - cannot measure, continuing"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
total_mb=$(( total_kb / 1024 ))
|
||||||
|
avail_mb=$(( ${avail_kb:-0} / 1024 ))
|
||||||
|
# Older kernels have no MemAvailable; report that rather than 0, which would
|
||||||
|
# read as a permanently exhausted runner.
|
||||||
|
if [ -n "$avail_kb" ]; then
|
||||||
|
avail_display="${avail_mb} MB available"
|
||||||
|
else
|
||||||
|
avail_display="available unknown"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A container usually sees the host's RAM, but may carry its own lower ceiling
|
||||||
|
# (cgroup v2 memory.max, v1 memory.limit_in_bytes). That ceiling is what the
|
||||||
|
# build is actually killed against, so it counts as the runner's size.
|
||||||
|
limit_mb=""
|
||||||
|
for f in /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory/memory.limit_in_bytes; do
|
||||||
|
[ -r "$f" ] || continue
|
||||||
|
v=$(cat "$f")
|
||||||
|
case "$v" in
|
||||||
|
''|max|*[!0-9]*) continue ;;
|
||||||
|
esac
|
||||||
|
# An unlimited v1 cgroup reports a sentinel near 2^63 bytes.
|
||||||
|
[ "$v" -lt 100000000000 ] || continue
|
||||||
|
candidate=$(( v / 1024 / 1024 ))
|
||||||
|
if [ -z "$limit_mb" ] || [ "$candidate" -lt "$limit_mb" ]; then
|
||||||
|
limit_mb="$candidate"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "runner memory: ${total_mb} MB total, ${avail_display}, ${cpus} vCPU${limit_mb:+, ${limit_mb} MB cgroup limit}"
|
||||||
|
|
||||||
|
effective_mb=$total_mb
|
||||||
|
if [ -n "$limit_mb" ] && [ "$limit_mb" -lt "$effective_mb" ]; then
|
||||||
|
effective_mb=$limit_mb
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$effective_mb" -lt "$MIN_TOTAL_MB" ]; then
|
||||||
|
echo "::error::runner has ${effective_mb} MB of memory, a client job needs at least ${MIN_TOTAL_MB} MB (8 GB is the supported minimum; a cold production build alone peaks at about 3.8 GB, and the Cypress and Lighthouse jobs add a dev server and a browser to that)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Not a gate - a second job landing on the same box shows up here first.
|
||||||
|
if [ -n "$avail_kb" ] && [ "$avail_mb" -lt 5000 ]; then
|
||||||
|
echo "::warning::only ${avail_mb} MB is available on this runner; another job may be running on the same box"
|
||||||
|
fi
|
||||||
@@ -9,21 +9,27 @@
|
|||||||
# - hook-programs asserts that an edit raises an approval row against its
|
# - hook-programs asserts that an edit raises an approval row against its
|
||||||
# target table; it runs early, before other specs have added rows to the
|
# target table; it runs early, before other specs have added rows to the
|
||||||
# approval tables.
|
# approval tables.
|
||||||
|
# - metanav only reads the metadata tree, and applies a licence key so it can
|
||||||
|
# run on its own; it goes last so that key cannot change another spec.
|
||||||
#
|
#
|
||||||
# Specs added later are appended rather than inserted, so the sequence that was
|
# Specs added later are appended rather than inserted, so the sequence that was
|
||||||
# known green keeps its shape. metanav is deliberately absent - see
|
# known green keeps its shape.
|
||||||
# https://git.datacontroller.io/dc/dc/issues/395.
|
|
||||||
#
|
#
|
||||||
# download is held out for a different reason - its download button is covered by
|
# download was held out while its selectors were stale - see
|
||||||
# a fixed-position element, see
|
# https://git.datacontroller.io/dc/dc/issues/398. Its first case uploads a fixture
|
||||||
# https://git.datacontroller.io/dc/dc/issues/398.
|
# to mpe_x_test and submits it, so it creates the approval it downloads instead of
|
||||||
|
# depending on what earlier specs leave behind. That approval stays: the CI job
|
||||||
|
# deletes the appLoc and re-runs makedata before the specs, so nothing accumulates
|
||||||
|
# between runs, but a hand-run against a reused estate leaves one pending row per
|
||||||
|
# run. It is appended because a spec added later goes on the end rather than
|
||||||
|
# reordering the sequence that was known green.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SPECS="csv-limited hook-programs liveness editor excel-multi-load excel csv filtering filter-panel licensing viewer-labels full-table-search viewbox stage browser-info ddl-export row-cell-limits derived-variables startupservice-launcher coltype-delete-record submitted-details embed-va"
|
SPECS="csv-limited hook-programs liveness editor excel-multi-load excel csv filtering filter-panel licensing viewer-labels full-table-search viewbox stage browser-info ddl-export row-cell-limits derived-variables startupservice-launcher coltype-delete-record submitted-details embed-va download metanav"
|
||||||
|
|
||||||
# Specs that exist but are held out of CI, each with the reason. Listed here so
|
# Specs that exist but are held out of CI, each with the reason. Listed here so
|
||||||
# the coverage check below can tell a deliberate exclusion from a forgotten file.
|
# the coverage check below can tell a deliberate exclusion from a forgotten file.
|
||||||
EXCLUDED="metanav download"
|
EXCLUDED=""
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
all)
|
all)
|
||||||
|
|||||||
+159
-38
@@ -3,6 +3,8 @@ run-name: Running Lint Check and Licence checker on Pull Request
|
|||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
# the server the mocked services and app tests run against - bump deliberately
|
||||||
|
SASJS_SERVER_VERSION: '3.6.2'
|
||||||
NODE_VERSION: '24.15.0'
|
NODE_VERSION: '24.15.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -11,6 +13,11 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check runner size
|
||||||
|
# An undersized runner dies inside `ng build` (exitcode 137) or as a
|
||||||
|
# Karma disconnect twenty minutes in. Fail here instead, in seconds,
|
||||||
|
# with the measured numbers in the log - see the script.
|
||||||
|
run: bash .gitea/scripts/check-runner-memory.sh
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
@@ -22,9 +29,8 @@ jobs:
|
|||||||
# credential must fail the build, not just a commit.
|
# credential must fail the build, not just a commit.
|
||||||
# Uses the same @nogoo9/gitleaks binary the pre-commit hook pins - the
|
# Uses the same @nogoo9/gitleaks binary the pre-commit hook pins - the
|
||||||
# gitleaks-action requires a license key for organization repos.
|
# gitleaks-action requires a license key for organization repos.
|
||||||
# Runs before client/.npmrc is written below, because that file is
|
# Runs before the registry config is written, so the scan cannot see
|
||||||
# created from the NPMRC secret and a later scan would flag the
|
# the token this job injects.
|
||||||
# registry token this job injects itself.
|
|
||||||
# --no-git scans the working tree, which is what a pull request gate
|
# --no-git scans the working tree, which is what a pull request gate
|
||||||
# is about.
|
# is about.
|
||||||
run: |
|
run: |
|
||||||
@@ -38,7 +44,11 @@ jobs:
|
|||||||
apt install -y ./google-chrome*.deb
|
apt install -y ./google-chrome*.deb
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: echo "$NPMRC" >> client/.npmrc
|
run: |
|
||||||
|
# the registry secret stays outside the repo: client/.npmrc is
|
||||||
|
# tracked now, and one git add -A would commit a live token
|
||||||
|
echo "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
||||||
|
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
@@ -51,7 +61,13 @@ jobs:
|
|||||||
gpg --batch --yes --passphrase-fd 0 \
|
gpg --batch --yes --passphrase-fd 0 \
|
||||||
--output ./libraries/sheet-crypto.tgz \
|
--output ./libraries/sheet-crypto.tgz \
|
||||||
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
# dependency hooks stay off (client/.npmrc); the client's own
|
||||||
|
# generator runs explicitly, because the app imports the file it makes
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
npm run postinstall
|
||||||
|
# cypress downloads its binary in a dependency install hook, which the
|
||||||
|
# policy skips, so it is installed explicitly here
|
||||||
|
npx cypress install
|
||||||
|
|
||||||
- name: Check audit
|
- name: Check audit
|
||||||
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
|
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
|
||||||
@@ -62,9 +78,27 @@ jobs:
|
|||||||
cd ../client
|
cd ../client
|
||||||
npm audit --omit=dev
|
npm audit --omit=dev
|
||||||
|
|
||||||
|
- name: Check archiver directory walk
|
||||||
|
# the readdir-glob minimatch override in client/package.json is a major
|
||||||
|
# bump; archiver's directory() is the only runtime caller of that path,
|
||||||
|
# so exercise it rather than trusting that the install succeeded
|
||||||
|
run: |
|
||||||
|
cd client
|
||||||
|
npm run check:archiver
|
||||||
|
|
||||||
- name: Lint check
|
- name: Lint check
|
||||||
run: npm run lint:check
|
run: npm run lint:check
|
||||||
|
|
||||||
|
- name: SAS lint check
|
||||||
|
# sas/.sasjslint promotes each rule to "error" as its findings are
|
||||||
|
# cleared, and only errors change the exit code - so this gate fails on
|
||||||
|
# a regression in a cleared rule and passes while other rules still
|
||||||
|
# carry warnings.
|
||||||
|
run: |
|
||||||
|
cd sas
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
npm run lint:sas
|
||||||
|
|
||||||
- name: Licence checker
|
- name: Licence checker
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
@@ -96,11 +130,21 @@ jobs:
|
|||||||
cd client
|
cd client
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Save the production build for the Cypress job
|
||||||
|
# The Cypress job needs client/dist: the server-ci target streams it as
|
||||||
|
# the 'clickme' web app (see CONTRIBUTING.md). Handing it the build this
|
||||||
|
# job has already made saves a second cold `npm run build` (1.6 minutes,
|
||||||
|
# measured over thirteen runs) and guarantees both jobs test the same
|
||||||
|
# bytes.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: client-dist
|
||||||
|
path: client/dist
|
||||||
|
|
||||||
# Cypress shard 1 of 4. The setup repeats in every shard because each
|
- name: Remove registry config
|
||||||
# one gets its own runner and its own SASjs server; that fixed cost is what
|
# written earlier in this job; nothing deletes it on the way out
|
||||||
# limits how far sharding helps. The specs it runs come from
|
if: always()
|
||||||
# .gitea/scripts/cypress-specs.sh.
|
run: rm -f "$RUNNER_TEMP/npmrc"
|
||||||
|
|
||||||
Build-and-test-development:
|
Build-and-test-development:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -113,16 +157,57 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Detect client changes
|
||||||
|
id: changes
|
||||||
|
# This job runs the mocked estate (sas/mocks) against the client, so a
|
||||||
|
# pull request that touches neither client/ nor sas/mocks cannot change
|
||||||
|
# its outcome: a sas/ service change is not deployed here, and neither
|
||||||
|
# is a documentation change. Skipping saves the twenty-six minute suite
|
||||||
|
# plus its installs on those pull requests.
|
||||||
|
#
|
||||||
|
# The condition on every gated step is `!= 'false'`, not `== 'true'`: if
|
||||||
|
# this step ever fails to publish its output the suite still runs. A CI
|
||||||
|
# change whose worst case is "slow" is safe; one whose worst case is
|
||||||
|
# "silently tests nothing" is not.
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BASE="${{ github.base_ref }}"
|
||||||
|
if [ -z "$BASE" ]; then
|
||||||
|
echo "no base ref (not a pull request) - running the suite"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git fetch --no-tags --depth=1 origin "+refs/heads/${BASE}:refs/remotes/origin/${BASE}"
|
||||||
|
CHANGED="$(git diff --name-only "refs/remotes/origin/${BASE}" HEAD)"
|
||||||
|
if printf '%s\n' "$CHANGED" | grep -qE '^(client/|sas/mocks/|package-lock\.json|\.gitea/workflows/build\.yaml)'; then
|
||||||
|
echo "client=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "client=false" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'Skipping the Cypress suite - nothing under client/ or sas/mocks/ changed:\n%s\n' "$CHANGED"
|
||||||
|
fi
|
||||||
|
- name: Check runner size
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
|
# Each Cypress job compiles the frontend and runs a browser on top; a
|
||||||
|
# runner too small for that fails as a flaky-looking disconnect - see
|
||||||
|
# the script.
|
||||||
|
run: bash .gitea/scripts/check-runner-memory.sh
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: |
|
run: |
|
||||||
touch client/.npmrc
|
# the secret arrives through the environment, not interpolated into
|
||||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
# this script: a quote or $( in the value would otherwise break the
|
||||||
|
# step or execute
|
||||||
|
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
||||||
|
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
||||||
|
env:
|
||||||
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
@@ -133,32 +218,56 @@ jobs:
|
|||||||
update-locale LANG=en_GB.UTF-8 LC_ALL=en_GB.UTF-8
|
update-locale LANG=en_GB.UTF-8 LC_ALL=en_GB.UTF-8
|
||||||
|
|
||||||
- name: Write cypress credentials
|
- name: Write cypress credentials
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
CYPRESS_CREDS: ${{ secrets.CYPRESS_CREDS }}
|
CYPRESS_CREDS: ${{ secrets.CYPRESS_CREDS }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
# dependency hooks stay off (client/.npmrc); the client's own
|
||||||
|
# generator runs explicitly, because the app imports the file it makes
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
npm run postinstall
|
||||||
|
# cypress downloads its binary in a dependency install hook, which the
|
||||||
|
# policy skips, so it is installed explicitly here
|
||||||
|
npx cypress install
|
||||||
|
|
||||||
- name: Build frontend for web streaming
|
- name: Fetch the production build
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
# The server-ci target streams client/dist as the 'clickme' web app (see
|
# The server-ci target streams client/dist as the 'clickme' web app (see
|
||||||
# CONTRIBUTING.md), so the production build has to exist before the mock
|
# CONTRIBUTING.md), so the production build has to exist before the mock
|
||||||
# services are deployed. Without it the deploy logs
|
# services are deployed. Without it the deploy logs
|
||||||
# "webSourcePath: .../client/dist ... doesn't exist" and streams no frontend.
|
# "webSourcePath: .../client/dist ... doesn't exist" and streams no
|
||||||
run: |
|
# frontend. It is the artefact Build-and-ng-test published, so this job
|
||||||
cd client
|
# does not compile the frontend a second time.
|
||||||
npm run build
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: client-dist
|
||||||
|
path: client/dist
|
||||||
|
|
||||||
|
- name: Cache the npm downloads
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
|
# The server is installed from npm at a pinned version, so the tarball is
|
||||||
|
# immutable and the key never needs invalidating by hand: a hit skips
|
||||||
|
# the 10.7MB download, a miss costs exactly what the zip cost before.
|
||||||
|
# continue-on-error, because a runner without cache support should still
|
||||||
|
# run the pipeline - this step only ever saves time.
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
|
||||||
|
|
||||||
- name: Setup and start SASjs server
|
- name: Setup and start SASjs server
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: |
|
run: |
|
||||||
npm i -g pm2
|
npm i -g pm2
|
||||||
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
|
||||||
unzip linux.zip
|
|
||||||
touch .env
|
touch .env
|
||||||
echo RUN_TIMES=js >> .env
|
echo RUN_TIMES=js >> .env
|
||||||
echo NODE_PATH=node >> .env
|
echo NODE_PATH=node >> .env
|
||||||
@@ -170,11 +279,10 @@ jobs:
|
|||||||
# in a throwaway container, so opt in explicitly.
|
# in a throwaway container, so opt in explicitly.
|
||||||
echo RUN_AS=root >> .env
|
echo RUN_AS=root >> .env
|
||||||
cat .env
|
cat .env
|
||||||
pm2 start api-linux --wait-ready
|
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
|
||||||
# `pm2 start --wait-ready` can return while the app is still
|
# `pm2 start` returns before the app is listening, and the next step
|
||||||
# launching (it does not always wait for the ready signal), and the
|
# hits :5000 immediately. Wait for the server to answer, and dump its
|
||||||
# next step hits :5000 immediately. Wait for the server to answer,
|
# log if it never does.
|
||||||
# and dump its log if it never does.
|
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if curl -sf -o /dev/null http://localhost:5000/; then
|
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||||
echo "SASjs server answered on attempt ${i}"
|
echo "SASjs server answered on attempt ${i}"
|
||||||
@@ -184,10 +292,11 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "SASjs server did not come up within 120s"
|
echo "SASjs server did not come up within 120s"
|
||||||
pm2 list
|
pm2 list
|
||||||
pm2 logs api-linux --lines 100 --nostream
|
pm2 logs sasjs-server --lines 100 --nostream
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Deploy mocked services
|
- name: Deploy mocked services
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: |
|
run: |
|
||||||
cd ./sas/mocks/sasjs
|
cd ./sas/mocks/sasjs
|
||||||
npm install -g @sasjs/cli
|
npm install -g @sasjs/cli
|
||||||
@@ -204,6 +313,7 @@ jobs:
|
|||||||
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
|
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
|
||||||
|
|
||||||
- name: Prepare and run frontend and cypress
|
- name: Prepare and run frontend and cypress
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
# One job, one SASjs server, one ng serve, one Chrome: four of each on one
|
# One job, one SASjs server, one ng serve, one Chrome: four of each on one
|
||||||
# runner starved the app under test and every failure in that run was a
|
# runner starved the app under test and every failure in that run was a
|
||||||
# timeout, not an assertion.
|
# timeout, not an assertion.
|
||||||
@@ -230,25 +340,36 @@ jobs:
|
|||||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||||
cat ./cypress.config.ts
|
cat ./cypress.config.ts
|
||||||
# Start frontend and run cypress
|
# Start frontend and run cypress
|
||||||
# timeout 2700: SIGTERM after 45 min so Cypress can flush video/screenshots
|
# timeout 2700: SIGTERM after 45 min so Cypress can flush screenshots
|
||||||
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
|
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
|
||||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 2700 npx cypress run --browser chrome --spec "$SPECS"
|
#
|
||||||
|
# video=false, screenshotOnRunFailure=true: recording twenty-four
|
||||||
|
# specs costs encode time on every run, and the recording is only ever
|
||||||
|
# opened after a failure. A screenshot of the failing step carries the
|
||||||
|
# same information for a fraction of the cost - and cypress.config.ts
|
||||||
|
# sets screenshotOnRunFailure false by default, so both are passed
|
||||||
|
# here, as CLI overrides, leaving local runs (and the demo recorder,
|
||||||
|
# which sets RECORD_WINDOW_SIZE) on video.
|
||||||
|
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 2700 npx cypress run --browser chrome --config video=false,screenshotOnRunFailure=true --spec "$SPECS"
|
||||||
|
|
||||||
- name: Zip Cypress videos
|
- name: Zip Cypress failure evidence
|
||||||
# Only on a failure: a green run has nothing to watch, and zipping and
|
# Only on a failure: a green run has nothing to look at.
|
||||||
# uploading every run's videos is the part of the video cost that lands
|
if: failure() && steps.changes.outputs.client != 'false'
|
||||||
# on the clock.
|
|
||||||
if: failure()
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./client/cypress/videos
|
mkdir -p ./client/cypress/screenshots ./client/cypress/videos
|
||||||
zip -r cypress-videos ./client/cypress/videos
|
zip -r cypress-artifacts.zip ./client/cypress/screenshots ./client/cypress/videos
|
||||||
|
|
||||||
- name: Add cypress videos artifacts
|
- name: Add cypress failure evidence as an artifact
|
||||||
if: failure()
|
if: failure() && steps.changes.outputs.client != 'false'
|
||||||
# v3, not v4: this runner reports "GHESNotSupportedError:
|
# v3, not v4: this runner reports "GHESNotSupportedError:
|
||||||
# @actions/artifact v2.0.0+, upload-artifact@v4+ ... not supported on
|
# @actions/artifact v2.0.0+, upload-artifact@v4+ ... not supported on
|
||||||
# GHES", so the current major cannot upload here at all.
|
# GHES", so the current major cannot upload here at all.
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: cypress-videos.zip
|
name: cypress-artifacts.zip
|
||||||
path: cypress-videos.zip
|
path: cypress-artifacts.zip
|
||||||
|
|
||||||
|
- name: Remove registry config
|
||||||
|
# written earlier in this job; nothing deletes it on the way out
|
||||||
|
if: always()
|
||||||
|
run: rm -f "$RUNNER_TEMP/npmrc"
|
||||||
@@ -28,8 +28,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
printf '%s\n' "$NPMRC" >> client/.npmrc
|
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
||||||
echo "legacy-peer-deps=true" >> client/.npmrc
|
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
|
||||||
|
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
NPMRC: ${{ secrets.NPMRC }}
|
NPMRC: ${{ secrets.NPMRC }}
|
||||||
@@ -40,6 +41,9 @@ jobs:
|
|||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
# client install scripts are off (client/.npmrc); the generator that
|
||||||
|
# creates src/environments/version.ts runs explicitly
|
||||||
|
npm run postinstall
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
SHEET_PWD: ${{ secrets.SHEET_PWD }}
|
SHEET_PWD: ${{ secrets.SHEET_PWD }}
|
||||||
@@ -76,6 +80,11 @@ jobs:
|
|||||||
--password "$SURFER_PASSWORD" \
|
--password "$SURFER_PASSWORD" \
|
||||||
--message "Typedoc from ${REF_NAME} @ ${COMMIT_SHA:0:8}"
|
--message "Typedoc from ${REF_NAME} @ ${COMMIT_SHA:0:8}"
|
||||||
|
|
||||||
|
- name: Remove registry config
|
||||||
|
# written earlier in this job; nothing deletes it on the way out
|
||||||
|
if: always()
|
||||||
|
run: rm -f "$RUNNER_TEMP/npmrc"
|
||||||
|
|
||||||
sas-docs:
|
sas-docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ run-name: Running Lighthouse Performance and Accessibility Checks on Pull Reques
|
|||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
# the server the mocked services and app tests run against - bump deliberately
|
||||||
|
SASJS_SERVER_VERSION: '3.6.2'
|
||||||
NODE_VERSION: '24.15.0'
|
NODE_VERSION: '24.15.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -12,21 +14,68 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Detect client changes
|
||||||
|
id: changes
|
||||||
|
# The audit measures the client, served from the mocked estate. A pull
|
||||||
|
# request that changes neither cannot move a score, so it does not need
|
||||||
|
# nineteen minutes of runner (measured median) to prove it. As in
|
||||||
|
# build.yaml the gate is `!= 'false'`, so a missing output runs the job
|
||||||
|
# rather than silently passing it.
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BASE="${{ github.base_ref }}"
|
||||||
|
if [ -z "$BASE" ]; then
|
||||||
|
echo "no base ref (not a pull request) - running the audit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git fetch --no-tags --depth=1 origin "+refs/heads/${BASE}:refs/remotes/origin/${BASE}"
|
||||||
|
CHANGED="$(git diff --name-only "refs/remotes/origin/${BASE}" HEAD)"
|
||||||
|
if printf '%s\n' "$CHANGED" | grep -qE '^(client/|sas/mocks/|package-lock\.json|\.gitea/workflows/lighthouse\.yaml)'; then
|
||||||
|
echo "client=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "client=false" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'Skipping the audit - nothing under client/ or sas/mocks/ changed:\n%s\n' "$CHANGED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check runner size
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
|
# This job runs a production build, then a SASjs server, a dev server
|
||||||
|
# and Chrome. An undersized runner dies inside the build with exitcode
|
||||||
|
# 137; fail here in seconds instead - see the script.
|
||||||
|
run: bash .gitea/scripts/check-runner-memory.sh
|
||||||
|
|
||||||
- name: Use Node.js ${{ env.NODE_VERSION }}
|
- name: Use Node.js ${{ env.NODE_VERSION }}
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Install Google Chrome
|
- name: Install Google Chrome
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
apt install -y ./google-chrome*.deb
|
apt install -y ./google-chrome*.deb
|
||||||
|
|
||||||
- name: Install global packages
|
- name: Install global packages
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: npm i -g pm2 @sasjs/cli wait-on
|
run: npm i -g pm2 @sasjs/cli wait-on
|
||||||
|
|
||||||
|
- name: Cache the npm downloads
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
|
# The server is installed from npm at a pinned version, so the tarball is
|
||||||
|
# immutable and the key never needs invalidating by hand: a hit skips
|
||||||
|
# the 10.7MB download, a miss costs exactly what the zip cost before.
|
||||||
|
# continue-on-error, because a runner without cache support should still
|
||||||
|
# run the pipeline - this step only ever saves time.
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
|
||||||
|
|
||||||
- name: Setup and start SASjs server
|
- name: Setup and start SASjs server
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: |
|
run: |
|
||||||
touch .env
|
touch .env
|
||||||
echo RUN_TIMES=js >> .env
|
echo RUN_TIMES=js >> .env
|
||||||
@@ -39,13 +88,10 @@ jobs:
|
|||||||
# in a throwaway container, so opt in explicitly.
|
# in a throwaway container, so opt in explicitly.
|
||||||
echo RUN_AS=root >> .env
|
echo RUN_AS=root >> .env
|
||||||
cat .env
|
cat .env
|
||||||
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
|
||||||
unzip linux.zip
|
# `pm2 start` returns before the app is listening, and the next step
|
||||||
pm2 start api-linux --wait-ready
|
# hits :5000 immediately. Wait for the server to answer, and dump its
|
||||||
# `pm2 start --wait-ready` can return while the app is still
|
# log if it never does.
|
||||||
# launching (it does not always wait for the ready signal), and the
|
|
||||||
# next step hits :5000 immediately. Wait for the server to answer,
|
|
||||||
# and dump its log if it never does.
|
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if curl -sf -o /dev/null http://localhost:5000/; then
|
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||||
echo "SASjs server answered on attempt ${i}"
|
echo "SASjs server answered on attempt ${i}"
|
||||||
@@ -55,16 +101,22 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "SASjs server did not come up within 120s"
|
echo "SASjs server did not come up within 120s"
|
||||||
pm2 list
|
pm2 list
|
||||||
pm2 logs api-linux --lines 100 --nostream
|
pm2 logs sasjs-server --lines 100 --nostream
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: echo "$NPMRC" > client/.npmrc
|
if: steps.changes.outputs.client != 'false'
|
||||||
|
run: |
|
||||||
|
# the registry secret stays outside the repo: client/.npmrc is
|
||||||
|
# tracked now, and one git add -A would commit a live token
|
||||||
|
echo "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
||||||
|
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
|
|
||||||
- name: Install npm dependencies
|
- name: Install npm dependencies
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
@@ -73,39 +125,57 @@ jobs:
|
|||||||
--output ./libraries/sheet-crypto.tgz \
|
--output ./libraries/sheet-crypto.tgz \
|
||||||
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
# client install scripts are off (client/.npmrc); the generator that
|
||||||
|
# creates src/environments/version.ts runs explicitly
|
||||||
|
npm run postinstall
|
||||||
npm install -g replace-in-files-cli
|
npm install -g replace-in-files-cli
|
||||||
|
|
||||||
- name: Update appLoc in index.html
|
- name: Update appLoc in index.html
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/proj/sasjs/genesis-mocks"' ./src/index.html
|
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/proj/sasjs/genesis-mocks"' ./src/index.html
|
||||||
|
|
||||||
- name: Build Frontend
|
- name: Build Frontend
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Deploy JS mocked services and frontend to the local SASjs Server instance
|
- name: Deploy JS mocked services and frontend to the local SASjs Server instance
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: |
|
run: |
|
||||||
cd sas/mocks
|
cd sas/mocks
|
||||||
npm ci
|
npm ci
|
||||||
sasjs cbd -t server-ci
|
sasjs cbd -t server-ci
|
||||||
|
|
||||||
- name: Start frontend server
|
- name: Start frontend server
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
npx ng serve --host 0.0.0.0 --port 4200 &
|
npx ng serve --host 0.0.0.0 --port 4200 &
|
||||||
wait-on http://localhost:4200
|
wait-on http://localhost:4200
|
||||||
|
|
||||||
- name: Run Lighthouse CI
|
- name: Run Lighthouse CI
|
||||||
|
if: steps.changes.outputs.client != 'false'
|
||||||
|
# One run per URL: the assertions aggregate by median, so three runs
|
||||||
|
# bought a steadier median at three times the navigations (13.6 minutes
|
||||||
|
# of this job, measured). A pull request gate only needs to know the
|
||||||
|
# page still clears the thresholds it cleared before; a regression that
|
||||||
|
# only shows in a median of three is a flake to chase separately.
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
npx lhci autorun
|
npx lhci autorun
|
||||||
|
|
||||||
- name: Lighthouse Result Artifacts
|
- name: Lighthouse Result Artifacts
|
||||||
if: always()
|
if: always() && steps.changes.outputs.client != 'false'
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: Lighthouse results
|
name: Lighthouse results
|
||||||
path: client/lighthouse-reports
|
path: client/lighthouse-reports
|
||||||
include-hidden-files: true
|
include-hidden-files: true
|
||||||
|
|
||||||
|
- name: Remove registry config
|
||||||
|
# written earlier in this job; nothing deletes it on the way out
|
||||||
|
if: always()
|
||||||
|
run: rm -f "$RUNNER_TEMP/npmrc"
|
||||||
@@ -34,14 +34,19 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
- name: Check runner size
|
||||||
|
# Builds the same frontend as the release jobs; an undersized runner
|
||||||
|
# fails inside the build - see the script.
|
||||||
|
run: bash .gitea/scripts/check-runner-memory.sh
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
printf '%s\n' "$NPMRC" >> client/.npmrc
|
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
||||||
echo "legacy-peer-deps=true" >> client/.npmrc
|
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
|
||||||
|
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
NPMRC: ${{ secrets.NPMRC }}
|
NPMRC: ${{ secrets.NPMRC }}
|
||||||
@@ -91,6 +96,9 @@ jobs:
|
|||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
# client install scripts are off (client/.npmrc); the generator that
|
||||||
|
# creates src/environments/version.ts runs explicitly
|
||||||
|
npm run postinstall
|
||||||
npm i webpack
|
npm i webpack
|
||||||
npm run build
|
npm run build
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -207,3 +215,8 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "Uploading SHA256SUMS ..."
|
echo "Uploading SHA256SUMS ..."
|
||||||
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
|
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
|
||||||
|
|
||||||
|
- name: Remove registry config
|
||||||
|
# written earlier in this job; nothing deletes it on the way out
|
||||||
|
if: always()
|
||||||
|
run: rm -f "$RUNNER_TEMP/npmrc"
|
||||||
@@ -6,6 +6,8 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
# the server the mocked services and app tests run against - bump deliberately
|
||||||
|
SASJS_SERVER_VERSION: '3.6.2'
|
||||||
NODE_VERSION: '24.5.0'
|
NODE_VERSION: '24.5.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -16,14 +18,24 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check runner size
|
||||||
|
# A release job that OOMs costs the tag as well as the slot; fail in
|
||||||
|
# seconds on an undersized runner instead of twenty minutes in - see
|
||||||
|
# the script.
|
||||||
|
run: bash .gitea/scripts/check-runner-memory.sh
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
touch client/.npmrc
|
# the secret arrives through the environment, not interpolated into
|
||||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
# this script: a quote or $( in the value would otherwise break the
|
||||||
|
# step or execute
|
||||||
|
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
||||||
|
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
||||||
|
env:
|
||||||
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
|
|
||||||
- name: Install Chrome for Angular tests
|
- name: Install Chrome for Angular tests
|
||||||
run: |
|
run: |
|
||||||
@@ -43,6 +55,9 @@ jobs:
|
|||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
# client install scripts are off (client/.npmrc); the generator that
|
||||||
|
# creates src/environments/version.ts runs explicitly
|
||||||
|
npm run postinstall
|
||||||
|
|
||||||
- name: Check audit
|
- name: Check audit
|
||||||
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
|
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
|
||||||
@@ -62,8 +77,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
npm run postinstall
|
npm run postinstall
|
||||||
|
npm run check:archiver
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Remove registry config
|
||||||
|
# written earlier in this job; nothing deletes it on the way out
|
||||||
|
if: always()
|
||||||
|
run: rm -f "$RUNNER_TEMP/npmrc"
|
||||||
|
|
||||||
Build-and-test-development:
|
Build-and-test-development:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: Build-production-and-ng-test
|
needs: Build-production-and-ng-test
|
||||||
@@ -75,14 +96,23 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check runner size
|
||||||
|
# The development build plus a browser: same undersized-runner failure
|
||||||
|
# as the review jobs, on the path that makes the tag - see the script.
|
||||||
|
run: bash .gitea/scripts/check-runner-memory.sh
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
touch client/.npmrc
|
# the secret arrives through the environment, not interpolated into
|
||||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
# this script: a quote or $( in the value would otherwise break the
|
||||||
|
# step or execute
|
||||||
|
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
||||||
|
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
||||||
|
env:
|
||||||
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -106,6 +136,9 @@ jobs:
|
|||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
# client install scripts are off (client/.npmrc); the generator that
|
||||||
|
# creates src/environments/version.ts runs explicitly
|
||||||
|
npm run postinstall
|
||||||
|
|
||||||
- name: Build frontend for web streaming
|
- name: Build frontend for web streaming
|
||||||
# The server-ci target streams client/dist as the 'clickme' web app, so
|
# The server-ci target streams client/dist as the 'clickme' web app, so
|
||||||
@@ -117,11 +150,21 @@ jobs:
|
|||||||
cd client
|
cd client
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Cache the npm downloads
|
||||||
|
# The server is installed from npm at a pinned version, so the tarball is
|
||||||
|
# immutable and the key never needs invalidating by hand: a hit skips
|
||||||
|
# the 10.7MB download, a miss costs exactly what the zip cost before.
|
||||||
|
# continue-on-error, because a runner without cache support should still
|
||||||
|
# run the pipeline - this step only ever saves time.
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
|
||||||
|
|
||||||
- name: Setup and start SASjs server
|
- name: Setup and start SASjs server
|
||||||
run: |
|
run: |
|
||||||
npm i -g pm2
|
npm i -g pm2
|
||||||
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
|
||||||
unzip linux.zip
|
|
||||||
touch .env
|
touch .env
|
||||||
echo RUN_TIMES=js >> .env
|
echo RUN_TIMES=js >> .env
|
||||||
echo NODE_PATH=node >> .env
|
echo NODE_PATH=node >> .env
|
||||||
@@ -133,11 +176,10 @@ jobs:
|
|||||||
# in a throwaway container, so opt in explicitly.
|
# in a throwaway container, so opt in explicitly.
|
||||||
echo RUN_AS=root >> .env
|
echo RUN_AS=root >> .env
|
||||||
cat .env
|
cat .env
|
||||||
pm2 start api-linux --wait-ready
|
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
|
||||||
# `pm2 start --wait-ready` can return while the app is still
|
# `pm2 start` returns before the app is listening, and the next step
|
||||||
# launching (it does not always wait for the ready signal), and the
|
# hits :5000 immediately. Wait for the server to answer, and dump its
|
||||||
# next step hits :5000 immediately. Wait for the server to answer,
|
# log if it never does.
|
||||||
# and dump its log if it never does.
|
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if curl -sf -o /dev/null http://localhost:5000/; then
|
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||||
echo "SASjs server answered on attempt ${i}"
|
echo "SASjs server answered on attempt ${i}"
|
||||||
@@ -147,7 +189,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "SASjs server did not come up within 120s"
|
echo "SASjs server did not come up within 120s"
|
||||||
pm2 list
|
pm2 list
|
||||||
pm2 logs api-linux --lines 100 --nostream
|
pm2 logs sasjs-server --lines 100 --nostream
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Deploy mocked services
|
- name: Deploy mocked services
|
||||||
@@ -174,6 +216,9 @@ jobs:
|
|||||||
replace-in-files --regex='"password".*' --replacement='"password":"'${{ secrets.CYPRESS_PWD_SASJS }}'" ' ./cypress.env.json
|
replace-in-files --regex='"password".*' --replacement='"password":"'${{ secrets.CYPRESS_PWD_SASJS }}'" ' ./cypress.env.json
|
||||||
cat ./cypress.env.json
|
cat ./cypress.env.json
|
||||||
npm run postinstall
|
npm run postinstall
|
||||||
|
# cypress downloads its binary in a dependency install hook, which the
|
||||||
|
# client policy skips
|
||||||
|
npx cypress install
|
||||||
# Prepare index.html to SASJS local
|
# Prepare index.html to SASJS local
|
||||||
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
|
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
|
||||||
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/dc"' ./src/index.html
|
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/dc"' ./src/index.html
|
||||||
@@ -196,6 +241,11 @@ jobs:
|
|||||||
name: cypress-videos.zip
|
name: cypress-videos.zip
|
||||||
path: cypress-videos.zip
|
path: cypress-videos.zip
|
||||||
|
|
||||||
|
- name: Remove registry config
|
||||||
|
# written earlier in this job; nothing deletes it on the way out
|
||||||
|
if: always()
|
||||||
|
run: rm -f "$RUNNER_TEMP/npmrc"
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [Build-production-and-ng-test, Build-and-test-development]
|
needs: [Build-production-and-ng-test, Build-and-test-development]
|
||||||
@@ -216,14 +266,19 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check runner size
|
||||||
|
# This job builds the frontend twice (webdoc and the release check);
|
||||||
|
# both die the same way on an undersized runner - see the script.
|
||||||
|
run: bash .gitea/scripts/check-runner-memory.sh
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
echo "$NPMRC" > client/.npmrc
|
echo "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
||||||
echo "legacy-peer-deps=true" >> client/.npmrc
|
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
|
||||||
|
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
@@ -241,6 +296,9 @@ jobs:
|
|||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
# client install scripts are off (client/.npmrc); the generator that
|
||||||
|
# creates src/environments/version.ts runs explicitly
|
||||||
|
npm run postinstall
|
||||||
npm i webpack
|
npm i webpack
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
@@ -459,3 +517,8 @@ jobs:
|
|||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
|
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
|
||||||
"$BASE/actions/workflows/sites.yaml/dispatches"
|
"$BASE/actions/workflows/sites.yaml/dispatches"
|
||||||
|
|
||||||
|
- name: Remove registry config
|
||||||
|
# written earlier in this job; nothing deletes it on the way out
|
||||||
|
if: always()
|
||||||
|
run: rm -f "$RUNNER_TEMP/npmrc"
|
||||||
@@ -10,6 +10,8 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
# the server the mocked services and app tests run against - bump deliberately
|
||||||
|
SASJS_SERVER_VERSION: '3.6.2'
|
||||||
NODE_VERSION: '24.15.0'
|
NODE_VERSION: '24.15.0'
|
||||||
APP_LOC: /Public/app/dc
|
APP_LOC: /Public/app/dc
|
||||||
SURFER_HOST: https://demo.datacontroller.io
|
SURFER_HOST: https://demo.datacontroller.io
|
||||||
@@ -36,14 +38,23 @@ jobs:
|
|||||||
# The tag is what the client's build stamp describes (src/version.ts
|
# The tag is what the client's build stamp describes (src/version.ts
|
||||||
# runs git-describe), so the demo build needs the tags in the clone.
|
# runs git-describe), so the demo build needs the tags in the clone.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
- name: Check runner size
|
||||||
|
# The demo build is the same cold production build; an undersized
|
||||||
|
# runner fails inside it - see the script.
|
||||||
|
run: bash .gitea/scripts/check-runner-memory.sh
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
touch client/.npmrc
|
# the secret arrives through the environment, not interpolated into
|
||||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
# this script: a quote or $( in the value would otherwise break the
|
||||||
|
# step or execute
|
||||||
|
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
|
||||||
|
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
|
||||||
|
env:
|
||||||
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -51,6 +62,9 @@ jobs:
|
|||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
# client install scripts are off (client/.npmrc); the generator that
|
||||||
|
# creates src/environments/version.ts runs explicitly
|
||||||
|
npm run postinstall
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
# The site serves this build. `sasjs build` rewrites its index.html to
|
# The site serves this build. `sasjs build` rewrites its index.html to
|
||||||
@@ -66,19 +80,29 @@ jobs:
|
|||||||
cd sas
|
cd sas
|
||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
|
- name: Cache the npm downloads
|
||||||
|
# The server is installed from npm at a pinned version, so the tarball is
|
||||||
|
# immutable and the key never needs invalidating by hand: a hit skips
|
||||||
|
# the 10.7MB download, a miss costs exactly what the zip cost before.
|
||||||
|
# continue-on-error, because a runner without cache support should still
|
||||||
|
# run the pipeline - this step only ever saves time.
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
|
||||||
|
|
||||||
- name: Start SASjs server
|
- name: Start SASjs server
|
||||||
# Needed only to run makedata: the drive the site ships with is captured
|
# Needed only to run makedata: the drive the site ships with is captured
|
||||||
# from a real server (see the snapshot step below).
|
# from a real server (see the snapshot step below).
|
||||||
run: |
|
run: |
|
||||||
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
|
||||||
unzip -q linux.zip
|
|
||||||
touch .env
|
touch .env
|
||||||
echo RUN_TIMES=js >> .env
|
echo RUN_TIMES=js >> .env
|
||||||
echo NODE_PATH=node >> .env
|
echo NODE_PATH=node >> .env
|
||||||
echo HTTP_PORT=5000 >> .env
|
echo HTTP_PORT=5000 >> .env
|
||||||
# The server refuses to run as root unless RUN_AS names an account
|
# The server refuses to run as root unless RUN_AS names an account
|
||||||
echo RUN_AS=root >> .env
|
echo RUN_AS=root >> .env
|
||||||
nohup ./api-linux > server.log 2>&1 &
|
nohup npx --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }} > server.log 2>&1 &
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if curl -sf -o /dev/null http://localhost:5000/; then
|
if curl -sf -o /dev/null http://localhost:5000/; then
|
||||||
echo "SASjs server answered on attempt ${i}"
|
echo "SASjs server answered on attempt ${i}"
|
||||||
@@ -181,3 +205,8 @@ jobs:
|
|||||||
--message "$MESSAGE" \
|
--message "$MESSAGE" \
|
||||||
"${SITE_ARGS[@]}" \
|
"${SITE_ARGS[@]}" \
|
||||||
sas/mocks/sasjsbuild/mock
|
sas/mocks/sasjsbuild/mock
|
||||||
|
|
||||||
|
- name: Remove registry config
|
||||||
|
# written earlier in this job; nothing deletes it on the way out
|
||||||
|
if: always()
|
||||||
|
run: rm -f "$RUNNER_TEMP/npmrc"
|
||||||
@@ -20,7 +20,6 @@ sasjsbuild
|
|||||||
sasjsresults
|
sasjsresults
|
||||||
.env*
|
.env*
|
||||||
.sasjsrc
|
.sasjsrc
|
||||||
client/.npmrc
|
|
||||||
*~
|
*~
|
||||||
.lighthouseci
|
.lighthouseci
|
||||||
audit.log
|
audit.log
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and
|
|||||||
Never consider a change complete until the relevant linters pass on the files you touched — do not rely on the user's pre-commit hooks to catch it:
|
Never consider a change complete until the relevant linters pass on the files you touched — do not rely on the user's pre-commit hooks to catch it:
|
||||||
|
|
||||||
- **Client (TypeScript/HTML/etc.)**: run `npm run lint:check` from the `client/` directory (prettier). Fix any failures with `npm run lint:fix`.
|
- **Client (TypeScript/HTML/etc.)**: run `npm run lint:check` from the `client/` directory (prettier). Fix any failures with `npm run lint:fix`.
|
||||||
- **SAS**: after creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored).
|
- **SAS**: after creating or modifying any `.sas` files, run `npm run lint:sas` from the `sas/` directory. That script runs the `@sasjs/cli` version pinned in `sas/package.json`, so it matches CI; a global or `npx` `sasjs` may resolve a different `@sasjs/lint` and report a different rule set.
|
||||||
|
Rules are promoted to `error` in `sas/.sasjslint` one at a time as their findings reach zero, and only errors change the exit code - so the whole project must stay clean of the promoted rules, not just the files you touched. Warnings under rules not yet promoted are the remaining backlog; do not add to them in files you touch, and do not clear other files' findings in the same change unless that rule is the point of the change.
|
||||||
`sas/.sasjslint` carries `ignoredMacros: ["webout", "mpeinit", "mpeterm"]`. `webout` is the linter's own default - `sasjs compile` injects `%webout` into every service - and an explicit list replaces that default, so it has to be restated there. `mpeinit` and `mpeterm` are listed because every service is compiled with a serviceInit program (`sasjs/utils/serviceinitsas9|viya|server.sas`) that declares both macros in its own header, so a service body may call them without declaring them. Add a macro to that list only when it reaches a file the same way - injected by the compiler, through serviceInit, testSetUp or another implicit provider - never to silence a genuinely missing declaration.
|
`sas/.sasjslint` carries `ignoredMacros: ["webout", "mpeinit", "mpeterm"]`. `webout` is the linter's own default - `sasjs compile` injects `%webout` into every service - and an explicit list replaces that default, so it has to be restated there. `mpeinit` and `mpeterm` are listed because every service is compiled with a serviceInit program (`sasjs/utils/serviceinitsas9|viya|server.sas`) that declares both macros in its own header, so a service body may call them without declaring them. Add a macro to that list only when it reaches a file the same way - injected by the compiler, through serviceInit, testSetUp or another implicit provider - never to silence a genuinely missing declaration.
|
||||||
`noBannedStrings` flags the literal words `error ` and `warning ` anywhere in a file, comments included. Reword prose; where the text has to stay (a real `%put`, a doxygen `@warning` tag), keep the log output identical with `%put %str(WARN)ING:` rather than a per-file override, and use `@sasjslint {"noBannedStrings": false}` only when the word itself is the content.
|
`noBannedStrings` flags the literal words `error ` and `warning ` anywhere in a file, comments included. Reword prose; where the text has to stay (a real `%put`, a doxygen `@warning` tag), keep the log output identical with `%put %str(WARN)ING:` rather than a per-file override, and use `@sasjslint {"noBannedStrings": false}` only when the word itself is the content.
|
||||||
|
Hook scripts (`sasjs/services/hooks/*.sas`) run inside the service that includes them, so the macros they call come from that service's session. They carry a file-local `@sasjslint {"ignoredMacros": [...]}` rather than a macro list of their own, because end users copy and edit these scripts and should not have to maintain a declaration section.
|
||||||
|
`noMacroIfInOpenCode` is on, so `%if` must sit inside a `%macro` definition. Where a conditional has to stay in a service body, wrap the block in a small macro and call it unconditionally (`%macro build_staged(); ... %end; %mend build_staged; %build_staged()`), or resolve the value in a data step with `call symputx`. Two traps: a `%let` inside such a wrapper is local, so a macro variable that must survive the block has to be written with `call symputx(...,'G')`; and open-code `%if` itself needs SAS 9.4M5, which the code no longer relies on.
|
||||||
|
|
||||||
## No external assets
|
## No external assets
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
# [7.21.0](https://git.datacontroller.io/dc/dc/compare/v7.20.0...v7.21.0) (2026-10-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **client:** parse word-date values from VA parameters ([405b28d](https://git.datacontroller.io/dc/dc/commit/405b28dcccfe5994521bf63f0270d4877f544294))
|
||||||
|
* **client:** parse word-date values from VA parameters ([ce5b1aa](https://git.datacontroller.io/dc/dc/commit/ce5b1aa96175d0fd1f048fea6d127cbc743804f2))
|
||||||
|
* **makedata:** name the sasjs server group in the estate seed ([eeffa92](https://git.datacontroller.io/dc/dc/commit/eeffa92f9e973b323171bbefe2ef1cbc27e3aee7))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **derived:** apply the core field scan in the MPE_DERIVED hook ([a4a0f9c](https://git.datacontroller.io/dc/dc/commit/a4a0f9cb2f7c0b6252405b71df64aae912d1b075))
|
||||||
|
|
||||||
# [7.20.0](https://git.datacontroller.io/dc/dc/compare/v7.19.0...v7.20.0) (2026-10-28)
|
# [7.20.0](https://git.datacontroller.io/dc/dc/compare/v7.19.0...v7.20.0) (2026-10-28)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+25
-2
@@ -26,8 +26,9 @@ The standard [pre-commit](https://pre-commit.com) route is available as well, an
|
|||||||
[SheetJS Pro Version](https://www.npmjs.com/package/sheetjs)
|
[SheetJS Pro Version](https://www.npmjs.com/package/sheetjs)
|
||||||
|
|
||||||
|
|
||||||
To auth for SheetJS Pro version you need to use their private registry
|
To auth for SheetJS Pro version you need to use their private registry. Keep that config out of
|
||||||
Add `client/.npmrc` file with following content:
|
the repo - `client/.npmrc` is tracked and holds policy only (see below). Put the registry auth in
|
||||||
|
your user config, `~/.npmrc`:
|
||||||
```
|
```
|
||||||
@sheet:registry=https://pylon.sheetjs.com:54111/
|
@sheet:registry=https://pylon.sheetjs.com:54111/
|
||||||
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
|
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
|
||||||
@@ -46,8 +47,30 @@ echo "YOUR_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
|
|||||||
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||||
|
|
||||||
npm i
|
npm i
|
||||||
|
npm run postinstall
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The root and `sas/` `.npmrc` files set `ignore-scripts=true` too, and the root `package.json`
|
||||||
|
carries no install script - the root install is `npm ci` plus the subprojects, each run explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci # root tooling: gitleaks, prettier, semantic-release
|
||||||
|
cd client && npm ci && npm run postinstall
|
||||||
|
cd ../sas && npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
The client `.npmrc` is now tracked and sets `ignore-scripts=true`, so dependency install hooks do not
|
||||||
|
run. Two of them matter here: the client's own `postinstall` - which generates
|
||||||
|
`src/environments/version.ts`, imported throughout the app - and cypress's binary download. Run
|
||||||
|
`npm run postinstall` after installing (and `npx cypress install` if you are running the Cypress
|
||||||
|
suite).
|
||||||
|
|
||||||
|
Registry credentials do **not** belong in `client/.npmrc`: it is a tracked policy file, and a token
|
||||||
|
written there is one `git add -A` away from being committed. Keep the token in `~/.npmrc`, npm's
|
||||||
|
default user config, or point npm at a file outside the repo with
|
||||||
|
`NPM_CONFIG_USERCONFIG=/path/to/npmrc`. The pre-commit hook rejects a staged `client/.npmrc` that
|
||||||
|
holds `_authToken`, `_auth` or `_password`.
|
||||||
|
|
||||||
[Handsontable](https://www.npmjs.com/package/handsontable)
|
[Handsontable](https://www.npmjs.com/package/handsontable)
|
||||||
|
|
||||||
The licence key comes from the backend licence: the decrypted licence carries `hot_license_key`,
|
The licence key comes from the backend licence: the decrypted licence carries `hot_license_key`,
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ignore-scripts=true
|
||||||
|
save-exact=true
|
||||||
|
# dependency install hooks are skipped: the client's own postinstall generates
|
||||||
|
# src/environments/version.ts and the hot icons, so run `npm run postinstall`
|
||||||
|
# after installing - see CONTRIBUTING.md for the full sequence
|
||||||
@@ -54,6 +54,43 @@ export default defineConfig({
|
|||||||
err ? reject(err) : resolve(null)
|
err ? reject(err) : resolve(null)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a copy of a fixture whose primary keys are renumbered from a
|
||||||
|
* random base, and returns the path to write it to.
|
||||||
|
*
|
||||||
|
* An upload that matches what the table already holds produces no
|
||||||
|
* changes, and a changeset with no changes has no approval waiting - so
|
||||||
|
* re-running a spec that uploads the same file finds nothing on the
|
||||||
|
* approve screen. That is not a UI fault, and a retry after a partial
|
||||||
|
* first attempt hits it every time. New keys make the upload a real
|
||||||
|
* insert whichever attempt gets there.
|
||||||
|
*/
|
||||||
|
makeUniqueExcel({ source, dest }: { source: string; dest: string }) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const ExcelJS = require('exceljs')
|
||||||
|
const workbook = new ExcelJS.Workbook()
|
||||||
|
const base = 1000000 + Math.floor(Math.random() * 8000000)
|
||||||
|
|
||||||
|
return workbook.xlsx.readFile(source).then(() => {
|
||||||
|
const sheet = workbook.worksheets[0]
|
||||||
|
let rowNumber = 0
|
||||||
|
|
||||||
|
sheet.eachRow((row: any, index: number) => {
|
||||||
|
if (index === 1) return // header
|
||||||
|
row.getCell(1).value = base + rowNumber++
|
||||||
|
})
|
||||||
|
|
||||||
|
return workbook.xlsx.writeFile(dest).then(() => dest)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/** removes one file - deleteFolder would take the source fixture too */
|
||||||
|
deleteFile(filePath: string) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
;(fs as any).rm(filePath, { force: true }, () => resolve(null))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,21 @@ const serverType = Cypress.env('serverType')
|
|||||||
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
||||||
const downloadsFolder = Cypress.config('downloadsFolder')
|
const downloadsFolder = Cypress.config('downloadsFolder')
|
||||||
|
|
||||||
|
/* set by test 1 and removed afterwards, since it is written per attempt */
|
||||||
|
let generatedExcel = ''
|
||||||
|
|
||||||
import { deleteDownloadsFolder } from '../util/deleteDownloadFolder'
|
import { deleteDownloadsFolder } from '../util/deleteDownloadFolder'
|
||||||
|
|
||||||
context('download files test: ', function () {
|
context('download files test: ', function () {
|
||||||
this.beforeAll(() => {
|
this.beforeAll(() => {
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||||
cy.loginAndUpdateValidKey()
|
/* the estate ships no licence, so it runs on the free tier - and the free
|
||||||
|
tier disables file upload, which is the whole point of the audit case.
|
||||||
|
Asking for a licensed estate here means the spec does not depend on
|
||||||
|
another spec having planted a key first: the key persists in the estate's
|
||||||
|
config, so whether this case could run depended on the order the specs
|
||||||
|
were sharded into. */
|
||||||
|
cy.loginAndUpdateValidKey(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.beforeEach(() => {
|
this.beforeEach(() => {
|
||||||
@@ -37,22 +46,84 @@ context('download files test: ', function () {
|
|||||||
|
|
||||||
this.afterEach(() => {
|
this.afterEach(() => {
|
||||||
deleteDownloadsFolder()
|
deleteDownloadsFolder()
|
||||||
|
|
||||||
|
if (generatedExcel) {
|
||||||
|
cy.task('deleteFile', 'cypress/fixtures/excels/' + generatedExcel)
|
||||||
|
generatedExcel = ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('1 | downloads audit file', (done) => {
|
it('1 | downloads audit file', (done) => {
|
||||||
visitPage('approve/toapprove')
|
/* The approve grid renders a download button per table that is waiting for
|
||||||
|
approval, and a freshly seeded estate has none, so create one: upload a
|
||||||
|
fixture to the test table and submit it from the stage screen. The row is
|
||||||
|
then found on the approve screen by the table it names, because the stage
|
||||||
|
route does not carry that table's id.
|
||||||
|
The submitted row is left behind: CI recreates the appLoc and re-runs
|
||||||
|
makedata before the specs. On an estate reused by hand the rows accumulate
|
||||||
|
and the approve grid paginates, so a row pushed past the first page would
|
||||||
|
not be found - the table name still anchors the assertion, but it cannot
|
||||||
|
rescue a row that is not on the page. */
|
||||||
|
/* the fixture is renumbered before every attempt: an upload identical to
|
||||||
|
what the table already holds produces no changes, and a changeset with no
|
||||||
|
changes leaves nothing waiting for approval. On a retry that is exactly
|
||||||
|
what happens - the first attempt's rows are already in the table - so the
|
||||||
|
keys come from a random base and the upload is always an insert. */
|
||||||
|
generatedExcel = 'generated_' + Date.now() + '.xlsx'
|
||||||
|
|
||||||
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
cy.task('makeUniqueExcel', {
|
||||||
.should('not.exist')
|
source: 'cypress/fixtures/excels/regular_excel.xlsx',
|
||||||
.then(() => {
|
dest: 'cypress/fixtures/excels/' + generatedExcel
|
||||||
cy.get('.btn.btn-success')
|
|
||||||
.should('be.visible')
|
|
||||||
.then((buttons) => {
|
|
||||||
buttons[0].click()
|
|
||||||
const id = buttons[0].id
|
|
||||||
|
|
||||||
checkForFileDownloaded(id, 'zip', () => done())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
attachExcelFile(generatedExcel)
|
||||||
|
|
||||||
|
submitExcel()
|
||||||
|
|
||||||
|
/* the stage route carries a load reference, not the table id: an upload
|
||||||
|
lands on /stage/DC20261031-201657.470_601010_4449 rather than on
|
||||||
|
/stage/<table id>, so taking the id from the URL looked for a button that
|
||||||
|
could never exist. The row is found by the table it names, and the id the
|
||||||
|
download is named after is read from the button itself. */
|
||||||
|
visitPage('review/approve')
|
||||||
|
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
||||||
|
'not.exist'
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.get('clr-datagrid clr-dg-row', { timeout: longerCommandTimeout }).should(
|
||||||
|
($rows) => {
|
||||||
|
const texts = Array.from($rows).map((row: any) =>
|
||||||
|
row.innerText.replace(/\s+/g, ' ')
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
texts.some((text: string) => text.includes('MPE_X_TEST')),
|
||||||
|
`no approval row names MPE_X_TEST - rows: ${texts
|
||||||
|
.slice(0, 5)
|
||||||
|
.join(' | ')
|
||||||
|
.slice(0, 300)}`
|
||||||
|
).to.be.true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.get('clr-datagrid clr-dg-row')
|
||||||
|
.filter(':contains("MPE_X_TEST")')
|
||||||
|
.first()
|
||||||
|
.find('[aria-label="Download audit file"]')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.then(($btn) => {
|
||||||
|
const tableId = $btn.attr('id') as string
|
||||||
|
|
||||||
|
expect(tableId, 'the approve button carries the table id').to.be.a(
|
||||||
|
'string'
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.wrap($btn).click()
|
||||||
|
|
||||||
|
checkForFileDownloaded(tableId, 'zip', () => done())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -83,7 +154,7 @@ context('download files test: ', function () {
|
|||||||
|
|
||||||
openDownloadModal(() => {
|
openDownloadModal(() => {
|
||||||
cy.get('select')
|
cy.get('select')
|
||||||
.select('Excel')
|
.select('EXCEL')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
||||||
button.trigger('click')
|
button.trigger('click')
|
||||||
@@ -103,7 +174,7 @@ context('download files test: ', function () {
|
|||||||
|
|
||||||
openDownloadModal(() => {
|
openDownloadModal(() => {
|
||||||
cy.get('select')
|
cy.get('select')
|
||||||
.select('SAS Datalines')
|
.select('SAS')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
||||||
button.trigger('click')
|
button.trigger('click')
|
||||||
@@ -123,7 +194,7 @@ context('download files test: ', function () {
|
|||||||
|
|
||||||
openDownloadModal(() => {
|
openDownloadModal(() => {
|
||||||
cy.get('select')
|
cy.get('select')
|
||||||
.select('SAS DDL')
|
.select('SAS_DDL')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
||||||
button.trigger('click')
|
button.trigger('click')
|
||||||
@@ -143,7 +214,7 @@ context('download files test: ', function () {
|
|||||||
|
|
||||||
openDownloadModal(() => {
|
openDownloadModal(() => {
|
||||||
cy.get('select')
|
cy.get('select')
|
||||||
.select('TSQL DDL')
|
.select('TSQL_DDL')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
||||||
button.trigger('click')
|
button.trigger('click')
|
||||||
@@ -163,7 +234,7 @@ context('download files test: ', function () {
|
|||||||
|
|
||||||
openDownloadModal(() => {
|
openDownloadModal(() => {
|
||||||
cy.get('select')
|
cy.get('select')
|
||||||
.select('PGSQL DDL')
|
.select('PGSQL_DDL')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
||||||
button.trigger('click')
|
button.trigger('click')
|
||||||
@@ -176,6 +247,26 @@ context('download files test: ', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('8 | downloads viewer PDF report', (done) => {
|
||||||
|
visitPage('view/data')
|
||||||
|
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
openDownloadModal(() => {
|
||||||
|
cy.get('select')
|
||||||
|
.select('PDF')
|
||||||
|
.then(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
||||||
|
button.trigger('click')
|
||||||
|
|
||||||
|
const id = button[0].id
|
||||||
|
|
||||||
|
checkForFileDownloaded(id, 'pdf', () => done())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
this.afterEach(() => {
|
this.afterEach(() => {
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||||
})
|
})
|
||||||
@@ -209,18 +300,23 @@ const checkForFileDownloaded = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openDownloadModal = (callback?: any) => {
|
const openDownloadModal = (callback?: any) => {
|
||||||
cy.get('.btn.btn-sm.btn-outline.filterSide.dropdown-toggle')
|
/* filterSide is the viewer's options dropdown; it no longer carries a
|
||||||
|
dropdown-toggle class - see filter-panel.cy.ts and row-cell-limits.cy.ts.
|
||||||
|
Its entries are div[clrDropdownItem], not buttons, so select Download by its
|
||||||
|
text the way filter-panel.cy.ts selects Filter. */
|
||||||
|
cy.get('.btn.btn-sm.btn-outline.filterSide', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
.click()
|
.click()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('clr-dropdown-menu button').then((buttons) => {
|
/* the click handler is on the clrDropdownItem element, not on the span
|
||||||
for (let button of buttons) {
|
holding the text, and the whole label is matched: more than one entry
|
||||||
if (button.innerText.toLowerCase().includes('download')) {
|
may mention Download, and an ambiguous cy.contains fails outright */
|
||||||
button.click()
|
cy.contains('clr-dropdown-menu [clrDropdownItem]', /^\s*Download\s*$/, {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).click()
|
||||||
|
|
||||||
if (callback) callback()
|
if (callback) callback()
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,3 +355,45 @@ const openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fixturePath = 'excels'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a fixture to the open table. The editor renders the file input inside
|
||||||
|
* its upload modal, which the Upload button in the button bar opens, so that
|
||||||
|
* button is clicked first - by its label, not by position, so a change to the
|
||||||
|
* bar's button order cannot break this.
|
||||||
|
*/
|
||||||
|
const attachExcelFile = (excelFilename: string) => {
|
||||||
|
cy.get('.buttonBar button', { timeout: longerCommandTimeout })
|
||||||
|
.contains('Upload')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('input[type="file"]#file-upload', { timeout: longerCommandTimeout })
|
||||||
|
.should('have.length', 1)
|
||||||
|
.attachFile(`/${fixturePath}/${excelFilename}`)
|
||||||
|
|
||||||
|
/* a parsed upload raises the info modal with this title. The modal component
|
||||||
|
is the same one aborts use, so the title is what distinguishes them: any
|
||||||
|
other text here - an abort, a validation error - fails the case with its own
|
||||||
|
message rather than being clicked through. */
|
||||||
|
cy.get('.clr-abort-modal .modal-title', { timeout: longerCommandTimeout })
|
||||||
|
.should('be.visible')
|
||||||
|
.invoke('text')
|
||||||
|
.should('include', 'Table Found')
|
||||||
|
|
||||||
|
cy.get('.clr-abort-modal .modal-footer button').contains('Close').click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitExcel = () => {
|
||||||
|
cy.get('.buttonBar button.preview-submit', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).click()
|
||||||
|
|
||||||
|
/* the URL is already a stage route before the submit lands, so waiting on it
|
||||||
|
proves nothing; the editor leaving preview mode is the signal that the
|
||||||
|
submit was accepted */
|
||||||
|
cy.get('.buttonBar button.preview-submit', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should('not.exist')
|
||||||
|
}
|
||||||
@@ -49,6 +49,16 @@ context('excel multi load tests: ', function () {
|
|||||||
undefined,
|
undefined,
|
||||||
(includes: boolean) => {
|
(includes: boolean) => {
|
||||||
if (includes) {
|
if (includes) {
|
||||||
|
// MPE_TABLES is left out of the submission, and the pane
|
||||||
|
// says why - as a sheet that could not be read or as one
|
||||||
|
// with no rows, depending on what the sheet utility
|
||||||
|
// reported. Either way it is named.
|
||||||
|
cy.get('#file-extraction-failures, #file-empty-sheets', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
|
.should('be.visible')
|
||||||
|
.and('contain.text', mpeTablesTable)
|
||||||
|
|
||||||
// MPE_TABLES sheet does not have data so 1 error image must be shown
|
// MPE_TABLES sheet does not have data so 1 error image must be shown
|
||||||
hasErrorTables(1, (valid: boolean) => {
|
hasErrorTables(1, (valid: boolean) => {
|
||||||
if (valid) done()
|
if (valid) done()
|
||||||
@@ -193,6 +203,46 @@ context('excel multi load tests: ', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The file's own status line: size and stage. The stages are momentary, so
|
||||||
|
// the dataset lookups are held open to make "Fetching the dataset details"
|
||||||
|
// assertable (and visible in the recording).
|
||||||
|
it('4 | Shows the file size and the loading stage while the file is processed', () => {
|
||||||
|
attachExcelFile('multi_load_test_1.xlsx', () => {
|
||||||
|
cy.contains('File size:')
|
||||||
|
.should('be.visible')
|
||||||
|
.invoke('text')
|
||||||
|
.should('match', /File size:\s*[\d.]+ MB/)
|
||||||
|
|
||||||
|
// The stage line lives in the file pane until the datasets are fetched,
|
||||||
|
// then in the no-table-selected pane while the sheets are extracted, so
|
||||||
|
// both ids are asked for - the element that carries it changes.
|
||||||
|
const STAGE = '#file-loading-stage, #file-extraction-stage'
|
||||||
|
|
||||||
|
// The sheet scan is quick; from then on the file waits for the user.
|
||||||
|
cy.get(STAGE, { timeout: longerCommandTimeout })
|
||||||
|
.should('be.visible')
|
||||||
|
.and('contain.text', 'File ready to load')
|
||||||
|
|
||||||
|
// The adapter posts to /SASjsApi/stp/execute/?_program=... - a glob on
|
||||||
|
// 'execute**' does not match the trailing slash, so match on the path.
|
||||||
|
cy.intercept({ url: /SASjsApi\/stp\/execute/ }, (req) => {
|
||||||
|
req.on('response', (res) => {
|
||||||
|
res.setDelay(1500)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('#continue-btn').trigger('click')
|
||||||
|
|
||||||
|
cy.get(STAGE, { timeout: longerCommandTimeout })
|
||||||
|
.should('be.visible')
|
||||||
|
.and('contain.text', 'Fetching the dataset details')
|
||||||
|
|
||||||
|
// Once every sheet has been extracted the stage gives way to the hint.
|
||||||
|
cy.get(STAGE, { timeout: longerCommandTimeout }).should('not.exist')
|
||||||
|
cy.contains('Please select a dataset on the left').should('be.visible')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
this.afterEach(() => {
|
this.afterEach(() => {
|
||||||
colorLog(`TEST END -------------`, '#3498DB')
|
colorLog(`TEST END -------------`, '#3498DB')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -419,6 +419,46 @@ context('excel tests: ', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('26 | Discarding an upload that fails validation clears the red cells', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
// The loaded table's row count is what the grid must return to - the
|
||||||
|
// preview replaces the data source with the parsed file.
|
||||||
|
cy.get('h4.editor-title', { timeout: longerCommandTimeout })
|
||||||
|
.invoke('text')
|
||||||
|
.should('match', /\(\d+ rows?/)
|
||||||
|
.then((titleBefore) => {
|
||||||
|
const rowsBefore = titleBefore.match(/\(([\d,]+) rows?/)?.[1]
|
||||||
|
|
||||||
|
attachExcelFile('mixed_content_excel.xlsx', () => {
|
||||||
|
// The preview paints the cells that fail validation red - the state
|
||||||
|
// the user is looking at when they decide to abandon the file.
|
||||||
|
cy.get('#hotTable .htInvalid', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should('have.length.greaterThan', 0)
|
||||||
|
|
||||||
|
cy.get('.buttonBar .btn-upload-preview.btn-outline-danger').click()
|
||||||
|
cy.get('clr-modal .modal-footer .btn-success-outline').click()
|
||||||
|
|
||||||
|
// Back to the loaded table, with no red cells left over. cancelEdit
|
||||||
|
// re-runs the HOT validation after restoring the data; without that
|
||||||
|
// the invalid cell meta survives the restore and the grid stays red.
|
||||||
|
cy.get('h4.editor-title', { timeout: longerCommandTimeout }).should(
|
||||||
|
'contain.text',
|
||||||
|
`(${rowsBefore} rows`
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.get('#hotTable .htInvalid').should('not.exist')
|
||||||
|
|
||||||
|
// And the page is back in its normal read-only state, not the
|
||||||
|
// preview's button bar.
|
||||||
|
cy.get('.btnCtrl .btn-primary', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should('contain.text', 'Edit')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Large files break Cypress
|
// Large files break Cypress
|
||||||
|
|
||||||
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
||||||
|
|||||||
@@ -331,7 +331,10 @@ const setFilterWithValue = (
|
|||||||
closeAutocompleteList('#vals_0_0')
|
closeAutocompleteList('#vals_0_0')
|
||||||
cy.get('#datalist_vals_0_0').should('have.class', 'hidden')
|
cy.get('#datalist_vals_0_0').should('have.class', 'hidden')
|
||||||
|
|
||||||
cy.get('.filter-modal button[aria-label="Ok"]').click()
|
// Live on the markup rather than a label: the filter modal's submit button
|
||||||
|
// has its text ("Ok") and no explicit aria-label, and the ARIA pass no
|
||||||
|
// longer synthesises one.
|
||||||
|
cy.contains('.filter-modal .modal-footer button', /^\s*Ok\s*$/).click()
|
||||||
|
|
||||||
if (callback) callback()
|
if (callback) callback()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
const username = Cypress.env('username')
|
export {}
|
||||||
const password = Cypress.env('password')
|
|
||||||
const hostUrl = Cypress.env('hosturl')
|
const hostUrl = Cypress.env('hosturl')
|
||||||
const appLocation = Cypress.env('appLocation')
|
const appLocation = Cypress.env('appLocation')
|
||||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||||
const serverType = Cypress.env('serverType')
|
|
||||||
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
|
||||||
const fixturePath = 'excels_general/'
|
|
||||||
|
|
||||||
context('metanav tests: ', function () {
|
context('metanav tests: ', function () {
|
||||||
this.beforeAll(() => {
|
this.beforeAll(() => {
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||||
cy.loginAndUpdateValidKey()
|
cy.loginAndUpdateValidKey(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.beforeEach(() => {
|
this.beforeEach(() => {
|
||||||
@@ -21,14 +18,34 @@ context('metanav tests: ', function () {
|
|||||||
visitPage('view/metadata')
|
visitPage('view/metadata')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('1 | Opens metadata object', (done) => {
|
it('1 | Opens metadata object', () => {
|
||||||
openFirstMetadataFromTree(() => {
|
openFirstMetadataType()
|
||||||
// BLOCKER
|
|
||||||
// For unkown reasons, .clr-accordion-header-button always null although it is present on the page.
|
/**
|
||||||
cy.get('.clr-accordion-header-button').then((panelNodes: any) => {
|
* Query the accordion at document level. The panel renders in the content
|
||||||
panelNodes[0].querySelector('button').click()
|
* area, while `.within(<tree node>)` scopes every following `cy.get` to
|
||||||
})
|
* that node alone - which finds nothing however long it retries.
|
||||||
})
|
*/
|
||||||
|
cy.get('.clr-accordion-header-button', { timeout: longerCommandTimeout })
|
||||||
|
.should('have.length.greaterThan', 0)
|
||||||
|
.first()
|
||||||
|
// Clarity puts the class on the button itself, so it is the click target.
|
||||||
|
.click()
|
||||||
|
|
||||||
|
// The panel reports its own open state, so the assertion is on the app's
|
||||||
|
// marker rather than on the content happening to be present.
|
||||||
|
cy.get('.clr-accordion-panel-open').should('have.length', 1)
|
||||||
|
cy.get('.clr-accordion-header-button')
|
||||||
|
.first()
|
||||||
|
.should('have.attr', 'aria-expanded', 'true')
|
||||||
|
|
||||||
|
// Opening the panel loads the object's details: the association tree and
|
||||||
|
// the attribute grid both come from `metanav/metadetails`.
|
||||||
|
cy.get('.clr-accordion-inner-content clr-tree-node', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).should('have.length.greaterThan', 0)
|
||||||
|
cy.get('clr-dg-row').should('have.length.greaterThan', 0)
|
||||||
|
cy.get('clr-dg-cell').contains('Name')
|
||||||
})
|
})
|
||||||
|
|
||||||
this.afterEach(() => {
|
this.afterEach(() => {
|
||||||
@@ -36,23 +53,16 @@ context('metanav tests: ', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const openFirstMetadataFromTree = (callback?: any) => {
|
const openFirstMetadataType = () => {
|
||||||
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
||||||
.should('not.exist')
|
// The metadata types are the tree nodes after the search box; the search box
|
||||||
.then(() => {
|
// is its own node (`.search-node`), so selecting by position would tie the
|
||||||
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
// spec to that detail. The <p> is the element that owns the click handler.
|
||||||
|
cy.get('.nav-tree clr-tree > clr-tree-node:not(.search-node) p', {
|
||||||
timeout: longerCommandTimeout
|
timeout: longerCommandTimeout
|
||||||
}).then((treeNodes: any) => {
|
|
||||||
let firstMetaNode
|
|
||||||
|
|
||||||
firstMetaNode = treeNodes[1]
|
|
||||||
|
|
||||||
cy.get(firstMetaNode).within(() => {
|
|
||||||
cy.get('.clr-treenode-content').click()
|
|
||||||
callback()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const visitPage = (url: string) => {
|
const visitPage = (url: string) => {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
ci: {
|
ci: {
|
||||||
collect: {
|
collect: {
|
||||||
|
// One pass per URL. lhci defaults to three and takes the median, which
|
||||||
|
// triples the wall clock of the audit (13.6 minutes measured across
|
||||||
|
// seventeen runs) to steady a number that only has to clear a fixed
|
||||||
|
// threshold. Raise this if a score starts flapping.
|
||||||
|
numberOfRuns: 1,
|
||||||
settings: {
|
settings: {
|
||||||
preset: 'desktop',
|
preset: 'desktop',
|
||||||
chromeFlags: '--no-sandbox --disable-dev-shm-usage'
|
chromeFlags: '--no-sandbox --disable-dev-shm-usage'
|
||||||
@@ -12,7 +17,6 @@ module.exports = {
|
|||||||
'http://localhost:5000/AppStream/clickme/#/view/data/DC996664',
|
'http://localhost:5000/AppStream/clickme/#/view/data/DC996664',
|
||||||
'http://localhost:5000/AppStream/clickme/#/view/data/DC996664.MPE_X_TEST',
|
'http://localhost:5000/AppStream/clickme/#/view/data/DC996664.MPE_X_TEST',
|
||||||
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups',
|
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups',
|
||||||
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups',
|
|
||||||
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups/1',
|
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups/1',
|
||||||
'http://localhost:5000/AppStream/clickme/#/view/usernav/users/1',
|
'http://localhost:5000/AppStream/clickme/#/view/usernav/users/1',
|
||||||
'http://localhost:5000/AppStream/clickme/#/home/excel-maps',
|
'http://localhost:5000/AppStream/clickme/#/home/excel-maps',
|
||||||
|
|||||||
Generated
+73
-49
@@ -22,7 +22,7 @@
|
|||||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||||
"@handsontable/angular-wrapper": "18.1.2",
|
"@handsontable/angular-wrapper": "18.1.2",
|
||||||
"@sasjs/adapter": "5.1.2",
|
"@sasjs/adapter": "5.1.2",
|
||||||
"@sasjs/utils": "4.0.0",
|
"@sasjs/utils": "4.3.1",
|
||||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||||
"@types/d3-graphviz": "^2.6.7",
|
"@types/d3-graphviz": "^2.6.7",
|
||||||
"@types/text-encoding": "0.0.35",
|
"@types/text-encoding": "0.0.35",
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
"@types/randombytes": "^2.0.3",
|
"@types/randombytes": "^2.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "8.65.0",
|
"@typescript-eslint/eslint-plugin": "8.65.0",
|
||||||
"@typescript-eslint/parser": "8.65.0",
|
"@typescript-eslint/parser": "8.65.0",
|
||||||
|
"archiver": "8.0.0",
|
||||||
"core-js": "^2.5.4",
|
"core-js": "^2.5.4",
|
||||||
"cypress": "^15.19.0",
|
"cypress": "^15.19.0",
|
||||||
"cypress-file-upload": "^5.0.8",
|
"cypress-file-upload": "^5.0.8",
|
||||||
@@ -5208,6 +5209,12 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@isaacs/nonrecursive-glob-match": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
|
||||||
|
"license": "BlueOak-1.0.0"
|
||||||
|
},
|
||||||
"node_modules/@istanbuljs/schema": {
|
"node_modules/@istanbuljs/schema": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||||
@@ -8722,9 +8729,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/utils": {
|
"node_modules/@sasjs/utils": {
|
||||||
"version": "4.0.0",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.3.1.tgz",
|
||||||
"integrity": "sha512-lARoCnRM4gSVf8ShoP5elHi7W28K/14MK037dQ0ABgMlP3+r6OuhAZYwK/a70tED47tKxyIoCAKLdP6jrG0ewQ==",
|
"integrity": "sha512-P98zjjfrhVJ7/ubKTWTn8Oiyb4/a+SIfVQOIa9oMXZrsRsZ5RTLj40sl6jmv1iCxbsqfziOrnNc0Yu6gx5Qp1A==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fast-csv/format": "5.0.7",
|
"@fast-csv/format": "5.0.7",
|
||||||
@@ -9877,9 +9884,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz",
|
||||||
"integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==",
|
"integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -9946,17 +9953,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
|
||||||
"integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==",
|
"integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.72.0",
|
"@typescript-eslint/scope-manager": "8.72.1",
|
||||||
"@typescript-eslint/types": "8.72.0",
|
"@typescript-eslint/types": "8.72.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.72.0"
|
"@typescript-eslint/typescript-estree": "8.72.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -9971,15 +9978,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz",
|
||||||
"integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==",
|
"integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.72.0",
|
"@typescript-eslint/tsconfig-utils": "^8.72.1",
|
||||||
"@typescript-eslint/types": "^8.72.0",
|
"@typescript-eslint/types": "^8.72.1",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -9994,15 +10001,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz",
|
||||||
"integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==",
|
"integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.72.0",
|
"@typescript-eslint/types": "8.72.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.72.0"
|
"@typescript-eslint/visitor-keys": "8.72.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -10013,9 +10020,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz",
|
||||||
"integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==",
|
"integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -10031,17 +10038,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz",
|
||||||
"integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==",
|
"integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.72.0",
|
"@typescript-eslint/project-service": "8.72.1",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.72.0",
|
"@typescript-eslint/tsconfig-utils": "8.72.1",
|
||||||
"@typescript-eslint/types": "8.72.0",
|
"@typescript-eslint/types": "8.72.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
"@typescript-eslint/visitor-keys": "8.72.1",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@@ -10060,14 +10067,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.72.0",
|
"version": "8.72.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz",
|
||||||
"integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==",
|
"integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.72.0",
|
"@typescript-eslint/types": "8.72.1",
|
||||||
"eslint-visitor-keys": "^5.0.0"
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -15246,9 +15253,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.1",
|
"version": "1.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
|
||||||
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -19280,6 +19287,7 @@
|
|||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||||
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^5.0.8"
|
"brace-expansion": "^5.0.8"
|
||||||
@@ -19601,9 +19609,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mocha/node_modules/argparse": {
|
"node_modules/mocha/node_modules/argparse": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.4.tgz",
|
||||||
"integrity": "sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==",
|
"integrity": "sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -22269,6 +22277,22 @@
|
|||||||
"url": "https://github.com/sponsors/yqnn"
|
"url": "https://github.com/sponsors/yqnn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||||
|
"version": "11.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.3.tgz",
|
||||||
|
"integrity": "sha512-iny/40PEGkNJQ5/Pv6z3nm6FbRAby32GeZRjCUJk2WnNDbgPSTcdI+DDvLqsgBdD4cD5sMtMheqPPnehCCa8Nw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/nonrecursive-glob-match": "^1.0.3",
|
||||||
|
"brace-expansion": "^5.0.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdir-scoped-modules": {
|
"node_modules/readdir-scoped-modules": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz",
|
||||||
@@ -25171,9 +25195,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vis-data": {
|
"node_modules/vis-data": {
|
||||||
"version": "8.0.5",
|
"version": "8.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.6.tgz",
|
||||||
"integrity": "sha512-tRQKcTGaclo60rTY1j4v7BgD9v5jfD3wl+JgR1q6I4AI7go3QH5OjMCLIuZPpL2sf4IRMlwyPlRLnK4m+Phjsw==",
|
"integrity": "sha512-6NjsyqWL18ithgkpPqfYGvwx7JlrxgO0MkMJdZus4eovFl6ebUjNcn/UEzgm5NWvCAz4psUgkvcdLyAXFxeeDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "(Apache-2.0 OR MIT)",
|
"license": "(Apache-2.0 OR MIT)",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -25206,9 +25230,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vis-util": {
|
"node_modules/vis-util": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vis-util/-/vis-util-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/vis-util/-/vis-util-6.0.3.tgz",
|
||||||
"integrity": "sha512-sYawqWllCqwmTPLurRgPChmfRppbWI/XoBWdGNM6N2s4XuVR8VO8gtz/v9AvrAAOfZxel/U5UBNOwyhFn0qb7A==",
|
"integrity": "sha512-2ar+MN4JYYfhRSh+sds7o0Sg7/i/mNMVXv32DaMg5I4N1AJjTaHvXqNtHcISXl4WyaVkJ6twiCfqStudO2TYFg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "(Apache-2.0 OR MIT)",
|
"license": "(Apache-2.0 OR MIT)",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
|
|||||||
+35
-32
@@ -3,43 +3,44 @@
|
|||||||
"description": "DataController Client",
|
"description": "DataController Client",
|
||||||
"angular-cli": {},
|
"angular-cli": {},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng serve",
|
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
|
||||||
"prestart": "npm run generate-eula",
|
"audit:prod": "npm audit --omit=dev",
|
||||||
"generate-eula": "node ./src/eula.ts",
|
|
||||||
"license-checker": "node licenseChecker.js",
|
|
||||||
"prebuild": "node ./src/version.ts && npm run generate-eula && npm run license-checker",
|
|
||||||
"build": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration production",
|
"build": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration production",
|
||||||
"postbuild": "rimraf dist/3rdpartylicenses.txt",
|
|
||||||
"build-dev": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration development",
|
"build-dev": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration development",
|
||||||
"build-watch": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration development --watch",
|
"build-watch": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration development --watch",
|
||||||
"sync": "./node_modules/.bin/watch --wait=3 \"echo Account: ${npm_config_account} && npm run deploy_${npm_config_server} --account=${npm_config_account} && notify-send 'Data Controller' 'App is synced!' ; echo 'App is synced!'\" dist",
|
"check:archiver": "node scripts/check-archiver.mjs",
|
||||||
"deploy_sas9": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/${npm_config_account}/dc/dev",
|
|
||||||
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
|
|
||||||
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
|
|
||||||
"viyabuild": "cd build; ./viyabuild.sh",
|
|
||||||
"lint": "npm run lint:fix",
|
|
||||||
"lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
|
|
||||||
"lint:fix:silent": "npx prettier --log-level silent --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
|
|
||||||
"lint:check": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
|
|
||||||
"lint:check:silent": "npx prettier --log-level silent --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
|
|
||||||
"test": "npx ng test",
|
|
||||||
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
|
|
||||||
"watch": "ng test watch=true",
|
|
||||||
"pree2e": "webdriver-manager update",
|
|
||||||
"e2e": "protractor protractor.config.js",
|
|
||||||
"postinstall": "node ./src/version.ts && npm run add-githook && node ./scripts/strip-clr-base64-fonts.mjs && node ./scripts/gen-hot-icons.mjs",
|
|
||||||
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
|
|
||||||
"cypress": "cypress open",
|
|
||||||
"cy:run": "cypress run",
|
|
||||||
"audit:prod": "npm audit --omit=dev",
|
|
||||||
"sasdocs": "sasjs doc && ./sasjs/utils/deploydocs.sh",
|
|
||||||
"compodoc:build": "compodoc -p tsconfig.doc.json --name 'Data Controller Client'",
|
"compodoc:build": "compodoc -p tsconfig.doc.json --name 'Data Controller Client'",
|
||||||
"compodoc:build-and-serve": "compodoc -p tsconfig.doc.json -s --name 'Data Controller Client'",
|
"compodoc:build-and-serve": "compodoc -p tsconfig.doc.json -s --name 'Data Controller Client'",
|
||||||
"compodoc:serve": "compodoc -s --name 'Data Controller Client'",
|
"compodoc:serve": "compodoc -s --name 'Data Controller Client'",
|
||||||
|
"cy:run": "cypress run",
|
||||||
|
"cypress": "cypress open",
|
||||||
|
"deploy_sas9": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/${npm_config_account}/dc/dev",
|
||||||
|
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
|
||||||
|
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
|
||||||
|
"e2e": "protractor protractor.config.js",
|
||||||
|
"generate-eula": "node ./src/eula.ts",
|
||||||
|
"license-checker": "node licenseChecker.js",
|
||||||
"lighthouse": "lhci autorun",
|
"lighthouse": "lhci autorun",
|
||||||
|
"lint": "npm run lint:fix",
|
||||||
|
"lint:check": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
|
||||||
|
"lint:check:silent": "npx prettier --log-level silent --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
|
||||||
|
"lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
|
||||||
|
"lint:fix:silent": "npx prettier --log-level silent --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
"postbuild": "rimraf dist/3rdpartylicenses.txt",
|
||||||
|
"postinstall": "node ./src/version.ts && npm run add-githook && node ./scripts/strip-clr-base64-fonts.mjs && node ./scripts/gen-hot-icons.mjs",
|
||||||
|
"prebuild": "node ./src/version.ts && npm run generate-eula && npm run license-checker",
|
||||||
|
"pree2e": "webdriver-manager update",
|
||||||
|
"prestart": "npm run generate-eula",
|
||||||
|
"sasdocs": "sasjs doc && ./sasjs/utils/deploydocs.sh",
|
||||||
|
"start": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng serve",
|
||||||
|
"sync": "./node_modules/.bin/watch --wait=3 \"echo Account: ${npm_config_account} && npm run deploy_${npm_config_server} --account=${npm_config_account} && notify-send 'Data Controller' 'App is synced!' ; echo 'App is synced!'\" dist",
|
||||||
|
"test": "npx ng test",
|
||||||
|
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
|
||||||
|
"test:tz-east": "TZ=Pacific/Auckland npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI --include='**/va-*.spec.ts'",
|
||||||
"test:tz-west": "TZ=America/New_York npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI --include='**/va-*.spec.ts'",
|
"test:tz-west": "TZ=America/New_York npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI --include='**/va-*.spec.ts'",
|
||||||
"test:tz-east": "TZ=Pacific/Auckland npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI --include='**/va-*.spec.ts'"
|
"viyabuild": "cd build; ./viyabuild.sh",
|
||||||
|
"watch": "ng test watch=true"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -58,7 +59,7 @@
|
|||||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||||
"@handsontable/angular-wrapper": "18.1.2",
|
"@handsontable/angular-wrapper": "18.1.2",
|
||||||
"@sasjs/adapter": "5.1.2",
|
"@sasjs/adapter": "5.1.2",
|
||||||
"@sasjs/utils": "4.0.0",
|
"@sasjs/utils": "4.3.1",
|
||||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||||
"@types/d3-graphviz": "^2.6.7",
|
"@types/d3-graphviz": "^2.6.7",
|
||||||
"@types/text-encoding": "0.0.35",
|
"@types/text-encoding": "0.0.35",
|
||||||
@@ -118,6 +119,7 @@
|
|||||||
"@types/randombytes": "^2.0.3",
|
"@types/randombytes": "^2.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "8.65.0",
|
"@typescript-eslint/eslint-plugin": "8.65.0",
|
||||||
"@typescript-eslint/parser": "8.65.0",
|
"@typescript-eslint/parser": "8.65.0",
|
||||||
|
"archiver": "8.0.0",
|
||||||
"core-js": "^2.5.4",
|
"core-js": "^2.5.4",
|
||||||
"cypress": "^15.19.0",
|
"cypress": "^15.19.0",
|
||||||
"cypress-file-upload": "^5.0.8",
|
"cypress-file-upload": "^5.0.8",
|
||||||
@@ -151,11 +153,12 @@
|
|||||||
"lighthouse": "13.4.0",
|
"lighthouse": "13.4.0",
|
||||||
"fast-uri": "4.2.3",
|
"fast-uri": "4.2.3",
|
||||||
"readdir-glob": {
|
"readdir-glob": {
|
||||||
"brace-expansion": "^5.0.9"
|
"brace-expansion": "5.0.12",
|
||||||
|
"minimatch": "11.0.3"
|
||||||
},
|
},
|
||||||
"exceljs": {
|
"exceljs": {
|
||||||
"archiver": "^8.0.0",
|
"archiver": "8.0.0",
|
||||||
"unzipper": "^0.12.5"
|
"unzipper": "0.12.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/* Guards the readdir-glob -> minimatch override in client/package.json.
|
||||||
|
*
|
||||||
|
* The override forces minimatch 11 where readdir-glob asks for ^10, and
|
||||||
|
* readdir-glob reaches minimatch on two paths: the directory walk, and the
|
||||||
|
* pattern/ignore matching behind archiver's glob(). Both are exercised here,
|
||||||
|
* because a matcher that breaks shows up as files silently skipped rather than
|
||||||
|
* as an error.
|
||||||
|
*
|
||||||
|
* Entries are read from archiver's own 'entry' events, so nothing has to be
|
||||||
|
* unzipped and the check needs no reader dependency.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { ZipArchive } from 'archiver'
|
||||||
|
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-arch-'))
|
||||||
|
const source = path.join(root, 'source')
|
||||||
|
|
||||||
|
const cleanup = () => fs.rmSync(root, { recursive: true, force: true })
|
||||||
|
const fail = (message) => {
|
||||||
|
console.error(message)
|
||||||
|
cleanup()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(source)
|
||||||
|
fs.writeFileSync(path.join(source, 'a.txt'), 'hello')
|
||||||
|
fs.mkdirSync(path.join(source, 'sub'))
|
||||||
|
fs.writeFileSync(path.join(source, 'sub', 'b.txt'), 'world')
|
||||||
|
fs.writeFileSync(path.join(source, 'sub', 'c.md'), 'ignored by pattern')
|
||||||
|
|
||||||
|
let runs = 0
|
||||||
|
|
||||||
|
const archiveWith = (configure) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const out = path.join(root, `test-${++runs}.zip`)
|
||||||
|
const sink = fs.createWriteStream(out)
|
||||||
|
const archive = new ZipArchive({ zlib: { level: 9 } })
|
||||||
|
const names = []
|
||||||
|
archive.on('entry', (entry) => names.push(entry.name))
|
||||||
|
archive.on('error', reject)
|
||||||
|
sink.on('error', reject)
|
||||||
|
sink.on('close', () => resolve(names))
|
||||||
|
archive.pipe(sink)
|
||||||
|
configure(archive)
|
||||||
|
archive.finalize()
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
/* the directory walk: every file, nested directories included */
|
||||||
|
const walked = await archiveWith((archive) => archive.directory(source, false))
|
||||||
|
const missing = ['a.txt', 'sub/b.txt'].filter((n) => !walked.includes(n))
|
||||||
|
if (missing.length) {
|
||||||
|
fail('directory walk missed: ' + missing.join(', ') + ' (got: ' + walked.join(', ') + ')')
|
||||||
|
}
|
||||||
|
|
||||||
|
/* pattern and ignore matching, which is minimatch used directly */
|
||||||
|
const globbed = await archiveWith((archive) =>
|
||||||
|
archive.glob('**/*.txt', { cwd: source, ignore: ['sub/**'] })
|
||||||
|
)
|
||||||
|
if (!globbed.includes('a.txt')) {
|
||||||
|
fail('glob did not match a.txt (got: ' + globbed.join(', ') + ')')
|
||||||
|
}
|
||||||
|
if (globbed.some((n) => n.startsWith('sub/'))) {
|
||||||
|
fail('glob ignore did not exclude sub/ (got: ' + globbed.join(', ') + ')')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('archiver walk ok: ' + walked.join(', '))
|
||||||
|
console.log('archiver glob ok: ' + globbed.join(', '))
|
||||||
|
cleanup()
|
||||||
|
} catch (err) {
|
||||||
|
fail('archiver error: ' + err.message)
|
||||||
|
}
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
@if (!errTop) {
|
@if (!errTop) {
|
||||||
<app-alerts></app-alerts>
|
<app-alerts></app-alerts>
|
||||||
}
|
}
|
||||||
<app-requests-modal [(opened)]="requestsModal"></app-requests-modal>
|
<app-sas-logs-modal [(opened)]="sasLogsModal"></app-sas-logs-modal>
|
||||||
<app-excel-password-modal></app-excel-password-modal>
|
<app-excel-password-modal></app-excel-password-modal>
|
||||||
|
|
||||||
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
|
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export class AppComponent {
|
|||||||
public licenceProblem = this.licenceService.licenceProblem
|
public licenceProblem = this.licenceService.licenceProblem
|
||||||
public appOverCapacity: boolean = false
|
public appOverCapacity: boolean = false
|
||||||
public appActive: boolean | null = null
|
public appActive: boolean | null = null
|
||||||
public requestsModal: boolean = false
|
public sasLogsModal: boolean = false
|
||||||
public showRegistration: boolean = true
|
public showRegistration: boolean = true
|
||||||
public startupDataLoaded: boolean = false
|
public startupDataLoaded: boolean = false
|
||||||
public startupSteps: StartupStep[] = []
|
public startupSteps: StartupStep[] = []
|
||||||
@@ -181,7 +181,7 @@ export class AppComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.subscribeToShowAbortModal()
|
this.subscribeToShowAbortModal()
|
||||||
this.subscribeToRequestsModal()
|
this.subscribeToSasLogsModal()
|
||||||
this.subscribeToStartupData()
|
this.subscribeToStartupData()
|
||||||
this.subscribeToAppActive()
|
this.subscribeToAppActive()
|
||||||
this.subscribeToDemoLimitModal()
|
this.subscribeToDemoLimitModal()
|
||||||
@@ -353,9 +353,9 @@ export class AppComponent {
|
|||||||
/**
|
/**
|
||||||
* Opens requests modal when requested from event service
|
* Opens requests modal when requested from event service
|
||||||
*/
|
*/
|
||||||
public subscribeToRequestsModal() {
|
public subscribeToSasLogsModal() {
|
||||||
this.eventService.onRequestsModalOpen.subscribe((value: boolean) => {
|
this.eventService.onSasLogsModalOpen.subscribe((value: boolean) => {
|
||||||
this.requestsModal = true
|
this.sasLogsModal = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { LicensingGuard } from './routes/licensing.guard'
|
|||||||
import { UsernavRouteComponent } from './routes/usernav-route/usernav-route.component'
|
import { UsernavRouteComponent } from './routes/usernav-route/usernav-route.component'
|
||||||
import { AppService } from './services/app.service'
|
import { AppService } from './services/app.service'
|
||||||
import { InfoModalComponent } from './shared/abort-modal/info-modal.component'
|
import { InfoModalComponent } from './shared/abort-modal/info-modal.component'
|
||||||
import { RequestsModalComponent } from './shared/requests-modal/requests-modal.component'
|
import { SasLogsModalComponent } from './shared/sas-logs-modal/sas-logs-modal.component'
|
||||||
import { DirectivesModule } from './directives/directives.module'
|
import { DirectivesModule } from './directives/directives.module'
|
||||||
import { ViyaApiExplorerComponent } from './viya-api-explorer/viya-api-explorer.component'
|
import { ViyaApiExplorerComponent } from './viya-api-explorer/viya-api-explorer.component'
|
||||||
import { NgxJsonViewerModule } from 'ngx-json-viewer'
|
import { NgxJsonViewerModule } from 'ngx-json-viewer'
|
||||||
@@ -32,7 +32,7 @@ import { AppSettingsService } from './services/app-settings.service'
|
|||||||
ReviewRouteComponent,
|
ReviewRouteComponent,
|
||||||
ReviewRouteComponent,
|
ReviewRouteComponent,
|
||||||
UsernavRouteComponent,
|
UsernavRouteComponent,
|
||||||
RequestsModalComponent,
|
SasLogsModalComponent,
|
||||||
InfoModalComponent,
|
InfoModalComponent,
|
||||||
ViyaApiExplorerComponent
|
ViyaApiExplorerComponent
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'
|
|||||||
import { DeployComponent } from './deploy.component'
|
import { DeployComponent } from './deploy.component'
|
||||||
import { AutomaticComponent } from './sections/automatic/automatic.component'
|
import { AutomaticComponent } from './sections/automatic/automatic.component'
|
||||||
import { SasjsConfiguratorComponent } from './sections/sasjs-configurator/sasjs-configurator.component'
|
import { SasjsConfiguratorComponent } from './sections/sasjs-configurator/sasjs-configurator.component'
|
||||||
|
import { DcLocationComponent } from './sections/dc-location/dc-location.component'
|
||||||
import { ClarityModule } from '@clr/angular'
|
import { ClarityModule } from '@clr/angular'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { DeployRoutingModule } from './deploy-routing.module'
|
import { DeployRoutingModule } from './deploy-routing.module'
|
||||||
@@ -11,7 +12,8 @@ import { DeployRoutingModule } from './deploy-routing.module'
|
|||||||
declarations: [
|
declarations: [
|
||||||
DeployComponent,
|
DeployComponent,
|
||||||
AutomaticComponent,
|
AutomaticComponent,
|
||||||
SasjsConfiguratorComponent
|
SasjsConfiguratorComponent,
|
||||||
|
DcLocationComponent
|
||||||
],
|
],
|
||||||
imports: [CommonModule, FormsModule, ClarityModule, DeployRoutingModule]
|
imports: [CommonModule, FormsModule, ClarityModule, DeployRoutingModule]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,20 +8,6 @@
|
|||||||
@if (autodeployDone) {
|
@if (autodeployDone) {
|
||||||
<p class="m-0 align-self-start">Done</p>
|
<p class="m-0 align-self-start">Done</p>
|
||||||
<hr class="w-100" />
|
<hr class="w-100" />
|
||||||
@if (autoDeployStatus.deployServicePack !== null) {
|
|
||||||
<div class="deploy-status-row">
|
|
||||||
@if (autoDeployStatus.deployServicePack === true) {
|
|
||||||
<clr-icon
|
|
||||||
class="deploy-success"
|
|
||||||
shape="success-standard"
|
|
||||||
></clr-icon>
|
|
||||||
}
|
|
||||||
@if (!autoDeployStatus.deployServicePack === false) {
|
|
||||||
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
|
||||||
}
|
|
||||||
<p>Deploy SAS Jobs</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (autoDeployStatus.runMakeData !== null) {
|
@if (autoDeployStatus.runMakeData !== null) {
|
||||||
<div class="deploy-status-row">
|
<div class="deploy-status-row">
|
||||||
@if (autoDeployStatus.runMakeData) {
|
@if (autoDeployStatus.runMakeData) {
|
||||||
@@ -33,16 +19,13 @@
|
|||||||
@if (autoDeployStatus.runMakeData === false) {
|
@if (autoDeployStatus.runMakeData === false) {
|
||||||
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
||||||
}
|
}
|
||||||
<p>Create database</p>
|
<p>Create control library</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<hr class="w-100" />
|
<hr class="w-100" />
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
|
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
|
||||||
@if (
|
@if (autoDeployStatus.runMakeData === false) {
|
||||||
autoDeployStatus.deployServicePack === false ||
|
|
||||||
autoDeployStatus.runMakeData === false
|
|
||||||
) {
|
|
||||||
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
||||||
}
|
}
|
||||||
LAUNCH
|
LAUNCH
|
||||||
@@ -65,10 +48,10 @@
|
|||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="openSasRequestsModal()"
|
(click)="openSasLogsModal()"
|
||||||
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
|
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
|
||||||
>
|
>
|
||||||
SAS Requests
|
SAS Logs
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -78,9 +61,9 @@
|
|||||||
|
|
||||||
<h4 class="text-center my-15">Viya Deploy</h4>
|
<h4 class="text-center my-15">Viya Deploy</h4>
|
||||||
<p class="deploy-intro">
|
<p class="deploy-intro">
|
||||||
Configure and deploy Data Controller to your Viya environment. Deployment
|
Configure Data Controller for your Viya environment. The frontend and services
|
||||||
creates the backend SAS services and the control library used by the
|
are already in place; this runs the one-off job that creates the control
|
||||||
application.
|
library and its configuration data, then removes itself.
|
||||||
</p>
|
</p>
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
@@ -106,9 +89,7 @@
|
|||||||
history here.
|
history here.
|
||||||
</p>
|
</p>
|
||||||
<div class="clr-control-container dc-loc-input-wrapper">
|
<div class="clr-control-container dc-loc-input-wrapper">
|
||||||
<div class="clr-input-wrapper small-mt">
|
<app-dc-location [(path)]="dcPath" inputId="dcloc"></app-dc-location>
|
||||||
<input clrInput name="dcloc" [(ngModel)]="dcPath" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -248,19 +229,8 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
|
<!-- Runs the one-off makedata job. This is the screen's only action: the
|
||||||
|
frontend and services are already deployed. -->
|
||||||
<!-- <clr-checkbox-wrapper>
|
|
||||||
<input
|
|
||||||
clrCheckbox
|
|
||||||
[(ngModel)]="recreateDatabase"
|
|
||||||
(click)="recreateDatabaseClicked($event)"
|
|
||||||
type="checkbox"
|
|
||||||
checked
|
|
||||||
/>
|
|
||||||
<label>Recreate database</label>
|
|
||||||
</clr-checkbox-wrapper> -->
|
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -269,53 +239,3 @@
|
|||||||
>
|
>
|
||||||
Deploy
|
Deploy
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
|
|
||||||
|
|
||||||
<!-- <button
|
|
||||||
(click)="executeJson()"
|
|
||||||
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
|
|
||||||
[disabled]="!jsonFile"
|
|
||||||
>
|
|
||||||
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
|
|
||||||
</button> -->
|
|
||||||
|
|
||||||
<!-- <button
|
|
||||||
(click)="uploadJsonAuto.click()"
|
|
||||||
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
|
|
||||||
>
|
|
||||||
Upload different file to deploy
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
#uploadJsonAuto
|
|
||||||
type="file"
|
|
||||||
hidden
|
|
||||||
(click)="clearUploadInput($event)"
|
|
||||||
(change)="onJsonFileChange($event)"
|
|
||||||
/> -->
|
|
||||||
|
|
||||||
<clr-modal [(clrModalOpen)]="recreateDatabaseModal" [clrModalClosable]="false">
|
|
||||||
<h3 class="modal-title">Warning</h3>
|
|
||||||
<div class="modal-body">
|
|
||||||
This action will recreate the database (if it exists). For an initial
|
|
||||||
deployment, this is expected. If this is a re-deployment, you will lose any
|
|
||||||
existing tables in <strong>{{ dcPath }}</strong
|
|
||||||
>.
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-primary"
|
|
||||||
(click)="recreateDatabaseModal = false; recreateDatabase = false"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-primary"
|
|
||||||
(click)="recreateDatabaseModal = false; recreateDatabase = true"
|
|
||||||
>
|
|
||||||
Ok
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</clr-modal>
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { SASjsConfig } from '@sasjs/adapter'
|
||||||
|
import { AutomaticComponent } from './automatic.component'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Viya setup screen has one job: run `makedata` with the selections the
|
||||||
|
* form collected - the admin group, the DC path, and the compute context the
|
||||||
|
* job runs in. These tests pin that payload and the new-window URL, because a
|
||||||
|
* screen whose only action silently stopped calling the service would still
|
||||||
|
* compile, still render, and still look like it worked.
|
||||||
|
*/
|
||||||
|
describe('AutomaticComponent (Viya setup)', () => {
|
||||||
|
const build = () => {
|
||||||
|
const requests: any[][] = []
|
||||||
|
|
||||||
|
const sasJs: any = {
|
||||||
|
request: (...args: any[]) => {
|
||||||
|
requests.push(args)
|
||||||
|
return Promise.resolve({ result: [{}] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sasService: any = {
|
||||||
|
getSasjsConfig: () => new SASjsConfig(),
|
||||||
|
getExecutionPath: () => '/SASJobExecution/',
|
||||||
|
shouldLogin: { next: () => undefined },
|
||||||
|
viyaMakedataSuccessfull: () => Promise.resolve(true),
|
||||||
|
updateFileContent: () => Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = new AutomaticComponent(
|
||||||
|
{
|
||||||
|
showAbortModal: () => undefined,
|
||||||
|
showInfoModal: () => undefined
|
||||||
|
} as any,
|
||||||
|
{ downloadFile: () => undefined } as any,
|
||||||
|
sasService,
|
||||||
|
{} as any,
|
||||||
|
{ error: () => undefined } as any,
|
||||||
|
{} as any
|
||||||
|
)
|
||||||
|
|
||||||
|
component.sasJs = sasJs
|
||||||
|
const config = {
|
||||||
|
contextName: 'Compute Reusable',
|
||||||
|
serverUrl: 'https://viya.example.com',
|
||||||
|
appLoc: '/Users/demo/dc'
|
||||||
|
} as any
|
||||||
|
component.sasJsConfig = config
|
||||||
|
// The new-window URL is built from the adapter config the component holds,
|
||||||
|
// while the request path reads the @Input one.
|
||||||
|
component.sasjsConfig = config
|
||||||
|
component.appLoc = '/Users/demo/dc'
|
||||||
|
component.dcPath = '/export/dc'
|
||||||
|
component.selectedAdminGroup = 'viyagroup18'
|
||||||
|
|
||||||
|
return { component, requests, sasService }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('posts makedata with the admin group and the DC path', async () => {
|
||||||
|
const { component, requests } = build()
|
||||||
|
component.deployInNewWindow = false
|
||||||
|
|
||||||
|
await component.createDatabase()
|
||||||
|
|
||||||
|
expect(requests[0][0]).toBe('services/admin/makedata')
|
||||||
|
expect(requests[0][1]).toEqual({
|
||||||
|
fromjs: [
|
||||||
|
{ ACTION: 'MAKEDATA', ADMIN: 'viyagroup18', DCPATH: '/export/dc' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runs the job in the configured context, on the web method', async () => {
|
||||||
|
const { component, requests } = build()
|
||||||
|
component.deployInNewWindow = false
|
||||||
|
|
||||||
|
await component.createDatabase()
|
||||||
|
|
||||||
|
expect(requests[0][2]).toEqual({
|
||||||
|
useComputeApi: null,
|
||||||
|
contextName: 'Compute Reusable',
|
||||||
|
debug: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers the context chosen in the form over the configured one', async () => {
|
||||||
|
const { component, requests } = build()
|
||||||
|
component.deployInNewWindow = false
|
||||||
|
component.computeContexts = [
|
||||||
|
{ id: 'ctx-1', name: 'Selected Context' } as any
|
||||||
|
]
|
||||||
|
component.selectedComputeContext = 'ctx-1'
|
||||||
|
|
||||||
|
await component.createDatabase()
|
||||||
|
|
||||||
|
expect(requests[0][2].contextName).toBe('Selected Context')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens makedata in a new window with the selections as URL parameters', () => {
|
||||||
|
const { component } = build()
|
||||||
|
const opened: string[] = []
|
||||||
|
spyOn(window, 'open').and.callFake((url?: any) => {
|
||||||
|
opened.push(String(url))
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
spyOn(component as any, 'pollMakedataCompletion')
|
||||||
|
component.deployInNewWindow = true
|
||||||
|
|
||||||
|
component.createDatabase()
|
||||||
|
|
||||||
|
expect(opened.length).toBe(1)
|
||||||
|
expect(opened[0]).toContain('/Users/demo/dc/services/admin/makedata')
|
||||||
|
expect(opened[0]).toContain('_contextname=Compute%20Reusable')
|
||||||
|
expect(opened[0]).toContain('admin=viyagroup18')
|
||||||
|
expect(opened[0]).toContain('dcpath=%2Fexport%2Fdc')
|
||||||
|
expect(opened[0]).toContain('action=MAKEDATA')
|
||||||
|
expect(opened[0]).toContain('_debug=131')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('percent-encodes the values that need it in the new-window URL', () => {
|
||||||
|
// A context name or admin group can hold characters that would otherwise
|
||||||
|
// split the query string - an ampersand would start a new parameter and a
|
||||||
|
// space would arrive truncated.
|
||||||
|
const { component } = build()
|
||||||
|
const opened: string[] = []
|
||||||
|
spyOn(window, 'open').and.callFake((url?: any) => {
|
||||||
|
opened.push(String(url))
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
component.runMakedataInNewWindow({
|
||||||
|
contextName: 'SAS Job Execution & co',
|
||||||
|
admin: 'group 1+2',
|
||||||
|
dcPath: '/export/dc data'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(opened[0]).toContain('_contextname=SAS%20Job%20Execution%20%26%20co')
|
||||||
|
expect(opened[0]).toContain('admin=group%201%2B2')
|
||||||
|
expect(opened[0]).toContain('dcpath=%2Fexport%2Fdc%20data')
|
||||||
|
// the parameter names themselves are not encoded
|
||||||
|
expect(opened[0]).toContain('&admin=group%201%2B2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts the job when deploy is pressed', () => {
|
||||||
|
// The screen's only button calls this; a version where it stopped doing so
|
||||||
|
// would leave the setup page unable to do anything at all.
|
||||||
|
const { component } = build()
|
||||||
|
const spy = spyOn(component, 'createDatabase').and.returnValue(
|
||||||
|
Promise.resolve() as any
|
||||||
|
)
|
||||||
|
|
||||||
|
component.runAutoDeploy()
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -51,17 +51,8 @@ export class AutomaticComponent implements OnInit {
|
|||||||
|
|
||||||
public selectedComputeContext: string = ''
|
public selectedComputeContext: string = ''
|
||||||
public makeDataResponse: string = ''
|
public makeDataResponse: string = ''
|
||||||
public jsonFile: any = null
|
|
||||||
public autodeploying: boolean = false
|
public autodeploying: boolean = false
|
||||||
public autodeployDone: boolean = false
|
public autodeployDone: boolean = false
|
||||||
public recreateDatabaseModal: boolean = false
|
|
||||||
public isSubmittingJson: boolean = false
|
|
||||||
public isJsonSubmitted: boolean = false
|
|
||||||
/**
|
|
||||||
* Default was `false` when deploy was done with frontend and backend separately.
|
|
||||||
* Now we are using only streaming app, so we always want to recreate database (makedata)
|
|
||||||
*/
|
|
||||||
public recreateDatabase: boolean = true
|
|
||||||
public createDatabaseLoading: boolean = false
|
public createDatabaseLoading: boolean = false
|
||||||
public adminGroupsLoading: boolean = false
|
public adminGroupsLoading: boolean = false
|
||||||
public currentUserInfoLoading: boolean = false
|
public currentUserInfoLoading: boolean = false
|
||||||
@@ -86,18 +77,14 @@ export class AutomaticComponent implements OnInit {
|
|||||||
public currentUserInfo: ViyaApiCurrentUser | null = null
|
public currentUserInfo: ViyaApiCurrentUser | null = null
|
||||||
public computeContexts: ComputeContextItem[] = []
|
public computeContexts: ComputeContextItem[] = []
|
||||||
|
|
||||||
/** autoDeployStatus
|
/**
|
||||||
* This object presents the status for two steps that we have for deploy.
|
* Status of the one step this screen runs: `makedata`, which creates the
|
||||||
* `deployServicePack` - Creating services based on `viya.json`
|
* control library and its configuration data and then deletes itself.
|
||||||
* `runMakeData` - Running `makedata` service
|
* `null` means it has not run yet, `false` that it failed.
|
||||||
* If any of them is `null` or `false` that means step failed
|
|
||||||
* and will be shown to user on deploy done modal.
|
|
||||||
*/
|
*/
|
||||||
public autoDeployStatus: {
|
public autoDeployStatus: {
|
||||||
deployServicePack: any
|
|
||||||
runMakeData: any
|
runMakeData: any
|
||||||
} = {
|
} = {
|
||||||
deployServicePack: null,
|
|
||||||
runMakeData: null
|
runMakeData: null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,61 +407,15 @@ export class AutomaticComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes sas.json file to deploy the backend
|
* Runs the deployment. The control library and its configuration data are
|
||||||
* Method will first try to run the `auto deploy`
|
* created by `makedata`, which self-deletes once it has run; the frontend and
|
||||||
* If that fails the rest of the code is ignored.
|
* services arrived with the sasjs deploy, so there is nothing else to deploy
|
||||||
* If request is successfull, method will continue to try
|
* from here.
|
||||||
* to create database if checkbox is toggled on
|
|
||||||
*/
|
*/
|
||||||
public async executeJson() {
|
public runAutoDeploy() {
|
||||||
this.isSubmittingJson = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
let uploadJsonFile = await this.sasJs.deployServicePack(
|
|
||||||
this.jsonFile,
|
|
||||||
this.dcAdapterSettings?.appLoc,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
|
|
||||||
this.autoDeployStatus.deployServicePack = true
|
|
||||||
this.isJsonSubmitted = true
|
|
||||||
} catch (ex: any) {
|
|
||||||
let textEx = ''
|
|
||||||
|
|
||||||
if (typeof ex.message !== 'string') {
|
|
||||||
textEx = JSON.stringify(ex).replace(/\\/gm, '')
|
|
||||||
} else {
|
|
||||||
textEx = ex.message
|
|
||||||
}
|
|
||||||
|
|
||||||
this.autoDeployStatus.deployServicePack = false
|
|
||||||
this.eventService.showInfoModal(
|
|
||||||
'Deploy error',
|
|
||||||
`Exception: \n ${textEx !== '' ? textEx : ex}`
|
|
||||||
)
|
|
||||||
this.autodeploying = false
|
|
||||||
this.autodeployDone = false
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isSubmittingJson = false
|
|
||||||
}
|
|
||||||
|
|
||||||
public async runAutoDeploy(executeJson: boolean = false) {
|
|
||||||
if (!this.deployInNewWindow) this.autodeploying = true
|
if (!this.deployInNewWindow) this.autodeploying = true
|
||||||
|
|
||||||
if (executeJson) {
|
|
||||||
this.executeJson()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.recreateDatabase) {
|
|
||||||
this.createDatabase()
|
this.createDatabase()
|
||||||
} else {
|
|
||||||
if (!this.deployInNewWindow) this.autodeployDone = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -484,6 +425,7 @@ export class AutomaticComponent implements OnInit {
|
|||||||
let data = {
|
let data = {
|
||||||
fromjs: [
|
fromjs: [
|
||||||
{
|
{
|
||||||
|
ACTION: 'MAKEDATA',
|
||||||
ADMIN: this.selectedAdminGroup,
|
ADMIN: this.selectedAdminGroup,
|
||||||
DCPATH: this.dcPath
|
DCPATH: this.dcPath
|
||||||
}
|
}
|
||||||
@@ -578,9 +520,11 @@ export class AutomaticComponent implements OnInit {
|
|||||||
let serverUrl = this.sasjsConfig.serverUrl
|
let serverUrl = this.sasjsConfig.serverUrl
|
||||||
let appLoc = this.sasjsConfig.appLoc
|
let appLoc = this.sasjsConfig.appLoc
|
||||||
const execPath = this.sasService.getExecutionPath()
|
const execPath = this.sasService.getExecutionPath()
|
||||||
let contextname = `&_contextname=${params.contextName}`
|
let contextname = `&_contextname=${encodeURIComponent(params.contextName)}`
|
||||||
let admin = `&admin=${params.admin}`
|
let admin = `&admin=${encodeURIComponent(params.admin)}`
|
||||||
let dcPath = `&dcpath=${params.dcPath}`
|
let dcPath = `&dcpath=${encodeURIComponent(params.dcPath)}`
|
||||||
|
// makedata requires ACTION; MAKEDATA is the seeding run
|
||||||
|
let action = '&action=MAKEDATA'
|
||||||
// Debug is ALWAYS enabled for makedata — it runs only once during
|
// Debug is ALWAYS enabled for makedata — it runs only once during
|
||||||
// deployment and the full log is needed to diagnose any issues.
|
// deployment and the full log is needed to diagnose any issues.
|
||||||
let debug = '&_debug=131'
|
let debug = '&_debug=131'
|
||||||
@@ -594,6 +538,7 @@ export class AutomaticComponent implements OnInit {
|
|||||||
contextname +
|
contextname +
|
||||||
admin +
|
admin +
|
||||||
dcPath +
|
dcPath +
|
||||||
|
action +
|
||||||
debug
|
debug
|
||||||
|
|
||||||
window.open(programUrl)
|
window.open(programUrl)
|
||||||
@@ -708,24 +653,8 @@ export class AutomaticComponent implements OnInit {
|
|||||||
this.deployService.downloadFile(content, filename, extension)
|
this.deployService.downloadFile(content, filename, extension)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async onJsonFileChange(event: any) {
|
public openSasLogsModal() {
|
||||||
let file = event.target.files[0]
|
this.eventService.openSasLogsModal()
|
||||||
|
|
||||||
this.jsonFile = await this.deployService.readFile(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
public recreateDatabaseClicked(event: Event) {
|
|
||||||
;(<HTMLInputElement>event.target).checked === true
|
|
||||||
? (this.recreateDatabaseModal = true)
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
public clearUploadInput(event: Event) {
|
|
||||||
this.deployService.clearUploadInput(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
public openSasRequestsModal() {
|
|
||||||
this.eventService.openRequestsModal()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public navigateToHome() {
|
public navigateToHome() {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<div class="dc-location">
|
||||||
|
<div class="clr-input-wrapper dc-location-input">
|
||||||
|
<input
|
||||||
|
clrInput
|
||||||
|
[id]="inputId"
|
||||||
|
[name]="inputId"
|
||||||
|
[attr.aria-label]="ariaLabel"
|
||||||
|
[ngModel]="path"
|
||||||
|
(ngModelChange)="onPathChange($event)"
|
||||||
|
placeholder="Type a folder, or browse below"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="{{ inputId }}-browse"
|
||||||
|
class="btn btn-sm btn-outline dc-location-button"
|
||||||
|
(click)="toggleBrowse()"
|
||||||
|
>
|
||||||
|
{{ browsing ? 'Hide' : 'Browse' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="{{ inputId }}-validate"
|
||||||
|
class="btn btn-sm btn-outline dc-location-button"
|
||||||
|
[disabled]="validating"
|
||||||
|
(click)="validate()"
|
||||||
|
>
|
||||||
|
@if (validating) {
|
||||||
|
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
|
||||||
|
}
|
||||||
|
@if (!validating) {
|
||||||
|
Validate
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (checkResult) {
|
||||||
|
<p
|
||||||
|
id="{{ inputId }}-validate-result"
|
||||||
|
class="dc-location-result"
|
||||||
|
[class.dc-location-ok]="checkResult.writeable"
|
||||||
|
[class.dc-location-bad]="!checkResult.writeable"
|
||||||
|
>
|
||||||
|
@if (checkResult.writeable) {
|
||||||
|
<clr-icon shape="check"></clr-icon>
|
||||||
|
<span>{{ checkResult.path }} is writeable</span>
|
||||||
|
}
|
||||||
|
@if (!checkResult.writeable) {
|
||||||
|
<clr-icon shape="exclamation-triangle"></clr-icon>
|
||||||
|
<span>{{ checkResult.msg }}</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (browsing) {
|
||||||
|
<div class="dc-browser" id="{{ inputId }}-browser">
|
||||||
|
<div class="dc-browser-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="{{ inputId }}-up"
|
||||||
|
class="btn btn-sm btn-link dc-browser-up"
|
||||||
|
[disabled]="listingIsRoot"
|
||||||
|
(click)="up()"
|
||||||
|
>
|
||||||
|
Up
|
||||||
|
</button>
|
||||||
|
<span class="dc-browser-path">
|
||||||
|
@if (listingIsRoot) {
|
||||||
|
Select a drive or root folder
|
||||||
|
}
|
||||||
|
@if (!listingIsRoot) {
|
||||||
|
{{ listingParent }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
@if (!listingIsRoot) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="{{ inputId }}-use-current"
|
||||||
|
class="btn btn-sm btn-outline dc-browser-use"
|
||||||
|
(click)="useCurrent()"
|
||||||
|
>
|
||||||
|
Use this folder
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="dc-browser-loading">
|
||||||
|
<span class="spinner spinner-sm"> Loading... </span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading && !!listingError) {
|
||||||
|
<p class="dc-location-bad" id="{{ inputId }}-browser-error">
|
||||||
|
<clr-icon shape="exclamation-triangle"></clr-icon>
|
||||||
|
<span>{{ listingError }}</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading && !listingError) {
|
||||||
|
<ul class="dc-folder-list">
|
||||||
|
@for (folder of folders; track folder.filepath) {
|
||||||
|
<li class="dc-folder">
|
||||||
|
@if (!folder.locked) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-link dc-folder-open"
|
||||||
|
(click)="enter(folder)"
|
||||||
|
>
|
||||||
|
<clr-icon shape="folder"></clr-icon>
|
||||||
|
<span>{{ folder.filename }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (folder.locked) {
|
||||||
|
<span class="dc-folder-locked">
|
||||||
|
<clr-icon shape="lock"></clr-icon>
|
||||||
|
<span>{{ folder.filename }}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (!!folder.info) {
|
||||||
|
<span class="dc-folder-info">{{ folder.info }}</span>
|
||||||
|
}
|
||||||
|
@if (!folder.locked) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-link dc-folder-use"
|
||||||
|
(click)="choose(folder)"
|
||||||
|
>
|
||||||
|
Use
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (!folders.length) {
|
||||||
|
<li class="dc-folder dc-folder-empty" id="{{ inputId }}-no-folders">
|
||||||
|
No folders here
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (truncated) {
|
||||||
|
<p class="dc-browser-truncated" id="{{ inputId }}-truncated">
|
||||||
|
Only the first folders are shown - type the path if it is not listed.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
.dc-location-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-location-input input {
|
||||||
|
flex: 1 1 22rem;
|
||||||
|
min-width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-location-button {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-location-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-location-ok {
|
||||||
|
color: #306b00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-location-bad {
|
||||||
|
color: #c21d00;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[cds-theme='dark'] .dc-location-ok {
|
||||||
|
color: #7fd14a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[cds-theme='dark'] .dc-location-bad {
|
||||||
|
color: #ff8a73;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-browser {
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid light-dark(#ccc, #4a4a4a);
|
||||||
|
border-radius: 4px;
|
||||||
|
max-height: 18rem;
|
||||||
|
overflow: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-browser-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid light-dark(#e8e8e8, #4a4a4a);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: light-dark(#fafafa, #1b1b1b);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-browser-path {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-browser-up,
|
||||||
|
.dc-browser-use {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-browser-loading {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-folder-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-folder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-folder:hover {
|
||||||
|
background: light-dark(#f0f0f0, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-folder-open {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-folder-info {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-folder-use {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-folder-empty {
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-browser-truncated {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-folder-locked {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
import { Component, NO_ERRORS_SCHEMA } from '@angular/core'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { SasService } from 'src/app/services/sas.service'
|
||||||
|
import { DcLocationComponent } from './dc-location.component'
|
||||||
|
|
||||||
|
describe('DcLocationComponent', () => {
|
||||||
|
let component: DcLocationComponent
|
||||||
|
let fixture: ComponentFixture<DcLocationComponent>
|
||||||
|
|
||||||
|
let requests: { url: string; data: any }[]
|
||||||
|
let response: any
|
||||||
|
let rejection: any
|
||||||
|
let defer: boolean
|
||||||
|
let pending: {
|
||||||
|
url: string
|
||||||
|
data: any
|
||||||
|
resolve: (value: any) => void
|
||||||
|
reject: (reason: any) => void
|
||||||
|
}[]
|
||||||
|
|
||||||
|
const sasService = {
|
||||||
|
request: (url: string, data: any) => {
|
||||||
|
requests.push({ url, data })
|
||||||
|
if (defer) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pending.push({ url, data, resolve, reject })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (rejection) return Promise.reject(rejection)
|
||||||
|
return Promise.resolve(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts the picker so the spec can set `inputId` the way a deploy screen
|
||||||
|
* does - the element ids are only observable through the parent binding.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dc-location-host',
|
||||||
|
template: `<app-dc-location
|
||||||
|
[(path)]="path"
|
||||||
|
[inputId]="inputId"
|
||||||
|
[ariaLabel]="ariaLabel"
|
||||||
|
></app-dc-location>`,
|
||||||
|
standalone: false
|
||||||
|
})
|
||||||
|
class DcLocationHostComponent {
|
||||||
|
path = ''
|
||||||
|
inputId = 'dcloc'
|
||||||
|
ariaLabel = 'Data Controller location'
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirResponse = (parent: string, rows: any[], extra: any = {}) => ({
|
||||||
|
adapterResponse: {
|
||||||
|
dirinfo: [
|
||||||
|
{
|
||||||
|
PARENT: parent,
|
||||||
|
SEP: '/',
|
||||||
|
TRUNCATED: 'N',
|
||||||
|
PLATFORM: 'UNIX',
|
||||||
|
DEPTH: 1,
|
||||||
|
...extra
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dirlist: rows
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const folder = (filepath: string, level: number, filename?: string) => ({
|
||||||
|
FILEPATH: filepath,
|
||||||
|
FILENAME: filename || filepath.split('/').pop(),
|
||||||
|
FILE_OR_FOLDER: 'folder',
|
||||||
|
LEVEL: level,
|
||||||
|
MSG: ' '
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
requests = []
|
||||||
|
response = undefined
|
||||||
|
rejection = undefined
|
||||||
|
defer = false
|
||||||
|
pending = []
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [DcLocationComponent, DcLocationHostComponent],
|
||||||
|
imports: [FormsModule],
|
||||||
|
providers: [{ provide: SasService, useValue: sasService }],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
})
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(DcLocationComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats the SAS blank padding as blank', () => {
|
||||||
|
expect(component.trimSas(' ')).toBe('')
|
||||||
|
expect(component.trimSas(undefined)).toBe('')
|
||||||
|
expect(component.trimSas(' /tmp ')).toBe('/tmp')
|
||||||
|
|
||||||
|
const rows = component.parseFolders(
|
||||||
|
[{ FILEPATH: ' ', FILENAME: ' ', FILE_OR_FOLDER: 'folder', LEVEL: 1 }],
|
||||||
|
false
|
||||||
|
)
|
||||||
|
expect(rows.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('browses from the typed path, trimmed', async () => {
|
||||||
|
component.path = ' /tmp '
|
||||||
|
component.toggleBrowse()
|
||||||
|
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(requests.length).toBe(1)
|
||||||
|
expect(requests[0].url).toBe('admin/makedata')
|
||||||
|
expect(requests[0].data.fromjs[0].ACTION).toBe('DIRLIST')
|
||||||
|
expect(requests[0].data.fromjs[0].PARENT).toBe('/tmp')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the platform roots when the parent is blank', async () => {
|
||||||
|
response = dirResponse(' ', [folder('/', 0, '/')])
|
||||||
|
component.path = ''
|
||||||
|
component.toggleBrowse()
|
||||||
|
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(component.listingIsRoot).toBeTrue()
|
||||||
|
expect(component.folders.length).toBe(1)
|
||||||
|
expect(component.folders[0].filepath).toBe('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not offer the parent itself as a folder to drill into', async () => {
|
||||||
|
response = dirResponse('/tmp', [
|
||||||
|
folder('/tmp', 0, 'tmp'),
|
||||||
|
folder('/tmp/aa', 1, 'aa')
|
||||||
|
])
|
||||||
|
component.path = '/tmp'
|
||||||
|
component.toggleBrowse()
|
||||||
|
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(component.folders.length).toBe(1)
|
||||||
|
expect(component.folders[0].filepath).toBe('/tmp/aa')
|
||||||
|
expect(component.listingIsRoot).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drills into a folder', async () => {
|
||||||
|
response = dirResponse('/tmp', [folder('/tmp/aa', 1, 'aa')])
|
||||||
|
component.path = '/tmp'
|
||||||
|
component.toggleBrowse()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
response = dirResponse('/tmp/aa', [folder('/tmp/aa/bb', 1, 'bb')])
|
||||||
|
component.enter(component.folders[0])
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(requests.length).toBe(2)
|
||||||
|
expect(requests[1].data.fromjs[0].PARENT).toBe('/tmp/aa')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes a folder as the answer and closes the browser', async () => {
|
||||||
|
response = dirResponse('/tmp', [folder('/tmp/aa', 1, 'aa')])
|
||||||
|
const emitted: string[] = []
|
||||||
|
component.pathChange.subscribe((value: string) => emitted.push(value))
|
||||||
|
|
||||||
|
component.path = '/tmp'
|
||||||
|
component.toggleBrowse()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
component.choose(component.folders[0])
|
||||||
|
|
||||||
|
expect(emitted).toEqual(['/tmp/aa'])
|
||||||
|
expect(component.browsing).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goes up one level', async () => {
|
||||||
|
response = dirResponse('/tmp/aa', [folder('/tmp/aa/bb', 1, 'bb')])
|
||||||
|
component.path = '/tmp/aa'
|
||||||
|
component.toggleBrowse()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
component.up()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(requests[1].data.fromjs[0].PARENT).toBe('/tmp')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parentOf', () => {
|
||||||
|
it('walks unix paths up to a root that is not empty', () => {
|
||||||
|
expect(component.parentOf('/tmp/a', '/')).toBe('/tmp')
|
||||||
|
expect(component.parentOf('/tmp', '/')).toBe('/')
|
||||||
|
expect(component.parentOf('/', '/')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a drive root, and goes to the roots from it', () => {
|
||||||
|
expect(component.parentOf('C:\\Users', '\\')).toBe('C:\\')
|
||||||
|
expect(component.parentOf('C:\\', '\\')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a writeable folder', async () => {
|
||||||
|
response = {
|
||||||
|
adapterResponse: {
|
||||||
|
checkwrite: [
|
||||||
|
{
|
||||||
|
PATH: '/tmp ',
|
||||||
|
PATH_EXISTS: 'Y',
|
||||||
|
IS_FOLDER: 'Y',
|
||||||
|
WRITEABLE: 'Y',
|
||||||
|
MSG: ' '
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component.path = '/tmp'
|
||||||
|
component.validate()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(requests[0].data.fromjs[0].ACTION).toBe('WRITECHECK')
|
||||||
|
expect(requests[0].data.fromjs[0].PATH).toBe('/tmp')
|
||||||
|
expect(component.checkResult?.writeable).toBeTrue()
|
||||||
|
expect(component.checkResult?.path).toBe('/tmp')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('explains a refusal instead of showing a blank message', async () => {
|
||||||
|
response = {
|
||||||
|
adapterResponse: {
|
||||||
|
checkwrite: [
|
||||||
|
{
|
||||||
|
PATH: '/root/private ',
|
||||||
|
PATH_EXISTS: 'Y',
|
||||||
|
IS_FOLDER: 'Y',
|
||||||
|
WRITEABLE: 'N',
|
||||||
|
MSG: ' '
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component.path = '/root/private'
|
||||||
|
component.validate()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(component.checkResult?.writeable).toBeFalse()
|
||||||
|
expect((component.checkResult?.msg || '').trim()).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports the service error rather than a generic failure', async () => {
|
||||||
|
rejection = { adapterResponse: { error: 'Invalid path' } }
|
||||||
|
|
||||||
|
component.path = '%nrstr(&path)'
|
||||||
|
component.validate()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(component.checkResult?.writeable).toBeFalse()
|
||||||
|
expect(component.checkResult?.msg).toBe('Invalid path')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call the service when there is nothing to validate', async () => {
|
||||||
|
component.path = ' '
|
||||||
|
component.validate()
|
||||||
|
|
||||||
|
expect(requests.length).toBe(0)
|
||||||
|
expect(component.checkResult?.msg).toBe('Type or pick a folder first')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts the type in any case, and keeps entries it could not open', async () => {
|
||||||
|
response = dirResponse('/tmp', [
|
||||||
|
{
|
||||||
|
FILEPATH: '/tmp/upper',
|
||||||
|
FILENAME: 'upper',
|
||||||
|
FILE_OR_FOLDER: 'FOLDER',
|
||||||
|
LEVEL: 1,
|
||||||
|
MSG: ' '
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FILEPATH: '/tmp/quiet',
|
||||||
|
FILENAME: 'quiet',
|
||||||
|
FILE_OR_FOLDER: 'Folder',
|
||||||
|
LEVEL: 1,
|
||||||
|
MSG: ' '
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FILEPATH: '/tmp/shut',
|
||||||
|
FILENAME: 'shut',
|
||||||
|
FILE_OR_FOLDER: 'locked',
|
||||||
|
LEVEL: 1,
|
||||||
|
MSG: 'Permission denied'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
FILEPATH: '/tmp/readme.txt',
|
||||||
|
FILENAME: 'readme.txt',
|
||||||
|
FILE_OR_FOLDER: 'file',
|
||||||
|
LEVEL: 1,
|
||||||
|
MSG: ' '
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
component.path = '/tmp'
|
||||||
|
component.toggleBrowse()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
const names = component.folders.map((folder) => folder.filename)
|
||||||
|
expect(names).toEqual(['upper', 'quiet', 'shut'])
|
||||||
|
expect(component.folders[2].locked).toBeTrue()
|
||||||
|
expect(component.folders[0].locked).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a locked entry without the buttons that would use it', async () => {
|
||||||
|
response = dirResponse('/tmp', [
|
||||||
|
{
|
||||||
|
FILEPATH: '/tmp/shut',
|
||||||
|
FILENAME: 'shut',
|
||||||
|
FILE_OR_FOLDER: 'locked',
|
||||||
|
LEVEL: 1,
|
||||||
|
MSG: 'Permission denied'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
component.path = '/tmp'
|
||||||
|
component.toggleBrowse()
|
||||||
|
await fixture.whenStable()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const rows = fixture.nativeElement.querySelectorAll('.dc-folder')
|
||||||
|
expect(rows.length).toBe(1)
|
||||||
|
expect(rows[0].querySelector('.dc-folder-use')).toBeNull()
|
||||||
|
expect(rows[0].querySelector('.dc-folder-open')).toBeNull()
|
||||||
|
expect(rows[0].querySelector('.dc-folder-locked')).toBeTruthy()
|
||||||
|
expect(rows[0].textContent).toContain('Permission denied')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the browser only while browsing, and the verdict after a check', async () => {
|
||||||
|
response = dirResponse('/tmp', [folder('/tmp/aa', 1, 'aa')])
|
||||||
|
component.path = '/tmp'
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('#dcloc-browser')).toBeNull()
|
||||||
|
|
||||||
|
component.toggleBrowse()
|
||||||
|
await fixture.whenStable()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('#dcloc-browser')).toBeTruthy()
|
||||||
|
|
||||||
|
response = {
|
||||||
|
adapterResponse: {
|
||||||
|
checkwrite: [
|
||||||
|
{
|
||||||
|
PATH: '/tmp ',
|
||||||
|
PATH_EXISTS: 'Y',
|
||||||
|
IS_FOLDER: 'Y',
|
||||||
|
WRITEABLE: 'Y',
|
||||||
|
MSG: ' '
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
component.validate()
|
||||||
|
await fixture.whenStable()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const verdict = fixture.nativeElement.querySelector(
|
||||||
|
'#dcloc-validate-result'
|
||||||
|
)
|
||||||
|
expect(verdict).toBeTruthy()
|
||||||
|
expect(verdict.textContent).toContain('is writeable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels the input for assistive tech, from the input', () => {
|
||||||
|
const hostFixture = TestBed.createComponent(DcLocationHostComponent)
|
||||||
|
hostFixture.detectChanges()
|
||||||
|
|
||||||
|
const input = (): HTMLInputElement =>
|
||||||
|
hostFixture.nativeElement.querySelector('#dcloc')
|
||||||
|
|
||||||
|
expect(input()).toBeTruthy()
|
||||||
|
expect(input().getAttribute('aria-label')).toBe('Data Controller location')
|
||||||
|
|
||||||
|
hostFixture.componentInstance.ariaLabel = 'Choose a staging folder'
|
||||||
|
hostFixture.detectChanges()
|
||||||
|
|
||||||
|
expect(input().getAttribute('aria-label')).toBe('Choose a staging folder')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a listing that arrives after a newer one', async () => {
|
||||||
|
defer = true
|
||||||
|
component.browsing = true
|
||||||
|
|
||||||
|
component.list('a')
|
||||||
|
component.list('b')
|
||||||
|
expect(pending.length).toBe(2)
|
||||||
|
|
||||||
|
/* the newer request answers first, then the older one lands late */
|
||||||
|
pending[1].resolve(dirResponse('/b', [folder('/b/bb', 1, 'bb')]))
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
pending[0].resolve(dirResponse('/a', [folder('/a/aa', 1, 'aa')]))
|
||||||
|
await fixture.whenStable()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.listingParent).toBe('/b')
|
||||||
|
expect(component.folders.map((entry) => entry.filename)).toEqual(['bb'])
|
||||||
|
|
||||||
|
const rendered = Array.from(
|
||||||
|
fixture.nativeElement.querySelectorAll('.dc-folder')
|
||||||
|
).map((row: any) => row.textContent)
|
||||||
|
expect(rendered.join(' ')).toContain('bb')
|
||||||
|
expect(rendered.join(' ')).not.toContain('aa')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores an error from a listing that has been superseded', async () => {
|
||||||
|
defer = true
|
||||||
|
component.browsing = true
|
||||||
|
|
||||||
|
component.list('a')
|
||||||
|
component.list('b')
|
||||||
|
|
||||||
|
pending[1].resolve(dirResponse('/b', [folder('/b/bb', 1, 'bb')]))
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
pending[0].reject({ adapterResponse: { error: 'Invalid path' } })
|
||||||
|
await fixture.whenStable()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.listingError).toBe('')
|
||||||
|
expect(component.loading).toBeFalse()
|
||||||
|
expect(component.listingParent).toBe('/b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('derives every element id from the inputId', async () => {
|
||||||
|
const hostFixture = TestBed.createComponent(DcLocationHostComponent)
|
||||||
|
hostFixture.componentInstance.inputId = 'otherPicker'
|
||||||
|
hostFixture.detectChanges()
|
||||||
|
|
||||||
|
const host: HTMLElement = hostFixture.nativeElement
|
||||||
|
expect(host.querySelector('#otherPicker-browse')).toBeTruthy()
|
||||||
|
expect(host.querySelector('#otherPicker-validate')).toBeTruthy()
|
||||||
|
expect(host.querySelector('#DCBrowse')).toBeNull()
|
||||||
|
expect(host.querySelector('#DCValidate')).toBeNull()
|
||||||
|
|
||||||
|
host.querySelector<HTMLButtonElement>('#otherPicker-browse')!.click()
|
||||||
|
await hostFixture.whenStable()
|
||||||
|
hostFixture.detectChanges()
|
||||||
|
|
||||||
|
expect(host.querySelector('#otherPicker-browser')).toBeTruthy()
|
||||||
|
expect(host.querySelector('#otherPicker-up')).toBeTruthy()
|
||||||
|
expect(host.querySelector('#otherPicker-no-folders')).toBeTruthy()
|
||||||
|
expect(host.querySelector('#DCBrowser')).toBeNull()
|
||||||
|
expect(host.querySelector('#DCNoFolders')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||||
|
import {
|
||||||
|
DcFolderEntry,
|
||||||
|
DcFolderRow,
|
||||||
|
DcWriteCheckResult,
|
||||||
|
DcWriteCheckRow
|
||||||
|
} from 'src/app/models/sas/dc-location.model'
|
||||||
|
import { SasService } from 'src/app/services/sas.service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folder picker for the deploy screens.
|
||||||
|
*
|
||||||
|
* The deploy screen asks where Data Controller should keep its control
|
||||||
|
* library, and that has to be answered before the control library exists - so
|
||||||
|
* the question is served by `makedata` itself (`ACTION=DIRLIST` to browse,
|
||||||
|
* `ACTION=WRITECHECK` to prove the folder is writeable).
|
||||||
|
*
|
||||||
|
* The typed value is always editable: browsing is a convenience over it, never
|
||||||
|
* a replacement, because the picker cannot see a folder the deployment user has
|
||||||
|
* no permission on and a customer may want to type a path that does not exist
|
||||||
|
* yet. The parent is listed one level at a time, so a folder holding tens of
|
||||||
|
* thousands of subfolders does not arrive in one response.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dc-location',
|
||||||
|
templateUrl: './dc-location.component.html',
|
||||||
|
styleUrls: ['./dc-location.component.scss'],
|
||||||
|
standalone: false
|
||||||
|
})
|
||||||
|
export class DcLocationComponent {
|
||||||
|
@Input() path = ''
|
||||||
|
@Input() inputId = 'dcloc'
|
||||||
|
@Input() ariaLabel = 'Data Controller location'
|
||||||
|
@Output() pathChange = new EventEmitter<string>()
|
||||||
|
|
||||||
|
/** panel state */
|
||||||
|
public browsing = false
|
||||||
|
public loading = false
|
||||||
|
public folders: DcFolderEntry[] = []
|
||||||
|
public listingParent = ''
|
||||||
|
public listingIsRoot = true
|
||||||
|
public truncated = false
|
||||||
|
public listingError = ''
|
||||||
|
|
||||||
|
/** validate state */
|
||||||
|
public validating = false
|
||||||
|
public checkResult: DcWriteCheckResult | null = null
|
||||||
|
|
||||||
|
private separator = '/'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the DIRLIST calls made, so a response to an older request can be
|
||||||
|
* recognised and dropped. Two overlapping requests may arrive in either
|
||||||
|
* order, and the older one must not overwrite the newer listing.
|
||||||
|
*/
|
||||||
|
private listRequestId = 0
|
||||||
|
|
||||||
|
constructor(private sasService: SasService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAS has no zero length character value, so a blank field arrives as a single
|
||||||
|
* space. Everything read from the service goes through here before it is
|
||||||
|
* tested, or `' '` reads as a value.
|
||||||
|
*/
|
||||||
|
public trimSas(value: any): string {
|
||||||
|
if (value === null || value === undefined) return ''
|
||||||
|
return String(value).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
public onPathChange(value: string) {
|
||||||
|
this.path = value
|
||||||
|
this.checkResult = null
|
||||||
|
this.pathChange.emit(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleBrowse() {
|
||||||
|
this.browsing = !this.browsing
|
||||||
|
if (this.browsing) this.list(this.trimSas(this.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lists one level below `parent`. A blank parent is the platform roots. */
|
||||||
|
public list(parent: string) {
|
||||||
|
const requestId = ++this.listRequestId
|
||||||
|
this.loading = true
|
||||||
|
this.listingError = ''
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
fromjs: [{ ACTION: 'DIRLIST', PARENT: parent, DEPTH: 1, MAXOBS: 500 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sasService
|
||||||
|
.request('admin/makedata', data, undefined, {
|
||||||
|
suppressErrorAbortModal: true,
|
||||||
|
suppressSuccessAbortModal: true
|
||||||
|
})
|
||||||
|
.then((res: any) => {
|
||||||
|
if (requestId !== this.listRequestId) return
|
||||||
|
|
||||||
|
const response = res?.adapterResponse || {}
|
||||||
|
const info = (response.dirinfo || [])[0] || {}
|
||||||
|
const listedParent = this.trimSas(info.PARENT)
|
||||||
|
|
||||||
|
this.separator = this.trimSas(info.SEP) || '/'
|
||||||
|
this.truncated = this.trimSas(info.TRUNCATED) === 'Y'
|
||||||
|
this.listingParent = listedParent
|
||||||
|
this.listingIsRoot = listedParent === ''
|
||||||
|
this.folders = this.parseFolders(response.dirlist, this.listingIsRoot)
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
if (requestId !== this.listRequestId) return
|
||||||
|
|
||||||
|
this.folders = []
|
||||||
|
this.loading = false
|
||||||
|
this.listingError = this.errorText(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the folders, and the entries the service could not open. The type is
|
||||||
|
* compared case insensitively: mpe_dirlist writes it lowercase, but a value
|
||||||
|
* that arrived uppercased would otherwise empty the picker. The row at LEVEL 0
|
||||||
|
* is the parent being listed, which is not something to drill into - except
|
||||||
|
* when the parent is blank, where the level 0 rows *are* the platform roots.
|
||||||
|
*/
|
||||||
|
public parseFolders(rows: any, rootListing: boolean): DcFolderEntry[] {
|
||||||
|
const list: DcFolderRow[] = Array.isArray(rows) ? rows : []
|
||||||
|
|
||||||
|
return list
|
||||||
|
.filter((row) =>
|
||||||
|
['folder', 'locked'].includes(
|
||||||
|
this.trimSas(row?.FILE_OR_FOLDER).toLowerCase()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter((row) => rootListing || Number(row?.LEVEL) > 0)
|
||||||
|
.map((row) => ({
|
||||||
|
filepath: this.trimSas(row?.FILEPATH),
|
||||||
|
filename: this.trimSas(row?.FILENAME),
|
||||||
|
level: Number(row?.LEVEL) || 0,
|
||||||
|
info: this.trimSas(row?.MSG),
|
||||||
|
locked: this.trimSas(row?.FILE_OR_FOLDER).toLowerCase() === 'locked'
|
||||||
|
}))
|
||||||
|
.filter((folder) => !!folder.filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drills into a folder. */
|
||||||
|
public enter(folder: DcFolderEntry) {
|
||||||
|
this.list(folder.filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes a folder as the answer. */
|
||||||
|
public choose(folder: DcFolderEntry) {
|
||||||
|
this.applyPath(folder.filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes the folder being listed - the one the user is standing in. */
|
||||||
|
public useCurrent() {
|
||||||
|
this.applyPath(this.listingParent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Goes up one level; from the top of a drive it goes back to the roots. */
|
||||||
|
public up() {
|
||||||
|
this.list(this.parentOf(this.listingParent, this.separator))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The parent of a folder, without asking the server for it. */
|
||||||
|
public parentOf(folder: string, sep: string): string {
|
||||||
|
let value = this.trimSas(folder)
|
||||||
|
|
||||||
|
while (value.length > 1 && value.endsWith(sep)) {
|
||||||
|
/* a drive root keeps its separator: C:\ is not C: */
|
||||||
|
if (value.length === 3 && value.charAt(1) === ':') break
|
||||||
|
value = value.slice(0, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = value.lastIndexOf(sep)
|
||||||
|
if (index < 0) return ''
|
||||||
|
if (index === 0) return value === sep ? '' : sep
|
||||||
|
|
||||||
|
/* C:\Users -> C:\ , and C:\ -> the roots */
|
||||||
|
if (index === 2 && value.charAt(1) === ':') {
|
||||||
|
return value.length === 3 ? '' : value.slice(0, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.slice(0, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPath(value: string) {
|
||||||
|
this.browsing = false
|
||||||
|
this.listingError = ''
|
||||||
|
this.onPathChange(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks the service whether this folder exists, is a folder, and is writeable. */
|
||||||
|
public validate() {
|
||||||
|
const target = this.trimSas(this.path)
|
||||||
|
this.checkResult = null
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
this.checkResult = {
|
||||||
|
path: '',
|
||||||
|
exists: false,
|
||||||
|
isFolder: false,
|
||||||
|
writeable: false,
|
||||||
|
msg: 'Type or pick a folder first'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validating = true
|
||||||
|
|
||||||
|
this.sasService
|
||||||
|
.request(
|
||||||
|
'admin/makedata',
|
||||||
|
{ fromjs: [{ ACTION: 'WRITECHECK', PATH: target }] },
|
||||||
|
undefined,
|
||||||
|
{ suppressErrorAbortModal: true, suppressSuccessAbortModal: true }
|
||||||
|
)
|
||||||
|
.then((res: any) => {
|
||||||
|
const response = res?.adapterResponse || {}
|
||||||
|
const row: DcWriteCheckRow = (response.checkwrite || [])[0] || {}
|
||||||
|
this.checkResult = this.parseWriteCheck(row, target)
|
||||||
|
this.validating = false
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
this.checkResult = {
|
||||||
|
path: target,
|
||||||
|
exists: false,
|
||||||
|
isFolder: false,
|
||||||
|
writeable: false,
|
||||||
|
msg: this.errorText(err)
|
||||||
|
}
|
||||||
|
this.validating = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public parseWriteCheck(
|
||||||
|
row: DcWriteCheckRow,
|
||||||
|
asked: string
|
||||||
|
): DcWriteCheckResult {
|
||||||
|
const exists = this.trimSas(row?.PATH_EXISTS) === 'Y'
|
||||||
|
const isFolder = this.trimSas(row?.IS_FOLDER) === 'Y'
|
||||||
|
const writeable = this.trimSas(row?.WRITEABLE) === 'Y'
|
||||||
|
const path = this.trimSas(row?.PATH) || asked
|
||||||
|
let msg = this.trimSas(row?.MSG)
|
||||||
|
|
||||||
|
if (!msg && !writeable) {
|
||||||
|
msg = exists
|
||||||
|
? `${path} cannot be written to by the deployment user`
|
||||||
|
: `${path} does not exist`
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path, exists, isFolder, writeable, msg }
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorText(err: any): string {
|
||||||
|
const fromAdapter = err?.adapterResponse?.error
|
||||||
|
if (fromAdapter) return String(fromAdapter)
|
||||||
|
if (err?.message) return String(err.message)
|
||||||
|
return 'The request to makedata failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-17
@@ -18,24 +18,14 @@
|
|||||||
<strong>{{ SYSUSERID }}</strong> can write, on behalf of Data Controller.
|
<strong>{{ SYSUSERID }}</strong> can write, on behalf of Data Controller.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label class="mt-20 clr-control-label">DC Staging Directory</label>
|
<label for="dcDirectory" class="mt-20 clr-control-label"
|
||||||
|
>DC Staging Directory</label
|
||||||
|
>
|
||||||
<div class="mb-10 clr-control-container">
|
<div class="mb-10 clr-control-container">
|
||||||
<div class="clr-input-wrapper">
|
<app-dc-location
|
||||||
<input
|
[(path)]="dcDirectory"
|
||||||
class="clr-input"
|
inputId="dcDirectory"
|
||||||
placeholder="Type in directory"
|
></app-dc-location>
|
||||||
[(ngModel)]="dcDirectory"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- <button
|
|
||||||
(click)="directoryBrowse.click()"
|
|
||||||
class="mt-15 text-center"
|
|
||||||
class="btn btn-sm btn-outline"
|
|
||||||
>
|
|
||||||
Browse
|
|
||||||
</button>
|
|
||||||
<input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> -->
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="m-0 mt-10">
|
<p class="m-0 mt-10">
|
||||||
|
|||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
import { ServerType } from '@sasjs/utils/types/serverType'
|
||||||
|
import { SasjsConfiguratorComponent } from './sasjs-configurator.component'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SAS9 / SASjs Server setup screen cannot deploy anything: it builds the
|
||||||
|
* one-off `makedata` URL and opens it, with the admin group and the directory
|
||||||
|
* the control library will live in. These tests pin the URL shape, because a
|
||||||
|
* wrong parameter name or a lost encode still produces a page that opens and
|
||||||
|
* merely fails inside SAS, where nobody is looking.
|
||||||
|
*/
|
||||||
|
describe('SasjsConfiguratorComponent (SAS9 / SASjs setup)', () => {
|
||||||
|
const build = (serverType: ServerType) => {
|
||||||
|
const sasService: any = {
|
||||||
|
getExecutionPath: () => '/SASStoredProcess/do',
|
||||||
|
sasjsMakedataChecking: () => Promise.resolve(false),
|
||||||
|
logout: () => undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = new SasjsConfiguratorComponent(
|
||||||
|
sasService,
|
||||||
|
{ getServerInfo: () => ({ subscribe: () => undefined }) } as any,
|
||||||
|
{ replaceState: () => undefined } as any
|
||||||
|
)
|
||||||
|
|
||||||
|
component.sasJsConfig = {
|
||||||
|
serverType,
|
||||||
|
serverUrl: 'https://sas9.example.com',
|
||||||
|
appLoc: '/User Folders/dc'
|
||||||
|
} as any
|
||||||
|
component.dcDirectory = '/tmp/DataController'
|
||||||
|
component.dcAdminGroup = 'DCADMINS'
|
||||||
|
|
||||||
|
const opened: string[] = []
|
||||||
|
spyOn(window, 'open').and.callFake((url?: any) => {
|
||||||
|
opened.push(String(url))
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
return { component, opened, sasService }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('opens makedata on SAS9 with the admin group and the directory', () => {
|
||||||
|
const { component, opened } = build(ServerType.Sas9)
|
||||||
|
|
||||||
|
component.makeData()
|
||||||
|
|
||||||
|
expect(opened.length).toBe(1)
|
||||||
|
expect(opened[0]).toContain(
|
||||||
|
'_program=/User Folders/dc/services/admin/makedata'
|
||||||
|
)
|
||||||
|
expect(opened[0]).toContain('admin=DCADMINS')
|
||||||
|
expect(opened[0]).toContain('path=%2Ftmp%2FDataController')
|
||||||
|
expect(opened[0]).toContain('action=MAKEDATA')
|
||||||
|
expect(opened[0]).toContain('_debug=131')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays on the page origin for SAS9 rather than the configured server', () => {
|
||||||
|
const { component, opened } = build(ServerType.Sas9)
|
||||||
|
|
||||||
|
component.makeData()
|
||||||
|
|
||||||
|
// A SAS9 deployment is reached through the URL the user is already on; the
|
||||||
|
// configured serverUrl would point the browser at a host it may not reach.
|
||||||
|
expect(opened[0].startsWith(window.location.origin)).toBe(true)
|
||||||
|
expect(opened[0]).not.toContain('sas9.example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the directory readable for SASjs Server', () => {
|
||||||
|
const { component, opened } = build(ServerType.Sasjs)
|
||||||
|
|
||||||
|
component.makeData()
|
||||||
|
|
||||||
|
// The SASjs branch does not encode the path, so a plain directory stays as
|
||||||
|
// written; encoding it there would make makedata look for a folder with %2F
|
||||||
|
// in its name.
|
||||||
|
expect(opened[0]).toContain('path=/tmp/DataController')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks the SASjs Server deployment before reloading the app', () => {
|
||||||
|
const { component, sasService } = build(ServerType.Sasjs)
|
||||||
|
const spy = spyOn(sasService, 'sasjsMakedataChecking').and.returnValue(
|
||||||
|
Promise.resolve(false)
|
||||||
|
)
|
||||||
|
|
||||||
|
component.makeData()
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -145,7 +145,7 @@ export class SasjsConfiguratorComponent implements OnInit {
|
|||||||
|
|
||||||
const url = `${
|
const url = `${
|
||||||
serverUrl ? serverUrl : ''
|
serverUrl ? serverUrl : ''
|
||||||
}${executor}?_program=${root}/services/admin/makedata&admin=${
|
}${executor}?_program=${root}/services/admin/makedata&action=MAKEDATA&admin=${
|
||||||
this.dcAdminGroup
|
this.dcAdminGroup
|
||||||
}&path=${dcDirectory}${_debug}`
|
}&path=${dcDirectory}${_debug}`
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ActivatedRoute, Router } from '@angular/router'
|
|||||||
import Handsontable, { CellRange } from 'handsontable'
|
import Handsontable, { CellRange } from 'handsontable'
|
||||||
import { Subject, Subscription } from 'rxjs'
|
import { Subject, Subscription } from 'rxjs'
|
||||||
import { sanitiseForSas } from '../shared/utils/sanitise'
|
import { sanitiseForSas } from '../shared/utils/sanitise'
|
||||||
|
import { fixGridAria } from '../shared/utils/grid-aria'
|
||||||
import { SasStoreService } from '../services/sas-store.service'
|
import { SasStoreService } from '../services/sas-store.service'
|
||||||
|
|
||||||
type AOA = any[][]
|
type AOA = any[][]
|
||||||
@@ -58,6 +59,10 @@ import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
|
|||||||
import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
|
import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
|
||||||
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
|
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
|
||||||
import { excelRound } from '../shared/dc-validator/utils/excelRound'
|
import { excelRound } from '../shared/dc-validator/utils/excelRound'
|
||||||
|
import {
|
||||||
|
applyCaseTransform,
|
||||||
|
isCaseTransformSkipped
|
||||||
|
} from '../shared/dc-validator/utils/caseTransform'
|
||||||
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
|
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
|
||||||
import { hasFormulaRules } from '../shared/dc-validator/utils/hasFormulaRules'
|
import { hasFormulaRules } from '../shared/dc-validator/utils/hasFormulaRules'
|
||||||
import { getFormulaColumnNames } from '../shared/dc-validator/utils/getFormulaColumnNames'
|
import { getFormulaColumnNames } from '../shared/dc-validator/utils/getFormulaColumnNames'
|
||||||
@@ -108,6 +113,7 @@ import { VaFilterService } from '../services/va-filter.service'
|
|||||||
import { UploadFileResponse } from '../models/UploadFile'
|
import { UploadFileResponse } from '../models/UploadFile'
|
||||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||||
import { ParseResult } from '../models/ParseResult.interface'
|
import { ParseResult } from '../models/ParseResult.interface'
|
||||||
|
import { observeGridContainerResize } from './utils/refreshGridAfterContainerResize'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-editor',
|
selector: 'app-editor',
|
||||||
@@ -714,8 +720,6 @@ export class EditorComponent
|
|||||||
|
|
||||||
public licenceState = this.licenceService.licenceState
|
public licenceState = this.licenceService.licenceState
|
||||||
|
|
||||||
private ariaObserver: MutationObserver | undefined
|
|
||||||
private ariaCheckInterval: any | undefined
|
|
||||||
private gridResizeObserver: ResizeObserver | undefined
|
private gridResizeObserver: ResizeObserver | undefined
|
||||||
private pasteListener: ((event: ClipboardEvent) => void) | undefined
|
private pasteListener: ((event: ClipboardEvent) => void) | undefined
|
||||||
|
|
||||||
@@ -3647,7 +3651,16 @@ export class EditorComponent
|
|||||||
if (clauses.length === 0) return false
|
if (clauses.length === 0) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await this.sasStoreService.saveQuery(libds, clauses)
|
// A `va_parmap` entry is a column the report author typed, so this
|
||||||
|
// request can legitimately fail: the table may not have that column, and
|
||||||
|
// validatefilter aborts on an unknown variable (dc/dc#404). Attempting it
|
||||||
|
// is still worth it - when the mapping is right the very first getdata is
|
||||||
|
// already filtered - but the failure must not stop the page. An abort
|
||||||
|
// modal here covers an editor that is about to load fine; the mapping
|
||||||
|
// banner names the bad entry once the real metadata arrives.
|
||||||
|
const res: any = await this.sasStoreService.saveQuery(libds, clauses, {
|
||||||
|
suppressErrorAbortModal: true
|
||||||
|
})
|
||||||
const id = res?.result?.[0]?.FILTER_RK
|
const id = res?.result?.[0]?.FILTER_RK
|
||||||
const table = res?.result?.[0]?.FILTER_TABLE
|
const table = res?.result?.[0]?.FILTER_TABLE
|
||||||
if (id === undefined || table === undefined) return false
|
if (id === undefined || table === undefined) return false
|
||||||
@@ -3750,18 +3763,6 @@ export class EditorComponent
|
|||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this._queryParams?.unsubscribe()
|
this._queryParams?.unsubscribe()
|
||||||
|
|
||||||
// Clean up the MutationObserver
|
|
||||||
if (this.ariaObserver) {
|
|
||||||
this.ariaObserver.disconnect()
|
|
||||||
this.ariaObserver = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up the interval
|
|
||||||
if (this.ariaCheckInterval) {
|
|
||||||
clearInterval(this.ariaCheckInterval)
|
|
||||||
this.ariaCheckInterval = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop observing the grid container for resizes
|
// Stop observing the grid container for resizes
|
||||||
if (this.gridResizeObserver) {
|
if (this.gridResizeObserver) {
|
||||||
this.gridResizeObserver.disconnect()
|
this.gridResizeObserver.disconnect()
|
||||||
@@ -3792,169 +3793,28 @@ export class EditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-run height+render when the grid container settles.
|
* Re-measure the grid whenever its container settles. The observer itself
|
||||||
|
* lives in editor/utils/refreshGridAfterContainerResize.ts so the wiring is
|
||||||
|
* covered by a spec.
|
||||||
*/
|
*/
|
||||||
private observeGridResize() {
|
private observeGridResize() {
|
||||||
const el = this.hotInstance?.rootElement?.parentElement
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
this.gridResizeObserver?.disconnect()
|
this.gridResizeObserver?.disconnect()
|
||||||
this.gridResizeObserver = new ResizeObserver(() => {
|
this.gridResizeObserver = observeGridContainerResize(
|
||||||
requestAnimationFrame(() => {
|
this.hotInstance,
|
||||||
const hot = this.hotInstance
|
this.hotInstance?.rootElement?.parentElement
|
||||||
if (!hot || hot.isDestroyed) return
|
)
|
||||||
this.updateSettingsSortSafe({ height: this.hotTable.height }, false)
|
|
||||||
hot.render()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
this.gridResizeObserver.observe(el)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixes ARIA accessibility issues in the Handsontable component
|
* Corrects the ARIA markup Handsontable puts on its own grid wrapper - see
|
||||||
* This addresses the accessibility report issues with treegrid and presentation roles
|
* shared/utils/grid-aria.ts for what is corrected and why. Scoped to this
|
||||||
|
* grid so it does not touch any other grid or component on the page.
|
||||||
*/
|
*/
|
||||||
private fixAriaAccessibility() {
|
private fixAriaAccessibility(): void {
|
||||||
// Use a more aggressive approach to find and fix all ARIA issues
|
fixGridAria(
|
||||||
const fixAriaIssues = () => {
|
this.hotInstance?.rootElement ??
|
||||||
// Specifically target Handsontable wrapper elements that are causing issues
|
this.hotTableComponent?.hotInstance?.rootElement
|
||||||
const hotWrappers = document.querySelectorAll(
|
|
||||||
'.ht-wrapper, .wtHolder, [id^="ht_"]'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
hotWrappers.forEach((wrapper) => {
|
|
||||||
// Remove problematic ARIA attributes from Handsontable wrappers
|
|
||||||
wrapper.removeAttribute('role')
|
|
||||||
wrapper.removeAttribute('aria-rowcount')
|
|
||||||
wrapper.removeAttribute('aria-colcount')
|
|
||||||
wrapper.removeAttribute('aria-multiselectable')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Find all elements with problematic ARIA roles in the entire document
|
|
||||||
const allTreegridElements = document.querySelectorAll('[role="treegrid"]')
|
|
||||||
const allPresentationElements = document.querySelectorAll(
|
|
||||||
'[role="presentation"]'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fix treegrid role issues - remove them completely as they're causing problems
|
|
||||||
allTreegridElements.forEach((element) => {
|
|
||||||
element.removeAttribute('role')
|
|
||||||
element.removeAttribute('aria-rowcount')
|
|
||||||
element.removeAttribute('aria-colcount')
|
|
||||||
element.removeAttribute('aria-multiselectable')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fix presentation role issues - remove them if they contain interactive elements
|
|
||||||
allPresentationElements.forEach((element) => {
|
|
||||||
const hasInteractiveChildren =
|
|
||||||
element.querySelectorAll(
|
|
||||||
'button, input, select, textarea, [tabindex], [onclick], [contenteditable]'
|
|
||||||
).length > 0
|
|
||||||
if (hasInteractiveChildren) {
|
|
||||||
element.removeAttribute('role')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Also fix any elements with aria-rowcount="-1" which is problematic
|
|
||||||
const negativeRowCountElements = document.querySelectorAll(
|
|
||||||
'[aria-rowcount="-1"]'
|
|
||||||
)
|
|
||||||
negativeRowCountElements.forEach((element) => {
|
|
||||||
element.removeAttribute('aria-rowcount')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Ensure proper table structure
|
|
||||||
const tableElements = document.querySelectorAll('table')
|
|
||||||
tableElements.forEach((table) => {
|
|
||||||
if (!table.getAttribute('role')) {
|
|
||||||
table.setAttribute('role', 'table')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure table headers have proper scope
|
|
||||||
const headerCells = table.querySelectorAll('th')
|
|
||||||
headerCells.forEach((th) => {
|
|
||||||
if (!th.getAttribute('scope')) {
|
|
||||||
th.setAttribute('scope', 'col')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add proper ARIA labels to interactive elements
|
|
||||||
const interactiveElements = document.querySelectorAll(
|
|
||||||
'button, input, select, textarea, [contenteditable]'
|
|
||||||
)
|
|
||||||
interactiveElements.forEach((element) => {
|
|
||||||
if (
|
|
||||||
!element.getAttribute('aria-label') &&
|
|
||||||
!element.getAttribute('aria-labelledby')
|
|
||||||
) {
|
|
||||||
const textContent = element.textContent?.trim()
|
|
||||||
if (textContent) {
|
|
||||||
element.setAttribute('aria-label', textContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the fix immediately
|
|
||||||
fixAriaIssues()
|
|
||||||
|
|
||||||
// Run it again after a short delay to catch any dynamically created elements
|
|
||||||
setTimeout(fixAriaIssues, 100)
|
|
||||||
setTimeout(fixAriaIssues, 500)
|
|
||||||
setTimeout(fixAriaIssues, 1000)
|
|
||||||
setTimeout(fixAriaIssues, 2000)
|
|
||||||
|
|
||||||
// Set up a periodic check to ensure accessibility fixes are maintained
|
|
||||||
if (!this.ariaCheckInterval) {
|
|
||||||
this.ariaCheckInterval = setInterval(fixAriaIssues, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up a MutationObserver to continuously monitor for new problematic elements
|
|
||||||
if (!this.ariaObserver) {
|
|
||||||
this.ariaObserver = new MutationObserver((mutations) => {
|
|
||||||
let shouldFix = false
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (
|
|
||||||
mutation.type === 'attributes' &&
|
|
||||||
(mutation.attributeName === 'role' ||
|
|
||||||
mutation.attributeName === 'aria-rowcount')
|
|
||||||
) {
|
|
||||||
shouldFix = true
|
|
||||||
}
|
|
||||||
if (mutation.type === 'childList') {
|
|
||||||
mutation.addedNodes.forEach((node) => {
|
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
const element = node as Element
|
|
||||||
if (
|
|
||||||
element.hasAttribute('role') ||
|
|
||||||
element.hasAttribute('aria-rowcount')
|
|
||||||
) {
|
|
||||||
shouldFix = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (shouldFix) {
|
|
||||||
setTimeout(fixAriaIssues, 50)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start observing the entire document for changes
|
|
||||||
this.ariaObserver.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: [
|
|
||||||
'role',
|
|
||||||
'aria-rowcount',
|
|
||||||
'aria-colcount',
|
|
||||||
'aria-multiselectable'
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initSetup(response: EditorsGetDataServiceResponse, attempt = 0) {
|
initSetup(response: EditorsGetDataServiceResponse, attempt = 0) {
|
||||||
@@ -4443,6 +4303,23 @@ export class EditorComponent
|
|||||||
if (col === firstDerivedCol) th.classList.add('dc-derived-first')
|
if (col === firstDerivedCol) th.classList.add('dc-derived-first')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A column whose CASE rule forces a case commits every entry in that
|
||||||
|
// case (see the beforeChange hook) - mark the header so the user can
|
||||||
|
// tell the capitals or the lower-case came from the grid and not
|
||||||
|
// from them. The cell shows the value the grid actually holds, so
|
||||||
|
// nothing here re-cases a value the user has not touched.
|
||||||
|
const caseTransform = column
|
||||||
|
? this.dcValidator?.getCaseTransform(column)
|
||||||
|
: undefined
|
||||||
|
if (caseTransform) {
|
||||||
|
th.classList.add('dc-case-header')
|
||||||
|
const caseHint =
|
||||||
|
caseTransform === 'upper'
|
||||||
|
? 'Values are converted to upper case on entry'
|
||||||
|
: 'Values are converted to lower case on entry'
|
||||||
|
th.title = th.title ? `${th.title}; ${caseHint}` : caseHint
|
||||||
|
}
|
||||||
|
|
||||||
// Remove header arrow from Delete column
|
// Remove header arrow from Delete column
|
||||||
if (col === 0) {
|
if (col === 0) {
|
||||||
th.classList.add('firstColumnHeaderStyle')
|
th.classList.add('firstColumnHeaderStyle')
|
||||||
@@ -4630,6 +4507,36 @@ export class EditorComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CASE=UPCASE / CASE=LOWCASE: commit the value in the case the rule
|
||||||
|
// requires, so it is not flagged as invalid and retyped. Applied here
|
||||||
|
// (rather than in afterChange) because it runs before the commit - the
|
||||||
|
// cursor and selection stay put, undo restores the previous value, and
|
||||||
|
// an edit, a paste range and a fill-down all take the same path. The
|
||||||
|
// CASE validation itself still stands for values that arrive from
|
||||||
|
// anywhere else (API, staged data, Excel import).
|
||||||
|
//
|
||||||
|
// Two exclusions. `loadData` is the grid re-sending its own dataset on
|
||||||
|
// every updateSettings({data}) - converting there would rewrite the
|
||||||
|
// service's values and show rows as modified before the user has
|
||||||
|
// touched them (the unchanged baseline is cloned from what the grid
|
||||||
|
// hands back). Undo and redo restore a previous value rather than
|
||||||
|
// entering a new one, and re-casing it would hand back something other
|
||||||
|
// than what the user asked to get back. A formula column holds a live
|
||||||
|
// `=...` string, which is re-cased into nonsense - HARDFORMULA is
|
||||||
|
// read-only so it never reaches here, but SOFTFORMULA is editable.
|
||||||
|
if (!isCaseTransformSkipped(source) && typeof newValue === 'string') {
|
||||||
|
const caseTransform = this.dcValidator?.getCaseTransform(colName)
|
||||||
|
if (
|
||||||
|
caseTransform &&
|
||||||
|
!this.dcValidator?.hasDqRules(colName, [
|
||||||
|
'HARDFORMULA',
|
||||||
|
'SOFTFORMULA'
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
change[3] = applyCaseTransform(newValue, caseTransform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A `=`-led value the user types or pastes is never auto-escaped
|
// A `=`-led value the user types or pastes is never auto-escaped
|
||||||
// (see character-column-formula-plan.md) - it's left exactly as
|
// (see character-column-formula-plan.md) - it's left exactly as
|
||||||
// entered, which genuinely evaluates as a live formula since
|
// entered, which genuinely evaluates as a live formula since
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
import {
|
||||||
|
applyCaseTransform,
|
||||||
|
CaseTransform,
|
||||||
|
isCaseTransformSkipped
|
||||||
|
} from '../../shared/dc-validator/utils/caseTransform'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mechanics coverage for the CASE rule transform, against a real Handsontable
|
||||||
|
* instance - the same technique the other grid specs in this folder use.
|
||||||
|
*
|
||||||
|
* editor.component.ts has no TestBed harness, so the hook below mirrors its
|
||||||
|
* beforeChange branch using the real transform function: what is being proven
|
||||||
|
* here is the part that belongs to Handsontable and is easy to get wrong - that
|
||||||
|
* mutating the pending change commits the converted value for an edit, a paste
|
||||||
|
* range and a fill-down, that it does not touch a dataset handed back to the
|
||||||
|
* grid on load, and that undo still restores what was there before.
|
||||||
|
*/
|
||||||
|
describe('CASE rule transform on entry integration', () => {
|
||||||
|
// the rule table DcValidator.getCaseTransform builds from MPE_VALIDATIONS
|
||||||
|
const caseRules: Record<string, CaseTransform> = {
|
||||||
|
UPPER_COL: 'upper',
|
||||||
|
LOWER_COL: 'lower'
|
||||||
|
}
|
||||||
|
// a SOFTFORMULA column is user-editable and holds a live formula string
|
||||||
|
const formulaCols = ['SOFT_FORMULA_COL']
|
||||||
|
|
||||||
|
const columnNames = [
|
||||||
|
'KEY',
|
||||||
|
'UPPER_COL',
|
||||||
|
'LOWER_COL',
|
||||||
|
'LABEL_COL',
|
||||||
|
'SOFT_FORMULA_COL'
|
||||||
|
]
|
||||||
|
|
||||||
|
let container: HTMLDivElement
|
||||||
|
let hot: Handsontable
|
||||||
|
|
||||||
|
const buildGrid = (data: Record<string, any>[]) => {
|
||||||
|
hot = new Handsontable(container, {
|
||||||
|
data,
|
||||||
|
columns: columnNames.map((name) => ({ data: name })),
|
||||||
|
licenseKey: 'non-commercial-and-evaluation'
|
||||||
|
})
|
||||||
|
|
||||||
|
// mirrors editor.component.ts's beforeChange branch
|
||||||
|
hot.addHook('beforeChange', (changes: any[], source: string) => {
|
||||||
|
if (!changes || isCaseTransformSkipped(source)) return
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
if (!change) continue
|
||||||
|
|
||||||
|
const [, prop, , newValue] = change
|
||||||
|
const colName =
|
||||||
|
typeof prop === 'string' ? prop : (hot.colToProp(prop) as string)
|
||||||
|
|
||||||
|
if (typeof newValue !== 'string') continue
|
||||||
|
if (formulaCols.includes(colName)) continue
|
||||||
|
|
||||||
|
change[3] = applyCaseTransform(newValue, caseRules[colName])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
return hot
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = () => ({
|
||||||
|
KEY: 1,
|
||||||
|
UPPER_COL: 'keep',
|
||||||
|
LOWER_COL: 'KEEP',
|
||||||
|
LABEL_COL: 'Keep Me',
|
||||||
|
SOFT_FORMULA_COL: '=upper(KEY)'
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
hot?.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commits a typed value in the case the column rule requires', () => {
|
||||||
|
buildGrid([row()])
|
||||||
|
|
||||||
|
hot.setDataAtCell(0, 1, 'typed lower', 'edit')
|
||||||
|
hot.setDataAtCell(0, 2, 'TYPED UPPER', 'edit')
|
||||||
|
|
||||||
|
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('TYPED LOWER')
|
||||||
|
expect(hot.getDataAtRowProp(0, 'LOWER_COL')).toEqual('typed upper')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts every cell of a pasted range', () => {
|
||||||
|
buildGrid([row(), row()])
|
||||||
|
|
||||||
|
hot.populateFromArray(
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
[
|
||||||
|
['one', 'ONE'],
|
||||||
|
['two', 'TWO']
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
'paste'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('ONE')
|
||||||
|
expect(hot.getDataAtRowProp(1, 'UPPER_COL')).toEqual('TWO')
|
||||||
|
expect(hot.getDataAtRowProp(0, 'LOWER_COL')).toEqual('one')
|
||||||
|
expect(hot.getDataAtRowProp(1, 'LOWER_COL')).toEqual('two')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts a value filled down into the cells below', () => {
|
||||||
|
buildGrid([row(), row(), row()])
|
||||||
|
|
||||||
|
hot.populateFromArray(0, 1, [['seed']], 2, 1, 'autofill')
|
||||||
|
|
||||||
|
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('SEED')
|
||||||
|
expect(hot.getDataAtRowProp(1, 'UPPER_COL')).toEqual('SEED')
|
||||||
|
expect(hot.getDataAtRowProp(2, 'UPPER_COL')).toEqual('SEED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the case of the dataset the grid is handed on load alone', () => {
|
||||||
|
const data = [row()]
|
||||||
|
|
||||||
|
buildGrid(data)
|
||||||
|
|
||||||
|
// the service's own values, before the user has touched anything - a
|
||||||
|
// conversion here would rewrite backend data and mark the row modified
|
||||||
|
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('keep')
|
||||||
|
expect(hot.getDataAtRowProp(0, 'LOWER_COL')).toEqual('KEEP')
|
||||||
|
|
||||||
|
// the same holds for a later bulk data update (add-row flow, Excel preview)
|
||||||
|
hot.updateSettings({ data: [row()] })
|
||||||
|
|
||||||
|
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('keep')
|
||||||
|
expect(hot.getDataAtRowProp(0, 'LOWER_COL')).toEqual('KEEP')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a column without a CASE rule, and a formula column, exactly as entered', () => {
|
||||||
|
buildGrid([row()])
|
||||||
|
|
||||||
|
hot.setDataAtCell(0, 3, 'Mixed Case Label', 'edit')
|
||||||
|
hot.setDataAtCell(0, 4, '=UPPER(KEY)', 'edit')
|
||||||
|
|
||||||
|
expect(hot.getDataAtRowProp(0, 'LABEL_COL')).toEqual('Mixed Case Label')
|
||||||
|
expect(hot.getDataAtRowProp(0, 'SOFT_FORMULA_COL')).toEqual('=UPPER(KEY)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('undo restores the value the cell held before the converted entry', () => {
|
||||||
|
buildGrid([row()])
|
||||||
|
|
||||||
|
hot.setDataAtCell(0, 1, 'replaced', 'edit')
|
||||||
|
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('REPLACED')
|
||||||
|
|
||||||
|
hot.getPlugin('undoRedo').undo()
|
||||||
|
|
||||||
|
// exactly what was there - the restore takes the 'UndoRedo.undo' source,
|
||||||
|
// which is not user entry and is therefore not converted. A value from the
|
||||||
|
// service that did not satisfy the rule comes back as it arrived, not
|
||||||
|
// silently re-cased into one that does.
|
||||||
|
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('keep')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redo re-applies the converted value', () => {
|
||||||
|
buildGrid([row()])
|
||||||
|
|
||||||
|
hot.setDataAtCell(0, 1, 'replaced', 'edit')
|
||||||
|
hot.getPlugin('undoRedo').undo()
|
||||||
|
hot.getPlugin('undoRedo').redo()
|
||||||
|
|
||||||
|
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('REPLACED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the value Revert puts back exactly as the service supplied it', () => {
|
||||||
|
buildGrid([row()])
|
||||||
|
|
||||||
|
hot.setDataAtCell(0, 1, 'typed', 'edit')
|
||||||
|
|
||||||
|
// the Revert menu item writes the service's own value with this source -
|
||||||
|
// converting it would leave the cell modified against the unchanged
|
||||||
|
// baseline, so the revert would not have reverted
|
||||||
|
hot.setDataAtCell(0, 1, 'keep', 'revert')
|
||||||
|
|
||||||
|
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('keep')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
import { HyperFormula } from 'hyperformula'
|
||||||
|
import {
|
||||||
|
observeGridContainerResize,
|
||||||
|
refreshGridAfterContainerResize
|
||||||
|
} from './refreshGridAfterContainerResize'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor refreshes its grid when the container changes size (sidebar
|
||||||
|
* toggle, modal open/close). That path must not touch grid settings: any
|
||||||
|
* `updateSettings()` while a multi-column sort is active corrupts formula
|
||||||
|
* references into `#REF!` (the bug `updateSettingsSortSafe()` works around),
|
||||||
|
* and its workaround - clear the sort, apply, re-apply - re-sorts the data
|
||||||
|
* underneath an open cell editor.
|
||||||
|
*
|
||||||
|
* This pins that contract against a real Handsontable instance, using the same
|
||||||
|
* technique as the other editor integration specs (editor.component.ts has no
|
||||||
|
* TestBed harness).
|
||||||
|
*/
|
||||||
|
describe('refreshGridAfterContainerResize', () => {
|
||||||
|
let container: HTMLDivElement
|
||||||
|
let hot: Handsontable
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
hot = new Handsontable(container, {
|
||||||
|
data: [
|
||||||
|
[3, 10, '=A1*B1'],
|
||||||
|
[1, 20, '=A2*B2'],
|
||||||
|
[2, 30, '=A3*B3']
|
||||||
|
],
|
||||||
|
columns: [{ data: 0 }, { data: 1 }, { data: 2, readOnly: true }],
|
||||||
|
formulas: { engine: HyperFormula, licenseKey: 'gpl-v3' },
|
||||||
|
multiColumnSorting: true,
|
||||||
|
licenseKey: 'non-commercial-and-evaluation'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (hot && !hot.isDestroyed) hot.destroy()
|
||||||
|
hot = undefined as any
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-measures without changing settings, so an active sort survives', () => {
|
||||||
|
const sorting = hot.getPlugin('multiColumnSorting') as any
|
||||||
|
sorting.sort([{ column: 0, sortOrder: 'asc' }])
|
||||||
|
const sortBefore = JSON.parse(JSON.stringify(sorting.getSortConfig()))
|
||||||
|
|
||||||
|
const updateSettings = spyOn(hot, 'updateSettings').and.callThrough()
|
||||||
|
const clearSort = spyOn(sorting, 'clearSort').and.callThrough()
|
||||||
|
const sort = spyOn(sorting, 'sort').and.callThrough()
|
||||||
|
const refreshDimensions = spyOn(hot, 'refreshDimensions').and.callThrough()
|
||||||
|
|
||||||
|
refreshGridAfterContainerResize(hot)
|
||||||
|
|
||||||
|
expect(refreshDimensions).toHaveBeenCalledTimes(1)
|
||||||
|
expect(updateSettings).not.toHaveBeenCalled()
|
||||||
|
expect(clearSort).not.toHaveBeenCalled()
|
||||||
|
expect(sort).not.toHaveBeenCalled()
|
||||||
|
expect(JSON.parse(JSON.stringify(sorting.getSortConfig()))).toEqual(
|
||||||
|
sortBefore
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves formula values intact across a resize', () => {
|
||||||
|
const valuesBefore = hot.getData()
|
||||||
|
|
||||||
|
refreshGridAfterContainerResize(hot)
|
||||||
|
|
||||||
|
expect(hot.getData()).toEqual(valuesBefore)
|
||||||
|
const flat = (hot.getData() as any[][]).flat()
|
||||||
|
expect(flat.some((value) => String(value).includes('#REF'))).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a missing or destroyed instance', () => {
|
||||||
|
expect(() => refreshGridAfterContainerResize(null)).not.toThrow()
|
||||||
|
expect(() => refreshGridAfterContainerResize(undefined)).not.toThrow()
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
expect(() => refreshGridAfterContainerResize(hot)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The observer is what makes the helper fire, and it is the wiring - not the
|
||||||
|
* helper body - that regressed into `updateSettings()` in the first place. A
|
||||||
|
* helper-only spec passes even if the observer reapplies settings, so this one
|
||||||
|
* drives a real container resize through the observer and fails on any
|
||||||
|
* settings call.
|
||||||
|
*/
|
||||||
|
describe('observeGridContainerResize', () => {
|
||||||
|
it('re-measures on a container resize without touching grid settings', async () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.style.width = '500px'
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const hot = new Handsontable(container, {
|
||||||
|
data: [
|
||||||
|
[3, 10, '=A1*B1'],
|
||||||
|
[1, 20, '=A2*B2']
|
||||||
|
],
|
||||||
|
colHeaders: true,
|
||||||
|
width: '100%',
|
||||||
|
height: 300,
|
||||||
|
licenseKey: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const settingsSpy = spyOn(hot, 'updateSettings').and.callThrough()
|
||||||
|
const refreshSpy = spyOn(hot, 'refreshDimensions').and.callThrough()
|
||||||
|
|
||||||
|
const observer = observeGridContainerResize(hot, container)
|
||||||
|
expect(observer).toBeDefined()
|
||||||
|
|
||||||
|
container.style.width = '320px'
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||||
|
|
||||||
|
expect(refreshSpy).toHaveBeenCalled()
|
||||||
|
expect(settingsSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
observer?.disconnect()
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nothing when the grid or container is missing', () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
|
||||||
|
expect(observeGridContainerResize(null, container)).toBeUndefined()
|
||||||
|
expect(observeGridContainerResize(undefined, undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-measures a grid after its container changed size (sidebar toggle, modal
|
||||||
|
* open/close, window resize).
|
||||||
|
*
|
||||||
|
* Deliberately does NOT call `updateSettings()`. Any settings call while a
|
||||||
|
* multi-column sort is active corrupts formula cell references into `#REF!` -
|
||||||
|
* that is the bug `updateSettingsSortSafe()` exists for - and the workaround
|
||||||
|
* (clear the sort, apply, re-apply the sort) re-sorts the data underneath an
|
||||||
|
* open cell editor, so typing can land on a different row than the one being
|
||||||
|
* edited. A container resize has no business changing settings at all:
|
||||||
|
* `refreshDimensions()` measures `rootElement.getBoundingClientRect()` and
|
||||||
|
* compares it with the last measured size, re-running `adjustElementsSize()`
|
||||||
|
* and a render only when the box actually moved. Both grids take their height
|
||||||
|
* from a viewport-derived `calc()` setting (editor.component.ts:203), so a
|
||||||
|
* container resize that matters to the grid is a width change or a viewport
|
||||||
|
* change - both of which move that box. It also fires
|
||||||
|
* `beforeRefreshDimensions` / `afterRefreshDimensions` for anything that needs
|
||||||
|
* to react.
|
||||||
|
*/
|
||||||
|
export function refreshGridAfterContainerResize(
|
||||||
|
hot: Handsontable | null | undefined
|
||||||
|
): void {
|
||||||
|
if (!hot || hot.isDestroyed) return
|
||||||
|
hot.refreshDimensions()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches a grid's container and re-measures the grid whenever its box changes
|
||||||
|
* (sidebar toggle, modal open/close, window resize).
|
||||||
|
*
|
||||||
|
* The observer lives here rather than in the component so the wiring is
|
||||||
|
* covered by a spec: what matters is not that `refreshDimensions()` is called
|
||||||
|
* but that a resize never reaches `updateSettings()`.
|
||||||
|
*
|
||||||
|
* Returns the observer so the caller can disconnect it on destroy.
|
||||||
|
*/
|
||||||
|
export function observeGridContainerResize(
|
||||||
|
hot: Handsontable | null | undefined,
|
||||||
|
container: HTMLElement | null | undefined
|
||||||
|
): ResizeObserver | undefined {
|
||||||
|
if (!hot || !container || typeof ResizeObserver === 'undefined') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
requestAnimationFrame(() => refreshGridAfterContainerResize(hot))
|
||||||
|
})
|
||||||
|
observer.observe(container)
|
||||||
|
|
||||||
|
return observer
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Shapes returned by the makedata service's filesystem actions.
|
||||||
|
*
|
||||||
|
* The service answers two questions the deploy screens ask before the control
|
||||||
|
* library exists: what is below this folder (`ACTION=DIRLIST`) and can we write
|
||||||
|
* to this folder (`ACTION=WRITECHECK`). Fields arrive with SAS blank padding,
|
||||||
|
* so every consumer trims before it tests.
|
||||||
|
*/
|
||||||
|
export interface DcDirInfo {
|
||||||
|
PARENT: string
|
||||||
|
SEP: string
|
||||||
|
TRUNCATED: string
|
||||||
|
PLATFORM: string
|
||||||
|
DEPTH: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DcFolderRow {
|
||||||
|
FILEPATH: string
|
||||||
|
FILENAME: string
|
||||||
|
FILE_OR_FOLDER: string
|
||||||
|
LEVEL: number
|
||||||
|
MSG: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DcFolderEntry {
|
||||||
|
filepath: string
|
||||||
|
filename: string
|
||||||
|
level: number
|
||||||
|
info: string
|
||||||
|
/** an entry mp_dirlist listed but could not open - shown, not usable */
|
||||||
|
locked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DcWriteCheckRow {
|
||||||
|
PATH: string
|
||||||
|
PATH_EXISTS: string
|
||||||
|
IS_FOLDER: string
|
||||||
|
WRITEABLE: string
|
||||||
|
MSG: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DcWriteCheckResult {
|
||||||
|
path: string
|
||||||
|
exists: boolean
|
||||||
|
isFolder: boolean
|
||||||
|
writeable: boolean
|
||||||
|
msg: string
|
||||||
|
}
|
||||||
@@ -157,6 +157,16 @@
|
|||||||
<p cds-text="secondary regular" class="mb-20">
|
<p cds-text="secondary regular" class="mb-20">
|
||||||
File size: <strong>{{ selectedFile.sizeMB }} MB</strong>
|
File size: <strong>{{ selectedFile.sizeMB }} MB</strong>
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
cds-text="secondary regular"
|
||||||
|
class="mb-20 d-flex clr-align-items-center"
|
||||||
|
id="file-loading-stage"
|
||||||
|
>
|
||||||
|
Stage: <strong class="ml-5">{{ fileLoadingState }}</strong>
|
||||||
|
@if (isFileBusy) {
|
||||||
|
<clr-spinner class="ml-10" clrSmall></clr-spinner>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
<p cds-text="secondary regular" class="mb-15">
|
<p cds-text="secondary regular" class="mb-15">
|
||||||
Paste or type the list of datasets to upload:
|
Paste or type the list of datasets to upload:
|
||||||
</p>
|
</p>
|
||||||
@@ -188,18 +198,26 @@
|
|||||||
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
|
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
|
||||||
@if (!activeParsedDataset) {
|
@if (!activeParsedDataset) {
|
||||||
<div class="no-table-selected pointer-events-none">
|
<div class="no-table-selected pointer-events-none">
|
||||||
@if (fileLoadingState !== FileLoadingState.parsed) {
|
@if (isFileBusy) {
|
||||||
<clr-icon
|
<clr-icon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
shape="process-on-vm"
|
shape="process-on-vm"
|
||||||
size="40"
|
size="40"
|
||||||
class="is-info icon-dc-fill"
|
class="is-info icon-dc-fill"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
<p class="text-center color-gray mt-10" cds-text="section">
|
<p
|
||||||
|
class="text-center color-gray mt-10"
|
||||||
|
cds-text="section"
|
||||||
|
id="file-extraction-stage"
|
||||||
|
>
|
||||||
{{ fileLoadingState }}...
|
{{ fileLoadingState }}...
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-center color-gray" cds-text="secondary regular">
|
||||||
|
{{ selectedFile?.name }} - {{ selectedFile?.sizeMB }} MB
|
||||||
|
</p>
|
||||||
|
<clr-spinner class="mt-10" clrSmall></clr-spinner>
|
||||||
}
|
}
|
||||||
@if (fileLoadingState === FileLoadingState.parsed) {
|
@if (!isFileBusy) {
|
||||||
<clr-icon
|
<clr-icon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
shape="warning-standard"
|
shape="warning-standard"
|
||||||
@@ -209,6 +227,32 @@
|
|||||||
<p class="text-center color-gray mt-10" cds-text="section">
|
<p class="text-center color-gray mt-10" cds-text="section">
|
||||||
Please select a dataset on the left to review the data
|
Please select a dataset on the left to review the data
|
||||||
</p>
|
</p>
|
||||||
|
@if (sheetsNotExtracted.length) {
|
||||||
|
<p
|
||||||
|
id="file-extraction-failures"
|
||||||
|
cds-text="secondary regular"
|
||||||
|
class="mt-15 color-red"
|
||||||
|
>
|
||||||
|
Could not extract
|
||||||
|
<strong class="ml-5">{{
|
||||||
|
sheetsNotExtracted.join(', ')
|
||||||
|
}}</strong
|
||||||
|
>. Those tables are left out of the submission.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (sheetsWithoutRows.length) {
|
||||||
|
<p
|
||||||
|
id="file-empty-sheets"
|
||||||
|
cds-text="secondary regular"
|
||||||
|
class="mt-15"
|
||||||
|
>
|
||||||
|
No rows found in
|
||||||
|
<strong class="ml-5">{{
|
||||||
|
sheetsWithoutRows.join(', ')
|
||||||
|
}}</strong
|
||||||
|
>. Those tables are left out of the submission.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { ChangeDetectorRef } from '@angular/core'
|
||||||
|
import { TestBed } from '@angular/core/testing'
|
||||||
|
import {
|
||||||
|
EventService,
|
||||||
|
HelperService,
|
||||||
|
LicenceService,
|
||||||
|
SasService,
|
||||||
|
SasStoreService
|
||||||
|
} from '../services'
|
||||||
|
import { SpreadsheetService } from '../services/spreadsheet.service'
|
||||||
|
import { MultiDatasetComponent } from './multi-dataset.component'
|
||||||
|
|
||||||
|
const DATASET = (libds: string): any => ({
|
||||||
|
libds,
|
||||||
|
includeInSubmission: true,
|
||||||
|
parsingTable: true,
|
||||||
|
datasetInfo: {
|
||||||
|
libds,
|
||||||
|
dcValidator: {},
|
||||||
|
headerPks: [],
|
||||||
|
headerArray: [],
|
||||||
|
timeHeaders: [],
|
||||||
|
dateHeaders: [],
|
||||||
|
dateTimeHeaders: [],
|
||||||
|
xlRules: {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MultiDatasetComponent extraction', () => {
|
||||||
|
let component: MultiDatasetComponent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built through the injector rather than with positional arguments, so an
|
||||||
|
* added or reordered constructor parameter fails here instead of binding a
|
||||||
|
* stub to the wrong collaborator.
|
||||||
|
*/
|
||||||
|
const buildComponent = (parseExcelFile: any): MultiDatasetComponent => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
MultiDatasetComponent,
|
||||||
|
{ provide: EventService, useValue: {} },
|
||||||
|
{ provide: LicenceService, useValue: { licenceState: { value: {} } } },
|
||||||
|
{ provide: HelperService, useValue: {} },
|
||||||
|
{ provide: SasStoreService, useValue: {} },
|
||||||
|
{ provide: SpreadsheetService, useValue: { parseExcelFile } },
|
||||||
|
{ provide: SasService, useValue: {} },
|
||||||
|
{ provide: ChangeDetectorRef, useValue: {} }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
return TestBed.inject(MultiDatasetComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResult = {
|
||||||
|
data: [
|
||||||
|
[10, 'first'],
|
||||||
|
[20, 'second']
|
||||||
|
],
|
||||||
|
headerShow: ['age', 'name']
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => TestBed.resetTestingModule())
|
||||||
|
|
||||||
|
describe('parseDatasetSheet', () => {
|
||||||
|
it('fills the dataset from the parsed sheet', async () => {
|
||||||
|
component = buildComponent(() => Promise.resolve(parseResult))
|
||||||
|
component.selectedFile = { name: 'file.xlsx' } as any
|
||||||
|
const dataset = DATASET('WORK.FIRST')
|
||||||
|
|
||||||
|
await component.parseDatasetSheet(dataset, {} as any)
|
||||||
|
|
||||||
|
expect(dataset.datasource).toEqual([
|
||||||
|
{
|
||||||
|
age: 10,
|
||||||
|
name: 'first',
|
||||||
|
_____DELETE__THIS__RECORD_____: 'No'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
age: 20,
|
||||||
|
name: 'second',
|
||||||
|
_____DELETE__THIS__RECORD_____: 'No'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
expect(dataset.parsingTable).toBeFalse()
|
||||||
|
expect(component.sheetsNotExtracted).toEqual([])
|
||||||
|
expect(component.sheetsWithoutRows).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a sheet that could not be parsed and names it', async () => {
|
||||||
|
component = buildComponent(() => Promise.reject('sheet is corrupt'))
|
||||||
|
component.selectedFile = { name: 'file.xlsx' } as any
|
||||||
|
const dataset = DATASET('WORK.BROKEN')
|
||||||
|
|
||||||
|
await component.parseDatasetSheet(dataset, {} as any)
|
||||||
|
|
||||||
|
expect(dataset.datasource).toEqual([])
|
||||||
|
expect(dataset.includeInSubmission).toBeFalse()
|
||||||
|
expect(dataset.parsingTable).toBeFalse()
|
||||||
|
expect(component.sheetsNotExtracted).toEqual(['WORK.BROKEN'])
|
||||||
|
expect(component.sheetsWithoutRows).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names a sheet that resolved without rows as empty rather than unreadable', async () => {
|
||||||
|
component = buildComponent(() => Promise.resolve(undefined))
|
||||||
|
component.selectedFile = { name: 'file.xlsx' } as any
|
||||||
|
const dataset = DATASET('WORK.EMPTY')
|
||||||
|
|
||||||
|
await component.parseDatasetSheet(dataset, {} as any)
|
||||||
|
|
||||||
|
expect(dataset.datasource).toEqual([])
|
||||||
|
expect(dataset.includeInSubmission).toBeFalse()
|
||||||
|
expect(dataset.parsingTable).toBeFalse()
|
||||||
|
expect(component.sheetsWithoutRows).toEqual(['WORK.EMPTY'])
|
||||||
|
expect(component.sheetsNotExtracted).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names a sheet the utility reports as holding no data as empty', async () => {
|
||||||
|
component = buildComponent(() =>
|
||||||
|
Promise.reject('No relevant data found in File !')
|
||||||
|
)
|
||||||
|
component.selectedFile = { name: 'file.xlsx' } as any
|
||||||
|
const dataset = DATASET('WORK.NODATA')
|
||||||
|
|
||||||
|
await component.parseDatasetSheet(dataset, {} as any)
|
||||||
|
|
||||||
|
expect(dataset.includeInSubmission).toBeFalse()
|
||||||
|
expect(component.sheetsWithoutRows).toEqual(['WORK.NODATA'])
|
||||||
|
expect(component.sheetsNotExtracted).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a sheet whose rows could not be derived as a failure', async () => {
|
||||||
|
component = buildComponent(() =>
|
||||||
|
Promise.resolve({ data: [[10, 'first']], headerShow: undefined })
|
||||||
|
)
|
||||||
|
component.selectedFile = { name: 'file.xlsx' } as any
|
||||||
|
const dataset = DATASET('WORK.MALFORMED')
|
||||||
|
|
||||||
|
await component.parseDatasetSheet(dataset, {} as any)
|
||||||
|
|
||||||
|
expect(dataset.datasource).toEqual([])
|
||||||
|
expect(dataset.includeInSubmission).toBeFalse()
|
||||||
|
expect(dataset.parsingTable).toBeFalse()
|
||||||
|
expect(component.sheetsNotExtracted).toEqual(['WORK.MALFORMED'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a parse that resolves with an empty data array as a sheet with no rows', async () => {
|
||||||
|
component = buildComponent(() =>
|
||||||
|
Promise.resolve({ data: [], headerShow: [] })
|
||||||
|
)
|
||||||
|
component.selectedFile = { name: 'file.xlsx' } as any
|
||||||
|
const dataset = DATASET('WORK.EMPTY')
|
||||||
|
|
||||||
|
await component.parseDatasetSheet(dataset, {} as any)
|
||||||
|
|
||||||
|
expect(dataset.includeInSubmission).toBeFalse()
|
||||||
|
expect(component.sheetsWithoutRows).toEqual(['WORK.EMPTY'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('finishExtraction', () => {
|
||||||
|
it('does not report a clean finish when a sheet could not be read', () => {
|
||||||
|
component = buildComponent(() => Promise.resolve(parseResult))
|
||||||
|
component.sheetsNotExtracted = ['WORK.BROKEN']
|
||||||
|
|
||||||
|
component.finishExtraction()
|
||||||
|
|
||||||
|
expect(component.fileLoadingState).toEqual(
|
||||||
|
'Extracting the data finished with a problem'
|
||||||
|
)
|
||||||
|
expect(component.isFileBusy).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not report a clean finish when a sheet was empty', () => {
|
||||||
|
component = buildComponent(() => Promise.resolve(parseResult))
|
||||||
|
component.sheetsWithoutRows = ['WORK.EMPTY']
|
||||||
|
|
||||||
|
component.finishExtraction()
|
||||||
|
|
||||||
|
expect(component.fileLoadingState).toEqual(
|
||||||
|
'Extracting the data finished with a problem'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports the finish it had when every sheet was extracted', () => {
|
||||||
|
component = buildComponent(() => Promise.resolve(parseResult))
|
||||||
|
|
||||||
|
component.finishExtraction()
|
||||||
|
|
||||||
|
expect(component.fileLoadingState).toEqual('Extracting the data finished')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -37,13 +37,23 @@ import { UploadFileResponse } from '../models/UploadFile'
|
|||||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||||
import { ParseResult } from '../models/ParseResult.interface'
|
import { ParseResult } from '../models/ParseResult.interface'
|
||||||
import XLSX from 'xlsx'
|
import XLSX from 'xlsx'
|
||||||
|
import { observeGridAria } from '../shared/utils/grid-aria'
|
||||||
|
|
||||||
|
/** The sheet utility's rejection for a sheet that parsed but holds no rows. */
|
||||||
|
const isEmptySheet = (error: unknown): boolean =>
|
||||||
|
typeof error === 'string' && error.startsWith('No relevant data found')
|
||||||
|
|
||||||
enum FileLoadingState {
|
enum FileLoadingState {
|
||||||
reading = 'Reading the file',
|
reading = 'Reading the file',
|
||||||
parsing = 'Searching for the data in the file',
|
scanning = 'Searching for the data in the file',
|
||||||
parsed = 'Searching for the data finished',
|
ready = 'File ready to load',
|
||||||
|
fetching = 'Fetching the dataset details',
|
||||||
|
extracting = 'Extracting the data',
|
||||||
|
extracted = 'Extracting the data finished',
|
||||||
|
/** Some sheets could not be extracted, so the file is not fully loaded. */
|
||||||
|
extractionFailed = 'Extracting the data finished with a problem',
|
||||||
/**
|
/**
|
||||||
* Defualt value
|
* Default value
|
||||||
*/
|
*/
|
||||||
notSelected = 'File not selected'
|
notSelected = 'File not selected'
|
||||||
}
|
}
|
||||||
@@ -64,8 +74,24 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
|
|
||||||
public workbookInterval: any
|
public workbookInterval: any
|
||||||
public fileLoadingState: FileLoadingState = FileLoadingState.notSelected
|
public fileLoadingState: FileLoadingState = FileLoadingState.notSelected
|
||||||
|
/** The sheets the last extraction could not read, so the stage can name them. */
|
||||||
|
public sheetsNotExtracted: string[] = []
|
||||||
|
/** The sheets the last extraction read but found no rows in. */
|
||||||
|
public sheetsWithoutRows: string[] = []
|
||||||
|
|
||||||
public FileLoadingState = FileLoadingState
|
/**
|
||||||
|
* True while the file is being worked on. `ready`, `extracted` and
|
||||||
|
* `extractionFailed` are idle states, so the spinner and the stage line are
|
||||||
|
* only shown for the phases that actually have work running.
|
||||||
|
*/
|
||||||
|
public get isFileBusy(): boolean {
|
||||||
|
return (
|
||||||
|
this.fileLoadingState === FileLoadingState.reading ||
|
||||||
|
this.fileLoadingState === FileLoadingState.scanning ||
|
||||||
|
this.fileLoadingState === FileLoadingState.fetching ||
|
||||||
|
this.fileLoadingState === FileLoadingState.extracting
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
public hotTableLicenseKey: string | undefined = undefined
|
public hotTableLicenseKey: string | undefined = undefined
|
||||||
public hotTableMaxRows =
|
public hotTableMaxRows =
|
||||||
@@ -275,6 +301,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
|
|
||||||
if (this.selectedFile) {
|
if (this.selectedFile) {
|
||||||
this.fileLoadingState = FileLoadingState.reading
|
this.fileLoadingState = FileLoadingState.reading
|
||||||
|
this.sheetsNotExtracted = []
|
||||||
|
this.sheetsWithoutRows = []
|
||||||
|
|
||||||
this.selectedFile.sizeMB = this.spreadsheetService.bytesToMB(
|
this.selectedFile.sizeMB = this.spreadsheetService.bytesToMB(
|
||||||
this.selectedFile.size
|
this.selectedFile.size
|
||||||
@@ -282,8 +310,13 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
|
|
||||||
// Read the excel file to be ready
|
// Read the excel file to be ready
|
||||||
this.spreadsheetService.xlsxReadFile(this.selectedFile!).then((wb) => {
|
this.spreadsheetService.xlsxReadFile(this.selectedFile!).then((wb) => {
|
||||||
this.fileLoadingState = FileLoadingState.parsing
|
|
||||||
this.selectedFile!.workbook = wb
|
this.selectedFile!.workbook = wb
|
||||||
|
|
||||||
|
// The sheet scan runs in parallel and can finish first, so only
|
||||||
|
// advance the stage if the read is still the phase in progress.
|
||||||
|
if (this.fileLoadingState === FileLoadingState.reading) {
|
||||||
|
this.fileLoadingState = FileLoadingState.scanning
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,10 +373,12 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
onDiscardFile() {
|
onDiscardFile() {
|
||||||
this.selectedFile = null
|
this.selectedFile = null
|
||||||
this.userInputDatasets = ''
|
this.userInputDatasets = ''
|
||||||
|
this.fileLoadingState = FileLoadingState.notSelected
|
||||||
}
|
}
|
||||||
|
|
||||||
async onStartParsingFile() {
|
async onStartParsingFile() {
|
||||||
this.uploadLoading = true
|
this.uploadLoading = true
|
||||||
|
this.fileLoadingState = FileLoadingState.fetching
|
||||||
|
|
||||||
const datasetFetchingPromises: Promise<
|
const datasetFetchingPromises: Promise<
|
||||||
EditorsGetDataServiceResponse | undefined
|
EditorsGetDataServiceResponse | undefined
|
||||||
@@ -377,6 +412,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
datasetObjects = this.buildDatasetsObjects(datasets)
|
datasetObjects = this.buildDatasetsObjects(datasets)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Error building dataset objects', err)
|
console.warn('Error building dataset objects', err)
|
||||||
|
this.fileLoadingState = FileLoadingState.ready
|
||||||
this.eventService.showInfoModal('Upload error', (err as Error).message)
|
this.eventService.showInfoModal('Upload error', (err as Error).message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -391,9 +427,70 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.workbookLoaded().then((workbook) => {
|
this.workbookLoaded().then((workbook) => {
|
||||||
|
this.fileLoadingState = FileLoadingState.extracting
|
||||||
|
this.sheetsNotExtracted = []
|
||||||
|
this.sheetsWithoutRows = []
|
||||||
|
|
||||||
|
const parsingPromises: Promise<void>[] = []
|
||||||
|
|
||||||
for (let parsedDataset of this.parsedDatasets) {
|
for (let parsedDataset of this.parsedDatasets) {
|
||||||
this.spreadsheetService
|
parsingPromises.push(this.parseDatasetSheet(parsedDataset, workbook))
|
||||||
.parseExcelFile({
|
}
|
||||||
|
|
||||||
|
// The stage belongs to the whole file, not to the sheet that finished
|
||||||
|
// last, so it only moves on once every sheet has been extracted - and a
|
||||||
|
// sheet that could not be extracted is not a clean finish.
|
||||||
|
Promise.allSettled(parsingPromises).then(() => this.finishExtraction())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stage the file finishes at: a clean finish only when every sheet was
|
||||||
|
* extracted, and a named failure when one was left out of the submission.
|
||||||
|
*/
|
||||||
|
public finishExtraction(): void {
|
||||||
|
const leftOut =
|
||||||
|
this.sheetsNotExtracted.length > 0 || this.sheetsWithoutRows.length > 0
|
||||||
|
|
||||||
|
this.fileLoadingState = leftOut
|
||||||
|
? FileLoadingState.extractionFailed
|
||||||
|
: FileLoadingState.extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a sheet out of the submission and names it, under the reason it left:
|
||||||
|
* a sheet that could not be read is not the same as one that was read and
|
||||||
|
* holds no rows, and the two read differently to the user.
|
||||||
|
*/
|
||||||
|
public leaveSheetOut(
|
||||||
|
parsedDataset: ParsedDataset,
|
||||||
|
reason: 'failed' | 'empty'
|
||||||
|
): void {
|
||||||
|
parsedDataset.datasource = []
|
||||||
|
parsedDataset.includeInSubmission = false
|
||||||
|
parsedDataset.parsingTable = false
|
||||||
|
|
||||||
|
const sheets =
|
||||||
|
reason === 'empty' ? this.sheetsWithoutRows : this.sheetsNotExtracted
|
||||||
|
|
||||||
|
sheets.push(parsedDataset.libds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts one sheet into its dataset. A sheet that cannot be extracted is
|
||||||
|
* left out of the submission and named, so the stage the file finishes at can
|
||||||
|
* say that it did not fully extract rather than reporting a clean finish over
|
||||||
|
* a sheet that was silently dropped.
|
||||||
|
*/
|
||||||
|
public async parseDatasetSheet(
|
||||||
|
parsedDataset: ParsedDataset,
|
||||||
|
workbook: XLSX.WorkBook
|
||||||
|
): Promise<void> {
|
||||||
|
let parseResult: ParseResult | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
parseResult = await this.spreadsheetService.parseExcelFile({
|
||||||
file: this.selectedFile!,
|
file: this.selectedFile!,
|
||||||
workbook: workbook,
|
workbook: workbook,
|
||||||
password: this.selectedFile!.password || undefined,
|
password: this.selectedFile!.password || undefined,
|
||||||
@@ -406,13 +503,43 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
dateTimeHeaders: parsedDataset.datasetInfo.dateTimeHeaders,
|
dateTimeHeaders: parsedDataset.datasetInfo.dateTimeHeaders,
|
||||||
xlRules: parsedDataset.datasetInfo.xlRules
|
xlRules: parsedDataset.datasetInfo.xlRules
|
||||||
})
|
})
|
||||||
.then((parseResult: ParseResult | undefined) => {
|
|
||||||
this.fileLoadingState = FileLoadingState.parsed
|
|
||||||
|
|
||||||
if (parseResult && parseResult.data) {
|
// A resolve carrying no rows did extract - it just holds nothing - so it
|
||||||
let datasource: any[] = []
|
// is named as empty rather than as unreadable, and the stage still does
|
||||||
|
// not call the file cleanly loaded.
|
||||||
|
if (!parseResult || !parseResult.data || parseResult.data.length === 0) {
|
||||||
|
this.leaveSheetOut(parsedDataset, 'empty')
|
||||||
|
|
||||||
parseResult.data.map((item) => {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The derivation is inside the try on purpose: a row shape the sheet
|
||||||
|
// utility did not expect throws here, and outside it the throw would
|
||||||
|
// escape as a rejection that allSettled swallows - leaving the sheet in
|
||||||
|
// the submission with a spinner and a clean finish reported.
|
||||||
|
parsedDataset.datasource = this.buildDatasource(parseResult)
|
||||||
|
parsedDataset.parseResult = parseResult
|
||||||
|
parsedDataset.parsingTable = false
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Parsing excel file error.', error)
|
||||||
|
|
||||||
|
// The sheet utility reports a sheet that parsed to nothing by rejecting
|
||||||
|
// with "No relevant data found", which is a different thing to a sheet it
|
||||||
|
// could not read - the reader is sent looking for a corrupt file by the
|
||||||
|
// second, and to the sheet itself by the first.
|
||||||
|
this.leaveSheetOut(
|
||||||
|
parsedDataset,
|
||||||
|
isEmptySheet(error) ? 'empty' : 'failed'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rows the grid renders, built from a parsed sheet. */
|
||||||
|
private buildDatasource(parseResult: ParseResult): any[] {
|
||||||
|
const datasource: any[] = []
|
||||||
|
const rows = parseResult.data!
|
||||||
|
|
||||||
|
rows.map((item) => {
|
||||||
let itemObject: any = {}
|
let itemObject: any = {}
|
||||||
|
|
||||||
parseResult.headerShow!.map((header: any, index: number) => {
|
parseResult.headerShow!.map((header: any, index: number) => {
|
||||||
@@ -426,21 +553,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
datasource.push(itemObject)
|
datasource.push(itemObject)
|
||||||
})
|
})
|
||||||
|
|
||||||
parsedDataset.datasource = datasource
|
return datasource
|
||||||
parsedDataset.parseResult = parseResult
|
|
||||||
parsedDataset.parsingTable = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error: string) => {
|
|
||||||
console.warn('Parsing excel file error.', error)
|
|
||||||
|
|
||||||
parsedDataset.datasource = []
|
|
||||||
parsedDataset.includeInSubmission = false
|
|
||||||
parsedDataset.parsingTable = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmitAll() {
|
onSubmitAll() {
|
||||||
@@ -460,12 +573,14 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
this.selectedFile = null
|
this.selectedFile = null
|
||||||
this.userInputDatasets = ''
|
this.userInputDatasets = ''
|
||||||
this.submitReasonMessage = ''
|
this.submitReasonMessage = ''
|
||||||
|
this.fileLoadingState = FileLoadingState.notSelected
|
||||||
}
|
}
|
||||||
|
|
||||||
initHot() {
|
initHot() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.hotTableMainComponent?.hotInstance) {
|
if (this.hotTableMainComponent?.hotInstance) {
|
||||||
this.hotInstance = this.hotTableMainComponent.hotInstance
|
this.hotInstance = this.hotTableMainComponent.hotInstance
|
||||||
|
observeGridAria(this.hotInstance)
|
||||||
|
|
||||||
// Set height of parsed data to full height of the page content area
|
// Set height of parsed data to full height of the page content area
|
||||||
const contentAreaHeight = this.contentAreaRef.nativeElement.clientHeight
|
const contentAreaHeight = this.contentAreaRef.nativeElement.clientHeight
|
||||||
@@ -505,6 +620,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
if (this.hotTableUserDatasetComponent?.hotInstance) {
|
if (this.hotTableUserDatasetComponent?.hotInstance) {
|
||||||
this.hotInstanceUserDataset =
|
this.hotInstanceUserDataset =
|
||||||
this.hotTableUserDatasetComponent.hotInstance
|
this.hotTableUserDatasetComponent.hotInstance
|
||||||
|
observeGridAria(this.hotInstanceUserDataset)
|
||||||
|
|
||||||
// Load initial data manually after instance is ready
|
// Load initial data manually after instance is ready
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -674,6 +790,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
}
|
}
|
||||||
if (!this.hotInstanceUserDataset) {
|
if (!this.hotInstanceUserDataset) {
|
||||||
console.warn('hotInstanceUserDataset not ready after waiting')
|
console.warn('hotInstanceUserDataset not ready after waiting')
|
||||||
|
this.fileLoadingState = FileLoadingState.ready
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -746,6 +863,10 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
this.dynamicCellValidations()
|
this.dynamicCellValidations()
|
||||||
|
|
||||||
|
// The sheet scan is done; the file waits for the user to confirm the
|
||||||
|
// dataset list, so no stage is running any more.
|
||||||
|
this.fileLoadingState = FileLoadingState.ready
|
||||||
}
|
}
|
||||||
|
|
||||||
onParsedDatasetClick(parsedDataset: ParsedDataset) {
|
onParsedDatasetClick(parsedDataset: ParsedDataset) {
|
||||||
|
|||||||
@@ -26,22 +26,4 @@ export class DeployService {
|
|||||||
document.body.removeChild(downloadLink)
|
document.body.removeChild(downloadLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public readFile(file: File): Promise<any> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let JsonFileReader = new FileReader()
|
|
||||||
|
|
||||||
JsonFileReader.onload = () => {
|
|
||||||
if (JsonFileReader.result) {
|
|
||||||
resolve(JSON.parse(JsonFileReader.result.toString()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonFileReader.readAsText(file)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public clearUploadInput(event: Event) {
|
|
||||||
;(<HTMLInputElement>event.target).value = ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ export class EventService {
|
|||||||
@Output() onStartupDataLoaded: EventEmitter<any> = new EventEmitter()
|
@Output() onStartupDataLoaded: EventEmitter<any> = new EventEmitter()
|
||||||
@Output()
|
@Output()
|
||||||
onShowAbortModal: EventEmitter<InfoModal> = new EventEmitter<InfoModal>()
|
onShowAbortModal: EventEmitter<InfoModal> = new EventEmitter<InfoModal>()
|
||||||
@Output() onRequestsModalOpen: EventEmitter<boolean> = new EventEmitter()
|
@Output() onSasLogsModalOpen: EventEmitter<boolean> = new EventEmitter()
|
||||||
@Output() onDemoLimitModalShow: EventEmitter<string> =
|
@Output() onDemoLimitModalShow: EventEmitter<string> =
|
||||||
new EventEmitter<string>()
|
new EventEmitter<string>()
|
||||||
|
|
||||||
@@ -78,8 +78,8 @@ export class EventService {
|
|||||||
this.onShowAbortModal.emit(infoModalObject)
|
this.onShowAbortModal.emit(infoModalObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
public openRequestsModal() {
|
public openSasLogsModal() {
|
||||||
this.onRequestsModalOpen.emit(true)
|
this.onSasLogsModalOpen.emit(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
public catchResponseError(sasService: string | null, err: any) {
|
public catchResponseError(sasService: string | null, err: any) {
|
||||||
@@ -128,15 +128,4 @@ export class EventService {
|
|||||||
public startupDataLoaded() {
|
public startupDataLoaded() {
|
||||||
this.onStartupDataLoaded.emit()
|
this.onStartupDataLoaded.emit()
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispatchEvent(eventName: string) {
|
|
||||||
let event
|
|
||||||
if (typeof Event === 'function') {
|
|
||||||
event = new Event(eventName)
|
|
||||||
} else {
|
|
||||||
event = document.createEvent('Event')
|
|
||||||
event.initEvent(eventName, true, true)
|
|
||||||
}
|
|
||||||
window.dispatchEvent(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -116,6 +116,53 @@ describe('SasStoreService', () => {
|
|||||||
expect(seen.length).toBe(0)
|
expect(seen.length).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('saveQuery', () => {
|
||||||
|
const capture = (adapterResponse: any = { result: [] }) => {
|
||||||
|
const calls: any[][] = []
|
||||||
|
const store = new SasStoreService(
|
||||||
|
{
|
||||||
|
request: (...args: any[]) => {
|
||||||
|
calls.push(args)
|
||||||
|
return Promise.resolve({ adapterResponse })
|
||||||
|
}
|
||||||
|
} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any
|
||||||
|
)
|
||||||
|
return { store, calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('posts the filter to public/validatefilter', async () => {
|
||||||
|
const { store, calls } = capture()
|
||||||
|
await store.saveQuery('DC.MY_TABLE', [
|
||||||
|
{ VARIABLE_NM: 'A', OPERATOR_NM: '=', RAW_VALUE: "'x'" } as any
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(calls[0][0]).toBe('public/validatefilter')
|
||||||
|
expect(calls[0][1].iwant[0].filter_table).toBe('DC.MY_TABLE')
|
||||||
|
expect(calls[0][1].filterquery.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes wrapper options through, so a rejected filter can stay quiet', async () => {
|
||||||
|
// The VA deferred load attempts a filter built from a va_parmap entry the
|
||||||
|
// table may not have, and must not cover a page that loads fine with an
|
||||||
|
// abort modal (dc/dc#404).
|
||||||
|
const { store, calls } = capture()
|
||||||
|
await store.saveQuery('DC.MY_TABLE', [], {
|
||||||
|
suppressErrorAbortModal: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(calls[0][3]).toEqual({ suppressErrorAbortModal: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends no wrapper options when the caller gives none', async () => {
|
||||||
|
const { store, calls } = capture()
|
||||||
|
await store.saveQuery('DC.MY_TABLE', [])
|
||||||
|
|
||||||
|
expect(calls[0][3]).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { HelperService } from './helper.service'
|
|||||||
import { QueryClause } from '../models/TableData'
|
import { QueryClause } from '../models/TableData'
|
||||||
import { globals } from '../_globals'
|
import { globals } from '../_globals'
|
||||||
import { FilterClause, FilterGroup, FilterQuery } from '../models/FilterQuery'
|
import { FilterClause, FilterGroup, FilterQuery } from '../models/FilterQuery'
|
||||||
|
import { RequestWrapperOptions } from '../models/request-wrapper/RequestWrapperOptions'
|
||||||
import {
|
import {
|
||||||
$DataFormats,
|
$DataFormats,
|
||||||
EditorsGetDataSASResponse,
|
EditorsGetDataSASResponse,
|
||||||
@@ -395,13 +396,30 @@ export class SasStoreService {
|
|||||||
).adapterResponse
|
).adapterResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
public async saveQuery(libds: string, filterQuery: QueryClause[]) {
|
/**
|
||||||
|
* Persists a filter and returns its FILTER_RK.
|
||||||
|
*
|
||||||
|
* `wrapperOptions` reaches the request wrapper, so a caller that can live with
|
||||||
|
* a rejected filter (the VA deferred load, whose `va_parmap` entry may name a
|
||||||
|
* column the table does not have) can suppress the abort modal that would
|
||||||
|
* otherwise cover a page which is about to load perfectly well.
|
||||||
|
*/
|
||||||
|
public async saveQuery(
|
||||||
|
libds: string,
|
||||||
|
filterQuery: QueryClause[],
|
||||||
|
wrapperOptions?: RequestWrapperOptions
|
||||||
|
) {
|
||||||
let tables = {
|
let tables = {
|
||||||
iwant: [{ filter_table: libds }],
|
iwant: [{ filter_table: libds }],
|
||||||
filterquery: filterQuery
|
filterquery: filterQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await this.sasService.request('public/validatefilter', tables)
|
const res = await this.sasService.request(
|
||||||
|
'public/validatefilter',
|
||||||
|
tables,
|
||||||
|
undefined,
|
||||||
|
wrapperOptions
|
||||||
|
)
|
||||||
|
|
||||||
this.filter.next(res)
|
this.filter.next(res)
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,41 @@ describe('VaFilterService', () => {
|
|||||||
expect(build([{ label: 'SOME_DATE', value: '2025/03/31' }])).toHaveSize(1)
|
expect(build([{ label: 'SOME_DATE', value: '2025/03/31' }])).toHaveSize(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('parses word dates, in either order and with an optional comma', () => {
|
||||||
|
const expected = "'01JAN2026'd"
|
||||||
|
// The reported shape: a date parameter displayed as WORDDATE.
|
||||||
|
expect(
|
||||||
|
build([{ label: 'SOME_DATE', value: 'January 1, 2026' }])[0].RAW_VALUE
|
||||||
|
).toBe(expected)
|
||||||
|
expect(
|
||||||
|
build([{ label: 'SOME_DATE', value: 'Jan 1, 2026' }])[0].RAW_VALUE
|
||||||
|
).toBe(expected)
|
||||||
|
expect(
|
||||||
|
build([{ label: 'SOME_DATE', value: 'Jan. 1, 2026' }])[0].RAW_VALUE
|
||||||
|
).toBe(expected)
|
||||||
|
expect(
|
||||||
|
build([{ label: 'SOME_DATE', value: '1 January 2026' }])[0].RAW_VALUE
|
||||||
|
).toBe(expected)
|
||||||
|
expect(
|
||||||
|
build([{ label: 'SOME_DATE', value: '1-Jan-2026' }])[0].RAW_VALUE
|
||||||
|
).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches a spelled-out month case-insensitively, and keeps DATE9 working', () => {
|
||||||
|
expect(
|
||||||
|
build([{ label: 'SOME_DATE', value: 'DECEMBER 31, 2025' }])[0].RAW_VALUE
|
||||||
|
).toBe("'31DEC2025'd")
|
||||||
|
expect(
|
||||||
|
build([{ label: 'SOME_DATE', value: '01AUG1962' }])[0].RAW_VALUE
|
||||||
|
).toBe("'01AUG1962'd")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still drops a month-name value that has no day', () => {
|
||||||
|
// A month and year alone is not a date - accepted shapes always carry a
|
||||||
|
// day, so this must not silently become the first of the month.
|
||||||
|
expect(build([{ label: 'SOME_DATE', value: 'January 2026' }])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves ambiguous slash dates day-first (never US), disambiguating by value', () => {
|
it('resolves ambiguous slash dates day-first (never US), disambiguating by value', () => {
|
||||||
const iso = build([{ label: 'SOME_DATE', value: '2025-03-31' }])[0]
|
const iso = build([{ label: 'SOME_DATE', value: '2025-03-31' }])[0]
|
||||||
.RAW_VALUE
|
.RAW_VALUE
|
||||||
|
|||||||
@@ -707,14 +707,42 @@ export class VaFilterService {
|
|||||||
return this.parseMonthNameDate(value) ?? this.parseNumericDate(value, order)
|
return this.parseMonthNameDate(value) ?? this.parseNumericDate(value, order)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962, 01AUG62) -> local Date, or null. */
|
/**
|
||||||
|
* Month-name date shapes -> local Date, or null. Three families:
|
||||||
|
* - DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962, 01AUG62);
|
||||||
|
* - word date with the month first, comma optional: "January 1, 2026",
|
||||||
|
* "Jan 1 2026", "Jan. 1, 2026";
|
||||||
|
* - word date with the day first: "1 January 2026", "1-Jan-2026".
|
||||||
|
*
|
||||||
|
* A date parameter's display format decides which of these the report author
|
||||||
|
* sees, and VA sends the formatted text, so a report using WORDDATE (or a
|
||||||
|
* long month name in any order) has to parse here rather than being dropped.
|
||||||
|
* The month name may be abbreviated or spelled out; only its first three
|
||||||
|
* letters are matched.
|
||||||
|
*/
|
||||||
private parseMonthNameDate(value: string): Date | null {
|
private parseMonthNameDate(value: string): Date | null {
|
||||||
const m = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
|
const monthByName = (name: string): number =>
|
||||||
if (!m) return null
|
VaFilterService.SAS_MONTHS.indexOf(
|
||||||
const [, day, monthAbbr, year] = m
|
name.replace(/\./g, '').slice(0, 3).toUpperCase()
|
||||||
const monthIndex = VaFilterService.SAS_MONTHS.indexOf(
|
|
||||||
monthAbbr.toUpperCase()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const date9 = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
|
||||||
|
if (date9) {
|
||||||
|
const [, day, monthAbbr, year] = date9
|
||||||
|
const monthIndex = monthByName(monthAbbr)
|
||||||
|
if (monthIndex < 0) return null
|
||||||
|
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthFirst =
|
||||||
|
/^([A-Za-z]{3,9})\.?[\s-]+(\d{1,2}),?[\s-]+(\d{2}|\d{4})$/.exec(value)
|
||||||
|
const dayFirst =
|
||||||
|
/^(\d{1,2})[\s-]+([A-Za-z]{3,9})\.?,?[\s-]+(\d{2}|\d{4})$/.exec(value)
|
||||||
|
const name = monthFirst ? monthFirst[1] : dayFirst ? dayFirst[2] : null
|
||||||
|
if (!name) return null
|
||||||
|
const day = monthFirst ? monthFirst[2] : dayFirst![1]
|
||||||
|
const year = monthFirst ? monthFirst[3] : dayFirst![3]
|
||||||
|
const monthIndex = monthByName(name)
|
||||||
if (monthIndex < 0) return null
|
if (monthIndex < 0) return null
|
||||||
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
|
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
(click)="openRequestsModal()"
|
(click)="openSasLogsModal()"
|
||||||
>
|
>
|
||||||
Open requests modal
|
Open logs modal
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ export class InfoModalComponent implements OnInit {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
openRequestsModal() {
|
openSasLogsModal() {
|
||||||
this.eventService.openRequestsModal()
|
this.eventService.openSasLogsModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
reload() {
|
reload() {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { EventEmitter } from '@angular/core'
|
||||||
|
import { fakeAsync, tick } from '@angular/core/testing'
|
||||||
|
import { AutocompleteComponent } from './autocomplete.component'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sidebar toggle moves the input without a window resize, so the dropdown
|
||||||
|
* has to follow the layout event itself. SidebarComponent used to reach this
|
||||||
|
* component by dispatching a synthetic window resize; that event is gone, so
|
||||||
|
* this subscription is the only thing keeping an open dropdown aligned.
|
||||||
|
*
|
||||||
|
* The component only needs ChangeDetectorRef and EventService, so it can be
|
||||||
|
* constructed directly - same approach as the sidebar spec. The work it
|
||||||
|
* schedules is on a timer, so the specs drive the fake clock rather than
|
||||||
|
* sleeping: a wall-clock wait would let a slow runner assert before the
|
||||||
|
* component ever ran.
|
||||||
|
*
|
||||||
|
* What these cover is the scheduling contract, not layout: that a toggle
|
||||||
|
* schedules one measurement, that a queued measurement is cancelled on
|
||||||
|
* destroy, and that nothing is measured after unsubscribe. Whether the
|
||||||
|
* measurement happens before or after a CSS transition is not observable here
|
||||||
|
* and is not what the component depends on.
|
||||||
|
*/
|
||||||
|
describe('AutocompleteComponent', () => {
|
||||||
|
const buildComponent = (eventService: any) =>
|
||||||
|
new AutocompleteComponent({ detectChanges: () => {} } as any, eventService)
|
||||||
|
|
||||||
|
it('repositions the dropdown when the sidebar toggles', fakeAsync(() => {
|
||||||
|
const eventService: any = { onSidebarToggle: new EventEmitter<any>() }
|
||||||
|
const component = buildComponent(eventService)
|
||||||
|
const reposition = spyOn(component, 'calculateInputPosition')
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
eventService.onSidebarToggle.emit({ open: false })
|
||||||
|
tick()
|
||||||
|
|
||||||
|
expect(reposition).toHaveBeenCalled()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('stops listening once destroyed', fakeAsync(() => {
|
||||||
|
const eventService: any = { onSidebarToggle: new EventEmitter<any>() }
|
||||||
|
const component = buildComponent(eventService)
|
||||||
|
const reposition = spyOn(component, 'calculateInputPosition')
|
||||||
|
component.ngOnInit()
|
||||||
|
component.ngOnDestroy()
|
||||||
|
|
||||||
|
eventService.onSidebarToggle.emit({ open: false })
|
||||||
|
tick()
|
||||||
|
|
||||||
|
expect(reposition).not.toHaveBeenCalled()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('cancels a reposition already queued when it is destroyed', fakeAsync(() => {
|
||||||
|
// Unsubscribing stops future notifications but not a timer already
|
||||||
|
// scheduled by one - a toggle immediately followed by destroy would still
|
||||||
|
// measure against a torn-down view.
|
||||||
|
const eventService: any = { onSidebarToggle: new EventEmitter<any>() }
|
||||||
|
const component = buildComponent(eventService)
|
||||||
|
const reposition = spyOn(component, 'calculateInputPosition')
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
eventService.onSidebarToggle.emit({ open: false })
|
||||||
|
component.ngOnDestroy()
|
||||||
|
tick()
|
||||||
|
|
||||||
|
expect(reposition).not.toHaveBeenCalled()
|
||||||
|
}))
|
||||||
|
})
|
||||||
@@ -5,11 +5,14 @@ import {
|
|||||||
EventEmitter,
|
EventEmitter,
|
||||||
HostListener,
|
HostListener,
|
||||||
Input,
|
Input,
|
||||||
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
Output,
|
Output,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ViewEncapsulation
|
ViewEncapsulation
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
|
import { Subscription } from 'rxjs'
|
||||||
|
import { EventService } from '../../services/event.service'
|
||||||
|
|
||||||
export type OnLoadingMoreEvent = {
|
export type OnLoadingMoreEvent = {
|
||||||
loadMoreFinished: (moreExists: boolean) => void
|
loadMoreFinished: (moreExists: boolean) => void
|
||||||
@@ -22,7 +25,7 @@ export type OnLoadingMoreEvent = {
|
|||||||
encapsulation: ViewEncapsulation.None,
|
encapsulation: ViewEncapsulation.None,
|
||||||
standalone: false
|
standalone: false
|
||||||
})
|
})
|
||||||
export class AutocompleteComponent implements OnInit, AfterViewInit {
|
export class AutocompleteComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
@ViewChild('input') inputElement: any
|
@ViewChild('input') inputElement: any
|
||||||
|
|
||||||
@Input() inputId: string = ''
|
@Input() inputId: string = ''
|
||||||
@@ -56,9 +59,36 @@ export class AutocompleteComponent implements OnInit, AfterViewInit {
|
|||||||
|
|
||||||
loadingMore: boolean = false
|
loadingMore: boolean = false
|
||||||
|
|
||||||
constructor(private cdr: ChangeDetectorRef) {}
|
constructor(
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
private eventService: EventService
|
||||||
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {}
|
private sidebarSub?: Subscription
|
||||||
|
private repositionTimer: any
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// A sidebar toggle moves this input without a window resize, which is the
|
||||||
|
// one layout change the dropdown has to follow. SidebarComponent used to
|
||||||
|
// reach here by dispatching a synthetic window resize; ask the layout
|
||||||
|
// event directly instead, so nothing depends on that event being fired.
|
||||||
|
this.sidebarSub = this.eventService.onSidebarToggle.subscribe(() => {
|
||||||
|
// Deferred by one task, not by an animation: the sidebar collapse is a
|
||||||
|
// plain `margin-left` change with no transition on the element
|
||||||
|
// (styles.scss `clr-vertical-nav.closed`), but Angular applies the
|
||||||
|
// class binding only after this handler returns, so measuring now would
|
||||||
|
// read the pre-toggle layout. The timer is held so a destroy between the
|
||||||
|
// toggle and the measurement cannot run it against a torn-down view.
|
||||||
|
clearTimeout(this.repositionTimer)
|
||||||
|
this.repositionTimer = setTimeout(() => this.calculateInputPosition())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.sidebarSub?.unsubscribe()
|
||||||
|
clearTimeout(this.repositionTimer)
|
||||||
|
this.repositionTimer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
DcValidationRuleUpdate
|
DcValidationRuleUpdate
|
||||||
} from './models/dc-validation.model'
|
} from './models/dc-validation.model'
|
||||||
import { DQRule, DQRuleTypes } from './models/dq-rules.model'
|
import { DQRule, DQRuleTypes } from './models/dq-rules.model'
|
||||||
|
import { CaseTransform } from './utils/caseTransform'
|
||||||
import { getDqDataCols } from './utils/getDqDataCols'
|
import { getDqDataCols } from './utils/getDqDataCols'
|
||||||
import { getNotNullDefault } from './utils/getNotNullDefault'
|
import { getNotNullDefault } from './utils/getNotNullDefault'
|
||||||
import { mergeColsRules } from './utils/mergeColsRules'
|
import { mergeColsRules } from './utils/mergeColsRules'
|
||||||
@@ -247,6 +248,39 @@ export class DcValidator {
|
|||||||
return isNaN(digits) ? undefined : digits
|
return isNaN(digits) ? undefined : digits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the case transform a `CASE` rule asks for on the given column, so
|
||||||
|
* the EDIT grid can commit the value in that case instead of flagging it.
|
||||||
|
*
|
||||||
|
* Only the two rule values the CASE validation understands are mapped
|
||||||
|
* (`UPCASE`, `LOWCASE`, see dq-validation.ts) - anything else leaves the
|
||||||
|
* value alone, which keeps the transform and the validation in step.
|
||||||
|
*
|
||||||
|
* @param col column name or index
|
||||||
|
* @returns 'upper', 'lower', or undefined when the column carries no usable
|
||||||
|
* CASE rule
|
||||||
|
*/
|
||||||
|
getCaseTransform(col: string | number): CaseTransform | undefined {
|
||||||
|
const rule = this.getRule(col)
|
||||||
|
|
||||||
|
// a column that is not in the grid has no data name to match a rule on
|
||||||
|
if (!rule?.data) return undefined
|
||||||
|
|
||||||
|
const caseRule = this.dqrules.find(
|
||||||
|
(dqrule: DQRule) =>
|
||||||
|
dqrule.BASE_COL === rule.data && dqrule.RULE_TYPE === 'CASE'
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (caseRule?.RULE_VALUE) {
|
||||||
|
case 'UPCASE':
|
||||||
|
return 'upper'
|
||||||
|
case 'LOWCASE':
|
||||||
|
return 'lower'
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the RULE_VALUEs of a HARDREGEX/SOFTREGEX rule on the given
|
* Returns the RULE_VALUEs of a HARDREGEX/SOFTREGEX rule on the given
|
||||||
* column, for display in the column-header info dropdown. Both are
|
* column, for display in the column-header info dropdown. Both are
|
||||||
|
|||||||
@@ -1041,6 +1041,66 @@ describe('DC Validator', () => {
|
|||||||
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toBeUndefined()
|
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getCaseTransform', () => {
|
||||||
|
const buildValidator = (dqRules: DQRule[]) =>
|
||||||
|
new DcValidator(
|
||||||
|
example_sasparams,
|
||||||
|
example_dataformats,
|
||||||
|
example_cols,
|
||||||
|
dqRules,
|
||||||
|
example_dqData
|
||||||
|
)
|
||||||
|
|
||||||
|
it('maps CASE=UPCASE to upper and CASE=LOWCASE to lower', () => {
|
||||||
|
const dcValidator = buildValidator(example_dqRules)
|
||||||
|
|
||||||
|
expect(dcValidator.getCaseTransform('SOME_CHAR')).toEqual('upper')
|
||||||
|
expect(dcValidator.getCaseTransform('SOME_CHAR_LOW')).toEqual('lower')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves a column index as well as a name', () => {
|
||||||
|
const dcValidator = buildValidator(example_dqRules)
|
||||||
|
|
||||||
|
// 2 is SOME_CHAR in COLHEADERS - the grid hands the hook a prop index
|
||||||
|
// for some sources
|
||||||
|
expect(dcValidator.getCaseTransform(2)).toEqual('upper')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for a column with no CASE rule', () => {
|
||||||
|
const dcValidator = buildValidator(example_dqRules)
|
||||||
|
|
||||||
|
expect(dcValidator.getCaseTransform('SOME_NUM')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for a column that is not in the grid', () => {
|
||||||
|
const dcValidator = buildValidator([
|
||||||
|
{
|
||||||
|
BASE_COL: 'NOT_IN_THE_GRID',
|
||||||
|
RULE_TYPE: 'CASE',
|
||||||
|
RULE_VALUE: 'UPCASE',
|
||||||
|
X: 0
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(dcValidator.getCaseTransform('NOT_IN_THE_GRID')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for a CASE rule value the validation does not apply', () => {
|
||||||
|
// dq-validation.ts only acts on UPCASE and LOWCASE; converting a value
|
||||||
|
// for any other rule value would be a transform the rule never asked for
|
||||||
|
const dcValidator = buildValidator([
|
||||||
|
{
|
||||||
|
BASE_COL: 'SOME_CHAR_ANY',
|
||||||
|
RULE_TYPE: 'CASE',
|
||||||
|
RULE_VALUE: 'TITLECASE',
|
||||||
|
X: 0
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(dcValidator.getCaseTransform('SOME_CHAR_ANY')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Minimal cols[] entry — only the fields rule ordering depends on. */
|
/** Minimal cols[] entry — only the fields rule ordering depends on. */
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { applyCaseTransform, isCaseTransformSkipped } from './caseTransform'
|
||||||
|
|
||||||
|
describe('applyCaseTransform', () => {
|
||||||
|
it('upper-cases a string when the column rule is UPCASE', () => {
|
||||||
|
expect(applyCaseTransform('abc', 'upper')).toEqual('ABC')
|
||||||
|
expect(applyCaseTransform('Mixed Case 1', 'upper')).toEqual('MIXED CASE 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lower-cases a string when the column rule is LOWCASE', () => {
|
||||||
|
expect(applyCaseTransform('ABC', 'lower')).toEqual('abc')
|
||||||
|
expect(applyCaseTransform('Mixed Case 1', 'lower')).toEqual('mixed case 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the value alone when the column carries no CASE rule', () => {
|
||||||
|
expect(applyCaseTransform('aBc', undefined)).toEqual('aBc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a value that is not a string alone', () => {
|
||||||
|
// a numeric column can still carry a CASE rule by mistake - it must not
|
||||||
|
// become the string '5'
|
||||||
|
expect(applyCaseTransform(5, 'upper')).toEqual(5)
|
||||||
|
expect(applyCaseTransform(5.5, 'lower')).toEqual(5.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves blank and missing values alone', () => {
|
||||||
|
expect(applyCaseTransform('', 'upper')).toEqual('')
|
||||||
|
expect(applyCaseTransform(null, 'upper')).toBeNull()
|
||||||
|
expect(applyCaseTransform(undefined, 'lower')).toBeUndefined()
|
||||||
|
// a SAS special missing is a character value on a character column, and
|
||||||
|
// stays exactly as typed
|
||||||
|
expect(applyCaseTransform('.a', 'upper')).toEqual('.A')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isCaseTransformSkipped', () => {
|
||||||
|
it('skips a dataset handed back to the grid on load', () => {
|
||||||
|
expect(isCaseTransformSkipped('loadData')).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips undo and redo, which restore a value rather than enter one', () => {
|
||||||
|
expect(isCaseTransformSkipped('UndoRedo.undo')).toBeTrue()
|
||||||
|
expect(isCaseTransformSkipped('UndoRedo.redo')).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips the Revert menu item, which puts the service value back', () => {
|
||||||
|
expect(isCaseTransformSkipped('revert')).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not skip a value the user enters', () => {
|
||||||
|
expect(isCaseTransformSkipped('edit')).toBeFalse()
|
||||||
|
expect(isCaseTransformSkipped('paste')).toBeFalse()
|
||||||
|
expect(isCaseTransformSkipped('autofill')).toBeFalse()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* The transform a `CASE` validation asks for, as the grid applies it on entry.
|
||||||
|
*
|
||||||
|
* `CASE=UPCASE` and `CASE=LOWCASE` are point-of-entry rules: a value in the
|
||||||
|
* other case is rejected by the validator, so the user is asked to retype it.
|
||||||
|
* The EDIT grid instead converts the pending value before it commits, so the
|
||||||
|
* typed text lands in the case the rule wants.
|
||||||
|
*/
|
||||||
|
export type CaseTransform = 'upper' | 'lower'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change sources whose values must not be re-cased.
|
||||||
|
*
|
||||||
|
* - `loadData` is the grid re-sending its own dataset on every
|
||||||
|
* `updateSettings({data})` (initial render, the add-row flow, the Excel
|
||||||
|
* preview). Converting there would rewrite the values the service supplied
|
||||||
|
* and show rows as modified before the user has touched them.
|
||||||
|
* - `UndoRedo.undo` / `UndoRedo.redo` restore the value the cell held rather
|
||||||
|
* than entering a new one. Re-casing a restored value would hand back
|
||||||
|
* something different from what the user asked to get back - and for a
|
||||||
|
* backend value that does not satisfy the rule it would quietly replace it
|
||||||
|
* with one that does.
|
||||||
|
* - `revert` is the Revert menu item, which puts back the value the service
|
||||||
|
* supplied for that cell. Converting it would leave the cell modified
|
||||||
|
* against the unchanged baseline, i.e. the revert would not have reverted.
|
||||||
|
*/
|
||||||
|
export const CASE_TRANSFORM_SKIPPED_SOURCES = [
|
||||||
|
'loadData',
|
||||||
|
'UndoRedo.undo',
|
||||||
|
'UndoRedo.redo',
|
||||||
|
'revert'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const isCaseTransformSkipped = (source: string): boolean =>
|
||||||
|
CASE_TRANSFORM_SKIPPED_SOURCES.includes(source)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the column's case transform to a pending cell value.
|
||||||
|
*
|
||||||
|
* Only a string is converted: a numeric value, a SAS missing, `null`,
|
||||||
|
* `undefined` and a blank all pass through untouched, so the transform can be
|
||||||
|
* applied to every change in a batch without inspecting the column type.
|
||||||
|
*
|
||||||
|
* Returns the value to commit - the caller assigns it back onto the pending
|
||||||
|
* change, rather than writing the cell, so the grid keeps its cursor,
|
||||||
|
* selection and undo history.
|
||||||
|
*/
|
||||||
|
export const applyCaseTransform = (
|
||||||
|
value: unknown,
|
||||||
|
transform: CaseTransform | undefined
|
||||||
|
): unknown => {
|
||||||
|
if (!transform || typeof value !== 'string') return value
|
||||||
|
|
||||||
|
return transform === 'upper' ? value.toUpperCase() : value.toLowerCase()
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
import {
|
||||||
|
buildGridPdfInput,
|
||||||
|
collectGridTable,
|
||||||
|
exportGrid,
|
||||||
|
isRangeEmpty,
|
||||||
|
getExportRange,
|
||||||
|
buildExportMenuItem
|
||||||
|
} from './hot-export.util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grid-reading side of the PDF export, against a real Handsontable
|
||||||
|
* instance so `getColHeader`, `getSelectedRangeLast` and the hiddenColumns
|
||||||
|
* plugin behave as they do in the viewer and editor.
|
||||||
|
*/
|
||||||
|
describe('hot-export grid extraction', () => {
|
||||||
|
let container: HTMLDivElement
|
||||||
|
let hot: Handsontable
|
||||||
|
|
||||||
|
const settings = (overrides: Record<string, any> = {}) => ({
|
||||||
|
data: [
|
||||||
|
['DELETE?', 'A1', '10.5'],
|
||||||
|
['', 'A2', '20.25'],
|
||||||
|
['', 'A3', null]
|
||||||
|
],
|
||||||
|
colHeaders: ['Delete?', 'ACCOUNT', 'BALANCE'],
|
||||||
|
rowHeaders: false,
|
||||||
|
licenseKey: 'non-commercial-and-evaluation',
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (hot && !hot.isDestroyed) hot.destroy()
|
||||||
|
hot = undefined as any
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads headers and every row when nothing is selected', () => {
|
||||||
|
hot = new Handsontable(container, settings())
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
const table = collectGridTable(hot)
|
||||||
|
|
||||||
|
expect(table.headers).toEqual(['Delete?', 'ACCOUNT', 'BALANCE'])
|
||||||
|
expect(table.rows.length).toBe(3)
|
||||||
|
expect(table.rows[0]).toEqual(['DELETE?', 'A1', '10.5'])
|
||||||
|
expect(table.rows[2][2]).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the leading housekeeping column when asked', () => {
|
||||||
|
hot = new Handsontable(container, settings())
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
const table = collectGridTable(hot, 1)
|
||||||
|
|
||||||
|
expect(table.headers).toEqual(['ACCOUNT', 'BALANCE'])
|
||||||
|
expect(table.rows[0]).toEqual(['A1', '10.5'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes columns hidden in the grid', () => {
|
||||||
|
hot = new Handsontable(
|
||||||
|
container,
|
||||||
|
settings({ hiddenColumns: { columns: [2], indicators: false } })
|
||||||
|
)
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
const table = collectGridTable(hot)
|
||||||
|
|
||||||
|
expect(table.headers).toEqual(['Delete?', 'ACCOUNT'])
|
||||||
|
expect(table.rows.map((row) => row.length)).toEqual([2, 2, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('survives a hiddenColumns plugin that cannot report (regression)', () => {
|
||||||
|
// getPlugin() hands back the instance even when the plugin is off, and its
|
||||||
|
// isHidden() then dereferences a map that was never created - seen in CI on
|
||||||
|
// grids with no hiddenColumns setting at all.
|
||||||
|
hot = new Handsontable(container, settings())
|
||||||
|
hot.render()
|
||||||
|
spyOn(hot, 'getPlugin').and.returnValue({
|
||||||
|
enabled: true,
|
||||||
|
isHidden: () => {
|
||||||
|
throw new TypeError(
|
||||||
|
"Cannot read properties of null (reading 'getValueAtIndex')"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
const table = collectGridTable(hot)
|
||||||
|
|
||||||
|
expect(table.headers).toEqual(['Delete?', 'ACCOUNT', 'BALANCE'])
|
||||||
|
expect(table.rows.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limits the export to a multi-cell selection', () => {
|
||||||
|
hot = new Handsontable(container, settings())
|
||||||
|
hot.render()
|
||||||
|
hot.selectCells([[1, 1, 2, 2]])
|
||||||
|
|
||||||
|
const range = getExportRange(hot)
|
||||||
|
const table = collectGridTable(hot)
|
||||||
|
|
||||||
|
expect(range).toEqual({ top: 1, left: 1, bottom: 2, right: 2 })
|
||||||
|
expect(table.headers).toEqual(['ACCOUNT', 'BALANCE'])
|
||||||
|
expect(table.rows).toEqual([
|
||||||
|
['A2', '20.25'],
|
||||||
|
['A3', null]
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exports nothing when the selection is inside the skipped columns', () => {
|
||||||
|
hot = new Handsontable(container, settings())
|
||||||
|
hot.render()
|
||||||
|
// Rows 0-2 of column 0 only: the Delete? column, which the caller skips.
|
||||||
|
hot.selectCells([[0, 0, 2, 0]])
|
||||||
|
|
||||||
|
const range = getExportRange(hot, 1)
|
||||||
|
|
||||||
|
expect(isRangeEmpty(range)).toBeTrue()
|
||||||
|
expect(collectGridTable(hot, 1).headers).toEqual([])
|
||||||
|
expect(buildGridPdfInput(hot, 'WORK.TEST', 1)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a single-cell cursor as "export everything"', () => {
|
||||||
|
hot = new Handsontable(container, settings())
|
||||||
|
hot.render()
|
||||||
|
hot.selectCell(1, 1)
|
||||||
|
|
||||||
|
expect(collectGridTable(hot).rows.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never starts inside the skipped housekeeping columns', () => {
|
||||||
|
hot = new Handsontable(container, settings())
|
||||||
|
hot.render()
|
||||||
|
// A selection that begins in the Delete? column still exports from column 1.
|
||||||
|
hot.selectCells([[0, 0, 2, 2]])
|
||||||
|
|
||||||
|
const range = getExportRange(hot, 1)
|
||||||
|
|
||||||
|
expect(range.left).toBe(1)
|
||||||
|
expect(collectGridTable(hot, 1).headers).toEqual(['ACCOUNT', 'BALANCE'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the skipped column out of CSV/Excel exports (regression)', async () => {
|
||||||
|
// The exportFile plugin is not enabled in these grids, so stand in for it
|
||||||
|
// and read the options the export passes through.
|
||||||
|
hot = new Handsontable(container, settings())
|
||||||
|
hot.render()
|
||||||
|
const downloadFile = jasmine.createSpy('downloadFile')
|
||||||
|
const getPlugin = hot.getPlugin.bind(hot)
|
||||||
|
spyOn(hot, 'getPlugin').and.callFake((name: string) =>
|
||||||
|
name === 'exportFile' ? ({ downloadFile } as any) : getPlugin(name as any)
|
||||||
|
)
|
||||||
|
|
||||||
|
await exportGrid(hot, 'csv', 'WORK.TEST', 1)
|
||||||
|
|
||||||
|
expect(downloadFile).toHaveBeenCalledTimes(1)
|
||||||
|
const opts = downloadFile.calls.mostRecent().args[1]
|
||||||
|
// Column 0 is the Delete? column; the export must not start at it, and the
|
||||||
|
// plugin must not be left to export the whole grid.
|
||||||
|
expect(opts['range']).toEqual([0, 1, 2, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops reading rows at the cap instead of collecting them all', () => {
|
||||||
|
hot = new Handsontable(container, settings())
|
||||||
|
hot.render()
|
||||||
|
const read = spyOn(hot, 'getDataAtCell').and.callThrough()
|
||||||
|
|
||||||
|
const table = collectGridTable(hot, 0, 2)
|
||||||
|
|
||||||
|
expect(table.rows.length).toBe(2)
|
||||||
|
expect(read).toHaveBeenCalledTimes(2 * 3) // 2 rows x 3 columns
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still produces a PDF for a grid with no rows', () => {
|
||||||
|
// The real grids declare their columns, so an empty grid still has a
|
||||||
|
// schema - which is the case this guards. Without `columns`, HOT reports
|
||||||
|
// zero columns for `data: []` and there would be no table to describe.
|
||||||
|
hot = new Handsontable(
|
||||||
|
container,
|
||||||
|
settings({
|
||||||
|
data: [],
|
||||||
|
columns: [{ data: 0 }, { data: 1 }, { data: 2 }]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
const input = buildGridPdfInput(hot, 'WORK.EMPTY')
|
||||||
|
|
||||||
|
expect(input).not.toBeNull()
|
||||||
|
expect(input!.headers).toEqual(['Delete?', 'ACCOUNT', 'BALANCE'])
|
||||||
|
expect(input!.rows).toEqual([])
|
||||||
|
expect(input!.subtitle).toBe('0 rows x 3 columns')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers PDF alongside CSV and Excel in the context menu', () => {
|
||||||
|
const item: any = buildExportMenuItem(() => 'WORK.TEST')
|
||||||
|
const keys = item.submenu.items.map((sub: any) => sub.key)
|
||||||
|
const names = item.submenu.items.map((sub: any) => sub.name)
|
||||||
|
|
||||||
|
expect(keys).toEqual([
|
||||||
|
'export_file:csv',
|
||||||
|
'export_file:xlsx',
|
||||||
|
'export_file:pdf'
|
||||||
|
])
|
||||||
|
expect(names).toContain('PDF (.pdf)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,193 @@
|
|||||||
import Handsontable from 'handsontable'
|
import Handsontable from 'handsontable'
|
||||||
import { MenuItemConfig } from 'handsontable/plugins/contextMenu'
|
import { MenuItemConfig } from 'handsontable/plugins/contextMenu'
|
||||||
|
import { downloadTablePdf, TablePdfInput } from './pdf-export.util'
|
||||||
|
|
||||||
export type HotExportFormat = 'csv' | 'xlsx'
|
export type HotExportFormat = 'csv' | 'xlsx' | 'pdf'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row cap for the PDF export. Unlike CSV/Excel, a PDF is laid out page by page
|
||||||
|
* in the browser, so an unbounded grid would freeze the tab and produce a
|
||||||
|
* document nobody reads; the export says so in its subtitle when it truncates.
|
||||||
|
*/
|
||||||
|
const MAX_PDF_ROWS = 5000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual (0-based, inclusive) block of the grid to export: honours the current
|
||||||
|
* selection when there is one, otherwise the whole grid. `left` never reaches
|
||||||
|
* into the leading housekeeping columns the caller asked to skip, and a
|
||||||
|
* selection that lies entirely inside those columns yields an empty range
|
||||||
|
* (`right < left`) rather than being shifted sideways into a column the user
|
||||||
|
* did not select.
|
||||||
|
*/
|
||||||
|
export interface GridRange {
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
bottom: number
|
||||||
|
right: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the range covers no column, so there is nothing to export. */
|
||||||
|
export function isRangeEmpty(range: GridRange): boolean {
|
||||||
|
return range.right < range.left
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Works out which block of cells an export should cover.
|
||||||
|
*
|
||||||
|
* HOT's own export item only honours a selection spanning more than one cell:
|
||||||
|
* a right-click places a single-cell cursor and the corner click is select-all
|
||||||
|
* (negative coordinates), and both of those mean "export the whole table".
|
||||||
|
*/
|
||||||
|
export function getExportRange(
|
||||||
|
hot: Handsontable,
|
||||||
|
skipLeadingCols = 0
|
||||||
|
): GridRange {
|
||||||
|
const sel = hot.getSelectedRangeLast()
|
||||||
|
const lastRow = hot.countRows() - 1
|
||||||
|
const lastCol = hot.countCols() - 1
|
||||||
|
|
||||||
|
let top = 0
|
||||||
|
let left = skipLeadingCols
|
||||||
|
let bottom = lastRow
|
||||||
|
let right = lastCol
|
||||||
|
|
||||||
|
// sel.from/to.row/col are typed nullable (CellCoords allows an unset state),
|
||||||
|
// but a range returned by getSelectedRangeLast() always has real coordinates.
|
||||||
|
const isCornerSelectAll = !!sel && sel.from.row! < 0 && sel.from.col! < 0
|
||||||
|
if (sel && !sel.isSingleCell() && !isCornerSelectAll) {
|
||||||
|
top = Math.max(0, Math.min(sel.from.row!, sel.to.row!))
|
||||||
|
bottom = Math.max(sel.from.row!, sel.to.row!)
|
||||||
|
const selectedLeft = Math.min(sel.from.col!, sel.to.col!)
|
||||||
|
const selectedRight = Math.max(sel.from.col!, sel.to.col!)
|
||||||
|
left = Math.max(skipLeadingCols, selectedLeft)
|
||||||
|
right = selectedRight
|
||||||
|
// Selecting only the skipped leading column(s) leaves nothing to export;
|
||||||
|
// `right` below `left` is the empty range, never a sideways shift.
|
||||||
|
if (right < left) right = left - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return { top, left, bottom, right }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Columns hidden in the grid (DQ rules in the viewer, filters elsewhere).
|
||||||
|
*
|
||||||
|
* `getPlugin()` returns the plugin instance even when it is not switched on, and
|
||||||
|
* `isHidden()` reads a map that only exists once the plugin has been enabled -
|
||||||
|
* so ask the enabled flag first, and treat any remaining failure as "visible"
|
||||||
|
* rather than losing the whole export.
|
||||||
|
*/
|
||||||
|
function isHiddenColumn(hot: Handsontable, visualColumn: number): boolean {
|
||||||
|
const plugin = hot.getPlugin('hiddenColumns') as any
|
||||||
|
if (
|
||||||
|
!plugin ||
|
||||||
|
plugin.enabled !== true ||
|
||||||
|
typeof plugin.isHidden !== 'function'
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return plugin.isHidden(visualColumn) === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridTable {
|
||||||
|
headers: string[]
|
||||||
|
rows: (string | number | null)[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the exportable slice of a grid into plain headers and rows, the same
|
||||||
|
* way the CSV export sees it: raw cell values (not rendered text), hidden
|
||||||
|
* columns dropped, leading housekeeping columns excluded.
|
||||||
|
*
|
||||||
|
* @param maxRows Stop after this many rows. The limit is applied to the range
|
||||||
|
* before any cell is read, so a capped export never pays for the rows it
|
||||||
|
* discards.
|
||||||
|
*/
|
||||||
|
export function collectGridTable(
|
||||||
|
hot: Handsontable,
|
||||||
|
skipLeadingCols = 0,
|
||||||
|
maxRows = Infinity
|
||||||
|
): GridTable {
|
||||||
|
const range = getExportRange(hot, skipLeadingCols)
|
||||||
|
const colHeaders = hot.getColHeader()
|
||||||
|
const headerArray = Array.isArray(colHeaders) ? (colHeaders as string[]) : []
|
||||||
|
|
||||||
|
const columns: number[] = []
|
||||||
|
for (let col = Math.max(0, range.left); col <= range.right; col++) {
|
||||||
|
if (!isHiddenColumn(hot, col)) columns.push(col)
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = columns.map((col) => {
|
||||||
|
const header = headerArray[col]
|
||||||
|
return header === null || header === undefined ? '' : String(header)
|
||||||
|
})
|
||||||
|
|
||||||
|
const firstRow = Math.max(0, range.top)
|
||||||
|
const lastRow = Math.min(range.bottom, firstRow + maxRows - 1)
|
||||||
|
const rows: (string | number | null)[][] = []
|
||||||
|
for (let row = firstRow; row <= lastRow; row++) {
|
||||||
|
rows.push(
|
||||||
|
columns.map(
|
||||||
|
(col) => hot.getDataAtCell(row, col) as string | number | null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headers, rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side PDF export of the grid's current view. Same "what you see"
|
||||||
|
* contract as the CSV/Excel export: the current selection if there is one,
|
||||||
|
* otherwise the whole grid, hidden columns excluded.
|
||||||
|
*/
|
||||||
|
export function exportGridPdf(
|
||||||
|
hot: Handsontable,
|
||||||
|
filename: string,
|
||||||
|
skipLeadingCols = 0
|
||||||
|
): void {
|
||||||
|
const input = buildGridPdfInput(hot, filename, skipLeadingCols)
|
||||||
|
// Without columns there is no table to describe; a grid with no rows still
|
||||||
|
// makes a page, with its headers and a "0 rows" subtitle, rather than the
|
||||||
|
// click doing nothing at all.
|
||||||
|
if (!input) return
|
||||||
|
downloadTablePdf(input, filename || 'export')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the PDF input for a grid, or `null` when the grid has no exportable
|
||||||
|
* columns. Split out from {@link exportGridPdf} so the "no rows" case can be
|
||||||
|
* asserted without touching the browser's download plumbing.
|
||||||
|
*/
|
||||||
|
export function buildGridPdfInput(
|
||||||
|
hot: Handsontable,
|
||||||
|
filename: string,
|
||||||
|
skipLeadingCols = 0
|
||||||
|
): TablePdfInput | null {
|
||||||
|
const range = getExportRange(hot, skipLeadingCols)
|
||||||
|
if (isRangeEmpty(range)) return null
|
||||||
|
const availableRows = Math.max(0, range.bottom - range.top + 1)
|
||||||
|
// The cap is applied inside collectGridTable so the discarded rows are never
|
||||||
|
// read; the subtitle still needs the real total.
|
||||||
|
const { headers, rows } = collectGridTable(hot, skipLeadingCols, MAX_PDF_ROWS)
|
||||||
|
if (!headers.length) return null
|
||||||
|
|
||||||
|
const truncated = availableRows > rows.length
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: filename || 'export',
|
||||||
|
subtitle:
|
||||||
|
`${rows.length} row${rows.length === 1 ? '' : 's'} x ` +
|
||||||
|
`${headers.length} column${headers.length === 1 ? '' : 's'}` +
|
||||||
|
(truncated ? ` (first ${rows.length} of ${availableRows} rows)` : ''),
|
||||||
|
headers,
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cell types whose values must be exported as raw stored strings rather than
|
* Cell types whose values must be exported as raw stored strings rather than
|
||||||
@@ -25,7 +211,7 @@ const RAW_EXPORT_TYPES = new Set([
|
|||||||
* from any server-side (SAS) download.
|
* from any server-side (SAS) download.
|
||||||
*
|
*
|
||||||
* @param hot Live Handsontable instance.
|
* @param hot Live Handsontable instance.
|
||||||
* @param format `'csv'` or `'xlsx'`.
|
* @param format `'csv'`, `'xlsx'` or `'pdf'`.
|
||||||
* @param filename File name (without extension).
|
* @param filename File name (without extension).
|
||||||
* @param skipLeadingCols Number of leading UI/housekeeping columns to exclude
|
* @param skipLeadingCols Number of leading UI/housekeeping columns to exclude
|
||||||
* (e.g. the editor's `Delete?` column at index 0).
|
* (e.g. the editor's `Delete?` column at index 0).
|
||||||
@@ -36,6 +222,11 @@ export async function exportGrid(
|
|||||||
filename: string,
|
filename: string,
|
||||||
skipLeadingCols = 0
|
skipLeadingCols = 0
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (format === 'pdf') {
|
||||||
|
exportGridPdf(hot, filename, skipLeadingCols)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const plugin = hot.getPlugin('exportFile')
|
const plugin = hot.getPlugin('exportFile')
|
||||||
|
|
||||||
const opts: Record<string, any> = {
|
const opts: Record<string, any> = {
|
||||||
@@ -45,26 +236,20 @@ export async function exportGrid(
|
|||||||
|
|
||||||
// Selection-aware, contiguous range (visual indexes). Hidden columns are
|
// Selection-aware, contiguous range (visual indexes). Hidden columns are
|
||||||
// excluded automatically (exportHiddenColumns defaults to false).
|
// excluded automatically (exportHiddenColumns defaults to false).
|
||||||
const sel = hot.getSelectedRangeLast()
|
const range = getExportRange(hot, skipLeadingCols)
|
||||||
const lastRow = hot.countRows() - 1
|
if (isRangeEmpty(range)) return
|
||||||
const lastCol = hot.countCols() - 1
|
// "Whole grid" means the export starts at visual column 0 - not at
|
||||||
|
// `skipLeadingCols`, or a caller that skips a leading housekeeping column
|
||||||
// Mirror HOT's own export item: only honor a selection that spans more than
|
// would look like a whole-grid export and the plugin would put that column
|
||||||
// one cell. A right-click places a single-cell cursor, and the corner click
|
// back in. Leaving `range` unset lets the plugin export the whole grid
|
||||||
// is select-all (negative coords) — both mean "export the whole table".
|
// exactly as its own menu item does; it is explicit whenever it is narrower.
|
||||||
// sel.from/to.row/col are typed nullable (CellCoords allows an unset state),
|
const isWholeGrid =
|
||||||
// but a range returned by getSelectedRangeLast() always has real coordinates.
|
range.top === 0 &&
|
||||||
const isCornerSelectAll = !!sel && sel.from.row! < 0 && sel.from.col! < 0
|
range.left === 0 &&
|
||||||
if (sel && !sel.isSingleCell() && !isCornerSelectAll) {
|
range.bottom === hot.countRows() - 1 &&
|
||||||
const top = Math.max(0, Math.min(sel.from.row!, sel.to.row!))
|
range.right === hot.countCols() - 1
|
||||||
const left = Math.max(0, Math.min(sel.from.col!, sel.to.col!))
|
if (!isWholeGrid) {
|
||||||
const bottom = Math.max(sel.from.row!, sel.to.row!)
|
opts['range'] = [range.top, range.left, range.bottom, range.right]
|
||||||
const right = Math.max(sel.from.col!, sel.to.col!)
|
|
||||||
opts['range'] = [top, Math.max(left, skipLeadingCols), bottom, right]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!opts['range'] && skipLeadingCols > 0) {
|
|
||||||
opts['range'] = [0, skipLeadingCols, lastRow, lastCol]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === 'csv') {
|
if (format === 'csv') {
|
||||||
@@ -126,7 +311,7 @@ export async function exportGrid(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a reusable `export_file` context-menu item (CSV / Excel submenu) that
|
* Builds a reusable `export_file` context-menu item (CSV / Excel / PDF submenu) that
|
||||||
* exports the given grid via {@link exportGrid}. Add it to any HOT context-menu
|
* exports the given grid via {@link exportGrid}. Add it to any HOT context-menu
|
||||||
* `items` map under the `export_file` key.
|
* `items` map under the `export_file` key.
|
||||||
*
|
*
|
||||||
@@ -166,6 +351,11 @@ export function buildExportMenuItem(
|
|||||||
key: 'export_file:xlsx',
|
key: 'export_file:xlsx',
|
||||||
name: 'Excel (.xlsx)',
|
name: 'Excel (.xlsx)',
|
||||||
callback: makeCb('xlsx')
|
callback: makeCb('xlsx')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'export_file:pdf',
|
||||||
|
name: 'PDF (.pdf)',
|
||||||
|
callback: makeCb('pdf')
|
||||||
}
|
}
|
||||||
] as any
|
] as any
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import {
|
||||||
|
createTablePdf,
|
||||||
|
downloadTablePdf,
|
||||||
|
TablePdfInput
|
||||||
|
} from './pdf-export.util'
|
||||||
|
|
||||||
|
/** PDF bytes are Latin-1 by construction, so decode them the same way. */
|
||||||
|
function decode(bytes: Uint8Array): string {
|
||||||
|
let text = ''
|
||||||
|
for (const byte of bytes) text += String.fromCharCode(byte)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(overrides: Partial<TablePdfInput> = {}): string {
|
||||||
|
return decode(
|
||||||
|
createTablePdf({
|
||||||
|
title: 'WORK.CUSTOMERS',
|
||||||
|
subtitle: '2 rows x 2 columns',
|
||||||
|
headers: ['ACCOUNT', 'BALANCE'],
|
||||||
|
rows: [
|
||||||
|
['A1', '1.25'],
|
||||||
|
['A2', '2.50']
|
||||||
|
],
|
||||||
|
generatedAt: new Date('2026-11-01T19:00:00Z'),
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `(literal) Tj` operands of a content stream, unescaped enough to measure. */
|
||||||
|
function literals(pdf: string): string[] {
|
||||||
|
return Array.from(pdf.matchAll(/\((.*?)\) Tj/g)).map((match) => match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('downloadTablePdf', () => {
|
||||||
|
it('releases the blob URL after the download has started, not during it', () => {
|
||||||
|
// Firefox and Safari read the blob asynchronously, so revoking in the same
|
||||||
|
// task as the click cancels the download.
|
||||||
|
jasmine.clock().install()
|
||||||
|
const click = spyOn(HTMLAnchorElement.prototype, 'click')
|
||||||
|
spyOn(URL, 'createObjectURL').and.returnValue('blob:test-url')
|
||||||
|
const revoke = spyOn(URL, 'revokeObjectURL')
|
||||||
|
|
||||||
|
downloadTablePdf({ title: 'T', headers: ['A'], rows: [['1']] }, 'export')
|
||||||
|
|
||||||
|
expect(click).toHaveBeenCalledTimes(1)
|
||||||
|
expect(revoke).not.toHaveBeenCalled()
|
||||||
|
jasmine.clock().tick(1000)
|
||||||
|
expect(revoke).toHaveBeenCalledWith('blob:test-url')
|
||||||
|
|
||||||
|
jasmine.clock().uninstall()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createTablePdf', () => {
|
||||||
|
it('produces a structurally valid PDF with a working xref', () => {
|
||||||
|
const pdf = build()
|
||||||
|
|
||||||
|
expect(pdf.startsWith('%PDF-1.4')).toBeTrue()
|
||||||
|
expect(pdf.trimEnd().endsWith('%%EOF')).toBeTrue()
|
||||||
|
|
||||||
|
const startxref = Number(pdf.match(/startxref\n(\d+)/)![1])
|
||||||
|
expect(pdf.slice(startxref, startxref + 4)).toBe('xref')
|
||||||
|
|
||||||
|
// Every object listed in the xref must actually start at the offset given.
|
||||||
|
const xref = pdf.slice(startxref).split('\n')
|
||||||
|
const entryCount = Number(xref[1].split(' ')[1])
|
||||||
|
for (let id = 1; id < entryCount; id++) {
|
||||||
|
const offset = Number(xref[2 + id].slice(0, 10))
|
||||||
|
expect(pdf.slice(offset, offset + `${id} 0 obj`.length))
|
||||||
|
.withContext(`object ${id}`)
|
||||||
|
.toBe(`${id} 0 obj`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('declares one /Type /Page per page and a matching /Count', () => {
|
||||||
|
const pdf = build()
|
||||||
|
const pageObjects = (pdf.match(/\/Type \/Page[^s]/g) || []).length
|
||||||
|
|
||||||
|
expect(pdf).toContain('/Count 1')
|
||||||
|
expect(pageObjects).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prints the title, subtitle and a generated stamp', () => {
|
||||||
|
const pdf = build()
|
||||||
|
|
||||||
|
expect(literals(pdf)).toContain('WORK.CUSTOMERS')
|
||||||
|
expect(literals(pdf)).toContain('2 rows x 2 columns')
|
||||||
|
expect(literals(pdf)).toContain('Generated 2026-11-01 19:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prints every cell value', () => {
|
||||||
|
const pdf = build()
|
||||||
|
// BALANCE is numeric, so its values are padded to the column's right edge.
|
||||||
|
const values = literals(pdf).map((literal) => literal.trim())
|
||||||
|
|
||||||
|
expect(values).toEqual(
|
||||||
|
jasmine.arrayContaining(['A1', 'A2', '1.25', '2.50'])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes the PDF string delimiters', () => {
|
||||||
|
const pdf = build({
|
||||||
|
headers: ['NOTE'],
|
||||||
|
rows: [['a (b) c \\ d']]
|
||||||
|
})
|
||||||
|
|
||||||
|
// The literal must carry the backslash escapes, not raw delimiters.
|
||||||
|
expect(pdf).toContain('(a \\(b\\) c \\\\ d) Tj')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps text to WinAnsi where it exists and falls back to ? elsewhere', () => {
|
||||||
|
const pdf = build({
|
||||||
|
headers: ['NAME', 'COST', 'OTHER'],
|
||||||
|
rows: [['M\u00fcller', '\u20ac5', '\u4f8b\u3048']]
|
||||||
|
})
|
||||||
|
const values = literals(pdf).join('|')
|
||||||
|
|
||||||
|
expect(values).toContain('M\\374ller') // u-umlaut: octal for cp1252 0xFC
|
||||||
|
expect(values).toContain('\\2005') // euro sign: cp1252 0x80
|
||||||
|
expect(values).toContain('??') // outside cp1252
|
||||||
|
})
|
||||||
|
|
||||||
|
it('right-aligns numeric columns and left-aligns the rest', () => {
|
||||||
|
const pdf = build({
|
||||||
|
headers: ['NAME', 'AMOUNT'],
|
||||||
|
rows: [
|
||||||
|
['x', '7.5'],
|
||||||
|
['long-name', '1234.25']
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const ops = literals(pdf)
|
||||||
|
|
||||||
|
// AMOUNT is 7 characters wide (its widest value), so 7.5 is padded to the
|
||||||
|
// right edge; NAME is left-aligned, so x is unpadded while the widest value
|
||||||
|
// fills the column.
|
||||||
|
expect(ops).toContain(' 7.5')
|
||||||
|
expect(ops).toContain('1234.25')
|
||||||
|
expect(ops).toContain('x')
|
||||||
|
expect(ops).toContain('long-name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps a value too long for its column instead of overflowing it', () => {
|
||||||
|
const word = 'X'.repeat(140)
|
||||||
|
const pdf = build({ headers: ['ONECOL'], rows: [[word]] })
|
||||||
|
const lines = literals(pdf).filter((line) => /^X+$/.test(line))
|
||||||
|
|
||||||
|
expect(lines.length).toBeGreaterThan(1)
|
||||||
|
expect(lines.join('')).toBe(word)
|
||||||
|
for (const line of lines) expect(line.length).toBeLessThanOrEqual(48)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a multi-line value on separate lines', () => {
|
||||||
|
const pdf = build({ headers: ['ADDR'], rows: [['LINE1', 'LINE2']] })
|
||||||
|
const pdfMulti = build({ headers: ['ADDR'], rows: [['LINE1\nLINE2']] })
|
||||||
|
|
||||||
|
expect(literals(pdfMulti)).toEqual(
|
||||||
|
jasmine.arrayContaining(['LINE1', 'LINE2'])
|
||||||
|
)
|
||||||
|
expect(literals(pdfMulti)).not.toEqual(literals(pdf))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('paginates long tables and repeats the column headers on every page', () => {
|
||||||
|
const rows = Array.from({ length: 400 }, (_, index) => [
|
||||||
|
`row${index}`,
|
||||||
|
index
|
||||||
|
])
|
||||||
|
const pdf = build({ headers: ['NAME', 'ID'], rows })
|
||||||
|
|
||||||
|
const pageCount = Number(pdf.match(/\/Count (\d+)/)![1])
|
||||||
|
expect(pageCount).toBeGreaterThan(1)
|
||||||
|
|
||||||
|
const headerAppearances = literals(pdf).filter(
|
||||||
|
(op) => op.trim() === 'NAME' || op.trim() === 'ID'
|
||||||
|
)
|
||||||
|
expect(headerAppearances.length).toBe(pageCount * 2)
|
||||||
|
|
||||||
|
// Footers are numbered from 1 to the total page count.
|
||||||
|
expect(literals(pdf)).toContain(`Page 1 of ${pageCount}`)
|
||||||
|
expect(literals(pdf)).toContain(`Page ${pageCount} of ${pageCount}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shrinks the body font rather than truncating headers on a 20-column table', () => {
|
||||||
|
const columnCount = 20
|
||||||
|
const headers = Array.from(
|
||||||
|
{ length: columnCount },
|
||||||
|
(_, index) => `COLUMN_${String(index + 1).padStart(2, '0')}`
|
||||||
|
)
|
||||||
|
const rows = [
|
||||||
|
Array.from({ length: columnCount }, (_, index) => `value ${index}`)
|
||||||
|
]
|
||||||
|
|
||||||
|
const pdf = build({ headers, rows })
|
||||||
|
const sizes = Array.from(pdf.matchAll(/\/F1 ([\d.]+) Tf/g)).map((m) =>
|
||||||
|
Number(m[1])
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every header survives intact, and the page is won back with font size.
|
||||||
|
expect(literals(pdf)).toContain('COLUMN_01')
|
||||||
|
expect(literals(pdf)).toContain('COLUMN_20')
|
||||||
|
expect(Math.min(...sizes)).toBeLessThan(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never places text outside the printable area', () => {
|
||||||
|
const headers = Array.from({ length: 60 }, (_, index) => `COL_${index}`)
|
||||||
|
const rows = [Array.from({ length: 60 }, (_, index) => `v${index}`)]
|
||||||
|
const pdf = build({ headers, rows })
|
||||||
|
|
||||||
|
const starts = Array.from(pdf.matchAll(/([\d.]+) [\d.-]+ Td/g)).map((m) =>
|
||||||
|
Number(m[1])
|
||||||
|
)
|
||||||
|
// 842 - 24 margin, plus a point of rounding slack.
|
||||||
|
expect(Math.max(...starts)).toBeLessThanOrEqual(819)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says so when the columns cannot fit even at the smallest font', () => {
|
||||||
|
const columnCount = 300
|
||||||
|
const headers = Array.from(
|
||||||
|
{ length: columnCount },
|
||||||
|
(_, index) => `HEADER_${index}`
|
||||||
|
)
|
||||||
|
const rows = [
|
||||||
|
Array.from({ length: columnCount }, (_, index) => `v${index}`)
|
||||||
|
]
|
||||||
|
const pdf = build({ headers, rows })
|
||||||
|
const note = literals(pdf).find((literal) =>
|
||||||
|
/^Showing the first \d+ of 300 columns$/.test(literal)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(note).toBeTruthy()
|
||||||
|
expect(literals(pdf)).not.toContain('HEADER_299')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps a row taller than the page and marks the omission', () => {
|
||||||
|
// 20k characters at a 48-character column is ~420 wrapped lines, far more
|
||||||
|
// than a page holds; the tail must be marked, not drawn off the page.
|
||||||
|
const value = Array.from({ length: 4000 }, (_, i) => `w${i}`).join(' ')
|
||||||
|
const pdf = build({ headers: ['LONG'], rows: [[value]] })
|
||||||
|
const lines = literals(pdf)
|
||||||
|
|
||||||
|
expect(lines.some((line) => line.trimEnd().endsWith('...'))).toBeTrue()
|
||||||
|
// The clamped row must fit the page it starts on, not spill onto its own.
|
||||||
|
expect(pdf).toContain('/Count 1')
|
||||||
|
|
||||||
|
// Nothing is drawn below the bottom margin (24pt) or above the page.
|
||||||
|
const tops = Array.from(pdf.matchAll(/[\d.]+ ([\d.-]+) Td/g)).map((m) =>
|
||||||
|
Number(m[1])
|
||||||
|
)
|
||||||
|
expect(Math.min(...tops)).toBeGreaterThanOrEqual(24)
|
||||||
|
expect(Math.max(...tops)).toBeLessThanOrEqual(595 - 24)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders an empty table as a single valid page', () => {
|
||||||
|
const pdf = build({ headers: ['ONLY'], rows: [] })
|
||||||
|
|
||||||
|
expect(pdf).toContain('/Count 1')
|
||||||
|
expect(literals(pdf)).toContain('ONLY')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
/**
|
||||||
|
* Zero-dependency PDF writer for tabular data (the VIEW/EDIT grid export).
|
||||||
|
*
|
||||||
|
* Implemented directly against the PDF 1.4 spec rather than pulling in a PDF
|
||||||
|
* library: the output is a plain text table, which needs no font embedding,
|
||||||
|
* no images and no compression. The base-14 Courier font is used because its
|
||||||
|
* glyphs are exactly 600/1000 em wide, so column widths are arithmetic on
|
||||||
|
* character counts instead of a font-metrics table, and the file stays a few
|
||||||
|
* kilobytes per page.
|
||||||
|
*
|
||||||
|
* Scope and limits (deliberate):
|
||||||
|
* - Text is encoded as WinAnsi (cp1252), the encoding the base-14 fonts cover
|
||||||
|
* without embedding. Western European characters round-trip; characters
|
||||||
|
* outside cp1252 are written as `?`.
|
||||||
|
* - Fonts are not embedded and there is no styling beyond a bold header band
|
||||||
|
* and row rules, so this is a "what you see" data dump, not a branded
|
||||||
|
* report. A typeset report belongs on the server (ODS), where SAS formats
|
||||||
|
* and DC metadata are available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Glyph advance of base-14 Courier, as a fraction of the font size. */
|
||||||
|
const COURIER_WIDTH = 0.6
|
||||||
|
const HEADING_SIZE = 10
|
||||||
|
const MARGIN = 24
|
||||||
|
/** A4 landscape, in points. */
|
||||||
|
const PAGE_WIDTH = 842
|
||||||
|
const PAGE_HEIGHT = 595
|
||||||
|
const MIN_COL_CHARS = 5
|
||||||
|
const MAX_COL_CHARS = 48
|
||||||
|
/** Blank characters kept between columns so values never touch. */
|
||||||
|
const GUTTER_CHARS = 2
|
||||||
|
const FOOTER_HEIGHT = 16
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body font sizes tried in turn when the natural column widths do not fit the
|
||||||
|
* page. The table keeps its column content (and its headers) intact by going
|
||||||
|
* smaller first; 5pt is the floor, below which the text stops being readable.
|
||||||
|
*/
|
||||||
|
const BODY_FONT_SIZES = [7, 6.5, 6, 5.5, 5]
|
||||||
|
/**
|
||||||
|
* Narrowest column once scaling is unavoidable at the smallest font size. Below
|
||||||
|
* this a header is a fragment and a value is choppped across lines, so the
|
||||||
|
* export drops trailing columns instead (and says so on the page) rather than
|
||||||
|
* squeezing them into unreadable slivers.
|
||||||
|
*/
|
||||||
|
const MIN_SCALED_COL_CHARS = 4
|
||||||
|
/** Gutter used once scaling is unavoidable, to buy back space per column. */
|
||||||
|
const MIN_GUTTER_CHARS = 1
|
||||||
|
/** Appended to a cell whose value is longer than one page can show. */
|
||||||
|
const TRUNCATION_MARKER = ' ...'
|
||||||
|
/** How long the blob URL is kept alive after a click, for slow downloaders. */
|
||||||
|
const REVOKE_DELAY_MS = 1000
|
||||||
|
|
||||||
|
export interface TablePdfInput {
|
||||||
|
/** Heading shown top-left on page 1, and in the "(continued)" marker after. */
|
||||||
|
title: string
|
||||||
|
/** Optional line under the heading, e.g. row/column counts. */
|
||||||
|
subtitle?: string
|
||||||
|
headers: string[]
|
||||||
|
rows: ReadonlyArray<ReadonlyArray<string | number | null | undefined>>
|
||||||
|
/** Override for the "Generated ..." stamp; defaults to now. */
|
||||||
|
generatedAt?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cp1252 code points that differ from Latin-1, which PDF needs as its own byte. */
|
||||||
|
const WINANSI_EXTRA: Record<number, number> = {
|
||||||
|
0x20ac: 0x80,
|
||||||
|
0x201a: 0x82,
|
||||||
|
0x0192: 0x83,
|
||||||
|
0x201e: 0x84,
|
||||||
|
0x2026: 0x85,
|
||||||
|
0x2020: 0x86,
|
||||||
|
0x2021: 0x87,
|
||||||
|
0x02c6: 0x88,
|
||||||
|
0x2030: 0x89,
|
||||||
|
0x0160: 0x8a,
|
||||||
|
0x2039: 0x8b,
|
||||||
|
0x0152: 0x8c,
|
||||||
|
0x017d: 0x8e,
|
||||||
|
0x2018: 0x91,
|
||||||
|
0x2019: 0x92,
|
||||||
|
0x201c: 0x93,
|
||||||
|
0x201d: 0x94,
|
||||||
|
0x2022: 0x95,
|
||||||
|
0x2013: 0x96,
|
||||||
|
0x2014: 0x97,
|
||||||
|
0x02dc: 0x98,
|
||||||
|
0x2122: 0x99,
|
||||||
|
0x0161: 0x9a,
|
||||||
|
0x203a: 0x9b,
|
||||||
|
0x0153: 0x9c,
|
||||||
|
0x017e: 0x9e,
|
||||||
|
0x0178: 0x9f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell values as printable text: nulls become empty, CR/LF is kept as a line
|
||||||
|
* break (so multi-line values stay multi-line), tabs become spaces and other
|
||||||
|
* control characters are dropped since they have no glyph.
|
||||||
|
*/
|
||||||
|
function sanitise(value: string | number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) return ''
|
||||||
|
return String(value)
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.replace(/\t/g, ' ')
|
||||||
|
.replace(/[\u0000-\u0009\u000b-\u001f]/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape text for a PDF literal string: `(`, `)` and `\` are the delimiters,
|
||||||
|
* and non-printable / non-ASCII bytes are written as three-digit octal of
|
||||||
|
* their WinAnsi code. Characters outside cp1252 become `?`.
|
||||||
|
*/
|
||||||
|
function escapeText(text: string): string {
|
||||||
|
let out = ''
|
||||||
|
for (const char of text) {
|
||||||
|
const code = char.codePointAt(0) as number
|
||||||
|
let byte: number
|
||||||
|
if (code >= 0x20 && code <= 0x7e) byte = code
|
||||||
|
else if (WINANSI_EXTRA[code] !== undefined) byte = WINANSI_EXTRA[code]
|
||||||
|
else if (code >= 0xa0 && code <= 0xff) byte = code
|
||||||
|
else byte = 0x3f // '?'
|
||||||
|
|
||||||
|
if (byte === 0x28 || byte === 0x29 || byte === 0x5c) {
|
||||||
|
out += '\\' + String.fromCharCode(byte)
|
||||||
|
} else if (byte < 0x20 || byte > 0x7e) {
|
||||||
|
out += '\\' + byte.toString(8).padStart(3, '0')
|
||||||
|
} else {
|
||||||
|
out += String.fromCharCode(byte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function textOp(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
size: number,
|
||||||
|
text: string,
|
||||||
|
font = 'F1'
|
||||||
|
): string {
|
||||||
|
return `BT /${font} ${size} Tf ${x.toFixed(2)} ${y.toFixed(2)} Td (${escapeText(text)}) Tj ET\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Longest line of a value; multi-line values are measured line by line. */
|
||||||
|
function longestLine(text: string): number {
|
||||||
|
return Math.max(...text.split('\n').map((part) => part.length), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Word wrap to `width` characters. Words longer than the column are hard-broken
|
||||||
|
* rather than allowed to overflow into the neighbouring column.
|
||||||
|
*/
|
||||||
|
function wrap(text: string, width: number): string[] {
|
||||||
|
const lines: string[] = []
|
||||||
|
for (const paragraph of text.split('\n')) {
|
||||||
|
let line = ''
|
||||||
|
for (const word of paragraph.split(' ')) {
|
||||||
|
if (line === '') {
|
||||||
|
line = word
|
||||||
|
} else if (line.length + 1 + word.length <= width) {
|
||||||
|
line += ' ' + word
|
||||||
|
} else {
|
||||||
|
lines.push(line)
|
||||||
|
line = word
|
||||||
|
}
|
||||||
|
while (line.length > width) {
|
||||||
|
lines.push(line.slice(0, width))
|
||||||
|
line = line.slice(width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(line)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A column is right-aligned when every non-missing value reads as a number. */
|
||||||
|
function isNumericColumn(rows: TablePdfInput['rows'], index: number): boolean {
|
||||||
|
let seen = 0
|
||||||
|
for (const row of rows) {
|
||||||
|
const value = row[index]
|
||||||
|
if (value === null || value === undefined || value === '') continue
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
seen++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!/^-?\d+(\.\d+)?$/.test(String(value))) return false
|
||||||
|
seen++
|
||||||
|
}
|
||||||
|
return seen > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Characters that fit across the page at a given font size. */
|
||||||
|
function usableChars(fontSize: number): number {
|
||||||
|
return Math.floor((PAGE_WIDTH - MARGIN * 2) / (fontSize * COURIER_WIDTH))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Characters a table occupies, gutters included. */
|
||||||
|
function tableChars(widths: number[], gutter: number): number {
|
||||||
|
return (
|
||||||
|
widths.reduce((total, width) => total + width, 0) + gutter * widths.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Layout {
|
||||||
|
fontSize: number
|
||||||
|
lineHeight: number
|
||||||
|
charWidth: number
|
||||||
|
/** Column content widths in characters. */
|
||||||
|
widths: number[]
|
||||||
|
gutter: number
|
||||||
|
/** Columns actually exported; below `headers.length` only for absurd widths. */
|
||||||
|
keptColumns: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fits the table across the page.
|
||||||
|
*
|
||||||
|
* The content widths are natural first: as wide as the widest value, bounded.
|
||||||
|
* When those do not fit, the body font shrinks through {@link BODY_FONT_SIZES}
|
||||||
|
* rather than the columns being squeezed, which keeps full headers and values
|
||||||
|
* readable on wide tables. Only at the smallest size are the columns scaled
|
||||||
|
* down (and the gutter narrowed), and columns stop shrinking at
|
||||||
|
* {@link MIN_SCALED_COL_CHARS}: past that, trailing columns are dropped and the
|
||||||
|
* page says how many were kept, so the omission is never silent.
|
||||||
|
*/
|
||||||
|
function fitLayout(input: TablePdfInput): Layout {
|
||||||
|
const naturalWidths = input.headers.map((header, index) => {
|
||||||
|
let widest = longestLine(sanitise(header))
|
||||||
|
for (const row of input.rows) {
|
||||||
|
widest = Math.max(widest, longestLine(sanitise(row[index])))
|
||||||
|
}
|
||||||
|
return Math.min(MAX_COL_CHARS, Math.max(MIN_COL_CHARS, widest))
|
||||||
|
})
|
||||||
|
|
||||||
|
const naturalTotal = tableChars(naturalWidths, GUTTER_CHARS)
|
||||||
|
for (const fontSize of BODY_FONT_SIZES) {
|
||||||
|
if (naturalTotal <= usableChars(fontSize)) {
|
||||||
|
return {
|
||||||
|
fontSize,
|
||||||
|
lineHeight: fontSize * 1.35,
|
||||||
|
charWidth: fontSize * COURIER_WIDTH,
|
||||||
|
widths: naturalWidths,
|
||||||
|
gutter: GUTTER_CHARS,
|
||||||
|
keptColumns: naturalWidths.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smallest font, so the only remaining lever is the column widths themselves.
|
||||||
|
const fontSize = BODY_FONT_SIZES[BODY_FONT_SIZES.length - 1]
|
||||||
|
const available = usableChars(fontSize)
|
||||||
|
const gutter = MIN_GUTTER_CHARS
|
||||||
|
const scaleToFit = (count: number): number[] => {
|
||||||
|
const natural = naturalWidths.slice(0, count)
|
||||||
|
const scale =
|
||||||
|
(available - gutter * count) / natural.reduce((a, b) => a + b, 0)
|
||||||
|
return natural.map((width) =>
|
||||||
|
Math.max(MIN_SCALED_COL_CHARS, Math.floor(width * scale))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let keptColumns = naturalWidths.length
|
||||||
|
let widths = scaleToFit(keptColumns)
|
||||||
|
while (keptColumns > 1 && tableChars(widths, gutter) > available) {
|
||||||
|
keptColumns--
|
||||||
|
widths = scaleToFit(keptColumns)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fontSize,
|
||||||
|
lineHeight: fontSize * 1.35,
|
||||||
|
charWidth: fontSize * COURIER_WIDTH,
|
||||||
|
widths,
|
||||||
|
gutter,
|
||||||
|
keptColumns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PagePlan {
|
||||||
|
/** Content stream operators for the page. */
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginate(input: TablePdfInput): PagePlan[] {
|
||||||
|
const layout = fitLayout(input)
|
||||||
|
const { fontSize, lineHeight, charWidth, widths, gutter, keptColumns } =
|
||||||
|
layout
|
||||||
|
// Columns beyond the page are dropped rather than drawn off it; the first
|
||||||
|
// page says so, so the document never silently omits data.
|
||||||
|
const headers = input.headers.slice(0, keptColumns)
|
||||||
|
const rows = input.rows.map((row) => row.slice(0, keptColumns))
|
||||||
|
const droppedColumns = input.headers.length - keptColumns
|
||||||
|
const numeric = headers.map((_, index) => isNumericColumn(rows, index))
|
||||||
|
const tableWidth = tableChars(widths, gutter) * charWidth
|
||||||
|
|
||||||
|
const columnsX: number[] = []
|
||||||
|
let cursor = MARGIN
|
||||||
|
for (const width of widths) {
|
||||||
|
columnsX.push(cursor)
|
||||||
|
cursor += (width + gutter) * charWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
const stamp = (input.generatedAt ?? new Date())
|
||||||
|
.toISOString()
|
||||||
|
.replace('T', ' ')
|
||||||
|
.slice(0, 16)
|
||||||
|
|
||||||
|
const headerLines = headers.map((header, index) =>
|
||||||
|
wrap(sanitise(header), widths[index])
|
||||||
|
)
|
||||||
|
const headerLineCount = Math.max(
|
||||||
|
...headerLines.map((lines) => lines.length),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
|
||||||
|
const pages: PagePlan[] = []
|
||||||
|
let ops = ''
|
||||||
|
let y = 0
|
||||||
|
|
||||||
|
const drawHeaderRow = () => {
|
||||||
|
const bandHeight = headerLineCount * lineHeight
|
||||||
|
ops +=
|
||||||
|
`q 0.93 g ${MARGIN.toFixed(2)} ${(y - bandHeight + lineHeight).toFixed(2)} ` +
|
||||||
|
`${tableWidth.toFixed(2)} ${bandHeight.toFixed(2)} re f Q\n`
|
||||||
|
for (let line = 0; line < headerLineCount; line++) {
|
||||||
|
for (let column = 0; column < headers.length; column++) {
|
||||||
|
const text = headerLines[column][line]
|
||||||
|
if (!text) continue
|
||||||
|
const label = numeric[column] ? text.padStart(widths[column]) : text
|
||||||
|
ops += textOp(columnsX[column], y, fontSize, label, 'F2')
|
||||||
|
}
|
||||||
|
y -= lineHeight
|
||||||
|
}
|
||||||
|
y -= 3
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPage = (first: boolean) => {
|
||||||
|
ops = ''
|
||||||
|
y = PAGE_HEIGHT - MARGIN
|
||||||
|
if (first) {
|
||||||
|
ops += textOp(MARGIN, y, HEADING_SIZE, input.title, 'F2')
|
||||||
|
if (input.subtitle)
|
||||||
|
ops += textOp(MARGIN, y - 13, fontSize, input.subtitle)
|
||||||
|
ops += textOp(
|
||||||
|
MARGIN + tableWidth - 26 * charWidth,
|
||||||
|
y - 13,
|
||||||
|
fontSize,
|
||||||
|
`Generated ${stamp}`
|
||||||
|
)
|
||||||
|
if (droppedColumns > 0) {
|
||||||
|
ops += textOp(
|
||||||
|
MARGIN,
|
||||||
|
y - 13 - lineHeight,
|
||||||
|
fontSize,
|
||||||
|
`Showing the first ${keptColumns} of ${input.headers.length} columns`
|
||||||
|
)
|
||||||
|
y -= fontSize + 4
|
||||||
|
}
|
||||||
|
y -= HEADING_SIZE + 20
|
||||||
|
} else {
|
||||||
|
ops += textOp(MARGIN, y, fontSize, `${input.title} (continued)`, 'F2')
|
||||||
|
y -= lineHeight + 4
|
||||||
|
}
|
||||||
|
drawHeaderRow()
|
||||||
|
}
|
||||||
|
|
||||||
|
const bottom = MARGIN + FOOTER_HEIGHT
|
||||||
|
// A single cell can hold thousands of characters, and MAX_COL_CHARS only
|
||||||
|
// bounds the column: at this font one value can wrap to more lines than a
|
||||||
|
// page has. Such a row is clamped to the tallest row that fits, with the
|
||||||
|
// marker on its last line, rather than being drawn through the footer and
|
||||||
|
// losing its tail without a trace.
|
||||||
|
const pageOneOverhead =
|
||||||
|
HEADING_SIZE +
|
||||||
|
20 +
|
||||||
|
(droppedColumns > 0 ? fontSize + 4 : 0) +
|
||||||
|
headerLineCount * lineHeight +
|
||||||
|
3
|
||||||
|
// Both margins, the footer and the page-1 header block all come off the
|
||||||
|
// height a single row may occupy, or an exactly-fitting row would be pushed
|
||||||
|
// onto a page of its own and leave the first page with only its headers.
|
||||||
|
const maxRowLines = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
(PAGE_HEIGHT - MARGIN * 2 - FOOTER_HEIGHT - pageOneOverhead) / lineHeight
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
startPage(true)
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
let cells = widths.map((width, index) => wrap(sanitise(row[index]), width))
|
||||||
|
if (cells.some((lines) => lines.length > maxRowLines)) {
|
||||||
|
cells = cells.map((lines) => {
|
||||||
|
if (lines.length <= maxRowLines) return lines
|
||||||
|
const kept = lines.slice(0, maxRowLines)
|
||||||
|
const last = kept[kept.length - 1]
|
||||||
|
kept[kept.length - 1] =
|
||||||
|
last.length > TRUNCATION_MARKER.length
|
||||||
|
? last.slice(0, last.length - TRUNCATION_MARKER.length) +
|
||||||
|
TRUNCATION_MARKER
|
||||||
|
: TRUNCATION_MARKER.trim()
|
||||||
|
return kept
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const lineCount = Math.max(...cells.map((lines) => lines.length), 1)
|
||||||
|
if (y - lineCount * lineHeight < bottom) {
|
||||||
|
pages.push({ content: ops })
|
||||||
|
startPage(false)
|
||||||
|
}
|
||||||
|
for (let line = 0; line < lineCount; line++) {
|
||||||
|
for (let column = 0; column < cells.length; column++) {
|
||||||
|
const text = cells[column][line]
|
||||||
|
if (!text) continue
|
||||||
|
const padded = numeric[column] ? text.padStart(widths[column]) : text
|
||||||
|
ops += textOp(columnsX[column], y, fontSize, padded)
|
||||||
|
}
|
||||||
|
y -= lineHeight
|
||||||
|
}
|
||||||
|
y -= 1.5
|
||||||
|
ops +=
|
||||||
|
`0.88 G 0.4 w ${MARGIN.toFixed(2)} ${y.toFixed(2)} m ` +
|
||||||
|
`${(MARGIN + tableWidth).toFixed(2)} ${y.toFixed(2)} l S\n`
|
||||||
|
}
|
||||||
|
pages.push({ content: ops })
|
||||||
|
|
||||||
|
// Footers last, once the total page count is known.
|
||||||
|
return pages.map((page, index) => {
|
||||||
|
const footer = `Page ${index + 1} of ${pages.length}`
|
||||||
|
const x = MARGIN + tableWidth - footer.length * charWidth
|
||||||
|
return { content: page.content + textOp(x, MARGIN, fontSize, footer) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the PDF bytes for a table. Returns a `Uint8Array` rather than a Blob so
|
||||||
|
* the output can be asserted in unit tests (Blob is the only browser-specific
|
||||||
|
* part, and it lives in {@link downloadTablePdf}).
|
||||||
|
*/
|
||||||
|
export function createTablePdf(input: TablePdfInput): Uint8Array<ArrayBuffer> {
|
||||||
|
const pages = paginate(input)
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let length = 0
|
||||||
|
|
||||||
|
const write = (text: string) => {
|
||||||
|
const bytes = new Uint8Array(text.length)
|
||||||
|
for (let index = 0; index < text.length; index++) {
|
||||||
|
bytes[index] = text.charCodeAt(index) & 0xff
|
||||||
|
}
|
||||||
|
chunks.push(bytes)
|
||||||
|
length += bytes.length
|
||||||
|
}
|
||||||
|
|
||||||
|
write('%PDF-1.4\n%\u00e2\u00e3\u00cf\u00d3\n')
|
||||||
|
|
||||||
|
// Object numbering: 1 catalog, 2 page tree, 3 and 4 the two fonts, then a
|
||||||
|
// page object + content stream pair per page.
|
||||||
|
const offsets: number[] = []
|
||||||
|
const markObject = (id: number) => {
|
||||||
|
offsets[id] = length
|
||||||
|
}
|
||||||
|
|
||||||
|
markObject(1)
|
||||||
|
write('1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n')
|
||||||
|
markObject(2)
|
||||||
|
const kids = pages.map((_, index) => `${5 + index * 2} 0 R`).join(' ')
|
||||||
|
write(
|
||||||
|
`2 0 obj\n<< /Type /Pages /Count ${pages.length} /Kids [${kids}] >>\nendobj\n`
|
||||||
|
)
|
||||||
|
markObject(3)
|
||||||
|
write(
|
||||||
|
'3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>\nendobj\n'
|
||||||
|
)
|
||||||
|
markObject(4)
|
||||||
|
write(
|
||||||
|
'4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>\nendobj\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
pages.forEach((page, index) => {
|
||||||
|
const pageId = 5 + index * 2
|
||||||
|
const contentId = pageId + 1
|
||||||
|
markObject(pageId)
|
||||||
|
write(
|
||||||
|
`${pageId} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] ` +
|
||||||
|
`/Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${contentId} 0 R >>\nendobj\n`
|
||||||
|
)
|
||||||
|
markObject(contentId)
|
||||||
|
write(`${contentId} 0 obj\n<< /Length ${page.content.length} >>\nstream\n`)
|
||||||
|
write(page.content)
|
||||||
|
write('endstream\nendobj\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
const lastObjectId = 4 + pages.length * 2
|
||||||
|
const xrefOffset = length
|
||||||
|
let xref = `xref\n0 ${lastObjectId + 1}\n0000000000 65535 f \n`
|
||||||
|
for (let id = 1; id <= lastObjectId; id++) {
|
||||||
|
xref += String(offsets[id]).padStart(10, '0') + ' 00000 n \n'
|
||||||
|
}
|
||||||
|
write(xref)
|
||||||
|
write(
|
||||||
|
`trailer\n<< /Size ${lastObjectId + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`
|
||||||
|
)
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(length)
|
||||||
|
let cursor = 0
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, cursor)
|
||||||
|
cursor += chunk.length
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the PDF and hands it to the browser as a download, mirroring what the
|
||||||
|
* CSV/Excel export does for the same grid. No new tab and no print dialog, so
|
||||||
|
* it behaves the same when DC is embedded in an iframe.
|
||||||
|
*/
|
||||||
|
export function downloadTablePdf(input: TablePdfInput, filename: string): void {
|
||||||
|
const blob = new Blob([createTablePdf(input)], { type: 'application/pdf' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `${filename || 'export'}.pdf`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
// The click only starts the download; Firefox and Safari read the blob
|
||||||
|
// asynchronously, so revoking in the same task can cancel it and the user
|
||||||
|
// gets nothing. Release the URL on a later turn instead - the blob is a few
|
||||||
|
// hundred kilobytes, so holding it briefly costs nothing.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS)
|
||||||
|
}
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
EventEmitter,
|
|
||||||
Input,
|
|
||||||
OnInit,
|
|
||||||
Output,
|
|
||||||
ViewEncapsulation
|
|
||||||
} from '@angular/core'
|
|
||||||
import { SASjsRequest } from '@sasjs/adapter'
|
|
||||||
import moment from 'moment'
|
|
||||||
import { HelperService } from 'src/app/services/helper.service'
|
|
||||||
import { LoggerService } from '../../services/logger.service'
|
|
||||||
import { SasService } from '../../services/sas.service'
|
|
||||||
import { ServerType } from '@sasjs/utils/types/serverType'
|
|
||||||
interface SASjsRequestExtended extends SASjsRequest {
|
|
||||||
parsedTimestamp?: string
|
|
||||||
logErrors?: string[]
|
|
||||||
logWarnings?: string[]
|
|
||||||
selectedTable?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-requests-modal',
|
|
||||||
templateUrl: './requests-modal.component.html',
|
|
||||||
styleUrls: ['./requests-modal.component.scss'],
|
|
||||||
encapsulation: ViewEncapsulation.None,
|
|
||||||
standalone: false
|
|
||||||
})
|
|
||||||
export class RequestsModalComponent implements OnInit {
|
|
||||||
private _opened: boolean = false
|
|
||||||
get opened(): boolean {
|
|
||||||
return this._opened
|
|
||||||
}
|
|
||||||
@Input()
|
|
||||||
set opened(value: boolean) {
|
|
||||||
this._opened = value
|
|
||||||
if (value) this.modalOpened()
|
|
||||||
|
|
||||||
this.loggerService.log(this.sasjsRequests)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Output() openedChange = new EventEmitter()
|
|
||||||
|
|
||||||
public sasLogActive: boolean = true
|
|
||||||
public sasSourceCodeActive: boolean = false
|
|
||||||
public sasGeneratedCodeActive: boolean = false
|
|
||||||
public tablesActive: boolean = false
|
|
||||||
|
|
||||||
public sasjsConfig = this.sasService.getSasjsConfig()
|
|
||||||
public serverType: string
|
|
||||||
public sasjsRequests: SASjsRequestExtended[] = []
|
|
||||||
public workTables: any
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private sasService: SasService,
|
|
||||||
private loggerService: LoggerService,
|
|
||||||
private helperService: HelperService
|
|
||||||
) {
|
|
||||||
this.sasjsConfig = this.sasService.getSasjsConfig()
|
|
||||||
this.serverType = this.sasService.getServerType()
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {}
|
|
||||||
|
|
||||||
public parseLogTimestamp(timestamp: any) {
|
|
||||||
return `${this.formatTimestamp(timestamp)} ${this.timestampFromNow(
|
|
||||||
timestamp
|
|
||||||
)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
public cutAppLoc(link: string) {
|
|
||||||
return link.replace(this.sasjsConfig.appLoc + '/', '')
|
|
||||||
}
|
|
||||||
|
|
||||||
public formatTimestamp(timestamp: any) {
|
|
||||||
return moment(timestamp).format()
|
|
||||||
? moment(timestamp).format('dddd, MMMM Do YYYY, h:mm:ss a')
|
|
||||||
: timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
public timestampFromNow(timestamp: any) {
|
|
||||||
return moment(timestamp).format() ? ` (${moment(timestamp).fromNow()})` : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
public modalOpenChange(state: any) {
|
|
||||||
this.opened = state
|
|
||||||
this.openedChange.emit(this.opened)
|
|
||||||
}
|
|
||||||
|
|
||||||
public modalOpened() {
|
|
||||||
this.sasjsRequests = this.sasService.getSasRequests()
|
|
||||||
|
|
||||||
for (let request of this.sasjsRequests) {
|
|
||||||
this.parseErrorsAndWarnings(request)
|
|
||||||
|
|
||||||
request.serviceLink = this.cutAppLoc(request.serviceLink)
|
|
||||||
request.parsedTimestamp = this.parseLogTimestamp(request.timestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public goToLogLine(
|
|
||||||
linkingLine: string,
|
|
||||||
requestStackId: string,
|
|
||||||
type: string
|
|
||||||
) {
|
|
||||||
const logWrapper: HTMLElement | null = document.querySelector(
|
|
||||||
`#${requestStackId} .log-wrapper.saslog`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!logWrapper) return
|
|
||||||
|
|
||||||
if (this.serverType === 'SASVIYA') {
|
|
||||||
// For VIYA (textContent approach)
|
|
||||||
const logContent = logWrapper.textContent || ''
|
|
||||||
const logLines = logContent.split('\n')
|
|
||||||
let lineIndex = -1
|
|
||||||
|
|
||||||
// Find the matching line index
|
|
||||||
for (let i = 0; i < logLines.length; i++) {
|
|
||||||
if (logLines[i].includes(linkingLine)) {
|
|
||||||
lineIndex = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lineIndex === -1) return
|
|
||||||
|
|
||||||
// Create a temporary div to calculate line height
|
|
||||||
const tempDiv = document.createElement('div')
|
|
||||||
tempDiv.className = 'temp-line-height-calc'
|
|
||||||
tempDiv.textContent = 'X'
|
|
||||||
logWrapper.appendChild(tempDiv)
|
|
||||||
const lineHeight = tempDiv.clientHeight
|
|
||||||
logWrapper.removeChild(tempDiv)
|
|
||||||
|
|
||||||
// Scroll to the appropriate position
|
|
||||||
logWrapper.scrollTop = lineHeight * lineIndex
|
|
||||||
|
|
||||||
// Create highlighting overlay at that position
|
|
||||||
const overlay = document.createElement('div')
|
|
||||||
overlay.className = `line-highlight-overlay ${type === 'error' ? 'error-highlight' : 'warning-highlight'}`
|
|
||||||
|
|
||||||
// We are setting the height using a CSS variable to the HTML element
|
|
||||||
// this does not trigger CSP errors because it's driven by JS
|
|
||||||
overlay.classList.add('temp-height-setter')
|
|
||||||
document.documentElement.style.setProperty(
|
|
||||||
'--line-height',
|
|
||||||
`${lineHeight}px`
|
|
||||||
)
|
|
||||||
overlay.classList.add('line-position-setter')
|
|
||||||
document.documentElement.style.setProperty(
|
|
||||||
'--line-top',
|
|
||||||
`${lineHeight * lineIndex}px`
|
|
||||||
)
|
|
||||||
|
|
||||||
logWrapper.appendChild(overlay)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (logWrapper.contains(overlay)) {
|
|
||||||
logWrapper.removeChild(overlay)
|
|
||||||
}
|
|
||||||
}, 3000)
|
|
||||||
} else {
|
|
||||||
// For non-VIYA (innerHTML approach)
|
|
||||||
const allLines: NodeListOf<Element> = document.querySelectorAll(
|
|
||||||
`#${requestStackId} .log-wrapper.saslog font`
|
|
||||||
)
|
|
||||||
|
|
||||||
for (let line of Array.from(allLines)) {
|
|
||||||
if (line.textContent?.includes(linkingLine)) {
|
|
||||||
logWrapper.scrollTop =
|
|
||||||
(line as HTMLElement).offsetTop - logWrapper.offsetTop
|
|
||||||
// Use class instead of inline style
|
|
||||||
line.classList.add('highlighted-line')
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
line.classList.remove('highlighted-line')
|
|
||||||
}, 3000)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async parseErrorsAndWarnings(req: any) {
|
|
||||||
if (!req || !req.logFile || typeof req.logFile !== 'string') return
|
|
||||||
if (req['logErrors'] !== undefined || req['logWarnings'] !== undefined)
|
|
||||||
return
|
|
||||||
|
|
||||||
let errorLines = []
|
|
||||||
let warningLines = []
|
|
||||||
|
|
||||||
// Create a safe version of the log content
|
|
||||||
let logLines = req.logFile.split('\n')
|
|
||||||
let originalLogLines = [...logLines]
|
|
||||||
|
|
||||||
for (let i = 0; i < logLines.length; i++) {
|
|
||||||
if (/<.*>ERROR/gm.test(logLines[i])) {
|
|
||||||
let errorLine = logLines[i].substring(
|
|
||||||
logLines[i].indexOf('E'),
|
|
||||||
logLines[i].length - 1
|
|
||||||
)
|
|
||||||
errorLines.push(errorLine)
|
|
||||||
} else if (/^ERROR/gm.test(logLines[i])) {
|
|
||||||
errorLines.push(logLines[i])
|
|
||||||
|
|
||||||
if (this.serverType !== 'SASVIYA') {
|
|
||||||
logLines[i] = '<font class="error-line">' + logLines[i] + '</font>'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/<.*>WARNING/gm.test(logLines[i])) {
|
|
||||||
let warningLine = logLines[i].substring(
|
|
||||||
logLines[i].indexOf('W'),
|
|
||||||
logLines[i].length - 1
|
|
||||||
)
|
|
||||||
warningLines.push(warningLine)
|
|
||||||
} else if (/^WARNING/gm.test(logLines[i])) {
|
|
||||||
warningLines.push(logLines[i])
|
|
||||||
|
|
||||||
if (this.serverType !== 'SASVIYA') {
|
|
||||||
logLines[i] = '<font class="warning-line">' + logLines[i] + '</font>'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loggerService.log(warningLines)
|
|
||||||
|
|
||||||
// For VIYA, store both versions of the log
|
|
||||||
if (this.serverType === 'SASVIYA') {
|
|
||||||
req.originalLogFile = originalLogLines.join('\n')
|
|
||||||
req.logFileLineMap = {}
|
|
||||||
|
|
||||||
// Create a mapping of error/warning lines to their indices
|
|
||||||
errorLines.forEach((errorLine) => {
|
|
||||||
for (let i = 0; i < originalLogLines.length; i++) {
|
|
||||||
if (originalLogLines[i].includes(errorLine)) {
|
|
||||||
if (!req.logFileLineMap.errors) req.logFileLineMap.errors = {}
|
|
||||||
req.logFileLineMap.errors[errorLine] = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
warningLines.forEach((warningLine) => {
|
|
||||||
for (let i = 0; i < originalLogLines.length; i++) {
|
|
||||||
if (originalLogLines[i].includes(warningLine)) {
|
|
||||||
if (!req.logFileLineMap.warnings) req.logFileLineMap.warnings = {}
|
|
||||||
req.logFileLineMap.warnings[warningLine] = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
req.logFile = logLines.join('\n')
|
|
||||||
req.logErrors = errorLines
|
|
||||||
req.logWarnings = warningLines
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadLog(logFile: string) {
|
|
||||||
const timestamp = new Date().valueOf()
|
|
||||||
this.helperService.downloadTextFile(`logFile-${timestamp}`, logFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadSourceCode(sourceCode: string) {
|
|
||||||
const timestamp = new Date().valueOf()
|
|
||||||
this.helperService.downloadTextFile(`sourceCode-${timestamp}`, sourceCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadGeneratedCode(generatedCode: string) {
|
|
||||||
const timestamp = new Date().valueOf()
|
|
||||||
this.helperService.downloadTextFile(
|
|
||||||
`generatedCode-${timestamp}`,
|
|
||||||
generatedCode
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+46
-27
@@ -2,10 +2,10 @@
|
|||||||
[clrModalOpen]="opened"
|
[clrModalOpen]="opened"
|
||||||
(clrModalOpenChange)="modalOpenChange($event)"
|
(clrModalOpenChange)="modalOpenChange($event)"
|
||||||
clrModalSize="xl"
|
clrModalSize="xl"
|
||||||
class="requests-modal"
|
class="sas-logs-modal"
|
||||||
>
|
>
|
||||||
<h3 class="modal-title">
|
<h3 class="modal-title">
|
||||||
Request History
|
SAS Logs
|
||||||
<p class="mt-0 d-inline-block">App Location: {{ sasjsConfig.appLoc }}</p>
|
<p class="mt-0 d-inline-block">App Location: {{ sasjsConfig.appLoc }}</p>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="modal-body h-70vh">
|
<div class="modal-body h-70vh">
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<clr-stack-label>
|
<clr-stack-label>
|
||||||
{{ programLog.serviceLink }}
|
{{ programLog.serviceLink }}
|
||||||
<span class="ml-10">
|
<span class="ml-10">
|
||||||
@if (programLog.logWarnings?.length! > 0) {
|
@if (programLog.warnings?.length! > 0) {
|
||||||
<span>
|
<span>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
class="mb-5 color-green"
|
class="mb-5 color-green"
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
></clr-icon>
|
></clr-icon>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@if (programLog.logErrors?.length! > 0) {
|
@if (programLog.errors?.length! > 0) {
|
||||||
<span>
|
<span>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
class="mb-5 color-red"
|
class="mb-5 color-red"
|
||||||
@@ -49,51 +49,70 @@
|
|||||||
<ng-template [(clrIfActive)]="sasLogActive">
|
<ng-template [(clrIfActive)]="sasLogActive">
|
||||||
<clr-tab-content>
|
<clr-tab-content>
|
||||||
<clr-tree>
|
<clr-tree>
|
||||||
@if (programLog.logErrors) {
|
@if (programLog.errors?.length) {
|
||||||
<clr-tree-node class="err-links">
|
<clr-tree-node class="err-links">
|
||||||
<clr-icon
|
<clr-icon
|
||||||
class="color-red"
|
class="color-red"
|
||||||
shape="exclamation-triangle"
|
shape="exclamation-triangle"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
Errors ({{ programLog.logErrors.length! }})
|
Errors ({{ programLog.errors!.length }})
|
||||||
@for (
|
@for (issue of programLog.errors; track $index) {
|
||||||
err of programLog.logErrors || [];
|
|
||||||
track err
|
|
||||||
) {
|
|
||||||
<clr-tree-node
|
<clr-tree-node
|
||||||
(click)="
|
(click)="goToLogLine(issue, 'request_' + i)"
|
||||||
goToLogLine(err, 'request_' + i, 'error')
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<button class="clr-treenode-link">
|
<button class="clr-treenode-link">
|
||||||
{{ err }}
|
@if (issue.code) {
|
||||||
|
<span class="issue-code">{{
|
||||||
|
issue.code
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
|
{{ issue.message }}
|
||||||
</button>
|
</button>
|
||||||
</clr-tree-node>
|
</clr-tree-node>
|
||||||
}
|
}
|
||||||
</clr-tree-node>
|
</clr-tree-node>
|
||||||
}
|
}
|
||||||
@if (programLog.logWarnings) {
|
@if (programLog.warnings?.length) {
|
||||||
<clr-tree-node class="err-links">
|
<clr-tree-node class="err-links">
|
||||||
<clr-icon
|
<clr-icon
|
||||||
class="color-green"
|
class="color-green"
|
||||||
shape="exclamation-circle"
|
shape="exclamation-circle"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
Warnings ({{ programLog.logWarnings.length! }})
|
Warnings ({{ programLog.warnings!.length }})
|
||||||
@for (
|
@for (
|
||||||
warning of programLog.logWarnings || [];
|
issue of programLog.warnings;
|
||||||
track warning
|
track $index
|
||||||
) {
|
) {
|
||||||
<clr-tree-node
|
<clr-tree-node
|
||||||
(click)="
|
(click)="goToLogLine(issue, 'request_' + i)"
|
||||||
goToLogLine(
|
|
||||||
warning,
|
|
||||||
'request_' + i,
|
|
||||||
'warning'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<button class="clr-treenode-link">
|
<button class="clr-treenode-link">
|
||||||
{{ warning }}
|
@if (issue.code) {
|
||||||
|
<span class="issue-code">{{
|
||||||
|
issue.code
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
|
{{ issue.message }}
|
||||||
|
</button>
|
||||||
|
</clr-tree-node>
|
||||||
|
}
|
||||||
|
</clr-tree-node>
|
||||||
|
}
|
||||||
|
@if (programLog.notes?.length) {
|
||||||
|
<clr-tree-node class="err-links">
|
||||||
|
<clr-icon shape="info-circle"></clr-icon>
|
||||||
|
Notes ({{ programLog.notes!.length }})
|
||||||
|
@for (issue of programLog.notes; track $index) {
|
||||||
|
<clr-tree-node
|
||||||
|
(click)="goToLogLine(issue, 'request_' + i)"
|
||||||
|
>
|
||||||
|
<button class="clr-treenode-link">
|
||||||
|
@if (issue.code) {
|
||||||
|
<span class="issue-code">{{
|
||||||
|
issue.code
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
|
{{ issue.message }}
|
||||||
</button>
|
</button>
|
||||||
</clr-tree-node>
|
</clr-tree-node>
|
||||||
}
|
}
|
||||||
@@ -103,7 +122,7 @@
|
|||||||
@if (serverType !== 'SASVIYA') {
|
@if (serverType !== 'SASVIYA') {
|
||||||
<div
|
<div
|
||||||
class="log-wrapper saslog"
|
class="log-wrapper saslog"
|
||||||
[innerHTML]="programLog.logFile"
|
[innerHTML]="programLog.logHtml"
|
||||||
></div>
|
></div>
|
||||||
}
|
}
|
||||||
@if (serverType === 'SASVIYA') {
|
@if (serverType === 'SASVIYA') {
|
||||||
File renamed without changes.
@@ -0,0 +1,319 @@
|
|||||||
|
import { LogIssue } from '@sasjs/utils/logs'
|
||||||
|
import { SasLogsModalComponent } from './sas-logs-modal.component'
|
||||||
|
|
||||||
|
const LOG = [
|
||||||
|
'1 data want;',
|
||||||
|
'2 set sashelp.class;',
|
||||||
|
'3 x = age / 0;',
|
||||||
|
'4 run;',
|
||||||
|
'NOTE: There were 19 observations read from the data set SASHELP.CLASS.',
|
||||||
|
'WARNING: Division by zero detected at line 3 column 11.',
|
||||||
|
'ERROR: File WORK.MISSING_DS.DATA does not exist.',
|
||||||
|
'ERROR 22-322: Syntax error, expecting one of the following: ;, CANCEL,',
|
||||||
|
' RUN, QUIT.',
|
||||||
|
'NOTE: Invalid data for age in line 42 3-5.'
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const issue = (overrides: Partial<LogIssue>): LogIssue => {
|
||||||
|
const line = overrides.line ?? 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
line,
|
||||||
|
endLine: line,
|
||||||
|
column: 0,
|
||||||
|
endColumn: 0,
|
||||||
|
severity: 'error',
|
||||||
|
message: 'a message',
|
||||||
|
...overrides
|
||||||
|
} as LogIssue
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SasLogsModalComponent', () => {
|
||||||
|
let component: SasLogsModalComponent
|
||||||
|
let sasService: any
|
||||||
|
let helperService: any
|
||||||
|
|
||||||
|
const buildComponent = (
|
||||||
|
requests: any[] = [],
|
||||||
|
serverType: string = 'SAS9'
|
||||||
|
): SasLogsModalComponent => {
|
||||||
|
sasService = {
|
||||||
|
getSasjsConfig: () => ({ appLoc: '/Public/app' }),
|
||||||
|
getServerType: () => serverType,
|
||||||
|
getSasRequests: () => requests
|
||||||
|
}
|
||||||
|
|
||||||
|
helperService = {
|
||||||
|
downloadTextFile: jasmine.createSpy('downloadTextFile')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SasLogsModalComponent(
|
||||||
|
sasService,
|
||||||
|
{ log: () => {} } as any,
|
||||||
|
helperService
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
component = buildComponent()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('analyseLog', () => {
|
||||||
|
it('groups the errors, the warnings and the notable notes of a log', () => {
|
||||||
|
const request: any = { logFile: LOG }
|
||||||
|
component.analyseLog(request)
|
||||||
|
|
||||||
|
expect(request.errors.map((i: LogIssue) => i.line)).toEqual([6, 7])
|
||||||
|
expect(request.warnings.map((i: LogIssue) => i.line)).toEqual([5])
|
||||||
|
expect(request.notes.map((i: LogIssue) => i.message)).toEqual([
|
||||||
|
'Invalid data for age in line 42 3-5.'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the message and the code the parser reported', () => {
|
||||||
|
const request: any = { logFile: LOG }
|
||||||
|
component.analyseLog(request)
|
||||||
|
|
||||||
|
expect(request.errors[0]).toEqual(
|
||||||
|
issue({
|
||||||
|
line: 6,
|
||||||
|
endLine: 6,
|
||||||
|
column: 0,
|
||||||
|
endColumn: 'ERROR: File WORK.MISSING_DS.DATA does not exist.'.length,
|
||||||
|
message: 'File WORK.MISSING_DS.DATA does not exist.'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(request.errors[1].code).toEqual('22-322')
|
||||||
|
expect(request.errors[1].endLine).toEqual(8)
|
||||||
|
expect(request.errors[1].message).toEqual(
|
||||||
|
'Syntax error, expecting one of the following: ;, CANCEL, RUN, QUIT.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports nothing for a log with nothing to report', () => {
|
||||||
|
const request: any = {
|
||||||
|
logFile: 'NOTE: There were 19 observations read.\n'
|
||||||
|
}
|
||||||
|
component.analyseLog(request)
|
||||||
|
|
||||||
|
expect(request.errors).toEqual([])
|
||||||
|
expect(request.warnings).toEqual([])
|
||||||
|
expect(request.notes).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports nothing when the request carries no log at all', () => {
|
||||||
|
const request: any = { serviceLink: 'services/x' }
|
||||||
|
component.analyseLog(request)
|
||||||
|
|
||||||
|
expect(request.logIssues).toEqual([])
|
||||||
|
expect(request.errors).toEqual([])
|
||||||
|
expect(request.logHtml).toEqual('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('markIssueLines', () => {
|
||||||
|
it('marks every line an issue occupies, the wrapped ones included', () => {
|
||||||
|
const request: any = { logFile: LOG }
|
||||||
|
component.analyseLog(request)
|
||||||
|
|
||||||
|
const lines: string[] = request.logHtml.split('\n')
|
||||||
|
|
||||||
|
expect(lines[5]).toEqual(
|
||||||
|
'<font class="warning-line">WARNING: Division by zero detected at line 3 column 11.</font>'
|
||||||
|
)
|
||||||
|
expect(lines[6]).toContain('<font class="error-line">ERROR: File WORK')
|
||||||
|
expect(lines[7]).toContain('<font class="error-line">ERROR 22-322:')
|
||||||
|
expect(lines[8]).toEqual(
|
||||||
|
'<font class="error-line"> RUN, QUIT.</font>'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a notable note unmarked, and the other lines untouched', () => {
|
||||||
|
const request: any = { logFile: LOG }
|
||||||
|
component.analyseLog(request)
|
||||||
|
|
||||||
|
const lines: string[] = request.logHtml.split('\n')
|
||||||
|
|
||||||
|
expect(lines[9]).toEqual('NOTE: Invalid data for age in line 42 3-5.')
|
||||||
|
expect(lines[0]).toEqual('1 data want;')
|
||||||
|
expect(lines).toHaveSize(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the log as it is when it holds no issues', () => {
|
||||||
|
const log = 'NOTE: There were 19 observations read.'
|
||||||
|
|
||||||
|
expect(component.markIssueLines(log, [])).toEqual(log)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('goToLogLine', () => {
|
||||||
|
let wrapper: HTMLElement
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const stackBlock = document.createElement('div')
|
||||||
|
stackBlock.id = 'request_0'
|
||||||
|
wrapper = document.createElement('div')
|
||||||
|
wrapper.className = 'log-wrapper saslog'
|
||||||
|
wrapper.textContent = LOG
|
||||||
|
stackBlock.appendChild(wrapper)
|
||||||
|
document.body.appendChild(stackBlock)
|
||||||
|
|
||||||
|
// The height of a rendered line needs a layout; a fixed value keeps the
|
||||||
|
// arithmetic under test deterministic.
|
||||||
|
;(component as any).measureLineHeight = () => 10
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => wrapper.parentElement?.remove())
|
||||||
|
|
||||||
|
it('positions the highlight from the line the parser reported', () => {
|
||||||
|
component.goToLogLine(issue({ line: 6 }), 'request_0')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue('--line-top')
|
||||||
|
).toEqual('60px')
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue('--line-height')
|
||||||
|
).toEqual('10px')
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue('--line-count')
|
||||||
|
).toEqual('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not jump to the first of two identical messages', () => {
|
||||||
|
const repeated = issue({ line: 7, message: 'a message' })
|
||||||
|
|
||||||
|
component.goToLogLine(repeated, 'request_0')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue('--line-top')
|
||||||
|
).toEqual('70px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('covers every line a wrapped message runs to', () => {
|
||||||
|
component.goToLogLine(issue({ line: 7, endLine: 8 }), 'request_0')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue('--line-count')
|
||||||
|
).toEqual('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flashes the colour of the severity it was given', () => {
|
||||||
|
component.goToLogLine(
|
||||||
|
issue({ line: 5, severity: 'warning' }),
|
||||||
|
'request_0'
|
||||||
|
)
|
||||||
|
|
||||||
|
const overlay = wrapper.querySelector('.line-highlight-overlay')
|
||||||
|
|
||||||
|
expect(overlay).not.toBeNull()
|
||||||
|
expect(overlay!.classList).toContain('warning-highlight')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing when the request has no log on screen', () => {
|
||||||
|
expect(() =>
|
||||||
|
component.goToLogLine(issue({ line: 1 }), 'request_9')
|
||||||
|
).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('modalOpened', () => {
|
||||||
|
it('analyses every request and cuts the app location from its link', () => {
|
||||||
|
component = buildComponent([
|
||||||
|
{
|
||||||
|
serviceLink: '/Public/app/services/editors/save',
|
||||||
|
timestamp: new Date(),
|
||||||
|
logFile: LOG
|
||||||
|
}
|
||||||
|
])
|
||||||
|
component.modalOpened()
|
||||||
|
|
||||||
|
const [request] = component.sasjsRequests as any[]
|
||||||
|
|
||||||
|
expect(request.serviceLink).toEqual('services/editors/save')
|
||||||
|
expect(request.errors).toHaveSize(2)
|
||||||
|
expect(request.warnings).toHaveSize(1)
|
||||||
|
expect(request.notes).toHaveSize(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the opened input', () => {
|
||||||
|
it('analyses the requests when the modal opens', () => {
|
||||||
|
component = buildComponent([
|
||||||
|
{
|
||||||
|
serviceLink: '/Public/app/services/x',
|
||||||
|
timestamp: new Date(),
|
||||||
|
logFile: LOG
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
component.opened = true
|
||||||
|
|
||||||
|
expect(component.opened).toBeTrue()
|
||||||
|
expect((component.sasjsRequests[0] as any).errors).toHaveSize(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not re-read the requests when it closes', () => {
|
||||||
|
const getSasRequests = jasmine
|
||||||
|
.createSpy('getSasRequests')
|
||||||
|
.and.returnValue([])
|
||||||
|
component = buildComponent([])
|
||||||
|
sasService.getSasRequests = getSasRequests
|
||||||
|
component.opened = true
|
||||||
|
getSasRequests.calls.reset()
|
||||||
|
|
||||||
|
component.opened = false
|
||||||
|
|
||||||
|
expect(component.opened).toBeFalse()
|
||||||
|
expect(getSasRequests).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports the new state to the parent', () => {
|
||||||
|
const openedChange = jasmine.createSpy('openedChange')
|
||||||
|
component.openedChange.subscribe(openedChange)
|
||||||
|
|
||||||
|
component.modalOpenChange(false)
|
||||||
|
|
||||||
|
expect(openedChange).toHaveBeenCalledWith(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('timestamps', () => {
|
||||||
|
it('formats a known date in full and relative to now', () => {
|
||||||
|
const date = new Date('2026-03-04T15:06:07Z')
|
||||||
|
|
||||||
|
expect(component.formatTimestamp(date)).toContain('March 4th 2026')
|
||||||
|
expect(component.parseLogTimestamp(date)).toContain('March 4th 2026')
|
||||||
|
expect(component.timestampFromNow(date)).toContain('ago')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hands back a timestamp it cannot parse', () => {
|
||||||
|
expect(component.formatTimestamp('not a date')).toEqual('not a date')
|
||||||
|
expect(component.timestampFromNow('not a date')).toEqual('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('downloads', () => {
|
||||||
|
it('writes the log it was given under a timestamped name', () => {
|
||||||
|
component.downloadLog('a log')
|
||||||
|
|
||||||
|
expect(helperService.downloadTextFile).toHaveBeenCalledWith(
|
||||||
|
jasmine.stringMatching(/^logFile-\d+$/),
|
||||||
|
'a log'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes the source and the generated code under their own names', () => {
|
||||||
|
component.downloadSourceCode('source')
|
||||||
|
component.downloadGeneratedCode('generated')
|
||||||
|
|
||||||
|
expect(helperService.downloadTextFile).toHaveBeenCalledWith(
|
||||||
|
jasmine.stringMatching(/^sourceCode-\d+$/),
|
||||||
|
'source'
|
||||||
|
)
|
||||||
|
expect(helperService.downloadTextFile).toHaveBeenCalledWith(
|
||||||
|
jasmine.stringMatching(/^generatedCode-\d+$/),
|
||||||
|
'generated'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
OnInit,
|
||||||
|
Output,
|
||||||
|
ViewEncapsulation
|
||||||
|
} from '@angular/core'
|
||||||
|
import { SASjsRequest } from '@sasjs/adapter'
|
||||||
|
import { LogIssue, parseLog } from '@sasjs/utils/logs'
|
||||||
|
import moment from 'moment'
|
||||||
|
import { HelperService } from 'src/app/services/helper.service'
|
||||||
|
import { LoggerService } from '../../services/logger.service'
|
||||||
|
import { SasService } from '../../services/sas.service'
|
||||||
|
import { ServerType } from '@sasjs/utils/types/serverType'
|
||||||
|
|
||||||
|
interface SASjsRequestExtended extends SASjsRequest {
|
||||||
|
parsedTimestamp?: string
|
||||||
|
/** Every error, warning and notable note in `logFile`, in log order. */
|
||||||
|
logIssues?: LogIssue[]
|
||||||
|
errors?: LogIssue[]
|
||||||
|
warnings?: LogIssue[]
|
||||||
|
notes?: LogIssue[]
|
||||||
|
/** The log with its error and warning lines wrapped in markup, for rendering. */
|
||||||
|
logHtml?: string
|
||||||
|
selectedTable?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-sas-logs-modal',
|
||||||
|
templateUrl: './sas-logs-modal.component.html',
|
||||||
|
styleUrls: ['./sas-logs-modal.component.scss'],
|
||||||
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
standalone: false
|
||||||
|
})
|
||||||
|
export class SasLogsModalComponent implements OnInit {
|
||||||
|
private _opened: boolean = false
|
||||||
|
get opened(): boolean {
|
||||||
|
return this._opened
|
||||||
|
}
|
||||||
|
@Input()
|
||||||
|
set opened(value: boolean) {
|
||||||
|
this._opened = value
|
||||||
|
if (value) this.modalOpened()
|
||||||
|
|
||||||
|
this.loggerService.log(this.sasjsRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Output() openedChange = new EventEmitter()
|
||||||
|
|
||||||
|
public sasLogActive: boolean = true
|
||||||
|
public sasSourceCodeActive: boolean = false
|
||||||
|
public sasGeneratedCodeActive: boolean = false
|
||||||
|
public tablesActive: boolean = false
|
||||||
|
|
||||||
|
public sasjsConfig = this.sasService.getSasjsConfig()
|
||||||
|
public serverType: string
|
||||||
|
public sasjsRequests: SASjsRequestExtended[] = []
|
||||||
|
public workTables: any
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private sasService: SasService,
|
||||||
|
private loggerService: LoggerService,
|
||||||
|
private helperService: HelperService
|
||||||
|
) {
|
||||||
|
this.sasjsConfig = this.sasService.getSasjsConfig()
|
||||||
|
this.serverType = this.sasService.getServerType()
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
public parseLogTimestamp(timestamp: any) {
|
||||||
|
return `${this.formatTimestamp(timestamp)} ${this.timestampFromNow(
|
||||||
|
timestamp
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public cutAppLoc(link: string) {
|
||||||
|
return link.replace(this.sasjsConfig.appLoc + '/', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
public formatTimestamp(timestamp: any) {
|
||||||
|
const parsed = moment(timestamp)
|
||||||
|
|
||||||
|
return parsed.isValid()
|
||||||
|
? parsed.format('dddd, MMMM Do YYYY, h:mm:ss a')
|
||||||
|
: timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
public timestampFromNow(timestamp: any) {
|
||||||
|
const parsed = moment(timestamp)
|
||||||
|
|
||||||
|
return parsed.isValid() ? ` (${parsed.fromNow()})` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
public modalOpenChange(state: any) {
|
||||||
|
this.opened = state
|
||||||
|
this.openedChange.emit(this.opened)
|
||||||
|
}
|
||||||
|
|
||||||
|
public modalOpened() {
|
||||||
|
this.sasjsRequests = this.sasService.getSasRequests()
|
||||||
|
|
||||||
|
for (let request of this.sasjsRequests) {
|
||||||
|
this.analyseLog(request)
|
||||||
|
|
||||||
|
request.serviceLink = this.cutAppLoc(request.serviceLink)
|
||||||
|
request.parsedTimestamp = this.parseLogTimestamp(request.timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the request's log once and keeps what the template renders: the
|
||||||
|
* issues SAS wrote, grouped by severity, and - for a log carrying markup -
|
||||||
|
* the log itself with those lines marked.
|
||||||
|
*/
|
||||||
|
public analyseLog(request: SASjsRequestExtended) {
|
||||||
|
const log = typeof request.logFile === 'string' ? request.logFile : ''
|
||||||
|
const issues = parseLog(log)
|
||||||
|
|
||||||
|
request.logIssues = issues
|
||||||
|
request.errors = issues.filter((issue) => issue.severity === 'error')
|
||||||
|
request.warnings = issues.filter((issue) => issue.severity === 'warning')
|
||||||
|
request.notes = issues.filter((issue) => issue.severity === 'note')
|
||||||
|
request.logHtml = this.markIssueLines(log, issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The log with each line an error or a warning occupies wrapped in a class
|
||||||
|
* that colours it, so the lists and the log agree about what is wrong and
|
||||||
|
* where. A wrapped message is marked on every line it runs to, and a notable
|
||||||
|
* note is left unmarked - it is not a defect.
|
||||||
|
*/
|
||||||
|
public markIssueLines(log: string, issues: LogIssue[]): string {
|
||||||
|
const severityByLine = new Map<number, string>()
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
if (issue.severity === 'note') continue
|
||||||
|
|
||||||
|
for (let line = issue.line; line <= issue.endLine; line++) {
|
||||||
|
severityByLine.set(line, issue.severity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (severityByLine.size === 0) return log
|
||||||
|
|
||||||
|
return log
|
||||||
|
.split('\n')
|
||||||
|
.map((line, index) => {
|
||||||
|
const severity = severityByLine.get(index)
|
||||||
|
|
||||||
|
return severity ? `<font class="${severity}-line">${line}</font>` : line
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the log to an issue and flashes its line, using the line number the
|
||||||
|
* parser reported rather than searching the rendered text - a log repeats its
|
||||||
|
* messages, so a search lands on the first of them every time.
|
||||||
|
*/
|
||||||
|
public goToLogLine(issue: LogIssue, requestStackId: string) {
|
||||||
|
const logWrapper = document.querySelector(
|
||||||
|
`#${requestStackId} .log-wrapper.saslog`
|
||||||
|
) as HTMLElement | null
|
||||||
|
|
||||||
|
if (!logWrapper) return
|
||||||
|
|
||||||
|
const lineHeight = this.measureLineHeight(logWrapper)
|
||||||
|
const lineCount = issue.endLine - issue.line + 1
|
||||||
|
const top = lineHeight * issue.line
|
||||||
|
|
||||||
|
logWrapper.scrollTop = top
|
||||||
|
|
||||||
|
// The height and position reach the stylesheet as CSS variables, which
|
||||||
|
// keeps the inline style away from the element and therefore away from CSP.
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
'--line-height',
|
||||||
|
`${lineHeight}px`
|
||||||
|
)
|
||||||
|
document.documentElement.style.setProperty('--line-count', `${lineCount}`)
|
||||||
|
document.documentElement.style.setProperty('--line-top', `${top}px`)
|
||||||
|
|
||||||
|
const overlay = document.createElement('div')
|
||||||
|
overlay.className = `line-highlight-overlay ${issue.severity}-highlight`
|
||||||
|
overlay.classList.add('temp-height-setter')
|
||||||
|
overlay.classList.add('line-position-setter')
|
||||||
|
|
||||||
|
logWrapper.appendChild(overlay)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (logWrapper.contains(overlay)) {
|
||||||
|
logWrapper.removeChild(overlay)
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The height of one rendered log line, measured rather than assumed. */
|
||||||
|
private measureLineHeight(logWrapper: HTMLElement): number {
|
||||||
|
const tempDiv = document.createElement('div')
|
||||||
|
tempDiv.className = 'temp-line-height-calc'
|
||||||
|
tempDiv.textContent = 'X'
|
||||||
|
logWrapper.appendChild(tempDiv)
|
||||||
|
const lineHeight = tempDiv.clientHeight
|
||||||
|
logWrapper.removeChild(tempDiv)
|
||||||
|
|
||||||
|
return lineHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadLog(logFile: string) {
|
||||||
|
const timestamp = new Date().valueOf()
|
||||||
|
this.helperService.downloadTextFile(`logFile-${timestamp}`, logFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadSourceCode(sourceCode: string) {
|
||||||
|
const timestamp = new Date().valueOf()
|
||||||
|
this.helperService.downloadTextFile(`sourceCode-${timestamp}`, sourceCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadGeneratedCode(generatedCode: string) {
|
||||||
|
const timestamp = new Date().valueOf()
|
||||||
|
this.helperService.downloadTextFile(
|
||||||
|
`generatedCode-${timestamp}`,
|
||||||
|
generatedCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EventEmitter } from '@angular/core'
|
||||||
import { SidebarComponent } from './sidebar.component'
|
import { SidebarComponent } from './sidebar.component'
|
||||||
|
|
||||||
describe('SidebarComponent', () => {
|
describe('SidebarComponent', () => {
|
||||||
@@ -31,4 +32,49 @@ describe('SidebarComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('sidebar toggle', () => {
|
||||||
|
const buildWithEvents = () => {
|
||||||
|
const eventService: any = {
|
||||||
|
onSidebarToggle: new EventEmitter<any>()
|
||||||
|
}
|
||||||
|
const component = new SidebarComponent(
|
||||||
|
{ url: '/home' } as any,
|
||||||
|
eventService,
|
||||||
|
{
|
||||||
|
getSasjsConfig: () => ({}),
|
||||||
|
getServerType: () => 'SASVIYA'
|
||||||
|
} as any
|
||||||
|
)
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
return { component, eventService }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('opens and closes from the layout event', () => {
|
||||||
|
const { component, eventService } = buildWithEvents()
|
||||||
|
|
||||||
|
eventService.onSidebarToggle.emit()
|
||||||
|
expect(component.sidebarOpen).toBe(false)
|
||||||
|
|
||||||
|
eventService.onSidebarToggle.emit({ open: true })
|
||||||
|
expect(component.sidebarOpen).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not synthesise a window resize', () => {
|
||||||
|
// The layout change used to be broadcast as a synthetic window resize
|
||||||
|
// event; the components that need it now follow the layout event
|
||||||
|
// directly, so a toggle must not reach window listeners at all.
|
||||||
|
const seen: string[] = []
|
||||||
|
const listener = () => seen.push('resize')
|
||||||
|
window.addEventListener('resize', listener)
|
||||||
|
|
||||||
|
const { eventService } = buildWithEvents()
|
||||||
|
eventService.onSidebarToggle.emit()
|
||||||
|
eventService.onSidebarToggle.emit({ open: true })
|
||||||
|
|
||||||
|
window.removeEventListener('resize', listener)
|
||||||
|
expect(seen).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -49,8 +49,6 @@ export class SidebarComponent implements OnInit {
|
|||||||
} else {
|
} else {
|
||||||
this.sidebarOpen = !this.sidebarOpen
|
this.sidebarOpen = !this.sidebarOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
this.eventService.dispatchEvent('resize')
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,8 +71,8 @@
|
|||||||
</clr-toggle-wrapper>
|
</clr-toggle-wrapper>
|
||||||
</clr-toggle-container>
|
</clr-toggle-container>
|
||||||
</div>
|
</div>
|
||||||
<a (click)="openRequestsModal()" clrDropdownItem>
|
<a (click)="openSasLogsModal()" clrDropdownItem>
|
||||||
<span>SAS Requests</span>
|
<span>SAS Logs</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@if (!isDeployPage) {
|
@if (!isDeployPage) {
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export class HeaderActions implements OnInit, OnDestroy {
|
|||||||
this.eventService.toggleDarkMode(value)
|
this.eventService.toggleDarkMode(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
openRequestsModal() {
|
openSasLogsModal() {
|
||||||
this.eventService.openRequestsModal()
|
this.eventService.openSasLogsModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
onDebugModeChange(dropdownItem?: any): void {
|
onDebugModeChange(dropdownItem?: any): void {
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
import { fixGridAria, observeGridAria } from './grid-aria'
|
||||||
|
|
||||||
|
const buildGrid = (id: string): HTMLElement => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.id = `container_${id}`
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="ht-wrapper handsontable" id="ht_${id}" role="treegrid"
|
||||||
|
aria-rowcount="-1" aria-colcount="3" aria-multiselectable="true">
|
||||||
|
<div class="wtHolder" role="presentation">
|
||||||
|
<table class="htCore">
|
||||||
|
<thead><tr><th>Class</th></tr></thead>
|
||||||
|
<tbody><tr><td><button>Edit record</button></td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('fixGridAria', () => {
|
||||||
|
it('strips the treegrid role and the row/column counts from the grid wrapper', () => {
|
||||||
|
const container = buildGrid('a')
|
||||||
|
const wrapper = container.querySelector('.ht-wrapper') as HTMLElement
|
||||||
|
|
||||||
|
fixGridAria(container)
|
||||||
|
|
||||||
|
expect(wrapper.getAttribute('role')).toBeNull()
|
||||||
|
expect(wrapper.getAttribute('aria-rowcount')).toBeNull()
|
||||||
|
expect(wrapper.getAttribute('aria-colcount')).toBeNull()
|
||||||
|
expect(wrapper.getAttribute('aria-multiselectable')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a presentation role only when the element holds focusable content', () => {
|
||||||
|
const container = buildGrid('b')
|
||||||
|
const interactive = container.querySelector('.wtHolder') as HTMLElement
|
||||||
|
const empty = document.createElement('div')
|
||||||
|
empty.setAttribute('role', 'presentation')
|
||||||
|
empty.innerHTML = '<span>overlay</span>'
|
||||||
|
container.appendChild(empty)
|
||||||
|
|
||||||
|
fixGridAria(container)
|
||||||
|
|
||||||
|
expect(interactive.getAttribute('role')).toBeNull()
|
||||||
|
expect(empty.getAttribute('role')).toBe('presentation')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives the table an explicit table role and its headers a column scope', () => {
|
||||||
|
const container = buildGrid('c')
|
||||||
|
|
||||||
|
fixGridAria(container)
|
||||||
|
|
||||||
|
expect(container.querySelector('table')?.getAttribute('role')).toBe('table')
|
||||||
|
expect(container.querySelector('th')?.getAttribute('scope')).toBe('col')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves accessible names to the markup', () => {
|
||||||
|
const container = buildGrid('d')
|
||||||
|
const labelled = document.createElement('button')
|
||||||
|
labelled.setAttribute('aria-label', 'Existing')
|
||||||
|
labelled.textContent = 'Overwrite me'
|
||||||
|
container.appendChild(labelled)
|
||||||
|
|
||||||
|
fixGridAria(container)
|
||||||
|
|
||||||
|
// The grid's own button has a text name already; nothing should invent an
|
||||||
|
// aria-label for it, and an existing one must survive untouched.
|
||||||
|
expect(
|
||||||
|
container.querySelector('table button')?.getAttribute('aria-label')
|
||||||
|
).toBeNull()
|
||||||
|
expect(labelled.getAttribute('aria-label')).toBe('Existing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves markup outside the grid container untouched', () => {
|
||||||
|
const container = buildGrid('e')
|
||||||
|
const other = buildGrid('f')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
document.body.appendChild(other)
|
||||||
|
const otherWrapper = other.querySelector('.ht-wrapper') as HTMLElement
|
||||||
|
|
||||||
|
fixGridAria(container)
|
||||||
|
|
||||||
|
expect(otherWrapper.getAttribute('role')).toBe('treegrid')
|
||||||
|
expect(otherWrapper.getAttribute('aria-rowcount')).toBe('-1')
|
||||||
|
|
||||||
|
container.remove()
|
||||||
|
other.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips the root element itself, whatever its id or class', () => {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
root.setAttribute('role', 'treegrid')
|
||||||
|
root.setAttribute('aria-rowcount', '-1')
|
||||||
|
root.setAttribute('aria-colcount', '4')
|
||||||
|
root.setAttribute('aria-multiselectable', 'true')
|
||||||
|
|
||||||
|
fixGridAria(root)
|
||||||
|
|
||||||
|
expect(root.getAttribute('role')).toBeNull()
|
||||||
|
expect(root.getAttribute('aria-rowcount')).toBeNull()
|
||||||
|
expect(root.getAttribute('aria-colcount')).toBeNull()
|
||||||
|
expect(root.getAttribute('aria-multiselectable')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a treegrid that a cell renderer put inside the grid alone', () => {
|
||||||
|
const container = buildGrid('h')
|
||||||
|
const nested = document.createElement('div')
|
||||||
|
nested.setAttribute('role', 'treegrid')
|
||||||
|
nested.setAttribute('aria-rowcount', '3')
|
||||||
|
container.querySelector('td')?.appendChild(nested)
|
||||||
|
|
||||||
|
fixGridAria(container)
|
||||||
|
|
||||||
|
expect(nested.getAttribute('role')).toBe('treegrid')
|
||||||
|
expect(nested.getAttribute('aria-rowcount')).toBe('3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a table a cell renderer owns alone', () => {
|
||||||
|
const container = buildGrid('i')
|
||||||
|
const rendererTable = document.createElement('table')
|
||||||
|
rendererTable.innerHTML = '<thead><tr><th>Inner</th></tr></thead>'
|
||||||
|
container.querySelector('td')?.appendChild(rendererTable)
|
||||||
|
|
||||||
|
fixGridAria(container)
|
||||||
|
|
||||||
|
expect(rendererTable.getAttribute('role')).toBeNull()
|
||||||
|
expect(rendererTable.querySelector('th')?.getAttribute('scope')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is idempotent', () => {
|
||||||
|
const container = buildGrid('g')
|
||||||
|
|
||||||
|
fixGridAria(container)
|
||||||
|
const first = container.innerHTML
|
||||||
|
fixGridAria(container)
|
||||||
|
|
||||||
|
expect(container.innerHTML).toBe(first)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a missing grid, which is the case before Handsontable has mounted', () => {
|
||||||
|
expect(() => fixGridAria(undefined)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fixGridAria against a live grid', () => {
|
||||||
|
it('re-strips the attributes Handsontable writes again on a data load', () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const hot = new Handsontable(container, {
|
||||||
|
data: [
|
||||||
|
[1, 2],
|
||||||
|
[3, 4]
|
||||||
|
],
|
||||||
|
height: 200,
|
||||||
|
width: 300,
|
||||||
|
licenseKey: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handsontable marks its own wrapper on create - that is what the pass
|
||||||
|
// removes, and why the pass runs after every render rather than once.
|
||||||
|
expect(hot.rootElement.getAttribute('role')).toBe('treegrid')
|
||||||
|
|
||||||
|
fixGridAria(hot.rootElement)
|
||||||
|
expect(hot.rootElement.getAttribute('role')).toBeNull()
|
||||||
|
expect(hot.rootElement.getAttribute('aria-rowcount')).toBeNull()
|
||||||
|
|
||||||
|
hot.loadData([
|
||||||
|
[5, 6],
|
||||||
|
[7, 8],
|
||||||
|
[9, 10]
|
||||||
|
])
|
||||||
|
expect(hot.rootElement.getAttribute('aria-rowcount')).toBe('-1')
|
||||||
|
|
||||||
|
fixGridAria(hot.rootElement)
|
||||||
|
expect(hot.rootElement.getAttribute('role')).toBeNull()
|
||||||
|
expect(hot.rootElement.getAttribute('aria-rowcount')).toBeNull()
|
||||||
|
} finally {
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('observeGridAria', () => {
|
||||||
|
it('corrects the grid on registration and after each render', async () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const hot = new Handsontable(container, {
|
||||||
|
data: [
|
||||||
|
[1, 2],
|
||||||
|
[3, 4]
|
||||||
|
],
|
||||||
|
height: 200,
|
||||||
|
width: 300,
|
||||||
|
licenseKey: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Registration alone corrects the attributes Handsontable wrote at init.
|
||||||
|
observeGridAria(hot)
|
||||||
|
expect(hot.rootElement.getAttribute('role')).toBeNull()
|
||||||
|
|
||||||
|
// Handsontable puts them back on a data load; the hook removes them again
|
||||||
|
// on the render that follows, without the caller doing anything.
|
||||||
|
hot.loadData([[5, 6]])
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(hot.rootElement.getAttribute('role')).toBeNull()
|
||||||
|
expect(hot.rootElement.getAttribute('aria-rowcount')).toBeNull()
|
||||||
|
|
||||||
|
// Registering twice must not stack hooks or throw.
|
||||||
|
observeGridAria(hot)
|
||||||
|
hot.render()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(hot.rootElement.getAttribute('role')).toBeNull()
|
||||||
|
} finally {
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a missing or destroyed grid', () => {
|
||||||
|
expect(() => observeGridAria(undefined)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type Handsontable from 'handsontable'
|
||||||
|
|
||||||
|
/** Elements that count as focusable/interactive for the presentation-role check. */
|
||||||
|
const INTERACTIVE =
|
||||||
|
'button, input, select, textarea, [tabindex], [onclick], [contenteditable]'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handsontable 18 marks its grid wrapper `role="treegrid"` with
|
||||||
|
* `aria-rowcount="-1"`, and marks the scroll holder `role="presentation"`.
|
||||||
|
* Neither survives an axe audit:
|
||||||
|
*
|
||||||
|
* - a treegrid must own rows (axe: `aria-required-children`, critical), but the
|
||||||
|
* wrapper's direct children are overlay divs, so the role is a lie;
|
||||||
|
* - `aria-rowcount="-1"` tells assistive tech the total row count is unknown
|
||||||
|
* when the table has already rendered it;
|
||||||
|
* - `role="presentation"` on the scroll holder that contains the focusable
|
||||||
|
* cells hides those cells from assistive tech.
|
||||||
|
*
|
||||||
|
* Measured on Handsontable 18 with axe-core: the default markup fails the first
|
||||||
|
* two rules; disabling the tags (`ariaTags: false`) only trades the critical
|
||||||
|
* failure for an `empty-table-header` one and leaves the scroll region
|
||||||
|
* unfocusable. So the attributes are corrected here instead - the wrapper is
|
||||||
|
* demoted to a plain container and the table gets an explicit `role="table"`
|
||||||
|
* with scoped headers.
|
||||||
|
*
|
||||||
|
* The pass is scoped to one grid's container, not the document: each grid owns
|
||||||
|
* its own markup, and a document-wide sweep would fight every other grid on the
|
||||||
|
* page (and every other component's DOM) on each render. It is idempotent, so
|
||||||
|
* calling it after every render and every data change is safe.
|
||||||
|
*
|
||||||
|
* It deliberately does not synthesise `aria-label` from an element's text. The
|
||||||
|
* old document-wide sweep did, which meant a button's accessible name depended
|
||||||
|
* on when the sweep last ran - and one Cypress spec had come to select the
|
||||||
|
* filter modal's submit button by a label that only the sweep ever wrote. An
|
||||||
|
* element's text is already its name; anything else belongs in the template.
|
||||||
|
*/
|
||||||
|
const collect = (root: HTMLElement, selector: string): Element[] => {
|
||||||
|
const found = Array.from(root.querySelectorAll(selector))
|
||||||
|
return root.matches(selector) ? [root, ...found] : found
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fixGridAria = (root: HTMLElement | null | undefined): void => {
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
// The grid's own wrapper is the root itself - Handsontable marks it with
|
||||||
|
// role/aria attributes, and its id carries no fixed prefix (it is the
|
||||||
|
// container the host component created). Everything else with those
|
||||||
|
// attributes is one of the overlay wrappers Handsontable renders inside it.
|
||||||
|
// Matching on [role="treegrid"] instead would also catch a treegrid a cell
|
||||||
|
// renderer deliberately put inside the grid, which is not ours to change.
|
||||||
|
const wrappers = [
|
||||||
|
root,
|
||||||
|
...Array.from(root.querySelectorAll('.ht-wrapper, .wtHolder, [id^="ht_"]'))
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const element of wrappers) {
|
||||||
|
element.removeAttribute('role')
|
||||||
|
element.removeAttribute('aria-rowcount')
|
||||||
|
element.removeAttribute('aria-colcount')
|
||||||
|
element.removeAttribute('aria-multiselectable')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of collect(root, '[role="presentation"]')) {
|
||||||
|
if (element.querySelector(INTERACTIVE)) element.removeAttribute('role')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handsontable's own tables all carry `htCore` (one per view/clone). Matching
|
||||||
|
// plain `table` would also catch a table a cell renderer owns - the same
|
||||||
|
// reason the wrapper loop does not match on [role="treegrid"].
|
||||||
|
for (const table of collect(root, 'table.htCore')) {
|
||||||
|
if (!table.getAttribute('role')) table.setAttribute('role', 'table')
|
||||||
|
|
||||||
|
// :scope keeps this to the grid's own header row - a renderer's nested
|
||||||
|
// table lives in a body cell and has its own thead.
|
||||||
|
for (const headerCell of Array.from(
|
||||||
|
table.querySelectorAll(':scope > thead > tr > th')
|
||||||
|
)) {
|
||||||
|
if (!headerCell.getAttribute('scope')) {
|
||||||
|
headerCell.setAttribute('scope', 'col')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grids already carrying the afterRender hook, so a second call is cheap. */
|
||||||
|
const hooked = new WeakSet<Handsontable>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps one grid's ARIA markup corrected for as long as it lives: runs the pass
|
||||||
|
* now and again after every render.
|
||||||
|
*
|
||||||
|
* Use this for grids whose host component does not otherwise own a render
|
||||||
|
* hook - the viewboxes and the multi-dataset preview. The editor and viewer
|
||||||
|
* call `fixGridAria()` from their own `afterRender` / `afterChange` hooks
|
||||||
|
* instead, which do more than this.
|
||||||
|
*
|
||||||
|
* Handsontable rewrites its wrapper attributes on create and on every data
|
||||||
|
* load, so a single pass at init is not enough.
|
||||||
|
*/
|
||||||
|
export const observeGridAria = (hot: Handsontable | null | undefined): void => {
|
||||||
|
if (!hot || hot.isDestroyed || hooked.has(hot)) return
|
||||||
|
hooked.add(hot)
|
||||||
|
|
||||||
|
// Handsontable writes the row/column counts *after* the render that follows a
|
||||||
|
// data load, so a pass running inside the hook still sees the old frame.
|
||||||
|
// Deferring by one microtask puts the pass after that write, at the end of
|
||||||
|
// the task that triggered the render.
|
||||||
|
hot.addHook('afterRender', () =>
|
||||||
|
queueMicrotask(() => fixGridAria(hot.rootElement))
|
||||||
|
)
|
||||||
|
fixGridAria(hot.rootElement)
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ const buildComponent = () => {
|
|||||||
} as any, // NgZone
|
} as any, // NgZone
|
||||||
{ licenceState: { value: {} } } as any, // LicenceService
|
{ licenceState: { value: {} } } as any, // LicenceService
|
||||||
{} as any, // SasService
|
{} as any, // SasService
|
||||||
{ dispatchEvent: () => {} } as any, // EventService
|
{} as any, // EventService
|
||||||
{} as any, // SasStoreService
|
{} as any, // SasStoreService
|
||||||
{} as any, // LoggerService
|
{} as any, // LoggerService
|
||||||
helperService,
|
helperService,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { TableClickEmitter } from 'src/app/shared/dc-tree/models/TableClickEmitt
|
|||||||
import { mergeColsRules } from 'src/app/shared/dc-validator/utils/mergeColsRules'
|
import { mergeColsRules } from 'src/app/shared/dc-validator/utils/mergeColsRules'
|
||||||
import { buildExportMenuItem } from 'src/app/shared/hot-export/hot-export.util'
|
import { buildExportMenuItem } from 'src/app/shared/hot-export/hot-export.util'
|
||||||
import { globals, initFilter } from 'src/app/_globals'
|
import { globals, initFilter } from 'src/app/_globals'
|
||||||
|
import { observeGridAria } from '../utils/grid-aria'
|
||||||
import { ViewboxHotTable } from './models/viewbox-hot-table.model'
|
import { ViewboxHotTable } from './models/viewbox-hot-table.model'
|
||||||
import { ViewboxTable } from './models/viewbox-table.model'
|
import { ViewboxTable } from './models/viewbox-table.model'
|
||||||
import { Viewbox } from './models/viewbox.model'
|
import { Viewbox } from './models/viewbox.model'
|
||||||
@@ -278,7 +279,6 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
await this.loadData(libDataset, viewbox)
|
await this.loadData(libDataset, viewbox)
|
||||||
|
|
||||||
viewbox.loadingData = false
|
viewbox.loadingData = false
|
||||||
this.eventService.dispatchEvent('resize') //Force HOT refresh
|
|
||||||
this.snapToGrid() //it will call viewboxChanged
|
this.snapToGrid() //it will call viewboxChanged
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,14 +695,12 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared debounced tail for every resize path (corner, single-edge,
|
* Shared debounced tail for every resize path (corner, single-edge,
|
||||||
* left-edge) - persists the new size/position, notifies listeners the
|
* left-edge) - persists the new size/position, then refreshes every
|
||||||
* viewport changed shape, and refreshes every viewbox's Handsontable
|
* viewbox's Handsontable instance to match.
|
||||||
* instance to match.
|
|
||||||
*/
|
*/
|
||||||
private scheduleAfterResizeRefresh(): void {
|
private scheduleAfterResizeRefresh(): void {
|
||||||
this.helperService.debounceCall(1000, () => {
|
this.helperService.debounceCall(1000, () => {
|
||||||
this.viewboxChanged()
|
this.viewboxChanged()
|
||||||
this.eventService.dispatchEvent('resize')
|
|
||||||
|
|
||||||
// Refresh all viewbox tables after resize and update their settings
|
// Refresh all viewbox tables after resize and update their settings
|
||||||
this.viewboxes.forEach((viewbox) => {
|
this.viewboxes.forEach((viewbox) => {
|
||||||
@@ -1568,7 +1566,6 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
await this.reloadData(libDataset, viewbox)
|
await this.reloadData(libDataset, viewbox)
|
||||||
|
|
||||||
viewbox.loadingData = false
|
viewbox.loadingData = false
|
||||||
this.eventService.dispatchEvent('resize') //Force HOT refresh
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1756,7 +1753,13 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hotTableComponent = hotComponents[hotComponentIndex]
|
const hotTableComponent = hotComponents[hotComponentIndex]
|
||||||
return hotTableComponent?.hotInstance || undefined
|
const hotInstance = hotTableComponent?.hotInstance || undefined
|
||||||
|
|
||||||
|
// Each viewbox owns a grid, and neither the editor nor the viewer owns
|
||||||
|
// this one's render hook - so keep its ARIA markup corrected here.
|
||||||
|
observeGridAria(hotInstance)
|
||||||
|
|
||||||
|
return hotInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -187,6 +187,7 @@
|
|||||||
<option value="TSQL_DDL">DDL (TSQL Flavour)</option>
|
<option value="TSQL_DDL">DDL (TSQL Flavour)</option>
|
||||||
<option value="EXCEL">Excel (.xlsx)</option>
|
<option value="EXCEL">Excel (.xlsx)</option>
|
||||||
<option value="MARKDOWN">Markdown (.md)</option>
|
<option value="MARKDOWN">Markdown (.md)</option>
|
||||||
|
<option value="PDF">PDF report (.pdf)</option>
|
||||||
</select>
|
</select>
|
||||||
</clr-select-container>
|
</clr-select-container>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { Col } from '../shared/dc-validator/models/col.model'
|
|||||||
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
|
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
|
||||||
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
|
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
|
||||||
import { buildColInfoHtml } from '../shared/utils/col-info-html'
|
import { buildColInfoHtml } from '../shared/utils/col-info-html'
|
||||||
|
import { fixGridAria } from '../shared/utils/grid-aria'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-viewer',
|
selector: 'app-viewer',
|
||||||
@@ -174,9 +175,6 @@ export class ViewerComponent
|
|||||||
public licenceState = this.licenceService.licenceState
|
public licenceState = this.licenceService.licenceState
|
||||||
public Infinity = Infinity
|
public Infinity = Infinity
|
||||||
|
|
||||||
private ariaObserver: MutationObserver | undefined
|
|
||||||
private ariaCheckInterval: any | undefined
|
|
||||||
|
|
||||||
public hotTable: HotTableInterface = {
|
public hotTable: HotTableInterface = {
|
||||||
data: [],
|
data: [],
|
||||||
colHeaders: [],
|
colHeaders: [],
|
||||||
@@ -1525,165 +1523,18 @@ export class ViewerComponent
|
|||||||
|
|
||||||
// Safely destroy Handsontable instance
|
// Safely destroy Handsontable instance
|
||||||
this.cleanupHotInstance()
|
this.cleanupHotInstance()
|
||||||
|
|
||||||
// Clean up ARIA accessibility observers
|
|
||||||
if (this.ariaObserver) {
|
|
||||||
this.ariaObserver.disconnect()
|
|
||||||
this.ariaObserver = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear ARIA check intervals
|
|
||||||
if (this.ariaCheckInterval) {
|
|
||||||
clearInterval(this.ariaCheckInterval)
|
|
||||||
this.ariaCheckInterval = undefined
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixes ARIA accessibility issues in the Handsontable component
|
* Corrects the ARIA markup Handsontable puts on its own grid wrapper - see
|
||||||
* This addresses the accessibility report issues with treegrid and presentation roles
|
* shared/utils/grid-aria.ts for what is corrected and why. Scoped to this
|
||||||
|
* grid so it does not touch any other grid or component on the page.
|
||||||
*/
|
*/
|
||||||
private fixAriaAccessibility() {
|
private fixAriaAccessibility(): void {
|
||||||
// Use a more aggressive approach to find and fix all ARIA issues
|
fixGridAria(
|
||||||
const fixAriaIssues = () => {
|
this.hotInstance?.rootElement ??
|
||||||
// Specifically target Handsontable wrapper elements that are causing issues
|
this.hotTableComponent?.hotInstance?.rootElement
|
||||||
const hotWrappers = document.querySelectorAll(
|
|
||||||
'.ht-wrapper, .wtHolder, [id^="ht_"]'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
hotWrappers.forEach((wrapper) => {
|
|
||||||
// Remove problematic ARIA attributes from Handsontable wrappers
|
|
||||||
wrapper.removeAttribute('role')
|
|
||||||
wrapper.removeAttribute('aria-rowcount')
|
|
||||||
wrapper.removeAttribute('aria-colcount')
|
|
||||||
wrapper.removeAttribute('aria-multiselectable')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Find all elements with problematic ARIA roles in the entire document
|
|
||||||
const allTreegridElements = document.querySelectorAll('[role="treegrid"]')
|
|
||||||
const allPresentationElements = document.querySelectorAll(
|
|
||||||
'[role="presentation"]'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fix treegrid role issues - remove them completely as they're causing problems
|
|
||||||
allTreegridElements.forEach((element) => {
|
|
||||||
element.removeAttribute('role')
|
|
||||||
element.removeAttribute('aria-rowcount')
|
|
||||||
element.removeAttribute('aria-colcount')
|
|
||||||
element.removeAttribute('aria-multiselectable')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fix presentation role issues - remove them if they contain interactive elements
|
|
||||||
allPresentationElements.forEach((element) => {
|
|
||||||
const hasInteractiveChildren =
|
|
||||||
element.querySelectorAll(
|
|
||||||
'button, input, select, textarea, [tabindex], [onclick], [contenteditable]'
|
|
||||||
).length > 0
|
|
||||||
if (hasInteractiveChildren) {
|
|
||||||
element.removeAttribute('role')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Also fix any elements with aria-rowcount="-1" which is problematic
|
|
||||||
const negativeRowCountElements = document.querySelectorAll(
|
|
||||||
'[aria-rowcount="-1"]'
|
|
||||||
)
|
|
||||||
negativeRowCountElements.forEach((element) => {
|
|
||||||
element.removeAttribute('aria-rowcount')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Ensure proper table structure
|
|
||||||
const tableElements = document.querySelectorAll('table')
|
|
||||||
tableElements.forEach((table) => {
|
|
||||||
if (!table.getAttribute('role')) {
|
|
||||||
table.setAttribute('role', 'table')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure table headers have proper scope
|
|
||||||
const headerCells = table.querySelectorAll('th')
|
|
||||||
headerCells.forEach((th) => {
|
|
||||||
if (!th.getAttribute('scope')) {
|
|
||||||
th.setAttribute('scope', 'col')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add proper ARIA labels to interactive elements
|
|
||||||
const interactiveElements = document.querySelectorAll(
|
|
||||||
'button, input, select, textarea, [contenteditable]'
|
|
||||||
)
|
|
||||||
interactiveElements.forEach((element) => {
|
|
||||||
if (
|
|
||||||
!element.getAttribute('aria-label') &&
|
|
||||||
!element.getAttribute('aria-labelledby')
|
|
||||||
) {
|
|
||||||
const textContent = element.textContent?.trim()
|
|
||||||
if (textContent) {
|
|
||||||
element.setAttribute('aria-label', textContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the fix immediately
|
|
||||||
fixAriaIssues()
|
|
||||||
|
|
||||||
// Run it again after a short delay to catch any dynamically created elements
|
|
||||||
setTimeout(fixAriaIssues, 100)
|
|
||||||
setTimeout(fixAriaIssues, 500)
|
|
||||||
setTimeout(fixAriaIssues, 1000)
|
|
||||||
setTimeout(fixAriaIssues, 2000)
|
|
||||||
|
|
||||||
// Set up a periodic check to ensure accessibility fixes are maintained
|
|
||||||
if (!this.ariaCheckInterval) {
|
|
||||||
this.ariaCheckInterval = setInterval(fixAriaIssues, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up a MutationObserver to continuously monitor for new problematic elements
|
|
||||||
if (!this.ariaObserver) {
|
|
||||||
this.ariaObserver = new MutationObserver((mutations) => {
|
|
||||||
let shouldFix = false
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (
|
|
||||||
mutation.type === 'attributes' &&
|
|
||||||
(mutation.attributeName === 'role' ||
|
|
||||||
mutation.attributeName === 'aria-rowcount')
|
|
||||||
) {
|
|
||||||
shouldFix = true
|
|
||||||
}
|
|
||||||
if (mutation.type === 'childList') {
|
|
||||||
mutation.addedNodes.forEach((node) => {
|
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
const element = node as Element
|
|
||||||
if (
|
|
||||||
element.hasAttribute('role') ||
|
|
||||||
element.hasAttribute('aria-rowcount')
|
|
||||||
) {
|
|
||||||
shouldFix = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (shouldFix) {
|
|
||||||
setTimeout(fixAriaIssues, 50)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start observing the entire document for changes
|
|
||||||
this.ariaObserver.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: [
|
|
||||||
'role',
|
|
||||||
'aria-rowcount',
|
|
||||||
'aria-colcount',
|
|
||||||
'aria-multiselectable'
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async ngAfterContentInit() {
|
async ngAfterContentInit() {
|
||||||
|
|||||||
+30
-8
@@ -298,7 +298,7 @@ app-editor {
|
|||||||
|
|
||||||
// APP.COMPONENT
|
// APP.COMPONENT
|
||||||
my-app {
|
my-app {
|
||||||
app-requests-modal {
|
app-sas-logs-modal {
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1566,11 +1566,18 @@ body[cds-theme='light'] {
|
|||||||
}
|
}
|
||||||
// USER.COMPONENT END
|
// USER.COMPONENT END
|
||||||
|
|
||||||
// REQUESTS-MODAL.COMPONENT
|
// SAS-LOGS-MODAL.COMPONENT
|
||||||
app-requests-modal {
|
app-sas-logs-modal {
|
||||||
--line-height: 16px;
|
--line-height: 16px;
|
||||||
|
--line-count: 1;
|
||||||
--line-top: 0px;
|
--line-top: 0px;
|
||||||
|
|
||||||
|
.issue-code {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.log-wrapper.saslog {
|
.log-wrapper.saslog {
|
||||||
position: relative;
|
position: relative;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
@@ -1591,7 +1598,7 @@ app-requests-modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.temp-height-setter {
|
.temp-height-setter {
|
||||||
height: var(--line-height);
|
height: calc(var(--line-height) * var(--line-count));
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-position-setter {
|
.line-position-setter {
|
||||||
@@ -1609,7 +1616,11 @@ app-requests-modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.warning-highlight {
|
.warning-highlight {
|
||||||
background-color: rgba(255, 255, 0, 0.2);
|
background-color: rgba(242, 175, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-highlight {
|
||||||
|
background-color: rgba(0, 120, 210, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeOut {
|
@keyframes fadeOut {
|
||||||
@@ -1656,18 +1667,18 @@ app-requests-modal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.requests-modal .modal-header--accessible--accessible .close clr-icon {
|
.sas-logs-modal .modal-header--accessible--accessible .close clr-icon {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.requests-modal .modal-content {
|
.sas-logs-modal .modal-content {
|
||||||
padding: 20px 10px 5px 10px;
|
padding: 20px 10px 5px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The SAS log pane is `white-space: pre`, so it never wraps. The stock `xl`
|
/* The SAS log pane is `white-space: pre`, so it never wraps. The stock `xl`
|
||||||
width is a fixed 57.6rem, which forced left/right scrolling to read a log
|
width is a fixed 57.6rem, which forced left/right scrolling to read a log
|
||||||
line. */
|
line. */
|
||||||
.requests-modal .modal-dialog {
|
.sas-logs-modal .modal-dialog {
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5046,6 +5057,17 @@ body[cds-theme='dark'] {
|
|||||||
border-left: 2px solid #1a6ea3 !important;
|
border-left: 2px solid #1a6ea3 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a column whose CASE validation forces a case: every entry is converted to
|
||||||
|
it as it is committed, so the header says so rather than the cell pretending
|
||||||
|
to hold a value it does not */
|
||||||
|
.handsontable th.dc-case-header div::after {
|
||||||
|
content: 'Aa';
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.handsontable td.dc-derived-cell.dc-derived-first {
|
.handsontable td.dc-derived-cell.dc-derived-first {
|
||||||
box-shadow: inset 2px 0 0 #1a6ea3;
|
box-shadow: inset 2px 0 0 #1a6ea3;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dcfrontend",
|
"name": "dcfrontend",
|
||||||
"version": "7.20.0",
|
"version": "7.21.0",
|
||||||
"description": "Data Controller",
|
"description": "Data Controller",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nogoo9/gitleaks": "8.30.1-post.2",
|
"@nogoo9/gitleaks": "8.30.1-post.2",
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
"got": "11.8.6"
|
"got": "11.8.6"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"install": "cd client && npm i && cd ../sas && npm i",
|
|
||||||
"build-frontend": "cd client && npm run build",
|
"build-frontend": "cd client && npm run build",
|
||||||
"lint": "cd client && npm run lint",
|
"lint": "cd client && npm run lint",
|
||||||
"lint:fix": "cd client && npm run lint:fix",
|
"lint:fix": "cd client && npm run lint:fix",
|
||||||
|
|||||||
+37
-3
@@ -4,13 +4,47 @@
|
|||||||
"hasDoxygenHeader": true,
|
"hasDoxygenHeader": true,
|
||||||
"noSpacesInFileNames": true,
|
"noSpacesInFileNames": true,
|
||||||
"lowerCaseFileNames": true,
|
"lowerCaseFileNames": true,
|
||||||
"maxLineLength": 180,
|
"maxLineLength": 80,
|
||||||
"noTabIndentation": true,
|
"noTabIndentation": true,
|
||||||
"indentationMultiple": 2,
|
"indentationMultiple": 2,
|
||||||
"hasMacroNameInMend": true,
|
"hasMacroNameInMend": true,
|
||||||
"noNestedMacros": true,
|
"noNestedMacros": true,
|
||||||
"hasMacroParentheses": true,
|
"hasMacroParentheses": true,
|
||||||
"strictMacroDefinition": true,
|
"strictMacroDefinition": true,
|
||||||
"ignoredMacros": ["webout", "mpeinit", "mpeterm"],
|
"noSingleAsteriskComments": true,
|
||||||
"ignoreList":["sasjs/scratch"]
|
"noUnusedLibnames": true,
|
||||||
|
"lineEndings": "lf",
|
||||||
|
"ignoredMacros": [
|
||||||
|
"webout",
|
||||||
|
"mpeinit",
|
||||||
|
"mpeterm"
|
||||||
|
],
|
||||||
|
"ignoreList": [
|
||||||
|
"sasjs/scratch"
|
||||||
|
],
|
||||||
|
"severityLevel": {
|
||||||
|
"hasDoxygenHeader": "error",
|
||||||
|
"hasMacroNameInMend": "error",
|
||||||
|
"hasMacroParentheses": "error",
|
||||||
|
"indentationMultiple": "error",
|
||||||
|
"lineEndings": "error",
|
||||||
|
"lowerCaseFileNames": "error",
|
||||||
|
"lowerCaseMacroNames": "error",
|
||||||
|
"maxLineLength": "error",
|
||||||
|
"minMacroNameLength": "error",
|
||||||
|
"noBannedStrings": "error",
|
||||||
|
"noGremlins": "error",
|
||||||
|
"noNestedCommentsInHeader": "error",
|
||||||
|
"noNestedMacros": "error",
|
||||||
|
"noSingleAsteriskComments": "error",
|
||||||
|
"noSpacesInFileNames": "error",
|
||||||
|
"noTabIndentation": "error",
|
||||||
|
"noTrailingSpaces": "error",
|
||||||
|
"noUndeclaredMacros": "error",
|
||||||
|
"noUnusedLibnames": "error",
|
||||||
|
"noUnusedMacros": "error",
|
||||||
|
"strictMacroDefinition": "error",
|
||||||
|
"noMacroIfInOpenCode": "error"
|
||||||
|
},
|
||||||
|
"noMacroIfInOpenCode": true
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
{
|
{
|
||||||
"ADMIN": "AllUsers",
|
"ADMIN": "AllUsers",
|
||||||
"DCPATH": "/opt/data/DataController",
|
"DCPATH": "/opt/data/DataController",
|
||||||
"OUTFMT": "JSON"
|
"OUTFMT": "JSON",
|
||||||
|
"ACTION": "MAKEDATA"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
{
|
{
|
||||||
"ADMIN": "viyagroup18",
|
"ADMIN": "viyagroup18",
|
||||||
"DCPATH": "/export/pvs/sasdata/sasbatch",
|
"DCPATH": "/export/pvs/sasdata/sasbatch",
|
||||||
"_CONTEXTNAME": "Compute Reusable"
|
"_CONTEXTNAME": "Compute Reusable",
|
||||||
|
"ACTION": "MAKEDATA"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"fromjs": [
|
"fromjs": [
|
||||||
{
|
{
|
||||||
"ADMIN": "AllUsers",
|
"ADMIN": "all-users",
|
||||||
"DCPATH": "/tmp/dcdata",
|
"DCPATH": "/tmp/dcdata",
|
||||||
"OUTFMT": "JSON"
|
"OUTFMT": "JSON",
|
||||||
|
"ACTION": "MAKEDATA"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
"fromjs": [
|
"fromjs": [
|
||||||
{
|
{
|
||||||
"ADMIN": "viyagroup08",
|
"ADMIN": "viyagroup08",
|
||||||
"DCPATH": "/export/pvs/sasdata/homes/viyademo08f/dc"
|
"DCPATH": "/export/pvs/sasdata/homes/viyademo08f/dc",
|
||||||
|
"ACTION": "MAKEDATA"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
"fromjs": [
|
"fromjs": [
|
||||||
{
|
{
|
||||||
"ADMIN": "DC Demo Group",
|
"ADMIN": "DC Demo Group",
|
||||||
"DCPATH": "/tmp/dcviya"
|
"DCPATH": "/tmp/dcviya",
|
||||||
|
"ACTION": "MAKEDATA"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -13,10 +13,12 @@
|
|||||||
// The rules mirror services/hooks/mpe_derived_postedit.sas, in its order:
|
// The rules mirror services/hooks/mpe_derived_postedit.sas, in its order:
|
||||||
// normalise the incoming rows, reject a duplicate name and a bad field, then
|
// normalise the incoming rows, reject a duplicate name and a bad field, then
|
||||||
// check the dependency graph that the resulting configuration would have (the
|
// check the dependency graph that the resulting configuration would have (the
|
||||||
// incoming rows plus the current rows they do not replace). As on the server,
|
// incoming rows plus the current rows they do not replace). The field rules
|
||||||
// the collision between VAR_NM and a physical column is left to the read-time
|
// are core's mp_derivedvarcheck, which the server hook calls and the read-time
|
||||||
// engine, which runs with the target library assigned - the hook does not have
|
// engine runs too - this file spells them out a second time, so a change there
|
||||||
// it here either.
|
// has to be mirrored here. As on the server, the collision between VAR_NM and
|
||||||
|
// a physical column is left to the read-time engine, which runs with the target
|
||||||
|
// library assigned - the hook does not have it here either.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
;(function mpeDerivedPostedit() {
|
;(function mpeDerivedPostedit() {
|
||||||
@@ -83,9 +85,45 @@
|
|||||||
seen.add(key)
|
seen.add(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Field level checks, the rules the engine applies at read time ─────────
|
// ─── Field level checks ────────────────────────────────────────────────────
|
||||||
// C0 controls and DEL: what a single-line label cannot carry
|
// Two passes, in the order the SAS hook runs them. The first is the fields
|
||||||
|
// this hook owns - the control table key, which the loader normalises but
|
||||||
|
// does not require, and ACTIVE, the picker pair the EDIT screen offers.
|
||||||
|
for (const row of rows) {
|
||||||
|
if (isDeleted(row)) continue
|
||||||
|
const name = field(row, 'VAR_NM')
|
||||||
|
let reason = ''
|
||||||
|
if (!field(row, 'LIBREF') || !field(row, 'DSN'))
|
||||||
|
reason = 'LIBREF and DSN are required'
|
||||||
|
// ACTIVE is a picker in the EDIT screen (HARDSELECT over the
|
||||||
|
// MPE_SELECTBOX pair) - the same pair the hook enforces
|
||||||
|
else if (!['0', '1'].includes(String(colVal(row, 'ACTIVE')).trim()))
|
||||||
|
reason = 'ACTIVE must be 1 or 0'
|
||||||
|
if (reason) return reject(`MPE_DERIVED: ${name}: ${reason}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The second pass is the rules for the derived variable itself, which are
|
||||||
|
// core's (mp_derivedvarcheck): the engine runs the same macro at read time,
|
||||||
|
// so a definition is measured against one rule list wherever it is checked.
|
||||||
|
|
||||||
|
// C0 controls and DEL: what a label cannot carry once a line break has been
|
||||||
|
// flattened to a space, which is what the engine does with it
|
||||||
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/
|
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/
|
||||||
|
const flatten = (v) => v.replace(/[\r\n]+/g, ' ')
|
||||||
|
const AUTOMATIC_VARS = ['_N_', '_ERROR_', '_IORC_']
|
||||||
|
|
||||||
|
// the capabilities refused by name - a function name cannot be assembled at
|
||||||
|
// run time, so naming them is a control the expression cannot evade
|
||||||
|
const DENY_FUNCS = new RegExp(
|
||||||
|
'\\b(DOSUBL|JAVAOBJ|JAVAMETA|SYMGET|SYMPUTX?|SYSGET|GETOPTION' +
|
||||||
|
'|PATHNAME|FILEEXIST|FOPEN|FREAD|FGET|FCLOSE' +
|
||||||
|
'|DOPEN|DREAD|DNUM|MOPEN|SLEEP' +
|
||||||
|
'|FILENAME|FDELETE|FCOPY|DCREATE|DLCREATE)\\s*\\('
|
||||||
|
)
|
||||||
|
|
||||||
|
// quoted literals are data, not calls
|
||||||
|
const unquoted = (v) =>
|
||||||
|
v.replace(/'([^']|'')*'/g, ' ').replace(/"([^"]|"")*"/g, ' ')
|
||||||
|
|
||||||
// true when the value carries a character outside the allowed set
|
// true when the value carries a character outside the allowed set
|
||||||
const hasInvalidChar = (value, chars) =>
|
const hasInvalidChar = (value, chars) =>
|
||||||
@@ -93,8 +131,6 @@
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (isDeleted(row)) continue
|
if (isDeleted(row)) continue
|
||||||
const libref = field(row, 'LIBREF')
|
|
||||||
const dsn = field(row, 'DSN')
|
|
||||||
const name = field(row, 'VAR_NM')
|
const name = field(row, 'VAR_NM')
|
||||||
const type = field(row, 'VAR_TYPE')
|
const type = field(row, 'VAR_TYPE')
|
||||||
const len = Number(colVal(row, 'VAR_LEN'))
|
const len = Number(colVal(row, 'VAR_LEN'))
|
||||||
@@ -103,19 +139,16 @@
|
|||||||
const logic = strip(colVal(row, 'VAR_LOGIC'))
|
const logic = strip(colVal(row, 'VAR_LOGIC'))
|
||||||
|
|
||||||
let reason = ''
|
let reason = ''
|
||||||
if (!libref || !dsn) reason = 'LIBREF and DSN are required'
|
if (!name) reason = 'VAR_NM is required'
|
||||||
else if (!name) reason = 'VAR_NM is required'
|
|
||||||
else if (hasInvalidChar(name, NAME_CHARS))
|
else if (hasInvalidChar(name, NAME_CHARS))
|
||||||
reason = 'VAR_NM must be a valid SAS name'
|
reason = 'VAR_NM must be a valid SAS name'
|
||||||
else if ('0123456789'.includes(name[0]))
|
else if ('0123456789'.includes(name[0]))
|
||||||
reason = 'VAR_NM must not start with a digit'
|
reason = 'VAR_NM must not start with a digit'
|
||||||
|
else if (AUTOMATIC_VARS.includes(name))
|
||||||
|
reason = 'VAR_NM is an automatic variable of the data step'
|
||||||
else if (name === DELETE_MARK) reason = 'VAR_NM is reserved'
|
else if (name === DELETE_MARK) reason = 'VAR_NM is reserved'
|
||||||
else if (type !== 'C' && type !== 'N')
|
else if (type !== 'C' && type !== 'N')
|
||||||
reason = 'VAR_TYPE must be C or N'
|
reason = 'VAR_TYPE must be C or N'
|
||||||
// ACTIVE is a picker in the EDIT screen (HARDSELECT over the
|
|
||||||
// MPE_SELECTBOX pair) - the same pair mpe_derived_check.sas enforces
|
|
||||||
else if (!['0', '1'].includes(String(colVal(row, 'ACTIVE')).trim()))
|
|
||||||
reason = 'ACTIVE must be 1 or 0'
|
|
||||||
else if (!Number.isInteger(len) || len < 1)
|
else if (!Number.isInteger(len) || len < 1)
|
||||||
reason = 'VAR_LEN must be a positive integer'
|
reason = 'VAR_LEN must be a positive integer'
|
||||||
else if (type === 'C' && len > 32767)
|
else if (type === 'C' && len > 32767)
|
||||||
@@ -132,20 +165,24 @@
|
|||||||
reason = 'VAR_FMT must be a format name, without ; & % or /* */'
|
reason = 'VAR_FMT must be a format name, without ; & % or /* */'
|
||||||
else if (hasInvalidChar(fmt, FMT_CHARS))
|
else if (hasInvalidChar(fmt, FMT_CHARS))
|
||||||
reason = 'VAR_FMT must be a format name'
|
reason = 'VAR_FMT must be a format name'
|
||||||
// free text, in any script - what is refused is anything that cannot be
|
// free text, in any script - what is refused is a control character,
|
||||||
// a single-line column label, and a value the engine would cut to 256
|
// and a value the engine would cut to 256
|
||||||
else if (CONTROL_CHARS.test(label))
|
else if (CONTROL_CHARS.test(flatten(label)))
|
||||||
reason =
|
reason = 'VAR_LABEL must be plain text - no control characters'
|
||||||
'VAR_LABEL must be plain text - no line breaks or control characters'
|
|
||||||
else if ([...label].length > 256)
|
else if ([...label].length > 256)
|
||||||
reason = 'VAR_LABEL exceeds 256 characters'
|
reason = 'VAR_LABEL exceeds 256 characters'
|
||||||
else if (!logic) reason = 'VAR_LOGIC is required'
|
else if (!logic) reason = 'VAR_LOGIC is required'
|
||||||
|
else if (logic.length > 4000)
|
||||||
|
reason = 'VAR_LOGIC exceeds 4000 characters'
|
||||||
else if (logic.includes(';'))
|
else if (logic.includes(';'))
|
||||||
reason = 'VAR_LOGIC must be a single expression - remove the ;'
|
reason = 'VAR_LOGIC must be a single expression - remove the ;'
|
||||||
else if (logic.includes('&') || logic.includes('%'))
|
else if (logic.includes('&') || logic.includes('%'))
|
||||||
reason = 'VAR_LOGIC must not contain macro triggers'
|
reason = 'VAR_LOGIC must not contain macro triggers'
|
||||||
else if (logic.includes('/*') || logic.includes('*/'))
|
else if (logic.includes('/*') || logic.includes('*/'))
|
||||||
reason = 'VAR_LOGIC must not contain comments'
|
reason = 'VAR_LOGIC must not contain comments'
|
||||||
|
else if (DENY_FUNCS.test(unquoted(logic).toUpperCase()))
|
||||||
|
reason =
|
||||||
|
'VAR_LOGIC may not call a function that submits code or reaches the file system, the environment or the macro symbol table'
|
||||||
|
|
||||||
if (reason) return reject(`MPE_DERIVED: ${name}: ${reason}`)
|
if (reason) return reject(`MPE_DERIVED: ${name}: ${reason}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
let mimeType = `application/octet-stream`
|
let mimeType = `application/octet-stream`
|
||||||
let filename = `dc-download`
|
let filename = `dc-download`
|
||||||
|
|
||||||
if (libref) filename = `${libref}.ddl`
|
/* typeof, not a truthiness test: if the service is called without a ds in scope
|
||||||
|
the read itself throws and the fallback never runs */
|
||||||
|
if (
|
||||||
|
typeof libref !== 'undefined' &&
|
||||||
|
libref &&
|
||||||
|
typeof ds !== 'undefined' &&
|
||||||
|
ds
|
||||||
|
)
|
||||||
|
filename = `${libref}_${ds}.ddl`
|
||||||
|
else if (typeof libref !== 'undefined' && libref) {
|
||||||
|
/* production aborts when ds is missing (getddl.sas:43-45), so the mock does
|
||||||
|
too: a client that dropped ds would otherwise stay green here */
|
||||||
|
throw new Error(`Dataset ${libref}..${ds} was not found`)
|
||||||
|
}
|
||||||
|
|
||||||
fs.writeFile(_SASJS_WEBOUT_HEADERS, `Content-type: ${mimeType} \n Content-Disposition: attachment;filename=\"${filename}\"`, function (err) {
|
fs.writeFile(_SASJS_WEBOUT_HEADERS, `Content-type: ${mimeType} \n Content-Disposition: attachment;filename=\"${filename}\"`, function (err) {
|
||||||
if (err) throw err;
|
if (err) throw err;
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ switch((type || '').toUpperCase()) {
|
|||||||
mimeType = 'application/octet-stream'
|
mimeType = 'application/octet-stream'
|
||||||
filename += '.md'
|
filename += '.md'
|
||||||
break
|
break
|
||||||
|
case 'PDF':
|
||||||
|
/* the mock layer has no ODS, so it streams the table listing as the
|
||||||
|
body: enough for the client to check the option, the content type
|
||||||
|
and the filename, which is what this layer owns. The real report is
|
||||||
|
laid out by mp_ds2pdf on the SAS side. */
|
||||||
|
mimeType = 'application/pdf'
|
||||||
|
filename += '.pdf'
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
mimeType = 'application/octet-stream'
|
mimeType = 'application/octet-stream'
|
||||||
filename += '.sas'
|
filename += '.sas'
|
||||||
|
|||||||
Generated
+27
-62
@@ -6,8 +6,8 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "dc-sas",
|
"name": "dc-sas",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/cli": "6.2.0",
|
"@sasjs/cli": "6.5.0",
|
||||||
"@sasjs/core": "6.1.0"
|
"@sasjs/core": "7.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
@@ -165,14 +165,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/cli": {
|
"node_modules/@sasjs/cli": {
|
||||||
"version": "6.2.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-6.5.0.tgz",
|
||||||
"integrity": "sha512-zovAOCmyPqcm+gooAZgwvgGH8n5cas3sY0lA80+hyFoFtmZ8VjW/bh19LM8OVwheT9dBCRCx4fnHwjy5XGpzvQ==",
|
"integrity": "sha512-1I7B0iKd+16ByIGPToGfODU3S1bxO/3VCGpmu5alql/HlXdxu75djdpuzCovbsSeWm+M4FrsrBKDZTc9hhBrLA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/adapter": "^5.1.1",
|
"@sasjs/adapter": "^5.1.1",
|
||||||
"@sasjs/core": "^6.0.0",
|
"@sasjs/core": "^6.1.1",
|
||||||
"@sasjs/utils": "^4.1.0",
|
"@sasjs/lint": "4.9.0",
|
||||||
|
"@sasjs/utils": "^4.1.2",
|
||||||
"axios": "1.20.0",
|
"axios": "1.20.0",
|
||||||
"chalk": "4.1.2",
|
"chalk": "4.1.2",
|
||||||
"dotenv": "18.0.8",
|
"dotenv": "18.0.8",
|
||||||
@@ -194,15 +195,18 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@sasjs/lint": "^4.3.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@sasjs/cli/node_modules/@sasjs/core": {
|
||||||
|
"version": "6.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-6.1.4.tgz",
|
||||||
|
"integrity": "sha512-vIF2LIftxPWg4CvO3i5raEfrHFHY/6S+MqVGpjRR71dgjLRJEYNyPJgGqD6BCZwHv5V6/2nDnBi/BIIMiLXfzA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@sasjs/cli/node_modules/@sasjs/utils": {
|
"node_modules/@sasjs/cli/node_modules/@sasjs/utils": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.1.2.tgz",
|
||||||
"integrity": "sha512-aC5pOugg6/2jKQR7ZaYsLtbsFy/iVacsrN3/Kzh6BouJUszuVrr13HMrQUSKcWL+rJzxYof7ymmmCl+vE6YwAw==",
|
"integrity": "sha512-5QLBVlCqiiDW/GoxiHUCyT+8GJjJA/S/TX0DS/t4O4HC1Im/QpQzxlS86QkjLAQBBHovYPyh+v4pgdh+IYwaEQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fast-csv/format": "5.0.7",
|
"@fast-csv/format": "5.0.7",
|
||||||
@@ -235,62 +239,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/core": {
|
"node_modules/@sasjs/core": {
|
||||||
"version": "6.1.0",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-7.1.3.tgz",
|
||||||
"integrity": "sha512-U0Ekq/TXVMquYV+AG6urBQSUgqUvV37yduufy68QQVgDj3PJiLUJDcOXKOGbhWGD+Nv877VuPCviGYqrqbjXvg==",
|
"integrity": "sha512-J86//hNr7pam4qsDYV0XAxm7dnSk+QwFgTLbxJTJp9jMs3j9tHZDkJSaQ7ns7HBn3md/b6ntX7LrXekLNzn0Tg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/lint": {
|
"node_modules/@sasjs/lint": {
|
||||||
"version": "4.3.0",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-4.9.0.tgz",
|
||||||
"integrity": "sha512-65cjy4IjA4PgSlicOruF0DI9N2HO3LJqtgphsZCg8g7N/StE2FWLJLJbD1N4OHxN6htrUfj5SEawJJiHo5T9Yw==",
|
"integrity": "sha512-QEBo19bWUdKIaE62Pk8eb35Q9EjHlifyOmbH+voItX6muCqHTX88SOzLD4MYtFVuagm/cnGplIVcKvUZzO+bSw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/utils": "4.0.0",
|
"@sasjs/utils": "^4.0.0",
|
||||||
"ignore": "7.0.11"
|
"ignore": "7.0.11"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sasjs/lint/node_modules/@sasjs/utils": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-lARoCnRM4gSVf8ShoP5elHi7W28K/14MK037dQ0ABgMlP3+r6OuhAZYwK/a70tED47tKxyIoCAKLdP6jrG0ewQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@fast-csv/format": "5.0.7",
|
|
||||||
"@types/fs-extra": "11.0.4",
|
|
||||||
"@types/prompts": "2.4.9",
|
|
||||||
"chalk": "4.1.1",
|
|
||||||
"consola": "3.4.2",
|
|
||||||
"fs-extra": "11.4.1",
|
|
||||||
"jwt-decode": "4.0.0",
|
|
||||||
"prompts": "2.4.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sasjs/lint/node_modules/chalk": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-styles": "^4.1.0",
|
|
||||||
"supports-color": "^7.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sasjs/utils": {
|
"node_modules/@sasjs/utils": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.1.tgz",
|
||||||
@@ -825,9 +791,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.1",
|
"version": "1.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
|
||||||
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -1106,7 +1072,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz",
|
||||||
"integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==",
|
"integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -3,6 +3,7 @@
|
|||||||
"description": "dc-sas",
|
"description": "dc-sas",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"cpfavicon": "cp sasjs/utils/favicon.ico ../client/dist/favicon.ico",
|
"cpfavicon": "cp sasjs/utils/favicon.ico ../client/dist/favicon.ico",
|
||||||
|
"lint:sas": "sasjs lint",
|
||||||
"build": "cd ../client && npm run build",
|
"build": "cd ../client && npm run build",
|
||||||
"deploy:server": "rsync -avhe ssh ../client/dist/* --delete allbow@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/allbow/server",
|
"deploy:server": "rsync -avhe ssh ../client/dist/* --delete allbow@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/allbow/server",
|
||||||
"sas9": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/${npm_config_account}/dc",
|
"sas9": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/${npm_config_account}/dc",
|
||||||
@@ -33,8 +34,8 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/cli": "6.2.0",
|
"@sasjs/cli": "6.5.0",
|
||||||
"@sasjs/core": "6.1.0"
|
"@sasjs/core": "7.1.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"nanoid": "3.3.18"
|
"nanoid": "3.3.18"
|
||||||
|
|||||||
@@ -5,9 +5,6 @@
|
|||||||
BREAKING CHANGE - deprecate MPE_APPROVALS and MPE_REVIEW (now MPE_SUBMIT does
|
BREAKING CHANGE - deprecate MPE_APPROVALS and MPE_REVIEW (now MPE_SUBMIT does
|
||||||
both)
|
both)
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
|
||||||
@li mm_assigndirectlib.sas
|
|
||||||
|
|
||||||
**/
|
**/
|
||||||
|
|
||||||
%let dclib=YOURDCLIB;
|
%let dclib=YOURDCLIB;
|
||||||
|
|||||||
@@ -23,9 +23,10 @@
|
|||||||
only for options that are not present at all - an existing install carries
|
only for options that are not present at all - an existing install carries
|
||||||
its own rows, so it keeps 500 / 100 until this is run.
|
its own rows, so it keeps 500 / 100 until this is run.
|
||||||
|
|
||||||
Each block below uses %do/%end on BOTH branches. A single-statement
|
Each block below sits inside its own macro, with %do/%end on both branches.
|
||||||
%then/%else in open code (outside a %macro) is rejected by the Viya compute
|
A single-statement branch is rejected by the Viya compute engine with
|
||||||
engine with "Expected %DO not found", so it must not be shortened back.
|
"Expected %DO not found", so it must not be shortened back, and the macro
|
||||||
|
wrapper is what keeps the conditional out of open code.
|
||||||
|
|
||||||
**/
|
**/
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ proc sql noprint;
|
|||||||
where var_name='DC_MAXCELLS_WEBEDIT' and var_active=0 and &now lt tx_to;
|
where var_name='DC_MAXCELLS_WEBEDIT' and var_active=0 and &now lt tx_to;
|
||||||
quit;
|
quit;
|
||||||
|
|
||||||
|
%macro apply_view_limit();
|
||||||
%if &off_view=0 %then %do;
|
%if &off_view=0 %then %do;
|
||||||
proc sql noprint;
|
proc sql noprint;
|
||||||
update &dclib..mpe_config
|
update &dclib..mpe_config
|
||||||
@@ -67,7 +69,10 @@ quit;
|
|||||||
%else %do;
|
%else %do;
|
||||||
%put NOTE: DC_MAXOBS_WEBVIEW is switched off (var_active=0) - not changed;
|
%put NOTE: DC_MAXOBS_WEBVIEW is switched off (var_active=0) - not changed;
|
||||||
%end;
|
%end;
|
||||||
|
%mend apply_view_limit;
|
||||||
|
%apply_view_limit()
|
||||||
|
|
||||||
|
%macro apply_edit_limit();
|
||||||
%if &off_edit=0 %then %do;
|
%if &off_edit=0 %then %do;
|
||||||
proc sql noprint;
|
proc sql noprint;
|
||||||
update &dclib..mpe_config
|
update &dclib..mpe_config
|
||||||
@@ -90,7 +95,10 @@ quit;
|
|||||||
%else %do;
|
%else %do;
|
||||||
%put NOTE: DC_MAXOBS_WEBEDIT is switched off (var_active=0) - not changed;
|
%put NOTE: DC_MAXOBS_WEBEDIT is switched off (var_active=0) - not changed;
|
||||||
%end;
|
%end;
|
||||||
|
%mend apply_edit_limit;
|
||||||
|
%apply_edit_limit()
|
||||||
|
|
||||||
|
%macro apply_cell_limit();
|
||||||
%if &off_cells=0 %then %do;
|
%if &off_cells=0 %then %do;
|
||||||
proc sql noprint;
|
proc sql noprint;
|
||||||
update &dclib..mpe_config
|
update &dclib..mpe_config
|
||||||
@@ -98,7 +106,7 @@ quit;
|
|||||||
where var_name='DC_MAXCELLS_WEBEDIT' and var_active=1 and &now lt tx_to;
|
where var_name='DC_MAXCELLS_WEBEDIT' and var_active=1 and &now lt tx_to;
|
||||||
|
|
||||||
/* Why the cell limit exists, kept out of var_desc - MPE_CONFIG.VAR_DESC
|
/* Why the cell limit exists, kept out of var_desc - MPE_CONFIG.VAR_DESC
|
||||||
is varchar(300), and a truncation WARNING aborts the build. A wide
|
is varchar(300), and a truncation aborts the build. A wide
|
||||||
table is expensive long before it is tall - 250 rows of a 1000 column
|
table is expensive long before it is tall - 250 rows of a 1000 column
|
||||||
table is a quarter of a million cells. */
|
table is a quarter of a million cells. */
|
||||||
insert into &dclib..mpe_config
|
insert into &dclib..mpe_config
|
||||||
@@ -117,3 +125,5 @@ quit;
|
|||||||
%else %do;
|
%else %do;
|
||||||
%put NOTE: DC_MAXCELLS_WEBEDIT is switched off (var_active=0) - not changed;
|
%put NOTE: DC_MAXCELLS_WEBEDIT is switched off (var_active=0) - not changed;
|
||||||
%end;
|
%end;
|
||||||
|
%mend apply_cell_limit;
|
||||||
|
%apply_cell_limit()
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
MPE_DERIVED rows added before this script runs will be lost when the table
|
MPE_DERIVED rows added before this script runs will be lost when the table
|
||||||
is created, so run it before configuring any derived variables.
|
is created, so run it before configuring any derived variables.
|
||||||
|
|
||||||
|
@sasjslint {"maxLineLength": 99}
|
||||||
|
|
||||||
**/
|
**/
|
||||||
|
|
||||||
%let dclib=YOURDCLIB;
|
%let dclib=YOURDCLIB;
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ Areas for optimisation
|
|||||||
@li mf_fmtdttm.sas
|
@li mf_fmtdttm.sas
|
||||||
@li mf_getattrn.sas
|
@li mf_getattrn.sas
|
||||||
@li mf_getengine.sas
|
@li mf_getengine.sas
|
||||||
|
@li mf_getquotedstr.sas
|
||||||
@li mf_getschema.sas
|
@li mf_getschema.sas
|
||||||
@li mf_getuniquefileref.sas
|
@li mf_getuniquefileref.sas
|
||||||
@li mf_getuniquename.sas
|
@li mf_getuniquename.sas
|
||||||
@@ -122,6 +123,8 @@ Areas for optimisation
|
|||||||
@warning multitemporal loads (bitemporal for multiple points in business time)
|
@warning multitemporal loads (bitemporal for multiple points in business time)
|
||||||
are in experimental stage
|
are in experimental stage
|
||||||
|
|
||||||
|
@sasjslint {"noBannedStrings": false, "maxLineLength": 87}
|
||||||
|
|
||||||
**/
|
**/
|
||||||
|
|
||||||
%macro bitemporal_dataloader(
|
%macro bitemporal_dataloader(
|
||||||
@@ -1449,7 +1452,7 @@ run;
|
|||||||
data work.mp_storediffs;
|
data work.mp_storediffs;
|
||||||
set work.mp_storediffs;
|
set work.mp_storediffs;
|
||||||
if MOVE_TYPE="M" and IS_PK=0 and IS_DIFF=0 then delete;
|
if MOVE_TYPE="M" and IS_PK=0 and IS_DIFF=0 then delete;
|
||||||
* putlog load_ref= libref= dsn= key_hash= tgtvar_nm=;
|
/* putlog load_ref= libref= dsn= key_hash= tgtvar_nm=; */
|
||||||
run;
|
run;
|
||||||
proc append base=&outds_audit data=work.mp_storediffs;
|
proc append base=&outds_audit data=work.mp_storediffs;
|
||||||
run;
|
run;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
|
@li bitemporal_closeouts.sas
|
||||||
@li bitemporal_dataloader.sas
|
@li bitemporal_dataloader.sas
|
||||||
@li dc_assignlib.sas
|
@li dc_assignlib.sas
|
||||||
@li mf_getattrn.sas
|
@li mf_getattrn.sas
|
||||||
@@ -1349,44 +1350,6 @@ run;
|
|||||||
proc sql; insert into results set test="TEST&testnum",result="&result";
|
proc sql; insert into results set test="TEST&testnum",result="&result";
|
||||||
%end;
|
%end;
|
||||||
|
|
||||||
/* test 30 - testing a load to OLEDB (should use SQL server temp table) */
|
|
||||||
%if (&test=ALL or &test=30 ) and 0 %then %do; /* skip until have a db */
|
|
||||||
%let testnum=30;
|
|
||||||
|
|
||||||
%assign_direct_lib(libref=XXX);
|
|
||||||
data appendtable&testnum ;
|
|
||||||
set XXX.temp_rk_table
|
|
||||||
(where=(tx_from le %sysfunc(datetime()) lt tx_to));
|
|
||||||
some_num+1;
|
|
||||||
output;
|
|
||||||
some_id=put(ranuni(0)*10000000,8.);output;
|
|
||||||
stop;
|
|
||||||
run;
|
|
||||||
proc sql noprint;
|
|
||||||
select count(*) into:testnobs&testnum from web.temp_rk_table;
|
|
||||||
|
|
||||||
%bitemporal_dataloader(dclib=work,PK=some_rk
|
|
||||||
,append_dsn=appendtable&testnum.
|
|
||||||
,ETLSOURCE=bitemporal_tester test&testnum override
|
|
||||||
,tech_from=tx_from
|
|
||||||
,tech_to=tx_to
|
|
||||||
,base_lib=web
|
|
||||||
,base_dsn=temp_rk_table
|
|
||||||
,LOG=YES
|
|
||||||
,rk_underlying=some_id
|
|
||||||
,LOADTARGET=YES
|
|
||||||
,LOADTYPE=TXTEMPORAL
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
%let result=;
|
|
||||||
proc sql noprint;
|
|
||||||
select count(*) into:testnobs2&testnum from web.temp_rk_table;
|
|
||||||
%if (&&testnobs2&testnum =%eval(&&testnobs&testnum+2)) %then %let result=PASS;
|
|
||||||
%else %let result=FAIL;
|
|
||||||
proc sql; insert into results set test="TEST&testnum",result="&result";
|
|
||||||
%end;
|
|
||||||
|
|
||||||
/* test 31 - bitemporal closeouts with embedded speechmarks in PK */
|
/* test 31 - bitemporal closeouts with embedded speechmarks in PK */
|
||||||
%let testnum=31;
|
%let testnum=31;
|
||||||
%if &test=ALL or &test=&testnum %then %do;
|
%if &test=ALL or &test=&testnum %then %do;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
<h4> SAS Macros </h4>
|
||||||
@li mf_getuniquename.sas
|
|
||||||
@li mf_getuser.sas
|
@li mf_getuser.sas
|
||||||
@li mp_assertdsobs.sas
|
@li mp_assertdsobs.sas
|
||||||
@li mpe_getgroups.sas
|
@li mpe_getgroups.sas
|
||||||
|
|||||||
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user