Compare commits

...
54 Commits
Author SHA1 Message Date
YuryShkoda 2411443ec7 fix(cypress): make licensing combined-key tests order-independent
- 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-18 16:29:06 +03:00
YuryShkoda 586b9c0f1d 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-17 12:04:42 +03: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
YuryShkoda 69cfccd565 chore: merge additional-validations-regex branch 2026-07-27 15:30:50 +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
96 changed files with 7568 additions and 2470 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.
+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.
+73
View File
@@ -0,0 +1,73 @@
---
name: sas
description: >
Use this skill whenever writing, modifying, or debugging SAS code in this repository — SAS
macros, sasjs services, hooks, or test files (*.sas). Triggers include: SAS macro compilation
errors, parameter parsing issues, writing sasjs tests with mp_assert / mp_assertdsobs /
mp_assertscope, macro-quoting questions (%str, %nrstr, %superq), %mp_abort usage, or unexpected
behaviour when passing free-text values (descriptions, messages) to macros. Also covers repo
conventions such as running `sasjs lint` after touching .sas files and the maximum line length
rule.
---
# SAS Development
Conventions and pitfalls for SAS code in this repository (macros in `sas/sasjs/macros/`, services
in `sas/sasjs/services/`, tests alongside them as `*.test.sas`).
## Always `%str()` free-text macro parameters
A comma inside a macro parameter value is parsed as a **parameter delimiter**. Free-text
parameters such as `desc=` (in `%mp_assert`, `%mp_assertdsobs`, `%mp_assertscope`) and `msg=` (in
`%mp_abort`) must be wrapped in `%str()` — even when they currently contain no comma, so later
edits cannot silently break the call.
Wrong — the text after the comma becomes an unexpected positional parameter and the compilation
fails (or worse, is misparsed):
```sas
%mp_assert(iftrue=(&oldrows=0),
desc=Test 2 - all staged records loaded, delete flags ignored,
outds=work.test_results
)
```
Right:
```sas
%mp_assert(iftrue=(&oldrows=0),
desc=%str(Test 2 - all staged records loaded, delete flags ignored),
outds=work.test_results
)
```
The same applies to any macro parameter that carries user-facing text: `%mp_abort(msg=%str(...))`,
`etlsource=` values containing punctuation (use `%superq()` when passing macro variables that may
contain special characters), etc.
Related quoting rules of thumb:
- `%str()` masks commas, parentheses, semicolons and quotes at compile time — sufficient for
static text like `desc=`.
- Use `%nrstr()` if the text must also mask `%` and `&` (rare in descriptions).
- Use `%superq(var)` when forwarding a macro **variable** whose value may contain special
characters (e.g. `etlsource=` in `mpe_targetloader.sas`).
## Tests must be idempotent
A test file must pass when run repeatedly (including after a run that failed partway). Conventions:
- Start the file with `%let syscc=0;` — many macros (e.g. `%mp_lockanytable(LOCK)`) abort on entry if `&syscc>0`, and any `WARNING` in a previous test bumps `syscc` to 4.
- Clean up **all** persistent 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`).
- Make prep defensive: delete-then-insert config records (handles leftovers from an aborted run), and recreate physical tables rather than assuming they are absent.
## Repo conventions for .sas files
- Run `npx sasjs lint` from the `sas/` directory after creating or modifying any `.sas` file; the
files you touched must have zero warnings (pre-existing warnings in other files can be ignored).
- Maximum line length is 80 characters (lint-enforced) — this is why long `msg=%str(...)` values
sometimes need shortening.
- 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 `.agent/docs/testing.md`.
- `sas/sasjsbuild/` is generated build output — never hand-edit it; edit sources under
`sas/sasjs/` only.
+1 -1
View File
@@ -146,7 +146,7 @@ jobs:
# Start frontend and run cypress # Start frontend and run cypress
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots # 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) # before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.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 - name: Zip Cypress videos
if: always() if: always()
+1 -1
View File
@@ -143,7 +143,7 @@ jobs:
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
cat ./cypress.config.ts cat ./cypress.config.ts
# Start frontend and run cypress # Start frontend and run cypress
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 - name: Zip Cypress videos
if: always() if: always()
+14
View File
@@ -1,9 +1,23 @@
# Agent Instructions # 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 `.agent/docs/` deep-dives here link out to it.
- **`datacontroller.io`** - the marketing site, blog and feed (Gatsby).
## Git ## 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. 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 `.agent/docs/releases-and-changelog.md`.
## Markdown files ## 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. 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.
+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](.agent/skills/handsontable/SKILL.md) for the editable grid and [HyperFormula](.agent/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/`.
- `.agent/docs/` - technical deep-dives (see below). `.agent/skills/` - task skills (handsontable, hyperformula, sas).
## 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 [.agent/docs/bitemporal-dataloader.md](.agent/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 [.agent/docs/replace-load-type.md](.agent/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 [.agent/docs/regex-validations.md](.agent/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 [.agent/docs/row-level-security.md](.agent/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 [.agent/docs/testing.md](.agent/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 [.agent/docs/releases-and-changelog.md](.agent/docs/releases-and-changelog.md).
See `AGENTS.md` for the full set of enforced rules (git, linting, no-wrap, no-external-assets, `.agent/` layout).
File diff suppressed because it is too large Load Diff
+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')
// Chained via .then() rather than called as the next plain
// statement - generateCombinedKey is a raw async function that
// itself invokes cy commands (cy.log), and Cypress can't
// reconcile that promise with the preceding cy.visit()/cy.wait()
// unless it's explicitly handed off through a real command chain.
cy.wait(2000).then(() => proceed())
} else {
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 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()
}
})
})
})
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 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()
}
})
})
})
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) { if (testLicenceUserLimits) {
it('4 | User try to register when limit is reached', (done) => { it('4 | User try to register when limit is reached', (done) => {
let keyData = { let keyData = {
@@ -417,7 +705,7 @@ const verifyLicensingPage = (text: string, callback: any) => {
cy.wait(1000) cy.wait(1000)
isLicensingPage((result: boolean) => { isLicensingPage((result: boolean) => {
if (result) { if (result) {
cy.get('p.key-error') cy.get('.key-error')
.should('contain', text) .should('contain', text)
.then((treeNodes: any) => { .then((treeNodes: any) => {
callback(true) callback(true)
@@ -441,6 +729,10 @@ const verifyLicensingWarning = (text: string, callback: any) => {
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => { const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button').contains('Paste licence').click() 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 }) cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', licenseKey) .invoke('val', licenseKey)
.trigger('input') .trigger('input')
@@ -452,6 +744,30 @@ const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button.apply-keys').click() 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) => { const updateUsersTable = (options: any, callback?: any) => {
visitPage('home') visitPage('home')
openTableFromTree(libraryToOpenIncludes, 'mpe_users') 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) => { const editTableField = (edits: EditConfigTableCells[], callback?: any) => {
cy.get('td').then((tdNodes: any) => { cy.get('td').then((tdNodes: any) => {
for (let edit of edits) { for (let edit of edits) {
+61
View File
@@ -0,0 +1,61 @@
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
context('stage tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('stage/DC20221007T122326121_612316_7259')
})
// getstagetable's mock ignores the table_id param entirely, so any id in
// the URL resolves to the same fixture row - no need to submit a real
// table first just to reach this page.
it('1 | Formatted/Unformatted toggle switches between fmt_stagetable and stagetable, defaulting to formatted', () => {
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '12FEB1960')
cy.get('.formatted-values-toggle').click()
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '42')
// Toggling back reverts to the formatted view.
cy.get('.formatted-values-toggle').click()
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '12FEB1960')
})
})
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
// 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))
})
}
+340
View File
@@ -353,6 +353,346 @@ context('editor tests: ', function () {
}) })
}) })
}) })
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 = () => { const removeAllColumns = () => {
+6
View File
@@ -183,6 +183,12 @@ const isLicensingPage = (callback: any) => {
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => { const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button').contains('Paste licence').click() 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 }) cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', licenseKey) .invoke('val', licenseKey)
.trigger('input') .trigger('input')
+21 -23
View File
@@ -1,32 +1,30 @@
import * as base64Converter from 'base64-arraybuffer'
export const base64ToArrayBuffer = (base64: string) => { export const base64ToArrayBuffer = (base64: string) => {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
const dataUrl = "data:application/octet-binary;base64," + base64; const dataUrl = 'data:application/octet-binary;base64,' + base64
fetch(dataUrl) fetch(dataUrl)
.then(res => res.arrayBuffer()) .then((res) => res.arrayBuffer())
.then(buffer => { .then((buffer) => {
resolve(new Uint8Array(buffer)) resolve(new Uint8Array(buffer))
}).catch((err) => { })
.catch((err) => {
reject(err) reject(err)
}) })
}) })
} }
export const arrayBufferToBase64 = (arrayBuffer: any) => { // Blob + FileReader.readAsDataURL (a natural alternative here) is
return new Promise((resolve, reject) => { // unreliable in the Cypress runner's iframe - onload/onerror can simply
const blob = new Blob([arrayBuffer]) // never fire, leaving the promise permanently unsettled (a silent hang,
// surfacing only as Mocha's "done() was never invoked" 30s later with no
const reader = new FileReader(); // clue why). base64-arraybuffer's encode() is synchronous and
// dependency-free - no browser API involved, so this class of hang isn't
reader.onload = async function(event){ // possible - and it's the same library the app itself uses for this exact
if (event.target) { // conversion.
var base64: any = event.target.result export const arrayBufferToBase64 = (
base64 = base64.substring(37, base64.length) arrayBuffer: ArrayBuffer
): Promise<string> => {
resolve(base64) return Promise.resolve(base64Converter.encode(arrayBuffer))
} }
};
reader.readAsDataURL(blob);
})
}
+82 -82
View File
@@ -7,15 +7,15 @@
"name": "data_controller-client", "name": "data_controller-client",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@angular/animations": "^20.3.26", "@angular/animations": "^20.3.27",
"@angular/cdk": "^20.2.14", "@angular/cdk": "^20.2.14",
"@angular/common": "^20.3.26", "@angular/common": "^20.3.27",
"@angular/compiler": "^20.3.26", "@angular/compiler": "^20.3.27",
"@angular/core": "^20.3.26", "@angular/core": "^20.3.27",
"@angular/forms": "^20.3.26", "@angular/forms": "^20.3.27",
"@angular/platform-browser": "^20.3.26", "@angular/platform-browser": "^20.3.27",
"@angular/platform-browser-dynamic": "^20.3.26", "@angular/platform-browser-dynamic": "^20.3.27",
"@angular/router": "^20.3.26", "@angular/router": "^20.3.27",
"@cds/core": "^6.15.1", "@cds/core": "^6.15.1",
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz", "@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
"@clr/icons": "^13.0.2", "@clr/icons": "^13.0.2",
@@ -66,7 +66,7 @@
"@angular-eslint/schematics": "19.8.1", "@angular-eslint/schematics": "19.8.1",
"@angular-eslint/template-parser": "19.8.1", "@angular-eslint/template-parser": "19.8.1",
"@angular/cli": "^20.3.32", "@angular/cli": "^20.3.32",
"@angular/compiler-cli": "^20.3.26", "@angular/compiler-cli": "^20.3.27",
"@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-methods": "^7.18.6",
"@compodoc/compodoc": "^2.0.0", "@compodoc/compodoc": "^2.0.0",
"@cypress/webpack-preprocessor": "^5.17.1", "@cypress/webpack-preprocessor": "^5.17.1",
@@ -1469,9 +1469,9 @@
} }
}, },
"node_modules/@angular/animations": { "node_modules/@angular/animations": {
"version": "20.3.26", "version": "20.3.27",
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.26.tgz", "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.27.tgz",
"integrity": "sha512-hfNrX19v8xs/usNkELSqc6q5IwBfS2GGW8sQ4OMpxAmLZDJwaLUxkj48t8VYhUFezsIfzR+sDEi6ZQBOaMIYug==", "integrity": "sha512-BgGTloDiD3qIFVSxZq8xO6CiyhKn00WbhQQiklZF8WI2hXd3Hmc1OUAAHqSMh2c9uL7X1ZYkg9lSzjiasK2vKg==",
"deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.", "deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1481,7 +1481,7 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/core": "20.3.26" "@angular/core": "20.3.27"
} }
}, },
"node_modules/@angular/cdk": { "node_modules/@angular/cdk": {
@@ -1973,9 +1973,9 @@
} }
}, },
"node_modules/@angular/common": { "node_modules/@angular/common": {
"version": "20.3.26", "version": "20.3.27",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.26.tgz", "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.27.tgz",
"integrity": "sha512-35+aHaCmldFZ2qFiH83+cHcDXwUqSEuUR5DVApcd+Ku8PfIIGo8uMiD5++Qq7QIUTbZCD2glAiE9jLroGrf1Cw==", "integrity": "sha512-4ectYP60XatB9zZ40WlfmaTzjmEhaz8SSqLsbZI4VZ8gDb5qNmxWtwwt8UxS3NmDHEgqdNL8UPO4E94+yKCICg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -1984,14 +1984,14 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/core": "20.3.26", "rxjs": "^6.5.3 || ^7.4.0",
"rxjs": "^6.5.3 || ^7.4.0" "@angular/core": "20.3.27"
} }
}, },
"node_modules/@angular/compiler": { "node_modules/@angular/compiler": {
"version": "20.3.26", "version": "20.3.27",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.26.tgz", "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.27.tgz",
"integrity": "sha512-H4DVTBCiyM4dGytFi2C8sMGflxXzPnoQ6Ajfs4hJ/Dekg6ypfvW5Ze7BDh4TaMQvaX2joM5LhBYW5jTxBx66hA==", "integrity": "sha512-in3THZ678GAYuOR9RZV18+zZz0KGhlGikyUEfLeALLjGf9ZaR3n+t19BmYx6G2VkF/Xqadne1omQ2vbl6PRASA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -2001,31 +2001,31 @@
} }
}, },
"node_modules/@angular/compiler-cli": { "node_modules/@angular/compiler-cli": {
"version": "20.3.26", "version": "20.3.27",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.26.tgz", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.27.tgz",
"integrity": "sha512-3rHtC87ecldvaiFHwQEZ6Wx3QaZ/Q7b0Gb7XORDOjn/M+5CYZ4rQsvbxE5TUjwreg07oQ4Y2h8ADESXTJEUYOQ==", "integrity": "sha512-R0j9mFfUdGmmw867V/TfMSOBkLZT6ASxyY5tc1NDNmxQioZDVIDP9pqBOayzhJ0xiuDc9JellQXUTZ+vm+b/Zg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/core": "7.29.7",
"@jridgewell/sourcemap-codec": "^1.4.14",
"chokidar": "^4.0.0",
"convert-source-map": "^1.5.1",
"reflect-metadata": "^0.2.0",
"semver": "^7.0.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"yargs": "^18.0.0" "yargs": "^18.0.0",
"semver": "^7.0.0",
"chokidar": "^4.0.0",
"@babel/core": "7.29.7",
"reflect-metadata": "^0.2.0",
"convert-source-map": "^1.5.1",
"@jridgewell/sourcemap-codec": "^1.4.14"
}, },
"bin": { "bin": {
"ng-xi18n": "bundles/src/bin/ng_xi18n.js", "ngc": "bundles/src/bin/ngc.js",
"ngc": "bundles/src/bin/ngc.js" "ng-xi18n": "bundles/src/bin/ng_xi18n.js"
}, },
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/compiler": "20.3.26", "typescript": ">=5.8 <6.0",
"typescript": ">=5.8 <6.0" "@angular/compiler": "20.3.27"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"typescript": { "typescript": {
@@ -2034,9 +2034,9 @@
} }
}, },
"node_modules/@angular/core": { "node_modules/@angular/core": {
"version": "20.3.26", "version": "20.3.27",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.26.tgz", "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.27.tgz",
"integrity": "sha512-v+YtZ9eQVDb6v3V1TbUUBHU63FEp8Hqqqb3UhM4MLAOm0chyyh9jah7FiHr3HbCKrV1f4long1coftFK/KThog==", "integrity": "sha512-8EfYIUST5CKldOF4MAYWTFFRB7EtwqUoQBZdar6US39/EEzWm/wm/iRNkH2jmKe4YuPa2GoeHS1WQ8VRuOk7Dg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -2045,23 +2045,23 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/compiler": "20.3.26",
"rxjs": "^6.5.3 || ^7.4.0", "rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0",
"@angular/compiler": "20.3.27"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@angular/compiler": { "zone.js": {
"optional": true "optional": true
}, },
"zone.js": { "@angular/compiler": {
"optional": true "optional": true
} }
} }
}, },
"node_modules/@angular/forms": { "node_modules/@angular/forms": {
"version": "20.3.26", "version": "20.3.27",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.26.tgz", "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.27.tgz",
"integrity": "sha512-ia0YaPVjlG2oBFKCfaAgqQ0jGRrhGTAcrbZG3tVeFDpi7LQ6WdP3Syw2H+0D3GPyzpl/5UqU10Fum5Wr1br4QQ==", "integrity": "sha512-cNG26wi3tr3m8At6puxJpAMVk9mBhEqREA4Jk/klalMwWuYEf8ApTEAw0a5NUfMxDkL3dcJJwDRf8rK6ma6TYQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -2070,16 +2070,16 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": "20.3.26", "rxjs": "^6.5.3 || ^7.4.0",
"@angular/core": "20.3.26", "@angular/core": "20.3.27",
"@angular/platform-browser": "20.3.26", "@angular/common": "20.3.27",
"rxjs": "^6.5.3 || ^7.4.0" "@angular/platform-browser": "20.3.27"
} }
}, },
"node_modules/@angular/platform-browser": { "node_modules/@angular/platform-browser": {
"version": "20.3.26", "version": "20.3.27",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.26.tgz", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.27.tgz",
"integrity": "sha512-In4wUiLUUT9LqyV9Rjz78k/dsnKAwec4AtDmwZoX8/ZmeJOSH7g5X1gTM+hxTxmifmZkrapQjXR299IcfIkzrw==", "integrity": "sha512-IV2zQ4zk6liyw5NE48bQqSk3nOAZ1rmDAQi7W4Kw0N8cs9MYVgK3zulo0zx5UqSv1kuNDAGb0HPR5tUGVOf9kw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -2088,9 +2088,9 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/animations": "20.3.26", "@angular/core": "20.3.27",
"@angular/common": "20.3.26", "@angular/common": "20.3.27",
"@angular/core": "20.3.26" "@angular/animations": "20.3.27"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@angular/animations": { "@angular/animations": {
@@ -2099,9 +2099,9 @@
} }
}, },
"node_modules/@angular/platform-browser-dynamic": { "node_modules/@angular/platform-browser-dynamic": {
"version": "20.3.26", "version": "20.3.27",
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.26.tgz", "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.27.tgz",
"integrity": "sha512-/9eq0GGmtMoBV5UvcjvhnV4ZDcgZr15h+dzoxkZodUncb2z7fxybSyha7wWD9aTeabZ/q/KYVUSnoYqVyBhbVQ==", "integrity": "sha512-4UUs8vOswgBOWCRoeZrswguarBLrM3j6WGb927Y3GXdN37fVJQYjyKHivNeQwZvwsGgl5Nl6mvpBpZv47n/OrQ==",
"deprecated": "@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.", "deprecated": "@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2111,16 +2111,16 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": "20.3.26", "@angular/core": "20.3.27",
"@angular/compiler": "20.3.26", "@angular/common": "20.3.27",
"@angular/core": "20.3.26", "@angular/compiler": "20.3.27",
"@angular/platform-browser": "20.3.26" "@angular/platform-browser": "20.3.27"
} }
}, },
"node_modules/@angular/router": { "node_modules/@angular/router": {
"version": "20.3.26", "version": "20.3.27",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.26.tgz", "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.27.tgz",
"integrity": "sha512-q0k0b5uuQx93Trk4qEMYe8LoPOozheRBIjze51q+LUTlLXWik4W0ughXLiTJL346KRudR/vB5ksfZP8b6WlQyA==", "integrity": "sha512-F3hfJQ0GAuD6LdeB7A6fMfaErc4HXCtCQGh3C/8VrbVEbGfIgSveNjQXzGYPNklnOLhj1BmWf5w0WniUAEjLBA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -2129,10 +2129,10 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": "20.3.26", "rxjs": "^6.5.3 || ^7.4.0",
"@angular/core": "20.3.26", "@angular/core": "20.3.27",
"@angular/platform-browser": "20.3.26", "@angular/common": "20.3.27",
"rxjs": "^6.5.3 || ^7.4.0" "@angular/platform-browser": "20.3.27"
} }
}, },
"node_modules/@arr/every": { "node_modules/@arr/every": {
@@ -11291,9 +11291,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.8", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
@@ -14941,17 +14941,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.4", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [ "funding": [
{ {
"type": "github", "url": "https://github.com/sponsors/fastify",
"url": "https://github.com/sponsors/fastify" "type": "github"
}, },
{ {
"type": "opencollective", "url": "https://opencollective.com/fastify",
"url": "https://opencollective.com/fastify" "type": "opencollective"
} }
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
@@ -25091,9 +25091,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.27.0", "version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+12 -9
View File
@@ -41,15 +41,15 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^20.3.26", "@angular/animations": "^20.3.27",
"@angular/cdk": "^20.2.14", "@angular/cdk": "^20.2.14",
"@angular/common": "^20.3.26", "@angular/common": "^20.3.27",
"@angular/compiler": "^20.3.26", "@angular/compiler": "^20.3.27",
"@angular/core": "^20.3.26", "@angular/core": "^20.3.27",
"@angular/forms": "^20.3.26", "@angular/forms": "^20.3.27",
"@angular/platform-browser": "^20.3.26", "@angular/platform-browser": "^20.3.27",
"@angular/platform-browser-dynamic": "^20.3.26", "@angular/platform-browser-dynamic": "^20.3.27",
"@angular/router": "^20.3.26", "@angular/router": "^20.3.27",
"@cds/core": "^6.15.1", "@cds/core": "^6.15.1",
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz", "@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
"@clr/icons": "^13.0.2", "@clr/icons": "^13.0.2",
@@ -100,7 +100,7 @@
"@angular-eslint/schematics": "19.8.1", "@angular-eslint/schematics": "19.8.1",
"@angular-eslint/template-parser": "19.8.1", "@angular-eslint/template-parser": "19.8.1",
"@angular/cli": "^20.3.32", "@angular/cli": "^20.3.32",
"@angular/compiler-cli": "^20.3.26", "@angular/compiler-cli": "^20.3.27",
"@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-methods": "^7.18.6",
"@compodoc/compodoc": "^2.0.0", "@compodoc/compodoc": "^2.0.0",
"@cypress/webpack-preprocessor": "^5.17.1", "@cypress/webpack-preprocessor": "^5.17.1",
@@ -148,6 +148,9 @@
"ajv": "8.18.0", "ajv": "8.18.0",
"uuid": "11.1.1", "uuid": "11.1.1",
"lighthouse": "13.4.0", "lighthouse": "13.4.0",
"readdir-glob": {
"brace-expansion": "^5.0.9"
},
"exceljs": { "exceljs": {
"archiver": "^8.0.0", "archiver": "^8.0.0",
"unzipper": "^0.12.5" "unzipper": "^0.12.5"
@@ -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 { EventService } from 'src/app/services/event.service'
import { LoggerService } from 'src/app/services/logger.service' import { LoggerService } from 'src/app/services/logger.service'
import { SasService } from 'src/app/services/sas.service' import { SasService } from 'src/app/services/sas.service'
import { getMalformedAdapterResponseMessage } from 'src/app/shared/utils/get-malformed-adapter-response-message'
@Component({ @Component({
selector: 'app-manual-deploy', selector: 'app-manual-deploy',
@@ -316,7 +317,13 @@ export class ManualComponent implements OnInit {
.then((res: RequestWrapperResponse) => { .then((res: RequestWrapperResponse) => {
this.loggerService.log(res.adapterResponse) 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' this.validationState = 'success'
} else { } else {
this.validationState = 'error' this.validationState = 'error'
+6 -6
View File
@@ -170,7 +170,7 @@
class="card-header clr-row buttonBar headerBar clr-flex-md-row clr-justify-content-center clr-justify-content-lg-end" class="card-header clr-row buttonBar headerBar clr-flex-md-row clr-justify-content-center clr-justify-content-lg-end"
> >
@if (tableTrue && !embed) { @if (tableTrue && !embed) {
<div class="clr-col-12 clr-col-md-3 clr-col-lg-4 backBtn"> <div class="clr-col-12 clr-col-md-auto clr-col-lg-auto backBtn">
<span <span
class="btn icon-collapse btn-sm btn-icon btn-dimmed" class="btn icon-collapse btn-sm btn-icon btn-dimmed"
[routerLink]="['/home']" [routerLink]="['/home']"
@@ -198,7 +198,7 @@
} }
<div <div
class="clr-col-12 clr-col-md-5 clr-col-lg-4 d-flex flex-column align-items-center" class="clr-col-12 clr-col-md clr-col-lg d-flex flex-column align-items-center"
[class.clr-col-lg-12]="!tableTrue" [class.clr-col-lg-12]="!tableTrue"
> >
<h4 <h4
@@ -258,12 +258,12 @@
</h4> </h4>
</div> </div>
@if (tableTrue) { @if (tableTrue) {
<div class="clr-col-12 clr-col-md-4 clr-col-lg-4 btnCtrl"> <div class="clr-col-12 clr-col-md-auto clr-col-lg-auto btnCtrl">
@if (hotTable.readOnly && !uploadPreview) { @if (hotTable.readOnly && !uploadPreview) {
@if (!isVaEmbed) { @if (!isVaEmbed) {
<button <button
type="button" type="button"
class="btnView btn icon-collapse btn-sm btn-icon btn-block btn-dimmed" class="btnView btn icon-collapse btn-sm btn-icon btn-dimmed"
(click)="openQb()" (click)="openQb()"
> >
<clr-icon aria-hidden="true" shape="filter"></clr-icon> <clr-icon aria-hidden="true" shape="filter"></clr-icon>
@@ -272,7 +272,7 @@
} }
<button <button
type="button" type="button"
class="btn icon-collapse btn-sm btn-primary btn-block" class="btn icon-collapse btn-sm btn-primary"
(click)="editTable()" (click)="editTable()"
> >
<clr-icon aria-hidden="true" shape="note"></clr-icon> <clr-icon aria-hidden="true" shape="note"></clr-icon>
@@ -282,7 +282,7 @@
<button <button
(click)="onShowUploadModal()" (click)="onShowUploadModal()"
type="button" type="button"
class="btn icon-collapse btn-sm btn-success btn-block mr-0" class="btn icon-collapse btn-sm btn-success mr-0"
> >
<clr-icon aria-hidden="true" shape="upload"></clr-icon> <clr-icon aria-hidden="true" shape="upload"></clr-icon>
<span class="text">Upload</span> <span class="text">Upload</span>
+673 -63
View File
@@ -39,6 +39,16 @@ import { EventService } from '../services/event.service'
import { HelperService } from '../services/helper.service' import { HelperService } from '../services/helper.service'
import { LoggerService } from '../services/logger.service' import { LoggerService } from '../services/logger.service'
import { SasService } from '../services/sas.service' import { SasService } from '../services/sas.service'
import { UserService } from '../shared/user.service'
import { applyFormulaRules } from '../shared/dc-validator/utils/applyFormulaRules'
import { expandCellRanges } from './utils/expandCellRanges'
import { preventMenuItemAutoClose } from './utils/preventMenuItemAutoClose'
import { findOverwrittenCells } from '../shared/dc-validator/utils/findOverwrittenCells'
import { getFormulaCellsToPreserveOnCancel } from '../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
import { getRevertableCols } from '../shared/dc-validator/utils/getRevertableCols'
import { getStableFormulaBaseCols } from '../shared/dc-validator/utils/getStableFormulaBaseCols'
import { syncOverwrittenCellComment } from '../shared/dc-validator/utils/syncOverwrittenCellComment'
import { parseFormulaRule } from '../shared/dc-validator/utils/parseFormulaRule'
import { DcValidator } from '../shared/dc-validator/dc-validator' import { DcValidator } from '../shared/dc-validator/dc-validator'
import { Col } from '../shared/dc-validator/models/col.model' import { Col } from '../shared/dc-validator/models/col.model'
import { DcValidation } from '../shared/dc-validator/models/dc-validation.model' import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
@@ -46,6 +56,8 @@ import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema' import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
import { excelRound } from '../shared/dc-validator/utils/excelRound' import { excelRound } from '../shared/dc-validator/utils/excelRound'
import { isEmpty } from '../shared/dc-validator/utils/isEmpty' import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
import { hasFormulaRules } from '../shared/dc-validator/utils/hasFormulaRules'
import { HyperFormula } from 'hyperformula'
import { parseLabelsParam } from '../shared/utils/parse-labels-param' import { parseLabelsParam } from '../shared/utils/parse-labels-param'
import { getDisplayColHeaders } from '../shared/utils/display-col-headers' import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
import { buildColInfoHtml } from '../shared/utils/col-info-html' import { buildColInfoHtml } from '../shared/utils/col-info-html'
@@ -58,6 +70,10 @@ import {
import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events' import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events'
import { EditorRestrictions } from './models/editor-restrictions.model' import { EditorRestrictions } from './models/editor-restrictions.model'
import { parseTableColumns } from './utils/grid.utils' import { parseTableColumns } from './utils/grid.utils'
import { classifyRow } from './utils/classifyRow'
import { withoutEditStatus } from './utils/withoutEditStatus'
import { getEditStatusSymbol } from './utils/getEditStatusSymbol'
import { EDIT_STATUS_COLUMN_NAME } from '../shared/dc-validator/utils/editStatusColumnRule'
import { import {
errorRenderer, errorRenderer,
noSpinnerRenderer, noSpinnerRenderer,
@@ -193,6 +209,65 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
} }
}, },
// Only ever shown when the selection contains at least one cell
// markOverwrittenCells (or the live afterChange sync) attached a
// comment to - i.e. a cell whose current value differs from what
// SAS actually sent for it, formula-caused or a direct edit. The
// comment's presence is a sufficient and exact signal, no need to
// separately recompute "is this overwritten" here too. The
// selection can be a single cell, a rectangular multi-cell range,
// a whole row/column (header click), or several disjoint ranges
// (ctrl-click) - expandCellRanges normalizes all of those into a
// flat list of individual cells.
revert_cells: {
name: 'Revert',
hidden(this: Handsontable.Core) {
if (this.getSettings().readOnly) return true
const ranges: CellRange[] | undefined = this.getSelectedRange()
if (!ranges || ranges.length === 0) return true
const commentsPlugin: any = this.getPlugin('comments')
const cells = expandCellRanges(
ranges,
this.countRows(),
this.countCols()
)
return !cells.some(({ row, col }) =>
commentsPlugin.getCommentAtCell(row, col)
)
},
callback: (key: string, selection: any[]) => {
const hot = this.hotInstance
const commentsPlugin: any = hot.getPlugin('comments')
const cells = expandCellRanges(
selection.map((sel) => ({ from: sel.start, to: sel.end })),
hot.countRows(),
hot.countCols()
)
for (const { row, col } of cells) {
const comment: string | undefined =
commentsPlugin.getCommentAtCell(row, col)
if (!comment) continue
const prop = hot.colToProp(col) as string
const rawValueText = comment.slice(
EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX.length
)
const isNumericCol =
this.dcValidator?.getRule(prop)?.type === 'numeric'
hot.setDataAtRowProp(
row,
prop,
isNumericCol ? Number(rawValueText) : rawValueText
)
commentsPlugin.removeCommentAtCell(row, col)
}
}
},
row_above: { row_above: {
name: 'Insert Row above', name: 'Insert Row above',
hidden: () => this.hotTable.readOnly === true, hidden: () => this.hotTable.readOnly === true,
@@ -349,6 +424,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
* with that cadence. * with that cadence.
*/ */
private static readonly VA_DEBOUNCE_MS = 800 private static readonly VA_DEBOUNCE_MS = 800
// Shared between markOverwrittenCells/syncOverwrittenCommentForCell
// (writes it) and the revert_cells context menu item (parses it back
// out) - see findOverwrittenCells.
private static readonly ORIGINAL_VALUE_COMMENT_PREFIX = 'Original value: '
public tableTrue: boolean | undefined public tableTrue: boolean | undefined
public saveLoading = false public saveLoading = false
public approvers: string[] = [] public approvers: string[] = []
@@ -403,6 +482,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
dataSource!: any[] dataSource!: any[]
prevDataSource!: any[] prevDataSource!: any[]
dataSourceUnchanged!: any[] dataSourceUnchanged!: any[]
// Raw, as-received-from-SAS snapshot, captured before applyFormulaRules
// overwrites HARDFORMULA/SOFTFORMULA columns - see findOverwrittenCells
// and getRevertableColumnNames. Distinct from dataSourceUnchanged, which is a
// per-editing-session baseline that gets reset on every editTable() call;
// this stays fixed for the table's whole lifetime, since "what did the
// real dataset actually have before any formula got involved" doesn't
// change across edit sessions.
dataSourceRaw!: any[]
dataSourceBeforeSubmit!: any[] dataSourceBeforeSubmit!: any[]
dataModified!: any[] dataModified!: any[]
@@ -486,7 +573,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
private cdf: ChangeDetectorRef, private cdf: ChangeDetectorRef,
private spreadsheetService: SpreadsheetService, private spreadsheetService: SpreadsheetService,
private vaMessaging: VaMessagingService, private vaMessaging: VaMessagingService,
private vaFilter: VaFilterService private vaFilter: VaFilterService,
private userService: UserService
) { ) {
this.parseRestrictions() this.parseRestrictions()
this.setRestrictions() this.setRestrictions()
@@ -760,6 +848,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (!itemObject['_____DELETE__THIS__RECORD_____']) if (!itemObject['_____DELETE__THIS__RECORD_____'])
itemObject['_____DELETE__THIS__RECORD_____'] = 'No' itemObject['_____DELETE__THIS__RECORD_____'] = 'No'
// EDIT_STATUS is never part of the uploaded file (client-only, see
// editStatusColumnRule.ts) - default it the same as a fresh load.
itemObject[EDIT_STATUS_COLUMN_NAME] = 'U'
previewDatasource.push(itemObject) previewDatasource.push(itemObject)
}) })
@@ -1021,6 +1113,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (newRow) { if (newRow) {
this.dataSourceUnchanged.pop() this.dataSourceUnchanged.pop()
} }
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
} }
this.hotTable.readOnly = false this.hotTable.readOnly = false
this.hotTable.data = this.dataSource this.hotTable.data = this.dataSource
@@ -1034,6 +1128,17 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.render() hot.render()
// Resync every revertable cell's "overwritten" comment against
// dataSourceRaw on every entry into edit mode. Needed for the Excel
// upload path in particular (previewTableEditConfirm -> editTable(true))
// - the preview's bulk hot.updateSettings({data: ...}) fires afterChange
// with source 'loadData', which the live sync hook deliberately ignores
// (see its own comment), so an uploaded value that differs from the
// original SAS data would otherwise never get flagged/commented. A
// plain Edit-button click re-syncs the same (already-correct) state,
// same as cancelEdit() does for the read-only return path.
this.syncOverwrittenComments()
for (const sortConfig of sortConfigs) { for (const sortConfig of sortConfigs) {
columnSorting.sort(sortConfig) columnSorting.sort(sortConfig)
} }
@@ -1085,7 +1190,34 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
: [columnSortConfig] : [columnSortConfig]
if (this.dataSourceUnchanged) { if (this.dataSourceUnchanged) {
// dataSourceUnchanged deliberately holds the RAW pre-formula value for
// a HARDFORMULA/SOFTFORMULA column that still has its
// markOverwrittenCells comment (see editTable's overlay) - needed
// so classifyRow flags the row as modified, but it isn't a session
// edit to discard: the formula recomputes on every load regardless.
// Snapshot those cells' live computed value before the blind restore
// below, then patch them back in - otherwise cancelling would freeze
// the display at the raw value and erase the row's modified marker.
const formulaBaseCols = this.getFormulaBaseCols()
const commentsPlugin = hot.getPlugin('comments')
const toPreserve = getFormulaCellsToPreserveOnCancel(
this.dataSource.length,
formulaBaseCols,
(rowIndex, baseCol) =>
!!commentsPlugin.getCommentAtCell(
rowIndex,
hot.propToCol(baseCol) as number
),
(rowIndex, prop) => hot.getDataAtRowProp(rowIndex, prop)
)
this.dataSource = this.helperService.deepClone(this.dataSourceUnchanged) this.dataSource = this.helperService.deepClone(this.dataSourceUnchanged)
for (const cell of toPreserve) {
if (this.dataSource[cell.rowIndex]) {
this.dataSource[cell.rowIndex][cell.prop] = cell.value
}
}
} }
this.hotTable.data = this.dataSource this.hotTable.data = this.dataSource
@@ -1099,6 +1231,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
false false
) )
// A cell's overwritten comment can be stale after this restore - e.g.
// a direct edit (not a formula overwrite) just got discarded, and
// dataSource now matches dataSourceRaw again for it. formula cells'
// comments are already correct (preserved above), so this is mostly
// about the general case, but running the same full resync either way
// is simpler than trying to only check what might have changed.
this.syncOverwrittenComments()
this.modifedRowsIndexes = [] this.modifedRowsIndexes = []
hot.validateCells() hot.validateCells()
// this.editRecordListeners(); // this.editRecordListeners();
@@ -1301,18 +1441,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
setTimeout(() => { setTimeout(() => {
const hot = this.hotInstance const hot = this.hotInstance
const newIndex = this.dataSource.length
// Create a new empty row object with proper structure // hot.alter() (rather than splicing dataSource and calling
const newRow = this.createEmptyRow() // updateSettings) is what triggers the formulas plugin's own
// insert-row hooks - without it, HyperFormula's sheet never learns
// Add the new row to the data source // about the new row and HARDFORMULA/SOFTFORMULA columns silently
this.dataSource.push(newRow) // fall out of sync with the grid's own data.
hot.alter('insert_row_below', newIndex - 1, 1)
// Update the hot table with the new data this.dataSource[newIndex].noLinkOption = true
hot.updateSettings({ data: this.dataSource }, false) this.seedFormulaValuesForRow(newIndex)
this.updateEditStatusForRow(newIndex)
// Select the newly added row // Select the newly added row
hot.selectCell(this.dataSource.length - 1, 0) hot.selectCell(newIndex, 0)
hot.render() hot.render()
this.addingNewRow = false this.addingNewRow = false
@@ -1321,39 +1463,298 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}) })
} }
/**
* Creates a new empty row object with proper structure.
* Columns with NOTNULL DQ rules are pre-populated with their RULE_VALUE.
*/
private createEmptyRow(): any {
const newRow: any = {}
this.cellValidation.forEach((rule: any) => {
const dataKey = rule.data
newRow[dataKey] = this.hotDataSchema.hasOwnProperty(dataKey)
? this.hotDataSchema[dataKey]
: ''
})
newRow['noLinkOption'] = true
return newRow
}
/** /**
* Inserts a new row at the specified position and updates the table * Inserts a new row at the specified position and updates the table
*/ */
private insertRowAtPosition(targetRow: number): void { private insertRowAtPosition(targetRow: number): void {
const newRow = this.createEmptyRow()
// Insert the new row at the target position
this.dataSource.splice(targetRow, 0, newRow)
// Update the hot table
const hot = this.hotInstance const hot = this.hotInstance
hot.updateSettings({ data: this.dataSource }, false)
hot.render() // See addRow()'s comment - hot.alter() is required for HyperFormula to
// learn about the new row. beforeCreateRow only allows this while
// addingNewRow is set (see its own hook registration).
this.addingNewRow = true
hot.alter('insert_row_above', targetRow, 1)
this.addingNewRow = false
// alter() builds the new row from the grid's configured dataSchema
// (NOTNULL defaults included), which doesn't know about noLinkOption -
// set it directly on the row alter() just spliced into dataSource.
this.dataSource[targetRow].noLinkOption = true
this.seedFormulaValuesForRow(targetRow)
this.updateEditStatusForRow(targetRow)
this.reSetCellValidationValues() this.reSetCellValidationValues()
} }
/**
* Seeds HARDFORMULA/SOFTFORMULA columns on a newly-inserted row with
* their computed formula string, the same way applyFormulaRules seeds
* every row on initial load - a new row otherwise sits with a blank
* formula cell until the next full reload. DC.ORIG_VALUE naturally
* resolves to blank for this row (applyFormulaRules can't find a
* dataSourceUnchanged match for a row that never existed before).
*
* Written via hot.setDataAtRowProp (not a bare dataSource mutation, which
* is what applyFormulaRules itself does) so HyperFormula's engine
* actually learns about the new cell content - the same class of fix as
* hot.alter() above, just for a single cell instead of a row structure
* change.
*/
private seedFormulaValuesForRow(rowIndex: number): void {
const dqRules = this.dcValidator?.getDqDetails()
if (!dqRules || !hasFormulaRules(dqRules)) return
const hot = this.hotInstance
const userName = this.userService.user?.username ?? ''
const formulaRules = dqRules.filter(
(rule) =>
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
)
// Computes only THIS row's formula string (parseFormulaRule directly,
// not applyFormulaRules on the full dataSource) - applyFormulaRules
// would overwrite every other row's formula column too, silently
// reverting any SOFTFORMULA override a user already typed elsewhere
// and rewriting every shifted row's cell-reference string (its
// spreadsheet position changed), both of which make classifyRow see a
// false diff against dataSourceUnchanged and misreport those untouched
// rows as modified.
for (const rule of formulaRules) {
const value = parseFormulaRule(rule.RULE_VALUE, {
columnNames: this.headerColumns,
rowIndex,
userName,
origValue: undefined
})
hot.setDataAtRowProp(rowIndex, rule.BASE_COL, value)
}
}
/**
* BASE_COL names of every HARDFORMULA/SOFTFORMULA rule whose result is
* expected to be *stable* for a given row (see getStableFormulaBaseCols
* for why DC.USER_NAME/DC.ORIG_VALUE/DC.ROW_STATUS-based rules are
* excluded). Used only by overlayFormulaRawValuesOnUnchanged and
* cancelEdit's preserve-on-cancel patch, which exist specifically to work
* around dataSourceUnchanged's formula-only raw overlay - see those
* methods' own doc comments for why that stays narrower than
* getRevertableColumnNames(). NOT the same filter seedFormulaValuesForRow
* uses above, which seeds every formula column regardless of this
* distinction.
*/
private getFormulaBaseCols(): string[] {
const dqRules = this.dcValidator?.getDqDetails()
if (!dqRules) return []
return getStableFormulaBaseCols(dqRules)
}
/**
* Overlays the true raw (pre-formula) value onto dataSourceUnchanged for
* every HARDFORMULA/SOFTFORMULA base col that already had real data (see
* markOverwrittenCells) - mutates in place. A HARDFORMULA/SOFTFORMULA
* rule can silently overwrite a value that already existed in the real
* dataset - that's a real change classifyRow's diff should pick up, not
* something that only becomes visible once an edit session starts. Shared
* by the initial load (so the row header agrees with EDIT_STATUS from the
* very first render) and editTable() (every time the baseline is rebuilt).
*/
private overlayFormulaRawValuesOnUnchanged(dataSourceUnchanged: any[]): void {
const formulaBaseCols = this.getFormulaBaseCols()
if (formulaBaseCols.length === 0 || !this.dataSourceRaw) return
dataSourceUnchanged.forEach((row, rowIndex) => {
const rawRow = this.dataSourceRaw[rowIndex]
if (!rawRow) return
for (const baseCol of formulaBaseCols) {
const rawValue = rawRow[baseCol]
if (rawValue === undefined || rawValue === null || rawValue === '')
continue
row[baseCol] = rawValue
}
})
}
/**
* Every column eligible to be checked/marked as "overwritten" - every
* revertable column (see getRevertableCols), not just the narrower
* formula-only set getFormulaBaseCols() returns. Used by
* markOverwrittenCells and the live afterChange sync - NOT by
* overlayFormulaRawValuesOnUnchanged/cancelEdit's preserve-on-cancel
* patch, which stay scoped to getFormulaBaseCols() specifically (see
* those methods' own doc comments for why).
*/
private getRevertableColumnNames(): string[] {
const dqRules = this.dcValidator?.getDqDetails()
if (!dqRules) return []
return getRevertableCols(dqRules, this.headerColumns)
}
/**
* Sets or clears a single cell's "overwritten" comment, matching its
* current value against dataSourceRaw (PK-matched, so this is safe to
* call after a row insert/delete/sort has moved things around). No-op if
* the row has no PK match in dataSourceRaw (a newly-inserted row) - see
* findOverwrittenCells for why that's never revertable.
*/
private syncOverwrittenCommentForCell(rowIndex: number, prop: string): void {
const hot = this.hotInstance
const dataRow = this.dataSource[rowIndex]
if (!dataRow || !this.dataSourceRaw) return
const rawRow = this.dataSourceRaw.find((candidate) =>
this.headerPks.every((pk) => candidate[pk] === dataRow[pk])
)
if (!rawRow) return
const colIndex = hot.propToCol(prop) as number
const commentsPlugin = hot.getPlugin('comments')
const currentValue = hot.getDataAtRowProp(rowIndex, prop)
const hasCommentAlready = !!commentsPlugin.getCommentAtCell(
rowIndex,
colIndex
)
const action = syncOverwrittenCellComment(
currentValue,
rawRow[prop],
hasCommentAlready
)
if (action === 'set') {
commentsPlugin.setCommentAtCell(
rowIndex,
colIndex,
`${EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX}${rawRow[prop]}`
)
} else if (action === 'remove') {
commentsPlugin.removeCommentAtCell(rowIndex, colIndex)
}
}
/**
* Full resync of every revertable cell's "overwritten" comment against
* the current data. Used both at initial load (nothing has a comment
* yet, so this is purely additive) and after Cancel (an edit may have
* just been discarded, so a previously-set comment can now be stale) -
* Handsontable's comments plugin has no way to enumerate its own
* comments, so the only way to find a stale one is to re-check every
* candidate cell.
*/
private syncOverwrittenComments(): void {
const revertableCols = this.getRevertableColumnNames()
if (revertableCols.length === 0 || !this.dataSourceRaw) return
this.dataSource.forEach((_row, rowIndex) => {
for (const col of revertableCols) {
this.syncOverwrittenCommentForCell(rowIndex, col)
}
})
}
/**
* Marks every cell whose current value differs from what SAS actually
* sent for it (see findOverwrittenCells) with a read-only comment
* showing the original value - a HARDFORMULA/SOFTFORMULA rule silently
* overwriting real pre-existing data is one way this happens, but so is
* any direct edit; both need the same "here's what it used to be, and a
* way back" treatment. Must run after hot.updateSettings() has processed
* the formulas: getDataAtRowProp only resolves a formula cell's live
* computed value once HyperFormula has actually evaluated it, not the
* raw formula string dataSource holds right after applyFormulaRules.
*
* Also flips each affected row's EDIT_STATUS to 'M' - this is a real,
* permanent difference from the raw dataset (every future load
* recomputes the same formula again, and a direct edit made before this
* method ever ran isn't something afterChange would have seen), so
* nothing else would otherwise mark these rows modified. Doing this once
* here, before dataSourceUnchanged is ever cloned from dataSource, means
* the 'M' baseline is already shared by both snapshots - no special
* handling needed to keep it from being wiped out by a later
* cancelEdit().
*/
private markOverwrittenCells(): void {
this.syncOverwrittenComments()
const hot = this.hotInstance
const revertableCols = this.getRevertableColumnNames()
if (revertableCols.length === 0 || !this.dataSourceRaw) return
const currentRows = this.dataSource.map((row, rowIndex) => ({
...row,
...Object.fromEntries(
revertableCols.map((col) => [col, hot.getDataAtRowProp(rowIndex, col)])
)
}))
const changes = findOverwrittenCells(
currentRows,
this.dataSourceRaw,
revertableCols,
this.headerPks
)
const changedRows = new Set(changes.map((change) => change.rowIndex))
// Not classifyRow/updateEditStatusForRow - dataSourceUnchanged doesn't
// exist yet this early (only editTable() sets it), and these rows are
// already known-modified from the comment sync above; no need to
// reclassify.
//
// Deferred: setDataAtRowProp needs the Formulas plugin's hidden-column
// index mapping to have completed at least one settle pass. Called
// synchronously, right after the initial hot.updateSettings(), that
// mapping isn't ready yet and HyperFormula throws
// ExpectedValueOfTypeError (address.col resolves to undefined for the
// trimmed/hidden dc.row_status column) - since ngOnInit's getdata
// .catch() swallows anything thrown here, that leaves the whole table
// silently hidden. editTable() defers its own post-render work the
// same way, for the same index-mapping reason.
if (changedRows.size > 0) {
setTimeout(() => {
for (const rowIndex of changedRows) {
hot.setDataAtRowProp(
rowIndex,
EDIT_STATUS_COLUMN_NAME,
'M',
'editStatus'
)
}
}, 0)
}
}
/**
* Recomputes this row's classification (M/A/D/U) and writes it into the
* EDIT_STATUS cell - the same classification the row-header symbol shows,
* but written into a real Handsontable cell so DC.ROW_STATUS-based
* formulas can actually react to it. Written via hot.setDataAtRowProp
* with a distinct source string (not a bare dataSource mutation) so
* HyperFormula is notified and dependents recalculate - the source string
* also lets the afterChange hook below recognise and ignore its own
* writes, avoiding infinite recursion.
*/
private updateEditStatusForRow(rowIndex: number): void {
const dataRow = this.dataSource[rowIndex]
if (!dataRow) return
const status = classifyRow(
dataRow,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks
)
this.hotInstance.setDataAtRowProp(
rowIndex,
EDIT_STATUS_COLUMN_NAME,
status,
'editStatus'
)
}
public cancelSubmit() { public cancelSubmit() {
this.dataSource = this.helperService.deepClone(this.dataSourceBeforeSubmit) this.dataSource = this.helperService.deepClone(this.dataSourceBeforeSubmit)
this.dataSourceBeforeSubmit = [] this.dataSourceBeforeSubmit = []
@@ -1402,31 +1803,23 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
for (let i = 0; i < this.dataSource.length; i++) { for (let i = 0; i < this.dataSource.length; i++) {
const dataRow = this.helperService.deepClone(this.dataSource[i]) const dataRow = this.helperService.deepClone(this.dataSource[i])
if (dataRow._____DELETE__THIS__RECORD_____ === 'Yes') { switch (classifyRow(dataRow, this.dataSourceUnchanged, this.headerPks)) {
this.dataModified.push(dataRow) case 'D':
rowsDeleted++ this.dataModified.push(dataRow)
} else { rowsDeleted++
const dataRowUnchanged = this.dataSourceUnchanged.find((row: any) => { break
for (const pkCol of this.headerPks) { case 'A':
if (row[pkCol] !== dataRow[pkCol]) {
return false
}
}
return true
})
if (dataRowUnchanged) {
if (JSON.stringify(dataRow) !== JSON.stringify(dataRowUnchanged)) {
this.dataModified.push(dataRow)
this.modifedRowsIndexes.push(i)
rowsUpdated++
}
} else {
this.dataModified.push(dataRow) this.dataModified.push(dataRow)
this.modifedRowsIndexes.push(i) this.modifedRowsIndexes.push(i)
rowsAdded++ rowsAdded++
} break
case 'M':
this.dataModified.push(dataRow)
this.modifedRowsIndexes.push(i)
rowsUpdated++
break
case 'U':
break
} }
} }
@@ -1819,7 +2212,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
while (this.dataSource.length > 0) { while (this.dataSource.length > 0) {
const lastRow = this.dataSource[this.dataSource.length - 1] const lastRow = this.dataSource[this.dataSource.length - 1]
const isEmpty = Object.keys(lastRow).every((key) => { const isEmpty = Object.keys(lastRow).every((key) => {
if (key === '_____DELETE__THIS__RECORD_____') return true if (
key === '_____DELETE__THIS__RECORD_____' ||
key === EDIT_STATUS_COLUMN_NAME
)
return true
return !lastRow[key] || lastRow[key] === '' return !lastRow[key] || lastRow[key] === ''
}) })
@@ -1884,7 +2281,34 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
public async saveTable(data: any) { public async saveTable(data: any) {
const hot = this.hotInstance const hot = this.hotInstance
const hotData = hot.getData()
// HARDFORMULA/SOFTFORMULA columns hold a live '=...' formula string in
// dataSource - HyperFormula recalculates the DISPLAYED value without
// ever mutating that underlying string, so submitting dataSource as-is
// would send the formula text itself instead of its computed result.
// getDataAtRowProp reads through the formulas plugin's own modifyData
// hook, which resolves the engine's live value for that cell. Must run
// over the full, unfiltered `data` (physical/array order, same
// reference as dataSource) - its index is what toVisualRow/
// getDataAtRowProp need; the filtered/mapped array below no longer
// lines up with actual grid rows.
const formulaRules = (this.dcValidator?.getDqDetails() ?? []).filter(
(rule) =>
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
)
if (formulaRules.length > 0) {
data.forEach((row: any, physicalRowIndex: number) => {
const visualRowIndex = hot.toVisualRow(physicalRowIndex)
for (const rule of formulaRules) {
row[rule.BASE_COL] = hot.getDataAtRowProp(
visualRowIndex,
rule.BASE_COL
)
}
})
}
data = data.filter((dataRow: any) => { data = data.filter((dataRow: any) => {
const elModified = this.dataModified.find((row) => { const elModified = this.dataModified.find((row) => {
@@ -1906,6 +2330,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
delete row['_____DELETE__THIS__RECORD_____'] delete row['_____DELETE__THIS__RECORD_____']
row['_____DELETE__THIS__RECORD_____'] = deleteColValue row['_____DELETE__THIS__RECORD_____'] = deleteColValue
// EDIT_STATUS is client-only (see editStatusColumnRule.ts) - it must
// never reach the backend.
delete row[EDIT_STATUS_COLUMN_NAME]
// If cell is numeric and value is dot `.` we change it to `null` // If cell is numeric and value is dot `.` we change it to `null`
Object.keys(row).map((key: string) => { Object.keys(row).map((key: string) => {
const colRule = this.dcValidator?.getRule(key) const colRule = this.dcValidator?.getRule(key)
@@ -2395,7 +2823,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
variable_nm: clickedColumnKey variable_nm: clickedColumnKey
} }
], ],
source_row: [clickedRow] // EDIT_STATUS is client-only (see editStatusColumnRule.ts) - it
// must never reach the backend.
source_row: [withoutEditStatus(clickedRow)]
} }
const validationHook = this.dcValidator const validationHook = this.dcValidator
@@ -2759,6 +3189,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
this.initSetup(res) this.initSetup(res)
}) })
.catch((err: any) => { .catch((err: any) => {
// A synchronous throw inside initSetup (called from the .then()
// above) lands here too, not just a network failure - log it, or
// the table just renders hidden with no clue why.
// eslint-disable-next-line no-console
console.error('editors/getdata failed:', err)
this.getdataError = true this.getdataError = true
this.tableTrue = true this.tableTrue = true
}) })
@@ -3125,6 +3560,13 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
this.headerArray = this.headerColumns.slice(1) this.headerArray = this.headerColumns.slice(1)
// EDIT_STATUS is never part of COLHEADERS (it's client-synthesized, see
// editStatusColumnRule.ts) - appended after headerArray is derived so it
// stays out of whatever headerArray drives, but before headerColumns is
// used below for cell-reference math (applyFormulaRules/DcValidator's
// rules must stay index-aligned with headerColumns).
this.headerColumns.push(EDIT_STATUS_COLUMN_NAME)
if (response.data.sasparams[0].DTVARS !== '') { if (response.data.sasparams[0].DTVARS !== '') {
this.dateHeaders = response.data.sasparams[0].DTVARS.split(' ') this.dateHeaders = response.data.sasparams[0].DTVARS.split(' ')
} }
@@ -3146,12 +3588,58 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
response.data.dqdata response.data.dqdata
) )
// Only turn on Handsontable's formulas plugin (HyperFormula engine) for
// tables that actually use HARDFORMULA/SOFTFORMULA - it's not free to
// run for every grid. gpl-v3: this app embeds HyperFormula under its
// GPLv3 free-tier terms, not a purchased commercial key.
this.hotTable.formulas = hasFormulaRules(response.data.dqrules)
? { engine: HyperFormula, licenseKey: 'gpl-v3' }
: false
this.cellValidation = this.dcValidator.getRules() this.cellValidation = this.dcValidator.getRules()
// to take datasource // to take datasource
this.dataSource = response.data.sasdata this.dataSource = response.data.sasdata
this.$dataFormats = response.data.$sasdata this.$dataFormats = response.data.$sasdata
// Raw, as-received snapshot - captured before applyFormulaRules below
// overwrites HARDFORMULA/SOFTFORMULA columns, so markOverwrittenCells
// can later tell "the formula filled in a blank" apart from "the
// formula overwrote a value the real dataset already had".
this.dataSourceRaw = this.helperService.deepClone(this.dataSource)
// Seed every row's EDIT_STATUS to 'U' before anything (HyperFormula
// included) ever sees this data - nothing has been edited yet, and this
// is also the baseline dataSourceUnchanged will later be cloned from, so
// future diffs never see a spurious EDIT_STATUS mismatch (see
// classifyRow's withoutEditStatus for why that would matter).
for (const row of this.dataSource) {
row[EDIT_STATUS_COLUMN_NAME] = 'U'
}
// Seed HARDFORMULA/SOFTFORMULA columns with their computed formula
// string per row. dataSourceUnchanged isn't populated yet this early in
// a fresh load (only the excel-preview/add-row flows set it) - at this
// point, before any edits exist, dataSource IS the unchanged baseline,
// so DC.ORIG_VALUE resolves correctly either way.
applyFormulaRules(
this.dataSource,
response.data.dqrules,
this.headerColumns,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks,
this.userService.user?.username ?? ''
)
// Seeded here too (not just editTable()) so a HARDFORMULA/SOFTFORMULA
// rule that silently overwrote real pre-existing data (see
// markOverwrittenCells) already shows the row as modified - both the
// '~' row header and EDIT_STATUS='M' - on the very first render, before
// the user ever clicks Edit. editTable() rebuilds this fresh every time
// it runs regardless, so setting it here doesn't affect that.
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
// Note: this.headerColumns and this.columnHeader contains same data // Note: this.headerColumns and this.columnHeader contains same data
// need to resolve redundancy // need to resolve redundancy
@@ -3183,6 +3671,26 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
columns: this.cellValidation, columns: this.cellValidation,
height: this.hotTable.height, height: this.hotTable.height,
formulas: this.hotTable.formulas, formulas: this.hotTable.formulas,
// The Formulas plugin resolves a =CELL_REF formula (e.g. what
// DC.ROW_STATUS compiles to, see parseFormulaRule.ts) through its own
// data-sync path into HyperFormula - a different path than
// getDataAtRowProp/datamap.get() (see editStatusColumnRule.ts for
// why a dotted `data` key is otherwise safe there). That path
// doesn't check for an own flat property before falling back to
// nested dot-notation, so a referenced cell whose `data` key
// contains a literal '.' (like dc.row_status) resolves to
// undefined/0 instead of its real value. This app never uses nested
// `data` paths, so disabling dot-notation entirely lets
// DC.ROW_STATUS (and anything that branches on it, e.g.
// CHANGE_SUMMARY_COL's IF) resolve correctly without giving up the
// collision-proof dotted name.
dataDotNotation: false,
// readOnly here means comments can never be added/edited/removed
// through the UI - only markOverwrittenCells/
// syncOverwrittenCommentForCell (via the plugin API) ever set one.
// The context menu below only offers our own "Revert" item, never
// the plugin's own add/edit/remove ones.
comments: { readOnly: true },
stretchH: 'all', stretchH: 'all',
readOnly: this.hotTable.readOnly, readOnly: this.hotTable.readOnly,
hiddenColumns: { hiddenColumns: {
@@ -3204,11 +3712,62 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
filters: false, filters: false,
manualRowResize: true, manualRowResize: true,
viewportRowRenderingOffset: 100, viewportRowRenderingOffset: 100,
// show a bar on the left to enable users to select an entire row // Doubles as the edit-status indicator: +/-/~ for
// added/deleted/modified rows. Handsontable re-invokes this on
// every render, so it stays live as rows are edited/added/deleted
// without any extra wiring. Falls back to a plain space (not an
// empty string) for unchanged rows and before dataSource is
// populated, keeping the original row-selection bar's click target
// intact - a space and '' render identically, so this changes
// nothing visually.
//
// `index` is the VISUAL row - once multiColumnSorting reorders the
// grid, that no longer matches dataSource's physical order, so it
// must be translated via toPhysicalRow() before indexing into
// dataSource. Without this, a sorted grid shows every row's symbol
// shifted to the wrong row.
rowHeaders: (index: number) => { rowHeaders: (index: number) => {
return ' ' const physicalRow = hot.toPhysicalRow(index)
const dataRow =
physicalRow === null ? undefined : this.dataSource[physicalRow]
if (!dataRow) return ' '
return (
getEditStatusSymbol(
classifyRow(
dataRow,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks
)
) || ' '
)
}, },
rowHeaderWidth: 15, // Colors the row-header cell to match its edit status (see
// rowHeaders above) - same physicalRow translation for the same
// sorted-grid reason.
afterGetRowHeader: (row: number, th: any) => {
const physicalRow = hot.toPhysicalRow(row)
const dataRow =
physicalRow === null ? undefined : this.dataSource[physicalRow]
if (!dataRow) return
const status = classifyRow(
dataRow,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks
)
th.classList.remove(
'rowStatusAdded',
'rowStatusDeleted',
'rowStatusModified'
)
if (status === 'A') th.classList.add('rowStatusAdded')
if (status === 'D') th.classList.add('rowStatusDeleted')
if (status === 'M') th.classList.add('rowStatusModified')
},
rowHeaderWidth: 20,
rowHeights: 24, rowHeights: 24,
maxRows: this.licenceState.value.editor_rows_allowed || Infinity, maxRows: this.licenceState.value.editor_rows_allowed || Infinity,
invalidCellClassName: 'htInvalid', invalidCellClassName: 'htInvalid',
@@ -3230,6 +3789,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}, },
info: { info: {
name: 'test info', name: 'test info',
// Purely informational (no callback) - without this, Handsontable
// still treats a click landing anywhere inside it as "the item
// was activated" (see preventMenuItemAutoClose's own comment),
// auto-closing the menu and blocking the native right-click
// "Copy" menu before the user can select any of the text.
isCommand: false,
renderer: ( renderer: (
hot: Handsontable.Core, hot: Handsontable.Core,
wrapper: HTMLElement, wrapper: HTMLElement,
@@ -3256,16 +3821,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
const { hardRegexValue, softRegexValue } = const { hardRegexValue, softRegexValue } =
this.dcValidator?.getRegexRuleValues(colName) || {} this.dcValidator?.getRegexRuleValues(colName) || {}
const formulaValue =
this.dcValidator?.getFormulaRuleValue(colName)
textInfo = buildColInfoHtml( textInfo = buildColInfoHtml(
colName, colName,
colInfo, colInfo,
hardRegexValue, hardRegexValue,
softRegexValue softRegexValue,
formulaValue
) )
} }
elem.innerHTML = textInfo elem.innerHTML = textInfo
preventMenuItemAutoClose(elem)
return elem return elem
} }
@@ -3351,6 +3920,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
false false
) )
// Must run after the updateSettings() call above: getDataAtRowProp only
// resolves formulas' live computed values once HyperFormula has
// actually evaluated them against the data/formulas settings just
// applied.
this.markOverwrittenCells()
this.hotTable.hidden = false this.hotTable.hidden = false
// Keep the context menu enabled in view mode too so Copy/Export remain // Keep the context menu enabled in view mode too so Copy/Export remain
// available; editing items hide themselves when read-only. // available; editing items hide themselves when read-only.
@@ -3475,6 +4050,41 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
}) })
// Keeps each edited row's EDIT_STATUS cell in sync live, so
// DC.ROW_STATUS-based formulas recalculate immediately - not just on
// insert/delete (already handled at their own call sites) but on any
// direct edit, paste or autofill too. 'loadData' fires on every
// hot.updateSettings({data: ...}) call this component already makes
// (initial load, cancelSubmit, ...) where dataSource is already
// consistent, so it's skipped; 'editStatus' is this same hook's own
// writes (via updateEditStatusForRow), skipped to avoid recursion.
//
// Also keeps each edited cell's "overwritten" comment in sync live -
// markOverwrittenCells only runs once, at initial load, so a direct
// edit made afterward (to a cell that wasn't already overwritten by a
// formula) needs its own comment set here; typing a value back to
// match the original just as readily needs that comment removed again.
hot.addHook('afterChange', (changes: any[], source: any) => {
if (!changes || source === 'loadData' || source === 'editStatus') return
const revertableCols = this.getRevertableColumnNames()
const changedRows = new Set<number>()
for (const change of changes) {
if (!change) continue
const [row, prop] = change
if (prop === EDIT_STATUS_COLUMN_NAME) continue
changedRows.add(row)
if (revertableCols.includes(prop)) {
this.syncOverwrittenCommentForCell(row, prop)
}
}
for (const row of changedRows) this.updateEditStatusForRow(row)
})
hot.addHook('afterPaste', async (_data: any, coords: any) => { hot.addHook('afterPaste', async (_data: any, coords: any) => {
// In read-only mode HOT discards the paste itself, so nothing to validate. // In read-only mode HOT discards the paste itself, so nothing to validate.
if (this.hotTable.readOnly) return if (this.hotTable.readOnly) return
@@ -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,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,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
}
+136 -31
View File
@@ -5,6 +5,11 @@
<div class="card-text"> <div class="card-text">
<ng-container *ngSwitchCase="'key'"> <ng-container *ngSwitchCase="'key'">
<p class="key-error" *ngIf="!keyError"> <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 Licence key is invalid. We can't provide you more details at the
moment moment
</p> </p>
@@ -20,6 +25,11 @@
<ng-container *ngSwitchCase="'limit'"> <ng-container *ngSwitchCase="'limit'">
<p class="key-error"> <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 The registered number of users reached the limit specified for your
licence. Please contact licence. Please contact
<contact-link classes="color-green" /> <contact-link classes="color-green" />
@@ -34,9 +44,9 @@
</p> </p>
</ng-container> </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> <strong>SYSSITE:</strong>
<span <span
*ngFor="let id of syssite.value; let i = index" *ngFor="let id of syssite.value; let i = index"
@@ -59,11 +69,6 @@
</a> </a>
</p> </p>
<p *ngIf="licenseKeyData && userCountLimitation" class="m-0">
<strong>Allowed users:</strong>
{{ licenseKeyData.users_allowed }}
</p>
<clr-tabs> <clr-tabs>
<clr-tab> <clr-tab>
<button clrTabLink>Upload licence</button> <button clrTabLink>Upload licence</button>
@@ -102,34 +107,102 @@
<clr-tab> <clr-tab>
<button clrTabLink>Paste licence</button> <button clrTabLink>Paste licence</button>
<clr-tab-content> <clr-tab-content>
<form class="clr-form license-key-form"> <ng-container *ngIf="keyFormat === 'combined'">
<p>Licence key:</p> <form class="clr-form combined-key-form">
<div class="clr-control-container"> <p>Licence key:</p>
<textarea <div class="clr-control-container">
[(ngModel)]="licenceKeyValue" <textarea
(mouseleave)="trimKeys()" [(ngModel)]="combinedKeyValue"
name="license-key-area" (mouseleave)="onCombinedKeyInput()"
placeholder="Paste licence key here" name="combined-key-area"
class="clr-textarea" placeholder="Paste licence key here"
></textarea> class="clr-textarea"
</div> ></textarea>
</form> </div>
</form>
<form class="clr-form activation-key-form"> <button
<p>Activation key:</p> type="button"
<div class="clr-control-container"> class="btn btn-sm btn-link p-0"
<textarea (click)="toggleKeyFormat()"
[(ngModel)]="activationKeyValue" >
(mouseleave)="trimKeys()" Paste as two separate keys instead
name="activation-key-area" </button>
placeholder="Paste activation key here" </ng-container>
class="clr-textarea"
></textarea> <ng-container *ngIf="keyFormat === 'legacy'">
</div> <form class="clr-form license-key-form">
</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-content>
</clr-tab> </clr-tab>
</clr-tabs> </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>
<div class="card-footer d-flex clr-align-items-center"> <div class="card-footer d-flex clr-align-items-center">
@@ -150,6 +223,38 @@
Continue with free tier Continue with free tier
</button> </button>
</div> </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>
</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 { Component, OnInit, ViewEncapsulation } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { AppService, LicenceService, SasService } from '../services' import { AppService, LicenceService, SasService } from '../services'
import { LicenseKeyData } from '../models/LicenseKeyData' import { LicenseKeyData } from '../models/LicenseKeyData'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse' 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 { enum LicenseActions {
key = 'key', key = 'key',
@@ -21,12 +31,19 @@ enum LicenseActions {
export class LicensingComponent implements OnInit { export class LicensingComponent implements OnInit {
public action: LicenseActions | null = null public action: LicenseActions | null = null
public licenseErrors: { [key: string]: string } = { // Each message carries its own icon markup, rather than relying on a
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.`, // sibling element in the template - these render via [innerHTML], which
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.`, // replaces all of the host element's children, so a real <clr-icon>
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.`, // placed in the template around the binding would never survive.
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.` 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 keyError: string | undefined
public errorDetails: string | undefined public errorDetails: string | undefined
@@ -34,6 +51,12 @@ export class LicensingComponent implements OnInit {
public licenceKeyValue: string = '' public licenceKeyValue: string = ''
public activationKeyValue: 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 applyingKeys: boolean = false
public protocol: string = public protocol: string =
location.protocol === 'https:' location.protocol === 'https:'
@@ -44,7 +67,6 @@ export class LicensingComponent implements OnInit {
public currentLicenceKey = this.licenceService.licenceKey public currentLicenceKey = this.licenceService.licenceKey
public currentActivationKey = this.licenceService.activationKey public currentActivationKey = this.licenceService.activationKey
public isAppFreeTier = this.licenceService.isAppFreeTier public isAppFreeTier = this.licenceService.isAppFreeTier
public userCountLimitation = this.licenceService.userCountLimitation
public licenseKeyData: LicenseKeyData | null = null public licenseKeyData: LicenseKeyData | null = null
@@ -60,8 +82,24 @@ export class LicensingComponent implements OnInit {
private router: Router, private router: Router,
private licenceService: LicenceService, private licenceService: LicenceService,
private sasService: SasService, 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 { ngOnInit(): void {
this.licenceKeyValue = this.currentLicenceKey || '' this.licenceKeyValue = this.currentLicenceKey || ''
@@ -89,9 +127,73 @@ export class LicensingComponent implements OnInit {
this.licenseKeyData = this.licenceService.getLicenseKeyData() this.licenseKeyData = this.licenceService.getLicenseKeyData()
} }
public trimKeys() { public async trimKeys() {
this.licenceKeyValue = this.licenceKeyValue.trim() this.licenceKeyValue = this.licenceKeyValue.trim()
this.activationKeyValue = this.activationKeyValue.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[]) { public copySyssite(copyIconRef: any, copyTooltip: any, syssite: string[]) {
@@ -150,7 +252,7 @@ export class LicensingComponent implements OnInit {
const reader = new FileReader() const reader = new FileReader()
reader.onload = (evt) => { reader.onload = async (evt) => {
this.licenceFileError = 'Error reading file.' this.licenceFileError = 'Error reading file.'
if (!evt || !evt.target) return if (!evt || !evt.target) return
@@ -160,9 +262,20 @@ export class LicensingComponent implements OnInit {
this.licenceFileLoading = false this.licenceFileLoading = false
this.licenceFileError = undefined this.licenceFileError = undefined
const fileArr = evt.target.result.toString().split('\n')
this.activationKeyValue = fileArr[1] const fileArr = evt.target.result.toString().trim().split('\n')
this.licenceKeyValue = fileArr[0]
// 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) reader.readAsText(file)
@@ -180,7 +293,29 @@ export class LicensingComponent implements OnInit {
this.activationKeyValue === this.currentActivationKey this.activationKeyValue === this.currentActivationKey
) )
return true 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 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,59 @@
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 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()),
new Response(decompressionStream.readable).arrayBuffer()
])
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,37 @@
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.
*
* 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
)
}
+5
View File
@@ -15,6 +15,11 @@ export interface LicenceState {
} }
/** /**
* Legacy-format decode table only - newly-issued keys use a named
* LicenceFeaturesObject instead (see decodeLicenceFeatures.ts). Kept
* unchanged and permanently, since every already-issued key still relies
* on these ordinal positions to decode.
*
* '-' means unset * '-' means unset
* '0' disabled * '0' disabled
* '1' enabled * '1' enabled
+3 -1
View File
@@ -1,3 +1,5 @@
import { LicenceFeaturesObject } from '../services/utils/decodeLicenceFeatures'
export interface LicenseKeyData { export interface LicenseKeyData {
valid_until: string valid_until: string
users_allowed: number users_allowed: number
@@ -5,5 +7,5 @@ export interface LicenseKeyData {
site_id_multiple: string[] site_id_multiple: string[]
demo: boolean demo: boolean
hot_license_key: string | undefined hot_license_key: string | undefined
features?: string features?: string | LicenceFeaturesObject
} }
@@ -138,4 +138,36 @@ describe('AppService - startup retry', () => {
expect(deps.sasService.request).toHaveBeenCalledTimes(1) expect(deps.sasService.request).toHaveBeenCalledTimes(1)
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1) expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
}) })
// Reproduces a misconfigured Viya computeTasks deployment: the Compute
// service returns a plain-text "Job error" body instead of JSON: the
// adapter can't parse it and resolves with the raw text as adapterResponse
// itself (see getMalformedAdapterResponseMessage's own doc comment).
// "Globvars, Sasdatasets, Saslibs, XLMaps are not present" would also be
// technically true here, but hides the real cause.
it('shows the real response text (not the generic missing-props message) 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 appService = buildAppService(deps)
;(appService as any).retryOptions = {
wait: () => Promise.resolve(),
random: () => 0
}
await appService.startUpData()
expect(deps.sasService.request).toHaveBeenCalledTimes(1)
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
const [, message] = deps.eventService.showInfoModal.calls.mostRecent().args
expect(message).toContain(rawJobError)
expect(message).not.toContain('Globvars, Sasdatasets, Saslibs, XLMaps')
expect(deps.licenceService.isAppActivated.value).toBeFalse()
})
}) })
+12
View File
@@ -12,6 +12,7 @@ import { AppThemes } from '../models/AppSettings'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse' import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { AppStoreService } from './app-store.service' import { AppStoreService } from './app-store.service'
import { retryOnce, RetryOnceOptions } from '../shared/utils/retry-once' import { retryOnce, RetryOnceOptions } from '../shared/utils/retry-once'
import { getMalformedAdapterResponseMessage } from '../shared/utils/get-malformed-adapter-response-message'
@Injectable() @Injectable()
export class AppService { export class AppService {
@@ -94,6 +95,17 @@ export class AppService {
this.retryOptions this.retryOptions
) )
.then(async (res: RequestWrapperResponse) => { .then(async (res: RequestWrapperResponse) => {
const malformedMessage = getMalformedAdapterResponseMessage(
res.adapterResponse
)
if (malformedMessage) {
startupServiceError = true
this.eventService.showInfoModal('Error', malformedMessage)
this.licenceService.isAppActivated.next(false)
return
}
this.syssite.next([res.adapterResponse.SYSSITE]) this.syssite.next([res.adapterResponse.SYSSITE])
let missingProps: string[] = [] let missingProps: string[] = []
+10 -60
View File
@@ -10,7 +10,8 @@ import { Globvar } from '../models/sas/public-startupservice.model'
import { freeTierConfig } from '../free-tier.config' import { freeTierConfig } from '../free-tier.config'
import { AppStoreService } from './app-store.service' import { AppStoreService } from './app-store.service'
import { HelperService } from './helper.service' import { HelperService } from './helper.service'
import { LicenceFeaturesMap, LicenceState } from '../models/LicenceState' import { LicenceState } from '../models/LicenceState'
import { decodeLicenceFeatures as decodeFeatures } from './utils/decodeLicenceFeatures'
import { LoggerService } from './logger.service' import { LoggerService } from './logger.service'
import { EventService } from './event.service' import { EventService } from './event.service'
@@ -255,9 +256,9 @@ export class LicenceService {
} }
/** /**
* Decode and set features that are encoded in the key * Decode and set features that are encoded in the key - accepts either
* featureValue - used as number/Infinity for limit set * an already-issued key's legacy positional string or a newly-issued
* featureToggle - used as boolean, based on 1 or 0 encoded in the key * key's named object, see decodeLicenceFeatures for the format contract.
* @param licenseData from the licence key * @param licenseData from the licence key
*/ */
private decodeLicenceFeatures(licenseData: LicenseKeyData) { private decodeLicenceFeatures(licenseData: LicenseKeyData) {
@@ -270,72 +271,21 @@ export class LicenceService {
} }
} }
const featuresMap = licenseData.features.split(',')
this._licenceState = { this._licenceState = {
...this._licenceState, ...this._licenceState,
viewer_rows_allowed: this.parseFeatureValue( ...decodeFeatures(licenseData.features)
featuresMap[LicenceFeaturesMap.viewer_rows_allowed]
),
editor_rows_allowed: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.editor_rows_allowed]
),
stage_rows_allowed: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.stage_rows_allowed]
),
history_rows_allowed: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.history_rows_allowed]
),
submit_rows_limit: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.submit_rows_limit]
),
tables_in_library_limit: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.tables_in_library_limit]
),
viewbox_limit: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.viewbox_limit]
),
lineage_daily_limit: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.lineage_daily_limit]
),
viewbox: this.parseFeatureToggle(featuresMap[LicenceFeaturesMap.viewbox]),
fileUpload: this.parseFeatureToggle(
featuresMap[LicenceFeaturesMap.fileUpload]
),
editRecord: this.parseFeatureToggle(
featuresMap[LicenceFeaturesMap.editRecord]
),
addRecord: this.parseFeatureToggle(
featuresMap[LicenceFeaturesMap.addRecord]
)
} }
this.loggerService.log('Licence state:', this._licenceState) this.loggerService.log('Licence state:', this._licenceState)
} }
/**
* Converts licence key feature code value to the number or Infinity
* Used for limiting rows, submits etc.
* @param codeBit from licence key encoded features. Every bit is separated by comma(,) Eg. 5,10,15
* @returns number
*/
private parseFeatureValue(codeBit: string): number {
if (codeBit === '-') return Infinity
return parseInt(codeBit)
}
/**
* Converts licence key feature code value to the boolean. Depending on if it's 0 or 1
* @param codeBit from licence key encoded features. Every bit is separated by comma(,) Eg. 1,1,0
* @returns boolean to turn on or off the value
*/
private parseFeatureToggle(codeBit: string): boolean {
return !!parseInt(codeBit)
}
/** /**
* If decryption fails, key will be marked as invalid and app not activated. * If decryption fails, key will be marked as invalid and app not activated.
* Public (not just used internally by licensing()) since it's a pure
* decrypt with no side effects - also used to preview a pasted key's
* details before it's actually applied.
*/ */
private decryptLicenseKey( public decryptLicenseKey(
licenseKey: string, licenseKey: string,
activationKey: string activationKey: string
): Promise<LicenseKeyData> { ): Promise<LicenseKeyData> {
@@ -0,0 +1,79 @@
import { decodeLicenceFeatures } from './decodeLicenceFeatures'
describe('decodeLicenceFeatures', () => {
// Positional order per LicenceFeaturesMap: viewer_rows_allowed,
// editor_rows_allowed, stage_rows_allowed, history_rows_allowed,
// submit_rows_limit, tables_in_library_limit, viewbox, viewbox_limit,
// lineage_daily_limit, fileUpload, editRecord, addRecord.
const legacyFeaturesString = '10,15,20,-,5,35,1,3,7,1,0,1'
const newFeaturesObject = {
vra: 10,
era: 15,
sra: 20,
hra: null,
srl: 5,
till: 35,
vb: true,
vbl: 3,
ldl: 7,
fu: true,
er: false,
ar: true
}
const expectedDecodedState = {
viewer_rows_allowed: 10,
editor_rows_allowed: 15,
stage_rows_allowed: 20,
history_rows_allowed: Infinity,
submit_rows_limit: 5,
tables_in_library_limit: 35,
viewbox: true,
viewbox_limit: 3,
lineage_daily_limit: 7,
fileUpload: true,
editRecord: false,
addRecord: true
}
it('decodes a legacy positional string exactly as before', () => {
expect(decodeLicenceFeatures(legacyFeaturesString)).toEqual(
expectedDecodedState
)
})
it('decodes a new named object', () => {
expect(decodeLicenceFeatures(newFeaturesObject)).toEqual(
expectedDecodedState
)
})
it('produces an identical result for the same licence expressed in either format', () => {
expect(decodeLicenceFeatures(legacyFeaturesString)).toEqual(
decodeLicenceFeatures(newFeaturesObject)
)
})
it('maps "-" in a legacy limit position to Infinity', () => {
const result = decodeLicenceFeatures(legacyFeaturesString)
expect(result.history_rows_allowed).toBe(Infinity)
})
it('maps null in a new-format limit field to Infinity', () => {
const result = decodeLicenceFeatures(newFeaturesObject)
expect(result.history_rows_allowed).toBe(Infinity)
})
it('maps "1"/"0" in legacy toggle positions to true/false', () => {
const result = decodeLicenceFeatures(legacyFeaturesString)
expect(result.viewbox).toBe(true)
expect(result.editRecord).toBe(false)
})
it('passes new-format boolean toggle fields straight through', () => {
const result = decodeLicenceFeatures(newFeaturesObject)
expect(result.viewbox).toBe(true)
expect(result.editRecord).toBe(false)
})
})
@@ -0,0 +1,91 @@
import { LicenceFeaturesMap, LicenceState } from '../../models/LicenceState'
// Field names are abbreviated on the wire to keep the RSA-OAEP-encrypted
// payload small - see "Why abbreviated field names" in
// licence-features-object-plan.md for the full name each one stands for
// and why this is a fixed, append-only contract (same invariant as
// LicenceFeaturesMap's ordinals).
export type LicenceFeaturesObject = {
vra: number | null // viewer_rows_allowed
era: number | null // editor_rows_allowed
sra: number | null // stage_rows_allowed
hra: number | null // history_rows_allowed
srl: number | null // submit_rows_limit
till: number | null // tables_in_library_limit
vb: boolean // viewbox
vbl: number | null // viewbox_limit
ldl: number | null // lineage_daily_limit
fu: boolean // fileUpload
er: boolean // editRecord
ar: boolean // addRecord
}
// Accepts either an already-issued key's positional string
// ("-,-,-,-,-,-,1,-,-,1,1,1", decoded via LicenceFeaturesMap) or a
// newly-issued key's named object - see licence-features-object-plan.md
// for why these two shapes are how format versioning is done here (no
// separate version field: the shapes are already distinguishable).
export const decodeLicenceFeatures = (
features: string | LicenceFeaturesObject
): Partial<LicenceState> => {
if (typeof features === 'object') {
return decodeFeaturesObject(features)
}
return decodeFeaturesString(features)
}
const decodeFeaturesObject = (
features: LicenceFeaturesObject
): Partial<LicenceState> => ({
viewer_rows_allowed: features.vra ?? Infinity,
editor_rows_allowed: features.era ?? Infinity,
stage_rows_allowed: features.sra ?? Infinity,
history_rows_allowed: features.hra ?? Infinity,
submit_rows_limit: features.srl ?? Infinity,
tables_in_library_limit: features.till ?? Infinity,
viewbox: features.vb,
viewbox_limit: features.vbl ?? Infinity,
lineage_daily_limit: features.ldl ?? Infinity,
fileUpload: features.fu,
editRecord: features.er,
addRecord: features.ar
})
// Existing positional-string decode, moved here unchanged from
// LicenceService.decodeLicenceFeatures/parseFeatureValue/parseFeatureToggle.
const decodeFeaturesString = (features: string): Partial<LicenceState> => {
const featuresMap = features.split(',')
const parseValue = (codeBit: string) =>
codeBit === '-' ? Infinity : parseInt(codeBit)
const parseToggle = (codeBit: string) => !!parseInt(codeBit)
return {
viewer_rows_allowed: parseValue(
featuresMap[LicenceFeaturesMap.viewer_rows_allowed]
),
editor_rows_allowed: parseValue(
featuresMap[LicenceFeaturesMap.editor_rows_allowed]
),
stage_rows_allowed: parseValue(
featuresMap[LicenceFeaturesMap.stage_rows_allowed]
),
history_rows_allowed: parseValue(
featuresMap[LicenceFeaturesMap.history_rows_allowed]
),
submit_rows_limit: parseValue(
featuresMap[LicenceFeaturesMap.submit_rows_limit]
),
tables_in_library_limit: parseValue(
featuresMap[LicenceFeaturesMap.tables_in_library_limit]
),
viewbox_limit: parseValue(featuresMap[LicenceFeaturesMap.viewbox_limit]),
lineage_daily_limit: parseValue(
featuresMap[LicenceFeaturesMap.lineage_daily_limit]
),
viewbox: parseToggle(featuresMap[LicenceFeaturesMap.viewbox]),
fileUpload: parseToggle(featuresMap[LicenceFeaturesMap.fileUpload]),
editRecord: parseToggle(featuresMap[LicenceFeaturesMap.editRecord]),
addRecord: parseToggle(featuresMap[LicenceFeaturesMap.addRecord])
}
}
@@ -19,6 +19,7 @@ import { getNotNullDefault } from './utils/getNotNullDefault'
import { mergeColsRules } from './utils/mergeColsRules' import { mergeColsRules } from './utils/mergeColsRules'
import { parseColTypeRow } from './utils/parseColTypeRow' import { parseColTypeRow } from './utils/parseColTypeRow'
import { DELETE_RECORD_COLUMN_RULE } from './utils/deleteRecordColumnRule' import { DELETE_RECORD_COLUMN_RULE } from './utils/deleteRecordColumnRule'
import { EDIT_STATUS_COLUMN_RULE } from './utils/editStatusColumnRule'
import { dqValidate } from './validations/dq-validation' import { dqValidate } from './validations/dq-validation'
import { import {
datetimeValidator, datetimeValidator,
@@ -84,6 +85,13 @@ export class DcValidator {
this.rules = mergeColsRules(cols, this.rules, $dataFormats) this.rules = mergeColsRules(cols, this.rules, $dataFormats)
this.rules = applyNumericFormats(this.rules) this.rules = applyNumericFormats(this.rules)
this.rules = mapIntlCellTypes(this.rules) this.rules = mapIntlCellTypes(this.rules)
// EDIT_STATUS is appended last, always hidden - it's a purely
// client-synthesized column (never in COLHEADERS) that gives
// DC.ROW_STATUS a real cell to reference. See its own doc comment.
this.rules.push({ ...EDIT_STATUS_COLUMN_RULE })
this.hiddenColumns.push(this.rules.length - 1)
this.dqrules = dqRules this.dqrules = dqRules
this.dqdata = dqData this.dqdata = dqData
this.primaryKeys = sasparams.PK.split(' ') this.primaryKeys = sasparams.PK.split(' ')
@@ -229,6 +237,25 @@ export class DcValidator {
} }
} }
/**
* Returns the RULE_VALUE of a HARDFORMULA/SOFTFORMULA rule on the given
* column, for display in the column-header info dropdown. A column only
* ever carries one of the two in practice (readonly-computed vs.
* overridable-default aren't a meaningful combination), so unlike
* getRegexRuleValues there's no need to distinguish which one this is.
*
* @param col column name
*/
getFormulaRuleValue(col: string): string | undefined {
const formulaRule = this.dqrules.find(
(rule: DQRule) =>
rule.BASE_COL === col &&
(rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA')
)
return formulaRule?.RULE_VALUE
}
/** /**
* Retrieves dropdown source for given dc validation rule * Retrieves dropdown source for given dc validation rule
* The values comes from MPE_SELECTBOX table * The values comes from MPE_SELECTBOX table
@@ -439,6 +466,14 @@ export class DcValidator {
this.rules[i].readOnly = true this.rules[i].readOnly = true
} }
// HARDFORMULA: same read-only treatment as an explicit READONLY rule
// - its value is always computed (see applyFormulaRules), never
// user-editable. SOFTFORMULA deliberately does not set this: its
// computed value is only a default, the user can overwrite it.
if (this.hasDqRules(ruleColName, ['HARDFORMULA'])) {
this.rules[i].readOnly = true
}
// HIDDEN: hide column in HOT but keep its data (still submitted via hot.getData()) // HIDDEN: hide column in HOT but keep its data (still submitted via hot.getData())
if (this.hasDqRules(ruleColName, ['HIDDEN'])) { if (this.hasDqRules(ruleColName, ['HIDDEN'])) {
this.hiddenColumns.push(i) this.hiddenColumns.push(i)
@@ -20,3 +20,5 @@ export type DQRuleTypes =
| 'NUMBER_FORMAT' | 'NUMBER_FORMAT'
| 'HARDREGEX' | 'HARDREGEX'
| 'SOFTREGEX' | 'SOFTREGEX'
| 'HARDFORMULA'
| 'SOFTFORMULA'
@@ -3,6 +3,7 @@ import { DQData, SASParam } from 'src/app/models/TableData'
import { DcValidator } from '../dc-validator' import { DcValidator } from '../dc-validator'
import { Col } from '../models/col.model' import { Col } from '../models/col.model'
import { DQRule } from '../models/dq-rules.model' import { DQRule } from '../models/dq-rules.model'
import { EDIT_STATUS_COLUMN_NAME } from '../utils/editStatusColumnRule'
describe('DC Validator', () => { describe('DC Validator', () => {
it('should create an instance of validator with correct rules', () => { it('should create an instance of validator with correct rules', () => {
@@ -24,10 +25,10 @@ describe('DC Validator', () => {
expect(cols[0].TYPE).toEqual('char') expect(cols[0].TYPE).toEqual('char')
// Get all — one rule per cols[] entry, plus the injected // Get all — one rule per cols[] entry, plus the injected
// DELETE_RECORD_COLUMN_RULE (never present in cols[], see its own // DELETE_RECORD_COLUMN_RULE and EDIT_STATUS_COLUMN_RULE (neither is ever
// doc comment) // present in cols[], see their own doc comments)
const validationRules = dcValidator.getRules() const validationRules = dcValidator.getRules()
expect(validationRules).toHaveSize(example_cols.length + 1) expect(validationRules).toHaveSize(example_cols.length + 2)
// Get col with notnull validation // Get col with notnull validation
const someNumRule = dcValidator.getRule('SOME_NUM') const someNumRule = dcValidator.getRule('SOME_NUM')
@@ -75,6 +76,17 @@ describe('DC Validator', () => {
) )
expect(deleteRecordRule?.type).toEqual('dropdown') expect(deleteRecordRule?.type).toEqual('dropdown')
expect(deleteRecordRule?.source).toEqual(['No', 'Yes']) expect(deleteRecordRule?.source).toEqual(['No', 'Yes'])
// EDIT_STATUS is likewise never a cols[] entry - it's a purely
// client-synthesized column (see editStatusColumnRule.ts) so
// DC.ROW_STATUS has a real cell to reference. Always the last rule,
// always read-only, always hidden.
const editStatusRule = dcValidator.getRule(EDIT_STATUS_COLUMN_NAME)
expect(editStatusRule?.readOnly).toBeTrue()
expect(validationRules[validationRules.length - 1].data).toEqual(
EDIT_STATUS_COLUMN_NAME
)
expect(dcValidator.getHiddenColumns()).toContain(validationRules.length - 1)
}) })
it('should create an instance of validator and execute its functions', () => { it('should create an instance of validator and execute its functions', () => {
@@ -305,9 +317,10 @@ describe('DC Validator', () => {
example_dqData example_dqData
) )
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual( expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
example_sasparams.COLHEADERS.split(',') ...example_sasparams.COLHEADERS.split(','),
) EDIT_STATUS_COLUMN_NAME
])
}) })
it("6 | keeps rules aligned with COLHEADERS when the PK is not the source table's first column", () => { it("6 | keeps rules aligned with COLHEADERS when the PK is not the source table's first column", () => {
@@ -344,9 +357,10 @@ describe('DC Validator', () => {
[] []
) )
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual( expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
sasparams.COLHEADERS.split(',') ...sasparams.COLHEADERS.split(','),
) EDIT_STATUS_COLUMN_NAME
])
}) })
it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => { it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => {
@@ -373,9 +387,10 @@ describe('DC Validator', () => {
[] []
) )
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual( expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
sasparams.COLHEADERS.split(',') ...sasparams.COLHEADERS.split(','),
) EDIT_STATUS_COLUMN_NAME
])
}) })
it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => { it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => {
@@ -872,6 +887,53 @@ describe('DC Validator', () => {
}) })
}) })
}) })
describe('16 | getFormulaRuleValue (column-header info display)', () => {
const buildValidator = (dqRules: DQRule[]) =>
new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
dqRules,
example_dqData
)
it('returns the RULE_VALUE for a HARDFORMULA rule', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDFORMULA',
RULE_VALUE: 'A_COL * B_COL',
X: 0
}
])
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toEqual(
'A_COL * B_COL'
)
})
it('returns the RULE_VALUE for a SOFTFORMULA rule', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTFORMULA',
RULE_VALUE: 'A_COL + B_COL',
X: 0
}
])
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toEqual(
'A_COL + B_COL'
)
})
it('returns undefined for a column with no formula rule', () => {
const dcValidator = buildValidator([])
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toBeUndefined()
})
})
}) })
/** Minimal cols[] entry — only the fields rule ordering depends on. */ /** Minimal cols[] entry — only the fields rule ordering depends on. */
@@ -0,0 +1,116 @@
import { DQRule } from '../models/dq-rules.model'
import { applyFormulaRules } from './applyFormulaRules'
const rule = (overrides: Partial<DQRule>): DQRule => ({
BASE_COL: 'REVENUE',
RULE_TYPE: 'HARDFORMULA',
RULE_VALUE: '=PRICE * VOLUME',
X: 0,
...overrides
})
const columnNames = ['ITEM', 'PRICE', 'VOLUME', 'REVENUE']
const headerPks = ['ITEM']
describe('applyFormulaRules', () => {
it('leaves the dataset untouched when there are no formula rules', () => {
const dataSource = [{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, REVENUE: '' }]
const result = applyFormulaRules(
dataSource,
[],
columnNames,
dataSource,
headerPks,
'sasdemo'
)
expect(result).toEqual(dataSource)
})
it("injects the row-relative formula string for a HARDFORMULA column (issue's own PRICE*VOLUME example)", () => {
const dataSource = [
{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, REVENUE: '' },
{ ITEM: 'PEN', PRICE: 61.02, VOLUME: 1971, REVENUE: '' }
]
const result = applyFormulaRules(
dataSource,
[rule({})],
columnNames,
dataSource,
headerPks,
'sasdemo'
)
expect(result[0].REVENUE).toEqual('=B1 * C1')
expect(result[1].REVENUE).toEqual('=B2 * C2')
})
it('injects the formula for a SOFTFORMULA column too (only readOnly-ness differs, handled elsewhere)', () => {
const dataSource = [{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, REVENUE: '' }]
const result = applyFormulaRules(
dataSource,
[rule({ RULE_TYPE: 'SOFTFORMULA' })],
columnNames,
dataSource,
headerPks,
'sasdemo'
)
expect(result[0].REVENUE).toEqual('=B1 * C1')
})
it('resolves DC.ORIG_VALUE via the original (pre-edit) row, PK-matched', () => {
const dataSourceUnchanged = [
{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, NOTE: 'original note' }
]
const dataSource = [{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 999, NOTE: '' }]
const result = applyFormulaRules(
dataSource,
[rule({ BASE_COL: 'NOTE', RULE_VALUE: '=DC.ORIG_VALUE' })],
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
dataSourceUnchanged,
headerPks,
'sasdemo'
)
expect(result[0].NOTE).toEqual('="original note"')
})
it('resolves DC.ORIG_VALUE to an empty literal for a newly-added row with no PK match', () => {
const dataSource = [{ ITEM: 'NEWITEM', PRICE: 1, VOLUME: 1, NOTE: '' }]
const result = applyFormulaRules(
dataSource,
[rule({ BASE_COL: 'NOTE', RULE_VALUE: '=DC.ORIG_VALUE' })],
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
[],
headerPks,
'sasdemo'
)
expect(result[0].NOTE).toEqual('=""')
})
it('resolves DC.USER_NAME to the current username for every row', () => {
const dataSource = [
{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, NOTE: '' },
{ ITEM: 'PEN', PRICE: 61.02, VOLUME: 1971, NOTE: '' }
]
const result = applyFormulaRules(
dataSource,
[rule({ BASE_COL: 'NOTE', RULE_VALUE: '=DC.USER_NAME' })],
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
dataSource,
headerPks,
'sasdemo'
)
expect(result[0].NOTE).toEqual('="sasdemo"')
expect(result[1].NOTE).toEqual('="sasdemo"')
})
})
@@ -0,0 +1,44 @@
import { DQRule } from '../models/dq-rules.model'
import { parseFormulaRule } from './parseFormulaRule'
/**
* Injects the computed `=...` formula string into each row's data for every
* HARDFORMULA/SOFTFORMULA column, so Handsontable/HyperFormula displays the
* evaluated result. HARDFORMULA's readOnly-ness is handled separately in
* dc-validator.ts, alongside the existing READONLY rule - this only deals
* with the actual computed value.
*
* Mutates and returns `dataSource` (same in-place-mutate convention as
* applyNumericFormats).
*/
export const applyFormulaRules = (
dataSource: any[],
dqRules: DQRule[],
columnNames: string[],
dataSourceUnchanged: any[],
headerPks: string[],
userName: string
): any[] => {
const formulaRules = dqRules.filter(
(rule) =>
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
)
if (formulaRules.length === 0) return dataSource
dataSource.forEach((row, rowIndex) => {
for (const formulaRule of formulaRules) {
const origRow = dataSourceUnchanged.find((candidate) =>
headerPks.every((pk) => candidate[pk] === row[pk])
)
row[formulaRule.BASE_COL] = parseFormulaRule(formulaRule.RULE_VALUE, {
columnNames,
rowIndex,
userName,
origValue: origRow ? origRow[formulaRule.BASE_COL] : undefined
})
}
})
return dataSource
}
@@ -0,0 +1,25 @@
import { DcValidation } from '../models/dc-validation.model'
/**
* EDIT_STATUS is a purely client-synthesized column (M/A/D/U from
* classifyRow), added so DC.ROW_STATUS has a real, HyperFormula-addressable
* cell to point at. It never comes from COLHEADERS, is always hidden (see
* DcValidator's constructor) and is stripped before the submit payload
* leaves the browser (see editor.component.ts's saveTable()).
*
* Named with a period rather than decorated with underscores (like
* DELETE_RECORD_COLUMN_RULE) - a SAS variable name can never contain a
* period, so this makes a collision with a real dataset column structurally
* impossible, not just unlikely. Handsontable's own datamap.get()/set() only
* treat a dot in a `data` key as a nested-path separator when the row has
* no OWN flat property of that exact name - since every row always gets
* this key from hotDataSchema (a flat copy, dots included, see
* DataMap.createRow's deepExtend), that fallback never triggers here.
*/
export const EDIT_STATUS_COLUMN_NAME = 'dc.row_status'
export const EDIT_STATUS_COLUMN_RULE: DcValidation = {
data: EDIT_STATUS_COLUMN_NAME,
type: 'text',
readOnly: true
}
@@ -0,0 +1,122 @@
import { findOverwrittenCells } from './findOverwrittenCells'
describe('findOverwrittenCells', () => {
it('reports a change when the current value differs from a meaningful raw value', () => {
const currentRows = [
{ PRIMARY_KEY_FIELD: 1, FORMULA_HARD_COL: 20, FORMULA_SOFT_COL: 12 }
]
const rawRows = [
{ PRIMARY_KEY_FIELD: 1, FORMULA_HARD_COL: 1111, FORMULA_SOFT_COL: 2222 }
]
expect(
findOverwrittenCells(
currentRows,
rawRows,
['FORMULA_HARD_COL', 'FORMULA_SOFT_COL'],
['PRIMARY_KEY_FIELD']
)
).toEqual([
{ rowIndex: 0, col: 'FORMULA_HARD_COL', originalValue: 1111 },
{ rowIndex: 0, col: 'FORMULA_SOFT_COL', originalValue: 2222 }
])
})
it('reports nothing when the current value matches the raw value', () => {
const currentRows = [{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'unchanged' }]
const rawRows = [{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'unchanged' }]
expect(
findOverwrittenCells(
currentRows,
rawRows,
['SOME_CHAR'],
['PRIMARY_KEY_FIELD']
)
).toEqual([])
})
it('compares loosely (string vs number) so a "20" raw value matching a 20 current value is not reported', () => {
const currentRows = [{ PRIMARY_KEY_FIELD: 1, SOME_NUM: 20 }]
const rawRows = [{ PRIMARY_KEY_FIELD: 1, SOME_NUM: '20' }]
expect(
findOverwrittenCells(
currentRows,
rawRows,
['SOME_NUM'],
['PRIMARY_KEY_FIELD']
)
).toEqual([])
})
it('ignores a column with no meaningful raw value (blank/null/undefined) - nothing to have changed from', () => {
const currentRows = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'a' },
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'b' },
{ PRIMARY_KEY_FIELD: 3, SOME_CHAR: 'c' }
]
const rawRows = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: '' },
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: null },
{ PRIMARY_KEY_FIELD: 3, SOME_CHAR: undefined }
]
expect(
findOverwrittenCells(
currentRows,
rawRows,
['SOME_CHAR'],
['PRIMARY_KEY_FIELD']
)
).toEqual([])
})
it('only reports the rows/columns that actually changed, across multiple rows', () => {
const currentRows = [
{ PRIMARY_KEY_FIELD: 1, A: 20, B: 12 },
{ PRIMARY_KEY_FIELD: 2, A: 30, B: 13 }
]
const rawRows = [
{ PRIMARY_KEY_FIELD: 1, A: 20, B: 2222 },
{ PRIMARY_KEY_FIELD: 2, A: 30, B: 13 }
]
expect(
findOverwrittenCells(
currentRows,
rawRows,
['A', 'B'],
['PRIMARY_KEY_FIELD']
)
).toEqual([{ rowIndex: 0, col: 'B', originalValue: 2222 }])
})
it('matches rows by primary key, not array position', () => {
const currentRows = [
{ PRIMARY_KEY_FIELD: 2, A: 999 },
{ PRIMARY_KEY_FIELD: 1, A: 20 }
]
// rawRows deliberately in a different order than currentRows
const rawRows = [
{ PRIMARY_KEY_FIELD: 1, A: 20 },
{ PRIMARY_KEY_FIELD: 2, A: 30 }
]
expect(
findOverwrittenCells(currentRows, rawRows, ['A'], ['PRIMARY_KEY_FIELD'])
).toEqual([{ rowIndex: 0, col: 'A', originalValue: 30 }])
})
it('skips a row with no primary-key match in rawRows (e.g. a newly-inserted row)', () => {
const currentRows = [
{ PRIMARY_KEY_FIELD: 1, A: 20 },
{ PRIMARY_KEY_FIELD: undefined, A: 999 }
]
const rawRows = [{ PRIMARY_KEY_FIELD: 1, A: 1111 }]
expect(
findOverwrittenCells(currentRows, rawRows, ['A'], ['PRIMARY_KEY_FIELD'])
).toEqual([{ rowIndex: 0, col: 'A', originalValue: 1111 }])
})
})
@@ -0,0 +1,50 @@
export interface OverwrittenCell {
rowIndex: number
col: string
originalValue: unknown
}
/**
* Finds every (row, column) pair where the current value differs from the
* real, raw value SAS actually sent for it - i.e. something (a direct edit,
* paste, a formula, ...) silently changed a value that pre-existed in the
* actual data, not just filled in a blank. Rows are matched by primary key,
* not array position, so detection stays correct across row insert/delete/
* sort. A row with no PK match in rawRows (a newly-inserted row) is skipped
* entirely, since there's no original value to have overwritten. Compared
* loosely (via string coercion) since the raw value arrives as whatever
* type SAS sent while the current value may be a different but equal
* representation (e.g. HyperFormula's own numeric result).
*/
export const findOverwrittenCells = (
currentRows: Record<string, unknown>[],
rawRows: Record<string, unknown>[],
revertableCols: string[],
headerPks: string[]
): OverwrittenCell[] => {
const changes: OverwrittenCell[] = []
currentRows.forEach((row, rowIndex) => {
const rawRow = rawRows.find((candidate) =>
headerPks.every((pk) => candidate[pk] === row[pk])
)
if (!rawRow) return
for (const col of revertableCols) {
const rawValue = rawRow[col]
if (rawValue === undefined || rawValue === null || rawValue === '') {
continue
}
const currentValue = row[col]
if (String(rawValue) === String(currentValue)) continue
changes.push({ rowIndex, col, originalValue: rawValue })
}
})
return changes
}
@@ -0,0 +1,75 @@
import { getFormulaCellsToPreserveOnCancel } from './getFormulaCellsToPreserveOnCancel'
describe('getFormulaCellsToPreserveOnCancel', () => {
it('preserves a formula column that still has its changed-value comment', () => {
const hasComment = (rowIndex: number, baseCol: string) =>
rowIndex === 0 && baseCol === 'FORMULA_HARD_COL'
const getComputedValue = (rowIndex: number, prop: string) =>
`computed-${rowIndex}-${prop}`
expect(
getFormulaCellsToPreserveOnCancel(
1,
['FORMULA_HARD_COL'],
hasComment,
getComputedValue
)
).toEqual([
{
rowIndex: 0,
prop: 'FORMULA_HARD_COL',
value: 'computed-0-FORMULA_HARD_COL'
}
])
})
it('skips a formula column with no comment - either it never differed, or the user already reverted it', () => {
expect(
getFormulaCellsToPreserveOnCancel(
1,
['FORMULA_HARD_COL'],
() => false,
() => 'unused'
)
).toEqual([])
})
it('checks every row and every formula base col independently', () => {
const hasComment = (rowIndex: number, baseCol: string) =>
(rowIndex === 0 && baseCol === 'FORMULA_HARD_COL') ||
(rowIndex === 2 && baseCol === 'FORMULA_SOFT_COL')
const getComputedValue = (rowIndex: number, prop: string) =>
`computed-${rowIndex}-${prop}`
expect(
getFormulaCellsToPreserveOnCancel(
3,
['FORMULA_HARD_COL', 'FORMULA_SOFT_COL'],
hasComment,
getComputedValue
)
).toEqual([
{
rowIndex: 0,
prop: 'FORMULA_HARD_COL',
value: 'computed-0-FORMULA_HARD_COL'
},
{
rowIndex: 2,
prop: 'FORMULA_SOFT_COL',
value: 'computed-2-FORMULA_SOFT_COL'
}
])
})
it('returns nothing when there are no formula base cols', () => {
expect(
getFormulaCellsToPreserveOnCancel(
5,
[],
() => true,
() => 'unused'
)
).toEqual([])
})
})
@@ -0,0 +1,44 @@
export interface PreservedFormulaCell {
rowIndex: number
prop: string
value: unknown
}
/**
* Cells that must keep their LIVE computed value when an edit session is
* cancelled, rather than being blindly overwritten by dataSourceUnchanged's
* raw baseline. dataSourceUnchanged deliberately holds the raw pre-formula
* value for a HARDFORMULA/SOFTFORMULA column whenever it still has its
* markFormulaChangedCells comment (see editTable's overlay) - that's needed
* so classifyRow flags the row as modified, but it isn't a session edit to
* discard: the formula recomputes on every load regardless of what happens
* to be sitting in dataSourceUnchanged.
*
* A cell with NO comment doesn't need preserving either way: it never
* differed from raw (dataSourceUnchanged already matches computed), or the
* user already reverted it (dataSourceUnchanged's raw value already matches
* the reverted plain value sitting in dataSource) - in both cases the
* baseline is already correct.
*/
export const getFormulaCellsToPreserveOnCancel = (
rowCount: number,
formulaBaseCols: string[],
hasComment: (rowIndex: number, baseCol: string) => boolean,
getComputedValue: (rowIndex: number, prop: string) => unknown
): PreservedFormulaCell[] => {
const preserved: PreservedFormulaCell[] = []
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
for (const baseCol of formulaBaseCols) {
if (!hasComment(rowIndex, baseCol)) continue
preserved.push({
rowIndex,
prop: baseCol,
value: getComputedValue(rowIndex, baseCol)
})
}
}
return preserved
}
@@ -0,0 +1,82 @@
import { DQRule } from '../models/dq-rules.model'
import { getRevertableCols } from './getRevertableCols'
const rule = (overrides: Partial<DQRule>): DQRule => ({
BASE_COL: 'SOME_COL',
RULE_TYPE: 'SOFTFORMULA',
RULE_VALUE: '=A_COL + B_COL',
X: 0,
...overrides
})
describe('getRevertableCols', () => {
it('excludes the delete-flag column', () => {
expect(
getRevertableCols(
[],
['_____DELETE__THIS__RECORD_____', 'PRIMARY_KEY_FIELD']
)
).toEqual(['PRIMARY_KEY_FIELD'])
})
it('excludes the delete-flag column even after the editor renames it to its display label', () => {
// editor.component.ts renames headerColumns' delete-flag entry to
// 'Delete?' in place (for colHeaders display) before this runs, while
// the actual Handsontable column data prop stays the raw name - so
// both forms must be excluded, or propToCol('Delete?') returns -1.
expect(getRevertableCols([], ['Delete?', 'PRIMARY_KEY_FIELD'])).toEqual([
'PRIMARY_KEY_FIELD'
])
})
it('excludes the hidden dc.row_status column', () => {
expect(
getRevertableCols([], ['PRIMARY_KEY_FIELD', 'dc.row_status'])
).toEqual(['PRIMARY_KEY_FIELD'])
})
it('excludes a DC.*-referencing formula column', () => {
expect(
getRevertableCols(
[
rule({
BASE_COL: 'CHANGE_SUMMARY_COL',
RULE_VALUE: '=DC.ROW_STATUS'
})
],
['PRIMARY_KEY_FIELD', 'CHANGE_SUMMARY_COL']
)
).toEqual(['PRIMARY_KEY_FIELD'])
})
it('includes a plain column-arithmetic formula column', () => {
expect(
getRevertableCols(
[
rule({
BASE_COL: 'FORMULA_SOFT_COL',
RULE_TYPE: 'SOFTFORMULA',
RULE_VALUE: '=A_COL + B_COL'
})
],
['PRIMARY_KEY_FIELD', 'FORMULA_SOFT_COL']
)
).toEqual(['PRIMARY_KEY_FIELD', 'FORMULA_SOFT_COL'])
})
it('includes an ordinary non-formula column', () => {
expect(getRevertableCols([], ['PRIMARY_KEY_FIELD', 'SOME_CHAR'])).toEqual([
'PRIMARY_KEY_FIELD',
'SOME_CHAR'
])
})
it('includes a column governed only by an unrelated rule type (e.g. HARDSELECT)', () => {
expect(
getRevertableCols(
[rule({ BASE_COL: 'SOME_HARDSELECT', RULE_TYPE: 'HARDSELECT' })],
['PRIMARY_KEY_FIELD', 'SOME_HARDSELECT']
)
).toEqual(['PRIMARY_KEY_FIELD', 'SOME_HARDSELECT'])
})
})
@@ -0,0 +1,47 @@
import { DQRule } from '../models/dq-rules.model'
import { DELETE_RECORD_COLUMN_RULE } from './deleteRecordColumnRule'
import { EDIT_STATUS_COLUMN_NAME } from './editStatusColumnRule'
import { getStableFormulaBaseCols } from './getStableFormulaBaseCols'
/**
* Display label editor.component.ts renames the delete-flag column's
* headerColumns entry to (see initSetup) - the actual Handsontable column
* data prop stays DELETE_RECORD_COLUMN_RULE.data, so by the time
* headerColumns reaches this function the raw name is already gone from
* it and only this label is left to match against.
*/
const DELETE_RECORD_COLUMN_LABEL = 'Delete?'
/**
* Every column eligible to be checked/marked as "overwritten" (see
* findOverwrittenCells) - all of headerColumns except the delete-flag
* column, the hidden EDIT_STATUS column, and any HARDFORMULA/SOFTFORMULA
* column excluded by getStableFormulaBaseCols (DC.USER_NAME/DC.ORIG_VALUE/
* DC.ROW_STATUS-referencing formulas are inherently session-dependent, so
* comparing their live result against the raw SAS value is never a
* meaningful "was this overwritten" signal).
*/
export const getRevertableCols = (
dqRules: DQRule[],
headerColumns: string[]
): string[] => {
const stableFormulaBaseCols = new Set(getStableFormulaBaseCols(dqRules))
const unstableFormulaBaseCols = new Set(
dqRules
.filter(
(rule) =>
(rule.RULE_TYPE === 'HARDFORMULA' ||
rule.RULE_TYPE === 'SOFTFORMULA') &&
!stableFormulaBaseCols.has(rule.BASE_COL)
)
.map((rule) => rule.BASE_COL)
)
return headerColumns.filter(
(col) =>
col !== DELETE_RECORD_COLUMN_RULE.data &&
col !== DELETE_RECORD_COLUMN_LABEL &&
col !== EDIT_STATUS_COLUMN_NAME &&
!unstableFormulaBaseCols.has(col)
)
}
@@ -0,0 +1,65 @@
import { DQRule } from '../models/dq-rules.model'
import { getStableFormulaBaseCols } from './getStableFormulaBaseCols'
const rule = (overrides: Partial<DQRule>): DQRule => ({
BASE_COL: 'SOME_COL',
RULE_TYPE: 'SOFTFORMULA',
RULE_VALUE: '=A_COL + B_COL',
X: 0,
...overrides
})
describe('getStableFormulaBaseCols', () => {
it('includes a plain column-arithmetic HARDFORMULA/SOFTFORMULA rule', () => {
expect(
getStableFormulaBaseCols([
rule({ BASE_COL: 'FORMULA_HARD_COL', RULE_TYPE: 'HARDFORMULA' }),
rule({ BASE_COL: 'FORMULA_SOFT_COL', RULE_TYPE: 'SOFTFORMULA' })
])
).toEqual(['FORMULA_HARD_COL', 'FORMULA_SOFT_COL'])
})
it('excludes a rule referencing DC.ORIG_VALUE - its own raw value is expected to differ from a stable computed default', () => {
expect(
getStableFormulaBaseCols([
rule({ BASE_COL: 'ORIG_VALUE_COL', RULE_VALUE: '=DC.ORIG_VALUE' })
])
).toEqual([])
})
it('excludes a rule referencing DC.USER_NAME', () => {
expect(
getStableFormulaBaseCols([
rule({ BASE_COL: 'USER_NAME_COL', RULE_VALUE: '=DC.USER_NAME' })
])
).toEqual([])
})
it('excludes a rule referencing DC.ROW_STATUS', () => {
expect(
getStableFormulaBaseCols([
rule({ BASE_COL: 'ROW_STATUS_COL', RULE_VALUE: '=DC.ROW_STATUS' })
])
).toEqual([])
})
it('excludes a rule combining multiple DC.* variables, even alongside real column names', () => {
expect(
getStableFormulaBaseCols([
rule({
BASE_COL: 'CHANGE_SUMMARY_COL',
RULE_VALUE:
'=IF( DC.ROW_STATUS ="U","unedited", DC.USER_NAME &" changed from "& DC.ORIG_VALUE )'
})
])
).toEqual([])
})
it('ignores non-formula rules entirely (e.g. NOTNULL)', () => {
expect(
getStableFormulaBaseCols([
rule({ BASE_COL: 'PRIMARY_KEY_FIELD', RULE_TYPE: 'NOTNULL' })
])
).toEqual([])
})
})
@@ -0,0 +1,22 @@
import { DQRule } from '../models/dq-rules.model'
/**
* BASE_COL names of every HARDFORMULA/SOFTFORMULA rule whose result is
* expected to be *stable* for a given row - i.e. excludes rules
* referencing DC.USER_NAME/DC.ORIG_VALUE/DC.ROW_STATUS. Those three are
* inherently session/edit-state-dependent (who's editing, what the row
* looked like before, whether it's currently modified) - comparing their
* live result against a frozen raw value doesn't mean "the formula
* overwrote real data" the way it does for a plain column-arithmetic
* formula (e.g. A_COL * B_COL); it would just always differ, for reasons
* unrelated to the dataset ever having real data there.
*/
export const getStableFormulaBaseCols = (dqRules: DQRule[]): string[] =>
dqRules
.filter(
(rule) =>
(rule.RULE_TYPE === 'HARDFORMULA' ||
rule.RULE_TYPE === 'SOFTFORMULA') &&
!/DC\.(USER_NAME|ORIG_VALUE|ROW_STATUS)/.test(rule.RULE_VALUE)
)
.map((rule) => rule.BASE_COL)
@@ -0,0 +1,48 @@
import { DQRule } from '../models/dq-rules.model'
import { hasFormulaRules } from './hasFormulaRules'
const rule = (overrides: Partial<DQRule>): DQRule => ({
BASE_COL: 'SOME_COL',
RULE_TYPE: 'NOTNULL',
RULE_VALUE: '',
X: 0,
...overrides
})
describe('hasFormulaRules', () => {
it('is false for an empty rule set', () => {
expect(hasFormulaRules([])).toBeFalse()
})
it('is false when no rule is HARDFORMULA/SOFTFORMULA', () => {
const rules = [
rule({ RULE_TYPE: 'NOTNULL' }),
rule({ RULE_TYPE: 'HARDREGEX' }),
rule({ RULE_TYPE: 'SOFTREGEX' })
]
expect(hasFormulaRules(rules)).toBeFalse()
})
it('is true when a HARDFORMULA rule is present', () => {
const rules = [rule({ RULE_TYPE: 'HARDFORMULA' })]
expect(hasFormulaRules(rules)).toBeTrue()
})
it('is true when a SOFTFORMULA rule is present', () => {
const rules = [rule({ RULE_TYPE: 'SOFTFORMULA' })]
expect(hasFormulaRules(rules)).toBeTrue()
})
it('is true when a formula rule is mixed in with unrelated rules', () => {
const rules = [
rule({ RULE_TYPE: 'NOTNULL' }),
rule({ RULE_TYPE: 'SOFTFORMULA' }),
rule({ RULE_TYPE: 'HARDREGEX' })
]
expect(hasFormulaRules(rules)).toBeTrue()
})
})
@@ -0,0 +1,12 @@
import { DQRule } from '../models/dq-rules.model'
/**
* Whether any rule in the set is HARDFORMULA/SOFTFORMULA - used to gate
* turning on Handsontable's `formulas` plugin (HyperFormula engine) only
* when a table actually uses it, rather than for every grid.
*/
export const hasFormulaRules = (dqRules: DQRule[]): boolean =>
dqRules.some(
(rule) =>
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
)
@@ -0,0 +1,104 @@
import { parseFormulaRule, FormulaVariableContext } from './parseFormulaRule'
import { EDIT_STATUS_COLUMN_NAME } from './editStatusColumnRule'
const context = (overrides: Partial<FormulaVariableContext> = {}) => ({
columnNames: ['ITEM', 'PRICE', 'VOLUME', 'REVENUE'],
rowIndex: 0,
userName: 'sasdemo',
origValue: 'sasinstaller',
...overrides
})
describe('parseFormulaRule', () => {
it("substitutes column-name variables with this row's cell references (issue's own PRICE/VOLUME example)", () => {
expect(parseFormulaRule('=PRICE * VOLUME', context())).toEqual('=B1 * C1')
})
it('uses the row-relative reference for a later row', () => {
expect(
parseFormulaRule('=PRICE * VOLUME', context({ rowIndex: 1 }))
).toEqual('=B2 * C2')
})
it('requires a leading/trailing blank (or string edge) around a variable - no match without it', () => {
// No spaces around '*' - PRICE/VOLUME here should NOT be recognized as
// variables (the issue's own rule: a named variable must have a
// leading and trailing blank to avoid clashing with function names).
expect(parseFormulaRule('=PRICE*VOLUME', context())).toEqual(
'=PRICE*VOLUME'
)
})
it("does not substitute a variable name matched inside a function call with no surrounding blanks (the issue's MATCH() clash example)", () => {
// MATCH is not one of our column names here, but this proves adjacency
// to parens alone doesn't trigger substitution - only literal
// surrounding whitespace (or string start/end) does.
expect(parseFormulaRule('=MATCH(PRICE)', context())).toEqual(
'=MATCH(PRICE)'
)
})
it("leaves variable occurrences inside quoted strings untouched (the issue's own ITEM example)", () => {
expect(parseFormulaRule('=ITEM & " string ITEM "', context())).toEqual(
'=A1 & " string ITEM "'
)
})
it('substitutes DC.USER_NAME with the quoted, literal current username', () => {
expect(
parseFormulaRule('=DC.USER_NAME', context({ userName: 'sasdemo' }))
).toEqual('=\"sasdemo\"')
})
it('substitutes DC.ORIG_VALUE with the quoted, literal original cell value', () => {
expect(
parseFormulaRule('=DC.ORIG_VALUE', context({ origValue: 'sasinstaller' }))
).toEqual('=\"sasinstaller\"')
})
it('does not substitute DC.USER_NAME/DC.ORIG_VALUE inside quoted strings either', () => {
expect(parseFormulaRule('="DC.USER_NAME"', context())).toEqual(
'=\"DC.USER_NAME\"'
)
})
it('substitutes DC.ROW_STATUS with a cell reference to the EDIT_STATUS column, not a quoted literal', () => {
expect(
parseFormulaRule(
'=IF( DC.ROW_STATUS ="U","unchanged","changed")',
context({
columnNames: ['ITEM', 'PRICE', 'VOLUME', EDIT_STATUS_COLUMN_NAME],
rowIndex: 0
})
)
).toEqual('=IF( D1 ="U","unchanged","changed")')
})
it('uses the row-relative reference for DC.ROW_STATUS on a later row', () => {
expect(
parseFormulaRule(
'=DC.ROW_STATUS',
context({
columnNames: ['ITEM', EDIT_STATUS_COLUMN_NAME],
rowIndex: 4
})
)
).toEqual('=B5')
})
it('leaves DC.ROW_STATUS untouched when the EDIT_STATUS column is not present', () => {
expect(parseFormulaRule('=DC.ROW_STATUS', context())).toEqual(
'=DC.ROW_STATUS'
)
})
it('does not substitute DC.ROW_STATUS inside quoted strings either', () => {
expect(parseFormulaRule('="DC.ROW_STATUS"', context())).toEqual(
'=\"DC.ROW_STATUS\"'
)
})
it('does not insert a leading = when the rule value does not have one, so HOT treats it as plain text rather than a formula', () => {
expect(parseFormulaRule('PRICE * VOLUME', context())).toEqual('B1 * C1')
})
})
@@ -0,0 +1,102 @@
import Handsontable from 'handsontable'
import { EDIT_STATUS_COLUMN_NAME } from './editStatusColumnRule'
export interface FormulaVariableContext {
/** Column names in grid order - index drives the cell-reference letter. */
columnNames: string[]
/** 0-based physical row index - drives the cell-reference row number. */
rowIndex: number
/** Current logged-in user - DC.USER_NAME resolves to this, quoted. */
userName: string
/** Original (pre-edit) value of the cell this rule applies to - DC.ORIG_VALUE resolves to this, quoted. */
origValue: string | number | undefined
}
const escapeRegExpMetacharacters = (text: string): string =>
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/**
* Replaces `token` with `replacement` wherever it has a leading and
* trailing blank (a literal space, or the start/end of this span). This
* lets `PRICE * VOLUME` substitute correctly while `MATCH(PRICE)` (no
* surrounding blanks) does not, without needing to know anything about
* function names.
*/
const substituteBoundedToken = (
text: string,
token: string,
replacement: string
): string => {
const pattern = new RegExp(
`(?<=^|\\s)${escapeRegExpMetacharacters(token)}(?=$|\\s)`,
'g'
)
return text.replace(pattern, replacement)
}
const quoteLiteral = (value: string | number | undefined): string =>
`"${value ?? ''}"`
export const parseFormulaRule = (
ruleValue: string,
context: FormulaVariableContext
): string => {
// A leading '=' is the rule author's own responsibility- it's not
// inserted here. It's stripped before
// substitution and re-attached after so substituteBoundedToken's
// start-of-string boundary check still lines up with the first real
// token, exactly as if it had never been there.
const hasLeadingEquals = ruleValue.startsWith('=')
const formulaBody = hasLeadingEquals ? ruleValue.slice(1) : ruleValue
const quotedSpanPattern = /"[^"]*"|'[^']*'/g
let result = ''
let lastIndex = 0
let match: RegExpExecArray | null
const substituteUnquotedSpan = (span: string): string => {
let substituted = substituteBoundedToken(
span,
'DC.USER_NAME',
quoteLiteral(context.userName)
)
substituted = substituteBoundedToken(
substituted,
'DC.ORIG_VALUE',
quoteLiteral(context.origValue)
)
// Unlike DC.USER_NAME/DC.ORIG_VALUE, DC.ROW_STATUS resolves to a real
// cell reference (not a quoted literal) - it points at the EDIT_STATUS
// column, which holds this row's live M/A/D/U classification, so other
// formulas can react to it, e.g. `=if(A1!='U',"sasdemo","sasinstaller")`.
const rowStatusColIndex = context.columnNames.indexOf(
EDIT_STATUS_COLUMN_NAME
)
if (rowStatusColIndex !== -1) {
const rowStatusCellRef = `${Handsontable.helper.spreadsheetColumnLabel(rowStatusColIndex)}${context.rowIndex + 1}`
substituted = substituteBoundedToken(
substituted,
'DC.ROW_STATUS',
rowStatusCellRef
)
}
context.columnNames.forEach((columnName, columnIndex) => {
const cellRef = `${Handsontable.helper.spreadsheetColumnLabel(columnIndex)}${context.rowIndex + 1}`
substituted = substituteBoundedToken(substituted, columnName, cellRef)
})
return substituted
}
while ((match = quotedSpanPattern.exec(formulaBody))) {
result += substituteUnquotedSpan(formulaBody.slice(lastIndex, match.index))
result += match[0]
lastIndex = match.index + match[0].length
}
result += substituteUnquotedSpan(formulaBody.slice(lastIndex))
return hasLeadingEquals ? `=${result}` : result
}
@@ -0,0 +1,29 @@
import { syncOverwrittenCellComment } from './syncOverwrittenCellComment'
describe('syncOverwrittenCellComment', () => {
it('sets a comment when the value now differs from raw and none exists yet', () => {
expect(syncOverwrittenCellComment('999', 'orig', false)).toBe('set')
})
it('does nothing when the value still differs and a comment is already there', () => {
expect(syncOverwrittenCellComment('999', 'orig', true)).toBe('none')
})
it('removes the comment when the value has been typed back to match the raw value', () => {
expect(syncOverwrittenCellComment('orig', 'orig', true)).toBe('remove')
})
it('does nothing when the value matches raw and there is no comment', () => {
expect(syncOverwrittenCellComment('orig', 'orig', false)).toBe('none')
})
it('treats a blank/null/undefined raw value as never meaningful, even with a stale comment present', () => {
expect(syncOverwrittenCellComment('999', '', true)).toBe('remove')
expect(syncOverwrittenCellComment('999', null, true)).toBe('remove')
expect(syncOverwrittenCellComment('999', undefined, true)).toBe('remove')
})
it('compares loosely (string vs number) so equivalent values are not treated as overwritten', () => {
expect(syncOverwrittenCellComment(20, '20', false)).toBe('none')
})
})
@@ -0,0 +1,26 @@
export type OverwrittenCommentAction = 'set' | 'remove' | 'none'
/**
* Decides what a single cell's "overwritten" comment should do in response
* to a live edit (afterChange) - detection must run on every edit, since an
* edit can just as easily make an already-overwritten cell match its raw
* value again (typed back by hand) as it can make an untouched cell diverge
* from it. Uses the same "loosely equal, blank raw is never meaningful"
* rule as findOverwrittenCells, since a single afterChange call operates on
* one cell at a time, not the whole-grid batch that function expects.
*/
export const syncOverwrittenCellComment = (
currentValue: unknown,
rawValue: unknown,
hasCommentAlready: boolean
): OverwrittenCommentAction => {
const hasMeaningfulRawValue =
rawValue !== undefined && rawValue !== null && rawValue !== ''
const isOverwritten =
hasMeaningfulRawValue && String(rawValue) !== String(currentValue)
if (isOverwritten) return hasCommentAlready ? 'none' : 'set'
return hasCommentAlready ? 'remove' : 'none'
}
+1 -2
View File
@@ -6,7 +6,6 @@ import { RouterModule } from '@angular/router'
import { LoadingIndicatorComponent } from './loading-indicator/loading-indicator.component' import { LoadingIndicatorComponent } from './loading-indicator/loading-indicator.component'
import { LoginComponent } from './login/login.component' import { LoginComponent } from './login/login.component'
import { UserService } from './user.service'
import { AlertsService } from './alerts/alerts.service' import { AlertsService } from './alerts/alerts.service'
import { HeaderActions } from './user-nav-dropdown/header-actions.component' import { HeaderActions } from './user-nav-dropdown/header-actions.component'
import { AlertsComponent } from './alerts/alerts.component' import { AlertsComponent } from './alerts/alerts.component'
@@ -50,7 +49,7 @@ import { BulkValidationModalComponent } from './bulk-validation-modal/bulk-valid
ConfirmModalComponent, ConfirmModalComponent,
BulkValidationModalComponent BulkValidationModalComponent
], ],
providers: [UserService, AlertsService] providers: [AlertsService]
}) })
export class SharedModule implements OnInit { export class SharedModule implements OnInit {
ngOnInit(): void {} ngOnInit(): void {}
@@ -0,0 +1,43 @@
import { Injector } from '@angular/core'
import { TestBed } from '@angular/core/testing'
import { UserService } from './user.service'
describe('UserService', () => {
// UserService must be reachable without any consuming module declaring it
// as a local provider - that's what `providedIn: 'root'` gives you.
it('resolves to the exact same instance from an injector that never declares it as a provider', () => {
const rootInstance = TestBed.inject(UserService)
// Simulates a lazy-loaded feature module's own child injector (e.g.
// EditorModule) - it provides nothing of its own, so it can only
// resolve UserService by walking up to the root tree-shakable provider.
const lazyModuleInjector = Injector.create({
providers: [],
parent: TestBed.inject(Injector)
})
expect(lazyModuleInjector.get(UserService)).toBe(rootInstance)
})
it('shares live state (e.g. the logged-in user) across every injected reference', () => {
const fromRootModule = TestBed.inject(UserService)
const lazyModuleAInjector = Injector.create({
providers: [],
parent: TestBed.inject(Injector)
})
const lazyModuleBInjector = Injector.create({
providers: [],
parent: TestBed.inject(Injector)
})
const fromModuleA = lazyModuleAInjector.get(UserService)
const fromModuleB = lazyModuleBInjector.get(UserService)
// sas.service.ts sets .user through whichever reference it was injected
// with - here, simulated via the "root module"'s instance.
fromRootModule.user = { username: 'sasdemo' }
expect(fromModuleA.user?.username).toEqual('sasdemo')
expect(fromModuleB.user?.username).toEqual('sasdemo')
})
})
+1 -1
View File
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { User } from './user.interface' import { User } from './user.interface'
@Injectable() @Injectable({ providedIn: 'root' })
export class UserService { export class UserService {
private _user!: User private _user!: User
public userChange: Subject<User> = new Subject<User>() public userChange: Subject<User> = new Subject<User>()
@@ -74,4 +74,78 @@ describe('buildColInfoHtml', () => {
expect(buildColInfoHtml('SOME_CHAR', colInfo)).not.toContain('REGEX:') expect(buildColInfoHtml('SOME_CHAR', colInfo)).not.toContain('REGEX:')
}) })
it('appends a √x=<formula> line when a formula value is provided', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml(
'SOME_CHAR',
colInfo,
undefined,
undefined,
'SOME_SHORTNUM * SOME_BESTNUM'
)
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>√x=SOME_SHORTNUM * SOME_BESTNUM'
)
})
it('does not double the = when the formula value itself already has a leading = (RULE_VALUE is stored with it, see parseFormulaRule.ts)', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml(
'SOME_CHAR',
colInfo,
undefined,
undefined,
'=SOME_SHORTNUM * SOME_BESTNUM'
)
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>√x=SOME_SHORTNUM * SOME_BESTNUM'
)
})
it('omits the formula line when no formula value is provided', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(buildColInfoHtml('SOME_CHAR', colInfo)).not.toContain('√x=')
})
it('appends both the HARDREGEX line and the formula line when both are present', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml(
'SOME_CHAR',
colInfo,
'/^[A-Z]+$/i',
undefined,
'A_COL * B_COL'
)
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>HARDREGEX: /^[A-Z]+$/i<br>√x=A_COL * B_COL'
)
})
}) })
+15 -1
View File
@@ -9,7 +9,8 @@ export function buildColInfoHtml(
colName: string, colName: string,
colInfo?: DataFormat, colInfo?: DataFormat,
hardRegexValue?: string, hardRegexValue?: string,
softRegexValue?: string softRegexValue?: string,
formulaValue?: string
): string { ): string {
if (!colInfo) return 'No info found' if (!colInfo) return 'No info found'
@@ -25,5 +26,18 @@ export function buildColInfoHtml(
html += `<br>SOFTREGEX: ${softRegexValue}` html += `<br>SOFTREGEX: ${softRegexValue}`
} }
// '√x=' stands in for a text label here - HARDFORMULA vs SOFTFORMULA is
// already conveyed by the column's readOnly state, so there's no need to
// spell out which one this is. formulaValue is the raw RULE_VALUE, which
// may or may not include its own leading '=' (see parseFormulaRule.ts -
// it's the rule author's choice, not inserted) - strip it here so it's
// never doubled against this label's own '='.
if (formulaValue) {
const formula = formulaValue.startsWith('=')
? formulaValue.slice(1)
: formulaValue
html += `<br>√x=${formula}`
}
return html return html
} }
@@ -0,0 +1,50 @@
import { getMalformedAdapterResponseMessage } from './get-malformed-adapter-response-message'
describe('getMalformedAdapterResponseMessage', () => {
it('returns null for a normal object response', () => {
expect(
getMalformedAdapterResponseMessage({
SYSSITE: 'SITE1',
globvars: [{ ISADMIN: false }],
sasdatasets: [],
saslibs: {},
xlmaps: []
})
).toBeNull()
})
it("returns null for an object missing individual fields - that stays the caller's missing-props concern", () => {
expect(
getMalformedAdapterResponseMessage({
SYSSITE: 'SITE1'
// globvars/sasdatasets/saslibs/xlmaps deliberately omitted
})
).toBeNull()
})
it('returns a message containing the raw text verbatim for a raw "Job error" string response', () => {
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')
const message = getMalformedAdapterResponseMessage(rawJobError)
expect(message).not.toBeNull()
expect(message).toContain(rawJobError)
})
it('returns a message for null', () => {
expect(getMalformedAdapterResponseMessage(null)).not.toBeNull()
})
it('returns a message for undefined', () => {
expect(getMalformedAdapterResponseMessage(undefined)).not.toBeNull()
})
it('returns a message for a number', () => {
expect(getMalformedAdapterResponseMessage(404)).not.toBeNull()
})
})
@@ -0,0 +1,21 @@
/**
* A successful startupservice/deploy-validation response is always a plain
* JSON object. When SAS Viya's Compute service can't run the underlying job
* (e.g. computeTasks misconfigured), it can return a 202 whose body is a
* plain-text "Job error ... path: /compute/tasks ... correlator: ..." block
* instead - @sasjs/adapter fails to JSON.parse it, doesn't recognize the
* shape as an error either, and resolves with that raw text as-is. Property
* access on a string is always undefined, so callers that only check "are
* the expected fields present" end up reporting every field as missing
* without ever showing the real underlying text. Detecting "not an object
* at all" up front lets a caller show that real text instead.
*/
export const getMalformedAdapterResponseMessage = (
adapterResponse: unknown
): string | null => {
if (adapterResponse !== null && typeof adapterResponse === 'object') {
return null
}
return `The startupservice response was not in the expected format - it should be a JSON object, but the following was received instead:\n\n${String(adapterResponse)}`
}
@@ -0,0 +1,20 @@
import { selectFormattedRows } from './select-formatted-rows'
describe('selectFormattedRows', () => {
const rawRows = [{ SOME_NUM: 42, SOME_DATE: 42 }]
const formattedRows = [{ SOME_NUM: '42', SOME_DATE: '11FEB1960' }]
it('returns the formatted rows when showFormatted is true and formatted rows exist', () => {
expect(selectFormattedRows(rawRows, formattedRows, true)).toBe(
formattedRows
)
})
it('returns the raw rows when showFormatted is false, even if formatted rows exist', () => {
expect(selectFormattedRows(rawRows, formattedRows, false)).toBe(rawRows)
})
it('falls back to the raw rows when showFormatted is true but no formatted rows were provided', () => {
expect(selectFormattedRows(rawRows, undefined, true)).toBe(rawRows)
})
})
@@ -0,0 +1,13 @@
/**
* Picks which row set to render - raw or SAS-formatted - the same
* formatted/unformatted toggle already used on the review page, extracted
* so it isn't duplicated inline. Falls back to raw rows if the backend
* hasn't sent a formatted variant (e.g. an older service response).
*/
export function selectFormattedRows(
rawRows: any[],
formattedRows: any[] | undefined,
showFormatted: boolean
): any[] {
return showFormatted && formattedRows ? formattedRows : rawRows
}
@@ -327,8 +327,46 @@
#dragHandleCorner #dragHandleCorner
[id]="'handle_viewbox_' + viewbox.id" [id]="'handle_viewbox_' + viewbox.id"
class="dragHandle corner" class="dragHandle corner"
cdkDrag (pointerdown)="
(cdkDragMoved)="dragMove(dragHandleCorner, resizeBox, viewbox, $event)" startResize($event, dragHandleCorner, resizeBox, viewbox)
"
></span>
<span
#dragHandleCornerLeft
[id]="'handle_corner_left_viewbox_' + viewbox.id"
class="dragHandle corner-left"
(pointerdown)="
startResizeBottomLeft(
$event,
dragHandleCornerLeft,
resizeBox,
viewbox
)
"
></span>
<span
#dragHandleRight
[id]="'handle_right_viewbox_' + viewbox.id"
class="dragHandle right"
(pointerdown)="
startResizeRight($event, dragHandleRight, resizeBox, viewbox)
"
></span>
<span
#dragHandleBottom
[id]="'handle_bottom_viewbox_' + viewbox.id"
class="dragHandle bottom"
(pointerdown)="
startResizeBottom($event, dragHandleBottom, resizeBox, viewbox)
"
></span>
<span
#dragHandleLeft
[id]="'handle_left_viewbox_' + viewbox.id"
class="dragHandle left"
(pointerdown)="
startResizeLeft($event, dragHandleLeft, resizeBox, viewbox)
"
></span> ></span>
<form <form
class="d-flex align-items-center clr-justify-content-between clr-flex-wrap table-search-wrapper" class="d-flex align-items-center clr-justify-content-between clr-flex-wrap table-search-wrapper"
@@ -0,0 +1,621 @@
import { ViewboxesComponent } from './viewboxes.component'
/**
* resize()'s debounced callback (run synchronously here, see helperService
* below) also calls viewboxChanged() -> router.navigate() and
* prepareFilterCache() -> this.viewboxes - 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 buildComponent = () => {
const helperService: any = {
debounceCall: (_time: number, callback: () => void) => callback()
}
const component = new ViewboxesComponent(
{
runOutsideAngular: (fn: () => void) => fn(),
run: (fn: () => void) => fn()
} as any, // NgZone
{ licenceState: { value: {} } } as any, // LicenceService
{} as any, // SasService
{ dispatchEvent: () => {} } as any, // EventService
{} as any, // SasStoreService
{} as any, // LoggerService
helperService,
{ navigate: () => {} } as any, // Router
{} as any, // ActivatedRoute
{} as any // ChangeDetectorRef
)
// setAllHandleTransform() (called synchronously by resize()) iterates
// these two @ViewChildren query lists - empty arrays keep it a no-op
// without needing a real view to populate them.
component.resizeBoxQuery = [] as any
component.dragHandleCornerQuery = [] as any
component.viewboxes = []
return component
}
// Real DOM elements, positioned absolutely with known coordinates - Karma
// runs in an actual browser (ChromeHeadlessCI), so getBoundingClientRect()
// returns genuine, accurate values with no need to mock it.
const positionedDiv = (
left: number,
top: number,
width: number,
height: number
): HTMLDivElement => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
return el
}
describe('ViewboxesComponent - resize', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
toRemove.forEach((el) => el.remove())
})
const addDiv = (left: number, top: number, width: number, height: number) => {
const el = positionedDiv(left, top, width, height)
toRemove.push(el)
return el
}
it('computes width/height as the distance from the target corner to the drag handle', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
// Positioned so the handle's own bottom-right corner (280+20, 230+20)
// exactly coincides with target's current bottom-right corner
// (100+200, 100+150) - the handle "hasn't moved" from a fresh render,
// so the computed size should match the target's current size exactly.
const dragHandle = addDiv(280, 230, 20, 20)
const result = component.resize(dragHandle, target)
expect(result).toEqual({ width: 200, height: 150 })
})
it('grows the target element when the handle is dragged further from the corner', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
// Dragged 100px right and down from the exact-corner position used
// above.
const dragHandle = addDiv(400, 350, 20, 20)
const result = component.resize(dragHandle, target)
expect(result).toEqual({ width: 320, height: 270 })
expect(target.style.width).toBe('320px')
expect(target.style.height).toBe('270px')
})
it('shrinks the target element when the handle is dragged toward the corner', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
const dragHandle = addDiv(220, 180, 20, 20)
const result = component.resize(dragHandle, target)
expect(result).toEqual({ width: 140, height: 100 })
})
})
describe('ViewboxesComponent - setHandleTransform', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
toRemove.forEach((el) => el.remove())
})
const addDiv = (width: number, height: number) => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
toRemove.push(el)
return el
}
it('translates the handle to the target rect bottom-right corner, plus a 5px fine-tune, for position "both"', () => {
const component = buildComponent()
const dragHandle = addDiv(20, 20)
const targetRect = { width: 200, height: 150 } as DOMRect
component.setHandleTransform(dragHandle, targetRect, 'both')
// translateX = 200 - 20 + 5 = 185, translateY = 150 - 20 + 5 = 135
expect(dragHandle.style.transform).toBe('translate(185px, 135px)')
})
it('only translates along x for position "x"', () => {
const component = buildComponent()
const dragHandle = addDiv(20, 20)
const targetRect = { width: 200, height: 150 } as DOMRect
component.setHandleTransform(dragHandle, targetRect, 'x')
// The browser normalizes the unitless '0' the source writes back to
// '0px' when the style is read back.
expect(dragHandle.style.transform).toBe('translate(185px, 0px)')
})
it('only translates along y for position "y"', () => {
const component = buildComponent()
const dragHandle = addDiv(20, 20)
const targetRect = { width: 200, height: 150 } as DOMRect
component.setHandleTransform(dragHandle, targetRect, 'y')
expect(dragHandle.style.transform).toBe('translate(0px, 135px)')
})
it('translates to a small fixed left-edge offset for position "left", independent of target size', () => {
const component = buildComponent()
const dragHandle = addDiv(20, 20)
const targetRect = { width: 200, height: 150 } as DOMRect
component.setHandleTransform(dragHandle, targetRect, 'left')
expect(dragHandle.style.transform).toBe('translate(-5px, 0px)')
})
it('combines the left-edge x offset with the bottom-edge y calculation for position "bottomLeft"', () => {
const component = buildComponent()
const dragHandle = addDiv(20, 20)
const targetRect = { width: 200, height: 150 } as DOMRect
component.setHandleTransform(dragHandle, targetRect, 'bottomLeft')
// translateY = 150 - 20 + 5 = 135, same calculation as position "y"
expect(dragHandle.style.transform).toBe('translate(-5px, 135px)')
})
})
describe('ViewboxesComponent - parseHandleTranslate', () => {
it('returns {x: 0, y: 0} for an empty/unset transform', () => {
const component: any = buildComponent()
expect(component.parseHandleTranslate('')).toEqual({ x: 0, y: 0 })
})
it('parses an existing translate(...) transform, including negative values', () => {
const component: any = buildComponent()
expect(component.parseHandleTranslate('translate(12px, -34px)')).toEqual({
x: 12,
y: -34
})
})
})
describe('ViewboxesComponent - startResize', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
document.dispatchEvent(new PointerEvent('pointerup'))
toRemove.forEach((el) => el.remove())
})
const addDiv = (left: number, top: number, width: number, height: number) => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
toRemove.push(el)
return el
}
it('grows the target as the pointer moves after pointerdown on the handle', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
// Positioned exactly at target's current bottom-right corner - see the
// identical setup in the "resize" describe block above.
const dragHandle = addDiv(280, 230, 20, 20)
const viewbox: any = { width: 200, height: 150 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 300,
clientY: 250
})
component.startResize(pointerDown, dragHandle, target, viewbox)
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 400, clientY: 350 })
)
expect(viewbox.width).toBe(300)
expect(viewbox.height).toBe(250)
})
it('stops tracking the pointer once pointerup fires', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
const dragHandle = addDiv(280, 230, 20, 20)
const viewbox: any = { width: 200, height: 150 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 300,
clientY: 250
})
component.startResize(pointerDown, dragHandle, target, viewbox)
document.dispatchEvent(new PointerEvent('pointerup'))
const widthAfterPointerUp = viewbox.width
// A move after pointerup must not still be tracked - the listener was
// meant to be removed.
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 600, clientY: 600 })
)
expect(viewbox.width).toBe(widthAfterPointerUp)
})
})
describe('ViewboxesComponent - resizeWidth/resizeHeight (edge-only resize)', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
toRemove.forEach((el) => el.remove())
})
const addDiv = (left: number, top: number, width: number, height: number) => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
toRemove.push(el)
return el
}
it('resizeWidth grows/shrinks width from the target left edge, leaving height untouched', () => {
const component = buildComponent()
const target = addDiv(0, 0, 200, 150)
const dragHandle = addDiv(220, 0, 10, 10)
const width = component.resizeWidth(dragHandle, target)
expect(width).toBe(230)
expect(target.style.width).toBe('230px')
expect(target.style.height).toBe('150px')
})
it('resizeHeight grows/shrinks height from the target top edge, leaving width untouched', () => {
const component = buildComponent()
const target = addDiv(0, 0, 200, 150)
const dragHandle = addDiv(0, 170, 10, 10)
const height = component.resizeHeight(dragHandle, target)
expect(height).toBe(180)
expect(target.style.height).toBe('180px')
expect(target.style.width).toBe('200px')
})
})
describe('ViewboxesComponent - resizeFromLeftEdge', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
toRemove.forEach((el) => el.remove())
})
const addDiv = (left: number, top: number, width: number, height: number) => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
toRemove.push(el)
return el
}
it('grows width and moves x left by the same amount when dragged left (right edge stays put)', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150) // right edge = 300
const result = component.resizeFromLeftEdge(
/* pointerClientX */ 50,
target,
/* startTargetRight */ 300,
/* startX */ 10,
/* startWidth */ 200
)
// intended width = 300 - 50 = 250 (grew by 50)
expect(result.width).toBe(250)
// x moves left by exactly how much width grew, so the right edge stays
// visually fixed.
expect(result.x).toBe(-40) // 10 + (200 - 250)
})
it('shrinks width and moves x right when dragged right, without exceeding min-width', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
const result = component.resizeFromLeftEdge(250, target, 300, 10, 200)
// intended width = 300 - 250 = 50
expect(result.width).toBe(50)
expect(result.x).toBe(160) // 10 + (200 - 50)
})
it("does not let x drift past what the target's CSS min-width actually allows", () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
// Simulates .viewbox's real min-width: 200px constraint, which a plain
// test div doesn't have unless set explicitly.
target.style.minWidth = '200px'
const result = component.resizeFromLeftEdge(250, target, 300, 10, 200) // would intend width 50
// The browser clamps target's rendered width to 200px (min-width) -
// resizeFromLeftEdge must read that back rather than trust its own
// unclamped math, or x would drift while the box visibly stops moving.
expect(result.width).toBe(200)
expect(result.x).toBe(10)
})
it('does not compound growth across repeated moves even if the target itself gets repositioned via a transform in between (what CDK does as viewbox.x changes)', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150) // right edge = 300
const startTargetRight = 300
const startX = 10
const startWidth = 200
// First pointermove: dragged 20px left from an implied drag-start of
// clientX=100.
const first = component.resizeFromLeftEdge(
80,
target,
startTargetRight,
startX,
startWidth
)
expect(first.width).toBe(220)
// Simulate CDK repositioning the box in response to first.x - a real
// transform on target, exactly like [cdkDragFreeDragPosition] applies.
// This must not feed back into the next call's width.
target.style.transform = `translate(${first.x - startX}px, 0)`
// Second pointermove: another 20px left, same speed as the first move.
const second = component.resizeFromLeftEdge(
60,
target,
startTargetRight,
startX,
startWidth
)
// Constant pointer speed must produce constant growth - not more the
// second time just because the box moved in response to the first.
expect(second.width - first.width).toBe(20)
expect(second.width).toBe(240)
})
})
describe('ViewboxesComponent - startResizeRight/startResizeBottom (edge handles)', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
document.dispatchEvent(new PointerEvent('pointerup'))
toRemove.forEach((el) => el.remove())
})
const addDiv = (left: number, top: number, width: number, height: number) => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
toRemove.push(el)
return el
}
it('startResizeRight only changes width as the pointer moves', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150) // right edge = 300
// Handle's own bottom-right-ish corner (290+10) exactly at target's
// current right edge (300) - the "hasn't moved yet" baseline, same
// convention as the resize()/resizeWidth() tests above.
const dragHandle = addDiv(290, 100, 10, 10)
const viewbox: any = { width: 200, height: 150, x: 0, y: 0 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 300,
clientY: 100
})
component.startResizeRight(pointerDown, dragHandle, target, viewbox)
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 400, clientY: 100 })
)
expect(viewbox.width).toBe(300)
expect(viewbox.height).toBe(150)
expect(viewbox.x).toBe(0)
})
it('startResizeBottom only changes height as the pointer moves', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150) // bottom edge = 250
// Handle's own bottom edge (240+10) exactly at target's current bottom
// edge (250) - the "hasn't moved yet" baseline.
const dragHandle = addDiv(100, 240, 10, 10)
const viewbox: any = { width: 200, height: 150, x: 0, y: 0 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 100,
clientY: 250
})
component.startResizeBottom(pointerDown, dragHandle, target, viewbox)
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 100, clientY: 350 })
)
expect(viewbox.height).toBe(250)
expect(viewbox.width).toBe(200)
})
it('stops tracking the pointer for startResizeRight once pointerup fires', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
const dragHandle = addDiv(290, 100, 10, 10)
const viewbox: any = { width: 200, height: 150, x: 0, y: 0 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 300,
clientY: 100
})
component.startResizeRight(pointerDown, dragHandle, target, viewbox)
document.dispatchEvent(new PointerEvent('pointerup'))
const widthAfterPointerUp = viewbox.width
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 600, clientY: 100 })
)
expect(viewbox.width).toBe(widthAfterPointerUp)
})
})
describe('ViewboxesComponent - startResizeLeft/startResizeBottomLeft', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
document.dispatchEvent(new PointerEvent('pointerup'))
toRemove.forEach((el) => el.remove())
})
const addDiv = (left: number, top: number, width: number, height: number) => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
toRemove.push(el)
return el
}
it('startResizeLeft grows width and moves x left, leaving height untouched', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150) // right edge = 300
const dragHandle = addDiv(100, 100, 10, 10)
const viewbox: any = { width: 200, height: 150, x: 10, y: 0 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 100,
clientY: 100
})
component.startResizeLeft(pointerDown, dragHandle, target, viewbox)
// Dragged 50px left.
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 50, clientY: 100 })
)
expect(viewbox.width).toBe(250)
expect(viewbox.x).toBe(-40) // 10 + (200 - 250)
expect(viewbox.height).toBe(150)
})
it('startResizeBottomLeft grows width+x and height together', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150) // bottom edge = 250
// top=240 so the handle's own bottom edge (240+10) starts exactly at
// target's current bottom edge (250) - the height "hasn't moved yet"
// baseline (left=100 needs no equivalent adjustment - resizeFromLeftEdge
// only reads dragRect.left, never dragRect.width, see its own tests).
const dragHandle = addDiv(100, 240, 10, 10)
const viewbox: any = { width: 200, height: 150, x: 10, y: 0 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 100,
clientY: 250
})
component.startResizeBottomLeft(pointerDown, dragHandle, target, viewbox)
// Dragged 50px left and 50px down.
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 50, clientY: 300 })
)
expect(viewbox.width).toBe(250)
expect(viewbox.x).toBe(-40)
expect(viewbox.height).toBe(200)
})
it('stops tracking the pointer for startResizeLeft once pointerup fires', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
const dragHandle = addDiv(100, 100, 10, 10)
const viewbox: any = { width: 200, height: 150, x: 10, y: 0 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 100,
clientY: 100
})
component.startResizeLeft(pointerDown, dragHandle, target, viewbox)
document.dispatchEvent(new PointerEvent('pointerup'))
const xAfterPointerUp = viewbox.x
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: -200, clientY: 100 })
)
expect(viewbox.x).toBe(xAfterPointerUp)
})
})
@@ -1,7 +1,6 @@
import { import {
CdkDragDrop, CdkDragDrop,
CdkDragEnd, CdkDragEnd,
CdkDragMove,
moveItemInArray, moveItemInArray,
transferArrayItem transferArrayItem
} from '@angular/cdk/drag-drop' } from '@angular/cdk/drag-drop'
@@ -56,6 +55,14 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChildren('resizeBox') resizeBoxQuery!: QueryList<ElementRef> //make query list, handle multiple @ViewChildren('resizeBox') resizeBoxQuery!: QueryList<ElementRef> //make query list, handle multiple
@ViewChildren('dragHandleCorner') @ViewChildren('dragHandleCorner')
dragHandleCornerQuery!: QueryList<ElementRef> dragHandleCornerQuery!: QueryList<ElementRef>
@ViewChildren('dragHandleCornerLeft')
dragHandleCornerLeftQuery!: QueryList<ElementRef>
@ViewChildren('dragHandleRight')
dragHandleRightQuery!: QueryList<ElementRef>
@ViewChildren('dragHandleBottom')
dragHandleBottomQuery!: QueryList<ElementRef>
@ViewChildren('dragHandleLeft')
dragHandleLeftQuery!: QueryList<ElementRef>
@ViewChildren(HotTableComponent) @ViewChildren(HotTableComponent)
hotTableComponents!: QueryList<HotTableComponent> hotTableComponents!: QueryList<HotTableComponent>
@@ -136,6 +143,11 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
public filterLibds: string | undefined public filterLibds: string | undefined
public _query: Subscription | undefined public _query: Subscription | undefined
// Set by startResize() while a corner-handle drag is in progress, so
// ngOnDestroy can remove its document-level listeners if the component
// is torn down mid-drag - see startResize's own comment.
private endActiveResize: (() => void) | undefined
public licenceState = this.licenceService.licenceState public licenceState = this.licenceService.licenceState
public Infinity = Infinity public Infinity = Infinity
@@ -603,7 +615,91 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
target.style.height = height + 'px' target.style.height = height + 'px'
this.setAllHandleTransform() this.setAllHandleTransform()
this.scheduleAfterResizeRefresh()
return {
width,
height
}
}
/**
* Right-edge-only resize - same "distance from the target's opposite,
* fixed edge to the drag handle" math as resize(), but only applies to
* width. The target's left edge (and so viewbox.x) never moves for this
* handle, so no position bookkeeping is needed.
*/
resizeWidth(dragHandle: HTMLElement, target: HTMLElement): number {
const dragRect = dragHandle.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const width = dragRect.left - targetRect.left + dragRect.width
target.style.width = width + 'px'
return width
}
/**
* Bottom-edge-only resize - mirrors resizeWidth() for height. The
* target's top edge never moves for this handle either.
*/
resizeHeight(dragHandle: HTMLElement, target: HTMLElement): number {
const dragRect = dragHandle.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const height = dragRect.top - targetRect.top + dragRect.height
target.style.height = height + 'px'
return height
}
/**
* Left-edge resize - unlike resizeWidth()/resize(), the ANCHOR here is
* the target's opposite (right) edge, which must stay visually fixed
* while the left edge (and so viewbox.x) moves with the pointer. Needs
* startTargetRight/startX/startWidth captured once at drag start (not
* read live) since the target's own rect moves as a *result* of this
* same calculation - a live read would be self-referential.
*
* Takes the raw pointer clientX, not a handle element's rect (unlike
* every other resize* method here) - the resize handle is a child of
* target, and target itself moves during this specific drag (viewbox.x
* feeds CDK's own transform), so getBoundingClientRect() on a child
* would report a position contaminated by target's own prior movement:
* each frame's width would include target's last-frame shift on top of
* the actual pointer delta, compounding into runaway growth and visible
* jitter. Raw pointer coordinates are always absolute viewport
* coordinates, immune to any transform applied to elements under them.
*
* Reads back the actually-rendered width after writing it, rather than
* trusting the raw intended value, because CSS min-width can clamp it -
* if x were derived from the unclamped value, the box would keep
* drifting left even after its width visibly stopped shrinking.
*/
resizeFromLeftEdge(
pointerClientX: number,
target: HTMLElement,
startTargetRight: number,
startX: number,
startWidth: number
): { width: number; x: number } {
const intendedWidth = startTargetRight - pointerClientX
target.style.width = intendedWidth + 'px'
const actualWidth = target.getBoundingClientRect().width
const x = startX + (startWidth - actualWidth)
return { width: actualWidth, x }
}
/**
* Shared debounced tail for every resize path (corner, single-edge,
* left-edge) - persists the new size/position, notifies listeners the
* viewport changed shape, and refreshes every viewbox's Handsontable
* instance to match.
*/
private scheduleAfterResizeRefresh(): void {
this.helperService.debounceCall(1000, () => { this.helperService.debounceCall(1000, () => {
this.viewboxChanged() this.viewboxChanged()
this.eventService.dispatchEvent('resize') this.eventService.dispatchEvent('resize')
@@ -614,49 +710,325 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
this.refreshTableAfterResize(viewbox) this.refreshTableAfterResize(viewbox)
}) })
}) })
return {
width,
height
}
} }
/** /**
* Calls `resize()` outside of angular zone * Reads the handle's current translate(x, y) transform (empty/unset
* Running functions via #runOutsideAngular allows you to escape Angular's * parses as {x: 0, y: 0}) - used as the baseline a resize drag's pointer
* zone and do work that doesn't trigger Angular change-detection or is subject * delta gets added to, so the handle continues from wherever
* to Angular's error handling. * setHandleTransform() last placed it rather than jumping.
* @param dragHandle
* @param resizeBox
* @param viewbox
* @param $event
*/ */
dragMove( private parseHandleTranslate(transform: string): { x: number; y: number } {
dragHandle: HTMLElement, const match = transform.match(
resizeBox: any, /translate\(\s*(-?\d+(?:\.\d+)?)px,\s*(-?\d+(?:\.\d+)?)px\s*\)/
viewbox: Viewbox, )
$event: CdkDragMove<any> if (!match) return { x: 0, y: 0 }
) {
this.ngZone.runOutsideAngular(() => {
const newDimnesion = this.resize(dragHandle, resizeBox)
viewbox.width = newDimnesion.width return { x: parseFloat(match[1]), y: parseFloat(match[2]) }
viewbox.height = newDimnesion.height }
/**
* Starts a corner-handle resize drag on plain pointer events rather than
* CDK's cdkDrag - the handle's transform is also written directly by
* setHandleTransform() (via resize() below) after every move, so it needs
* to be the ONLY thing driving that element's transform. A cdkDrag on the
* handle would track its own transform from cumulative pointer delta,
* unaware of externally-forced values, letting the handle's visual
* position and CDK's internal drag state diverge from each other.
*/
startResize(
event: PointerEvent,
dragHandle: HTMLElement,
resizeBox: HTMLElement,
viewbox: Viewbox
) {
event.preventDefault()
event.stopPropagation()
this.ngZone.runOutsideAngular(() => {
const startX = event.clientX
const startY = event.clientY
const startTranslate = this.parseHandleTranslate(
dragHandle.style.transform
)
const onPointerMove = (moveEvent: PointerEvent) => {
const dx = moveEvent.clientX - startX
const dy = moveEvent.clientY - startY
dragHandle.style.transform = `translate(${startTranslate.x + dx}px, ${startTranslate.y + dy}px)`
const newDimnesion = this.resize(dragHandle, resizeBox)
viewbox.width = newDimnesion.width
viewbox.height = newDimnesion.height
}
const onPointerUp = () => {
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
this.endActiveResize = undefined
}
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp)
// Tracked so ngOnDestroy can remove these document-level listeners
// if the component is torn down mid-drag (e.g. navigating away
// while resizing) - they'd otherwise outlive the elements they
// reference.
this.endActiveResize = onPointerUp
}) })
} }
/** /**
* Sets the 'resize' handle in the correct corner position of the all boxes * Right-edge-only resize drag - same pointer-event approach as
* startResize(), affecting only width. resize()'s own debounced refresh
* is bundled into resize() itself; resizeWidth()/resizeHeight()/
* resizeFromLeftEdge() are pure, so every handler below schedules it
* explicitly.
*/
startResizeRight(
event: PointerEvent,
dragHandle: HTMLElement,
target: HTMLElement,
viewbox: Viewbox
) {
event.preventDefault()
event.stopPropagation()
this.ngZone.runOutsideAngular(() => {
const startX = event.clientX
const startTranslate = this.parseHandleTranslate(
dragHandle.style.transform
)
const onPointerMove = (moveEvent: PointerEvent) => {
const dx = moveEvent.clientX - startX
dragHandle.style.transform = `translate(${startTranslate.x + dx}px, ${startTranslate.y}px)`
viewbox.width = this.resizeWidth(dragHandle, target)
this.setAllHandleTransform()
this.scheduleAfterResizeRefresh()
}
const onPointerUp = () => {
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
this.endActiveResize = undefined
}
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp)
this.endActiveResize = onPointerUp
})
}
/**
* Bottom-edge-only resize drag, affecting only height.
*/
startResizeBottom(
event: PointerEvent,
dragHandle: HTMLElement,
target: HTMLElement,
viewbox: Viewbox
) {
event.preventDefault()
event.stopPropagation()
this.ngZone.runOutsideAngular(() => {
const startY = event.clientY
const startTranslate = this.parseHandleTranslate(
dragHandle.style.transform
)
const onPointerMove = (moveEvent: PointerEvent) => {
const dy = moveEvent.clientY - startY
dragHandle.style.transform = `translate(${startTranslate.x}px, ${startTranslate.y + dy}px)`
viewbox.height = this.resizeHeight(dragHandle, target)
this.setAllHandleTransform()
this.scheduleAfterResizeRefresh()
}
const onPointerUp = () => {
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
this.endActiveResize = undefined
}
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp)
this.endActiveResize = onPointerUp
})
}
/**
* Left-edge resize drag - unlike the handles above, this one also moves
* viewbox.x (the box's own left edge). viewbox.x feeds CDK's
* [cdkDragFreeDragPosition] input binding, which only takes effect when
* Angular change detection actually runs - so unlike every other resize
* handle here (which stay outside the zone for the whole drag), this one
* re-enters the zone specifically for the x update, or the box's
* CDK-driven position would silently fall out of sync with its own
* rendered width.
*
* startTargetRight/startX/startWidth are captured once at drag start,
* not read live - see resizeFromLeftEdge's own doc comment for why.
*/
startResizeLeft(
event: PointerEvent,
dragHandle: HTMLElement,
target: HTMLElement,
viewbox: Viewbox
) {
event.preventDefault()
event.stopPropagation()
const startTargetRight = target.getBoundingClientRect().right
const startX = viewbox.x
const startWidth = viewbox.width
this.ngZone.runOutsideAngular(() => {
const startPointerX = event.clientX
const startTranslate = this.parseHandleTranslate(
dragHandle.style.transform
)
const onPointerMove = (moveEvent: PointerEvent) => {
const dx = moveEvent.clientX - startPointerX
dragHandle.style.transform = `translate(${startTranslate.x + dx}px, ${startTranslate.y}px)`
const { width, x } = this.resizeFromLeftEdge(
moveEvent.clientX,
target,
startTargetRight,
startX,
startWidth
)
viewbox.width = width
this.ngZone.run(() => {
viewbox.x = x
})
this.setAllHandleTransform()
this.scheduleAfterResizeRefresh()
}
const onPointerUp = () => {
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
this.endActiveResize = undefined
}
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp)
this.endActiveResize = onPointerUp
})
}
/**
* Bottom-left corner resize drag - combines startResizeLeft's width/x
* handling with startResizeBottom's height handling.
*/
startResizeBottomLeft(
event: PointerEvent,
dragHandle: HTMLElement,
target: HTMLElement,
viewbox: Viewbox
) {
event.preventDefault()
event.stopPropagation()
const startTargetRight = target.getBoundingClientRect().right
const startX = viewbox.x
const startWidth = viewbox.width
this.ngZone.runOutsideAngular(() => {
const startPointerX = event.clientX
const startPointerY = event.clientY
const startTranslate = this.parseHandleTranslate(
dragHandle.style.transform
)
const onPointerMove = (moveEvent: PointerEvent) => {
const dx = moveEvent.clientX - startPointerX
const dy = moveEvent.clientY - startPointerY
dragHandle.style.transform = `translate(${startTranslate.x + dx}px, ${startTranslate.y + dy}px)`
const { width, x } = this.resizeFromLeftEdge(
moveEvent.clientX,
target,
startTargetRight,
startX,
startWidth
)
const height = this.resizeHeight(dragHandle, target)
viewbox.width = width
viewbox.height = height
this.ngZone.run(() => {
viewbox.x = x
})
this.setAllHandleTransform()
this.scheduleAfterResizeRefresh()
}
const onPointerUp = () => {
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
this.endActiveResize = undefined
}
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp)
this.endActiveResize = onPointerUp
})
}
/**
* Sets every resize handle (corner, corner-left, right/bottom/left edges)
* to its correct position on every box, keyed off each box's own id
* suffix so a handle from one box is never matched to another's rect.
*/ */
setAllHandleTransform() { setAllHandleTransform() {
const findHandle = (query: QueryList<ElementRef>, handleId: string) =>
query.find((el) => el.nativeElement.id === handleId)?.nativeElement
this.resizeBoxQuery.forEach((resizeBox: ElementRef) => { this.resizeBoxQuery.forEach((resizeBox: ElementRef) => {
const rect = resizeBox.nativeElement.getBoundingClientRect() const rect = resizeBox.nativeElement.getBoundingClientRect()
const handleId = `handle_${resizeBox.nativeElement.id}` const boxId = resizeBox.nativeElement.id
const dragHandleCorner = this.dragHandleCornerQuery.find( this.setHandleTransform(
(el, i) => el.nativeElement.id === handleId findHandle(this.dragHandleCornerQuery, `handle_${boxId}`),
rect,
'both'
)
this.setHandleTransform(
findHandle(
this.dragHandleCornerLeftQuery,
`handle_corner_left_${boxId}`
),
rect,
'bottomLeft'
)
this.setHandleTransform(
findHandle(this.dragHandleRightQuery, `handle_right_${boxId}`),
rect,
'x'
)
this.setHandleTransform(
findHandle(this.dragHandleBottomQuery, `handle_bottom_${boxId}`),
rect,
'y'
)
this.setHandleTransform(
findHandle(this.dragHandleLeftQuery, `handle_left_${boxId}`),
rect,
'left'
) )
this.setHandleTransform(dragHandleCorner?.nativeElement, rect, 'both')
}) })
} }
@@ -666,7 +1038,7 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
setHandleTransform( setHandleTransform(
dragHandle: HTMLElement, dragHandle: HTMLElement,
targetRect: ClientRect | DOMRect, targetRect: ClientRect | DOMRect,
position: 'x' | 'y' | 'both' position: 'x' | 'y' | 'both' | 'left' | 'bottomLeft'
) { ) {
const dragRect = dragHandle.getBoundingClientRect() const dragRect = dragHandle.getBoundingClientRect()
let translateX = targetRect.width - dragRect.width let translateX = targetRect.width - dragRect.width
@@ -687,6 +1059,19 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
if (position === 'both') { if (position === 'both') {
dragHandle.style.transform = `translate(${translateX}px, ${translateY}px)` dragHandle.style.transform = `translate(${translateX}px, ${translateY}px)`
} }
// 'left'/'bottomLeft': the handle sits at the box's left edge, which
// never moves relative to the box itself regardless of size (unlike
// the right edge, whose offset from the box's own left edge depends on
// targetRect.width) - a small fixed fine-tune mirroring 'x' mode's own
// +5, on the opposite side.
if (position === 'left') {
dragHandle.style.transform = `translate(-5px, 0)`
}
if (position === 'bottomLeft') {
dragHandle.style.transform = `translate(-5px, ${translateY}px)`
}
} }
/** /**
@@ -1498,5 +1883,6 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this._query?.unsubscribe() this._query?.unsubscribe()
this.endActiveResize?.()
} }
} }
+15 -1
View File
@@ -66,7 +66,7 @@
<div class="card-title text-center">Actions</div> <div class="card-title text-center">Actions</div>
</div> </div>
<div class="mt-20"> <div class="mt-20">
<div class="row"> <div class="row stage-actions-row">
<button <button
class="btn btn-sm btn-outline text-center mr-5i" class="btn btn-sm btn-outline text-center mr-5i"
(click)="viewerTableScreen()" (click)="viewerTableScreen()"
@@ -105,6 +105,20 @@
> >
<clr-icon shape="download" aria-hidden="true"></clr-icon> <clr-icon shape="download" aria-hidden="true"></clr-icon>
</button> </button>
<clr-toggle-container class="m-0 flex-shrink-0">
<clr-toggle-wrapper>
<input
type="checkbox"
clrToggle
checked
[(ngModel)]="formattedValues"
(change)="formattingChanged()"
/>
<label class="formatted-values-toggle">{{
formattedValues ? 'Formatted' : 'Unformatted'
}}</label>
</clr-toggle-wrapper>
</clr-toggle-container>
<clr-tooltip> <clr-tooltip>
@if (tableDetails?.['ALLOW_RESTORE'] === 'YES') { @if (tableDetails?.['ALLOW_RESTORE'] === 'YES') {
<button <button
+24
View File
@@ -0,0 +1,24 @@
// Named specifically (not a generic utility) since this component uses
// ViewEncapsulation.None - a generic name would leak globally.
//
// .row here has no CSS establishing it as a flex/grid container (Clarity
// doesn't define one) - the buttons lay out via normal inline flow, since
// .btn is display: inline-flex (an INLINE outer box). flex-wrap/align-items
// would be no-ops on it; vertical-align is what actually governs alignment
// between inline-level boxes of different heights.
.stage-actions-row {
clr-toggle-container {
// clr-form-control (Clarity's own class here) sets display: flex, not
// inline-flex - a block-level box takes the full width of its
// containing block regardless of its own width: auto, which is why it
// wrapped onto its own line. inline-flex gives it an inline outer box
// (sized to content) so it flows with the buttons instead.
display: inline-flex;
width: auto;
margin-top: 0;
// .btn already sets vertical-align: middle - matching it here is what
// actually centers this against the buttons, since neither is a flex
// item of a shared flex container.
vertical-align: middle;
}
}
+20 -1
View File
@@ -16,6 +16,7 @@ import { LicenceService } from '../services/licence.service'
import { globals } from '../_globals' import { globals } from '../_globals'
import { EditorsRestoreServiceResponse } from '../models/sas/editors-restore.model' import { EditorsRestoreServiceResponse } from '../models/sas/editors-restore.model'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse' import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { selectFormattedRows } from '../shared/utils/select-formatted-rows'
@Component({ @Component({
selector: 'app-stage', selector: 'app-stage',
@@ -34,6 +35,9 @@ export class StageComponent implements OnInit, AfterViewInit {
public tableDetails: any public tableDetails: any
public loaded: boolean = false public loaded: boolean = false
public revertingChanges: boolean = false public revertingChanges: boolean = false
public formattedValues: boolean = true
private rawStageTable: any[] = []
private fmtStageTable: any[] | undefined
public licenceState = this.licenceService.licenceState public licenceState = this.licenceService.licenceState
public hotTable: HotTableInterface = { public hotTable: HotTableInterface = {
data: [], data: [],
@@ -118,6 +122,14 @@ export class StageComponent implements OnInit, AfterViewInit {
} }
} }
public formattingChanged() {
this.hotTable.data = selectFormattedRows(
this.rawStageTable,
this.fmtStageTable,
this.formattedValues
)
}
public download(id: any) { public download(id: any) {
let sasjsConfig = this.sasService.getSasjsConfig() let sasjsConfig = this.sasService.getSasjsConfig()
let storage = sasjsConfig.serverUrl let storage = sasjsConfig.serverUrl
@@ -193,7 +205,14 @@ export class StageComponent implements OnInit, AfterViewInit {
return cellProperties return cellProperties
} }
this.hotTable.data = res.stagetable this.rawStageTable = res.stagetable
this.fmtStageTable = res.fmt_stagetable
this.hotTable.data = selectFormattedRows(
this.rawStageTable,
this.fmtStageTable,
this.formattedValues
)
this.hotTable.colHeaders = colHeaders this.hotTable.colHeaders = colHeaders
this.hotTable.columns = columns this.hotTable.columns = columns
this.hotTable.cells = cells this.hotTable.cells = cells
+2
View File
@@ -1,5 +1,6 @@
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { StageComponent } from './stage.component' import { StageComponent } from './stage.component'
import { HotTableModule } from '@handsontable/angular-wrapper' import { HotTableModule } from '@handsontable/angular-wrapper'
import { ClarityModule } from '@clr/angular' import { ClarityModule } from '@clr/angular'
@@ -11,6 +12,7 @@ const routes: Routes = [{ path: ':tableId', component: StageComponent }]
declarations: [StageComponent], declarations: [StageComponent],
imports: [ imports: [
CommonModule, CommonModule,
FormsModule,
ClarityModule, ClarityModule,
RouterModule.forChild(routes), RouterModule.forChild(routes),
HotTableModule HotTableModule
+46 -1
View File
@@ -94,6 +94,22 @@ app-editor {
td.readonlyCell { td.readonlyCell {
opacity: 0.5; opacity: 0.5;
} }
// Same tokens Clarity's own label-success/label-danger/label-warning
// use (see approve-details.component.html's row-count badges) - keeps
// this in sync with the rest of the app's added/deleted/modified
// color convention instead of a separately hand-picked shade.
th.rowStatusAdded {
background-color: var(--clr-label-success-bg-color) !important;
}
th.rowStatusDeleted {
background-color: var(--clr-label-danger-bg-color) !important;
}
th.rowStatusModified {
background-color: var(--clr-label-warning-bg-color) !important;
}
} }
.submit-reason { .submit-reason {
@@ -2528,7 +2544,8 @@ app-licensing {
} }
.license-key-form, .license-key-form,
.activation-key-form { .activation-key-form,
.combined-key-form {
padding: 0; padding: 0;
.clr-control-container { .clr-control-container {
@@ -2708,15 +2725,37 @@ app-viewboxes {
.dragHandle { .dragHandle {
position: absolute; position: absolute;
// Handsontable's own row/column-header "clone" overlays (.ht_clone_*)
// go up to z-index:160 (.ht_clone_top) - without a z-index higher than
// that here, the table's frozen header clones paint on top of any
// handle they happen to overlap, silently swallowing real clicks
// meant for the resize handle underneath. 180 sits comfortably above
// that, and still well below Handsontable's own dropdown/context-menu
// layers (205+).
z-index: 180;
// bottom: -8px; // bottom: -8px;
// right: -8px; // right: -8px;
// background-color: black; // background-color: black;
} }
// The corner handles are small (15x15) but sit right where the full-
// length edge strips below end (right/left span the box's whole
// height, bottom spans its whole width) - without a higher z-index than
// those, whichever edge handle happens to be later in the DOM wins the
// browser's hit-test in that overlap sliver, silently swallowing
// clicks aimed at the corner.
.dragHandle.corner { .dragHandle.corner {
width: 15px; width: 15px;
height: 15px; height: 15px;
cursor: nwse-resize; cursor: nwse-resize;
z-index: 181;
}
.dragHandle.corner-left {
width: 15px;
height: 15px;
cursor: nesw-resize;
z-index: 181;
} }
.dragHandle.right { .dragHandle.right {
@@ -2725,6 +2764,12 @@ app-viewboxes {
cursor: ew-resize; cursor: ew-resize;
} }
.dragHandle.left {
width: 2px;
height: 100%;
cursor: ew-resize;
}
.dragHandle.bottom { .dragHandle.bottom {
height: 2px; height: 2px;
width: 100%; width: 100%;
+3 -2096
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -8,8 +8,7 @@
"@semantic-release/commit-analyzer": "13.0.1", "@semantic-release/commit-analyzer": "13.0.1",
"@semantic-release/git": "^10.0.1", "@semantic-release/git": "^10.0.1",
"@semantic-release/npm": "13.1.5", "@semantic-release/npm": "13.1.5",
"@semantic-release/release-notes-generator": "14.1.0", "@semantic-release/release-notes-generator": "14.1.0"
"commit-and-tag-version": "12.7.1"
}, },
"overrides": { "overrides": {
"got": "11.8.6" "got": "11.8.6"
@@ -17,7 +16,6 @@
"scripts": { "scripts": {
"install": "cd client && npm i && cd ../sas && npm i", "install": "cd client && npm i && cd ../sas && npm i",
"build-frontend": "cd client && npm run build", "build-frontend": "cd client && npm run build",
"release": "commit-and-tag-version",
"lint": "cd client && npm run lint", "lint": "cd client && npm run lint",
"lint:fix": "cd client && npm run lint:fix", "lint:fix": "cd client && npm run lint:fix",
"lint:fix:silent": "cd client && npm run lint:fix:silent", "lint:fix:silent": "cd client && npm run lint:fix:silent",
@@ -4,6 +4,10 @@ _webout=`{"SYSDATE" : "26SEP22"
[ [
{"PRIMARY_KEY_FIELD":0 ,"SOME_BESTNUM":44 ,"SOME_CHAR":"this is dummy datass" ,"SOME_DATE":42 ,"SOME_DATETIME":42 ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":42 ,"SOME_SHORTNUM":3 ,"SOME_TIME":42 ,"_____DELETE__THIS__RECORD_____":"No" } {"PRIMARY_KEY_FIELD":0 ,"SOME_BESTNUM":44 ,"SOME_CHAR":"this is dummy datass" ,"SOME_DATE":42 ,"SOME_DATETIME":42 ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":42 ,"SOME_SHORTNUM":3 ,"SOME_TIME":42 ,"_____DELETE__THIS__RECORD_____":"No" }
] ]
, "fmt_stagetable":
[
{"PRIMARY_KEY_FIELD":"0" ,"SOME_BESTNUM":"44" ,"SOME_CHAR":"this is dummy datass" ,"SOME_DATE":"12FEB1960" ,"SOME_DATETIME":"01JAN1960:00:00:42" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"42" ,"SOME_SHORTNUM":"3" ,"SOME_TIME":"0:00:42" ,"_____DELETE__THIS__RECORD_____":"No" }
]
,"_DEBUG" : "" ,"_DEBUG" : ""
,"_METAUSER": "sasdemo@SAS" ,"_METAUSER": "sasdemo@SAS"
,"_METAPERSON": "sasdemo" ,"_METAPERSON": "sasdemo"
@@ -4,6 +4,10 @@ _webout = `{"SYSDATE" : "26SEP22"
[ [
{"PRIMARY_KEY_FIELD":0 ,"SOME_BESTNUM":44 ,"SOME_CHAR":"this is changed data" ,"SOME_DATE":42 ,"SOME_DATETIME":42 ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":42 ,"SOME_SHORTNUM":3 ,"SOME_TIME":42 ,"_____DELETE__THIS__RECORD_____":"No" } {"PRIMARY_KEY_FIELD":0 ,"SOME_BESTNUM":44 ,"SOME_CHAR":"this is changed data" ,"SOME_DATE":42 ,"SOME_DATETIME":42 ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":42 ,"SOME_SHORTNUM":3 ,"SOME_TIME":42 ,"_____DELETE__THIS__RECORD_____":"No" }
] ]
, "fmt_stagetable":
[
{"PRIMARY_KEY_FIELD":"0" ,"SOME_BESTNUM":"44" ,"SOME_CHAR":"this is changed data" ,"SOME_DATE":"12FEB1960" ,"SOME_DATETIME":"01JAN1960:00:00:42" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"42" ,"SOME_SHORTNUM":"3" ,"SOME_TIME":"0:00:42" ,"_____DELETE__THIS__RECORD_____":"No" }
]
,"_DEBUG" : "" ,"_DEBUG" : ""
,"_METAUSER": "sasdemo@SAS" ,"_METAUSER": "sasdemo@SAS"
,"_METAPERSON": "sasdemo" ,"_METAPERSON": "sasdemo"
+241 -2
View File
@@ -53,7 +53,7 @@ function makeRows(n) {
NUMFMT_COL: 1000 + i * 12.5, // shown as EUR NUMFMT_COL: 1000 + i * 12.5, // shown as EUR
REGEX_HARD_COL: "user@example.com", // HARDREGEX: email — starts valid REGEX_HARD_COL: "user@example.com", // HARDREGEX: email — starts valid
REGEX_SOFT_COL: "SW1A 1AA", // SOFTREGEX: UK postcode — starts valid REGEX_SOFT_COL: "SW1A 1AA", // SOFTREGEX: UK postcode — starts valid
REGEX_BOTH_COL: "ABC-123" // HARDREGEX + SOFTREGEX together — starts valid against both REGEX_BOTH_COL: "ABC-123" // HARDREGEX + SOFTREGEX together — starts valid against both
}) })
} }
return rows return rows
@@ -336,7 +336,9 @@ let webouts = {
{ NAME: "numfmt_col", MAXLEN: 8 }, { NAME: "numfmt_col", MAXLEN: 8 },
{ NAME: "regex_hard_col", MAXLEN: 128 }, { NAME: "regex_hard_col", MAXLEN: 128 },
{ NAME: "regex_soft_col", MAXLEN: 128 }, { NAME: "regex_soft_col", MAXLEN: 128 },
{ NAME: "regex_both_col", MAXLEN: 128 } { NAME: "regex_both_col", MAXLEN: 128 },
{ NAME: "formula_hard_col", MAXLEN: 128 },
{ NAME: "formula_soft_col", MAXLEN: 128 }
], ],
query: [], query: [],
sasdata: makeRows(100), sasdata: makeRows(100),
@@ -1511,6 +1513,241 @@ let webouts = {
SYSWARNINGTEXT: "ENCODING option ignored for files opened with RECFM=N.", SYSWARNINGTEXT: "ENCODING option ignored for files opened with RECFM=N.",
END_DTTM: "2023-03-10T12:39:16.070656", END_DTTM: "2023-03-10T12:39:16.070656",
MEMSIZE: "2GB" MEMSIZE: "2GB"
},
// Single-row, HARDFORMULA-only table - isolates whether HyperFormula
// computes at all when the row count is nowhere near the license's
// editor_rows_allowed cap (see maxRows investigation: Handsontable's
// formulas plugin forwards its own maxRows setting straight into the
// HyperFormula engine's sheet-size limit).
MPE_X_FORMULA_TEST: {
SYSDATE: "28JUL26",
SYSTIME: "12:00",
approvers: [],
cols: [
{
NAME: "PRIMARY_KEY_FIELD",
LABEL: "PRIMARY_KEY_FIELD",
FMTNAME: "",
DDTYPE: "N",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: "",
COLTYPE: "{\"data\":\"PRIMARY_KEY_FIELD\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "A_COL",
LABEL: "A_COL",
FMTNAME: "",
DDTYPE: "N",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: "",
COLTYPE: "{\"data\":\"A_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "B_COL",
LABEL: "B_COL",
FMTNAME: "",
DDTYPE: "N",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: "",
COLTYPE: "{\"data\":\"B_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "FORMULA_HARD_COL",
LABEL: "FORMULA_HARD_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "HARDFORMULA: computed A_COL * B_COL, readonly",
LONGDESC: "",
COLTYPE: "{\"data\":\"FORMULA_HARD_COL\"}"
},
{
NAME: "FORMULA_SOFT_COL",
LABEL: "FORMULA_SOFT_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: computed A_COL + B_COL as a default, user can overwrite",
LONGDESC: "",
COLTYPE: "{\"data\":\"FORMULA_SOFT_COL\"}"
},
{
NAME: "ROW_STATUS_COL",
LABEL: "ROW_STATUS_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: DC.ROW_STATUS demo, reacts live to this row's edit status",
LONGDESC: "",
COLTYPE: "{\"data\":\"ROW_STATUS_COL\"}"
},
{
NAME: "USER_NAME_COL",
LABEL: "USER_NAME_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: DC.USER_NAME demo, shows the current logged-in user",
LONGDESC: "",
COLTYPE: "{\"data\":\"USER_NAME_COL\"}"
},
{
NAME: "ORIG_VALUE_COL",
LABEL: "ORIG_VALUE_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: DC.ORIG_VALUE demo, echoes this row's pre-edit value (blank for a newly-inserted row)",
LONGDESC: "",
COLTYPE: "{\"data\":\"ORIG_VALUE_COL\"}"
},
{
NAME: "CHANGE_SUMMARY_COL",
LABEL: "CHANGE_SUMMARY_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: combines DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE - 'unedited' while unchanged, else '<user> changed from <original value>'",
LONGDESC: "",
COLTYPE: "{\"data\":\"CHANGE_SUMMARY_COL\"}"
},
{
NAME: "PLAIN_TEXT_COL",
LABEL: "PLAIN_TEXT_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "No DQ rule at all (not a formula column) - a plain character column for manually testing that revert/overwritten-comment also works when nothing but a direct edit (typing, paste, autofill) ever touches it. Its raw value can't be pre-seeded as already overwritten, unlike FORMULA_HARD_COL/FORMULA_SOFT_COL - see dataSourceRaw's own doc comment in editor.component.ts for why only formula columns can do that.",
LONGDESC: "",
COLTYPE: "{\"data\":\"PLAIN_TEXT_COL\"}"
}
],
dqdata: [],
dqrules: [
{ BASE_COL: "PRIMARY_KEY_FIELD", RULE_TYPE: "NOTNULL", RULE_VALUE: "" },
{ BASE_COL: "FORMULA_HARD_COL", RULE_TYPE: "HARDFORMULA", RULE_VALUE: "=A_COL * B_COL" },
{ BASE_COL: "FORMULA_SOFT_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "=A_COL + B_COL" },
{ BASE_COL: "ROW_STATUS_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "=DC.ROW_STATUS" },
{ BASE_COL: "USER_NAME_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "=DC.USER_NAME" },
{ BASE_COL: "ORIG_VALUE_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "=DC.ORIG_VALUE" },
{ BASE_COL: "CHANGE_SUMMARY_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "=IF( DC.ROW_STATUS =\"U\",\"unedited\", DC.USER_NAME &\" changed from \"& DC.ORIG_VALUE )" }
],
dsmeta: [
{ ODS_TABLE: "ATTRIBUTES", NAME: "Data Set Name", VALUE: "MPE_X_FORMULA_TEST" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Member Type", VALUE: "DATA" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Engine", VALUE: "V9" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Observations", VALUE: "10" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Variables", VALUE: "10" }
],
maxvarlengths: [
{ NAME: "_____DELETE__THIS__RECORD_____", MAXLEN: 3 },
{ NAME: "primary_key_field", MAXLEN: 8 },
{ NAME: "a_col", MAXLEN: 8 },
{ NAME: "b_col", MAXLEN: 8 },
{ NAME: "formula_hard_col", MAXLEN: 128 },
{ NAME: "formula_soft_col", MAXLEN: 128 },
{ NAME: "row_status_col", MAXLEN: 128 },
{ NAME: "user_name_col", MAXLEN: 128 },
{ NAME: "orig_value_col", MAXLEN: 128 },
{ NAME: "change_summary_col", MAXLEN: 128 },
{ NAME: "plain_text_col", MAXLEN: 128 }
],
query: [],
// 10 rows, well under the editor_rows_allowed=15 cap, with both
// HARDFORMULA and SOFTFORMULA rules present together. ORIG_VALUE_COL
// and CHANGE_SUMMARY_COL are seeded with a distinctive raw value
// (not blank, unlike the other formula columns) so DC.ORIG_VALUE -
// which always echoes THIS SAME column's own pre-edit value, never
// another column's - has something meaningful to echo back.
//
// Rows 7-10 (i===6..9) additionally seed FORMULA_HARD_COL/
// FORMULA_SOFT_COL with real pre-existing values that the
// HARDFORMULA/SOFTFORMULA rules above overwrite on load - a
// deliberately staggered pattern (row 7: hard-only, row 8:
// soft-only, rows 9-10: both) so a manual/Cypress tester can
// right-click a whole ROW (some already-overwritten cells mixed
// with untouched ones - e.g. row 7), a whole COLUMN (overwritten
// cells scattered across only some of its rows), or a multi-row/
// multi-column range and see "Revert" appear/disappear correctly
// depending on whether the selection actually contains an
// overwritten cell. Rows 1-6 are left untouched so a selection
// confined to them proves the negative case (no "Revert" offered).
// See Cypress test 28 (row 10) and 32-35 (general revert coverage).
sasdata: Array.from({ length: 10 }, (_, i) => ({
_____DELETE__THIS__RECORD_____: "No",
PRIMARY_KEY_FIELD: i + 1,
A_COL: i + 1,
B_COL: 10,
FORMULA_HARD_COL: i === 6 ? "7771" : i === 8 ? "9991" : i === 9 ? "1111" : "",
FORMULA_SOFT_COL: i === 7 ? "8882" : i === 8 ? "9992" : i === 9 ? "2222" : "",
ROW_STATUS_COL: "",
USER_NAME_COL: "",
ORIG_VALUE_COL: `orig-${i + 1}`,
CHANGE_SUMMARY_COL: `orig-${i + 1}`,
PLAIN_TEXT_COL: `note-${i + 1}`
})),
$sasdata: {
vars: {
_____DELETE__THIS__RECORD_____: { format: "$3.", label: "_____DELETE__THIS__RECORD_____", length: "3", type: "char" },
PRIMARY_KEY_FIELD: { format: "best.", label: "PRIMARY_KEY_FIELD", length: "8", type: "num" },
A_COL: { format: "best.", label: "A_COL", length: "8", type: "num" },
B_COL: { format: "best.", label: "B_COL", length: "8", type: "num" },
FORMULA_HARD_COL: { format: "$128.", label: "FORMULA_HARD_COL", length: "128", type: "char" },
FORMULA_SOFT_COL: { format: "$128.", label: "FORMULA_SOFT_COL", length: "128", type: "char" },
ROW_STATUS_COL: { format: "$128.", label: "ROW_STATUS_COL", length: "128", type: "char" },
USER_NAME_COL: { format: "$128.", label: "USER_NAME_COL", length: "128", type: "char" },
ORIG_VALUE_COL: { format: "$128.", label: "ORIG_VALUE_COL", length: "128", type: "char" },
CHANGE_SUMMARY_COL: { format: "$128.", label: "CHANGE_SUMMARY_COL", length: "128", type: "char" },
PLAIN_TEXT_COL: { format: "$128.", label: "PLAIN_TEXT_COL", length: "128", type: "char" }
}
},
sasparams: [
{
COLHEADERS: "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,A_COL,B_COL,FORMULA_HARD_COL,FORMULA_SOFT_COL,ROW_STATUS_COL,USER_NAME_COL,ORIG_VALUE_COL,CHANGE_SUMMARY_COL,PLAIN_TEXT_COL",
FILTER_TEXT: "",
PKCNT: 1,
PK: "PRIMARY_KEY_FIELD",
DTVARS: "",
DTTMVARS: "",
TMVARS: "",
LOADTYPE: "UPDATE",
RK_FLAG: 0,
CLS_FLAG: 0
}
],
xl_rules: [],
_DEBUG: "",
_PROGRAM: "/Public/app/dc/services/editors/getdata",
AUTOEXEC: "",
MF_GETUSER: "sasdemo",
SYSCC: "0",
SYSENCODING: "utf-8",
SYSERRORTEXT: "",
SYSHOSTNAME: "SAS",
SYSPROCESSID: "0",
SYSPROCESSMODE: "SAS Batch Mode",
SYSPROCESSNAME: "",
SYSJOBID: "1",
SYSSCPL: "Linux",
SYSSITE: "123",
SYSUSERID: "sasjssrv",
SYSVLONG: "9.04.01M7P080520",
SYSWARNINGTEXT: "",
END_DTTM: "2026-07-28T12:00:00.000000",
MEMSIZE: "1MB"
} }
} }
@@ -1549,6 +1786,8 @@ if (_WEBIN_FILEREF1) {
if (file1.includes('MPE_X_NEW')) { if (file1.includes('MPE_X_NEW')) {
table = 'MPE_X_NEW' table = 'MPE_X_NEW'
} else if (file1.includes('MPE_X_FORMULA_TEST')) {
table = 'MPE_X_FORMULA_TEST'
} else if (file1.includes('MPE_X_TEST')) { } else if (file1.includes('MPE_X_TEST')) {
table = 'MPE_X_TEST' table = 'MPE_X_TEST'
} else if (file1.includes('MPE_DATADICTIONARY')) { } else if (file1.includes('MPE_DATADICTIONARY')) {
@@ -55,6 +55,10 @@ _webout = `{"SYSDATE" : "26SEP22"
"LIBREF": "DC996664", "LIBREF": "DC996664",
"DSN": "MPE_X_NEW" "DSN": "MPE_X_NEW"
}, },
{
"LIBREF": "DC996664",
"DSN": "MPE_X_FORMULA_TEST"
},
{ {
"LIBREF": "DC996664", "LIBREF": "DC996664",
"DSN": "MPE_DATADICTIONARY" "DSN": "MPE_DATADICTIONARY"
+11 -11
View File
@@ -6,8 +6,8 @@
"": { "": {
"name": "dc-sas", "name": "dc-sas",
"dependencies": { "dependencies": {
"@sasjs/cli": "4.18.4", "@sasjs/cli": "4.18.5",
"@sasjs/core": "4.68.2" "@sasjs/core": "4.68.3"
} }
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
@@ -214,9 +214,9 @@
} }
}, },
"node_modules/@sasjs/cli": { "node_modules/@sasjs/cli": {
"version": "4.18.4", "version": "4.18.5",
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.18.4.tgz", "resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.18.5.tgz",
"integrity": "sha512-ZxAycth1GDqTsWPjDIx0uifdhmXsgC5LFx4SdujpThPK1bf5MNGLn4h2zqlBKlLTEWKn5UPXVXMDD8ktVn9wtg==", "integrity": "sha512-ev58r3DRPmYTH955fTLiwqwjRHC/A0sjhrc+vdwQszT0MnrwyUrYMtYLpJ6IwA0GMHTMIofOKgNt/zdNItICSw==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@sasjs/adapter": "4.17.2", "@sasjs/adapter": "4.17.2",
@@ -251,9 +251,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sasjs/core": { "node_modules/@sasjs/core": {
"version": "4.68.2", "version": "4.68.3",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.68.2.tgz", "resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.68.3.tgz",
"integrity": "sha512-flbx36sNN7PvEIjX8H6UuGIKMQ8Ra3ZHEze9O66Wtk6cU+T0RV1i199LYXVumkja/VXSW334HlNpiSVR4o9qqg==", "integrity": "sha512-2xBZyp8XXnZqR00fg0khsGIYucv4UKUOYWtyzb6mPWfI4mrtJLv6uox5aelVB3h9X/4pd7ZqafDg/aXK+6ylAg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sasjs/lint": { "node_modules/@sasjs/lint": {
@@ -1648,9 +1648,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
+5 -2
View File
@@ -28,7 +28,10 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@sasjs/cli": "4.18.4", "@sasjs/cli": "4.18.5",
"@sasjs/core": "4.68.2" "@sasjs/core": "4.68.3"
},
"overrides": {
"nanoid": "3.3.18"
} }
} }
@@ -0,0 +1,37 @@
/**
@file
@brief migration script to move to v7.13 of Data Controller
OPTIONAL CHANGE - upload additional validation rules (HARDFORMULA,
SOFTFORMULA) into the RULE_TYPE dropdown (data only)
**/
%let dclib=YOURDCLIB;
libname &dclib "/YOUR/DATACONTROLLER/LIBRARY/PATH";
/* add new validation rules */
proc sql noprint;
select max(selectbox_rk) into: maxrk
from &dclib..mpe_selectbox;
insert into &dclib..mpe_selectbox set
selectbox_rk=&maxrk+1
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value='HARDFORMULA'
,selectbox_order=15
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &dclib..mpe_selectbox set
selectbox_rk=&maxrk+2
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value='SOFTFORMULA'
,selectbox_order=16
,ver_to_dttm='31DEC5999:23:59:59'dt;
quit;
+18
View File
@@ -934,6 +934,24 @@ insert into &lib..mpe_selectbox set
,selectbox_value="SOFTREGEX" ,selectbox_value="SOFTREGEX"
,selectbox_order=14 ,selectbox_order=14
,ver_to_dttm='31DEC5999:23:59:59'dt; ,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="HARDFORMULA"
,selectbox_order=15
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="SOFTFORMULA"
,selectbox_order=16
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk) selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0 ,ver_from_dttm=0
+96 -2
View File
@@ -12,6 +12,8 @@
<h4> SAS Macros </h4> <h4> SAS Macros </h4>
@li bitemporal_dataloader.sas @li bitemporal_dataloader.sas
@li mf_existvar.sas @li mf_existvar.sas
@li mf_getengine.sas
@li mf_getuniquename.sas
@li mp_abort.sas @li mp_abort.sas
@li mp_loadformat.sas @li mp_loadformat.sas
@li mp_lockanytable.sas @li mp_lockanytable.sas
@@ -136,8 +138,100 @@ run;
%end; %end;
drop _____DELETE__THIS__RECORD_____; drop _____DELETE__THIS__RECORD_____;
run; run;
proc sql; delete * from &libds; %local engine_type;
proc append base=&libds data=WORK.&STAGING_DS force nowarn;run; %let engine_type=%mf_getengine(&lib);
%if &engine_type=CAS %then %do;
/* Fixed char variables cannot be appended to CAS varchar columns, so
cast them (per the target table structure) in a CASUSER copy of the
staging table, then append via data step. Approach mirrors the CAS
append in bitemporal_dataloader.sas */
proc contents noprint data=&libds
out=work.rpl_base_cols(keep=name type);
run;
proc contents noprint data=WORK.&STAGING_DS
out=work.rpl_stag_cols(keep=name type);
run;
proc sql noprint;
create table work.rpl_vchars as
select a.name
from work.rpl_base_cols a
inner join work.rpl_stag_cols b
on upcase(a.name)=upcase(b.name)
where a.type=6; /* varchar in target */
quit;
/* get varchar variables ready for casting */
%local vcfmt vcrename vcassign vcdrop tmpds;
%let vcfmt=;
%let vcrename=;
%let vcassign=;
%let vcdrop=;
data _null_;
set work.rpl_vchars end=last;
length vcrename vcassign vcdrop vcfmt $32767 rancol $32;
retain vcrename vcassign vcdrop vcfmt;
if _n_=1 then vcrename='(rename=(';
rancol=resolve('%mf_getuniquename()');
vcfmt=trim(vcfmt)!!'length '!!cats(name)!!' varchar(*);';
vcrename=trim(vcrename)!!' '!!cats(name,'=',rancol);
vcassign=cats(vcassign,name,'=',rancol,';');
vcdrop=cats(vcdrop,'drop '!!rancol,';');
if last then do;
vcrename=cats(vcrename,'))');
call symputx('vcfmt',vcfmt);
call symputx('vcrename',vcrename);
call symputx('vcassign',vcassign);
call symputx('vcdrop',vcdrop);
end;
run;
/* prepare a temp cas table with varchars casted */
%let tmpds=%mf_getuniquename();
data casuser.&tmpds;
&vcfmt
set WORK.&STAGING_DS &vcrename;
&vcassign
&vcdrop
run;
/* exit on err condition before the destructive truncate below */
%if &syscc>0 %then %do;
%mp_lockanytable(UNLOCK,lib=&lib,ds=&ds,ref=&ETLSOURCE (aborted),
ctl_ds=&dclib..mpe_lockanytable
)
%end;
%mp_abort(iftrue= (&syscc>0)
,mac=&sysmacroname
,msg=%str(syscc=&syscc - aborting before REPLACE truncate of &libds.)
)
/* CAS tables do not support SQL deletes, so truncate with deleteRows.
This is deliberately the last step before the append, to minimise
the time in which the target table is empty. */
proc cas;
table.deleteRows / table={caslib="&lib",name="&ds",where="1=1"};
quit;
/* load the target with varchars applied */
data &libds (append=yes) / sessref=dcsession;
set casuser.&tmpds;
run;
/* drop temp table */
proc sql;
drop table CASUSER.&tmpds;
quit;
%end;
%else %do;
/* exit on err condition before the destructive delete below */
%if &syscc>0 %then %do;
%mp_lockanytable(UNLOCK,lib=&lib,ds=&ds,ref=&ETLSOURCE (aborted),
ctl_ds=&dclib..mpe_lockanytable
)
%end;
%mp_abort(iftrue= (&syscc>0)
,mac=&sysmacroname
,msg=%str(syscc=&syscc - aborting before REPLACE delete of &libds.)
)
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,ctl_ds=&dclib..mpe_lockanytable) %mp_lockanytable(UNLOCK,lib=&lib,ds=&ds,ctl_ds=&dclib..mpe_lockanytable)
%end; %end;
+178
View File
@@ -0,0 +1,178 @@
/**
@file
@brief Testing mpe_targetloader macro - REPLACE loadtype
@details Covers the REPLACE branch of mpe_targetloader.sas:
* LOADTARGET=NO (diff screen preparation) - all staged records classed as
new, all existing records classed as deleted, target table unchanged
* LOADTARGET=YES (actual load) - target table fully replaced by the
staging table, delete flag column dropped, processed column stamped,
per-record delete flags ignored (everything is loaded)
A dedicated DCTEST.DC_REPLACE table is registered in MPE_TABLES (and the
registration removed again in cleanup). The DCTEST library is BASE engine,
so the CAS-specific branch (deleteRows truncation / varchar casting) is not
exercised here.
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mp_assertdsobs.sas
@li mp_assertscope.sas
@li mpe_targetloader.sas
@author 4GL Apps Ltd
@copyright 4GL Apps Ltd. This code may only be used within Data Controller
and may not be re-distributed or re-sold without the express permission of
4GL Apps Ltd.
**/
%let syscc=0;
/**
* Prep - physical target table and MPE_TABLES registration
*/
data dctest.dc_replace;
length pk $8 val $20;
pk='OLD1'; val='oldvalue1'; processed_dttm=0; output;
pk='OLD2'; val='oldvalue2'; processed_dttm=0; output;
format processed_dttm datetime19.;
run;
proc sql noprint;
delete from &dc_libref..mpe_tables where libref="DCTEST" and dsn='DC_REPLACE';
insert into &dc_libref..mpe_tables
set tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,libref="DCTEST"
,dsn='DC_REPLACE'
,buskey='PK'
,loadtype='REPLACE'
,var_processed='PROCESSED_DTTM'
,num_of_approvals_required=1;
quit;
/* staging table, as it would arrive from the approval package */
data work.staging_ds;
length pk $8 val $20 _____DELETE__THIS__RECORD_____ $3;
pk='NEW1'; val='newvalue1'; _____DELETE__THIS__RECORD_____='No'; output;
pk='NEW2'; val='newvalue2'; _____DELETE__THIS__RECORD_____='Yes'; output;
pk='NEW3'; val='newvalue3'; _____DELETE__THIS__RECORD_____='No'; output;
run;
/**
* Test 1 - LOADTARGET=NO builds the diff tables without touching the target
*/
%mp_assertscope(SNAPSHOT)
%mpe_targetloader(libds=DCTEST.DC_REPLACE
,etlsource=mpe_targetloader.test
,STAGING_DS=STAGING_DS
,LOADTARGET=NO
,dclib=&dc_libref
,dc_dttmtfmt=&dc_dttmtfmt.
)
%mp_assertscope(COMPARE,
desc=%str(Test 1 - checking macro variables against previous snapshot)
)
%mp_assert(iftrue=(&syscc=0),
desc=%str(Test 1 - REPLACE LOADTARGET=NO completed without errors),
outds=work.test_results
)
%mp_assertdsobs(work.outds_add,
desc=%str(Test 1 - all staged records classed as new),
test=EQUALS 3,
outds=work.test_results
)
%mp_assertdsobs(work.outds_del,
desc=%str(Test 1 - all existing records classed as deleted),
test=EQUALS 2,
outds=work.test_results
)
%mp_assertdsobs(work.outds_mod,
desc=%str(Test 1 - no records classed as modified),
test=EQUALS 0,
outds=work.test_results
)
%mp_assertdsobs(dctest.dc_replace,
desc=%str(Test 1 - target table not modified),
test=EQUALS 2,
outds=work.test_results
)
/**
* Test 2 - LOADTARGET=YES replaces the target table with the staged data
*/
%mp_assertscope(SNAPSHOT)
%mpe_targetloader(libds=DCTEST.DC_REPLACE
,etlsource=mpe_targetloader.test
,STAGING_DS=STAGING_DS
,LOADTARGET=YES
,dclib=&dc_libref
,dc_dttmtfmt=&dc_dttmtfmt.
)
%mp_assertscope(COMPARE,
desc=%str(Test 2 - checking macro variables against previous snapshot)
)
%mp_assert(iftrue=(&syscc=0),
desc=%str(Test 2 - REPLACE LOADTARGET=YES completed without errors),
outds=work.test_results
)
%mp_assertdsobs(dctest.dc_replace,
desc=%str(Test 2 - target row count matches staging table),
test=EQUALS 3,
outds=work.test_results
)
proc sql noprint;
select count(*) into: oldrows
from dctest.dc_replace where pk in ('OLD1','OLD2');
select count(*) into: newrows
from dctest.dc_replace where pk in ('NEW1','NEW2','NEW3');
select count(*) into: delcol from dictionary.columns
where libname='DCTEST' and memname='DC_REPLACE'
and upcase(name)='_____DELETE__THIS__RECORD_____';
select count(*) into: notstamped
from dctest.dc_replace where missing(processed_dttm) or processed_dttm=0;
quit;
%mp_assert(iftrue=(&oldrows=0),
desc=%str(Test 2 - pre-existing records removed from target),
outds=work.test_results
)
%mp_assert(iftrue=(&newrows=3),
desc=%str(Test 2 - all staged records loaded, delete flags ignored),
outds=work.test_results
)
%mp_assert(iftrue=(&delcol=0),
desc=%str(Test 2 - delete flag column not loaded to target),
outds=work.test_results
)
%mp_assert(iftrue=(&notstamped=0),
desc=%str(Test 2 - processed_dttm stamped on every loaded record),
outds=work.test_results
)
/**
* Cleanup - remove all persistent state created by this test, so that a
* subsequent run starts from the same position (including a run that
* previously failed partway through)
*/
proc sql noprint;
/* REPLACE table registration */
delete from &dc_libref..mpe_tables where libref="DCTEST" and dsn='DC_REPLACE';
/* lock record (may be left as LOCKED after an aborted run) */
delete from &dc_libref..mpe_lockanytable
where lock_lib="DCTEST" and lock_ds="DC_REPLACE";
quit;
/* physical target table */
proc datasets lib=dctest nolist;
delete dc_replace;
run;
quit;
/* assertion macro variables */
%symdel oldrows newrows delcol notstamped;
@@ -4,8 +4,12 @@
@details @details
<h4> SAS Macros </h4> <h4> SAS Macros </h4>
@li dc_assignlib.sas
@li mf_existds.sas
@li mf_getvalue.sas @li mf_getvalue.sas
@li mp_abort.sas @li mp_abort.sas
@li mp_applyformats.sas
@li mp_getcols.sas
@version 9.2 @version 9.2
@author 4GL Apps Ltd @author 4GL Apps Ltd
@@ -27,8 +31,40 @@ run;
,msg=%str(syscc=&syscc) ,msg=%str(syscc=&syscc)
) )
/* the staged dataset on disk has no formats, so source them from the base
table (via mpe_submit) and apply them to the work copy - this drives the
frontend's Formatted/Unformatted toggle */
%let base_lib=;
%let base_ds=;
data _null_;
set &mpelib..mpe_submit;
where TABLE_ID="&table_id";
call symputx('base_lib',base_lib);
call symputx('base_ds',base_ds);
run;
%dc_assignlib(READ,&base_lib)
%mp_getcols(&base_lib..&base_ds,outds=work.basecols)
data work.stagefmts;
set work.basecols;
length lib $8 ds $32 var $32 fmt $49;
lib='WORK';
ds='STAGETABLE';
var=name;
fmt=format;
keep lib ds var fmt;
run;
%mp_applyformats(work.stagefmts)
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program..sas
,msg=%str(syscc=&syscc after applying base formats)
)
%webout(OPEN) %webout(OPEN)
%webout(OBJ,stagetable,missing=STRING) %webout(OBJ,stagetable,missing=STRING)
/* same table again with SAS formats applied */
%webout(OBJ,stagetable,dslabel=fmt_stagetable,fmt=Y,missing=STRING)
%webout(CLOSE) %webout(CLOSE)