Compare commits

..
120 Commits
Author SHA1 Message Date
Yury fc6c9f844f Merge branch 'version7-13' into issue-148
Lighthouse Checks / lighthouse (pull_request) Successful in 21m27s
Build / Build-and-ng-test (pull_request) Failing after 1m48s
Build / Build-and-test-development (pull_request) Skipped
2026-08-03 08:29:59 +00:00
YuryShkoda 74c38e1641 feat(stage): add Formatted/Unformatted toggle to the staging page
Build / Build-and-ng-test (pull_request) Successful in 4m53s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m0s
Build / Build-and-test-development (pull_request) Successful in 20m17s
- Mirrors the review page's toggle: getstagetable.sas now also webouts
  fmt_stagetable (SAS-formatted), selectFormattedRows() picks between it
  and the raw stagetable, falling back to raw if fmt data is absent
- Adds stage.cy.ts e2e coverage and wires it into both build.yaml and
  release.yaml's cypress --spec lists (previously out of sync with
  each other on viewbox.cy.ts too)
2026-08-03 09:28:52 +03:00
4gl 28104f83e1 chore(test): test for mpe_targetloader update
Build / Build-and-ng-test (pull_request) Successful in 5m9s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m27s
Build / Build-and-test-development (pull_request) Successful in 22m4s
2026-07-31 15:01:00 +01:00
4gl ea05f07180 fix: CAS support for REPLACE type plus docs
Build / Build-and-test-development (pull_request) Successful in 22m24s
Build / Build-and-ng-test (pull_request) Successful in 5m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m39s
2026-07-31 13:04:19 +01:00
YuryShkoda acb97f4bfb fix(formulas): avoid EDIT_STATUS colliding with a real column of that name
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m48s
Build / Build-and-test-development (pull_request) Successful in 21m38s
2026-07-31 10:26:35 +03:00
YuryShkoda bb808617f4 chore: merge remote-tracking branch 'origin/main' into additional-validations-formulae
Build / Build-and-ng-test (pull_request) Successful in 5m7s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m44s
Build / Build-and-test-development (pull_request) Successful in 21m54s
2026-07-30 18:00:48 +03:00
YuryShkoda 4a8c39b4c0 feat(formulas): live DC.ROW_STATUS, insert-row formula fixes, UserService singleton fix
Build / Build-and-ng-test (pull_request) Successful in 5m13s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m31s
Build / Build-and-test-development (pull_request) Successful in 21m56s
- addRow()/insertRowAtPosition() use hot.alter() so HARDFORMULA/SOFTFORMULA
  values realign on insert instead of going stale
- New hidden EDIT_STATUS column gives DC.ROW_STATUS a real, live cell
  reference; row-header +/-/~ indicator now translates visual->physical
  row index so it stays correct when the grid is sorted
- UserService is now providedIn: 'root' instead of module-scoped, fixing
  DC.USER_NAME (and any other lazy-module reader) always seeing an unset user
- Column-info dropdown shows the applied formula ("√x=<formula>")
2026-07-30 16:03:11 +03:00
semantic-release-bot 42e02cdb05 chore(release): 7.12.0 [skip ci]
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)

### Bug Fixes

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

### Features

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

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

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

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

### Bug Fixes

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

### Features

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

### Bug Fixes

* **editor:** order validation rules by COLHEADERS, not cols[].VARNUM ([4c2aca3](4c2aca3495))
2026-07-15 14:05:25 +00:00
allan f4525cc62a Merge pull request 'Fix headers alignment' (#262) from fix/colheaders-rule-alignment into main
Release / Build-production-and-ng-test (push) Successful in 4m43s
Release / Build-and-test-development (push) Successful in 14m11s
Release / release (push) Successful in 9m6s
Reviewed-on: #262
Reviewed-by: Yury <yury@4gl.io>
2026-07-15 13:42:31 +00:00
sead d4aa70ddbf test(editor): make CLS EDIT un-hide regression test actually fail
Build / Build-and-ng-test (pull_request) Successful in 5m20s
Build / Build-and-test-development (pull_request) Successful in 14m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m51s
2026-07-15 15:08:31 +02:00
sead 4c2aca3495 fix(editor): order validation rules by COLHEADERS, not cols[].VARNUM 2026-07-15 14:30:26 +02:00
semantic-release-bot 36c9ef0e95 chore(release): 7.10.0 [skip ci]
# [7.10.0](https://git.datacontroller.io/dc/dc/compare/v7.9.1...v7.10.0) (2026-07-13)

### Bug Fixes

* adapt column validation to vertical-array COLTYPE ([#253](#253)) ([3016750](301675052f))
* backend updates to bring back labels under [#240](#240) ([fb94840](fb94840016))
* **editor:** normalise unpadded time strings on spreadsheet import ([7b4b4eb](7b4b4ebeeb))
* **hot:** row rendering on resize, resolve blank whitespace ([c7ba025](c7ba025d39))
* including datadictionary descs in labels, [#240](#240) ([e7453a9](e7453a9305))
* pass variable formats to front-end in a vertical array instead of horizontal list ([691d6f2](691d6f277e))
* remove coltype from sasparams table, add it to cols table. ([8101400](81014001ac))
* trim leading/trailing whitespace from Excel header cells on upload ([2728dac](2728dac873))
* **va:** date filters refactor, support more dates ([df5c975](df5c975869))
* **va:** enable readOnly and hold filtering while editing ([82a254d](82a254d22c))
* **va:** enable Upload button in va mode ([1beb3d4](1beb3d490d))
* **va:** guard deferred contextMenu toggle against destroyed instance ([b5e9b25](b5e9b25319))

### Features

* adding DC_MAXOBS_WEBVIEW config item, closes [#258](#258) ([ba7b610](ba7b61082d))
* viewer/editor column label display toggle (?labels=true) ([25c12f2](25c12f2b18))

### Reverts

* enable upload button in va mode ([0d7fd34](0d7fd34297))
2026-07-13 16:56:10 +00:00
allan 7511b64523 Merge pull request 'Issue 251' (#255) from issue-251 into main
Release / Build-production-and-ng-test (push) Successful in 4m52s
Release / Build-and-test-development (push) Successful in 14m15s
Release / release (push) Successful in 9m11s
Reviewed-on: #255
2026-07-13 16:33:04 +00:00
sead 7b4b4ebeeb fix(editor): normalise unpadded time strings on spreadsheet import
Build / Build-and-ng-test (pull_request) Successful in 5m24s
Build / Build-and-test-development (pull_request) Successful in 14m38s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m38s
2026-07-13 15:59:52 +02:00
sead c7ba025d39 fix(hot): row rendering on resize, resolve blank whitespace 2026-07-13 15:28:11 +02:00
sead b5e9b25319 fix(va): guard deferred contextMenu toggle against destroyed instance
Build / Build-and-ng-test (pull_request) Successful in 4m1s
Build / Build-and-test-development (pull_request) Successful in 10m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m5s
2026-07-13 10:12:38 +02:00
sead 82a254d22c fix(va): enable readOnly and hold filtering while editing
Build / Build-and-ng-test (pull_request) Successful in 3m54s
Build / Build-and-test-development (pull_request) Successful in 10m37s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m0s
2026-07-12 21:16:51 +02:00
sead 0d7fd34297 revert: enable upload button in va mode
This reverts commit 1beb3d490d.
2026-07-12 20:33:11 +02:00
sead 1beb3d490d fix(va): enable Upload button in va mode
Build / Build-and-ng-test (pull_request) Successful in 3m55s
Build / Build-and-test-development (pull_request) Successful in 10m32s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m13s
2026-07-10 15:58:21 +02:00
allan 1fb5e8eb37 chore: fixing header
Lighthouse Checks / lighthouse (pull_request) Has been cancelled
Build / Build-and-ng-test (pull_request) Successful in 4m3s
Build / Build-and-test-development (pull_request) Successful in 10m31s
2026-07-10 12:53:08 +01:00
allan 78dd29ba78 Merge pull request 'feat: adding DC_MAXOBS_WEBVIEW config item, closes #258' (#259) from issue258 into issue-251
Build / Build-and-ng-test (pull_request) Successful in 4m1s
Lighthouse Checks / lighthouse (pull_request) Has been cancelled
Build / Build-and-test-development (pull_request) Has been cancelled
Reviewed-on: #259
2026-07-10 11:52:24 +00:00
allan ba7b61082d feat: adding DC_MAXOBS_WEBVIEW config item, closes #258
Build / Build-and-ng-test (pull_request) Successful in 4m1s
Build / Build-and-test-development (pull_request) Has been cancelled
Lighthouse Checks / lighthouse (pull_request) Successful in 18m17s
2026-07-10 12:45:09 +01:00
Yury 1a9231809e Merge pull request 'fix(va): date filters refactor, support more dates' (#257) from fix/va-date-parsing into issue-251
Build / Build-and-ng-test (pull_request) Successful in 3m56s
Build / Build-and-test-development (pull_request) Successful in 10m33s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m15s
Reviewed-on: #257
Reviewed-by: Yury <yury@4gl.io>
2026-07-10 11:06:19 +00:00
sead df5c975869 fix(va): date filters refactor, support more dates
Build / Build-and-ng-test (pull_request) Successful in 3m52s
Build / Build-and-test-development (pull_request) Successful in 10m36s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m16s
2026-07-10 10:33:32 +02:00
YuryShkoda 2728dac873 fix: trim leading/trailing whitespace from Excel header cells on upload
Build / Build-and-ng-test (pull_request) Successful in 4m2s
Build / Build-and-test-development (pull_request) Successful in 10m45s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m58s
Header matching in searchDataInExcel() was case-insensitive but not
whitespace-tolerant, so a header like " SOME_CHAR" (an easy defect to
pick up via copy-paste from another spreadsheet/system) was reported as
a missing column and aborted the whole upload.

- trim() the cell value before matching, alongside the existing
  toLowerCase() normalization
- add spreadsheet-util.spec.ts (no prior spec file existed) covering
  the trimmed match and a regression guard that a genuinely different
  header is still correctly reported missing
- add a Cypress case + fixture in excel.cy.ts covering the same defect
  end to end
2026-07-10 10:07:54 +03:00
YuryShkoda 88f55b9d06 chore(merge): merge origin/issue-251 into issue-251
Build / Build-and-ng-test (pull_request) Successful in 3m52s
Build / Build-and-test-development (pull_request) Successful in 10m21s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m45s
2026-07-09 17:51:49 +03:00
YuryShkoda 301675052f fix: adapt column validation to vertical-array COLTYPE (#253)
getdata.sas now sends one COLTYPE JSON-object string per cols[] row (via
a LEFT JOIN on the real dataset's columns) instead of one comma-joined
sasparams.COLTYPE string, avoiding the 32K cats() buffer overflow on
wide tables (#253). Adapt the client accordingly:

- parseColTypeRow replaces parseColType; DcValidator builds validation
  rules per cols[] entry instead of from sasparams.COLTYPE
- _____DELETE__THIS__RECORD_____'s rule is never present in cols[] (it's
  a client-only synthetic column %mp_getcols doesn't know about), so
  it's now hardcoded via deleteRecordColumnRule.ts
- cols[] isn't guaranteed to arrive in COLHEADERS/VARNUM order (backend
  can serialize it alphabetically by NAME) but editor.component.ts pairs
  rules with headers positionally, so DcValidator now sorts cols by
  VARNUM before building rules
- update sas/mocks/sasjs getdata.js to the new per-column COLTYPE shape
- add Cypress coverage for the delete-record dropdown and an ordinary
  dropdown column end to end
2026-07-09 17:48:33 +03:00
allan 45c434271d Merge pull request 'Display LABELS instead of column NAMEs #240' (#252) from issue240 into issue-251
Build / Build-and-ng-test (pull_request) Successful in 4m18s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m44s
Build / Build-and-test-development (pull_request) Successful in 10m34s
Reviewed-on: #252
2026-07-09 09:36:55 +00:00
allan f523c798c3 Merge branch 'issue-251' into issue240
Build / Build-and-ng-test (pull_request) Successful in 4m12s
Build / Build-and-test-development (pull_request) Successful in 10m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m14s
2026-07-09 09:36:38 +00:00
YuryShkoda 41fd618884 chore(merge): merge issue240 into issue-251
Brings in the SAS-side fixes for issue #253 (getdata.sas passing
variable formats via a vertical cols.COLTYPE array instead of a
single concatenated sasparams.COLTYPE string, which overflowed SAS's
32K buffer on wide datasets) plus related label/datadictionary fixes.
No client TypeScript files are touched by this merge.
2026-07-09 11:52:37 +03:00
YuryShkoda f368242cb0 test: fixed viewer-labels.cy.ts
Build / Build-and-ng-test (pull_request) Successful in 4m6s
Build / Build-and-test-development (pull_request) Successful in 10m32s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m34s
2026-07-09 10:11:36 +03:00
YuryShkoda 636e4e5b4a test: fixed viewer-labels.cy.ts
Build / Build-and-ng-test (pull_request) Successful in 4m1s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m53s
Build / Build-and-test-development (pull_request) Failing after 13m57s
2026-07-09 09:26:03 +03:00
YuryShkoda 49710eb62d chore: fixed package-lock in client
Build / Build-and-ng-test (pull_request) Successful in 3m55s
Build / Build-and-test-development (pull_request) Failing after 13m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m50s
2026-07-09 08:53:00 +03:00
YuryShkoda 25c12f2b18 feat: viewer/editor column label display toggle (?labels=true)
Build / Build-and-ng-test (pull_request) Failing after 39s
Build / Build-and-test-development (pull_request) Has been skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 54s
Adds a URL-driven toggle (?labels=true, mirroring embed=va) that
swaps grid column headers between NAME and LABEL in both the Viewer
and Editor, with a "Show labels"/"Show names" item in each grid's
right-click context menu. The header-info popup now shows NAME first
regardless of display mode. Sorting/filtering/data binding stay
NAME-based throughout — only the displayed header text changes.

New shared, unit-tested utils (parse-labels-param, display-col-headers,
col-info-html) back both pages. Mock data (viewdata.js, getdata.js)
gains a couple of columns with LABEL != NAME plus a blank-LABEL case,
covered by a new viewer-labels.cy.ts e2e spec now wired into CI.

Also fixes a pre-existing gap in cypress/support/commands.ts: the
isLoggedIn/loginAndUpdateValidKey custom commands had no Cypress
Chainable type augmentation, and moment was imported as a namespace
import despite its `export =` typing making it uncallable that way.
2026-07-08 15:15:28 +03:00
allan 3ba8858260 Merge pull request 'fix: pass variable formats to front-end in a vertical array instead of horizontal list' (#254) from fix/high_column_count_exceeding_maxcharlen into issue240
Build / Build-and-ng-test (pull_request) Successful in 3m53s
Build / Build-and-test-development (pull_request) Successful in 9m19s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m9s
Reviewed-on: #254
2026-07-08 10:55:37 +00:00
Trevor Moody 81014001ac fix: remove coltype from sasparams table, add it to cols table.
Build / Build-and-ng-test (pull_request) Successful in 3m52s
Build / Build-and-test-development (pull_request) Successful in 9m22s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m54s
2026-07-08 03:24:22 +01:00
Trevor Moody 691d6f277e fix: pass variable formats to front-end in a vertical array instead of horizontal list 2026-07-07 17:24:16 +01:00
allan e7453a9305 fix: including datadictionary descs in labels, #240
Build / Build-and-ng-test (pull_request) Successful in 3m55s
Build / Build-and-test-development (pull_request) Successful in 9m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m48s
2026-07-07 15:30:14 +01:00
YuryShkoda 005b616adb chore(deps): upgrade Handsontable to v18
Bump handsontable and @handsontable/angular-wrapper 17.1 -> 18.0 and
adapt to its stricter TypeScript-core types: moved handsontable/common
and deep walkontable imports to their new public export paths, added
explicit generics/casts where getData()/getCellMeta()/getSelected() are
now typed unknown/nullable instead of the loose v17 shapes, and updated
the AutocompleteEditor override to the new open() signature.

Also allowlists @handsontable/angular-wrapper@18.0.0 and
handsontable@18.0.0 in licenseChecker.js — both versions report the
same non-SPDX "SEE LICENSE IN LICENSE.txt" field that license-checker
mis-resolves as a disallowed custom license, same as prior versions.
2026-07-06 15:44:27 +03:00
allan 0ae1781d28 Merge branch 'main' into issue240
Build / Build-and-ng-test (pull_request) Successful in 4m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m17s
Build / Build-and-test-development (pull_request) Successful in 9m23s
2026-07-05 10:51:11 +00:00
allan fb94840016 fix: backend updates to bring back labels under #240
Build / Build-and-ng-test (pull_request) Successful in 4m11s
Build / Build-and-test-development (pull_request) Successful in 9m31s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m31s
2026-07-05 11:49:36 +01:00
semantic-release-bot 6864c044dc chore(release): 7.9.1 [skip ci]
## [7.9.1](https://git.datacontroller.io/dc/dc/compare/v7.9.0...v7.9.1) (2026-06-30)

### Bug Fixes

* workflow file upload using action PAT ([a161670](a161670b86))
2026-06-30 07:29:21 +00:00
allan b30ba3e76b Merge pull request 'fix: workflow file upload using action PAT' (#250) from workflowfix into main
Release / Build-production-and-ng-test (push) Successful in 3m38s
Release / Build-and-test-development (push) Successful in 9m14s
Release / release (push) Successful in 7m4s
Reviewed-on: #250
Reviewed-by: sead <307+sead@noreply.git.datacontroller.io>
2026-06-30 07:12:54 +00:00
allan a4583f2db0 Merge branch 'main' into workflowfix
Build / Build-and-test-development (pull_request) Has been cancelled
Build / Build-and-ng-test (pull_request) Has been cancelled
Lighthouse Checks / lighthouse (pull_request) Has been cancelled
2026-06-30 07:12:40 +00:00
allan a161670b86 fix: workflow file upload using action PAT
Build / Build-and-test-development (pull_request) Has been cancelled
Build / Build-and-ng-test (pull_request) Has been cancelled
Lighthouse Checks / lighthouse (pull_request) Has been cancelled
2026-06-30 08:09:39 +01:00
189 changed files with 21834 additions and 14058 deletions
+147
View File
@@ -0,0 +1,147 @@
# Bitemporal Dataloader — Technical Deep Dive
This document explains how the Data Controller backend loads staged data into target tables: the load-type dispatch, the internals of the `%bitemporal_dataloader` macro, and a detailed description of the REPLACE load type. For user-facing load type documentation, see [docs.datacontroller.io](https://docs.datacontroller.io/).
## Overview
Every table registered for loading has a current record in `&mpelib..MPE_TABLES` with a `LOADTYPE` (selectbox values are seeded in `mpe_makedata.sas`):
| LOADTYPE | Loader used | History kept |
|---|---|---|
| `UPDATE` | `%bitemporal_dataloader` (no temporal vars) | None — changed records are deleted and re-appended |
| `REPLACE` | Inline code in `%mpe_targetloader` (does **not** use `%bitemporal_dataloader`) | None — entire table wiped and reloaded |
| `TXTEMPORAL` | `%bitemporal_dataloader` (technical time only) | SCD2-style, technical (transaction) time |
| `BITEMPORAL` | `%bitemporal_dataloader` (business + technical time) | Full two-dimensional history |
| `FORMAT_CAT` | `%mp_loadformat` | Format catalog load (table suffix `-FC`) |
The relevant `MPE_TABLES` columns read by the loader are: `LOADTYPE`, `BUSKEY` (primary key, space-separated, excluding temporal columns), `VAR_TXFROM` / `VAR_TXTO` (technical validity), `VAR_BUSFROM` / `VAR_BUSTO` (business validity), `VAR_PROCESSED` (processed timestamp column), `RK_UNDERLYING` (retained-key generation), `CLOSE_VARS`, and `AUDIT_LIBDS` (defaults to `&dclib..MPE_AUDIT`).
## Request Flow
```mermaid
flowchart TD
A[User submits changeset\neditors/stagedata.sas] --> B[Staging package written to\n&mpelocapprovals/&LOAD_REF\nCSV + jsdata]
B --> C[Approval workflow\nMPE_SUBMIT / MPE_REVIEW]
C --> D{auditors/postdata.sas}
D -->|action=SHOW_DIFFS| E["%mpe_targetloader(LOADTARGET=NO)\nbuilds work.outds_add / outds_mod / outds_del\nfor the diff screen only"]
D -->|action=APPROVE_TABLE| F["%mpe_targetloader(LOADTARGET=YES)\nactual load"]
E --> G[Diff CSV + TEMPDIFFS stored\nin approval package]
F --> H{LOADTYPE from\nMPE_TABLES}
H -->|UPDATE / TXTEMPORAL / BITEMPORAL| I["%bitemporal_dataloader"]
H -->|FORMAT_CAT| J["%mp_loadformat"]
H -->|REPLACE| K[Inline delete-all + append\nin %mpe_targetloader]
```
`%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`) is the single dispatch point. It reads the current `MPE_TABLES` record (`&dc_dttmtfmt. lt tx_to`), aborts if the table is not registered (or has duplicate config records), and routes to the loader. Note the two-phase design: `LOADTARGET=NO` prepares the intermediate `outds_*` tables so the approver can review diffs; `LOADTARGET=YES` performs the destructive load. Both phases run the same preparation logic, so the reviewed diffs correspond to what is actually applied.
## The Temporal Model
Bitemporal tables carry two independent time dimensions:
* **Business time** (`bus_from` / `bus_to`) — when the fact was true in the real world. Present on **both** staging and base tables.
* **Technical time** (`tech_from` / `tech_to`, a.k.a. transaction time) — when the record was known to the database. Present on the **base table only**; the loader stamps these itself.
All validity is expressed with **half-open intervals** (`from <= t < to`). Queries against bitemporal tables need two conditions and must not use `BETWEEN` or `from LE t LE to` (the latter excludes boundary records — see the macro header for background):
```sas
where &bus_from le [tstamp] lt &bus_to
and &tx_from le [tstamp] lt &tx_to
```
"Current" records have `tech_to` set to the high date (`'31DEC9999:23:59:59'dt` when called from `%mpe_targetloader`). Closing out a record means setting `tech_to = now` — records are never physically deleted from a temporal table, they are superseded.
## Key Components
| Component | Location | Role |
|---|---|---|
| `%mpe_targetloader` | `sas/sasjs/macros/mpe_targetloader.sas` | Reads `MPE_TABLES` config and dispatches per LOADTYPE; implements REPLACE inline |
| `%bitemporal_dataloader` | `sas/sasjs/macros/bitemporal_dataloader.sas` | Generic loader for UPDATE / TXTEMPORAL / BITEMPORAL |
| `%bitemporal_closeouts` | `sas/sasjs/macros/bitemporal_closeouts.sas` | Closes out (sets `tech_to=now`) live records matching a key |
| `%mp_retainedkey` | SASjs core | Generates retained (surrogate) keys when `RK_UNDERLYING` is configured |
| `%mp_rowhash` | SASjs core | MD5 hash of non-temporal columns, used for change detection |
| `%mp_storediffs` | SASjs core | Writes row-level audit records to `AUDIT_LIBDS` |
| `MPE_DATALOADS` | `&dclib` | Load log (counts, duration, macro version, user) — written only by `%bitemporal_dataloader` |
| `MPE_LOCKANYTABLE` | `&dclib` | Lock control table used by `%mp_lockanytable` |
## `%bitemporal_dataloader` Execution Flow
The macro builds a series of `work.bitemp*` intermediate tables, then applies the changes in two target-table operations (closeout, then append) under a single lock.
### 1. Pre-checks and setup
* Early return if the staging table is empty; hard abort if `&syscc > 0`.
* `CLOSE_VARS` is not supported on REDSHIFT / POSTGRES / SNOWFLAKE engines (returns with a NOTE).
* A zero-row snapshot of the base table (`&basecopy`) is taken with `data ... set base; stop;` — this doubles as a lock check, since metadata functions fail against a locked table.
* `proc contents` on the base table feeds column lists. Columns are split into character and numeric lists for hashing (`%mp_rowhash` hashes the two types via separate arrays). Temporal columns, the processed column, and the delete-flag column are excluded from the hash.
* Table names containing `___TMP___` or `_____` are rejected (they would collide with generated temp columns).
### 2. Locking
For `LOADTARGET=YES`, the base table (and audit table, if configured) is locked via `%mp_lockanytable` before any staging prep, because the load is a two-part update (closeouts + append) and must not interleave with another load.
### 3. Staging preparation (`work.bitemp0_append`)
* If `RK_UNDERLYING` is configured, `%mp_retainedkey` maps business keys to retained keys (filtering the base lookup to live records, `&now < tech_to`, for temporal types); otherwise the staging table is used as-is.
* `bus_from` / `bus_to` overrides are applied if provided; the processed column is stamped with the load timestamp (`now`). Even with `processed=0`, a column literally named `PROCESSED_DTTM` on the base table is used if present.
* The MD5 change-detection hash is computed for every staged record.
* If the staging table contains the delete-flag column (`_____DELETE__THIS__RECORD_____`), rows flagged `"Yes"` are diverted to `&outds_del` and closed out via `%bitemporal_closeouts` (PK is taken as `bus_from` + business key). The remaining rows continue as `bitemp0_append`.
### 4. CLOSE_VARS closeout
When `CLOSE_VARS` (a subset of the PK) is supplied, live base records whose CLOSE_VARS values appear in staging but whose full PK does **not** are closed out. This handles "this group was fully reloaded, so anything missing was removed" semantics without reloading the whole table.
### 5. Uniqueness check
If `CHECK_UNIQUENESS=YES` (the default) or business-date overrides are in play, the staging table is sorted `nodupkey` by the PK; a row-count mismatch aborts the load and releases the locks. The staging table must be a unique snapshot of the business key at one point in business time.
### 6. Base extract (`work.bitemp0_base`)
Only base records matching staged PKs are extracted — for temporal load types, only **currently live** records (`now < tech_to`). A left join from the staged keys to the base produces `___TMP___NEW_FLG` to identify brand-new keys. This is engine-specific:
* **OLEDB (SQL Server)** — staged keys are pushed to a `##global` temp table and joined via explicit pass-through.
* **REDSHIFT / POSTGRES / SNOWFLAKE** — an in-database temp table is created `like` the base, stripped to PK + an added `md5 varchar(32)` column, loaded with staged keys, and joined via pass-through. Snowflake uses transient tables; Redshift gets `alter sortkey none` plus any `DCBL_REDSH` config options from `MPE_CONFIG`.
* **CAS** — a FedSQL join against a CASUSER copy.
* **BASE/other** — plain PROC SQL in SAS.
### 7. Change classification
* **`&outds_add`** — staged records flagged as new (no base match). They get `tech_from=now`, `tech_to=high_date`.
* **`work.bitemp1_current`** — matched base records, re-hashed so hashes are comparable.
* **Inserts (BITEMPORAL only)** — a staged record whose business range falls strictly *inside* an existing record's range splits the existing record into a "before" and "after" segment (`bitemp3_inserts` / `bitemp3a_inserts`). The split segments replace the original in the comparison set (`bitemp3b_newbase`).
* **Updates (`bitemp4*`)** — staged records matching a base PK but with a different hash or different business dates. Base and staged versions are stacked, deduplicated, then aligned in **two passes** over the business timeline per key: a forward pass (carry `bus_from` forward across identical hashes; a staged record trims the preceding base record's range) and a reverse pass (carry `bus_to` back; records fully subsumed by the new version are deleted). Records that end up byte-identical to what is already stored are dropped via a hash lookup that includes the business dates (`bitemp5a_lkp` / `bitemp5b_updates`, BITEMPORAL only).
### 8. Closeout application
Changed records are closed out in the target before the new versions are appended. SAS SQL has no UPDATE-with-join, so a correlated `EXISTS` subquery against `work.bitemp5d_subquery` is used (pushed in-database as a temp table for OLEDB / REDSHIFT / POSTGRES / SNOWFLAKE):
* **BITEMPORAL** — per key, the closeout range is `min(bus_from)` / `max(bus_to)` of the changed records: `update base set tech_to=now (, processed=now) where tech_from <= now < tech_to and exists (key match and base.bus_from >= min and base.bus_to <= max)`.
* **TXTEMPORAL** — same, on key only.
* **UPDATE** — changed records are physically `delete`d (they are re-appended in the next step). On CAS this uses `table.deleteRows` with a whereTable; temporal types are not supported on CAS and abort.
* **BUSTEMPORAL** — closeouts are not implemented; the macro aborts at this point ("BUSTEMPORAL NOT YET SUPPORTED").
### 9. Append, unlock, audit, log
* The union of modified + new records, deduplicated on all columns (`bitemp6_unique`), is appended to the base table (`proc append ... force nowarn`; CAS appends via a varchar-casting step; Redshift applies `DCBL_REDSH` options). Locks are then released.
* If `outds_audit` is set (always, from `%mpe_targetloader``AUDIT_LIBDS` defaulting to `&dclib..MPE_AUDIT`), `%mp_storediffs` compares the pre-load snapshot with the applied changes and appends row-level audit records; modified-row entries with no actual value change are removed (`MOVE_TYPE="M" and IS_PK=0 and IS_DIFF=0`).
* A summary row is inserted into `&dclib..MPE_DATALOADS` (libref, dsn, etlsource, loadtype, changed/new/deleted counts, duration, macro version, user, timestamp) unless `LOG=0`.
## REPLACE Load Type
REPLACE is the simplest load type and is deliberately **not** routed through `%bitemporal_dataloader` — it is implemented inline in `%mpe_targetloader` and simply wipes the target table (`delete * from`) and re-appends the staging table verbatim, with no history, change detection, key matching, audit rows, or `MPE_DATALOADS` logging. See [replace-load-type.md](replace-load-type.md) for the full deep dive.
## Supporting Tables
| Table | Role |
|---|---|
| `MPE_TABLES` | Per-table load configuration (loadtype, keys, temporal vars, audit target) |
| `MPE_SELECTBOX` | Dropdown values, including the LOADTYPE list (seeded in `mpe_makedata.sas`) |
| `MPE_SUBMIT` / `MPE_REVIEW` | Approval workflow state |
| `MPE_LOADS` | Submission-level log of CSV package loads |
| `MPE_DATALOADS` | Per-load statistics (temporal/UPDATE loads only) |
| `MPE_AUDIT` (or `AUDIT_LIBDS`) | Row-level change audit (temporal/UPDATE/FORMAT_CAT loads only) |
| `MPE_LOCKANYTABLE` | Lock control table |
| `MPE_CONFIG` | Engine-specific config (e.g. `DCBL_REDSH` scope for Redshift bulk options) |
## Testing
Unit tests for the loaders live next to the macros: `sas/sasjs/macros/bitemporal_dataloader.test.[1-4].sas` and `sas/sasjs/macros/mpe_targetloader.test.sas` (REPLACE loadtype), executed as sasjs tests. See [testing.md](testing.md) for how to run them (`npm run 4gl` then `sasjs test -t 4gl` from the `sas/` directory), and remember `sasjs lint` after touching any `.sas` files.
+19
View File
@@ -0,0 +1,19 @@
# Dependency Updates Checklist
Whenever any `package.json` (root, `client/`, or `sas/`) or lockfile is modified, run the same checks CI runs before pushing:
1. **npm audit** (must be clean for prod deps):
```bash
npm audit --omit=dev # in repo root
cd sas && npm audit --omit=dev
cd ../client && npm audit --omit=dev
```
Fix with `npm audit fix`, targeted `overrides` in `package.json`, or version bumps — never `npm audit fix --force` blindly, as it can introduce breaking changes.
2. **License checker** (client only):
```bash
cd client && npm run license-checker
```
If a new dependency fails, either add its SPDX id to the `onlyAllow` list in `client/licenseChecker.js` (if the license is acceptable, e.g. permissive ones like `BlueOak-1.0.0`) or add the specific package to `excludePackages` with justification. Data Controller ships on-prem, so only OSI-approved permissive licenses are acceptable for production dependencies.
Both checks run in `.gitea/workflows/build.yaml` (`Check audit` and `Licence checker` steps) and will fail the build if skipped locally.
+86
View File
@@ -0,0 +1,86 @@
# Regex Validations (HARDREGEX / SOFTREGEX)
This document describes how the regex validation rules work internally. For user-facing documentation see `docs/dcc-validations.md` in the `docs.datacontroller.io` repo.
## Overview
Two validation rule types in `MPE_VALIDATIONS` validate cell values against a regular expression supplied in `RULE_VALUE`:
- `HARDREGEX` - submission-blocking. A failing value is rejected by the cell validator and painted red (HOT's own `htInvalid` class), so the row cannot be submitted.
- `SOFTREGEX` - display-only warning. A failing value is painted yellow (`dc-warning-cell` class) with a tooltip, but submission is not blocked.
Both rule types are selectable in the MPE_VALIDATIONS RULE_TYPE dropdown; they were added to the selectbox seed data in `sas/sasjs/macros/mpe_makedata.sas` and via the optional migration `sas/sasjs/db/migrations/20260720_v7.12_release.sas`. `RULE_VALUE` is limited to 128 characters, which constrains very long patterns.
## Config-time validation (SAS side)
`sas/sasjs/services/hooks/mpe_validations_postedit.sas` runs `prxparse()` on any staged HARDREGEX/SOFTREGEX rule value and aborts the edit with a list of offending `libref.table.column` references if the pattern is invalid. This is a best-effort syntax check to catch typos at config time; an empty pattern is treated as valid (it matches everything in JS). Rows marked for delete are skipped.
Because patterns must pass `prxparse`, rule values are authored in the SAS PRX delimiter form `/pattern/flags` (although a bare pattern is also accepted for backwards compatibility).
## Frontend evaluation
All regex handling lives in the client; there is no server-side re-validation of data values. The per-cell decision flow:
```mermaid
flowchart TD
A[Cell value] --> B{Row marked for delete<br/>and not a PK column?}
B -- Yes --> Z[No validation / no warning]
B -- No --> C{isRegexRuleExempt?<br/>blank, or "." on a numeric column}
C -- Yes --> Z
C -- No --> D{HARDREGEX rule on column?}
D -- Yes --> E{Pattern matches?}
E -- No --> F[Invalid: submission blocked,<br/>red htInvalid + REGEX tooltip]
E -- Yes --> J[Valid]
D -- No --> G{SOFTREGEX rule on column?}
G -- Yes --> H{Pattern matches?}
H -- No --> I[Warning: yellow dc-warning-cell<br/>+ REGEX tooltip, submission allowed]
H -- Yes --> J[Valid]
G -- No --> J
```
A malformed pattern never reaches this flow: it is treated as always-valid (HARDREGEX) / never-warn (SOFTREGEX) with a `console.warn`, rather than breaking the editor.
### `client/src/app/shared/dc-validator/utils/parseRegexRule.ts`
Converts an authored SAS PRX pattern into a JavaScript `RegExp`:
1. If the value matches `/^\/(.*)\/([a-z]*)$/s`, the delimiters are stripped and the trailing flags are passed to `new RegExp(body, flags)`. Otherwise the value is used as-is (backwards compatibility with bare patterns).
2. Three mechanical Perl→JS translations are applied to the body:
- a leading `(?i)` inline modifier is removed and folded into the `i` flag;
- `\Q...\E` literal sequences are replaced with escaped literal text;
- `\A``^` and `\z``(?![\s\S])` (end-of-string anchor).
3. Perl-only constructs that would need a capture-group-renumbering rewrite (atomic groups `(?>...)`, possessive quantifiers `a++`) are deliberately NOT translated. They throw from `new RegExp`, and every caller treats a throw as "always valid / never warn" (see below) rather than breaking the editor.
### `client/src/app/shared/dc-validator/utils/isRegexRuleExempt.ts`
Blank values (`undefined`, `null`, `''`) are exempt from pattern matching on any column type - use a separate NOTNULL rule if populated values must also be enforced. On numeric columns the plain SAS missing (`.`) is also exempt; special missings (`.A`-`.Z`, `._`, bare letters) are NOT exempt anywhere - being deliberately set, they are real values the pattern must match (and on a character column even `.` is real text). `isSpecialMissing` from `@sasjs/utils` is deliberately not used: its optional-dot regex would exempt any single-letter character value ("d", "z") before the regex ever ran. The check takes an `isNumeric` flag, passed by all callers (the dq validator via `dqValidate(rules, value, colType === 'numeric')`, the warning renderer via a `makeRegexWarningRenderer` argument, and `failsSoftRegex` via the column's HOT type).
### HARDREGEX - blocking validation
`HARDREGEX` is implemented as a cell validator in `client/src/app/shared/dc-validator/validations/dq-validation.ts`. It returns `true` (valid) for exempt values and for patterns that fail to compile (with a `console.warn`), and otherwise returns `parseRegexRule(ruleValue).test(value.toString())`. Returning `false` makes HOT mark the cell invalid, block submission, and paint it red via its standard `htInvalid` styling.
### SOFTREGEX - warning renderer
`client/src/app/editor/utils/regex-warning-renderer.ts` builds a display-only Handsontable renderer (registered per-column by `DcValidator.setupRules` in `client/src/app/shared/dc-validator/dc-validator.ts`). It never returns false; it only:
- adds a `REGEX: <pattern>` tooltip (`td.title`) when a rule fails;
- adds the yellow `dc-warning-cell` class when only SOFTREGEX fails.
`DcValidator.failsSoftRegex(col, value)` provides the same logic outside the grid (e.g. the edit-record screen).
### Precedence: HARD and SOFT on the same column
Only one regex ever runs per column. If a HARDREGEX rule exists, SOFTREGEX is ignored entirely - never compiled, never evaluated - regardless of whether individual cell values pass or fail the hard rule. A value failing HARDREGEX gets the red invalid styling (blocking submission) plus a `REGEX: <pattern>` tooltip; a SOFTREGEX-only column warns in yellow without blocking. This holds in both the renderer and `failsSoftRegex`.
### Other behaviour
- Rows marked for delete (`_____DELETE__THIS__RECORD_____ = 'Yes'`) are not warned/validated by the renderer (except primary key columns, which still validate).
- A malformed pattern never breaks the editor: the dq validator treats it as always-valid and the renderer disables the warning, logging to the console instead.
- The pattern is used as authored - it is NOT auto-anchored. Authors must include `^`/`$` to match the entire cell value.
- Column info: `client/src/app/shared/utils/col-info-html.ts` shows the applied pattern in the column-info dropdown - the HARDREGEX pattern if one exists (it is the rule actually applied when both are present), otherwise the SOFTREGEX pattern, otherwise nothing.
## Testing
- Unit tests: `parseRegexRule.spec.ts`, `isRegexRuleExempt.spec.ts`, `dq-validation.spec.ts`, `dc-validator.spec.ts` (under `client/src/app/shared/dc-validator/`), `client/src/app/editor/utils/regex-warning-renderer.spec.ts`, `client/src/app/shared/utils/col-info-html.spec.ts`.
- E2E: `client/cypress/e2e/editor.cy.ts`.
- SAS side: `sas/sasjs/services/editors/stagedata.test.3.sas`, plus seed data in `mpe_makedata.sas`: HARDREGEX "SOME_CHAR must contain 'the' or 'data'" (`/the|data/i`), SOFTREGEX "SOME_CHAR should contain the letter 't'", SOFTREGEX on PRIMARY_KEY_FIELD (`/^\d+$/` - yellow if the key contains a decimal), and HARDREGEX on SOME_SHORTNUM (`/^(?![1-5](\.\d+)?$).*/` - values 1-5 blocked; generated data starts at 6 so demos aren't blocked accidentally).
+97
View File
@@ -0,0 +1,97 @@
# REPLACE Load Type — Technical Deep Dive
This document describes the REPLACE load type in detail. For the overall loader architecture, the temporal model, and the other load types, see [bitemporal-dataloader.md](bitemporal-dataloader.md).
## Overview
REPLACE is the simplest load type and is deliberately **not** routed through `%bitemporal_dataloader`. It is implemented inline in `%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`, search for `&loadtype=REPLACE`). There is no history, no change detection, no key matching — the target table is wiped and reloaded from the staging table verbatim.
## Actual load (`LOADTARGET=YES`)
```sas
%mp_lockanytable(LOCK, lib=&lib, ds=&ds, ...)
data WORK.&STAGING_DS;
set WORK.&STAGING_DS;
/* only if the target contains the MPE_TABLES.VAR_PROCESSED variable: */
&VAR_PROCESSED = &now;
drop _____DELETE__THIS__RECORD_____;
run;
%if &engine_type=CAS %then %do;
/* prep first: cast varchar columns in a CASUSER copy of staging */
proc contents noprint data=&libds out=work.rpl_base_cols(keep=name type);
proc contents noprint data=WORK.&STAGING_DS out=work.rpl_stag_cols(keep=name type);
/* work.rpl_vchars = columns that are varchar in target AND in staging */
data casuser.&tmpds; /* temp copy with varchars casted */
length <varchar col> varchar(*); ...
set WORK.&STAGING_DS (rename=(<varchar col>=<tmp> ...));
<varchar col>=<tmp>; ... drop <tmp> ...;
run;
/* unlock + abort if any error so far - nothing destructive done yet */
%mp_abort(iftrue= (&syscc>0) ...)
/* destructive step, deliberately last before the append */
proc cas;
table.deleteRows / table={caslib="&lib",name="&ds",where="1=1"};
quit;
data &libds (append=yes) / sessref=dcsession;
set casuser.&tmpds;
run;
proc sql; drop table CASUSER.&tmpds; quit;
%end;
%else %do;
/* unlock + abort if any error so far - nothing destructive done yet */
%mp_abort(iftrue= (&syscc>0) ...)
proc sql;
delete * from &libds;
quit;
proc append base=&libds data=WORK.&STAGING_DS force nowarn; run;
%end;
%mp_lockanytable(UNLOCK, lib=&lib, ds=&ds, ...)
```
Step by step:
1. **Lock** — the target is locked via `%mp_lockanytable` (control table `&dclib..MPE_LOCKANYTABLE`). This is the only concurrency guard for the whole operation.
2. **Staging recopy** — the staging dataset is rewritten in place: the processed-timestamp column is set to the approval timestamp **only if** the target table contains the variable named by `MPE_TABLES.VAR_PROCESSED` (unlike `%bitemporal_dataloader`, there is no `PROCESSED_DTTM` fallback). The delete-flag column `_____DELETE__THIS__RECORD_____` is unconditionally dropped — per-record delete semantics do not exist in REPLACE (everything is deleted anyway), so a delete flag submitted by the user is silently discarded (a `drop` of a non-existent variable just produces a warning).
3. **Prepare the load (CAS only)** — fixed char variables cannot be appended to CAS varchar columns, so the staging table is first copied to a CASUSER temp table with every column that is varchar in the target (and present in staging) redeclared as `varchar(*)` via generated `length` / `rename` / assignment statements. This mirrors the CAS append in `%bitemporal_dataloader`.
4. **Pre-destructive abort check** — if `&syscc > 0` after all preparation, the target is unlocked and the macro aborts via `%mp_abort`. Nothing destructive has happened at this point, so a failed prep leaves the target intact (and without a stale lock).
5. **Delete all rows** — every row is removed from the target while preserving structure, indexes and metadata, deliberately as the last step before the append (to minimise the time the target sits empty). On most engines this is `proc sql; delete * from &libds;` (passed through as a `DELETE` on database libraries). CAS tables do not support SQL deletes, so on the CAS engine the table is truncated instead via the `table.deleteRows` action with `where="1=1"` (same approach as `%bitemporal_closeouts`), with the libref passed as the caslib.
6. **Append staged rows** — the entire staging table is appended. On most engines this is `proc append ... force nowarn` (`force` allows the append to proceed despite attribute mismatches, so lengths/formats may be coerced or values truncated, and columns present in only one side are handled; `nowarn` suppresses the associated warnings). On CAS the pre-cast CASUSER temp table is appended with `data &libds (append=yes) / sessref=dcsession` and then dropped.
7. **Unlock**.
## Diff screen (`LOADTARGET=NO`)
When the approver reviews a REPLACE submission, no real comparison is performed:
```sas
/* is full replace so treat all staged records as new in diff screen */
data work.outds_mod work.outds_add;
set work.&staging_ds;
output work.outds_add; /* every staged record is "NEW" */
run; /* outds_mod stays empty */
/* previous table will be considered fully deleted */
data work.outds_del;
set &lib..&ds; /* every existing record is "DELETED" */
run;
```
The approval screen therefore always shows full-table turnover: the entire current table as deleted and the entire staging table as added, with no "modified" records and no original/current comparison.
## What REPLACE does not do
Because it bypasses `%bitemporal_dataloader`, none of the following apply to REPLACE:
* **No row-level audit** — nothing is written to `AUDIT_LIBDS` / `MPE_AUDIT`. The record of the change is the approval package itself (staged CSV and the `TEMPDIFFS` CSV stored under `&mpelocapprovals/&LOAD_REF`).
* **No `MPE_DATALOADS` log entry** — the load-log insert lives inside `%bitemporal_dataloader`, so REPLACE loads do not appear in load history. (Consequently the SHOW_DIFFS timestamp lookup in `postdata.sas` finds no `MPE_DATALOADS` row for a REPLACE load and falls back to the current datetime.)
* **No PK usage** — `BUSKEY` is ignored: no uniqueness check, no dedup, no join to existing data. Staged rows are loaded exactly as submitted, duplicates included.
* **No temporal handling** — `VAR_TXFROM`/`VAR_TXTO`/`VAR_BUSFROM`/`VAR_BUSTO` and `CLOSE_VARS` are ignored. A REPLACE table should not be treated as temporal; querying it with the usual validity filters makes no sense.
* **No retained-key handling** — `RK_UNDERLYING` is ignored.
* **Minimal engine-specific handling** — unlike the temporal loaders, there is no pass-through/temp-table optimisation for OLEDB, Redshift, Postgres or Snowflake. The only engine conditional is CAS, where rows are removed via the `deleteRows` action and appended via a varchar-casting CASUSER temp table and a data-step append (SQL deletes and fixed-char-to-varchar appends are not possible on CAS).
* **Not atomic** — the delete/truncate and append are separate steps with no transaction or rollback, so a session failure between them still leaves the target empty or partially loaded (recovery is a re-approval of the same or a previous staging package). The risk is mitigated by performing all preparation first, aborting on any error before the destructive step, and executing the delete/truncate immediately before the append.
## Interaction with Row Level Security
REPLACE is incompatible with `EDIT`-scope RLS rules (a full-table wipe cannot honour row-level write restrictions); this is enforced at edit time in both directions — see [row-level-security.md](row-level-security.md#incompatibility-with-replace-load-type) and [issue #211](https://git.datacontroller.io/dc/dc/issues/211). `VIEW`-scope rules remain compatible.
+225
View File
@@ -0,0 +1,225 @@
# Row Level Security — Technical Deep Dive
This document explains **how** Row Level Security (RLS) is implemented in the Data Controller backend. For the user-facing configuration guide, see [docs.datacontroller.io/row-level-security](https://docs.datacontroller.io/row-level-security/).
## Overview
RLS in Data Controller is implemented as **server-side WHERE clause generation**. No data leaves SAS without passing through a dynamically generated filter. The filter is built at runtime per request, based on:
1. The requesting user's group memberships (SAS metadata groups + DC groups)
2. The active rules in the `MPE_ROW_LEVEL_SECURITY` control table
3. The access mode (VIEW / EDIT / download / upload)
Because the filter is expressed as a standard SAS `WHERE` expression, it works against **any** engine — Base SAS datasets, database libraries (via implicit SQL pass-through pushdown), SPDE, CAS libnames, etc.
## Request Flow
```mermaid
flowchart TD
A[Client request\nview / edit / download / upload] --> B{Service mode}
B -->|VIEW / EDIT / DLOAD| C["%mpe_filtermaster(mode, libds)"]
B -->|ULOAD stagedata.sas| C
C --> D["%mpe_getgroups()\nmetadata groups + MPE_GROUPS"]
D --> E{User in &mpeadmins?}
E -->|Yes| F[No RLS filter\n1=1]
E -->|No| G[Lookup active rules in\nMPE_ROW_LEVEL_SECURITY\nfor libref.table + user's groups]
G --> H{Rules found?}
H -->|No| F
H -->|Yes| I["%mp_filtergenerate()\nper group, OR'd together"]
I --> J[WHERE clause written\nto temp fileref]
F --> J
J --> K{Read or Write?}
K -->|Read\nviewdata / getdata / getrawdata| L["where %inc filtref;;\nrows filtered server-side"]
K -->|Write\nstagedata| M[Inverse filter\nwhere not( filtref )]
M --> N{badrecords > 0?}
N -->|Yes| O[Abort submission\nSecurity Problem]
N -->|No| P[Staging proceeds\nto approval workflow]
```
## Key Components
| Component | Location | Role |
|---|---|---|
| `MPE_ROW_LEVEL_SECURITY` | `&mpelib` (DC control library) | The rule table (scope, group, libref, table, logic, subgroup, variable, operator, raw value, active flag) |
| `%mpe_filtermaster()` | `sas/sasjs/macros/mpe_filtermaster.sas` | Master macro that assembles the full WHERE clause for a request |
| `%mpe_getgroups()` | `sas/sasjs/macros/mpe_getgroups.sas` | Resolves group membership (metadata groups via `%dc_getusergroups` + `MPE_GROUPS` DC-internal groups) |
| `%mp_filtergenerate()` | SASjs core | Converts a query table (logic/subgroup/variable/operator/value rows) into WHERE clause text |
| `%mp_filtercheck()` | SASjs core | Validates rule syntax at *edit time* (defence against SAS code injection) |
| `mpe_row_level_security_postedit.sas` | `sas/sasjs/services/hooks/` | Post-edit hook that runs `%mp_filtercheck` whenever the RLS table itself is edited |
## The Modes
`%mpe_filtermaster` accepts a `mode` parameter, and every service that surfaces data calls it with the appropriate mode:
| Mode | Caller | Purpose |
|---|---|---|
| `VIEW` | `services/public/viewdata.sas`, `getchangeinfo.sas` | Read-only table viewer |
| `EDIT` | `services/editors/getdata.sas` | The EDIT grid (adds "current records only" validity filtering) |
| `DLOAD` | `services/public/getrawdata.sas` | Raw file downloads (RLS scope treated as `VIEW`) |
| `ULOAD` | `services/editors/stagedata.sas` | **Upload validation** (RLS scope treated as `EDIT`) |
Scope mapping: `DLOAD` requests match rules with `RLS_SCOPE in ('VIEW','ALL')`; `ULOAD` requests match rules with `RLS_SCOPE in ('EDIT','ALL')`.
## Execution Flow of `%mpe_filtermaster`
The macro writes the final WHERE expression to a temporary **fileref** (`outref`), line by line. (A fileref is used because a generated filter may exceed the 64k macro variable limit — and note that `%include` of a fileref is not allowed directly in a `proc sql` where clause, hence callers typically use it in a DATA step or data step view.)
### 1. User-supplied filter (FILTER_RK)
If the request includes a stored filter (`filter_rk > 0`), its clauses are read from `MPE_FILTERANYTABLE` / `MPE_FILTERSOURCE` and generated first via `%mp_filtergenerate`. RLS clauses are then **AND-ed on top** — a user filter can only ever narrow results, never widen them beyond RLS.
### 2. Validity-date filtering (EDIT / DLOAD only)
`MPE_TABLES` may define `VAR_TXFROM` / `VAR_TXTO` (SCD2-style validity variables) for the target table. Unless the user explicitly filtered on those variables, the macro appends:
```
("<current datetime>"dt < VAR_TXTO)
```
so that only current records are surfaced in the EDIT grid and downloads.
### 3. Group resolution and admin bypass
```sas
%mpe_getgroups(user=%mf_getuser(), outds=work.groups)
```
Groups come from two sources, concatenated:
* SAS metadata groups (Viya / EBI / Base-specific logic in `%dc_getusergroups`)
* The `MPE_GROUPS` DC table (group assignments managed inside Data Controller)
If the user is a member of the `&mpeadmins` group, **RLS is skipped entirely** — admins always see all rows.
### 4. Rule extraction
Non-admin users trigger a lookup of active, current rules:
```sas
create table work.&rlsds as
select rls_group, rls_group_logic, rls_subgroup_logic, rls_subgroup_id,
rls_variable_nm, rls_operator_nm, rls_raw_value
from &mpelib..mpe_row_level_security
where &dc_dttmtfmt. lt tx_to /* only current (non-deleted) rules */
and rls_scope in ("&scopeval",'ALL')
and upcase(rls_group) in (select upcase(groupname) from work.groups)
and rls_libref = "<libref>" and rls_table = "<dsname>"
and rls_active = 1
order by rls_group, rls_subgroup_id;
```
### 5. Clause assembly
If rules exist, they are appended to the fileref as `AND ( ... )`. Each **group** the user belongs to contributes one sub-filter, and the group-level sub-filters are joined with `OR`:
```
AND ( <group 1 filter> OR <group 2 filter> ... )
```
Each group's filter is produced by `%mp_filtergenerate`, which honours:
* `RLS_GROUP_LOGIC` — how subgroups (identified by `RLS_SUBGROUP_ID`) are joined
* `RLS_SUBGROUP_LOGIC` — how individual clauses within a subgroup are joined (AND/OR)
So membership in multiple groups is **permissive** (OR): the user sees the union of rows permitted by each of their groups.
### 6. Empty-filter fallback
If nothing was written to the fileref (no stored filter, no validity vars, no matching RLS rules), the macro writes a literal `1=1` so the fileref can always be consumed as a valid WHERE expression. Absence of rules therefore means **no restriction** — RLS is opt-in per table/group.
## How Callers Apply the Filter
### Read path (viewdata.sas)
```sas
%mpe_filtermaster(VIEW, &libds, dclib=&mpelib, filter_rk=&filter_rk,
outref=filtref, outds=work.query)
data work.viewdata;
set &libds;
where %inc filtref;;
if _n_ > &DC_MAXOBS_WEBVIEW then stop;
run;
```
The fileref is `%include`d directly inside the `where` statement — the filter never passes through client-visible state, so it cannot be tampered with. For database libraries the WHERE expression is pushed down to the database by the SAS engine.
### Write path (stagedata.sas) — inverse filter
Uploads cannot be filtered; instead the filter is **inverted** and any submitted row matching the inverse is rejected:
```sas
%mpe_filtermaster(ULOAD, &libds, dclib=&mpelib, outref=filtref, ...)
/* prepare inverse query */
data _null_;
infile filtref end=eof;
file &tempref;
if _n_=1 then put 'where not(';
input; put _infile_;
if eof then put ')';
run;
data work.badrecords;
set work.jsdata; /* rows submitted by the user */
%inc &tempref/source2;;
run;
%mp_abort(iftrue=(%mf_nobs(work.badrecords)>0)
,msg=%str(Security Problem - N unauthorised records submitted))
```
If even one submitted row falls outside the user's permitted row set, the entire staging request is aborted before any approval/apply step.
## Incompatibility with REPLACE Load Type
RLS with `EDIT` scope is **incompatible** with tables configured with `LOAD_TYPE=REPLACE` in `MPE_TABLES`. A REPLACE load wipes and reloads the entire target table, so row-level filtering of submitted records cannot be enforced meaningfully (the rows a user is *not* allowed to see would also be deleted). Backend validations therefore abort in both directions (see [issue #211](https://git.datacontroller.io/dc/dc/issues/211)):
1. **`mpe_row_level_security_postedit.sas`** — aborts when activating a rule with `RLS_SCOPE in ('EDIT','ALL')` against a table whose current `MPE_TABLES` record has `LOADTYPE='REPLACE'`.
2. **`mpe_tables_postedit.sas`** — aborts when setting `LOADTYPE='REPLACE'` on a table that already has active, current `RLS_SCOPE in ('EDIT','ALL')` rules in `MPE_ROW_LEVEL_SECURITY`.
`VIEW`-scope rules remain compatible with REPLACE loads, since they only affect read paths.
## Edit-Time Validation (Injection Defence)
Because `RLS_RAW_VALUE` is free text that ends up inside a generated WHERE clause, it is a potential SAS code-injection vector. Mitigations:
1. **`mpe_row_level_security_postedit.sas`** — a post-edit hook on the `MPE_ROW_LEVEL_SECURITY` table itself. Every newly staged rule (with `rls_active=1`) is grouped by target `libref.table` and run through `%mp_filtercheck(targetds=..., abort=YES)`, which compiles/tests each clause against the real target table and rejects the whole submission on invalid syntax (see [mp_filtercheck](https://core.sasjs.io/mp__filtercheck_8sas.html)).
2. **Format rules** enforced by validation: character values must be single quoted, `IN`/`NOT IN` values must be bracketed, `BETWEEN` must contain `AND`.
Additionally, editing `MPE_ROW_LEVEL_SECURITY` is itself a Data Controller table edit, so it goes through the normal approval workflow, audit trail (`MPE_AUDIT`), and can be column-restricted via [Column Level Security](https://docs.datacontroller.io/column-level-security/).
## Worked Example
Given these active rules (all for `MYLIB.MYDS`, `RLS_ACTIVE=1`):
| SCOPE | GROUP | GROUP_LOGIC | SUBGRP_LOGIC | SUBGRP_ID | VAR | OP | VALUE |
|---|---|---|---|---|---|---|---|
| ALL | Group 1 | AND | AND | 1 | VAR_2 | IN | ('this','or') |
| ALL | Group 1 | AND | AND | 1 | VAR_3 | < | 42 |
| ALL | Group 2 | AND | AND | 1 | VAR_4 | CONTAINS | 'xyz' |
A non-admin user in **both** groups, opening the VIEW page, gets a fileref containing:
```
AND ( (VAR_2 IN ('this','or') AND VAR_3 < 42) OR (VAR_4 CONTAINS 'xyz') )
```
resulting in:
```sas
data work.viewdata;
set mylib.myds;
where (VAR_2 IN ('this','or') AND VAR_3 < 42) OR (VAR_4 CONTAINS 'xyz');
run;
```
The same user submitting an EDIT upload has the inverse applied to their staged rows; any row not matching the expression above aborts the submission.
## Summary of Security Properties
* **Enforced server-side** in every read service (VIEW/EDIT/DLOAD) and every write service (ULOAD via inverse filter).
* **Admin bypass** is explicit (`&mpeadmins` group membership check).
* **Multi-group semantics are OR** (union of permitted rows); rules within a group/subgroup are AND/OR per configuration.
* **Fail-open by design**: if no rules match a table, it is unrestricted — RLS must be opted into per table.
* **Injection-resistant**: values are validated with `%mp_filtercheck` at the time rules are edited, not at query time.
+21
View File
@@ -0,0 +1,21 @@
# Testing
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory.
## Commands
- Full deploy + test cycle: `npm run 4gl` (compiles, deploys to the 4gl target, runs makedata) then `sasjs test -t 4gl`.
- Run tests only (no rebuild/redeploy): `sasjs test -t 4gl`.
- Run a subset of tests: `sasjs test -t 4gl SOMESTRING` — the positional argument filters to matching tests (e.g. `sasjs test -t 4gl stagedata.test.3`).
## Results
- Test results: `sas/sasjsresults/testResults.json` / `.csv` / `.xml`
- Individual test logs: `sas/sasjsresults/logs/<testsuite>.log` (e.g. `services_editors_stagedata.test.3.log`)
- Coverage: `sas/sasjsresults/coverage.lcov`
## Notes
- Test source files live in `sas/sasjs/**`; `sas/sasjsbuild/` is generated build output — do not hand-edit it.
- `sasjs test` executes tests against the deployed app, so run `npm run 4gl` first after changing any service/hook/test code.
- Assertions are made with `%mp_assert()`; results are written to `work.test_results`.
+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.
+5 -5
View File
@@ -38,13 +38,13 @@ jobs:
npm ci npm ci
- name: Check audit - name: Check audit
# Audit should fail and stop the CI if critical vulnerability found # Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
run: | run: |
npm audit --audit-level=critical --omit=dev npm audit --omit=dev
cd ./sas cd ./sas
npm audit --audit-level=critical --omit=dev npm audit --omit=dev
cd ../client cd ../client
npm audit --audit-level=critical --omit=dev npm audit --omit=dev
- name: Lint check - name: Lint check
run: npm run lint:check run: npm run lint:check
@@ -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" 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()
+40 -17
View File
@@ -45,13 +45,13 @@ jobs:
npm ci npm ci
- name: Check audit - name: Check audit
# Audit should fail and stop the CI if critical vulnerability found # Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
run: | run: |
npm audit --audit-level=critical --omit=dev npm audit --omit=dev
cd ./sas cd ./sas
npm audit --audit-level=critical --omit=dev npm audit --omit=dev
cd ../client cd ../client
npm audit --audit-level=critical --omit=dev npm audit --omit=dev
- name: Angular Tests - name: Angular Tests
run: | run: |
@@ -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" 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()
@@ -311,17 +311,40 @@ jobs:
surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* / surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* /
- name: Upload assets to release - name: Upload assets to release
env:
# Use the same ephemeral per-job Gitea Actions token that the
# "Create Empty Release" step uses for semantic-release. It is scoped
# to this repo and is granted write access via the workflow's
# `permissions:` block at the top of the file (see contents: write).
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
RELEASE_ID=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.id'` set -euo pipefail
RELEASE_BODY=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.body'` # Send the token via Authorization header rather than ?access_token=
# Update body # (the query-string form is deprecated and leaks into access logs).
curl --data '{"draft": false,"body":"'"$RELEASE_BODY\n\nFor installation instructions, please visit https://docs.datacontroller.io/"'"}' -X PATCH --header 'Content-Type: application/json' -k https://git.datacontroller.io/api/v1/repos/dc/dc/releases/$RELEASE_ID?access_token=${{ secrets.RELEASE_TOKEN }} AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
RELEASE_JSON=$(curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
# Update body (also confirms the token has contents:write on this repo)
curl -k --fail-with-body -sS -X PATCH \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg body "$RELEASE_BODY"$'\n\nFor installation instructions, please visit https://docs.datacontroller.io/' \
'{draft:false, body:$body}')" \
"$BASE/releases/$RELEASE_ID"
# Upload assets # Upload assets
URL="https://git.datacontroller.io/api/v1/repos/dc/dc/releases/$RELEASE_ID/assets?access_token=${{ secrets.RELEASE_TOKEN }}" URL="$BASE/releases/$RELEASE_ID/assets"
curl -k $URL -F attachment=@frontend.zip for f in frontend.zip \
curl -k $URL -F attachment=@sas/demostream_sas9.sas sas/demostream_sas9.sas \
curl -k $URL -F attachment=@sas/viya.sas sas/viya.sas \
curl -k $URL -F attachment=@sas/sasjs_server.json.zip sas/sasjs_server.json.zip \
curl -k $URL -F attachment=@sas/sas9.sas sas/sas9.sas \
curl -k $URL -F attachment=@sas/viya_noweb.sas sas/viya_noweb.sas \
curl -k $URL -F attachment=@sas/viya_noweb.json sas/viya_noweb.json; do
echo "Uploading $f ..."
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
done
+1
View File
@@ -1,5 +1,6 @@
{ {
"cSpell.words": [ "cSpell.words": [
"Handsontable",
"Licence", "Licence",
"SYSERRORTEXT", "SYSERRORTEXT",
"SYSWARNINGTEXT", "SYSWARNINGTEXT",
+32
View File
@@ -0,0 +1,32 @@
# Agent Instructions
## 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.
## Markdown files
Do NOT hard-wrap Markdown files (no fixed-width line wrapping / carriage returns inside paragraphs). Each paragraph, list item, and heading should be a single logical line, regardless of length. Let the editor/viewer soft-wrap. This includes AGENTS.md itself.
This is different from `.sas` files, where a maximum line length applies. The no-wrap rule applies to all `*.md` files in this repo (docs, READMEs, etc.).
Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and reflowed, and Markdown renderers already handle wrapping.
## Linting (required before "done")
Never consider a change complete until the relevant linters pass on the files you touched — do not rely on the user's pre-commit hooks to catch it:
- **Client (TypeScript/HTML/etc.)**: run `npm run lint:check` from the `client/` directory (prettier). Fix any failures with `npm run lint:fix`.
- **SAS**: after creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored).
## No external assets
Data Controller must run entirely locally (offline / on-prem, no internet access). The built product must never fetch assets from remote servers — no external fonts, images, scripts, stylesheets, CDN links, or remote URLs in meta tags (e.g. `og:image`, `og:url`, `itemprop="image"`). All assets must be bundled and served locally.
## The .agent folder
Agent-related content lives in `.agent/`: technical/agent-facing documentation goes in `.agent/docs/` (not `docs/`), and skills in `.agent/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agent/docs/`.
## Code comments and test names
Never reference items that are not active parts of the repository — no "the original bug", "regression from this fix", "this session/PR/commit", or similar ephemeral context. Comments and test names must be self-contained: describe the behaviour being asserted, not the history of how it was discovered. The one exception is a literal link to a ticket/issue tracker.
+96
View File
@@ -1,3 +1,99 @@
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)
### Bug Fixes
* adding REGEX validations to mpe_x_test ([bd798b4](https://git.datacontroller.io/dc/dc/commit/bd798b424a7c4b90d613425348d3f99eeb49b025))
* default value for label ([a3e46a9](https://git.datacontroller.io/dc/dc/commit/a3e46a968ef08295347b230235f1e79dfa51c5a2))
* **deps:** retarget Angular upgrade to 20, not 21 (CI install was broken) ([cac9244](https://git.datacontroller.io/dc/dc/commit/cac9244f92544f432078125c312d05c36ed4b855))
* ensure only one REGEX applies at a time ([8fb58eb](https://git.datacontroller.io/dc/dc/commit/8fb58eb36e05fdf7262459b05e53643b3ca672f7))
* ensure that no assets (including og links) ever fetch from external sources ([33dcb98](https://git.datacontroller.io/dc/dc/commit/33dcb989d3ae0c5ee0a13fe64195862a945c4348))
* include peer dependencies in package-lock for npm ci in pipeline ([b51c770](https://git.datacontroller.io/dc/dc/commit/b51c770782a8b62816e5145496dbea9fb83059e0))
* licensecheker ([f60bcef](https://git.datacontroller.io/dc/dc/commit/f60bcef58381ba269415640fa19a27305274939a))
* **lint:** remove redundant optional chaining ([d881290](https://git.datacontroller.io/dc/dc/commit/d881290618f25ddb6335125f1a08b5aaf56a8c48))
* optimisation, renamed values for DDTYPE to save space ([cfb60e5](https://git.datacontroller.io/dc/dc/commit/cfb60e5e4bfd37dc79fa4e9a6c6649ab1a61f2e4))
* patch npm audit vulnerabilities in sas and client dependencies ([e22edf7](https://git.datacontroller.io/dc/dc/commit/e22edf7ed3aa54fc08411cf90d51df9ec30877c3))
* **query:** isolate viewbox filter state from the base table's ([7a35cf4](https://git.datacontroller.io/dc/dc/commit/7a35cf4a458791b26ec8141c0c84d97fcb555145))
* regenerate client lockfile to resolve Angular peer-dependency drift breaking npm ci ([05fe474](https://git.datacontroller.io/dc/dc/commit/05fe4744d58e7985ecc5ef5f9c1969dbab2d2efb))
* **regex:** special missing handling ([180c247](https://git.datacontroller.io/dc/dc/commit/180c2477ed9ae44b4e479e81ec642f40580bcbc8))
* removing low severity warning in npm audit ([2a771bb](https://git.datacontroller.io/dc/dc/commit/2a771bb91acaaa16aba44e2d7ddb51b8b7123c10))
* removing thousand seperator from plain numerics in EDIT mode ([0392a81](https://git.datacontroller.io/dc/dc/commit/0392a81cbd9a634590ca7a60a316cd12c690a5a5))
* **validations:** parse SAS PRX /pattern/flags syntax in HARDREGEX/SOFTREGEX ([7ed3730](https://git.datacontroller.io/dc/dc/commit/7ed3730ae3145dffd639ce3d2f831447d78f4e85))
### Features
* **docs:** adding agents.md and docs for RLS ([359d833](https://git.datacontroller.io/dc/dc/commit/359d833406ace79f96d03c08a5c72fbd3df29442))
* **editor:** add HARDREGEX/SOFTREGEX validation rules ([17e4802](https://git.datacontroller.io/dc/dc/commit/17e48028955d7b091e53bad9d7f9e3a089b53af9))
* **editor:** evaluate HARDREGEX/SOFTREGEX independently instead of hard-wins precedence ([57db117](https://git.datacontroller.io/dc/dc/commit/57db1179a97973ecf0f711bda6057383de546dd3))
* **editor:** show applied HARDREGEX/SOFTREGEX pattern in column info dropdown ([39c8855](https://git.datacontroller.io/dc/dc/commit/39c8855f37477f06d8b8cf5c97e04543070e8385))
* **regex:** backend validations on regex strings ([d2c93a4](https://git.datacontroller.io/dc/dc/commit/d2c93a46facb386fd31661c02c0d3d34f38f6e43))
* using ALL libraries as validation in MPE_SECURITY. Closes [#279](https://git.datacontroller.io/dc/dc/issues/279) ([62ff0ae](https://git.datacontroller.io/dc/dc/commit/62ff0aee4a976184de65d87ea3c8b8b7cb777938))
* validation checks to prevent incompatible RLS rules (eg REPLACE load type). Closes [#211](https://git.datacontroller.io/dc/dc/issues/211) ([ea00c5a](https://git.datacontroller.io/dc/dc/commit/ea00c5afad0daf2a66cdb206f2a7481b992192bf))
* validation on RLS for REPLACE, + docs + tests. Closes [#211](https://git.datacontroller.io/dc/dc/issues/211) ([7378f3b](https://git.datacontroller.io/dc/dc/commit/7378f3ba3014141008b4dbde0b89c21a6ce02f69))
# [7.11.0](https://git.datacontroller.io/dc/dc/compare/v7.10.1...v7.11.0) (2026-07-20)
### Bug Fixes
* audit-skipping syntax correction ([ad7efc9](https://git.datacontroller.io/dc/dc/commit/ad7efc90bc33c93383e17359d14c0605cded69a9))
* **editor:** ignore cell validation on rows marked for delete ([70c5805](https://git.datacontroller.io/dc/dc/commit/70c5805743c8c6e61bc216f37cbe8cd4f0614c98))
* explicit use of .sashdat in dc_casload ([6a95067](https://git.datacontroller.io/dc/dc/commit/6a95067618600d7b03b6af46bf3d74721e42c66a))
* refactor mf_wordsinstr1butnotstr2 and added a wide table to test suite ([be94c68](https://git.datacontroller.io/dc/dc/commit/be94c68f354d0f9b1eb52213807d1874a74d9b8a))
* retry the startup service request once before failing ([5391df8](https://git.datacontroller.io/dc/dc/commit/5391df8f04cf40205a600b23c207f97b6fdf5913))
* run dc_casload for dq tables in getdata. Closes [#264](https://git.datacontroller.io/dc/dc/issues/264). ([6b16f9b](https://git.datacontroller.io/dc/dc/commit/6b16f9b3647fca2e39d44544201ce972046e01a3))
* updating tests, moving mpe_rowhash to core library ([c5f0e4f](https://git.datacontroller.io/dc/dc/commit/c5f0e4f106aa4e6a7c2a2fd0ebb2e85d1e7bee7b))
### Features
* bumping core to get the mp_rowhash definition, [#269](https://git.datacontroller.io/dc/dc/issues/269) ([f73f5e4](https://git.datacontroller.io/dc/dc/commit/f73f5e47ac38a305bac4ebd7eac48d1a3edf4a83))
* new hash algorithm - allows 1000+ cols. Closes [#269](https://git.datacontroller.io/dc/dc/issues/269) ([08947f0](https://git.datacontroller.io/dc/dc/commit/08947f0fc8458be098e3aaf769b1da7fadf480cb))
## [7.10.1](https://git.datacontroller.io/dc/dc/compare/v7.10.0...v7.10.1) (2026-07-15)
### Bug Fixes
* **editor:** order validation rules by COLHEADERS, not cols[].VARNUM ([4c2aca3](https://git.datacontroller.io/dc/dc/commit/4c2aca34951d6d5c5cf2df69d5bf4256a9290d53))
# [7.10.0](https://git.datacontroller.io/dc/dc/compare/v7.9.1...v7.10.0) (2026-07-13)
### Bug Fixes
* adapt column validation to vertical-array COLTYPE ([#253](https://git.datacontroller.io/dc/dc/issues/253)) ([3016750](https://git.datacontroller.io/dc/dc/commit/301675052fb5cbcb11f54c370ad7eda1d60461fd))
* backend updates to bring back labels under [#240](https://git.datacontroller.io/dc/dc/issues/240) ([fb94840](https://git.datacontroller.io/dc/dc/commit/fb94840016f067fa3523a3539dd0616d8970eed0))
* **editor:** normalise unpadded time strings on spreadsheet import ([7b4b4eb](https://git.datacontroller.io/dc/dc/commit/7b4b4ebeeb0e34ec5fcd5c485741d7bbddb43b7e))
* **hot:** row rendering on resize, resolve blank whitespace ([c7ba025](https://git.datacontroller.io/dc/dc/commit/c7ba025d39aa9681ad51f239503e884fb26613e2))
* including datadictionary descs in labels, [#240](https://git.datacontroller.io/dc/dc/issues/240) ([e7453a9](https://git.datacontroller.io/dc/dc/commit/e7453a9305263faf4d4f288d50977121353040c7))
* pass variable formats to front-end in a vertical array instead of horizontal list ([691d6f2](https://git.datacontroller.io/dc/dc/commit/691d6f277ea87051f539681b34da2f39cade8fc3))
* remove coltype from sasparams table, add it to cols table. ([8101400](https://git.datacontroller.io/dc/dc/commit/81014001ac4da6b8d262c72c65e0c657f186ec84))
* trim leading/trailing whitespace from Excel header cells on upload ([2728dac](https://git.datacontroller.io/dc/dc/commit/2728dac873ab8c9fc44413f873e4730515ca0e21))
* **va:** date filters refactor, support more dates ([df5c975](https://git.datacontroller.io/dc/dc/commit/df5c9758697d7481e67d20ed7ff81e9ce4e92ec0))
* **va:** enable readOnly and hold filtering while editing ([82a254d](https://git.datacontroller.io/dc/dc/commit/82a254d22cb7aaf75aae7b332f0b07fc8bda3e44))
* **va:** enable Upload button in va mode ([1beb3d4](https://git.datacontroller.io/dc/dc/commit/1beb3d490d0e7b8d5fa7985f121495ed62740cfe))
* **va:** guard deferred contextMenu toggle against destroyed instance ([b5e9b25](https://git.datacontroller.io/dc/dc/commit/b5e9b2531924bec1a23c67d3ad0ca3f512a75655))
### Features
* adding DC_MAXOBS_WEBVIEW config item, closes [#258](https://git.datacontroller.io/dc/dc/issues/258) ([ba7b610](https://git.datacontroller.io/dc/dc/commit/ba7b61082d39767f9539a84de6742a1704573e81))
* viewer/editor column label display toggle (?labels=true) ([25c12f2](https://git.datacontroller.io/dc/dc/commit/25c12f2b18adda09e6edc93eba263bd5ea0f7409))
### Reverts
* enable upload button in va mode ([0d7fd34](https://git.datacontroller.io/dc/dc/commit/0d7fd342970744489a2e6c6fb08f239fe853cb81))
## [7.9.1](https://git.datacontroller.io/dc/dc/compare/v7.9.0...v7.9.1) (2026-06-30)
### Bug Fixes
* workflow file upload using action PAT ([a161670](https://git.datacontroller.io/dc/dc/commit/a161670b8656614f61d42ee673064ba8f010b71c))
# [7.9.0](https://git.datacontroller.io/dc/dc/compare/v7.8.2...v7.9.0) (2026-06-29) # [7.9.0](https://git.datacontroller.io/dc/dc/compare/v7.8.2...v7.9.0) (2026-06-29)
+24
View File
@@ -16,6 +16,21 @@ Add `client/.npmrc` file with following content:
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE" //pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
``` ```
In addition to the registry auth, the `@sheet/crypto` package is installed from a local tarball
(`client/libraries/sheet-crypto.tgz`) rather than the registry. This tarball is not committed to
the repo directly - only an encrypted copy is: `client/libraries/sheet-crypto.tgz.gpg`.
Before running `npm i`/`npm ci` in `client`, you must decrypt it using the passphrase
(stored as the `SHEET_PWD` CI secret - ask a repo maintainer if you don't have it):
```bash
cd client
echo "YOUR_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
npm i
```
[Handsontable](https://www.npmjs.com/package/handsontable) [Handsontable](https://www.npmjs.com/package/handsontable)
Licence should be inserted in the `client/src/index.html` file: Licence should be inserted in the `client/src/index.html` file:
@@ -66,6 +81,15 @@ To remove the release, you need to do it with repo administration over at [https
# Troubleshooting # Troubleshooting
## `npm i` fails with ENOENT on `sheet-crypto.tgz`
If `npm i`/`npm ci` in `client` fails with:
```
npm error enoent ENOENT: no such file or directory, open '.../client/libraries/sheet-crypto.tgz'
```
it means the encrypted `sheet-crypto.tgz.gpg` hasn't been decrypted yet. See
[Dependencies that requires licences](#dependencies-that-requires-licences) above for how to decrypt it.
## Makedata service "could not create directory" error ## Makedata service "could not create directory" error
The dcpath folder should have its permissions set so that the system account (SYSUSERID) can both read and write to it. The dcpath folder should have its permissions set so that the system account (SYSUSERID) can both read and write to it.
+26
View File
@@ -157,5 +157,31 @@
} }
} }
} }
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
} }
} }
@@ -0,0 +1,132 @@
// Marks this file as an ES module (rather than a global script) so its
// top-level consts don't collide, under the TS type-checker, with the same
// names declared in other spec files — see viewer-labels.cy.ts for the same
// pattern.
export {}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// Regression coverage for issue #253's vertical-array COLTYPE fix — see
// getdata.sas now sends one COLTYPE JSON-object string per cols[] row (via
// a LEFT JOIN keyed off the real dataset's columns), instead of one
// comma-joined sasparams.COLTYPE string covering every column. Two things
// are specifically at risk from that change and aren't covered elsewhere:
// 1. `_____DELETE__THIS__RECORD_____` (the delete checkbox column) is a
// client-side-only concept — %mp_getcols never has a row for it, so
// it can no longer travel via cols[].COLTYPE at all. Its Yes/No
// dropdown rule is now hardcoded client-side (deleteRecordColumnRule.ts)
// — test 1 proves that hardcode actually renders end to end.
// 2. Ordinary columns (e.g. SOME_DROPDOWN) still get their rule via the
// new per-row cols[].COLTYPE path — test 2 proves the migration didn't
// silently drop real columns' rules either.
// Column positions below come from COLHEADERS in
// sas/mocks/sasjs/services/editors/getdata.js:
// "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,..."
// — childNodes[0] on a body <tr> is the row-header <th>, so childNodes[N+1]
// is the Nth (0-indexed) column in that list.
context('coltype vertical-array regression tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
it('1 | delete-record column renders its hardcoded Yes/No dropdown', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit()
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).then(
(rows: any) => {
// childNodes[1] = _____DELETE__THIS__RECORD_____ (1st column)
cy.get(rows[1].childNodes[1])
.click({ force: true })
.then(($td) => {
cy.get('.htAutocompleteArrow', { withinSubject: $td })
.should('exist')
.click({ force: true })
cy.get('.autocompleteEditor .htCore tbody td').should(
($choices) => {
const texts = [...$choices].map((el) => el.innerText.trim())
expect(texts).to.deep.equal(['No', 'Yes'])
}
)
})
}
)
})
it('2 | an ordinary dropdown column (SOME_DROPDOWN) still gets its rule from cols[].COLTYPE', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit()
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).then(
(rows: any) => {
// childNodes[4] = SOME_DROPDOWN (4th column, 0-indexed as 3). This
// only holds if DcValidator emits rules in VARNUM order — see the
// "orders rules by VARNUM" regression test in dc-validator.spec.ts.
cy.get(rows[1].childNodes[4])
.click({ force: true })
.then(($td) => {
cy.get('.htAutocompleteArrow', { withinSubject: $td }).should(
'exist'
)
})
}
)
})
})
const clickOnEdit = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container > button').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
+783
View File
@@ -115,8 +115,791 @@ context('editor tests: ', function () {
}) })
}) })
}) })
// An invalid cell shouldn't block submission if its row
// is marked for delete
it('5 | Submits a delete-marked row with an invalid cell', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
cy.get('.ht_master tbody tr').then((rows: any) => {
// childNodes[5] = SOME_NUM (numeric) — feed it a non-numeric value
// to trigger HOT's native invalid mark.
cy.get(rows[1].childNodes[5])
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('not a number{enter}')
.then(() => {
// childNodes[1] = _____DELETE__THIS__RECORD_____
setDeleteFlag(rows[1].childNodes[1], 'Yes', () => {
submitTable(() => {
// Validation passed despite the invalid SOME_NUM cell:
// the confirm-submit modal's Submit button is enabled
// (validationDone === 1), not the "invalid values" abort.
cy.get('#submitBtn', {
timeout: longerCommandTimeout
})
.should('exist')
.should('not.be.disabled')
.then(() => done())
})
})
})
})
})
})
})
})
// The delete exemption must not affect the uniqueness (primary key) check.
it('6 | Still blocks a duplicate primary key even on a delete-marked row', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
cy.get('.ht_master tbody tr').then((rows: any) => {
cy.get(rows[0].childNodes[2]).then((firstPk) => {
const duplicateValue = firstPk.text()
// childNodes[2] = PRIMARY_KEY_FIELD
cy.get(rows[1].childNodes[2])
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type(`${duplicateValue}{enter}`)
.then(() => {
setDeleteFlag(rows[1].childNodes[1], 'Yes', () => {
submitTable()
cy.get('.duplicate-keys-modal', {
timeout: longerCommandTimeout
})
.should('exist')
.then(() => done())
})
})
})
})
})
})
})
})
// REGEX_HARD_COL/REGEX_SOFT_COL only exist un-stripped on MPE_X_NEW (see
// RULE_DEMO_COLS in the getdata.js mock) — MPE_X_TEST stays clean to
// match the excel fixtures.
it('7 | Blocks submission of a value that fails HARDREGEX', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
getCellByHeaderAndRow(1, 'REGEX_HARD_COL')
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('not-an-email{enter}')
.then(() => {
getCellByHeaderAndRow(1, 'REGEX_HARD_COL').should(
'have.attr',
'title',
'REGEX: /[\\w.]+@[\\w]+\\.[a-z]{2,}/'
)
submitTable(() => {
cy.get('.modal-body').then((modalBody: any) => {
if (
modalBody[0].innerHTML
.toLowerCase()
.includes(`invalid values are present`)
) {
done()
}
})
})
})
})
})
})
})
it('8 | Warns (but still submits) a value that fails SOFTREGEX', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('not a postcode{enter}')
.then(() => {
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
.should('have.class', 'dc-warning-cell')
.and(
'have.attr',
'title',
'REGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
)
submitTable(() => {
// Validation passed despite the SOFTREGEX warning: the
// confirm-submit modal's Submit button is enabled
// (validationDone === 1), not the "invalid values" abort.
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
.then(() => done())
})
})
})
})
})
})
// MPE_X_FORMULA_TEST is a small, dedicated fixture for formula testing
// (kept separate from MPE_X_NEW, which carries no formula columns at
// all) - row index 1 (0-indexed, i=1 in the mock's row generator) has
// A_COL=2, B_COL=10: HARDFORMULA (A_COL * B_COL) computes to 20,
// SOFTFORMULA (A_COL + B_COL) computes to 12.
it('9 | HARDFORMULA shows the computed value and blocks direct edits', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '20')
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL')
.dblclick({ force: true })
.then(() => {
// readOnly cells don't open an editor - nothing is focused to
// type into, so this is a no-op if the column is truly readonly.
cy.focused().type('9999{enter}')
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should(
'have.text',
'20'
)
})
})
})
})
it('10 | SOFTFORMULA shows the computed default but accepts an override', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '12')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('override{enter}')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should(
'have.text',
'override'
)
})
})
})
})
it('11 | Info dropdown shows the applied formula as √x=<formula>', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
openColumnDropdown('FORMULA_HARD_COL')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include('√x=A_COL * B_COL')
})
cy.get('body').click(0, 0) // close menu
openColumnDropdown('FORMULA_SOFT_COL')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include('√x=A_COL + B_COL')
})
})
})
})
it('12 | Info dropdown shows the applied HARDREGEX/SOFTREGEX pattern', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
openColumnDropdown('REGEX_HARD_COL')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include(
'HARDREGEX: /[\\w.]+@[\\w]+\\.[a-z]{2,}/'
)
})
cy.get('body').click(0, 0) // close menu
openColumnDropdown('REGEX_SOFT_COL')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include(
'SOFTREGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
)
})
})
})
})
it('13 | Info dropdown shows only the applied HARDREGEX pattern when a column has both', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
openColumnDropdown('REGEX_BOTH_COL')
cy.get('.htDropdownMenu').should(($menu) => {
const text = $menu.text()
expect(text).to.include('HARDREGEX: /^[A-Z0-9_-]+$/')
expect(text).to.not.include('SOFTREGEX: /^.{5,10}$/')
})
})
})
})
it('14 | REGEX_BOTH_COL: a HARDREGEX failure blocks submission and sets its own tooltip, not yellow', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
// 'bad value' fails HARDREGEX (lowercase + space) but is 9 chars,
// within SOFTREGEX's 5-10 range - isolates the hard-only failure.
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('bad value{enter}')
.then(() => {
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
.should('not.have.class', 'dc-warning-cell')
.and('have.attr', 'title', 'REGEX: /^[A-Z0-9_-]+$/')
submitTable(() => {
cy.get('.modal-body').then((modalBody: any) => {
if (
modalBody[0].innerHTML
.toLowerCase()
.includes(`invalid values are present`)
) {
done()
}
})
})
})
})
})
})
})
it('15 | REGEX_BOTH_COL: SOFTREGEX is ignored entirely when HARDREGEX is present', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
// 'AB' passes HARDREGEX (uppercase only) but fails SOFTREGEX (too
// short) - only one regex runs per column, so no warning is shown.
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('AB{enter}')
.then(() => {
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
.should('not.have.class', 'dc-warning-cell')
.and('not.have.attr', 'title')
submitTable(() => {
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
.then(() => done())
})
})
})
})
})
})
it("16 | Insert Row above keeps existing rows' formulas aligned with their own shifted data", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Row index 2 (0-indexed): PRIMARY_KEY_FIELD=3, A_COL=3, B_COL=10 ->
// HARDFORMULA (A_COL*B_COL) = 30, SOFTFORMULA (A_COL+B_COL) = 13.
getCellByHeaderAndRow(2, 'PRIMARY_KEY_FIELD').should('have.text', '3')
insertRowViaContextMenu(2, 'Insert Row above')
// The new blank row lands at index 2 - its formula columns are
// seeded with their own row-relative formula (A_COL/B_COL are both
// still empty, so HyperFormula evaluates the arithmetic as 0).
getCellByHeaderAndRow(2, 'FORMULA_HARD_COL').should('have.text', '0')
getCellByHeaderAndRow(2, 'FORMULA_SOFT_COL').should('have.text', '0')
// The row that WAS at index 2 (PK=3) is now shifted down to index
// 3 - its formulas must recompute from ITS OWN data, not read
// whatever the engine had cached at index 3 before the insert
// (that would be PK=4's values: 40/14).
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').should('have.text', '3')
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').should('have.text', '30')
getCellByHeaderAndRow(3, 'FORMULA_SOFT_COL').should('have.text', '13')
})
})
})
it("17 | Insert Row below keeps existing rows' formulas aligned with their own shifted data", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Row index 3 (0-indexed): PRIMARY_KEY_FIELD=4, A_COL=4, B_COL=10 ->
// HARDFORMULA = 40, SOFTFORMULA = 14.
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').should('have.text', '4')
// 'Insert Row below' on row index 2 (PK=3) inserts the new blank
// row at index 3, pushing the old index-3 row (PK=4) to index 4.
insertRowViaContextMenu(2, 'Insert Row below')
// The new blank row lands at index 3 - seeded the same way as
// above, evaluating to 0 while A_COL/B_COL are still empty.
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').should('have.text', '0')
getCellByHeaderAndRow(3, 'FORMULA_SOFT_COL').should('have.text', '0')
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').should('have.text', '4')
getCellByHeaderAndRow(4, 'FORMULA_HARD_COL').should('have.text', '40')
getCellByHeaderAndRow(4, 'FORMULA_SOFT_COL').should('have.text', '14')
})
})
})
it('18 | Insert Row seeds formulas that live-recalculate once A_COL/B_COL are filled in', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
insertRowViaContextMenu(0, 'Insert Row below')
// New row at index 1 - both formula columns evaluate the seeded
// formula (=C2*D2 / =C2+D2 in spreadsheet notation) against
// still-empty A_COL/B_COL, so HyperFormula treats them as 0.
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '0')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '0')
getCellByHeaderAndRow(1, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().type('5{enter}')
})
getCellByHeaderAndRow(1, 'B_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().type('3{enter}')
})
// Both formula columns must recompute live from THIS row's own
// A_COL/B_COL - proving the seeded formula is genuinely wired to
// HyperFormula, not a one-time static snapshot.
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '15')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '8')
})
})
})
it('19 | Row header shows blank for unchanged, ~ for a modified row', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getRowHeaderSymbol(0).should('have.text', ' ')
getCellByHeaderAndRow(0, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getRowHeaderSymbol(0).should('have.text', '~')
})
})
})
it('20 | Row header shows - for a delete-marked row and + for a newly inserted row', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(2, 'Delete?').then(($cell) => {
setDeleteFlag($cell[0], 'Yes', () => {
getRowHeaderSymbol(2).should('have.text', '-')
insertRowViaContextMenu(0, 'Insert Row below')
// New row lands at index 1, shifting the delete-marked row
// (was index 2) down to index 3 - its own status is unaffected
// by the shift, only its position.
getRowHeaderSymbol(1).should('have.text', '+')
getRowHeaderSymbol(3).should('have.text', '-')
})
})
})
})
})
it('21 | Inserting a row does not falsely mark unrelated rows as modified or revert an existing override', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getRowHeaderSymbol(0).should('have.text', '~')
// Untouched row, well away from the insert point below.
getRowHeaderSymbol(5).should('have.text', ' ')
insertRowViaContextMenu(2, 'Insert Row below')
// The override and its '~' mark must survive the insert...
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL').should('have.text', '999')
getRowHeaderSymbol(0).should('have.text', '~')
// ...and a row that was never touched must stay unmarked, even
// though its position shifted (row 5 is now row 6).
getRowHeaderSymbol(6).should('have.text', ' ')
})
})
})
it("22 | DC.ROW_STATUS resolves to a live cell reference, reacting to this row's own edit status", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// _____EDIT_STATUS_____ itself is a purely client-synthesized
// column (see editStatusColumnRule.ts) - it must never render as a
// visible header, only exist as something DC.ROW_STATUS can
// point a cell reference at.
cy.get('.ht_clone_top .htCore thead tr th').should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).not.to.include('_____EDIT_STATUS_____')
})
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'U')
getCellByHeaderAndRow(0, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'M')
})
})
})
it('23 | DC.USER_NAME resolves to the current logged-in user', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'USER_NAME_COL').should('have.text', 'sasdemo')
})
})
})
it("24 | DC.ORIG_VALUE echoes this row's pre-edit value, and is blank for a newly-inserted row", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'ORIG_VALUE_COL').should('have.text', 'orig-1')
insertRowViaContextMenu(0, 'Insert Row below')
// No dataSourceUnchanged match exists for a brand-new row, so
// DC.ORIG_VALUE falls back to blank rather than echoing anything.
getCellByHeaderAndRow(1, 'ORIG_VALUE_COL').should('have.text', '')
})
})
})
it('25 | Row header symbols stay aligned with their own row after sorting by another column', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Mark row 2 (PRIMARY_KEY_FIELD=3) for delete before sorting, so
// there's a distinctive symbol ('-') to track across the reorder.
getCellByHeaderAndRow(2, 'Delete?').then(($cell) => {
setDeleteFlag($cell[0], 'Yes', () => {
getRowHeaderSymbol(2).should('have.text', '-')
sortByColumn('FORMULA_SOFT_COL')
// Wherever PRIMARY_KEY_FIELD=3 lands visually after sorting,
// its OWN row header must show '-' - not whatever symbol
// previously belonged to that visual position.
cy.get('.ht_clone_top .htCore thead tr th')
.should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).to.include('PRIMARY_KEY_FIELD')
})
.then(($ths) => {
const pkColIndex = [...$ths].findIndex(
(th) => th.innerText.trim() === 'PRIMARY_KEY_FIELD'
)
cy.get('.ht_master tbody tr').then((rows: any) => {
const sortedRowIndex = [...rows].findIndex(
(row: any) =>
row.childNodes[pkColIndex].innerText.trim() === '3'
)
getRowHeaderSymbol(sortedRowIndex).should('have.text', '-')
})
})
})
})
})
})
})
// CHANGE_SUMMARY_COL combines all three DC.* variables in one formula:
// IF( DC.ROW_STATUS ="U","unedited", DC.USER_NAME &" changed from "& DC.ORIG_VALUE ).
// DC.USER_NAME/DC.ORIG_VALUE are frozen literals baked in at load time,
// while DC.ROW_STATUS is a live cell reference - so editing the row
// doesn't recompute the "changed from" text, it just flips which
// already-computed branch IF() reveals.
it("26 | Combining DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE in one formula reveals the frozen 'changed from' text once the row is edited", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
'have.text',
'unedited'
)
getCellByHeaderAndRow(0, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
'have.text',
'sasdemo changed from orig-1'
)
})
})
})
}) })
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
// the ones in the current viewport actually exist in the DOM. Scroll all
// the way right so REGEX_HARD_COL/REGEX_SOFT_COL/REGEX_BOTH_COL (the last
// three) render at all. Same technique already used in excel.cy.ts. Must
// be called (and re-settle) before any header/body query below, since
// scrolling replaces the previously-rendered column nodes.
const scrollGridRight = () => {
return cy
.get('#hotTable')
.find('div.ht_master.handsontable')
.find('div.wtHolder')
.scrollTo('right')
}
// Clicks a column header's sort indicator (Handsontable's multiColumnSorting
// plugin decorates every sortable header with a `.columnSorting` element -
// HEADER_SORT_CLASS in Handsontable's own source) to sort ascending by that
// column. force: true for the same reason as openColumnDropdown - an
// overlay clone layer can sit over the real target.
const sortByColumn = (headerText: string) => {
cy.get('.ht_clone_top .htCore thead tr th')
.filter((_, th) => Cypress.$(th).text().includes(headerText))
.last()
.find('.columnSorting')
.click({ force: true })
}
// Opens a column header's dropdown menu to reach its `info` item, which
// has a custom renderer showing NAME/LABEL/TYPE/LENGTH/FORMAT and, when the
// column has a HARDREGEX/SOFTREGEX rule, the applied pattern. Clicking the
// header text first selects the column - the renderer reads
// hot.getSelected() to decide which column to describe.
const openColumnDropdown = (headerText: string) => {
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
})
.parents('th')
.filter((_, th) => Cypress.$(th).text().includes(headerText))
.last()
.as('targetHeader')
cy.get('@targetHeader').click()
cy.get('@targetHeader').find('button.changeType').click({ force: true })
}
// Locates a body cell by its column's header text rather than a hardcoded
// childNodes index. Handsontable's hiddenColumns plugin (e.g. HIDDEN_COL,
// used by several demo columns ahead of REGEX_HARD_COL/REGEX_SOFT_COL in
// COLHEADERS) removes a hidden column's cells from BOTH the header row and
// every body row identically, so the header row's th index always lines up
// with the same-position childNode in any body row — this sidesteps having
// to know how many hidden columns precede the target. Re-queries both the
// header and body row fresh each call (rather than accepting a pre-fetched
// row) since virtualization/scrolling can replace either pane's nodes.
//
// The `.should()` retries until headerText actually shows up: Handsontable
// re-renders virtualized columns asynchronously after a scroll event, on
// its own schedule outside Cypress's command queue, so scrollTo() settling
// does not mean the target column has been rendered yet.
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))
})
}
// Reads the row-header gutter's text for the given row - this is the
// left-hand "select entire row" bar (rowHeaders callback in
// editor.component.ts), doubling as the edit-status indicator
// (+/-/~/blank). .ht_clone_left mirrors .ht_clone_top's frozen-clone role,
// just for the row axis instead of the column axis.
const getRowHeaderSymbol = (rowIndex: number) => {
return cy
.get('.ht_clone_left .htCore tbody tr')
.eq(rowIndex)
.find('th .rowHeader')
}
// Right-clicks the given row's PRIMARY_KEY_FIELD cell to open the row's
// context menu, then clicks the given item. 'Insert Row above'/'below' are
// hidden when hotTable.readOnly is true, so this only works after
// clickOnEdit(). force: true - same reasoning as toggleColumnLabelsFromContextMenu
// in viewer-labels.cy.ts, the right-clicked cell can sit under an overlay
// clone layer that fails Cypress's actionability check.
const insertRowViaContextMenu = (
rowIndex: number,
menuItemText: 'Insert Row above' | 'Insert Row below'
) => {
getCellByHeaderAndRow(rowIndex, 'PRIMARY_KEY_FIELD').rightclick({
force: true
})
cy.get('.htContextMenu').contains(menuItemText).click()
}
// Opens the _____DELETE__THIS__RECORD_____ dropdown editor on the given cell
// and picks the given choice ('Yes'/'No') — same technique as
// coltype-delete-record.cy.ts (arrow -> autocompleteEditor choice list).
const setDeleteFlag = (
deleteCell: any,
value: 'Yes' | 'No',
callback?: any
) => {
cy.get(deleteCell)
.click({ force: true })
.then(($td) => {
cy.get('.htAutocompleteArrow', { withinSubject: $td })
.should('exist')
.click({ force: true })
cy.get('.autocompleteEditor .htCore tbody td')
.contains(value)
.click({ force: true })
.then(() => {
if (callback) callback()
})
})
}
const clickOnEdit = (callback?: any) => { const clickOnEdit = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout }) cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click() .click()
+13
View File
@@ -386,6 +386,19 @@ context('excel tests: ', function () {
}) })
}) })
it('24 | Uploads Excel with leading whitespace in header row (should still succeed)', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// Every header except the first has a leading space, matching a
// real-world defect from copy-pasting between spreadsheets/systems.
// Header matching must tolerate
// this rather than reporting the columns missing and aborting.
attachExcelFile('leading_whitespace_header_excel.xlsx', () => {
submitExcel()
rejectExcel(done)
})
})
// Large files break Cypress // Large files break Cypress
// it ('? | Uploads Excel with size of 5MB', (done) => { // it ('? | Uploads Excel with size of 5MB', (done) => {
+32
View File
@@ -36,6 +36,38 @@ context('liveness tests: ', function () {
}) })
}) })
// Multiple concurrent DC instances can cause the startupservice request
// to time out even though a retry succeeds. Fail only the first
// startupservice call (any later ones, e.g. from other tests/reloads,
// pass through untouched) and confirm the app still reaches the nav tree
// instead of showing the startup error modal.
it('2 | Recovers from a failed startupservice request via the single retry', () => {
let startupServiceCallCount = 0
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
const isStartupService = JSON.stringify(req.body || '').includes(
'startupservice'
)
if (isStartupService) startupServiceCallCount++
if (isStartupService && startupServiceCallCount === 1) {
req.reply({ statusCode: 503, body: 'Service unavailable' })
} else {
req.continue()
}
}).as('stpExecute')
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
// Longer than the retry's own 0-3s pause on top of the normal timeout.
timeout: longerCommandTimeout + 3000
}).should('exist')
cy.get('.abortMsg').should('not.exist')
})
/** /**
* Thist part will be needed if we add more tests in future * Thist part will be needed if we add more tests in future
*/ */
+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))
})
}
+232 -238
View File
@@ -41,16 +41,13 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click() cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table]) openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.get('.open-viewbox').then((viewboxNodes: any) => { cy.contains('.open-viewbox', viewbox_table, {
for (let viewboxNode of viewboxNodes) { matchCase: false,
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) { timeout: longerCommandTimeout
return })
}
checkColumns(columns, () => { checkColumns(columns, () => {
done() done()
})
}
}) })
}) })
@@ -78,48 +75,21 @@ context('editor tests: ', function () {
libraryToOpenIncludes, libraryToOpenIncludes,
viewboxes.map((viewbox) => viewbox.viewbox_table) viewboxes.map((viewbox) => viewbox.viewbox_table)
) )
cy.get('.open-viewbox').then((viewboxNodes: any) => { viewboxes.forEach((viewbox) => {
let found = 0 cy.contains('.open-viewbox', viewbox.viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
})
for (let viewboxNode of viewboxNodes) { let result = 0
for (let viewbox of viewboxes) {
if (
viewboxNode.innerText.toLowerCase().includes(viewbox.viewbox_table)
)
found++
}
}
if (found < viewboxes.length) return viewboxes.forEach((viewbox) => {
checkColumns(viewbox.columns, () => {
result++
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then( if (result === viewboxes.length) done()
(viewboxNodes: any) => { })
for (let viewboxNode of viewboxNodes) {
cy.get(viewboxNode).within(() => {
cy.get('.table-title').then((tableTitle) => {
const title = tableTitle[0].innerText
const viewbox = viewboxes.find((vb) =>
title.toLowerCase().includes(vb.viewbox_table)
)
if (viewbox) {
cy.get('.ht_master.handsontable .htCore thead tr').then(
(viewboxColNodes: any) => {
let allColsHtml = viewboxColNodes[0].innerHTML
for (let col of viewbox?.columns) {
if (!allColsHtml.includes(col)) return
}
done()
}
)
}
})
})
}
}
)
}) })
}) })
@@ -133,22 +103,19 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click() cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table]) openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.get('.open-viewbox').then((viewboxNodes: any) => { cy.contains('.open-viewbox', viewbox_table, {
for (let viewboxNode of viewboxNodes) { matchCase: false,
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) { timeout: longerCommandTimeout
return })
}
openViewboxConfig(viewbox_table) openViewboxConfig(viewbox_table)
removeAllColumns() removeAllColumns()
addColumns(additionalColumns) addColumns(additionalColumns)
checkColumns([...columns, ...additionalColumns], () => { checkColumns([...columns, ...additionalColumns], () => {
done() done()
})
}
}) })
}) })
@@ -162,35 +129,32 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click() cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table]) openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.get('.open-viewbox').then((viewboxNodes: any) => { cy.contains('.open-viewbox', viewbox_table, {
for (let viewboxNode of viewboxNodes) { matchCase: false,
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) { timeout: longerCommandTimeout
return })
}
openViewboxConfig(viewbox_table) openViewboxConfig(viewbox_table)
removeAllColumns() removeAllColumns()
addColumns(additionalColumns, () => { addColumns(additionalColumns, () => {
cy.wait(1000) cy.wait(1000)
//reorder //reorder
cy.get('.col-box.column-MOVE_TYPE') cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' }) .realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' }) .realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK') cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' }) .realMouseMove(0, 0, { position: 'center' })
.realMouseUp() .realMouseUp()
//reorder end //reorder end
cy.wait(500) cy.wait(500)
checkColumns([...columns, ...additionalColumns.reverse()], () => { checkColumns([...columns, ...additionalColumns.reverse()], () => {
done() done()
}) })
})
}
}) })
}) })
@@ -204,53 +168,47 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click() cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table]) openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.get('.open-viewbox').then((viewboxNodes: any) => { cy.contains('.open-viewbox', viewbox_table, {
for (let viewboxNode of viewboxNodes) { matchCase: false,
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) { timeout: longerCommandTimeout
return })
}
viewboxNode.click() openViewboxConfig(viewbox_table)
removeAllColumns() removeAllColumns()
addColumns(additionalColumns, () => { addColumns(additionalColumns, () => {
cy.wait(1000) cy.wait(1000)
//reorder //reorder
cy.get('.col-box.column-MOVE_TYPE') cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' }) .realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' }) .realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK') cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' }) .realMouseMove(0, 0, { position: 'center' })
.realMouseUp() .realMouseUp()
//reorder end //reorder end
cy.wait(500) cy.wait(500)
checkColumns([...columns, ...additionalColumns.reverse()], () => { checkColumns([...columns, ...additionalColumns.reverse()], () => {
const colToRemove = 'MOVE_TYPE' const colToRemove = 'MOVE_TYPE'
removeColumn(colToRemove) removeColumn(colToRemove)
checkColumns( checkColumns(
[ [
...columns, ...columns,
...additionalColumns.filter((col) => col !== colToRemove) ...additionalColumns.filter((col) => col !== colToRemove)
], ],
() => { () => {
addColumns([colToRemove], () => { addColumns([colToRemove], () => {
checkColumns( checkColumns([...columns, ...additionalColumns.reverse()], () => {
[...columns, ...additionalColumns.reverse()], done()
() => { })
done() })
} }
) )
}) })
}
)
})
})
}
}) })
}) })
@@ -333,68 +291,68 @@ context('editor tests: ', function () {
}) })
}) })
// We will enable this test when we figure out how to mock filtering // The base VIEW table and a viewbox on a different table must never
// it('7 | Add viewboxes and filter', () => { // share a filter dialog's cached state - each keeps its own entry in
// const viewboxes = ['mpe_x_test', 'mpe_validations'] // the globals filter cache (getFilterObjPath), keyed by viewboxId where
// one is present. Opened from the VIEW page specifically
// (rootParam === 'view') - the one branch where this previously broke,
// since a 'view'-page QueryComponent was always treated as the base
// table regardless of viewboxId.
it('7 | Base table filter and viewbox filter stay isolated from each other', () => {
const viewbox_table = 'mpe_audit'
// openTableFromTree(libraryToOpenIncludes, 'mpe_x_test') visitPage('view/data')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// cy.get('.viewbox-open').click() // Filter the base table.
// openTableFromViewboxTree(libraryToOpenIncludes, viewboxes) openViewFilter()
setFilterWithValue('SOME_CHAR', 'this is dummy data', 'value', () => {
cy.get('app-query', { withinSubject: null }).should('not.exist')
// cy.wait(1000) // Open a viewbox on a *different* table and check its filter dialog
// does not show the base table's filter.
openViewViewboxes()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
closeViewboxModal()
// closeViewboxModal() // cy.contains retries until the viewbox's title actually shows the
// expected table name (it can render before its data/title loads),
// then .parents() walks back up to its container - avoids a
// one-shot .then() racing the title's own async update, which is
// what made this flaky in the first place.
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
.parents('.viewbox')
.first()
.within(() => {
cy.get('clr-icon[shape="filter"]').click()
// cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then( cy.get('.filter-modal code.language-sql', {
// (viewboxNodes: any) => { withinSubject: null
// for (let viewboxNode of viewboxNodes) { }).should(($code) => {
// cy.get(viewboxNode).within(() => { expect($code.text()).to.not.contain('SOME_CHAR')
// cy.get('.table-title').then((title: any) => { })
// cy.get('.hot-spinner')
// .should('not.exist')
// .then(() => {
// cy.get('clr-icon[shape="filter"]').then((filterButton) => {
// filterButton[0].click()
// })
// if (title[0].innerText.includes('MPE_X_TEST')) { // Reverse direction: filter the viewbox, then confirm
// setFilterWithValue( // re-opening the base table's own filter dialog still shows
// 'SOME_CHAR', // the base table's filter, not the viewbox's.
// 'this is dummy data', setFilterWithValue('LIBREF', 'sasdemo', 'value', () => {
// 'value', cy.get('app-query', { withinSubject: null }).should('not.exist')
// () => {
// cy.get('app-query', { withinSubject: null })
// .should('not.exist')
// .get('.ht_master.handsontable tbody tr')
// .then((rowNodes) => {
// const tr = rowNodes[0]
// expect(rowNodes).to.have.length(1) openViewFilter()
// expect(tr.innerText).to.equal('0')
// })
// }
// )
// } else if (title[0].innerText.includes('MPE_VALIDATIONS')) {
// setFilterWithValue('BASE_COL', 'ALERT_LIB', 'value', () => {
// cy.get('app-query', { withinSubject: null })
// .should('not.exist')
// .get('.ht_master.handsontable tbody tr')
// .then((rowNodes) => {
// const tr = rowNodes[0]
// expect(rowNodes).to.have.length(1) cy.get('.filter-modal code.language-sql', {
// expect(tr.innerText).to.contain('ALERT_LIB') withinSubject: null
// }) }).should(($code) => {
// }) expect($code.text()).to.contain('SOME_CHAR')
// } expect($code.text()).to.not.contain('LIBREF')
// }) })
// }) })
// }) })
// } })
// } })
// )
// })
}) })
const removeAllColumns = () => { const removeAllColumns = () => {
@@ -407,32 +365,40 @@ const removeAllColumns = () => {
) )
} }
// Asserts that at least one open viewbox currently shows exactly this
// column list, in order. Uses a single .should() callback (synchronous
// jQuery reads only, no nested cy.get()/.then()) so Cypress retries the
// whole check against a fresh DOM snapshot until it passes or times out -
// unlike .then(), which only ever sees one snapshot and, combined with an
// early return on a mismatch, would silently never invoke the caller's
// callback while Handsontable/the viewbox's data are still loading or
// re-rendering asynchronously.
const checkColumns = (columns: string[], callback: () => void) => { const checkColumns = (columns: string[], callback: () => void) => {
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then( cy.get('.viewboxes-container .viewbox', { withinSubject: null }).should(
(viewboxNodes: any) => { ($viewboxNodes) => {
for (let viewboxNode of viewboxNodes) { const matches = $viewboxNodes.toArray().some((viewboxNode) => {
cy.get(viewboxNode).within(() => { // rowHeaders: true (viewboxes.component.ts) adds a leading, unlabeled
cy.get('.ht_master.handsontable thead tr th').then( // corner <th> before the actual data columns - skip it, or every
(viewboxColNodes: any) => { // comparison is off by one against the row-header cell instead of
console.log('viewboxColNode', viewboxColNodes) // the first real column.
console.log('columns', columns) const headerCells = Cypress.$(viewboxNode)
for (let i = 0; i < viewboxColNodes.length; i++) { .find('.ht_master.handsontable thead tr th')
const col = columns[i] || '' .toArray()
const colNode = viewboxColNodes[i] .slice(1)
if ( return columns.every((col, i) =>
!colNode.innerHTML.toLowerCase().includes(col.toLowerCase()) (headerCells[i]?.innerHTML || '')
) .toLowerCase()
return .includes(col.toLowerCase())
} )
})
callback() expect(matches, `a viewbox showing columns: ${columns.join(', ')}`).to.be
} .true
)
})
}
} }
) )
cy.then(() => callback())
} }
const closeViewboxModal = () => { const closeViewboxModal = () => {
@@ -444,9 +410,7 @@ const removeColumn = (column: string) => {
} }
const addColumns = (columns: string[], callback?: () => void) => { const addColumns = (columns: string[], callback?: () => void) => {
for (let i = 0; i < columns.length; i++) { for (const column of columns) {
const column = columns[i]
cy.get('.cols-search input', { withinSubject: null }).type(column) cy.get('.cols-search input', { withinSubject: null }).type(column)
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null }) cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
.first() .first()
@@ -454,51 +418,81 @@ const addColumns = (columns: string[], callback?: () => void) => {
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null }) cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
.first() .first()
.trigger('keydown', { key: 'Enter' }) .trigger('keydown', { key: 'Enter' })
.then(() => {
if (i === columns.length - 1 && callback) callback() // Wait for the actual column box to render before moving on to the
}) // next column (or firing the callback) - selecting it doesn't add it
// to the DOM synchronously, and cy.get()'s own retry covers that gap
// instead of relying on a fixed cy.wait().
cy.get(`.col-box.column-${column}`, { timeout: longerCommandTimeout })
} }
if (callback) cy.then(() => callback())
} }
// The VIEW page's "options" dropdown (viewer.component.html) - distinct
// from the editor page's dedicated .viewbox-open button used by the
// tests above, since test 7 runs on the VIEW page specifically.
const openViewOptionsDropdown = () => {
cy.get('.filterSide', { withinSubject: null }).click()
}
const openViewFilter = () => {
openViewOptionsDropdown()
cy.get('[clrDropdownItem]', { withinSubject: null })
.contains('Filter')
.click()
}
const openViewViewboxes = () => {
openViewOptionsDropdown()
cy.get('[clrDropdownItem]', { withinSubject: null })
.contains('Viewboxes')
.click()
}
// cy.contains retries until a matching node appears/updates - the open
// viewbox's own list entry can render before its table-name text has
// actually loaded, so a one-shot .then() lookup here would race it.
const openViewboxConfig = (viewbox_tablename: string) => { const openViewboxConfig = (viewbox_tablename: string) => {
cy.get('.open-viewbox').then((viewboxes: any) => { cy.contains('.open-viewbox', viewbox_tablename, {
for (let openViewbox of viewboxes) { matchCase: false,
if (openViewbox.innerText.toLowerCase().includes(viewbox_tablename)) timeout: longerCommandTimeout
openViewbox.click() }).click()
}
})
} }
const openTableFromTree = (libNameIncludes: string, tablename: string) => { const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout }) cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) { // Small settle wait: right after a fresh visit (an extra navigation on
if (new RegExp(libNameIncludes).test(node.innerText.toLowerCase())) { // top of beforeEach's own, e.g. test 7's visitPage('view/data')), the
viyaLib = node // tree component can still be re-rendering, causing a node to be found
break // then swapped out mid-click - same fix already proven in
} // viewer-labels.cy.ts.
} cy.wait(300)
cy.get(viyaLib).within(() => { const libraryNodeSelector = '.nav-tree clr-tree > clr-tree-node'
cy.get('.clr-tree-node-content-container p').click() const libraryMatcher = new RegExp(libNameIncludes, 'i')
cy.get('.clr-treenode-link').then((innerNodes: any) => { cy.contains(libraryNodeSelector, libraryMatcher, {
for (let innerNode of innerNodes) { timeout: longerCommandTimeout
if (innerNode.innerText.toLowerCase().includes(tablename)) { })
innerNode.click() .find('.clr-tree-node-content-container p')
break .click()
}
} // Re-query the library node fresh rather than reusing the reference
}) // from before the click above - expanding a library can replace its
}) // whole DOM subtree once its child tables load (that's what made the
}) // earlier cached-reference version of this helper flaky: Cypress
}) // detected the click's own side effect detaching the element it was
// still verifying actionability against).
cy.contains(libraryNodeSelector, libraryMatcher, {
timeout: longerCommandTimeout
}).within(() => {
cy.contains('.clr-treenode-link', tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
})
} }
const setFilterWithValue = ( const setFilterWithValue = (
+243
View File
@@ -0,0 +1,243 @@
// Marks this file as an ES module (rather than a global script) so its
// top-level consts don't collide, under the TS type-checker, with the same
// names declared in other spec files — see e.g. download.cy.ts, which gets
// this for free via a real import.
export {}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// Viewer column-label display toggle (`?labels=true`): clients surfacing DC in
// SAS Visual Analytics want column LABELs shown instead of NAMEs. Default
// behavior (no param) must stay unchanged; the right-click context menu's
// "Show labels"/"Show names" item is the in-app affordance, and it drives the
// same URL param so state is shareable/refreshable. See mock data in
// sas/mocks/sasjs/services/public/viewdata.js: MPE_X_TEST's SOME_CHAR/SOME_DATE
// have LABELs that differ from NAME, and SOME_NUM has a blank LABEL (fallback
// to NAME) — this spec exercises exactly that fixture.
context('viewer column labels toggle tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
// forceLicenceKey=true: without it, loginAndUpdateValidKey() only
// generates key data and logs back out if no valid license already
// exists — it doesn't actually apply one (see commands.ts). Several
// specs call it with no argument and rely on an *earlier* spec in the
// same CI run having already forced a valid license onto the shared
// server first (csv.cy.ts, excel.cy.ts, etc.). This spec shouldn't
// depend on run order, so force it explicitly.
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
// No re-login here: beforeAll's loginAndUpdateValidKey() already
// authenticated the session, and it persists across tests in this file
// (see filtering.cy.ts/csv.cy.ts for the same pattern). Re-typing into
// the login form would fail — it's hidden once already logged in.
cy.visit(hostUrl + appLocation)
// Visit 'home' first, matching filtering.cy.ts/csv.cy.ts/download.cy.ts —
// going straight to 'view/data' skips whatever startup-data fetch 'home'
// triggers, which passed locally (fast round-trips) but left the
// nav-tree empty in CI's slower environment.
visitPage('home')
visitPage('view/data')
})
it('1 | default (no param): headers show NAMEs', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('SOME_CHAR')
expect($headerRow[0].innerHTML).to.not.include('Some Character Column')
})
})
it('2 | ?labels=true: headers show LABELs, blank LABEL falls back to NAME', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
appendUrlParam('labels=true')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
const html = $headerRow[0].innerHTML
expect(html).to.include('Some Character Column')
expect(html).to.include('Some Date')
// SOME_NUM has a blank LABEL in the mock -> falls back to NAME
expect(html).to.include('SOME_NUM')
})
})
it('3 | context menu "Show labels"/"Show names" toggles headers and the URL', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
toggleColumnLabelsFromContextMenu('Show labels')
cy.url().should('include', 'labels=true')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('Some Character Column')
})
toggleColumnLabelsFromContextMenu('Show names')
cy.url().should('not.include', 'labels=true')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('SOME_CHAR')
})
})
it('4 | ?embed=va&labels=true: labels shown with embed chrome hidden', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
appendUrlParam('embed=va&labels=true')
cy.get('header.app-header').should('not.exist')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('Some Character Column')
})
})
it('5 | info dropdown: first line is NAME (in both toggle states)', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// No assertion on the item's label text here: `info` has a custom
// `renderer`, so Handsontable never renders its `name` ("test info") as
// text at all — the renderer's own output (checked below) is the only
// real content.
openColumnDropdown('SOME_CHAR')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.match(/NAME: SOME_CHAR/)
})
cy.get('body').click(0, 0) // close menu
appendUrlParam('labels=true')
openColumnDropdown('Some Character Column')
cy.get('.htDropdownMenu').should(($menu) => {
// NAME is always shown first, even while headers display as LABEL
expect($menu.text()).to.match(/NAME: SOME_CHAR/)
})
})
})
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
// Re-visits the current route with extra hash-query params appended, then
// forces a real reload. Under hash routing (`useHash: true`), changing only
// the URL's hash fragment is a same-document navigation — Angular Router
// still reacts to it (which is enough for `useLabels`, read reactively from
// `route.queryParams`), but `embed` is parsed once from `window.location.hash`
// at app bootstrap (app.component.ts), so `?embed=va` only takes effect after
// an actual reload.
const appendUrlParam = (param: string) => {
cy.url().then((url) => {
const separator = url.includes('?') ? '&' : '?'
cy.visit(`${url}${separator}${param}`)
cy.reload()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
})
}
const openColumnDropdown = (headerText: string) => {
// Handsontable renders the sticky/frozen header via a separate clone pane
// (.ht_clone_top) for scroll behavior; the header row inside the main
// .ht_master pane is kept visibility:hidden (replaced visually by the
// clone). .ht_clone_top is the real, interactive one — confirmed via
// `document.querySelector('#hotTable .ht_clone_top thead').innerText` in
// a live browser session.
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
})
.parents('th')
.filter((_, th) => Cypress.$(th).text().includes(headerText))
.last()
.as('targetHeader')
// Click the header text first to select the column — the `info` item's
// renderer reads hot.getSelected() to decide which column to describe,
// so without an active selection it always shows "No info found".
cy.get('@targetHeader').click()
cy.get('@targetHeader').find('button.changeType').click({ force: true })
}
const toggleColumnLabelsFromContextMenu = (
menuItemText: 'Show labels' | 'Show names'
) => {
cy.get('#hotTable .ht_master.handsontable .htCore tbody tr td', {
timeout: longerCommandTimeout
})
.first()
// force: true — the first body row can sit under an overlay clone's
// header/sort-icon layer, which fails Cypress's actionability check
// even though the cell is the real rightclick target underneath.
.rightclick({ force: true })
cy.get('.htContextMenu').contains(menuItemText).click()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
// Small settle wait: right after a fresh visit/reload, the tree
// component can still be re-rendering, causing this node to be
// found then swapped out mid-click ("disappeared from the page").
cy.wait(300)
cy.get(
'.clr-tree-node-content-container .clr-treenode-content p'
).click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
// Selecting the table triggers async SPA routing + a viewdata fetch;
// wait for the grid to actually render before any subsequent action
// (reading the URL, right-clicking, opening a header dropdown) — without
// this, callers can act while still on the intermediate library-only
// route or a not-yet-rendered grid. Waiting specifically for a header's
// dropdown button (not just the <tr> shell) confirms Handsontable has
// finished populating header cell contents, not just their DOM rows.
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
}).should('exist')
}
+145 -91
View File
@@ -23,21 +23,33 @@
// //
// -- This will overwrite an existing command -- // -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
import 'cypress-file-upload'; import 'cypress-file-upload'
import { arrayBufferToBase64 } from './../util/helper-functions' import { arrayBufferToBase64 } from './../util/helper-functions'
import * as moment from 'moment' import moment from 'moment'
const username = Cypress.env('username'); // These custom commands were added via Cypress.Commands.add() below but never
const password = Cypress.env('password'); // had a type augmentation, so every spec calling cy.loginAndUpdateValidKey()/
const hostUrl = Cypress.env('hosturl'); // cy.isLoggedIn() had an unresolved-property error.
const appLocation = Cypress.env('appLocation'); declare global {
namespace Cypress {
interface Chainable {
isLoggedIn(callback: (exist: boolean) => void): Chainable<void>
loginAndUpdateValidKey(forceLicenceKey?: boolean): Chainable<void>
}
}
}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout') const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const site_id_SASJS = Cypress.env('site_id_SASJS') const site_id_SASJS = Cypress.env('site_id_SASJS')
Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => { Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
cy.get('body').then($body => { cy.get('body').then(($body) => {
if ($body.find(".nav-tree").length > 0) { if ($body.find('.nav-tree').length > 0) {
if (callback) callback(true) if (callback) callback(true)
} else { } else {
if (callback) callback(false) if (callback) callback(false)
@@ -45,76 +57,92 @@ Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
}) })
}) })
Cypress.Commands.add('loginAndUpdateValidKey', (forceLicenceKey: boolean = false) => { Cypress.Commands.add(
cy.visit(hostUrl + appLocation); 'loginAndUpdateValidKey',
(forceLicenceKey: boolean = false) => {
cy.visit(hostUrl + appLocation)
cy.wait(2000) cy.wait(2000)
cy.get('body').then($body =>{ cy.get('body').then(($body) => {
const usernameInput = $body.find("input.username")[0] const usernameInput = $body.find('input.username')[0]
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) { if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
cy.get('input.username').type(username); cy.get('input.username').type(username)
cy.get('input.password').type(password); cy.get('input.password').type(password)
cy.get('.login-group button').click() cy.get('.login-group button').click()
} }
cy.get('.app-loading', {timeout: longerCommandTimeout}).should('not.exist').then(() => { cy.get('.app-loading', { timeout: longerCommandTimeout })
cy.wait(2000) .should('not.exist')
.then(() => {
cy.wait(2000)
if ($body.find(".nav-tree").length > 0) { if ($body.find('.nav-tree').length > 0) {
/** /**
* If licence key is already working, then skip rest of the function * If licence key is already working, then skip rest of the function
*/ */
return logout(() => { return logout(() => {
return return
}) })
} else{ } else {
const keyData = { const keyData = {
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'), valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
number_of_users: 10, number_of_users: 10,
hot_license_key: '', hot_license_key: '',
site_id: '', site_id: '',
demo: false demo: false
}
return generateKeys(keyData.valid_until, keyData.number_of_users, keyData.hot_license_key, keyData.demo, (keysGen: any) => {
return acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
} }
if (!forceLicenceKey) { return generateKeys(
return logout(() => { keyData.valid_until,
return keyData.number_of_users,
}) keyData.hot_license_key,
} else { keyData.demo,
return updateLicenseKeyQuick(keysGen, () => { (keysGen: any) => {
cy.wait(25000)
return acceptTermsIfPresented((result: boolean) => { return acceptTermsIfPresented((result: boolean) => {
if (result) { if (result) {
cy.wait(20000) cy.wait(20000)
} }
return logout(() => {
return if (!forceLicenceKey) {
}) return logout(() => {
return
})
} else {
return updateLicenseKeyQuick(keysGen, () => {
cy.wait(25000)
return acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
return logout(() => {
return
})
})
})
}
}) })
}) }
} )
}) }
}) })
}
}) })
}); }
}); )
const logout = (callback?: any) => { const logout = (callback?: any) => {
cy.get('.header-actions .dropdown-toggle').click().then(() => { cy.get('.header-actions .dropdown-toggle')
cy.get('.header-actions .dropdown-menu > .separator').next().click().then(() => { .click()
if (callback) callback() .then(() => {
cy.get('.header-actions .dropdown-menu > .separator')
.next()
.click()
.then(() => {
if (callback) callback()
})
}) })
})
} }
const updateLicenseKeyQuick = (keys: any, callback: any) => { const updateLicenseKeyQuick = (keys: any, callback: any) => {
@@ -132,11 +160,15 @@ const updateLicenseKeyQuick = (keys: any, callback: any) => {
const acceptTermsIfPresented = (callback?: any) => { const acceptTermsIfPresented = (callback?: any) => {
cy.url().then((url: string) => { cy.url().then((url: string) => {
if (url.includes('licensing/register')) { if (url.includes('licensing/register')) {
cy.get('.card-block').scrollTo('bottom').then(() => { cy.get('.card-block')
cy.get('#checkbox1').click().then(() => { .scrollTo('bottom')
if (callback) callback(true) .then(() => {
cy.get('#checkbox1')
.click()
.then(() => {
if (callback) callback(true)
})
}) })
})
} else { } else {
if (callback) callback(false) if (callback) callback(false)
} }
@@ -151,23 +183,36 @@ 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()
cy.get('.license-key-form textarea', {timeout: longerCommandTimeout}).invoke('val', licenseKey).trigger('input').should('not.be.undefined') cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
cy.get('.activation-key-form textarea', {timeout: longerCommandTimeout}).invoke('val', activationKey).trigger('input').should('not.be.undefined') .invoke('val', licenseKey)
.trigger('input')
.should('not.be.undefined')
cy.get('.activation-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', activationKey)
.trigger('input')
.should('not.be.undefined')
cy.get('button.apply-keys').click() cy.get('button.apply-keys').click()
} }
const visitPage = (url: string) => { const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`); cy.visit(`${hostUrl}${appLocation}/#/${url}`)
} }
const generateKeys = async (valid_until: string, users_allowed: number, hot_license_key: string, demo: boolean, resultCallback?: any) => { const generateKeys = async (
let keyPair = await window.crypto.subtle.generateKey({ valid_until: string,
name: "RSA-OAEP", users_allowed: number,
modulusLength: 2024, hot_license_key: string,
publicExponent: new Uint8Array([1, 0, 1]), demo: boolean,
hash: "SHA-256" resultCallback?: any
) => {
let keyPair = await window.crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2024,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256'
}, },
true, true,
["encrypt", "decrypt"] ['encrypt', 'decrypt']
) )
let licenseData = { let licenseData = {
@@ -184,30 +229,39 @@ const generateKeys = async (valid_until: string, users_allowed: number, hot_lice
console.log(encoded) console.log(encoded)
let cipher = await window.crypto.subtle.encrypt( let cipher = await window.crypto.subtle
{ .encrypt(
name: "RSA-OAEP" {
}, name: 'RSA-OAEP'
keyPair.publicKey, },
encoded keyPair.publicKey,
).then((value) => { encoded
return value )
}, (err) => { .then(
console.log('Encrpyt error', err) (value) => {
}) return value
},
(err) => {
console.log('Encrpyt error', err)
}
)
if (!cipher) { if (!cipher) {
alert('Encryptin keys failed') alert('Encryptin keys failed')
throw new Error('Encryptin keys failed') throw new Error('Encryptin keys failed')
} }
let privateKeyBytes = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey) let privateKeyBytes = await window.crypto.subtle.exportKey(
'pkcs8',
keyPair.privateKey
)
let activationKey = await arrayBufferToBase64(privateKeyBytes) let activationKey = await arrayBufferToBase64(privateKeyBytes)
let licenseKey = await arrayBufferToBase64(cipher) let licenseKey = await arrayBufferToBase64(cipher)
if (resultCallback) resultCallback({ if (resultCallback)
activationKey, resultCallback({
licenseKey activationKey,
}) licenseKey
} })
}
Binary file not shown.
+2 -2
View File
@@ -8,9 +8,9 @@ const check = (cwd) => {
start: cwd, start: cwd,
excludePrivatePackages: true, excludePrivatePackages: true,
onlyAllow: onlyAllow:
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;', 'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;BlueOak-1.0.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
excludePackages: excludePackages:
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1' '@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
}, },
(error, json) => { (error, json) => {
if (error) { if (error) {
+6286 -6413
View File
File diff suppressed because it is too large Load Diff
+34 -25
View File
@@ -41,33 +41,35 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^19.2.20", "@angular/animations": "^20.3.26",
"@angular/cdk": "^19.2.19", "@angular/cdk": "^20.2.14",
"@angular/common": "^19.2.20", "@angular/common": "^20.3.26",
"@angular/compiler": "^19.2.20", "@angular/compiler": "^20.3.26",
"@angular/core": "^19.2.20", "@angular/core": "^20.3.26",
"@angular/forms": "^19.2.20", "@angular/forms": "^20.3.26",
"@angular/platform-browser": "^19.2.20", "@angular/platform-browser": "^20.3.26",
"@angular/platform-browser-dynamic": "^19.2.20", "@angular/platform-browser-dynamic": "^20.3.26",
"@angular/router": "^19.2.20", "@angular/router": "^20.3.26",
"@cds/core": "^6.15.1", "@cds/core": "^6.15.1",
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz", "@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
"@clr/icons": "^13.0.2", "@clr/icons": "^13.0.2",
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz", "@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular-wrapper": "^17.1.0", "@handsontable/angular-wrapper": "^18.0.0",
"@sasjs/adapter": "^4.17.0", "@sasjs/adapter": "^4.17.3",
"@sasjs/utils": "^3.5.3", "@sasjs/utils": "^3.5.9",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz", "@sheet/crypto": "file:libraries/sheet-crypto.tgz",
"@types/d3-graphviz": "^2.6.7", "@types/d3-graphviz": "^2.6.7",
"@types/text-encoding": "0.0.35", "@types/text-encoding": "0.0.35",
"base64-arraybuffer": "^0.2.0", "base64-arraybuffer": "^0.2.0",
"browserify-cipher": "^1.0.1",
"buffer": "^5.4.3", "buffer": "^5.4.3",
"crypto-browserify": "^3.12.1", "create-hash": "^1.2.0",
"create-hmac": "^1.1.7",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"d3-graphviz": "^5.0.2", "d3-graphviz": "^5.0.2",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"fs-extra": "^7.0.1", "fs-extra": "^7.0.1",
"handsontable": "^17.1.0", "handsontable": "^18.0.0",
"https-browserify": "1.0.0", "https-browserify": "1.0.0",
"hyperformula": "^2.5.0", "hyperformula": "^2.5.0",
"iconv-lite": "^0.5.0", "iconv-lite": "^0.5.0",
@@ -77,8 +79,8 @@
"moment": "^2.30.1", "moment": "^2.30.1",
"ngx-clipboard": "^16.0.0", "ngx-clipboard": "^16.0.0",
"ngx-json-viewer": "file:libraries/ngx-json-viewer-3.2.1.tgz", "ngx-json-viewer": "file:libraries/ngx-json-viewer-3.2.1.tgz",
"nodejs": "0.0.0",
"os-browserify": "0.3.0", "os-browserify": "0.3.0",
"randombytes": "^2.1.0",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"save-svg-as-png": "^1.4.17", "save-svg-as-png": "^1.4.17",
"stream-browserify": "3.0.0", "stream-browserify": "3.0.0",
@@ -91,29 +93,32 @@
"zone.js": "~0.15.1" "zone.js": "~0.15.1"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^19.2.24", "@angular-devkit/build-angular": "^20.3.32",
"@angular-eslint/builder": "19.8.1", "@angular-eslint/builder": "19.8.1",
"@angular-eslint/eslint-plugin": "19.8.1", "@angular-eslint/eslint-plugin": "19.8.1",
"@angular-eslint/eslint-plugin-template": "19.8.1", "@angular-eslint/eslint-plugin-template": "19.8.1",
"@angular-eslint/schematics": "19.8.1", "@angular-eslint/schematics": "19.8.1",
"@angular-eslint/template-parser": "19.8.1", "@angular-eslint/template-parser": "19.8.1",
"@angular/cli": "^19.2.24", "@angular/cli": "^20.3.32",
"@angular/compiler-cli": "^19.2.20", "@angular/compiler-cli": "^20.3.26",
"@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-methods": "^7.18.6",
"@compodoc/compodoc": "^1.2.1", "@compodoc/compodoc": "^2.0.0",
"@cypress/webpack-preprocessor": "^5.17.1", "@cypress/webpack-preprocessor": "^5.17.1",
"@lhci/cli": "^0.15.1", "@lhci/cli": "^0.15.1",
"@types/core-js": "^2.5.5", "@types/core-js": "^2.5.5",
"@types/create-hash": "^1.2.6",
"@types/create-hmac": "^1.1.3",
"@types/crypto-js": "^4.2.1", "@types/crypto-js": "^4.2.1",
"@types/es6-shim": "^0.31.39", "@types/es6-shim": "^0.31.39",
"@types/jasmine": "~5.1.4", "@types/jasmine": "~5.1.4",
"@types/lodash-es": "^4.17.3", "@types/lodash-es": "^4.17.3",
"@types/marked": "^4.3.0", "@types/marked": "^4.3.0",
"@types/node": "12.20.50", "@types/node": "12.20.50",
"@typescript-eslint/eslint-plugin": "8.31.1", "@types/randombytes": "^2.0.3",
"@typescript-eslint/parser": "8.31.1", "@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"core-js": "^2.5.4", "core-js": "^2.5.4",
"cypress": "^15.14.2", "cypress": "^15.19.0",
"cypress-file-upload": "^5.0.8", "cypress-file-upload": "^5.0.8",
"cypress-plugin-tab": "^1.0.5", "cypress-plugin-tab": "^1.0.5",
"cypress-real-events": "^1.8.1", "cypress-real-events": "^1.8.1",
@@ -135,13 +140,17 @@
"rimraf": "3.0.2", "rimraf": "3.0.2",
"ts-loader": "^9.2.8", "ts-loader": "^9.2.8",
"ts-node": "^3.3.0", "ts-node": "^3.3.0",
"typescript": "~5.8.3", "typescript": "~5.9.3",
"wait-on": "^6.0.1", "wait-on": "^9.0.10",
"watch": "^1.0.2" "watch": "^1.0.2"
}, },
"overrides": { "overrides": {
"ajv": "8.18.0", "ajv": "8.18.0",
"uuid": "11.1.1", "uuid": "11.1.1",
"lighthouse": "13.4.0" "lighthouse": "13.4.0",
"exceljs": {
"archiver": "^8.0.0",
"unzipper": "^0.12.5"
}
} }
} }
+281 -283
View File
@@ -1,292 +1,287 @@
<div class="main-container"> <div class="main-container">
<ng-container *ngIf="!router.url.includes('licensing')"> @if (!router.url.includes('licensing')) {
<div @if (freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)) {
*ngIf=" <div
freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0) class="alert alert-app-level alert-warning"
" id="demo-banner"
class="alert alert-app-level alert-warning" role="alert"
id="demo-banner"
role="alert"
>
<ng-container *ngIf="licenceProblem.value === null">
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - to upgrade contact
<contact-link classes="color-white" />
</div>
</div>
</div>
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
</ng-container>
<ng-container *ngIf="licenceProblem.value !== null">
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - Problem with licence
</div>
</div>
</div>
<a
(click)="licenceProblemDetails(licenceProblem.value)"
class="update-key cursor-pointer"
>More details</a
>
</ng-container>
</div>
<div
*ngIf="licenseExpiringDays && !freeTierBanner"
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
This license key will expire in {{ licenseExpiringDays }}
{{ licenseExpiringDays === 1 ? 'day' : 'days' }}. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
<a
*ngIf="!freeTierBanner"
routerLink="/licensing/update"
class="update-key"
>Update Licence Key</a
> >
</div> @if (licenceProblem.value === null) {
<div class="alert-items">
<div <div class="alert-item static">
*ngIf="appOverCapacity" <div class="alert-icon-wrapper">
class="alert alert-app-level alert-danger" <cds-icon
id="demo-banner" class="alert-icon"
role="alert" shape="warning-standard"
> ></cds-icon>
<div class="alert-items"> </div>
<div class="alert-item static"> <div class="alert-text">
<div class="alert-icon-wrapper"> Data Controller (FREE Tier) - to upgrade contact
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon> <contact-link classes="color-white" />
</div>
</div>
</div> </div>
<a routerLink="/licensing/update" class="update-key"
<div class="alert-text"> >Update Licence Key</a
The registered number of users exceeds the limit specified for your
license. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
<a
*ngIf="!licenseExpiringDays && !freeTierBanner"
routerLink="/licensing/update"
class="update-key"
>Update Licence Key</a
>
</div>
</ng-container>
<header class="app-header" *ngIf="!embed">
<!-- <button
*ngIf="
isMainRoute('view') ||
(isMainRoute('home') && !router.url.includes('licensing'))
"
class="header-hamburger-trigger"
(click)="toggleSidebar()"
type="button"
>
<span></span>
</button> -->
<div
*ngIf="
isMainRoute('view') ||
(isMainRoute('home') && !router.url.includes('licensing'))
"
(click)="toggleSidebar()"
type="button"
aria-label="Toggle sidebar"
class="cursor-pointer select-none ml-10 d-flex clr-justify-content-center clr-align-items-center"
>
<clr-icon size="24" shape="tree-view" aria-hidden="true"></clr-icon>
</div>
<div class="logo d-flex clr-align-items-center">
<a
*ngIf="!router.url.includes('deploy')"
href="#"
[routerLink]="['/']"
class="nav-link"
>
<img
class="without-text d-block d-md-none"
src="images/dc-logo.svg"
alt="datacontroller logo without text"
/>
<img
class="with-text d-none d-md-block"
src="images/datacontroller.svg"
alt="datacontroller logo"
/>
</a>
<a *ngIf="router.url.includes('deploy')">
<span class="clr-icon header-logo ml-10"></span>
</a>
</div>
<ng-container
*ngIf="
!router.url.includes('deploy') && !router.url.includes('licensing')
"
>
<div class="header-nav d-flex d-sm-none">
<clr-dropdown>
<button
class="nav-icon color-white-i"
clrDropdownTrigger
aria-label="toggle settings menu"
> >
Menu }
<!-- <clr-icon size="20" shape="bars"></clr-icon> --> @if (licenceProblem.value !== null) {
</button> <div class="alert-items">
<clr-dropdown-menu *clrIfOpen clrPosition="bottom-left"> <div class="alert-item static">
<a [routerLink]="['/view']" clrDropdownItem>VIEW</a> <div class="alert-icon-wrapper">
<a [routerLink]="['/home']" clrDropdownItem>LOAD</a> <cds-icon
<a [routerLink]="['/review/submitted']" clrDropdownItem>REVIEW</a> class="alert-icon"
</clr-dropdown-menu> shape="warning-standard"
</clr-dropdown> ></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - Problem with licence
</div>
</div>
</div>
<a
(click)="licenceProblemDetails(licenceProblem.value)"
class="update-key cursor-pointer"
>More details</a
>
}
</div> </div>
}
<div class="header-nav d-none d-sm-flex"> @if (licenseExpiringDays && !freeTierBanner) {
<a <div
[routerLink]="['/view']" class="alert alert-app-level alert-danger"
class="nav-link nav-text" id="demo-banner"
routerLinkActive="active" role="alert"
>VIEW</a >
> <div class="alert-items">
<a <div class="alert-item static">
[routerLink]="['/home']" <div class="alert-icon-wrapper">
class="nav-link nav-text" <cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
[class.active]=" </div>
router.url.includes('editor') || <div class="alert-text">
router.url.includes('edit-record') || This license key will expire in {{ licenseExpiringDays }}
router.url.includes('home') {{ licenseExpiringDays === 1 ? 'day' : 'days' }}. Please contact
" <contact-link classes="color-white" />
>LOAD</a or your reseller to arrange additional licence for site id
> {{ syssite.getValue() }}.
<a </div>
[routerLink]="['/review/submitted']" </div>
[class.active]=" </div>
router.url.includes('submitted') || @if (!freeTierBanner) {
router.url.includes('approve') || <a routerLink="/licensing/update" class="update-key"
router.url.includes('history') >Update Licence Key</a
" >
class="nav-link nav-text cursor-pointer" }
>REVIEW</a
>
</div> </div>
</ng-container> }
@if (appOverCapacity) {
<div
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
The registered number of users exceeds the limit specified for
your license. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
@if (!licenseExpiringDays && !freeTierBanner) {
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
</div>
}
}
<app-header-actions></app-header-actions> @if (!embed) {
</header> <header class="app-header">
<nav <!-- <button
*ngIf=" *ngIf="
!embed && isMainRoute('view') ||
(router.url.includes('submitted') || (isMainRoute('home') && !router.url.includes('licensing'))
router.url.includes('approve') || "
router.url.includes('history')) class="header-hamburger-trigger"
" (click)="toggleSidebar()"
class="subnav" type="button"
> >
<ul class="nav"> <span></span>
<li class="nav-item"> </button> -->
<a @if (
[routerLink]="['/review/submitted']" isMainRoute('view') ||
class="nav-link nav-text" (isMainRoute('home') && !router.url.includes('licensing'))
routerLinkActive="active" ) {
>SUBMIT</a <div
(click)="toggleSidebar()"
type="button"
aria-label="Toggle sidebar"
class="cursor-pointer select-none ml-10 d-flex clr-justify-content-center clr-align-items-center"
> >
</li> <clr-icon size="24" shape="tree-view" aria-hidden="true"></clr-icon>
<li class="nav-item"> </div>
<a }
[routerLink]="['/review/approve']" <div class="logo d-flex clr-align-items-center">
class="nav-link nav-text" @if (!router.url.includes('deploy')) {
[class.active]="router.url.includes('approve')" <a href="#" [routerLink]="['/']" class="nav-link">
routerLinkActive="active" <img
>APPROVE</a class="without-text d-block d-md-none"
> src="images/dc-logo.svg"
</li> alt="datacontroller logo without text"
<li class="nav-item"> />
<a <img
[routerLink]="['/review/history']" class="with-text d-none d-md-block"
class="nav-link nav-text" src="images/datacontroller.svg"
routerLinkActive="active" alt="datacontroller logo"
>HISTORY</a />
> </a>
</li> }
</ul> @if (router.url.includes('deploy')) {
</nav> <a>
<span class="clr-icon header-logo ml-10"></span>
</a>
}
</div>
@if (
!router.url.includes('deploy') && !router.url.includes('licensing')
) {
<div class="header-nav d-flex d-sm-none">
<clr-dropdown>
<button
class="nav-icon color-white-i"
clrDropdownTrigger
aria-label="toggle settings menu"
>
Menu
<!-- <clr-icon size="20" shape="bars"></clr-icon> -->
</button>
<clr-dropdown-menu *clrIfOpen clrPosition="bottom-left">
<a [routerLink]="['/view']" clrDropdownItem>VIEW</a>
<a [routerLink]="['/home']" clrDropdownItem>LOAD</a>
<a [routerLink]="['/review/submitted']" clrDropdownItem>REVIEW</a>
</clr-dropdown-menu>
</clr-dropdown>
</div>
<div class="header-nav d-none d-sm-flex">
<a
[routerLink]="['/view']"
class="nav-link nav-text"
routerLinkActive="active"
>VIEW</a
>
<a
[routerLink]="['/home']"
class="nav-link nav-text"
[class.active]="
router.url.includes('editor') ||
router.url.includes('edit-record') ||
router.url.includes('home')
"
>LOAD</a
>
<a
[routerLink]="['/review/submitted']"
[class.active]="
router.url.includes('submitted') ||
router.url.includes('approve') ||
router.url.includes('history')
"
class="nav-link nav-text cursor-pointer"
>REVIEW</a
>
</div>
}
<app-header-actions></app-header-actions>
</header>
}
@if (
!embed &&
(router.url.includes('submitted') ||
router.url.includes('approve') ||
router.url.includes('history'))
) {
<nav class="subnav">
<ul class="nav">
<li class="nav-item">
<a
[routerLink]="['/review/submitted']"
class="nav-link nav-text"
routerLinkActive="active"
>SUBMIT</a
>
</li>
<li class="nav-item">
<a
[routerLink]="['/review/approve']"
class="nav-link nav-text"
[class.active]="router.url.includes('approve')"
routerLinkActive="active"
>APPROVE</a
>
</li>
<li class="nav-item">
<a
[routerLink]="['/review/history']"
class="nav-link nav-text"
routerLinkActive="active"
>HISTORY</a
>
</li>
</ul>
</nav>
}
<app-alerts *ngIf="!errTop"></app-alerts> @if (!errTop) {
<app-alerts></app-alerts>
}
<app-requests-modal [(opened)]="requestsModal"></app-requests-modal> <app-requests-modal [(opened)]="requestsModal"></app-requests-modal>
<app-excel-password-modal></app-excel-password-modal> <app-excel-password-modal></app-excel-password-modal>
<!-- <app-terms *ngIf="showRegistration"></app-terms> --> <!-- <app-terms *ngIf="showRegistration"></app-terms> -->
<!-- VA embed, back to Editor button --> <!-- VA embed, back to Editor button -->
<div @if (embed === 'va' && vaEditorLibds && !isMainRoute('/editor')) {
*ngIf="embed === 'va' && vaEditorLibds && !isMainRoute('/editor')" <div class="va-back-bar">
class="va-back-bar" <button
> type="button"
<button class="btn btn-sm btn-primary"
type="button" (click)="backToEditor()"
class="btn btn-sm btn-primary" >
(click)="backToEditor()" <clr-icon
> aria-hidden="true"
<clr-icon shape="caret"
aria-hidden="true" dir="left"
shape="caret" size="16"
dir="left" ></clr-icon>
size="16" Back to Edit table
></clr-icon> </button>
Back to Edit table </div>
</button> }
</div>
<router-outlet *ngIf="startupDataLoaded"></router-outlet> @if (startupDataLoaded) {
<router-outlet></router-outlet>
}
<app-login></app-login> <app-login></app-login>
<app-alerts *ngIf="errTop"></app-alerts> @if (errTop) {
<app-info-modal <app-alerts></app-alerts>
*ngFor="let abort of sasjsAborts" }
[data]="abort" @for (abort of sasjsAborts; track abort) {
[forceReload]="!startupDataLoaded && sasjsAborts.length === 1" <app-info-modal
(onConfirmModalClick)="closeAbortModal(abort.id!)" [data]="abort"
> [forceReload]="!startupDataLoaded && sasjsAborts.length === 1"
</app-info-modal> (onConfirmModalClick)="closeAbortModal(abort.id!)"
>
</app-info-modal>
}
<clr-modal <clr-modal
appDragNdrop appDragNdrop
@@ -309,17 +304,20 @@
</div> </div>
<!-- App Loading Page --> <!-- App Loading Page -->
<div *ngIf="!startupDataLoaded" class="app-loading"> @if (!startupDataLoaded) {
<img <div class="app-loading">
class="loading-logo" <img
src="images/datacontroller.svg" class="loading-logo"
alt="datacontroller logo" src="images/datacontroller.svg"
/> alt="datacontroller logo"
/>
<div *ngIf="appActive === null" class="slider"> @if (appActive === null) {
<div class="line"></div> <div class="slider">
<div class="subline inc"></div> <div class="line"></div>
<div class="subline dec"></div> <div class="subline inc"></div>
<div class="subline dec"></div>
</div>
}
</div> </div>
</div> }
<!-- /App Loading Page --> <!-- /App Loading Page -->
+92 -91
View File
@@ -1,100 +1,101 @@
<main class="content-area position-relative"> <main class="content-area position-relative">
<div class="clr-row"> <div class="clr-row">
<!-- T&C section --> <!-- T&C section -->
<div *ngIf="step === 0" id="TCS" class="card"> @if (step === 0) {
<div class="card-header">Terms and Conditions</div> <div id="TCS" class="card">
<div class="card-block"> <div class="card-header">Terms and Conditions</div>
<div class="card-text"> <div class="card-block">
<p class="mt-0"> <div class="card-text">
The Demo version of Data Controller is free for EVALUATION purposes <p class="mt-0">
only. Before proceeding with configuration, please confirm that you The Demo version of Data Controller is free for EVALUATION
have read, understood, and agreed to the purposes only. Before proceeding with configuration, please
<a confirm that you have read, understood, and agreed to the
href="https://docs.datacontroller.io/evaluation-licence-agreement" <a
target="_blank" href="https://docs.datacontroller.io/evaluation-licence-agreement"
>Data Controller for SAS&#169; Evaluation Agreement</a target="_blank"
>. >Data Controller for SAS&#169; Evaluation Agreement</a
</p> >.
</p>
</div>
<hr class="light" />
<clr-checkbox-wrapper>
<input clrCheckbox type="checkbox" (change)="termsAgreeChange()" />
<label
>I have read and agree to the terms of the
<a
href="https://docs.datacontroller.io/evaluation-licence-agreement"
target="_blank"
>Data Controller for SAS&#169; Evaluation Agreement</a
></label
>
</clr-checkbox-wrapper>
<!-- <hr />
<div class="clr-checkbox-wrapper">
<input
[(ngModel)]="autodeploy"
type="checkbox"
id="checkbox2"
class="clr-checkbox"
checked
/>
<label for="checkbox2"
>Autodeploy
{{ !jsonFile ? '(json file is not available)' : '' }}</label
>
</div> -->
</div> </div>
<hr class="light" />
<clr-checkbox-wrapper>
<input clrCheckbox type="checkbox" (change)="termsAgreeChange()" />
<label
>I have read and agree to the terms of the
<a
href="https://docs.datacontroller.io/evaluation-licence-agreement"
target="_blank"
>Data Controller for SAS&#169; Evaluation Agreement</a
></label
>
</clr-checkbox-wrapper>
<!-- <hr />
<div class="clr-checkbox-wrapper">
<input
[(ngModel)]="autodeploy"
type="checkbox"
id="checkbox2"
class="clr-checkbox"
checked
/>
<label for="checkbox2"
>Autodeploy
{{ !jsonFile ? '(json file is not available)' : '' }}</label
>
</div> -->
</div> </div>
</div> }
</div> </div>
<!-- T&C section end --> <!-- T&C section end -->
<ng-container *ngIf="step > 0" [ngSwitch]="true"> @if (step > 0) {
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.SasViya"> @switch (true) {
<div *ngIf="autodeploy" class="autodeploy-section card"> @case (sasJsConfig.serverType === ServerType.SasViya) {
<app-automatic-deploy @if (autodeploy) {
[sasJs]="sasJs" <div class="autodeploy-section card">
[sasJsConfig]="sasJsConfig" <app-automatic-deploy
[dcAdapterSettings]="dcAdapterSettings" [sasJs]="sasJs"
[appLoc]="appLoc" [sasJsConfig]="sasJsConfig"
[dcPath]="dcPath" [dcAdapterSettings]="dcAdapterSettings"
[selectedAdminGroup]="selectedAdminGroup" [appLoc]="appLoc"
(onNavigateToHome)="onNavigateToHome()" [dcPath]="dcPath"
></app-automatic-deploy> [selectedAdminGroup]="selectedAdminGroup"
</div> (onNavigateToHome)="onNavigateToHome()"
></app-automatic-deploy>
<div *ngIf="!autodeploy" id="mainbody" class="card"> </div>
<app-manual-deploy }
[sasJs]="sasJs" @if (!autodeploy) {
[sasJsConfig]="sasJsConfig" <div id="mainbody" class="card">
[dcAdapterSettings]="dcAdapterSettings" <app-manual-deploy
(onNavigateToHome)="onNavigateToHome()" [sasJs]="sasJs"
></app-manual-deploy> [sasJsConfig]="sasJsConfig"
</div> [dcAdapterSettings]="dcAdapterSettings"
</ng-container> (onNavigateToHome)="onNavigateToHome()"
></app-manual-deploy>
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.Sasjs"> </div>
<div class="autodeploy-section card"> }
<app-sasjs-configurator }
[sasJs]="sasJs" @case (sasJsConfig.serverType === ServerType.Sasjs) {
[sasJsConfig]="sasJsConfig" <div class="autodeploy-section card">
[dcAdapterSettings]="dcAdapterSettings" <app-sasjs-configurator
(onNavigateToHome)="onNavigateToHome()" [sasJs]="sasJs"
></app-sasjs-configurator> [sasJsConfig]="sasJsConfig"
</div> [dcAdapterSettings]="dcAdapterSettings"
</ng-container> (onNavigateToHome)="onNavigateToHome()"
></app-sasjs-configurator>
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.Sas9"> </div>
<div class="autodeploy-section card"> }
<app-sasjs-configurator @case (sasJsConfig.serverType === ServerType.Sas9) {
[sasJs]="sasJs" <div class="autodeploy-section card">
[sasJsConfig]="sasJsConfig" <app-sasjs-configurator
[dcAdapterSettings]="dcAdapterSettings" [sasJs]="sasJs"
(onNavigateToHome)="onNavigateToHome()" [sasJsConfig]="sasJsConfig"
></app-sasjs-configurator> [dcAdapterSettings]="dcAdapterSettings"
</div> (onNavigateToHome)="onNavigateToHome()"
</ng-container> ></app-sasjs-configurator>
</ng-container> </div>
}
}
}
</main> </main>
@@ -1,91 +1,80 @@
<div *ngIf="autodeploying" class="auto-deploy"> @if (autodeploying) {
<div class="spinner-box"> <div class="auto-deploy">
<ng-container *ngIf="!autodeployDone"> <div class="spinner-box">
<span class="spinner spinner-md"> Loading... </span> @if (!autodeployDone) {
<p>Deploying...</p> <span class="spinner spinner-md"> Loading... </span>
</ng-container> <p>Deploying...</p>
}
<ng-container *ngIf="autodeployDone"> @if (autodeployDone) {
<p class="m-0 align-self-start">Done</p> <p class="m-0 align-self-start">Done</p>
<hr class="w-100" /> <hr class="w-100" />
@if (autoDeployStatus.deployServicePack !== null) {
<div <div class="deploy-status-row">
*ngIf="autoDeployStatus.deployServicePack !== null" @if (autoDeployStatus.deployServicePack === true) {
class="deploy-status-row" <clr-icon
> class="deploy-success"
<clr-icon shape="success-standard"
*ngIf="autoDeployStatus.deployServicePack === true" ></clr-icon>
class="deploy-success" }
shape="success-standard" @if (!autoDeployStatus.deployServicePack === false) {
></clr-icon> <clr-icon class="deploy-error" shape="times-circle"></clr-icon>
<clr-icon }
*ngIf="!autoDeployStatus.deployServicePack === false" <p>Deploy SAS Jobs</p>
class="deploy-error" </div>
shape="times-circle" }
></clr-icon> @if (autoDeployStatus.runMakeData !== null) {
<p>Deploy SAS Jobs</p> <div class="deploy-status-row">
</div> @if (autoDeployStatus.runMakeData) {
<clr-icon
<div class="deploy-success"
*ngIf="autoDeployStatus.runMakeData !== null" shape="success-standard"
class="deploy-status-row" ></clr-icon>
> }
<clr-icon @if (autoDeployStatus.runMakeData === false) {
*ngIf="autoDeployStatus.runMakeData" <clr-icon class="deploy-error" shape="times-circle"></clr-icon>
class="deploy-success" }
shape="success-standard" <p>Create database</p>
></clr-icon> </div>
<clr-icon }
*ngIf="autoDeployStatus.runMakeData === false" <hr class="w-100" />
class="deploy-error" <div class="buttons">
shape="times-circle" <button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
></clr-icon> @if (
<p>Create database</p>
</div>
<hr class="w-100" />
<div class="buttons">
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
<clr-icon
*ngIf="
autoDeployStatus.deployServicePack === false || autoDeployStatus.deployServicePack === false ||
autoDeployStatus.runMakeData === false autoDeployStatus.runMakeData === false
) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
LAUNCH
</button>
<button
(click)="
downloadFile(makeDataResponse, 'create-database-log', 'txt')
" "
class="deploy-error" class="btn btn-primary-outline mt-15 mr-0"
shape="times-circle" >
></clr-icon> Download log
LAUNCH </button>
</button> </div>
<hr class="w-100" />
<button <div class="buttons">
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')" <button
class="btn btn-primary-outline mt-15 mr-0" (click)="autodeploying = false; autodeployDone = false"
> class="btn btn-primary-outline mt-15 mr-0 align-self-end"
Download log >
</button> Close
</div> </button>
<button
<hr class="w-100" /> (click)="openSasRequestsModal()"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
<div class="buttons"> >
<button SAS Requests
(click)="autodeploying = false; autodeployDone = false" </button>
class="btn btn-primary-outline mt-15 mr-0 align-self-end" </div>
> }
Close </div>
</button>
<button
(click)="openSasRequestsModal()"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
>
SAS Requests
</button>
</div>
</ng-container>
</div> </div>
</div> }
<h4 class="text-center my-15">Viya Deploy</h4> <h4 class="text-center my-15">Viya Deploy</h4>
<hr /> <hr />
@@ -106,21 +95,18 @@
<label for="dcloc" class="mt-20 clr-control-label">SAS Admin group</label> <label for="dcloc" class="mt-20 clr-control-label">SAS Admin group</label>
<div class="mb-10 clr-control-container"> <div class="mb-10 clr-control-container">
<div class="clr-input-wrapper small-mt"> <div class="clr-input-wrapper small-mt">
<select @if (!adminGroupsLoading) {
*ngIf="!adminGroupsLoading" <select clrSelect name="options" [(ngModel)]="selectedAdminGroup">
clrSelect @for (adminGroup of adminGroups; track adminGroup) {
name="options" <option [value]="adminGroup.id">
[(ngModel)]="selectedAdminGroup" {{ adminGroup.name }}
> </option>
<option *ngFor="let adminGroup of adminGroups" [value]="adminGroup.id"> }
{{ adminGroup.name }} </select>
</option> }
</select> @if (adminGroupsLoading) {
<clr-spinner <clr-spinner clrInline class="spinner-sm"></clr-spinner>
clrInline }
class="spinner-sm"
*ngIf="adminGroupsLoading"
></clr-spinner>
</div> </div>
</div> </div>
@@ -129,36 +115,34 @@
> >
<div class="mb-10 clr-control-container"> <div class="mb-10 clr-control-container">
<div class="clr-input-wrapper small-mt"> <div class="clr-input-wrapper small-mt">
<select @if (!computeContextsLoading) {
*ngIf="!computeContextsLoading" <select
clrSelect clrSelect
name="options" name="options"
(ngModelChange)="onComputeContextChange($event)" (ngModelChange)="onComputeContextChange($event)"
[(ngModel)]="selectedComputeContext" [(ngModel)]="selectedComputeContext"
>
<option
*ngFor="let computeContext of computeContexts"
[value]="computeContext.id"
> >
{{ computeContext.name }} @for (computeContext of computeContexts; track computeContext) {
</option> <option [value]="computeContext.id">
</select> {{ computeContext.name }}
<clr-spinner </option>
clrInline }
class="spinner-sm" </select>
*ngIf="computeContextsLoading" }
></clr-spinner> @if (computeContextsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
</div> </div>
</div> </div>
<ng-container *ngIf="runningAsUser"> @if (runningAsUser) {
<label for="dcloc" class="mt-20 clr-control-label">Running as user:</label> <label for="dcloc" class="mt-20 clr-control-label">Running as user:</label>
<div class="mb-10 clr-control-container"> <div class="mb-10 clr-control-container">
<div class="clr-input-wrapper"> <div class="clr-input-wrapper">
<p class="mt-0">{{ runningAsUser }}</p> <p class="mt-0">{{ runningAsUser }}</p>
</div> </div>
</div> </div>
</ng-container> }
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) --> <!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
@@ -169,7 +153,7 @@
(click)="recreateDatabaseClicked($event)" (click)="recreateDatabaseClicked($event)"
type="checkbox" type="checkbox"
checked checked
/> />
<label>Recreate database</label> <label>Recreate database</label>
</clr-checkbox-wrapper> --> </clr-checkbox-wrapper> -->
@@ -185,18 +169,18 @@
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) --> <!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
<!-- <button <!-- <button
(click)="executeJson()" (click)="executeJson()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10" class="btn-autodeploy btn btn-primary d-inline-block mr-10"
[disabled]="!jsonFile" [disabled]="!jsonFile"
> >
Deploy {{ !jsonFile ? '(json file is not available)' : '' }} Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
</button> --> </button> -->
<!-- <button <!-- <button
(click)="uploadJsonAuto.click()" (click)="uploadJsonAuto.click()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10" class="btn-autodeploy btn btn-primary d-inline-block mr-10"
> >
Upload different file to deploy Upload different file to deploy
</button> </button>
<input <input
#uploadJsonAuto #uploadJsonAuto
@@ -204,7 +188,7 @@
hidden hidden
(click)="clearUploadInput($event)" (click)="clearUploadInput($event)"
(change)="onJsonFileChange($event)" (change)="onJsonFileChange($event)"
/> --> /> -->
<clr-modal [(clrModalOpen)]="recreateDatabaseModal" [clrModalClosable]="false"> <clr-modal [(clrModalOpen)]="recreateDatabaseModal" [clrModalClosable]="false">
<h3 class="modal-title">Warning</h3> <h3 class="modal-title">Warning</h3>
@@ -10,29 +10,29 @@
</div> </div>
<div class="card-block"> <div class="card-block">
<!-- <div class="card-title"> <!-- <div class="card-title">
Client Details Client Details
</div> --> </div> -->
<div *ngIf="needsLogin" id="loginForm" class="d-none"> @if (needsLogin) {
<p class="mb-10">Please log in first</p> <div id="loginForm" class="d-none">
<label for="username" class="clr-control-label">Username</label> <p class="mb-10">Please log in first</p>
<div class="mb-10 clr-control-container"> <label for="username" class="clr-control-label">Username</label>
<div class="clr-input-wrapper"> <div class="mb-10 clr-control-container">
<input type="text" id="username" class="clr-input" /> <div class="clr-input-wrapper">
<input type="text" id="username" class="clr-input" />
</div>
</div> </div>
</div> <label for="password" class="clr-control-label">Password</label>
<div class="mb-10 clr-control-container">
<label for="password" class="clr-control-label">Password</label> <div class="clr-input-wrapper">
<div class="mb-10 clr-control-container"> <input type="password" id="password" class="clr-input" />
<div class="clr-input-wrapper"> </div>
<input type="password" id="password" class="clr-input" />
</div> </div>
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
</div> </div>
}
<button class="btn btn-primary d-none" id="loginBtn">Log in</button> @if (!needsLogin) {
</div>
<ng-container *ngIf="!needsLogin">
<form> <form>
<div class="clr-form-control"> <div class="clr-form-control">
<label for="select-full" class="clr-control-label">Admin group</label> <label for="select-full" class="clr-control-label">Admin group</label>
@@ -45,17 +45,15 @@
id="adminGroupsSelect" id="adminGroupsSelect"
class="clr-select" class="clr-select"
> >
<option @for (adminGroup of adminGroups; track adminGroup) {
*ngFor="let adminGroup of adminGroups" <option [value]="adminGroup.id">
[value]="adminGroup.id" {{ adminGroup.name }}
> </option>
{{ adminGroup.name }} }
</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div class="clr-form-control"> <div class="clr-form-control">
<div [class.hidden]="contextsLoading"> <div [class.hidden]="contextsLoading">
<label for="select-full" class="clr-control-label">Context</label> <label for="select-full" class="clr-control-label">Context</label>
@@ -67,15 +65,14 @@
name="selectedContext" name="selectedContext"
class="clr-select" class="clr-select"
> >
<option @for (context of allContexts; track context) {
*ngFor="let context of allContexts" <option [value]="context.name">
[value]="context.name" {{ context.name }}
> @if ((context.attributes | json) != '{}') {
{{ context.name }} <span>( {{ context.attributes.sysUserId }} )</span>
<span *ngIf="(context.attributes | json) != '{}'" }
>( {{ context.attributes.sysUserId }} )</span </option>
> }
</option>
</select> </select>
</div> </div>
</div> </div>
@@ -88,14 +85,12 @@
<clr-icon shape="play"></clr-icon> <clr-icon shape="play"></clr-icon>
</button> </button>
</div> </div>
<div [class.hidden]="!contextsLoading" class="d-flex"> <div [class.hidden]="!contextsLoading" class="d-flex">
<span class="spinner spinner-inline mr-10"> <span class="spinner spinner-inline mr-10">
Loading contexts... Loading contexts...
</span> </span>
<span> Loading contexts... </span> <span> Loading contexts... </span>
</div> </div>
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label> <label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
<div class="mb-10 clr-control-container"> <div class="mb-10 clr-control-container">
<div class="clr-input-wrapper"> <div class="clr-input-wrapper">
@@ -110,7 +105,6 @@
/> />
</div> </div>
</div> </div>
<p class="mt-10"> <p class="mt-10">
Select JSON file to upload (json build file preloaded): Select JSON file to upload (json build file preloaded):
</p> </p>
@@ -121,10 +115,9 @@
(change)="onJsonFileChange($event)" (change)="onJsonFileChange($event)"
/> />
<!-- <button *ngIf="downloadFileBtn" (click)="downloadSasPrecodeFile()" style="width: 40px; min-width: 0;" class="btn btn-sm btn-icon"> <!-- <button *ngIf="downloadFileBtn" (click)="downloadSasPrecodeFile()" style="width: 40px; min-width: 0;" class="btn btn-sm btn-icon">
<clr-icon shape="download"></clr-icon> <clr-icon shape="download"></clr-icon>
</button> --> </button> -->
</div> </div>
<div class="mt-20 d-flex align-items-center"> <div class="mt-20 d-flex align-items-center">
<button <button
(click)="executeJson()" (click)="executeJson()"
@@ -135,13 +128,13 @@
> >
SUBMIT JSON SUBMIT JSON
</button> </button>
<span *ngIf="isJsonSubmitted">JSON Submitted Successfully</span> @if (isJsonSubmitted) {
<span>JSON Submitted Successfully</span>
}
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3"> <!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
Loading... Loading...
</span> --> </span> -->
</div> </div>
<p class="mt-10">Select SAS file to upload:</p> <p class="mt-10">Select SAS file to upload:</p>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<input <input
@@ -149,15 +142,15 @@
(click)="clearUploadInput($event)" (click)="clearUploadInput($event)"
(change)="onSasFileChange($event); downloadFileBtn = true" (change)="onSasFileChange($event); downloadFileBtn = true"
/> />
<button @if (downloadFileBtn) {
*ngIf="downloadFileBtn" <button
(click)="downloadSasPrecodeFile()" (click)="downloadSasPrecodeFile()"
class="btn btn-sm btn-icon min-w-0 w-40" class="btn btn-sm btn-icon min-w-0 w-40"
> >
<clr-icon shape="download"></clr-icon> <clr-icon shape="download"></clr-icon>
</button> </button>
}
</div> </div>
<div class="mt-20 d-flex align-items-center"> <div class="mt-20 d-flex align-items-center">
<button <button
(click)="executeSAS()" (click)="executeSAS()"
@@ -168,17 +161,14 @@
> >
SUBMIT SUBMIT
</button> </button>
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3"> <!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
Loading... Loading...
</span> --> </span> -->
</div> </div>
</div> </div>
@if (jobLog.length > 0) {
<ng-container *ngIf="jobLog.length > 0">
<p class="mb-0 mt-10">File execute completed</p> <p class="mb-0 mt-10">File execute completed</p>
<hr /> <hr />
<div> <div>
<button <button
(click)="downloadFile(jobLog, 'execute-script-log', 'txt')" (click)="downloadFile(jobLog, 'execute-script-log', 'txt')"
@@ -187,8 +177,7 @@
Download log Download log
</button> </button>
</div> </div>
</ng-container> }
<button <button
[clrLoading]="createDatabaseLoading" [clrLoading]="createDatabaseLoading"
(click)="createDatabase()" (click)="createDatabase()"
@@ -196,52 +185,45 @@
> >
Create Database Create Database
</button> </button>
@if (makeDataResponse.length > 0) {
<ng-container *ngIf="makeDataResponse.length > 0">
<p class="mb-0 mt-10">Create Database Completed</p> <p class="mb-0 mt-10">Create Database Completed</p>
<hr /> <hr />
@if (makeDataResponse.length > 0) {
<div *ngIf="makeDataResponse.length > 0" class="log-wrapper"> <div class="log-wrapper">
{{ makeDataResponse }} {{ makeDataResponse }}
</div> </div>
}
<button (click)="navigateToHome()" class="btn btn-primary mt-15"> <button (click)="navigateToHome()" class="btn btn-primary mt-15">
Let's get started Let's get started
</button> </button>
<button <button
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')" (click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
class="btn btn-primary mt-15" class="btn btn-primary mt-15"
> >
Download log Download log
</button> </button>
<button (click)="validateDeploy()" class="btn btn-primary mt-15"> <button (click)="validateDeploy()" class="btn btn-primary mt-15">
Validate Validate
</button> </button>
@if (validationState !== 'none' || isValidating) {
<div <div class="validation-bar">
*ngIf="validationState !== 'none' || isValidating" @if (isValidating) {
class="validation-bar" <span class="spinner spinner-inline mr-10">
> Validating deploy...
<ng-container *ngIf="isValidating"> </span>
<span class="spinner spinner-inline mr-10"> <span> Validating deploy... </span>
Validating deploy... }
</span> @if (!isValidating && validationState === 'error') {
<span> Validating deploy... </span> <clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
</ng-container> <span> Validation failed </span>
}
<ng-container *ngIf="!isValidating && validationState === 'error'"> @if (!isValidating && validationState === 'success') {
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon> <clr-icon shape="check-circle" class="is-success"></clr-icon>
<span> Validation failed </span> <span> Validation succeeded </span>
</ng-container> }
</div>
<ng-container *ngIf="!isValidating && validationState === 'success'"> }
<clr-icon shape="check-circle" class="is-success"></clr-icon> }
<span> Validation succeeded </span>
</ng-container>
</div>
</ng-container>
</form> </form>
</ng-container> }
</div> </div>
@@ -1,6 +1,8 @@
<div *ngIf="loading" class="thinProgress progresStatic progress loop"> @if (loading) {
<progress></progress> <div class="thinProgress progresStatic progress loop">
</div> <progress></progress>
</div>
}
<h4 class="text-center my-15">Sasjs Deploy</h4> <h4 class="text-center my-15">Sasjs Deploy</h4>
<hr class="light" /> <hr class="light" />
@@ -27,13 +29,13 @@
</div> </div>
<!-- <button <!-- <button
(click)="directoryBrowse.click()" (click)="directoryBrowse.click()"
class="mt-15 text-center" class="mt-15 text-center"
class="btn btn-sm btn-outline" class="btn btn-sm btn-outline"
> >
Browse Browse
</button> </button>
<input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> --> <input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> -->
</div> </div>
<p class="m-0 mt-10"> <p class="m-0 mt-10">
@@ -45,12 +47,11 @@
<label class="mt-20 clr-control-label">Data Controller Admin group</label> <label class="mt-20 clr-control-label">Data Controller Admin group</label>
<clr-select-container class="mb-10 mt-0 w-50vw"> <clr-select-container class="mb-10 mt-0 w-50vw">
<select [(ngModel)]="dcAdminGroup" clrSelect> <select [(ngModel)]="dcAdminGroup" clrSelect>
<option @for (adminGroup of dcAdminGroupList; track adminGroup) {
*ngFor="let adminGroup of dcAdminGroupList" <option [value]="adminGroup.GROUPNAME">
[value]="adminGroup.GROUPNAME" {{ adminGroup.GROUPNAME }} - {{ adminGroup.GROUPDESC }}
> </option>
{{ adminGroup.GROUPNAME }} - {{ adminGroup.GROUPDESC }} }
</option>
</select> </select>
</clr-select-container> </clr-select-container>
@@ -84,6 +84,20 @@
status="warning" status="warning"
></clr-icon> ></clr-icon>
<!-- SOFTREGEX: value doesn't match the expected pattern, but
submission is still allowed (unlike HARDREGEX, which is
surfaced via the existing invalid-data class below). -->
<clr-icon
*ngIf="
!currentRecordErrors.includes(colIndex) &&
currentRecordWarningCols.includes(col.key)
"
class="flex-unset position-absolute entry-input-left-offset"
shape="error-standard"
status="warning"
title="Value does not match the expected pattern"
></clr-icon>
<ng-container *ngSwitchCase="'numeric'"> <ng-container *ngSwitchCase="'numeric'">
<clr-input-container <clr-input-container
*ngIf=" *ngIf="
@@ -55,6 +55,7 @@ export class EditRecordComponent implements OnInit {
@Output() onPreviousRecord: EventEmitter<any> = new EventEmitter<any>() @Output() onPreviousRecord: EventEmitter<any> = new EventEmitter<any>()
public currentRecordInvalidCols: string[] = [] public currentRecordInvalidCols: string[] = []
public currentRecordWarningCols: string[] = []
public generateEditRecordUrlLoading: boolean = false public generateEditRecordUrlLoading: boolean = false
public generatedRecordUrl: string | null = null public generatedRecordUrl: string | null = null
public addRecordUrl: string | null = null public addRecordUrl: string | null = null
@@ -120,9 +121,12 @@ export class EditRecordComponent implements OnInit {
*/ */
private revalidateRecordCol(colName: string, value: any) { private revalidateRecordCol(colName: string, value: any) {
const colRules = this.currentRecordValidator?.getRule(colName) const colRules = this.currentRecordValidator?.getRule(colName)
this.validateRecordCol(colRules, value).then((valid: boolean) => this.validateRecordCol(colRules, value).then((valid: boolean) =>
this.updateValidationState(colName, valid) this.updateValidationState(colName, valid)
) )
this.updateWarningState(colName, value)
} }
/** /**
@@ -221,6 +225,8 @@ export class EditRecordComponent implements OnInit {
this.tryAutoPopulateNotNull(event, colName, colRules, value) this.tryAutoPopulateNotNull(event, colName, colRules, value)
} }
}) })
this.updateWarningState(colName, value)
}) })
} }
@@ -237,6 +243,22 @@ export class EditRecordComponent implements OnInit {
} }
} }
/**
* Updates the SOFTREGEX warning columns list — the modal's equivalent of
* makeRegexWarningRenderer in the grid (see DcValidator.failsSoftRegex).
*/
private updateWarningState(colName: string, value: any): void {
const failsSoftRegex =
this.currentRecordValidator?.failsSoftRegex(colName, value) ?? false
const index = this.currentRecordWarningCols.indexOf(colName)
if (!failsSoftRegex && index > -1) {
this.currentRecordWarningCols.splice(index, 1)
} else if (failsSoftRegex && index < 0) {
this.currentRecordWarningCols.push(colName)
}
}
/** /**
* Auto-populates NOTNULL default value when the field is empty and has a default * Auto-populates NOTNULL default value when the field is empty and has a default
*/ */
@@ -264,6 +286,8 @@ export class EditRecordComponent implements OnInit {
this.validateRecordCol(colRules, defaultValue).then((isValid: boolean) => { this.validateRecordCol(colRules, defaultValue).then((isValid: boolean) => {
this.updateValidationState(colName, isValid) this.updateValidationState(colName, isValid)
}) })
this.updateWarningState(colName, defaultValue)
} }
onNextRecordClick() { onNextRecordClick() {
@@ -1,7 +1,9 @@
<div> <div>
<p class="m-0" *ngFor="let state of processedStates; let i = index"> @for (state of processedStates; track state; let i = $index) {
{{ state }} <p class="m-0">
</p> {{ state }}
</p>
}
</div> </div>
<div class="text-center"> <div class="text-center">
<span class="spinner spinner-sm vertical-align-middle">Loading...</span> <span class="spinner spinner-sm vertical-align-middle">Loading...</span>
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,4 +1,3 @@
.va-filter-controls { .va-filter-controls {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -23,3 +22,8 @@
font-style: italic; font-style: italic;
color: #0072a3; color: #0072a3;
} }
.va-disabled-text {
font-style: italic;
opacity: 0.7;
}
+411 -98
View File
@@ -11,7 +11,7 @@ import {
ViewEncapsulation ViewEncapsulation
} from '@angular/core' } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import Handsontable from 'handsontable' import Handsontable, { CellRange } from 'handsontable'
import { Subject, Subscription } from 'rxjs' import { Subject, Subscription } from 'rxjs'
import { sanitiseForSas } from '../shared/utils/sanitise' import { sanitiseForSas } from '../shared/utils/sanitise'
import { SasStoreService } from '../services/sas-store.service' import { SasStoreService } from '../services/sas-store.service'
@@ -21,7 +21,6 @@ type AOA = any[][]
import { HotTableComponent } from '@handsontable/angular-wrapper' import { HotTableComponent } from '@handsontable/angular-wrapper'
import { UploadFile } from '@sasjs/adapter' import { UploadFile } from '@sasjs/adapter'
import { isSpecialMissing } from '@sasjs/utils/input/validators' import { isSpecialMissing } from '@sasjs/utils/input/validators'
import CellRange from 'handsontable/3rdparty/walkontable/src/cell/range'
import { CellValidationSource } from '../models/CellValidationSource' import { CellValidationSource } from '../models/CellValidationSource'
import { FileUploader } from '../models/FileUploader.class' import { FileUploader } from '../models/FileUploader.class'
import { FilterGroup, FilterQuery } from '../models/FilterQuery' import { FilterGroup, FilterQuery } from '../models/FilterQuery'
@@ -40,6 +39,9 @@ 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 { 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'
@@ -47,6 +49,11 @@ 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 { getDisplayColHeaders } from '../shared/utils/display-col-headers'
import { buildColInfoHtml } from '../shared/utils/col-info-html'
import { globals } from '../_globals' import { globals } from '../_globals'
import { UploadStaterComponent } from './components/upload-stater/upload-stater.component' import { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
import { import {
@@ -56,6 +63,9 @@ 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 { getEditStatusSymbol } from './utils/getEditStatusSymbol'
import { EDIT_STATUS_COLUMN_NAME } from '../shared/dc-validator/utils/editStatusColumnRule'
import { import {
errorRenderer, errorRenderer,
noSpinnerRenderer, noSpinnerRenderer,
@@ -161,8 +171,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
items: { items: {
edit_row: { edit_row: {
name: 'Edit row', name: 'Edit row',
hidden() { // HOT 18's MenuItemConfig types `hidden` as a plain `() => boolean` with
const hot: Handsontable.Core = this // no `this` type, so an object-literal method here has `this` inferred
// as the surrounding item config unless declared explicitly.
hidden(this: Handsontable.Core) {
const hot = this
// Hide editing actions in read-only (view) mode. // Hide editing actions in read-only (view) mode.
if (hot.getSettings().readOnly) return true if (hot.getSettings().readOnly) return true
@@ -243,6 +256,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
redo: { redo: {
name: 'Redo', name: 'Redo',
hidden: () => this.hotTable.readOnly === true hidden: () => this.hotTable.readOnly === true
},
// Navigates rather than just flipping `useLabels` locally, so the
// URL stays the single source of truth (shareable/refreshable
// state) — the queryParams subscription in ngOnInit picks up the
// change and re-renders colHeaders.
toggle_labels: {
name: () => (this.useLabels ? 'Show names' : 'Show labels'),
callback: () => {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { labels: this.useLabels ? null : true },
queryParamsHandling: 'merge'
})
}
} }
} }
} }
@@ -266,6 +293,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
public libTab: string | undefined public libTab: string | undefined
public queryFilter: any public queryFilter: any
public _query: Subscription | undefined public _query: Subscription | undefined
private _queryParams: Subscription | undefined
// `?labels=true` toggle (see parseLabelsParam) — URL is the source of
// truth, kept in sync via the queryParams subscription in ngOnInit.
public useLabels: boolean = false
public whereString: string | undefined public whereString: string | undefined
public clauses: any public clauses: any
@@ -448,6 +480,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
private ariaObserver: MutationObserver | undefined private ariaObserver: MutationObserver | undefined
private ariaCheckInterval: any | undefined private ariaCheckInterval: any | undefined
private gridResizeObserver: ResizeObserver | undefined
constructor( constructor(
private licenceService: LicenceService, private licenceService: LicenceService,
@@ -461,7 +494,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()
@@ -735,6 +769,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)
}) })
@@ -979,6 +1017,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
const hot = this.hotInstance const hot = this.hotInstance
if (!hot) return if (!hot) return
// Entering edit mode: cancel any scheduled VA filter apply so it can't fire
// mid-edit and wipe the edits. A pending filter is re-applied on the return
// to read-only (cancelEdit).
if (this.vaDebounceTimer) {
clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = undefined
}
const columnSorting = hot.getPlugin('multiColumnSorting') const columnSorting = hot.getPlugin('multiColumnSorting')
const sortConfigs = this.getCurrentSortConfigs() const sortConfigs = this.getCurrentSortConfigs()
@@ -1074,6 +1120,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
this.checkRowLimit() this.checkRowLimit()
// Back to read-only: apply any VA filter change that was held pending while
// editing (live mode). Confirm mode leaves it staged for the Apply button.
if (
this.isVaEmbed &&
this.vaAutoApply &&
this.vaFilterStatus === 'pending'
) {
this.applyPendingVaFilter()
}
} }
/** /**
@@ -1258,18 +1314,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
@@ -1278,39 +1336,102 @@ 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)
}
}
/**
* 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 = []
@@ -1320,7 +1441,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.updateSettings( hot.updateSettings(
{ {
data: this.dataSource, data: this.dataSource,
colHeaders: this.headerColumns, colHeaders: getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
),
columns: this.cellValidation, columns: this.cellValidation,
modifyColWidth: function (width: number, col: number) { modifyColWidth: function (width: number, col: number) {
if (col === 0) { if (col === 0) {
@@ -1355,31 +1480,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
} }
} }
@@ -1399,7 +1516,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.removeCellMeta(rowIndex, col, 'valid') hot.removeCellMeta(rowIndex, col, 'valid')
hot.removeCellMeta(rowIndex, col, 'dupKey') hot.removeCellMeta(rowIndex, col, 'dupKey')
// Remove our custom class from cell metadata // Remove our custom class from cell metadata
const cellMeta = hot.getCellMeta(rowIndex, col) // getCellMeta<M>() defaults to Record<string, unknown> in HOT 18; pin it to
// CellMeta so `.className` keeps its real string | string[] type below.
const cellMeta = hot.getCellMeta<Handsontable.CellMeta>(rowIndex, col)
if (cellMeta.className) { if (cellMeta.className) {
let cleanedClassName: string let cleanedClassName: string
if (Array.isArray(cellMeta.className)) { if (Array.isArray(cellMeta.className)) {
@@ -1770,7 +1889,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] === ''
}) })
@@ -1784,7 +1907,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.updateSettings( hot.updateSettings(
{ {
data: this.dataSource, data: this.dataSource,
colHeaders: this.headerColumns, colHeaders: getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
),
columns: this.cellValidation, columns: this.cellValidation,
modifyColWidth: function (width: number, col: number) { modifyColWidth: function (width: number, col: number) {
if (width > 500) return 500 if (width > 500) return 500
@@ -1853,6 +1980,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)
@@ -2144,29 +2275,38 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
toggleHotPlugin(pluginName: string, enable: boolean) { toggleHotPlugin(pluginName: string, enable: boolean) {
const hot = this.hotInstance const hot = this.hotInstance
hot.batch(() => { const contextMenuPlugin = hot.getPlugin<any>(pluginName)
const contextMenuPlugin = hot.getPlugin<any>(pluginName)
if (!contextMenuPlugin) { if (!contextMenuPlugin) {
console.warn( console.warn(
'Toggle Hot Plugin failed - Plugin named: ' + 'Toggle Hot Plugin failed - Plugin named: ' +
pluginName + pluginName +
' - could not be found.' ' - could not be found.'
) )
return
}
setTimeout(() => {
// The instance may be destroyed/rebuilt within this 100ms window (e.g. a
// VA filter reload repopulates the grid). A destroyed plugin has its `hot`
// reference deleted, so enablePlugin()/disablePlugin() would throw on
// `this.hot.getSettings()`. Bail if the instance is gone or was swapped.
if (
hot.isDestroyed ||
this.hotInstance !== hot ||
!contextMenuPlugin.hot
) {
return return
} }
setTimeout(() => { if (enable) {
if (enable) { contextMenuPlugin.enablePlugin()
contextMenuPlugin.enablePlugin() } else {
return
}
contextMenuPlugin.disablePlugin() contextMenuPlugin.disablePlugin()
}, 100) }
hot.render() hot.render()
}) }, 100)
} }
private dynamicCellValidationDisabled(row: number, col: number) { private dynamicCellValidationDisabled(row: number, col: number) {
@@ -2246,8 +2386,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
validationSourceIndex validationSourceIndex
].values.map((el) => el.RAW_VALUE) ].values.map((el) => el.RAW_VALUE)
// `.source` (dropdown/autocomplete list) is typed unknown[] | Function here since
// getCellMeta() isn't given a type param; cast to the array branch we actually use.
const cellHadSource = const cellHadSource =
(hot.getCellMeta(row, column).source || []).length < 1 ((hot.getCellMeta(row, column).source as unknown[]) || []).length < 1
const cellHasValue = cellData !== ' ' const cellHasValue = cellData !== ' '
hot.batch(() => { hot.batch(() => {
@@ -2638,6 +2780,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
) )
// URL is the source of truth for the labels toggle: react to `?labels=`
// changes (including same-page navigation from the dropdown menu item)
// rather than reading it once from the route snapshot.
this._queryParams = this.route.queryParams.subscribe((params) => {
this.useLabels = parseLabelsParam(new URLSearchParams(params).toString())
this.applyDisplayColHeaders()
})
this._query = this.sasStoreService.query.subscribe((query: any) => { this._query = this.sasStoreService.query.subscribe((query: any) => {
if (query.libds === this.libds) { if (query.libds === this.libds) {
this.whereString = query.string this.whereString = query.string
@@ -2766,7 +2916,26 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}, 100) }, 100)
} }
/**
* Recomputes `colHeaders` from `headerColumns`/`cols` for the current
* `useLabels` state, and pushes it into a live grid via `updateSettings`
* if one is mounted (no refetch needed).
*/
private applyDisplayColHeaders() {
if (!this.hotInstance || !this.headerColumns.length) return
const colHeaders = getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
)
this.hotInstance.updateSettings({ colHeaders }, false)
}
ngOnDestroy() { ngOnDestroy() {
this._queryParams?.unsubscribe()
// Clean up the MutationObserver // Clean up the MutationObserver
if (this.ariaObserver) { if (this.ariaObserver) {
this.ariaObserver.disconnect() this.ariaObserver.disconnect()
@@ -2779,6 +2948,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
this.ariaCheckInterval = undefined this.ariaCheckInterval = undefined
} }
// Stop observing the grid container for resizes
if (this.gridResizeObserver) {
this.gridResizeObserver.disconnect()
this.gridResizeObserver = undefined
}
// Cancel any pending debounced VA apply // Cancel any pending debounced VA apply
if (this.vaDebounceTimer) { if (this.vaDebounceTimer) {
clearTimeout(this.vaDebounceTimer) clearTimeout(this.vaDebounceTimer)
@@ -2792,6 +2967,25 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
} }
/**
* Re-run height+render when the grid container settles.
*/
private observeGridResize() {
const el = this.hotInstance?.rootElement?.parentElement
if (!el) return
this.gridResizeObserver?.disconnect()
this.gridResizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
const hot = this.hotInstance
if (!hot || hot.isDestroyed) return
hot.updateSettings({ height: this.hotTable.height }, false)
hot.render()
})
})
this.gridResizeObserver.observe(el)
}
/** /**
* Fixes ARIA accessibility issues in the Handsontable component * Fixes ARIA accessibility issues in the Handsontable component
* This addresses the accessibility report issues with treegrid and presentation roles * This addresses the accessibility report issues with treegrid and presentation roles
@@ -3009,6 +3203,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(' ')
} }
@@ -3030,12 +3231,43 @@ 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
// 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 ?? ''
)
// 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
@@ -3059,7 +3291,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.updateSettings( hot.updateSettings(
{ {
data: this.dataSource, data: this.dataSource,
colHeaders: this.headerColumns, colHeaders: getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
),
columns: this.cellValidation, columns: this.cellValidation,
height: this.hotTable.height, height: this.hotTable.height,
formulas: this.hotTable.formulas, formulas: this.hotTable.formulas,
@@ -3083,10 +3319,36 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
manualColumnResize: true, manualColumnResize: true,
filters: false, filters: false,
manualRowResize: true, manualRowResize: true,
viewportRowRenderingOffset: 50, 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, rowHeaderWidth: 15,
rowHeights: 24, rowHeights: 24,
@@ -3119,20 +3381,33 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
itemValue: string itemValue: string
) => { ) => {
const elem = document.createElement('span') const elem = document.createElement('span')
let colName = ''
let colInfo: DataFormat | undefined let colInfo: DataFormat | undefined
let textInfo = 'No info found' let textInfo = 'No info found'
if (this.hotInstance) { if (this.hotInstance) {
const hotSelected: [number, number, number, number][] = // getSelected() is typed number[][] in HOT 18 (was a 4-tuple
// array before); loosen the annotation to match.
const hotSelected: number[][] =
this.hotInstance.getSelected() || [] this.hotInstance.getSelected() || []
const selectedCol: number = hotSelected const selectedCol: number = hotSelected
? hotSelected[0][1] ? hotSelected[0][1]
: -1 : -1
const colName = this.hotInstance?.colToProp(selectedCol) colName = this.hotInstance?.colToProp(selectedCol) as string
colInfo = this.$dataFormats?.vars[colName] colInfo = this.$dataFormats?.vars[colName]
if (colInfo) const { hardRegexValue, softRegexValue } =
textInfo = `LABEL: ${colInfo?.label}<br>TYPE: ${colInfo?.type}<br>LENGTH: ${colInfo?.length}<br>FORMAT: ${colInfo?.format}` this.dcValidator?.getRegexRuleValues(colName) || {}
const formulaValue =
this.dcValidator?.getFormulaRuleValue(colName)
textInfo = buildColInfoHtml(
colName,
colInfo,
hardRegexValue,
softRegexValue,
formulaValue
)
} }
elem.innerHTML = textInfo elem.innerHTML = textInfo
@@ -3333,10 +3608,42 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (cellMeta && cellMeta.filter === false) if (cellMeta && cellMeta.filter === false)
hot.setCellMeta(row, col, 'filter', true) hot.setCellMeta(row, col, 'filter', true)
// Toggling Delete? changes every other cell's validation exemption
// in this row (see DcValidator.setDefaultValidator) — re-validate so
// invalid highlights clear/reappear immediately instead of only at
// submit time.
if (colProp === '_____DELETE__THIS__RECORD_____') {
hot.validateRows([row], () => hot.render())
}
this.executeDynamicCellValidationIfApplicable(colProp, col, row) this.executeDynamicCellValidationIfApplicable(colProp, col, row)
} }
}) })
// 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.
hot.addHook('afterChange', (changes: any[], source: any) => {
if (!changes || source === 'loadData' || source === 'editStatus') return
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)
}
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
@@ -3370,8 +3677,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
) )
hot.addHook('afterRender', (isForced: boolean) => { hot.addHook('afterRender', (isForced: boolean) => {
this.eventService.dispatchEvent('resize')
// Fix ARIA accessibility issues after each render // Fix ARIA accessibility issues after each render
this.fixAriaAccessibility() this.fixAriaAccessibility()
}) })
@@ -3502,16 +3807,18 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.render() hot.render()
this.observeGridResize()
// Fix ARIA accessibility issues after table initialization // Fix ARIA accessibility issues after table initialization
setTimeout(() => { setTimeout(() => {
this.fixAriaAccessibility() this.fixAriaAccessibility()
}, 500) }, 500)
// SAS Visual Analytics data-driven content mode: open editable immediately, // SAS Visual Analytics data-driven content mode: open in read-only view (NOT
// re-apply any column visibility chosen by VA before this (filter) reload, // immediate edit — the user clicks Edit to make changes), re-apply any column
// and start receiving VA messages over the postMessage interface. // visibility chosen by VA before this (filter) reload, and start receiving VA
// messages over the postMessage interface.
if (this.isVaEmbed) { if (this.isVaEmbed) {
this.editTable()
if (this.vaMessaging.visibleColumns) { if (this.vaMessaging.visibleColumns) {
this.applyVaColumnVisibility(new Set(this.vaMessaging.visibleColumns)) this.applyVaColumnVisibility(new Set(this.vaMessaging.visibleColumns))
if (this.hotInstance) this.hotInstance.render() if (this.hotInstance) this.hotInstance.render()
@@ -3604,9 +3911,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
this.vaPendingSignature = signature this.vaPendingSignature = signature
this.vaFilterStatus = 'pending' this.vaFilterStatus = 'pending'
// Live mode auto-advances after the debounce settles; confirm waits for the // Filtering repopulates the grid, so it must never run while the user is
// Apply button. The pending indicator stays visible throughout the debounce. // editing (it would wipe their in-progress edits). While in edit mode we
if (this.vaAutoApply) this.scheduleVaFilterApply() // only surface the pending indicator; the filter is applied on return to
// read-only (see cancelEdit). Live mode auto-advances after the debounce
// settles; confirm waits for the Apply button.
if (this.vaAutoApply && this.hotTable.readOnly) this.scheduleVaFilterApply()
} }
/** Live mode: debounce a burst of VA changes into a single apply of the latest. */ /** Live mode: debounce a burst of VA changes into a single apply of the latest. */
@@ -3669,6 +3979,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
/** Apply the staged VA filter (the debounce in live mode, or the Apply button). */ /** Apply the staged VA filter (the debounce in live mode, or the Apply button). */
public applyPendingVaFilter(): void { public applyPendingVaFilter(): void {
if (!this.vaPendingClauses) return if (!this.vaPendingClauses) return
// Never apply while editing — the reload would wipe in-progress edits. The
// pending filter stays staged and is applied when the user leaves edit mode.
if (!this.hotTable.readOnly) return
const clauses = this.vaPendingClauses const clauses = this.vaPendingClauses
const signature = this.vaPendingSignature const signature = this.vaPendingSignature
this.vaPendingClauses = null this.vaPendingClauses = null
@@ -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,44 @@
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
export type RowEditStatus = 'M' | 'A' | 'D' | 'U'
// 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'.
const withoutEditStatus = (row: any): any => {
const { [EDIT_STATUS_COLUMN_NAME]: _editStatus, ...rest } = row
return rest
}
/**
* 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'
return JSON.stringify(withoutEditStatus(dataRow)) !==
JSON.stringify(withoutEditStatus(dataRowUnchanged))
? 'M'
: 'U'
}
+16
View File
@@ -18,6 +18,22 @@ export const dateToTime = (date: Date) => {
return timeStr return timeStr
} }
/**
* Normalises a 24-hour time STRING to zero-padded `HH:mm:ss`.
*
* The `intl-time` cell type (validator + native `<input type=time>` editor) is
* strict and rejects `H:mm[:ss]`, so such a value paints red and the editor
* opens empty. Pad the hour and default the seconds so the value validates.
*
* Non time-like input is returned unchanged.
*/
export const padTimeString = (value: string): string => {
const match = value.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
if (!match) return value
const [, hours, minutes, seconds] = match
return `${hours.padStart(2, '0')}:${minutes}:${seconds ?? '00'}`
}
/** /**
* Converts date object to the YYYY-MM-DD * Converts date object to the YYYY-MM-DD
*/ */
@@ -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,314 @@
import Handsontable from 'handsontable'
import { makeRegexWarningRenderer } from './regex-warning-renderer'
describe('makeRegexWarningRenderer', () => {
const buildHot = (data: any[]) => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data,
columns: [
{ data: 'val', renderer: makeRegexWarningRenderer('^[A-Z]{3}$') },
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
return { hot, container }
}
const buildHotNumeric = (data: any[]) => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data,
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer('^[A-Z]{3}$', undefined, true)
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
return { hot, container }
}
it('adds dc-warning-cell and a REGEX: title when the value fails the pattern', () => {
const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeTrue()
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
hot.destroy()
container.remove()
})
it('does not add dc-warning-cell when the value matches the pattern', () => {
const { hot, container } = buildHot([
{ val: 'ABC', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('suppresses the warning on a row marked for delete, even though the value fails the pattern', () => {
const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'Yes' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('does not add dc-warning-cell for the plain SAS missing (".") on a numeric column', () => {
const { hot, container } = buildHotNumeric([
{ val: '.', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('DOES add dc-warning-cell for a special missing on a numeric column (a deliberately-set value)', () => {
const { hot, container } = buildHotNumeric([
{ val: '.a', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeTrue()
hot.destroy()
container.remove()
})
it('does not throw and never warns on a malformed pattern', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{ data: 'val', renderer: makeRegexWarningRenderer('[unterminated') },
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
expect(() => hot.render()).not.toThrow()
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('handles a PRX-delimited pattern with a case-insensitive flag, as authored for prxparse', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [
{ val: 'this is dummy data', _____DELETE__THIS__RECORD_____: 'No' },
{ val: 'THE WIND WAS BLOWING', _____DELETE__THIS__RECORD_____: 'No' },
{ val: 'nothing relevant here', _____DELETE__THIS__RECORD_____: 'No' }
],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer('/\\b(the|data)\\b/i')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
expect(hot.getCell(0, 0)?.classList.contains('dc-warning-cell')).toBeFalse()
expect(hot.getCell(1, 0)?.classList.contains('dc-warning-cell')).toBeFalse()
expect(hot.getCell(2, 0)?.classList.contains('dc-warning-cell')).toBeTrue()
hot.destroy()
container.remove()
})
it('is display-only — the stored value is untouched', () => {
const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
])
expect(hot.getDataAtCell(0, 0)).toEqual('abc')
hot.destroy()
container.remove()
})
describe('HARDREGEX (second, optional pattern)', () => {
// HARDREGEX already blocks submission via dqValidate/HOT's own
// htInvalid (unchanged, untouched here) - this renderer only adds the
// matching 'REGEX: <pattern>' title on top, so a column with only a
// HARDREGEX rule (no SOFTREGEX at all) still tells the user why a cell
// is red, not just that it is.
const buildHardOnlyHot = (value: string) => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: value, _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer(undefined, '^[A-Z]{3}$')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
return { hot, container }
}
it('sets a REGEX: title (no dc-warning-cell) when only HARDREGEX fails', () => {
const { hot, container } = buildHardOnlyHot('abc')
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
hot.destroy()
container.remove()
})
it('sets no title when the value passes HARDREGEX', () => {
const { hot, container } = buildHardOnlyHot('ABC')
const td = hot.getCell(0, 0)
expect(td?.title).toEqual('')
hot.destroy()
container.remove()
})
it('suppresses the HARDREGEX title on a row marked for delete', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'abc', _____DELETE__THIS__RECORD_____: 'Yes' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer(undefined, '^[A-Z]{3}$')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
expect(hot.getCell(0, 0)?.title).toEqual('')
hot.destroy()
container.remove()
})
it('prefers the HARDREGEX title over SOFTREGEX when a value fails both', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'ab', _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer('^.{5,10}$', '^[A-Z]{3}$')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
const td = hot.getCell(0, 0)
// 'ab' fails HARDREGEX (not 3 uppercase letters) AND SOFTREGEX (too
// short) - HARDREGEX wins: its title shows, and no yellow class is
// added (HOT's own red htInvalid governs this cell instead).
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
hot.destroy()
container.remove()
})
it('never warns for SOFTREGEX when the column also has HARDREGEX, even for a value that passes HARDREGEX', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'AB', _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer('^.{5,10}$', '^[A-Z0-9]+$')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
const td = hot.getCell(0, 0)
// 'AB' passes HARDREGEX (uppercase/digits) but fails SOFTREGEX (too
// short) - only one regex runs per column, so the soft rule is
// ignored entirely: no warning, no title.
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
expect(td?.title).toEqual('')
hot.destroy()
container.remove()
})
it('does not throw and never warns on a malformed HARDREGEX pattern', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer(undefined, '[unterminated')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
expect(() => hot.render()).not.toThrow()
expect(hot.getCell(0, 0)?.title).toEqual('')
hot.destroy()
container.remove()
})
})
})
@@ -0,0 +1,79 @@
import Handsontable from 'handsontable'
import { isRegexRuleExempt } from '../../shared/dc-validator/utils/isRegexRuleExempt'
import { parseRegexRule } from '../../shared/dc-validator/utils/parseRegexRule'
const compileRegex = (
pattern: string | undefined,
ruleType: 'SOFTREGEX' | 'HARDREGEX'
): RegExp | null => {
try {
return pattern ? parseRegexRule(pattern) : null
} catch (e) {
console.warn(`${ruleType} - invalid pattern, warning disabled: ${pattern}`)
return null
}
}
/**
* Builds a display-only HOT renderer for HARDREGEX/SOFTREGEX: neither ever
* returns false from the cell validator here (that's dqValidate's job for
* HARDREGEX, which blocks submission and paints HOT's own red htInvalid
* independently of this renderer) - this renderer only adds the matching
* `REGEX: <pattern>` title, plus a yellow `dc-warning-cell` class when only
* SOFTREGEX fails, same split as makeNumberFormatRenderer.
*
* Only one regex ever runs per column: when a HARDREGEX rule exists,
* SOFTREGEX is ignored entirely (never compiled, never evaluated) -
* regardless of whether individual cell values pass or fail the hard
* rule. A value failing HARDREGEX gets its title (no yellow class - red
* htInvalid already covers the color). This mirrors
* DcValidator.failsSoftRegex's own precedence.
*
* Suppressed on rows marked for delete (_____DELETE__THIS__RECORD_____ =
* 'Yes') — a warning about data about to be removed is just noise.
*
* Falls back to no warning ever showing when a pattern itself is malformed,
* rather than breaking the cell.
*/
export const makeRegexWarningRenderer = (
softPattern?: string,
hardPattern?: string,
isNumeric: boolean = false
) => {
const hardRegex = compileRegex(hardPattern, 'HARDREGEX')
const softRegex = hardRegex ? null : compileRegex(softPattern, 'SOFTREGEX')
const baseRenderer = Handsontable.renderers.getRenderer('text')
return (
instance: any,
td: any,
row: number,
col: number,
prop: string | number,
value: any,
cellProperties: any
) => {
baseRenderer(instance, td, row, col, prop, value, cellProperties)
const markedForDelete =
instance.getDataAtRowProp(row, '_____DELETE__THIS__RECORD_____') === 'Yes'
const exempt = isRegexRuleExempt(value, isNumeric)
const failsHard =
!!hardRegex && !exempt && !hardRegex.test(value.toString())
const failsSoft =
!!softRegex && !exempt && !softRegex.test(value.toString())
if (markedForDelete) return td
if (failsHard) {
td.title = `REGEX: ${hardPattern}`
} else if (failsSoft) {
td.classList.add('dc-warning-cell')
td.title = `REGEX: ${softPattern}`
}
return td
}
}
+153 -130
View File
@@ -1,144 +1,167 @@
<app-sidebar class="sidebar-height"> <app-sidebar class="sidebar-height">
<clr-tree> <clr-tree>
<clr-tree-node *ngIf="groups" class="search-node"> @if (groups) {
<div class="tree-search-wrapper"> <clr-tree-node class="search-node">
<input <div class="tree-search-wrapper">
appStealFocus <input
clrInput appStealFocus
#searchLibTreeInput clrInput
placeholder="Filter by Groups" #searchLibTreeInput
name="input" placeholder="Filter by Groups"
[(ngModel)]="groupSearch" name="input"
(keyup)="groupListOnFilter()" [(ngModel)]="groupSearch"
autocomplete="off" (keyup)="groupListOnFilter()"
/> autocomplete="off"
<clr-icon />
*ngIf="searchLibTreeInput.value.length < 1" @if (searchLibTreeInput.value.length < 1) {
shape="search" <clr-icon shape="search"></clr-icon>
></clr-icon> }
<clr-icon @if (searchLibTreeInput.value.length > 0) {
*ngIf="searchLibTreeInput.value.length > 0" <clr-icon
(click)="groupSearch = ''; groupListOnFilter()" (click)="groupSearch = ''; groupListOnFilter()"
shape="times" shape="times"
></clr-icon> ></clr-icon>
</div> }
</clr-tree-node> </div>
<ng-container *ngFor="let group of groups">
<clr-tree-node
(click)="groupOnClick(group)"
*ngIf="!group['hidden']"
[class.active]="group.GROUPURI === groupUri"
>
<p class="m-0 cursor-pointer list-padding">
<clr-icon shape="users"></clr-icon>
{{ group.GROUPNAME }}
</p>
</clr-tree-node> </clr-tree-node>
</ng-container> }
@for (group of groups; track group) {
@if (!group['hidden']) {
<clr-tree-node
(click)="groupOnClick(group)"
[class.active]="group.GROUPURI === groupUri"
>
<p class="m-0 cursor-pointer list-padding">
<clr-icon shape="users"></clr-icon>
{{ group.GROUPNAME }}
</p>
</clr-tree-node>
}
}
</clr-tree> </clr-tree>
</app-sidebar> </app-sidebar>
<div class="content-area"> <div class="content-area">
<div *ngIf="loading" class="loadingSpinner"> @if (loading) {
<span class="spinner"> Loading... </span> <div class="loadingSpinner">
</div> <span class="spinner"> Loading... </span>
<div *ngIf="groupMembers && !loading">
<div *ngIf="serverType !== ServerType.SasViya" class="clr-row">
<div class="clr-col-8">
<table class="table group-info">
<tbody>
<tr>
<td class="left">
<p class="group-info-text">
<b>{{ groupName }}</b>
</p>
</td>
</tr>
<tr>
<td class="left">
<i>{{ groupDesc || 'no description' }}</i>
</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
}
<div class="clr-row"> @if (groupMembers && !loading) {
<div class="clr-col-8"> <div>
<div class="card group-data"> @if (serverType !== ServerType.SasViya) {
<div *ngIf="serverType !== ServerType.SasViya"> <div class="clr-row">
<h3>MEMBERS ({{ groupMemberCount }})</h3> <div class="clr-col-8">
<h5 *ngIf="groupMemberCount == 0">No Members Present</h5> <table class="table group-info">
<div class="table-container"> <tbody>
<table *ngIf="groupMemberCount != 0" class="table member-table"> <tr>
<thead> <td class="left">
<tr> <p class="group-info-text">
<ng-container *ngIf="serverType === ServerType.Sas9"> <b>{{ groupName }}</b>
<td class="width-25"><b>NAME</b></td> </p>
<td class="width-25"><b>EMAIL</b></td> </td>
<td class="width-25"><b>CREATED</b></td> </tr>
<td class=""><b>UPDATED</b></td> <tr>
</ng-container> <td class="left">
<i>{{ groupDesc || 'no description' }}</i>
<ng-container *ngIf="serverType === ServerType.Sasjs"> </td>
<td class="width-25"><b>ID</b></td> </tr>
<td class="width-25"><b>DISPLAY NAME</b></td> </tbody>
<td class="width-25"><b>USER NAME</b></td> </table>
</ng-container>
</tr>
</thead>
<tbody>
<tr
[routerLink]="
'/view/usernav/users/' + (member.URIMEM || member.ID)
"
*ngFor="let member of groupMembers"
>
<ng-container *ngIf="serverType === ServerType.Sas9">
<td class="">{{ member.MEMBERNAME }}</td>
<td class="">{{ member.EMAIL }}</td>
<td class="">{{ member.MEMBERCREATED }}</td>
<td class="">{{ member.MEMBERUPDATED }}</td>
</ng-container>
<ng-container *ngIf="serverType === ServerType.Sasjs">
<td class="">{{ member.ID }}</td>
<td class="">{{ member.DISPLAYNAME }}</td>
<td class="">{{ member.USERNAME }}</td>
</ng-container>
</tr>
</tbody>
</table>
</div>
</div> </div>
<div *ngIf="serverType === ServerType.SasViya"> </div>
<h3>{{ groupName }}</h3> }
<h5 *ngIf="groupMemberCount == 0">No Members Present</h5> <div class="clr-row">
<div class="table-container"> <div class="clr-col-8">
<table *ngIf="groupMemberCount != 0" class="table member-table"> <div class="card group-data">
<thead> @if (serverType !== ServerType.SasViya) {
<tr> <div>
<td> <h3>MEMBERS ({{ groupMemberCount }})</h3>
<b>MEMBERS ({{ groupMemberCount }})</b> @if (groupMemberCount == 0) {
</td> <h5>No Members Present</h5>
</tr> }
</thead> <div class="table-container">
<tbody> @if (groupMemberCount != 0) {
<tr <table class="table member-table">
[routerLink]="'/view/usernav/users/' + member.MEMBERID" <thead>
*ngFor="let member of groupMembers" <tr>
> @if (serverType === ServerType.Sas9) {
<td class="">{{ member.MEMBERNAME }}</td> <td class="width-25"><b>NAME</b></td>
</tr> <td class="width-25"><b>EMAIL</b></td>
</tbody> <td class="width-25"><b>CREATED</b></td>
</table> <td class=""><b>UPDATED</b></td>
</div> }
@if (serverType === ServerType.Sasjs) {
<td class="width-25"><b>ID</b></td>
<td class="width-25"><b>DISPLAY NAME</b></td>
<td class="width-25"><b>USER NAME</b></td>
}
</tr>
</thead>
<tbody>
@for (member of groupMembers; track member) {
<tr
[routerLink]="
'/view/usernav/users/' +
(member.URIMEM || member.ID)
"
>
@if (serverType === ServerType.Sas9) {
<td class="">{{ member.MEMBERNAME }}</td>
<td class="">{{ member.EMAIL }}</td>
<td class="">{{ member.MEMBERCREATED }}</td>
<td class="">{{ member.MEMBERUPDATED }}</td>
}
@if (serverType === ServerType.Sasjs) {
<td class="">{{ member.ID }}</td>
<td class="">{{ member.DISPLAYNAME }}</td>
<td class="">{{ member.USERNAME }}</td>
}
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
@if (serverType === ServerType.SasViya) {
<div>
<h3>{{ groupName }}</h3>
@if (groupMemberCount == 0) {
<h5>No Members Present</h5>
}
<div class="table-container">
@if (groupMemberCount != 0) {
<table class="table member-table">
<thead>
<tr>
<td>
<b>MEMBERS ({{ groupMemberCount }})</b>
</td>
</tr>
</thead>
<tbody>
@for (member of groupMembers; track member) {
<tr
[routerLink]="
'/view/usernav/users/' + member.MEMBERID
"
>
<td class="">{{ member.MEMBERNAME }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
<br />
</div> </div>
<br />
</div> </div>
</div> </div>
</div> </div>
</div> }
</div> </div>
+143 -144
View File
@@ -1,159 +1,158 @@
<app-sidebar> <app-sidebar>
<clr-tree> <clr-tree>
<clr-tree-node *ngIf="treeNodeLibraries?.length! > 0" class="search-node"> @if (treeNodeLibraries?.length! > 0) {
<div class="tree-search-wrapper"> <clr-tree-node class="search-node">
<input <div class="tree-search-wrapper">
clrInput <input
appStealFocus clrInput
#searchLibTreeInput appStealFocus
placeholder="Libraries" #searchLibTreeInput
name="input" placeholder="Libraries"
[(ngModel)]="librariesSearch" name="input"
(keyup)="libraryOnFilter()" [(ngModel)]="librariesSearch"
autocomplete="off" (keyup)="libraryOnFilter()"
/> autocomplete="off"
/>
<clr-icon @if (searchLibTreeInput.value.length < 1) {
*ngIf="searchLibTreeInput.value.length < 1" <clr-icon shape="search" aria-hidden="true"></clr-icon>
shape="search" }
aria-hidden="true" @if (searchLibTreeInput.value.length > 0) {
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
aria-label="Clear libraries search"
></clr-icon>
</div>
</clr-tree-node>
<ng-container *ngFor="let library of treeNodeLibraries">
<clr-tree-node
(click)="treeNodeClicked($event, library); lib = library.LIBRARYREF"
*ngIf="!library['hidden']"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
>
<p
(click)="
lib = library.LIBRARYREF;
libraryOnClick(library.LIBRARYREF, library)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="rack-server" aria-hidden="true"></clr-icon>
{{ library.LIBRARYREF }}
</p>
<clr-tree-node *ngIf="library['tables']" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables')"
autocomplete="off"
/>
<clr-icon <clr-icon
*ngIf="searchTreeInput.value.length < 1" (click)="librariesSearch = ''; libraryOnFilter()"
shape="search"
aria-hidden="true"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables')
"
shape="times" shape="times"
aria-label="Clear tables search" aria-label="Clear libraries search"
></clr-icon> ></clr-icon>
</div> }
</clr-tree-node> </div>
<clr-tree-node
*ngFor="let libTable of library['tables']; let index = index"
>
<clr-tooltip
*ngVar="
index + 1 >
licenceState.value.tables_in_library_limit as tableLocked
"
>
<button
clrTooltipTrigger
(click)="!tableLocked ? onTableClick(libTable, library) : ''"
class="clr-treenode-link"
[class.dc-locked-control]="tableLocked"
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
>
<ng-container [ngSwitch]="libTable.includes('-FC')">
<clr-icon
*ngSwitchCase="true"
shape="bolt"
aria-hidden="true"
></clr-icon>
<clr-icon
*ngSwitchCase="false"
shape="table"
aria-hidden="true"
></clr-icon>
</ng-container>
{{ libTable.replace('-FC', '') }}
</button>
<ng-container *ngIf="tableLocked">
<clr-tooltip-content
clrPosition="bottom-right"
clrSize="lg"
*clrIfOpen
>
<span>
To unlock all tables, contact support&#64;datacontroller.io
</span>
</clr-tooltip-content>
</ng-container>
</clr-tooltip>
</clr-tree-node>
</clr-tree-node> </clr-tree-node>
</ng-container> }
@for (library of treeNodeLibraries; track library) {
@if (!library['hidden']) {
<clr-tree-node
(click)="treeNodeClicked($event, library); lib = library.LIBRARYREF"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
>
<p
(click)="
lib = library.LIBRARYREF;
libraryOnClick(library.LIBRARYREF, library)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="rack-server" aria-hidden="true"></clr-icon>
{{ library.LIBRARYREF }}
</p>
@if (library['tables']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search" aria-hidden="true"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables')
"
shape="times"
aria-label="Clear tables search"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (
libTable of library['tables'];
track libTable;
let index = $index
) {
<clr-tree-node>
<clr-tooltip
*ngVar="
index + 1 >
licenceState.value.tables_in_library_limit as tableLocked
"
>
<button
clrTooltipTrigger
(click)="!tableLocked ? onTableClick(libTable, library) : ''"
class="clr-treenode-link"
[class.dc-locked-control]="tableLocked"
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
>
@switch (libTable.includes('-FC')) {
@case (true) {
<clr-icon shape="bolt" aria-hidden="true"></clr-icon>
}
@case (false) {
<clr-icon shape="table" aria-hidden="true"></clr-icon>
}
}
{{ libTable.replace('-FC', '') }}
</button>
@if (tableLocked) {
<clr-tooltip-content
clrPosition="bottom-right"
clrSize="lg"
*clrIfOpen
>
<span>
To unlock all tables, contact
support&#64;datacontroller.io
</span>
</clr-tooltip-content>
}
</clr-tooltip>
</clr-tree-node>
}
</clr-tree-node>
}
}
</clr-tree> </clr-tree>
</app-sidebar> </app-sidebar>
<main class="content-area"> <main class="content-area">
<div class="card-block"> <div class="card-block">
<div *ngIf="loading" class="spinner-wrapper-fullpage"> @if (loading) {
<div class="loadingSpinner"> <div class="spinner-wrapper-fullpage">
<span class="spinner"> Loading... </span> <div class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
</div> </div>
</div> }
<div *ngIf="!loading" class="no-table-selected"> @if (!loading) {
<img <div class="no-table-selected">
src="images/select-table.png" <img
class="select-table-icon" src="images/select-table.png"
alt="select table icon" class="select-table-icon"
/> alt="select table icon"
<p />
*ngIf="treeNodeLibraries?.length! > 0" @if (treeNodeLibraries?.length! > 0) {
class="text-center color-gray mt-10" <p class="text-center color-gray mt-10" cds-text="section">
cds-text="section" Please select a table
> </p>
Please select a table }
</p> @if (treeNodeLibraries?.length! < 1) {
<p <p class="text-center color-gray mt-10" cds-text="section">
*ngIf="treeNodeLibraries?.length! < 1" No Editable Tables Configured
class="text-center color-gray mt-10" </p>
cds-text="section" }
> </div>
No Editable Tables Configured }
</p>
</div>
</div> </div>
</main> </main>
+251 -242
View File
@@ -1,168 +1,176 @@
<app-sidebar (scrolledToBottom)="loadMoreLibraries()"> <app-sidebar (scrolledToBottom)="loadMoreLibraries()">
<clr-tree> <clr-tree>
<clr-tree-node *ngIf="libraryList" class="search-node"> @if (libraryList) {
<div class="tree-search-wrapper"> <clr-tree-node class="search-node">
<input <div class="tree-search-wrapper">
appStealFocus <input
clrInput appStealFocus
#searchLibTreeInput clrInput
placeholder="Libraries" #searchLibTreeInput
name="input" placeholder="Libraries"
[(ngModel)]="librariesSearch" name="input"
(keyup)="libraryOnFilter()" [(ngModel)]="librariesSearch"
autocomplete="off" (keyup)="libraryOnFilter()"
/> autocomplete="off"
<clr-icon />
*ngIf="searchLibTreeInput.value.length < 1" @if (searchLibTreeInput.value.length < 1) {
shape="search" <clr-icon shape="search"></clr-icon>
></clr-icon> }
<clr-icon @if (searchLibTreeInput.value.length > 0) {
*ngIf="searchLibTreeInput.value.length > 0"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
<ng-container *ngFor="let library of libraryList">
<clr-tree-node
(click)="treeNodeClicked($event, library, libraryList)"
*ngIf="!library['hidden'] && library['inForeground']"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
>
<p
(click)="lib = library.LIBRARYID; libraryOnClick(lib || '', library)"
class="m-0 cursor-pointer"
>
<clr-icon shape="rack-server"></clr-icon>
{{ library.LIBRARYNAME }}
</p>
<clr-tree-node *ngIf="library['tables']" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables.TABLENAME')"
autocomplete="off"
/>
<clr-icon <clr-icon
*ngIf="searchTreeInput.value.length < 1" (click)="librariesSearch = ''; libraryOnFilter()"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables.TABLENAME')
"
shape="times" shape="times"
></clr-icon> ></clr-icon>
</div> }
</clr-tree-node> </div>
</clr-tree-node>
}
@for (library of libraryList; track library) {
@if (!library['hidden'] && library['inForeground']) {
<clr-tree-node <clr-tree-node
(click)="treeNodeClicked($event, libTable, library['tables'])" (click)="treeNodeClicked($event, library, libraryList)"
*ngFor="let libTable of library['tables']" [(clrExpanded)]="library['expanded']"
[(clrExpanded)]="libTable['expanded']" [clrLoading]="library['loadingTables'] && !library.tables"
[clrLoading]="libTable['loadingColumns'] && !libTable.columns" [class.clr-expanded]="library['expanded']"
[class.clr-expanded]="libTable['expanded']"
> >
<p <p
(click)="tableOnClick(libTable.TABLEURI, libTable, library)" (click)="
[id]="libTable.TABLEURI" lib = library.LIBRARYID; libraryOnClick(lib || '', library)
"
class="m-0 cursor-pointer" class="m-0 cursor-pointer"
> >
<clr-icon shape="table"></clr-icon> <clr-icon shape="rack-server"></clr-icon>
{{ libTable.TABLENAME }} {{ library.LIBRARYNAME }}
</p> </p>
@if (library['tables']) {
<clr-tree-node *ngIf="libTable['columns']" class="search-node"> <clr-tree-node class="search-node">
<div class="tree-search-wrapper"> <div class="tree-search-wrapper">
<input <input
appStealFocus appStealFocus
clrInput clrInput
#searchTreeInput #searchTreeInput
placeholder="Columns" placeholder="Tables"
name="input" name="input"
[(ngModel)]="libTable['searchString']" [(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')" (keyup)="treeOnFilter(library, 'tables.TABLENAME')"
autocomplete="off" autocomplete="off"
/> />
<clr-icon @if (searchTreeInput.value.length < 1) {
*ngIf="searchTreeInput.value.length < 1" <clr-icon shape="search"></clr-icon>
shape="search" }
></clr-icon> @if (searchTreeInput.value.length > 0) {
<clr-icon <clr-icon
*ngIf="searchTreeInput.value.length > 0" (click)="
(click)=" searchTreeInput.value = '';
searchTreeInput.value = ''; library['searchString'] = '';
libTable['searchString'] = ''; treeOnFilter(library, 'tables.TABLENAME')
treeOnFilter(libTable, 'columns.COLNAME') "
" shape="times"
shape="times" ></clr-icon>
></clr-icon> }
</div> </div>
</clr-tree-node> </clr-tree-node>
}
<clr-tree-node *ngFor="let libColumn of libTable['columns']"> @for (libTable of library['tables']; track libTable) {
<button <clr-tree-node
(click)="columnOnClick(libColumn, library, libTable)" (click)="treeNodeClicked($event, libTable, library['tables'])"
class="clr-treenode-link" [(clrExpanded)]="libTable['expanded']"
[class.column-active]="libColumnActive(libColumn.COLURI)" [clrLoading]="libTable['loadingColumns'] && !libTable.columns"
[class.clr-expanded]="libTable['expanded']"
> >
<clr-icon shape="objects"></clr-icon> <p
(click)="tableOnClick(libTable.TABLEURI, libTable, library)"
{{ libColumn.COLNAME }} [id]="libTable.TABLEURI"
</button> class="m-0 cursor-pointer"
</clr-tree-node> >
<clr-icon shape="table"></clr-icon>
{{ libTable.TABLENAME }}
</p>
@if (libTable['columns']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Columns"
name="input"
[(ngModel)]="libTable['searchString']"
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
(click)="
searchTreeInput.value = '';
libTable['searchString'] = '';
treeOnFilter(libTable, 'columns.COLNAME')
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (libColumn of libTable['columns']; track libColumn) {
<clr-tree-node>
<button
(click)="columnOnClick(libColumn, library, libTable)"
class="clr-treenode-link"
[class.column-active]="libColumnActive(libColumn.COLURI)"
>
<clr-icon shape="objects"></clr-icon>
{{ libColumn.COLNAME }}
</button>
</clr-tree-node>
}
</clr-tree-node>
}
</clr-tree-node> </clr-tree-node>
</clr-tree-node> }
</ng-container> }
</clr-tree> </clr-tree>
<div *ngIf="librariesPaging" class="w-100 text-center"> @if (librariesPaging) {
<span class="spinner spinner-sm"> Loading... </span> <div class="w-100 text-center">
</div> <span class="spinner spinner-sm"> Loading... </span>
</div>
}
</app-sidebar> </app-sidebar>
<div class="content-area"> <div class="content-area">
<div class="card"> <div class="card">
<div *ngIf="!column && !table" class="no-table-selected"> @if (!column && !table) {
<clr-icon <div class="no-table-selected">
shape="warning-standard" <clr-icon
size="60" shape="warning-standard"
class="is-info icon-dc-fill" size="60"
></clr-icon> class="is-info icon-dc-fill"
<p class="text-center color-gray mt-10" cds-text="section"> ></clr-icon>
Please select a column or table <p class="text-center color-gray mt-10" cds-text="section">
</p> Please select a column or table
</div> </p>
<ng-container *ngIf="column || table">
<div
*ngIf="!graphContainer"
class="card-header d-flex flex-column justify-content-center"
>
<h3
*ngIf="!currentLineagePathColumn && !currentLineagePathLibTable"
class="text-center pb-10"
>
{{
currentLineagePathColumn
? currentLineagePathLibTable + '.' + currentLineagePathColumn
: currentLineagePathLibTable
}}
</h3>
</div> </div>
}
@if (column || table) {
@if (!graphContainer) {
<div class="card-header d-flex flex-column justify-content-center">
@if (!currentLineagePathColumn && !currentLineagePathLibTable) {
<h3 class="text-center pb-10">
{{
currentLineagePathColumn
? currentLineagePathLibTable + '.' + currentLineagePathColumn
: currentLineagePathLibTable
}}
</h3>
}
</div>
}
<div class="card-block"> <div class="card-block">
<section <section
class="form-block sw position-relative d-flex align-items-center" class="form-block sw position-relative d-flex align-items-center"
@@ -170,114 +178,115 @@
<div <div
class="linage-title-wrapper d-flex align-items-center font-weight-bold position-absolute" class="linage-title-wrapper d-flex align-items-center font-weight-bold position-absolute"
> >
<span *ngIf="lineageTableName.length > 0"> @if (lineageTableName.length > 0) {
{{ lineageTableName.split('.')[0] }}.<a <span>
[routerLink]="'/view/data/' + lineageTableName" {{ lineageTableName.split('.')[0] }}.<a
>{{ lineageTableName.split('.')[1] }}</a [routerLink]="'/view/data/' + lineageTableName"
>{{ lineageColumnName.length > 0 ? '.' + lineageColumnName : '' }} >{{ lineageTableName.split('.')[1] }}</a
</span> >{{
lineageColumnName.length > 0 ? '.' + lineageColumnName : ''
}}
</span>
}
</div> </div>
@if (graphContainer) {
<div <div class="clr-col-md-12 text-center d-flex justify-content-end">
*ngIf="graphContainer"
class="clr-col-md-12 text-center d-flex justify-content-end"
>
<button
(click)="limitDotDepth = true"
type="button"
class="btn btn-outline mr-5"
>
Limit depth
</button>
<!-- <button class="btn btn-outline" (click)='showSvg()'> Open in New Tab </button> -->
<div class="btn-group direction d-block">
<div
class="radio btn"
(click)="
forwardLineage = false;
router.url.includes('column')
? onGenerateClick()
: onGenerateGraphTableClick()
"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="!forwardLineage"
/>
<label>Backward</label>
</div>
<div
class="radio btn"
(click)="
forwardLineage = true;
router.url.includes('column')
? onGenerateClick()
: onGenerateGraphTableClick()
"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="forwardLineage"
/>
<label>Forward</label>
</div>
</div>
<clr-dropdown class="mr-10">
<button <button
class="btn btn-info-outline" (click)="limitDotDepth = true"
clrDropdownTrigger type="button"
[disabled]="!column && !table" class="btn btn-outline mr-5"
> >
Download Limit depth
<clr-icon shape="caret down"></clr-icon>
</button> </button>
<!-- <button class="btn btn-outline" (click)='showSvg()'> Open in New Tab </button> -->
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen> <div class="btn-group direction d-block">
<div (click)="downloadSVG()" clrDropdownItem>SVG</div> <div
<div (click)="downloadPNG()" clrDropdownItem>PNG</div> class="radio btn"
<div (click)="downloadDot()" clrDropdownItem>Dot</div> (click)="
<div *ngIf="flatdata" (click)="downloadCSV()" clrDropdownItem> forwardLineage = false;
CSV router.url.includes('column')
? onGenerateClick()
: onGenerateGraphTableClick()
"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="!forwardLineage"
/>
<label>Backward</label>
</div>
<div
class="radio btn"
(click)="
forwardLineage = true;
router.url.includes('column')
? onGenerateClick()
: onGenerateGraphTableClick()
"
>
<input
type="radio"
name="btn-group-demo-radios"
[checked]="forwardLineage"
/>
<label>Forward</label>
</div> </div>
</clr-dropdown-menu>
</clr-dropdown>
<clr-checkbox-wrapper
*ngIf="column"
class="d-flex align-items-center"
>
<input
type="checkbox"
(change)="generateGraph()"
clrCheckbox
name="refreshCache"
[(ngModel)]="refreshCache"
/>
<label>Refresh Cache</label>
</clr-checkbox-wrapper>
</div>
</section>
<div *ngIf="graphContainer" [class.mt-2]="tableFlag">
<div class="text-center">
<span *ngIf="graphLoading" class="spinner"> Loading... </span>
<div *ngIf="!graphLoading" class="position-relative">
<div class="graph-render-spinner">
<span *ngIf="graphRendering" class="spinner spinner-sm"></span>
</div> </div>
<clr-dropdown class="mr-10">
<div id="graph"></div> <button
class="btn btn-info-outline"
clrDropdownTrigger
[disabled]="!column && !table"
>
Download
<clr-icon shape="caret down"></clr-icon>
</button>
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
<div (click)="downloadSVG()" clrDropdownItem>SVG</div>
<div (click)="downloadPNG()" clrDropdownItem>PNG</div>
<div (click)="downloadDot()" clrDropdownItem>Dot</div>
@if (flatdata) {
<div (click)="downloadCSV()" clrDropdownItem>CSV</div>
}
</clr-dropdown-menu>
</clr-dropdown>
@if (column) {
<clr-checkbox-wrapper class="d-flex align-items-center">
<input
type="checkbox"
(change)="generateGraph()"
clrCheckbox
name="refreshCache"
[(ngModel)]="refreshCache"
/>
<label>Refresh Cache</label>
</clr-checkbox-wrapper>
}
</div>
}
</section>
@if (graphContainer) {
<div [class.mt-2]="tableFlag">
<div class="text-center">
@if (graphLoading) {
<span class="spinner"> Loading... </span>
}
@if (!graphLoading) {
<div class="position-relative">
<div class="graph-render-spinner">
@if (graphRendering) {
<span class="spinner spinner-sm"></span>
}
</div>
<div id="graph"></div>
</div>
}
</div> </div>
</div> </div>
</div> }
</div> </div>
</ng-container> }
</div> </div>
</div> </div>
+218 -188
View File
@@ -8,58 +8,60 @@
[(ngModel)]="repository" [(ngModel)]="repository"
(change)="updateSelectedRepository()" (change)="updateSelectedRepository()"
> >
<option @for (repository of repositories; track repository) {
*ngFor="let repository of repositories" <option value="{{ repository }}">
value="{{ repository }}" {{ repository }}
> </option>
{{ repository }} }
</option>
</select> </select>
</clr-select-container> </clr-select-container>
</div> </div>
<clr-tree> <clr-tree>
<clr-tree-node *ngIf="metaDataList" class="search-node"> @if (metaDataList) {
<div class="tree-search-wrapper"> <clr-tree-node class="search-node">
<input <div class="tree-search-wrapper">
appStealFocus <input
clrInput appStealFocus
#searchLibTreeInput clrInput
placeholder="search SAS Types" #searchLibTreeInput
name="input" placeholder="search SAS Types"
[(ngModel)]="metaDataSearch" name="input"
(keyup)="metaListOnFilter()" [(ngModel)]="metaDataSearch"
autocomplete="off" (keyup)="metaListOnFilter()"
/> autocomplete="off"
<clr-icon />
*ngIf="searchLibTreeInput.value.length < 1" @if (searchLibTreeInput.value.length < 1) {
shape="search" <clr-icon shape="search"></clr-icon>
></clr-icon> }
<clr-icon @if (searchLibTreeInput.value.length > 0) {
*ngIf="searchLibTreeInput.value.length > 0" <clr-icon
(click)="metaDataSearch = ''; metaListOnFilter()" (click)="metaDataSearch = ''; metaListOnFilter()"
shape="times" shape="times"
></clr-icon> ></clr-icon>
</div> }
</clr-tree-node> </div>
<ng-container *ngFor="let metaData of metaDataList">
<clr-tree-node
(click)="treeNodeClicked($event, metaData, metaDataList)"
*ngIf="!metaData['hidden']"
[(clrExpanded)]="metaData['expanded']"
[clrLoading]="metaData['loadingTables'] && !metaData.tables"
>
<p
(click)="
metaDataId = metaData.ID; metaDataOnClick(metaDataId, metaData)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="block"></clr-icon>
{{ metaData.ID }}
</p>
</clr-tree-node> </clr-tree-node>
</ng-container> }
@for (metaData of metaDataList; track metaData) {
@if (!metaData['hidden']) {
<clr-tree-node
(click)="treeNodeClicked($event, metaData, metaDataList)"
[(clrExpanded)]="metaData['expanded']"
[clrLoading]="metaData['loadingTables'] && !metaData.tables"
>
<p
(click)="
metaDataId = metaData.ID; metaDataOnClick(metaDataId, metaData)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="block"></clr-icon>
{{ metaData.ID }}
</p>
</clr-tree-node>
}
}
</clr-tree> </clr-tree>
</app-sidebar> </app-sidebar>
@@ -67,54 +69,121 @@
<div class="card background-transparent-i"> <div class="card background-transparent-i">
<h3 class="color-gray">{{ assoTypeSelected }}</h3> <h3 class="color-gray">{{ assoTypeSelected }}</h3>
<div *ngIf="!loading && !metaObjectList" class="no-table-selected"> @if (!loading && !metaObjectList) {
<clr-icon <div class="no-table-selected">
shape="warning-standard" <clr-icon
size="60" shape="warning-standard"
class="is-info icon-dc-fill" size="60"
></clr-icon> class="is-info icon-dc-fill"
<p class="text-center color-gray mt-10" cds-text="section"> ></clr-icon>
Please select a type <p class="text-center color-gray mt-10" cds-text="section">
</p> Please select a type
</div> </p>
<div class="loadingSpinner" *ngIf="loading">
<span class="spinner"> Loading... </span>
<div *ngIf="loading">
<h4 *ngIf="metatypesLoading">Loading metadata types</h4>
<h4 *ngIf="!metatypesLoading">Loading metadata objects</h4>
</div> </div>
</div> }
<div *ngIf="showData" class="clr-row clr-flex-grow-1">
<div class="clr-col-6"> @if (loading) {
<div *ngIf="metaObjectList && !objectRoute" class="search-input"> <div class="loadingSpinner">
<input <span class="spinner"> Loading... </span>
clrInput @if (loading) {
#searchObjTreeInput <div>
placeholder="search" @if (metatypesLoading) {
name="input" <h4>Loading metadata types</h4>
[(ngModel)]="metaObjectSearch" }
(keyup)="metaObjectOnFilter()" @if (!metatypesLoading) {
autocomplete="off" <h4>Loading metadata objects</h4>
/> }
</div> </div>
<br /> }
<div *ngIf="!objectView" class="objects-col"> </div>
<clr-accordion> }
<ng-container *ngFor="let metaObject of metaObjectShowList"> @if (showData) {
<clr-accordion-panel <div class="clr-row clr-flex-grow-1">
(clrAccordionPanelOpenChange)=" <div class="clr-col-6">
$event ? panelChange($event, metaObject) : '' @if (metaObjectList && !objectRoute) {
" <div class="search-input">
*ngIf="!metaObject['hidden']" <input
> clrInput
<clr-accordion-title #searchObjTreeInput
><clr-icon shape="rack-server"></clr-icon> placeholder="search"
{{ metaObject.NAME }} name="input"
<p class="float-right">{{ metaObject.ID }}</p> [(ngModel)]="metaObjectSearch"
</clr-accordion-title> (keyup)="metaObjectOnFilter()"
<clr-accordion-content *clrIfExpanded> autocomplete="off"
<clr-tree *ngIf="showAcc" [clrLazy]="true"> />
</div>
}
<br />
@if (!objectView) {
<div class="objects-col">
<clr-accordion>
@for (metaObject of metaObjectShowList; track metaObject) {
@if (!metaObject['hidden']) {
<clr-accordion-panel
(clrAccordionPanelOpenChange)="
$event ? panelChange($event, metaObject) : ''
"
>
<clr-accordion-title
><clr-icon shape="rack-server"></clr-icon>
{{ metaObject.NAME }}
<p class="float-right">{{ metaObject.ID }}</p>
</clr-accordion-title>
<clr-accordion-content *clrIfExpanded>
@if (showAcc) {
<clr-tree [clrLazy]="true">
<clr-tree-node
*clrRecursiveFor="
let entry of root$ | async;
getChildren: getChildren
"
[clrExpandable]="true"
>
<div
[class.object-header]="!entry.count"
class="full-width"
>
<div>
@if (!entry.count) {
<clr-icon shape="rack-server"></clr-icon>
}
@if (entry.count) {
<clr-icon shape="block"></clr-icon>
}
{{ entry.display }}
</div>
@if (!entry.count) {
<p class="float-right object-uri">
{{ entry.URI }}
</p>
}
</div>
</clr-tree-node>
</clr-tree>
}
</clr-accordion-content>
</clr-accordion-panel>
}
}
</clr-accordion>
</div>
}
@if (objectView) {
<div class="objects-col">
@for (metaObject of metaObjectShowList; track metaObject) {
<div class="cols-head">
<clr-icon shape="rack-server"></clr-icon>
<div class="object-text">
<p class="m-0 word-break mr-20">
{{ metaObject.NAME }}
</p>
<p class="float-right ml-3">
{{ metaObject.ID }}
</p>
</div>
</div>
@if (showAcc) {
<clr-tree [clrLazy]="true">
<clr-tree-node <clr-tree-node
*clrRecursiveFor=" *clrRecursiveFor="
let entry of root$ | async; let entry of root$ | async;
@@ -127,109 +196,70 @@
class="full-width" class="full-width"
> >
<div> <div>
<clr-icon @if (!entry.count) {
*ngIf="!entry.count" <clr-icon shape="rack-server"></clr-icon>
shape="rack-server" }
></clr-icon> @if (entry.count) {
<clr-icon <clr-icon shape="block"></clr-icon>
*ngIf="entry.count" }
shape="block"
></clr-icon>
{{ entry.display }} {{ entry.display }}
</div> </div>
@if (!entry.count) {
<p class="float-right object-uri" *ngIf="!entry.count"> <p class="float-right object-uri">
{{ entry.URI }} {{ entry.URI }}
</p> </p>
}
</div> </div>
</clr-tree-node> </clr-tree-node>
</clr-tree> </clr-tree>
</clr-accordion-content> }
</clr-accordion-panel> }
</ng-container>
</clr-accordion>
</div>
<div *ngIf="objectView" class="objects-col">
<ng-container *ngFor="let metaObject of metaObjectShowList">
<div class="cols-head">
<clr-icon shape="rack-server"></clr-icon>
<div class="object-text">
<p class="m-0 word-break mr-20">
{{ metaObject.NAME }}
</p>
<p class="float-right ml-3">
{{ metaObject.ID }}
</p>
</div>
</div> </div>
<clr-tree *ngIf="showAcc" [clrLazy]="true"> }
<clr-tree-node </div>
*clrRecursiveFor=" <div class="clr-col-6 text-center">
let entry of root$ | async; <h3>{{ assoObjectSelected }}</h3>
getChildren: getChildren @if (showTable) {
" <clr-datagrid class="datagrid-custom-footer">
[clrExpandable]="true" <clr-dg-column>
TYPE
<clr-dg-string-filter
[clrDgStringFilter]="typeFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>
NAME
<clr-dg-string-filter
[clrDgStringFilter]="nameFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>
VALUE
<clr-dg-string-filter
[clrDgStringFilter]="valueFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-row
*clrDgItems="let metaObjectAttribute of metaObjectAttributes"
> >
<div [class.object-header]="!entry.count" class="full-width"> <clr-dg-cell>{{ metaObjectAttribute.TYPE }}</clr-dg-cell>
<div> <clr-dg-cell>{{ metaObjectAttribute.NAME }}</clr-dg-cell>
<clr-icon <clr-dg-cell>{{ metaObjectAttribute.VALUE }}</clr-dg-cell>
*ngIf="!entry.count" </clr-dg-row>
shape="rack-server" <clr-dg-footer>
></clr-icon> <clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-icon *ngIf="entry.count" shape="block"></clr-icon> <clr-dg-page-size [clrPageSizeOptions]="[10, 20, 50, 100]"
{{ entry.display }} >Attributes per page</clr-dg-page-size
</div> >
{{ pagination.firstItem + 1 }} -
<p class="float-right object-uri" *ngIf="!entry.count"> {{ pagination.lastItem + 1 }} of
{{ entry.URI }} {{ pagination.totalItems }} Attributes
</p> </clr-dg-pagination>
</div> </clr-dg-footer>
</clr-tree-node> </clr-datagrid>
</clr-tree> }
</ng-container>
</div> </div>
</div> </div>
}
<div class="clr-col-6 text-center">
<h3>{{ assoObjectSelected }}</h3>
<clr-datagrid class="datagrid-custom-footer" *ngIf="showTable">
<clr-dg-column>
TYPE
<clr-dg-string-filter
[clrDgStringFilter]="typeFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>
NAME
<clr-dg-string-filter
[clrDgStringFilter]="nameFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>
VALUE
<clr-dg-string-filter
[clrDgStringFilter]="valueFilter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-row
*clrDgItems="let metaObjectAttribute of metaObjectAttributes"
>
<clr-dg-cell>{{ metaObjectAttribute.TYPE }}</clr-dg-cell>
<clr-dg-cell>{{ metaObjectAttribute.NAME }}</clr-dg-cell>
<clr-dg-cell>{{ metaObjectAttribute.VALUE }}</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-dg-page-size [clrPageSizeOptions]="[10, 20, 50, 100]"
>Attributes per page</clr-dg-page-size
>
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} Attributes
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
</div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -14,7 +14,8 @@ export interface ColumnDetail {
LENGTH: number LENGTH: number
NAME: string NAME: string
TYPE: string TYPE: string
VARNUM: number // No longer sent by viewdata.sas — optional for legacy responses.
VARNUM?: number
} }
export interface Approver { export interface Approver {
@@ -37,7 +38,6 @@ export interface MaxVarLength {
export interface SASParam { export interface SASParam {
COLHEADERS: string COLHEADERS: string
COLTYPE: string
DTTMVARS: string DTTMVARS: string
DTVARS: string DTVARS: string
CLS_FLAG: number CLS_FLAG: number
@@ -1,117 +1,118 @@
<app-sidebar> <app-sidebar>
<div *ngIf="datasetsLoading" class="my-10-mx-auto text-center"> @if (datasetsLoading) {
<clr-spinner clrMedium></clr-spinner> <div class="my-10-mx-auto text-center">
</div> <clr-spinner clrMedium></clr-spinner>
<div *ngIf="!parsedDatasets.length" class="text-center mb-10">
<button
(click)="fileUploadInput.click()"
id="browse-file"
class="btn btn-primary btn-sm"
[disabled]="selectedFile !== null || submittingCsv"
>
Browse file
</button>
<input
hidden
#fileUploadInput
id="file-upload"
type="file"
(change)="onFileChange($event)"
multiple
/>
</div>
<ng-container *ngIf="parsedDatasets.length && !submittedCsvDatasets.length">
<div *ngIf="!excelsSubmitted" class="text-center mb-10">
<button (click)="onDiscard()" class="btn btn-danger btn-sm mr-10">
Discard
</button>
<button
(click)="onSubmitAll()"
id="submit-all"
class="btn btn-primary btn-sm"
>
Submit All
</button>
</div> </div>
}
@if (!parsedDatasets.length) {
<div class="text-center mb-10">
<button
(click)="fileUploadInput.click()"
id="browse-file"
class="btn btn-primary btn-sm"
[disabled]="selectedFile !== null || submittingCsv"
>
Browse file
</button>
<input
hidden
#fileUploadInput
id="file-upload"
type="file"
(change)="onFileChange($event)"
multiple
/>
</div>
}
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
@if (!excelsSubmitted) {
<div class="text-center mb-10">
<button (click)="onDiscard()" class="btn btn-danger btn-sm mr-10">
Discard
</button>
<button
(click)="onSubmitAll()"
id="submit-all"
class="btn btn-primary btn-sm"
>
Submit All
</button>
</div>
}
<p cds-text="caption" class="ml-10 mb-10">Found tables:</p> <p cds-text="caption" class="ml-10 mb-10">Found tables:</p>
<clr-tree> <clr-tree>
<clr-tree-node *ngFor="let dataset of parsedDatasets"> @for (dataset of parsedDatasets; track dataset) {
<button <clr-tree-node>
(click)="onParsedDatasetClick(dataset)" <button
class="clr-treenode-link whitespace-nowrap d-flex clr-align-items-center" (click)="onParsedDatasetClick(dataset)"
[class.active]="dataset.active" class="clr-treenode-link whitespace-nowrap d-flex clr-align-items-center"
> [class.active]="dataset.active"
<ng-container *ngIf="dataset.submitResult"> >
<cds-icon @if (dataset.submitResult) {
*ngIf="dataset.submitResult.error" @if (dataset.submitResult.error) {
status="danger" <cds-icon status="danger" shape="exclamation-circle"></cds-icon>
shape="exclamation-circle" }
></cds-icon> @if (dataset.submitResult.success) {
<cds-icon <cds-icon status="success" shape="check-circle"></cds-icon>
*ngIf="dataset.submitResult.success" }
status="success" }
shape="check-circle" @if (!dataset.submitResult) {
></cds-icon> @if (dataset.datasource) {
</ng-container> @if (!(dataset.datasource.length && dataset.parseResult)) {
<cds-icon
<ng-container *ngIf="!dataset.submitResult"> status="danger"
<ng-container *ngIf="dataset.datasource"> shape="exclamation-circle"
<cds-icon ></cds-icon>
*ngIf="!(dataset.datasource.length && dataset.parseResult)" }
status="danger" @if (dataset.datasource.length && dataset.parseResult) {
shape="exclamation-circle" <cds-icon shape="table"></cds-icon>
></cds-icon> }
<cds-icon }
*ngIf="dataset.datasource.length && dataset.parseResult" @if (!dataset.datasource) {
shape="table" @if (!dataset.parsingTable) {
></cds-icon> <cds-icon shape="table"></cds-icon>
</ng-container> }
@if (dataset.parsingTable) {
<ng-container *ngIf="!dataset.datasource"> <clr-spinner clrSmall></clr-spinner>
<cds-icon *ngIf="!dataset.parsingTable" shape="table"></cds-icon> }
}
<clr-spinner *ngIf="dataset.parsingTable" clrSmall></clr-spinner> }
</ng-container> <span class="ml-5"> {{ dataset.libds }} </span>
</ng-container> </button>
</clr-tree-node>
<span class="ml-5"> {{ dataset.libds }} </span> }
</button>
</clr-tree-node>
</clr-tree> </clr-tree>
</ng-container> }
<ng-container *ngIf="submittedCsvDatasets.length"> @if (submittedCsvDatasets.length) {
<p cds-text="caption" class="ml-10 mb-10 mt-10">Submitted tables:</p> <p cds-text="caption" class="ml-10 mb-10 mt-10">Submitted tables:</p>
<clr-tree> <clr-tree>
<clr-tree-node *ngFor="let dataset of submittedCsvDatasets"> @for (dataset of submittedCsvDatasets; track dataset) {
<button <clr-tree-node>
(click)="onSubmittedCsvDatasetClick(dataset)" <button
class="clr-treenode-link whitespace-nowrap" (click)="onSubmittedCsvDatasetClick(dataset)"
[class.active]="dataset.active" class="clr-treenode-link whitespace-nowrap"
> [class.active]="dataset.active"
<cds-icon >
*ngIf="dataset.error" @if (dataset.error) {
status="danger" <cds-icon status="danger" shape="exclamation-circle"></cds-icon>
shape="exclamation-circle" }
></cds-icon> @if (dataset.success) {
<cds-icon <cds-icon status="success" shape="check-circle"></cds-icon>
*ngIf="dataset.success" }
status="success" <cds-icon shape="table"></cds-icon>
shape="check-circle" {{ dataset.libds }}
></cds-icon> </button>
<cds-icon shape="table"></cds-icon> </clr-tree-node>
{{ dataset.libds }} }
</button>
</clr-tree-node>
</clr-tree> </clr-tree>
</ng-container> }
<!-- <div *ngIf="librariesPaging" class="w-100 text-center"> <!-- <div *ngIf="librariesPaging" class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span> <span class="spinner spinner-sm"> Loading... </span>
</div> --> </div> -->
</app-sidebar> </app-sidebar>
<div #contentArea class="content-area"> <div #contentArea class="content-area">
@@ -122,23 +123,22 @@
<p cds-text="section">Multi Dataset Load</p> <p cds-text="section">Multi Dataset Load</p>
</div> </div>
<div @if (selectedFile === null && !submittingCsv) {
*ngIf="selectedFile === null && !submittingCsv" <div class="no-table-selected pointer-events-none">
class="no-table-selected pointer-events-none" <clr-icon
> aria-hidden="true"
<clr-icon shape="upload-cloud"
aria-hidden="true" size="40"
shape="upload-cloud" class="is-info icon-dc-fill"
size="40" ></clr-icon>
class="is-info icon-dc-fill" <p class="text-center color-gray mt-10" cds-text="section">
></clr-icon> Please upload a file
<p class="text-center color-gray mt-10" cds-text="section"> </p>
Please upload a file </div>
</p> }
</div>
<ng-container *ngIf="selectedFile !== null || submittingCsv"> @if (selectedFile !== null || submittingCsv) {
<ng-container *ngIf="!parsedDatasets.length && selectedFile !== null"> @if (!parsedDatasets.length && selectedFile !== null) {
<div class="d-flex clr-justify-content-center mt-15"> <div class="d-flex clr-justify-content-center mt-15">
<div class="dataset-input-wrapper"> <div class="dataset-input-wrapper">
<p cds-text="secondary regular" class="mb-5"> <p cds-text="secondary regular" class="mb-5">
@@ -160,12 +160,10 @@
<p cds-text="secondary regular" class="mb-15"> <p cds-text="secondary regular" class="mb-15">
Paste or type the list of datasets to upload: Paste or type the list of datasets to upload:
</p> </p>
<clr-control-helper class="mb-5" <clr-control-helper class="mb-5"
>Each row is one dataset. We will automatically detect tables by >Each row is one dataset. We will automatically detect tables by
the sheetname and populate if any.</clr-control-helper the sheetname and populate if any.</clr-control-helper
> >
<hot-table <hot-table
#hotInstanceUserDataset #hotInstanceUserDataset
id="hotTableUserDataset" id="hotTableUserDataset"
@@ -173,7 +171,6 @@
[settings]="hotUserDatasetsSettings" [settings]="hotUserDatasetsSettings"
> >
</hot-table> </hot-table>
<div class="dataset-selection-actions text-right mt-10"> <div class="dataset-selection-actions text-right mt-10">
<button <button
(click)="onStartParsingFile()" (click)="onStartParsingFile()"
@@ -187,109 +184,90 @@
</div> </div>
</div> </div>
</div> </div>
</ng-container> }
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
<ng-container @if (!activeParsedDataset) {
*ngIf="parsedDatasets.length && !submittedCsvDatasets.length" <div class="no-table-selected pointer-events-none">
> @if (fileLoadingState !== FileLoadingState.parsed) {
<div <clr-icon
*ngIf="!activeParsedDataset" aria-hidden="true"
class="no-table-selected pointer-events-none" shape="process-on-vm"
> size="40"
<ng-container *ngIf="fileLoadingState !== FileLoadingState.parsed"> class="is-info icon-dc-fill"
<clr-icon ></clr-icon>
aria-hidden="true" <p class="text-center color-gray mt-10" cds-text="section">
shape="process-on-vm" {{ fileLoadingState }}...
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
{{ fileLoadingState }}...
</p>
</ng-container>
<ng-container *ngIf="fileLoadingState === FileLoadingState.parsed">
<clr-icon
aria-hidden="true"
shape="warning-standard"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a dataset on the left to review the data
</p>
</ng-container>
</div>
<ng-container *ngIf="activeParsedDataset">
<div
*ngIf="activeParsedDataset.submitResult"
class="d-flex clr-justify-content-between p-10 mt-15 submission-results"
>
<div>
<p cds-text="secondary regular" class="mb-10">
Submit Status:
<span
*ngIf="activeParsedDataset.submitResult?.success"
class="color-green"
><strong>SUCCESS</strong></span
>
<span
*ngIf="activeParsedDataset.submitResult?.error"
class="color-red"
><strong>ERROR</strong></span
>
</p> </p>
<p }
*ngIf="activeParsedDataset.submitResult?.error" @if (fileLoadingState === FileLoadingState.parsed) {
cds-text="secondary regular" <clr-icon
> aria-hidden="true"
Error details: shape="warning-standard"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a dataset on the left to review the data
</p> </p>
</div> }
<div>
<button
*ngIf="
!submittingCsv && activeParsedDataset.submitResult?.error
"
(click)="reSubmitTable(activeParsedDataset)"
class="btn btn-primary mt-10"
[clrLoading]="submitLoading"
>
Resubmit
</button>
<button
(click)="
downloadFile(
activeParsedDataset.submitResult.log ||
activeParsedDataset.submitResult.success ||
activeParsedDataset.submitResult.error
)
"
class="btn btn-primary-outline mt-10"
>
Download log
</button>
</div>
</div> </div>
}
<div @if (activeParsedDataset) {
*ngIf="activeParsedDataset.submitResult?.error" @if (activeParsedDataset.submitResult) {
class="error-field mt-15" <div
> class="d-flex clr-justify-content-between p-10 mt-15 submission-results"
<div class="log-wrapper"> >
{{ activeParsedDataset.submitResult?.error | json }} <div>
<p cds-text="secondary regular" class="mb-10">
Submit Status:
@if (activeParsedDataset.submitResult.success) {
<span class="color-green"><strong>SUCCESS</strong></span>
}
@if (activeParsedDataset.submitResult.error) {
<span class="color-red"><strong>ERROR</strong></span>
}
</p>
@if (activeParsedDataset.submitResult.error) {
<p cds-text="secondary regular">Error details:</p>
}
</div>
<div>
@if (!submittingCsv && activeParsedDataset.submitResult.error) {
<button
(click)="reSubmitTable(activeParsedDataset)"
class="btn btn-primary mt-10"
[clrLoading]="submitLoading"
>
Resubmit
</button>
}
<button
(click)="
downloadFile(
activeParsedDataset.submitResult.log ||
activeParsedDataset.submitResult.success ||
activeParsedDataset.submitResult.error
)
"
class="btn btn-primary-outline mt-10"
>
Download log
</button>
</div>
</div> </div>
</div> }
@if (activeParsedDataset.submitResult?.error) {
<div class="error-field mt-15">
<div class="log-wrapper">
{{ activeParsedDataset.submitResult?.error | json }}
</div>
</div>
}
<div class="d-flex clr-justify-content-between p-10 mt-15"> <div class="d-flex clr-justify-content-between p-10 mt-15">
<div> <div>
<p cds-text="secondary regular" class="mb-10"> <p cds-text="secondary regular" class="mb-10">
Found in range: Found in range:
@if (activeParsedDataset.parseResult) {
<ng-container *ngIf="activeParsedDataset.parseResult">
<strong <strong
>"{{ >"{{
activeParsedDataset.parseResult.rangeSheetRes?.sheetName activeParsedDataset.parseResult.rangeSheetRes?.sheetName
@@ -298,21 +276,18 @@
?.rangeAddress ?.rangeAddress
}}</strong }}</strong
> >
</ng-container> }
@if (!activeParsedDataset.parseResult) {
<ng-container *ngIf="!activeParsedDataset.parseResult"> @if (!activeParsedDataset.parsingTable) {
<strong *ngIf="!activeParsedDataset.parsingTable" <strong>No data found</strong>
>No data found</strong }
> @if (activeParsedDataset.parsingTable) {
<span class="d-flex clr-align-items-center">
<span <strong>Searching for the data...</strong>
*ngIf="activeParsedDataset.parsingTable" <clr-spinner class="ml-5" clrSmall></clr-spinner>
class="d-flex clr-align-items-center" </span>
> }
<strong>Searching for the data...</strong> }
<clr-spinner class="ml-5" clrSmall></clr-spinner>
</span>
</ng-container>
</p> </p>
<p cds-text="secondary regular"> <p cds-text="secondary regular">
Dataset: Dataset:
@@ -333,7 +308,6 @@
</strong> </strong>
</p> </p>
</div> </div>
<div> <div>
<clr-toggle-wrapper> <clr-toggle-wrapper>
<input <input
@@ -354,11 +328,11 @@
</clr-toggle-wrapper> </clr-toggle-wrapper>
</div> </div>
</div> </div>
@if (isHotHidden) {
<div *ngIf="isHotHidden" class="text-center w-100"> <div class="text-center w-100">
<clr-spinner class="spinner-md"></clr-spinner> <clr-spinner class="spinner-md"></clr-spinner>
</div> </div>
}
<hot-table <hot-table
#hotInstanceMain #hotInstanceMain
id="hotTable" id="hotTable"
@@ -366,27 +340,24 @@
[settings]="hotMainTableSettings" [settings]="hotMainTableSettings"
> >
</hot-table> </hot-table>
</ng-container> }
</ng-container> }
@if (submittedCsvDatasets.length) {
<ng-container *ngIf="submittedCsvDatasets.length"> @if (!activeSubmittedCsvDataset) {
<div <div class="no-table-selected pointer-events-none">
*ngIf="!activeSubmittedCsvDataset" <clr-icon
class="no-table-selected pointer-events-none" aria-hidden="true"
> shape="warning-standard"
<clr-icon size="40"
aria-hidden="true" class="is-info icon-dc-fill"
shape="warning-standard" ></clr-icon>
size="40" <p class="text-center color-gray mt-10" cds-text="section">
class="is-info icon-dc-fill" Please select a dataset on the left to review the submit results
></clr-icon> </p>
<p class="text-center color-gray mt-10" cds-text="section"> </div>
Please select a dataset on the left to review the submit results }
</p> }
</div> @if (activeSubmittedCsvDataset) {
</ng-container>
<ng-container *ngIf="activeSubmittedCsvDataset">
<div class="d-flex clr-justify-content-between p-10"> <div class="d-flex clr-justify-content-between p-10">
<div> <div>
<p cds-text="secondary regular" class="mb-10"> <p cds-text="secondary regular" class="mb-10">
@@ -409,23 +380,17 @@
</p> </p>
<p cds-text="secondary regular" class="mb-10"> <p cds-text="secondary regular" class="mb-10">
Status: Status:
<span @if (activeSubmittedCsvDataset.success) {
*ngIf="activeSubmittedCsvDataset.success" <span class="color-green"><strong>SUCCESS</strong></span>
class="color-green" }
><strong>SUCCESS</strong></span @if (activeSubmittedCsvDataset.error) {
> <span class="color-red"><strong>ERROR</strong></span>
<span *ngIf="activeSubmittedCsvDataset.error" class="color-red" }
><strong>ERROR</strong></span
>
</p>
<p
*ngIf="activeSubmittedCsvDataset.error"
cds-text="secondary regular"
>
Error details:
</p> </p>
@if (activeSubmittedCsvDataset.error) {
<p cds-text="secondary regular">Error details:</p>
}
</div> </div>
<div> <div>
<button <button
(click)=" (click)="
@@ -440,28 +405,29 @@
</button> </button>
</div> </div>
</div> </div>
@if (activeSubmittedCsvDataset.error) {
<div *ngIf="activeSubmittedCsvDataset.error" class="error-field mt-15"> <div class="error-field mt-15">
<div class="log-wrapper"> <div class="log-wrapper">
{{ activeSubmittedCsvDataset.error | json }} {{ activeSubmittedCsvDataset.error | json }}
</div>
</div> </div>
</div> }
</ng-container> }
</ng-container> }
<!-- <div> <!-- <div>
<p <p
*ngIf=" *ngIf="
licenceState.value.viewer_rows_allowed !== Infinity && licenceState.value.viewer_rows_allowed !== Infinity &&
hotTable.data && hotTable.data &&
hotTable.data.length > licenceState.value.viewer_rows_allowed hotTable.data.length > licenceState.value.viewer_rows_allowed
" "
class="mt-2-i w-100 text-center" class="mt-2-i w-100 text-center"
> >
To display more than {{ licenceState.value.viewer_rows_allowed }} rows, To display more than {{ licenceState.value.viewer_rows_allowed }} rows,
contact <contact-link /> contact <contact-link />
</p> </p>
</div> --> </div> -->
</div> </div>
</div> </div>
@@ -471,16 +437,14 @@
{{ tablesToSubmit.length === 1 ? 'table' : 'tables' }} for approval {{ tablesToSubmit.length === 1 ? 'table' : 'tables' }} for approval
</h3> </h3>
<div class="modal-body"> <div class="modal-body">
<p @if (licenceState.value.submit_rows_limit !== Infinity) {
*ngIf="licenceState.value.submit_rows_limit !== Infinity" <p cds-text="body" class="licence-limit-notice mt-0 mb-15">
cds-text="body" Due to current licence, only
class="licence-limit-notice mt-0 mb-15" {{ licenceState.value.submit_rows_limit }} rows in each file will be
> submitted. To remove the restriction, contact
Due to current licence, only support&#64;datacontroller.io.
{{ licenceState.value.submit_rows_limit }} rows in each file will be </p>
submitted. To remove the restriction, contact }
support&#64;datacontroller.io.
</p>
<div class="text-area-full-width"> <div class="text-area-full-width">
<label for="formFields_8" class="mb-5 d-block">Message</label> <label for="formFields_8" class="mb-5 d-block">Message</label>
@@ -22,10 +22,9 @@ import { ExcelRule } from '../models/TableData'
import { HotTableInterface } from '../models/HotTable.interface' import { HotTableInterface } from '../models/HotTable.interface'
import { Col } from '../shared/dc-validator/models/col.model' import { Col } from '../shared/dc-validator/models/col.model'
import { SpreadsheetService } from '../services/spreadsheet.service' import { SpreadsheetService } from '../services/spreadsheet.service'
import Handsontable from 'handsontable' import Handsontable, { CellChange, ChangeSource } from 'handsontable'
import { HotTableComponent } from '@handsontable/angular-wrapper' import { HotTableComponent } from '@handsontable/angular-wrapper'
import { EditorsStageDataSASResponse } from '../models/sas/editors-stagedata.model' import { EditorsStageDataSASResponse } from '../models/sas/editors-stagedata.model'
import { CellChange, ChangeSource } from 'handsontable/common'
import { baseAfterGetColHeader } from '../shared/utils/hot.utils' import { baseAfterGetColHeader } from '../shared/utils/hot.utils'
import { ColumnSettings } from 'handsontable/settings' import { ColumnSettings } from 'handsontable/settings'
import { UploadFile } from '@sasjs/adapter' import { UploadFile } from '@sasjs/adapter'
@@ -502,7 +501,9 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
if (changes) { if (changes) {
for (let change of changes) { for (let change of changes) {
if (change && change[3]) { if (change && change[3]) {
change[3] = change[3].toUpperCase() // CellValue is `unknown` in HOT 18 (was a concrete union before);
// this column is always a dataset name string.
change[3] = (change[3] as string).toUpperCase()
} }
} }
} }
@@ -552,7 +553,9 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
dynamicCellValidations() { dynamicCellValidations() {
if (!this.hotInstanceUserDataset) return if (!this.hotInstanceUserDataset) return
const hotData = this.hotInstanceUserDataset.getData() // getData() returns unknown[][] in HOT 18 (was CellValue[][]); this grid's
// data is always library/table name strings.
const hotData = this.hotInstanceUserDataset.getData() as string[][]
hotData.forEach((row, rowIndex) => { hotData.forEach((row, rowIndex) => {
const library = row[0] const library = row[0]
@@ -573,10 +576,12 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
if (dataAtRow && dataAtRow[0] && dataAtRow[1]) { if (dataAtRow && dataAtRow[0] && dataAtRow[1]) {
if (!this.matchedDatasets.includes(dataset)) { if (!this.matchedDatasets.includes(dataset)) {
// getCellMetaAtRow() returns Record<string, unknown>[] in HOT 18, so
// `.col` needs casting back to number for setCellMeta() (same below).
cellMetaAtRow.forEach((cellMeta) => { cellMetaAtRow.forEach((cellMeta) => {
this.hotInstanceUserDataset.setCellMeta( this.hotInstanceUserDataset.setCellMeta(
row, row,
cellMeta.col, cellMeta.col as number,
'className', 'className',
'not-matched' 'not-matched'
) )
@@ -585,7 +590,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
cellMetaAtRow.forEach((cellMeta) => { cellMetaAtRow.forEach((cellMeta) => {
this.hotInstanceUserDataset.setCellMeta( this.hotInstanceUserDataset.setCellMeta(
row, row,
cellMeta.col, cellMeta.col as number,
'className', 'className',
'' ''
) )
@@ -595,7 +600,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
cellMetaAtRow.forEach((cellMeta) => { cellMetaAtRow.forEach((cellMeta) => {
this.hotInstanceUserDataset.setCellMeta( this.hotInstanceUserDataset.setCellMeta(
row, row,
cellMeta.col, cellMeta.col as number,
'className', 'className',
'' ''
) )
@@ -991,7 +996,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
private getDatasetsFromHot(): string[] { private getDatasetsFromHot(): string[] {
if (!this.hotInstanceUserDataset) return [] if (!this.hotInstanceUserDataset) return []
const hotData = this.hotInstanceUserDataset.getData() // getData() returns unknown[][] in HOT 18; see dynamicCellValidations() above.
const hotData = this.hotInstanceUserDataset.getData() as string[][]
return hotData return hotData
.filter((row) => row[0]?.length && row[1]?.length) .filter((row) => row[0]?.length && row[1]?.length)
+308 -298
View File
@@ -30,20 +30,21 @@
(ngModelChange)="setGroupLogic(groupLogic)" (ngModelChange)="setGroupLogic(groupLogic)"
clrSelect clrSelect
> >
<option @for (logic of logicOperators; track logic) {
*ngFor="let logic of logicOperators" <option [selected]="logicOperators[0]">
[selected]="logicOperators[0]" {{ logic }}
> </option>
{{ logic }} }
</option>
</select> </select>
</clr-select-container> </clr-select-container>
</div> </div>
<div class="clr-col-md-10 mb-30"> <div class="clr-col-md-10 mb-30">
<pre class="line-numbers language-markup"> <pre class="line-numbers language-markup">
<div *ngIf="whereClauseLoading" class="progresStatic progress loop"> @if (whereClauseLoading) {
<progress></progress> <div class="progresStatic progress loop">
</div> <progress></progress>
</div>
}
<code class="language-sql">{{whereClause}}</code> <code class="language-sql">{{whereClause}}</code>
</pre> </pre>
@@ -55,245 +56,246 @@
class="clauses-container clr-col-md-12" class="clauses-container clr-col-md-12"
[class.clr-col-md-10]="clauses?.queryObj?.length > 1" [class.clr-col-md-10]="clauses?.queryObj?.length > 1"
> >
<div *ngIf="clauses?.queryObj?.length > 1"></div> @if (clauses?.queryObj?.length > 1) {
<div></div>
}
<div @for (clause of clauses.queryObj; track clause; let clauseIndex = $index) {
class="clause-row" <div class="clause-row">
*ngFor="let clause of clauses.queryObj; let clauseIndex = index" <div class="clr-row" [class.invalid-clause]="clause.invalidClause">
> <div class="clause-logic clr-col-md-2">
<div class="clr-row" [class.invalid-clause]="clause.invalidClause"> <div class="select">
<div class="clause-logic clr-col-md-2">
<div class="select">
<clr-select-container>
<label>Logic</label>
<select
[(ngModel)]="clause.clauseLogic"
(ngModelChange)="setLogic()"
[disabled]="clause.elements.length < 2"
clrSelect
>
<option
*ngFor="let logic of logicOperators"
[selected]="logicOperators[0]"
>
{{ logic }}
</option>
</select>
</clr-select-container>
</div>
<br />
<button
*ngIf="innerWidth > 768"
class="btn btn-primary btn-block mt-10"
(click)="addGroupClause()"
>
<clr-icon shape="plus"></clr-icon>
<span>Group</span>
</button>
</div>
<div class="clause-query clr-col-md-10">
<clr-icon
*ngIf="clauses.queryObj.length > 1"
(click)="removeGroupClause(clauseIndex)"
shape="times"
size="36"
class="remove-group-clause-button"
></clr-icon>
<div
class="clr-row"
*ngFor="let query of clause.elements; let queryIndex = index"
[class.invalid-clause]="query.invalidClause"
>
<!-- VARIABLE -->
<div class="variable-col form-group clr-col-md-3">
<div class="datalist-wrapper">
<app-soft-select
label="Variable"
[id]="'select_vals_var_id' + queryIndex + '_' + clauseIndex"
[inputId]="'vals_var_id' + queryIndex + '_' + clauseIndex"
[emitOnlySelected]="true"
[(value)]="query.variable"
(onInputEvent)="
variableInputChange(
query.variable,
queryIndex,
clauseIndex,
$event
)
"
>
<option *ngFor="let column of cols">
{{ column.NAME }}
</option>
</app-soft-select>
</div>
</div>
<!-- OPERATOR -->
<div class="operator-col form-group clr-col-md-3">
<clr-select-container> <clr-select-container>
<label>Operator</label> <label>Logic</label>
<select <select
[(ngModel)]="query.operator" [(ngModel)]="clause.clauseLogic"
(ngModelChange)=" (ngModelChange)="setLogic()"
setVariableOperator(queryIndex, query.operator, clauseIndex) [disabled]="clause.elements.length < 2"
"
clrSelect clrSelect
> >
<option *ngFor="let opr of query.operators">{{ opr }}</option> @for (logic of logicOperators; track logic) {
<option [selected]="logicOperators[0]">
{{ logic }}
</option>
}
</select> </select>
</clr-select-container> </clr-select-container>
</div> </div>
<br />
<!-- VALUE --> @if (innerWidth > 768) {
<div
*ngVar="
query.ddtype === 'DATE' ||
query.ddtype === 'DATETIME' ||
query.ddtype === 'TIME' as isDateTime
"
class="value-col form-group clr-col-md-3"
>
<div
*ngIf="query.operator === 'IN' || query.operator === 'NOT IN'"
class="checkbox-vals"
>
<button
(click)="
currentQueryIndex = queryIndex;
currentClauseIndex = clauseIndex
"
type="button"
class="btn btn-link"
>
Choose values
</button>
<ng-container
*ngTemplateOutlet="
checkboxValues;
context: {
query: query,
queryIndex: queryIndex,
clauseIndex: clauseIndex
}
"
>
</ng-container>
</div>
<div
*ngIf="
query.operator !== 'BETWEEN' &&
query.operator !== 'IN' &&
query.operator !== 'NOT IN' &&
query.operator !== 'LIKE' &&
query.operator !== 'CONTAINS' &&
query.operator !== 'BEGINS_WITH'
"
class="single-field-vals"
>
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPicker;
context: {
query: query,
queryIndex: queryIndex,
clauseIndex: clauseIndex,
isDateTime: isDateTime
}
"
>
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
<div *ngIf="query.operator === 'BETWEEN'" class="range-vals">
<div class="from">
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPickerRange;
context: {
range: 'start',
query: query,
queryValueIndex: 0,
queryIndex: queryIndex,
clauseIndex: clauseIndex,
isDateTime: isDateTime
}
"
>
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
<div class="to">
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPickerRange;
context: {
range: 'end',
query: query,
queryValueIndex: 1,
queryIndex: queryIndex,
clauseIndex: clauseIndex,
isDateTime: isDateTime
}
"
>
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
</div>
<div
*ngIf="
query.operator === 'LIKE' ||
query.operator === 'BEGINS_WITH' ||
query.operator === 'CONTAINS'
"
class="contains-vals"
>
<label class="clr-control-label">Value</label>
<input
class="input-val"
type="text"
[(ngModel)]="query.value"
(ngModelChange)="
setVariableValues($event, queryIndex, clauseIndex)
"
/>
</div>
</div>
<div class="clause-buttons clr-col-md-2 btn-group">
<button <button
class="btn btn-warning btn-block" class="btn btn-primary btn-block mt-10"
(click)="removeClause(queryIndex, clauseIndex)" (click)="addGroupClause()"
[disabled]="clauses.queryObj[clauseIndex].elements.length === 1"
>
<clr-icon shape="minus"></clr-icon>
<span></span>
</button>
<button
class="btn btn-success btn-block"
(click)="addClause(clauseIndex)"
> >
<clr-icon shape="plus"></clr-icon> <clr-icon shape="plus"></clr-icon>
<span></span> <span>Group</span>
</button> </button>
</div> }
</div>
<div class="clause-query clr-col-md-10">
@if (clauses.queryObj.length > 1) {
<clr-icon
(click)="removeGroupClause(clauseIndex)"
shape="times"
size="36"
class="remove-group-clause-button"
></clr-icon>
}
@for (
query of clause.elements;
track query;
let queryIndex = $index
) {
<div class="clr-row" [class.invalid-clause]="query.invalidClause">
<!-- VARIABLE -->
<div class="variable-col form-group clr-col-md-3">
<div class="datalist-wrapper">
<app-soft-select
label="Variable"
[id]="
'select_vals_var_id' + queryIndex + '_' + clauseIndex
"
[inputId]="'vals_var_id' + queryIndex + '_' + clauseIndex"
[emitOnlySelected]="true"
[(value)]="query.variable"
(onInputEvent)="
variableInputChange(
query.variable,
queryIndex,
clauseIndex,
$event
)
"
>
@for (column of cols; track column) {
<option>
{{ column.NAME }}
</option>
}
</app-soft-select>
</div>
</div>
<!-- OPERATOR -->
<div class="operator-col form-group clr-col-md-3">
<clr-select-container>
<label>Operator</label>
<select
[(ngModel)]="query.operator"
(ngModelChange)="
setVariableOperator(
queryIndex,
query.operator,
clauseIndex
)
"
clrSelect
>
@for (opr of query.operators; track opr) {
<option>{{ opr }}</option>
}
</select>
</clr-select-container>
</div>
<!-- VALUE -->
<div
*ngVar="
query.ddtype === 'DATE' ||
query.ddtype === 'DATETIME' ||
query.ddtype === 'TIME' as isDateTime
"
class="value-col form-group clr-col-md-3"
>
@if (query.operator === 'IN' || query.operator === 'NOT IN') {
<div class="checkbox-vals">
<button
(click)="
currentQueryIndex = queryIndex;
currentClauseIndex = clauseIndex
"
type="button"
class="btn btn-link"
>
Choose values
</button>
<ng-container
*ngTemplateOutlet="
checkboxValues;
context: {
query: query,
queryIndex: queryIndex,
clauseIndex: clauseIndex
}
"
>
</ng-container>
</div>
}
@if (
query.operator !== 'BETWEEN' &&
query.operator !== 'IN' &&
query.operator !== 'NOT IN' &&
query.operator !== 'LIKE' &&
query.operator !== 'CONTAINS' &&
query.operator !== 'BEGINS_WITH'
) {
<div class="single-field-vals">
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPicker;
context: {
query: query,
queryIndex: queryIndex,
clauseIndex: clauseIndex,
isDateTime: isDateTime
}
"
>
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
}
@if (query.operator === 'BETWEEN') {
<div class="range-vals">
<div class="from">
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPickerRange;
context: {
range: 'start',
query: query,
queryValueIndex: 0,
queryIndex: queryIndex,
clauseIndex: clauseIndex,
isDateTime: isDateTime
}
"
>
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
<div class="to">
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPickerRange;
context: {
range: 'end',
query: query,
queryValueIndex: 1,
queryIndex: queryIndex,
clauseIndex: clauseIndex,
isDateTime: isDateTime
}
"
>
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
</div>
}
@if (
query.operator === 'LIKE' ||
query.operator === 'BEGINS_WITH' ||
query.operator === 'CONTAINS'
) {
<div class="contains-vals">
<label class="clr-control-label">Value</label>
<input
class="input-val"
type="text"
[(ngModel)]="query.value"
(ngModelChange)="
setVariableValues($event, queryIndex, clauseIndex)
"
/>
</div>
}
</div>
<div class="clause-buttons clr-col-md-2 btn-group">
<button
class="btn btn-warning btn-block"
(click)="removeClause(queryIndex, clauseIndex)"
[disabled]="
clauses.queryObj[clauseIndex].elements.length === 1
"
>
<clr-icon shape="minus"></clr-icon>
<span></span>
</button>
<button
class="btn btn-success btn-block"
(click)="addClause(clauseIndex)"
>
<clr-icon shape="plus"></clr-icon>
<span></span>
</button>
</div>
</div>
}
</div> </div>
</div> </div>
<br />
</div> </div>
<br /> }
</div>
</div> </div>
</div> </div>
@@ -305,8 +307,8 @@
let-queryIndex="queryIndex" let-queryIndex="queryIndex"
let-clauseIndex="clauseIndex" let-clauseIndex="clauseIndex"
> >
<ng-container [ngSwitch]="query.ddtype"> @switch (query.ddtype) {
<ng-container *ngSwitchCase="'DATE'"> @case ('DATE') {
<app-soft-select <app-soft-select
label="Value" label="Value"
type="date" type="date"
@@ -323,14 +325,13 @@
> >
<!-- In case we want to enable dropdown on the pickers, uncomment below --> <!-- In case we want to enable dropdown on the pickers, uncomment below -->
<!-- <ng-container *ngFor="let value of query.values"> <!-- <ng-container *ngFor="let value of query.values">
<option *ngIf="value.unformatted !== null"> <option *ngIf="value.unformatted !== null">
{{ value.formatted | dateTimeFormatter: 'date' }} {{ value.formatted | dateTimeFormatter: 'date' }}
</option> </option>
</ng-container> --> </ng-container> -->
</app-soft-select> </app-soft-select>
</ng-container> }
@case ('DATETIME') {
<ng-container *ngSwitchCase="'DATETIME'">
<app-soft-select <app-soft-select
label="Value" label="Value"
type="date" type="date"
@@ -348,12 +349,11 @@
> >
<!-- In case we want to enable dropdown on the pickers, uncomment below --> <!-- In case we want to enable dropdown on the pickers, uncomment below -->
<!-- <ng-container *ngFor="let value of query.values"> <!-- <ng-container *ngFor="let value of query.values">
<option *ngIf="value.unformatted !== null"> <option *ngIf="value.unformatted !== null">
{{ value.formatted | dateTimeFormatter: 'date' }} {{ value.formatted | dateTimeFormatter: 'date' }}
</option> </option>
</ng-container> --> </ng-container> -->
</app-soft-select> </app-soft-select>
<app-soft-select <app-soft-select
type="time" type="time"
[disableSoftselect]="true" [disableSoftselect]="true"
@@ -370,14 +370,13 @@
> >
<!-- In case we want to enable dropdown on the pickers, uncomment below --> <!-- In case we want to enable dropdown on the pickers, uncomment below -->
<!-- <ng-container *ngFor="let value of query.values"> <!-- <ng-container *ngFor="let value of query.values">
<option *ngIf="value.unformatted !== null"> <option *ngIf="value.unformatted !== null">
{{ value.formatted | dateTimeFormatter: 'time' }} {{ value.formatted | dateTimeFormatter: 'time' }}
</option> </option>
</ng-container> --> </ng-container> -->
</app-soft-select> </app-soft-select>
</ng-container> }
@case ('TIME') {
<ng-container *ngSwitchCase="'TIME'">
<app-soft-select <app-soft-select
label="Value" label="Value"
type="time" type="time"
@@ -394,13 +393,13 @@
> >
<!-- In case we want to enable dropdown on the pickers, uncomment below --> <!-- In case we want to enable dropdown on the pickers, uncomment below -->
<!-- <ng-container *ngFor="let value of query.values"> <!-- <ng-container *ngFor="let value of query.values">
<option *ngIf="value.unformatted !== null"> <option *ngIf="value.unformatted !== null">
{{ value.formatted | dateTimeFormatter: 'time' }} {{ value.formatted | dateTimeFormatter: 'time' }}
</option> </option>
</ng-container> --> </ng-container> -->
</app-soft-select> </app-soft-select>
</ng-container> }
</ng-container> }
</ng-template> </ng-template>
<ng-template <ng-template
@@ -425,19 +424,27 @@
onAutocompleteLoadingMore($event, query.variable, queryIndex, clauseIndex) onAutocompleteLoadingMore($event, query.variable, queryIndex, clauseIndex)
" "
> >
<div *ngIf="!query.valueVariable"> @if (!query.valueVariable) {
<option [value]="column.unformatted" *ngFor="let column of query.values"> <div>
{{ column.formatted.trim() }} @for (column of query.values; track column) {
</option> <option [value]="column.unformatted">
</div> {{ column.formatted.trim() }}
</option>
}
</div>
}
<div *ngIf="query.valueVariable"> @if (query.valueVariable) {
<ng-container *ngFor="let column of cols"> <div>
<option [value]="column.NAME" *ngIf="column.TYPE === query.type"> @for (column of cols; track column) {
{{ column.NAME }} @if (column.TYPE === query.type) {
</option> <option [value]="column.NAME">
</ng-container> {{ column.NAME }}
</div> </option>
}
}
</div>
}
</app-soft-select> </app-soft-select>
</ng-template> </ng-template>
@@ -466,9 +473,11 @@
: false : false
" "
> >
<option [value]="column.formatted" *ngFor="let column of query.values"> @for (column of query.values; track column) {
{{ column.formatted }} <option [value]="column.formatted">
</option> {{ column.formatted }}
</option>
}
</app-soft-select> </app-soft-select>
</ng-template> </ng-template>
@@ -487,30 +496,31 @@
> >
<h3 class="modal-title">Select values</h3> <h3 class="modal-title">Select values</h3>
<div class="modal-body"> <div class="modal-body">
<h5 *ngIf="!isArr(query.value)" class="no-values"> @if (!isArr(query.value)) {
No values available. <h5 class="no-values">No values available.</h5>
</h5> }
<section class="form-block" *ngIf="isArr(query.value)"> @if (isArr(query.value)) {
<clr-checkbox-container> <section class="form-block">
<clr-checkbox-wrapper <clr-checkbox-container>
*ngFor="let column of query.values; let i = index" @for (column of query.values; track column; let i = $index) {
> <clr-checkbox-wrapper>
<input <input
type="checkbox" type="checkbox"
clrCheckbox clrCheckbox
[(ngModel)]="query.value[i].checked" [(ngModel)]="query.value[i].checked"
(ngModelChange)=" (ngModelChange)="
setVariableValues($event, queryIndex, clauseIndex) setVariableValues($event, queryIndex, clauseIndex)
" "
/> />
<label>
<label> {{ column.formatted }}
{{ column.formatted }} </label>
</label> </clr-checkbox-wrapper>
</clr-checkbox-wrapper> }
</clr-checkbox-container> </clr-checkbox-container>
</section> </section>
}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button <button
+11 -37
View File
@@ -22,6 +22,7 @@ import { QueryDateTime } from './models/QueryDateTime.model'
import { isSpecialMissing } from '@sasjs/utils/input/validators' import { isSpecialMissing } from '@sasjs/utils/input/validators'
import { get } from 'lodash-es' import { get } from 'lodash-es'
import { OnLoadingMoreEvent } from '../shared/autocomplete/autocomplete.component' import { OnLoadingMoreEvent } from '../shared/autocomplete/autocomplete.component'
import { getFilterObjPath } from './utils/getFilterObjPath'
registerLocaleData(localeEnGB) registerLocaleData(localeEnGB)
@Component({ @Component({
@@ -251,17 +252,7 @@ export class QueryComponent
public setToGlobals() { public setToGlobals() {
if (!this.caching) return if (!this.caching) return
let objPath = '' const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
if (this.viewboxId) {
objPath = `viewboxes.${this.viewboxId}`
} else {
objPath = 'editor'
}
} else if (globals.rootParam === 'view') {
objPath = 'viewer'
}
get(globals, objPath).filter.groupLogic = this.groupLogic get(globals, objPath).filter.groupLogic = this.groupLogic
if (typeof this.whereClause === 'string') { if (typeof this.whereClause === 'string') {
@@ -280,17 +271,7 @@ export class QueryComponent
public getFromGlobals() { public getFromGlobals() {
if (!this.caching) return if (!this.caching) return
let objPath = '' const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
if (this.viewboxId) {
objPath = `viewboxes.${this.viewboxId}`
} else {
objPath = 'editor'
}
} else if (globals.rootParam === 'view') {
objPath = 'viewer'
}
if (get(globals, objPath).filter.cols.length > 0) { if (get(globals, objPath).filter.cols.length > 0) {
this.cols = JSON.parse(JSON.stringify(get(globals, objPath).filter.cols)) this.cols = JSON.parse(JSON.stringify(get(globals, objPath).filter.cols))
@@ -1051,22 +1032,23 @@ export class QueryComponent
this.columnsSub = this.sasStoreService.columns.subscribe( this.columnsSub = this.sasStoreService.columns.subscribe(
(response: any) => { (response: any) => {
let cols = response.data.cols let cols = response.data.cols
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
if (globals.rootParam === 'home' || globals.rootParam === 'editor') { if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
this.cols = cols this.cols = cols
let some = cols[0].NAME let some = cols[0].NAME
this.libds = response.libds this.libds = response.libds
globals.editor.filter.cols = JSON.parse(JSON.stringify(cols)) get(globals, objPath).filter.cols = JSON.parse(JSON.stringify(cols))
} }
if (globals.rootParam === 'view') { if (globals.rootParam === 'view') {
if (globals.viewer.filter.cols.length < 1) { if (get(globals, objPath).filter.cols.length < 1) {
this.cols = cols this.cols = cols
let some = cols[0].NAME let some = cols[0].NAME
this.libds = response.libds this.libds = response.libds
globals.viewer.filter.cols = JSON.parse(JSON.stringify(cols)) get(globals, objPath).filter.cols = JSON.parse(JSON.stringify(cols))
} }
} }
@@ -1077,20 +1059,12 @@ export class QueryComponent
) )
this.valuesSub = this.sasStoreService.values.subscribe((res: any) => { this.valuesSub = this.sasStoreService.values.subscribe((res: any) => {
if (globals.rootParam === 'home' || globals.rootParam === 'editor') { const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
if (globals.editor.filter.vals.length < 1) {
this.vals = res.vals
globals.editor.filter.vals = JSON.parse(JSON.stringify(res.vals)) if (get(globals, objPath).filter.vals.length < 1) {
} this.vals = res.vals
}
if (globals.rootParam === 'view') { get(globals, objPath).filter.vals = JSON.parse(JSON.stringify(res.vals))
if (globals.viewer.filter.vals.length < 1) {
this.vals = res.vals
globals.viewer.filter.vals = JSON.parse(JSON.stringify(res.vals))
}
} }
}) })
} }
@@ -0,0 +1,31 @@
import { getFilterObjPath } from './getFilterObjPath'
describe('getFilterObjPath', () => {
it("resolves to 'viewer' for the view page with no viewboxId (existing correct case)", () => {
expect(getFilterObjPath('view', undefined)).toEqual('viewer')
})
it("resolves to 'editor' for the editor page with no viewboxId", () => {
expect(getFilterObjPath('editor', undefined)).toEqual('editor')
})
it("resolves to 'editor' for the home page with no viewboxId (existing home/editor grouping)", () => {
expect(getFilterObjPath('home', undefined)).toEqual('editor')
})
it("resolves to 'viewboxes.<id>' when viewboxId is set on the view page - the actual bug (previously returned 'viewer')", () => {
expect(getFilterObjPath('view', 42)).toEqual('viewboxes.42')
})
it("resolves to 'viewboxes.<id>' when viewboxId is set on the editor page (already correct today)", () => {
expect(getFilterObjPath('editor', 42)).toEqual('viewboxes.42')
})
it("resolves to 'viewboxes.<id>' when viewboxId is set on the home page (already correct today)", () => {
expect(getFilterObjPath('home', 42)).toEqual('viewboxes.42')
})
it('resolves to an empty string for an unrecognized rootParam (matches existing fallthrough)', () => {
expect(getFilterObjPath('', undefined)).toEqual('')
})
})
@@ -0,0 +1,15 @@
/**
* Resolves which bucket of the `globals` filter cache (_globals.ts) a
* QueryComponent instance should read/write. viewboxId is checked first,
* unconditionally.
*/
export const getFilterObjPath = (
rootParam: string,
viewboxId?: number
): string => {
if (viewboxId) return `viewboxes.${viewboxId}`
if (rootParam === 'home' || rootParam === 'editor') return 'editor'
if (rootParam === 'view') return 'viewer'
return ''
}
File diff suppressed because it is too large Load Diff
@@ -1,148 +1,161 @@
<div class="content-area"> <div class="content-area">
<div class="card"> <div class="card">
<div *ngIf="remained === 0" class="d-flex justify-content-center"> @if (remained === 0) {
<div class="card-block noapprovals-info-wrapper"> <div class="d-flex justify-content-center">
<clr-icon <div class="card-block noapprovals-info-wrapper">
shape="warning-standard" <clr-icon
size="60" shape="warning-standard"
class="is-info icon-dc-fill" size="60"
></clr-icon> class="is-info icon-dc-fill"
<h3 class="text-center color-gray">There are no approvals remaining</h3> ></clr-icon>
<h3 class="text-center color-gray">
There are no approvals remaining
</h3>
</div>
</div> </div>
</div> }
<div class="card-header" [ngClass]="{ noBorder: !loaded }"> <div class="card-header" [ngClass]="{ noBorder: !loaded }">
<h3 @if (loaded && remained !== 0) {
class="center clr-col-md-12 text-center" <h3 class="center clr-col-md-12 text-center">REVIEW</h3>
*ngIf="loaded && remained !== 0" }
> @if (loaded && remained !== 0) {
REVIEW <p class="text-center font-weight-700 color-dark-gray">
</h3> You have <span>{{ remained }} </span>approvals remaining
<p </p>
class="text-center font-weight-700 color-dark-gray" }
*ngIf="loaded && remained !== 0"
>
You have <span>{{ remained }} </span>approvals remaining
</p>
</div> </div>
<div *ngIf="!loaded" class="approvals-list-wrapper"> @if (!loaded) {
<span class="spinner" *ngIf="!loaded"> Loading... </span> <div class="approvals-list-wrapper">
<div *ngIf="!loaded"> @if (!loaded) {
<h3>Loading approvals list</h3> <span class="spinner"> Loading... </span>
}
@if (!loaded) {
<div>
<h3>Loading approvals list</h3>
</div>
}
</div> </div>
</div> }
<div class="clr-col-md-12" ng-if="loaded"> <div class="clr-col-md-12" ng-if="loaded">
<div *ngIf="approveList && remained !== 0"> @if (approveList && remained !== 0) {
<clr-datagrid class="datagrid-compact datagrid-custom-footer"> <div>
<clr-dg-column [clrDgField]="'submitter'"> <clr-datagrid class="datagrid-compact datagrid-custom-footer">
SUBMITTER <clr-dg-column [clrDgField]="'submitter'">
<clr-dg-string-filter SUBMITTER
[clrDgStringFilter]="submitterFilter" <clr-dg-string-filter
aria-label="Filter submitter" [clrDgStringFilter]="submitterFilter"
></clr-dg-string-filter> aria-label="Filter submitter"
</clr-dg-column> ></clr-dg-string-filter>
<clr-dg-column [clrDgField]="'baseTable'"> </clr-dg-column>
BASE TABLE <clr-dg-column [clrDgField]="'baseTable'">
<clr-dg-string-filter BASE TABLE
[clrDgStringFilter]="baseTableFilter" <clr-dg-string-filter
aria-label="Filter base table" [clrDgStringFilter]="baseTableFilter"
></clr-dg-string-filter> aria-label="Filter base table"
</clr-dg-column> ></clr-dg-string-filter>
<clr-dg-column [clrDgField]="'submitted'"> </clr-dg-column>
SUBMITTED <clr-dg-column [clrDgField]="'submitted'">
<clr-dg-string-filter SUBMITTED
[clrDgStringFilter]="submittedFilter" <clr-dg-string-filter
aria-label="Filter submitted date" [clrDgStringFilter]="submittedFilter"
></clr-dg-string-filter> aria-label="Filter submitted date"
</clr-dg-column> ></clr-dg-string-filter>
<clr-dg-column [clrDgField]="'submitReason'"> </clr-dg-column>
SUBMIT REASON <clr-dg-column [clrDgField]="'submitReason'">
<clr-dg-string-filter SUBMIT REASON
[clrDgStringFilter]="submitReasonFilter" <clr-dg-string-filter
aria-label="Filter submit reason" [clrDgStringFilter]="submitReasonFilter"
></clr-dg-string-filter> aria-label="Filter submit reason"
</clr-dg-column> ></clr-dg-string-filter>
<clr-dg-column>ACTION</clr-dg-column> </clr-dg-column>
<clr-dg-column>DOWNLOAD</clr-dg-column> <clr-dg-column>ACTION</clr-dg-column>
<clr-dg-column>DOWNLOAD</clr-dg-column>
<clr-dg-row <clr-dg-row
*clrDgItems="let approveItem of approveList; let i = index" *clrDgItems="let approveItem of approveList; let i = index"
> >
<clr-dg-cell>{{ approveItem.submitter }}</clr-dg-cell> <clr-dg-cell>{{ approveItem.submitter }}</clr-dg-cell>
<clr-dg-cell>{{ approveItem.baseTable }}</clr-dg-cell> <clr-dg-cell>{{ approveItem.baseTable }}</clr-dg-cell>
<clr-dg-cell>{{ approveItem.submitted }}</clr-dg-cell> <clr-dg-cell>{{ approveItem.submitted }}</clr-dg-cell>
<clr-dg-cell>{{ approveItem.submitReason }}</clr-dg-cell> <clr-dg-cell>{{ approveItem.submitReason }}</clr-dg-cell>
<clr-dg-cell> <clr-dg-cell>
<div <div
class="clr-row d-flex justify-content-around" class="clr-row d-flex justify-content-around"
role="toolbar" role="toolbar"
aria-label="Table actions" aria-label="Table actions"
>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-green"
(click)="getClicked(i)"
> >
<clr-icon <a
shape="check" class="column-center links tooltip tooltip-md tooltip-bottom-left color-green"
size="24" (click)="getClicked(i)"
aria-hidden="true" >
></clr-icon> <clr-icon
<span class="tooltip-content">Go to review page screen</span> shape="check"
</a> size="24"
<a aria-hidden="true"
class="column-center links tooltip tooltip-md tooltip-bottom-left color-red" ></clr-icon>
(click)="!approveItem.rejectLoading ? rejecting(i) : ''" <span class="tooltip-content"
>Go to review page screen</span
>
</a>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-red"
(click)="!approveItem.rejectLoading ? rejecting(i) : ''"
>
@if (!approveItem.rejectLoading) {
<clr-icon
shape="ban"
size="22"
aria-hidden="true"
></clr-icon>
}
@if (approveItem.rejectLoading) {
<clr-spinner
[clrSmall]="true"
aria-hidden="true"
></clr-spinner>
}
<span class="tooltip-content">Reject</span>
</a>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
(click)="getTable(approveItem.tableId)"
>
<clr-icon
shape="code"
size="28"
aria-hidden="true"
></clr-icon>
<span class="tooltip-content"
>Go to staged data screen</span
>
</a>
</div>
</clr-dg-cell>
<clr-dg-cell class="p-0 d-flex justify-content-center">
<button
class="btn btn-success"
aria-label="Download audit file"
[id]="approveItem.tableId"
(click)="
download(approveItem.tableId); $event.stopPropagation()
"
> >
<clr-icon <clr-icon shape="download"></clr-icon>
*ngIf="!approveItem.rejectLoading" </button>
shape="ban" </clr-dg-cell>
size="22" </clr-dg-row>
aria-hidden="true" <clr-dg-footer>
></clr-icon> <clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-spinner <clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
*ngIf="approveItem.rejectLoading" >Items per page</clr-dg-page-size
[clrSmall]="true"
aria-hidden="true"
></clr-spinner>
<span class="tooltip-content">Reject</span>
</a>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
(click)="getTable(approveItem.tableId)"
> >
<clr-icon {{ pagination.firstItem + 1 }} -
shape="code" {{ pagination.lastItem + 1 }} of
size="28" {{ pagination.totalItems }} approvals
aria-hidden="true" </clr-dg-pagination>
></clr-icon> </clr-dg-footer>
<span class="tooltip-content">Go to staged data screen</span> </clr-datagrid>
</a> </div>
</div> }
</clr-dg-cell>
<clr-dg-cell class="p-0 d-flex justify-content-center">
<button
class="btn btn-success"
aria-label="Download audit file"
[id]="approveItem.tableId"
(click)="
download(approveItem.tableId); $event.stopPropagation()
"
>
<clr-icon shape="download"></clr-icon>
</button>
</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
>Items per page</clr-dg-page-size
>
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} approvals
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1,12 +1,13 @@
<div class="content-area"> <div class="content-area">
<div @if (noData) {
*ngIf="noData" <div
id="noDataContainer" id="noDataContainer"
class="card-block d-flex justify-content-center flex-column align-items-center" class="card-block d-flex justify-content-center flex-column align-items-center"
> >
<clr-icon shape="warning-standard" size="60" class="is-info"></clr-icon> <clr-icon shape="warning-standard" size="60" class="is-info"></clr-icon>
<h3 class="text-center color-gray">There is no history to show</h3> <h3 class="text-center color-gray">There is no history to show</h3>
</div> </div>
}
<clr-modal [(clrModalOpen)]="openModal" [clrModalSize]="'xl'"> <clr-modal [(clrModalOpen)]="openModal" [clrModalSize]="'xl'">
<h4 class="modal-title">Approval details</h4> <h4 class="modal-title">Approval details</h4>
<div class="modal-body"> <div class="modal-body">
@@ -18,31 +19,38 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let col of tableTitles; let ind = index"> @for (col of tableTitles; track col; let ind = $index) {
<td class="left">{{ col }}</td> <tr>
<td class="left"> <td class="left">{{ col }}</td>
<a <td class="left">
*ngIf="ind < 1" @if (ind < 1) {
(click)="getTable(approveData[col])" <a
class="cursor-pointer table-link" (click)="getTable(approveData[col])"
>{{ approveData[col] }}</a class="cursor-pointer table-link"
> >{{ approveData[col] }}</a
<div *ngIf="ind < 2 && ind >= 1"> >
<a }
(click)="getBaseTable(approveData[col])" @if (ind < 2 && ind >= 1) {
class="cursor-pointer table-link" <div>
>VIEW</a <a
> (click)="getBaseTable(approveData[col])"
<span> / </span> class="cursor-pointer table-link"
<a >VIEW</a
(click)="getEditTable(approveData[col])" >
class="cursor-pointer table-link" <span> / </span>
>EDIT</a <a
> (click)="getEditTable(approveData[col])"
</div> class="cursor-pointer table-link"
<span *ngIf="ind >= 2">{{ approveData[col] }}</span> >EDIT</a
</td> >
</tr> </div>
}
@if (ind >= 2) {
<span>{{ approveData[col] }}</span>
}
</td>
</tr>
}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -58,148 +66,159 @@
</div> </div>
</clr-modal> </clr-modal>
<div @if (!loaded) {
*ngIf="!loaded"
class="h-70vh d-flex justify-content-center flex-column align-items-center"
>
<span class="spinner" *ngIf="!loaded"> Loading... </span>
<div *ngIf="!loaded">
<h3>Loading history</h3>
</div>
</div>
<div *ngIf="!noData && loaded" class="card">
<div class="card-header">
<h3 class="center clr-col-md-12 text-center" *ngIf="loaded">HISTORY</h3>
<p
class="text-center font-weight-700 color-dark-gray"
*ngIf="licenceState.value.history_rows_allowed !== Infinity"
>
To unlock more than
{{ licenceState.value.history_rows_allowed }} records, contact
support&#64;datacontroller.io
</p>
</div>
<clr-datagrid
class="datagrid-history datagrid-custom-footer"
*ngIf="loaded"
>
<clr-dg-column [clrDgField]="'basetable'">
BASE_TABLE
<clr-dg-string-filter
[clrDgStringFilter]="baseTableFilter"
aria-label="Filter base table"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'status'">
STATUS
<clr-dg-string-filter
[clrDgStringFilter]="statusFilter"
aria-label="Filter status"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitter'">
SUBMITTER
<clr-dg-string-filter
[clrDgStringFilter]="submitterFilter"
aria-label="Filter submitter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submittedReason'">
SUBMIT REASON
<clr-dg-string-filter
[clrDgStringFilter]="submitReasonFilter"
aria-label="Filter submit reason"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">
SUBMITTED
<clr-dg-string-filter
[clrDgStringFilter]="submittedFilter"
aria-label="Filter submitted date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'reviewed'">
APPROVED / REJECTED
<clr-dg-string-filter
[clrDgStringFilter]="reviewedFilter"
aria-label="Filter reviewed date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>DOWNLOAD</clr-dg-column>
<clr-dg-row
*clrDgItems="let historyItem of history"
(click)="getApprIndex(historyItem)"
>
<clr-dg-cell class="verCenter">
<a
class="btn btn-sm btn-link m-0"
(click)="getBaseTable(historyItem.basetable)"
>{{ historyItem.basetable }}</a
>
</clr-dg-cell>
<clr-dg-cell
class="verCenter"
[ngClass]="{
rejected: historyItem.status === 'REJECTED',
accepted: historyItem.status === 'APPROVED'
}"
>{{ historyItem.status }}</clr-dg-cell
>
<clr-dg-cell class="verCenter">{{ historyItem.submitter }}</clr-dg-cell>
<clr-dg-cell class="verCenter">{{
historyItem.submittedReason
}}</clr-dg-cell>
<clr-dg-cell class="verCenter">{{ historyItem.submitted }}</clr-dg-cell>
<clr-dg-cell class="verCenter">{{ historyItem.reviewed }}</clr-dg-cell>
<clr-dg-cell class="verCenter p-0 d-flex justify-content-center">
<button
aria-label="Download audit file"
class="btn btn-success"
(click)="download(historyItem.tableId); $event.stopPropagation()"
>
<clr-icon shape="download"></clr-icon>
</button>
</clr-dg-cell>
</clr-dg-row>
<!-- Let's keep this part if in future we decide to do the paging instead of `load more` approach -->
<!-- <clr-dg-footer class="d-flex justify-content-start">
<span>items per page</span>
<select class="mx-5" [(ngModel)]="itemsNum">
<option [ngValue]="3">3</option>
<option [ngValue]="5">5</option>
<option [ngValue]="10">10</option>
<option [ngValue]="15">15</option>
</select>
<clr-dg-pagination
#pagination
[clrDgPageSize]="itemsNum"
class="center"
>
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} updates
</clr-dg-pagination>
</clr-dg-footer> -->
</clr-datagrid>
<div <div
class="load-more d-flex clr-justify-content-center clr-align-items-center" class="h-70vh d-flex justify-content-center flex-column align-items-center"
> >
<button @if (!loaded) {
*ngIf=" <span class="spinner"> Loading... </span>
}
@if (!loaded) {
<div>
<h3>Loading history</h3>
</div>
}
</div>
}
@if (!noData && loaded) {
<div class="card">
<div class="card-header">
@if (loaded) {
<h3 class="center clr-col-md-12 text-center">HISTORY</h3>
}
@if (licenceState.value.history_rows_allowed !== Infinity) {
<p class="text-center font-weight-700 color-dark-gray">
To unlock more than
{{ licenceState.value.history_rows_allowed }} records, contact
support&#64;datacontroller.io
</p>
}
</div>
@if (loaded) {
<clr-datagrid class="datagrid-history datagrid-custom-footer">
<clr-dg-column [clrDgField]="'basetable'">
BASE_TABLE
<clr-dg-string-filter
[clrDgStringFilter]="baseTableFilter"
aria-label="Filter base table"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'status'">
STATUS
<clr-dg-string-filter
[clrDgStringFilter]="statusFilter"
aria-label="Filter status"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitter'">
SUBMITTER
<clr-dg-string-filter
[clrDgStringFilter]="submitterFilter"
aria-label="Filter submitter"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submittedReason'">
SUBMIT REASON
<clr-dg-string-filter
[clrDgStringFilter]="submitReasonFilter"
aria-label="Filter submit reason"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">
SUBMITTED
<clr-dg-string-filter
[clrDgStringFilter]="submittedFilter"
aria-label="Filter submitted date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'reviewed'">
APPROVED / REJECTED
<clr-dg-string-filter
[clrDgStringFilter]="reviewedFilter"
aria-label="Filter reviewed date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column>DOWNLOAD</clr-dg-column>
<clr-dg-row
*clrDgItems="let historyItem of history"
(click)="getApprIndex(historyItem)"
>
<clr-dg-cell class="verCenter">
<a
class="btn btn-sm btn-link m-0"
(click)="getBaseTable(historyItem.basetable)"
>{{ historyItem.basetable }}</a
>
</clr-dg-cell>
<clr-dg-cell
class="verCenter"
[ngClass]="{
rejected: historyItem.status === 'REJECTED',
accepted: historyItem.status === 'APPROVED'
}"
>{{ historyItem.status }}</clr-dg-cell
>
<clr-dg-cell class="verCenter">{{
historyItem.submitter
}}</clr-dg-cell>
<clr-dg-cell class="verCenter">{{
historyItem.submittedReason
}}</clr-dg-cell>
<clr-dg-cell class="verCenter">{{
historyItem.submitted
}}</clr-dg-cell>
<clr-dg-cell class="verCenter">{{
historyItem.reviewed
}}</clr-dg-cell>
<clr-dg-cell class="verCenter p-0 d-flex justify-content-center">
<button
aria-label="Download audit file"
class="btn btn-success"
(click)="
download(historyItem.tableId); $event.stopPropagation()
"
>
<clr-icon shape="download"></clr-icon>
</button>
</clr-dg-cell>
</clr-dg-row>
<!-- Let's keep this part if in future we decide to do the paging instead of `load more` approach -->
<!-- <clr-dg-footer class="d-flex justify-content-start">
<span>items per page</span>
<select class="mx-5" [(ngModel)]="itemsNum">
<option [ngValue]="3">3</option>
<option [ngValue]="5">5</option>
<option [ngValue]="10">10</option>
<option [ngValue]="15">15</option>
</select>
<clr-dg-pagination
#pagination
[clrDgPageSize]="itemsNum"
class="center"
>
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} updates
</clr-dg-pagination>
</clr-dg-footer> -->
</clr-datagrid>
}
<div
class="load-more d-flex clr-justify-content-center clr-align-items-center"
>
@if (
this.licenceState.value.history_rows_allowed === Infinity && this.licenceState.value.history_rows_allowed === Infinity &&
rowsLeftToLoad > 0 rowsLeftToLoad > 0
" ) {
(click)="loadData()" <button
[clrLoading]="loadingMore" (click)="loadData()"
class="btn btn-success" [clrLoading]="loadingMore"
> class="btn btn-success"
Load {{ rowsLeftToLoad }} more >
</button> Load {{ rowsLeftToLoad }} more
</button>
}
</div>
</div> </div>
</div> }
</div> </div>
@@ -1,123 +1,136 @@
<div class="w-100"> <div class="w-100">
<div *ngIf="!subReady" class="content-area"> @if (!subReady) {
<div *ngIf="!subReady" class="card"> <div class="content-area">
<div @if (!subReady) {
*ngIf="remained === 0 && loaded" <div class="card">
class="d-flex justify-content-center" @if (remained === 0 && loaded) {
> <div class="d-flex justify-content-center">
<div <div
class="no-submitted-tables card-block d-flex justify-content-center flex-column align-items-center" class="no-submitted-tables card-block d-flex justify-content-center flex-column align-items-center"
> >
<clr-icon <clr-icon
shape="warning-standard" shape="warning-standard"
size="60" size="60"
class="is-info" class="is-info"
></clr-icon> ></clr-icon>
<h3 class="text-center color-gray"> <h3 class="text-center color-gray">
You have not submitted any tables You have not submitted any tables
</h3> </h3>
</div> </div>
</div> </div>
<div class="card-header" [ngClass]="{ noBorder: !loaded }"> }
<h3 class="center clr-col-md-12 text-center" *ngIf="remained !== 0"> <div class="card-header" [ngClass]="{ noBorder: !loaded }">
SUBMIT QUEUE @if (remained !== 0) {
</h3> <h3 class="center clr-col-md-12 text-center">SUBMIT QUEUE</h3>
<p }
class="text-center font-weight-700 color-dark-gray" @if (loaded && remained !== 0) {
*ngIf="loaded && remained !== 0" <p class="text-center font-weight-700 color-dark-gray">
> You have <span>{{ remained }} </span>submissions waiting to be
You have <span>{{ remained }} </span>submissions waiting to be approved
approved </p>
</p> }
</div> </div>
@if (!loaded) {
<div <div
*ngIf="!loaded" class="h-70vh d-flex justify-content-center flex-column align-items-center"
class="h-70vh d-flex justify-content-center flex-column align-items-center"
>
<span class="spinner" *ngIf="!loaded"> Loading... </span>
<div *ngIf="!loaded">
<h3>Loading submitted list</h3>
</div>
</div>
<div class="clr-col-md-12" *ngIf="loaded">
<div *ngIf="submitterList && remained !== 0">
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
<clr-dg-column>BASE TABLE</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">
SUBMITTED
<clr-dg-string-filter
[clrDgStringFilter]="submittedFilter"
aria-label="Filter submitted date"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column [clrDgField]="'submitReason'">
SUBMIT REASON
<clr-dg-string-filter
[clrDgStringFilter]="submitReasonFilter"
aria-label="Filter submit reason"
></clr-dg-string-filter>
</clr-dg-column>
<clr-dg-column class="d-flex justify-content-center"
>ACTION</clr-dg-column
> >
<clr-dg-column class="d-flex justify-content-center" @if (!loaded) {
>DOWNLOAD</clr-dg-column <span class="spinner"> Loading... </span>
> }
<clr-dg-row @if (!loaded) {
*clrDgItems="let sub of submitterList; let i = index" <div>
(click)="goToDetails(sub.tableId)" <h3>Loading submitted list</h3>
class="cursor-pointer"
>
<clr-dg-cell>{{ sub.base }}</clr-dg-cell>
<clr-dg-cell>{{ sub.submitted }}</clr-dg-cell>
<!-- <clr-dg-cell>{{sub.approver}}</clr-dg-cell> -->
<clr-dg-cell>{{ sub.submitReason }}</clr-dg-cell>
<clr-dg-cell>
<div
class="row justify-content-around"
role="tooltip"
aria-label="Go to staged data screen"
>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
(click)="goToStage(sub.tableId)"
>
<clr-icon shape="code" size="28"></clr-icon>
<span class="tooltip-content"
>Go to staged data screen</span
>
</a>
</div> </div>
</clr-dg-cell> }
<clr-dg-cell class="p-0 d-flex justify-content-center"> </div>
<button }
class="btn btn-success" @if (loaded) {
aria-label="Download audit file for table record" <div class="clr-col-md-12">
(click)="download(sub.tableId); $event.stopPropagation()" @if (submitterList && remained !== 0) {
> <div>
<clr-icon shape="download"></clr-icon> <clr-datagrid class="datagrid-compact datagrid-custom-footer">
</button> <clr-dg-column>BASE TABLE</clr-dg-column>
</clr-dg-cell> <clr-dg-column [clrDgField]="'submitted'">
</clr-dg-row> SUBMITTED
<clr-dg-string-filter
<clr-dg-footer> [clrDgStringFilter]="submittedFilter"
<clr-dg-pagination #pagination [clrDgPageSize]="10"> aria-label="Filter submitted date"
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]" ></clr-dg-string-filter>
>Items per page</clr-dg-page-size </clr-dg-column>
> <clr-dg-column [clrDgField]="'submitReason'">
{{ pagination.firstItem + 1 }} - SUBMIT REASON
{{ pagination.lastItem + 1 }} of <clr-dg-string-filter
{{ pagination.totalItems }} submissions [clrDgStringFilter]="submitReasonFilter"
</clr-dg-pagination> aria-label="Filter submit reason"
</clr-dg-footer> ></clr-dg-string-filter>
</clr-datagrid> </clr-dg-column>
<clr-dg-column class="d-flex justify-content-center"
>ACTION</clr-dg-column
>
<clr-dg-column class="d-flex justify-content-center"
>DOWNLOAD</clr-dg-column
>
<clr-dg-row
*clrDgItems="let sub of submitterList; let i = index"
(click)="goToDetails(sub.tableId)"
class="cursor-pointer"
>
<clr-dg-cell>{{ sub.base }}</clr-dg-cell>
<clr-dg-cell>{{ sub.submitted }}</clr-dg-cell>
<!-- <clr-dg-cell>{{sub.approver}}</clr-dg-cell> -->
<clr-dg-cell>{{ sub.submitReason }}</clr-dg-cell>
<clr-dg-cell>
<div
class="row justify-content-around"
role="tooltip"
aria-label="Go to staged data screen"
>
<a
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
(click)="goToStage(sub.tableId)"
>
<clr-icon shape="code" size="28"></clr-icon>
<span class="tooltip-content"
>Go to staged data screen</span
>
</a>
</div>
</clr-dg-cell>
<clr-dg-cell class="p-0 d-flex justify-content-center">
<button
class="btn btn-success"
aria-label="Download audit file for table record"
(click)="
download(sub.tableId); $event.stopPropagation()
"
>
<clr-icon shape="download"></clr-icon>
</button>
</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination #pagination [clrDgPageSize]="10">
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
>Items per page</clr-dg-page-size
>
{{ pagination.firstItem + 1 }} -
{{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} submissions
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
}
</div>
}
</div> </div>
</div> }
</div> </div>
</div> }
<div *ngIf="subReady"> @if (subReady) {
<app-approve-details></app-approve-details> <div>
</div> <app-approve-details></app-approve-details>
</div>
}
</div> </div>
+131 -115
View File
@@ -1,130 +1,146 @@
<app-sidebar class="sidebar-height"> <app-sidebar class="sidebar-height">
<clr-tree> <clr-tree>
<clr-tree-node *ngIf="roles" class="search-node"> @if (roles) {
<div class="tree-search-wrapper"> <clr-tree-node class="search-node">
<input <div class="tree-search-wrapper">
appStealFocus <input
clrInput appStealFocus
#searchLibTreeInput clrInput
placeholder="Filter by Roles" #searchLibTreeInput
name="input" placeholder="Filter by Roles"
[(ngModel)]="roleSearch" name="input"
(keyup)="roleListOnFilter()" [(ngModel)]="roleSearch"
autocomplete="off" (keyup)="roleListOnFilter()"
/> autocomplete="off"
<clr-icon />
*ngIf="searchLibTreeInput.value.length < 1" @if (searchLibTreeInput.value.length < 1) {
shape="search" <clr-icon shape="search"></clr-icon>
></clr-icon> }
<clr-icon @if (searchLibTreeInput.value.length > 0) {
*ngIf="searchLibTreeInput.value.length > 0" <clr-icon
(click)="roleSearch = ''; roleListOnFilter()" (click)="roleSearch = ''; roleListOnFilter()"
shape="times" shape="times"
></clr-icon> ></clr-icon>
</div> }
</clr-tree-node> </div>
<ng-container *ngFor="let role of roles">
<clr-tree-node
(click)="roleOnClick(role)"
*ngIf="!role['hidden']"
[class.active]="role.ROLEURI === roleUri"
>
<p class="m-0 cursor-pointer list-padding">
<clr-icon shape="blocks-group"></clr-icon>
{{ role.ROLENAME }}
</p>
</clr-tree-node> </clr-tree-node>
</ng-container> }
@for (role of roles; track role) {
@if (!role['hidden']) {
<clr-tree-node
(click)="roleOnClick(role)"
[class.active]="role.ROLEURI === roleUri"
>
<p class="m-0 cursor-pointer list-padding">
<clr-icon shape="blocks-group"></clr-icon>
{{ role.ROLENAME }}
</p>
</clr-tree-node>
}
}
</clr-tree> </clr-tree>
</app-sidebar> </app-sidebar>
<div class="content-area"> <div class="content-area">
<div *ngIf="loading" class="loadingSpinner"> @if (loading) {
<span class="spinner"> Loading... </span> <div class="loadingSpinner">
</div> <span class="spinner"> Loading... </span>
<div *ngIf="roleMembers && !loading">
<div class="clr-row">
<div class="clr-col-8">
<table class="table role-info">
<tbody>
<tr>
<td class="left">
<p class="role-info-text">
<b>{{ roleName }}</b>
</p>
</td>
</tr>
<tr>
<td class="left">
<i>{{ roleDesc }}</i>
</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
}
<div class="clr-row"> @if (roleMembers && !loading) {
<div class="clr-col-8"> <div>
<div class="card role-data"> <div class="clr-row">
<div> <div class="clr-col-8">
<h3>MEMBERS ({{ roleMembersCount }})</h3> <table class="table role-info">
<h5 *ngIf="roleMembersCount == 0">No Members Present</h5> <tbody>
<div class="table-container"> <tr>
<table *ngIf="roleMembersCount != 0" class="table member-table"> <td class="left">
<thead> <p class="role-info-text">
<tr> <b>{{ roleName }}</b>
<td class="width-25"><b>NAME</b></td> </p>
<td class="width-25"><b>EMAIL</b></td> </td>
<td class="width-25"><b>CREATED</b></td> </tr>
<td class=""><b>UPDATED</b></td> <tr>
</tr> <td class="left">
</thead> <i>{{ roleDesc }}</i>
<tbody> </td>
<tr </tr>
[routerLink]="'/view/usernav/users/' + member.URIMEM" </tbody>
*ngFor="let member of roleMembers" </table>
> </div>
<td class="">{{ member.MEMBERNAME }}</td> </div>
<td class="">{{ member.EMAIL }}</td> <div class="clr-row">
<td class="">{{ member.MEMBERCREATED }}</td> <div class="clr-col-8">
<td class="">{{ member.MEMBERUPDATED }}</td> <div class="card role-data">
</tr> <div>
</tbody> <h3>MEMBERS ({{ roleMembersCount }})</h3>
</table> @if (roleMembersCount == 0) {
<h5>No Members Present</h5>
}
<div class="table-container">
@if (roleMembersCount != 0) {
<table class="table member-table">
<thead>
<tr>
<td class="width-25"><b>NAME</b></td>
<td class="width-25"><b>EMAIL</b></td>
<td class="width-25"><b>CREATED</b></td>
<td class=""><b>UPDATED</b></td>
</tr>
</thead>
<tbody>
@for (member of roleMembers; track member) {
<tr
[routerLink]="'/view/usernav/users/' + member.URIMEM"
>
<td class="">{{ member.MEMBERNAME }}</td>
<td class="">{{ member.EMAIL }}</td>
<td class="">{{ member.MEMBERCREATED }}</td>
<td class="">{{ member.MEMBERUPDATED }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div> </div>
</div> <hr />
<hr /> <div>
<div> <h3>Groups ({{ roleGroupsCount }})</h3>
<h3>Groups ({{ roleGroupsCount }})</h3> @if (roleGroupsCount == 0) {
<h5 *ngIf="roleGroupsCount == 0">No Groups Present !</h5> <h5>No Groups Present !</h5>
<div class="table-container"> }
<table *ngIf="roleGroupsCount != 0" class="table member-table"> <div class="table-container">
<thead> @if (roleGroupsCount != 0) {
<tr> <table class="table member-table">
<td class="width-25"><b>NAME</b></td> <thead>
<td class="width-25"><b>EMAIL</b></td> <tr>
<td class="width-25"><b>CREATED</b></td> <td class="width-25"><b>NAME</b></td>
<td class=""><b>UPDATED</b></td> <td class="width-25"><b>EMAIL</b></td>
</tr> <td class="width-25"><b>CREATED</b></td>
</thead> <td class=""><b>UPDATED</b></td>
<tbody> </tr>
<tr </thead>
[routerLink]="'/view/usernav/groups/' + group.URIMEM" <tbody>
*ngFor="let group of roleGroups" @for (group of roleGroups; track group) {
> <tr
<td class="">{{ group.MEMBERNAME }}</td> [routerLink]="'/view/usernav/groups/' + group.URIMEM"
<td class="">{{ group.EMAIL }}</td> >
<td class="">{{ group.MEMBERCREATED }}</td> <td class="">{{ group.MEMBERNAME }}</td>
<td class="">{{ group.MEMBERUPDATED }}</td> <td class="">{{ group.EMAIL }}</td>
</tr> <td class="">{{ group.MEMBERCREATED }}</td>
</tbody> <td class="">{{ group.MEMBERUPDATED }}</td>
</table> </tr>
}
</tbody>
</table>
}
</div>
</div> </div>
<br />
</div> </div>
<br />
</div> </div>
</div> </div>
</div> </div>
</div> }
</div> </div>
+141
View File
@@ -0,0 +1,141 @@
import { EventEmitter } from '@angular/core'
import { BehaviorSubject, Subject } from 'rxjs'
import { AppService } from './app.service'
/**
* AppService's constructor subscribes to several emitters/subjects (see
* subscribe() and the persistSelectedTheme check) and startUpData() touches
* a handful of methods on each collaborator — these stubs cover exactly
* that surface, not the full real services (no TestBed/DI needed, same
* plain-instantiation-with-stubs precedent as va-filter.service.spec.ts).
*/
const buildDeps = () => {
const licenceService: any = {
isAppActivated: new BehaviorSubject<boolean | null>(null),
activation: jasmine.createSpy('activation').and.resolveTo(undefined)
}
const eventService: any = {
showInfoModal: jasmine.createSpy('showInfoModal'),
startupDataLoaded: jasmine.createSpy('startupDataLoaded'),
toggleDarkMode: jasmine.createSpy('toggleDarkMode')
}
const sasService: any = {
loadStartupServiceEmitter: new EventEmitter<any>(),
requestSiteIdEmitter: new EventEmitter<string>(),
request: jasmine.createSpy('request')
}
const loggerService: any = {
log: jasmine.createSpy('log')
}
const appSettingsService: any = {
settings: new BehaviorSubject({ persistSelectedTheme: false } as any)
}
const router: any = {
events: new Subject(),
url: '',
navigateByUrl: jasmine.createSpy('navigateByUrl')
}
const appStoreService: any = {
getDcAdapterSettings: () => undefined
}
return {
licenceService,
eventService,
sasService,
loggerService,
appSettingsService,
router,
appStoreService
}
}
const buildAppService = (deps: ReturnType<typeof buildDeps>) =>
new AppService(
deps.licenceService,
deps.eventService,
deps.sasService,
deps.loggerService,
deps.appSettingsService,
deps.router,
deps.appStoreService
)
// Minimal valid startupservice payload — just enough to pass startUpData()'s
// missing-props check (Globvars/Sasdatasets/Saslibs/XLMaps).
const validStartupResponse = () => ({
adapterResponse: {
SYSSITE: 'SITE1',
globvars: [{ ISADMIN: false, DC_ADMIN_GROUP: '', DCLIB: 'DCLIB' }],
sasdatasets: [],
saslibs: {},
xlmaps: []
}
})
describe('AppService - startup retry', () => {
it('retries once and succeeds without showing the error modal when only the first attempt rejects', async () => {
const deps = buildDeps()
deps.sasService.request.and.returnValues(
Promise.reject('timeout'),
Promise.resolve(validStartupResponse())
)
const appService = buildAppService(deps)
// Keep the retry pause instant in tests — see STARTUP_RETRY_PLAN.md.
;(appService as any).retryOptions = {
wait: () => Promise.resolve(),
random: () => 0
}
await appService.startUpData()
expect(deps.sasService.request).toHaveBeenCalledTimes(2)
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
expect(deps.eventService.startupDataLoaded).toHaveBeenCalled()
})
it('shows the error modal once (not twice) when both attempts reject', async () => {
const deps = buildDeps()
deps.sasService.request.and.returnValues(
Promise.reject('timeout-1'),
Promise.reject('timeout-2')
)
const appService = buildAppService(deps)
;(appService as any).retryOptions = {
wait: () => Promise.resolve(),
random: () => 0
}
await appService.startUpData()
expect(deps.sasService.request).toHaveBeenCalledTimes(2)
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
expect(deps.licenceService.isAppActivated.value).toBeFalse()
})
it('does not retry a response that arrives but is missing required properties', async () => {
const deps = buildDeps()
deps.sasService.request.and.resolveTo({
adapterResponse: {
SYSSITE: 'SITE1',
// globvars deliberately omitted
sasdatasets: [],
saslibs: {},
xlmaps: []
}
})
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)
})
})
+11 -2
View File
@@ -11,11 +11,15 @@ import { AppSettingsService } from './app-settings.service'
import { AppThemes } from '../models/AppSettings' 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'
@Injectable() @Injectable()
export class AppService { export class AppService {
public syssite = new BehaviorSubject<string[] | null>(null) public syssite = new BehaviorSubject<string[] | null>(null)
private environmentInfo: EnvironmentInfo = {} private environmentInfo: EnvironmentInfo = {}
// Overridable in tests to keep the retry pause instant — see
// retry-once.spec.ts and app.service.spec.ts.
private retryOptions: RetryOnceOptions = {}
constructor( constructor(
private licenceService: LicenceService, private licenceService: LicenceService,
@@ -82,8 +86,13 @@ export class AppService {
public async startUpData() { public async startUpData() {
let startupServiceError = false let startupServiceError = false
await this.sasService // Retry once on failure — multiple concurrent DC instances (e.g. VA
.request('public/startupservice', null) // multi-page reports) can cause this request to time out even though a
// second attempt succeeds. See STARTUP_RETRY_PLAN.md.
await retryOnce(
() => this.sasService.request('public/startupservice', null),
this.retryOptions
)
.then(async (res: RequestWrapperResponse) => { .then(async (res: RequestWrapperResponse) => {
this.syssite.next([res.adapterResponse.SYSSITE]) this.syssite.next([res.adapterResponse.SYSSITE])
@@ -55,11 +55,11 @@ describe('VaFilterService', () => {
'SOME_DATETIME' 'SOME_DATETIME'
] ]
const cols = [ const cols = [
{ NAME: 'SOME_CHAR', TYPE: 'char', DDTYPE: 'CHAR' }, { NAME: 'SOME_CHAR', DDTYPE: 'C' },
{ NAME: 'SOME_NUM', TYPE: 'num', DDTYPE: 'NUMERIC' }, { NAME: 'SOME_NUM', DDTYPE: 'N' },
{ NAME: 'SOME_TIME', TYPE: 'num', DDTYPE: 'TIME' }, { NAME: 'SOME_TIME', DDTYPE: 'TIME' },
{ NAME: 'SOME_DATE', TYPE: 'num', DDTYPE: 'DATE' }, { NAME: 'SOME_DATE', DDTYPE: 'DATE' },
{ NAME: 'SOME_DATETIME', TYPE: 'num', DDTYPE: 'DATETIME' } { NAME: 'SOME_DATETIME', DDTYPE: 'DATETIME' }
] ]
beforeEach(() => { beforeEach(() => {
@@ -122,6 +122,90 @@ describe('VaFilterService', () => {
).toBe('365') ).toBe('365')
}) })
it('passes an already-internal (bare number) temporal value through raw', () => {
// VA sends the raw SAS internal value when the data item has no format.
expect(build([{ label: 'SOME_DATE', value: '182' }])[0].RAW_VALUE).toBe(
'182'
)
expect(
build([{ label: 'SOME_DATETIME', value: '365' }])[0].RAW_VALUE
).toBe('365')
expect(build([{ label: 'SOME_TIME', value: '2' }])[0].RAW_VALUE).toBe('2')
})
it('parses ISO date shapes identically to DATE9 (VA sends no format)', () => {
const date9 = build([{ label: 'SOME_DATE', value: '01AUG1962' }])[0]
.RAW_VALUE
// The production symptom: YYMMDDS "2025/03/31" style must parse, not drop.
expect(
build([{ label: 'SOME_DATE', value: '1962-08-01' }])[0].RAW_VALUE
).toBe(date9)
expect(
build([{ label: 'SOME_DATE', value: '1962/08/01' }])[0].RAW_VALUE
).toBe(date9)
// A real production-format value must yield a clause (not be dropped).
expect(build([{ label: 'SOME_DATE', value: '2025/03/31' }])).toHaveSize(1)
})
it('resolves ambiguous slash dates day-first (never US), disambiguating by value', () => {
const iso = build([{ label: 'SOME_DATE', value: '2025-03-31' }])[0]
.RAW_VALUE
// 31 can only be a day -> both orders resolve to 2025-03-31.
expect(
build([{ label: 'SOME_DATE', value: '31/03/2025' }])[0].RAW_VALUE
).toBe(iso) // day-first
expect(
build([{ label: 'SOME_DATE', value: '03/31/2025' }])[0].RAW_VALUE
).toBe(iso) // value forces month-first
// Genuinely ambiguous (both <= 12): default DAY-first => 4th March, NOT 3rd April.
expect(
build([{ label: 'SOME_DATE', value: '04/03/2025' }])[0].RAW_VALUE
).toBe(build([{ label: 'SOME_DATE', value: '2025-03-04' }])[0].RAW_VALUE)
// Impossible date is dropped, not rolled over.
expect(build([{ label: 'SOME_DATE', value: '31/02/2025' }])).toEqual([])
})
it('uses the column FMTNAME to resolve ambiguous dates (MMDDYY reads US month-first)', () => {
const iso = (v: string) =>
service.buildClauses(
msg([{ label: 'D', value: v }]),
new Map([['d', 'D']]),
[{ NAME: 'D', DDTYPE: 'DATE' }]
)[0]?.RAW_VALUE
const withFmt = (fmt: string, v: string) =>
service.buildClauses(
msg([{ label: 'D', value: v }]),
new Map([['d', 'D']]),
[{ NAME: 'D', DDTYPE: 'DATE', FMTNAME: fmt }]
)[0]?.RAW_VALUE
// 04/03/2025, both <= 12 (ambiguous): FMTNAME decides.
expect(withFmt('MMDDYY', '04/03/2025')).toBe(iso('2025-04-03')) // 3 April
expect(withFmt('DDMMYY', '04/03/2025')).toBe(iso('2025-03-04')) // 4 March
// Value still overrides a wrong FMTNAME: 31 can only be the day.
expect(withFmt('MMDDYY', '31/03/2025')).toBe(iso('2025-03-31'))
// Falls back to the full FORMAT (e.g. MMDDYY10.) when FMTNAME is blank.
const withFormat = service.buildClauses(
msg([{ label: 'D', value: '04/03/2025' }]),
new Map([['d', 'D']]),
[{ NAME: 'D', DDTYPE: 'DATE', FMTNAME: '', FORMAT: 'MMDDYY10.' }]
)[0]?.RAW_VALUE
expect(withFormat).toBe(iso('2025-04-03')) // 3 April, from FORMAT
})
it('parses ISO datetime shapes identically to SAS DATETIME', () => {
const sas = build([
{ label: 'SOME_DATETIME', value: '01JAN1960:00:06:05' }
])[0].RAW_VALUE
expect(
build([{ label: 'SOME_DATETIME', value: '1960-01-01T00:06:05' }])[0]
.RAW_VALUE
).toBe(sas)
expect(
build([{ label: 'SOME_DATETIME', value: '1960-01-01 00:06:05' }])[0]
.RAW_VALUE
).toBe(sas)
})
it('matches the column by label case-insensitively', () => { it('matches the column by label case-insensitively', () => {
expect(build([{ label: 'some_char', value: 'x' }])[0].VARIABLE_NM).toBe( expect(build([{ label: 'some_char', value: 'x' }])[0].VARIABLE_NM).toBe(
'SOME_CHAR' 'SOME_CHAR'
@@ -153,6 +237,21 @@ describe('VaFilterService', () => {
['SOME_DATETIME', '365'] ['SOME_DATETIME', '365']
]) ])
}) })
it('infers ISO date/datetime shapes by value shape', () => {
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'SOME_DATE', value: '1960-07-01' },
{ name: 'pr2', label: 'SOME_DATE2', value: '1960/07/01' },
{ name: 'pr3', label: 'SOME_DTTM', value: '1960-01-01T00:06:05' }
])
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_DATE', '182'],
['SOME_DATE2', '182'],
['SOME_DTTM', '365']
])
})
}) })
describe('signature', () => { describe('signature', () => {
+197 -36
View File
@@ -72,7 +72,9 @@ export class VaFilterService {
const varName = labelToVar.get(key) const varName = labelToVar.get(key)
if (!varName) continue if (!varName) continue
const kind = this.columnKind(varName, cols) const col = this.findCol(varName, cols)
const kind = this.columnKind(col)
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
const rawValues = Array.isArray(param.value) ? param.value : [param.value] const rawValues = Array.isArray(param.value) ? param.value : [param.value]
// Format/validate every value for the column's SAS type. Numeric columns // Format/validate every value for the column's SAS type. Numeric columns
// must receive a finite number (untrusted postMessage input is // must receive a finite number (untrusted postMessage input is
@@ -81,7 +83,7 @@ export class VaFilterService {
// escaped. Invalid values are dropped. // escaped. Invalid values are dropped.
const formatted = rawValues const formatted = rawValues
.filter((v) => v !== undefined && v !== null && v !== '') .filter((v) => v !== undefined && v !== null && v !== '')
.map((v) => this.formatValueByKind(v, kind)) .map((v) => this.formatValueByKind(v, kind, order))
.filter((v): v is string => v !== null) .filter((v): v is string => v !== null)
if (formatted.length === 0) continue if (formatted.length === 0) continue
clauses.push(this.clause(varName, formatted)) clauses.push(this.clause(varName, formatted))
@@ -151,34 +153,69 @@ export class VaFilterService {
} }
} }
/** /** The loaded column spec whose NAME matches varName (case-insensitive). */
* SAS data-type kind of a column, from the loaded column specs. DDTYPE carries private findCol(varName: string, cols: any[]): any {
* TIME/DATE/DATETIME; checked DATETIME-before-DATE (substring). return (cols || []).find(
*/
private columnKind(varName: string, cols: any[]): VaColumnKind {
const col = (cols || []).find(
(x: any) => (x: any) =>
(x?.NAME ?? '').toString().toUpperCase() === varName.toUpperCase() (x?.NAME ?? '').toString().toUpperCase() === varName.toUpperCase()
) )
}
/**
* SAS data-type kind of a column spec. DDTYPE carries
* TIME/DATE/DATETIME in full, with C/N for CHARACTER/NUMERIC (legacy
* responses may still carry the full strings); checked DATETIME-before-DATE
* (substring).
*/
private columnKind(col: any): VaColumnKind {
const ddtype = (col?.DDTYPE ?? '').toString().toUpperCase() const ddtype = (col?.DDTYPE ?? '').toString().toUpperCase()
if (ddtype.includes('DATETIME')) return 'datetime' if (ddtype.includes('DATETIME')) return 'datetime'
if (ddtype.includes('DATE')) return 'date' if (ddtype.includes('DATE')) return 'date'
if (ddtype.includes('TIME')) return 'time' if (ddtype.includes('TIME')) return 'time'
if ((col?.TYPE ?? '') === 'num') return 'numeric' if (
ddtype === 'N' ||
ddtype.includes('NUMERIC') ||
(col?.TYPE ?? '') === 'num'
) {
return 'numeric'
}
return 'char' return 'char'
} }
/**
* Day/month/year order declared by a column's SAS format, used to resolve
* ambiguous numeric dates. Accepts either the base FMTNAME (e.g. `DDMMYY`) or
* the full FORMAT (e.g. `DDMMYY10.`) — the leading name is the same and is all
* that matters for order (width is irrelevant). This is the SOURCE column's
* format; the VA report can in principle display a different one, so
* value-based disambiguation (a group > 12 is the day) still takes precedence —
* this only decides the genuinely-ambiguous case. `undefined` when the format
* is absent, month-name (DATE*), or locale-dependent (NLDATE).
*/
private dateOrderFromFmt(fmtname: any): 'YMD' | 'DMY' | 'MDY' | undefined {
const f = (fmtname ?? '').toString().toUpperCase()
if (/^MMDDYY/.test(f)) return 'MDY'
if (/^(DDMMYY|EURDF)/.test(f)) return 'DMY'
if (/^(YYMMDD|E8601DA|B8601DA)/.test(f)) return 'YMD'
return undefined
}
/** /**
* Infers a column's SAS kind from a VA value string (deferred load, no * Infers a column's SAS kind from a VA value string (deferred load, no
* metadata). VA's temporal strings are distinctive — `ddMMMyyyy[:HH:MM:SS]` * metadata). VA's temporal strings are distinctive — DATE9 `ddMMMyyyy` and ISO
* and `H:MM:SS` — so they're detected by shape; otherwise fall back to the VA * `yyyy-MM-dd`/`yyyy/MM/dd`, optionally with a `[ T:]HH:MM[:SS]` time part, and
* `H:MM:SS` — so they're detected by shape; otherwise fall back to the VA
* `dataType`. * `dataType`.
*/ */
private inferKindFromValue(value: string, dataType?: string): VaColumnKind { private inferKindFromValue(value: string, dataType?: string): VaColumnKind {
if (/^\d{1,2}[A-Za-z]{3}\d{4}:\d{2}:\d{2}(:\d{2})?$/.test(value)) { const monName = String.raw`\d{1,2}[A-Za-z]{3}\d{2,4}`
const ymd = String.raw`\d{4}[-/.]\d{1,2}[-/.]\d{1,2}` // 4-digit year first
const dmy = String.raw`\d{1,2}[-/.]\d{1,2}[-/.]\d{4}` // 4-digit year last
const date = `${monName}|${ymd}|${dmy}`
if (new RegExp(`^(?:${date})[ T:]\\d{1,2}:\\d{2}(:\\d{2})?$`).test(value)) {
return 'datetime' return 'datetime'
} }
if (/^\d{1,2}[A-Za-z]{3}\d{4}$/.test(value)) return 'date' if (new RegExp(`^(?:${date})$`).test(value)) return 'date'
if (/^\d{1,2}:\d{2}(:\d{2})?(\.\d+)?$/.test(value)) return 'time' if (/^\d{1,2}:\d{2}(:\d{2})?(\.\d+)?$/.test(value)) return 'time'
if (dataType === 'number') return 'numeric' if (dataType === 'number') return 'numeric'
return 'char' return 'char'
@@ -197,8 +234,18 @@ export class VaFilterService {
* either yields a finite number or is dropped — also the injection guard. * either yields a finite number or is dropped — also the injection guard.
* Date/datetime reuse HelperService.convertJsDateToSasDate so the value is * Date/datetime reuse HelperService.convertJsDateToSasDate so the value is
* byte-identical to what DC's own pickers produce. * byte-identical to what DC's own pickers produce.
*
* A temporal value that is already a bare number is the SAS internal value
* itself (VA sends it unformatted when the data item carries no format / is
* used as a measure), so it is passed through raw. This is unambiguous because
* the only date formats in use (DATE9, ISO) always contain a letter or a
* separator — a plain integer is never a formatted date.
*/ */
private formatValueByKind(value: any, kind: VaColumnKind): string | null { private formatValueByKind(
value: any,
kind: VaColumnKind,
order?: 'YMD' | 'DMY' | 'MDY'
): string | null {
const s = String(value).trim() const s = String(value).trim()
if (s === '') return null if (s === '') return null
switch (kind) { switch (kind) {
@@ -207,17 +254,20 @@ export class VaFilterService {
return Number.isFinite(num) ? `${num}` : null return Number.isFinite(num) ? `${num}` : null
} }
case 'time': { case 'time': {
if (this.isBareNumber(s)) return `${Number(s)}`
const secs = this.timeToSeconds(s) const secs = this.timeToSeconds(s)
return secs === null ? null : `${secs}` return secs === null ? null : `${secs}`
} }
case 'date': { case 'date': {
const d = this.sasDateToJsDate(s) if (this.isBareNumber(s)) return `${Number(s)}`
const d = this.parseDate(s, order)
return d return d
? `${this.helperService.convertJsDateToSasDate(d, 'days')}` ? `${this.helperService.convertJsDateToSasDate(d, 'days')}`
: null : null
} }
case 'datetime': { case 'datetime': {
const d = this.sasDatetimeToJsDate(s) if (this.isBareNumber(s)) return `${Number(s)}`
const d = this.parseDatetime(s, order)
return d return d
? `${this.helperService.convertJsDateToSasDate(d, 'seconds')}` ? `${this.helperService.convertJsDateToSasDate(d, 'seconds')}`
: null : null
@@ -238,29 +288,140 @@ export class VaFilterService {
return h * 3600 + m * 60 + sec return h * 3600 + m * 60 + sec
} }
/** SAS "ddMMMyyyy" (e.g. 01JUL1960) -> local JS Date, or null. */ /**
private sasDateToJsDate(value: string): Date | null { * VA date string -> local JS Date, or null. VA sends no format metadata, so the
const m = /^(\d{1,2})([A-Za-z]{3})(\d{4})$/.exec(value) * shape is sniffed. Accepted forms (separator may be `-`, `/`, or `.`):
if (!m) return null * - DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962)
const monIdx = VaFilterService.SAS_MONTHS.indexOf(m[2].toUpperCase()) * - ISO/YMD "yyyy-MM-dd" (4-digit year first, e.g. 2025-03-31)
if (monIdx < 0) return null * - year-last "dd/mm/yyyy" or "mm/dd/yyyy" (4-digit year last)
return new Date(Number(m[3]), monIdx, Number(m[1])) *
* Day/month order for the year-last form is resolved in priority order:
* 1. by value — a group > 12 must be the day (ground truth);
* 2. by `order` — the column's declared FMTNAME order (DMY/MDY/YMD);
* 3. default DAY-FIRST — never US month-first — matching SAS's DDMMYY
* default and non-US convention.
* `new Date(y, m-1, d)` is local, matching HelperService.convertJsDateToSasDate
* so the value is byte-identical to DC's own pickers. The constructed date is
* round-trip validated to reject impossible values (e.g. 31/02) rather than
* silently roll over.
*/
private parseDate(value: string, order?: 'YMD' | 'DMY' | 'MDY'): Date | null {
return this.parseMonthNameDate(value) ?? this.parseNumericDate(value, order)
} }
/** SAS "ddMMMyyyy:HH:MM[:SS]" (e.g. 01JAN1960:00:06:05) -> local JS Date, or null. */ /** DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962, 01AUG62) -> local Date, or null. */
private sasDatetimeToJsDate(value: string): Date | null { private parseMonthNameDate(value: string): Date | null {
const m = const m = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
/^(\d{1,2})([A-Za-z]{3})(\d{4}):(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value)
if (!m) return null if (!m) return null
const monIdx = VaFilterService.SAS_MONTHS.indexOf(m[2].toUpperCase()) const [, day, monthAbbr, year] = m
if (monIdx < 0) return null const monthIndex = VaFilterService.SAS_MONTHS.indexOf(
return new Date( monthAbbr.toUpperCase()
Number(m[3]),
monIdx,
Number(m[1]),
Number(m[4]),
Number(m[5]),
Number(m[6] || 0)
) )
if (monthIndex < 0) return null
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
}
/**
* Numeric date with `-`, `/`, or `.` separators -> local Date, or null:
* - year-first "yyyy-MM-dd" -> YMD directly;
* - year-last "dd/mm/yyyy" or "mm/dd/yyyy" -> day/month resolved by
* resolveDayMonth (value, then column order, then day-first default).
* A 4-digit year (leading or trailing) is required to anchor the parse;
* an all-2-digit value (e.g. "03/04/05") is too ambiguous and is dropped.
*/
private parseNumericDate(
value: string,
order?: 'YMD' | 'DMY' | 'MDY'
): Date | null {
const m = /^(\d{1,4})[-/.](\d{1,2})[-/.](\d{1,4})$/.exec(value)
if (!m) return null
const [, first, middle, last] = m
const yearIsFirst = first.length === 4
const yearIsLast = last.length === 4
if (yearIsFirst) {
return this.buildDate(Number(first), Number(middle), Number(last))
}
if (!yearIsLast) return null
const { day, month } = this.resolveDayMonth(
Number(first),
Number(middle),
order
)
return this.buildDate(Number(last), month, day)
}
/**
* Resolves the day/month of a year-last date whose two leading groups (`a`,
* `b`) are in unknown order, in priority:
* 1. by value — a group > 12 must be the day (ground truth);
* 2. by `order` — only MDY flips to month-first; DMY/YMD keep day-first;
* 3. default — DAY-first (`a` is the day), never US month-first.
*/
private resolveDayMonth(
a: number,
b: number,
order?: 'YMD' | 'DMY' | 'MDY'
): { day: number; month: number } {
if (a > 12 && b <= 12) return { day: a, month: b }
if (b > 12 && a <= 12) return { day: b, month: a }
if (order === 'MDY') return { day: b, month: a }
return { day: a, month: b }
}
/** Local Date from 1-based month, round-trip validated (null if impossible). */
private buildDate(
year: number,
month: number,
day: number,
hours = 0,
minutes = 0,
seconds = 0
): Date | null {
if (month < 1 || month > 12 || day < 1 || day > 31) return null
const d = new Date(year, month - 1, day, hours, minutes, seconds, 0)
// Reject rollovers (e.g. 31 Feb -> 3 Mar) so a bad value is dropped, not
// silently converted to the wrong SAS date.
if (
d.getFullYear() !== year ||
d.getMonth() !== month - 1 ||
d.getDate() !== day
) {
return null
}
return d
}
/**
* VA datetime string -> local JS Date, or null. The date part is any shape
* accepted by parseDate; the time part is "HH:MM[:SS]". They are joined by the
* SAS ":" separator (e.g. 01JAN1960:05:13:03), an ISO "T", or a space
* (e.g. 2025-03-31T05:13:03, 2025-03-31 05:13:03).
*/
private parseDatetime(
value: string,
order?: 'YMD' | 'DMY' | 'MDY'
): Date | null {
const m = /^(.+?)[ T:](\d{1,2}:\d{2}(?::\d{2})?)$/.exec(value)
if (!m) return null
const date = this.parseDate(m[1], order)
if (!date) return null
const t = m[2].split(':')
date.setHours(Number(t[0]), Number(t[1]), Number(t[2] || 0), 0)
return date
}
/** True when the value is a bare (already-internal) SAS number: optional sign,
* digits, optional fraction. No letters/separators — so never a formatted date. */
private isBareNumber(value: string): boolean {
return /^-?\d+(\.\d+)?$/.test(value) && Number.isFinite(Number(value))
}
/** 2-digit SAS year -> 4-digit (SAS default cutoff: 00-19 -> 20xx, else 19xx). */
private fullYear(year: string): number {
if (year.length === 4) return Number(year)
const y = Number(year)
return y <= 19 ? 2000 + y : 1900 + y
} }
} }
@@ -7,66 +7,62 @@
> >
<h3 class="modal-title"> <h3 class="modal-title">
{{ data.modalTitle }} {{ data.modalTitle }}
<p @if (data.sasService && data.sasService.length > 0) {
*ngIf="data.sasService && data.sasService.length > 0" <p class="sasService mt-0">
class="sasService mt-0" SAS Service: <strong>{{ data.sasService }}</strong>
> </p>
SAS Service: <strong>{{ data.sasService }}</strong> }
</p>
</h3> </h3>
<div class="modal-body"> <div class="modal-body">
<div [innerHTML]="data.message" class="abortMsg"></div> <div [innerHTML]="data.message" class="abortMsg"></div>
<div *ngIf="data.details !== null" class="systext"> @if (data.details !== null) {
<p><strong>SYSWARNINGTEXT:</strong> {{ data.details.SYSWARNINGTEXT }}</p> <div class="systext">
<p><strong>SYSERRORTEXT:</strong> {{ data.details.SYSERRORTEXT }}</p> <p>
<p><strong>MAC:</strong> {{ data.details.MAC }}</p> <strong>SYSWARNINGTEXT:</strong> {{ data.details.SYSWARNINGTEXT }}
</div> </p>
<p><strong>SYSERRORTEXT:</strong> {{ data.details.SYSERRORTEXT }}</p>
<p><strong>MAC:</strong> {{ data.details.MAC }}</p>
</div>
}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button @if (showConfiguratorButton(data.sasService)) {
*ngIf="showConfiguratorButton(data.sasService)" <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
(click)="openConfigurator()" (click)="openConfigurator()"
> >
Open configurator Open configurator
</button> </button>
}
<button @if (data.details !== null) {
*ngIf="data.details !== null" <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
(click)="openRequestsModal()" (click)="openRequestsModal()"
> >
Open requests modal Open requests modal
</button> </button>
}
<button @if (data.details?.LOG && (data.details?.LOG?.trim())!.length > 0) {
*ngIf="data.details?.LOG && (data.details?.LOG?.trim())!.length > 0" <button type="button" class="btn btn-primary" (click)="downloadLog()">
type="button" Download log
class="btn btn-primary" </button>
(click)="downloadLog()" }
>
Download log
</button>
<button @if (!forceReload) {
*ngIf="!forceReload" <button type="button" class="btn btn-primary" (click)="closeAbortModal()">
type="button" Close
class="btn btn-primary" </button>
(click)="closeAbortModal()" }
>
Close
</button>
<button @if (forceReload) {
*ngIf="forceReload" <button type="button" class="btn btn-primary" (click)="reload()">
type="button" Reload
class="btn btn-primary" </button>
(click)="reload()" }
>
Reload
</button>
</div> </div>
</clr-modal> </clr-modal>
@@ -1,15 +1,18 @@
<clr-alerts *ngIf="hasOpenAlert"> @if (hasOpenAlert) {
<clr-alert <clr-alerts>
*ngFor="let alert of alerts" @for (alert of alerts; track alert) {
[clrAlertType]="alert.type" <clr-alert
[clrAlertAppLevel]="true" [clrAlertType]="alert.type"
[(clrAlertClosed)]="alert.closed" [clrAlertAppLevel]="true"
(clrAlertClosedChange)="onAlertClose()" [(clrAlertClosed)]="alert.closed"
> (clrAlertClosedChange)="onAlertClose()"
<div class="alert-item"> >
<span class="alert-text"> <div class="alert-item">
{{ alert.message }} <span class="alert-text">
</span> {{ alert.message }}
</div> </span>
</clr-alert> </div>
</clr-alerts> </clr-alert>
}
</clr-alerts>
}
@@ -41,12 +41,10 @@
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>
<option @if (options.children.length > 0 && enableLoadMore) {
*ngIf="options.children.length > 0 && enableLoadMore" <option data-type="load-more" class="load-more">
data-type="load-more" {{ loadingMore ? 'Loading...' : 'LOAD MORE' }}
class="load-more" </option>
> }
{{ loadingMore ? 'Loading...' : 'LOAD MORE' }}
</option>
</div> </div>
</div> </div>
@@ -6,35 +6,37 @@
> >
<h3 class="modal-title center text-center color-darker-gray">Dataset Meta</h3> <h3 class="modal-title center text-center color-darker-gray">Dataset Meta</h3>
<div class="modal-body"> <div class="modal-body">
<p *ngIf="dsmetaTabs.length < 1" class="text-center"> @if (dsmetaTabs.length < 1) {
No dataset meta to show. <p class="text-center">No dataset meta to show.</p>
</p> }
<clr-tabs clrLayout="vertical"> <clr-tabs clrLayout="vertical">
<clr-tab *ngFor="let tab of tabs; let index = index"> @for (tab of tabs; track tab; let index = $index) {
<button clrTabLink id="link1">{{ tab.name }}</button> <clr-tab>
<clr-tab-content <button clrTabLink id="link1">{{ tab.name }}</button>
id="content1" <clr-tab-content
*clrIfActive="index === 0" id="content1"
class="d-flex clr-justify-content-center w-100" *clrIfActive="index === 0"
> class="d-flex clr-justify-content-center w-100"
<clr-datagrid> >
<ng-container *ngFor="let col of tab.colsToDisplay"> <clr-datagrid>
<clr-dg-column>{{ col.colName || col.colKey }}</clr-dg-column> @for (col of tab.colsToDisplay; track col) {
</ng-container> <clr-dg-column>{{ col.colName || col.colKey }}</clr-dg-column>
}
<clr-dg-row @for (info of tab.meta; track info) {
(click)="tab.onRowClick ? tab.onRowClick(info) : ''" <clr-dg-row
class="clickable-row" (click)="tab.onRowClick ? tab.onRowClick(info) : ''"
*ngFor="let info of tab.meta" class="clickable-row"
> >
<ng-container *ngFor="let col of tab.colsToDisplay"> @for (col of tab.colsToDisplay; track col) {
<clr-dg-cell>{{ info[col.colKey] }}</clr-dg-cell> <clr-dg-cell>{{ info[col.colKey] }}</clr-dg-cell>
</ng-container> }
</clr-dg-row> </clr-dg-row>
</clr-datagrid> }
</clr-tab-content> </clr-datagrid>
</clr-tab> </clr-tab-content>
</clr-tab>
}
</clr-tabs> </clr-tabs>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -1,164 +1,180 @@
<clr-tree> <clr-tree>
<clr-tree-node *ngIf="libraryList" class="search-node"> @if (libraryList) {
<div class="tree-search-wrapper"> <clr-tree-node class="search-node">
<input <div class="tree-search-wrapper">
appStealFocus <input
clrInput appStealFocus
#searchLibTreeInput clrInput
placeholder="Libraries" #searchLibTreeInput
name="input" placeholder="Libraries"
[(ngModel)]="librariesSearch" name="input"
(keyup)="libraryOnFilter()" [(ngModel)]="librariesSearch"
autocomplete="off" (keyup)="libraryOnFilter()"
/> autocomplete="off"
<clr-icon />
*ngIf="searchLibTreeInput.value.length < 1" @if (searchLibTreeInput.value.length < 1) {
shape="search" <clr-icon shape="search"></clr-icon>
></clr-icon> }
<clr-icon @if (searchLibTreeInput.value.length > 0) {
*ngIf="searchLibTreeInput.value.length > 0"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
></clr-icon>
</div>
</clr-tree-node>
<ng-container *ngFor="let library of libraryList">
<clr-tree-node
#libTreeNode
(click)="treeNodeClicked($event, library, libraryList)"
*ngIf="!library['hidden'] && library['inForeground']"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
>
<p
(click)="
lib = library.LIBRARYID;
libraryOnClick(lib || '', library, libTreeNode)
"
class="m-0 cursor-pointer"
>
<clr-icon shape="rack-server"></clr-icon>
{{ library.LIBRARYNAME }}
</p>
<clr-tree-node *ngIf="library['tables']" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Tables"
name="input"
[(ngModel)]="library['searchString']"
(keyup)="treeOnFilter(library, 'tables')"
autocomplete="off"
/>
<clr-icon <clr-icon
*ngIf="searchTreeInput.value.length < 1" (click)="librariesSearch = ''; libraryOnFilter()"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
treeOnFilter(library, 'tables.TABLENAME')
"
shape="times" shape="times"
></clr-icon> ></clr-icon>
</div> }
</clr-tree-node> </div>
</clr-tree-node>
}
@for (library of libraryList; track library) {
@if (!library['hidden'] && library['inForeground']) {
<clr-tree-node <clr-tree-node
(click)="treeNodeClicked($event, libTable, library['tables'])" #libTreeNode
*ngFor="let libTable of library['tables']; let index = index" (click)="treeNodeClicked($event, library, libraryList)"
[(clrExpanded)]="libTable['expanded']" [(clrExpanded)]="library['expanded']"
[clrLoading]="libTable['loadingColumns'] && !libTable.columns" [clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="libTable['expanded']" [class.clr-expanded]="library['expanded']"
> >
<clr-tooltip <p
*ngVar=" (click)="
index + 1 > lib = library.LIBRARYID;
licenceState.value.tables_in_library_limit as tableLocked libraryOnClick(lib || '', library, libTreeNode)
" "
class="m-0 cursor-pointer"
> >
<button <clr-icon shape="rack-server"></clr-icon>
clrTooltipTrigger {{ library.LIBRARYNAME }}
(click)=" </p>
!tableLocked @if (library['tables']) {
? tableOnClick(libTable.TABLEURI, libTable, library) <clr-tree-node class="search-node">
: '' <div class="tree-search-wrapper">
" <input
class="clr-treenode-link" appStealFocus
[class.dc-locked-control]="tableLocked" clrInput
[class.active]="libTabActive(library.LIBRARYREF, libTable)" #searchTreeInput
> placeholder="Tables"
<ng-container [ngSwitch]="libTable.includes('-FC')"> name="input"
<clr-icon *ngSwitchCase="true" shape="bolt"></clr-icon> [(ngModel)]="library['searchString']"
<clr-icon *ngSwitchCase="false" shape="table"></clr-icon> (keyup)="treeOnFilter(library, 'tables')"
</ng-container> autocomplete="off"
{{ libTable.replace('-FC', '') }} />
</button> @if (searchTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
<clr-tooltip-content }
clrPosition="bottom-right" @if (searchTreeInput.value.length > 0) {
clrSize="lg"
*clrIfOpen
>
<span *ngIf="tableLocked">
To unlock all tables, contact support&#64;datacontroller.io
</span>
</clr-tooltip-content>
<ng-container *ngIf="hasColumns">
<clr-tree-node *ngIf="libTable['columns']" class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Columns"
name="input"
[(ngModel)]="libTable['searchString']"
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
autocomplete="off"
/>
<clr-icon <clr-icon
*ngIf="searchTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)=" (click)="
searchTreeInput.value = ''; searchTreeInput.value = '';
libTable['searchString'] = ''; library['searchString'] = '';
treeOnFilter(libTable, 'columns.COLNAME') treeOnFilter(library, 'tables.TABLENAME')
" "
shape="times" shape="times"
></clr-icon> ></clr-icon>
</div> }
</clr-tree-node> </div>
</clr-tree-node>
<clr-tree-node *ngFor="let libColumn of libTable['columns']"> }
@for (
libTable of library['tables'];
track libTable;
let index = $index
) {
<clr-tree-node
(click)="treeNodeClicked($event, libTable, library['tables'])"
[(clrExpanded)]="libTable['expanded']"
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
[class.clr-expanded]="libTable['expanded']"
>
<clr-tooltip
*ngVar="
index + 1 >
licenceState.value.tables_in_library_limit as tableLocked
"
>
<button <button
(click)="columnOnClick(libColumn, library, libTable)" clrTooltipTrigger
(click)="
!tableLocked
? tableOnClick(libTable.TABLEURI, libTable, library)
: ''
"
class="clr-treenode-link" class="clr-treenode-link"
[class.column-active]="libColumnActive(libColumn.COLURI)" [class.dc-locked-control]="tableLocked"
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
> >
<clr-icon shape="objects"></clr-icon> @switch (libTable.includes('-FC')) {
@case (true) {
{{ libColumn.COLNAME }} <clr-icon shape="bolt"></clr-icon>
}
@case (false) {
<clr-icon shape="table"></clr-icon>
}
}
{{ libTable.replace('-FC', '') }}
</button> </button>
</clr-tree-node> <clr-tooltip-content
</ng-container> clrPosition="bottom-right"
</clr-tooltip> clrSize="lg"
*clrIfOpen
>
@if (tableLocked) {
<span>
To unlock all tables, contact support&#64;datacontroller.io
</span>
}
</clr-tooltip-content>
@if (hasColumns) {
@if (libTable['columns']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
clrInput
#searchTreeInput
placeholder="Columns"
name="input"
[(ngModel)]="libTable['searchString']"
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
(click)="
searchTreeInput.value = '';
libTable['searchString'] = '';
treeOnFilter(libTable, 'columns.COLNAME')
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (libColumn of libTable['columns']; track libColumn) {
<clr-tree-node>
<button
(click)="columnOnClick(libColumn, library, libTable)"
class="clr-treenode-link"
[class.column-active]="libColumnActive(libColumn.COLURI)"
>
<clr-icon shape="objects"></clr-icon>
{{ libColumn.COLNAME }}
</button>
</clr-tree-node>
}
}
</clr-tooltip>
</clr-tree-node>
}
</clr-tree-node> </clr-tree-node>
</clr-tree-node> }
</ng-container> }
</clr-tree> </clr-tree>
<div *ngIf="librariesPaging" class="w-100 text-center"> @if (librariesPaging) {
<span class="spinner spinner-sm"> Loading... </span> <div class="w-100 text-center">
</div> <span class="spinner spinner-sm"> Loading... </span>
</div>
}
@@ -17,7 +17,9 @@ import { DQRule, DQRuleTypes } from './models/dq-rules.model'
import { getDqDataCols } from './utils/getDqDataCols' import { getDqDataCols } from './utils/getDqDataCols'
import { getNotNullDefault } from './utils/getNotNullDefault' import { getNotNullDefault } from './utils/getNotNullDefault'
import { mergeColsRules } from './utils/mergeColsRules' import { mergeColsRules } from './utils/mergeColsRules'
import { parseColType } from './utils/parseColType' import { parseColTypeRow } from './utils/parseColTypeRow'
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,
@@ -28,6 +30,9 @@ import { mapIntlCellTypes } from './utils/mapIntlCellTypes'
import { CustomAutocompleteEditor } from './editors/numericAutocomplete' import { CustomAutocompleteEditor } from './editors/numericAutocomplete'
import { registerIntlCellTypes } from './cellTypes/intlCellTypes' import { registerIntlCellTypes } from './cellTypes/intlCellTypes'
import { makeNumberFormatRenderer } from '../../editor/utils/renderers.utils' import { makeNumberFormatRenderer } from '../../editor/utils/renderers.utils'
import { makeRegexWarningRenderer } from '../../editor/utils/regex-warning-renderer'
import { isRegexRuleExempt } from './utils/isRegexRuleExempt'
import { parseRegexRule } from './utils/parseRegexRule'
export class DcValidator { export class DcValidator {
private rules: DcValidation[] = [] private rules: DcValidation[] = []
@@ -50,10 +55,43 @@ export class DcValidator {
this.sasparams = sasparams this.sasparams = sasparams
this.hotInstance = hotInstance this.hotInstance = hotInstance
this.rules = parseColType(sasparams.COLTYPE) // Each cols[i].COLTYPE is a single JSON-object string (one per column, as
// of the vertical-array getdata.sas fix — issue #253), not the old
// comma-joined sasparams.COLTYPE list. DELETE_RECORD_COLUMN_RULE is
// injected separately — see its own doc comment for why.
//
// COLHEADERS is the authoritative grid order and the only safe key to
// build against: it is the variable list of the *output* dataset, where
// getdata.sas hoists the delete column and the PK to the front ("order
// delete var and pk fields at start of table"), whereas cols[].VARNUM
// describes the *source* table and cols[] itself can arrive sorted by
// NAME. Those orders diverge whenever the PK isn't already the source's
// first column, so rules are keyed by name and emitted in COLHEADERS
// order — never paired positionally against cols[] or VARNUM.
//
// A column with no parseable COLTYPE falls back to a plain text rule
// rather than being dropped: getRules() is consumed positionally against
// COLHEADERS, so a missing entry would shift every later column.
const ruleByName = new Map<string, DcValidation>([
[DELETE_RECORD_COLUMN_RULE.data, { ...DELETE_RECORD_COLUMN_RULE }]
])
for (const col of cols) {
const rule = parseColTypeRow(col.COLTYPE)
if (rule) ruleByName.set(col.NAME, rule)
}
this.rules = sasparams.COLHEADERS.split(',').map(
(name) => ruleByName.get(name) ?? { data: name }
)
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(' ')
@@ -174,6 +212,50 @@ export class DcValidator {
return isNaN(digits) ? undefined : digits return isNaN(digits) ? undefined : digits
} }
/**
* Returns the RULE_VALUEs of a HARDREGEX/SOFTREGEX rule on the given
* column, for display in the column-header info dropdown. Both are
* returned raw - the caller (buildColInfoHtml) decides which one is
* actually applied (HARDREGEX wins when both exist).
*
* @param col column name
*/
getRegexRuleValues(col: string): {
hardRegexValue: string | undefined
softRegexValue: string | undefined
} {
const hardRegexRule = this.dqrules.find(
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'HARDREGEX'
)
const softRegexRule = this.dqrules.find(
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'SOFTREGEX'
)
return {
hardRegexValue: hardRegexRule?.RULE_VALUE,
softRegexValue: softRegexRule?.RULE_VALUE
}
}
/**
* 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
@@ -212,6 +294,41 @@ export class DcValidator {
return details return details
} }
/**
* Whether a value fails a SOFTREGEX rule on the given column, if one
* exists. SOFTREGEX never goes through dqValidate/the cell validator (see
* setupValidations — it's a display-only grid renderer instead), so the
* edit-record modal, which has no grid renderer to hook into, uses this
* directly to show the same warning outside the grid.
*
* A column can carry both HARDREGEX and SOFTREGEX at once, but only one
* regex ever runs per column: if a HARDREGEX rule exists, SOFTREGEX is
* ignored entirely - the cell is already governed by the blocking rule,
* so a yellow warning on top would be redundant, even for values that
* pass the hard rule. Same precedence as makeRegexWarningRenderer.
*/
failsSoftRegex(col: string, value: any): boolean {
const isNumeric =
this.rules.find((rule) => rule.data === col)?.type === 'numeric'
if (isRegexRuleExempt(value, isNumeric)) return false
const hardRegexRule = this.dqrules.find(
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'HARDREGEX'
)
if (hardRegexRule) return false
const softRegexRule = this.dqrules.find(
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'SOFTREGEX'
)
if (!softRegexRule) return false
try {
return !parseRegexRule(softRegexRule.RULE_VALUE).test(value.toString())
} catch (e) {
return false
}
}
/** /**
* SOFTSELECT is not defined in DQ RULES * SOFTSELECT is not defined in DQ RULES
* This function fetches it's values and pushes in the DQ RULES array with SOFTSELECT type * This function fetches it's values and pushes in the DQ RULES array with SOFTSELECT type
@@ -349,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)
@@ -368,6 +493,24 @@ export class DcValidator {
// editor/validator stay intact via `type`. // editor/validator stay intact via `type`.
this.rules[i].numericFormat = undefined this.rules[i].numericFormat = undefined
} }
// HARDREGEX/SOFTREGEX: submission-blocking for HARDREGEX still goes
// through the normal validator/dqValidate path (see dq-validation.ts)
// and is unaffected by this renderer. This only wires the display
// layer - a 'REGEX: <pattern>' title, plus a yellow dc-warning-cell
// when only SOFTREGEX fails (never for HARDREGEX, which relies on
// HOT's own red htInvalid instead). Last-wins against NUMBER_FORMAT
// if a column somehow carried both (not expected in practice — one
// formats numbers, the other pattern-matches text).
if (this.hasDqRules(ruleColName, ['HARDREGEX', 'SOFTREGEX'])) {
const { hardRegexValue, softRegexValue } =
this.getRegexRuleValues(ruleColName)
this.rules[i].renderer = makeRegexWarningRenderer(
softRegexValue,
hardRegexValue,
this.rules[i].type === 'numeric'
)
}
} }
const self = this const self = this
@@ -389,6 +532,27 @@ export class DcValidator {
const colType = self.rules[i].type || '' const colType = self.rules[i].type || ''
let handsontableValid = null let handsontableValid = null
// A row marked for delete is exempt from cell-level validation — its
// values are about to be removed. Two deliberate exceptions:
// - PK columns still validate: the key identifies WHICH record to
// delete, and blank/invalid PKs are also skipped by the separate
// duplicate-key check (validatePrimaryKeys), so this is the only
// net that would otherwise catch them.
// - Uniqueness checks are unaffected by construction: they run in a
// separate pass (editor.component.ts validatePrimaryKeys) that
// never goes through this validator.
const markedForDelete =
this.instance?.getDataAtRowProp?.(
this.row,
'_____DELETE__THIS__RECORD_____'
) === 'Yes'
if (markedForDelete && !self.primaryKeys.includes(col || '')) {
callback(true)
return
}
// We call handsontable predefined validators, if it returns false, we validate as FALSE // We call handsontable predefined validators, if it returns false, we validate as FALSE
// If return is true, we validate with our custom DQ validations // If return is true, we validate with our custom DQ validations
@@ -433,7 +597,11 @@ export class DcValidator {
} }
if (self.isDqCol(col || '')) { if (self.isDqCol(col || '')) {
const dqValid = dqValidate(self.getDqDetails(col || ''), value) const dqValid = dqValidate(
self.getDqDetails(col || ''),
value,
colType === 'numeric'
)
if (!dqValid) { if (!dqValid) {
console.warn(`DQ Validation - invalid (Value: ${value})`) console.warn(`DQ Validation - invalid (Value: ${value})`)
@@ -540,8 +708,11 @@ export class DcValidator {
if (rule.clsRule === 'EDIT') { if (rule.clsRule === 'EDIT') {
rule.readOnly = false rule.readOnly = false
// Guard the -1: splice(-1, 1) drops the LAST hidden column rather than
// no-op'ing, so an EDIT column that was never hidden would un-hide an
// unrelated one (e.g. a column hidden by a HIDDEN DQ rule).
const hiddenIndex = this.hiddenColumns.indexOf(index) const hiddenIndex = this.hiddenColumns.indexOf(index)
this.hiddenColumns.splice(hiddenIndex, 1) if (hiddenIndex !== -1) this.hiddenColumns.splice(hiddenIndex, 1)
} }
} }
} }
@@ -1,10 +1,9 @@
import Handsontable from 'handsontable' import Handsontable from 'handsontable'
import Core from 'handsontable/core'
export class CustomAutocompleteEditor export class CustomAutocompleteEditor
extends Handsontable.editors.AutocompleteEditor extends Handsontable.editors.AutocompleteEditor
{ {
constructor(instance: Core) { constructor(instance: Handsontable.Core) {
super(instance) super(instance)
} }
@@ -13,8 +12,9 @@ export class CustomAutocompleteEditor
} }
// Listbox open // Listbox open
open(event?: Event | undefined): void { // HOT 18's AutocompleteEditor.open() takes no arguments (was `event?: Event`).
super.open(event) open(): void {
super.open()
if (this.isCellNumeric()) { if (this.isCellNumeric()) {
this.htContainer.classList.add('numericListbox') this.htContainer.classList.add('numericListbox')
@@ -1,6 +1,8 @@
export interface Col { export interface Col {
NAME: string NAME: string
VARNUM: number // No longer sent by getdata.sas (dropped to trim the COLS payload) —
// column order in the array is authoritative. Optional for legacy responses.
VARNUM?: number
LABEL: string LABEL: string
FMTNAME: string FMTNAME: string
DDTYPE: string DDTYPE: string
@@ -9,4 +11,8 @@ export interface Col {
MEMLABEL: string MEMLABEL: string
DESC: string DESC: string
LONGDESC: string LONGDESC: string
// Handsontable column-formatting spec (JSON-object string), one per column
// — see parseColTypeRow.ts. Optional: getdata.sas LEFT JOINs this in, so
// an unmatched row can leave it unset.
COLTYPE?: string
} }
@@ -18,3 +18,7 @@ export type DQRuleTypes =
| 'HIDDEN' | 'HIDDEN'
| 'ROUND' | 'ROUND'
| 'NUMBER_FORMAT' | 'NUMBER_FORMAT'
| 'HARDREGEX'
| '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', () => {
@@ -23,11 +24,11 @@ describe('DC Validator', () => {
// Check if COLS merged with FORMATS // Check if COLS merged with FORMATS
expect(cols[0].TYPE).toEqual('char') expect(cols[0].TYPE).toEqual('char')
// Get all // Get all — one rule per cols[] entry, plus the injected
// DELETE_RECORD_COLUMN_RULE and EDIT_STATUS_COLUMN_RULE (neither is ever
// present in cols[], see their own doc comments)
const validationRules = dcValidator.getRules() const validationRules = dcValidator.getRules()
expect(validationRules).toHaveSize( expect(validationRules).toHaveSize(example_cols.length + 2)
JSON.parse(`[${example_COLTYPE}]`).length
)
// Get col with notnull validation // Get col with notnull validation
const someNumRule = dcValidator.getRule('SOME_NUM') const someNumRule = dcValidator.getRule('SOME_NUM')
@@ -66,6 +67,26 @@ describe('DC Validator', () => {
const someDropdownHardRule = dcValidator.getRule('SOME_DROPDOWN_HARD') const someDropdownHardRule = dcValidator.getRule('SOME_DROPDOWN_HARD')
// Check strict - it is hardselect so strict should be true // Check strict - it is hardselect so strict should be true
expect(someDropdownHardRule?.strict).toBeTrue() expect(someDropdownHardRule?.strict).toBeTrue()
// _____DELETE__THIS__RECORD_____ never has a cols[] entry (%mp_getcols
// doesn't know about it — see DELETE_RECORD_COLUMN_RULE), but its rule
// must still exist with its fixed dropdown shape, independent of cols
const deleteRecordRule = dcValidator.getRule(
'_____DELETE__THIS__RECORD_____'
)
expect(deleteRecordRule?.type).toEqual('dropdown')
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', () => {
@@ -278,8 +299,659 @@ describe('DC Validator', () => {
expect(dcValidator.getRoundDigits('SOME_SHORTNUM')).toEqual(2) expect(dcValidator.getRoundDigits('SOME_SHORTNUM')).toEqual(2)
expect(dcValidator.getRoundDigits('SOME_NUM')).toBeUndefined() expect(dcValidator.getRoundDigits('SOME_NUM')).toBeUndefined()
}) })
it('5 | orders rules by COLHEADERS when cols[] arrives in a different order', () => {
// %mp_getcols/cols1 can serialize cols[] alphabetically by NAME rather
// than in COLHEADERS order (confirmed against a real getdata.sas
// response). getRules() is consumed positionally against headerColumns,
// so rule order must follow COLHEADERS regardless of cols[]'s own order.
const alphabeticalCols = [...example_cols].sort((a, b) =>
a.NAME.localeCompare(b.NAME)
)
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
alphabeticalCols,
example_dqRules,
example_dqData
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
...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", () => {
// Regression test for the real-world misalignment: getdata.sas hoists the
// delete column and PK to the front of the *output* dataset ("order
// delete var and pk fields at start of table"), so COLHEADERS order and
// cols[].VARNUM order (which describes the *source* table) diverge
// whenever the PK isn't already the source's first column. Worst case (as
// seen in the wild): the PK is the source's LAST column, so pairing rules
// to headers by VARNUM shifts every single column by one.
const cols: Col[] = [
// deliberately alphabetical, as the backend serializes it
makeCol('PRIMARY_KEY_FIELD', 6), // the PK — last in the source table
makeCol('SOME_CHAR', 1),
makeCol('SOME_DATE', 2),
makeCol('SOME_DROPDOWN', 3),
makeCol('SOME_NUM', 4),
makeCol('SOME_TIME', 5)
]
const sasparams: SASParam = {
...example_sasparams,
// PK hoisted to the front by getdata.sas, ahead of VARNUM 1..5
COLHEADERS:
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,' +
'SOME_DATE,SOME_DROPDOWN,SOME_NUM,SOME_TIME',
PK: 'PRIMARY_KEY_FIELD'
} as SASParam
const dcValidator: DcValidator = new DcValidator(
sasparams,
{ vars: {} } as $DataFormats,
cols,
[],
[]
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
...sasparams.COLHEADERS.split(','),
EDIT_STATUS_COLUMN_NAME
])
})
it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => {
// A dropped rule would shorten getRules() and shift every later column
// against headerColumns. getdata.sas LEFT JOINs COLTYPE in, so an
// unmatched row can legitimately leave it unset.
const cols: Col[] = [
makeCol('PRIMARY_KEY_FIELD', 1),
{ ...makeCol('SOME_CHAR', 2), COLTYPE: undefined },
{ ...makeCol('SOME_NUM', 3), COLTYPE: 'not valid json' }
]
const sasparams: SASParam = {
...example_sasparams,
COLHEADERS:
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_NUM',
PK: 'PRIMARY_KEY_FIELD'
} as SASParam
const dcValidator: DcValidator = new DcValidator(
sasparams,
{ vars: {} } as $DataFormats,
cols,
[],
[]
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
...sasparams.COLHEADERS.split(','),
EDIT_STATUS_COLUMN_NAME
])
})
it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => {
// hiddenColumns.indexOf() returns -1 for a column that isn't hidden, and
// splice(-1, 1) drops the LAST element instead of no-op'ing — so an EDIT
// column used to silently un-hide a column hidden by a HIDDEN DQ rule.
//
// The EDIT column must not be the PK: setColumnLevelSecurity forces PK
// columns to clsRule 'READ' before the EDIT branch is reached, which would
// skip the splice entirely and leave this test passing against the bug.
const cols: Col[] = [
{ ...makeCol('PK_COL', 1), CLS_RULE: 'READ' },
{ ...makeCol('SECRET_COL', 2), CLS_RULE: 'READ' },
{ ...makeCol('OPEN_COL', 3), CLS_RULE: 'EDIT' }
]
const sasparams: SASParam = {
...example_sasparams,
CLS_FLAG: 1,
COLHEADERS: '_____DELETE__THIS__RECORD_____,PK_COL,SECRET_COL,OPEN_COL',
PK: 'PK_COL'
} as SASParam
const dqRules: DQRule[] = [
{ BASE_COL: 'SECRET_COL', RULE_TYPE: 'HIDDEN', RULE_VALUE: '', X: 0 }
]
const dcValidator: DcValidator = new DcValidator(
sasparams,
{ vars: {} } as $DataFormats,
cols,
dqRules,
[]
)
// SECRET_COL is rules[2]; CLS_FLAG also hides the delete column (index 0).
// OPEN_COL (rules[3]) is EDIT and was never hidden, so the splice guard is
// what keeps SECRET_COL hidden here.
expect(dcValidator.getHiddenColumns()).toContain(2)
})
describe('9 | ignores cell validation on rows marked for delete', () => {
// An invalid cell shouldn't block submission if its row
// is about to be deleted anyway. The PK is exempt from the exemption — it
// identifies WHICH record to delete, and a blank/invalid PK on a delete
// row would also slip past the separate uniqueness check (which skips
// blank PKs).
//
// The validator is a plain `function` invoked with cellProperties as
// `this` (row + instance), so a minimal fake stands in for a real HOT
// instance here — only `instance.getDataAtRowProp` is read by the guard.
const fakeCellProps = (markedForDelete: boolean): any => ({
row: 0,
editor: undefined,
instance: {
getDataAtRowProp: (_row: number, prop: string) =>
prop === '_____DELETE__THIS__RECORD_____'
? markedForDelete
? 'Yes'
: 'No'
: undefined
}
})
const buildValidator = () =>
new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
example_dqRules,
example_dqData
)
it('exempts an invalid non-PK cell on a delete-marked row', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
fakeCellProps(true),
'not a number',
(valid: boolean) => {
expect(valid).toBeTrue()
}
)
})
it('exempts a NOTNULL-empty non-PK cell on a delete-marked row', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
fakeCellProps(true),
null,
(valid: boolean) => {
expect(valid).toBeTrue()
}
)
})
it('still rejects an invalid non-PK cell when the row is not marked for delete', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
fakeCellProps(false),
'not a number',
(valid: boolean) => {
expect(valid).toBeFalse()
}
)
})
it('still accepts a valid non-PK cell when the row is not marked for delete', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
fakeCellProps(false),
2,
(valid: boolean) => {
expect(valid).toBeTrue()
}
)
})
it('still rejects an invalid PK cell even when the row is marked for delete', () => {
const dcValidator = buildValidator()
const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')
pkRule!.validator!.call(
fakeCellProps(true),
'not a number',
(valid: boolean) => {
expect(valid).toBeFalse()
}
)
})
it('treats the edit-record modal path (dummy HOT instance, no data) as not marked for delete', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
// executeHotValidator builds cellProperties against a real but
// data-less Handsontable instance — getDataAtRowProp() there returns
// undefined, never 'Yes', so the exemption never applies in the modal.
dcValidator.executeHotValidator(someNumRule!, null, (valid: boolean) => {
expect(valid).toBeFalse()
})
})
})
it('10 | blocks submission of a value that fails a HARDREGEX rule', () => {
// SOME_CHAR already has its own CASE=UPCASE rule in example_dqRules —
// 'AB1' passes that (it equals its own uppercase form) but fails a
// letters-only HARDREGEX pattern, isolating HARDREGEX's own effect
// rather than piggybacking on CASE rejecting the value too.
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
[
...example_dqRules,
{
BASE_COL: 'SOME_CHAR',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
],
example_dqData
)
const someCharRule = dcValidator.getRule('SOME_CHAR')
dcValidator.executeHotValidator(someCharRule!, 'ABC', (valid: boolean) => {
expect(valid).toBeTrue()
})
dcValidator.executeHotValidator(someCharRule!, 'AB1', (valid: boolean) => {
expect(valid).toBeFalse()
})
})
it('11 | wires a function renderer for a HARDREGEX-only rule too (for the REGEX: tooltip)', () => {
// HARDREGEX blocking itself is covered by test 10 above - this isolates
// the newer addition: even with no SOFTREGEX at all, a renderer must
// still be wired so a failing cell gets a 'REGEX: <pattern>' title on
// top of HOT's own red htInvalid, not just silence.
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
[
...example_dqRules,
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
],
example_dqData
)
const someCharAnyRule = dcValidator.getRule('SOME_CHAR_ANY')
expect(typeof someCharAnyRule?.renderer).toEqual('function')
})
it('12 | wires a function renderer for a SOFTREGEX rule, without blocking submission', () => {
// SOME_CHAR_ANY carries no other DQ rules in the shared fixture, so this
// isolates SOFTREGEX's own wiring. The renderer's own pass/fail/delete-
// suppression behaviour is covered by regex-warning-renderer.spec.ts —
// this only proves setupValidations() assigns it and that, unlike
// HARDREGEX, a non-matching value still submits.
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
[
...example_dqRules,
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
],
example_dqData
)
const someCharAnyRule = dcValidator.getRule('SOME_CHAR_ANY')
expect(typeof someCharAnyRule?.renderer).toEqual('function')
dcValidator.executeHotValidator(
someCharAnyRule!,
'not uppercase',
(valid: boolean) => {
expect(valid).toBeTrue()
}
)
})
it('13 | wires a renderer for a dual-rule column, and HARDREGEX still blocks submission', () => {
// Both rules on the same column: submission blocking is still governed
// entirely by HARDREGEX/dqValidate (unchanged). A renderer is wired so
// the cell gets a 'REGEX: <pattern>' title - its own hard-vs-soft
// precedence and coloring are covered by regex-warning-renderer.spec.ts.
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
[
...example_dqRules,
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
},
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
],
example_dqData
)
const rule = dcValidator.getRule('SOME_CHAR_ANY')
dcValidator.executeHotValidator(
rule!,
'not uppercase',
(valid: boolean) => {
expect(valid).toBeFalse()
}
)
expect(typeof rule?.renderer).toEqual('function')
})
describe('14 | failsSoftRegex (edit-record modal support for SOFTREGEX)', () => {
// SOFTREGEX never goes through dqValidate (see the wiring in
// setupValidations), so the edit-record modal — which has no grid
// renderer to hook into — calls this directly to show the same warning.
const buildValidator = (dqRules: DQRule[]) =>
new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
dqRules,
example_dqData
)
it('is true for a value that fails the pattern', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'lowercase')
).toBeTrue()
})
it('is false for a value that matches the pattern', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'UPPERCASE')
).toBeFalse()
})
it('handles a PRX-delimited pattern with a case-insensitive flag, as authored for prxparse', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '/\\b(the|data)\\b/i',
X: 0
}
])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'this is dummy data')
).toBeFalse()
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'THE WIND WAS BLOWING')
).toBeFalse()
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'nothing relevant here')
).toBeTrue()
})
it('is false for a column with no SOFTREGEX rule', () => {
const dcValidator = buildValidator([])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'anything')
).toBeFalse()
})
it('is false for blank values; special-missing-looking values are real text on a character column', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
])
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '')).toBeFalse()
// ".a" is real text here and fails ^[A-Z]+$
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '.a')).toBeTrue()
})
it('exempts the plain missing (".") but not special missings on a numeric column', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_NUM',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[0-9]+$',
X: 0
}
])
expect(dcValidator.failsSoftRegex('SOME_NUM', '.')).toBeFalse()
// Special missings are deliberately-set values and face the pattern.
expect(dcValidator.failsSoftRegex('SOME_NUM', '.a')).toBeTrue()
})
it('is false when a value fails both HARDREGEX and SOFTREGEX (precedence — no yellow on a red cell)', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]{3}$',
X: 0
},
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^.{5,10}$',
X: 0
}
])
// 'ab' fails both HARDREGEX (not 3 uppercase letters) and SOFTREGEX
// (too short) - HARDREGEX wins, so this must stay false rather than
// report a SOFTREGEX warning on a value that's already blocked.
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'ab')).toBeFalse()
})
it('is false for every value when the column has HARDREGEX - SOFTREGEX never runs, even if HARDREGEX passes', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z0-9]+$',
X: 0
},
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^.{5,10}$',
X: 0
}
])
// 'AB' passes HARDREGEX (uppercase/digits) but fails SOFTREGEX (too
// short) - only one regex runs per column, so the soft rule is
// ignored entirely.
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'AB')).toBeFalse()
})
})
describe('15 | getRegexRuleValues (column-header info display)', () => {
const buildValidator = (dqRules: DQRule[]) =>
new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
dqRules,
example_dqData
)
it('returns only hardRegexValue for a column with just HARDREGEX', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
])
expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
hardRegexValue: '^[A-Z]+$',
softRegexValue: undefined
})
})
it('returns only softRegexValue for a column with just SOFTREGEX', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '/\\b(the|data)\\b/i',
X: 0
}
])
expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
hardRegexValue: undefined,
softRegexValue: '/\\b(the|data)\\b/i'
})
})
it('returns both values for a column with both HARDREGEX and SOFTREGEX', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^HARD$',
X: 0
},
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^SOFT$',
X: 0
}
])
expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
hardRegexValue: '^HARD$',
softRegexValue: '^SOFT$'
})
})
it('returns both undefined for a column with no regex rule', () => {
const dcValidator = buildValidator([])
expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
hardRegexValue: undefined,
softRegexValue: undefined
})
})
})
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. */
const makeCol = (name: string, varnum: number): Col =>
({
NAME: name,
VARNUM: varnum,
LABEL: name,
FMTNAME: '',
DDTYPE: 'C',
TYPE: '',
CLS_RULE: 'READ',
MEMLABEL: '',
DESC: '',
LONGDESC: '',
COLTYPE: `{"data":"${name}"}`
}) as Col
const example_dqData = [ const example_dqData = [
{ {
BASE_COL: 'SOME_DROPDOWN', BASE_COL: 'SOME_DROPDOWN',
@@ -383,10 +1055,17 @@ const example_dqRules: any = [
} }
] ]
// One row per column, each carrying its own COLTYPE JSON-object string — the
// vertical-array shape from issue #253's getdata.sas fix. Order: SOME_DROPDOWN
// then SOME_NUM first (the `cols[0].TYPE` assertion above is positional),
// remaining columns in the same order the old concatenated
// sasparams.COLTYPE used to list them. _____DELETE__THIS__RECORD_____ is
// deliberately absent — DcValidator injects its rule directly (see
// DELETE_RECORD_COLUMN_RULE), since %mp_getcols never has a row for it.
const example_cols = [ const example_cols = [
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
DDTYPE: 'CHARACTER', DDTYPE: 'C',
DESC: 'dropdown_desc', DESC: 'dropdown_desc',
TYPE: '', TYPE: '',
FMTNAME: '', FMTNAME: '',
@@ -394,11 +1073,12 @@ const example_cols = [
LONGDESC: '', LONGDESC: '',
MEMLABEL: '', MEMLABEL: '',
NAME: 'SOME_DROPDOWN', NAME: 'SOME_DROPDOWN',
VARNUM: 3 VARNUM: 3,
COLTYPE: '{"data":"SOME_DROPDOWN"}'
}, },
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
DDTYPE: 'NUMERIC', DDTYPE: 'N',
DESC: '', DESC: '',
TYPE: '', TYPE: '',
FMTNAME: '', FMTNAME: '',
@@ -406,77 +1086,157 @@ const example_cols = [
LONGDESC: '', LONGDESC: '',
MEMLABEL: '', MEMLABEL: '',
NAME: 'SOME_NUM', NAME: 'SOME_NUM',
VARNUM: 4 VARNUM: 4,
COLTYPE: '{"data":"SOME_NUM","type":"numeric","format":"0"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'N',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'PRIMARY_KEY_FIELD',
LONGDESC: '',
MEMLABEL: '',
NAME: 'PRIMARY_KEY_FIELD',
VARNUM: 1,
COLTYPE: '{"data":"PRIMARY_KEY_FIELD","type":"numeric","format":"0"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'C',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_CHAR',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_CHAR',
VARNUM: 2,
COLTYPE: '{"data":"SOME_CHAR"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'C',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_CHAR_LOW',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_CHAR_LOW',
VARNUM: 5,
COLTYPE: '{"data":"SOME_CHAR_LOW"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'C',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_CHAR_ANY',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_CHAR_ANY',
VARNUM: 6,
COLTYPE: '{"data":"SOME_CHAR_ANY"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'C',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_DROPDOWN_HARD',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DROPDOWN_HARD',
VARNUM: 7,
COLTYPE: '{"data":"SOME_DROPDOWN_HARD"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'DATE',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_DATE',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DATE',
VARNUM: 8,
COLTYPE: '{"data":"SOME_DATE","type":"date"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'DATETIME',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_DATETIME',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DATETIME',
VARNUM: 9,
COLTYPE: '{"data":"SOME_DATETIME","type":"datetime"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'TIME',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_TIME',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_TIME',
VARNUM: 10,
COLTYPE: '{"data":"SOME_TIME","type":"time"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'N',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_SHORTNUM',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_SHORTNUM',
VARNUM: 11,
COLTYPE: '{"data":"SOME_SHORTNUM","type":"numeric","format":"0"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'N',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_BESTNUM',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_BESTNUM',
VARNUM: 12,
COLTYPE: '{"data":"SOME_BESTNUM","type":"numeric","format":"0"}'
} }
] ]
const example_COLTYPE = ` // COLHEADERS mirrors a real getdata.sas response: the *output* dataset's
{ // variable list, i.e. the delete column and PK hoisted to the front, then the
"data":"_____DELETE__THIS__RECORD_____", // remaining source columns in VARNUM order. It drives rule order, so it must
"type":"dropdown", // list every example_cols entry.
"source":[
"No",
"Yes"
]
},
{
"data":"PRIMARY_KEY_FIELD",
"type":"numeric",
"format":"0"
},
{
"data":"SOME_CHAR"
},
{
"data":"SOME_CHAR_LOW"
},
{
"data":"SOME_CHAR_ANY"
},
{
"data":"SOME_DROPDOWN"
},
{
"data":"SOME_DROPDOWN_HARD"
},
{
"data":"SOME_NUM",
"type":"numeric",
"format":"0"
},
{
"data":"SOME_DATE",
"type":"date"
},
{
"data":"SOME_DATETIME",
"type":"datetime"
},
{
"data":"SOME_TIME",
"type":"time"
},
{
"data":"SOME_SHORTNUM",
"type":"numeric",
"format":"0"
},
{
"data":"SOME_BESTNUM",
"type":"numeric",
"format":"0"
}`
const example_sasparams = { const example_sasparams = {
CLS_FLAG: 0, CLS_FLAG: 0,
COLHEADERS: 'head1 head2', COLHEADERS:
COLTYPE: example_COLTYPE, '_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,' +
'SOME_NUM,SOME_CHAR_LOW,SOME_CHAR_ANY,SOME_DROPDOWN_HARD,SOME_DATE,' +
'SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM',
DTTMVARS: 'dttm vars', DTTMVARS: 'dttm vars',
DTVARS: 'dt vars', DTVARS: 'dt vars',
FILTER_TEXT: 'filter text', FILTER_TEXT: 'filter text',
LOADTYPE: 'load type', LOADTYPE: 'load type',
PK: 'primary key', PK: 'PRIMARY_KEY_FIELD',
PKCNT: 0, PKCNT: 1,
RK_FLAG: 0, RK_FLAG: 0,
TMVARS: 'string' TMVARS: 'string'
} }
@@ -507,6 +1267,24 @@ const example_dataformats = {
length: '128', length: '128',
type: 'char' type: 'char'
}, },
SOME_CHAR_LOW: {
format: '$128.',
label: 'SOME_CHAR_LOW',
length: '128',
type: 'char'
},
SOME_CHAR_ANY: {
format: '$128.',
label: 'SOME_CHAR_ANY',
length: '128',
type: 'char'
},
SOME_DROPDOWN_HARD: {
format: '$128.',
label: 'SOME_DROPDOWN_HARD',
length: '128',
type: 'char'
},
SOME_NUM: { SOME_NUM: {
format: 'best.', format: 'best.',
label: 'SOME_NUM', label: 'SOME_NUM',
@@ -1,23 +0,0 @@
import { HotColumnSettings } from '../models/dc-validation.model'
import { parseColType } from '../utils/parseColType'
describe('DC Validator - parse col type', () => {
it('should return array of parsed json', () => {
const colTypeString =
'{"data":"test","test2":"test2"}, {"data":"test3","test4":"test4"}'
const expected: HotColumnSettings[] = [
{ data: 'test', test2: 'test2' },
{ data: 'test3', test4: 'test4' }
]
expect(parseColType(colTypeString)).toEqual(expected)
})
it('should return empty array for invalid json', () => {
const colTypeString =
'{"test":"test""test:"test2"}, {"test3":"test3","test4":"test4"}'
const expected: HotColumnSettings[] = []
expect(parseColType(colTypeString)).toEqual(expected)
})
})
@@ -0,0 +1,24 @@
import { HotColumnSettings } from '../models/dc-validation.model'
import { parseColTypeRow } from '../utils/parseColTypeRow'
describe('DC Validator - parse col type row', () => {
it('parses a single JSON object', () => {
const expected: HotColumnSettings = { data: 'test', type: 'numeric' }
expect(parseColTypeRow('{"data":"test","type":"numeric"}')).toEqual(
expected
)
})
it('returns undefined for undefined input (LEFT JOIN can leave it unset)', () => {
expect(parseColTypeRow(undefined)).toBeUndefined()
})
it('returns undefined for an empty string', () => {
expect(parseColTypeRow('')).toBeUndefined()
})
it('returns undefined for malformed JSON, without throwing', () => {
expect(parseColTypeRow('{not valid')).toBeUndefined()
})
})
@@ -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
}
@@ -7,6 +7,10 @@ import { DcValidation } from '../models/dc-validation.model'
* Uses Intl.NumberFormat options (HOT 17+) instead of the deprecated numbro * Uses Intl.NumberFormat options (HOT 17+) instead of the deprecated numbro
* `pattern`/`culture`. `maximumFractionDigits: 20` preserves all natural * `pattern`/`culture`. `maximumFractionDigits: 20` preserves all natural
* decimals (Intl's default of 3 would round); `locale` replaces `culture`. * decimals (Intl's default of 3 would round); `locale` replaces `culture`.
* `useGrouping: false` keeps raw digits (no thousands separator) - a
* separator would leak into anything that pattern-matches the displayed
* value (eg HARDREGEX/SOFTREGEX), and grouping can be opted into per-column
* with the NUMBER_FORMAT rule.
* *
* @param rules Cell Validation rules to be updated * @param rules Cell Validation rules to be updated
* Those rules are passed in the `columns` property Of handsontable settings. * Those rules are passed in the `columns` property Of handsontable settings.
@@ -14,7 +18,7 @@ import { DcValidation } from '../models/dc-validation.model'
export const applyNumericFormats = (rules: DcValidation[]): DcValidation[] => { export const applyNumericFormats = (rules: DcValidation[]): DcValidation[] => {
for (let rule of rules) { for (let rule of rules) {
if (rule.type === 'numeric') { if (rule.type === 'numeric') {
rule.numericFormat = { useGrouping: true, maximumFractionDigits: 20 } rule.numericFormat = { useGrouping: false, maximumFractionDigits: 20 }
rule.locale = window.navigator.language rule.locale = window.navigator.language
} }
} }
@@ -0,0 +1,16 @@
import { DcValidation } from '../models/dc-validation.model'
/**
* `_____DELETE__THIS__RECORD_____` is a client-side UI concept (the delete
* checkbox column) that getdata.sas synthesizes into vars3/vars4 for display
* purposes only — it's never a real column of the underlying dataset, so
* `%mp_getcols` (which builds `cols1`, the left side of the join into
* `cols`) never has a row for it, and its rule can no longer travel via
* `cols[].COLTYPE` (see issue #253's vertical-array fix). The rule is fixed
* and data-independent, so it's injected directly here instead.
*/
export const DELETE_RECORD_COLUMN_RULE: DcValidation = {
data: '_____DELETE__THIS__RECORD_____',
type: 'dropdown',
source: ['No', 'Yes']
}
@@ -0,0 +1,22 @@
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()).
*
* Heavily decorated with underscores, same convention as
* DELETE_RECORD_COLUMN_RULE - a plain 'EDIT_STATUS' could collide with a
* real column of that name in the actual dataset, which would silently
* overwrite that column's real data (see editor.component.ts's seeding
* loop) and drop it from the submit payload entirely.
*/
export const EDIT_STATUS_COLUMN_NAME = '_____EDIT_STATUS_____'
export const EDIT_STATUS_COLUMN_RULE: DcValidation = {
data: EDIT_STATUS_COLUMN_NAME,
type: 'text',
readOnly: true
}
@@ -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,40 @@
import { isRegexRuleExempt } from './isRegexRuleExempt'
describe('isRegexRuleExempt', () => {
it('exempts blank/undefined/null on any column type', () => {
expect(isRegexRuleExempt('')).toBeTrue()
expect(isRegexRuleExempt(undefined)).toBeTrue()
expect(isRegexRuleExempt(null)).toBeTrue()
expect(isRegexRuleExempt('', true)).toBeTrue()
})
it('exempts the plain SAS missing (".") on numeric columns', () => {
expect(isRegexRuleExempt('.', true)).toBeTrue()
})
it('does not exempt special missings on numeric columns (they are deliberately-set values)', () => {
expect(isRegexRuleExempt('.a', true)).toBeFalse()
expect(isRegexRuleExempt('._', true)).toBeFalse()
expect(isRegexRuleExempt('_', true)).toBeFalse()
expect(isRegexRuleExempt('d', true)).toBeFalse()
})
it('exempts nothing but blank on character columns', () => {
expect(isRegexRuleExempt('.')).toBeFalse()
expect(isRegexRuleExempt('.a')).toBeFalse()
expect(isRegexRuleExempt('_')).toBeFalse()
})
it('does not exempt a bare single letter on character columns (real value, not a missing)', () => {
// A bare single letter is real character data, not a SAS special
// missing - it must reach the pattern, not be exempted.
expect(isRegexRuleExempt('d')).toBeFalse()
expect(isRegexRuleExempt('z')).toBeFalse()
expect(isRegexRuleExempt('A')).toBeFalse()
})
it('does not exempt an ordinary value', () => {
expect(isRegexRuleExempt('ABC123')).toBeFalse()
expect(isRegexRuleExempt('ABC123', true)).toBeFalse()
})
})
@@ -0,0 +1,22 @@
/**
* HARDREGEX/SOFTREGEX both skip pattern-matching for:
*
* - blank values (undefined, null, '') on any column type - enforcing
* populated values is NOTNULL's job, not the pattern's; and
* - the plain SAS numeric missing (".") on numeric columns - it
* represents the absence of a value, same as blank.
*
* SPECIAL missings (.a-.z, ._, bare letters) are NOT exempt, even on
* numeric columns: being deliberately set, they are real values the
* pattern is meant to check. This also means isSpecialMissing from
* @sasjs/utils (which would match them, with an optional dot) is
* deliberately not used here.
*/
export const isRegexRuleExempt = (
value: any,
isNumeric: boolean = false
): boolean => {
if (value === undefined || value === null || value === '') return true
return isNumeric && value === '.'
}
@@ -27,7 +27,7 @@ export const mergeColsRules = (
: colFormats.type : colFormats.type
if (rule && col.DESC) rule.desc = col.DESC if (rule && col.DESC) rule.desc = col.DESC
if (rule && colFormats.length) rule.length = parseInt(colFormats.length) if (rule && colFormats?.length) rule.length = parseInt(colFormats.length)
if (rule && col.CLS_RULE) rule.clsRule = col.CLS_RULE if (rule && col.CLS_RULE) rule.clsRule = col.CLS_RULE
if (rule && colFormats?.type) rule.sasType = colFormats.type if (rule && colFormats?.type) rule.sasType = colFormats.type
} }
@@ -1,16 +0,0 @@
import { HotColumnSettings } from '../models/dc-validation.model'
/**
* From sas we get a string instead of array of objects, in that string `[]` are missing so
* before parsing JSON we need to add them.
*
* @param coltype string (objects) that comes from sas
* @returns JSON Handsontable.ColumnSettings[]
*/
export const parseColType = (coltype: string): HotColumnSettings[] => {
try {
return JSON.parse(`[${coltype}]`)
} catch (err: any) {
return []
}
}

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