Compare commits

..
Author SHA1 Message Date
allan 24636c9a87 Merge pull request 'fix(security): escape cell values and harden libds inputs' (#319) from security/harden-xss-and-inputs into fix/viya-test-suite-and-refreshcatalog-json
Build / Build-and-ng-test (pull_request) Successful in 5m28s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m5s
Build / Build-and-test-development (pull_request) Successful in 24m38s
Reviewed-on: #319
2026-09-14 16:20:07 +00:00
hermes 11f56cc54a refactor(deploy): remove vestigial client credential fields
Build / Build-and-ng-test (pull_request) Successful in 5m25s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m57s
Build / Build-and-test-development (pull_request) Successful in 24m44s
The deploy client_id/client_secret fields were only read from localStorage
and never used by the manual or automatic deploy flows. Remove the dead
code so no credential-shaped value is read from browser storage.
2026-09-14 16:45:20 +01:00
hermes 2d31e5a8b9 fix(security): validate libds in stagedata and loadfile
Sanitise the libref.dataset input the same way getdata does, via
mp_validatecol, and abort the service on an invalid value to prevent code
injection through the libds identifier. Format catalog inputs resolve to
work.fmtextract and still pass the check.
2026-09-14 16:45:13 +01:00
hermes cfd8f06435 fix(security): escape cell values in status renderers to prevent DOM XSS
The error/no-spinner/spinner cell renderers wrote the cell value straight
into td.innerHTML. A value containing markup (which can arrive from a dataset
served by the getdata stored program or from a typed edit) was therefore
parsed and executed by the browser. Escape the value so it renders as inert
text, keeping the hardcoded icon markup intact, and add a regression test
that reproduces the injection via a real Handsontable instance.
2026-09-14 16:44:59 +01:00
hermes a6a111db8a test(viya): scope stagedata abort assertions to the submitted load
Build / Build-and-ng-test (pull_request) Successful in 6m4s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m41s
Build / Build-and-test-development (pull_request) Successful in 25m35s
The abort checks matched any non-success load in the time window, so a parallel session's aborted/IN PROGRESS load could satisfy them even if the tested load completed. Key them on this test's unique submitted message (reason_txt), which is preserved because a hook-aborted load finishes before mpe_loadfail can overwrite it.
2026-09-14 13:25:32 +01:00
allan ca19dbaefb Merge pull request 'chore(hooks): add gitleaks secret scan to pre-commit' (#317) from chore/gitleaks-precommit into fix/viya-test-suite-and-refreshcatalog-json
Build / Build-and-ng-test (pull_request) Successful in 5m26s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m28s
Build / Build-and-test-development (pull_request) Successful in 25m39s
Reviewed-on: #317
2026-09-14 12:15:38 +00:00
hermes 8418e0cb1d docs(contributing): document one-time git hook activation
Build / Build-and-ng-test (pull_request) Successful in 5m21s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m1s
Build / Build-and-test-development (pull_request) Successful in 24m44s
2026-09-14 12:44:10 +01:00
hermes d301fc2b4d fix(deps): bump @sasjs/cli to 4.20.3
Clears the moderate adm-zip advisory (GHSA-vwc7-r8mq-g2x9) from the sas npm audit gate - @sasjs/cli 4.20.3 replaces adm-zip with yauzl/yazl.
2026-09-14 12:43:58 +01:00
dc fbcde41321 fix(hooks): require the repo-pinned gitleaks, drop the fallbacks
Build / Build-and-ng-test (pull_request) Failing after 1m46s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 21m12s
A soft skip (and the system-gitleaks fallback) let a clone without
node_modules commit with no secrets scan at all, so the hook would
never force the install. The hook now hard-blocks with a hint to run
'npm i' until the @nogoo9/gitleaks binary is present in
node_modules/.bin.

Verified with the binary present (scan runs and passes) and absent
(blocked, exit 1).
2026-09-10 23:06:34 +00:00
dc 53838036e6 fix(hooks): make the pre-commit size check actually run
Build / Build-and-ng-test (pull_request) Failing after 1m47s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m51s
The size check sat after the prettier block, whose branches both exit,
so it was unreachable - and it had three further defects: the exit 1
lived inside a ( ... ) subshell so it could never block the commit,
the 2**20 arithmetic errors under /bin/sh (dash) leaving size_limit
unset, and it measured HEAD^..HEAD (the previous commit) rather than
the content being committed, which does not exist yet in pre-commit.

Rewrite it to sum the sizes of the new blobs the commit would
introduce (git diff --cached --raw, new blob ids -> cat-file
--batch-check), keep the 2MB threshold and the documented --no-verify
escape hatch, and move it before the prettier check so it always runs.

Verified with a staged 3MB blob (blocked, exit 1), a small staged
change (passes through to prettier), and a mode-only change (no new
blob, passes).
2026-09-10 23:03:43 +00:00
dc b3b9755aa7 chore(hooks): add gitleaks secret scan to pre-commit
Build / Build-and-ng-test (pull_request) Failing after 1m45s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m14s
Run 'gitleaks protect --staged' before prettier in the pre-commit hook.
The gitleaks binary comes from the @nogoo9/gitleaks devDependency,
which ships official binaries as platform optionalDependencies - no
postinstall scripts, so it installs under ignore-scripts=true.
Falls back to a system gitleaks, and soft-skips with a hint to run
'npm i' on a fresh clone rather than blocking the commit.
2026-09-10 22:36:44 +00:00
hermes 82a45b80b4 chore(viya): run tests on the reusable compute context, harden chunk deploy
Build / Build-and-ng-test (pull_request) Successful in 5m20s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m59s
Build / Build-and-test-development (pull_request) Successful in 24m27s
The test jobs on the nextviya estate run as a batch identity that is
not authorised to create sessions in the SAS Job Execution compute
context - child service submissions via mx_execute all failed with
403.  Default the test context to Compute Reusable (matching the
target contextName) in the shared test init programs.

Also give the chunk deploy helper a retry loop for the per-chunk
sasjs run timeouts - estate cold-starts can exceed a single 300s
window.
2026-09-08 20:52:19 +01:00
hermes a8194d73e7 test(viya): align stagedata test payloads with the adapter contract
The web editor submits date/datetime/time cells as formatted strings
and always provides the format-catalog buskey, so the staged CSVs
round-trip through the ANYDT* informats in mpe_loader.  Some tests
built jsdata with raw numeric values or without FMTROW, causing the
staged loads to fail on Viya.

- getstagetable.test: send some_date/some_datetime/some_time as
  formatted strings (as postdata.test.1 already does), and assert on
  flags captured from the first read - the JSON libname can only be
  read once, so re-reading stagetable returns -1 observations
- postdata.test.2: add fmtrow to the staged format catalog rows (the
  TYPE FMTNAME FMTROW buskey)
- stagedata.test.3: assert the #211 hook aborts through MPE_LOADS - a
  hook-aborted child never returns a webout, and the load stays IN
  PROGRESS because mpe_loadfail cannot run after the abort.  The WIDEBOY
  RLS rule is inserted in the prep section so the (possibly pooled)
  child session reads it from the shared filesystem
2026-09-08 20:52:19 +01:00
hermes 1dccf61f62 fix(admin): return refreshcatalog output as webout JSON
The service wrote a raw HTML line straight to the _webout fileref
after the load completed.  On Viya, API-submitted jobs (eg via
mx_execute) do not always have that fileref connected to a job result,
so the output was lost and job-result consumers aborted.  Return a
proper sasparams table through %webout() instead - the same pattern as
the other admin services - and update the test to assert on the
webout sasparams message.
2026-09-08 20:52:19 +01:00
hermes 09f31ce6da fix(deps): bump @sasjs/core to 5.2.7
Brings in the mv_jobflow JSON-escape fix (prevents jobExecution 400s
for job arguments containing quotes or CR/LF) and the graceful
handling of canceled jobs in mv_getjoblog / mv_getjobresult (a
canceled child no longer aborts the calling program when its log or
result is unavailable).  Verified against a live Viya 2026.07 estate:
the full SAS test suite passes with these macros sourced from core.
2026-09-08 20:52:19 +01:00
semantic-release-bot 90c937c32a chore(release): 7.14.0 [skip ci]
# [7.14.0](https://git.datacontroller.io/dc/dc/compare/v7.13.0...v7.14.0) (2026-09-04)

### Features

* **system:** show session timezone on system information screen ([966dc07](966dc072e0))
2026-09-04 14:31:37 +00:00
allan d9bd46e97b Merge pull request 'feat(system): show session timezone on system information screen' (#315) from fix/system-timezone-info into main
Release / Build-production-and-ng-test (push) Successful in 6m47s
Release / Build-and-test-development (push) Successful in 25m59s
Release / release (push) Successful in 10m45s
Reviewed-on: #315
2026-09-04 13:52:54 +00:00
allan 55c38805d7 Merge branch 'main' into fix/system-timezone-info
Build / Build-and-ng-test (pull_request) Successful in 5m37s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m58s
Build / Build-and-test-development (pull_request) Successful in 25m13s
2026-09-04 12:19:50 +00:00
hermes 966dc072e0 feat(system): show session timezone on system information screen
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m13s
Build / Build-and-test-development (pull_request) Successful in 25m42s
Add TIMEZONE, SYSTIMEZONEIDENT and SYSTIMEZONEOFFSET to the startupservice
globvars and display them in the Environment Details block, so the session
clock used to stamp submission timestamps is directly visible to admins.
2026-09-04 12:15:01 +00:00
semantic-release-bot 81c725d10b chore(release): 7.13.0 [skip ci]
# [7.13.0](https://git.datacontroller.io/dc/dc/compare/v7.12.0...v7.13.0) (2026-09-03)

### Bug Fixes

*  dynamic js mocks ([3b9344c](3b9344cb60))
* adding formula types ([c07a01a](c07a01a38d))
* address hermes review feedback (formula quoting, cell revert, addRow guard) ([36963aa](36963aa746))
* agent skills and nextviya deploys ([f7db871](f7db8719f5))
* bump core for mp_validate fix ([5a44b28](5a44b2804f))
* CAS support for REPLACE type plus docs ([ea05f07](ea05f07180))
* clarify debug comment is permanent, extend ComputeContextDetails with Viya response fields ([72484ae](72484ae844))
* core major bump plus autofix of viya context on deploy ([6527c10](6527c10f26))
* **core:** bump to v5 (breaking change) ([59e9e96](59e9e96f5a))
* **cypress:** correct mock data, test expectations, and abort modal ([13c10be](13c10be30f))
* **cypress:** make licensing combined-key tests order-independent ([4db8d62](4db8d62dca))
* **deploy:** Viya deploy checks, startup diagnostics, and chunked deploy script ([917925f](917925f553)), closes [#303](#303) [#200](#200) [#125](#125)
* **deps:** align @angular/* packages to the same lockstep version ([24e6297](24e6297187))
* **deps:** override nanoid to 3.3.18 in sas/ ([d57ae03](d57ae03fc4))
* **deps:** pin @handsontable/angular-wrapper to 18.0.0 ([2d7df66](2d7df66fc2))
* **deps:** pin babel-loader to resolve an unresolvable peer conflict ([3bf3bf0](3bf3bf0dde))
* **deps:** regenerate lockfile with Node 24 and pin handsontable to 18.0.0 ([7da29bd](7da29bdb11))
* **deps:** regenerate lockfile with strict peer-dep resolution to fix npm ci in CI ([efd2e18](efd2e18c7b))
* **deps:** regenerate package-lock.json to resolve npm ci sync errors ([a501903](a501903e6d))
* **deps:** resolve npm audit findings and align @angular/* to the same lockstep version ([b9e4b27](b9e4b2733f))
* **deps:** resolve npm audit findings via direct lockfile patch, not regeneration ([4d1bfa6](4d1bfa6343))
* **deps:** resolve npm audit vulnerabilities in production dependencies ([257f69c](257f69ccc6))
* **deps:** scope the brace-expansion override to its actual vulnerable chain ([f9d061c](f9d061c489))
* dynamic getdynamiccolvals.js ([d59ef44](d59ef44226))
* **editor:** clear sort before reading cells to preserve on cancel ([4137ebd](4137ebdf6b))
* **editor:** escape formula-looking values in uploaded Excel data ([e69df3d](e69df3deb2))
* **editor:** fix formula $-substitution corruption and sorted-grid row/formula desync ([e139c37](e139c37fbf))
* **editor:** let the column info dropdown's text be selected and copied ([1e516f4](1e516f4012))
* **editor:** re-mark a reverted cell as auto-escaped so "Apply as formula" works again ([12091c4](12091c4044))
* **editor:** resolve a primary key's live formula to its computed value, not the raw formula text ([d72e19a](d72e19a308))
* **editor:** resolve computed value for a live formula in numeric columns too ([2cc8489](2cc8489892))
* **editor:** restore multi-column sort with one call, not a loop ([8fafeb7](8fafeb7923))
* **editor:** size table-header buttons and title to content, not fixed grid thirds ([d0a7561](d0a7561f1a))
* **editor:** stop dc.row_status from being submitted to the backend ([dc0f6a7](dc0f6a7baa))
* **editor:** stop turning missing columns into undefined, and fix column lookup for sparse rows ([a104f78](a104f78645))
* **editor:** submit a live formula's computed value, not its raw text ([b7d3106](b7d31066ae))
* ensure cleanup of casuser temp table in error condition ([c4123e5](c4123e5c96))
* ensuring formats arrive from backend in getstagetable.sas ([daacb49](daacb49c8a))
* escape regex metacharacters in context name before prxchange ([9023eb2](9023eb2a39))
* failing test ([f213d24](f213d24f09))
* fixed npm vuln ([26b55b1](26b55b1bde))
* **formulas:** avoid EDIT_STATUS colliding with a real column of that name ([acb97f4](acb97f4bfb))
* hardening following PR review feedback ([1f4aa6f](1f4aa6fcea))
* history mock ([0082828](0082828543))
* **licensing:** bridge async proceed() through cy.then and replace Response.arrayBuffer with manual stream reader ([8e51fde](8e51fde524))
* **mocks:** add fixture staged data and MPE_SUBMIT row for stage.cy.ts ([6e2548d](6e2548d78c))
* **mocks:** add LIBRARYNAME to startupservice, fix viewbox column order ([c0af86e](c0af86ef29))
* **mocks:** limit stage fixture to 1 row, fix column order ([f25ecd8](f25ecd8828))
* **mocks:** look up PK_FIELDS from MPE_TABLES in viewdata, fallback for MPE_AUDIT ([94d2a79](94d2a7970e))
* **mocks:** remove MPE_VALIDATIONS and MPE_ALERTS from DC996664 libref ([4f7b845](4f7b845390))
* **mocks:** remove shadowed saveTableData in rejection.js ([fed0396](fed0396737))
* **mocks:** remove SOME_SHORTNUM HARDREGEX from MPE_VALIDATIONS ([34bd660](34bd6607c5))
* **mocks:** rename saveTableData in postdata.js to avoid shadowing ([e4b04b1](e4b04b1d44))
* **mocks:** SAS datetime format in getchangeinfo, login in stage.cy.ts ([c180934](c180934167))
* more mock improvements ([a80927c](a80927cbb6))
* mp_execute dep ([023c29f](023c29f00f))
* pin fast-uri to 3.1.7 to close high-severity SSRF/host-confusion advisories ([f4325b2](f4325b2056)), closes [hi#severity](https://git.datacontroller.io/hi/issues/severity)
* spy for startup ([69b4155](69b41559f7))
* **stage:** submit data via editor flow, use dynamic date assertion ([490d013](490d0137b9))
* **startup:** show the real startupservice response text on a malformed reply ([9a1b7d0](9a1b7d0f52))
* tests and realistic getcolvals.js ([5d03164](5d03164b6a))
* **tests:** new libref ([c0bc87d](c0bc87d331))
* unnecessary getsubmits call removed ([cc2ff87](cc2ff873fa))
* using utility macro for webout ([3595bb9](3595bb9499))

### Features

* **editor:** color row-header status cells and switch modified symbol to ± ([e15f2c2](e15f2c2a34))
* **editor:** generalize cell revert to any overwritten value, not just formulas ([07d586d](07d586da52))
* **editor:** scope live formulas to character columns, not just HARDFORMULA/SOFTFORMULA ones ([92d0d91](92d0d91440))
* **editor:** translate column names to cell references on formula paste ([1031ea7](1031ea7ed7))
* **formulas:** flag formula-overwritten cells with revert, harden dc.row_status against SAS name collisions ([a8237b2](a8237b2881))
* **formulas:** live DC.ROW_STATUS, insert-row formula fixes, UserService singleton fix ([4a8c39b](4a8c39b4c0))
* **formulas:** wire HyperFormula into HARDFORMULA/SOFTFORMULA rules ([cbea04c](cbea04c8e1))
* functional JS mocks ([ee52bb8](ee52bb8967))
* **licensing:** support combined-key paste, object-format features, and a live key preview ([20ba633](20ba633087))
* **licensing:** warn and block applying a licence key generated for the wrong protocol ([41680e2](41680e2ecc))
* **stage:** add Formatted/Unformatted toggle to the staging page ([74c38e1](74c38e1641))
* updated configurator for Viya deploy ([df2027d](df2027dab6))
* **viewboxes:** add drag-to-resize on all four edges and corners ([e5a5bf2](e5a5bf2144))
2026-09-03 14:48:53 +00:00
allan 34ff2ac8d0 Merge pull request 'Version 7.13 Release' (#294) from version7-13 into main
Release / Build-production-and-ng-test (push) Successful in 4m46s
Release / Build-and-test-development (push) Successful in 24m7s
Release / release (push) Successful in 8m58s
Reviewed-on: #294
2026-09-03 14:16:02 +00:00
allan 1ad27d55f1 Merge pull request 'fix(editor): restore multi-column sort with one call, not a loop' (#312) from version7-13-fix into version7-13
Build / Build-and-ng-test (pull_request) Successful in 6m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m40s
Build / Build-and-test-development (pull_request) Successful in 24m57s
Reviewed-on: #312
2026-09-03 10:12:21 +00:00
YuryShkoda 8fafeb7923 fix(editor): restore multi-column sort with one call, not a loop
Build / Build-and-ng-test (pull_request) Successful in 5m23s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m1s
Build / Build-and-test-development (pull_request) Successful in 25m4s
multiColumnSorting's sort() replaces the entire sort state on every call rather than accumulating, so the restore loop after updateSettings()/editTable()/cancelEdit() silently dropped every sorted column but the last. Pass the captured configs as a single array instead. Adds a Karma integration test proving the loop
loses columns, and a Cypress test verifying cancelEdit() keeps a real shift-click multi-column sort intact end-to-end.
2026-09-03 12:46:31 +03:00
allan f797b1130a Merge pull request 'fix(editor): fix formula $-substitution corruption and sorted-grid row/formula desync' (#311) from version7-13-fix into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m30s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m1s
Build / Build-and-test-development (pull_request) Successful in 24m32s
Reviewed-on: #311
2026-09-03 09:21:56 +00:00
YuryShkoda 4137ebdf6b fix(editor): clear sort before reading cells to preserve on cancel
Build / Build-and-ng-test (pull_request) Successful in 5m16s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m2s
Build / Build-and-test-development (pull_request) Successful in 24m42s
cancelEdit()'s getFormulaCellsToPreserveOnCancel callbacks passed a
physical rowIndex straight into visual-row-expecting Handsontable APIs
(getCommentAtCell, getDataAtRowProp) while the grid could still be
sorted, since clearSort() only ran later, right before updateSettings().
On a sorted grid this read the wrong row's comment/value, corrupting
which formula cells got preserved across cancel. Move clearSort() (via
getCurrentSortConfigs()) ahead of those callbacks instead.
2026-09-03 11:47:24 +03:00
YuryShkoda 3380150d1c refactor(editor): harden pasteListener cleanup and document handsontable version pin
Build / Build-and-ng-test (pull_request) Successful in 5m58s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m39s
Build / Build-and-test-development (pull_request) Successful in 25m27s
2026-09-03 10:59:19 +03:00
YuryShkoda 2d7df66fc2 fix(deps): pin @handsontable/angular-wrapper to 18.0.0
Build / Build-and-ng-test (pull_request) Successful in 5m24s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m35s
Build / Build-and-test-development (pull_request) Successful in 25m4s
18.1.0 has a malformed license field (points at a logo SVG instead of a real identifier), failing the license checker's --onlyAllow gate.
2026-09-03 10:40:56 +03:00
YuryShkoda efd2e18c7b fix(deps): regenerate lockfile with strict peer-dep resolution to fix npm ci in CI
Build / Build-and-ng-test (pull_request) Failing after 1m51s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 2m0s
2026-09-03 10:19:38 +03:00
YuryShkoda 7da29bdb11 fix(deps): regenerate lockfile with Node 24 and pin handsontable to 18.0.0
Lighthouse Checks / lighthouse (pull_request) Failing after 1m3s
Build / Build-and-ng-test (pull_request) Failing after 45s
Build / Build-and-test-development (pull_request) Skipped
2026-09-03 09:37:48 +03:00
YuryShkoda 8f12fe74a6 chore: regenerated client/package-lock.json
Build / Build-and-ng-test (pull_request) Failing after 54s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 1m12s
2026-09-03 09:14:02 +03:00
YuryShkoda f4325b2056 fix: pin fast-uri to 3.1.7 to close high-severity SSRF/host-confusion advisories
Build / Build-and-ng-test (pull_request) Failing after 53s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 1m5s
2026-09-03 09:10:02 +03:00
YuryShkoda cc68c218d8 refactor(editor): document updateSettingsSortSafe's exceptions and dedupe sort-config normalization
Build / Build-and-ng-test (pull_request) Failing after 2m7s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 21m46s
2026-09-03 08:57:35 +03:00
allan d3b881fa85 Merge branch 'version7-13' into version7-13-fix
Build / Build-and-ng-test (pull_request) Successful in 5m55s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m23s
Build / Build-and-test-development (pull_request) Successful in 25m15s
2026-09-02 14:39:26 +00:00
YuryShkoda e139c37fbf fix(editor): fix formula $-substitution corruption and sorted-grid row/formula desync
Build / Build-and-ng-test (pull_request) Successful in 5m23s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m6s
Build / Build-and-test-development (pull_request) Successful in 25m7s
String.replace interpreted $&/$`/$'/$<digit> specially in DC.ORIG_VALUE/DC.USER_NAME substitutions, corrupting formulas whose cell data happened to contain a literal $. Separately, afterChange indexed dataSource with Handsontable's visual row instead of translating to physical (as beforeChange already does), desyncing EDIT_STATUS/overwritten-comment writes on a sorted grid. Investigating that surfaced a third, unrelated Handsontable bug: updateSettings() corrupts formula cell references whenever a sort is active, regardless of what it changes - fixed by clearing the sort around every updateSettings() call.
2026-09-02 17:19:58 +03:00
Unknown c4123e5c96 fix: ensure cleanup of casuser temp table in error condition
Build / Build-and-ng-test (pull_request) Successful in 5m26s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m58s
Build / Build-and-test-development (pull_request) Successful in 24m16s
2026-09-02 14:07:16 +01:00
hermes 1ed31f2a49 chore: docs
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m52s
Build / Build-and-test-development (pull_request) Successful in 24m31s
2026-08-31 08:54:05 +01:00
allan 25434b35b9 Merge pull request 'fix(editor): submit a live formula's computed value, not its raw text' (#310) from process-formula into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m11s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m34s
Build / Build-and-test-development (pull_request) Successful in 24m12s
Reviewed-on: #310
2026-08-30 22:00:06 +00:00
hermes 490d0137b9 fix(stage): submit data via editor flow, use dynamic date assertion
Build / Build-and-ng-test (pull_request) Successful in 5m14s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m30s
Build / Build-and-test-development (pull_request) Successful in 24m11s
Rewrite stage.cy.ts to follow the same pattern as excel.cy.ts:
open MPE_X_TEST, attach regular_excel.xlsx, submit to create staged
data, then test the formatted/unformatted toggle on the stage page.

The date assertion is now dynamic: reads the raw SOME_DATE value
from the unformatted view and computes the expected SAS date9.
formatted value, rather than hardcoding 12FEB1960.

Remove the fixture stageSubmission code from makedata.js — staged
data is created at runtime by stagedata.js, not by makedata.
2026-08-30 22:29:40 +01:00
hermes f25ecd8828 fix(mocks): limit stage fixture to 1 row, fix column order
Build / Build-and-ng-test (pull_request) Successful in 5m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m49s
Build / Build-and-test-development (pull_request) Successful in 24m6s
The stage.cy.ts fixture had 7 rows of MPE_X_TEST data with wide string
values, causing Handsontable to auto-size columns wider so only 4 of
10 fit in the 1280px viewport. The test checks .ht_clone_top headers
which only render visible columns.

Limited to 1 row (matching the original sas9 mock) and moved
_____DELETE__THIS__RECORD_____ to the end of the column order.
2026-08-30 20:20:15 +01:00
hermes c180934167 fix(mocks): SAS datetime format in getchangeinfo, login in stage.cy.ts
getchangeinfo.js: use SAS datetime19. format (DDMMMYYYY:HH:MM:SS)
for SUBMITTED_ON_DTTM and REVIEWED_ON_DTTM, matching the real SAS
output. Use SUBMIT_STATUS_CD field name (not REVIEW_STATUS_ID)
to match the MPE_SUBMIT column name and getchangeinfo.sas output.

stage.cy.ts: add login step in beforeEach (the beforeAll logs out
after uploading the license, so each test needs to re-login).
Use loginAndUpdateValidKey(true) to force license upload.
2026-08-30 20:10:52 +01:00
hermes 6e2548d78c fix(mocks): add fixture staged data and MPE_SUBMIT row for stage.cy.ts
Build / Build-and-ng-test (pull_request) Successful in 5m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m25s
Build / Build-and-test-development (pull_request) Failing after 25m9s
stage.cy.ts navigates directly to /stage/DC20221007T122326121_612316_7259
and expects a hot table with MPE_X_TEST data. The mock had no fixture
staged.json or MPE_SUBMIT row for this table ID, so getstagetable
returned 0 rows and getchangeinfo returned empty jsparams.

Added a fixture staged.json with MPE_X_TEST rows (SOME_DATE=42) and
a matching MPE_SUBMIT row (SUBMITTED status) in makedata.js.
2026-08-30 19:25:42 +01:00
hermes 94d2a7970e fix(mocks): look up PK_FIELDS from MPE_TABLES in viewdata, fallback for MPE_AUDIT
Build / Build-and-ng-test (pull_request) Successful in 5m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m54s
Build / Build-and-test-development (pull_request) Failing after 25m39s
viewdata.js hardcoded PK_FIELDS as empty, so the viewbox grid treated
all columns as non-PK. removeAllColumns() could delete every column
including the ones the tests expect to be protected.

Now reads MPE_TABLES buskey (mirrors mp_getpk) and falls back to
LOAD_REF LIBREF DSN KEY_HASH TGTVAR_NM for MPE_AUDIT (which has a
known index-based PK in real SAS but isn't registered in MPE_TABLES).

All 12 viewbox.cy.ts tests now pass.
2026-08-30 19:18:36 +01:00
hermes c0af86ef29 fix(mocks): add LIBRARYNAME to startupservice, fix viewbox column order
Build / Build-and-ng-test (pull_request) Successful in 5m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m27s
Build / Build-and-test-development (pull_request) Failing after 27m3s
startupservice.js only returned LIBREF in saslibs, but the dc-tree
component renders LIBRARYNAME — without it the nav tree node text was
empty, so libraryToOpenIncludes could not match. Added LIBRARYNAME,
LIBRARYID, and ENGINE to match the viewlibs service format.

Changed libraryToOpenIncludes_SASJS from 'dc' to 'dc_jslib' to avoid
matching multiple libraries (DC_JSLIB and DC996664 both contain 'dc').

viewdata.js: removed alphabetical .sort() on cols (SAS preserves
physical column order via mp_getcols); added empty row with correct
SAS missing values ('.' for numeric, '' for character) when a table
has no data, so the viewbox grid can derive column headers.
2026-08-30 18:52:54 +01:00
hermes 34bd6607c5 fix(mocks): remove SOME_SHORTNUM HARDREGEX from MPE_VALIDATIONS
Build / Build-and-ng-test (pull_request) Successful in 5m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m6s
Build / Build-and-test-development (pull_request) Failing after 31m25s
The Excel fixture files (7th_tab, extra_column, duplicate_column,
duplicate_row, regular_xls, regular_with_delete, password, leading
whitespace) contain SOME_SHORTNUM values of 0, 1, and 2. The
HARDREGEX /^(?![0-2](\.\d+)?$).*/ blocks those values, aborting
the upload before it reaches the submit/approve flow. Removing the
rule allows all 23 excel.cy.ts tests and both liveness.cy.ts tests
to pass.
2026-08-30 10:30:32 +01:00
hermes e4b04b1d44 fix(mocks): rename saveTableData in postdata.js to avoid shadowing
Same shadowing issue as rejection.js: postdata.js defined its own
saveTableData(tableName, tableData, tableLibref) which was shadowed
by dcMockUtils.saveTableData(dataDir, tableName, tableData) from the
eval. Renamed to saveTableToLib to eliminate the collision.
2026-08-30 02:39:57 +01:00
hermes fed0396737 fix(mocks): remove shadowed saveTableData in rejection.js
rejection.js defined its own local saveTableData(tableName, tableData)
with 2 args, but dcMockUtils.js (loaded via eval) also defines
saveTableData(dataDir, tableName, tableData) with 3 args. The
dcMockUtils version was shadowing the local one, causing
saveTableData('MPE_SUBMIT', submitData) to pass the table name as
dataDir and the data object as tableName, crashing with
'TypeError: tableName.toLowerCase is not a function'.

Removed the local saveTableData and updated calls to use the
dcMockUtils 3-arg signature: saveTableData(dataDir, tableName, data).
2026-08-30 02:38:13 +01:00
hermes 4f7b845390 fix(mocks): remove MPE_VALIDATIONS and MPE_ALERTS from DC996664 libref
Build / Build-and-ng-test (pull_request) Successful in 5m20s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m59s
Build / Build-and-test-development (pull_request) Failing after 8h28m57s
The multi-load fixture sheets for DC996664.MPE_VALIDATIONS had data
misaligned with the schema, corrupting the shared submit state. The
tests only need MPE_X_TEST and MPE_TABLES, so only register those two.
2026-08-30 01:18:07 +01:00
hermes 13c10be30f fix(cypress): correct mock data, test expectations, and abort modal
Build / Build-and-ng-test (pull_request) Successful in 5m20s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m39s
Build / Build-and-test-development (pull_request) Failing after 34m1s
- editor.cy.ts: assert DC.USER_NAME is non-empty instead of hardcoded
  'sasdemo' (CI runs as a different OS user)
- editor.cy.ts: use =SOME_BESTNUM * 0 + 1936 for a deterministic formula
  result regardless of the column's actual value
- excel.cy.ts: expect computed value '2' instead of formula text '=1+1'
  (no XL_RULE=FORMULA configured on that column)
- excel-multi-load.cy.ts: use DC996664 libref to match the actual fixture
  sheet names
- makedata.js: add labels to DC_JSLIB.MPE_X_TEST schema (SOME_CHAR,
  SOME_DATE, SOME_DATETIME, SOME_TIME) for the viewer-labels tests
- makedata.js: register DC996664 libref with MPE_X_TEST/MPE_TABLES/
  MPE_VALIDATIONS/MPE_ALERTS and copy table files for multi-load fixtures
- makedata.js: change SOME_SHORTNUM HARDREGEX from [1-5] to [0-2]
- loadfile.js: exclude VAR_TXFROM/VAR_TXTO/VAR_PROCESSED columns from the
  missing-cols check for TXTEMPORAL tables (computed at load time)
- InfoModal.ts: add _PROGRAM to AbortDetails
- info-modal.component.html: display _PROGRAM in the abort modal
- 4 call sites: pass abortRes._PROGRAM when building AbortDetails
2026-08-29 23:53:13 +01:00
hermes 8e51fde524 fix(licensing): bridge async proceed() through cy.then and replace Response.arrayBuffer with manual stream reader
Build / Build-and-ng-test (pull_request) Successful in 5m9s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m31s
Build / Build-and-test-development (pull_request) Failing after 33m53s
- licensing.cy.ts: call proceed() unconditionally via cy.then() instead of conditionally chaining .then() after cy.wait(), so the command queue stays open across the async gap and enqueued commands always run
- combinedLicenceKey.ts: replace new Response(stream).arrayBuffer() with an explicit streamToArrayBuffer helper for broader browser support
- remove debug-license.cy.ts, an untracked throwaway repro for the promise-handling question
2026-08-29 20:13:22 +01:00
YuryShkoda 4db8d62dca fix(cypress): make licensing combined-key tests order-independent
Build / Build-and-ng-test (pull_request) Successful in 5m16s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m48s
Build / Build-and-test-development (pull_request) Failing after 33m43s
- Tests 7-10 only acted when already on the licensing page, silently
  no-oping otherwise; now they navigate to licensing/update explicitly
  when needed, chained via .then() to avoid Cypress conflating a raw
  async function's promise with the preceding cy.visit()/cy.wait()
- Replace Response(readable).arrayBuffer() with a direct
  ReadableStream reader loop in generateCombinedKey - the Response-based
  read never settled under Cypress, likely due to its fetch/Response
  patching for network interception
2026-08-29 14:06:11 +01:00
hermes 5d03164b6a fix: tests and realistic getcolvals.js
Build / Build-and-ng-test (pull_request) Successful in 5m49s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m56s
Build / Build-and-test-development (pull_request) Failing after 33m48s
2026-08-29 13:14:18 +01:00
hermes c0bc87d331 fix(tests): new libref
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m39s
Build / Build-and-test-development (pull_request) Failing after 33m47s
2026-08-29 00:17:13 +01:00
hermes d59ef44226 fix: dynamic getdynamiccolvals.js
Build / Build-and-ng-test (pull_request) Successful in 5m22s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m27s
Build / Build-and-test-development (pull_request) Failing after 33m37s
2026-08-28 23:40:44 +01:00
hermes f213d24f09 fix: failing test
Build / Build-and-ng-test (pull_request) Successful in 5m24s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m25s
Build / Build-and-test-development (pull_request) Failing after 33m33s
2026-08-28 21:29:15 +01:00
YuryShkoda 20ba633087 feat(licensing): support combined-key paste, object-format features, and a live key preview
- Accept a single pasted/uploaded combined licence key (base64+gzip of
  licence+activation key), auto-detected in either paste mode, alongside
  the legacy two-field format
- Warn and block applying a key generated for the wrong protocol before
  it ever reaches the backend
- Decode both the legacy positional features string and a new named
  features object, so already-issued keys keep working unchanged
- Show a live "Key Details" preview (validity, users, site IDs, enabled
  features) as a key is typed, pasted, or uploaded
- Fix cypress test setup broken by the paste-format default change, and
  an unreliable FileReader-based base64 conversion in the test helpers
2026-08-28 20:01:45 +01:00
hermes a80927cbb6 fix: more mock improvements
Build / Build-and-ng-test (pull_request) Successful in 5m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m34s
Build / Build-and-test-development (pull_request) Failing after 33m31s
2026-08-28 16:25:29 +01:00
hermes cc2ff873fa fix: unnecessary getsubmits call removed
Build / Build-and-ng-test (pull_request) Successful in 5m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m40s
Build / Build-and-test-development (pull_request) Failing after 33m35s
2026-08-28 14:02:52 +01:00
YuryShkoda a104f78645 fix(editor): stop turning missing columns into undefined, and fix column lookup for sparse rows
Build / Build-and-ng-test (pull_request) Successful in 5m36s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m58s
Build / Build-and-test-development (pull_request) Failing after 33m41s
Writing back an unescaped cell's value unconditionally materialized a column the backend never populated as an explicit `undefined`, which the SASjs adapter rejects at submit time. dynamicCellValidation's column-name lookup (Object.keys(row)[column]) only worked by coincidence when every row had a full key set - fixed to use hot.colToProp instead.
2026-08-28 12:42:02 +03:00
hermes 3b9344cb60 fix: dynamic js mocks
Build / Build-and-ng-test (pull_request) Successful in 5m16s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m35s
Build / Build-and-test-development (pull_request) Failing after 33m28s
2026-08-28 00:35:31 +01:00
hermes 0082828543 fix: history mock
Build / Build-and-ng-test (pull_request) Successful in 5m16s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m32s
Build / Build-and-test-development (pull_request) Failing after 33m38s
2026-08-27 22:41:47 +01:00
hermes 3595bb9499 fix: using utility macro for webout
Build / Build-and-ng-test (pull_request) Successful in 5m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m26s
Build / Build-and-test-development (pull_request) Failing after 33m42s
2026-08-27 17:53:41 +01:00
hermes ee52bb8967 feat: functional JS mocks
Build / Build-and-ng-test (pull_request) Successful in 5m28s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m25s
Build / Build-and-test-development (pull_request) Failing after 33m34s
2026-08-27 17:21:01 +01:00
hermes 18a16fcb06 chore: lint fix
Build / Build-and-ng-test (pull_request) Successful in 5m53s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m3s
Build / Build-and-test-development (pull_request) Failing after 33m36s
2026-08-27 12:42:08 +01:00
YuryShkoda d72e19a308 fix(editor): resolve a primary key's live formula to its computed value, not the raw formula text
Build / Build-and-ng-test (pull_request) Successful in 5m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m40s
Build / Build-and-test-development (pull_request) Successful in 27m13s
2026-08-26 19:08:57 +03:00
YuryShkoda 2cc8489892 fix(editor): resolve computed value for a live formula in numeric columns too
Build / Build-and-ng-test (pull_request) Successful in 5m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m55s
Build / Build-and-test-development (pull_request) Successful in 26m47s
saveTable() only resolved a live formula's computed value for character columns - coerceNumericRow (beforePaste/beforeAutofill) deliberately leaves a non-numeric, formula-looking value alone rather than forcing it to NaN, so a numeric column can hold a live formula too. Its raw `=...` text was reaching the backend, which a numeric SAS field can't parse.
2026-08-26 10:52:47 +03:00
allan 1b1937bad8 Merge branch 'version7-13' into process-formula
Build / Build-and-ng-test (pull_request) Successful in 6m0s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m29s
Build / Build-and-test-development (pull_request) Successful in 27m4s
2026-08-25 15:43:39 +00:00
YuryShkoda b7d31066ae fix(editor): submit a live formula's computed value, not its raw text
Build / Build-and-ng-test (pull_request) Successful in 5m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m27s
Build / Build-and-test-development (pull_request) Successful in 27m6s
saveTable() only resolved computed values for HARDFORMULA/SOFTFORMULA
columns - a formula typed, pasted, or "Apply as formula"'d into any
other character column still had its raw `=...` text submitted,
since HyperFormula recomputes the display without ever mutating the
underlying dataSource string.
2026-08-25 18:15:58 +03:00
hermes 1f4aa6fcea fix: hardening following PR review feedback
Build / Build-and-ng-test (pull_request) Successful in 5m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m52s
Build / Build-and-test-development (pull_request) Successful in 26m20s
2026-08-24 15:11:46 +01:00
allan a7bb13c3ae Merge pull request 'feat(editor): translate column names to cell references on formula paste' (#308) from process-formula into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m31s
Build / Build-and-test-development (pull_request) Successful in 26m27s
Reviewed-on: #308
2026-08-21 17:14:45 +00:00
hermes 69b41559f7 fix: spy for startup
Build / Build-and-ng-test (pull_request) Successful in 5m18s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m27s
Build / Build-and-test-development (pull_request) Successful in 26m24s
2026-08-21 17:31:00 +01:00
hermes 917925f553 fix(deploy): Viya deploy checks, startup diagnostics, and chunked deploy script
Build / Build-and-ng-test (pull_request) Failing after 2m43s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 21m6s
- Add StartupCheckService to show deploy check progress on loading screen
  with step-by-step status (appLoc check, Viya deploy, startup service)
- Add console logging throughout checkViyaDeploy and viyaMakedataSuccessfull
  to diagnose deploy flow issues
- Fix missing return after resolve(false) in viyaMakedataSuccessfull when
  folderId is undefined, which caused fall-through to getFolderMembers
  with an undefined ID
- Fix Viya Folders API pagination: getFolderMembers now requests limit=500
  so all members are returned (admin folder has 32 members, default page
  size was hiding makedata)
- Fix error handler in viyaMakedataSuccessfull to resolve(false) instead of
  reject() so the app falls back to setup screen on API errors
- Fix licence key whitespace: SAS makedata initialises keys as a single
  space which is truthy in JS; trim keys before checking so empty keys
  correctly fall back to free tier instead of triggering decryption errors
- Always set _debug=131 on makedata URLs (automatic and manual deploy)
  so full debug output is available for the one-time setup service
- Add makedata completion polling after runMakedataInNewWindow so the app
  auto-reloads when the makedata job self-deletes, instead of hanging
- Fix loading screen slider position from absolute to relative so it does
  not obscure the startup check steps
- Add chunk_deploy.py to sasjs/utils for chunked Viya deploys, splitting
  viya.sas by service and web file boundaries with correct %let path=
  tracking per chunk

Closes #303, #200, #125
2026-08-21 16:00:59 +01:00
YuryShkoda 12091c4044 fix(editor): re-mark a reverted cell as auto-escaped so "Apply as formula" works again
Build / Build-and-ng-test (pull_request) Successful in 5m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m37s
Build / Build-and-test-development (pull_request) Successful in 26m33s
dataSourceRaw is now escaped the same way dataSource is, so Revert
restores the escaped text a user actually saw instead of the raw
backend string (which would otherwise evaluate live again).
beforeChange re-marks a cell revert_cells restores to its escaped
form, distinguishing that from a user typing a literal leading `'`
themselves via the write's source tag.
2026-08-21 14:31:22 +03:00
YuryShkoda e69df3deb2 fix(editor): escape formula-looking values in uploaded Excel data
Build / Build-and-ng-test (pull_request) Successful in 5m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m15s
Build / Build-and-test-development (pull_request) Successful in 26m20s
Uploaded file content is bulk external data, not user-typed input, so
a `=`-led value in a character column must be escaped the same way a
fresh backend load is - otherwise it silently evaluates as a live
formula now that formulas are enabled for every table.
2026-08-21 09:59:55 +03:00
YuryShkoda d0d17995d3 chore: merge branch 'process-formula' of https://git.datacontroller.io/dc/dc into process-formula
Build / Build-and-ng-test (pull_request) Successful in 5m18s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m47s
Build / Build-and-test-development (pull_request) Failing after 26m20s
2026-08-20 18:50:05 +03:00
YuryShkoda 92d0d91440 feat(editor): scope live formulas to character columns, not just HARDFORMULA/SOFTFORMULA ones
Only backend-sourced `=`-led values are auto-escaped now; anything the
user types or pastes into a character column evaluates as a real
formula, and "Apply as formula" lets a user promote an auto-escaped
cell explicitly. Formulas are enabled on every table instead of only
ones with formula rules.
2026-08-20 18:44:47 +03:00
hermes 9dab3d50ef chore: sample record in mpe_x_test
Build / Build-and-ng-test (pull_request) Successful in 5m29s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m51s
Build / Build-and-test-development (pull_request) Successful in 25m32s
2026-08-20 12:07:50 +01:00
hermes f7db8719f5 fix: agent skills and nextviya deploys
Build / Build-and-ng-test (pull_request) Successful in 5m8s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m24s
Build / Build-and-test-development (pull_request) Successful in 25m20s
2026-08-19 17:17:30 +01:00
Yury 772a359da7 Merge branch 'version7-13' into process-formula
Build / Build-and-ng-test (pull_request) Successful in 5m40s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m30s
Build / Build-and-test-development (pull_request) Successful in 26m26s
2026-08-19 09:54:07 +00:00
YuryShkoda 1031ea7ed7 feat(editor): translate column names to cell references on formula paste
Build / Build-and-ng-test (pull_request) Successful in 5m48s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m28s
Build / Build-and-test-development (pull_request) Successful in 26m35s
- Paste a formula using column names (e.g. "=A_COL * B_COL") into any
  cell and have it translated to that row's cell references
  ("=B4 * C4") so HyperFormula can evaluate it - covers both a
  grid-level paste and pasting directly into an open cell editor
- Only applies on tables that already have formulas enabled (an
  existing HARDFORMULA/SOFTFORMULA column); otherwise the pasted text
  is left untouched rather than becoming inert translated text
- New substituteColumnReferences() reuses parseFormulaRule's
  boundary-matching helpers but deliberately skips DC.* variable
  substitution, which only makes sense for admin-defined rule values
2026-08-19 12:48:32 +03:00
4gl 2dfeb02faf chore: packages / deploy
Build / Build-and-ng-test (pull_request) Successful in 5m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m48s
Build / Build-and-test-development (pull_request) Successful in 26m22s
2026-08-19 10:45:57 +01:00
Yury 9deccd072d Merge pull request 'fix: core major bump plus autofix of viya context on deploy' (#306) from sasfixes into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m39s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m13s
Build / Build-and-test-development (pull_request) Successful in 26m26s
Reviewed-on: #306
2026-08-19 09:40:20 +00:00
hermes 9023eb2a39 fix: escape regex metacharacters in context name before prxchange
Build / Build-and-ng-test (pull_request) Successful in 5m30s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m5s
Build / Build-and-test-development (pull_request) Successful in 25m28s
symget('_contextname') is concatenated directly into the PRX replacement
string in makedata.sas. A context name containing \ or $ would be
interpreted as a backreference or variable interpolation in the Perl
regex replacement. Escape both characters with a leading backslash in a
data step before embedding the value in the prxchange call.
2026-08-18 14:46:34 +01:00
hermes 72484ae844 fix: clarify debug comment is permanent, extend ComputeContextDetails with Viya response fields
Build / Build-and-ng-test (pull_request) Successful in 5m30s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m2s
Build / Build-and-test-development (pull_request) Successful in 26m15s
- sasjs-configurator: rewrite _debug=131 comment to make explicit that
  debug is ALWAYS enabled for the makedata service (runs once during
  deployment, debug output always wanted for diagnostics). Remove the
  commented-out debug-off line that caused reviewer confusion.
- viya-compute-context-details.model: extend Attributes with
  allowXCMD, sessionInactiveTimeout (number), and make all attribute
  fields optional. Add Environment interface with autoExecLines string
  array. Add environment? to ComputeContextDetails (optional, not all
  contexts expose it).
- automatic.component: rewrite extractRunAs to use
  attributes.runServerAs directly per the actual Viya API response
  shape. Remove all any casts and the non-existent runAsUserId /
  environment.runAsUserId field probes.
2026-08-18 14:45:04 +01:00
hermes 003550b965 chore: reference configurator improvements for Viya deploy
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
fixes #303
2026-08-18 12:44:57 +01:00
hermes 45226e19d6 chore: address PR #306 review feedback
- Add explanatory comments for deliberately enabled debug mode in configurator
- Fix deploy-field-description font-size from 0.55rem to 0.85rem (was unreadably small)
2026-08-18 12:44:54 +01:00
4gl df2027dab6 feat: updated configurator for Viya deploy
Build / Build-and-ng-test (pull_request) Successful in 5m34s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m2s
Build / Build-and-test-development (pull_request) Successful in 24m52s
2026-08-18 12:21:29 +01:00
4gl 6527c10f26 fix: core major bump plus autofix of viya context on deploy
Build / Build-and-ng-test (pull_request) Successful in 5m8s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m36s
Build / Build-and-test-development (pull_request) Successful in 25m4s
2026-08-17 15:46:52 +01:00
allan bf54a589f5 Merge pull request 'fix: address hermes review feedback (formula quoting, cell revert, addRow guard)' (#305) from pr-feedback into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m11s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m1s
Build / Build-and-test-development (pull_request) Successful in 25m9s
Reviewed-on: #305
2026-08-17 12:43:26 +00:00
YuryShkoda 36963aa746 fix: address hermes review feedback (formula quoting, cell revert, addRow guard)
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m42s
Build / Build-and-test-development (pull_request) Successful in 24m59s
- Escape embedded double quotes in DC.USER_NAME/DC.ORIG_VALUE formula
  literals, preventing malformed HyperFormula expressions
- Extract cell-revert numeric parsing into resolveRevertedCellValue,
  falling back to the raw text instead of writing NaN
- Guard addRow() against an empty dataSource before indexing into it
- Document the cross-repo assumption behind licence protocol-mismatch
  detection
2026-08-17 13:11:25 +03:00
allan 9b2e0df2b0 Merge pull request 'fix(core): bump to v5 (breaking change)' (#304) from corebump into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m6s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m22s
Build / Build-and-test-development (pull_request) Successful in 24m38s
Reviewed-on: #304
2026-08-14 11:05:18 +00:00
4gl 023c29f00f fix: mp_execute dep
Build / Build-and-ng-test (pull_request) Successful in 5m6s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m24s
Build / Build-and-test-development (pull_request) Successful in 25m2s
2026-08-14 10:34:59 +01:00
4gl 59e9e96f5a fix(core): bump to v5 (breaking change)
Build / Build-and-ng-test (pull_request) Successful in 5m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m15s
Build / Build-and-test-development (pull_request) Successful in 24m56s
2026-08-14 08:53:28 +01:00
4gl 067c08765b chore: adding additional mp_abort per per review
Build / Build-and-ng-test (pull_request) Successful in 5m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m38s
Build / Build-and-test-development (pull_request) Successful in 24m54s
2026-08-14 08:19:10 +01:00
4gl 9cd976495b chore(tidy up): remove trailing space and deprecated logic
Build / Build-and-ng-test (pull_request) Successful in 5m2s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m22s
Build / Build-and-test-development (pull_request) Successful in 24m50s
2026-08-12 21:39:28 +01:00
allan 9f4b357108 Merge pull request 'feat(editor): colour row-header status cells and switch modified symbol to ±' (#301) from formula-status-fix into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m14s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m21s
Build / Build-and-test-development (pull_request) Successful in 25m11s
Reviewed-on: #301
2026-08-12 11:55:14 +00:00
Yury 8209c1cb49 Merge branch 'version7-13' into formula-status-fix
Build / Build-and-ng-test (pull_request) Successful in 5m58s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m2s
Build / Build-and-test-development (pull_request) Successful in 25m24s
2026-08-12 07:23:36 +00:00
YuryShkoda e15f2c2a34 feat(editor): color row-header status cells and switch modified symbol to ±
Build / Build-and-ng-test (pull_request) Successful in 6m2s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m18s
Build / Build-and-test-development (pull_request) Successful in 26m6s
2026-08-12 10:19:52 +03:00
allan c0fec25c4d Merge pull request 'feat(licensing): warn and block applying a licence key generated for the wrong protocol' (#300) from issue-133 into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m22s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m23s
Build / Build-and-test-development (pull_request) Successful in 24m54s
Reviewed-on: #300
2026-08-11 17:33:03 +00:00
YuryShkoda 41680e2ecc feat(licensing): warn and block applying a licence key generated for the wrong protocol
Build / Build-and-ng-test (pull_request) Successful in 5m16s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m18s
Build / Build-and-test-development (pull_request) Successful in 25m0s
A key generated for one connection type (http/https) always fails to
decrypt on the other, surfacing only as a generic "invalid key" error
after a round trip to the backend. Detects the mismatch client-side
from the raw key text before the user submits, and disables the apply
button while it's showing.
2026-08-11 16:54:11 +03:00
allan 20a007622c Merge pull request 'fix(editor): stop dc.row_status from being submitted to the backend' (#299) from formula-status-fix into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m11s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m7s
Build / Build-and-test-development (pull_request) Successful in 25m0s
Reviewed-on: #299
2026-08-11 09:34:33 +00:00
YuryShkoda 5615e6b0db chore: merge remote-tracking branch 'origin/version7-13' into formula-status-fix
Build / Build-and-ng-test (pull_request) Successful in 5m30s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m5s
Build / Build-and-test-development (pull_request) Successful in 25m18s
2026-08-11 11:33:31 +03:00
YuryShkoda dc0f6a7baa fix(editor): stop dc.row_status from being submitted to the backend
Build / Build-and-ng-test (pull_request) Failing after 1m54s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 22m34s
dynamicCellValidation() built its editors/getdynamiccolvals payload
from a raw dataSource row clone, which always carries the client-only
EDIT_STATUS column - unlike saveTable()'s submit path, nothing
stripped it before the request went out. Extracted the strip logic
(previously private to classifyRow) into a shared withoutEditStatus
util and applied it here too.
2026-08-11 11:20:18 +03:00
allan fabb9e5bfd Merge pull request 'feat(viewboxes): add drag-to-resize on all four edges and corners' (#298) from issue-190 into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m6s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m33s
Build / Build-and-test-development (pull_request) Successful in 25m28s
Reviewed-on: #298
2026-08-11 08:08:09 +00:00
YuryShkoda d57ae03fc4 fix(deps): override nanoid to 3.3.18 in sas/
Build / Build-and-ng-test (pull_request) Successful in 5m8s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m12s
Build / Build-and-test-development (pull_request) Successful in 24m44s
@sasjs/cli's own nanoid override doesn't propagate to consumers - npm
only reads overrides from the root project being installed. Adding it
here directly, rather than a raw lockfile patch, so it survives future
npm install runs instead of silently reverting again.
2026-08-11 09:42:09 +03:00
YuryShkoda 7c33839b99 chore: merge remote-tracking branch 'origin/version7-13' into issue-190
Build / Build-and-ng-test (pull_request) Failing after 2m17s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 21m8s
2026-08-11 09:16:06 +03:00
YuryShkoda 26b55b1bde fix: fixed npm vuln
Build / Build-and-ng-test (pull_request) Successful in 5m16s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m7s
Build / Build-and-test-development (pull_request) Successful in 23m55s
2026-08-10 17:09:15 +03:00
YuryShkoda e5a5bf2144 feat(viewboxes): add drag-to-resize on all four edges and corners
Build / Build-and-ng-test (pull_request) Failing after 1m41s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m36s
Adds right, left, bottom, and bottom-left-corner resize handles
alongside the existing bottom-right corner, and raises resize handle
z-index above Handsontable's frozen header clones so clicks reach the
handle instead of the table underneath it.
2026-08-10 15:35:46 +03:00
4gl 76bb83d860 chore: updated release docs
Build / Build-and-ng-test (pull_request) Failing after 1m46s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m35s
2026-08-09 19:55:56 +01:00
4gl 5a44b2804f fix: bump core for mp_validate fix
Build / Build-and-ng-test (pull_request) Successful in 5m8s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m43s
Build / Build-and-test-development (pull_request) Successful in 23m34s
2026-08-07 14:38:11 +01:00
4gl daacb49c8a fix: ensuring formats arrive from backend in getstagetable.sas
Build / Build-and-ng-test (pull_request) Successful in 5m4s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m34s
Build / Build-and-test-development (pull_request) Successful in 23m26s
2026-08-07 12:45:24 +01:00
allan 1807d66ea1 Merge pull request 'Show the real startupservice response text on a malformed reply' (#297) from issue-277 into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m18s
Build / Build-and-test-development (pull_request) Successful in 23m14s
Reviewed-on: #297
2026-08-07 08:57:01 +00:00
allan 84cce5eb0f Merge branch 'version7-13' into issue-277
Build / Build-and-test-development (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 4s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
2026-08-07 08:56:05 +00:00
allan aa4b3b94cd Merge pull request 'Revert any overwritten cell' (#296) from editor-improvements into version7-13
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Reviewed-on: #296
2026-08-07 08:54:57 +00:00
allan 72cae704df Merge branch 'version7-13' into editor-improvements
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
2026-08-07 08:54:45 +00:00
allan 0815fcea2c Merge pull request 'fix: CAS support for REPLACE type plus docs' (#291) from issue289 into version7-13
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Reviewed-on: #291
2026-08-07 08:54:33 +00:00
4gl c07a01a38d fix: adding formula types
Build / Build-and-test-development (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 2m17s
Lighthouse Checks / lighthouse (pull_request) Canceled after 4s
2026-08-07 09:54:09 +01:00
allan eea9fbd938 Merge branch 'version7-13' into editor-improvements
Build / Build-and-ng-test (pull_request) Successful in 5m9s
Build / Build-and-test-development (pull_request) Canceled after 4m18s
Lighthouse Checks / lighthouse (pull_request) Canceled after 9m32s
2026-08-07 08:48:07 +00:00
YuryShkoda 9a1b7d0f52 fix(startup): show the real startupservice response text on a malformed reply
Build / Build-and-ng-test (pull_request) Successful in 5m17s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m4s
Build / Build-and-test-development (pull_request) Successful in 22m42s
A misconfigured Viya computeTasks deployment makes the Compute service
return a plain-text "Job error" body instead of JSON. @sasjs/adapter can't
parse it and resolves with that raw text as adapterResponse, so both
startup and manual deploy validation reported every expected field as
"not present" without ever showing the actual cause. Added
getMalformedAdapterResponseMessage to detect a non-object adapterResponse
and surface its real text instead.
2026-08-07 11:00:17 +03:00
YuryShkoda 1e516f4012 fix(editor): let the column info dropdown's text be selected and copied
Build / Build-and-ng-test (pull_request) Successful in 5m24s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m1s
Build / Build-and-test-development (pull_request) Successful in 23m23s
Handsontable's Menu widget closes on any mouseup inside an item and
unconditionally preventDefault()s contextmenu, even for the info item,
which has no callback and exists purely to show read-only column details.
That made its text impossible to select or right-click-copy. Marked the
item isCommand: false and stop mousedown/mouseup/contextmenu/selectstart
from bubbling past its rendered content to the menu's own listeners.
2026-08-06 14:48:30 +03:00
YuryShkoda d0a7561f1a fix(editor): size table-header buttons and title to content, not fixed grid thirds
Build / Build-and-ng-test (pull_request) Successful in 7m24s
Lighthouse Checks / lighthouse (pull_request) Successful in 23m26s
Build / Build-and-test-development (pull_request) Successful in 22m52s
The Filter/Edit/Upload buttons used btn-block (width: 100%), stretching
them wider than the Cancel/Add Row/Submit buttons shown during edit.
The back/viewboxes, title, and action-button columns were also locked to
equal 12-col grid thirds regardless of actual content, so the dataset
name/row-count wrapped even with visible free space on either side.
Switched the outer columns to size to their content (clr-col-*-auto) and
let the title column flex-grow into whatever space is left.
2026-08-06 13:16:08 +03:00
YuryShkoda 07d586da52 feat(editor): generalize cell revert to any overwritten value, not just formulas
Build / Build-and-ng-test (pull_request) Successful in 5m7s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m7s
Build / Build-and-test-development (pull_request) Successful in 23m29s
2026-08-06 12:47:41 +03:00
allan 93a513a15f Merge branch 'version7-13' into issue289
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m8s
Build / Build-and-test-development (pull_request) Successful in 22m32s
2026-08-05 14:36:53 +00:00
allan 3c9eba4df4 Merge pull request 'feat(stage): add Formatted/Unformatted toggle to the staging page' (#293) from issue-148 into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m35s
Lighthouse Checks / lighthouse (pull_request) Successful in 24m55s
Build / Build-and-test-development (pull_request) Successful in 23m59s
Reviewed-on: #293
2026-08-05 14:36:26 +00:00
allan 48d06ec6a7 Merge branch 'version7-13' into issue-148
Build / Build-and-ng-test (pull_request) Successful in 5m57s
Lighthouse Checks / lighthouse (pull_request) Successful in 25m2s
Build / Build-and-test-development (pull_request) Successful in 23m12s
2026-08-05 09:01:50 +00:00
allan ff8f10d533 Merge branch 'version7-13' into issue289
Build / Build-and-ng-test (pull_request) Successful in 6m35s
Lighthouse Checks / lighthouse (pull_request) Canceled after 15m33s
Build / Build-and-test-development (pull_request) Failing after 1m43s
2026-08-05 08:46:39 +00:00
allan 7d6dc652be Merge pull request 'Additional validations formulae' (#290) from additional-validations-formulae into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m30s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m11s
Build / Build-and-test-development (pull_request) Successful in 23m19s
Reviewed-on: #290
2026-08-05 08:46:26 +00:00
YuryShkoda f9d061c489 fix(deps): scope the brace-expansion override to its actual vulnerable chain
Build / Build-and-ng-test (pull_request) Successful in 5m39s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m16s
Build / Build-and-test-development (pull_request) Successful in 22m18s
An unscoped "brace-expansion": "^5.0.9" override applies to every
occurrence in the tree, not just the vulnerable one - it was silently
replacing the 1.1.16 that glob's bundled minimatch@3.x actually needs,
breaking license-checker with "TypeError: expand is not a function".
Scoped it to the readdir-glob chain (exceljs -> archiver -> readdir-glob
-> minimatch@10.x), the same nested-override pattern already used here
for exceljs's own archiver/unzipper pins.
2026-08-04 16:07:53 +03:00
YuryShkoda 4d1bfa6343 fix(deps): resolve npm audit findings via direct lockfile patch, not regeneration
Build / Build-and-ng-test (pull_request) Failing after 2m0s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 2m12s
Fixed by patching only the 12 needed entries directly (9 @angular/*
packages to 20.3.27, brace-expansion to 5.0.9, fast-uri to 3.1.5, undici to 6.28.0) with exact registry resolved/integrity metadata, leaving the other 1925 packages untouched.
2026-08-04 15:44:19 +03:00
YuryShkoda d841163dd0 chore: trying package.json and package-lock.json from the main branch
Build / Build-and-ng-test (pull_request) Failing after 2m2s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m44s
2026-08-04 15:16:14 +03:00
YuryShkoda 44aac556b9 chore: regenerated package-lock.json with correct env
Build / Build-and-ng-test (pull_request) Failing after 48s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 1m6s
2026-08-04 14:42:30 +03:00
YuryShkoda 3bf3bf0dde fix(deps): pin babel-loader to resolve an unresolvable peer conflict
Build / Build-and-ng-test (pull_request) Failing after 46s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 1m11s
2026-08-04 14:28:09 +03:00
YuryShkoda a501903e6d fix(deps): regenerate package-lock.json to resolve npm ci sync errors
Build / Build-and-ng-test (pull_request) Failing after 49s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 1m6s
Incremental `npm install <pkg>@version` patches left the lockfile
internally inconsistent enough that CI's `npm ci` rejected it as out of
sync with package.json (a long list of unrelated nested deps reported
missing). Regenerated from scratch instead of patching further; verified
with a clean `npm ci`, matching the resolved versions from the prior fix.
2026-08-04 13:31:21 +03:00
YuryShkoda b9e4b2733f fix(deps): resolve npm audit findings and align @angular/* to the same lockstep version
Build / Build-and-ng-test (pull_request) Failing after 46s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 1m6s
- Angular packages (core/common/compiler/animations/forms/
  platform-browser*/router/compiler-cli) all peer-depend on each other's
  exact version - npm audit fix only bumped the three with a direct
  advisory, leaving the rest mismatched; a plain `npm install` tolerated
  it but CI's strict `npm ci` didn't. All now resolve to 20.3.27.
- undici/fast-uri updated via npm audit fix; brace-expansion pinned to
  ^5.0.9 via overrides (npm's resolver wasn't proactively bumping a
  nested transitive dep that already satisfied its declared range)
2026-08-04 12:31:47 +03:00
YuryShkoda 24e6297187 fix(deps): align @angular/* packages to the same lockstep version
Build / Build-and-ng-test (pull_request) Failing after 43s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 1m6s
npm audit fix only bumped @angular/core/common/compiler to 20.3.27 (the
packages with a direct advisory), leaving animations/forms/platform-browser*/
router at 20.3.26 - each peer-depends on the others' exact version, so the
mismatch passed a lenient `npm install` locally but failed CI's strict
`npm ci`. All seven now resolve to 20.3.27.
2026-08-04 12:21:29 +03:00
YuryShkoda 257f69ccc6 fix(deps): resolve npm audit vulnerabilities in production dependencies
Build / Build-and-ng-test (pull_request) Failing after 44s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 1m4s
- Angular packages resolve within their existing ^20.3.26 range up to
  20.3.27, which patches two advisories (HttpTransferCache cache-key
  ambiguity, i18n XSS via event-handler attributes)
- brace-expansion (via exceljs -> archiver -> readdir-glob -> minimatch)
  needed an override to ^5.0.9 - npm's resolver won't proactively bump a
  nested transitive dep that already satisfies its declared semver range,
  even when a patched version exists inside that same range
2026-08-04 11:51:32 +03:00
Yury b88b22684a Merge branch 'version7-13' into additional-validations-formulae
Build / Build-and-ng-test (pull_request) Failing after 2m2s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 21m21s
2026-08-04 08:19:03 +00:00
YuryShkoda a8237b2881 feat(formulas): flag formula-overwritten cells with revert, harden dc.row_status against SAS name collisions
Build / Build-and-ng-test (pull_request) Failing after 1m49s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 21m51s
- Formulas' HyperFormula sync resolves a dotted `data` key differently
  than getDataAtRowProp/datamap.get() - dataDotNotation: false is needed
  for the renamed dc.row_status column to work as a live cell reference
- saveTable() now submits the live computed value for a formula cell, not
  the raw '=...' string still sitting in dataSource
2026-08-04 11:15:11 +03:00
Yury fc6c9f844f Merge branch 'version7-13' into issue-148
Lighthouse Checks / lighthouse (pull_request) Successful in 21m27s
Build / Build-and-ng-test (pull_request) Failing after 1m48s
Build / Build-and-test-development (pull_request) Skipped
2026-08-03 08:29:59 +00:00
YuryShkoda 74c38e1641 feat(stage): add Formatted/Unformatted toggle to the staging page
Build / Build-and-ng-test (pull_request) Successful in 4m53s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m0s
Build / Build-and-test-development (pull_request) Successful in 20m17s
- Mirrors the review page's toggle: getstagetable.sas now also webouts
  fmt_stagetable (SAS-formatted), selectFormattedRows() picks between it
  and the raw stagetable, falling back to raw if fmt data is absent
- Adds stage.cy.ts e2e coverage and wires it into both build.yaml and
  release.yaml's cypress --spec lists (previously out of sync with
  each other on viewbox.cy.ts too)
2026-08-03 09:28:52 +03:00
4gl 28104f83e1 chore(test): test for mpe_targetloader update
Build / Build-and-ng-test (pull_request) Successful in 5m9s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m27s
Build / Build-and-test-development (pull_request) Successful in 22m4s
2026-07-31 15:01:00 +01:00
4gl ea05f07180 fix: CAS support for REPLACE type plus docs
Build / Build-and-test-development (pull_request) Successful in 22m24s
Build / Build-and-ng-test (pull_request) Successful in 5m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m39s
2026-07-31 13:04:19 +01:00
YuryShkoda acb97f4bfb fix(formulas): avoid EDIT_STATUS colliding with a real column of that name
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m48s
Build / Build-and-test-development (pull_request) Successful in 21m38s
2026-07-31 10:26:35 +03:00
YuryShkoda bb808617f4 chore: merge remote-tracking branch 'origin/main' into additional-validations-formulae
Build / Build-and-ng-test (pull_request) Successful in 5m7s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m44s
Build / Build-and-test-development (pull_request) Successful in 21m54s
2026-07-30 18:00:48 +03:00
YuryShkoda 4a8c39b4c0 feat(formulas): live DC.ROW_STATUS, insert-row formula fixes, UserService singleton fix
Build / Build-and-ng-test (pull_request) Successful in 5m13s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m31s
Build / Build-and-test-development (pull_request) Successful in 21m56s
- addRow()/insertRowAtPosition() use hot.alter() so HARDFORMULA/SOFTFORMULA
  values realign on insert instead of going stale
- New hidden EDIT_STATUS column gives DC.ROW_STATUS a real, live cell
  reference; row-header +/-/~ indicator now translates visual->physical
  row index so it stays correct when the grid is sorted
- UserService is now providedIn: 'root' instead of module-scoped, fixing
  DC.USER_NAME (and any other lazy-module reader) always seeing an unset user
- Column-info dropdown shows the applied formula ("√x=<formula>")
2026-07-30 16:03:11 +03:00
semantic-release-bot 42e02cdb05 chore(release): 7.12.0 [skip ci]
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)

### Bug Fixes

* adding REGEX validations to mpe_x_test ([bd798b4](bd798b424a))
* default value for label ([a3e46a9](a3e46a968e))
* **deps:** retarget Angular upgrade to 20, not 21 (CI install was broken) ([cac9244](cac9244f92))
* ensure only one REGEX applies at a time ([8fb58eb](8fb58eb36e))
* ensure that no assets (including og links) ever fetch from external sources ([33dcb98](33dcb989d3))
* include peer dependencies in package-lock for npm ci in pipeline ([b51c770](b51c770782))
* licensecheker ([f60bcef](f60bcef583))
* **lint:** remove redundant optional chaining ([d881290](d881290618))
* optimisation, renamed values for DDTYPE to save space ([cfb60e5](cfb60e5e4b))
* patch npm audit vulnerabilities in sas and client dependencies ([e22edf7](e22edf7ed3))
* **query:** isolate viewbox filter state from the base table's ([7a35cf4](7a35cf4a45))
* regenerate client lockfile to resolve Angular peer-dependency drift breaking npm ci ([05fe474](05fe4744d5))
* **regex:** special missing handling ([180c247](180c2477ed))
* removing low severity warning in npm audit ([2a771bb](2a771bb91a))
* removing thousand seperator from plain numerics in EDIT mode ([0392a81](0392a81cbd))
* **validations:** parse SAS PRX /pattern/flags syntax in HARDREGEX/SOFTREGEX ([7ed3730](7ed3730ae3))

### Features

* **docs:** adding agents.md and docs for RLS ([359d833](359d833406))
* **editor:** add HARDREGEX/SOFTREGEX validation rules ([17e4802](17e4802895))
* **editor:** evaluate HARDREGEX/SOFTREGEX independently instead of hard-wins precedence ([57db117](57db1179a9))
* **editor:** show applied HARDREGEX/SOFTREGEX pattern in column info dropdown ([39c8855](39c8855f37))
* **regex:** backend validations on regex strings ([d2c93a4](d2c93a46fa))
* using ALL libraries as validation in MPE_SECURITY.  Closes [#279](#279) ([62ff0ae](62ff0aee4a))
* validation checks to prevent incompatible RLS rules (eg REPLACE load type).  Closes [#211](#211) ([ea00c5a](ea00c5afad))
* validation on RLS for REPLACE, + docs + tests.  Closes [#211](#211) ([7378f3b](7378f3ba30))
2026-07-28 10:15:48 +00:00
Yury 347923900f Merge pull request 'v7.12 release' (#278) from additional-validations-regex into main
Release / Build-production-and-ng-test (push) Successful in 4m30s
Release / Build-and-test-development (push) Successful in 15m24s
Release / release (push) Successful in 8m47s
Reviewed-on: #278
2026-07-28 09:52:12 +00:00
allan a4c3989c26 Merge pull request 'fix(regex): special missing handling' (#286) from regexfix into additional-validations-regex
Build / Build-and-ng-test (pull_request) Successful in 5m0s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m53s
Build / Build-and-test-development (pull_request) Successful in 20m17s
Reviewed-on: #286
2026-07-27 18:06:47 +00:00
4gl 0392a81cbd fix: removing thousand seperator from plain numerics in EDIT mode
Build / Build-and-ng-test (pull_request) Successful in 5m11s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m12s
Build / Build-and-test-development (pull_request) Successful in 20m7s
2026-07-27 18:37:46 +01:00
4gl f9ea53cf78 chore(demo): adding extra regex's to mpe_x_test
Build / Build-and-ng-test (pull_request) Successful in 5m13s
Build / Build-and-test-development (pull_request) Canceled after 11m54s
Lighthouse Checks / lighthouse (pull_request) Canceled after 16m54s
2026-07-27 18:18:41 +01:00
4gl 8fb58eb36e fix: ensure only one REGEX applies at a time
Build / Build-and-ng-test (pull_request) Successful in 5m32s
Build / Build-and-test-development (pull_request) Canceled after 7m12s
Lighthouse Checks / lighthouse (pull_request) Canceled after 10m14s
2026-07-27 18:05:30 +01:00
4gl 180c2477ed fix(regex): special missing handling
Build / Build-and-ng-test (pull_request) Successful in 4m59s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m20s
Build / Build-and-test-development (pull_request) Successful in 20m45s
2026-07-27 17:51:00 +01:00
YuryShkoda 69cfccd565 chore: merge additional-validations-regex branch 2026-07-27 15:30:50 +03:00
allan f171375899 Merge pull request 'feat(editor): show applied HARDREGEX/SOFTREGEX pattern in column info dropdown and cell tooltip' (#284) from regex-info into additional-validations-regex
Build / Build-and-ng-test (pull_request) Successful in 5m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m53s
Build / Build-and-test-development (pull_request) Successful in 20m59s
Reviewed-on: #284
2026-07-27 11:39:29 +00:00
YuryShkoda 57db1179a9 feat(editor): evaluate HARDREGEX/SOFTREGEX independently instead of hard-wins precedence
Build / Build-and-ng-test (pull_request) Successful in 4m59s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m21s
Build / Build-and-test-development (pull_request) Successful in 21m3s
Both rules can now apply to one column: HARDREGEX still blocks submission
and takes its own tooltip, but SOFTREGEX is evaluated (and shown) whenever
HARDREGEX passes, instead of being silently suppressed whenever HARDREGEX
was merely present. Column-header info dropdown labels each rule separately
when a column has both.
2026-07-27 14:15:36 +03:00
allan d13fab267f Merge branch 'additional-validations-regex' into regex-info
Build / Build-and-ng-test (pull_request) Successful in 5m35s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m51s
Build / Build-and-test-development (pull_request) Successful in 20m2s
2026-07-25 18:17:36 +00:00
allan 44bc7f7fea Merge pull request 'fix: optimisation, renamed values for DDTYPE to save space' (#285) from ddtype_rename into additional-validations-regex
Build / Build-and-ng-test (pull_request) Successful in 5m23s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m50s
Build / Build-and-test-development (pull_request) Successful in 20m35s
Reviewed-on: #285
2026-07-25 18:17:22 +00:00
4gl f60bcef583 fix: licensecheker
Build / Build-and-ng-test (pull_request) Successful in 5m0s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m7s
Build / Build-and-test-development (pull_request) Successful in 19m40s
2026-07-25 17:41:39 +01:00
4gl e7abb0a08a chore: down to 0 vulns
Build / Build-and-ng-test (pull_request) Failing after 1m57s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 2m12s
2026-07-25 17:29:00 +01:00
4gl cfb60e5e4b fix: optimisation, renamed values for DDTYPE to save space
Build / Build-and-ng-test (pull_request) Failing after 1m45s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m4s
2026-07-25 16:18:42 +01:00
4gl 33dcb989d3 fix: ensure that no assets (including og links) ever fetch from external sources
Build / Build-and-ng-test (pull_request) Successful in 5m3s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m7s
Build / Build-and-test-development (pull_request) Successful in 19m37s
2026-07-24 17:45:02 +01:00
allan aaf406b386 Merge branch 'additional-validations-regex' into regex-info
Build / Build-and-ng-test (pull_request) Successful in 4m58s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m51s
Build / Build-and-test-development (pull_request) Successful in 19m50s
2026-07-24 15:43:45 +00:00
allan 8efea8c744 Merge pull request 'fix(query): isolate viewbox filter state from the base table's' (#282) from issue-239 into additional-validations-regex
Build / Build-and-ng-test (pull_request) Successful in 5m17s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m21s
Build / Build-and-test-development (pull_request) Successful in 20m8s
Reviewed-on: #282
2026-07-24 14:24:29 +00:00
YuryShkoda 39c8855f37 feat(editor): show applied HARDREGEX/SOFTREGEX pattern in column info dropdown
Build / Build-and-ng-test (pull_request) Failing after 1m44s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 21m25s
Add DcValidator.getRegexRuleValue (HARDREGEX takes precedence), thread it
through buildColInfoHtml, and cover it with a Cypress test.
2026-07-24 17:15:42 +03:00
allan 5c56c7579f Merge branch 'additional-validations-regex' into issue-239
Build / Build-and-ng-test (pull_request) Successful in 4m53s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m48s
Build / Build-and-test-development (pull_request) Successful in 19m27s
2026-07-24 13:54:51 +00:00
allan 92c1e20126 Merge pull request 'fix: removing low severity warning in npm audit' (#283) from auditfix into additional-validations-regex
Build / Build-and-test-development (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 31s
Lighthouse Checks / lighthouse (pull_request) Canceled after 5s
Reviewed-on: #283
Reviewed-by: Yury <yury@4gl.io>
2026-07-24 13:54:39 +00:00
Yury 7050808d80 Merge branch 'additional-validations-regex' into issue-239
Build / Build-and-test-development (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 1m15s
Lighthouse Checks / lighthouse (pull_request) Canceled after 1m17s
2026-07-24 13:54:26 +00:00
YuryShkoda 37a98e1d64 test(cypress): fix flaky viewbox.cy.ts assertions and DOM lookups
Build / Build-and-ng-test (pull_request) Successful in 5m53s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m19s
Build / Build-and-test-development (pull_request) Successful in 20m2s
Replace one-shot .then()/for-loop matching with retrying .should()/cy.contains()
lookups, drop stale cached DOM node references (viyaLib) in favor of live re-queries.
2026-07-24 16:21:42 +03:00
allan 66f7b87b07 Merge branch 'additional-validations-regex' into auditfix
Build / Build-and-ng-test (pull_request) Successful in 5m5s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m42s
Build / Build-and-test-development (pull_request) Successful in 15m52s
2026-07-24 12:57:27 +00:00
4gl fae9496bbc chore: adding crypt shim to tests
Build / Build-and-ng-test (pull_request) Successful in 5m16s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m50s
Build / Build-and-test-development (pull_request) Successful in 15m32s
2026-07-24 13:53:21 +01:00
4gl cfe1e75be4 chore(docs): no auto-commit rule
Build / Build-and-test-development (pull_request) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 43s
Lighthouse Checks / lighthouse (pull_request) Canceled after 4m7s
2026-07-24 13:49:25 +01:00
4gl b51c770782 fix: include peer dependencies in package-lock for npm ci in pipeline
Build / Build-and-ng-test (pull_request) Failing after 2m38s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Canceled after 6m25s
2026-07-24 13:45:59 +01:00
4gl 2a771bb91a fix: removing low severity warning in npm audit
Build / Build-and-ng-test (pull_request) Failing after 48s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 1m9s
2026-07-24 13:12:58 +01:00
YuryShkoda 54b8c78f86 chore: merge branch additional-validations-regex
Build / Build-and-ng-test (pull_request) Failing after 1m44s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 22m50s
2026-07-24 14:59:37 +03:00
YuryShkoda ea74f95144 test(validations): add regression coverage for real HARDREGEX/SOFTREGEX rule values
Verifies parseRegexRule against a corpus of RULE_VALUEs collected from an
existing MPE_VALIDATIONS table - none use PRX-only syntax, so behavior is
confirmed to match SAS PRX exactly, not just "doesn't throw". Pins two
data quirks found along the way (a range vs. literal-hyphen character
class, and a double-escaped lookahead that's a no-op in both engines)
rather than silently treating either as a bug to fix.
2026-07-24 14:55:05 +03:00
4gl bd798b424a fix: adding REGEX validations to mpe_x_test
Build / Build-and-ng-test (pull_request) Failing after 1m58s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 21m55s
2026-07-24 12:54:16 +01:00
4gl ee3c6c9e0f chore: making fail on npm audit a strict check 2026-07-24 12:53:51 +01:00
4gl a3e46a968e fix: default value for label
Build / Build-and-ng-test (pull_request) Successful in 5m35s
Build / Build-and-test-development (pull_request) Successful in 15m36s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m51s
2026-07-24 12:53:26 +01:00
YuryShkoda ee4e9b9271 chore: added viewbox.cy.ts to CI tests
Build / Build-and-ng-test (pull_request) Successful in 4m54s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m0s
Build / Build-and-test-development (pull_request) Failing after 21m21s
2026-07-24 13:12:07 +03:00
YuryShkoda 7a35cf4a45 fix(query): isolate viewbox filter state from the base table's
Build / Build-and-ng-test (pull_request) Successful in 5m8s
Build / Build-and-test-development (pull_request) Successful in 15m11s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m21s
Also guards $dataformats.vars[column] in viewboxes.component.html against
a column not present in the map (unrelated crash hit while reproducing).
2026-07-24 12:22:34 +03:00
YuryShkoda d881290618 fix(lint): remove redundant optional chaining
Build / Build-and-ng-test (pull_request) Successful in 4m59s
Build / Build-and-test-development (pull_request) Successful in 15m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m15s
2026-07-23 18:35:04 +03:00
YuryShkoda cbea04c8e1 feat(formulas): wire HyperFormula into HARDFORMULA/SOFTFORMULA rules
Adds HARDFORMULA/SOFTFORMULA DQ rule types, backed by Handsontable's
formulas plugin (gated per-table via hasFormulaRules). parseFormulaRule
substitutes column names with row-relative cell references (quote-aware,
blank-boundary matching per spec) and resolves DC.USER_NAME/DC.ORIG_VALUE
to literal values; applyFormulaRules injects the computed formula per row,
reusing the existing READONLY mechanism for HARDFORMULA.

DC.ROW_STATUS/EDIT_STATUS deliberately deferred - would require prepending
COLHEADERS, which headerColumns has undocumented positional coupling to
elsewhere in editor.component.ts.
2026-07-23 17:35:07 +03:00
allan 994e7fc973 Merge pull request 'fix: patch npm audit vulnerabilities in sas and client dependencies' (#280) from issue-276 into additional-validations-regex
Build / Build-and-ng-test (pull_request) Successful in 5m40s
Build / Build-and-test-development (pull_request) Successful in 15m30s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m21s
Reviewed-on: #280
2026-07-23 12:23:38 +00:00
YuryShkoda 578c403994 chore: regenerated sas/package-lock.json
Build / Build-and-ng-test (pull_request) Successful in 5m45s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m19s
Build / Build-and-test-development (pull_request) Successful in 15m48s
2026-07-23 14:16:07 +03:00
YuryShkoda 56b7854db5 chore: bumped @sasjs/core
Build / Build-and-ng-test (pull_request) Successful in 5m6s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m4s
Build / Build-and-test-development (pull_request) Successful in 15m24s
2026-07-23 14:11:59 +03:00
YuryShkoda 7ed3730ae3 fix(validations): parse SAS PRX /pattern/flags syntax in HARDREGEX/SOFTREGEX
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Build / Build-and-test-development (pull_request) Successful in 15m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m43s
RULE_VALUE is authored in PRX delimiter form because prxparse() requires
it, but HARDREGEX, the SOFTREGEX grid renderer, and failsSoftRegex were
all passing that string straight into `new RegExp()`, so the delimiters
and flags were matched as literal characters instead of applied - making
these rules silently never match real data.

Adds parseRegexRule (extracted, tested independently) to strip the
delimiters, apply flags, hoist a leading (?i) modifier, and translate
\Q...\E and \A/\z to their JS equivalents. Atomic groups and possessive
quantifiers are left unfixed (documented, fail-safe) - translating them
risks renumbering the pattern's own capture groups.

Also switches the REGEX_HARD_COL/REGEX_SOFT_COL mock rules to the
delimited form so editor.cy.ts's existing e2e coverage actually exercises
this path.
2026-07-23 13:37:22 +03:00
allan 6cd9b68581 Merge pull request 'Issue211' (#281) from issue211 into additional-validations-regex
Build / Build-and-ng-test (pull_request) Successful in 5m22s
Build / Build-and-test-development (pull_request) Successful in 15m39s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m31s
Reviewed-on: #281
2026-07-22 18:25:08 +00:00
4gl 7378f3ba30 feat: validation on RLS for REPLACE, + docs + tests. Closes #211
Build / Build-and-ng-test (pull_request) Successful in 5m24s
Build / Build-and-test-development (pull_request) Successful in 15m37s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m43s
2026-07-22 19:01:21 +01:00
YuryShkoda cac9244f92 fix(deps): retarget Angular upgrade to 20, not 21 (CI install was broken)
Build / Build-and-ng-test (pull_request) Successful in 8m13s
Build / Build-and-test-development (pull_request) Successful in 25m4s
Lighthouse Checks / lighthouse (pull_request) Successful in 52m4s
Retargets @angular/* to 20.3.26/cdk 20.2.14, reverts main.ts's
21-only bootstrap option, and widens Clarity's vendored peerDependencies
metadata to declare Angular 20 support (verified compatible; only 21 actually breaks it).
2026-07-22 17:08:40 +03:00
4gl ea00c5afad feat: validation checks to prevent incompatible RLS rules (eg REPLACE load type). Closes #211 2026-07-22 13:44:19 +01:00
4gl 359d833406 feat(docs): adding agents.md and docs for RLS 2026-07-22 13:24:16 +01:00
YuryShkoda 022981390e chore(deps): upgrade Angular 19 -> 21 to resolve audit CVEs
Build / Build-and-ng-test (pull_request) Failing after 52s
Build / Build-and-test-development (pull_request) Has been skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 1m6s
Bumps @angular/* to 21 (passing through 20 as a transient step, since
vendored Clarity has no Angular-20-compatible release) and
@typescript-eslint to 8.65.0 for TS 5.9 compat. Applies the required
ng update migrations, including *ngIf/*ngFor/*ngSwitch -> control-flow
syntax across 37 templates.
2026-07-22 14:56:20 +03:00
YuryShkoda 05fe4744d5 fix: regenerate client lockfile to resolve Angular peer-dependency drift breaking npm ci
Build / Build-and-ng-test (pull_request) Successful in 5m19s
Build / Build-and-test-development (pull_request) Successful in 14m55s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m47s
2026-07-22 11:25:26 +03:00
YuryShkoda e22edf7ed3 fix: patch npm audit vulnerabilities in sas and client dependencies
Build / Build-and-ng-test (pull_request) Failing after 1m18s
Build / Build-and-test-development (pull_request) Has been skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 1m28s
2026-07-22 10:52:52 +03:00
4gl 62ff0aee4a feat: using ALL libraries as validation in MPE_SECURITY. Closes #279
Build / Build-and-ng-test (pull_request) Successful in 5m20s
Build / Build-and-test-development (pull_request) Successful in 15m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m50s
2026-07-21 18:08:42 +01:00
YuryShkoda 3ed7cfdbee chore: merge remote-tracking branch 'origin/main' into additional-validations-regex
Build / Build-and-ng-test (pull_request) Successful in 5m29s
Build / Build-and-test-development (pull_request) Successful in 15m36s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m27s
2026-07-21 09:23:18 +03:00
semantic-release-bot 9176296816 chore(release): 7.11.0 [skip ci]
# [7.11.0](https://git.datacontroller.io/dc/dc/compare/v7.10.1...v7.11.0) (2026-07-20)

### Bug Fixes

* audit-skipping syntax correction ([ad7efc9](ad7efc90bc))
* **editor:** ignore cell validation on rows marked for delete ([70c5805](70c5805743))
* explicit use of .sashdat in dc_casload ([6a95067](6a95067618))
* refactor mf_wordsinstr1butnotstr2 and added a wide table to test suite ([be94c68](be94c68f35))
* retry the startup service request once before failing ([5391df8](5391df8f04))
* run dc_casload for dq tables in getdata.  Closes [#264](#264). ([6b16f9b](6b16f9b364))
* updating tests, moving mpe_rowhash to core library ([c5f0e4f](c5f0e4f106))

### Features

* bumping core to get the mp_rowhash definition, [#269](#269) ([f73f5e4](f73f5e47ac))
* new hash algorithm - allows 1000+ cols.  Closes [#269](#269) ([08947f0](08947f0fc8))
2026-07-20 20:20:55 +00:00
allan bac7b0a600 Merge pull request 'Additional support for VA data control' (#265) from issue264 into main
Release / Build-production-and-ng-test (push) Successful in 5m28s
Release / Build-and-test-development (push) Successful in 16m7s
Release / release (push) Successful in 9m15s
Reviewed-on: #265
2026-07-20 19:55:17 +00:00
4gl d2c93a46fa feat(regex): backend validations on regex strings
Build / Build-and-ng-test (pull_request) Successful in 5m36s
Build / Build-and-test-development (pull_request) Successful in 15m57s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m28s
2026-07-20 20:52:38 +01:00
YuryShkoda 17e4802895 feat(editor): add HARDREGEX/SOFTREGEX validation rules
Build / Build-and-ng-test (pull_request) Successful in 5m25s
Build / Build-and-test-development (pull_request) Successful in 14m56s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m35s
Two new DQ rule types apply regular expressions to cell values:
HARDREGEX blocks submission on a non-matching value (same path as the
existing CASE/MINVAL/MAXVAL rules); SOFTREGEX is display-only — a
non-matching value gets a yellow warning cell but can still submit,
so it's wired as a grid renderer rather than a validator, and mirrored
in the edit-record modal (which has no grid renderer to hook into) via
DcValidator.failsSoftRegex. Both rules exempt blank and SAS special
missing values, and fail open on a malformed pattern rather than
blocking every submission on that column. HARDREGEX takes precedence
when both rules apply to the same column, so a failing value renders
red/blocked, never yellow.
2026-07-20 18:15:57 +03:00
allan 600937a471 Merge pull request 'fix: audit-skipping syntax correction' (#275) from fix/unbug_audit_skipping_for_audit_libds_eq_0 into issue264
Build / Build-and-ng-test (pull_request) Successful in 5m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m21s
Build / Build-and-test-development (pull_request) Successful in 15m1s
Reviewed-on: #275
2026-07-20 14:15:31 +00:00
allan f100a16c8b Merge branch 'issue264' into fix/unbug_audit_skipping_for_audit_libds_eq_0
Build / Build-and-ng-test (pull_request) Successful in 5m47s
Build / Build-and-test-development (pull_request) Successful in 15m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m42s
2026-07-20 14:15:22 +00:00
allan 9e54504474 Merge pull request 'feat: new hash algorithm - allows 1000+ cols. Closes #269' (#271) from issue269 into issue264
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m46s
Build / Build-and-test-development (pull_request) Successful in 16m1s
Reviewed-on: #271
2026-07-19 23:26:25 +00:00
allan 0285bc90fd Merge pull request 'fix: refactor mf_wordsinstr1butnotstr2 and added a wide table to test suite' (#272) from issue269b into issue269
Build / Build-and-ng-test (pull_request) Successful in 5m54s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m12s
Build / Build-and-test-development (pull_request) Successful in 16m22s
Reviewed-on: #272
2026-07-19 23:25:06 +00:00
dcbot be94c68f35 fix: refactor mf_wordsinstr1butnotstr2 and added a wide table to test suite
Build / Build-and-ng-test (pull_request) Successful in 5m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m40s
Build / Build-and-test-development (pull_request) Successful in 16m0s
2026-07-20 00:24:02 +01:00
allan f73f5e47ac feat: bumping core to get the mp_rowhash definition, #269
Build / Build-and-ng-test (pull_request) Successful in 5m16s
Build / Build-and-test-development (pull_request) Successful in 14m54s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m17s
2026-07-18 22:58:29 +01:00
dcbot c5f0e4f106 fix: updating tests, moving mpe_rowhash to core library
Build / Build-and-ng-test (pull_request) Successful in 5m24s
Build / Build-and-test-development (pull_request) Successful in 14m57s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m22s
2026-07-18 20:42:44 +01:00
dcbot 8e4f045eb9 chore(docs): explaining the sheet crypto part
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Build / Build-and-test-development (pull_request) Successful in 15m1s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m29s
2026-07-18 14:00:40 +01:00
dcbot 08947f0fc8 feat: new hash algorithm - allows 1000+ cols. Closes #269
Build / Build-and-ng-test (pull_request) Successful in 5m49s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m52s
Build / Build-and-test-development (pull_request) Successful in 15m34s
2026-07-17 22:53:53 +01:00
dcbot efcccd7d11 Merge pull request 'chore(ai): moving .claude to .agent' (#270) from foldermove into issue264
Build / Build-and-ng-test (pull_request) Successful in 5m36s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m8s
Build / Build-and-test-development (pull_request) Successful in 16m20s
Reviewed-on: #270
2026-07-17 21:36:21 +00:00
dcbot 078da3d947 chore(ai): moving .claude to .agent
Build / Build-and-ng-test (pull_request) Successful in 5m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m1s
Build / Build-and-test-development (pull_request) Successful in 16m6s
2026-07-17 22:30:05 +01:00
allan 61012f80e6 Merge pull request 'fix(editor): ignore cell validation on rows marked for delete' (#267) from issue-260 into issue264
Build / Build-and-ng-test (pull_request) Successful in 5m24s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m21s
Build / Build-and-test-development (pull_request) Successful in 15m31s
Reviewed-on: #267
Reviewed-by: sead <307+sead@noreply.git.datacontroller.io>
2026-07-17 21:26:26 +00:00
allan 65bcf08266 Merge branch 'issue264' into issue-260
Build / Build-and-ng-test (pull_request) Successful in 5m37s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m4s
Build / Build-and-test-development (pull_request) Successful in 15m45s
2026-07-17 21:25:48 +00:00
Trevor Moody ad7efc90bc fix: audit-skipping syntax correction
Build / Build-and-ng-test (pull_request) Successful in 5m22s
Build / Build-and-test-development (pull_request) Successful in 14m37s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m31s
2026-07-17 18:13:55 +01:00
YuryShkoda 70c5805743 fix(editor): ignore cell validation on rows marked for delete
Build / Build-and-ng-test (pull_request) Successful in 5m17s
Build / Build-and-test-development (pull_request) Successful in 14m56s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m18s
An invalid cell no longer blocks submission if its row is marked for
delete, since those values are about to be removed anyway. Primary key
columns are exempt from the exemption: the key identifies which record
to delete, and a blank/invalid PK would otherwise also slip past the
separate duplicate-key check. Toggling the delete flag now re-validates
the row immediately so invalid highlights update without waiting for
submit.
2026-07-17 12:02:21 +03:00
allan dfe80bb42a Merge pull request 'fix: retry the startup service request once before failing' (#266) from issue-261 into issue264
Build / Build-and-ng-test (pull_request) Successful in 5m36s
Build / Build-and-test-development (pull_request) Successful in 14m54s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m26s
Reviewed-on: #266
Reviewed-by: sead <307+sead@noreply.git.datacontroller.io>
2026-07-17 08:14:20 +00:00
Yury af27933e46 Merge branch 'issue264' into issue-261
Build / Build-and-ng-test (pull_request) Successful in 5m25s
Build / Build-and-test-development (pull_request) Successful in 14m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m30s
2026-07-17 07:43:47 +00:00
allan 6a95067618 fix: explicit use of .sashdat in dc_casload
Build / Build-and-ng-test (pull_request) Successful in 5m28s
Build / Build-and-test-development (pull_request) Successful in 14m39s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m44s
2026-07-16 15:41:11 +01:00
YuryShkoda 5391df8f04 fix: retry the startup service request once before failing
Build / Build-and-ng-test (pull_request) Successful in 5m20s
Build / Build-and-test-development (pull_request) Successful in 14m42s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m51s
2026-07-16 17:12:04 +03:00
allan 6b16f9b364 fix: run dc_casload for dq tables in getdata. Closes #264.
Build / Build-and-ng-test (pull_request) Successful in 5m25s
Build / Build-and-test-development (pull_request) Successful in 14m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m42s
2026-07-16 10:20:28 +01:00
semantic-release-bot 2dcae9060f chore(release): 7.10.1 [skip ci]
## [7.10.1](https://git.datacontroller.io/dc/dc/compare/v7.10.0...v7.10.1) (2026-07-15)

### Bug Fixes

* **editor:** order validation rules by COLHEADERS, not cols[].VARNUM ([4c2aca3](4c2aca3495))
2026-07-15 14:05:25 +00:00
allan f4525cc62a Merge pull request 'Fix headers alignment' (#262) from fix/colheaders-rule-alignment into main
Release / Build-production-and-ng-test (push) Successful in 4m43s
Release / Build-and-test-development (push) Successful in 14m11s
Release / release (push) Successful in 9m6s
Reviewed-on: #262
Reviewed-by: Yury <yury@4gl.io>
2026-07-15 13:42:31 +00:00
sead d4aa70ddbf test(editor): make CLS EDIT un-hide regression test actually fail
Build / Build-and-ng-test (pull_request) Successful in 5m20s
Build / Build-and-test-development (pull_request) Successful in 14m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m51s
2026-07-15 15:08:31 +02:00
sead 4c2aca3495 fix(editor): order validation rules by COLHEADERS, not cols[].VARNUM 2026-07-15 14:30:26 +02:00
327 changed files with 37145 additions and 31581 deletions
+147
View File
@@ -0,0 +1,147 @@
# Bitemporal Dataloader — Technical Deep Dive
This document explains how the Data Controller backend loads staged data into target tables: the load-type dispatch, the internals of the `%bitemporal_dataloader` macro, and a detailed description of the REPLACE load type. For user-facing load type documentation, see [docs.datacontroller.io](https://docs.datacontroller.io/).
## Overview
Every table registered for loading has a current record in `&mpelib..MPE_TABLES` with a `LOADTYPE` (selectbox values are seeded in `mpe_makedata.sas`):
| LOADTYPE | Loader used | History kept |
|---|---|---|
| `UPDATE` | `%bitemporal_dataloader` (no temporal vars) | None — changed records are deleted and re-appended |
| `REPLACE` | Inline code in `%mpe_targetloader` (does **not** use `%bitemporal_dataloader`) | None — entire table wiped and reloaded |
| `TXTEMPORAL` | `%bitemporal_dataloader` (technical time only) | SCD2-style, technical (transaction) time |
| `BITEMPORAL` | `%bitemporal_dataloader` (business + technical time) | Full two-dimensional history |
| `FORMAT_CAT` | `%mp_loadformat` | Format catalog load (table suffix `-FC`) |
The relevant `MPE_TABLES` columns read by the loader are: `LOADTYPE`, `BUSKEY` (primary key, space-separated, excluding temporal columns), `VAR_TXFROM` / `VAR_TXTO` (technical validity), `VAR_BUSFROM` / `VAR_BUSTO` (business validity), `VAR_PROCESSED` (processed timestamp column), `RK_UNDERLYING` (retained-key generation), `CLOSE_VARS`, and `AUDIT_LIBDS` (defaults to `&dclib..MPE_AUDIT`).
## Request Flow
```mermaid
flowchart TD
A[User submits changeset\neditors/stagedata.sas] --> B[Staging package written to\n&mpelocapprovals/&LOAD_REF\nCSV + jsdata]
B --> C[Approval workflow\nMPE_SUBMIT / MPE_REVIEW]
C --> D{auditors/postdata.sas}
D -->|action=SHOW_DIFFS| E["%mpe_targetloader(LOADTARGET=NO)\nbuilds work.outds_add / outds_mod / outds_del\nfor the diff screen only"]
D -->|action=APPROVE_TABLE| F["%mpe_targetloader(LOADTARGET=YES)\nactual load"]
E --> G[Diff CSV + TEMPDIFFS stored\nin approval package]
F --> H{LOADTYPE from\nMPE_TABLES}
H -->|UPDATE / TXTEMPORAL / BITEMPORAL| I["%bitemporal_dataloader"]
H -->|FORMAT_CAT| J["%mp_loadformat"]
H -->|REPLACE| K[Inline delete-all + append\nin %mpe_targetloader]
```
`%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`) is the single dispatch point. It reads the current `MPE_TABLES` record (`&dc_dttmtfmt. lt tx_to`), aborts if the table is not registered (or has duplicate config records), and routes to the loader. Note the two-phase design: `LOADTARGET=NO` prepares the intermediate `outds_*` tables so the approver can review diffs; `LOADTARGET=YES` performs the destructive load. Both phases run the same preparation logic, so the reviewed diffs correspond to what is actually applied.
## The Temporal Model
Bitemporal tables carry two independent time dimensions:
* **Business time** (`bus_from` / `bus_to`) — when the fact was true in the real world. Present on **both** staging and base tables.
* **Technical time** (`tech_from` / `tech_to`, a.k.a. transaction time) — when the record was known to the database. Present on the **base table only**; the loader stamps these itself.
All validity is expressed with **half-open intervals** (`from <= t < to`). Queries against bitemporal tables need two conditions and must not use `BETWEEN` or `from LE t LE to` (the latter excludes boundary records — see the macro header for background):
```sas
where &bus_from le [tstamp] lt &bus_to
and &tx_from le [tstamp] lt &tx_to
```
"Current" records have `tech_to` set to the high date (`'31DEC9999:23:59:59'dt` when called from `%mpe_targetloader`). Closing out a record means setting `tech_to = now` — records are never physically deleted from a temporal table, they are superseded.
## Key Components
| Component | Location | Role |
|---|---|---|
| `%mpe_targetloader` | `sas/sasjs/macros/mpe_targetloader.sas` | Reads `MPE_TABLES` config and dispatches per LOADTYPE; implements REPLACE inline |
| `%bitemporal_dataloader` | `sas/sasjs/macros/bitemporal_dataloader.sas` | Generic loader for UPDATE / TXTEMPORAL / BITEMPORAL |
| `%bitemporal_closeouts` | `sas/sasjs/macros/bitemporal_closeouts.sas` | Closes out (sets `tech_to=now`) live records matching a key |
| `%mp_retainedkey` | SASjs core | Generates retained (surrogate) keys when `RK_UNDERLYING` is configured |
| `%mp_rowhash` | SASjs core | MD5 hash of non-temporal columns, used for change detection |
| `%mp_storediffs` | SASjs core | Writes row-level audit records to `AUDIT_LIBDS` |
| `MPE_DATALOADS` | `&dclib` | Load log (counts, duration, macro version, user) — written only by `%bitemporal_dataloader` |
| `MPE_LOCKANYTABLE` | `&dclib` | Lock control table used by `%mp_lockanytable` |
## `%bitemporal_dataloader` Execution Flow
The macro builds a series of `work.bitemp*` intermediate tables, then applies the changes in two target-table operations (closeout, then append) under a single lock.
### 1. Pre-checks and setup
* Early return if the staging table is empty; hard abort if `&syscc > 0`.
* `CLOSE_VARS` is not supported on REDSHIFT / POSTGRES / SNOWFLAKE engines (returns with a NOTE).
* A zero-row snapshot of the base table (`&basecopy`) is taken with `data ... set base; stop;` — this doubles as a lock check, since metadata functions fail against a locked table.
* `proc contents` on the base table feeds column lists. Columns are split into character and numeric lists for hashing (`%mp_rowhash` hashes the two types via separate arrays). Temporal columns, the processed column, and the delete-flag column are excluded from the hash.
* Table names containing `___TMP___` or `_____` are rejected (they would collide with generated temp columns).
### 2. Locking
For `LOADTARGET=YES`, the base table (and audit table, if configured) is locked via `%mp_lockanytable` before any staging prep, because the load is a two-part update (closeouts + append) and must not interleave with another load.
### 3. Staging preparation (`work.bitemp0_append`)
* If `RK_UNDERLYING` is configured, `%mp_retainedkey` maps business keys to retained keys (filtering the base lookup to live records, `&now < tech_to`, for temporal types); otherwise the staging table is used as-is.
* `bus_from` / `bus_to` overrides are applied if provided; the processed column is stamped with the load timestamp (`now`). Even with `processed=0`, a column literally named `PROCESSED_DTTM` on the base table is used if present.
* The MD5 change-detection hash is computed for every staged record.
* If the staging table contains the delete-flag column (`_____DELETE__THIS__RECORD_____`), rows flagged `"Yes"` are diverted to `&outds_del` and closed out via `%bitemporal_closeouts` (PK is taken as `bus_from` + business key). The remaining rows continue as `bitemp0_append`.
### 4. CLOSE_VARS closeout
When `CLOSE_VARS` (a subset of the PK) is supplied, live base records whose CLOSE_VARS values appear in staging but whose full PK does **not** are closed out. This handles "this group was fully reloaded, so anything missing was removed" semantics without reloading the whole table.
### 5. Uniqueness check
If `CHECK_UNIQUENESS=YES` (the default) or business-date overrides are in play, the staging table is sorted `nodupkey` by the PK; a row-count mismatch aborts the load and releases the locks. The staging table must be a unique snapshot of the business key at one point in business time.
### 6. Base extract (`work.bitemp0_base`)
Only base records matching staged PKs are extracted — for temporal load types, only **currently live** records (`now < tech_to`). A left join from the staged keys to the base produces `___TMP___NEW_FLG` to identify brand-new keys. This is engine-specific:
* **OLEDB (SQL Server)** — staged keys are pushed to a `##global` temp table and joined via explicit pass-through.
* **REDSHIFT / POSTGRES / SNOWFLAKE** — an in-database temp table is created `like` the base, stripped to PK + an added `md5 varchar(32)` column, loaded with staged keys, and joined via pass-through. Snowflake uses transient tables; Redshift gets `alter sortkey none` plus any `DCBL_REDSH` config options from `MPE_CONFIG`.
* **CAS** — a FedSQL join against a CASUSER copy.
* **BASE/other** — plain PROC SQL in SAS.
### 7. Change classification
* **`&outds_add`** — staged records flagged as new (no base match). They get `tech_from=now`, `tech_to=high_date`.
* **`work.bitemp1_current`** — matched base records, re-hashed so hashes are comparable.
* **Inserts (BITEMPORAL only)** — a staged record whose business range falls strictly *inside* an existing record's range splits the existing record into a "before" and "after" segment (`bitemp3_inserts` / `bitemp3a_inserts`). The split segments replace the original in the comparison set (`bitemp3b_newbase`).
* **Updates (`bitemp4*`)** — staged records matching a base PK but with a different hash or different business dates. Base and staged versions are stacked, deduplicated, then aligned in **two passes** over the business timeline per key: a forward pass (carry `bus_from` forward across identical hashes; a staged record trims the preceding base record's range) and a reverse pass (carry `bus_to` back; records fully subsumed by the new version are deleted). Records that end up byte-identical to what is already stored are dropped via a hash lookup that includes the business dates (`bitemp5a_lkp` / `bitemp5b_updates`, BITEMPORAL only).
### 8. Closeout application
Changed records are closed out in the target before the new versions are appended. SAS SQL has no UPDATE-with-join, so a correlated `EXISTS` subquery against `work.bitemp5d_subquery` is used (pushed in-database as a temp table for OLEDB / REDSHIFT / POSTGRES / SNOWFLAKE):
* **BITEMPORAL** — per key, the closeout range is `min(bus_from)` / `max(bus_to)` of the changed records: `update base set tech_to=now (, processed=now) where tech_from <= now < tech_to and exists (key match and base.bus_from >= min and base.bus_to <= max)`.
* **TXTEMPORAL** — same, on key only.
* **UPDATE** — changed records are physically `delete`d (they are re-appended in the next step). On CAS this uses `table.deleteRows` with a whereTable; temporal types are not supported on CAS and abort.
* **BUSTEMPORAL** — closeouts are not implemented; the macro aborts at this point ("BUSTEMPORAL NOT YET SUPPORTED").
### 9. Append, unlock, audit, log
* The union of modified + new records, deduplicated on all columns (`bitemp6_unique`), is appended to the base table (`proc append ... force nowarn`; CAS appends via a varchar-casting step; Redshift applies `DCBL_REDSH` options). Locks are then released.
* If `outds_audit` is set (always, from `%mpe_targetloader``AUDIT_LIBDS` defaulting to `&dclib..MPE_AUDIT`), `%mp_storediffs` compares the pre-load snapshot with the applied changes and appends row-level audit records; modified-row entries with no actual value change are removed (`MOVE_TYPE="M" and IS_PK=0 and IS_DIFF=0`).
* A summary row is inserted into `&dclib..MPE_DATALOADS` (libref, dsn, etlsource, loadtype, changed/new/deleted counts, duration, macro version, user, timestamp) unless `LOG=0`.
## REPLACE Load Type
REPLACE is the simplest load type and is deliberately **not** routed through `%bitemporal_dataloader` — it is implemented inline in `%mpe_targetloader` and simply wipes the target table (`delete * from`) and re-appends the staging table verbatim, with no history, change detection, key matching, audit rows, or `MPE_DATALOADS` logging. See [replace-load-type.md](replace-load-type.md) for the full deep dive.
## Supporting Tables
| Table | Role |
|---|---|
| `MPE_TABLES` | Per-table load configuration (loadtype, keys, temporal vars, audit target) |
| `MPE_SELECTBOX` | Dropdown values, including the LOADTYPE list (seeded in `mpe_makedata.sas`) |
| `MPE_SUBMIT` / `MPE_REVIEW` | Approval workflow state |
| `MPE_LOADS` | Submission-level log of CSV package loads |
| `MPE_DATALOADS` | Per-load statistics (temporal/UPDATE loads only) |
| `MPE_AUDIT` (or `AUDIT_LIBDS`) | Row-level change audit (temporal/UPDATE/FORMAT_CAT loads only) |
| `MPE_LOCKANYTABLE` | Lock control table |
| `MPE_CONFIG` | Engine-specific config (e.g. `DCBL_REDSH` scope for Redshift bulk options) |
## Testing
Unit tests for the loaders live next to the macros: `sas/sasjs/macros/bitemporal_dataloader.test.[1-4].sas` and `sas/sasjs/macros/mpe_targetloader.test.sas` (REPLACE loadtype), executed as sasjs tests. See [testing.md](testing.md) for how to run them (`npm run 4gl` then `sasjs test -t 4gl` from the `sas/` directory), and remember `sasjs lint` after touching any `.sas` files.
+19
View File
@@ -0,0 +1,19 @@
# Dependency Updates Checklist
Whenever any `package.json` (root, `client/`, or `sas/`) or lockfile is modified, run the same checks CI runs before pushing:
1. **npm audit** (must be clean for prod deps):
```bash
npm audit --omit=dev # in repo root
cd sas && npm audit --omit=dev
cd ../client && npm audit --omit=dev
```
Fix with `npm audit fix`, targeted `overrides` in `package.json`, or version bumps — never `npm audit fix --force` blindly, as it can introduce breaking changes.
2. **License checker** (client only):
```bash
cd client && npm run license-checker
```
If a new dependency fails, either add its SPDX id to the `onlyAllow` list in `client/licenseChecker.js` (if the license is acceptable, e.g. permissive ones like `BlueOak-1.0.0`) or add the specific package to `excludePackages` with justification. Data Controller ships on-prem, so only OSI-approved permissive licenses are acceptable for production dependencies.
Both checks run in `.gitea/workflows/build.yaml` (`Check audit` and `Licence checker` steps) and will fail the build if skipped locally.
+86
View File
@@ -0,0 +1,86 @@
# Regex Validations (HARDREGEX / SOFTREGEX)
This document describes how the regex validation rules work internally. For user-facing documentation see `docs/dcc-validations.md` in the `docs.datacontroller.io` repo.
## Overview
Two validation rule types in `MPE_VALIDATIONS` validate cell values against a regular expression supplied in `RULE_VALUE`:
- `HARDREGEX` - submission-blocking. A failing value is rejected by the cell validator and painted red (HOT's own `htInvalid` class), so the row cannot be submitted.
- `SOFTREGEX` - display-only warning. A failing value is painted yellow (`dc-warning-cell` class) with a tooltip, but submission is not blocked.
Both rule types are selectable in the MPE_VALIDATIONS RULE_TYPE dropdown; they were added to the selectbox seed data in `sas/sasjs/macros/mpe_makedata.sas` and via the optional migration `sas/sasjs/db/migrations/20260720_v7.12_release.sas`. `RULE_VALUE` is limited to 128 characters, which constrains very long patterns.
## Config-time validation (SAS side)
`sas/sasjs/services/hooks/mpe_validations_postedit.sas` runs `prxparse()` on any staged HARDREGEX/SOFTREGEX rule value and aborts the edit with a list of offending `libref.table.column` references if the pattern is invalid. This is a best-effort syntax check to catch typos at config time; an empty pattern is treated as valid (it matches everything in JS). Rows marked for delete are skipped.
Because patterns must pass `prxparse`, rule values are authored in the SAS PRX delimiter form `/pattern/flags` (although a bare pattern is also accepted for backwards compatibility).
## Frontend evaluation
All regex handling lives in the client; there is no server-side re-validation of data values. The per-cell decision flow:
```mermaid
flowchart TD
A[Cell value] --> B{Row marked for delete<br/>and not a PK column?}
B -- Yes --> Z[No validation / no warning]
B -- No --> C{isRegexRuleExempt?<br/>blank, or "." on a numeric column}
C -- Yes --> Z
C -- No --> D{HARDREGEX rule on column?}
D -- Yes --> E{Pattern matches?}
E -- No --> F[Invalid: submission blocked,<br/>red htInvalid + REGEX tooltip]
E -- Yes --> J[Valid]
D -- No --> G{SOFTREGEX rule on column?}
G -- Yes --> H{Pattern matches?}
H -- No --> I[Warning: yellow dc-warning-cell<br/>+ REGEX tooltip, submission allowed]
H -- Yes --> J[Valid]
G -- No --> J
```
A malformed pattern never reaches this flow: it is treated as always-valid (HARDREGEX) / never-warn (SOFTREGEX) with a `console.warn`, rather than breaking the editor.
### `client/src/app/shared/dc-validator/utils/parseRegexRule.ts`
Converts an authored SAS PRX pattern into a JavaScript `RegExp`:
1. If the value matches `/^\/(.*)\/([a-z]*)$/s`, the delimiters are stripped and the trailing flags are passed to `new RegExp(body, flags)`. Otherwise the value is used as-is (backwards compatibility with bare patterns).
2. Three mechanical Perl→JS translations are applied to the body:
- a leading `(?i)` inline modifier is removed and folded into the `i` flag;
- `\Q...\E` literal sequences are replaced with escaped literal text;
- `\A``^` and `\z``(?![\s\S])` (end-of-string anchor).
3. Perl-only constructs that would need a capture-group-renumbering rewrite (atomic groups `(?>...)`, possessive quantifiers `a++`) are deliberately NOT translated. They throw from `new RegExp`, and every caller treats a throw as "always valid / never warn" (see below) rather than breaking the editor.
### `client/src/app/shared/dc-validator/utils/isRegexRuleExempt.ts`
Blank values (`undefined`, `null`, `''`) are exempt from pattern matching on any column type - use a separate NOTNULL rule if populated values must also be enforced. On numeric columns the plain SAS missing (`.`) is also exempt; special missings (`.A`-`.Z`, `._`, bare letters) are NOT exempt anywhere - being deliberately set, they are real values the pattern must match (and on a character column even `.` is real text). `isSpecialMissing` from `@sasjs/utils` is deliberately not used: its optional-dot regex would exempt any single-letter character value ("d", "z") before the regex ever ran. The check takes an `isNumeric` flag, passed by all callers (the dq validator via `dqValidate(rules, value, colType === 'numeric')`, the warning renderer via a `makeRegexWarningRenderer` argument, and `failsSoftRegex` via the column's HOT type).
### HARDREGEX - blocking validation
`HARDREGEX` is implemented as a cell validator in `client/src/app/shared/dc-validator/validations/dq-validation.ts`. It returns `true` (valid) for exempt values and for patterns that fail to compile (with a `console.warn`), and otherwise returns `parseRegexRule(ruleValue).test(value.toString())`. Returning `false` makes HOT mark the cell invalid, block submission, and paint it red via its standard `htInvalid` styling.
### SOFTREGEX - warning renderer
`client/src/app/editor/utils/regex-warning-renderer.ts` builds a display-only Handsontable renderer (registered per-column by `DcValidator.setupRules` in `client/src/app/shared/dc-validator/dc-validator.ts`). It never returns false; it only:
- adds a `REGEX: <pattern>` tooltip (`td.title`) when a rule fails;
- adds the yellow `dc-warning-cell` class when only SOFTREGEX fails.
`DcValidator.failsSoftRegex(col, value)` provides the same logic outside the grid (e.g. the edit-record screen).
### Precedence: HARD and SOFT on the same column
Only one regex ever runs per column. If a HARDREGEX rule exists, SOFTREGEX is ignored entirely - never compiled, never evaluated - regardless of whether individual cell values pass or fail the hard rule. A value failing HARDREGEX gets the red invalid styling (blocking submission) plus a `REGEX: <pattern>` tooltip; a SOFTREGEX-only column warns in yellow without blocking. This holds in both the renderer and `failsSoftRegex`.
### Other behaviour
- Rows marked for delete (`_____DELETE__THIS__RECORD_____ = 'Yes'`) are not warned/validated by the renderer (except primary key columns, which still validate).
- A malformed pattern never breaks the editor: the dq validator treats it as always-valid and the renderer disables the warning, logging to the console instead.
- The pattern is used as authored - it is NOT auto-anchored. Authors must include `^`/`$` to match the entire cell value.
- Column info: `client/src/app/shared/utils/col-info-html.ts` shows the applied pattern in the column-info dropdown - the HARDREGEX pattern if one exists (it is the rule actually applied when both are present), otherwise the SOFTREGEX pattern, otherwise nothing.
## Testing
- Unit tests: `parseRegexRule.spec.ts`, `isRegexRuleExempt.spec.ts`, `dq-validation.spec.ts`, `dc-validator.spec.ts` (under `client/src/app/shared/dc-validator/`), `client/src/app/editor/utils/regex-warning-renderer.spec.ts`, `client/src/app/shared/utils/col-info-html.spec.ts`.
- E2E: `client/cypress/e2e/editor.cy.ts`.
- SAS side: `sas/sasjs/services/editors/stagedata.test.3.sas`, plus seed data in `mpe_makedata.sas`: HARDREGEX "SOME_CHAR must contain 'the' or 'data'" (`/the|data/i`), SOFTREGEX "SOME_CHAR should contain the letter 't'", SOFTREGEX on PRIMARY_KEY_FIELD (`/^\d+$/` - yellow if the key contains a decimal), and HARDREGEX on SOME_SHORTNUM (`/^(?![1-5](\.\d+)?$).*/` - values 1-5 blocked; generated data starts at 6 so demos aren't blocked accidentally).
+28
View File
@@ -0,0 +1,28 @@
# Releases and the CHANGELOG
**Do not edit `CHANGELOG.md` by hand, and do not bump the version in `package.json` yourself.** Both are generated automatically by the release pipeline. Manual edits produce duplicate/colliding version sections and merge conflicts, and they are pointless because the next release overwrites them anyway.
## How releases work
Releases are driven by [semantic-release](https://semantic-release.gitbook.io/) from the `release` job in `.gitea/workflows/release.yaml`, which runs on every push to `main` (after the build/test jobs pass). The configuration lives in `.releaserc` at the repo root.
The plugin chain (`.releaserc` `plugins`) does the following on each release:
1. `@semantic-release/commit-analyzer` - inspects Conventional Commit messages since the last tag to decide the next semantic version (`fix:` -> patch, `feat:` -> minor, breaking change -> major).
2. `@semantic-release/release-notes-generator` - builds the release notes from those commits.
3. `@semantic-release/changelog` - **writes the new section into `CHANGELOG.md`**.
4. `@semantic-release/npm` - updates the `version` in `package.json` (it does not publish; the package is `private`).
5. `@semantic-release/git` - commits `CHANGELOG.md` and `package.json` back to `main` as `chore(release): <version> [skip ci]` and tags it.
6. `@saithodev/semantic-release-gitea` - creates the Gitea release.
The build assets (frontend zip, SAS 9 / Viya / SASjs Server deployment files) are attached to the release afterwards by the workflow's own `Upload assets to release` step using the Gitea API (`curl`), not by the plugin. Before creating the release the workflow first runs `semantic-release --dry-run` and aborts the job if there are no releasable changes since the last tag.
Because the changelog entry and version bump are committed by the pipeline (step 5), they must **not** exist in your working tree beforehand. If they do, the release commit collides with them.
## What this means for you
- Never add, remove, or reorder entries in `CHANGELOG.md`.
- Never change `version` in `package.json`.
- Control what appears in the changelog through your **commit messages** (Conventional Commits: `fix:`, `feat:`, `feat!:`/`BREAKING CHANGE:`, plus scopes like `fix(editor): ...`). The scope and description become the changelog line.
- If you find hand-written entries in `CHANGELOG.md` in the working tree, revert them (`git checkout CHANGELOG.md`).
- CI does not use any local release script - the pipeline installs and runs `semantic-release` directly.
+97
View File
@@ -0,0 +1,97 @@
# REPLACE Load Type — Technical Deep Dive
This document describes the REPLACE load type in detail. For the overall loader architecture, the temporal model, and the other load types, see [bitemporal-dataloader.md](bitemporal-dataloader.md).
## Overview
REPLACE is the simplest load type and is deliberately **not** routed through `%bitemporal_dataloader`. It is implemented inline in `%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`, search for `&loadtype=REPLACE`). There is no history, no change detection, no key matching — the target table is wiped and reloaded from the staging table verbatim.
## Actual load (`LOADTARGET=YES`)
```sas
%mp_lockanytable(LOCK, lib=&lib, ds=&ds, ...)
data WORK.&STAGING_DS;
set WORK.&STAGING_DS;
/* only if the target contains the MPE_TABLES.VAR_PROCESSED variable: */
&VAR_PROCESSED = &now;
drop _____DELETE__THIS__RECORD_____;
run;
%if &engine_type=CAS %then %do;
/* prep first: cast varchar columns in a CASUSER copy of staging */
proc contents noprint data=&libds out=work.rpl_base_cols(keep=name type);
proc contents noprint data=WORK.&STAGING_DS out=work.rpl_stag_cols(keep=name type);
/* work.rpl_vchars = columns that are varchar in target AND in staging */
data casuser.&tmpds; /* temp copy with varchars casted */
length <varchar col> varchar(*); ...
set WORK.&STAGING_DS (rename=(<varchar col>=<tmp> ...));
<varchar col>=<tmp>; ... drop <tmp> ...;
run;
/* unlock + abort if any error so far - nothing destructive done yet */
%mp_abort(iftrue= (&syscc>0) ...)
/* destructive step, deliberately last before the append */
proc cas;
table.deleteRows / table={caslib="&lib",name="&ds",where="1=1"};
quit;
data &libds (append=yes) / sessref=dcsession;
set casuser.&tmpds;
run;
proc sql; drop table CASUSER.&tmpds; quit;
%end;
%else %do;
/* unlock + abort if any error so far - nothing destructive done yet */
%mp_abort(iftrue= (&syscc>0) ...)
proc sql;
delete * from &libds;
quit;
proc append base=&libds data=WORK.&STAGING_DS force nowarn; run;
%end;
%mp_lockanytable(UNLOCK, lib=&lib, ds=&ds, ...)
```
Step by step:
1. **Lock** — the target is locked via `%mp_lockanytable` (control table `&dclib..MPE_LOCKANYTABLE`). This is the only concurrency guard for the whole operation.
2. **Staging recopy** — the staging dataset is rewritten in place: the processed-timestamp column is set to the approval timestamp **only if** the target table contains the variable named by `MPE_TABLES.VAR_PROCESSED` (unlike `%bitemporal_dataloader`, there is no `PROCESSED_DTTM` fallback). The delete-flag column `_____DELETE__THIS__RECORD_____` is unconditionally dropped — per-record delete semantics do not exist in REPLACE (everything is deleted anyway), so a delete flag submitted by the user is silently discarded (a `drop` of a non-existent variable just produces a warning).
3. **Prepare the load (CAS only)** — fixed char variables cannot be appended to CAS varchar columns, so the staging table is first copied to a CASUSER temp table with every column that is varchar in the target (and present in staging) redeclared as `varchar(*)` via generated `length` / `rename` / assignment statements. This mirrors the CAS append in `%bitemporal_dataloader`.
4. **Pre-destructive abort check** — if `&syscc > 0` after all preparation, the target is unlocked and the macro aborts via `%mp_abort`. Nothing destructive has happened at this point, so a failed prep leaves the target intact (and without a stale lock).
5. **Delete all rows** — every row is removed from the target while preserving structure, indexes and metadata, deliberately as the last step before the append (to minimise the time the target sits empty). On most engines this is `proc sql; delete * from &libds;` (passed through as a `DELETE` on database libraries). CAS tables do not support SQL deletes, so on the CAS engine the table is truncated instead via the `table.deleteRows` action with `where="1=1"` (same approach as `%bitemporal_closeouts`), with the libref passed as the caslib.
6. **Append staged rows** — the entire staging table is appended. On most engines this is `proc append ... force nowarn` (`force` allows the append to proceed despite attribute mismatches, so lengths/formats may be coerced or values truncated, and columns present in only one side are handled; `nowarn` suppresses the associated warnings). On CAS the pre-cast CASUSER temp table is appended with `data &libds (append=yes) / sessref=dcsession` and then dropped.
7. **Unlock**.
## Diff screen (`LOADTARGET=NO`)
When the approver reviews a REPLACE submission, no real comparison is performed:
```sas
/* is full replace so treat all staged records as new in diff screen */
data work.outds_mod work.outds_add;
set work.&staging_ds;
output work.outds_add; /* every staged record is "NEW" */
run; /* outds_mod stays empty */
/* previous table will be considered fully deleted */
data work.outds_del;
set &lib..&ds; /* every existing record is "DELETED" */
run;
```
The approval screen therefore always shows full-table turnover: the entire current table as deleted and the entire staging table as added, with no "modified" records and no original/current comparison.
## What REPLACE does not do
Because it bypasses `%bitemporal_dataloader`, none of the following apply to REPLACE:
* **No row-level audit** — nothing is written to `AUDIT_LIBDS` / `MPE_AUDIT`. The record of the change is the approval package itself (staged CSV and the `TEMPDIFFS` CSV stored under `&mpelocapprovals/&LOAD_REF`).
* **No `MPE_DATALOADS` log entry** — the load-log insert lives inside `%bitemporal_dataloader`, so REPLACE loads do not appear in load history. (Consequently the SHOW_DIFFS timestamp lookup in `postdata.sas` finds no `MPE_DATALOADS` row for a REPLACE load and falls back to the current datetime.)
* **No PK usage** — `BUSKEY` is ignored: no uniqueness check, no dedup, no join to existing data. Staged rows are loaded exactly as submitted, duplicates included.
* **No temporal handling** — `VAR_TXFROM`/`VAR_TXTO`/`VAR_BUSFROM`/`VAR_BUSTO` and `CLOSE_VARS` are ignored. A REPLACE table should not be treated as temporal; querying it with the usual validity filters makes no sense.
* **No retained-key handling** — `RK_UNDERLYING` is ignored.
* **Minimal engine-specific handling** — unlike the temporal loaders, there is no pass-through/temp-table optimisation for OLEDB, Redshift, Postgres or Snowflake. The only engine conditional is CAS, where rows are removed via the `deleteRows` action and appended via a varchar-casting CASUSER temp table and a data-step append (SQL deletes and fixed-char-to-varchar appends are not possible on CAS).
* **Not atomic** — the delete/truncate and append are separate steps with no transaction or rollback, so a session failure between them still leaves the target empty or partially loaded (recovery is a re-approval of the same or a previous staging package). The risk is mitigated by performing all preparation first, aborting on any error before the destructive step, and executing the delete/truncate immediately before the append.
## Interaction with Row Level Security
REPLACE is incompatible with `EDIT`-scope RLS rules (a full-table wipe cannot honour row-level write restrictions); this is enforced at edit time in both directions — see [row-level-security.md](row-level-security.md#incompatibility-with-replace-load-type) and [issue #211](https://git.datacontroller.io/dc/dc/issues/211). `VIEW`-scope rules remain compatible.
+225
View File
@@ -0,0 +1,225 @@
# Row Level Security — Technical Deep Dive
This document explains **how** Row Level Security (RLS) is implemented in the Data Controller backend. For the user-facing configuration guide, see [docs.datacontroller.io/row-level-security](https://docs.datacontroller.io/row-level-security/).
## Overview
RLS in Data Controller is implemented as **server-side WHERE clause generation**. No data leaves SAS without passing through a dynamically generated filter. The filter is built at runtime per request, based on:
1. The requesting user's group memberships (SAS metadata groups + DC groups)
2. The active rules in the `MPE_ROW_LEVEL_SECURITY` control table
3. The access mode (VIEW / EDIT / download / upload)
Because the filter is expressed as a standard SAS `WHERE` expression, it works against **any** engine — Base SAS datasets, database libraries (via implicit SQL pass-through pushdown), SPDE, CAS libnames, etc.
## Request Flow
```mermaid
flowchart TD
A[Client request\nview / edit / download / upload] --> B{Service mode}
B -->|VIEW / EDIT / DLOAD| C["%mpe_filtermaster(mode, libds)"]
B -->|ULOAD stagedata.sas| C
C --> D["%mpe_getgroups()\nmetadata groups + MPE_GROUPS"]
D --> E{User in &mpeadmins?}
E -->|Yes| F[No RLS filter\n1=1]
E -->|No| G[Lookup active rules in\nMPE_ROW_LEVEL_SECURITY\nfor libref.table + user's groups]
G --> H{Rules found?}
H -->|No| F
H -->|Yes| I["%mp_filtergenerate()\nper group, OR'd together"]
I --> J[WHERE clause written\nto temp fileref]
F --> J
J --> K{Read or Write?}
K -->|Read\nviewdata / getdata / getrawdata| L["where %inc filtref;;\nrows filtered server-side"]
K -->|Write\nstagedata| M[Inverse filter\nwhere not( filtref )]
M --> N{badrecords > 0?}
N -->|Yes| O[Abort submission\nSecurity Problem]
N -->|No| P[Staging proceeds\nto approval workflow]
```
## Key Components
| Component | Location | Role |
|---|---|---|
| `MPE_ROW_LEVEL_SECURITY` | `&mpelib` (DC control library) | The rule table (scope, group, libref, table, logic, subgroup, variable, operator, raw value, active flag) |
| `%mpe_filtermaster()` | `sas/sasjs/macros/mpe_filtermaster.sas` | Master macro that assembles the full WHERE clause for a request |
| `%mpe_getgroups()` | `sas/sasjs/macros/mpe_getgroups.sas` | Resolves group membership (metadata groups via `%dc_getusergroups` + `MPE_GROUPS` DC-internal groups) |
| `%mp_filtergenerate()` | SASjs core | Converts a query table (logic/subgroup/variable/operator/value rows) into WHERE clause text |
| `%mp_filtercheck()` | SASjs core | Validates rule syntax at *edit time* (defence against SAS code injection) |
| `mpe_row_level_security_postedit.sas` | `sas/sasjs/services/hooks/` | Post-edit hook that runs `%mp_filtercheck` whenever the RLS table itself is edited |
## The Modes
`%mpe_filtermaster` accepts a `mode` parameter, and every service that surfaces data calls it with the appropriate mode:
| Mode | Caller | Purpose |
|---|---|---|
| `VIEW` | `services/public/viewdata.sas`, `getchangeinfo.sas` | Read-only table viewer |
| `EDIT` | `services/editors/getdata.sas` | The EDIT grid (adds "current records only" validity filtering) |
| `DLOAD` | `services/public/getrawdata.sas` | Raw file downloads (RLS scope treated as `VIEW`) |
| `ULOAD` | `services/editors/stagedata.sas` | **Upload validation** (RLS scope treated as `EDIT`) |
Scope mapping: `DLOAD` requests match rules with `RLS_SCOPE in ('VIEW','ALL')`; `ULOAD` requests match rules with `RLS_SCOPE in ('EDIT','ALL')`.
## Execution Flow of `%mpe_filtermaster`
The macro writes the final WHERE expression to a temporary **fileref** (`outref`), line by line. (A fileref is used because a generated filter may exceed the 64k macro variable limit — and note that `%include` of a fileref is not allowed directly in a `proc sql` where clause, hence callers typically use it in a DATA step or data step view.)
### 1. User-supplied filter (FILTER_RK)
If the request includes a stored filter (`filter_rk > 0`), its clauses are read from `MPE_FILTERANYTABLE` / `MPE_FILTERSOURCE` and generated first via `%mp_filtergenerate`. RLS clauses are then **AND-ed on top** — a user filter can only ever narrow results, never widen them beyond RLS.
### 2. Validity-date filtering (EDIT / DLOAD only)
`MPE_TABLES` may define `VAR_TXFROM` / `VAR_TXTO` (SCD2-style validity variables) for the target table. Unless the user explicitly filtered on those variables, the macro appends:
```
("<current datetime>"dt < VAR_TXTO)
```
so that only current records are surfaced in the EDIT grid and downloads.
### 3. Group resolution and admin bypass
```sas
%mpe_getgroups(user=%mf_getuser(), outds=work.groups)
```
Groups come from two sources, concatenated:
* SAS metadata groups (Viya / EBI / Base-specific logic in `%dc_getusergroups`)
* The `MPE_GROUPS` DC table (group assignments managed inside Data Controller)
If the user is a member of the `&mpeadmins` group, **RLS is skipped entirely** — admins always see all rows.
### 4. Rule extraction
Non-admin users trigger a lookup of active, current rules:
```sas
create table work.&rlsds as
select rls_group, rls_group_logic, rls_subgroup_logic, rls_subgroup_id,
rls_variable_nm, rls_operator_nm, rls_raw_value
from &mpelib..mpe_row_level_security
where &dc_dttmtfmt. lt tx_to /* only current (non-deleted) rules */
and rls_scope in ("&scopeval",'ALL')
and upcase(rls_group) in (select upcase(groupname) from work.groups)
and rls_libref = "<libref>" and rls_table = "<dsname>"
and rls_active = 1
order by rls_group, rls_subgroup_id;
```
### 5. Clause assembly
If rules exist, they are appended to the fileref as `AND ( ... )`. Each **group** the user belongs to contributes one sub-filter, and the group-level sub-filters are joined with `OR`:
```
AND ( <group 1 filter> OR <group 2 filter> ... )
```
Each group's filter is produced by `%mp_filtergenerate`, which honours:
* `RLS_GROUP_LOGIC` — how subgroups (identified by `RLS_SUBGROUP_ID`) are joined
* `RLS_SUBGROUP_LOGIC` — how individual clauses within a subgroup are joined (AND/OR)
So membership in multiple groups is **permissive** (OR): the user sees the union of rows permitted by each of their groups.
### 6. Empty-filter fallback
If nothing was written to the fileref (no stored filter, no validity vars, no matching RLS rules), the macro writes a literal `1=1` so the fileref can always be consumed as a valid WHERE expression. Absence of rules therefore means **no restriction** — RLS is opt-in per table/group.
## How Callers Apply the Filter
### Read path (viewdata.sas)
```sas
%mpe_filtermaster(VIEW, &libds, dclib=&mpelib, filter_rk=&filter_rk,
outref=filtref, outds=work.query)
data work.viewdata;
set &libds;
where %inc filtref;;
if _n_ > &DC_MAXOBS_WEBVIEW then stop;
run;
```
The fileref is `%include`d directly inside the `where` statement — the filter never passes through client-visible state, so it cannot be tampered with. For database libraries the WHERE expression is pushed down to the database by the SAS engine.
### Write path (stagedata.sas) — inverse filter
Uploads cannot be filtered; instead the filter is **inverted** and any submitted row matching the inverse is rejected:
```sas
%mpe_filtermaster(ULOAD, &libds, dclib=&mpelib, outref=filtref, ...)
/* prepare inverse query */
data _null_;
infile filtref end=eof;
file &tempref;
if _n_=1 then put 'where not(';
input; put _infile_;
if eof then put ')';
run;
data work.badrecords;
set work.jsdata; /* rows submitted by the user */
%inc &tempref/source2;;
run;
%mp_abort(iftrue=(%mf_nobs(work.badrecords)>0)
,msg=%str(Security Problem - N unauthorised records submitted))
```
If even one submitted row falls outside the user's permitted row set, the entire staging request is aborted before any approval/apply step.
## Incompatibility with REPLACE Load Type
RLS with `EDIT` scope is **incompatible** with tables configured with `LOAD_TYPE=REPLACE` in `MPE_TABLES`. A REPLACE load wipes and reloads the entire target table, so row-level filtering of submitted records cannot be enforced meaningfully (the rows a user is *not* allowed to see would also be deleted). Backend validations therefore abort in both directions (see [issue #211](https://git.datacontroller.io/dc/dc/issues/211)):
1. **`mpe_row_level_security_postedit.sas`** — aborts when activating a rule with `RLS_SCOPE in ('EDIT','ALL')` against a table whose current `MPE_TABLES` record has `LOADTYPE='REPLACE'`.
2. **`mpe_tables_postedit.sas`** — aborts when setting `LOADTYPE='REPLACE'` on a table that already has active, current `RLS_SCOPE in ('EDIT','ALL')` rules in `MPE_ROW_LEVEL_SECURITY`.
`VIEW`-scope rules remain compatible with REPLACE loads, since they only affect read paths.
## Edit-Time Validation (Injection Defence)
Because `RLS_RAW_VALUE` is free text that ends up inside a generated WHERE clause, it is a potential SAS code-injection vector. Mitigations:
1. **`mpe_row_level_security_postedit.sas`** — a post-edit hook on the `MPE_ROW_LEVEL_SECURITY` table itself. Every newly staged rule (with `rls_active=1`) is grouped by target `libref.table` and run through `%mp_filtercheck(targetds=..., abort=YES)`, which compiles/tests each clause against the real target table and rejects the whole submission on invalid syntax (see [mp_filtercheck](https://core.sasjs.io/mp__filtercheck_8sas.html)).
2. **Format rules** enforced by validation: character values must be single quoted, `IN`/`NOT IN` values must be bracketed, `BETWEEN` must contain `AND`.
Additionally, editing `MPE_ROW_LEVEL_SECURITY` is itself a Data Controller table edit, so it goes through the normal approval workflow, audit trail (`MPE_AUDIT`), and can be column-restricted via [Column Level Security](https://docs.datacontroller.io/column-level-security/).
## Worked Example
Given these active rules (all for `MYLIB.MYDS`, `RLS_ACTIVE=1`):
| SCOPE | GROUP | GROUP_LOGIC | SUBGRP_LOGIC | SUBGRP_ID | VAR | OP | VALUE |
|---|---|---|---|---|---|---|---|
| ALL | Group 1 | AND | AND | 1 | VAR_2 | IN | ('this','or') |
| ALL | Group 1 | AND | AND | 1 | VAR_3 | < | 42 |
| ALL | Group 2 | AND | AND | 1 | VAR_4 | CONTAINS | 'xyz' |
A non-admin user in **both** groups, opening the VIEW page, gets a fileref containing:
```
AND ( (VAR_2 IN ('this','or') AND VAR_3 < 42) OR (VAR_4 CONTAINS 'xyz') )
```
resulting in:
```sas
data work.viewdata;
set mylib.myds;
where (VAR_2 IN ('this','or') AND VAR_3 < 42) OR (VAR_4 CONTAINS 'xyz');
run;
```
The same user submitting an EDIT upload has the inverse applied to their staged rows; any row not matching the expression above aborts the submission.
## Summary of Security Properties
* **Enforced server-side** in every read service (VIEW/EDIT/DLOAD) and every write service (ULOAD via inverse filter).
* **Admin bypass** is explicit (`&mpeadmins` group membership check).
* **Multi-group semantics are OR** (union of permitted rows); rules within a group/subgroup are AND/OR per configuration.
* **Fail-open by design**: if no rules match a table, it is unrestricted — RLS must be opted into per table.
* **Injection-resistant**: values are validated with `%mp_filtercheck` at the time rules are edited, not at query time.
+21
View File
@@ -0,0 +1,21 @@
# Testing
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory.
## Commands
- Full deploy + test cycle: `npm run 4gl` (compiles, deploys to the 4gl target, runs makedata) then `sasjs test -t 4gl`.
- Run tests only (no rebuild/redeploy): `sasjs test -t 4gl`.
- Run a subset of tests: `sasjs test -t 4gl SOMESTRING` — the positional argument filters to matching tests (e.g. `sasjs test -t 4gl stagedata.test.3`).
## Results
- Test results: `sas/sasjsresults/testResults.json` / `.csv` / `.xml`
- Individual test logs: `sas/sasjsresults/logs/<testsuite>.log` (e.g. `services_editors_stagedata.test.3.log`)
- Coverage: `sas/sasjsresults/coverage.lcov`
## Notes
- Test source files live in `sas/sasjs/**`; `sas/sasjsbuild/` is generated build output — do not hand-edit it.
- `sasjs test` executes tests against the deployed app, so run `npm run 4gl` first after changing any service/hook/test code.
- Assertions are made with `%mp_assert()`; results are written to `work.test_results`.
+82
View File
@@ -0,0 +1,82 @@
---
name: dc-sas
description: >
Use this skill alongside the sasjs/skills collection (sas, sasjs-core, sasjs-cli) when working on
Data Controller SAS code. Covers the repo-specific SAS knowledge that is NOT in those skills —
DC test-state conventions (MPE control tables, dctest, %let syscc) and the chunked-deploy
strategy for the huge generated build script (sasjsbuild/viya.sas). For general SAS language,
@sasjs/core macro, and @sasjs/cli guidance, rely on the sas, sasjs-core and sasjs-cli skills.
---
# SAS Development in Data Controller
The sas, sasjs-core and sasjs-cli skills cover general SAS language and SASjs conventions (macro
quoting, `%mp_assert*` testing style, `sasjs lint`, `sasjs run`, `sasjsbuild/` being generated
output). These notes only cover the parts specific to Data Controller.
## Tests must be idempotent
A test file must pass when run repeatedly (including after a run that failed partway).
- Clean up **all** persistent DC state, not just the obvious one: `MPE_TABLES` registrations,
`MPE_LOCKANYTABLE` lock records (a run dying between LOCK and UNLOCK leaves a stale `LOCKED`
row), physical tables in `dctest` (`proc datasets ... delete`), and global macro variables
created via `select ... into:` (`%symdel`).
## Chunked deploy to Viya
The full DC build script (`sasjsbuild/viya.sas`) is very large (11+ MB / 300k+ lines). The Viya
compute API can hang or fail when submitting a file this size in a single request.
**Chunked deploy.** Split the build script at `%let path=<folder>` boundaries — these are clean
section breaks between service/test folders (always preceded by blank lines, never inside a data
step). Each sub-chunk must be prepended with the shared header (the first ~3827 lines containing
macro definitions, `appLoc` setup, and logging options:
`options ps=max nonotes nosgen nomprint nomlogic nosource2 nosource noquotelenmax;`
Logging is already suppressed in the header — no extra action needed. Keep each chunk under ~1.5
MB. The `tests/macros`, `tests/services`, and streaming-app tail sections are the largest and may
need further sub-splitting at `%let service=` boundaries.
When splitting mid-folder (between services in the same folder), the sub-chunk that starts
mid-folder must re-assert `%let path=<folder>;` before its first `%let service=` line — otherwise
`&path` is unresolved and the `%mv_createwebservice(path=&appLoc/&path, ...)` call will abort with
a recursive reference error.
Deploy each chunk in order with `sasjs run <chunk>.sas -t <target>`. After all chunks are
deployed, run `sasjs request services/admin/makedata -d deploy/makeDataViya.json -t <target>` to
create the database.
**Non-fatal errors.** `ERROR: Unauthorized` on `/dataSources/providers/Compute/` lines in the
log are expected for non-admin Viya users — they come from the Data Sources API during
lineage/catalog refresh and do not affect the deploy.
## Repo conventions for .sas files
- Test files are named `<thing>.test.sas` (or `<thing>.test.N.sas`) and run via
`npm run 4gl && sasjs test -t 4gl` from `sas/` — see `.agents/docs/testing.md`.
- `sas/sasjsbuild/` is generated build output — never hand-edit it; edit sources under
`sas/sasjs/` only.
## Temp and scratch files stay out of the repo
Transient artifacts from manual test runs against sasjs-server — captured `_webout` JSON
responses (e.g. `*_out.json`), request/response logs (e.g. `*.log`), and iteration snapshots
(`vd_out2.json`, `stage_check2.json`, etc.) — must not be left in the working tree. They
clutter `git status`, are never referenced by source or tests, and easily leak local
filesystem paths (the sasjs-server session dir, `/home/.../sasjs_root/sessions/.../`) into
committed history.
Write them to a gitignored tmp location instead. The repo already provides two:
- `tmp/` (gitignored at repo root) — general scratch space.
- `sas/tmp/` (gitignored via the root `tmp/` rule) — for SAS-side artifacts.
If a helper script dumps captured output for comparison during a debug session, point it at
`tmp/` (or `sas/tmp/` for SAS-local paths) rather than alongside the tracked mocks in
`sas/mocks/`. The tracked mock fixtures live under `sas/mocks/sas9/` and
`sas/mocks/sasjs/services/`; loose `*_out.json` / `*.log` files at the `sas/mocks/` root are
not mocks, they are leftover captures — remove them with `git clean -fd sas/mocks/` or
write to `tmp/` in the first place.
+63
View File
@@ -0,0 +1,63 @@
---
name: sas
description: Expert guidance for the SAS programming language — DATA step, PROC SQL, macro language, formats, ODS, and common procedures. Pure SAS syntax only, no SASjs-framework content. Use when writing, reviewing, or debugging .sas programs, or answering SAS language questions.
---
# SAS Language
Write idiomatic, production-quality SAS code. This skill covers the SAS language itself, independent of any framework.
## Scope
- DATA step programming (SET/MERGE/BY, RETAIN, arrays, DO loops, hash objects, first./last. processing)
- PROC SQL (joins, subqueries, views, pass-through with CONNECT TO)
- Macro language (%macro/%mend, macro variables, %local/%global, conditional %IF logic, quoting functions %STR/%NRSTR/%BQUOTE, CALL SYMPUTX)
- Common PROCs: SORT, MEANS/SUMMARY, FREQ, TRANSPOSE, REPORT, PRINT, CONTENTS, DATASETS, FORMAT, IMPORT/EXPORT
- Formats and informats (user-defined via PROC FORMAT, picture formats)
- ODS output (HTML, PDF, RTF, Excel, OUTPUT destination)
- File I/O: LIBNAME, FILENAME, INFILE/INPUT, FILE/PUT
## Non-negotiable: no WARNINGs
Generated SAS code must run cleanly — **zero WARNINGs (and zero ERRORs) in the log**. Treat every `WARNING:` as a defect: uninitialized variables, implicit type conversions, truncation notes that should be warnings, "no observations", unresolved macro references, etc. If a warning is truly unavoidable, suppress it deliberately (e.g. an explicit option) and comment why. Likewise avoid superfluous NOTEs where reasonable.
## Style rules
These follow the @sasjs/core coding standards — apply them to all SAS code:
- One statement per line; indentation = 2 spaces, no tabs, no trailing whitespace
- Lines no longer than 80 characters; unix (LF) line endings; UTF-8
- Avoid non-ASCII / special characters entirely — maximum compatibility across SAS installations and encodings
- Always end steps with `run;`; for `proc sql` (and CAS-connected procs) `quit;` is essential to avoid `WARNING: You cannot disconnect or terminate session ...` on Viya
- All dataset references must be 2-level (`work.blah`, not `blah`) — protects against `DATASTMTCHK=ALLKEYWORDS` and an active `USER` library
- Explicit `length` / `attrib` for character variables rather than relying on defaults (avoids implicit length=8 truncation)
- Use literal suffixes for clarity (`'01JAN2020'd`, `'12:30't`)
- Prefer `proc sort` with `nodupkey` over manual dedup logic
- Macros:
- Define with parentheses, even with no parameters: `%macro x();` not `%macro x;`
- Closing `%mend;` must repeat the macro name
- Macro calls are not terminated with a semicolon: `%my_macro()` not `%my_macro();`
- Mandatory parameters positional; optional parameters keyword (`var=`) style
- Macro names lowercase, verb-noun convention
- Macro variables without trailing dot (`&var` not `&var.`) unless needed to prevent incorrect resolution
- Macro variable NAMES are case-insensitive: `&Foo`, `&FOO`, and `&foo` all resolve to the same symbol (same for `%symexist`/`%superq`/`symget` arguments, which take a NAME not a value). Don't chase case mismatches as a bug — it's never the cause.
- ALL macro variables must be `%local` unless deliberately global (globals should use an application prefix to avoid collisions); use `call symputx` (not `symput`) in DATA steps
- Comment with `/* */` inside macros (asterisk comments are compiled into the macro)
- Guard macro logic with `%length(&var)=0` checks rather than `&var=` (empty comparisons are unsafe)
- Avoid naming collisions: use `%sysfunc`-/`&syslast`-based work tables (e.g. `data &output; set &syslast; run;`) rather than hard-coded names
- No open (non-macro) conditional code: wrap platform-branching or conditionally-executed blocks (e.g. `%if %mf_getplatform()=VIYA %then %do; ... %end;`) in a `%macro ... %mend` and invoke the macro. Open `%if` at program level fails in some execution contexts (job/scheduler/test harnesses) and hides scope leaks.
## Portability awareness
- Note when code differs between SAS 9.4 and Viya (e.g. CAS actions vs procs, `proc casutil` for sashdat loading, no X command on locked-down servers)
- Avoid hard-coded physical paths and engine-specific options unless asked
- No open macro code with if/else logic: wrap branching blocks in `%macro ... %mend` and call them — open `%if`/`%else` does not behave as expected in all SAS environments.
## Common pitfalls to flag when reviewing
- Unintended many-to-many MERGEs
- Automatic variable `_ERROR_` / implicit RETAIN surprises
- Macro timing issues: referencing `&macrovar` before it exists, `%if` evaluating data-step variables (use `if`/`symget` instead)
- Automatic macro variables (`&syscc`, `&syswarningtext`, `&syserrortext`, `&sysdate`, `&sysuserid`, etc.) are READ-ONLY — attempting to overwrite them (e.g. `%let syswarningtext=;` or `call symput('syscc',...)`) raises `ERROR: Unable to assign value to a macro variable that is read only` (or similar). Never try to "clear" them.
- Truncation from implicit length=8 on first assignment
- `proc sql` cartesian product warnings
+68
View File
@@ -0,0 +1,68 @@
---
name: sasjs-adapter
description: Frontend/Node integration with SAS backends using @sasjs/adapter — configuring the SASjs class, authentication (SAS 9, Viya, SASjs server), requests with input/output tables, file upload, and session management. Use when writing TypeScript/JavaScript that calls SAS services or jobs.
---
# @sasjs/adapter
`@sasjs/adapter` is the TypeScript library for calling SAS services/jobs from browsers or Node, with a unified API across three server types: `SAS9`, `SASVIYA`, `SASJS`.
## Basic setup
```ts
import SASjs from '@sasjs/adapter'
const sasjs = new SASjs({
serverUrl: 'https://sas.example.com',
serverType: 'SASVIYA', // SAS9 | SASVIYA | SASJS
appLoc: '/Public/app/myapp', // root folder of deployed services
contextName: 'SAS Job Execution compute context', // Viya only
debug: false
})
```
## Executing a request
```ts
const response = await sasjs.request('services/common/getdata', {
mytable: [{ col1: 'value', col2: 42 }] // input tables as JS arrays of objects
})
// response.result contains output tables sent back from SAS (_webout JSON)
```
- Input tables become SAS datasets via the `sasjs_tables` mechanism (work tables named after the JS keys).
- The SAS service must write JSON to `_webout` — conventionally with the `mp_jsonout` macro from @sasjs/core, wrapped in `proc stp`-style begin/end macros.
- Responses follow the `SASjsRequest`/`SASjsResponse` types; check `response.result` for tables and `response.log` where available.
## Authentication
- **SAS 9**: `sasjs.logIn(username, password)` (form-based against the stored process server). Session cookie is managed automatically.
- **Viya**: OAuth client/secret (client credentials grant) or authorization code flow; tokens are refreshed automatically. Configure via CLI (`sasjs add cred`) for Node usage.
- **SASJS server**: token-based auth against the sasjs/server API.
## Key classes / modules
- `SASjs` — main facade: `request()`, `logIn()/logOut()`, `uploadFile()`, `executeScript()`
- `SessionManager` — Viya compute session lifecycle
- `ContextManager` — Viya compute context selection
- `SASViyaApiClient` / `SAS9ApiClient` / `SASjsApiClient` — low-level per-platform clients (rarely needed directly)
- `file/` utilities — file upload to SAS (binary content handling)
## Tips
- Set `debug: true` to surface the SAS log in responses while developing.
- Always handle `response.status` / error responses — SAS-side errors (e.g. from `%mp_abort`) come back in the JSON, not necessarily as HTTP errors.
- For large payloads prefer CSV upload or streamed files over JSON input tables.
- Keep `appLoc` consistent with the `appLoc` in `sasjsconfig.json` used to deploy.
## Important: request() inputs are ALWAYS tables
Every key in the `data` object of `sasjs.request(path, data)` is serialized via the `sasjs_tables` CSV mechanism and arrives in SAS as a **work dataset named after the key** — even scalar values. You cannot pass ad-hoc macro variables this way; services must read inputs from the work table (e.g. `data _null_; set work.config; call symputx('rootdir', rootdir); run;`). Output column names in `response.result.<table>` come back UPPERCASE (SAS dataset semantics).
## Using the adapter without a bundler (zero-build / strict CSP frontends)
The package root `index.js` is a UMD bundle exposing a global `SASjs`. Pattern (from the minimal seed app):
1. `"prepare": "cp node_modules/@sasjs/adapter/index.js src/sasjs.js"` in package.json (runs on `npm i`).
2. `<script src="sasjs.js"></script>` before your app script.
3. Configure via a hidden custom element: `<sasjs serverType="SASJS" appLoc="/Public/app/myapp" debug="false"></sasjs>` and read attributes with `document.querySelector('sasjs')`. When the app is streamed by SAS itself, omit `serverUrl` — same-origin requests just work (CSP `default-src 'self'` safe).
+115
View File
@@ -0,0 +1,115 @@
---
name: sasjs-cli
description: Using the SASjs CLI (@sasjs/cli) to create, compile, build, deploy, run, and test SASjs projects against SAS 9, Viya, and SASjs server targets. Use for any `sasjs <command>` usage, CI/CD pipelines, target/auth config, sasjsconfig.json, service packs, or frontend streaming builds.
---
# @sasjs/cli
The SASjs CLI (`npm i -g @sasjs/cli`, invoked as `sasjs`) automates compiling, building, and deploying SAS projects. All commands support `-t <target>` to select a target from `sasjsconfig.json`.
## Targets and auth
- A **target** = `{ name, serverUrl, serverType, appLoc }`. `serverType` is one of `SAS9`, `SASVIYA`, `SASJS`.
- Credentials: `sasjs add cred` (or `.env` file). Viya uses client/secret **or** `sasjs auth login` (user/pass, no client/secret needed — see below); SAS 9 uses user/pass; SASJS server uses an access token.
- `sasjs context` manages Viya compute contexts; `sasjs add target` adds a new target.
## Core workflow
```
sasjs create myapp # scaffold a new app (templates available)
sasjs compile # gather macros/services/jobs into per-file build outputs (sasjsbuild/)
sasjs build # produce deployable artefacts: JSON + .sas per target
sasjs deploy # deploy compiled/built artefacts to the target server
sasjs cbd # compile + build + deploy in one step (-t viya etc.)
```
## Command reference
| Command | Purpose |
|---|---|
| `sasjs create / init` | Scaffold new app or add SASjs to existing repo |
| `sasjs compile` | Resolve dependencies (`<h4> SAS Macros </h4>` etc.) into `sasjsbuild/` |
| `sasjs build` | Create build JSON / service pack per target |
| `sasjs deploy` / `cbd` | Deploy to server (servicepack or direct) |
| `sasjs run <file.sas>` | Execute an arbitrary SAS file on the server, return log |
| `sasjs request <path>` | Execute a deployed service/job with input data (`-d`) |
| `sasjs job execute` | Run a deployed job |
| `sasjs flow execute` | Run a sequence of jobs with dependencies (CSV-defined flows) |
| `sasjs servicepack deploy` | Deploy from a JSON service pack |
| `sasjs web` | Build the frontend and stream it into the SAS web root (streamConfig) |
| `sasjs db` | Build database DDL/data load scripts from `sasjs/db` |
| `sasjs doc` | Generate doxygen documentation + lineage |
| `sasjs folder create/delete/move` | Manage folders in SAS metadata / SAS Drive |
| `sasjs fs` | File-system operations against the server (sync folders) |
| `sasjs test` | Execute tests defined in `sasjs/tests` with init/term programs |
| `sasjs lint` | Lint `.sas` files per `.sasjslint` config |
| `sasjs version` | Print/set version info |
## Conventions
- Test coverage is generated only from a `sasjs compile` (or `sasjs c`). It accepts a target (`-t <target>`), but nothing is deployed to that target — compilation and coverage are fully local/offline, so no server needs to be available or reachable. Missing macro dependencies (e.g. `mp_ds2csv.sas`) mean `@sasjs/core` isn't installed — run `npm i` first.
- Dependencies are declared in doxygen headers: `<h4> SAS Macros </h4>`, `<h4> SAS Files </h4>`, `<h4> SAS Folders </h4>`, and `@li item` entries — the CLI builds the dependency tree from these.
- `sasjs compile` output goes to the `sasjsbuild/` folder (git-ignore it); `sasjsresults/` holds test/run outputs.
- CI/CD: `sasjs cbd -t viya` is the standard deploy step; combine with `sasjs servicepack deploy` for artefact-based releases.
- Exit codes are non-zero on failure — safe for pipelines.
## Gotchas
- Run `npm i` before `sasjs cb` — macro dependency resolution needs `node_modules/@sasjs/core` present, and `@sasjs/core` (and `@sasjs/adapter` if used) must be listed in `package.json`.
- Credentials files are per-target: `.env.<targetname>` (e.g. `.env.server`) with `CLIENT`, `ACCESS_TOKEN`, `REFRESH_TOKEN`. Never commit them — gitignore `.env*`.
## Viya auth without a client/secret (`sasjs auth login`)
`sasjs auth login -t <target>` authenticates with a regular SAS username/password via the OAuth2 password grant against the built-in, secret-less `sas.cli` public client. No admin-registered OAuth client is needed — the fastest way to get `sasjs run`/`deploy` working on dev/demo estates. The password is never stored; the minted ACCESS_TOKEN/REFRESH_TOKEN pair is persisted to `.env.<target>` (local) or `~/.sasjsrc` (global) and verified via `/identities/users/@currentUser` (`Logged in as <id> (<name>)`). Bare `sasjs auth` is still an alias for `sasjs add cred`.
- Token expiry: the CLI silently refreshes via the stored refresh token (works with and without a client/secret), and re-persists the rotated pair — Viya refresh tokens are **single-use/rotating**, so this persistence is what keeps later invocations working. If refresh fails, re-run `sasjs auth login`.
- Some estates give `sas.cli` a short access-token TTL (e.g. 1h); a refresh on most invocations is normal.
- Opaque (non-JWT) tokens are treated as usable — the server is the authority on expiry.
- Limitations: local/LDAP accounts only (no SSO/SAML/MFA estates); password grant must be enabled for `sas.cli` (default on Viya 3.5+/4); ROPC is deprecated in OAuth 2.1 — use a registered client/secret for CI/production.
- `sasjs run` 403 on session creation = the account isn't authorised for the configured compute context — set `contextName: "SAS Studio compute context"` on the target. First run on a cold estate can take many minutes (compute pod spin-up) and may appear to hang.
- Self-signed estates: use `--insecure` on `auth login`, or configure `httpsAgentOptions` on the target.
## Viya streaming apps (streamConfig.streamWeb)
With `streamWeb: true`, `sasjs web`/`cbd -t viya` deploys the frontend **into SAS Files Service**: a streaming job at `<appLoc>/services/<streamServiceName>.html` serves `index.html`, and assets land in `<appLoc>/services/web/...`. At build time every asset/script/css URL in the HTML is rewritten to `/SASJobExecution?_FILE=<appLoc>/services/web/...` and the adapter config element (`<sasjs apploc=...>`) is stamped with the target `appLoc`.
**Consequence: the app only works when served from the exact `appLoc` it was deployed against.** If the streaming HTML is placed anywhere else (e.g. manually uploaded to a user home folder like `/Users/<id>/myapp/...`), all rewritten `/Public/app/...` asset links 404 and the adapter calls the wrong service paths — the page renders with no CSS/JS and no backend. Fix by redeploying. The original build (`sasjs cb`) has the apploc from the sasjsconfig.json - when the app is deployed as a SAS program, the supplied `%let apploc = ` (runtime value) is swapped with the `compiled_apploc` (build time value) to allow apps to be dynamically deployed to a given apploc at deploy time.
### Verifying a Viya deployment headlessly (no browser)
1. Get a token (password grant works out of the box with the built-in `sas.ec` client, empty secret):
`curl -X POST <server>/SASLogon/oauth/token -u 'sas.ec:' -d 'grant_type=password&username=U&password=P'`
2. Fetch the app: `GET /SASJobExecution/?_FILE=<appLoc>/services/<name>.html` with `Authorization: Bearer` → expect `200` and the full `index.html`. `401` = auth, anything else = not deployed there.
3. Fetch each asset the HTML references. Response tells you what's wrong:
- `200` with file content → asset deployed correctly.
- `202` + a tiny plain-text body (e.g. `Parameter Error\nFile error`) → **file does not exist at that Drive path** (classic appLoc-mismatch symptom). Note `_FILE` responses can be async: add `&_action=wait` to get content synchronously.
- Services: `POST /SASJobExecution/?_program=<appLoc>/services/<svc>&_action=wait``Parameter Error / Unable to get job definition` means the service was never deployed as a JES job (files on Drive alone are not enough — only `sasjs deploy`/`cbd`/`servicepack deploy` registers them).
### Talking to SAS — ALWAYS use the adapter or the CLI
When executing a SAS service or job for any purpose (debugging, reproduction, CI), **always go through the `@sasjs/adapter` or `sasjs` CLI** — never hand-roll curl against `SASJobExecution`. The adapter and CLI handle the things that are trivial to get wrong by hand: the input-data CSV format (space-separated `name:$format.` headers, CRLF, double-quoted special values, `%nrstr(...)` wrapping), the execution-mode routing (`_executionTasks=true` reads `sasjs<N>data` as **macro variables**, not file uploads), the `_debug`/`>>weboutBEGIN<<`/`>>weboutEND<<` wrapper parsing, the token refresh, and the `_contextname` URL param. A hand-built comma-separated CSV will read as blanks, silently skip guarded macro blocks, and send you down a rabbit hole of phantom bugs.
- Build input data as a JSON file: `{ "<table>": [{ "<col>": "<val>", ... }] }`, e.g. `{ "config": [{ "rootdir": "/export/...", "runastask": "true", "usecomputeapi": "null", "contextname": "Compute Reusable" }] }`.
- Run: `sasjs request '<appLoc>/services/common/<svc>' -t viya -d data.json -l <path>.log -o <path>.json`. The CLI hardcodes `debug: true`, so the `-l` flag always captures the full MPRINT/NOTE/`&syscc` log; `-o` saves the parsed webout. **Always pass `-l`** — reproducing a bug without the log means re-running the whole thing.
- The Viya Folders/JES REST APIs ARE fine to hit directly with curl (token + `Authorization: Bearer`) for folder/file/member management and for `GET /SASJobExecution/?_FILE=...` asset checks — just not for *executing your own services with input data*.
#### Adapter execution-context and input-data facts
- The adapter automatically appends `_contextname=<value>` as a **URL parameter** to every Viya JES request (you do NOT need to pass it yourself). BUT JES request params are **NOT auto-promoted to SAS macro variables**`%symexist(_contextname)` is false inside the service. If the service needs the chosen context name (e.g. to stamp it into the streamed HTML), pass it in the **input data table** (a `contextname` column) and read it with `call symputx` — that is the reliable channel.
- The target's `contextName` in `sasjsconfig.json` decides which compute context the service runs under (and thus the `runAs` identity). The adapter's URL `_contextname` param is the same value. To reproduce a service under a batch/reusable context via `sasjs request`, set the target's `contextName` to that reusable context (e.g. `Compute Reusable`, runAs=sasbatch) — otherwise it runs as your own identity and write-test steps to batch-owned folders fail with `User does not have appropriate authorization level`.
- Adapter CSV format (so you can read the `NOTE: The infile ... is:` RULE in the log correctly): row 1 is the header with `name:$informat.` pairs **space-separated**; data rows are **comma-separated** (CRLF), with values double-quoted only if they contain a special char (`,`, `"`, tab, newline). The sasjs/core webout reader reads it back with `dsd` + `firstobs=2` + an `input <name>:$informat.;` statement derived from the header.
- `_executionTasks=true` (runAsTask) changes how input data arrives: as `sasjs<N>data` **macro variables** (chunked into `sasjs<N>data0..N`), NOT as `_WEBIN_FILE` uploads. The core webout `mv_webout` macro handles both, but it branches on `_EXECUTIONTASKS` — be aware when reading logs.
### Redeploying cleanly on Viya (the 409 Conflict problem)
Re-running `sasjs cbd`/`sasjs run viya.sas` against an **existing** appLoc often fails mid-deploy with `409 Conflict` (and an `mp_abort``abort cancel`): `mv_createfile` DELETEs the old file id then tries to recreate it, but when an intermediate **folder** already exists (e.g. `<appLoc>/services/web/js`) the recreate step conflicts and the whole deploy aborts — leaving a half-deployed app (services present, some assets missing). The `?recursive=true` folder DELETE also returns `409 You cannot delete the folder because it is not empty`, and individual member/folder DELETEs can return `403` even as the owner, so you cannot easily tear the tree down by hand.
The reliable workaround is to **move the top appLoc folder out of the way** and redeploy to the original path — the deploy creates a fresh folder tree with no conflicts, and the old folder is retained as a backup. This is far faster and more reliable than fighting per-member deletes, and is the recommended pre-deploy step for any non-CI redeploy on Viya. Two ways to move it:
- **Rename in place** (simplest): `PATCH /folders/folders/{id}` with `{"name":"<old>.bak.<ts>","version":2}` — frees the original name, keeps the old folder as a sibling backup.
- **MOVE into a backup parent** (tidier — keeps all backups in one place): create (once) a backup parent folder e.g. `/Users/<id>/macrodash-backups`, then `POST /folders/folders/{backupParentId}/members/{appLocFolderId}?action=move` moves the whole tree (with contents) into it, freeing the original path for the fresh deploy.
(On sasjs/server just `sasjs fs delete` the appLoc first — Drive there supports clean recursive deletes.)
JES applies its own CSP header when streaming (includes `unsafe-inline`/`unsafe-eval`); a strict CSP meta tag in the app's HTML still applies and is the one that matters for the app code.
+99
View File
@@ -0,0 +1,99 @@
---
name: sasjs-core
description: Standards and conventions for the @sasjs/core SAS macro library (mf_*, mp_*, mm*, ms_*, mv_* macros). Use when writing or editing SAS macros in a sasjs/core-style repo, picking an existing macro over reinventing one, or the sasjs/core build, lint, doxygen, and testing conventions.
---
# @sasjs/core — SAS Macro Library
@sasjs/core is an MIT-licensed library of production-quality SAS macros for SAS application development, portable across SAS 9 (meta), Viya, and SASjs server.
## Coding standards (mandatory)
- One macro per file; filename must match the macro name (lowercase, no spaces)
- Macro definitions must use parentheses: `%macro x();` not `%macro x;`
- Macro *calls* are NOT terminated with a semicolon: `%my_macro()` not `%my_macro();`
- All macro variables must be declared `%local` to prevent scope leakage
- Always use `mf_getuniquefileref` when assigning filerefs, and `mf_getuniquelibref` when assigning librefs (never hardcode or hand-roll unique references)
- 2-space indentation, no tabs, no trailing spaces, no invisible characters, max line length 300 (hard lint limit) but keep lines to 80 chars max where possible
- Every file must have a Doxygen header:
```sas
/**
@file
@brief One-line description of the macro
<h4> SAS Macros </h4>
@li mf_othermacro.sas
@param [in] paramname Description
@param [out] outparam Description
<h4> Related Macros </h4>
@li mp_related.sas
@version 9.4
@author Your Name
**/
```
## Folder / prefix conventions
| Folder | Prefix | Platform |
|---|---|---|
| `base/` | `mf_` (function-style), `mp_` (procedure-style) | All platforms |
| `meta/` | `mm_` | SAS 9 metadata |
| `metax/` | `mmx_` | SAS 9 metadata (OS command dependent) |
| `viya/` | `mv_` | Viya |
| `server/` | `ms_` | SASjs server |
| `xplatform/` | `mx_` | Runtime platform detection |
| `fcmp/`, `lua/`, `ddl/` | — | PROC FCMP functions, LUA wrappers, DDL |
Use `mf_` macros when the macro returns a value usable in an expression; use `mp_` for procedural macros that generate code/statements.
**Cross-suite rule:** `mp_` macros must never reference `mx_` macros. Platform dispatching (SAS 9 / Viya / SASjs server) belongs in the `mx_` suite, which delegates to `ms_`/`mv_`/PROC STP per platform. If an `mp_` macro seems to need platform-specific behaviour, the macro itself belongs in `xplatform/` as an `mx_` macro instead.
## Reuse before writing
Before writing a new macro, check the library for an existing one — common utilities already exist, e.g. `mp_abort` (the deprecated `mf_abort` is retained for backwards compatibility — don't use it in new code), `mf_existds`, `mf_existvar`, `mf_existfileref`, `mf_getuser`, `mp_jsonout` (SAS datasets → JSON for `_webout`), `mp_ds2ddl`, `mp_hashdataset`. Platform-specific variants exist under `meta/`, `viya/`, `server/` and are selected at compile time by the CLI.
## Aborting safely
Never invoke `%mp_abort` from inside an `%if/%else` block — as a procedural macro, the macro processor can continue executing statements after it before the abort takes effect. Use the `iftrue=` condition parameter instead:
```sas
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program
,msg=%str(Something went wrong)
)
```
When `%mp_abort` is called from within a `%include` block, SAS cannot exit cleanly (e.g. to `_webout`). Call `%mp_abort(mode=INCLUDE)` after the include (OUTSIDE any macro wrapper) — it checks `work.mp_abort_errds` for an abort status:
```sas
%mp_abort(mode=INCLUDE)
```
Note: `%include`s inside macros should be performed with `%mp_include()` so the `_SYSINCLUDEFILEDEVICE` indicator is set and the abort dataset (`work.mp_abort_errds`) is passed back to the calling program.
## Testing macros (mandatory conventions)
- **Always apply `%mp_assertscope` around the macro under test** to catch scope leakage (macro variables must stay `%local`):
```sas
%mp_assertscope(SNAPSHOT)
%mx_foo(args)
%mp_assertscope(COMPARE,
desc=Test 1: mx_foo does not leak scope,
outds=work.test_results
)
```
- Assertions go to `work.test_results` via `%mp_assert(iftrue=(...), desc=..., outds=work.test_results)`.
## Lint and build
- Run `sasjs lint` after every change; do not consider work done until it passes
- NEVER bump the version in `package.json` (semantic-release handles it)
- Do NOT edit generated files by hand: `all.sas`, `mc_*.sas`, the `lua/` wrappers, and `sasjsbuild/` outputs are produced by the CI build
- Markdown files: never hard-wrap; one paragraph per line
+103
View File
@@ -0,0 +1,103 @@
---
name: sasjs-framework
description: Building full SASjs applications — project structure, sasjsconfig.json, services/jobs/macros folders, multi-target (SAS 9 / Viya / SASjs server) configuration, streaming frontends, mocks and tests. Use when creating or modifying a SASjs app, editing sasjsconfig.json, or writing backend services returning JSON to a web frontend.
---
# SASjs Framework — Building SASjs Applications
A SASjs app = a web frontend (any framework: Angular, React, vanilla) + SAS backend code organised in a standard layout, compiled and deployed by `@sasjs/cli` to SAS 9, Viya, or SASjs server. Frontend talks to SAS via `@sasjs/adapter`; backend services return JSON via `_webout`.
## Standard project layout
```
sasjs/
sasjsconfig.json # project + target configuration
macros/ # project-specific macros (macroFolders)
services/ # web services called from the frontend
jobs/ # jobs (scheduled / flow / long-running)
programs/ # plain programs (initProgram, termProgram, utilities)
db/ # DDL + static data per library (sasjs db)
tests/ # tests run by `sasjs test`
mocks/ # mock responses for offline frontend dev (syncFolder)
doxy/ # extra doxygen content for `sasjs doc`
```
## sasjsconfig.json
Root config holds defaults; each entry in `targets[]` can override them. Key sections:
- `macroFolders`, `binaryFolders` — where the CLI finds macros/binaries
- `serviceConfig.serviceFolders` — service source folders; `initProgram` runs before every service (set up libnames, options)
- `jobConfig.jobFolders` — job source folders
- `programFolders` — programs compiled/deployed with the app
- `streamConfig``streamWeb: true` streams the built frontend into SAS so it is served by the platform itself (no separate web server needed); `webSourcePath` points at the frontend build output
- `syncFolder` — folder synced to the server (e.g. mocks)
- `testConfig` — init/term programs for `sasjs test`
- `targets[]` — per-environment overrides: `serverUrl`, `serverType` (`SAS9`/`SASVIYA`/`SASJS`), `appLoc` (deploy root, e.g. `/Public/app/myapp`), target-specific macroFolders (e.g. `targets/viya/macros_viya` for platform shims), `httpsAgentOptions`, `deployConfig`
The full JSON schema is bundled at `sasjsconfig-schema.json` next to this file — validate config changes against it. Reference it with `"$schema": "https://cli.sasjs.io/sasjsconfig-schema.json"`.
## Streamed frontend files on Viya (mime types)
When `streamWeb: true`, the CLI uploads the frontend (`index.html`, renamed per `streamServiceName`, plus css/js) to the Viya Files service using the `%mv_createfile` macro. That macro creates the file in a very particular way to ensure it streams correctly:
- POSTs to `/files/files` with the content type derived from the extension (`%mf_mimetype`)
- sets `typeDefName=file_html` (via `%mv_getViyaFileExtParms`) so the file is recognised as HTML
- sends `Content-Disposition` **without** `attachment` for HTML/SVG so it renders in the browser
**Never update a streamed frontend file in place** with a `filename filesrvc` fileref + data step rewrite — the Files service then treats it as a generic blob and the mime type is lost, so the app no longer streams (browser downloads it or shows raw text). To modify a streamed file at runtime (eg patching the compute `contextname` in the html), read it (a `filesrvc` fileref is fine for *reading*), write the modified content to a temp fileref, and **re-create the file with `%mv_createfile(path=..., name=..., inref=...)`** (it deletes the old file and re-POSTs with the correct mime type).
## Service contract (frontend ↔ SAS)
1. Adapter POSTs to `services/<folder>/<name>` with input tables (arrays of objects) → work datasets named after the JS keys.
2. Service SAS code runs after `initProgram`; it reads inputs, does work, and writes output JSON to `_webout`.
3. Conventional pattern using @sasjs/core macros:
```sas
/**
@file
@brief Example service returning data
<h4> SAS Macros </h4>
@li mp_jsonout.sas
@li mp_abort.sas
**/
/* validation / logic here */
%mp_jsonout(OPEN)
%mp_jsonout(OBJ,results,dslabel=results)
%mp_jsonout(CLOSE)
```
4. On error, abort cleanly with `%mp_abort(...)` (`mf_abort` is deprecated) so the adapter receives a structured error in the JSON, not a half-written response. Do **not** call `%mp_abort` inside an `%if/%else` block — the macro processor may keep executing beyond the abort. Use the conditional `iftrue=` parameter instead, e.g.:
```sas
%mp_abort(iftrue= (%mf_existds(work.results)=0)
,mac=&_program
,msg=%str(No results found)
)
```
If the abort happens inside a `%include` block, SAS cannot exit to `_webout` cleanly — after the include, call `%mp_abort(mode=INCLUDE)` (outside any macro wrapper), which checks `work.mp_abort_errds` for an abort status.
## Multi-target discipline
- Keep backend code platform-neutral in shared folders; put platform-specific shims in `targets/<name>/macros_*` folders and register them only on that target.
- Platform capability macros exist in @sasjs/core (`mm_*` metadata, `mv_*` Viya, `ms_*` server) — don't branch on server type by hand.
## Quality gates (follow the conventions of mature apps like Data Controller)
- Run `sasjs lint` after touching any `.sas` file; fix all warnings in files you touched.
- The linter enforces 2-space indentation everywhere, including continuation lines inside `/* ... */` block comments — never align comment text with 3+ spaces.
- Add tests and run `sasjs test` for backend logic changes. When testing macros, always wrap the macro under test with `%mp_assertscope(SNAPSHOT)` / `%mp_assertscope(COMPARE, ...)` to catch macro-variable scope leakage, and wrap any platform-branching code in `%macro` wrappers (no open conditional macro code in test programs).
- Provide mocks in `sasjs/mocks` so the frontend can be developed without a live SAS server.
- Never auto-commit or bump versions; releases are pipeline-driven (conventional commits).
- Markdown files: no hard wrapping — one paragraph per line.
- Apps must work offline/on-prem: no external CDN assets in the frontend bundle.
## Reference implementations
Look at existing apps for patterns: folder layouts, `sasjsconfig.json` multi-target setups, service structure, streaming builds, and test/mock conventions, eg:
* https://git.datacontroller.io/dc/dc
* https://github.com/sasjs/react-seed-app
* https://github.com/sasjs/macro-dash
@@ -0,0 +1,966 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/sasjs/utils/blob/main/src/types/sasjsconfig-schema.json",
"type": "object",
"title": "SASjs Config File",
"description": "The SASjs Config file provides the settings and structure for your SASjs project. ",
"default": {},
"examples": [
{
"macroFolders": ["macros"],
"programFolders": ["programs"],
"binaryFolders": ["binaries"],
"defaultTarget": "viya",
"targets": [
{
"name": "viya",
"serverType": "SASVIYA",
"serverUrl": "https://sas.sasjs.com",
"appLoc": "/Public/app",
"contextName": "SAS Job Execution compute context",
"deployConfig": {
"deployServicePack": true,
"deployScripts": ["sasjsbuild/myviyadeploy.sas"]
},
"serviceConfig": {
"serviceFolders": ["targets/viya/services/admin"],
"initProgram": "build/serviceinit.sas",
"termProgram": "build/serviceinit.sas",
"macroVars": {
"name": "viyavalue",
"extravar": "this too"
}
},
"jobConfig": {
"jobFolders": [],
"initProgram": "",
"termProgram": "",
"macroVars": {}
},
"streamConfig": {
"assetPaths": [],
"streamWeb": false,
"streamWebFolder": "webv",
"webSourcePath": "dist"
},
"testConfig": {
"initProgram": "sasjs/tests/testinit.sas",
"termProgram": "sasjs/tests/testterm.sas",
"macroVars": {
"testVar": "testValue"
},
"testSetUp": "sasjs/tests/testsetup.sas",
"testTearDown": "sasjs/tests/testteardown.sas"
},
"macroFolders": ["targets/viya/macros"],
"programFolders": [],
"binaryFolders": ["binaries"]
},
{
"name": "sas9",
"serverType": "SAS9",
"serverUrl": "https://sas.sasjs.com:7980",
"appLoc": "/User Folders/&sysuserid/My Folder",
"serverName": "Foundation",
"repositoryName": "SASApp",
"buildConfig": {
"buildOutputFileName": "mysas9deploy.sas",
"initProgram": "",
"termProgram": "",
"macroVars": {}
},
"deployConfig": {
"deployScripts": ["build/deploysas9.sh"],
"deployServicePack": false
},
"serviceConfig": {
"serviceFolders": ["targets/sas9/services/admin"],
"initProgram": "",
"termProgram": "build/servicetermother.sas",
"macroVars": {}
},
"streamConfig": {
"assetPaths": [],
"streamWeb": false,
"streamWebFolder": "web9",
"webSourcePath": "dist"
},
"testConfig": {
"initProgram": "sasjs/tests/testinit.sas",
"termProgram": "sasjs/tests/testterm.sas",
"macroVars": {
"testVar": "testValue"
},
"testSetUp": "sasjs/tests/testsetup.sas",
"testTearDown": "sasjs/tests/testteardown.sas"
},
"macroFolders": ["targets/sas9/macros"],
"programFolders": [],
"binaryFolders": ["binaries"]
}
]
}
],
"properties": {
"binaryFolders": {
"$id": "#/properties/binaryFolders",
"type": "array",
"title": "The binaryFolders array",
"description": "These local folders are searched for Binary Files when running sasjs compile. Folders are relative to the sasjs/sasjsconfig.json file.",
"examples": [["binaries", "../../more_binaries"]]
},
"sasjsBuildFolder": {
"$id": "#/properties/sasjsBuildFolder",
"type": "string",
"title": "sasjsBuildFolder",
"description": "The name of the folder containing the compiled output. The `sasjs build` command will take all of the subfolders here as inputs to create the build pack. By default this will be named `sasjsbuild`. In global, the default is `~/.sasjsbuild`.",
"default": "sasjsbuild",
"examples": ["sasjsbuild", ".sasjsbuild"]
},
"sasjsResultsFolder": {
"$id": "#/properties/sasjsResultsFolder",
"type": "string",
"title": "sasjsResultsFolder",
"description": "The name of the folder containing the output (eg logs, ODS output) from `sasjs run`. By default this will be named `sasjsresults`. In global, the default is `~/.sasjsresults`.",
"default": "sasjsresults",
"examples": ["sasjsresults", ".sasjsresults"]
},
"defaultTarget": {
"$id": "#/properties/defaultTarget",
"type": "string",
"title": "Default Target",
"description": "If a target is not specified, this target is used by default. The default target must exist in the (local) targets array.",
"default": "viya",
"examples": ["viya"]
},
"docConfig": {
"$id": "#/properties/docConfig",
"type": "object",
"title": "The docConfig schema",
"description": "SASjs uses doxygen to auto-generate HTML documentation using the headers in your SAS programs, macros, services & jobs. For more info, see [https://cli.sasjs.io/doc](https://cli.sasjs.io/doc). Some properties are taken from package.json (such as the project Name).",
"default": {
"dataControllerUrl": "https://mysasserver.com/web/datacontroller/#",
"enableLineage": true,
"doxyContent": {
"readMe": "../../README.md"
}
},
"examples": [
{
"displayMacroCore": true,
"outDirectory": "/some/output/directory",
"dataControllerUrl": "https://mysasserver.com/web/datacontroller/#",
"enableLineage": true,
"doxyContent": {
"readMe": "../../my/custom/homepage.md",
"path": "my/custom/doxy/folder"
}
}
],
"properties": {
"displayMacroCore": {
"$id": "#/properties/docConfig/properties/displayMacroCore",
"type": "boolean",
"title": "The displayMacroCore docConfig option",
"description": "The CLI will autocompile macro dependencies that exist in the SASjs Macro Core library. These will also show in the documentation under 'node_modules'. If you'd prefer not to show these in the rendered docs, set this value to false.",
"default": true,
"examples": [true]
},
"outDirectory": {
"$id": "#/properties/docConfig/properties/outDirectory",
"type": "string",
"title": "The outDirectory docConfig option",
"description": "The location to which the generated HTML SAS documentation is written. If missing, or left blank, the files will be written to the `sasjsbuild/doc` directory (default behaviour).",
"default": "sasjsbuild/doc",
"examples": ["/my/preferred/docs/directory"]
},
"dataControllerUrl": {
"$id": "#/properties/docConfig/properties/dataControllerUrl",
"type": "string",
"title": "The dataControllerUrl docConfig option",
"description": "Provide the full URL to Data Controller so that `sasjs doc` can link the lineage diagram directly to the table viewer in [Data Controller](https://datacontroller.io).\nIf left blank, or undefined, no links will be generated.",
"default": "",
"examples": ["https://yourserver.co.uk/dcviya/#"]
},
"enableLineage": {
"$id": "#/properties/docConfig/properties/enableLineage",
"type": "boolean",
"title": "Enable Lineage",
"description": "If true, sasjs doc will generate and display Data Lineage from Jobs and Services.",
"default": true
},
"doxyContent": {
"$id": "#/properties/docConfig/properties/doxyContent",
"type": "object",
"title": "doxyContent",
"description": "Configuration of the Doxyfile variables",
"default": {},
"examples": [
{
"favIcon": "favicon.ico",
"footer": "new_footer.html",
"header": "new_header.html",
"layout": "DoxygenLayout.xml",
"logo": "logo.png",
"readMe": "../../README.md",
"stylesheet": "new_stylesheet.css",
"path": "sasjs/doxy"
}
],
"properties": {
"favIcon": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/favIcon",
"type": "string",
"title": "Doxygen favicon",
"description": "The favicon used in the doxygen documentation",
"default": "favicon.ico"
},
"footer": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/footer",
"type": "string",
"title": "Doxygen footer",
"description": "The footer HTML file used in the doxygen documentation",
"default": "new_footer.html"
},
"header": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/header",
"type": "string",
"title": "Doxygen header",
"description": "The header HTML file used in the doxygen documentation",
"default": "new_header.html"
},
"layout": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/layout",
"type": "string",
"title": "Doxygen Layout XML File",
"description": "The layout XML file used to build the doxygen documentation",
"default": "DoxygenLayout.xml"
},
"logo": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/logo",
"type": "string",
"title": "Doxygen Logo",
"description": "The logo file used by doxygen",
"default": "logo.png"
},
"readMe": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/readMe",
"type": "string",
"title": "Doxygen Homepage",
"description": "The file used to generate the doxygen homepage (defaults to the readme.md)",
"default": "../../README.md"
},
"stylesheet": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/stylesheet",
"type": "string",
"title": "Doxygen CSS",
"description": "The CSS file used to extend Doxygen",
"default": "new_stylesheet.css"
},
"path": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/path",
"type": "string",
"title": "Doxygen Path",
"description": "The path to the Doxygen configuration files (relative to sasjs/sasjsconfig.json)",
"default": "sasjs/doxy"
}
}
}
}
},
"httpsAgentOptions": {
"$id": "#/properties/httpsAgentOptions",
"type": "object",
"title": "httpsAgentOptions",
"description": "Configure https agent by setting all supported attribute such as `key`, `cert`, `ca`, `rejectUnauthorized` and `requestCert`",
"examples": [
{
"allowInsecureRequests": false,
"caPath": "path/to/caFile",
"keyPath": "path/to/keyFile",
"certPath": "path/to/certFile",
"requestCert": false,
"rejectUnauthorized": true
}
],
"properties": {
"allowInsecureRequests": {
"$id": "#/properties/httpsAgentOptions/properties/allowInsecureRequests",
"type": "boolean",
"title": "allowInsecureRequests",
"description": "If you are having certificate errors connecting to SAS, that cannot be properly resolved, try setting this value to true. This option only has an effect if rejectUnauthorized is not present.",
"default": false,
"examples": [true, false]
},
"caPath": {
"$id": "#/properties/httpsAgentOptions/properties/caPath",
"type": "string",
"title": "caPath",
"description": "Optionally override the trusted CA certificates. Default is to trust the well-known CAs curated by Mozilla. Mozilla's CAs are completely replaced when CAs are explicitly specified using this option.",
"examples": ["path/to/caFile"]
},
"keyFile": {
"$id": "#/properties/httpsAgentOptions/properties/keyFile",
"type": "string",
"title": "keyFile",
"description": "Private keys in PEM format. PEM allows the option of private keys being encrypted. Encrypted keys will be decrypted with options.passphrase. Multiple keys using different algorithms can be provided either as an array of unencrypted key strings or buffers, or an array of objects in the form {pem: <string|buffer>[, passphrase: <string>]}. The object form can only occur in an array. object.passphrase is optional. Encrypted keys will be decrypted with object.passphrase if provided, or options.passphrase if it is not.",
"examples": ["path/to/keyFile"]
},
"certFile": {
"$id": "#/properties/httpsAgentOptions/properties/certFile",
"type": "string",
"title": "certFile",
"description": "Cert chains in PEM format. One cert chain should be provided per private key. Each cert chain should consist of the PEM formatted certificate for a provided private key, followed by the PEM formatted intermediate certificates (if any), in order, and not including the root CA (the root CA must be pre-known to the peer, see ca). When providing multiple cert chains, they do not have to be in the same order as their private keys in key. If the intermediate certificates are not provided, the peer will not be able to validate the certificate, and the handshake will fail.",
"examples": ["path/to/certFile"]
},
"requestCert": {
"$id": "#/properties/httpsAgentOptions/properties/requestCert",
"type": "boolean",
"title": "requestCert",
"description": "If true the server will request a certificate from clients that connect and attempt to verify that certificate. Defaults to false.",
"default": false,
"examples": [true, false]
},
"rejectUnauthorized": {
"$id": "#/properties/httpsAgentOptions/properties/rejectUnauthorized",
"type": "boolean",
"title": "rejectUnauthorized",
"description": "If true the server will reject any connection which is not authorized with the list of supplied CAs. This option only has an effect if requestCert is true.",
"default": true,
"examples": [true, false]
}
}
},
"buildConfig": {
"$id": "#/properties/buildConfig",
"type": "object",
"title": "buildConfig",
"description": "Dictates which files get compiled into the build program (.sas), used to deploy services into SAS 9 or Viya environments (without a client/secret). You may use this config to include build specific macros, programs or macro variables - which is run a single time, on deployment - for things like database creation, or exporting a SAS 9 SPK after service creation.",
"examples": [
{
"initProgram": "build/buildinit.sas",
"termProgram": "build/buildterm.sas",
"macroVars": {
"name": "value",
"numvar": "42"
},
"buildOutputFileName": "buildpack.sas"
}
],
"properties": {
"buildOutputFileName": {
"$id": "#/properties/buildConfig/properties/buildOutputFileName",
"type": "string",
"title": "buildOutputFileName",
"description": "The name of the generated .sas program, which can be used to deploy the app using only SAS Studio. By default, this will be the name of the target.",
"examples": ["viya.sas", "sas9.sas"]
},
"initProgram": {
"$id": "#/properties/buildConfig/properties/initProgram",
"type": "string",
"title": "The buildConfig initProgram",
"description": "The path to a .sas program that will be inserted at the start of the build .sas program (created when running `sasjs build`).",
"default": "sasjs/buildinit.sas"
},
"termProgram": {
"$id": "#/properties/buildConfig/properties/termProgram",
"type": "string",
"title": "The buildConfig termProgram",
"description": "The path to a .sas program that will be inserted at the end of the build .sas program (created when running `sasjs build`).",
"default": "sasjs/buildterm.sas"
},
"macroVars": {
"$id": "#/properties/buildConfig/properties/macroVars",
"type": "object",
"title": "The buildConfig macro variables",
"description": "A series of name value pairs that will be turned into SAS macro variables in the build .sas program (generated when running `sasjs build`). The example provided will generate sas code as follows:\n```\n%let name=value;\n%let numvar=42;\n```",
"default": {},
"examples": [
{
"name": "value",
"numvar": "42"
}
]
}
}
},
"deployConfig": {
"$id": "#/properties/deployConfig",
"type": "object",
"title": "The deployConfig schema",
"description": "The deployConfig object enables settings that relate to the deployment of a SAS app - be that Viya, or SAS 9, or a pure Base environment.",
"default": {},
"examples": [
{
"deployScripts": ["build/deployscript.sh"]
},
{
"deployScripts": ["build/deployscript.sh"],
"deployServicePack": true
}
],
"properties": {
"deployScripts": {
"$id": "#/properties/deployConfig/properties/deployScripts",
"type": "array",
"title": "The deployConfig deployScripts array",
"description": "These scripts are executed when running `sasjs deploy`. If the file is a .sas file, it is executed on the SAS server (Viya only). Otherwise it is executed locally. These scripts are run AFTER the deployment of the servicepack, if `deployServicePack:true` (Viya only).",
"default": [],
"examples": [["build/deployscript.sh", "build/myprogram.sas"]]
},
"deployServicePack": {
"$id": "#/properties/deployConfig/properties/deployServicePack",
"type": "boolean",
"title": "The deployConfig deployServicePack flag",
"description": "If set to `true` the json pack produced by `sasjs build` will be auto-deployed to the `appLoc` of the specified target (creating all jobs and services in the SAS folder tree). Currently only Viya is supported for this flag.",
"default": false
}
}
},
"serviceConfig": {
"$id": "#/properties/serviceConfig",
"type": "object",
"title": "The serviceConfig schema",
"description": "The serviceConfig object defines how SASjs web services are compiled. Web services differ from jobs in that they include some fixed pre-code (eg the macros to stream out the result json).",
"default": {},
"examples": [
{
"serviceFolders": ["services/common", "services/admin"],
"initProgram": "build/serviceinit.sas",
"termProgram": "build/serviceterm.sas",
"macroVars": {
"mac1": "value",
"mac2": "42"
}
}
],
"required": [],
"properties": {
"serviceFolders": {
"$id": "#/properties/serviceConfig/properties/serviceFolders",
"type": "array",
"title": "The serviceConfig serviceFolders array",
"description": "When running `sasjs compile`, all programs in the folders defined in this array are compiled and placed into same-named folders under `sasjsbuild/services`. They will be compiled as services (so, with the service pre-code). Folders can be absolute, or relative to the `sasjs` folder.",
"default": [],
"examples": [["services/common", "services/admin"]]
},
"initProgram": {
"$id": "#/properties/serviceConfig/properties/initProgram",
"type": "string",
"title": "The serviceConfig initProgram",
"description": "The serviceConfig `initProgram` is a .sas file that is inserted at the start of every SAS service (after compiled macros and any `macroVars`, and before the service itself). ",
"default": "",
"examples": ["build/serviceinit.sas"]
},
"termProgram": {
"$id": "#/properties/serviceConfig/properties/termProgram",
"type": "string",
"title": "The serviceConfig termProgram",
"description": "The serviceConfig termProgram is inserted at the end of every service as part of `sasjs compile`.",
"default": "",
"examples": ["build/serviceterm.sas"]
},
"macroVars": {
"$id": "#/properties/serviceConfig/properties/macroVars",
"type": "object",
"title": "The `serviceConfig` macroVars",
"description": "This object allows `sasjs compile` to insert specific macro variables at the start of every service. In this case, the code generated would be:\n```\n%let mac1=value;\n%let mac2=42;\n```",
"default": {},
"examples": [
{
"mac1": "value",
"mac2": "42"
}
]
}
}
},
"jobConfig": {
"$id": "#/properties/jobConfig",
"type": "object",
"title": "The jobConfig schema",
"description": "The jobConfig object defines how SASjs Jobs are compiled. ",
"default": {},
"examples": [
{
"jobFolders": ["jobs/extract", "jobs/load"],
"initProgram": "jobs/jobinit.sas",
"termProgram": "jobs/jobterm.sas",
"macroVars": {
"mac1": "value",
"mac2": "42"
}
}
],
"properties": {
"jobFolders": {
"$id": "#/properties/jobConfig/properties/jobFolders",
"type": "array",
"title": "The jobConfig jobFolders array",
"description": "When running `sasjs compile`, all programs in the local folders defined in this array are compiled and placed into same-named folders under `sasjsbuild/jobs`. Folders can be absolute, or relative to the local project `/sasjs` folder.",
"default": [],
"examples": [["jobs/extract", "jobs/transform", "jobs/load"]]
},
"initProgram": {
"$id": "#/properties/jobConfig/properties/initProgram",
"type": "string",
"title": "The jobConfig initProgram",
"description": "The jobConfig `initProgram` is a local .sas file that is inserted at the start of every SAS Job (after compiled macros and any `macroVars`, and before the Job itself). ",
"default": "",
"examples": ["jobs/jobinit.sas"]
},
"termProgram": {
"$id": "#/properties/jobConfig/properties/termProgram",
"type": "string",
"title": "The jobConfig termProgram",
"description": "The jobConfig termProgram is inserted at the end of every Job as part of `sasjs compile`.",
"default": "",
"examples": ["jobs/jobterm.sas"]
},
"macroVars": {
"$id": "#/properties/jobConfig/properties/macroVars",
"type": "object",
"title": "The `jobConfig` macroVars",
"description": "This object allows `sasjs compile` to insert specific macro variables at the start of every Job. In this case, the code generated would be:\n```\n%let mac1=value;\n%let mac2=42;\n```",
"default": {},
"examples": [
{
"mac1": "value",
"mac2": "42"
}
]
}
}
},
"streamConfig": {
"$id": "#/properties/streamConfig",
"type": "object",
"title": "streamConfig",
"description": "SASjs allows a local web app to be compiled such that all html, css, javascript, and other assets such as png or mp4 are converted into web services and streamed directly from SAS.\n\nThis approach is convenient as it bypasses the need to deploy to a web server. ",
"default": {},
"examples": [
{
"assetPaths": [],
"streamWeb": false,
"streamWebFolder": "webv",
"webSourcePath": "dist",
"streamLogo": "logo.png"
}
],
"required": ["streamWeb"],
"properties": {
"assetPaths": {
"$id": "#/properties/streamConfig/properties/assetPaths",
"type": "array",
"title": "The streamConfig assetPaths array",
"description": "An array of local folders. All assets placed in these folders are converted into web services - example file types could be png, svg, mp3, mp4, excel - anything really.",
"default": [],
"examples": [["/myassets"]]
},
"streamLogo": {
"$id": "#/properties/streamConfig/properties/streamLogo",
"type": "string",
"title": "Icon shown in AppStream (sasjs/server)",
"description": "Provide the location of a square image, under the webSourcePath. Used as the display icon on the appStream page.",
"default": "logo.png",
"examples": ["logo.png", "favicon.ico"]
},
"streamWeb": {
"$id": "#/properties/streamConfig/properties/streamWeb",
"type": "boolean",
"title": "The streamConfig streamWeb flag",
"description": "When set to `true`, frontend files saved in the `webSourcePath` will be converted to streaming services in the `streamWebFolder` in SAS.",
"default": false,
"examples": [true]
},
"streamWebFolder": {
"$id": "#/properties/streamConfig/properties/streamWebFolder",
"type": "string",
"title": "The streamConfig streamWebFolder",
"description": "This is the target SAS folder (relative to the appLoc) where the compiled web assets will be created.",
"default": "webv"
},
"webSourcePath": {
"$id": "#/properties/streamConfig/properties/webSourcePath",
"type": "string",
"title": "The webSourcePath schema",
"description": "Active when `streamConfig` is `true`. Is the source (or build, or dist) LOCAL folder, relative to the `sasjs` folder, which contains the frontend to be deployed. All assets (PNG, JS, CSS, HTML etc) are taken from here and converted to streaming services in `streamWebFolder`. In Viya and SAS 9, any relative URLS will be modified such that the links still work and the assets still load.",
"default": "dist",
"examples": ["dist", "build"]
},
"streamServiceName": {
"$id": "#/properties/streamConfig/properties/streamServiceName",
"type": "string",
"title": "streamServiceName Schema",
"description": "The name of the service containing the `index.html` for a streaming web app. Defaults to `clickme` and is always deployed under the appLoc/services SAS Folder.",
"default": "clickme.sas"
}
}
},
"testConfig": {
"$id": "#/properties/testConfig",
"type": "object",
"title": "testConfig",
"description": "Create tests for Macros, Services & Jobs by simply adding a '.test.sas' extension. ",
"default": {
"initProgram": "sasjs/tests/testinit.sas",
"termProgram": "sasjs/tests/testterm.sas",
"macroVars": {
"testVar": "testValue"
},
"testSetUp": "sasjs/tests/testsetup.sas",
"testTearDown": "sasjs/tests/testteardown.sas"
},
"properties": {
"initProgram": {
"$id": "#/properties/testConfig/properties/initProgram",
"type": "string",
"title": "The jobConfig initProgram",
"description": "The testConfig `initProgram` is a local .sas file that is inserted at the start of every Test (after compiled macros and any `macroVars`, and before the Test itself). ",
"default": "",
"examples": ["sasjs/tests/testinit.sas"]
},
"termProgram": {
"$id": "#/properties/testConfig/properties/termProgram",
"type": "string",
"title": "The jobConfig termProgram",
"description": "The testConfig termProgram is inserted at the end of every Test as part of `sasjs compile`.",
"default": "",
"examples": ["jobs/jobterm.sas"]
},
"macroVars": {
"$id": "#/properties/testConfig/properties/macroVars",
"type": "object",
"title": "The `jobConfig` macroVars",
"description": "This object allows `sasjs compile` to insert specific macro variables at the start of every Test. In this case, the code generated would be:\n```\n%let mac1=value;\n%let mac2=42;\n```",
"default": {},
"examples": [
{
"mac1": "value",
"mac2": "42"
}
]
},
"testSetUp": {
"$id": "#/properties/testConfig/properties/testsetup",
"type": "string",
"title": "The jobConfig testSetUp",
"description": "This program is the first to execute as part of 'sasjs test'. It does not contain the testInit, testTerm or macroVariables. It IS compiled.",
"default": "sasjs/tests/testsetup.sas",
"examples": ["sasjs/tests/testsetup.sas"]
},
"testTearDown": {
"$id": "#/properties/testConfig/properties/testTearDown",
"type": "string",
"title": "The jobConfig testTearDown",
"description": "The last program to execute as part of 'sasjs test'.",
"default": "sasjs/tests/testteardown.sas",
"examples": ["sasjs/tests/testteardown.sas"]
}
}
},
"macroFolders": {
"$id": "#/properties/macroFolders",
"type": "array",
"title": "The macroFolders array",
"description": "These local folders are searched for SAS Macros when running `sasjs compile`. Folders are relative to the `sasjs/sasjsconfig.json` file.",
"default": [],
"examples": [["macros", "../../more_macros"]]
},
"programFolders": {
"$id": "#/properties/programFolders",
"type": "array",
"title": "The programFolders array",
"description": "These local folders are searched for SAS Programs when running `sasjs compile`. Folders are relative to the `sasjs/sasjsconfig.json` file.",
"default": [],
"examples": [["programs", "../../more_programs"]]
},
"syncFolder": {
"$id": "#/properties/syncFolder",
"type": "string",
"title": "Sync Folder",
"description": "The contents of this folder are simply copied to the sasjsbuild directory AFTER the rest of the project is compiled. Useful for synchronising random / generic content with SAS logical folders.",
"default": "sasjs/static_files"
},
"syncDirectories": {
"$id": "#/properties/syncDirectories",
"type": "array",
"title": "syncDirectories",
"description": "Maps the local filesystem to remote (SAS) physical directories.",
"examples": [
[
{
"local": "C:\\temp\\local\\fs1",
"remote": "/opt/data/fs1"
},
{
"local": "C:\\temp\\elsewhere",
"remote": "/opt/somewhere"
}
]
]
},
"targets": {
"$id": "#/properties/targets",
"type": "array",
"title": "The targets array",
"description": "A target is an alias for a deployment location, and includes at a minimum, the `serverUrl`, `serverType` and `appLoc`. This array allows multiple targets to be defined (eg dev / test / prod). Any properties defined here will override same-named properties in the sasjsconfig root.",
"default": [],
"examples": [
[
{
"name": "sas9target",
"serverType": "SAS9",
"serverUrl": "https://mysas9server",
"appLoc": "/Shared Folders/myApp"
}
],
[
{
"name": "viya",
"serverType": "SASVIYA",
"serverUrl": "https://sas.sasjs.com",
"appLoc": "/Public/app",
"contextName": "SAS Job Execution compute context",
"buildConfig": {
"buildOutputFileName": "myviyadeploy.sas",
"initProgram": "build/buildinitviya.sas",
"termProgram": "targets/viya/viyabuildterm.sas",
"macroVars": {
"name": "viyavalue",
"extravar": "this too"
}
},
"deployConfig": {
"deployServicePack": true,
"deployScripts": ["sasjsbuild/myviyadeploy.sas"]
},
"serviceConfig": {
"serviceFolders": ["targets/viya/services/admin"],
"initProgram": "build/serviceinit.sas",
"termProgram": "build/serviceinit.sas",
"macroVars": {
"name": "viyavalue",
"extravar": "this too"
}
},
"streamConfig": {
"assetPaths": [],
"streamWeb": false,
"streamWebFolder": "webv",
"webSourcePath": "dist"
},
"testConfig": {
"initProgram": "sasjs/tests/testinit.sas",
"termProgram": "sasjs/tests/testterm.sas",
"macroVars": {
"testVar": "testValue"
},
"testSetUp": "sasjs/tests/testsetup.sas",
"testTearDown": "sasjs/tests/testteardown.sas"
},
"macroFolders": ["targets/viya/macros"]
},
{
"name": "sas9",
"serverType": "SAS9",
"serverUrl": "https://sas.sasjs.com:7980",
"appLoc": "/User Folders/&sysuserid/My Folder",
"serverName": "Foundation",
"repositoryName": "SASApp",
"buildConfig": {
"buildOutputFileName": "mysas9deploy.sas",
"initProgram": "",
"termProgram": "",
"macroVars": {}
},
"deployConfig": {
"deployScripts": ["build/deploysas9.sh"],
"deployServicePack": false
},
"serviceConfig": {
"serviceFolders": ["targets/sas9/services/admin"],
"initProgram": "",
"termProgram": "build/servicetermother.sas",
"macroVars": {}
},
"streamConfig": {
"assetPaths": [],
"streamWeb": false,
"streamWebFolder": "web9",
"webSourcePath": "dist"
},
"testConfig": {
"initProgram": "sasjs/tests/testinit.sas",
"termProgram": "sasjs/tests/testterm.sas",
"macroVars": {
"testVar": "testValue"
},
"testSetUp": "sasjs/tests/testsetup.sas",
"testTearDown": "sasjs/tests/testteardown.sas"
},
"macroFolders": ["targets/sas9/macros"],
"programFolders": []
}
]
],
"items": {
"$id": "#/properties/targets/items",
"anyOf": [
{
"$id": "#/properties/targets/items/anyOf/0",
"type": "object",
"title": "SASjs Targets",
"description": "A target provides the configuration specific to a particular deployment, eg DEV / TEST, or SAS9 / SASVIYA.",
"default": {},
"examples": [
{
"name": "viya",
"serverType": "SASVIYA",
"serverUrl": "https://sas.sasjs.com",
"appLoc": "/Public/app",
"contextName": "SAS Job Execution compute context",
"buildConfig": {
"buildOutputFileName": "myviyadeploy.sas",
"initProgram": "build/buildinitviya.sas",
"termProgram": "targets/viya/viyabuildterm.sas",
"macroVars": {
"name": "viyavalue",
"extravar": "this too"
}
},
"deployConfig": {
"deployServicePack": true,
"deployScripts": ["sasjsbuild/myviyadeploy.sas"]
},
"serviceConfig": {
"serviceFolders": ["targets/viya/services/admin"],
"initProgram": "build/serviceinit.sas",
"termProgram": "build/serviceinit.sas",
"macroVars": {
"name": "viyavalue",
"extravar": "this too"
}
},
"jobConfig": {
"jobFolders": [],
"initProgram": "",
"termProgram": "",
"macroVars": {}
},
"streamConfig": {
"assetPaths": [],
"streamWeb": false,
"streamWebFolder": "webv",
"webSourcePath": "dist"
},
"testConfig": {
"initProgram": "sasjs/tests/testinit.sas",
"termProgram": "sasjs/tests/testterm.sas",
"macroVars": {
"testVar": "testValue"
},
"testSetUp": "sasjs/tests/testsetup.sas",
"testTearDown": "sasjs/tests/testteardown.sas"
},
"macroFolders": ["targets/viya/macros"],
"programFolders": []
}
],
"required": ["name", "serverType", "appLoc"],
"properties": {
"name": {
"$id": "#/properties/targets/items/anyOf/0/properties/name",
"type": "string",
"title": "Target name property",
"description": "A target name can only contain alphanumeric characters and dashes. It cannot contain spaces. It is used as the alias when referencing the target using the `-t` attribute in many of the SASjs commands.",
"default": "",
"examples": ["viya"]
},
"serverType": {
"$id": "#/properties/targets/items/anyOf/0/properties/serverType",
"type": "string",
"title": "The Target serverType",
"description": "The serverType can be either SAS9, SASVIYA or SASJS.",
"default": "",
"examples": ["SASVIYA"]
},
"appLoc": {
"$id": "#/properties/targets/items/anyOf/0/properties/appLoc",
"type": "string",
"title": "The Target appLoc",
"description": "The appLoc provides the root SAS folder location under which all jobs and services are deployed and executed. The SAS folder could be metadata in SAS 9, or SAS Drive in Viya. ",
"default": "",
"examples": ["/Public/app"]
},
"binaryFolders": {
"$ref": "#/properties/binaryFolders"
},
"buildConfig": {
"$ref": "#/properties/buildConfig"
},
"contextName": {
"$id": "#/properties/targets/items/anyOf/0/properties/contextName",
"type": "string",
"title": "The Target contextName",
"description": "The name of the compute context used to execute SAS code. The context determines the way in which the SAS session is spawned (eg user credentials, autoexec code, system options etc).\nContexts can be created / modified / deleted using the `sasjs context` command.",
"default": "SAS Job Execution compute context",
"examples": ["SAS Job Execution compute context"]
},
"deployConfig": {
"$ref": "#/properties/deployConfig"
},
"httpsAgentOptions": {
"$ref": "#/properties/httpsAgentOptions"
},
"serverUrl": {
"$id": "#/properties/targets/items/anyOf/0/properties/serverUrl",
"type": "string",
"title": "The Target serverUrl",
"description": "The serverUrl is the location to which the app is deployed, and against which any server based operations are performed. If SAS is served from a particular port, that port should also be included here.",
"default": "",
"examples": [
"https://sas.sasjs.com",
"https://sas.sasjs.com:8080"
]
},
"serviceConfig": {
"$ref": "#/properties/serviceConfig"
},
"jobConfig": {
"$ref": "#/properties/jobConfig"
},
"docConfig": {
"$ref": "#/properties/docConfig"
},
"streamConfig": {
"$ref": "#/properties/streamConfig"
},
"syncDirectories": {
"$ref": "#/properties/syncDirectories"
},
"syncFolder": {
"$ref": "#/properties/syncFolder"
},
"testConfig": {
"$ref": "#/properties/testConfig"
},
"macroFolders": {
"$ref": "#/properties/macroFolders"
},
"programFolders": {
"$ref": "#/properties/programFolders"
},
"sasjsBuildFolder": {
"$ref": "#properties/sasjsBuildFolder"
},
"sasjsResultsFolder": {
"$ref": "#properties/sasjsResultsFolder"
}
}
}
]
}
}
}
}
+83
View File
@@ -0,0 +1,83 @@
---
name: sasjs-server
description: Installing, configuring, and running @sasjs/server — the open-source NodeJS wrapper around the SAS binary that provides a REST API, filesystem (SASjs Drive), Stored Program execution, and web app streaming. Covers desktop vs server modes, runtimes (SAS/JS/Python/R), env vars, auth (tokens, LDAP), and mock servers. Use when deploying, troubleshooting, or developing against sasjs/server.
---
# @sasjs/server
SASjs Server is an open-source NodeJS wrapper for calling the SAS binary executable. It runs on a real SAS server or a local desktop and provides:
- A filesystem (SASjs Drive) for storing SAS programs and content
- Execution of Stored Programs from a URL (equivalent to SAS 9 Stored Processes / Viya Jobs)
- Web app streaming (serve frontend apps straight from SAS content)
- A REST API with Swagger docs
- Portability: apps built for SASjs Server deploy unchanged to SAS 9 / Viya via @sasjs/cli
## Modes
- **Desktop mode** (`MODE=desktop`, default): single-user, no authentication, no database. CORS enabled by default.
- **Server mode** (`MODE=server`): multi-user with authentication, requires a database (`DB_CONNECT`, `DB_TYPE=mongodb|cosmos_mongodb`). CORS disabled by default — configure `WHITELIST` if enabling.
## Installation
Download the relevant zip from GitHub releases and run the packaged executable (`api-linux`, etc.):
```bash
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip linux.zip && ./api-linux
```
On first run it prompts (unless set as env vars) for the SAS executable path and the filesystem location for Stored Programs/temp files. Docker is also supported (`DockerfileApi`, docker-compose files in the repo).
## Configuration via environment variables
Set in `/etc/environment`, exported, prepended to the command, or in a `.env` file alongside the executable. Key variables:
| Variable | Purpose |
|---|---|
| `MODE` | `desktop` (default) or `server` |
| `SAS_PATH` | Path to `sas.exe` / `sas.sh` |
| `RUN_TIMES` | Comma-separated runtime priority, e.g. `sas,js,py` — options: `sas`, `js`, `py`, `r`. Each needs its path: `SAS_PATH`, `NODE_PATH`, `PYTHON_PATH`, `R_PATH` |
| `SASJS_ROOT` | Working directory: SAS WORK, staged files, drive, config |
| `DRIVE_LOCATION` | Location for files, sasjs packages, `appStreamConfig.json` |
| `PROTOCOL` / `PORT` | `http` (default) or `https` (needs `PRIVATE_KEY`, `CERT_CHAIN`, optional `CA_ROOT`); default port 5000 |
| `SAS_OPTIONS` / `SASV9_OPTIONS` | Extra SAS system options auto-applied to sessions (Windows vs Unix), e.g. `-NOXCMD` |
| `DB_CONNECT` / `DB_TYPE` | MongoDB connection string / type — required for server mode |
| `AUTH_PROVIDERS` + `LDAP_*` | LDAP auth: `LDAP_URL`, `LDAP_BIND_DN`, `LDAP_BIND_PASSWORD`, `LDAP_USERS_BASE_DN`, `LDAP_GROUPS_BASE_DN` |
| `CORS` / `WHITELIST` | CORS is only applied when `CORS=enable`, and only origins in `WHITELIST` (space-separated) receive `Access-Control-Allow-Origin` — an empty whitelist means NO cross-origin calls work |
| `MOCK_SERVERTYPE` / `STATIC_MOCK_LOCATION` | Emulate `sas9`/`sasviya` API responses for frontend testing against a sasjs server (static canned files only — no logic) |
## Developing against the API
- Server type for @sasjs/adapter / CLI targets is `SASJS` (`serverType: 'SASJS'`); auth is token-based.
- The REST API is self-documented via Swagger on the running instance.
- Server-side execution uses `ms_*` macros from @sasjs/core (e.g. `ms_createfile`, `ms_adduser2group`) — services/jobs deployed by the CLI work as on other platforms.
- Repo layout (for contributors): `api/` (Express/TypeScript backend: controllers, routes, middlewares, model), `web/` (frontend), `restClient/` (REST examples), `mongo-seed/` (server-mode DB seed).
## Mock services with the JS runtime (no SAS required)
With `RUN_TIMES=js` (and `NODE_PATH` set), any `.js` file on SASjs Drive is an executable Stored Program — this is how react-seed-app / Data Controller provide **mock backends** for frontend development. Desktop mode (`MODE=desktop`) has no auth, which makes local mocking trivial.
Writing a JS stored program (docs: https://server.sasjs.io/storedprograms/#js-programs):
- The runtime template predeclares `const fs = require('fs')`, `_program`, `weboutPath`, `_SASJS_TOKENFILE`, `_SASJS_WEBOUT_HEADERS`, `_SASJS_USERNAME` / `_SASJS_USERID` / `_SASJS_DISPLAYNAME`, `_METAPERSON`, `_METAUSER`, `SASJSPROCESSMODE`. **Do NOT redeclare `fs`**`const fs = require('fs')` in your program crashes it with `Identifier 'fs' has already been declared`.
- Output: assign a JSON **string** to `_webout` (e.g. `_webout = JSON.stringify({...})`); it is written back only if non-empty. `console.log()` output is returned in the response `log` (like a SAS log). Custom response headers can be written as lines to the `_SASJS_WEBOUT_HEADERS` file.
- Mimic real services by including the standard SASjs automatic fields in the JSON: `_PROGRAM` (from `_program`), `SYSDATE` / `SYSTIME` (format `DDMMMYY` / `HH:mm`), `_METAUSER`, `SASJSPROCESSMODE`.
- URL/body parameters arrive as `const <name> = \`<value>\`` strings.
- Input tables (the `sasjs_tables` mechanism) arrive **either** as an inline CSV const **or** — when the adapter sends multipart — as an uploaded `<name>.csv` file in the session folder, referenced by generated module-scope consts (handle BOTH):
- `_WEBIN_FILE_COUNT` (always created), `_WEBIN_NAME<n>` (table/field name), `_WEBIN_FILENAME<n>` (original filename), `_WEBIN_FILEREF<n>` (file **contents**, a Buffer from `fs.readFileSync` — call `.toString('utf8')`)
- these consts are **not on `globalThis`** — look them up with `typeof` guards or direct `eval()` in module scope (server-side JS, no CSP)
- adapter CSV quirks: header row is **space-separated** `name:format.` entries (e.g. `rootdir:$char256.`) — strip the `:format` suffix; lines end CRLF; values containing special characters are wrapped in double quotes with `""` escaping
- Adapter response shape: `sasjs.request()` resolves with the webout JSON **already unwrapped** — output tables are arrays of row objects directly on the response (`res.mytable[0].COL`). A table named `result` is perfectly fine (`res.result` is then that array); do NOT add your own `res.result`-unwrapping layer, it breaks exactly that case.
- Third-party npm packages are NOT resolvable at runtime — bundle the service first (e.g. `npx webpack --mode none --target node --entry <file> --output-path sasjsbuild/... --output-filename <name>.js`), then `sasjs build` / `sasjs deploy`.
Deploying mocks:
- `sasjs fs sync` does NOT work on a JS-only server (it generates and executes SAS code to hash remote files). Upload files directly via the Drive API instead: `DELETE` then `POST /SASjsApi/drive/file?_filePath=<appLoc>/services/<folder>/<name>.js` (multipart `file` field). In desktop mode no auth headers are needed; in server mode read the `Authorization` header line from `_SASJS_TOKENFILE`.
- Mocks can be stateful with the predeclared `fs`. Prefer real locations over `/tmp`: the SASjs Drive root is derivable from `weboutPath` (`<root>/sessions/<id>/webout.txt``path.resolve(weboutPath, '..', '..', '..', 'drive')`), and a mock `configure`-style service can treat a configured folder as a real local path (the server IS local). `require('path')` and other core modules work (only `fs` is predeclared).
- A JS program can even call the server's own REST API (`http://127.0.0.1:$PORT/SASjsApi/...`) — e.g. to rewrite a streamed `index.html` on the Drive (`GET` + `PATCH /SASjsApi/drive/file`).
Gotchas:
- The packaged binaries (`api-linux` etc.) reject some globally-exported `NODE_OPTIONS` (e.g. `--network-family-autoselection`) — start with `NODE_OPTIONS="" ./api-linux`.
- AppStream URLs redirect to a trailing slash (`/AppStream/MyApp` → 301 → `/AppStream/MyApp/`) — test/automation scripts should use the trailing-slash URL directly.
+36 -11
View File
@@ -3,6 +3,42 @@
# Using `--silent` helps for showing any errs in the first line of the response
# The first line is picked up by the VS Code GIT UI popup when rc is not 0
# Scan staged changes for secrets before anything else runs.
# The binary is pinned by the @nogoo9/gitleaks devDependency and lands
# in node_modules/.bin after npm i. Commits are blocked until it is
# installed so no clone silently skips the secrets scan.
gitleaks_bin=node_modules/.bin/gitleaks
if [ ! -x "$gitleaks_bin" ]; then
echo "❌ gitleaks not found - run 'npm i' to install it"
exit 1
fi
if ! "$gitleaks_bin" protect --staged --redact; then
echo "❌ gitleaks detected a potential secret in your staged changes"
echo "Remove the secret, or add a .gitleaksignore entry if it is a false positive"
exit 1
fi
## Avoid large commits
# https://www.backblaze.com/blog/how-many-bytes-are-in-a-megabyte-really/
size_limit=$((2 * 1024 * 1024)) # 2MB
# Sum the sizes of the blobs this commit would introduce: the staged
# added/copied/modified/renamed/typechanged entries in the index.
# https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt---batch-check
# Renames are treated as add+delete (--no-renames) and pure mode changes
# (same blob, different permissions) add no new blob, so both are safe.
commit_size=$(git diff --cached --raw --no-renames --diff-filter=ACMRT |
awk '$3 != $4 { print $4 }' |
git cat-file --batch-check='%(objectsize)' |
awk '{ s += $1 } END { print s + 0 }')
if [ "$commit_size" -ge "$size_limit" ]; then
echo "Commit size is too large: $commit_size > $size_limit"
echo "Force commit using --no-verify"
exit 1
fi
if npm run --silent lint:check:silent ; then
exit 0
else
@@ -10,14 +46,3 @@ else
echo "❌ Prettier check failed! We ran lint:fix for you. Please add & commit again."
exit 1
fi
## Avoid large commits
# https://www.backblaze.com/blog/how-many-bytes-are-in-a-megabyte-really/
size_limit=$((2 * 2**20)) # 2mbs
# https://git-scm.com/docs/git-rev-list#Documentation/git-rev-list.txt---disk-usage
commit_size=$(git rev-list --disk-usage HEAD^..HEAD)
test "$commit_size" -lt "$size_limit" || (
echo "Commit size is too large: $commit_size > $size_limit"
echo "Force commit using --no-verify"
exit 1
)
+15 -7
View File
@@ -38,13 +38,13 @@ jobs:
npm ci
- name: Check audit
# Audit should fail and stop the CI if critical vulnerability found
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
run: |
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
cd ./sas
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
cd ../client
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
- name: Lint check
run: npm run lint:check
@@ -125,8 +125,16 @@ jobs:
cd ./sas/mocks/sasjs
npm install -g @sasjs/cli
npm install -g replace-in-files-cli
# Remove any previous deployment (drive folder delete API) so the
# deploy and makedata start from a clean appLoc
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
sasjs cbd -t server-ci
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
# Seed the DC database (mock data files on the drive).
# makedata replies with HTML (it is normally called as a URL redirect),
# which the CLI reports as "invalid Json string" - ignore that.
# NOTE: -d paths resolve from the sasjs project root (sas/mocks),
# not the cwd (sas/mocks/sasjs).
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
- name: Prepare and run frontend and cypress
timeout-minutes: 35
@@ -139,14 +147,14 @@ jobs:
npm run postinstall
# Prepare index.html to SASJS local
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/devtest"' ./src/index.html
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/dc"' ./src/index.html
replace-in-files --regex='serverType=".*?"' --replacement='serverType="SASJS"' ./src/index.html
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
cat ./cypress.config.ts
# Start frontend and run cypress
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
- name: Zip Cypress videos
if: always()
+15 -7
View File
@@ -45,13 +45,13 @@ jobs:
npm ci
- name: Check audit
# Audit should fail and stop the CI if critical vulnerability found
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
run: |
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
cd ./sas
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
cd ../client
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
- name: Angular Tests
run: |
@@ -125,8 +125,16 @@ jobs:
cd ./sas/mocks/sasjs
npm install -g @sasjs/cli
npm install -g replace-in-files-cli
# Remove any previous deployment (drive folder delete API) so the
# deploy and makedata start from a clean appLoc
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
sasjs cbd -t server-ci
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
# Seed the DC database (mock data files on the drive).
# makedata replies with HTML (it is normally called as a URL redirect),
# which the CLI reports as "invalid Json string" - ignore that.
# NOTE: -d paths resolve from the sasjs project root (sas/mocks),
# not the cwd (sas/mocks/sasjs).
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
- name: Prepare and run frontend and cypress
run: |
@@ -138,12 +146,12 @@ jobs:
npm run postinstall
# Prepare index.html to SASJS local
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/devtest"' ./src/index.html
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/dc"' ./src/index.html
replace-in-files --regex='serverType=".*?"' --replacement='serverType="SASJS"' ./src/index.html
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
cat ./cypress.config.ts
# Start frontend and run cypress
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
- name: Zip Cypress videos
if: always()
+3 -1
View File
@@ -22,4 +22,6 @@ sasjsresults
.sasjsrc
client/.npmrc
*~
.lighthouseci
.lighthouseci
audit.log
tmp/
+48
View File
@@ -0,0 +1,48 @@
# Agent Instructions
Read **`CONTEXT.md`** at the repo root first - it is the domain glossary and orientation for Data Controller (roles, load types, MPE control tables, validations, security). Use its vocabulary in your output.
## Related repositories
Data Controller spans three sibling repos (usually checked out side by side):
- **`dc`** (this repo) - the product source (Angular client + SAS backend).
- **`docs.datacontroller.io`** - the user-facing product documentation (MkDocs). User-facing behaviour is documented there; the `.agents/docs/` deep-dives here link out to it.
- **`datacontroller.io`** - the marketing site, blog and feed (Gatsby).
## Git
Do NOT auto-commit. Never run `git commit` (or `git push`) unless the user explicitly asks. Leave changes in the working tree for the user to review and commit themselves.
## CHANGELOG and versioning
Do NOT edit `CHANGELOG.md` by hand, and do NOT bump the `version` in `package.json`. The release pipeline (semantic-release, in `.gitea/workflows/release.yaml`) generates both automatically from Conventional Commit messages on every push to `main`. Manual entries collide with the pipeline's release commit and are overwritten anyway. Control the changelog through your commit messages instead. See `.agents/docs/releases-and-changelog.md`.
## Markdown files
Do NOT hard-wrap Markdown files (no fixed-width line wrapping / carriage returns inside paragraphs). Each paragraph, list item, and heading should be a single logical line, regardless of length. Let the editor/viewer soft-wrap. This includes AGENTS.md itself.
This is different from `.sas` files, where a maximum line length applies. The no-wrap rule applies to all `*.md` files in this repo (docs, READMEs, etc.).
Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and reflowed, and Markdown renderers already handle wrapping.
## Linting (required before "done")
Never consider a change complete until the relevant linters pass on the files you touched — do not rely on the user's pre-commit hooks to catch it:
- **Client (TypeScript/HTML/etc.)**: run `npm run lint:check` from the `client/` directory (prettier). Fix any failures with `npm run lint:fix`.
- **SAS**: after creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored).
## No external assets
Data Controller must run entirely locally (offline / on-prem, no internet access). The built product must never fetch assets from remote servers — no external fonts, images, scripts, stylesheets, CDN links, or remote URLs in meta tags (e.g. `og:image`, `og:url`, `itemprop="image"`). All assets must be bundled and served locally.
## The .agents folder
Agent-related content lives in `.agents/`: technical/agent-facing documentation goes in `.agents/docs/` (not `docs/`), and skills in `.agents/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agents/docs/`.
Skills installed via `npx skills add` are tracked in `skills-lock.json` and live under `.agents/skills/`. Repo-local custom skills (e.g. `dc-sas` for Data Controller SAS development, `handsontable`, `hyperformula`) are kept here too. Avoid editing npx-managed skill files directly — use `npx skills update` instead.
## Code comments and test names
Never reference items that are not active parts of the repository — no "the original bug", "regression from this fix", "this session/PR/commit", or similar ephemeral context. Comments and test names must be self-contained: describe the behaviour being asserted, not the history of how it was discovered. The one exception is a literal link to a ticket/issue tracker.
+154
View File
@@ -1,3 +1,157 @@
# [7.14.0](https://git.datacontroller.io/dc/dc/compare/v7.13.0...v7.14.0) (2026-09-04)
### Features
* **system:** show session timezone on system information screen ([966dc07](https://git.datacontroller.io/dc/dc/commit/966dc072e0cb8bb44bd93a7682c3d7335428d688))
# [7.13.0](https://git.datacontroller.io/dc/dc/compare/v7.12.0...v7.13.0) (2026-09-03)
### Bug Fixes
* dynamic js mocks ([3b9344c](https://git.datacontroller.io/dc/dc/commit/3b9344cb602b609774737dcbb6df6756a2a08edb))
* adding formula types ([c07a01a](https://git.datacontroller.io/dc/dc/commit/c07a01a38da6a6e4edaf014ef2fd4d2691b29698))
* address hermes review feedback (formula quoting, cell revert, addRow guard) ([36963aa](https://git.datacontroller.io/dc/dc/commit/36963aa74627b861aee9ec91428a8dbee0946dfc))
* agent skills and nextviya deploys ([f7db871](https://git.datacontroller.io/dc/dc/commit/f7db8719f5fe860605f4e6da81bad925d7412599))
* bump core for mp_validate fix ([5a44b28](https://git.datacontroller.io/dc/dc/commit/5a44b2804feddbd616ece1d721865e02bfbedda8))
* CAS support for REPLACE type plus docs ([ea05f07](https://git.datacontroller.io/dc/dc/commit/ea05f0718047a0ac54cffc58938b6d06f5a112e2))
* clarify debug comment is permanent, extend ComputeContextDetails with Viya response fields ([72484ae](https://git.datacontroller.io/dc/dc/commit/72484ae8441715813983faa221be348cf421b735))
* core major bump plus autofix of viya context on deploy ([6527c10](https://git.datacontroller.io/dc/dc/commit/6527c10f262249187c27723d983edd32c3848d72))
* **core:** bump to v5 (breaking change) ([59e9e96](https://git.datacontroller.io/dc/dc/commit/59e9e96f5a6a6c9c0560cbb8387982611a0c8ca4))
* **cypress:** correct mock data, test expectations, and abort modal ([13c10be](https://git.datacontroller.io/dc/dc/commit/13c10be30faae736d27b584e0507eaab702d2d32))
* **cypress:** make licensing combined-key tests order-independent ([4db8d62](https://git.datacontroller.io/dc/dc/commit/4db8d62dca9257764438b00ef034103a0703111a))
* **deploy:** Viya deploy checks, startup diagnostics, and chunked deploy script ([917925f](https://git.datacontroller.io/dc/dc/commit/917925f55393aca919c2a7c7cd7dbecadbc24ace)), closes [#303](https://git.datacontroller.io/dc/dc/issues/303) [#200](https://git.datacontroller.io/dc/dc/issues/200) [#125](https://git.datacontroller.io/dc/dc/issues/125)
* **deps:** align @angular/* packages to the same lockstep version ([24e6297](https://git.datacontroller.io/dc/dc/commit/24e62971879e61eef0fc3ea9f07796b92231dbe3))
* **deps:** override nanoid to 3.3.18 in sas/ ([d57ae03](https://git.datacontroller.io/dc/dc/commit/d57ae03fc44770f41de5dbe0389d33cd11fc0971))
* **deps:** pin @handsontable/angular-wrapper to 18.0.0 ([2d7df66](https://git.datacontroller.io/dc/dc/commit/2d7df66fc28ffe07e859920c052b9818ed58647f))
* **deps:** pin babel-loader to resolve an unresolvable peer conflict ([3bf3bf0](https://git.datacontroller.io/dc/dc/commit/3bf3bf0dde943246380528d5af7c46ad1f518c79))
* **deps:** regenerate lockfile with Node 24 and pin handsontable to 18.0.0 ([7da29bd](https://git.datacontroller.io/dc/dc/commit/7da29bdb1180a9f50055380c1df3649283e9cdf6))
* **deps:** regenerate lockfile with strict peer-dep resolution to fix npm ci in CI ([efd2e18](https://git.datacontroller.io/dc/dc/commit/efd2e18c7b09f80655528a2f6985034f5a41dcf3))
* **deps:** regenerate package-lock.json to resolve npm ci sync errors ([a501903](https://git.datacontroller.io/dc/dc/commit/a501903e6d4276f1b4eba8f8ae0feccafcfbcf86))
* **deps:** resolve npm audit findings and align @angular/* to the same lockstep version ([b9e4b27](https://git.datacontroller.io/dc/dc/commit/b9e4b2733fa01e2b8060dab54277000a2c6252b8))
* **deps:** resolve npm audit findings via direct lockfile patch, not regeneration ([4d1bfa6](https://git.datacontroller.io/dc/dc/commit/4d1bfa6343342a167bdc315204866a18bcbca8ed))
* **deps:** resolve npm audit vulnerabilities in production dependencies ([257f69c](https://git.datacontroller.io/dc/dc/commit/257f69ccc6dd070daf8c73f597a08d01e8ea6888))
* **deps:** scope the brace-expansion override to its actual vulnerable chain ([f9d061c](https://git.datacontroller.io/dc/dc/commit/f9d061c48901d9ef131303940f0c2e2948b12fdf))
* dynamic getdynamiccolvals.js ([d59ef44](https://git.datacontroller.io/dc/dc/commit/d59ef442266bd4823f5cad3f7f4985e3df9afb48))
* **editor:** clear sort before reading cells to preserve on cancel ([4137ebd](https://git.datacontroller.io/dc/dc/commit/4137ebdf6b6563eeac4f805708c3b4ffcd099fb1))
* **editor:** escape formula-looking values in uploaded Excel data ([e69df3d](https://git.datacontroller.io/dc/dc/commit/e69df3deb22d80b1371b63a5ae1c6a44538b00bc))
* **editor:** fix formula $-substitution corruption and sorted-grid row/formula desync ([e139c37](https://git.datacontroller.io/dc/dc/commit/e139c37fbfe46123cce2cbfcf7e919bc68e19a2f))
* **editor:** let the column info dropdown's text be selected and copied ([1e516f4](https://git.datacontroller.io/dc/dc/commit/1e516f4012947c3f42dec1f3a46fa364c39bfe3e))
* **editor:** re-mark a reverted cell as auto-escaped so "Apply as formula" works again ([12091c4](https://git.datacontroller.io/dc/dc/commit/12091c4044b0982c36b7d6011b51625bfb6b8cb5))
* **editor:** resolve a primary key's live formula to its computed value, not the raw formula text ([d72e19a](https://git.datacontroller.io/dc/dc/commit/d72e19a3086ef578a1304fe032e1088c6ef60cf7))
* **editor:** resolve computed value for a live formula in numeric columns too ([2cc8489](https://git.datacontroller.io/dc/dc/commit/2cc8489892638aaf97fd5999ebc6086ffd674a76))
* **editor:** restore multi-column sort with one call, not a loop ([8fafeb7](https://git.datacontroller.io/dc/dc/commit/8fafeb7923bc7dae178c4fed0898195d5b5825a6))
* **editor:** size table-header buttons and title to content, not fixed grid thirds ([d0a7561](https://git.datacontroller.io/dc/dc/commit/d0a7561f1aeb67b214e6a473cc6235509daa80bb))
* **editor:** stop dc.row_status from being submitted to the backend ([dc0f6a7](https://git.datacontroller.io/dc/dc/commit/dc0f6a7baa128e0b75d7341ac7c03dcd4b1ffee5))
* **editor:** stop turning missing columns into undefined, and fix column lookup for sparse rows ([a104f78](https://git.datacontroller.io/dc/dc/commit/a104f78645bc7777fee672956d3f028ff52ed9ba))
* **editor:** submit a live formula's computed value, not its raw text ([b7d3106](https://git.datacontroller.io/dc/dc/commit/b7d31066ae5272856ea442d0f76d2f0752015dbb))
* ensure cleanup of casuser temp table in error condition ([c4123e5](https://git.datacontroller.io/dc/dc/commit/c4123e5c965ba3f78ec8cf32f915f263d218ca56))
* ensuring formats arrive from backend in getstagetable.sas ([daacb49](https://git.datacontroller.io/dc/dc/commit/daacb49c8a2c6a266dd842a8b7ccdebaa992a1d7))
* escape regex metacharacters in context name before prxchange ([9023eb2](https://git.datacontroller.io/dc/dc/commit/9023eb2a393ee8441bab5b9c5ff4f53b500e0e91))
* failing test ([f213d24](https://git.datacontroller.io/dc/dc/commit/f213d24f096d814d286bfdac5a28748e97e7293f))
* fixed npm vuln ([26b55b1](https://git.datacontroller.io/dc/dc/commit/26b55b1bde5e28afde2d4f9889cb452985410c1d))
* **formulas:** avoid EDIT_STATUS colliding with a real column of that name ([acb97f4](https://git.datacontroller.io/dc/dc/commit/acb97f4bfb1c7d6a0b38dd33207170f6ec2bbd1d))
* hardening following PR review feedback ([1f4aa6f](https://git.datacontroller.io/dc/dc/commit/1f4aa6fceafc4b51ecc5a19f4789cf5bd018a5d3))
* history mock ([0082828](https://git.datacontroller.io/dc/dc/commit/00828285435a412e3e5dccb045b053c1096e8876))
* **licensing:** bridge async proceed() through cy.then and replace Response.arrayBuffer with manual stream reader ([8e51fde](https://git.datacontroller.io/dc/dc/commit/8e51fde524e799652fbd1f0b0763d667cb3a4845))
* **mocks:** add fixture staged data and MPE_SUBMIT row for stage.cy.ts ([6e2548d](https://git.datacontroller.io/dc/dc/commit/6e2548d78c613d684fc8d874ba11b30fc9b4e32e))
* **mocks:** add LIBRARYNAME to startupservice, fix viewbox column order ([c0af86e](https://git.datacontroller.io/dc/dc/commit/c0af86ef297334ee531e979754d4d289ed3900ea))
* **mocks:** limit stage fixture to 1 row, fix column order ([f25ecd8](https://git.datacontroller.io/dc/dc/commit/f25ecd88283433770f56167774617f0e2089c6ef))
* **mocks:** look up PK_FIELDS from MPE_TABLES in viewdata, fallback for MPE_AUDIT ([94d2a79](https://git.datacontroller.io/dc/dc/commit/94d2a7970e49845b235c71523418e4a5d2a70396))
* **mocks:** remove MPE_VALIDATIONS and MPE_ALERTS from DC996664 libref ([4f7b845](https://git.datacontroller.io/dc/dc/commit/4f7b845390d50db50114afa7fa851ddf3e853ed7))
* **mocks:** remove shadowed saveTableData in rejection.js ([fed0396](https://git.datacontroller.io/dc/dc/commit/fed039673777659594feb9a92a5d9c2dca2cc544))
* **mocks:** remove SOME_SHORTNUM HARDREGEX from MPE_VALIDATIONS ([34bd660](https://git.datacontroller.io/dc/dc/commit/34bd6607c547c3d40c1129395a28e4cbf0fa1475))
* **mocks:** rename saveTableData in postdata.js to avoid shadowing ([e4b04b1](https://git.datacontroller.io/dc/dc/commit/e4b04b1d447640542c122dce93d65219719cb094))
* **mocks:** SAS datetime format in getchangeinfo, login in stage.cy.ts ([c180934](https://git.datacontroller.io/dc/dc/commit/c1809341672ad51baa603f104176fdc302e4c0c1))
* more mock improvements ([a80927c](https://git.datacontroller.io/dc/dc/commit/a80927cbb6c8a5ce26a1d251dc10cae4bae1eedd))
* mp_execute dep ([023c29f](https://git.datacontroller.io/dc/dc/commit/023c29f00f74f964825e0ee01768baf44aead332))
* pin fast-uri to 3.1.7 to close high-severity SSRF/host-confusion advisories ([f4325b2](https://git.datacontroller.io/dc/dc/commit/f4325b205699bf02158c232ffd8c279489778512)), closes [hi#severity](https://git.datacontroller.io/hi/issues/severity)
* spy for startup ([69b4155](https://git.datacontroller.io/dc/dc/commit/69b41559f7aa66a537b685e26e00c44f1f2568c4))
* **stage:** submit data via editor flow, use dynamic date assertion ([490d013](https://git.datacontroller.io/dc/dc/commit/490d0137b921c150b431083b847d471aa379bc42))
* **startup:** show the real startupservice response text on a malformed reply ([9a1b7d0](https://git.datacontroller.io/dc/dc/commit/9a1b7d0f52eebe6bea229dedbcb8f5c19bc96141))
* tests and realistic getcolvals.js ([5d03164](https://git.datacontroller.io/dc/dc/commit/5d03164b6a3245b47f1278fe962c537f72a8f473))
* **tests:** new libref ([c0bc87d](https://git.datacontroller.io/dc/dc/commit/c0bc87d331b081d787b4ed3d04c639c139d0fa1b))
* unnecessary getsubmits call removed ([cc2ff87](https://git.datacontroller.io/dc/dc/commit/cc2ff873fae58f41332dcbe725a49f0815b219b3))
* using utility macro for webout ([3595bb9](https://git.datacontroller.io/dc/dc/commit/3595bb94993aa054235cce3f41b9d83711907c46))
### Features
* **editor:** color row-header status cells and switch modified symbol to ± ([e15f2c2](https://git.datacontroller.io/dc/dc/commit/e15f2c2a3488f43ed4e5814fdc22527961cc5c22))
* **editor:** generalize cell revert to any overwritten value, not just formulas ([07d586d](https://git.datacontroller.io/dc/dc/commit/07d586da5235a2d94b2b689c774c40851b9a57e2))
* **editor:** scope live formulas to character columns, not just HARDFORMULA/SOFTFORMULA ones ([92d0d91](https://git.datacontroller.io/dc/dc/commit/92d0d91440ff6d58d10f1af291386a1813cb07d4))
* **editor:** translate column names to cell references on formula paste ([1031ea7](https://git.datacontroller.io/dc/dc/commit/1031ea7ed7b1a74ecc77bf155f6d329feeaf4b13))
* **formulas:** flag formula-overwritten cells with revert, harden dc.row_status against SAS name collisions ([a8237b2](https://git.datacontroller.io/dc/dc/commit/a8237b2881b85854bb52c85ca2f17e3368f91f4a))
* **formulas:** live DC.ROW_STATUS, insert-row formula fixes, UserService singleton fix ([4a8c39b](https://git.datacontroller.io/dc/dc/commit/4a8c39b4c0d6e0edb3493ac3d26d86788ebfdf90))
* **formulas:** wire HyperFormula into HARDFORMULA/SOFTFORMULA rules ([cbea04c](https://git.datacontroller.io/dc/dc/commit/cbea04c8e129d7d28a626215aae3b67735a036d5))
* functional JS mocks ([ee52bb8](https://git.datacontroller.io/dc/dc/commit/ee52bb8967e5bcdcfd17500678295e6bffbe2cf0))
* **licensing:** support combined-key paste, object-format features, and a live key preview ([20ba633](https://git.datacontroller.io/dc/dc/commit/20ba63308717dbde929dbe5974b76f160343de29))
* **licensing:** warn and block applying a licence key generated for the wrong protocol ([41680e2](https://git.datacontroller.io/dc/dc/commit/41680e2eccad82584bf9fd71a1efd1fd2d8f1a39))
* **stage:** add Formatted/Unformatted toggle to the staging page ([74c38e1](https://git.datacontroller.io/dc/dc/commit/74c38e16416dcd8be20faa66d9f282a6438522b5))
* updated configurator for Viya deploy ([df2027d](https://git.datacontroller.io/dc/dc/commit/df2027dab6ae51fb9cabf21e35d1fa5d6e1f7957))
* **viewboxes:** add drag-to-resize on all four edges and corners ([e5a5bf2](https://git.datacontroller.io/dc/dc/commit/e5a5bf2144d7f35c62368924c7e59f55eeb246ad))
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)
### Bug Fixes
* adding REGEX validations to mpe_x_test ([bd798b4](https://git.datacontroller.io/dc/dc/commit/bd798b424a7c4b90d613425348d3f99eeb49b025))
* default value for label ([a3e46a9](https://git.datacontroller.io/dc/dc/commit/a3e46a968ef08295347b230235f1e79dfa51c5a2))
* **deps:** retarget Angular upgrade to 20, not 21 (CI install was broken) ([cac9244](https://git.datacontroller.io/dc/dc/commit/cac9244f92544f432078125c312d05c36ed4b855))
* ensure only one REGEX applies at a time ([8fb58eb](https://git.datacontroller.io/dc/dc/commit/8fb58eb36e05fdf7262459b05e53643b3ca672f7))
* ensure that no assets (including og links) ever fetch from external sources ([33dcb98](https://git.datacontroller.io/dc/dc/commit/33dcb989d3ae0c5ee0a13fe64195862a945c4348))
* include peer dependencies in package-lock for npm ci in pipeline ([b51c770](https://git.datacontroller.io/dc/dc/commit/b51c770782a8b62816e5145496dbea9fb83059e0))
* licensecheker ([f60bcef](https://git.datacontroller.io/dc/dc/commit/f60bcef58381ba269415640fa19a27305274939a))
* **lint:** remove redundant optional chaining ([d881290](https://git.datacontroller.io/dc/dc/commit/d881290618f25ddb6335125f1a08b5aaf56a8c48))
* optimisation, renamed values for DDTYPE to save space ([cfb60e5](https://git.datacontroller.io/dc/dc/commit/cfb60e5e4bfd37dc79fa4e9a6c6649ab1a61f2e4))
* patch npm audit vulnerabilities in sas and client dependencies ([e22edf7](https://git.datacontroller.io/dc/dc/commit/e22edf7ed3aa54fc08411cf90d51df9ec30877c3))
* **query:** isolate viewbox filter state from the base table's ([7a35cf4](https://git.datacontroller.io/dc/dc/commit/7a35cf4a458791b26ec8141c0c84d97fcb555145))
* regenerate client lockfile to resolve Angular peer-dependency drift breaking npm ci ([05fe474](https://git.datacontroller.io/dc/dc/commit/05fe4744d58e7985ecc5ef5f9c1969dbab2d2efb))
* **regex:** special missing handling ([180c247](https://git.datacontroller.io/dc/dc/commit/180c2477ed9ae44b4e479e81ec642f40580bcbc8))
* removing low severity warning in npm audit ([2a771bb](https://git.datacontroller.io/dc/dc/commit/2a771bb91acaaa16aba44e2d7ddb51b8b7123c10))
* removing thousand seperator from plain numerics in EDIT mode ([0392a81](https://git.datacontroller.io/dc/dc/commit/0392a81cbd9a634590ca7a60a316cd12c690a5a5))
* **validations:** parse SAS PRX /pattern/flags syntax in HARDREGEX/SOFTREGEX ([7ed3730](https://git.datacontroller.io/dc/dc/commit/7ed3730ae3145dffd639ce3d2f831447d78f4e85))
### Features
* **docs:** adding agents.md and docs for RLS ([359d833](https://git.datacontroller.io/dc/dc/commit/359d833406ace79f96d03c08a5c72fbd3df29442))
* **editor:** add HARDREGEX/SOFTREGEX validation rules ([17e4802](https://git.datacontroller.io/dc/dc/commit/17e48028955d7b091e53bad9d7f9e3a089b53af9))
* **editor:** evaluate HARDREGEX/SOFTREGEX independently instead of hard-wins precedence ([57db117](https://git.datacontroller.io/dc/dc/commit/57db1179a97973ecf0f711bda6057383de546dd3))
* **editor:** show applied HARDREGEX/SOFTREGEX pattern in column info dropdown ([39c8855](https://git.datacontroller.io/dc/dc/commit/39c8855f37477f06d8b8cf5c97e04543070e8385))
* **regex:** backend validations on regex strings ([d2c93a4](https://git.datacontroller.io/dc/dc/commit/d2c93a46facb386fd31661c02c0d3d34f38f6e43))
* using ALL libraries as validation in MPE_SECURITY. Closes [#279](https://git.datacontroller.io/dc/dc/issues/279) ([62ff0ae](https://git.datacontroller.io/dc/dc/commit/62ff0aee4a976184de65d87ea3c8b8b7cb777938))
* validation checks to prevent incompatible RLS rules (eg REPLACE load type). Closes [#211](https://git.datacontroller.io/dc/dc/issues/211) ([ea00c5a](https://git.datacontroller.io/dc/dc/commit/ea00c5afad0daf2a66cdb206f2a7481b992192bf))
* validation on RLS for REPLACE, + docs + tests. Closes [#211](https://git.datacontroller.io/dc/dc/issues/211) ([7378f3b](https://git.datacontroller.io/dc/dc/commit/7378f3ba3014141008b4dbde0b89c21a6ce02f69))
# [7.11.0](https://git.datacontroller.io/dc/dc/compare/v7.10.1...v7.11.0) (2026-07-20)
### Bug Fixes
* audit-skipping syntax correction ([ad7efc9](https://git.datacontroller.io/dc/dc/commit/ad7efc90bc33c93383e17359d14c0605cded69a9))
* **editor:** ignore cell validation on rows marked for delete ([70c5805](https://git.datacontroller.io/dc/dc/commit/70c5805743c8c6e61bc216f37cbe8cd4f0614c98))
* explicit use of .sashdat in dc_casload ([6a95067](https://git.datacontroller.io/dc/dc/commit/6a95067618600d7b03b6af46bf3d74721e42c66a))
* refactor mf_wordsinstr1butnotstr2 and added a wide table to test suite ([be94c68](https://git.datacontroller.io/dc/dc/commit/be94c68f354d0f9b1eb52213807d1874a74d9b8a))
* retry the startup service request once before failing ([5391df8](https://git.datacontroller.io/dc/dc/commit/5391df8f04cf40205a600b23c207f97b6fdf5913))
* run dc_casload for dq tables in getdata. Closes [#264](https://git.datacontroller.io/dc/dc/issues/264). ([6b16f9b](https://git.datacontroller.io/dc/dc/commit/6b16f9b3647fca2e39d44544201ce972046e01a3))
* updating tests, moving mpe_rowhash to core library ([c5f0e4f](https://git.datacontroller.io/dc/dc/commit/c5f0e4f106aa4e6a7c2a2fd0ebb2e85d1e7bee7b))
### Features
* bumping core to get the mp_rowhash definition, [#269](https://git.datacontroller.io/dc/dc/issues/269) ([f73f5e4](https://git.datacontroller.io/dc/dc/commit/f73f5e47ac38a305bac4ebd7eac48d1a3edf4a83))
* new hash algorithm - allows 1000+ cols. Closes [#269](https://git.datacontroller.io/dc/dc/issues/269) ([08947f0](https://git.datacontroller.io/dc/dc/commit/08947f0fc8458be098e3aaf769b1da7fadf480cb))
## [7.10.1](https://git.datacontroller.io/dc/dc/compare/v7.10.0...v7.10.1) (2026-07-15)
### Bug Fixes
* **editor:** order validation rules by COLHEADERS, not cols[].VARNUM ([4c2aca3](https://git.datacontroller.io/dc/dc/commit/4c2aca34951d6d5c5cf2df69d5bf4256a9290d53))
# [7.10.0](https://git.datacontroller.io/dc/dc/compare/v7.9.1...v7.10.0) (2026-07-13)
+93
View File
@@ -0,0 +1,93 @@
# Context: Data Controller for SAS®
Data Controller for SAS® is a web application that lets users safely add, modify and delete data in SAS datasets and databases. Every change is **staged** and **approved** before being applied to the **target table**, and the full history of the change is retained. It runs on SAS Viya, SAS 9 EBI, and [SASjs Server](https://server.sasjs.io), and must run entirely **on-prem / offline** (no external assets or network calls at runtime).
This file is the shared glossary and orientation for the repo. When your output names a domain concept, use the term as defined here rather than a synonym. Related repos: `docs.datacontroller.io` (user-facing docs) and `datacontroller.io` (marketing site).
## Repository layout
- `client/` - the Angular frontend (TypeScript). Uses [Handsontable](.agents/skills/handsontable/SKILL.md) for the editable grid and [HyperFormula](.agents/skills/hyperformula/SKILL.md) for Excel-formula support. Lint with `npm run lint:check` from `client/`.
- `sas/` - the SAS backend. Services, macros, hooks and tests live under `sas/sasjs/**`; `sas/sasjsbuild/` is generated output (never hand-edit). Lint `.sas` files with `sasjs lint` from `sas/`.
- `.agents/docs/` - technical deep-dives (see below). `.agents/skills/` - task skills (handsontable, hyperformula, dc-sas for Data Controller SAS development, plus npx-installed sasjs/ sas skills).
## Roles
The five user roles, in increasing privilege:
- **Viewer** - explores/links to data without locking datasets.
- **Editor** - makes changes (add/modify/delete) and submits them for approval.
- **Approver** - accepts or rejects staged changes; on acceptance the change is applied to the target table.
- **Auditor** - reviews the history of changes to a table.
- **Administrator** - registers tables and configures security (at metadata group level).
Admins are listed in `&mpeadmins`; admin membership bypasses Row Level Security.
## Core concepts
- **Target table** - the physical table (SAS dataset or database table) a user is changing. Its attributes (primary key, load type, library, SCD/temporal variables) are predefined by an administrator in `MPE_TABLES`.
- **Submission** - the set of changed rows staged for approval. Submissions are *never* applied automatically; they always require one or more approvals. Three kinds: **Web submission** (only changed rows from the edited extract), **Excel submission** (whole Excel file staged with the file kept for audit; becomes a web submission if the rows are edited in the grid first), and **CSV submission** (all rows sent straight to staging, suitable for larger uploads).
- **Staging area** - the secure location where submitted changes wait for approval before being loaded.
- **Edit-Stage-Approve workflow** - the central flow: up to 500 rows edited in the web grid, staged, and applied to the target after approval.
- **Changeset / diff** - new / modified / deleted rows computed for a submission; approvers review the diff before accepting.
## Load types
Set per table in `MPE_TABLES.LOADTYPE`. Determines the loader and how history is kept (see [.agents/docs/bitemporal-dataloader.md](.agents/docs/bitemporal-dataloader.md)):
- **UPDATE** - no history; changed records deleted and re-appended (via `%bitemporal_dataloader`).
- **REPLACE** - no history; whole table wiped and reloaded. Implemented inline in `%mpe_targetloader`, deliberately *not* via `%bitemporal_dataloader` (see [.agents/docs/replace-load-type.md](.agents/docs/replace-load-type.md)).
- **TXTEMPORAL** - SCD2-style history on technical (transaction) time.
- **BITEMPORAL** - full two-dimensional history (business time + technical time).
- **FORMAT_CAT** - format-catalog load (via `%mp_loadformat`).
**Bitemporal** = two independent time dimensions: **business time** (`VAR_BUSFROM`/`VAR_BUSTO`, when a fact is true in the real world) and **technical/transaction time** (`VAR_TXFROM`/`VAR_TXTO`, when the system knew it). **SCD2** = slowly changing dimension, type 2 (row-versioned history via open/close datetimes). **BUSKEY** = the business/primary key (space-separated columns, excluding temporal columns).
## MPE control tables
Configuration and state live in `MPE_*` tables (the "control tables") in the DC control library (`&mpelib` / `&dclib`). Key ones:
- `MPE_TABLES` - registered target tables and their load config (`LOADTYPE`, `BUSKEY`, temporal vars, etc.).
- `MPE_VALIDATIONS` - point-of-entry data-quality rules (see below).
- `MPE_ROW_LEVEL_SECURITY` - Row Level Security rules.
- `MPE_COLUMN_LEVEL_SECURITY` - Column Level Security rules.
- `MPE_SUBMIT` / `MPE_REVIEW` / `MPE_REQUESTS` - the approval workflow tables.
- `MPE_AUDIT` - change history.
- `MPE_XLMAP_INFO` / `MPE_XLMAP_RULES` / `MPE_XLMAP_DATA` - Excel-map (XLMAP) definitions for structured Excel uploads.
- `MPE_CONFIG`, `MPE_GROUPS`, `MPE_SECURITY`, `MPE_EMAIL`, `MPE_LOCKANYTABLE`, and the `MPE_DATACATALOG_*` / `MPE_DATASTATUS_*` catalog tables.
Selectbox seed values for these tables are defined in `sas/sasjs/macros/mpe_makedata.sas`. User-facing documentation for each table is in the `docs.datacontroller.io` repo under `docs/tables/`.
## Validations
`MPE_VALIDATIONS` applies point-of-entry rules per `BASE_LIB`/`BASE_DS`/`BASE_COL` when `RULE_ACTIVE=1`. `RULE_TYPE` values include `CASE`, `NOTNULL`, `MINVAL`, `MAXVAL`, `READONLY`, `HIDDEN`, `ROUND`, `NUMBER_FORMAT`, `HARDREGEX`, `SOFTREGEX`, `HARDSELECT`, `SOFTSELECT`, `HARDSELECT_HOOK`, `SOFTSELECT_HOOK`.
- **HARDREGEX** - submission-blocking regex; failing cells are painted red and cannot be submitted.
- **SOFTREGEX** - display-only warning regex; failing cells are painted yellow but submission is allowed.
Regex rule values are authored in SAS PRX form `/pattern/flags` and limited to 128 chars. See [.agents/docs/regex-validations.md](.agents/docs/regex-validations.md).
## Security
- **Row Level Security (RLS)** - server-side `WHERE`-clause generation via `%mpe_filtermaster` / `%mp_filtergenerate`; no data leaves SAS without passing the filter. Works on any engine because it's expressed as a standard SAS `WHERE`. On write, the *inverse* filter is applied to reject out-of-scope rows. See [.agents/docs/row-level-security.md](.agents/docs/row-level-security.md).
- **Column Level Security (CLS)** - restricts visibility/editability at column level.
## Key macros / services
- `%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`) - the single load dispatch point; routes by `LOADTYPE`. Two-phase: `LOADTARGET=NO` builds diff tables for review, `LOADTARGET=YES` performs the destructive load.
- `%bitemporal_dataloader` - the temporal loader for UPDATE / TXTEMPORAL / BITEMPORAL.
- `%mpe_filtermaster` - builds the RLS filter for a request.
## Testing
Backend tests run with the sasjs CLI from `sas/`. `npm run 4gl` compiles+deploys+seeds then `sasjs test -t 4gl` runs tests. Assertions use `%mp_assert()`. See [.agents/docs/testing.md](.agents/docs/testing.md).
## Conventions
- Do not hard-wrap Markdown (one logical line per paragraph/list-item/heading).
- Use regular dashes, not em-dashes.
- No external assets - everything must be bundled and served locally.
- Comments and test names must be self-contained (no "original bug", "this PR", etc.); the only exception is a literal issue-tracker link.
- Do not auto-commit or push.
- Never edit `CHANGELOG.md` or bump `package.json` version by hand - the release pipeline generates both from Conventional Commit messages. See [.agents/docs/releases-and-changelog.md](.agents/docs/releases-and-changelog.md).
See `AGENTS.md` for the full set of enforced rules (git, linting, no-wrap, no-external-assets, `.agents/` layout).
+104
View File
@@ -4,6 +4,21 @@
## Workflow guidelines
[Wiki Page](https://git.datacontroller.io/dc/dc/wiki/Git-Workflow)
## Git hooks
The repo ships its own git hooks in [`.git-hooks/`](./.git-hooks):
- `pre-commit` - scans staged changes for secrets with gitleaks, blocks commits that would add more than 2MB of new blobs, and runs the prettier check (auto-fixing on failure)
- `commit-msg` - verifies the commit message follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) standard
The repo `.npmrc` sets `ignore-scripts=true`, so the `prepare` and `postinstall` lifecycle scripts that would normally set `core.hooksPath` during `npm i` never run. After cloning (or if your commits are not being checked), activate the hooks with a one-time command, run from the repo root:
```bash
git config core.hooksPath ./.git-hooks
```
The pre-commit hook requires the gitleaks binary provided by the root `@nogoo9/gitleaks` devDependency, so make sure `npm i` has run in the repo root before your first commit.
## Dependencies that requires licences
[SheetJS Pro Version](https://www.npmjs.com/package/sheetjs)
@@ -16,6 +31,21 @@ Add `client/.npmrc` file with following content:
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
```
In addition to the registry auth, the `@sheet/crypto` package is installed from a local tarball
(`client/libraries/sheet-crypto.tgz`) rather than the registry. This tarball is not committed to
the repo directly - only an encrypted copy is: `client/libraries/sheet-crypto.tgz.gpg`.
Before running `npm i`/`npm ci` in `client`, you must decrypt it using the passphrase
(stored as the `SHEET_PWD` CI secret - ask a repo maintainer if you don't have it):
```bash
cd client
echo "YOUR_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
npm i
```
[Handsontable](https://www.npmjs.com/package/handsontable)
Licence should be inserted in the `client/src/index.html` file:
@@ -39,6 +69,71 @@ cd client
npm start
```
## Mocked backend (frontend development without SAS)
Data Controller ships a full set of JS mock services under `sas/mocks/sasjs/` that mimic the SAS backend using [SASjs Server](https://server.sasjs.io) in JS-only mode. This lets you run the entire frontend (editor grid, approvals, viewer, admin, Excel maps, etc.) without a SAS licence or SAS server. The Cypress E2E tests and Lighthouse CI both run against this setup.
### Prerequisites
- Node.js (same version used for the frontend build)
- The frontend dependencies installed (`cd client && npm ci` after decrypting sheet-crypto as described above)
### 1. Start SASjs Server
Download the latest Linux (or macOS/Windows) binary from [SASjs Server releases](https://github.com/sasjs/server/releases), unzip it, and create a `.env` file alongside the executable:
```
RUN_TIMES=js
NODE_PATH=node
CORS=enable
WHITELIST=http://localhost:4200
```
`RUN_TIMES=js` means the server executes `.js` files as Stored Programs (no SAS binary needed). `CORS=enable` plus `WHITELIST` allows the Angular dev server on port 4200 to call the mock services on port 5000. Start the server:
```
./api-linux # or api-macos / api-win.exe depending on your platform
```
The server runs in desktop mode (no auth) on `http://localhost:5000` by default.
### 2. Deploy the mock services and streaming frontend
From `sas/mocks/sasjs/`:
```
npm ci
sasjs cbd -t server-ci
```
This compiles, builds, and deploys the JS mock services plus the Angular frontend (as a streaming app) to the SASjs Server. The `server-ci` target in `sas/mocks/sasjs/sasjsconfig.json` points to `http://localhost:5000` with appLoc `/Public/app/dc` and stream service name `clickme`.
After deploy, the streamed app is available at: `http://localhost:5000/AppStream/clickme/`
### 3. Run the Angular dev server (optional, for hot reload)
For frontend development with live reload, point `client/src/index.html` at the local SASjs Server and start the dev server:
```
cd client
# Edit src/index.html: set serverUrl="http://localhost:5000", appLoc="/Public/app/dc", serverType="SASJS"
npm start
```
The app will be at `http://localhost:4200`. It calls the JS mock services on port 5000 via the SASjs adapter (CORS is enabled by the `.env` above).
### How the mocks work
Each `.js` file in `sas/mocks/sasjs/services/` corresponds to a real SAS service (e.g. `public/startupservice.js` mimics `services/public/startupservice.sas`). The JS runtime predeclares `fs` and `_webout`; the mock assigns a JSON string to `_webout` that matches the structure the real SAS service would return. Some mocks are stateful: they persist data to a `mock-storage/` folder on the SASjs Drive (e.g. `licence.json`, `users.json`, `filter.txt`) using the predeclared `fs` module.
The mock data includes sample tables (`DC996664.MPE_X_TEST`, `MPE_X_NEW`, `MPE_X_FORMULA_TEST`, etc.) with realistic columns, validation rules (HARDREGEX, SOFTREGEX, HARDSELECT, READONLY, HIDDEN, ROUND, NUMBER_FORMAT), and dropdown values. This is sufficient to exercise the full editor grid, approval workflow, viewer, and admin screens.
There is also a SAS 9 mock variant under `sas/mocks/sas9/` (target `sas9-mocks`) that mimics SAS 9 stored processes with the same JS approach but a different appLoc (`/User Folders/sasdemo/`).
### Re-deploying after changes
After changing mock services, re-run `sasjs cbd -t server-ci` from `sas/mocks/sasjs/`. After changing the frontend, rebuild (`cd client && npm run build`) then re-deploy so the streaming app picks up the new `client/dist/`. If you are using `ng serve` (step 3), only the mock services need re-deploying; the frontend hot-reloads automatically.
## GUI Elements
For documentation on the Clarity Design System, including a list of components and example usage, see [our website](https://vmware.github.io/clarity).
@@ -66,6 +161,15 @@ To remove the release, you need to do it with repo administration over at [https
# Troubleshooting
## `npm i` fails with ENOENT on `sheet-crypto.tgz`
If `npm i`/`npm ci` in `client` fails with:
```
npm error enoent ENOENT: no such file or directory, open '.../client/libraries/sheet-crypto.tgz'
```
it means the encrypted `sheet-crypto.tgz.gpg` hasn't been decrypted yet. See
[Dependencies that requires licences](#dependencies-that-requires-licences) above for how to decrypt it.
## Makedata service "could not create directory" error
The dcpath folder should have its permissions set so that the system account (SYSUSERID) can both read and write to it.
+26
View File
@@ -157,5 +157,31 @@
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}
+2 -1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
html: true,
json: false
},
viewportHeight: 900,
viewportWidth: 1600,
@@ -24,7 +25,7 @@ export default defineConfig({
serverType: 'SASJS',
libraryToOpenIncludes_SASVIYA: 'viya',
libraryToOpenIncludes_SAS9: 'dc',
libraryToOpenIncludes_SASJS: 'dc',
libraryToOpenIncludes_SASJS: 'dc_jslib',
debug: false,
screenshotOnRunFailure: false,
longerCommandTimeout: 50000,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -546,7 +546,7 @@ const checkResultOfFormulaUpload = (callback?: any) => {
.find('tbody')
.then((data) => {
const cell: any = data[0].children[0].children[5]
expect(cell.innerText).to.equal('=1+1')
expect(cell.innerText).to.equal('2')
if (callback) callback()
})
}
+24 -35
View File
@@ -25,7 +25,7 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_CHAR', 'this is dummy data', 'value', () => {
checkInfoBarIncludes(
`AND,AND,0,SOME_CHAR,=,"'this is dummy data'"`,
`(( SOME_CHAR = 'this is dummy data' ))`,
(includes: boolean) => {
if (includes) done()
}
@@ -39,7 +39,7 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_NUM', '42', 'value', () => {
checkInfoBarIncludes(`AND,AND,0,SOME_NUM,=,42`, (includes: boolean) => {
checkInfoBarIncludes(`(( SOME_NUM = 42 ))`, (includes: boolean) => {
if (includes) done()
})
})
@@ -51,12 +51,9 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_TIME', '00:00:42', 'time', () => {
checkInfoBarIncludes(
`AND,AND,0,SOME_TIME,=,42`,
(includes: boolean) => {
if (includes) done()
}
)
checkInfoBarIncludes(`(( SOME_TIME = 42 ))`, (includes: boolean) => {
if (includes) done()
})
})
})
})
@@ -66,12 +63,9 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_TIME', '42', 'value', () => {
checkInfoBarIncludes(
`AND,AND,0,SOME_TIME,=,42`,
(includes: boolean) => {
if (includes) done()
}
)
checkInfoBarIncludes(`(( SOME_TIME = 42 ))`, (includes: boolean) => {
if (includes) done()
})
})
}, false)
})
@@ -81,12 +75,9 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_DATE', '12/02/1960', 'date', () => {
checkInfoBarIncludes(
`AND,AND,0,SOME_DATE,=,42`,
(includes: boolean) => {
if (includes) done()
}
)
checkInfoBarIncludes(`(( SOME_DATE = 42 ))`, (includes: boolean) => {
if (includes) done()
})
})
})
})
@@ -96,12 +87,9 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_DATE', '42', 'value', () => {
checkInfoBarIncludes(
`AND,AND,0,SOME_DATE,=,42`,
(includes: boolean) => {
if (includes) done()
}
)
checkInfoBarIncludes(`(( SOME_DATE = 42 ))`, (includes: boolean) => {
if (includes) done()
})
})
}, false)
})
@@ -116,7 +104,7 @@ context('filtering tests: ', function () {
'datetime',
() => {
checkInfoBarIncludes(
`AND,AND,0,SOME_DATETIME,=,42`,
`(( SOME_DATETIME = 42 ))`,
(includes: boolean) => {
if (includes) done()
}
@@ -132,7 +120,7 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_DATETIME', '42', 'value', () => {
checkInfoBarIncludes(
`AND,AND,0,SOME_DATETIME,=,42`,
`(( SOME_DATETIME = 42 ))`,
(includes: boolean) => {
if (includes) done()
}
@@ -146,12 +134,9 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_DATE', '', 'in', () => {
checkInfoBarIncludes(
`AND,AND,0,SOME_DATE,IN,(0)`,
(includes: boolean) => {
if (includes) done()
}
)
checkInfoBarIncludes(`(( SOME_DATE IN (42) ))`, (includes: boolean) => {
if (includes) done()
})
})
})
})
@@ -311,7 +296,11 @@ const setFilterWithValue = (
})
})
break
// The IN clause is submitted by the modal footer button above; the
// trailing tab/click sequence below is for the plain value fields and
// must not run here (focus is gone once the modal closes and the
// editor reloads with the stored filter).
return
}
case 'between': {
cy.focused().tab()
+373 -1
View File
@@ -244,6 +244,294 @@ context('licensing tests: ', function () {
})
})
it('6 | pasting an HTTP-format key (identical licence/activation text) on this secure connection warns before applying', (done) => {
visitPage('licensing/update')
cy.get('button').contains('Paste licence').click()
// Defaults to the combined single-field form - switch to the legacy
// two-field layout, since this test targets that path specifically.
cy.get('button').contains('Paste as two separate keys instead').click()
// HTTP-format keys are generated as the exact same string for both
// fields (see detectLicenceKeyProtocolMismatch's own doc comment) -
// any non-empty matching pair triggers the warning, no real key needed.
// Cypress always runs against localhost, which browsers treat as a
// secure connection even over plain http, so this is reachable without
// stubbing anything - unlike the reverse case (an HTTPS-format key
// where WebCrypto is unavailable), which is covered by
// detectLicenceKeyProtocolMismatch.spec.ts's Karma tests instead.
const httpFormatKey = 'same-text-for-both-fields'
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', httpFormatKey)
.trigger('input')
cy.get('.activation-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', httpFormatKey)
.trigger('input')
cy.get('p.protocol-mismatch-warning')
.should('contain', 'generated for an insecure (HTTP) connection')
.then(() => {
done()
})
})
it('7 | Combined single-string key (default paste format) activates successfully', (done) => {
let keyData = {
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
users_allowed: 4,
hot_license_key: '',
demo: false,
site_id: site_id
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - earlier tests may have left the app on it (a
// bad key) or already activated (a good one), and this test should
// pass either way rather than silently no-op when it's the latter.
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
inputCombinedKeyPage(combinedKey)
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then(() => {
done()
})
})
}
)
if (!result) {
visitPage('licensing/update')
cy.wait(2000)
}
// proceed() generates the combined key asynchronously, then
// enqueues the actual page commands only once its promise
// resolves. Bridged through cy.then() so the command chain stays
// open across that async gap: a bare proceed() call returns a
// promise that nothing awaits, and once the surrounding queue
// has drained, commands enqueued from that promise's continuation
// are never run.
cy.then(() => proceed())
})
})
})
it('8 | Pasting a combined key into the legacy licence-key field alone still auto-detects and activates', (done) => {
let keyData = {
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
users_allowed: 4,
hot_license_key: '',
demo: false,
site_id: site_id
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why.
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.get('button').contains('Paste licence').click()
cy.get('button')
.contains('Paste as two separate keys instead')
.click()
// Only the licence-key field gets the combined string - the
// activation-key field is left empty, proving the split
// populates both fields from this one paste.
cy.get('.license-key-form textarea', {
timeout: longerCommandTimeout
})
.invoke('val', combinedKey)
.trigger('input')
.trigger('mouseleave')
cy.get('.activation-key-form textarea', {
timeout: longerCommandTimeout
}).should(($textarea) => {
expect($textarea.val()).to.equal(keysGen.activationKey)
})
cy.get('button.apply-keys').click()
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then(() => {
done()
})
})
}
)
// See test 7's own comment for why proceed() is bridged through
// cy.then() rather than called as a plain statement.
if (!result) {
visitPage('licensing/update')
cy.wait(2000)
}
cy.then(() => proceed())
})
})
})
it('9 | Uploading a single-line combined-format file activates successfully', (done) => {
let keyData = {
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
users_allowed: 4,
hot_license_key: '',
demo: false,
site_id: site_id
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why.
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.get('input[type="file"]').attachFile({
fileContent: new Blob([combinedKey], { type: 'text/plain' }),
fileName: 'datacontroller-licence-combined.txt',
mimeType: 'text/plain'
})
cy.get('button.apply-keys', {
timeout: longerCommandTimeout
}).click()
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then(() => {
done()
})
})
}
)
// See test 7's own comment for why proceed() is bridged through
// cy.then() rather than called as a plain statement.
if (!result) {
visitPage('licensing/update')
cy.wait(2000)
}
cy.then(() => proceed())
})
})
})
it('10 | Key details preview shows for a validly-pasted key and disappears once the key input is cleared', (done) => {
let keyData = {
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
users_allowed: 4,
hot_license_key: '',
demo: false,
site_id: site_id,
// Kept to just the two enabled toggles (relying on
// decodeLicenceFeatures' fallback for the rest) to keep the
// encrypted payload well under this suite's RSA-OAEP plaintext
// ceiling - see generateKeys' modulusLength below.
features: { vb: true, fu: true }
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why.
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.get('button').contains('Paste licence').click()
cy.get('.combined-key-form textarea', {
timeout: longerCommandTimeout
})
.invoke('val', combinedKey)
.trigger('input')
.trigger('mouseleave')
cy.get('.key-details')
.should('contain', 'Valid until:')
.and('contain', 'Allowed users:')
.and('contain', '4')
.and('contain', 'Site ID(s) in this key:')
.and('contain', 'Enabled features:')
.and('contain', 'Viewbox')
.and('contain', 'File Upload')
.invoke('text')
.then((text) => {
expect(text).to.not.contain('Edit Record')
expect(text).to.not.contain('Add Record')
})
cy.get('.combined-key-form textarea')
.invoke('val', '')
.trigger('input')
.trigger('mouseleave')
cy.get('.key-details')
.should('not.exist')
.then(() => {
done()
})
}
)
// See test 7's own comment for why this is chained via .then()
// rather than called as the next plain statement.
if (!result) {
visitPage('licensing/update')
cy.wait(2000).then(() => proceed())
} else {
proceed()
}
})
})
})
if (testLicenceUserLimits) {
it('4 | User try to register when limit is reached', (done) => {
let keyData = {
@@ -417,7 +705,7 @@ const verifyLicensingPage = (text: string, callback: any) => {
cy.wait(1000)
isLicensingPage((result: boolean) => {
if (result) {
cy.get('p.key-error')
cy.get('.key-error')
.should('contain', text)
.then((treeNodes: any) => {
callback(true)
@@ -441,6 +729,10 @@ const verifyLicensingWarning = (text: string, callback: any) => {
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button').contains('Paste licence').click()
// Defaults to the combined single-field form - this helper exercises the
// two-part path specifically, so switch to it first.
cy.get('button').contains('Paste as two separate keys instead').click()
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', licenseKey)
.trigger('input')
@@ -452,6 +744,30 @@ const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button.apply-keys').click()
}
const inputCombinedKeyPage = (combinedKey: string) => {
cy.get('button').contains('Paste licence').click()
// Combined is the default paste format - no toggle click needed here,
// unlike inputLicenseKeyPage's legacy two-field path.
cy.get('.combined-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', combinedKey)
.trigger('input')
.trigger('mouseleave')
.should('not.be.undefined')
// mouseleave's handler (onCombinedKeyInput) does async work - gzip
// decompress the split, then decrypt for the key-details preview -
// before licenceKeyValue/activationKeyValue are ready to submit.
// Wait for that preview to land rather than racing "Apply licence
// keys" against the still-in-flight promise.
cy.get('.key-details', { timeout: longerCommandTimeout }).should(
'contain',
'Valid until:'
)
cy.get('button.apply-keys').click()
}
const updateUsersTable = (options: any, callback?: any) => {
visitPage('home')
openTableFromTree(libraryToOpenIncludes, 'mpe_users')
@@ -596,6 +912,62 @@ const generateKeys = async (licenseData: any, resultCallback?: any) => {
})
}
// Mirrors dckey's own encodeCombinedKey()/gzipCompress() (main.js) and
// DC's own splitCombinedLicenceKey() decode side, so this stays a genuine
// end-to-end check of the real format rather than a fixture the app
// happens to accept.
const COMBINED_KEY_PREFIX = 'DCKEY1:'
// Reads a ReadableStream directly via its own reader, rather than through
// `new Response(readable).arrayBuffer()` - Cypress patches fetch/Response
// globally for its network-interception features, which appears to hang
// a Response built locally around a stream that never touches the network
// (writer.write()/close() resolve fine; only the Response-based read never
// settles).
const streamToArrayBuffer = async (
readable: ReadableStream<Uint8Array>
): Promise<ArrayBuffer> => {
const reader = readable.getReader()
const chunks: Uint8Array[] = []
let totalLength = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
totalLength += value.length
}
const result = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.length
}
return result.buffer
}
const generateCombinedKey = async (
licenseKey: string,
activationKey: string
): Promise<string> => {
const payloadBytes = new TextEncoder().encode(
`${licenseKey} ${activationKey}`
)
const compressionStream = new CompressionStream('gzip')
const writer = compressionStream.writable.getWriter()
const [, compressedBuffer] = await Promise.all([
writer.write(payloadBytes).then(() => writer.close()),
streamToArrayBuffer(compressionStream.readable)
])
const compressedBase64 = await arrayBufferToBase64(compressedBuffer)
return COMBINED_KEY_PREFIX + compressedBase64
}
const editTableField = (edits: EditConfigTableCells[], callback?: any) => {
cy.get('td').then((tdNodes: any) => {
for (let edit of edits) {
+32
View File
@@ -36,6 +36,38 @@ context('liveness tests: ', function () {
})
})
// Multiple concurrent DC instances can cause the startupservice request
// to time out even though a retry succeeds. Fail only the first
// startupservice call (any later ones, e.g. from other tests/reloads,
// pass through untouched) and confirm the app still reaches the nav tree
// instead of showing the startup error modal.
it('2 | Recovers from a failed startupservice request via the single retry', () => {
let startupServiceCallCount = 0
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
const isStartupService = JSON.stringify(req.body || '').includes(
'startupservice'
)
if (isStartupService) startupServiceCallCount++
if (isStartupService && startupServiceCallCount === 1) {
req.reply({ statusCode: 503, body: 'Service unavailable' })
} else {
req.continue()
}
}).as('stpExecute')
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
// Longer than the retry's own 0-3s pause on top of the normal timeout.
timeout: longerCommandTimeout + 3000
}).should('exist')
cy.get('.abortMsg').should('not.exist')
})
/**
* Thist part will be needed if we add more tests in future
*/
+167
View File
@@ -0,0 +1,167 @@
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
const fixturePath = 'excels/'
context('stage tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
it('1 | Formatted/Unformatted toggle switches between fmt_stagetable and stagetable, defaulting to formatted', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
attachExcelFile('regular_excel.xlsx', () => {
submitExcel()
// The app navigates to /stage/{tableId}.
cy.url({ timeout: longerCommandTimeout }).should('include', '/stage/')
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
// Toggle to unformatted first to read the raw SOME_DATE value.
cy.get('.formatted-values-toggle').click()
getCellByHeaderAndRow(0, 'SOME_DATE')
.invoke('text')
.then((rawText) => {
const rawDate = parseInt(rawText, 10)
// Toggle back to formatted view.
cy.get('.formatted-values-toggle').click()
// Compute the expected SAS date9. format from the raw value.
const sasEpoch = new Date(Date.UTC(1960, 0, 1))
const formatted = new Date(sasEpoch.getTime() + rawDate * 86400000)
const months = [
'JAN',
'FEB',
'MAR',
'APR',
'MAY',
'JUN',
'JUL',
'AUG',
'SEP',
'OCT',
'NOV',
'DEC'
]
const expected =
String(formatted.getUTCDate()).padStart(2, '0') +
months[formatted.getUTCMonth()] +
formatted.getUTCFullYear()
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', expected)
// Toggle to unformatted again to verify round-trip.
cy.get('.formatted-values-toggle').click()
getCellByHeaderAndRow(0, 'SOME_DATE').should(
'have.text',
String(rawDate)
)
})
})
})
})
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container > button').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
const attachExcelFile = (excelFilename: string, callback?: any) => {
cy.get('.buttonBar button:last-child')
.should('exist')
.click()
.then(() => {
cy.get('input[type="file"]#file-upload')
.attachFile(`/${fixturePath}/${excelFilename}`)
.then(() => {
cy.get('.clr-abort-modal .modal-title').then((modalTitle) => {
if (!modalTitle[0].innerHTML.includes('Abort Message')) {
cy.get('.modal-footer .btn.btn-primary').then((modalBtn) => {
modalBtn.click()
if (callback) callback()
})
} else {
if (callback) callback()
}
})
})
})
}
const submitExcel = (callback?: any) => {
cy.get('.buttonBar button.preview-submit', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
// Locates a body cell by its column's header text rather than a hardcoded
// childNodes index - same helper as editor.cy.ts's own.
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
return cy
.get('.ht_clone_top .htCore thead tr th')
.should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).to.include(headerText)
})
.then(($ths) => {
const index = [...$ths].findIndex(
(th) => th.innerText.trim() === headerText
)
return cy
.get('.ht_master tbody tr')
.then((rows: any) => rows[rowIndex].childNodes[index])
.then((cell) => cy.get(cell))
})
}
@@ -0,0 +1,73 @@
// Marks this file as an ES module (rather than a global script) so its
// top-level consts don't collide, under the TS type-checker, with the same
// names declared in other spec files — see e.g. stage.cy.ts, which gets
// this for free via a real import.
export {}
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
context('submitted-details tests: ', function () {
this.beforeAll(() => {
cy.loginAndUpdateValidKey(true)
})
it('1 | clicking an open submit loads its diffs with a single postdata request', () => {
// Count every STP request the app makes during the test.
// The STP URL is `stp/execute/?_program=...` - glob segments don't match
// past `execute/` with a single `*`, so keep the glob within the path.
cy.intercept('POST', '**/SASjsApi/stp/**').as('stpExecute')
cy.visit(`${hostUrl}${appLocation}/#/review/submitted`)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
// the SUBMIT queue list must be rendered with at least one row
cy.get('app-submitter clr-datagrid clr-dg-row', {
timeout: longerCommandTimeout
})
.should('exist')
.and('have.length.greaterThan', 0)
// route-based flow: clicking a row navigates to /review/submitted/:tableId
// and triggers exactly one SHOW_DIFFS (auditors/postdata) request
cy.get('app-submitter clr-datagrid clr-dg-row')
.first()
.click()
.then(() => {
cy.url().should('include', '/review/submitted/')
})
// wait for the diff table to render (the response was applied)
cy.get('app-approve-details .card', { timeout: longerCommandTimeout })
.should('exist')
.should('be.visible')
// give any (incorrect) duplicate request time to surface
cy.wait(3000)
cy.get('@stpExecute.all').then((allRequests: any) => {
// the adapter posts multipart form data, so match on the _program
// query param rather than the request body
const postdataRequests = allRequests.filter((req: any) =>
JSON.stringify(req.request.query || {}).includes('auditors/postdata')
)
expect(
postdataRequests.length,
'exactly one auditors/postdata SHOW_DIFFS request'
).to.equal(1)
// the submit queue was already fetched to render the list - opening a
// row must not fetch it again
const getsubmitsRequests = allRequests.filter((req: any) =>
JSON.stringify(req.request.query || {}).includes('editors/getsubmits')
)
expect(
getsubmitsRequests.length,
'no editors/getsubmits request after opening a submit'
).to.equal(1)
})
})
})
+572 -238
View File
@@ -41,16 +41,13 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.get('.open-viewbox').then((viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
return
}
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
checkColumns(columns, () => {
done()
})
}
checkColumns(columns, () => {
done()
})
})
@@ -78,48 +75,21 @@ context('editor tests: ', function () {
libraryToOpenIncludes,
viewboxes.map((viewbox) => viewbox.viewbox_table)
)
cy.get('.open-viewbox').then((viewboxNodes: any) => {
let found = 0
viewboxes.forEach((viewbox) => {
cy.contains('.open-viewbox', viewbox.viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
})
for (let viewboxNode of viewboxNodes) {
for (let viewbox of viewboxes) {
if (
viewboxNode.innerText.toLowerCase().includes(viewbox.viewbox_table)
)
found++
}
}
let result = 0
if (found < viewboxes.length) return
viewboxes.forEach((viewbox) => {
checkColumns(viewbox.columns, () => {
result++
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
(viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
cy.get(viewboxNode).within(() => {
cy.get('.table-title').then((tableTitle) => {
const title = tableTitle[0].innerText
const viewbox = viewboxes.find((vb) =>
title.toLowerCase().includes(vb.viewbox_table)
)
if (viewbox) {
cy.get('.ht_master.handsontable .htCore thead tr').then(
(viewboxColNodes: any) => {
let allColsHtml = viewboxColNodes[0].innerHTML
for (let col of viewbox?.columns) {
if (!allColsHtml.includes(col)) return
}
done()
}
)
}
})
})
}
}
)
if (result === viewboxes.length) done()
})
})
})
@@ -133,22 +103,19 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.get('.open-viewbox').then((viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
return
}
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
openViewboxConfig(viewbox_table)
openViewboxConfig(viewbox_table)
removeAllColumns()
removeAllColumns()
addColumns(additionalColumns)
addColumns(additionalColumns)
checkColumns([...columns, ...additionalColumns], () => {
done()
})
}
checkColumns([...columns, ...additionalColumns], () => {
done()
})
})
@@ -162,35 +129,32 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.get('.open-viewbox').then((viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
return
}
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
openViewboxConfig(viewbox_table)
openViewboxConfig(viewbox_table)
removeAllColumns()
removeAllColumns()
addColumns(additionalColumns, () => {
cy.wait(1000)
//reorder
cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' })
.realMouseUp()
//reorder end
addColumns(additionalColumns, () => {
cy.wait(1000)
//reorder
cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' })
.realMouseUp()
//reorder end
cy.wait(500)
cy.wait(500)
checkColumns([...columns, ...additionalColumns.reverse()], () => {
done()
})
})
}
checkColumns([...columns, ...additionalColumns.reverse()], () => {
done()
})
})
})
@@ -204,53 +168,47 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.get('.open-viewbox').then((viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
return
}
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
viewboxNode.click()
openViewboxConfig(viewbox_table)
removeAllColumns()
removeAllColumns()
addColumns(additionalColumns, () => {
cy.wait(1000)
//reorder
cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' })
.realMouseUp()
//reorder end
addColumns(additionalColumns, () => {
cy.wait(1000)
//reorder
cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' })
.realMouseUp()
//reorder end
cy.wait(500)
cy.wait(500)
checkColumns([...columns, ...additionalColumns.reverse()], () => {
const colToRemove = 'MOVE_TYPE'
checkColumns([...columns, ...additionalColumns.reverse()], () => {
const colToRemove = 'MOVE_TYPE'
removeColumn(colToRemove)
checkColumns(
[
...columns,
...additionalColumns.filter((col) => col !== colToRemove)
],
() => {
addColumns([colToRemove], () => {
checkColumns(
[...columns, ...additionalColumns.reverse()],
() => {
done()
}
)
})
}
)
})
})
}
removeColumn(colToRemove)
checkColumns(
[
...columns,
...additionalColumns.filter((col) => col !== colToRemove)
],
() => {
addColumns([colToRemove], () => {
checkColumns([...columns, ...additionalColumns.reverse()], () => {
done()
})
})
}
)
})
})
})
@@ -333,68 +291,408 @@ context('editor tests: ', function () {
})
})
// We will enable this test when we figure out how to mock filtering
// it('7 | Add viewboxes and filter', () => {
// const viewboxes = ['mpe_x_test', 'mpe_validations']
// The base VIEW table and a viewbox on a different table must never
// share a filter dialog's cached state - each keeps its own entry in
// the globals filter cache (getFilterObjPath), keyed by viewboxId where
// one is present. Opened from the VIEW page specifically
// (rootParam === 'view') - the one branch where this previously broke,
// since a 'view'-page QueryComponent was always treated as the base
// table regardless of viewboxId.
it('7 | Base table filter and viewbox filter stay isolated from each other', () => {
const viewbox_table = 'mpe_audit'
// openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
visitPage('view/data')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// cy.get('.viewbox-open').click()
// openTableFromViewboxTree(libraryToOpenIncludes, viewboxes)
// Filter the base table.
openViewFilter()
setFilterWithValue('SOME_CHAR', 'this is dummy data', 'value', () => {
cy.get('app-query', { withinSubject: null }).should('not.exist')
// cy.wait(1000)
// Open a viewbox on a *different* table and check its filter dialog
// does not show the base table's filter.
openViewViewboxes()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
closeViewboxModal()
// closeViewboxModal()
// cy.contains retries until the viewbox's title actually shows the
// expected table name (it can render before its data/title loads),
// then .parents() walks back up to its container - avoids a
// one-shot .then() racing the title's own async update, which is
// what made this flaky in the first place.
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
.parents('.viewbox')
.first()
.within(() => {
cy.get('clr-icon[shape="filter"]').click()
// cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
// (viewboxNodes: any) => {
// for (let viewboxNode of viewboxNodes) {
// cy.get(viewboxNode).within(() => {
// cy.get('.table-title').then((title: any) => {
// cy.get('.hot-spinner')
// .should('not.exist')
// .then(() => {
// cy.get('clr-icon[shape="filter"]').then((filterButton) => {
// filterButton[0].click()
// })
cy.get('.filter-modal code.language-sql', {
withinSubject: null
}).should(($code) => {
expect($code.text()).to.not.contain('SOME_CHAR')
})
// if (title[0].innerText.includes('MPE_X_TEST')) {
// setFilterWithValue(
// 'SOME_CHAR',
// 'this is dummy data',
// 'value',
// () => {
// cy.get('app-query', { withinSubject: null })
// .should('not.exist')
// .get('.ht_master.handsontable tbody tr')
// .then((rowNodes) => {
// const tr = rowNodes[0]
// Reverse direction: filter the viewbox, then confirm
// re-opening the base table's own filter dialog still shows
// the base table's filter, not the viewbox's.
setFilterWithValue('LIBREF', 'sasdemo', 'value', () => {
cy.get('app-query', { withinSubject: null }).should('not.exist')
// expect(rowNodes).to.have.length(1)
// expect(tr.innerText).to.equal('0')
// })
// }
// )
// } else if (title[0].innerText.includes('MPE_VALIDATIONS')) {
// setFilterWithValue('BASE_COL', 'ALERT_LIB', 'value', () => {
// cy.get('app-query', { withinSubject: null })
// .should('not.exist')
// .get('.ht_master.handsontable tbody tr')
// .then((rowNodes) => {
// const tr = rowNodes[0]
openViewFilter()
// expect(rowNodes).to.have.length(1)
// expect(tr.innerText).to.contain('ALERT_LIB')
// })
// })
// }
// })
// })
// })
// }
// }
// )
// })
cy.get('.filter-modal code.language-sql', {
withinSubject: null
}).should(($code) => {
expect($code.text()).to.contain('SOME_CHAR')
expect($code.text()).to.not.contain('LIBREF')
})
})
})
})
})
it('8 | Dragging a viewbox by its corner handle resizes it', () => {
const viewbox_table = 'mpe_audit'
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
// The Viewboxes picker modal is still open at this point, its backdrop
// sitting above .viewboxes-container - a real click (unlike cy.find(),
// which doesn't care what's visually on top) would land on the modal
// instead of the handle underneath it, silently no-opping the drag.
closeViewboxModal()
// Re-query by the title text rather than caching a reference, since
// the viewbox can still be re-rendering its content right after
// opening.
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
.parents('.viewbox')
.first()
.as('viewboxEl')
// tableOnClick() calls snapToGrid() asynchronously right after its data
// load resolves, which overwrites width/height/x/y for every open
// viewbox via the [style.width.px]/[style.height.px] bindings - totally
// independent of any drag. The table title above renders immediately
// (before that data load finishes), so capturing initialRect right
// after it appears races snapToGrid: the box can still silently resize
// itself out from under the test between capturing initialRect and the
// drag. Waiting for the table's own header cells (populated only once
// that same data load - and so snapToGrid - has completed) closes that
// race, same signal checkColumns() uses elsewhere in this file.
cy.get('@viewboxEl').find('.ht_master.handsontable thead tr th', {
timeout: longerCommandTimeout
})
cy.get('@viewboxEl').then(($box) => {
const initialRect = $box[0].getBoundingClientRect()
// Chained off one query, not re-queried between each step, so
// cypress-real-events keeps operating on the same subject throughout.
//
// Dragging INWARD (negative offsets, shrinking) rather than outward:
// a freshly opened viewbox starts flush against the right edge of the
// viewport (x: window.innerWidth - defaultConfig.width, so
// box.right === innerWidth exactly). realMouseMove's target
// coordinate is computed as an absolute point on the page, so dragging
// the corner outward/rightward to grow the box would target a point
// past the viewport's right edge, outside the Cypress AUT iframe
// entirely - Chrome never delivers a move event for a point outside
// the iframe. Shrinking stays well within the box's own footprint
// (500x300 default, 200x200 min per .viewbox's CSS), so the target
// coordinate is always safely on-screen - same resize() code path,
// just exercised in the direction that's actually simulatable.
cy.get('@viewboxEl')
.find('.dragHandle.corner')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(-100, -80, { position: 'topLeft' })
.realMouseUp()
// A generous threshold, not an exact pixel match - real rendering has
// its own small offsets (see setHandleTransform's "fine tune" +5).
// The point is confirming the box actually resizes at all in response
// to the drag.
cy.get('@viewboxEl').should(($box) => {
const rect = $box[0].getBoundingClientRect()
expect(rect.width, 'width after resize').to.be.lessThan(
initialRect.width - 30
)
expect(rect.height, 'height after resize').to.be.lessThan(
initialRect.height - 30
)
})
})
})
it('9 | Dragging a viewbox by its right-edge handle resizes width only', () => {
const viewbox_table = 'mpe_audit'
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
closeViewboxModal()
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
.parents('.viewbox')
.first()
.as('viewboxEl')
// See test 8's comment: snapToGrid() resizes every open viewbox
// asynchronously right after its data load resolves, racing initialRect
// capture if that happens right after the title (which renders before
// the load finishes) appears. Wait for the table's own header cells
// first, since those only populate once that same load has completed.
cy.get('@viewboxEl').find('.ht_master.handsontable thead tr th', {
timeout: longerCommandTimeout
})
cy.get('@viewboxEl').then(($box) => {
const initialRect = $box[0].getBoundingClientRect()
// Chained off one query - see test 8's comment on why re-querying
// between down/move/up silently drops the move. Dragging INWARD
// (negative offset, shrinking) rather than outward - see test 8's
// comment: a freshly opened viewbox starts flush against the
// viewport's right edge, so growing rightward would target a point
// past the AUT iframe's edge, which Chrome never delivers a move
// event for.
cy.get('@viewboxEl')
.find('.dragHandle.right')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(-100, 0, { position: 'topLeft' })
.realMouseUp()
cy.get('@viewboxEl').should(($box) => {
const rect = $box[0].getBoundingClientRect()
expect(rect.width, 'width after resize').to.be.lessThan(
initialRect.width - 30
)
// This handle must only affect width - height (and the box's own
// left edge) stay put.
expect(rect.height, 'height unchanged').to.be.closeTo(
initialRect.height,
5
)
expect(rect.left, 'left edge unchanged').to.be.closeTo(
initialRect.left,
5
)
})
})
})
it('10 | Dragging a viewbox by its bottom-left corner handle resizes it and moves its left edge', () => {
const viewbox_table = 'mpe_audit'
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
closeViewboxModal()
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
.parents('.viewbox')
.first()
.as('viewboxEl')
// See test 8's comment: wait for snapToGrid()'s async resize to settle
// before capturing initialRect, or the box can silently change size out
// from under the test.
cy.get('@viewboxEl').find('.ht_master.handsontable thead tr th', {
timeout: longerCommandTimeout
})
cy.get('@viewboxEl').then(($box) => {
const initialRect = $box[0].getBoundingClientRect()
// Chained off one query - see test 8's comment on why re-querying
// between down/move/up silently drops the move. Dragging this
// handle right+down (only positive offsets are meaningful for
// realMouseMove's position-anchored coordinates) shrinks width
// while moving the box's own left edge right by the same amount,
// and grows height - unlike every other handle here, this one is
// expected to move the box (viewbox.x) as well as resize it, which
// is the more error-prone half of this handler to cover.
cy.get('@viewboxEl')
.find('.dragHandle.corner-left')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(60, 80, { position: 'topLeft' })
.realMouseUp()
cy.get('@viewboxEl').should(($box) => {
const rect = $box[0].getBoundingClientRect()
expect(rect.width, 'width after resize').to.be.lessThan(
initialRect.width - 30
)
expect(rect.left, 'left edge moved right').to.be.greaterThan(
initialRect.left + 30
)
expect(rect.height, 'height after resize').to.be.greaterThan(
initialRect.height + 30
)
})
})
})
it('11 | Dragging a viewbox by its left-edge handle resizes width and moves its left edge, leaving height alone', () => {
const viewbox_table = 'mpe_audit'
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
closeViewboxModal()
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
.parents('.viewbox')
.first()
.as('viewboxEl')
// See test 8's comment: wait for snapToGrid()'s async resize to settle
// before capturing initialRect, or the box can silently change size out
// from under the test.
cy.get('@viewboxEl').find('.ht_master.handsontable thead tr th', {
timeout: longerCommandTimeout
})
cy.get('@viewboxEl').then(($box) => {
const initialRect = $box[0].getBoundingClientRect()
// Chained off one query - see test 8's comment on why re-querying
// between down/move/up silently drops the move. Dragging this
// handle right (only positive offsets are meaningful for
// realMouseMove's position-anchored coordinates) shrinks width
// while moving the box's own left edge right by the same amount -
// same viewbox.x-moving behavior as the bottom-left corner, minus
// the height change.
cy.get('@viewboxEl')
.find('.dragHandle.left')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(60, 0, { position: 'topLeft' })
.realMouseUp()
cy.get('@viewboxEl').should(($box) => {
const rect = $box[0].getBoundingClientRect()
expect(rect.width, 'width after resize').to.be.lessThan(
initialRect.width - 30
)
expect(rect.left, 'left edge moved right').to.be.greaterThan(
initialRect.left + 30
)
expect(rect.height, 'height unchanged').to.be.closeTo(
initialRect.height,
5
)
})
})
})
it('12 | Dragging a viewbox by its bottom-edge handle resizes height only', () => {
const viewbox_table = 'mpe_audit'
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
closeViewboxModal()
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
.parents('.viewbox')
.first()
.as('viewboxEl')
// See test 8's comment: wait for snapToGrid()'s async resize to settle
// before capturing initialRect, or the box can silently change size out
// from under the test.
cy.get('@viewboxEl').find('.ht_master.handsontable thead tr th', {
timeout: longerCommandTimeout
})
cy.get('@viewboxEl').then(($box) => {
const initialRect = $box[0].getBoundingClientRect()
// Chained off one query - see test 8's comment on why re-querying
// between down/move/up silently drops the move.
cy.get('@viewboxEl')
.find('.dragHandle.bottom')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 100, { position: 'topLeft' })
.realMouseUp()
cy.get('@viewboxEl').should(($box) => {
const rect = $box[0].getBoundingClientRect()
expect(rect.height, 'height after resize').to.be.greaterThan(
initialRect.height + 30
)
// This handle must only affect height - width (and the box's own
// left edge) stay put.
expect(rect.width, 'width unchanged').to.be.closeTo(
initialRect.width,
5
)
expect(rect.left, 'left edge unchanged').to.be.closeTo(
initialRect.left,
5
)
})
})
})
})
const removeAllColumns = () => {
@@ -407,32 +705,40 @@ const removeAllColumns = () => {
)
}
// Asserts that at least one open viewbox currently shows exactly this
// column list, in order. Uses a single .should() callback (synchronous
// jQuery reads only, no nested cy.get()/.then()) so Cypress retries the
// whole check against a fresh DOM snapshot until it passes or times out -
// unlike .then(), which only ever sees one snapshot and, combined with an
// early return on a mismatch, would silently never invoke the caller's
// callback while Handsontable/the viewbox's data are still loading or
// re-rendering asynchronously.
const checkColumns = (columns: string[], callback: () => void) => {
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
(viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
cy.get(viewboxNode).within(() => {
cy.get('.ht_master.handsontable thead tr th').then(
(viewboxColNodes: any) => {
console.log('viewboxColNode', viewboxColNodes)
console.log('columns', columns)
for (let i = 0; i < viewboxColNodes.length; i++) {
const col = columns[i] || ''
const colNode = viewboxColNodes[i]
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).should(
($viewboxNodes) => {
const matches = $viewboxNodes.toArray().some((viewboxNode) => {
// rowHeaders: true (viewboxes.component.ts) adds a leading, unlabeled
// corner <th> before the actual data columns - skip it, or every
// comparison is off by one against the row-header cell instead of
// the first real column.
const headerCells = Cypress.$(viewboxNode)
.find('.ht_master.handsontable thead tr th')
.toArray()
.slice(1)
if (
!colNode.innerHTML.toLowerCase().includes(col.toLowerCase())
)
return
}
return columns.every((col, i) =>
(headerCells[i]?.innerHTML || '')
.toLowerCase()
.includes(col.toLowerCase())
)
})
callback()
}
)
})
}
expect(matches, `a viewbox showing columns: ${columns.join(', ')}`).to.be
.true
}
)
cy.then(() => callback())
}
const closeViewboxModal = () => {
@@ -444,9 +750,7 @@ const removeColumn = (column: string) => {
}
const addColumns = (columns: string[], callback?: () => void) => {
for (let i = 0; i < columns.length; i++) {
const column = columns[i]
for (const column of columns) {
cy.get('.cols-search input', { withinSubject: null }).type(column)
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
.first()
@@ -454,51 +758,81 @@ const addColumns = (columns: string[], callback?: () => void) => {
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
.first()
.trigger('keydown', { key: 'Enter' })
.then(() => {
if (i === columns.length - 1 && callback) callback()
})
// Wait for the actual column box to render before moving on to the
// next column (or firing the callback) - selecting it doesn't add it
// to the DOM synchronously, and cy.get()'s own retry covers that gap
// instead of relying on a fixed cy.wait().
cy.get(`.col-box.column-${column}`, { timeout: longerCommandTimeout })
}
if (callback) cy.then(() => callback())
}
// The VIEW page's "options" dropdown (viewer.component.html) - distinct
// from the editor page's dedicated .viewbox-open button used by the
// tests above, since test 7 runs on the VIEW page specifically.
const openViewOptionsDropdown = () => {
cy.get('.filterSide', { withinSubject: null }).click()
}
const openViewFilter = () => {
openViewOptionsDropdown()
cy.get('[clrDropdownItem]', { withinSubject: null })
.contains('Filter')
.click()
}
const openViewViewboxes = () => {
openViewOptionsDropdown()
cy.get('[clrDropdownItem]', { withinSubject: null })
.contains('Viewboxes')
.click()
}
// cy.contains retries until a matching node appears/updates - the open
// viewbox's own list entry can render before its table-name text has
// actually loaded, so a one-shot .then() lookup here would race it.
const openViewboxConfig = (viewbox_tablename: string) => {
cy.get('.open-viewbox').then((viewboxes: any) => {
for (let openViewbox of viewboxes) {
if (openViewbox.innerText.toLowerCase().includes(viewbox_tablename))
openViewbox.click()
}
})
cy.contains('.open-viewbox', viewbox_tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
for (let node of treeNodes) {
if (new RegExp(libNameIncludes).test(node.innerText.toLowerCase())) {
viyaLib = node
break
}
}
// Small settle wait: right after a fresh visit (an extra navigation on
// top of beforeEach's own, e.g. test 7's visitPage('view/data')), the
// tree component can still be re-rendering, causing a node to be found
// then swapped out mid-click - same fix already proven in
// viewer-labels.cy.ts.
cy.wait(300)
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container p').click()
const libraryNodeSelector = '.nav-tree clr-tree > clr-tree-node'
const libraryMatcher = new RegExp(libNameIncludes, 'i')
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
cy.contains(libraryNodeSelector, libraryMatcher, {
timeout: longerCommandTimeout
})
.find('.clr-tree-node-content-container p')
.click()
// Re-query the library node fresh rather than reusing the reference
// from before the click above - expanding a library can replace its
// whole DOM subtree once its child tables load (that's what made the
// earlier cached-reference version of this helper flaky: Cypress
// detected the click's own side effect detaching the element it was
// still verifying actionability against).
cy.contains(libraryNodeSelector, libraryMatcher, {
timeout: longerCommandTimeout
}).within(() => {
cy.contains('.clr-treenode-link', tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
})
}
const setFilterWithValue = (
Binary file not shown.
+6
View File
@@ -183,6 +183,12 @@ const isLicensingPage = (callback: any) => {
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button').contains('Paste licence').click()
// Combined single-string paste is the default format now - switch to
// the legacy two-field layout, since this helper fills licenseKey and
// activationKey as two separate values.
cy.get('button').contains('Paste as two separate keys instead').click()
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', licenseKey)
.trigger('input')
+21 -23
View File
@@ -1,32 +1,30 @@
import * as base64Converter from 'base64-arraybuffer'
export const base64ToArrayBuffer = (base64: string) => {
return new Promise(async (resolve, reject) => {
const dataUrl = "data:application/octet-binary;base64," + base64;
const dataUrl = 'data:application/octet-binary;base64,' + base64
fetch(dataUrl)
.then(res => res.arrayBuffer())
.then(buffer => {
.then((res) => res.arrayBuffer())
.then((buffer) => {
resolve(new Uint8Array(buffer))
}).catch((err) => {
})
.catch((err) => {
reject(err)
})
})
}
export const arrayBufferToBase64 = (arrayBuffer: any) => {
return new Promise((resolve, reject) => {
const blob = new Blob([arrayBuffer])
const reader = new FileReader();
reader.onload = async function(event){
if (event.target) {
var base64: any = event.target.result
base64 = base64.substring(37, base64.length)
resolve(base64)
}
};
reader.readAsDataURL(blob);
})
}
// Blob + FileReader.readAsDataURL (a natural alternative here) is
// unreliable in the Cypress runner's iframe - onload/onerror can simply
// never fire, leaving the promise permanently unsettled (a silent hang,
// surfacing only as Mocha's "done() was never invoked" 30s later with no
// clue why). base64-arraybuffer's encode() is synchronous and
// dependency-free - no browser API involved, so this class of hang isn't
// possible - and it's the same library the app itself uses for this exact
// conversion.
export const arrayBufferToBase64 = (
arrayBuffer: ArrayBuffer
): Promise<string> => {
return Promise.resolve(base64Converter.encode(arrayBuffer))
}
Binary file not shown.
+1 -1
View File
@@ -8,7 +8,7 @@ const check = (cwd) => {
start: cwd,
excludePrivatePackages: true,
onlyAllow:
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;BlueOak-1.0.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
excludePackages:
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
},
+6979 -6857
View File
File diff suppressed because it is too large Load Diff
+38 -25
View File
@@ -41,33 +41,35 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.20",
"@angular/cdk": "^19.2.19",
"@angular/common": "^19.2.20",
"@angular/compiler": "^19.2.20",
"@angular/core": "^19.2.20",
"@angular/forms": "^19.2.20",
"@angular/platform-browser": "^19.2.20",
"@angular/platform-browser-dynamic": "^19.2.20",
"@angular/router": "^19.2.20",
"@angular/animations": "^20.3.27",
"@angular/cdk": "^20.2.14",
"@angular/common": "^20.3.27",
"@angular/compiler": "^20.3.27",
"@angular/core": "^20.3.27",
"@angular/forms": "^20.3.27",
"@angular/platform-browser": "^20.3.27",
"@angular/platform-browser-dynamic": "^20.3.27",
"@angular/router": "^20.3.27",
"@cds/core": "^6.15.1",
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
"@clr/icons": "^13.0.2",
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular-wrapper": "^18.0.0",
"@sasjs/adapter": "^4.17.0",
"@sasjs/utils": "^3.5.3",
"@handsontable/angular-wrapper": "18.0.0",
"@sasjs/adapter": "^4.18.0",
"@sasjs/utils": "^3.6.0",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
"@types/d3-graphviz": "^2.6.7",
"@types/text-encoding": "0.0.35",
"base64-arraybuffer": "^0.2.0",
"browserify-cipher": "^1.0.1",
"buffer": "^5.4.3",
"crypto-browserify": "^3.12.1",
"create-hash": "^1.2.0",
"create-hmac": "^1.1.7",
"crypto-js": "^4.2.0",
"d3-graphviz": "^5.0.2",
"exceljs": "^4.4.0",
"fs-extra": "^7.0.1",
"handsontable": "^18.0.0",
"handsontable": "18.0.0",
"https-browserify": "1.0.0",
"hyperformula": "^2.5.0",
"iconv-lite": "^0.5.0",
@@ -77,8 +79,8 @@
"moment": "^2.30.1",
"ngx-clipboard": "^16.0.0",
"ngx-json-viewer": "file:libraries/ngx-json-viewer-3.2.1.tgz",
"nodejs": "0.0.0",
"os-browserify": "0.3.0",
"randombytes": "^2.1.0",
"rxjs": "^7.8.0",
"save-svg-as-png": "^1.4.17",
"stream-browserify": "3.0.0",
@@ -91,29 +93,32 @@
"zone.js": "~0.15.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.24",
"@angular-devkit/build-angular": "^20.3.32",
"@angular-eslint/builder": "19.8.1",
"@angular-eslint/eslint-plugin": "19.8.1",
"@angular-eslint/eslint-plugin-template": "19.8.1",
"@angular-eslint/schematics": "19.8.1",
"@angular-eslint/template-parser": "19.8.1",
"@angular/cli": "^19.2.24",
"@angular/compiler-cli": "^19.2.20",
"@angular/cli": "^20.3.32",
"@angular/compiler-cli": "^20.3.27",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@compodoc/compodoc": "^1.2.1",
"@compodoc/compodoc": "^2.0.0",
"@cypress/webpack-preprocessor": "^5.17.1",
"@lhci/cli": "^0.15.1",
"@types/core-js": "^2.5.5",
"@types/create-hash": "^1.2.6",
"@types/create-hmac": "^1.1.3",
"@types/crypto-js": "^4.2.1",
"@types/es6-shim": "^0.31.39",
"@types/jasmine": "~5.1.4",
"@types/lodash-es": "^4.17.3",
"@types/marked": "^4.3.0",
"@types/node": "12.20.50",
"@typescript-eslint/eslint-plugin": "8.31.1",
"@typescript-eslint/parser": "8.31.1",
"@types/randombytes": "^2.0.3",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"core-js": "^2.5.4",
"cypress": "^15.14.2",
"cypress": "^15.19.0",
"cypress-file-upload": "^5.0.8",
"cypress-plugin-tab": "^1.0.5",
"cypress-real-events": "^1.8.1",
@@ -135,13 +140,21 @@
"rimraf": "3.0.2",
"ts-loader": "^9.2.8",
"ts-node": "^3.3.0",
"typescript": "~5.8.3",
"wait-on": "^6.0.1",
"typescript": "~5.9.3",
"wait-on": "^9.0.10",
"watch": "^1.0.2"
},
"overrides": {
"ajv": "8.18.0",
"uuid": "11.1.1",
"lighthouse": "13.4.0"
"lighthouse": "13.4.0",
"fast-uri": "3.1.7",
"readdir-glob": {
"brace-expansion": "^5.0.9"
},
"exceljs": {
"archiver": "^8.0.0",
"unzipper": "^0.12.5"
}
}
}
+313 -283
View File
@@ -1,292 +1,280 @@
<div class="main-container">
<ng-container *ngIf="!router.url.includes('licensing')">
<div
*ngIf="
freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)
"
class="alert alert-app-level alert-warning"
id="demo-banner"
role="alert"
>
<ng-container *ngIf="licenceProblem.value === null">
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - to upgrade contact
<contact-link classes="color-white" />
</div>
</div>
</div>
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
</ng-container>
<ng-container *ngIf="licenceProblem.value !== null">
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - Problem with licence
</div>
</div>
</div>
<a
(click)="licenceProblemDetails(licenceProblem.value)"
class="update-key cursor-pointer"
>More details</a
>
</ng-container>
</div>
<div
*ngIf="licenseExpiringDays && !freeTierBanner"
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
This license key will expire in {{ licenseExpiringDays }}
{{ licenseExpiringDays === 1 ? 'day' : 'days' }}. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
<a
*ngIf="!freeTierBanner"
routerLink="/licensing/update"
class="update-key"
>Update Licence Key</a
@if (!router.url.includes('licensing')) {
@if (freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)) {
<div
class="alert alert-app-level alert-warning"
id="demo-banner"
role="alert"
>
</div>
<div
*ngIf="appOverCapacity"
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
@if (licenceProblem.value === null) {
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon
class="alert-icon"
shape="warning-standard"
></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - to upgrade contact
<contact-link classes="color-white" />
</div>
</div>
</div>
<div class="alert-text">
The registered number of users exceeds the limit specified for your
license. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
<a
*ngIf="!licenseExpiringDays && !freeTierBanner"
routerLink="/licensing/update"
class="update-key"
>Update Licence Key</a
>
</div>
</ng-container>
<header class="app-header" *ngIf="!embed">
<!-- <button
*ngIf="
isMainRoute('view') ||
(isMainRoute('home') && !router.url.includes('licensing'))
"
class="header-hamburger-trigger"
(click)="toggleSidebar()"
type="button"
>
<span></span>
</button> -->
<div
*ngIf="
isMainRoute('view') ||
(isMainRoute('home') && !router.url.includes('licensing'))
"
(click)="toggleSidebar()"
type="button"
aria-label="Toggle sidebar"
class="cursor-pointer select-none ml-10 d-flex clr-justify-content-center clr-align-items-center"
>
<clr-icon size="24" shape="tree-view" aria-hidden="true"></clr-icon>
</div>
<div class="logo d-flex clr-align-items-center">
<a
*ngIf="!router.url.includes('deploy')"
href="#"
[routerLink]="['/']"
class="nav-link"
>
<img
class="without-text d-block d-md-none"
src="images/dc-logo.svg"
alt="datacontroller logo without text"
/>
<img
class="with-text d-none d-md-block"
src="images/datacontroller.svg"
alt="datacontroller logo"
/>
</a>
<a *ngIf="router.url.includes('deploy')">
<span class="clr-icon header-logo ml-10"></span>
</a>
</div>
<ng-container
*ngIf="
!router.url.includes('deploy') && !router.url.includes('licensing')
"
>
<div class="header-nav d-flex d-sm-none">
<clr-dropdown>
<button
class="nav-icon color-white-i"
clrDropdownTrigger
aria-label="toggle settings menu"
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
Menu
<!-- <clr-icon size="20" shape="bars"></clr-icon> -->
</button>
<clr-dropdown-menu *clrIfOpen clrPosition="bottom-left">
<a [routerLink]="['/view']" clrDropdownItem>VIEW</a>
<a [routerLink]="['/home']" clrDropdownItem>LOAD</a>
<a [routerLink]="['/review/submitted']" clrDropdownItem>REVIEW</a>
</clr-dropdown-menu>
</clr-dropdown>
}
@if (licenceProblem.value !== null) {
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon
class="alert-icon"
shape="warning-standard"
></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - Problem with licence
</div>
</div>
</div>
<a
(click)="licenceProblemDetails(licenceProblem.value)"
class="update-key cursor-pointer"
>More details</a
>
}
</div>
<div class="header-nav d-none d-sm-flex">
<a
[routerLink]="['/view']"
class="nav-link nav-text"
routerLinkActive="active"
>VIEW</a
>
<a
[routerLink]="['/home']"
class="nav-link nav-text"
[class.active]="
router.url.includes('editor') ||
router.url.includes('edit-record') ||
router.url.includes('home')
"
>LOAD</a
>
<a
[routerLink]="['/review/submitted']"
[class.active]="
router.url.includes('submitted') ||
router.url.includes('approve') ||
router.url.includes('history')
"
class="nav-link nav-text cursor-pointer"
>REVIEW</a
>
}
@if (licenseExpiringDays && !freeTierBanner) {
<div
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
This license key will expire in {{ licenseExpiringDays }}
{{ licenseExpiringDays === 1 ? 'day' : 'days' }}. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
@if (!freeTierBanner) {
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
</div>
</ng-container>
}
@if (appOverCapacity) {
<div
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
The registered number of users exceeds the limit specified for
your license. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
@if (!licenseExpiringDays && !freeTierBanner) {
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
</div>
}
}
<app-header-actions></app-header-actions>
</header>
<nav
*ngIf="
!embed &&
(router.url.includes('submitted') ||
router.url.includes('approve') ||
router.url.includes('history'))
"
class="subnav"
>
<ul class="nav">
<li class="nav-item">
<a
[routerLink]="['/review/submitted']"
class="nav-link nav-text"
routerLinkActive="active"
>SUBMIT</a
@if (!embed) {
<header class="app-header">
<!-- <button
*ngIf="
isMainRoute('view') ||
(isMainRoute('home') && !router.url.includes('licensing'))
"
class="header-hamburger-trigger"
(click)="toggleSidebar()"
type="button"
>
<span></span>
</button> -->
@if (
isMainRoute('view') ||
(isMainRoute('home') && !router.url.includes('licensing'))
) {
<div
(click)="toggleSidebar()"
type="button"
aria-label="Toggle sidebar"
class="cursor-pointer select-none ml-10 d-flex clr-justify-content-center clr-align-items-center"
>
</li>
<li class="nav-item">
<a
[routerLink]="['/review/approve']"
class="nav-link nav-text"
[class.active]="router.url.includes('approve')"
routerLinkActive="active"
>APPROVE</a
>
</li>
<li class="nav-item">
<a
[routerLink]="['/review/history']"
class="nav-link nav-text"
routerLinkActive="active"
>HISTORY</a
>
</li>
</ul>
</nav>
<clr-icon size="24" shape="tree-view" aria-hidden="true"></clr-icon>
</div>
}
<div class="logo d-flex clr-align-items-center">
<a href="#" [routerLink]="['/']" class="nav-link">
<img
class="without-text d-block d-md-none"
src="images/dc-logo.svg"
alt="datacontroller logo without text"
/>
<img
class="with-text d-none d-md-block"
src="images/datacontroller.svg"
alt="datacontroller logo"
/>
</a>
</div>
@if (
!router.url.includes('deploy') && !router.url.includes('licensing')
) {
<div class="header-nav d-flex d-sm-none">
<clr-dropdown>
<button
class="nav-icon color-white-i"
clrDropdownTrigger
aria-label="toggle settings menu"
>
Menu
<!-- <clr-icon size="20" shape="bars"></clr-icon> -->
</button>
<clr-dropdown-menu *clrIfOpen clrPosition="bottom-left">
<a [routerLink]="['/view']" clrDropdownItem>VIEW</a>
<a [routerLink]="['/home']" clrDropdownItem>LOAD</a>
<a [routerLink]="['/review/submitted']" clrDropdownItem>REVIEW</a>
</clr-dropdown-menu>
</clr-dropdown>
</div>
<div class="header-nav d-none d-sm-flex">
<a
[routerLink]="['/view']"
class="nav-link nav-text"
routerLinkActive="active"
>VIEW</a
>
<a
[routerLink]="['/home']"
class="nav-link nav-text"
[class.active]="
router.url.includes('editor') ||
router.url.includes('edit-record') ||
router.url.includes('home')
"
>LOAD</a
>
<a
[routerLink]="['/review/submitted']"
[class.active]="
router.url.includes('submitted') ||
router.url.includes('approve') ||
router.url.includes('history')
"
class="nav-link nav-text cursor-pointer"
>REVIEW</a
>
</div>
}
<app-header-actions></app-header-actions>
</header>
}
@if (
!embed &&
(router.url.includes('submitted') ||
router.url.includes('approve') ||
router.url.includes('history'))
) {
<nav class="subnav">
<ul class="nav">
<li class="nav-item">
<a
[routerLink]="['/review/submitted']"
class="nav-link nav-text"
routerLinkActive="active"
>SUBMIT</a
>
</li>
<li class="nav-item">
<a
[routerLink]="['/review/approve']"
class="nav-link nav-text"
[class.active]="router.url.includes('approve')"
routerLinkActive="active"
>APPROVE</a
>
</li>
<li class="nav-item">
<a
[routerLink]="['/review/history']"
class="nav-link nav-text"
routerLinkActive="active"
>HISTORY</a
>
</li>
</ul>
</nav>
}
<app-alerts *ngIf="!errTop"></app-alerts>
@if (!errTop) {
<app-alerts></app-alerts>
}
<app-requests-modal [(opened)]="requestsModal"></app-requests-modal>
<app-excel-password-modal></app-excel-password-modal>
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
<!-- VA embed, back to Editor button -->
<div
*ngIf="embed === 'va' && vaEditorLibds && !isMainRoute('/editor')"
class="va-back-bar"
>
<button
type="button"
class="btn btn-sm btn-primary"
(click)="backToEditor()"
>
<clr-icon
aria-hidden="true"
shape="caret"
dir="left"
size="16"
></clr-icon>
Back to Edit table
</button>
</div>
@if (embed === 'va' && vaEditorLibds && !isMainRoute('/editor')) {
<div class="va-back-bar">
<button
type="button"
class="btn btn-sm btn-primary"
(click)="backToEditor()"
>
<clr-icon
aria-hidden="true"
shape="caret"
dir="left"
size="16"
></clr-icon>
Back to Edit table
</button>
</div>
}
<router-outlet *ngIf="startupDataLoaded"></router-outlet>
@if (startupDataLoaded) {
<router-outlet></router-outlet>
}
<app-login></app-login>
<app-alerts *ngIf="errTop"></app-alerts>
<app-info-modal
*ngFor="let abort of sasjsAborts"
[data]="abort"
[forceReload]="!startupDataLoaded && sasjsAborts.length === 1"
(onConfirmModalClick)="closeAbortModal(abort.id!)"
>
</app-info-modal>
@if (errTop) {
<app-alerts></app-alerts>
}
@for (abort of sasjsAborts; track abort) {
<app-info-modal
[data]="abort"
[forceReload]="!startupDataLoaded && sasjsAborts.length === 1"
(onConfirmModalClick)="closeAbortModal(abort.id!)"
>
</app-info-modal>
}
<clr-modal
appDragNdrop
@@ -309,17 +297,59 @@
</div>
<!-- App Loading Page -->
<div *ngIf="!startupDataLoaded" class="app-loading">
<img
class="loading-logo"
src="images/datacontroller.svg"
alt="datacontroller logo"
/>
<div *ngIf="appActive === null" class="slider">
<div class="line"></div>
<div class="subline inc"></div>
<div class="subline dec"></div>
@if (!startupDataLoaded) {
<div class="app-loading">
<img
class="loading-logo"
src="images/datacontroller.svg"
alt="datacontroller logo"
/>
@if (appActive === null) {
<div class="slider">
<div class="line"></div>
<div class="subline inc"></div>
<div class="subline dec"></div>
</div>
}
@if (startupSteps.length > 0) {
<div class="startup-checks">
@for (step of startupSteps; track step.label) {
<div
class="startup-check"
[class.startup-check--error]="step.status === 'error'"
[class.startup-check--done]="step.status === 'done'"
>
@switch (step.status) {
@case ('pending') {
<clr-icon
shape="circle"
class="is-solid startup-check__icon--pending"
></clr-icon>
}
@case ('in_progress') {
<clr-spinner clrSmall></clr-spinner>
}
@case ('done') {
<clr-icon
shape="check-circle"
class="is-solid startup-check__icon--done"
></clr-icon>
}
@case ('error') {
<clr-icon
shape="exclamation-circle"
class="is-solid startup-check__icon--error"
></clr-icon>
}
}
<span class="startup-check__label">{{ step.label }}</span>
@if (step.detail) {
<span class="startup-check__detail">{{ step.detail }}</span>
}
</div>
}
</div>
}
</div>
</div>
}
<!-- /App Loading Page -->
+10
View File
@@ -19,6 +19,10 @@ import { InfoModal } from './models/InfoModal'
import { DcAdapterSettings } from './models/DcAdapterSettings'
import { AppStoreService } from './services/app-store.service'
import { LicenceService } from './services/licence.service'
import {
StartupCheckService,
StartupStep
} from './services/startup-check.service'
import '@cds/core/icon/register.js'
import {
ClarityIcons,
@@ -64,6 +68,7 @@ export class AppComponent {
public requestsModal: boolean = false
public showRegistration: boolean = true
public startupDataLoaded: boolean = false
public startupSteps: StartupStep[] = []
public demoLimitNotice: { open: boolean; featureName: string } = {
open: false,
featureName: ''
@@ -81,11 +86,16 @@ export class AppComponent {
private location: Location,
private eventService: EventService,
private appStoreService: AppStoreService,
private startupCheckService: StartupCheckService,
private cdr: ChangeDetectorRef,
private elementRef: ElementRef
) {
this.parseDcAdapterSettings()
this.startupCheckService.steps$.subscribe((steps) => {
this.startupSteps = steps
})
/**
* Prints app info in the console such as:
* - Adapter versions
+92 -91
View File
@@ -1,100 +1,101 @@
<main class="content-area position-relative">
<div class="clr-row">
<!-- T&C section -->
<div *ngIf="step === 0" id="TCS" class="card">
<div class="card-header">Terms and Conditions</div>
<div class="card-block">
<div class="card-text">
<p class="mt-0">
The Demo version of Data Controller is free for EVALUATION purposes
only. Before proceeding with configuration, please confirm that you
have read, understood, and agreed to the
<a
href="https://docs.datacontroller.io/evaluation-licence-agreement"
target="_blank"
>Data Controller for SAS&#169; Evaluation Agreement</a
>.
</p>
@if (step === 0) {
<div id="TCS" class="card">
<div class="card-header">Terms and Conditions</div>
<div class="card-block">
<div class="card-text">
<p class="mt-0">
The Demo version of Data Controller is free for EVALUATION
purposes only. Before proceeding with configuration, please
confirm that you have read, understood, and agreed to the
<a
href="https://docs.datacontroller.io/evaluation-licence-agreement"
target="_blank"
>Data Controller for SAS&#169; Evaluation Agreement</a
>.
</p>
</div>
<hr class="light" />
<clr-checkbox-wrapper>
<input clrCheckbox type="checkbox" (change)="termsAgreeChange()" />
<label
>I have read and agree to the terms of the
<a
href="https://docs.datacontroller.io/evaluation-licence-agreement"
target="_blank"
>Data Controller for SAS&#169; Evaluation Agreement</a
></label
>
</clr-checkbox-wrapper>
<!-- <hr />
<div class="clr-checkbox-wrapper">
<input
[(ngModel)]="autodeploy"
type="checkbox"
id="checkbox2"
class="clr-checkbox"
checked
/>
<label for="checkbox2"
>Autodeploy
{{ !jsonFile ? '(json file is not available)' : '' }}</label
>
</div> -->
</div>
<hr class="light" />
<clr-checkbox-wrapper>
<input clrCheckbox type="checkbox" (change)="termsAgreeChange()" />
<label
>I have read and agree to the terms of the
<a
href="https://docs.datacontroller.io/evaluation-licence-agreement"
target="_blank"
>Data Controller for SAS&#169; Evaluation Agreement</a
></label
>
</clr-checkbox-wrapper>
<!-- <hr />
<div class="clr-checkbox-wrapper">
<input
[(ngModel)]="autodeploy"
type="checkbox"
id="checkbox2"
class="clr-checkbox"
checked
/>
<label for="checkbox2"
>Autodeploy
{{ !jsonFile ? '(json file is not available)' : '' }}</label
>
</div> -->
</div>
</div>
}
</div>
<!-- T&C section end -->
<ng-container *ngIf="step > 0" [ngSwitch]="true">
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.SasViya">
<div *ngIf="autodeploy" class="autodeploy-section card">
<app-automatic-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
[appLoc]="appLoc"
[dcPath]="dcPath"
[selectedAdminGroup]="selectedAdminGroup"
(onNavigateToHome)="onNavigateToHome()"
></app-automatic-deploy>
</div>
<div *ngIf="!autodeploy" id="mainbody" class="card">
<app-manual-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-manual-deploy>
</div>
</ng-container>
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.Sasjs">
<div class="autodeploy-section card">
<app-sasjs-configurator
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-sasjs-configurator>
</div>
</ng-container>
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.Sas9">
<div class="autodeploy-section card">
<app-sasjs-configurator
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-sasjs-configurator>
</div>
</ng-container>
</ng-container>
@if (step > 0) {
@switch (true) {
@case (sasJsConfig.serverType === ServerType.SasViya) {
@if (autodeploy) {
<div class="autodeploy-section card">
<app-automatic-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
[appLoc]="appLoc"
[dcPath]="dcPath"
[selectedAdminGroup]="selectedAdminGroup"
(onNavigateToHome)="onNavigateToHome()"
></app-automatic-deploy>
</div>
}
@if (!autodeploy) {
<div id="mainbody" class="card">
<app-manual-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-manual-deploy>
</div>
}
}
@case (sasJsConfig.serverType === ServerType.Sasjs) {
<div class="autodeploy-section card">
<app-sasjs-configurator
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-sasjs-configurator>
</div>
}
@case (sasJsConfig.serverType === ServerType.Sas9) {
<div class="autodeploy-section card">
<app-sasjs-configurator
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-sasjs-configurator>
</div>
}
}
}
</main>
@@ -21,8 +21,6 @@ export class DeployComponent implements OnInit {
public step: number = 0
public adminGroups: any = []
public client_id: string = ''
public client_secret: string = ''
public appLoc: string = ''
public dcPath: string = ''
public selectedAdminGroup: string = ''
@@ -52,9 +50,6 @@ export class DeployComponent implements OnInit {
this.sasJs = this.sasService.getSasjsInstance()
this.sasJsConfig = this.sasService.getSasjsConfig()
this.appLoc = this.dcAdapterSettings?.appLoc || ''
this.client_id = localStorage.getItem('deploy_client_id') || ''
this.client_secret = localStorage.getItem('deploy_secret_key') || ''
this.dcPath = localStorage.getItem('deploy_dc_loc') || ''
}
ngOnInit() {
@@ -1,164 +1,252 @@
<div *ngIf="autodeploying" class="auto-deploy">
<div class="spinner-box">
<ng-container *ngIf="!autodeployDone">
<span class="spinner spinner-md"> Loading... </span>
<p>Deploying...</p>
</ng-container>
<ng-container *ngIf="autodeployDone">
<p class="m-0 align-self-start">Done</p>
<hr class="w-100" />
<div
*ngIf="autoDeployStatus.deployServicePack !== null"
class="deploy-status-row"
>
<clr-icon
*ngIf="autoDeployStatus.deployServicePack === true"
class="deploy-success"
shape="success-standard"
></clr-icon>
<clr-icon
*ngIf="!autoDeployStatus.deployServicePack === false"
class="deploy-error"
shape="times-circle"
></clr-icon>
<p>Deploy SAS Jobs</p>
</div>
<div
*ngIf="autoDeployStatus.runMakeData !== null"
class="deploy-status-row"
>
<clr-icon
*ngIf="autoDeployStatus.runMakeData"
class="deploy-success"
shape="success-standard"
></clr-icon>
<clr-icon
*ngIf="autoDeployStatus.runMakeData === false"
class="deploy-error"
shape="times-circle"
></clr-icon>
<p>Create database</p>
</div>
<hr class="w-100" />
<div class="buttons">
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
<clr-icon
*ngIf="
@if (autodeploying) {
<div class="auto-deploy">
<div class="spinner-box">
@if (!autodeployDone) {
<span class="spinner spinner-md"> Loading... </span>
<p>Deploying...</p>
}
@if (autodeployDone) {
<p class="m-0 align-self-start">Done</p>
<hr class="w-100" />
@if (autoDeployStatus.deployServicePack !== null) {
<div class="deploy-status-row">
@if (autoDeployStatus.deployServicePack === true) {
<clr-icon
class="deploy-success"
shape="success-standard"
></clr-icon>
}
@if (!autoDeployStatus.deployServicePack === false) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
<p>Deploy SAS Jobs</p>
</div>
}
@if (autoDeployStatus.runMakeData !== null) {
<div class="deploy-status-row">
@if (autoDeployStatus.runMakeData) {
<clr-icon
class="deploy-success"
shape="success-standard"
></clr-icon>
}
@if (autoDeployStatus.runMakeData === false) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
<p>Create database</p>
</div>
}
<hr class="w-100" />
<div class="buttons">
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
@if (
autoDeployStatus.deployServicePack === false ||
autoDeployStatus.runMakeData === false
) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
LAUNCH
</button>
<button
(click)="
downloadFile(makeDataResponse, 'create-database-log', 'txt')
"
class="deploy-error"
shape="times-circle"
></clr-icon>
LAUNCH
</button>
<button
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
class="btn btn-primary-outline mt-15 mr-0"
>
Download log
</button>
</div>
<hr class="w-100" />
<div class="buttons">
<button
(click)="autodeploying = false; autodeployDone = false"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
>
Close
</button>
<button
(click)="openSasRequestsModal()"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
>
SAS Requests
</button>
</div>
</ng-container>
</div>
</div>
<h4 class="text-center my-15">Viya Deploy</h4>
<hr />
<label for="dcloc" class="mt-20 clr-control-label">App Loc</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<p class="mt-0">{{ appLoc }}</p>
</div>
</div>
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
<div class="mb-10 clr-control-container dc-loc-input-wrapper">
<div class="clr-input-wrapper small-mt">
<input clrInput name="dcloc" [(ngModel)]="dcPath" />
</div>
</div>
<label for="dcloc" class="mt-20 clr-control-label">SAS Admin group</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper small-mt">
<select
*ngIf="!adminGroupsLoading"
clrSelect
name="options"
[(ngModel)]="selectedAdminGroup"
>
<option *ngFor="let adminGroup of adminGroups" [value]="adminGroup.id">
{{ adminGroup.name }}
</option>
</select>
<clr-spinner
clrInline
class="spinner-sm"
*ngIf="adminGroupsLoading"
></clr-spinner>
</div>
</div>
<label for="computeContext" class="mt-20 clr-control-label"
>Compute Context</label
>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper small-mt">
<select
*ngIf="!computeContextsLoading"
clrSelect
name="options"
(ngModelChange)="onComputeContextChange($event)"
[(ngModel)]="selectedComputeContext"
>
<option
*ngFor="let computeContext of computeContexts"
[value]="computeContext.id"
>
{{ computeContext.name }}
</option>
</select>
<clr-spinner
clrInline
class="spinner-sm"
*ngIf="computeContextsLoading"
></clr-spinner>
</div>
</div>
<ng-container *ngIf="runningAsUser">
<label for="dcloc" class="mt-20 clr-control-label">Running as user:</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<p class="mt-0">{{ runningAsUser }}</p>
class="btn btn-primary-outline mt-15 mr-0"
>
Download log
</button>
</div>
<hr class="w-100" />
<div class="buttons">
<button
(click)="autodeploying = false; autodeployDone = false"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
>
Close
</button>
<button
(click)="openSasRequestsModal()"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
>
SAS Requests
</button>
</div>
}
</div>
</div>
</ng-container>
}
<h4 class="text-center my-15">Viya Deploy</h4>
<p class="deploy-intro">
Configure and deploy Data Controller to your Viya environment. Deployment
creates the backend SAS services and the control library used by the
application.
</p>
<hr />
<div class="deploy-field">
<label class="clr-control-label">App Loc</label>
<p class="deploy-field-description">
The SAS drive folder in which the Data Controller frontend and services are
deployed. Derived from your deployment configuration &mdash; it cannot be
changed here.
</p>
<div class="clr-control-container">
<div class="clr-input-wrapper">
<p class="m-0">{{ appLoc }}</p>
</div>
</div>
</div>
<div class="deploy-field">
<label for="dcloc" class="clr-control-label">DC Loc</label>
<p class="deploy-field-description">
A physical directory on the compute server to which the deployment user can
write. Data Controller stores its control tables, staging area and audit
history here.
</p>
<div class="clr-control-container dc-loc-input-wrapper">
<div class="clr-input-wrapper small-mt">
<input clrInput name="dcloc" [(ngModel)]="dcPath" />
</div>
</div>
</div>
<div class="deploy-field">
<label for="adminGroup" class="clr-control-label">SAS Admin group</label>
<p class="deploy-field-description">
The group you select becomes the Data Controller Admin Group &mdash;
everyone in it will have unrestricted access to Data Controller. By default
the list is filtered to the groups your identity
@if (currentUserInfo?.id) {
(<strong>{{ currentUserInfo?.id }}</strong
>)
}
is a member of. Toggle below to list every group on the Viya server.
</p>
<clr-toggle-container class="mt-0">
<clr-toggle-wrapper>
<input
id="show-all-groups-toggle"
type="checkbox"
[(ngModel)]="showAllGroups"
(ngModelChange)="onShowAllGroupsChange()"
clrToggle
/>
<label for="show-all-groups-toggle">Show all groups</label>
</clr-toggle-wrapper>
</clr-toggle-container>
<div class="clr-control-container">
<div class="clr-input-wrapper small-mt">
@if (!adminGroupsLoading) {
<select clrSelect name="adminGroup" [(ngModel)]="selectedAdminGroup">
@for (adminGroup of adminGroups; track adminGroup) {
<option [value]="adminGroup.id">
{{ adminGroup.name }}
</option>
}
</select>
}
@if (adminGroupsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
</div>
</div>
</div>
@if (!showAllContexts) {
<div class="deploy-field">
<label for="batchId" class="clr-control-label">Batch ID</label>
<p class="deploy-field-description">
The batch identity (<code>runServerAs</code>) under which Data Controller
jobs will execute. It is used to filter the Compute Context dropdown below
to only the contexts running as this identity.
</p>
<div class="clr-control-container">
<div class="clr-input-wrapper small-mt">
@if (!computeContextsLoading) {
<select
clrSelect
name="batchId"
[(ngModel)]="selectedBatchId"
(ngModelChange)="onBatchIdChange()"
>
@for (batchId of batchIds; track batchId) {
<option [value]="batchId">
{{ batchId }}
</option>
}
</select>
}
@if (computeContextsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
</div>
</div>
</div>
}
<div class="deploy-field">
<label for="computeContext" class="clr-control-label">Compute Context</label>
<p class="deploy-field-description">
The compute context used to run the Data Controller services (including the
<code>makedata</code> deployment job). Only reusable contexts matching the
selected Batch ID are shown, unless the toggle below is enabled.
</p>
<clr-toggle-container class="mt-0">
<clr-toggle-wrapper>
<input
id="show-all-contexts-toggle"
type="checkbox"
[(ngModel)]="showAllContexts"
(ngModelChange)="onShowAllContextsChange()"
clrToggle
/>
<label for="show-all-contexts-toggle">Show all compute contexts</label>
</clr-toggle-wrapper>
</clr-toggle-container>
<div class="clr-control-container">
<div class="clr-input-wrapper small-mt">
@if (!computeContextsLoading) {
<select
clrSelect
name="computeContext"
(ngModelChange)="onComputeContextChange($event)"
[(ngModel)]="selectedComputeContext"
>
@for (
computeContext of getFilteredComputeContexts();
track computeContext
) {
<option [value]="computeContext.id">
{{ computeContext.name }}
</option>
}
</select>
}
@if (computeContextsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
</div>
</div>
</div>
@if (runningAsUser) {
<div class="deploy-field">
<label class="clr-control-label">Running as user</label>
<p class="deploy-field-description">
The identity under which Data Controller jobs will run with the selected
compute context. This user must be able to write to the DC Loc above.
</p>
<div class="clr-control-container">
<div class="clr-input-wrapper">
<p class="m-0">
<strong>{{ runningAsUser }}</strong>
</p>
</div>
</div>
</div>
}
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
@@ -169,7 +257,7 @@
(click)="recreateDatabaseClicked($event)"
type="checkbox"
checked
/>
/>
<label>Recreate database</label>
</clr-checkbox-wrapper> -->
@@ -185,18 +273,18 @@
<!-- 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"
(click)="executeJson()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
[disabled]="!jsonFile"
>
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
</button> -->
<!-- <button
(click)="uploadJsonAuto.click()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
(click)="uploadJsonAuto.click()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
>
Upload different file to deploy
Upload different file to deploy
</button>
<input
#uploadJsonAuto
@@ -204,7 +292,7 @@
hidden
(click)="clearUploadInput($event)"
(change)="onJsonFileChange($event)"
/> -->
/> -->
<clr-modal [(clrModalOpen)]="recreateDatabaseModal" [clrModalClosable]="false">
<h3 class="modal-title">Warning</h3>
@@ -25,6 +25,13 @@ import {
Item as ComputeContextItem
} from 'src/app/viya-api-explorer/models/viya-compute-contexts.model'
export interface ComputeContextInfo {
id: string
name: string
runAs: string | null
reusable: boolean
}
@Component({
selector: 'app-automatic-deploy',
templateUrl: './automatic.component.html',
@@ -60,6 +67,21 @@ export class AutomaticComponent implements OnInit {
public currentUserInfoLoading: boolean = false
public computeContextsLoading: boolean = false
public adminGroups: { id: string; name: string }[] = []
/** Compute contexts enriched with their batch identity and reusability */
public computeContextInfos: ComputeContextInfo[] = []
/** Distinct batch identities (runServerAs) across reusable contexts */
public batchIds: string[] = []
public selectedBatchId: string = ''
/**
* When enabled, any compute context can be chosen (the batch id dropdown
* is hidden and no reusability / batch id filtering is applied)
*/
public showAllContexts: boolean = false
/** All groups on the Viya server */
public allAdminGroups: { id: string; name: string }[] = []
/** Groups the current user is a member of */
public memberAdminGroups: { id: string; name: string }[] = []
public showAllGroups: boolean = false
public runningAsUser: string | undefined
public currentUserInfo: ViyaApiCurrentUser | null = null
public computeContexts: ComputeContextItem[] = []
@@ -104,9 +126,9 @@ export class AutomaticComponent implements OnInit {
}
public async loadData() {
await this.getCurrentUser()
await this.getAdminGroups()
await this.getComputeContexts()
await this.getCurrentUser()
setTimeout(() => {
if (this.selectedComputeContext) {
@@ -120,20 +142,13 @@ export class AutomaticComponent implements OnInit {
this.computeContextsLoading = true
this.sasViyaService.getComputeContexts().subscribe(
(res: ViyaComputeContexts) => {
this.computeContextsLoading = false
const defaultContext = res.items.find(
(item: ComputeContextItem) =>
item.name === 'SAS Job Execution compute context'
)
if (defaultContext) {
this.selectedComputeContext = defaultContext.id
}
async (res: ViyaComputeContexts) => {
this.computeContexts = res.items
await this.getComputeContextDetails()
this.computeContextsLoading = false
resolve()
},
(err) => {
@@ -143,6 +158,137 @@ export class AutomaticComponent implements OnInit {
})
}
/**
* The `/compute/contexts` list view is minimal, so each context is fetched
* in full (in parallel) to pick up its batch identity (runServerAs) and
* whether it reuses server processes. Only reusable contexts with a batch
* identity feed the batch id dropdown.
*/
public async getComputeContextDetails() {
const infos = await Promise.all(
this.computeContexts.map(
(context: ComputeContextItem) =>
new Promise<ComputeContextInfo>((resolveInfo) => {
this.sasViyaService.getComputeContextById(context.id).subscribe(
(details: ComputeContextDetails) => {
resolveInfo({
id: context.id,
name: context.name,
runAs: this.extractRunAs(details),
reusable: details.attributes?.reuseServerProcesses === 'true'
})
},
(err: any) => {
this.loggerService.error(
`Error while getting compute context ${context.name}`,
err
)
resolveInfo({
id: context.id,
name: context.name,
runAs: null,
reusable: false
})
}
)
})
)
)
this.computeContextInfos = infos
this.batchIds = [
...new Set(
infos
.filter((info) => info.reusable && info.runAs)
.map((info) => info.runAs as string)
)
]
if (this.batchIds.length) {
this.selectedBatchId = this.batchIds[0]
}
const filteredContexts = this.getFilteredComputeContexts()
const defaultContext =
filteredContexts.find(
(info: ComputeContextInfo) =>
info.name === 'SAS Job Execution compute context'
) || filteredContexts[0]
if (defaultContext) {
this.selectedComputeContext = defaultContext.id
}
}
/**
* Pulls the runAs (batch) identity out of a compute context. The batch user
* is exposed as `attributes.runServerAs` in the Viya compute context API
* response. Not all contexts expose one.
*/
public extractRunAs(details: ComputeContextDetails): string | null {
return details.attributes?.runServerAs || null
}
/**
* The contexts offered in the dropdown. By default only reusable contexts
* (`reuseServerProcesses`) matching the selected batch id are shown. When
* the `showAllContexts` toggle is on, all contexts are shown.
*/
public getFilteredComputeContexts(): ComputeContextInfo[] {
if (this.showAllContexts) {
return this.computeContextInfos
}
return this.computeContextInfos.filter(
(info: ComputeContextInfo) =>
info.reusable && info.runAs === this.selectedBatchId
)
}
/**
* Changing the batch id re-filters the compute context dropdown and makes
* sure the currently selected context exists in the displayed list.
*/
public onBatchIdChange() {
this.ensureSelectedComputeContext()
}
/**
* Toggles the compute context dropdown between contexts matching the
* selected batch id and all contexts on the Viya server.
*/
public onShowAllContextsChange() {
this.ensureSelectedComputeContext()
}
private ensureSelectedComputeContext() {
const filteredContexts = this.getFilteredComputeContexts()
const selectedContextExists = filteredContexts.some(
(info: ComputeContextInfo) => info.id === this.selectedComputeContext
)
if (!selectedContextExists) {
this.selectedComputeContext = filteredContexts.length
? filteredContexts[0].id
: ''
}
this.updateRunningAsUser()
}
private updateRunningAsUser() {
const selectedContext = this.computeContextInfos.find(
(info: ComputeContextInfo) => info.id === this.selectedComputeContext
)
this.runningAsUser =
selectedContext?.runAs || this.currentUserInfo?.id || 'unknown'
}
public async getCurrentUser() {
return new Promise<void>((resolve, reject) => {
this.currentUserInfoLoading = true
@@ -170,16 +316,21 @@ export class AutomaticComponent implements OnInit {
this.adminGroupsLoading = true
;(this.sasViyaService
.getAdminGroups()
.subscribe((res: ViyaApiIdentities) => {
this.adminGroupsLoading = false
.subscribe(async (res: ViyaApiIdentities) => {
// Map admin groups with only needed fields
this.adminGroups = res.items.map((item: Item) => {
this.allAdminGroups = res.items.map((item: Item) => {
return {
id: item.id,
name: item.name
}
})
await this.getMemberAdminGroups()
this.applyAdminGroupFilter()
this.adminGroupsLoading = false
resolve()
}),
(err: any) => {
@@ -192,16 +343,72 @@ export class AutomaticComponent implements OnInit {
})
}
public async onComputeContextChange(computeContextId: string) {
this.sasViyaService
.getComputeContextById(computeContextId)
.subscribe((res: ComputeContextDetails) => {
if (res.attributes && res.attributes.runServerAs) {
this.runningAsUser = res.attributes.runServerAs
} else {
this.runningAsUser = this.currentUserInfo?.id || 'unknown'
/**
* Fetches the groups the current user is a member of, from the Viya
* identities API. If the memberships cannot be fetched, the member list
* falls back to all groups.
*/
public async getMemberAdminGroups() {
return new Promise<void>((resolve) => {
this.sasViyaService.getCurrentUserGroupMemberships().subscribe(
(res: ViyaApiIdentities) => {
const memberIds = res.items.map((item: Item) => item.id)
this.memberAdminGroups = this.allAdminGroups.filter(
(group: { id: string; name: string }) =>
memberIds.includes(group.id)
)
resolve()
},
(err: any) => {
this.loggerService.error(
'Error while getting user group memberships',
err
)
this.memberAdminGroups = this.allAdminGroups
resolve()
}
})
)
})
}
/**
* Toggles the SAS Admin group dropdown between the groups the current user
* is a member of and all groups on the Viya server.
*/
public onShowAllGroupsChange() {
this.applyAdminGroupFilter()
}
/**
* Applies the member/all groups filter to the dropdown and makes sure the
* currently selected group exists in the displayed list.
*/
private applyAdminGroupFilter() {
this.adminGroups =
this.showAllGroups || !this.memberAdminGroups.length
? this.allAdminGroups
: this.memberAdminGroups
const selectedGroupExists = this.adminGroups.some(
(group: { id: string; name: string }) =>
group.id === this.selectedAdminGroup
)
if (!selectedGroupExists && this.adminGroups.length) {
this.selectedAdminGroup = this.adminGroups[0].id
}
}
public async onComputeContextChange(computeContextId: string) {
// The explicit (ngModelChange) handler runs before the [(ngModel)]
// assignment updates the field, so synchronise the selected context id
// here before deriving the running user. Otherwise the displayed user
// lags behind the dropdown by one change.
this.selectedComputeContext = computeContextId
this.updateRunningAsUser()
}
public getComputeContextName(id: string): string | undefined {
@@ -341,7 +548,8 @@ export class AutomaticComponent implements OnInit {
this.eventService.showAbortModal('makedata', abortMsg, {
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
SYSERRORTEXT: abortRes.SYSERRORTEXT,
MAC: macMsg
MAC: macMsg,
_PROGRAM: abortRes._PROGRAM
})
}
@@ -373,7 +581,9 @@ export class AutomaticComponent implements OnInit {
let contextname = `&_contextname=${params.contextName}`
let admin = `&admin=${params.admin}`
let dcPath = `&dcpath=${params.dcPath}`
let debug = this.sasService.getDebugUrlParam()
// Debug is ALWAYS enabled for makedata — it runs only once during
// deployment and the full log is needed to diagnose any issues.
let debug = '&_debug=131'
let programUrl =
serverUrl +
@@ -387,6 +597,53 @@ export class AutomaticComponent implements OnInit {
debug
window.open(programUrl)
// Poll for makedata completion. The makedata service self-deletes
// when it finishes successfully, so we check if it's still present
// in the admin folder. When it's gone, reload the app fresh.
this.pollMakedataCompletion()
}
private makedataPollInterval: any = null
private pollMakedataCompletion() {
if (this.makedataPollInterval) {
clearInterval(this.makedataPollInterval)
}
let attempts = 0
const maxAttempts = 120 // 10 minutes at 5s intervals
this.makedataPollInterval = setInterval(async () => {
attempts++
console.log(
`pollMakedataCompletion: checking if makedata is gone (attempt ${attempts}/${maxAttempts})`
)
try {
const makedataGone = await this.sasService.viyaMakedataSuccessfull()
if (makedataGone) {
console.log(
'pollMakedataCompletion: makedata job is gone, reloading app'
)
clearInterval(this.makedataPollInterval)
this.makedataPollInterval = null
// Reload the app fresh from the SASJobExecution URL
window.location.href = window.location.href.split('#')[0]
}
} catch (err) {
console.error('pollMakedataCompletion: error checking', err)
}
if (attempts >= maxAttempts) {
console.warn(
'pollMakedataCompletion: timed out after 10 minutes, stopping poll'
)
clearInterval(this.makedataPollInterval)
this.makedataPollInterval = null
}
}, 5000)
}
/**
@@ -428,9 +685,12 @@ export class AutomaticComponent implements OnInit {
return
}
/* Use a function replacement so $ in computeContextName is treated as a
literal character. In a string replacement, $ has special meaning
($&, $1, $` etc.); a function return value is used verbatim. */
const updatedContent = indexHtmlContent.replace(
/contextname="[^"]*"/g,
`contextname="${computeContextName}"`
() => `contextname="${computeContextName}"`
)
await this.sasService
@@ -10,29 +10,29 @@
</div>
<div class="card-block">
<!-- <div class="card-title">
Client Details
</div> -->
Client Details
</div> -->
<div *ngIf="needsLogin" id="loginForm" class="d-none">
<p class="mb-10">Please log in first</p>
<label for="username" class="clr-control-label">Username</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input type="text" id="username" class="clr-input" />
@if (needsLogin) {
<div id="loginForm" class="d-none">
<p class="mb-10">Please log in first</p>
<label for="username" class="clr-control-label">Username</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input type="text" id="username" class="clr-input" />
</div>
</div>
</div>
<label for="password" class="clr-control-label">Password</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input type="password" id="password" class="clr-input" />
<label for="password" class="clr-control-label">Password</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input type="password" id="password" class="clr-input" />
</div>
</div>
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
</div>
}
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
</div>
<ng-container *ngIf="!needsLogin">
@if (!needsLogin) {
<form>
<div class="clr-form-control">
<label for="select-full" class="clr-control-label">Admin group</label>
@@ -45,17 +45,15 @@
id="adminGroupsSelect"
class="clr-select"
>
<option
*ngFor="let adminGroup of adminGroups"
[value]="adminGroup.id"
>
{{ adminGroup.name }}
</option>
@for (adminGroup of adminGroups; track adminGroup) {
<option [value]="adminGroup.id">
{{ adminGroup.name }}
</option>
}
</select>
</div>
</div>
</div>
<div class="clr-form-control">
<div [class.hidden]="contextsLoading">
<label for="select-full" class="clr-control-label">Context</label>
@@ -67,15 +65,14 @@
name="selectedContext"
class="clr-select"
>
<option
*ngFor="let context of allContexts"
[value]="context.name"
>
{{ context.name }}
<span *ngIf="(context.attributes | json) != '{}'"
>( {{ context.attributes.sysUserId }} )</span
>
</option>
@for (context of allContexts; track context) {
<option [value]="context.name">
{{ context.name }}
@if ((context.attributes | json) != '{}') {
<span>( {{ context.attributes.sysUserId }} )</span>
}
</option>
}
</select>
</div>
</div>
@@ -88,14 +85,12 @@
<clr-icon shape="play"></clr-icon>
</button>
</div>
<div [class.hidden]="!contextsLoading" class="d-flex">
<span class="spinner spinner-inline mr-10">
Loading contexts...
</span>
<span> Loading contexts... </span>
</div>
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
@@ -110,7 +105,6 @@
/>
</div>
</div>
<p class="mt-10">
Select JSON file to upload (json build file preloaded):
</p>
@@ -121,10 +115,9 @@
(change)="onJsonFileChange($event)"
/>
<!-- <button *ngIf="downloadFileBtn" (click)="downloadSasPrecodeFile()" style="width: 40px; min-width: 0;" class="btn btn-sm btn-icon">
<clr-icon shape="download"></clr-icon>
</button> -->
<clr-icon shape="download"></clr-icon>
</button> -->
</div>
<div class="mt-20 d-flex align-items-center">
<button
(click)="executeJson()"
@@ -135,13 +128,13 @@
>
SUBMIT JSON
</button>
<span *ngIf="isJsonSubmitted">JSON Submitted Successfully</span>
@if (isJsonSubmitted) {
<span>JSON Submitted Successfully</span>
}
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
Loading...
</span> -->
Loading...
</span> -->
</div>
<p class="mt-10">Select SAS file to upload:</p>
<div class="d-flex flex-column">
<input
@@ -149,15 +142,15 @@
(click)="clearUploadInput($event)"
(change)="onSasFileChange($event); downloadFileBtn = true"
/>
<button
*ngIf="downloadFileBtn"
(click)="downloadSasPrecodeFile()"
class="btn btn-sm btn-icon min-w-0 w-40"
>
<clr-icon shape="download"></clr-icon>
</button>
@if (downloadFileBtn) {
<button
(click)="downloadSasPrecodeFile()"
class="btn btn-sm btn-icon min-w-0 w-40"
>
<clr-icon shape="download"></clr-icon>
</button>
}
</div>
<div class="mt-20 d-flex align-items-center">
<button
(click)="executeSAS()"
@@ -168,17 +161,14 @@
>
SUBMIT
</button>
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
Loading...
</span> -->
Loading...
</span> -->
</div>
</div>
<ng-container *ngIf="jobLog.length > 0">
@if (jobLog.length > 0) {
<p class="mb-0 mt-10">File execute completed</p>
<hr />
<div>
<button
(click)="downloadFile(jobLog, 'execute-script-log', 'txt')"
@@ -187,8 +177,7 @@
Download log
</button>
</div>
</ng-container>
}
<button
[clrLoading]="createDatabaseLoading"
(click)="createDatabase()"
@@ -196,52 +185,45 @@
>
Create Database
</button>
<ng-container *ngIf="makeDataResponse.length > 0">
@if (makeDataResponse.length > 0) {
<p class="mb-0 mt-10">Create Database Completed</p>
<hr />
<div *ngIf="makeDataResponse.length > 0" class="log-wrapper">
{{ makeDataResponse }}
</div>
@if (makeDataResponse.length > 0) {
<div class="log-wrapper">
{{ makeDataResponse }}
</div>
}
<button (click)="navigateToHome()" class="btn btn-primary mt-15">
Let's get started
</button>
<button
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
class="btn btn-primary mt-15"
>
Download log
</button>
<button (click)="validateDeploy()" class="btn btn-primary mt-15">
Validate
</button>
<div
*ngIf="validationState !== 'none' || isValidating"
class="validation-bar"
>
<ng-container *ngIf="isValidating">
<span class="spinner spinner-inline mr-10">
Validating deploy...
</span>
<span> Validating deploy... </span>
</ng-container>
<ng-container *ngIf="!isValidating && validationState === 'error'">
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
<span> Validation failed </span>
</ng-container>
<ng-container *ngIf="!isValidating && validationState === 'success'">
<clr-icon shape="check-circle" class="is-success"></clr-icon>
<span> Validation succeeded </span>
</ng-container>
</div>
</ng-container>
@if (validationState !== 'none' || isValidating) {
<div class="validation-bar">
@if (isValidating) {
<span class="spinner spinner-inline mr-10">
Validating deploy...
</span>
<span> Validating deploy... </span>
}
@if (!isValidating && validationState === 'error') {
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
<span> Validation failed </span>
}
@if (!isValidating && validationState === 'success') {
<clr-icon shape="check-circle" class="is-success"></clr-icon>
<span> Validation succeeded </span>
}
</div>
}
}
</form>
</ng-container>
}
</div>
@@ -0,0 +1,90 @@
import { ManualComponent } from './manual.component'
// validateDeploy() is fire-and-forget (doesn't return its own promise
// chain), so tests need to wait for it to settle some other way. A
// macrotask boundary guarantees every already-queued microtask (regardless
// of how many .then() hops validateDeploy's chain has) has run first.
const flushPromiseChain = () => new Promise((resolve) => setTimeout(resolve, 0))
/**
* validateDeploy() only touches sasService.request, eventService.showInfoModal
* and loggerService.log - these stubs cover exactly that surface, not the
* full real services (no TestBed/DI needed, same plain-instantiation-with-
* stubs precedent as app.service.spec.ts).
*/
const buildDeps = () => {
const sasService: any = {
request: jasmine.createSpy('request')
}
const eventService: any = {
showInfoModal: jasmine.createSpy('showInfoModal')
}
const loggerService: any = {
log: jasmine.createSpy('log')
}
const deployService: any = {}
return { sasService, eventService, loggerService, deployService }
}
const buildManualComponent = (deps: ReturnType<typeof buildDeps>) =>
new ManualComponent(
deps.sasService,
deps.eventService,
deps.loggerService,
deps.deployService
)
describe('ManualComponent - validateDeploy', () => {
it('sets validationState to success when saslibs is present on a normal object response', async () => {
const deps = buildDeps()
deps.sasService.request.and.resolveTo({
adapterResponse: { saslibs: { SOME_LIB: ['SOME_TABLE'] } }
})
const component = buildManualComponent(deps)
component.validateDeploy()
await flushPromiseChain()
expect(component.validationState).toBe('success')
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
})
it('sets validationState to error with no modal when saslibs is missing from an otherwise normal object response', async () => {
const deps = buildDeps()
deps.sasService.request.and.resolveTo({
adapterResponse: { SYSSITE: 'SITE1' }
// saslibs deliberately omitted
})
const component = buildManualComponent(deps)
component.validateDeploy()
await flushPromiseChain()
expect(component.validationState).toBe('error')
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
})
// Same underlying bug as app.service.spec.ts's equivalent test: a
// misconfigured Viya computeTasks deployment returns a raw "Job error"
// text body, which the adapter resolves as adapterResponse verbatim.
it('sets validationState to error and shows the real response text when adapterResponse is a raw string, not an object', async () => {
const deps = buildDeps()
const rawJobError = [
'Job error',
'The Compute service could not execute the task c74a707a-8b88-46a5-8f28-9a7694ad5e13 because the context 340cd3eb-72ae is not reusable. Please provide a reusable context or supply the ID of an existing session in the task request.',
'path: /compute/tasks',
'correlator: dc1a9fda-8f65-4f32-b4d4-2a70e02179b1;1bcb6a4c-8a16-4053-a790-292544c25a03'
].join('\n')
deps.sasService.request.and.resolveTo({ adapterResponse: rawJobError })
const component = buildManualComponent(deps)
component.validateDeploy()
await flushPromiseChain()
expect(component.validationState).toBe('error')
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
const [, message] = deps.eventService.showInfoModal.calls.mostRecent().args
expect(message).toContain(rawJobError)
})
})
@@ -13,6 +13,7 @@ import { DeployService } from 'src/app/services/deploy.service'
import { EventService } from 'src/app/services/event.service'
import { LoggerService } from 'src/app/services/logger.service'
import { SasService } from 'src/app/services/sas.service'
import { getMalformedAdapterResponseMessage } from 'src/app/shared/utils/get-malformed-adapter-response-message'
@Component({
selector: 'app-manual-deploy',
@@ -241,6 +242,9 @@ export class ManualComponent implements OnInit {
*/
public createDatabase(newTab: boolean = true) {
if (newTab) {
// Debug is ALWAYS enabled for makedata — it runs only once during
// deployment and the full log is needed to diagnose any issues.
const _debug = '&_debug=131'
let url =
this.sasService.getSasjsConfig().serverUrl +
'/SASJobExecution/?_program=' +
@@ -251,7 +255,7 @@ export class ManualComponent implements OnInit {
this.selectedAdminGroup +
'&DCPATH=' +
this.dcPath +
this.sasService.getDebugUrlParam()
_debug
window.open(url, '_blank')
@@ -316,7 +320,13 @@ export class ManualComponent implements OnInit {
.then((res: RequestWrapperResponse) => {
this.loggerService.log(res.adapterResponse)
if (res.adapterResponse.saslibs) {
const malformedMessage = getMalformedAdapterResponseMessage(
res.adapterResponse
)
if (malformedMessage) {
this.validationState = 'error'
this.eventService.showInfoModal('Error', malformedMessage)
} else if (res.adapterResponse.saslibs) {
this.validationState = 'success'
} else {
this.validationState = 'error'
@@ -1,6 +1,8 @@
<div *ngIf="loading" class="thinProgress progresStatic progress loop">
<progress></progress>
</div>
@if (loading) {
<div class="thinProgress progresStatic progress loop">
<progress></progress>
</div>
}
<h4 class="text-center my-15">Sasjs Deploy</h4>
<hr class="light" />
@@ -27,13 +29,13 @@
</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/> -->
(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>
<p class="m-0 mt-10">
@@ -45,12 +47,11 @@
<label class="mt-20 clr-control-label">Data Controller Admin group</label>
<clr-select-container class="mb-10 mt-0 w-50vw">
<select [(ngModel)]="dcAdminGroup" clrSelect>
<option
*ngFor="let adminGroup of dcAdminGroupList"
[value]="adminGroup.GROUPNAME"
>
{{ adminGroup.GROUPNAME }} - {{ adminGroup.GROUPDESC }}
</option>
@for (adminGroup of dcAdminGroupList; track adminGroup) {
<option [value]="adminGroup.GROUPNAME">
{{ adminGroup.GROUPNAME }} - {{ adminGroup.GROUPDESC }}
</option>
}
</select>
</clr-select-container>
@@ -120,8 +120,10 @@ export class SasjsConfiguratorComponent implements OnInit {
* Creating database
*/
makeData() {
// const _debug = "&_debug=131"; //debug on
const _debug = ' ' //debug off
// Debug is ALWAYS enabled for this service. The makedata service runs only once
// during deployment configuration, so full debug output (131) is always wanted
// to help diagnose any issues. This is NOT temporary — do not remove or gate it.
const _debug = '&_debug=131'
let executor = this.sasService.getExecutionPath()
const root = this.sasJsConfig.appLoc
@@ -84,6 +84,20 @@
status="warning"
></clr-icon>
<!-- SOFTREGEX: value doesn't match the expected pattern, but
submission is still allowed (unlike HARDREGEX, which is
surfaced via the existing invalid-data class below). -->
<clr-icon
*ngIf="
!currentRecordErrors.includes(colIndex) &&
currentRecordWarningCols.includes(col.key)
"
class="flex-unset position-absolute entry-input-left-offset"
shape="error-standard"
status="warning"
title="Value does not match the expected pattern"
></clr-icon>
<ng-container *ngSwitchCase="'numeric'">
<clr-input-container
*ngIf="
@@ -55,6 +55,7 @@ export class EditRecordComponent implements OnInit {
@Output() onPreviousRecord: EventEmitter<any> = new EventEmitter<any>()
public currentRecordInvalidCols: string[] = []
public currentRecordWarningCols: string[] = []
public generateEditRecordUrlLoading: boolean = false
public generatedRecordUrl: string | null = null
public addRecordUrl: string | null = null
@@ -120,9 +121,12 @@ export class EditRecordComponent implements OnInit {
*/
private revalidateRecordCol(colName: string, value: any) {
const colRules = this.currentRecordValidator?.getRule(colName)
this.validateRecordCol(colRules, value).then((valid: boolean) =>
this.updateValidationState(colName, valid)
)
this.updateWarningState(colName, value)
}
/**
@@ -221,6 +225,8 @@ export class EditRecordComponent implements OnInit {
this.tryAutoPopulateNotNull(event, colName, colRules, value)
}
})
this.updateWarningState(colName, value)
})
}
@@ -237,6 +243,22 @@ export class EditRecordComponent implements OnInit {
}
}
/**
* Updates the SOFTREGEX warning columns list — the modal's equivalent of
* makeRegexWarningRenderer in the grid (see DcValidator.failsSoftRegex).
*/
private updateWarningState(colName: string, value: any): void {
const failsSoftRegex =
this.currentRecordValidator?.failsSoftRegex(colName, value) ?? false
const index = this.currentRecordWarningCols.indexOf(colName)
if (!failsSoftRegex && index > -1) {
this.currentRecordWarningCols.splice(index, 1)
} else if (failsSoftRegex && index < 0) {
this.currentRecordWarningCols.push(colName)
}
}
/**
* Auto-populates NOTNULL default value when the field is empty and has a default
*/
@@ -264,6 +286,8 @@ export class EditRecordComponent implements OnInit {
this.validateRecordCol(colRules, defaultValue).then((isValid: boolean) => {
this.updateValidationState(colName, isValid)
})
this.updateWarningState(colName, defaultValue)
}
onNextRecordClick() {
@@ -1,7 +1,9 @@
<div>
<p class="m-0" *ngFor="let state of processedStates; let i = index">
{{ state }}
</p>
@for (state of processedStates; track state; let i = $index) {
<p class="m-0">
{{ state }}
</p>
}
</div>
<div class="text-center">
<span class="spinner spinner-sm vertical-align-middle">Loading...</span>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
import { classifyRow } from './classifyRow'
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
describe('classifyRow', () => {
const headerPks = ['PRIMARY_KEY_FIELD']
it('is D when the row is marked for delete, regardless of other field changes', () => {
const dataRow = {
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'changed',
_____DELETE__THIS__RECORD_____: 'Yes'
}
const dataSourceUnchanged = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('D')
})
it('is A when no row in dataSourceUnchanged matches the primary key(s)', () => {
const dataRow = { PRIMARY_KEY_FIELD: 99, SOME_CHAR: 'new row' }
const dataSourceUnchanged = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('A')
})
it('is M when a matching row exists but a field differs', () => {
const dataRow = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'changed' }
const dataSourceUnchanged = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('M')
})
it('is U when a matching row exists and nothing differs', () => {
const dataRow = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
const dataSourceUnchanged = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('U')
})
it('matches on all primary key columns for a composite key', () => {
const headerPksComposite = ['LIB', 'ID']
const dataRow = { LIB: 'WORK', ID: 1, SOME_CHAR: 'original' }
const dataSourceUnchanged = [
{ LIB: 'OTHER', ID: 1, SOME_CHAR: 'original' },
{ LIB: 'WORK', ID: 1, SOME_CHAR: 'original' }
]
// Same ID=1 exists under a different LIB - must not match that one.
expect(
classifyRow(dataRow, dataSourceUnchanged, headerPksComposite)
).toEqual('U')
})
it('ignores its own EDIT_STATUS column value when diffing, so writing the classification back never self-perpetuates a stale M', () => {
const dataRow = {
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'original',
[EDIT_STATUS_COLUMN_NAME]: 'M'
}
const dataSourceUnchanged = [
{
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'original',
[EDIT_STATUS_COLUMN_NAME]: 'U'
}
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('U')
})
})
@@ -0,0 +1,39 @@
import { withoutEditStatus } from './withoutEditStatus'
export type RowEditStatus = 'M' | 'A' | 'D' | 'U'
/**
* Classifies a single row as Modified/Added/Deleted/Unchanged against the
* pre-edit snapshot (`dataSourceUnchanged`), PK-matched via `headerPks`.
*
* Extracted from editor.component.ts's getRowsSubmittingCount(), which used
* this same logic inline, only at submit time. Kept here as a pure function
* so it can run live (e.g. on every afterChange, for the EDIT_STATUS column)
* without duplicating the diffing rules.
*/
export const classifyRow = (
dataRow: any,
dataSourceUnchanged: any[],
headerPks: string[]
): RowEditStatus => {
if (dataRow._____DELETE__THIS__RECORD_____ === 'Yes') return 'D'
const dataRowUnchanged = dataSourceUnchanged.find((row: any) => {
for (const pkCol of headerPks) {
if (row[pkCol] !== dataRow[pkCol]) return false
}
return true
})
if (!dataRowUnchanged) return 'A'
// EDIT_STATUS holds this very function's own prior output - comparing it
// like any other field would self-perpetuate: once a row is written as
// 'M', its EDIT_STATUS would forever differ from the unchanged snapshot's
// 'U' even after the real edit is reverted, permanently stuck on 'M'.
return JSON.stringify(withoutEditStatus(dataRow)) !==
JSON.stringify(withoutEditStatus(dataRowUnchanged))
? 'M'
: 'U'
}
@@ -0,0 +1,101 @@
import { expandCellRanges } from './expandCellRanges'
describe('expandCellRanges', () => {
it('expands a single-cell range', () => {
expect(
expandCellRanges(
[{ from: { row: 1, col: 1 }, to: { row: 1, col: 1 } }],
3,
3
)
).toEqual([{ row: 1, col: 1 }])
})
it('expands a rectangular multi-cell range', () => {
expect(
expandCellRanges(
[{ from: { row: 0, col: 0 }, to: { row: 1, col: 1 } }],
3,
3
)
).toEqual([
{ row: 0, col: 0 },
{ row: 0, col: 1 },
{ row: 1, col: 0 },
{ row: 1, col: 1 }
])
})
it('normalizes a whole-row selection (from.col === -1) to every real column', () => {
expect(
expandCellRanges(
[{ from: { row: 1, col: -1 }, to: { row: 1, col: 2 } }],
3,
3
)
).toEqual([
{ row: 1, col: 0 },
{ row: 1, col: 1 },
{ row: 1, col: 2 }
])
})
it('normalizes a whole-column selection (from.row === -1) to every real row', () => {
expect(
expandCellRanges(
[{ from: { row: -1, col: 1 }, to: { row: 2, col: 1 } }],
3,
3
)
).toEqual([
{ row: 0, col: 1 },
{ row: 1, col: 1 },
{ row: 2, col: 1 }
])
})
it('expands every disjoint range when multiple are given (ctrl-click)', () => {
expect(
expandCellRanges(
[
{ from: { row: 0, col: 0 }, to: { row: 0, col: 0 } },
{ from: { row: 2, col: 2 }, to: { row: 2, col: 2 } }
],
3,
3
)
).toEqual([
{ row: 0, col: 0 },
{ row: 2, col: 2 }
])
})
it('treats a null row/col the same as -1 (Handsontable.CellRange types these as number | null)', () => {
expect(
expandCellRanges(
[{ from: { row: 1, col: null }, to: { row: 1, col: 2 } }],
3,
3
)
).toEqual([
{ row: 1, col: 0 },
{ row: 1, col: 1 },
{ row: 1, col: 2 }
])
})
it('handles from/to given in reverse order (drag selection upward/leftward)', () => {
expect(
expandCellRanges(
[{ from: { row: 2, col: 2 }, to: { row: 1, col: 1 } }],
3,
3
)
).toEqual([
{ row: 1, col: 1 },
{ row: 1, col: 2 },
{ row: 2, col: 1 },
{ row: 2, col: 2 }
])
})
})
@@ -0,0 +1,54 @@
export interface SimpleCellRange {
from: { row: number | null; col: number | null }
to: { row: number | null; col: number | null }
}
export interface SimpleCell {
row: number
col: number
}
/**
* Expands one or more Handsontable selection ranges (as returned by
* getSelectedRange()/the context menu callback's selection argument) into
* every individual (row, col) cell they cover. A whole-row selection (row
* header click) reports col: -1 (Handsontable.CellRange itself types this
* as number | null, so null is treated the same way here) on whichever end
* is the "start" of the range; a whole-column selection reports row: -1
* the same way - normalized here to the real 0..totalRows-1/0..totalCols-1
* bounds, since neither -1 nor null is a usable index for anything
* downstream (comments lookup, setDataAtRowProp, ...). Multiple ranges
* (ctrl-click) are all expanded, not just the first.
*/
export const expandCellRanges = (
ranges: SimpleCellRange[],
totalRows: number,
totalCols: number
): SimpleCell[] => {
const cells: SimpleCell[] = []
const normalizeRow = (row: number | null) =>
row === -1 || row === null ? undefined : row
const normalizeCol = (col: number | null) =>
col === -1 || col === null ? undefined : col
for (const range of ranges) {
const rowA = normalizeRow(range.from.row) ?? 0
const rowB = normalizeRow(range.to.row) ?? totalRows - 1
const colA = normalizeCol(range.from.col) ?? 0
const colB = normalizeCol(range.to.col) ?? totalCols - 1
const startRow = Math.min(rowA, rowB)
const endRow = Math.max(rowA, rowB)
const startCol = Math.min(colA, colB)
const endCol = Math.max(colA, colB)
for (let row = startRow; row <= endRow; row++) {
for (let col = startCol; col <= endCol; col++) {
cells.push({ row, col })
}
}
}
return cells
}
@@ -0,0 +1,19 @@
import { getEditStatusSymbol } from './getEditStatusSymbol'
describe('getEditStatusSymbol', () => {
it("returns '+' for Added", () => {
expect(getEditStatusSymbol('A')).toEqual('+')
})
it("returns '-' for Deleted", () => {
expect(getEditStatusSymbol('D')).toEqual('-')
})
it("returns '±' for Modified", () => {
expect(getEditStatusSymbol('M')).toEqual('±')
})
it("returns '' for Unchanged", () => {
expect(getEditStatusSymbol('U')).toEqual('')
})
})
@@ -0,0 +1,20 @@
import { RowEditStatus } from './classifyRow'
/**
* Maps a row's edit status to the symbol shown in the row-header gutter
* (see editor.component.ts's rowHeaders callback). Unchanged renders as an
* empty string rather than a letter, since it's the common case and would
* otherwise clutter every untouched row.
*/
export const getEditStatusSymbol = (status: RowEditStatus): string => {
switch (status) {
case 'A':
return '+'
case 'D':
return '-'
case 'M':
return '±'
case 'U':
return ''
}
}
@@ -0,0 +1,20 @@
import { normalizeSortConfig } from './normalizeSortConfig'
describe('normalizeSortConfig', () => {
it('wraps a single config in an array', () => {
const cfg = { column: 0, sortOrder: 'asc' as const }
expect(normalizeSortConfig(cfg)).toEqual([cfg])
})
it('passes an array of configs through unchanged', () => {
const cfgs = [
{ column: 0, sortOrder: 'asc' as const },
{ column: 1, sortOrder: 'desc' as const }
]
expect(normalizeSortConfig(cfgs)).toEqual(cfgs)
})
it('returns an empty array for undefined (nothing currently sorted)', () => {
expect(normalizeSortConfig(undefined)).toEqual([])
})
})
@@ -0,0 +1,13 @@
/**
* multiColumnSorting's getSortConfig() returns a single config, an array of
* them, or undefined depending on how many columns are currently sorted -
* normalizes all three shapes into one array so callers never need three
* branches to handle it. `SortConfig` isn't part of Handsontable's public
* API surface in this version (only re-exported internally), and its own
* `.d.ts` shape isn't structurally assignable to `Record<string, unknown>`
* either (a specific interface isn't assignable to an index-signature type
* without one of its own), so `any` is the pragmatic choice here rather
* than fighting the type system for a type this file can't even name.
*/
export const normalizeSortConfig = (cfg: any): any[] =>
Array.isArray(cfg) ? cfg : cfg ? [cfg] : []
@@ -0,0 +1,53 @@
import { preventMenuItemAutoClose } from './preventMenuItemAutoClose'
describe('preventMenuItemAutoClose', () => {
let parent: HTMLElement
let child: HTMLElement
beforeEach(() => {
parent = document.createElement('div')
child = document.createElement('span')
parent.appendChild(child)
document.body.appendChild(parent)
})
afterEach(() => {
document.body.removeChild(parent)
})
const bubblingEvents = ['mousedown', 'mouseup', 'contextmenu', 'selectstart']
bubblingEvents.forEach((eventName) => {
it(`stops a ${eventName} dispatched on the element from bubbling to its parent`, () => {
let bubbledToParent = false
parent.addEventListener(eventName, () => (bubbledToParent = true))
preventMenuItemAutoClose(child)
child.dispatchEvent(new Event(eventName, { bubbles: true }))
expect(bubbledToParent).toBe(false)
})
it(`still lets a ${eventName} dispatched directly on the parent reach the parent (propagation isn't globally broken)`, () => {
let bubbledToParent = false
parent.addEventListener(eventName, () => (bubbledToParent = true))
preventMenuItemAutoClose(child)
parent.dispatchEvent(new Event(eventName, { bubbles: true }))
expect(bubbledToParent).toBe(true)
})
})
it('does not call preventDefault on contextmenu, so the browser can still show its own menu', () => {
preventMenuItemAutoClose(child)
const event = new Event('contextmenu', {
bubbles: true,
cancelable: true
})
child.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
})
})
@@ -0,0 +1,23 @@
// Handsontable's Menu widget (used by both dropdownMenu and contextMenu)
// treats a click landing anywhere inside a non-passive item as "the item was
// activated": mouseup auto-closes the menu, and contextmenu is
// unconditionally preventDefault()'d, so the browser's own right-click menu
// never appears - regardless of whether the item actually has a callback.
// A custom-rendered, read-only item (plain informational text, nothing to
// click) still gets this treatment, making its content impossible to select
// or copy. Handsontable's own listeners are bubble-phase, attached on
// ancestors of the rendered item element, so stopping propagation at the
// item itself is enough to reach them before they run - no capture-phase
// handling needed.
const EVENTS_TO_ISOLATE = [
'mousedown',
'mouseup',
'contextmenu',
'selectstart'
] as const
export const preventMenuItemAutoClose = (elem: HTMLElement): void => {
for (const eventName of EVENTS_TO_ISOLATE) {
elem.addEventListener(eventName, (event) => event.stopPropagation())
}
}
@@ -0,0 +1,314 @@
import Handsontable from 'handsontable'
import { makeRegexWarningRenderer } from './regex-warning-renderer'
describe('makeRegexWarningRenderer', () => {
const buildHot = (data: any[]) => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data,
columns: [
{ data: 'val', renderer: makeRegexWarningRenderer('^[A-Z]{3}$') },
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
return { hot, container }
}
const buildHotNumeric = (data: any[]) => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data,
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer('^[A-Z]{3}$', undefined, true)
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
return { hot, container }
}
it('adds dc-warning-cell and a REGEX: title when the value fails the pattern', () => {
const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeTrue()
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
hot.destroy()
container.remove()
})
it('does not add dc-warning-cell when the value matches the pattern', () => {
const { hot, container } = buildHot([
{ val: 'ABC', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('suppresses the warning on a row marked for delete, even though the value fails the pattern', () => {
const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'Yes' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('does not add dc-warning-cell for the plain SAS missing (".") on a numeric column', () => {
const { hot, container } = buildHotNumeric([
{ val: '.', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('DOES add dc-warning-cell for a special missing on a numeric column (a deliberately-set value)', () => {
const { hot, container } = buildHotNumeric([
{ val: '.a', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeTrue()
hot.destroy()
container.remove()
})
it('does not throw and never warns on a malformed pattern', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{ data: 'val', renderer: makeRegexWarningRenderer('[unterminated') },
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
expect(() => hot.render()).not.toThrow()
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('handles a PRX-delimited pattern with a case-insensitive flag, as authored for prxparse', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [
{ val: 'this is dummy data', _____DELETE__THIS__RECORD_____: 'No' },
{ val: 'THE WIND WAS BLOWING', _____DELETE__THIS__RECORD_____: 'No' },
{ val: 'nothing relevant here', _____DELETE__THIS__RECORD_____: 'No' }
],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer('/\\b(the|data)\\b/i')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
expect(hot.getCell(0, 0)?.classList.contains('dc-warning-cell')).toBeFalse()
expect(hot.getCell(1, 0)?.classList.contains('dc-warning-cell')).toBeFalse()
expect(hot.getCell(2, 0)?.classList.contains('dc-warning-cell')).toBeTrue()
hot.destroy()
container.remove()
})
it('is display-only — the stored value is untouched', () => {
const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
])
expect(hot.getDataAtCell(0, 0)).toEqual('abc')
hot.destroy()
container.remove()
})
describe('HARDREGEX (second, optional pattern)', () => {
// HARDREGEX already blocks submission via dqValidate/HOT's own
// htInvalid (unchanged, untouched here) - this renderer only adds the
// matching 'REGEX: <pattern>' title on top, so a column with only a
// HARDREGEX rule (no SOFTREGEX at all) still tells the user why a cell
// is red, not just that it is.
const buildHardOnlyHot = (value: string) => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: value, _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer(undefined, '^[A-Z]{3}$')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
return { hot, container }
}
it('sets a REGEX: title (no dc-warning-cell) when only HARDREGEX fails', () => {
const { hot, container } = buildHardOnlyHot('abc')
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
hot.destroy()
container.remove()
})
it('sets no title when the value passes HARDREGEX', () => {
const { hot, container } = buildHardOnlyHot('ABC')
const td = hot.getCell(0, 0)
expect(td?.title).toEqual('')
hot.destroy()
container.remove()
})
it('suppresses the HARDREGEX title on a row marked for delete', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'abc', _____DELETE__THIS__RECORD_____: 'Yes' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer(undefined, '^[A-Z]{3}$')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
expect(hot.getCell(0, 0)?.title).toEqual('')
hot.destroy()
container.remove()
})
it('prefers the HARDREGEX title over SOFTREGEX when a value fails both', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'ab', _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer('^.{5,10}$', '^[A-Z]{3}$')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
const td = hot.getCell(0, 0)
// 'ab' fails HARDREGEX (not 3 uppercase letters) AND SOFTREGEX (too
// short) - HARDREGEX wins: its title shows, and no yellow class is
// added (HOT's own red htInvalid governs this cell instead).
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
hot.destroy()
container.remove()
})
it('never warns for SOFTREGEX when the column also has HARDREGEX, even for a value that passes HARDREGEX', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'AB', _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer('^.{5,10}$', '^[A-Z0-9]+$')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
const td = hot.getCell(0, 0)
// 'AB' passes HARDREGEX (uppercase/digits) but fails SOFTREGEX (too
// short) - only one regex runs per column, so the soft rule is
// ignored entirely: no warning, no title.
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
expect(td?.title).toEqual('')
hot.destroy()
container.remove()
})
it('does not throw and never warns on a malformed HARDREGEX pattern', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer(undefined, '[unterminated')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
expect(() => hot.render()).not.toThrow()
expect(hot.getCell(0, 0)?.title).toEqual('')
hot.destroy()
container.remove()
})
})
})
@@ -0,0 +1,79 @@
import Handsontable from 'handsontable'
import { isRegexRuleExempt } from '../../shared/dc-validator/utils/isRegexRuleExempt'
import { parseRegexRule } from '../../shared/dc-validator/utils/parseRegexRule'
const compileRegex = (
pattern: string | undefined,
ruleType: 'SOFTREGEX' | 'HARDREGEX'
): RegExp | null => {
try {
return pattern ? parseRegexRule(pattern) : null
} catch (e) {
console.warn(`${ruleType} - invalid pattern, warning disabled: ${pattern}`)
return null
}
}
/**
* Builds a display-only HOT renderer for HARDREGEX/SOFTREGEX: neither ever
* returns false from the cell validator here (that's dqValidate's job for
* HARDREGEX, which blocks submission and paints HOT's own red htInvalid
* independently of this renderer) - this renderer only adds the matching
* `REGEX: <pattern>` title, plus a yellow `dc-warning-cell` class when only
* SOFTREGEX fails, same split as makeNumberFormatRenderer.
*
* Only one regex ever runs per column: when a HARDREGEX rule exists,
* SOFTREGEX is ignored entirely (never compiled, never evaluated) -
* regardless of whether individual cell values pass or fail the hard
* rule. A value failing HARDREGEX gets its title (no yellow class - red
* htInvalid already covers the color). This mirrors
* DcValidator.failsSoftRegex's own precedence.
*
* Suppressed on rows marked for delete (_____DELETE__THIS__RECORD_____ =
* 'Yes') — a warning about data about to be removed is just noise.
*
* Falls back to no warning ever showing when a pattern itself is malformed,
* rather than breaking the cell.
*/
export const makeRegexWarningRenderer = (
softPattern?: string,
hardPattern?: string,
isNumeric: boolean = false
) => {
const hardRegex = compileRegex(hardPattern, 'HARDREGEX')
const softRegex = hardRegex ? null : compileRegex(softPattern, 'SOFTREGEX')
const baseRenderer = Handsontable.renderers.getRenderer('text')
return (
instance: any,
td: any,
row: number,
col: number,
prop: string | number,
value: any,
cellProperties: any
) => {
baseRenderer(instance, td, row, col, prop, value, cellProperties)
const markedForDelete =
instance.getDataAtRowProp(row, '_____DELETE__THIS__RECORD_____') === 'Yes'
const exempt = isRegexRuleExempt(value, isNumeric)
const failsHard =
!!hardRegex && !exempt && !hardRegex.test(value.toString())
const failsSoft =
!!softRegex && !exempt && !softRegex.test(value.toString())
if (markedForDelete) return td
if (failsHard) {
td.title = `REGEX: ${hardPattern}`
} else if (failsSoft) {
td.classList.add('dc-warning-cell')
td.title = `REGEX: ${softPattern}`
}
return td
}
}
@@ -1,5 +1,10 @@
import Handsontable from 'handsontable'
import { makeNumberFormatRenderer } from './renderers.utils'
import {
makeNumberFormatRenderer,
errorRenderer,
noSpinnerRenderer,
spinnerRenderer
} from './renderers.utils'
describe('makeNumberFormatRenderer', () => {
it('renders a numeric cell as EUR currency without changing the value', () => {
@@ -86,3 +91,67 @@ describe('makeNumberFormatRenderer', () => {
container.remove()
})
})
/**
* DOM-injection reproduction mirroring the editor's cell-render cycle.
* During dynamic cell validation the editor applies one of the status
* renderers to a cell via setCellMeta + hot.render(). Those renderers paint
* the cell value with td.innerHTML, so a value containing markup is injected
* and executed (the <img onerror> fires in the browser). The value can come
* straight from a dataset row served by the getdata stored program, or from a
* typed edit. These fail on the vulnerable implementation and pass once the
* renderer escapes the value.
*/
describe('grid cell renderers do not inject raw HTML', () => {
const maliciousValue = '<img src=x onerror=alert(1)>'
// Seed a real Handsontable grid with the payload as a loaded cell value,
// then apply the given status renderer and render — exactly the sequence the
// editor uses during the dynamic-validation cycle.
const renderWith = (
renderer: (
i: any,
td: any,
r: number,
c: number,
p: any,
v: any,
cp: any
) => any
) => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ SOME_CHAR: maliciousValue }],
columns: [{ data: 'SOME_CHAR', type: 'text' }],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
hot.setCellMeta(0, 0, 'renderer', renderer)
hot.render()
const td: HTMLTableCellElement | null = hot.getCell(0, 0)
hot.destroy()
container.remove()
return td
}
// A vulnerable renderer turns the value into a real <img> element with an
// onerror handler (proven by the browser firing alert(1)). A safe
// renderer leaves no such element. Asserting on the parsed DOM rather
// than the raw string avoids false passes from browser attribute normalising.
const assertNoInjectedElement = (td: HTMLTableCellElement | null) => {
expect(td?.querySelector('img[onerror]')).toBeNull()
}
it('noSpinnerRenderer escapes rather than injecting the value', () => {
assertNoInjectedElement(renderWith(noSpinnerRenderer))
})
it('errorRenderer escapes rather than injecting the value', () => {
assertNoInjectedElement(renderWith(errorRenderer))
})
it('spinnerRenderer escapes rather than injecting the value', () => {
assertNoInjectedElement(renderWith(spinnerRenderer))
})
})
+21 -7
View File
@@ -1,5 +1,23 @@
import Handsontable from 'handsontable'
/**
* Returns string-safe text of any value so it can be assigned to innerHTML.
* The cell values painted by the status renderers are user/DB-controlled,
* so they must never be parsed as HTML by the browser — escaping turns any
* embedded markup into inert text.
*/
const escapeHtml = (value: any): string =>
String(value ?? '').replace(/[&<>"']/g, (char) => {
const entities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
return entities[char]
})
/**
* Builds a display-only HOT renderer that formats numeric cell values using
* Intl.NumberFormat. The stored/submitted value is never changed — only the
@@ -67,9 +85,7 @@ export const errorRenderer = (
) => {
addDarkClass(td)
td.innerHTML = `${
value ? value.toString() : ''
} <cds-icon shape="exclamation-triangle" status="warning"></cds-icon>`
td.innerHTML = `${escapeHtml(value)} <cds-icon shape="exclamation-triangle" status="warning"></cds-icon>`
return td
}
@@ -89,7 +105,7 @@ export const noSpinnerRenderer = (
) => {
addDarkClass(td)
td.innerHTML = value ? value : ''
td.innerHTML = escapeHtml(value)
return td
}
@@ -110,9 +126,7 @@ export const spinnerRenderer = (
) => {
addDarkClass(td)
td.innerHTML = `${
value ? value.toString() : ''
} <span class="spinner spinner-sm vertical-align-middle"></span>`
td.innerHTML = `${escapeHtml(value)} <span class="spinner spinner-sm vertical-align-middle"></span>`
return td
}
@@ -0,0 +1,540 @@
import Handsontable from 'handsontable'
import { HyperFormula } from 'hyperformula'
import { classifyRow } from './classifyRow'
import { normalizeSortConfig } from './normalizeSortConfig'
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
import { parseFormulaRule } from '../../shared/dc-validator/utils/parseFormulaRule'
import { getFormulaCellsToPreserveOnCancel } from '../../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
/**
* afterChange delivers changes as [visualRow, prop, oldValue, newValue] -
* Handsontable's own documented behaviour. Once multiColumnSorting reorders
* the grid, visual row order no longer matches dataSource's physical order.
* editor.component.ts's afterChange hook (the one syncing EDIT_STATUS and
* the "overwritten" comment) passes that raw visual row straight into
* updateEditStatusForRow/syncOverwrittenCommentForCell, both of which index
* dataSource directly - the same [visualRow] shape the beforeChange hook a
* few lines up in the same file already translates via
* hot.toPhysicalRow(changeRow) before doing the same kind of dataSource
* access.
*
* Mirrors the row extraction only (not the full hook), against a real
* Handsontable + multiColumnSorting instance, since proving visual/physical
* divergence requires an actual sort to have happened.
*/
describe('afterChange must translate the changed row to physical before indexing dataSource, on a sorted grid', () => {
const setup = (): { hot: Handsontable; dataSource: any[] } => {
// Physical row 0: PK=1. Physical row 1: PK=2.
const dataSource = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'b' },
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'a' }
]
const hot = new Handsontable(document.createElement('div'), {
data: dataSource,
columns: [{ data: 'PRIMARY_KEY_FIELD' }, { data: 'SOME_CHAR' }],
multiColumnSorting: true,
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
// Ascending by SOME_CHAR flips the order: physical row 1 (SOME_CHAR
// 'a') becomes visual row 0; physical row 0 (SOME_CHAR 'b') becomes
// visual row 1.
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
sortPlugin.sort({ column: 1, sortOrder: 'asc' })
hot.render()
return { hot, dataSource }
}
it('demonstrates the failure mode: indexing dataSource with the raw visual row reads the wrong row', () => {
const { hot, dataSource } = setup()
// The user edits what they see as the FIRST row on screen - visual
// row 0, which after sorting is physical row 1 (PK=2).
const visualRow = 0
// Mirrors the current (buggy) afterChange hook: uses the visual row
// straight from `changes` to index dataSource.
const wronglyReadRow = dataSource[visualRow]
expect(wronglyReadRow.PRIMARY_KEY_FIELD).not.toEqual(2)
expect(wronglyReadRow.PRIMARY_KEY_FIELD).toEqual(1) // the OTHER row
// Mirrors the fix: translate visual -> physical first, matching
// beforeChange's own hot.toPhysicalRow(changeRow) pattern.
const physicalRow = hot.toPhysicalRow(visualRow)
const correctlyReadRow = dataSource[physicalRow as number]
expect(correctlyReadRow.PRIMARY_KEY_FIELD).toEqual(2)
hot.destroy()
})
})
/**
* updateEditStatusForRow/syncOverwrittenCommentForCell's own established
* contract (per their other existing callers - addRow/insertRowAtPosition,
* and syncOverwrittenComments()'s own `dataSource.forEach((_row, rowIndex)
* => ...)`) is a PHYSICAL row index, used to index dataSource. But both
* also call Handsontable APIs - hot.setDataAtRowProp/getDataAtRowProp
* (updateEditStatusForRow) and the comments plugin's setCommentAtCell/
* getCommentAtCell/removeCommentAtCell (syncOverwrittenCommentForCell) -
* which Handsontable's own JSDoc documents as expecting the VISUAL row.
* Passing the physical row straight through to those (the current code)
* only happens to work when visual and physical coincide - i.e. an
* unsorted grid. On a sorted one, translating the afterChange hook's own
* row (see the describe block above) is necessary but not sufficient -
* these two methods must also convert their own physical rowIndex
* parameter to visual before calling any Handsontable API, or the fix
* above just moves the bug rather than closing it.
*/
describe('updateEditStatusForRow resolves the correct visual cell on a sorted grid', () => {
const headerPks = ['PRIMARY_KEY_FIELD']
const setup = (): { hot: Handsontable; dataSource: any[] } => {
const dataSource = [
{
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'b',
[EDIT_STATUS_COLUMN_NAME]: 'U'
},
{
PRIMARY_KEY_FIELD: 2,
SOME_CHAR: 'a',
[EDIT_STATUS_COLUMN_NAME]: 'U'
}
]
const hot = new Handsontable(document.createElement('div'), {
data: dataSource,
columns: [
{ data: 'PRIMARY_KEY_FIELD' },
{ data: 'SOME_CHAR' },
{ data: EDIT_STATUS_COLUMN_NAME }
],
multiColumnSorting: true,
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
// Ascending by SOME_CHAR: physical row 1 (PK=2) -> visual row 0;
// physical row 0 (PK=1) -> visual row 1.
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
sortPlugin.sort({ column: 1, sortOrder: 'asc' })
hot.render()
return { hot, dataSource }
}
// Mirrors the CURRENT updateEditStatusForRow - uses the (correct,
// physical) rowIndex directly for the Handsontable write too.
const updateEditStatusForRowBuggy = (
hot: Handsontable,
dataSource: any[],
dataSourceUnchanged: any[],
rowIndex: number
): void => {
const dataRow = dataSource[rowIndex]
if (!dataRow) return
const status = classifyRow(dataRow, dataSourceUnchanged, headerPks)
hot.setDataAtRowProp(
rowIndex,
EDIT_STATUS_COLUMN_NAME,
status,
'editStatus'
)
}
// Mirrors the fix - dataSource access stays physical; the Handsontable
// write is translated to visual first.
const updateEditStatusForRowFixed = (
hot: Handsontable,
dataSource: any[],
dataSourceUnchanged: any[],
rowIndex: number
): void => {
const dataRow = dataSource[rowIndex]
if (!dataRow) return
const status = classifyRow(dataRow, dataSourceUnchanged, headerPks)
const visualRow = hot.toVisualRow(rowIndex)
if (visualRow === null) return
hot.setDataAtRowProp(
visualRow,
EDIT_STATUS_COLUMN_NAME,
status,
'editStatus'
)
}
it('demonstrates the failure mode: the buggy version writes the status to the wrong cell', () => {
const { hot, dataSource } = setup()
// Physical row 1 (PK=2) is the one that actually changed vs its
// unchanged baseline (SOME_CHAR 'a' vs 'ORIGINAL') - classifyRow
// should mark it 'M'. It's currently displayed at visual row 0.
const dataSourceUnchanged = [
{
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'b',
[EDIT_STATUS_COLUMN_NAME]: 'U'
},
{
PRIMARY_KEY_FIELD: 2,
SOME_CHAR: 'ORIGINAL',
[EDIT_STATUS_COLUMN_NAME]: 'U'
}
]
updateEditStatusForRowBuggy(hot, dataSource, dataSourceUnchanged, 1)
// Buggy: wrote 'M' to visual row 1 (PK=1's cell, unaffected), not
// visual row 0 (PK=2's actual current position).
expect(hot.getDataAtRowProp(1, EDIT_STATUS_COLUMN_NAME)).toEqual('M')
expect(hot.getDataAtRowProp(0, EDIT_STATUS_COLUMN_NAME)).toEqual('U')
hot.destroy()
})
it('writes the status to the correct (visually current) cell once fixed', () => {
const { hot, dataSource } = setup()
const dataSourceUnchanged = [
{
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'b',
[EDIT_STATUS_COLUMN_NAME]: 'U'
},
{
PRIMARY_KEY_FIELD: 2,
SOME_CHAR: 'ORIGINAL',
[EDIT_STATUS_COLUMN_NAME]: 'U'
}
]
updateEditStatusForRowFixed(hot, dataSource, dataSourceUnchanged, 1)
expect(hot.getDataAtRowProp(0, EDIT_STATUS_COLUMN_NAME)).toEqual('M')
expect(hot.getDataAtRowProp(1, EDIT_STATUS_COLUMN_NAME)).toEqual('U')
hot.destroy()
})
})
/**
* Same physical/visual split as updateEditStatusForRow above, for the
* comments plugin's own row-indexed API (setCommentAtCell/
* getCommentAtCell/removeCommentAtCell all document a VISUAL row -
* propToCol, unlike the row side, already returns a visual column, so
* only the row needs translating).
*/
describe('syncOverwrittenCommentForCell resolves the correct visual cell on a sorted grid', () => {
const setup = (): { hot: Handsontable } => {
const dataSource = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'b' },
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'a' }
]
const hot = new Handsontable(document.createElement('div'), {
data: dataSource,
columns: [{ data: 'PRIMARY_KEY_FIELD' }, { data: 'SOME_CHAR' }],
multiColumnSorting: true,
comments: true,
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
sortPlugin.sort({ column: 1, sortOrder: 'asc' })
hot.render()
return { hot }
}
it('demonstrates the failure mode: setCommentAtCell(physicalRow, ...) lands on the wrong visual cell', () => {
const { hot } = setup()
const commentsPlugin: any = hot.getPlugin('comments')
// Mirrors the buggy call: the established-physical rowIndex (1) used
// directly against a Handsontable API that wants visual.
commentsPlugin.setCommentAtCell(1, 1, 'Original value: b')
// Landed on visual row 1 (PK=1's cell), not visual row 0 (PK=2's
// actual current position, physical row 1).
expect(commentsPlugin.getCommentAtCell(1, 1)).toEqual('Original value: b')
expect(commentsPlugin.getCommentAtCell(0, 1)).toBeUndefined()
hot.destroy()
})
it('lands on the correct visual cell once translated to visual first', () => {
const { hot } = setup()
const commentsPlugin: any = hot.getPlugin('comments')
const visualRow = hot.toVisualRow(1)
commentsPlugin.setCommentAtCell(visualRow, 1, 'Original value: a')
expect(commentsPlugin.getCommentAtCell(0, 1)).toEqual('Original value: a')
expect(commentsPlugin.getCommentAtCell(1, 1)).toBeUndefined()
hot.destroy()
})
})
/**
* A genuine Handsontable multiColumnSorting + formulas plugin bug (not
* specific to any of DC's own hooks): calling hot.updateSettings() - with
* ANY settings, even a bare {} - while a sort is active corrupts formula
* cell references, rendering '#REF!' instead of their computed value.
* Reproduced directly: a no-op {} settings object alone is enough to
* trigger it, independent of what it actually changes. editTable(),
* cancelEdit(), and every other place editor.component.ts calls
* updateSettings() after the grid could already be sorted (e.g. the user
* sorted while still read-only, then clicked Edit) hits this. The fix
* (updateSettingsSortSafe) clears the sort before the call and restores it
* immediately afterward.
*/
describe('updateSettings must not run while a sort is active - it corrupts formula cell references', () => {
const buildSortedGrid = (): Handsontable => {
const columnNames = [
'PRIMARY_KEY_FIELD',
'A_COL',
'B_COL',
'FORMULA_HARD_COL',
'PLAIN_TEXT_COL'
]
const data = Array.from({ length: 10 }, (_, i) => ({
PRIMARY_KEY_FIELD: i + 1,
A_COL: i + 1,
B_COL: 10,
FORMULA_HARD_COL: '',
PLAIN_TEXT_COL: `note-${i + 1}`
}))
data.forEach((row: any, i) => {
row.FORMULA_HARD_COL = parseFormulaRule('=A_COL * B_COL', {
columnNames,
rowIndex: i,
userName: 'test',
origValue: undefined
})
})
const hot = new Handsontable(document.createElement('div'), {
data,
columns: columnNames.map((c) => ({ data: c })),
formulas: { engine: HyperFormula, licenseKey: 'gpl-v3' },
multiColumnSorting: true,
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
sortPlugin.sort({
column: columnNames.indexOf('PLAIN_TEXT_COL'),
sortOrder: 'desc'
})
hot.render()
return hot
}
const formulaHardColValues = (hot: Handsontable): any[] =>
Array.from({ length: 10 }, (_, r) =>
hot.getDataAtRowProp(r, 'FORMULA_HARD_COL')
)
it('demonstrates the failure mode: updateSettings while sorted corrupts formula cells', () => {
const hot = buildSortedGrid()
hot.updateSettings({}, false)
hot.render()
expect(formulaHardColValues(hot)).toContain('#REF!')
hot.destroy()
})
it('clearing the sort before updateSettings and restoring it after avoids the corruption', () => {
const hot = buildSortedGrid()
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
const sortConfigs = normalizeSortConfig(sortPlugin.getSortConfig())
sortPlugin.clearSort()
hot.updateSettings({}, false)
hot.render()
if (sortConfigs.length > 0) sortPlugin.sort(sortConfigs)
hot.render()
expect(formulaHardColValues(hot)).not.toContain('#REF!')
hot.destroy()
})
})
/**
* multiColumnSorting's own JSDoc: "every call of `sort` function set an
* entirely new sort order. Previous sort configs aren't preserved." A loop
* calling sort() once per captured config - the shape editor.component.ts
* used before this fix, in updateSettingsSortSafe/editTable/cancelEdit's
* restore step - therefore doesn't accumulate a multi-column sort: each
* call replaces the last, so only the FINAL config in the array survives.
* Restoring a captured multi-column sort must pass the whole array to a
* single sort() call instead.
*/
describe('restoring a multi-column sort must apply as a single call, not a loop', () => {
const columnNames = ['PRIMARY_KEY_FIELD', 'GROUP_COL', 'VALUE_COL']
const buildGrid = (): Handsontable => {
const data = [
{ PRIMARY_KEY_FIELD: 1, GROUP_COL: 'B', VALUE_COL: 2 },
{ PRIMARY_KEY_FIELD: 2, GROUP_COL: 'A', VALUE_COL: 1 },
{ PRIMARY_KEY_FIELD: 3, GROUP_COL: 'A', VALUE_COL: 3 },
{ PRIMARY_KEY_FIELD: 4, GROUP_COL: 'B', VALUE_COL: 4 }
]
const hot = new Handsontable(document.createElement('div'), {
data,
columns: columnNames.map((c) => ({ data: c })),
multiColumnSorting: true,
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
return hot
}
// Sort by GROUP_COL asc, then VALUE_COL desc within each group.
const sortConfigs = [
{ column: 1, sortOrder: 'asc' },
{ column: 2, sortOrder: 'desc' }
]
const visiblePkOrder = (hot: Handsontable): number[] =>
Array.from(
{ length: 4 },
(_, r) => hot.getDataAtRowProp(r, 'PRIMARY_KEY_FIELD') as number
)
it('demonstrates the failure mode: looping sort() per column loses every column but the last', () => {
const hot = buildGrid()
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
for (const sc of sortConfigs) sortPlugin.sort(sc)
hot.render()
// GROUP_COL asc got wiped out by the second call - only VALUE_COL desc
// (the last config) survived, sorting the whole table by value alone.
expect(visiblePkOrder(hot)).toEqual([4, 3, 1, 2])
hot.destroy()
})
it('applying the full config array in one call restores the actual multi-column order', () => {
const hot = buildGrid()
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
sortPlugin.sort(sortConfigs)
hot.render()
// Group A first (value desc: 3, 1), then group B (value desc: 4, 2).
expect(visiblePkOrder(hot)).toEqual([3, 2, 4, 1])
hot.destroy()
})
})
/**
* getFormulaCellsToPreserveOnCancel's rowIndex parameter is established as
* PHYSICAL (it iterates 0..dataSource.length, matching dataSource's own
* order - see the file's own docblock). cancelEdit() passes that rowIndex
* straight into commentsPlugin.getCommentAtCell and hot.getDataAtRowProp,
* both of which - same as updateEditStatusForRow/syncOverwrittenCommentForCell
* above - document a VISUAL row. On a sorted grid this both checks and
* reads the wrong row's cell: a row that actually has a comment can go
* unflagged while a different row gets wrongly flagged, carrying that other
* row's live value onto its own prop - corrupting cancelEdit()'s restore
* rather than just skipping it. The fix is to clear the sort before this
* runs at all (cancelEdit() now does this - see its own comment), rather
* than translate physical->visual per call, since dataSourceUnchanged's
* restore right after also needs an unsorted physical dataSource to index
* into.
*/
describe('getFormulaCellsToPreserveOnCancel callbacks resolve the correct cell on a sorted grid', () => {
const formulaBaseCols = ['FORMULA_HARD_COL']
const setup = (): { hot: Handsontable } => {
const dataSource = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'b', FORMULA_HARD_COL: 10 },
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'a', FORMULA_HARD_COL: 20 }
]
const hot = new Handsontable(document.createElement('div'), {
data: dataSource,
columns: [
{ data: 'PRIMARY_KEY_FIELD' },
{ data: 'SOME_CHAR' },
{ data: 'FORMULA_HARD_COL' }
],
multiColumnSorting: true,
comments: true,
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
// Physical row 0 (PK=1) has the comment/live value that must be
// preserved. Ascending by SOME_CHAR flips it to visual row 1; physical
// row 1 (PK=2, no comment) becomes visual row 0.
const commentsPlugin: any = hot.getPlugin('comments')
commentsPlugin.setCommentAtCell(0, 2, 'Original value: 5')
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
sortPlugin.sort({ column: 1, sortOrder: 'asc' })
hot.render()
return { hot }
}
it('demonstrates the failure mode: physical rowIndex passed straight to visual-row APIs resolves the wrong cell', () => {
const { hot } = setup()
const commentsPlugin: any = hot.getPlugin('comments')
const toPreserve = getFormulaCellsToPreserveOnCancel(
2,
formulaBaseCols,
(rowIndex, baseCol) =>
!!commentsPlugin.getCommentAtCell(rowIndex, hot.propToCol(baseCol)),
(rowIndex, prop) => hot.getDataAtRowProp(rowIndex, prop)
)
// Physical row 0's comment lives at visual row 1 post-sort, but the
// buggy call checked physical rowIndex 0 straight as a visual row -
// landing on physical row 1's cell (no comment, value 20) instead.
// rowIndex 1 then wrongly reads AS visual row 1, which is physical row
// 0's actual commented cell (value 10) - so the wrong row (1, not 0)
// gets flagged, carrying the wrong row's value.
expect(toPreserve).toEqual([
{ rowIndex: 1, prop: 'FORMULA_HARD_COL', value: 10 }
])
hot.destroy()
})
it('resolves the correct cell once the sort is cleared before reading', () => {
const { hot } = setup()
const commentsPlugin: any = hot.getPlugin('comments')
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
const sortConfigs = normalizeSortConfig(sortPlugin.getSortConfig())
sortPlugin.clearSort()
const toPreserve = getFormulaCellsToPreserveOnCancel(
2,
formulaBaseCols,
(rowIndex, baseCol) =>
!!commentsPlugin.getCommentAtCell(rowIndex, hot.propToCol(baseCol)),
(rowIndex, prop) => hot.getDataAtRowProp(rowIndex, prop)
)
expect(toPreserve).toEqual([
{ rowIndex: 0, prop: 'FORMULA_HARD_COL', value: 10 }
])
for (const sc of sortConfigs) sortPlugin.sort(sc)
hot.destroy()
})
})
@@ -0,0 +1,31 @@
import { withoutEditStatus } from './withoutEditStatus'
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
describe('withoutEditStatus', () => {
it('removes the EDIT_STATUS column from a row', () => {
const row = {
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'original',
[EDIT_STATUS_COLUMN_NAME]: 'U'
}
expect(withoutEditStatus(row)).toEqual({
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'original'
})
})
it('leaves a row without the EDIT_STATUS column unchanged', () => {
const row = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
expect(withoutEditStatus(row)).toEqual(row)
})
it('does not mutate the row passed in', () => {
const row = { PRIMARY_KEY_FIELD: 1, [EDIT_STATUS_COLUMN_NAME]: 'U' }
withoutEditStatus(row)
expect(row[EDIT_STATUS_COLUMN_NAME]).toEqual('U')
})
})
@@ -0,0 +1,14 @@
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
/**
* Returns a copy of row without the client-only EDIT_STATUS column (see
* editStatusColumnRule.ts) - shared by classifyRow (which must ignore it
* when diffing, or writing its own prior output back would self-perpetuate)
* and by anywhere a row is sent to the backend (which must never receive
* it at all).
*/
export const withoutEditStatus = (row: any): any => {
const { [EDIT_STATUS_COLUMN_NAME]: _editStatus, ...rest } = row
return rest
}
+153 -130
View File
@@ -1,144 +1,167 @@
<app-sidebar class="sidebar-height">
<clr-tree>
<clr-tree-node *ngIf="groups" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="Filter by Groups"
name="input"
[(ngModel)]="groupSearch"
(keyup)="groupListOnFilter()"
autocomplete="off"
/>
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="groupSearch = ''; groupListOnFilter()"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
<ng-container *ngFor="let group of groups">
<clr-tree-node
(click)="groupOnClick(group)"
*ngIf="!group['hidden']"
[class.active]="group.GROUPURI === groupUri"
>
<p class="m-0 cursor-pointer list-padding">
<clr-icon shape="users"></clr-icon>
{{ group.GROUPNAME }}
</p>
@if (groups) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="Filter by Groups"
name="input"
[(ngModel)]="groupSearch"
(keyup)="groupListOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
(click)="groupSearch = ''; groupListOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
</ng-container>
}
@for (group of groups; track group) {
@if (!group['hidden']) {
<clr-tree-node
(click)="groupOnClick(group)"
[class.active]="group.GROUPURI === groupUri"
>
<p class="m-0 cursor-pointer list-padding">
<clr-icon shape="users"></clr-icon>
{{ group.GROUPNAME }}
</p>
</clr-tree-node>
}
}
</clr-tree>
</app-sidebar>
<div class="content-area">
<div *ngIf="loading" class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
<div *ngIf="groupMembers && !loading">
<div *ngIf="serverType !== ServerType.SasViya" class="clr-row">
<div class="clr-col-8">
<table class="table group-info">
<tbody>
<tr>
<td class="left">
<p class="group-info-text">
<b>{{ groupName }}</b>
</p>
</td>
</tr>
<tr>
<td class="left">
<i>{{ groupDesc || 'no description' }}</i>
</td>
</tr>
</tbody>
</table>
</div>
@if (loading) {
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
<div class="clr-row">
<div class="clr-col-8">
<div class="card group-data">
<div *ngIf="serverType !== ServerType.SasViya">
<h3>MEMBERS ({{ groupMemberCount }})</h3>
<h5 *ngIf="groupMemberCount == 0">No Members Present</h5>
<div class="table-container">
<table *ngIf="groupMemberCount != 0" class="table member-table">
<thead>
<tr>
<ng-container *ngIf="serverType === ServerType.Sas9">
<td class="width-25"><b>NAME</b></td>
<td class="width-25"><b>EMAIL</b></td>
<td class="width-25"><b>CREATED</b></td>
<td class=""><b>UPDATED</b></td>
</ng-container>
<ng-container *ngIf="serverType === ServerType.Sasjs">
<td class="width-25"><b>ID</b></td>
<td class="width-25"><b>DISPLAY NAME</b></td>
<td class="width-25"><b>USER NAME</b></td>
</ng-container>
</tr>
</thead>
<tbody>
<tr
[routerLink]="
'/view/usernav/users/' + (member.URIMEM || member.ID)
"
*ngFor="let member of groupMembers"
>
<ng-container *ngIf="serverType === ServerType.Sas9">
<td class="">{{ member.MEMBERNAME }}</td>
<td class="">{{ member.EMAIL }}</td>
<td class="">{{ member.MEMBERCREATED }}</td>
<td class="">{{ member.MEMBERUPDATED }}</td>
</ng-container>
<ng-container *ngIf="serverType === ServerType.Sasjs">
<td class="">{{ member.ID }}</td>
<td class="">{{ member.DISPLAYNAME }}</td>
<td class="">{{ member.USERNAME }}</td>
</ng-container>
</tr>
</tbody>
</table>
</div>
}
@if (groupMembers && !loading) {
<div>
@if (serverType !== ServerType.SasViya) {
<div class="clr-row">
<div class="clr-col-8">
<table class="table group-info">
<tbody>
<tr>
<td class="left">
<p class="group-info-text">
<b>{{ groupName }}</b>
</p>
</td>
</tr>
<tr>
<td class="left">
<i>{{ groupDesc || 'no description' }}</i>
</td>
</tr>
</tbody>
</table>
</div>
<div *ngIf="serverType === ServerType.SasViya">
<h3>{{ groupName }}</h3>
<h5 *ngIf="groupMemberCount == 0">No Members Present</h5>
<div class="table-container">
<table *ngIf="groupMemberCount != 0" class="table member-table">
<thead>
<tr>
<td>
<b>MEMBERS ({{ groupMemberCount }})</b>
</td>
</tr>
</thead>
<tbody>
<tr
[routerLink]="'/view/usernav/users/' + member.MEMBERID"
*ngFor="let member of groupMembers"
>
<td class="">{{ member.MEMBERNAME }}</td>
</tr>
</tbody>
</table>
</div>
</div>
}
<div class="clr-row">
<div class="clr-col-8">
<div class="card group-data">
@if (serverType !== ServerType.SasViya) {
<div>
<h3>MEMBERS ({{ groupMemberCount }})</h3>
@if (groupMemberCount == 0) {
<h5>No Members Present</h5>
}
<div class="table-container">
@if (groupMemberCount != 0) {
<table class="table member-table">
<thead>
<tr>
@if (serverType === ServerType.Sas9) {
<td class="width-25"><b>NAME</b></td>
<td class="width-25"><b>EMAIL</b></td>
<td class="width-25"><b>CREATED</b></td>
<td class=""><b>UPDATED</b></td>
}
@if (serverType === ServerType.Sasjs) {
<td class="width-25"><b>ID</b></td>
<td class="width-25"><b>DISPLAY NAME</b></td>
<td class="width-25"><b>USER NAME</b></td>
}
</tr>
</thead>
<tbody>
@for (member of groupMembers; track member) {
<tr
[routerLink]="
'/view/usernav/users/' +
(member.URIMEM || member.ID)
"
>
@if (serverType === ServerType.Sas9) {
<td class="">{{ member.MEMBERNAME }}</td>
<td class="">{{ member.EMAIL }}</td>
<td class="">{{ member.MEMBERCREATED }}</td>
<td class="">{{ member.MEMBERUPDATED }}</td>
}
@if (serverType === ServerType.Sasjs) {
<td class="">{{ member.ID }}</td>
<td class="">{{ member.DISPLAYNAME }}</td>
<td class="">{{ member.USERNAME }}</td>
}
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
@if (serverType === ServerType.SasViya) {
<div>
<h3>{{ groupName }}</h3>
@if (groupMemberCount == 0) {
<h5>No Members Present</h5>
}
<div class="table-container">
@if (groupMemberCount != 0) {
<table class="table member-table">
<thead>
<tr>
<td>
<b>MEMBERS ({{ groupMemberCount }})</b>
</td>
</tr>
</thead>
<tbody>
@for (member of groupMembers; track member) {
<tr
[routerLink]="
'/view/usernav/users/' + member.MEMBERID
"
>
<td class="">{{ member.MEMBERNAME }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
<br />
</div>
<br />
</div>
</div>
</div>
</div>
}
</div>
+143 -144
View File
@@ -1,159 +1,158 @@
<app-sidebar>
<clr-tree>
<clr-tree-node *ngIf="treeNodeLibraries?.length! > 0" class="search-node">
<div class="tree-search-wrapper">
<input
clrInput
appStealFocus
#searchLibTreeInput
placeholder="Libraries"
name="input"
[(ngModel)]="librariesSearch"
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
aria-hidden="true"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
aria-label="Clear libraries search"
></clr-icon>
</div>
</clr-tree-node>
<ng-container *ngFor="let library of treeNodeLibraries">
<clr-tree-node
(click)="treeNodeClicked($event, library); lib = library.LIBRARYREF"
*ngIf="!library['hidden']"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
>
<p
(click)="
lib = library.LIBRARYREF;
libraryOnClick(library.LIBRARYREF, library)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="rack-server" aria-hidden="true"></clr-icon>
{{ library.LIBRARYREF }}
</p>
<clr-tree-node *ngIf="library['tables']" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables')"
autocomplete="off"
/>
@if (treeNodeLibraries?.length! > 0) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
clrInput
appStealFocus
#searchLibTreeInput
placeholder="Libraries"
name="input"
[(ngModel)]="librariesSearch"
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search" aria-hidden="true"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchTreeInput.value.length < 1"
shape="search"
aria-hidden="true"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables')
"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
aria-label="Clear tables search"
aria-label="Clear libraries search"
></clr-icon>
</div>
</clr-tree-node>
<clr-tree-node
*ngFor="let libTable of library['tables']; let index = index"
>
<clr-tooltip
*ngVar="
index + 1 >
licenceState.value.tables_in_library_limit as tableLocked
"
>
<button
clrTooltipTrigger
(click)="!tableLocked ? onTableClick(libTable, library) : ''"
class="clr-treenode-link"
[class.dc-locked-control]="tableLocked"
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
>
<ng-container [ngSwitch]="libTable.includes('-FC')">
<clr-icon
*ngSwitchCase="true"
shape="bolt"
aria-hidden="true"
></clr-icon>
<clr-icon
*ngSwitchCase="false"
shape="table"
aria-hidden="true"
></clr-icon>
</ng-container>
{{ libTable.replace('-FC', '') }}
</button>
<ng-container *ngIf="tableLocked">
<clr-tooltip-content
clrPosition="bottom-right"
clrSize="lg"
*clrIfOpen
>
<span>
To unlock all tables, contact support&#64;datacontroller.io
</span>
</clr-tooltip-content>
</ng-container>
</clr-tooltip>
</clr-tree-node>
}
</div>
</clr-tree-node>
</ng-container>
}
@for (library of treeNodeLibraries; track library) {
@if (!library['hidden']) {
<clr-tree-node
(click)="treeNodeClicked($event, library); lib = library.LIBRARYREF"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
>
<p
(click)="
lib = library.LIBRARYREF;
libraryOnClick(library.LIBRARYREF, library)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="rack-server" aria-hidden="true"></clr-icon>
{{ library.LIBRARYREF }}
</p>
@if (library['tables']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search" aria-hidden="true"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables')
"
shape="times"
aria-label="Clear tables search"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (
libTable of library['tables'];
track libTable;
let index = $index
) {
<clr-tree-node>
<clr-tooltip
*ngVar="
index + 1 >
licenceState.value.tables_in_library_limit as tableLocked
"
>
<button
clrTooltipTrigger
(click)="!tableLocked ? onTableClick(libTable, library) : ''"
class="clr-treenode-link"
[class.dc-locked-control]="tableLocked"
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
>
@switch (libTable.includes('-FC')) {
@case (true) {
<clr-icon shape="bolt" aria-hidden="true"></clr-icon>
}
@case (false) {
<clr-icon shape="table" aria-hidden="true"></clr-icon>
}
}
{{ libTable.replace('-FC', '') }}
</button>
@if (tableLocked) {
<clr-tooltip-content
clrPosition="bottom-right"
clrSize="lg"
*clrIfOpen
>
<span>
To unlock all tables, contact
support&#64;datacontroller.io
</span>
</clr-tooltip-content>
}
</clr-tooltip>
</clr-tree-node>
}
</clr-tree-node>
}
}
</clr-tree>
</app-sidebar>
<main class="content-area">
<div class="card-block">
<div *ngIf="loading" class="spinner-wrapper-fullpage">
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
@if (loading) {
<div class="spinner-wrapper-fullpage">
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
</div>
</div>
}
<div *ngIf="!loading" class="no-table-selected">
<img
src="images/select-table.png"
class="select-table-icon"
alt="select table icon"
/>
<p
*ngIf="treeNodeLibraries?.length! > 0"
class="text-center color-gray mt-10"
cds-text="section"
>
Please select a table
</p>
<p
*ngIf="treeNodeLibraries?.length! < 1"
class="text-center color-gray mt-10"
cds-text="section"
>
No Editable Tables Configured
</p>
</div>
@if (!loading) {
<div class="no-table-selected">
<img
src="images/select-table.png"
class="select-table-icon"
alt="select table icon"
/>
@if (treeNodeLibraries?.length! > 0) {
<p class="text-center color-gray mt-10" cds-text="section">
Please select a table
</p>
}
@if (treeNodeLibraries?.length! < 1) {
<p class="text-center color-gray mt-10" cds-text="section">
No Editable Tables Configured
</p>
}
</div>
}
</div>
</main>
+136 -31
View File
@@ -5,6 +5,11 @@
<div class="card-text">
<ng-container *ngSwitchCase="'key'">
<p class="key-error" *ngIf="!keyError">
<clr-icon
class="is-error"
shape="exclamation-circle"
size="26"
></clr-icon>
Licence key is invalid. We can't provide you more details at the
moment
</p>
@@ -20,6 +25,11 @@
<ng-container *ngSwitchCase="'limit'">
<p class="key-error">
<clr-icon
class="is-error"
shape="exclamation-circle"
size="26"
></clr-icon>
The registered number of users reached the limit specified for your
licence. Please contact
<contact-link classes="color-green" />
@@ -34,9 +44,9 @@
</p>
</ng-container>
<p><strong>Protocol:</strong> {{ protocol }}</p>
<p class="m-0 mt-10"><strong>Protocol:</strong> {{ protocol }}</p>
<p>
<p class="m-0">
<strong>SYSSITE:</strong>
<span
*ngFor="let id of syssite.value; let i = index"
@@ -59,11 +69,6 @@
</a>
</p>
<p *ngIf="licenseKeyData && userCountLimitation" class="m-0">
<strong>Allowed users:</strong>
{{ licenseKeyData.users_allowed }}
</p>
<clr-tabs>
<clr-tab>
<button clrTabLink>Upload licence</button>
@@ -102,34 +107,102 @@
<clr-tab>
<button clrTabLink>Paste licence</button>
<clr-tab-content>
<form class="clr-form license-key-form">
<p>Licence key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="licenceKeyValue"
(mouseleave)="trimKeys()"
name="license-key-area"
placeholder="Paste licence key here"
class="clr-textarea"
></textarea>
</div>
</form>
<ng-container *ngIf="keyFormat === 'combined'">
<form class="clr-form combined-key-form">
<p>Licence key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="combinedKeyValue"
(mouseleave)="onCombinedKeyInput()"
name="combined-key-area"
placeholder="Paste licence key here"
class="clr-textarea"
></textarea>
</div>
</form>
<form class="clr-form activation-key-form">
<p>Activation key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="activationKeyValue"
(mouseleave)="trimKeys()"
name="activation-key-area"
placeholder="Paste activation key here"
class="clr-textarea"
></textarea>
</div>
</form>
<button
type="button"
class="btn btn-sm btn-link p-0"
(click)="toggleKeyFormat()"
>
Paste as two separate keys instead
</button>
</ng-container>
<ng-container *ngIf="keyFormat === 'legacy'">
<form class="clr-form license-key-form">
<p>Licence key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="licenceKeyValue"
(mouseleave)="trimKeys()"
name="license-key-area"
placeholder="Paste licence key here"
class="clr-textarea"
></textarea>
</div>
</form>
<form class="clr-form activation-key-form">
<p>Activation key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="activationKeyValue"
(mouseleave)="trimKeys()"
name="activation-key-area"
placeholder="Paste activation key here"
class="clr-textarea"
></textarea>
</div>
</form>
<button
type="button"
class="btn btn-sm btn-link p-0"
(click)="toggleKeyFormat()"
>
Paste as a single key instead
</button>
</ng-container>
</clr-tab-content>
</clr-tab>
</clr-tabs>
<!--
licenseKeyData starts as whatever key is currently active (set once
in ngOnInit), then live-updates to preview a pasted-but-not-yet-
applied key's own details instead - see refreshKeyPreview().
-->
<div *ngIf="licenseKeyData" class="key-details">
<h6 cds-text="subsection" class="mt-15 mb-10">Key Details</h6>
<p class="m-0">
<strong>Valid until:</strong>
{{ licenseKeyData.valid_until }}
<span *ngIf="licenseKeyData.demo">(demo/free tier key)</span>
</p>
<p class="m-0">
<strong>Allowed users:</strong>
{{ licenseKeyData.users_allowed }}
</p>
<p class="m-0">
<strong>Site ID(s) in this key:</strong>
{{
(licenseKeyData.site_id_multiple?.length
? licenseKeyData.site_id_multiple
: [licenseKeyData.site_id]
).join(', ')
}}
</p>
<p *ngIf="enabledFeatures.length" class="m-0">
<strong>Enabled features:</strong>
{{ enabledFeatures.join(', ') }}
</p>
</div>
</div>
<div class="card-footer d-flex clr-align-items-center">
@@ -150,6 +223,38 @@
Continue with free tier
</button>
</div>
<p
class="key-error protocol-mismatch-warning"
*ngIf="protocolMismatch === 'requiresHttps'"
>
<clr-icon
class="color-orange"
shape="exclamation-triangle"
size="26"
></clr-icon>
This key was generated for a secure (HTTPS) connection, but DataController
is currently running over HTTP - it will not activate here. Access
DataController via HTTPS, or contact
<contact-link classes="color-green" />
for an HTTP-compatible key.
</p>
<p
class="key-error protocol-mismatch-warning"
*ngIf="protocolMismatch === 'requiresHttp'"
>
<clr-icon
class="color-orange"
shape="exclamation-triangle"
size="26"
></clr-icon>
This key was generated for an insecure (HTTP) connection, but this page is
running in a secure browsing context (HTTPS, or localhost) - it will not
activate here. Contact
<contact-link classes="color-green" />
for an HTTPS-compatible key.
</p>
</div>
</div>
@@ -0,0 +1,7 @@
.key-error clr-icon {
// clr-icon defaults to vertical-align: middle, which sits it noticeably
// above the text baseline next to it - align its bottom edge with the
// text's instead.
vertical-align: bottom;
margin-right: 10px;
}
+149 -14
View File
@@ -1,8 +1,18 @@
import { Component, OnInit, ViewEncapsulation } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { ActivatedRoute, Router } from '@angular/router'
import { AppService, LicenceService, SasService } from '../services'
import { LicenseKeyData } from '../models/LicenseKeyData'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import {
detectLicenceKeyProtocolMismatch,
LicenceKeyProtocolMismatch
} from './utils/detectLicenceKeyProtocolMismatch'
import {
isCombinedLicenceKey,
splitCombinedLicenceKey
} from './utils/combinedLicenceKey'
import { getEnabledFeatureLabels } from './utils/getEnabledFeatureLabels'
enum LicenseActions {
key = 'key',
@@ -21,12 +31,19 @@ enum LicenseActions {
export class LicensingComponent implements OnInit {
public action: LicenseActions | null = null
public licenseErrors: { [key: string]: string } = {
missing: `Licence key is missing - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
expired: `Licence key is expired - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
invalid: `Licence key is invalid - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
missmatch: `Your SYSSITE (below) is not found in the licence key - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
}
// Each message carries its own icon markup, rather than relying on a
// sibling element in the template - these render via [innerHTML], which
// replaces all of the host element's children, so a real <clr-icon>
// placed in the template around the binding would never survive.
private readonly errorIcon = `<clr-icon class="is-error" shape="exclamation-circle" size="26"></clr-icon>`
// SafeHtml, built via bypassSecurityTrustHtml in the constructor (needs
// DomSanitizer, not yet available in a field initializer) - Angular's
// default [innerHTML] sanitizer strips unrecognised tags like <clr-icon>
// (a custom element, not on its standard-tags allowlist), even though it
// leaves the plain <a> in these same strings alone. Safe to bypass since
// this content is entirely hardcoded here, never derived from user input.
public licenseErrors: { [key: string]: SafeHtml }
public keyError: string | undefined
public errorDetails: string | undefined
@@ -34,6 +51,12 @@ export class LicensingComponent implements OnInit {
public licenceKeyValue: string = ''
public activationKeyValue: string = ''
// Purely which INPUT LAYOUT is shown (one field vs two) - not a strict
// parsing gate. Recognising a combined key doesn't depend on this: see
// maybeSplitCombinedKey(), called from both layouts' own input handlers.
public keyFormat: 'combined' | 'legacy' = 'combined'
public combinedKeyValue: string = ''
public applyingKeys: boolean = false
public protocol: string =
location.protocol === 'https:'
@@ -44,7 +67,6 @@ export class LicensingComponent implements OnInit {
public currentLicenceKey = this.licenceService.licenceKey
public currentActivationKey = this.licenceService.activationKey
public isAppFreeTier = this.licenceService.isAppFreeTier
public userCountLimitation = this.licenceService.userCountLimitation
public licenseKeyData: LicenseKeyData | null = null
@@ -60,8 +82,24 @@ export class LicensingComponent implements OnInit {
private router: Router,
private licenceService: LicenceService,
private sasService: SasService,
private appService: AppService
) {}
private appService: AppService,
private sanitizer: DomSanitizer
) {
this.licenseErrors = {
missing: this.sanitizer.bypassSecurityTrustHtml(
`${this.errorIcon} Licence key is missing - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
),
expired: this.sanitizer.bypassSecurityTrustHtml(
`${this.errorIcon} Licence key is expired - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
),
invalid: this.sanitizer.bypassSecurityTrustHtml(
`${this.errorIcon} Licence key is invalid - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
),
missmatch: this.sanitizer.bypassSecurityTrustHtml(
`${this.errorIcon} Your SYSSITE (below) is not found in the licence key - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
)
}
}
ngOnInit(): void {
this.licenceKeyValue = this.currentLicenceKey || ''
@@ -89,9 +127,73 @@ export class LicensingComponent implements OnInit {
this.licenseKeyData = this.licenceService.getLicenseKeyData()
}
public trimKeys() {
public async trimKeys() {
this.licenceKeyValue = this.licenceKeyValue.trim()
this.activationKeyValue = this.activationKeyValue.trim()
// Auto-detect regardless of keyFormat - a combined key pasted into the
// legacy "Licence key" field on its own still gets recognised and
// splits into both fields, rather than requiring the toggle to match.
await this.maybeSplitCombinedKey(this.licenceKeyValue)
await this.refreshKeyPreview()
}
public async onCombinedKeyInput() {
this.combinedKeyValue = this.combinedKeyValue.trim()
// The combined field is the sole source of truth for
// licenceKeyValue/activationKeyValue in this layout - if it's been
// cleared (or isn't a recognisable combined key), there's no key data
// to preview, so clear rather than leave a stale split from an earlier
// paste (which would otherwise keep decrypting to that old key).
const wasSplit = await this.maybeSplitCombinedKey(this.combinedKeyValue)
if (!wasSplit) {
this.licenceKeyValue = ''
this.activationKeyValue = ''
}
await this.refreshKeyPreview()
}
// Decrypts whatever's currently in licenceKeyValue/activationKeyValue
// purely to preview its details (valid_until, users_allowed, ...) before
// the user ever clicks Apply - decryptLicenseKey() has no side effects,
// so this is safe to call speculatively on every input change. Clears
// rather than leaves stale data on failure/incomplete input, since
// showing a previous key's details next to a since-changed paste would
// be actively misleading, not just uninformative.
private async refreshKeyPreview(): Promise<void> {
if (!this.licenceKeyValue || !this.activationKeyValue) {
this.licenseKeyData = null
return
}
try {
this.licenseKeyData = await this.licenceService.decryptLicenseKey(
this.licenceKeyValue,
this.activationKeyValue
)
} catch {
this.licenseKeyData = null
}
}
public toggleKeyFormat() {
this.keyFormat = this.keyFormat === 'combined' ? 'legacy' : 'combined'
}
// Shared by both input layouts' own handlers above - populates
// licenceKeyValue/activationKeyValue when value is recognisably a
// combined key, regardless of which field it was typed/pasted into.
private async maybeSplitCombinedKey(value: string): Promise<boolean> {
if (!isCombinedLicenceKey(value)) return false
const split = await splitCombinedLicenceKey(value)
if (!split) return false
this.licenceKeyValue = split.licenceKey
this.activationKeyValue = split.activationKey
return true
}
public copySyssite(copyIconRef: any, copyTooltip: any, syssite: string[]) {
@@ -150,7 +252,7 @@ export class LicensingComponent implements OnInit {
const reader = new FileReader()
reader.onload = (evt) => {
reader.onload = async (evt) => {
this.licenceFileError = 'Error reading file.'
if (!evt || !evt.target) return
@@ -160,9 +262,20 @@ export class LicensingComponent implements OnInit {
this.licenceFileLoading = false
this.licenceFileError = undefined
const fileArr = evt.target.result.toString().split('\n')
this.activationKeyValue = fileArr[1]
this.licenceKeyValue = fileArr[0]
const fileArr = evt.target.result.toString().trim().split('\n')
// A combined-key file is a single line - splits into both fields.
// Anything else keeps the existing 2-line legacy file behaviour.
if (
fileArr.length !== 1 ||
!(await this.maybeSplitCombinedKey(fileArr[0]))
) {
this.activationKeyValue = fileArr[1]
this.licenceKeyValue = fileArr[0]
}
await this.refreshKeyPreview()
}
reader.readAsText(file)
@@ -180,7 +293,29 @@ export class LicensingComponent implements OnInit {
this.activationKeyValue === this.currentActivationKey
)
return true
// A protocol mismatch is a deterministic predictor of decrypt failure
// (see detectLicenceKeyProtocolMismatch's own doc comment) - submitting
// anyway would just trade this specific warning for a generic "invalid
// key" error after a round trip to the backend.
if (this.protocolMismatch) return true
return false
}
// Warns before the user even submits - covers both the file-upload and
// paste tabs, since both feed licenceKeyValue/activationKeyValue. Passes
// actual WebCrypto availability, not location.protocol - see
// detectLicenceKeyProtocolMismatch's own doc comment for why (localhost
// is a secure context even over http).
get protocolMismatch(): LicenceKeyProtocolMismatch {
return detectLicenceKeyProtocolMismatch(
this.licenceKeyValue,
this.activationKeyValue,
!!(window.crypto && window.crypto.subtle)
)
}
get enabledFeatures(): string[] {
return getEnabledFeatureLabels(this.licenseKeyData?.features)
}
}
@@ -0,0 +1,90 @@
import * as base64Converter from 'base64-arraybuffer'
import {
COMBINED_KEY_PREFIX,
isCombinedLicenceKey,
splitCombinedLicenceKey
} from './combinedLicenceKey'
// Mirrors dckey's own encodeCombinedKey() (main.js) exactly, so these tests
// build fixtures the same way a real generated key would be produced,
// rather than relying on a hardcoded string that could go stale if either
// side's algorithm ever changes.
const gzipCompress = async (bytes: Uint8Array): Promise<ArrayBuffer> => {
const compressionStream = new CompressionStream('gzip')
const writer = compressionStream.writable.getWriter()
writer.write(new Uint8Array(bytes))
writer.close()
return new Response(compressionStream.readable).arrayBuffer()
}
const encodeCombinedLicenceKeyFixture = async (
licenceKey: string,
activationKey: string
): Promise<string> => {
const payloadBytes = new TextEncoder().encode(
`${licenceKey} ${activationKey}`
)
const compressedBytes = await gzipCompress(payloadBytes)
return COMBINED_KEY_PREFIX + base64Converter.encode(compressedBytes)
}
describe('isCombinedLicenceKey', () => {
it('is true for prefixed text', () => {
expect(isCombinedLicenceKey('DCKEY1:abc123')).toBeTrue()
})
it('is false for a plain legacy key', () => {
expect(isCombinedLicenceKey('some-legacy-licence-key')).toBeFalse()
})
it('is false for an empty string', () => {
expect(isCombinedLicenceKey('')).toBeFalse()
})
it('is true even with leading/trailing whitespace around the prefix', () => {
expect(isCombinedLicenceKey(' DCKEY1:abc123 ')).toBeTrue()
})
})
describe('splitCombinedLicenceKey', () => {
it('round-trips a fixture built the same way dckey encodes one', async () => {
const combined = await encodeCombinedLicenceKeyFixture(
'licence-key-value',
'activation-key-value'
)
expect(await splitCombinedLicenceKey(combined)).toEqual({
licenceKey: 'licence-key-value',
activationKey: 'activation-key-value'
})
})
it('returns null for text without the prefix', async () => {
expect(
await splitCombinedLicenceKey('a-plain-legacy-licence-key')
).toBeNull()
})
it('rejects for prefixed text that is not valid compressed data', async () => {
await expectAsync(
splitCombinedLicenceKey('DCKEY1:not-valid-base64-gzip-data')
).toBeRejected()
})
it('splits on the first space only, not every space', async () => {
// Not a real-world value today (licence/activation keys are base64,
// which never contains a space) - guards the split logic itself rather
// than that assumption.
const combined = await encodeCombinedLicenceKeyFixture(
'licence key with spaces',
'activation-key-value'
)
expect(await splitCombinedLicenceKey(combined)).toEqual({
licenceKey: 'licence',
activationKey: 'key with spaces activation-key-value'
})
})
})
@@ -0,0 +1,83 @@
import * as base64Converter from 'base64-arraybuffer'
/**
* Format produced by dckey's own encodeCombinedKey() (main.js):
* "DCKEY1:" + base64(gzip(licenceKey + " " + activationKey)). The prefix is
* plain ASCII, not itself compressed/encoded - base64's alphabet never
* contains ":", so it can never collide with the start of a legacy licence
* key or activation key, making format detection unambiguous.
*/
export const COMBINED_KEY_PREFIX = 'DCKEY1:'
export const isCombinedLicenceKey = (text: string): boolean =>
text.trim().startsWith(COMBINED_KEY_PREFIX)
const streamToArrayBuffer = async (
readable: ReadableStream<Uint8Array>
): Promise<ArrayBuffer> => {
const reader = readable.getReader()
const chunks: Uint8Array[] = []
let totalLength = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
totalLength += value.length
}
const result = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.length
}
return result.buffer
}
const gzipDecompress = async (bytes: ArrayBuffer): Promise<ArrayBuffer> => {
const decompressionStream = new DecompressionStream('gzip')
const writer = decompressionStream.writable.getWriter()
// Awaited (unlike a fire-and-forget write) so invalid gzip data rejects
// through this function's own returned promise, not as a separate
// unhandled rejection racing the readable side below.
const result = await Promise.all([
writer.write(new Uint8Array(bytes)).then(() => writer.close()),
streamToArrayBuffer(decompressionStream.readable)
])
return result[1]
}
/**
* Splits a combined licence key back into its two parts. Returns null (not
* a rejected promise) when text isn't a combined key at all, so callers can
* use it as a "try this, then fall back to the legacy two-field input" check
* without a try/catch for that common case - a prefixed-but-corrupted
* string still rejects, since that's a real error, not a format mismatch.
*/
export const splitCombinedLicenceKey = async (
text: string
): Promise<{ licenceKey: string; activationKey: string } | null> => {
const trimmed = text.trim()
if (!isCombinedLicenceKey(trimmed)) return null
const compressedBytes = base64Converter.decode(
trimmed.slice(COMBINED_KEY_PREFIX.length)
)
const decompressedBytes = await gzipDecompress(compressedBytes)
const payload = new TextDecoder().decode(decompressedBytes)
const separatorIndex = payload.indexOf(' ')
if (separatorIndex === -1) {
throw new Error('Invalid combined licence key: missing separator')
}
return {
licenceKey: payload.slice(0, separatorIndex),
activationKey: payload.slice(separatorIndex + 1)
}
}
@@ -0,0 +1,61 @@
import { detectLicenceKeyProtocolMismatch } from './detectLicenceKeyProtocolMismatch'
describe('detectLicenceKeyProtocolMismatch', () => {
// A key generated for an insecure connection always carries the same
// text in both fields; a key generated for a secure connection always
// carries two different values.
const httpsFormatLicenceKey = 'licence-key-value-abc123'
const httpsFormatActivationKey = 'activation-key-value-xyz789'
const httpFormatKey = 'same-value-for-both-fields'
it('flags an HTTPS-format key used where WebCrypto is unavailable', () => {
expect(
detectLicenceKeyProtocolMismatch(
httpsFormatLicenceKey,
httpsFormatActivationKey,
false
)
).toEqual('requiresHttps')
})
it('does not flag an HTTPS-format key where WebCrypto is available', () => {
expect(
detectLicenceKeyProtocolMismatch(
httpsFormatLicenceKey,
httpsFormatActivationKey,
true
)
).toBeNull()
})
it('flags an HTTP-format key where WebCrypto is available - e.g. localhost, which browsers always treat as a secure context even over plain http', () => {
expect(
detectLicenceKeyProtocolMismatch(httpFormatKey, httpFormatKey, true)
).toEqual('requiresHttp')
})
it('does not flag an HTTP-format key where WebCrypto is unavailable', () => {
expect(
detectLicenceKeyProtocolMismatch(httpFormatKey, httpFormatKey, false)
).toBeNull()
})
it('returns null when either key is empty', () => {
expect(
detectLicenceKeyProtocolMismatch('', httpsFormatActivationKey, false)
).toBeNull()
expect(
detectLicenceKeyProtocolMismatch(httpsFormatLicenceKey, '', false)
).toBeNull()
})
it('trims whitespace before comparing', () => {
expect(
detectLicenceKeyProtocolMismatch(
` ${httpFormatKey} `,
`${httpFormatKey}\n`,
true
)
).toEqual('requiresHttp')
})
})
@@ -0,0 +1,41 @@
export type LicenceKeyProtocolMismatch = 'requiresHttps' | 'requiresHttp' | null
/**
* Detects a licence key generated for one protocol (http/https) being
* pasted/uploaded on a page where the other one is actually in effect -
* before any decrypt attempt, purely from the raw key text.
*
* A key generated for an insecure connection always carries the same text
* in both the licence key and activation key fields; a key generated for a
* secure connection always carries two different values. That distinction
* alone tells us which format a pasted key is, without needing to attempt
* decryption first.
*
* This is a structural assumption about key-generation logic
* (a separate repo) - if that ever changes to generate distinct values for
* HTTP-format keys too, this detection would silently misclassify keys.
*
* isSecureContext must reflect whether this browsing context can actually
* decrypt a secure-connection key (mirrors the check licence.service.ts's
* own decryptLicenseKey() makes) - not simply `location.protocol ===
* 'https:'`. Browsers treat localhost as a secure context even over plain
* http, so a key meant for an insecure connection still fails to decrypt
* there, exactly like it would on a real https page.
*/
export const detectLicenceKeyProtocolMismatch = (
licenceKey: string,
activationKey: string,
isSecureContext: boolean
): LicenceKeyProtocolMismatch => {
const trimmedLicenceKey = licenceKey.trim()
const trimmedActivationKey = activationKey.trim()
if (!trimmedLicenceKey || !trimmedActivationKey) return null
const isHttpsFormatKey = trimmedLicenceKey !== trimmedActivationKey
if (isHttpsFormatKey && !isSecureContext) return 'requiresHttps'
if (!isHttpsFormatKey && isSecureContext) return 'requiresHttp'
return null
}
@@ -0,0 +1,73 @@
import { getEnabledFeatureLabels } from './getEnabledFeatureLabels'
describe('getEnabledFeatureLabels', () => {
it('returns an empty list when features is undefined', () => {
expect(getEnabledFeatureLabels(undefined)).toEqual([])
})
it('lists only the enabled toggle features from a new-format object', () => {
expect(
getEnabledFeatureLabels({
vra: null,
era: null,
sra: null,
hra: null,
srl: null,
till: null,
vb: true,
vbl: null,
ldl: null,
fu: false,
er: true,
ar: false
})
).toEqual(['Viewbox', 'Edit Record'])
})
it('lists all four when every toggle is enabled', () => {
expect(
getEnabledFeatureLabels({
vra: null,
era: null,
sra: null,
hra: null,
srl: null,
till: null,
vb: true,
vbl: null,
ldl: null,
fu: true,
er: true,
ar: true
})
).toEqual(['Viewbox', 'File Upload', 'Edit Record', 'Add Record'])
})
it('returns an empty list when every toggle is disabled', () => {
expect(
getEnabledFeatureLabels({
vra: null,
era: null,
sra: null,
hra: null,
srl: null,
till: null,
vb: false,
vbl: null,
ldl: null,
fu: false,
er: false,
ar: false
})
).toEqual([])
})
it('decodes a legacy positional string the same way', () => {
// viewbox=1 (position 6), fileUpload=0 (position 9), editRecord=1
// (position 10), addRecord=0 (position 11) - see LicenceFeaturesMap.
expect(getEnabledFeatureLabels('-,-,-,-,-,-,1,-,-,0,1,0')).toEqual([
'Viewbox',
'Edit Record'
])
})
})
@@ -0,0 +1,24 @@
import {
decodeLicenceFeatures,
LicenceFeaturesObject
} from '../../services/utils/decodeLicenceFeatures'
import { LicenceState } from '../../models/LicenceState'
const TOGGLE_FEATURE_LABELS: { key: keyof LicenceState; label: string }[] = [
{ key: 'viewbox', label: 'Viewbox' },
{ key: 'fileUpload', label: 'File Upload' },
{ key: 'editRecord', label: 'Edit Record' },
{ key: 'addRecord', label: 'Add Record' }
]
export const getEnabledFeatureLabels = (
features: string | LicenceFeaturesObject | undefined
): string[] => {
if (!features) return []
const decoded = decodeLicenceFeatures(features)
return TOGGLE_FEATURE_LABELS.filter(({ key }) => decoded[key]).map(
({ label }) => label
)
}
+251 -242
View File
@@ -1,168 +1,176 @@
<app-sidebar (scrolledToBottom)="loadMoreLibraries()">
<clr-tree>
<clr-tree-node *ngIf="libraryList" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="Libraries"
name="input"
[(ngModel)]="librariesSearch"
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
<ng-container *ngFor="let library of libraryList">
<clr-tree-node
(click)="treeNodeClicked($event, library, libraryList)"
*ngIf="!library['hidden'] && library['inForeground']"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
>
<p
(click)="lib = library.LIBRARYID; libraryOnClick(lib || '', library)"
class="m-0 cursor-pointer"
>
<clr-icon shape="rack-server"></clr-icon>
{{ library.LIBRARYNAME }}
</p>
<clr-tree-node *ngIf="library['tables']" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables.TABLENAME')"
autocomplete="off"
/>
@if (libraryList) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="Libraries"
name="input"
[(ngModel)]="librariesSearch"
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables.TABLENAME')
"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
}
</div>
</clr-tree-node>
}
@for (library of libraryList; track library) {
@if (!library['hidden'] && library['inForeground']) {
<clr-tree-node
(click)="treeNodeClicked($event, libTable, library['tables'])"
*ngFor="let libTable of library['tables']"
[(clrExpanded)]="libTable['expanded']"
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
[class.clr-expanded]="libTable['expanded']"
(click)="treeNodeClicked($event, library, libraryList)"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
>
<p
(click)="tableOnClick(libTable.TABLEURI, libTable, library)"
[id]="libTable.TABLEURI"
(click)="
lib = library.LIBRARYID; libraryOnClick(lib || '', library)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="table"></clr-icon>
{{ libTable.TABLENAME }}
<clr-icon shape="rack-server"></clr-icon>
{{ library.LIBRARYNAME }}
</p>
<clr-tree-node *ngIf="libTable['columns']" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Columns"
name="input"
[(ngModel)]="libTable['searchString']"
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
autocomplete="off"
/>
<clr-icon
*ngIf="searchTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
libTable['searchString'] = '';
treeOnFilter(libTable, 'columns.COLNAME')
"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
<clr-tree-node *ngFor="let libColumn of libTable['columns']">
<button
(click)="columnOnClick(libColumn, library, libTable)"
class="clr-treenode-link"
[class.column-active]="libColumnActive(libColumn.COLURI)"
@if (library['tables']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables.TABLENAME')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables.TABLENAME')
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (libTable of library['tables']; track libTable) {
<clr-tree-node
(click)="treeNodeClicked($event, libTable, library['tables'])"
[(clrExpanded)]="libTable['expanded']"
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
[class.clr-expanded]="libTable['expanded']"
>
<clr-icon shape="objects"></clr-icon>
{{ libColumn.COLNAME }}
</button>
</clr-tree-node>
<p
(click)="tableOnClick(libTable.TABLEURI, libTable, library)"
[id]="libTable.TABLEURI"
class="m-0 cursor-pointer"
>
<clr-icon shape="table"></clr-icon>
{{ libTable.TABLENAME }}
</p>
@if (libTable['columns']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Columns"
name="input"
[(ngModel)]="libTable['searchString']"
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
(click)="
searchTreeInput.value = '';
libTable['searchString'] = '';
treeOnFilter(libTable, 'columns.COLNAME')
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (libColumn of libTable['columns']; track libColumn) {
<clr-tree-node>
<button
(click)="columnOnClick(libColumn, library, libTable)"
class="clr-treenode-link"
[class.column-active]="libColumnActive(libColumn.COLURI)"
>
<clr-icon shape="objects"></clr-icon>
{{ libColumn.COLNAME }}
</button>
</clr-tree-node>
}
</clr-tree-node>
}
</clr-tree-node>
</clr-tree-node>
</ng-container>
}
}
</clr-tree>
<div *ngIf="librariesPaging" class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div>
@if (librariesPaging) {
<div class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div>
}
</app-sidebar>
<div class="content-area">
<div class="card">
<div *ngIf="!column && !table" class="no-table-selected">
<clr-icon
shape="warning-standard"
size="60"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a column or table
</p>
</div>
<ng-container *ngIf="column || table">
<div
*ngIf="!graphContainer"
class="card-header d-flex flex-column justify-content-center"
>
<h3
*ngIf="!currentLineagePathColumn && !currentLineagePathLibTable"
class="text-center pb-10"
>
{{
currentLineagePathColumn
? currentLineagePathLibTable + '.' + currentLineagePathColumn
: currentLineagePathLibTable
}}
</h3>
@if (!column && !table) {
<div class="no-table-selected">
<clr-icon
shape="warning-standard"
size="60"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a column or table
</p>
</div>
}
@if (column || table) {
@if (!graphContainer) {
<div class="card-header d-flex flex-column justify-content-center">
@if (!currentLineagePathColumn && !currentLineagePathLibTable) {
<h3 class="text-center pb-10">
{{
currentLineagePathColumn
? currentLineagePathLibTable + '.' + currentLineagePathColumn
: currentLineagePathLibTable
}}
</h3>
}
</div>
}
<div class="card-block">
<section
class="form-block sw position-relative d-flex align-items-center"
@@ -170,114 +178,115 @@
<div
class="linage-title-wrapper d-flex align-items-center font-weight-bold position-absolute"
>
<span *ngIf="lineageTableName.length > 0">
{{ lineageTableName.split('.')[0] }}.<a
[routerLink]="'/view/data/' + lineageTableName"
>{{ lineageTableName.split('.')[1] }}</a
>{{ lineageColumnName.length > 0 ? '.' + lineageColumnName : '' }}
</span>
@if (lineageTableName.length > 0) {
<span>
{{ lineageTableName.split('.')[0] }}.<a
[routerLink]="'/view/data/' + lineageTableName"
>{{ lineageTableName.split('.')[1] }}</a
>{{
lineageColumnName.length > 0 ? '.' + lineageColumnName : ''
}}
</span>
}
</div>
<div
*ngIf="graphContainer"
class="clr-col-md-12 text-center d-flex justify-content-end"
>
<button
(click)="limitDotDepth = true"
type="button"
class="btn btn-outline mr-5"
>
Limit depth
</button>
<!-- <button class="btn btn-outline" (click)='showSvg()'> Open in New Tab </button> -->
<div class="btn-group direction d-block">
<div
class="radio btn"
(click)="
forwardLineage = false;
router.url.includes('column')
? onGenerateClick()
: onGenerateGraphTableClick()
"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="!forwardLineage"
/>
<label>Backward</label>
</div>
<div
class="radio btn"
(click)="
forwardLineage = true;
router.url.includes('column')
? onGenerateClick()
: onGenerateGraphTableClick()
"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="forwardLineage"
/>
<label>Forward</label>
</div>
</div>
<clr-dropdown class="mr-10">
@if (graphContainer) {
<div class="clr-col-md-12 text-center d-flex justify-content-end">
<button
class="btn btn-info-outline"
clrDropdownTrigger
[disabled]="!column && !table"
(click)="limitDotDepth = true"
type="button"
class="btn btn-outline mr-5"
>
Download
<clr-icon shape="caret down"></clr-icon>
Limit depth
</button>
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
<div (click)="downloadSVG()" clrDropdownItem>SVG</div>
<div (click)="downloadPNG()" clrDropdownItem>PNG</div>
<div (click)="downloadDot()" clrDropdownItem>Dot</div>
<div *ngIf="flatdata" (click)="downloadCSV()" clrDropdownItem>
CSV
<!-- <button class="btn btn-outline" (click)='showSvg()'> Open in New Tab </button> -->
<div class="btn-group direction d-block">
<div
class="radio btn"
(click)="
forwardLineage = false;
router.url.includes('column')
? onGenerateClick()
: onGenerateGraphTableClick()
"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="!forwardLineage"
/>
<label>Backward</label>
</div>
<div
class="radio btn"
(click)="
forwardLineage = true;
router.url.includes('column')
? onGenerateClick()
: onGenerateGraphTableClick()
"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="forwardLineage"
/>
<label>Forward</label>
</div>
</clr-dropdown-menu>
</clr-dropdown>
<clr-checkbox-wrapper
*ngIf="column"
class="d-flex align-items-center"
>
<input
type="checkbox"
(change)="generateGraph()"
clrCheckbox
name="refreshCache"
[(ngModel)]="refreshCache"
/>
<label>Refresh Cache</label>
</clr-checkbox-wrapper>
</div>
</section>
<div *ngIf="graphContainer" [class.mt-2]="tableFlag">
<div class="text-center">
<span *ngIf="graphLoading" class="spinner"> Loading... </span>
<div *ngIf="!graphLoading" class="position-relative">
<div class="graph-render-spinner">
<span *ngIf="graphRendering" class="spinner spinner-sm"></span>
</div>
<div id="graph"></div>
<clr-dropdown class="mr-10">
<button
class="btn btn-info-outline"
clrDropdownTrigger
[disabled]="!column && !table"
>
Download
<clr-icon shape="caret down"></clr-icon>
</button>
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
<div (click)="downloadSVG()" clrDropdownItem>SVG</div>
<div (click)="downloadPNG()" clrDropdownItem>PNG</div>
<div (click)="downloadDot()" clrDropdownItem>Dot</div>
@if (flatdata) {
<div (click)="downloadCSV()" clrDropdownItem>CSV</div>
}
</clr-dropdown-menu>
</clr-dropdown>
@if (column) {
<clr-checkbox-wrapper class="d-flex align-items-center">
<input
type="checkbox"
(change)="generateGraph()"
clrCheckbox
name="refreshCache"
[(ngModel)]="refreshCache"
/>
<label>Refresh Cache</label>
</clr-checkbox-wrapper>
}
</div>
}
</section>
@if (graphContainer) {
<div [class.mt-2]="tableFlag">
<div class="text-center">
@if (graphLoading) {
<span class="spinner"> Loading... </span>
}
@if (!graphLoading) {
<div class="position-relative">
<div class="graph-render-spinner">
@if (graphRendering) {
<span class="spinner spinner-sm"></span>
}
</div>
<div id="graph"></div>
</div>
}
</div>
</div>
</div>
}
</div>
</ng-container>
}
</div>
</div>
+218 -188
View File
@@ -8,58 +8,60 @@
[(ngModel)]="repository"
(change)="updateSelectedRepository()"
>
<option
*ngFor="let repository of repositories"
value="{{ repository }}"
>
{{ repository }}
</option>
@for (repository of repositories; track repository) {
<option value="{{ repository }}">
{{ repository }}
</option>
}
</select>
</clr-select-container>
</div>
<clr-tree>
<clr-tree-node *ngIf="metaDataList" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="search SAS Types"
name="input"
[(ngModel)]="metaDataSearch"
(keyup)="metaListOnFilter()"
autocomplete="off"
/>
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="metaDataSearch = ''; metaListOnFilter()"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
<ng-container *ngFor="let metaData of metaDataList">
<clr-tree-node
(click)="treeNodeClicked($event, metaData, metaDataList)"
*ngIf="!metaData['hidden']"
[(clrExpanded)]="metaData['expanded']"
[clrLoading]="metaData['loadingTables'] && !metaData.tables"
>
<p
(click)="
metaDataId = metaData.ID; metaDataOnClick(metaDataId, metaData)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="block"></clr-icon>
{{ metaData.ID }}
</p>
@if (metaDataList) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchLibTreeInput
placeholder="search SAS Types"
name="input"
[(ngModel)]="metaDataSearch"
(keyup)="metaListOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
(click)="metaDataSearch = ''; metaListOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
</ng-container>
}
@for (metaData of metaDataList; track metaData) {
@if (!metaData['hidden']) {
<clr-tree-node
(click)="treeNodeClicked($event, metaData, metaDataList)"
[(clrExpanded)]="metaData['expanded']"
[clrLoading]="metaData['loadingTables'] && !metaData.tables"
>
<p
(click)="
metaDataId = metaData.ID; metaDataOnClick(metaDataId, metaData)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="block"></clr-icon>
{{ metaData.ID }}
</p>
</clr-tree-node>
}
}
</clr-tree>
</app-sidebar>
@@ -67,54 +69,121 @@
<div class="card background-transparent-i">
<h3 class="color-gray">{{ assoTypeSelected }}</h3>
<div *ngIf="!loading && !metaObjectList" class="no-table-selected">
<clr-icon
shape="warning-standard"
size="60"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a type
</p>
</div>
<div class="loadingSpinner" *ngIf="loading">
<span class="spinner"> Loading... </span>
<div *ngIf="loading">
<h4 *ngIf="metatypesLoading">Loading metadata types</h4>
<h4 *ngIf="!metatypesLoading">Loading metadata objects</h4>
@if (!loading && !metaObjectList) {
<div class="no-table-selected">
<clr-icon
shape="warning-standard"
size="60"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a type
</p>
</div>
</div>
<div *ngIf="showData" class="clr-row clr-flex-grow-1">
<div class="clr-col-6">
<div *ngIf="metaObjectList && !objectRoute" class="search-input">
<input
clrInput
#searchObjTreeInput
placeholder="search"
name="input"
[(ngModel)]="metaObjectSearch"
(keyup)="metaObjectOnFilter()"
autocomplete="off"
/>
</div>
<br />
<div *ngIf="!objectView" class="objects-col">
<clr-accordion>
<ng-container *ngFor="let metaObject of metaObjectShowList">
<clr-accordion-panel
(clrAccordionPanelOpenChange)="
$event ? panelChange($event, metaObject) : ''
"
*ngIf="!metaObject['hidden']"
>
<clr-accordion-title
><clr-icon shape="rack-server"></clr-icon>
{{ metaObject.NAME }}
<p class="float-right">{{ metaObject.ID }}</p>
</clr-accordion-title>
<clr-accordion-content *clrIfExpanded>
<clr-tree *ngIf="showAcc" [clrLazy]="true">
}
@if (loading) {
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
@if (loading) {
<div>
@if (metatypesLoading) {
<h4>Loading metadata types</h4>
}
@if (!metatypesLoading) {
<h4>Loading metadata objects</h4>
}
</div>
}
</div>
}
@if (showData) {
<div class="clr-row clr-flex-grow-1">
<div class="clr-col-6">
@if (metaObjectList && !objectRoute) {
<div class="search-input">
<input
clrInput
#searchObjTreeInput
placeholder="search"
name="input"
[(ngModel)]="metaObjectSearch"
(keyup)="metaObjectOnFilter()"
autocomplete="off"
/>
</div>
}
<br />
@if (!objectView) {
<div class="objects-col">
<clr-accordion>
@for (metaObject of metaObjectShowList; track metaObject) {
@if (!metaObject['hidden']) {
<clr-accordion-panel
(clrAccordionPanelOpenChange)="
$event ? panelChange($event, metaObject) : ''
"
>
<clr-accordion-title
><clr-icon shape="rack-server"></clr-icon>
{{ metaObject.NAME }}
<p class="float-right">{{ metaObject.ID }}</p>
</clr-accordion-title>
<clr-accordion-content *clrIfExpanded>
@if (showAcc) {
<clr-tree [clrLazy]="true">
<clr-tree-node
*clrRecursiveFor="
let entry of root$ | async;
getChildren: getChildren
"
[clrExpandable]="true"
>
<div
[class.object-header]="!entry.count"
class="full-width"
>
<div>
@if (!entry.count) {
<clr-icon shape="rack-server"></clr-icon>
}
@if (entry.count) {
<clr-icon shape="block"></clr-icon>
}
{{ entry.display }}
</div>
@if (!entry.count) {
<p class="float-right object-uri">
{{ entry.URI }}
</p>
}
</div>
</clr-tree-node>
</clr-tree>
}
</clr-accordion-content>
</clr-accordion-panel>
}
}
</clr-accordion>
</div>
}
@if (objectView) {
<div class="objects-col">
@for (metaObject of metaObjectShowList; track metaObject) {
<div class="cols-head">
<clr-icon shape="rack-server"></clr-icon>
<div class="object-text">
<p class="m-0 word-break mr-20">
{{ metaObject.NAME }}
</p>
<p class="float-right ml-3">
{{ metaObject.ID }}
</p>
</div>
</div>
@if (showAcc) {
<clr-tree [clrLazy]="true">
<clr-tree-node
*clrRecursiveFor="
let entry of root$ | async;
@@ -127,109 +196,70 @@
class="full-width"
>
<div>
<clr-icon
*ngIf="!entry.count"
shape="rack-server"
></clr-icon>
<clr-icon
*ngIf="entry.count"
shape="block"
></clr-icon>
@if (!entry.count) {
<clr-icon shape="rack-server"></clr-icon>
}
@if (entry.count) {
<clr-icon shape="block"></clr-icon>
}
{{ entry.display }}
</div>
<p class="float-right object-uri" *ngIf="!entry.count">
{{ entry.URI }}
</p>
@if (!entry.count) {
<p class="float-right object-uri">
{{ entry.URI }}
</p>
}
</div>
</clr-tree-node>
</clr-tree>
</clr-accordion-content>
</clr-accordion-panel>
</ng-container>
</clr-accordion>
</div>
<div *ngIf="objectView" class="objects-col">
<ng-container *ngFor="let metaObject of metaObjectShowList">
<div class="cols-head">
<clr-icon shape="rack-server"></clr-icon>
<div class="object-text">
<p class="m-0 word-break mr-20">
{{ metaObject.NAME }}
</p>
<p class="float-right ml-3">
{{ metaObject.ID }}
</p>
</div>
}
}
</div>
<clr-tree *ngIf="showAcc" [clrLazy]="true">
<clr-tree-node
*clrRecursiveFor="
let entry of root$ | async;
getChildren: getChildren
"
[clrExpandable]="true"
}
</div>
<div class="clr-col-6 text-center">
<h3>{{ assoObjectSelected }}</h3>
@if (showTable) {
<clr-datagrid class="datagrid-custom-footer">
<clr-dg-column>
TYPE
<clr-dg-string-filter
[clrDgStringFilter]="typeFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>
NAME
<clr-dg-string-filter
[clrDgStringFilter]="nameFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>
VALUE
<clr-dg-string-filter
[clrDgStringFilter]="valueFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-row
*clrDgItems="let metaObjectAttribute of metaObjectAttributes"
>
<div [class.object-header]="!entry.count" class="full-width">
<div>
<clr-icon
*ngIf="!entry.count"
shape="rack-server"
></clr-icon>
<clr-icon *ngIf="entry.count" shape="block"></clr-icon>
{{ entry.display }}
</div>
<p class="float-right object-uri" *ngIf="!entry.count">
{{ entry.URI }}
</p>
</div>
</clr-tree-node>
</clr-tree>
</ng-container>
<clr-dg-cell>{{ metaObjectAttribute.TYPE }}</clr-dg-cell>
<clr-dg-cell>{{ metaObjectAttribute.NAME }}</clr-dg-cell>
<clr-dg-cell>{{ metaObjectAttribute.VALUE }}</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-dg-page-size [clrPageSizeOptions]="[10, 20, 50, 100]"
>Attributes per page</clr-dg-page-size
>
{{ pagination.firstItem + 1 }} -
{{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} Attributes
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
}
</div>
</div>
<div class="clr-col-6 text-center">
<h3>{{ assoObjectSelected }}</h3>
<clr-datagrid class="datagrid-custom-footer" *ngIf="showTable">
<clr-dg-column>
TYPE
<clr-dg-string-filter
[clrDgStringFilter]="typeFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>
NAME
<clr-dg-string-filter
[clrDgStringFilter]="nameFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>
VALUE
<clr-dg-string-filter
[clrDgStringFilter]="valueFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-row
*clrDgItems="let metaObjectAttribute of metaObjectAttributes"
>
<clr-dg-cell>{{ metaObjectAttribute.TYPE }}</clr-dg-cell>
<clr-dg-cell>{{ metaObjectAttribute.NAME }}</clr-dg-cell>
<clr-dg-cell>{{ metaObjectAttribute.VALUE }}</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-dg-page-size [clrPageSizeOptions]="[10, 20, 50, 100]"
>Attributes per page</clr-dg-page-size
>
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} Attributes
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
</div>
}
</div>
</div>
+1
View File
@@ -10,5 +10,6 @@ export class AbortDetails {
SYSWARNINGTEXT?: string
SYSERRORTEXT?: string
MAC?: string
_PROGRAM?: string
LOG?: string
}

Some files were not shown because too many files have changed in this diff Show More