Compare commits

...
73 Commits
Author SHA1 Message Date
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
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
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
132 changed files with 17055 additions and 13635 deletions
+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).
+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`.
+5 -5
View File
@@ -38,13 +38,13 @@ jobs:
npm ci
- name: Check audit
# Audit should fail and stop the CI if critical vulnerability found
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
run: |
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
cd ./sas
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
cd ../client
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
- name: Lint check
run: npm run lint:check
@@ -146,7 +146,7 @@ jobs:
# Start frontend and run cypress
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
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"
- name: Zip Cypress videos
if: always()
+4 -4
View File
@@ -45,13 +45,13 @@ jobs:
npm ci
- name: Check audit
# Audit should fail and stop the CI if critical vulnerability found
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
run: |
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
cd ./sas
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
cd ../client
npm audit --audit-level=critical --omit=dev
npm audit --omit=dev
- name: Angular Tests
run: |
+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.
+53
View File
@@ -1,3 +1,56 @@
# [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)
+24
View File
@@ -16,6 +16,21 @@ Add `client/.npmrc` file with following content:
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
```
In addition to the registry auth, the `@sheet/crypto` package is installed from a local tarball
(`client/libraries/sheet-crypto.tgz`) rather than the registry. This tarball is not committed to
the repo directly - only an encrypted copy is: `client/libraries/sheet-crypto.tgz.gpg`.
Before running `npm i`/`npm ci` in `client`, you must decrypt it using the passphrase
(stored as the `SHEET_PWD` CI secret - ask a repo maintainer if you don't have it):
```bash
cd client
echo "YOUR_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
npm i
```
[Handsontable](https://www.npmjs.com/package/handsontable)
Licence should be inserted in the `client/src/index.html` file:
@@ -66,6 +81,15 @@ To remove the release, you need to do it with repo administration over at [https
# Troubleshooting
## `npm i` fails with ENOENT on `sheet-crypto.tgz`
If `npm i`/`npm ci` in `client` fails with:
```
npm error enoent ENOENT: no such file or directory, open '.../client/libraries/sheet-crypto.tgz'
```
it means the encrypted `sheet-crypto.tgz.gpg` hasn't been decrypted yet. See
[Dependencies that requires licences](#dependencies-that-requires-licences) above for how to decrypt it.
## Makedata service "could not create directory" error
The dcpath folder should have its permissions set so that the system account (SYSUSERID) can both read and write to it.
+26
View File
@@ -157,5 +157,31 @@
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}
+367
View File
@@ -115,8 +115,375 @@ 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())
})
})
})
})
})
})
it('9 | 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('10 | 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('11 | 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('12 | 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())
})
})
})
})
})
})
})
// Handsontable virtualizes columns — with 17 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 (the last two) 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')
}
// 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))
})
}
// 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) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
+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
*/
+180 -186
View File
@@ -41,17 +41,14 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.get('.open-viewbox').then((viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
return
}
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
checkColumns(columns, () => {
done()
})
}
})
})
it('2 | Add two viewboxes', (done) => {
@@ -78,48 +75,21 @@ context('editor tests: ', function () {
libraryToOpenIncludes,
viewboxes.map((viewbox) => viewbox.viewbox_table)
)
cy.get('.open-viewbox').then((viewboxNodes: any) => {
let found = 0
for (let viewboxNode of viewboxNodes) {
for (let viewbox of viewboxes) {
if (
viewboxNode.innerText.toLowerCase().includes(viewbox.viewbox_table)
)
found++
}
}
if (found < viewboxes.length) return
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
(viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
cy.get(viewboxNode).within(() => {
cy.get('.table-title').then((tableTitle) => {
const title = tableTitle[0].innerText
const viewbox = viewboxes.find((vb) =>
title.toLowerCase().includes(vb.viewbox_table)
)
if (viewbox) {
cy.get('.ht_master.handsontable .htCore thead tr').then(
(viewboxColNodes: any) => {
let allColsHtml = viewboxColNodes[0].innerHTML
for (let col of viewbox?.columns) {
if (!allColsHtml.includes(col)) return
}
done()
}
)
}
viewboxes.forEach((viewbox) => {
cy.contains('.open-viewbox', viewbox.viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
})
}
}
)
let result = 0
viewboxes.forEach((viewbox) => {
checkColumns(viewbox.columns, () => {
result++
if (result === viewboxes.length) done()
})
})
})
@@ -133,11 +103,10 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.get('.open-viewbox').then((viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
return
}
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
openViewboxConfig(viewbox_table)
@@ -148,8 +117,6 @@ context('editor tests: ', function () {
checkColumns([...columns, ...additionalColumns], () => {
done()
})
}
})
})
it('4 | Add viewbox, add columns and reorder', (done) => {
@@ -162,11 +129,10 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.get('.open-viewbox').then((viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
return
}
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
openViewboxConfig(viewbox_table)
@@ -190,8 +156,6 @@ context('editor tests: ', function () {
done()
})
})
}
})
})
it('5 | Add viewbox, add columns, reorder, remove column, add again', (done) => {
@@ -204,13 +168,12 @@ context('editor tests: ', function () {
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.get('.open-viewbox').then((viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
return
}
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
viewboxNode.click()
openViewboxConfig(viewbox_table)
removeAllColumns()
@@ -239,19 +202,14 @@ context('editor tests: ', function () {
],
() => {
addColumns([colToRemove], () => {
checkColumns(
[...columns, ...additionalColumns.reverse()],
() => {
checkColumns([...columns, ...additionalColumns.reverse()], () => {
done()
}
)
})
})
}
)
})
})
}
})
})
it('6 | Add viewboxes, reload and check url restored configuration', (done) => {
@@ -333,68 +291,68 @@ context('editor tests: ', function () {
})
})
// We will enable this test when we figure out how to mock filtering
// it('7 | Add viewboxes and filter', () => {
// const viewboxes = ['mpe_x_test', 'mpe_validations']
// The base VIEW table and a viewbox on a different table must never
// share a filter dialog's cached state - each keeps its own entry in
// the globals filter cache (getFilterObjPath), keyed by viewboxId where
// one is present. Opened from the VIEW page specifically
// (rootParam === 'view') - the one branch where this previously broke,
// since a 'view'-page QueryComponent was always treated as the base
// table regardless of viewboxId.
it('7 | Base table filter and viewbox filter stay isolated from each other', () => {
const viewbox_table = 'mpe_audit'
// openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
visitPage('view/data')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// cy.get('.viewbox-open').click()
// openTableFromViewboxTree(libraryToOpenIncludes, viewboxes)
// Filter the base table.
openViewFilter()
setFilterWithValue('SOME_CHAR', 'this is dummy data', 'value', () => {
cy.get('app-query', { withinSubject: null }).should('not.exist')
// cy.wait(1000)
// Open a viewbox on a *different* table and check its filter dialog
// does not show the base table's filter.
openViewViewboxes()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
closeViewboxModal()
// closeViewboxModal()
// cy.contains retries until the viewbox's title actually shows the
// expected table name (it can render before its data/title loads),
// then .parents() walks back up to its container - avoids a
// one-shot .then() racing the title's own async update, which is
// what made this flaky in the first place.
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
.parents('.viewbox')
.first()
.within(() => {
cy.get('clr-icon[shape="filter"]').click()
// cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
// (viewboxNodes: any) => {
// for (let viewboxNode of viewboxNodes) {
// cy.get(viewboxNode).within(() => {
// cy.get('.table-title').then((title: any) => {
// cy.get('.hot-spinner')
// .should('not.exist')
// .then(() => {
// cy.get('clr-icon[shape="filter"]').then((filterButton) => {
// filterButton[0].click()
// })
cy.get('.filter-modal code.language-sql', {
withinSubject: null
}).should(($code) => {
expect($code.text()).to.not.contain('SOME_CHAR')
})
// if (title[0].innerText.includes('MPE_X_TEST')) {
// setFilterWithValue(
// 'SOME_CHAR',
// 'this is dummy data',
// 'value',
// () => {
// cy.get('app-query', { withinSubject: null })
// .should('not.exist')
// .get('.ht_master.handsontable tbody tr')
// .then((rowNodes) => {
// const tr = rowNodes[0]
// Reverse direction: filter the viewbox, then confirm
// re-opening the base table's own filter dialog still shows
// the base table's filter, not the viewbox's.
setFilterWithValue('LIBREF', 'sasdemo', 'value', () => {
cy.get('app-query', { withinSubject: null }).should('not.exist')
// expect(rowNodes).to.have.length(1)
// expect(tr.innerText).to.equal('0')
// })
// }
// )
// } else if (title[0].innerText.includes('MPE_VALIDATIONS')) {
// setFilterWithValue('BASE_COL', 'ALERT_LIB', 'value', () => {
// cy.get('app-query', { withinSubject: null })
// .should('not.exist')
// .get('.ht_master.handsontable tbody tr')
// .then((rowNodes) => {
// const tr = rowNodes[0]
openViewFilter()
// expect(rowNodes).to.have.length(1)
// expect(tr.innerText).to.contain('ALERT_LIB')
// })
// })
// }
// })
// })
// })
// }
// }
// )
// })
cy.get('.filter-modal code.language-sql', {
withinSubject: null
}).should(($code) => {
expect($code.text()).to.contain('SOME_CHAR')
expect($code.text()).to.not.contain('LIBREF')
})
})
})
})
})
})
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) => {
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
(viewboxNodes: any) => {
for (let viewboxNode of viewboxNodes) {
cy.get(viewboxNode).within(() => {
cy.get('.ht_master.handsontable thead tr th').then(
(viewboxColNodes: any) => {
console.log('viewboxColNode', viewboxColNodes)
console.log('columns', columns)
for (let i = 0; i < viewboxColNodes.length; i++) {
const col = columns[i] || ''
const colNode = viewboxColNodes[i]
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).should(
($viewboxNodes) => {
const matches = $viewboxNodes.toArray().some((viewboxNode) => {
// rowHeaders: true (viewboxes.component.ts) adds a leading, unlabeled
// corner <th> before the actual data columns - skip it, or every
// comparison is off by one against the row-header cell instead of
// the first real column.
const headerCells = Cypress.$(viewboxNode)
.find('.ht_master.handsontable thead tr th')
.toArray()
.slice(1)
if (
!colNode.innerHTML.toLowerCase().includes(col.toLowerCase())
)
return
}
callback()
}
return columns.every((col, i) =>
(headerCells[i]?.innerHTML || '')
.toLowerCase()
.includes(col.toLowerCase())
)
})
}
expect(matches, `a viewbox showing columns: ${columns.join(', ')}`).to.be
.true
}
)
cy.then(() => callback())
}
const closeViewboxModal = () => {
@@ -444,9 +410,7 @@ const removeColumn = (column: string) => {
}
const addColumns = (columns: string[], callback?: () => void) => {
for (let i = 0; i < columns.length; i++) {
const column = columns[i]
for (const column of columns) {
cy.get('.cols-search input', { withinSubject: null }).type(column)
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
.first()
@@ -454,50 +418,80 @@ const addColumns = (columns: string[], callback?: () => void) => {
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
.first()
.trigger('keydown', { key: 'Enter' })
.then(() => {
if (i === columns.length - 1 && callback) callback()
})
// Wait for the actual column box to render before moving on to the
// next column (or firing the callback) - selecting it doesn't add it
// to the DOM synchronously, and cy.get()'s own retry covers that gap
// instead of relying on a fixed cy.wait().
cy.get(`.col-box.column-${column}`, { timeout: longerCommandTimeout })
}
if (callback) cy.then(() => callback())
}
// The VIEW page's "options" dropdown (viewer.component.html) - distinct
// from the editor page's dedicated .viewbox-open button used by the
// tests above, since test 7 runs on the VIEW page specifically.
const openViewOptionsDropdown = () => {
cy.get('.filterSide', { withinSubject: null }).click()
}
const openViewFilter = () => {
openViewOptionsDropdown()
cy.get('[clrDropdownItem]', { withinSubject: null })
.contains('Filter')
.click()
}
const openViewViewboxes = () => {
openViewOptionsDropdown()
cy.get('[clrDropdownItem]', { withinSubject: null })
.contains('Viewboxes')
.click()
}
// cy.contains retries until a matching node appears/updates - the open
// viewbox's own list entry can render before its table-name text has
// actually loaded, so a one-shot .then() lookup here would race it.
const openViewboxConfig = (viewbox_tablename: string) => {
cy.get('.open-viewbox').then((viewboxes: any) => {
for (let openViewbox of viewboxes) {
if (openViewbox.innerText.toLowerCase().includes(viewbox_tablename))
openViewbox.click()
}
})
cy.contains('.open-viewbox', viewbox_tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
// Small settle wait: right after a fresh visit (an extra navigation on
// top of beforeEach's own, e.g. test 7's visitPage('view/data')), the
// tree component can still be re-rendering, causing a node to be found
// then swapped out mid-click - same fix already proven in
// viewer-labels.cy.ts.
cy.wait(300)
const libraryNodeSelector = '.nav-tree clr-tree > clr-tree-node'
const libraryMatcher = new RegExp(libNameIncludes, 'i')
cy.contains(libraryNodeSelector, libraryMatcher, {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (new RegExp(libNameIncludes).test(node.innerText.toLowerCase())) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container p').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
.find('.clr-tree-node-content-container p')
.click()
// Re-query the library node fresh rather than reusing the reference
// from before the click above - expanding a library can replace its
// whole DOM subtree once its child tables load (that's what made the
// earlier cached-reference version of this helper flaky: Cypress
// detected the click's own side effect detaching the element it was
// still verifying actionability against).
cy.contains(libraryNodeSelector, libraryMatcher, {
timeout: longerCommandTimeout
}).within(() => {
cy.contains('.clr-treenode-link', tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
})
}
Binary file not shown.
+1 -1
View File
@@ -8,7 +8,7 @@ const check = (cwd) => {
start: cwd,
excludePrivatePackages: true,
onlyAllow:
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;BlueOak-1.0.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
excludePackages:
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
},
+6249 -6468
View File
File diff suppressed because it is too large Load Diff
+32 -23
View File
@@ -41,28 +41,30 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.20",
"@angular/cdk": "^19.2.19",
"@angular/common": "^19.2.20",
"@angular/compiler": "^19.2.20",
"@angular/core": "^19.2.20",
"@angular/forms": "^19.2.20",
"@angular/platform-browser": "^19.2.20",
"@angular/platform-browser-dynamic": "^19.2.20",
"@angular/router": "^19.2.20",
"@angular/animations": "^20.3.26",
"@angular/cdk": "^20.2.14",
"@angular/common": "^20.3.26",
"@angular/compiler": "^20.3.26",
"@angular/core": "^20.3.26",
"@angular/forms": "^20.3.26",
"@angular/platform-browser": "^20.3.26",
"@angular/platform-browser-dynamic": "^20.3.26",
"@angular/router": "^20.3.26",
"@cds/core": "^6.15.1",
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
"@clr/icons": "^13.0.2",
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular-wrapper": "^18.0.0",
"@sasjs/adapter": "^4.17.0",
"@sasjs/utils": "^3.5.3",
"@sasjs/adapter": "^4.17.3",
"@sasjs/utils": "^3.5.9",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
"@types/d3-graphviz": "^2.6.7",
"@types/text-encoding": "0.0.35",
"base64-arraybuffer": "^0.2.0",
"browserify-cipher": "^1.0.1",
"buffer": "^5.4.3",
"crypto-browserify": "^3.12.1",
"create-hash": "^1.2.0",
"create-hmac": "^1.1.7",
"crypto-js": "^4.2.0",
"d3-graphviz": "^5.0.2",
"exceljs": "^4.4.0",
@@ -77,8 +79,8 @@
"moment": "^2.30.1",
"ngx-clipboard": "^16.0.0",
"ngx-json-viewer": "file:libraries/ngx-json-viewer-3.2.1.tgz",
"nodejs": "0.0.0",
"os-browserify": "0.3.0",
"randombytes": "^2.1.0",
"rxjs": "^7.8.0",
"save-svg-as-png": "^1.4.17",
"stream-browserify": "3.0.0",
@@ -91,29 +93,32 @@
"zone.js": "~0.15.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.24",
"@angular-devkit/build-angular": "^20.3.32",
"@angular-eslint/builder": "19.8.1",
"@angular-eslint/eslint-plugin": "19.8.1",
"@angular-eslint/eslint-plugin-template": "19.8.1",
"@angular-eslint/schematics": "19.8.1",
"@angular-eslint/template-parser": "19.8.1",
"@angular/cli": "^19.2.24",
"@angular/compiler-cli": "^19.2.20",
"@angular/cli": "^20.3.32",
"@angular/compiler-cli": "^20.3.26",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@compodoc/compodoc": "^1.2.1",
"@compodoc/compodoc": "^2.0.0",
"@cypress/webpack-preprocessor": "^5.17.1",
"@lhci/cli": "^0.15.1",
"@types/core-js": "^2.5.5",
"@types/create-hash": "^1.2.6",
"@types/create-hmac": "^1.1.3",
"@types/crypto-js": "^4.2.1",
"@types/es6-shim": "^0.31.39",
"@types/jasmine": "~5.1.4",
"@types/lodash-es": "^4.17.3",
"@types/marked": "^4.3.0",
"@types/node": "12.20.50",
"@typescript-eslint/eslint-plugin": "8.31.1",
"@typescript-eslint/parser": "8.31.1",
"@types/randombytes": "^2.0.3",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"core-js": "^2.5.4",
"cypress": "^15.14.2",
"cypress": "^15.19.0",
"cypress-file-upload": "^5.0.8",
"cypress-plugin-tab": "^1.0.5",
"cypress-real-events": "^1.8.1",
@@ -135,13 +140,17 @@
"rimraf": "3.0.2",
"ts-loader": "^9.2.8",
"ts-node": "^3.3.0",
"typescript": "~5.8.3",
"wait-on": "^6.0.1",
"typescript": "~5.9.3",
"wait-on": "^9.0.10",
"watch": "^1.0.2"
},
"overrides": {
"ajv": "8.18.0",
"uuid": "11.1.1",
"lighthouse": "13.4.0"
"lighthouse": "13.4.0",
"exceljs": {
"archiver": "^8.0.0",
"unzipper": "^0.12.5"
}
}
}
+69 -71
View File
@@ -1,18 +1,19 @@
<div class="main-container">
<ng-container *ngIf="!router.url.includes('licensing')">
@if (!router.url.includes('licensing')) {
@if (freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)) {
<div
*ngIf="
freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)
"
class="alert alert-app-level alert-warning"
id="demo-banner"
role="alert"
>
<ng-container *ngIf="licenceProblem.value === null">
@if (licenceProblem.value === null) {
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
<cds-icon
class="alert-icon"
shape="warning-standard"
></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - to upgrade contact
@@ -20,34 +21,34 @@
</div>
</div>
</div>
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
</ng-container>
<ng-container *ngIf="licenceProblem.value !== null">
}
@if (licenceProblem.value !== null) {
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
<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>
}
@if (licenseExpiringDays && !freeTierBanner) {
<div
*ngIf="licenseExpiringDays && !freeTierBanner"
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
@@ -57,7 +58,6 @@
<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
@@ -67,17 +67,15 @@
</div>
</div>
</div>
<a
*ngIf="!freeTierBanner"
routerLink="/licensing/update"
class="update-key"
@if (!freeTierBanner) {
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
</div>
}
@if (appOverCapacity) {
<div
*ngIf="appOverCapacity"
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
@@ -87,27 +85,26 @@
<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
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"
@if (!licenseExpiringDays && !freeTierBanner) {
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
</div>
</ng-container>
}
}
<header class="app-header" *ngIf="!embed">
@if (!embed) {
<header class="app-header">
<!-- <button
*ngIf="
isMainRoute('view') ||
@@ -119,12 +116,11 @@
>
<span></span>
</button> -->
<div
*ngIf="
@if (
isMainRoute('view') ||
(isMainRoute('home') && !router.url.includes('licensing'))
"
) {
<div
(click)="toggleSidebar()"
type="button"
aria-label="Toggle sidebar"
@@ -132,14 +128,10 @@
>
<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"
>
@if (!router.url.includes('deploy')) {
<a href="#" [routerLink]="['/']" class="nav-link">
<img
class="without-text d-block d-md-none"
src="images/dc-logo.svg"
@@ -151,17 +143,16 @@
alt="datacontroller logo"
/>
</a>
<a *ngIf="router.url.includes('deploy')">
}
@if (router.url.includes('deploy')) {
<a>
<span class="clr-icon header-logo ml-10"></span>
</a>
}
</div>
<ng-container
*ngIf="
@if (
!router.url.includes('deploy') && !router.url.includes('licensing')
"
>
) {
<div class="header-nav d-flex d-sm-none">
<clr-dropdown>
<button
@@ -179,7 +170,6 @@
</clr-dropdown-menu>
</clr-dropdown>
</div>
<div class="header-nav d-none d-sm-flex">
<a
[routerLink]="['/view']"
@@ -208,19 +198,17 @@
>REVIEW</a
>
</div>
</ng-container>
}
<app-header-actions></app-header-actions>
</header>
<nav
*ngIf="
}
@if (
!embed &&
(router.url.includes('submitted') ||
router.url.includes('approve') ||
router.url.includes('history'))
"
class="subnav"
>
) {
<nav class="subnav">
<ul class="nav">
<li class="nav-item">
<a
@@ -249,18 +237,19 @@
</li>
</ul>
</nav>
}
<app-alerts *ngIf="!errTop"></app-alerts>
@if (!errTop) {
<app-alerts></app-alerts>
}
<app-requests-modal [(opened)]="requestsModal"></app-requests-modal>
<app-excel-password-modal></app-excel-password-modal>
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
<!-- VA embed, back to Editor button -->
<div
*ngIf="embed === 'va' && vaEditorLibds && !isMainRoute('/editor')"
class="va-back-bar"
>
@if (embed === 'va' && vaEditorLibds && !isMainRoute('/editor')) {
<div class="va-back-bar">
<button
type="button"
class="btn btn-sm btn-primary"
@@ -275,18 +264,24 @@
Back to Edit table
</button>
</div>
}
<router-outlet *ngIf="startupDataLoaded"></router-outlet>
@if (startupDataLoaded) {
<router-outlet></router-outlet>
}
<app-login></app-login>
<app-alerts *ngIf="errTop"></app-alerts>
@if (errTop) {
<app-alerts></app-alerts>
}
@for (abort of sasjsAborts; track abort) {
<app-info-modal
*ngFor="let abort of sasjsAborts"
[data]="abort"
[forceReload]="!startupDataLoaded && sasjsAborts.length === 1"
(onConfirmModalClick)="closeAbortModal(abort.id!)"
>
</app-info-modal>
}
<clr-modal
appDragNdrop
@@ -309,17 +304,20 @@
</div>
<!-- App Loading Page -->
<div *ngIf="!startupDataLoaded" class="app-loading">
@if (!startupDataLoaded) {
<div class="app-loading">
<img
class="loading-logo"
src="images/datacontroller.svg"
alt="datacontroller logo"
/>
<div *ngIf="appActive === null" class="slider">
@if (appActive === null) {
<div class="slider">
<div class="line"></div>
<div class="subline inc"></div>
<div class="subline dec"></div>
</div>
</div>
}
</div>
}
<!-- /App Loading Page -->
+22 -21
View File
@@ -1,14 +1,15 @@
<main class="content-area position-relative">
<div class="clr-row">
<!-- T&C section -->
<div *ngIf="step === 0" id="TCS" class="card">
@if (step === 0) {
<div id="TCS" class="card">
<div class="card-header">Terms and Conditions</div>
<div class="card-block">
<div class="card-text">
<p class="mt-0">
The Demo version of Data Controller is free for EVALUATION purposes
only. Before proceeding with configuration, please confirm that you
have read, understood, and agreed to the
The Demo version of Data Controller is free for EVALUATION
purposes only. Before proceeding with configuration, please
confirm that you have read, understood, and agreed to the
<a
href="https://docs.datacontroller.io/evaluation-licence-agreement"
target="_blank"
@@ -16,9 +17,7 @@
>.
</p>
</div>
<hr class="light" />
<clr-checkbox-wrapper>
<input clrCheckbox type="checkbox" (change)="termsAgreeChange()" />
<label
@@ -30,9 +29,7 @@
></label
>
</clr-checkbox-wrapper>
<!-- <hr />
<div class="clr-checkbox-wrapper">
<input
[(ngModel)]="autodeploy"
@@ -48,12 +45,15 @@
</div> -->
</div>
</div>
}
</div>
<!-- T&C section end -->
<ng-container *ngIf="step > 0" [ngSwitch]="true">
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.SasViya">
<div *ngIf="autodeploy" class="autodeploy-section card">
@if (step > 0) {
@switch (true) {
@case (sasJsConfig.serverType === ServerType.SasViya) {
@if (autodeploy) {
<div class="autodeploy-section card">
<app-automatic-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
@@ -64,8 +64,9 @@
(onNavigateToHome)="onNavigateToHome()"
></app-automatic-deploy>
</div>
<div *ngIf="!autodeploy" id="mainbody" class="card">
}
@if (!autodeploy) {
<div id="mainbody" class="card">
<app-manual-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
@@ -73,9 +74,9 @@
(onNavigateToHome)="onNavigateToHome()"
></app-manual-deploy>
</div>
</ng-container>
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.Sasjs">
}
}
@case (sasJsConfig.serverType === ServerType.Sasjs) {
<div class="autodeploy-section card">
<app-sasjs-configurator
[sasJs]="sasJs"
@@ -84,9 +85,8 @@
(onNavigateToHome)="onNavigateToHome()"
></app-sasjs-configurator>
</div>
</ng-container>
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.Sas9">
}
@case (sasJsConfig.serverType === ServerType.Sas9) {
<div class="autodeploy-section card">
<app-sasjs-configurator
[sasJs]="sasJs"
@@ -95,6 +95,7 @@
(onNavigateToHome)="onNavigateToHome()"
></app-sasjs-configurator>
</div>
</ng-container>
</ng-container>
}
}
}
</main>
@@ -1,73 +1,62 @@
<div *ngIf="autodeploying" class="auto-deploy">
@if (autodeploying) {
<div class="auto-deploy">
<div class="spinner-box">
<ng-container *ngIf="!autodeployDone">
@if (!autodeployDone) {
<span class="spinner spinner-md"> Loading... </span>
<p>Deploying...</p>
</ng-container>
<ng-container *ngIf="autodeployDone">
}
@if (autodeployDone) {
<p class="m-0 align-self-start">Done</p>
<hr class="w-100" />
<div
*ngIf="autoDeployStatus.deployServicePack !== null"
class="deploy-status-row"
>
@if (autoDeployStatus.deployServicePack !== null) {
<div class="deploy-status-row">
@if (autoDeployStatus.deployServicePack === true) {
<clr-icon
*ngIf="autoDeployStatus.deployServicePack === true"
class="deploy-success"
shape="success-standard"
></clr-icon>
<clr-icon
*ngIf="!autoDeployStatus.deployServicePack === false"
class="deploy-error"
shape="times-circle"
></clr-icon>
}
@if (!autoDeployStatus.deployServicePack === false) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
<p>Deploy SAS Jobs</p>
</div>
<div
*ngIf="autoDeployStatus.runMakeData !== null"
class="deploy-status-row"
>
}
@if (autoDeployStatus.runMakeData !== null) {
<div class="deploy-status-row">
@if (autoDeployStatus.runMakeData) {
<clr-icon
*ngIf="autoDeployStatus.runMakeData"
class="deploy-success"
shape="success-standard"
></clr-icon>
<clr-icon
*ngIf="autoDeployStatus.runMakeData === false"
class="deploy-error"
shape="times-circle"
></clr-icon>
}
@if (autoDeployStatus.runMakeData === false) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
<p>Create database</p>
</div>
}
<hr class="w-100" />
<div class="buttons">
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
<clr-icon
*ngIf="
@if (
autoDeployStatus.deployServicePack === false ||
autoDeployStatus.runMakeData === false
"
class="deploy-error"
shape="times-circle"
></clr-icon>
) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
LAUNCH
</button>
<button
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
(click)="
downloadFile(makeDataResponse, 'create-database-log', 'txt')
"
class="btn btn-primary-outline mt-15 mr-0"
>
Download log
</button>
</div>
<hr class="w-100" />
<div class="buttons">
<button
(click)="autodeploying = false; autodeployDone = false"
@@ -75,7 +64,6 @@
>
Close
</button>
<button
(click)="openSasRequestsModal()"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
@@ -83,9 +71,10 @@
SAS Requests
</button>
</div>
</ng-container>
}
</div>
</div>
</div>
}
<h4 class="text-center my-15">Viya Deploy</h4>
<hr />
@@ -106,21 +95,18 @@
<label for="dcloc" class="mt-20 clr-control-label">SAS Admin group</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper small-mt">
<select
*ngIf="!adminGroupsLoading"
clrSelect
name="options"
[(ngModel)]="selectedAdminGroup"
>
<option *ngFor="let adminGroup of adminGroups" [value]="adminGroup.id">
@if (!adminGroupsLoading) {
<select clrSelect name="options" [(ngModel)]="selectedAdminGroup">
@for (adminGroup of adminGroups; track adminGroup) {
<option [value]="adminGroup.id">
{{ adminGroup.name }}
</option>
}
</select>
<clr-spinner
clrInline
class="spinner-sm"
*ngIf="adminGroupsLoading"
></clr-spinner>
}
@if (adminGroupsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
</div>
</div>
@@ -129,36 +115,34 @@
>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper small-mt">
@if (!computeContextsLoading) {
<select
*ngIf="!computeContextsLoading"
clrSelect
name="options"
(ngModelChange)="onComputeContextChange($event)"
[(ngModel)]="selectedComputeContext"
>
<option
*ngFor="let computeContext of computeContexts"
[value]="computeContext.id"
>
@for (computeContext of computeContexts; track computeContext) {
<option [value]="computeContext.id">
{{ computeContext.name }}
</option>
}
</select>
<clr-spinner
clrInline
class="spinner-sm"
*ngIf="computeContextsLoading"
></clr-spinner>
}
@if (computeContextsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
</div>
</div>
<ng-container *ngIf="runningAsUser">
@if (runningAsUser) {
<label for="dcloc" class="mt-20 clr-control-label">Running as user:</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<p class="mt-0">{{ runningAsUser }}</p>
</div>
</div>
</ng-container>
}
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
@@ -185,18 +169,18 @@
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
<!-- <button
(click)="executeJson()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
[disabled]="!jsonFile"
(click)="executeJson()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
[disabled]="!jsonFile"
>
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
</button> -->
<!-- <button
(click)="uploadJsonAuto.click()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
(click)="uploadJsonAuto.click()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
>
Upload different file to deploy
Upload different file to deploy
</button>
<input
#uploadJsonAuto
@@ -204,7 +188,7 @@
hidden
(click)="clearUploadInput($event)"
(change)="onJsonFileChange($event)"
/> -->
/> -->
<clr-modal [(clrModalOpen)]="recreateDatabaseModal" [clrModalClosable]="false">
<h3 class="modal-title">Warning</h3>
@@ -11,9 +11,10 @@
<div class="card-block">
<!-- <div class="card-title">
Client Details
</div> -->
</div> -->
<div *ngIf="needsLogin" id="loginForm" class="d-none">
@if (needsLogin) {
<div id="loginForm" class="d-none">
<p class="mb-10">Please log in first</p>
<label for="username" class="clr-control-label">Username</label>
<div class="mb-10 clr-control-container">
@@ -21,18 +22,17 @@
<input type="text" id="username" class="clr-input" />
</div>
</div>
<label for="password" class="clr-control-label">Password</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input type="password" id="password" class="clr-input" />
</div>
</div>
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
</div>
}
<ng-container *ngIf="!needsLogin">
@if (!needsLogin) {
<form>
<div class="clr-form-control">
<label for="select-full" class="clr-control-label">Admin group</label>
@@ -45,17 +45,15 @@
id="adminGroupsSelect"
class="clr-select"
>
<option
*ngFor="let adminGroup of adminGroups"
[value]="adminGroup.id"
>
@for (adminGroup of adminGroups; track adminGroup) {
<option [value]="adminGroup.id">
{{ adminGroup.name }}
</option>
}
</select>
</div>
</div>
</div>
<div class="clr-form-control">
<div [class.hidden]="contextsLoading">
<label for="select-full" class="clr-control-label">Context</label>
@@ -67,15 +65,14 @@
name="selectedContext"
class="clr-select"
>
<option
*ngFor="let context of allContexts"
[value]="context.name"
>
@for (context of allContexts; track context) {
<option [value]="context.name">
{{ context.name }}
<span *ngIf="(context.attributes | json) != '{}'"
>( {{ context.attributes.sysUserId }} )</span
>
@if ((context.attributes | json) != '{}') {
<span>( {{ context.attributes.sysUserId }} )</span>
}
</option>
}
</select>
</div>
</div>
@@ -88,14 +85,12 @@
<clr-icon shape="play"></clr-icon>
</button>
</div>
<div [class.hidden]="!contextsLoading" class="d-flex">
<span class="spinner spinner-inline mr-10">
Loading contexts...
</span>
<span> Loading contexts... </span>
</div>
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
@@ -110,7 +105,6 @@
/>
</div>
</div>
<p class="mt-10">
Select JSON file to upload (json build file preloaded):
</p>
@@ -124,7 +118,6 @@
<clr-icon shape="download"></clr-icon>
</button> -->
</div>
<div class="mt-20 d-flex align-items-center">
<button
(click)="executeJson()"
@@ -135,13 +128,13 @@
>
SUBMIT JSON
</button>
<span *ngIf="isJsonSubmitted">JSON Submitted Successfully</span>
@if (isJsonSubmitted) {
<span>JSON Submitted Successfully</span>
}
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
Loading...
</span> -->
</div>
<p class="mt-10">Select SAS file to upload:</p>
<div class="d-flex flex-column">
<input
@@ -149,15 +142,15 @@
(click)="clearUploadInput($event)"
(change)="onSasFileChange($event); downloadFileBtn = true"
/>
@if (downloadFileBtn) {
<button
*ngIf="downloadFileBtn"
(click)="downloadSasPrecodeFile()"
class="btn btn-sm btn-icon min-w-0 w-40"
>
<clr-icon shape="download"></clr-icon>
</button>
}
</div>
<div class="mt-20 d-flex align-items-center">
<button
(click)="executeSAS()"
@@ -168,17 +161,14 @@
>
SUBMIT
</button>
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
Loading...
</span> -->
</div>
</div>
<ng-container *ngIf="jobLog.length > 0">
@if (jobLog.length > 0) {
<p class="mb-0 mt-10">File execute completed</p>
<hr />
<div>
<button
(click)="downloadFile(jobLog, 'execute-script-log', 'txt')"
@@ -187,8 +177,7 @@
Download log
</button>
</div>
</ng-container>
}
<button
[clrLoading]="createDatabaseLoading"
(click)="createDatabase()"
@@ -196,52 +185,45 @@
>
Create Database
</button>
<ng-container *ngIf="makeDataResponse.length > 0">
@if (makeDataResponse.length > 0) {
<p class="mb-0 mt-10">Create Database Completed</p>
<hr />
<div *ngIf="makeDataResponse.length > 0" class="log-wrapper">
@if (makeDataResponse.length > 0) {
<div class="log-wrapper">
{{ makeDataResponse }}
</div>
}
<button (click)="navigateToHome()" class="btn btn-primary mt-15">
Let's get started
</button>
<button
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
class="btn btn-primary mt-15"
>
Download log
</button>
<button (click)="validateDeploy()" class="btn btn-primary mt-15">
Validate
</button>
<div
*ngIf="validationState !== 'none' || isValidating"
class="validation-bar"
>
<ng-container *ngIf="isValidating">
@if (validationState !== 'none' || isValidating) {
<div class="validation-bar">
@if (isValidating) {
<span class="spinner spinner-inline mr-10">
Validating deploy...
</span>
<span> Validating deploy... </span>
</ng-container>
<ng-container *ngIf="!isValidating && validationState === 'error'">
}
@if (!isValidating && validationState === 'error') {
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
<span> Validation failed </span>
</ng-container>
<ng-container *ngIf="!isValidating && validationState === 'success'">
}
@if (!isValidating && validationState === 'success') {
<clr-icon shape="check-circle" class="is-success"></clr-icon>
<span> Validation succeeded </span>
</ng-container>
}
</div>
</ng-container>
}
}
</form>
</ng-container>
}
</div>
@@ -1,6 +1,8 @@
<div *ngIf="loading" class="thinProgress progresStatic progress loop">
@if (loading) {
<div class="thinProgress progresStatic progress loop">
<progress></progress>
</div>
</div>
}
<h4 class="text-center my-15">Sasjs Deploy</h4>
<hr class="light" />
@@ -32,8 +34,8 @@
class="btn btn-sm btn-outline"
>
Browse
</button>
<input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> -->
</button>
<input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> -->
</div>
<p class="m-0 mt-10">
@@ -45,12 +47,11 @@
<label class="mt-20 clr-control-label">Data Controller Admin group</label>
<clr-select-container class="mb-10 mt-0 w-50vw">
<select [(ngModel)]="dcAdminGroup" clrSelect>
<option
*ngFor="let adminGroup of dcAdminGroupList"
[value]="adminGroup.GROUPNAME"
>
@for (adminGroup of dcAdminGroupList; track adminGroup) {
<option [value]="adminGroup.GROUPNAME">
{{ adminGroup.GROUPNAME }} - {{ adminGroup.GROUPDESC }}
</option>
}
</select>
</clr-select-container>
@@ -84,6 +84,20 @@
status="warning"
></clr-icon>
<!-- SOFTREGEX: value doesn't match the expected pattern, but
submission is still allowed (unlike HARDREGEX, which is
surfaced via the existing invalid-data class below). -->
<clr-icon
*ngIf="
!currentRecordErrors.includes(colIndex) &&
currentRecordWarningCols.includes(col.key)
"
class="flex-unset position-absolute entry-input-left-offset"
shape="error-standard"
status="warning"
title="Value does not match the expected pattern"
></clr-icon>
<ng-container *ngSwitchCase="'numeric'">
<clr-input-container
*ngIf="
@@ -55,6 +55,7 @@ export class EditRecordComponent implements OnInit {
@Output() onPreviousRecord: EventEmitter<any> = new EventEmitter<any>()
public currentRecordInvalidCols: string[] = []
public currentRecordWarningCols: string[] = []
public generateEditRecordUrlLoading: boolean = false
public generatedRecordUrl: string | null = null
public addRecordUrl: string | null = null
@@ -120,9 +121,12 @@ export class EditRecordComponent implements OnInit {
*/
private revalidateRecordCol(colName: string, value: any) {
const colRules = this.currentRecordValidator?.getRule(colName)
this.validateRecordCol(colRules, value).then((valid: boolean) =>
this.updateValidationState(colName, valid)
)
this.updateWarningState(colName, value)
}
/**
@@ -221,6 +225,8 @@ export class EditRecordComponent implements OnInit {
this.tryAutoPopulateNotNull(event, colName, colRules, value)
}
})
this.updateWarningState(colName, value)
})
}
@@ -237,6 +243,22 @@ export class EditRecordComponent implements OnInit {
}
}
/**
* Updates the SOFTREGEX warning columns list — the modal's equivalent of
* makeRegexWarningRenderer in the grid (see DcValidator.failsSoftRegex).
*/
private updateWarningState(colName: string, value: any): void {
const failsSoftRegex =
this.currentRecordValidator?.failsSoftRegex(colName, value) ?? false
const index = this.currentRecordWarningCols.indexOf(colName)
if (!failsSoftRegex && index > -1) {
this.currentRecordWarningCols.splice(index, 1)
} else if (failsSoftRegex && index < 0) {
this.currentRecordWarningCols.push(colName)
}
}
/**
* Auto-populates NOTNULL default value when the field is empty and has a default
*/
@@ -264,6 +286,8 @@ export class EditRecordComponent implements OnInit {
this.validateRecordCol(colRules, defaultValue).then((isValid: boolean) => {
this.updateValidationState(colName, isValid)
})
this.updateWarningState(colName, defaultValue)
}
onNextRecordClick() {
@@ -1,7 +1,9 @@
<div>
<p class="m-0" *ngFor="let state of processedStates; let i = index">
@for (state of processedStates; track state; let i = $index) {
<p class="m-0">
{{ state }}
</p>
}
</div>
<div class="text-center">
<span class="spinner spinner-sm vertical-align-middle">Loading...</span>
+131 -119
View File
@@ -62,10 +62,8 @@
</clr-radio-container>
</div>
</div>
<div
*ngIf="!excelFileParsing && !uploadLoading"
class="clr-col-md-3 filterBtn"
>
@if (!excelFileParsing && !uploadLoading) {
<div class="clr-col-md-3 filterBtn">
<span class="filterBtn w-100">
<label
for="file-upload"
@@ -84,11 +82,10 @@
(change)="attachFile($event)"
/>
</div>
}
<div
*ngIf="!excelFileParsing && !uploadLoading"
class="clr-col-md-3 editBtn"
>
@if (!excelFileParsing && !uploadLoading) {
<div class="clr-col-md-3 editBtn">
<button
[disabled]="true"
class="btnView btn btn-sm btn-success profile-buttons w-100"
@@ -97,10 +94,11 @@
Upload
</button>
</div>
}
<div class="clr-col-md-auto">
@if (excelUploadState !== 'Validating') {
<button
*ngIf="excelUploadState !== 'Validating'"
type="button"
class="btn btn-primary mr-0"
(click)="showUploadModal = false"
@@ -108,19 +106,23 @@
>
Close
</button>
}
@if (excelUploadState === 'Validating-DQ') {
<button
*ngIf="excelUploadState === 'Validating-DQ'"
type="button"
class="btn btn-primary mr-0"
(click)="showUploadModal = false"
>
Skip
</button>
}
</div>
<div *ngIf="excelFileParsing" class="excel-parsing clr-col-md-12">
@if (excelFileParsing) {
<div class="excel-parsing clr-col-md-12">
<app-upload-stater #uploadStater></app-upload-stater>
</div>
}
</div>
</div>
</div>
@@ -130,8 +132,8 @@
</div>
</clr-modal>
@if (currentEditRecordIndex > -1) {
<app-edit-record
*ngIf="currentEditRecordIndex > -1"
[currentRecord]="currentEditRecord"
[recordAction]="recordAction"
[libds]="libds"
@@ -150,11 +152,14 @@
(onNextRecord)="onNextRecord()"
(onPreviousRecord)="onPreviousRecord()"
></app-edit-record>
}
<div #ht class="clr-flex-1">
<div *ngIf="!validationDone && submit" class="progresStatic progress loop">
@if (!validationDone && submit) {
<div class="progresStatic progress loop">
<progress></progress>
</div>
}
<div
appDragNdrop
@@ -164,10 +169,8 @@
<div
class="card-header clr-row buttonBar headerBar clr-flex-md-row clr-justify-content-center clr-justify-content-lg-end"
>
<div
*ngIf="tableTrue && !embed"
class="clr-col-12 clr-col-md-3 clr-col-lg-4 backBtn"
>
@if (tableTrue && !embed) {
<div class="clr-col-12 clr-col-md-3 clr-col-lg-4 backBtn">
<span
class="btn icon-collapse btn-sm btn-icon btn-dimmed"
[routerLink]="['/home']"
@@ -192,6 +195,7 @@
<span class="text">Viewboxes</span>
</span>
</div>
}
<div
class="clr-col-12 clr-col-md-5 clr-col-lg-4 d-flex flex-column align-items-center"
@@ -214,12 +218,13 @@
size="24"
></clr-icon>
@if (libdsParsed.tableName.includes('-FC')) {
<clr-icon
*ngIf="libdsParsed.tableName.includes('-FC')"
aria-hidden="true"
shape="bolt"
class="color-yellow"
></clr-icon>
}
<span clrTooltipTrigger>
{{ libdsParsed.libName }}.<a
@@ -229,7 +234,7 @@
>
</span>
<ng-container *ngIf="this.dsNote && this.dsNote.length > 0">
@if (this.dsNote && this.dsNote.length > 0) {
<clr-tooltip-content
clrPosition="bottom-left"
clrSize="lg"
@@ -237,27 +242,26 @@
>
{{ this.dsNote }}
</clr-tooltip-content>
</ng-container>
}
</clr-tooltip>
<ng-container *ngIf="dataSource">
<ng-container *ngIf="!zeroFilterRows">
@if (dataSource) {
@if (!zeroFilterRows) {
({{ dataSource.length | thousandSeparator: ',' }}
{{ dataSource.length === 1 ? 'row' : 'rows' }}, {{ cols.length
}}{{ cols.length === 1 ? ' col' : ' cols' }})
</ng-container>
<ng-container *ngIf="zeroFilterRows"> (0 rows) </ng-container>
</ng-container>
}
@if (zeroFilterRows) {
(0 rows)
}
}
</h4>
</div>
<div
*ngIf="tableTrue"
class="clr-col-12 clr-col-md-4 clr-col-lg-4 btnCtrl"
>
<ng-container *ngIf="hotTable.readOnly && !uploadPreview">
@if (tableTrue) {
<div class="clr-col-12 clr-col-md-4 clr-col-lg-4 btnCtrl">
@if (hotTable.readOnly && !uploadPreview) {
@if (!isVaEmbed) {
<button
*ngIf="!isVaEmbed"
type="button"
class="btnView btn icon-collapse btn-sm btn-icon btn-block btn-dimmed"
(click)="openQb()"
@@ -265,7 +269,7 @@
<clr-icon aria-hidden="true" shape="filter"></clr-icon>
<span class="text">Filter</span>
</button>
}
<button
type="button"
class="btn icon-collapse btn-sm btn-primary btn-block"
@@ -274,9 +278,8 @@
<clr-icon aria-hidden="true" shape="note"></clr-icon>
<span class="text">Edit</span>
</button>
@if (!columnLevelSecurityFlag) {
<button
*ngIf="!columnLevelSecurityFlag"
(click)="onShowUploadModal()"
type="button"
class="btn icon-collapse btn-sm btn-success btn-block mr-0"
@@ -284,9 +287,9 @@
<clr-icon aria-hidden="true" shape="upload"></clr-icon>
<span class="text">Upload</span>
</button>
</ng-container>
<ng-container *ngIf="!hotTable.readOnly && !uploadPreview">
}
}
@if (!hotTable.readOnly && !uploadPreview) {
<button
type="button"
class="btn btn-sm btn-icon btn-outline-danger"
@@ -295,7 +298,6 @@
<clr-icon aria-hidden="true" shape="times"></clr-icon>
<span>Cancel</span>
</button>
<clr-tooltip>
<button
clrTooltipTrigger
@@ -308,13 +310,13 @@
<clr-icon aria-hidden="true" shape="plus" size="16"></clr-icon
>Add Row
</button>
<clr-tooltip-content
clrPosition="bottom-left"
clrSize="lg"
*clrIfOpen
>
<span *ngIf="restrictions.restrictAddRow"
@if (restrictions.restrictAddRow) {
<span
>To unlock more than
{{ licenceState.value.editor_rows_allowed }}
{{
@@ -323,9 +325,9 @@
: 'rows'
}}, contact support&#64;datacontroller.io</span
>
}
</clr-tooltip-content>
</clr-tooltip>
<button
type="button"
class="btn btn-sm btn-primary"
@@ -334,9 +336,8 @@
<clr-icon aria-hidden="true" shape="check" size="20"></clr-icon
>Submit
</button>
</ng-container>
<ng-container *ngIf="uploadPreview">
}
@if (uploadPreview) {
<button
type="button"
class="btn btn-sm btn-icon btn-outline-danger btn-upload-preview"
@@ -345,9 +346,8 @@
<clr-icon aria-hidden="true" shape="times"></clr-icon>
<span>Discard file</span>
</button>
@if (hotTable.readOnly) {
<button
*ngIf="hotTable.readOnly"
type="button"
class="btn btn-sm btn-primary btn-upload-preview"
(click)="manualFileEditModal = true"
@@ -355,7 +355,7 @@
<clr-icon aria-hidden="true" shape="note"></clr-icon>
<span>Edit</span>
</button>
}
<button
type="button"
class="btn btn-sm btn-success preview-submit"
@@ -365,35 +365,34 @@
<clr-icon aria-hidden="true" shape="check" size="20"></clr-icon>
Submit
</button>
</ng-container>
}
</div>
<div
*ngIf="!['', ' '].includes(queryText)"
class="clr-col-md-12 infoBar"
>
}
@if (!['', ' '].includes(queryText)) {
<div class="clr-col-md-12 infoBar">
<span
>FILTER :
<b>{{ queryText }}</b>
</span>
</div>
}
</div>
<div
class="card-block p-0 clr-flex-1 background-transparent"
[ngClass]="{ headerBarFilter: !['', ' '].includes(queryText) }"
>
<div *ngIf="!tableTrue" class="card-text">
@if (!tableTrue) {
<div class="card-text">
<div
class="h-70vh d-flex justify-content-center flex-column align-items-center"
>
<ng-container *ngIf="!getdataError">
@if (!getdataError) {
<span class="spinner"> Loading... </span>
<div class="mt-10">
<p cds-text="section">Loading table</p>
</div>
</ng-container>
<ng-container *ngIf="getdataError">
}
@if (getdataError) {
<span>
<clr-icon
aria-hidden="true"
@@ -401,13 +400,13 @@
class="error-icon"
></clr-icon>
</span>
<div class="mt-10">
<p cds-text="section">Loading table error</p>
</div>
</ng-container>
}
</div>
</div>
}
<div
class="card-text hotEditor h-100 d-flex clr-flex-column background-transparent"
@@ -435,11 +434,10 @@
</div>
<div>
<clr-tooltip
*ngIf="
@if (
tableTrue && !restrictions.removeAddRecordButton && !isVaEmbed
"
>
) {
<clr-tooltip>
<button
clrTooltipTrigger
type="button"
@@ -449,16 +447,20 @@
!restrictions.restrictAddRow ? addRecordButtonClick() : ''
"
>
<clr-icon aria-hidden="true" shape="plus" size="16"></clr-icon>
<clr-icon
aria-hidden="true"
shape="plus"
size="16"
></clr-icon>
Add Record
</button>
<clr-tooltip-content
clrPosition="top-right"
clrSize="lg"
*clrIfOpen
>
<span *ngIf="restrictions.restrictAddRow"
@if (restrictions.restrictAddRow) {
<span
>To unlock more than
{{ licenceState.value.editor_rows_allowed }}
{{
@@ -467,19 +469,21 @@
: 'rows'
}}, contact support&#64;datacontroller.io</span
>
}
</clr-tooltip-content>
</clr-tooltip>
}
<!-- VA data-driven content mode: filter controls only. Edit/Submit
use the normal top toolbar (same as embed=true). VA opens
read-only and filters live with the report; filtering
repopulates the grid, so it is held while editing (change shown
pending) and resumes on return to read-only. -->
<ng-container *ngIf="isVaEmbed">
@if (isVaEmbed) {
<div class="va-filter-controls w-100 mt-2-i">
<!-- Read-only: auto-apply toggle + manual Apply (confirm mode).
Hidden while editing (filtering is held). -->
<ng-container *ngIf="hotTable.readOnly">
@if (hotTable.readOnly) {
<label class="va-toggle">
<input
type="checkbox"
@@ -488,9 +492,8 @@
/>
Auto-apply VA filters
</label>
@if (!vaAutoApply) {
<button
*ngIf="!vaAutoApply"
[disabled]="
vaFilterStatus !== 'pending' &&
vaFilterStatus !== 'loading'
@@ -501,52 +504,51 @@
>
Apply filter
</button>
</ng-container>
}
}
<!-- Edit mode: filtering is held so edits aren't wiped. -->
<span *ngIf="!hotTable.readOnly" class="va-disabled-text">
@if (!hotTable.readOnly) {
<span class="va-disabled-text">
VA filtering paused while editing
</span>
}
<!-- Persistent live region (shown in both modes) so a filter
change that arrives while editing is announced as pending. -->
<span class="va-filter-status" role="status" aria-live="polite">
<span
*ngIf="vaFilterStatus === 'pending'"
class="va-pending-text"
>
Filter change pending
</span>
<span
*ngIf="vaFilterStatus === 'loading'"
class="va-loading-text"
>
Loading filter…
</span>
@if (vaFilterStatus === 'pending') {
<span class="va-pending-text"> Filter change pending </span>
}
@if (vaFilterStatus === 'loading') {
<span class="va-loading-text"> Loading filter… </span>
}
</span>
</div>
</ng-container>
}
<p
*ngIf="
@if (
licenceState.value.editor_rows_allowed !== Infinity &&
dataSource &&
dataSource.length > licenceState.value.editor_rows_allowed
"
class="mt-2-i w-100 text-center"
>
) {
<p class="mt-2-i w-100 text-center">
To display more than
{{ licenceState.value.editor_rows_allowed }} rows, contact
<contact-link />
</p>
}
</div>
<!--<hr>
<hot-table hotId="hotInstance" id="hotTable">
</hot-table>-->
<div class="modal" *ngIf="submit">
@if (submit) {
<div class="modal">
<!-- && validationDone should be in constraint, but validation is not correct on front end so it is comment -->
<!-- TODO: fix validation in line above -->
<div class="modal-dialog modal-lg" role="dialog" aria-hidden="true">
<div
class="modal-dialog modal-lg"
role="dialog"
aria-hidden="true"
>
<div class="modal-content">
<div class="modal-header">
<button aria-label="Close" class="close" type="button">
@@ -559,12 +561,11 @@
</h3>
</div>
<div class="modal-body">
<span
*ngIf="
@if (
totalRowsChanged > licenceState.value.submit_rows_limit &&
licenceState.value.submit_rows_limit !== Infinity
"
class="d-block w-100 text-center color-red"
) {
<span class="d-block w-100 text-center color-red"
>Only
{{ licenceState.value.submit_rows_limit }}
{{
@@ -575,7 +576,9 @@
will be submitted. To remove the restriction, contact
support&#64;datacontroller.io</span
>
<div *ngIf="tableTrue" class="clr-offset-md-2 clr-col-md-8">
}
@if (tableTrue) {
<div class="clr-offset-md-2 clr-col-md-8">
<div class="text-area-full-width">
<label for="formFields_8" class="mb-5 d-block"
>Message</label
@@ -606,6 +609,7 @@
</div>
</div> -->
</div>
}
</div>
<div class="modal-footer justify-content-center-i">
<button
@@ -623,7 +627,9 @@
type="button"
class="btn btn-sm btn-outline"
tabindex="0"
(click)="cancelSubmit(); submit = false; validationDone = 0"
(click)="
cancelSubmit(); submit = false; validationDone = 0
"
>
Cancel
</button>
@@ -631,6 +637,7 @@
</div>
</div>
</div>
}
<clr-modal [(clrModalOpen)]="submitLimitNotice">
<h3 class="modal-title">Notice</h3>
@@ -714,18 +721,19 @@
Duplicate values cannot be submitted for Primary Key columns
</p>
<p>Duplicates found:</p>
<span
class="d-block"
*ngFor="
let duplicatePkIndex of duplicatePkIndexes;
let i = index
"
>
@for (
duplicatePkIndex of duplicatePkIndexes;
track duplicatePkIndex;
let i = $index
) {
<span class="d-block">
On row {{ duplicatePkIndex + 1 }}:
{{
pkFields[duplicatePkIndex].replace('|', '') | pkSpaceSeparate
pkFields[duplicatePkIndex].replace('|', '')
| pkSpaceSeparate
}}
</span>
}
</div>
<div class="modal-footer">
<button
@@ -743,9 +751,9 @@
<p>
The following columns have values that exceed length limits:
</p>
<p *ngFor="let cell of exceedCells">
{{ cell.col }}({{ cell.len }}), {{ cell.val }}
</p>
@for (cell of exceedCells; track cell) {
<p>{{ cell.col }}({{ cell.len }}), {{ cell.val }}</p>
}
</div>
<div class="modal-footer">
<button
@@ -765,7 +773,8 @@
</div>
</main>
<div class="modal z-index-highest" *ngIf="nullVariables">
@if (nullVariables) {
<div class="modal z-index-highest">
<div class="modal-dialog" role="dialog" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
@@ -788,7 +797,8 @@
</div>
</div>
</div>
</div>
</div>
}
<clr-modal
[(clrModalOpen)]="filter"
@@ -801,7 +811,9 @@
<span> {{ libds }} </span>
</h3>
<div class="modal-body">
<app-query #queryFilter *ngIf="filter"></app-query>
@if (filter) {
<app-query #queryFilter></app-query>
}
</div>
<div class="modal-footer">
<button (click)="resetFilter()" type="button" class="btn btn-sm btn-link">
@@ -889,9 +901,9 @@
/>
</div>
<div class="modal-footer">
<p *ngIf="fileUnlockError" class="m-0 color-red">
Sorry that didn't work, try again.
</p>
@if (fileUnlockError) {
<p class="m-0 color-red">Sorry that didn't work, try again.</p>
}
<button
type="button"
class="btn btn-sm btn-outline"
+17 -1
View File
@@ -3254,7 +3254,15 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
colName = this.hotInstance?.colToProp(selectedCol) as string
colInfo = this.$dataFormats?.vars[colName]
textInfo = buildColInfoHtml(colName, colInfo)
const { hardRegexValue, softRegexValue } =
this.dcValidator?.getRegexRuleValues(colName) || {}
textInfo = buildColInfoHtml(
colName,
colInfo,
hardRegexValue,
softRegexValue
)
}
elem.innerHTML = textInfo
@@ -3455,6 +3463,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (cellMeta && cellMeta.filter === false)
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)
}
})
@@ -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
}
}
+56 -33
View File
@@ -1,6 +1,7 @@
<app-sidebar class="sidebar-height">
<clr-tree>
<clr-tree-node *ngIf="groups" class="search-node">
@if (groups) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -12,22 +13,23 @@
(keyup)="groupListOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="groupSearch = ''; groupListOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
<ng-container *ngFor="let group of groups">
@for (group of groups; track group) {
@if (!group['hidden']) {
<clr-tree-node
(click)="groupOnClick(group)"
*ngIf="!group['hidden']"
[class.active]="group.GROUPURI === groupUri"
>
<p class="m-0 cursor-pointer list-padding">
@@ -35,16 +37,21 @@
{{ group.GROUPNAME }}
</p>
</clr-tree-node>
</ng-container>
}
}
</clr-tree>
</app-sidebar>
<div class="content-area">
<div *ngIf="loading" class="loadingSpinner">
@if (loading) {
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
<div *ngIf="groupMembers && !loading">
<div *ngIf="serverType !== ServerType.SasViya" class="clr-row">
}
@if (groupMembers && !loading) {
<div>
@if (serverType !== ServerType.SasViya) {
<div class="clr-row">
<div class="clr-col-8">
<table class="table group-info">
<tbody>
@@ -64,60 +71,70 @@
</table>
</div>
</div>
}
<div class="clr-row">
<div class="clr-col-8">
<div class="card group-data">
<div *ngIf="serverType !== ServerType.SasViya">
@if (serverType !== ServerType.SasViya) {
<div>
<h3>MEMBERS ({{ groupMemberCount }})</h3>
<h5 *ngIf="groupMemberCount == 0">No Members Present</h5>
@if (groupMemberCount == 0) {
<h5>No Members Present</h5>
}
<div class="table-container">
<table *ngIf="groupMemberCount != 0" class="table member-table">
@if (groupMemberCount != 0) {
<table class="table member-table">
<thead>
<tr>
<ng-container *ngIf="serverType === ServerType.Sas9">
@if (serverType === ServerType.Sas9) {
<td class="width-25"><b>NAME</b></td>
<td class="width-25"><b>EMAIL</b></td>
<td class="width-25"><b>CREATED</b></td>
<td class=""><b>UPDATED</b></td>
</ng-container>
<ng-container *ngIf="serverType === ServerType.Sasjs">
}
@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>
</ng-container>
}
</tr>
</thead>
<tbody>
@for (member of groupMembers; track member) {
<tr
[routerLink]="
'/view/usernav/users/' + (member.URIMEM || member.ID)
'/view/usernav/users/' +
(member.URIMEM || member.ID)
"
*ngFor="let member of groupMembers"
>
<ng-container *ngIf="serverType === ServerType.Sas9">
@if (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">
}
@if (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 *ngIf="serverType === ServerType.SasViya">
}
@if (serverType === ServerType.SasViya) {
<div>
<h3>{{ groupName }}</h3>
<h5 *ngIf="groupMemberCount == 0">No Members Present</h5>
@if (groupMemberCount == 0) {
<h5>No Members Present</h5>
}
<div class="table-container">
<table *ngIf="groupMemberCount != 0" class="table member-table">
@if (groupMemberCount != 0) {
<table class="table member-table">
<thead>
<tr>
<td>
@@ -126,19 +143,25 @@
</tr>
</thead>
<tbody>
@for (member of groupMembers; track member) {
<tr
[routerLink]="'/view/usernav/users/' + member.MEMBERID"
*ngFor="let member of groupMembers"
[routerLink]="
'/view/usernav/users/' + member.MEMBERID
"
>
<td class="">{{ member.MEMBERNAME }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
<br />
</div>
</div>
</div>
</div>
}
</div>
+51 -52
View File
@@ -1,6 +1,7 @@
<app-sidebar>
<clr-tree>
<clr-tree-node *ngIf="treeNodeLibraries?.length! > 0" class="search-node">
@if (treeNodeLibraries?.length! > 0) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
clrInput
@@ -12,25 +13,24 @@
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search" aria-hidden="true"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
aria-hidden="true"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
aria-label="Clear libraries search"
></clr-icon>
}
</div>
</clr-tree-node>
}
<ng-container *ngFor="let library of treeNodeLibraries">
@for (library of treeNodeLibraries; track library) {
@if (!library['hidden']) {
<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']"
@@ -45,8 +45,8 @@
<clr-icon shape="rack-server" aria-hidden="true"></clr-icon>
{{ library.LIBRARYREF }}
</p>
<clr-tree-node *ngIf="library['tables']" class="search-node">
@if (library['tables']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -58,14 +58,11 @@
(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
*ngIf="searchTreeInput.value.length < 1"
shape="search"
aria-hidden="true"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
@@ -74,12 +71,16 @@
shape="times"
aria-label="Clear tables search"
></clr-icon>
}
</div>
</clr-tree-node>
<clr-tree-node
*ngFor="let libTable of library['tables']; let index = index"
>
}
@for (
libTable of library['tables'];
track libTable;
let index = $index
) {
<clr-tree-node>
<clr-tooltip
*ngVar="
index + 1 >
@@ -93,67 +94,65 @@
[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>
@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>
<ng-container *ngIf="tableLocked">
@if (tableLocked) {
<clr-tooltip-content
clrPosition="bottom-right"
clrSize="lg"
*clrIfOpen
>
<span>
To unlock all tables, contact support&#64;datacontroller.io
To unlock all tables, contact
support&#64;datacontroller.io
</span>
</clr-tooltip-content>
</ng-container>
}
</clr-tooltip>
</clr-tree-node>
}
</clr-tree-node>
</ng-container>
}
}
</clr-tree>
</app-sidebar>
<main class="content-area">
<div class="card-block">
<div *ngIf="loading" class="spinner-wrapper-fullpage">
@if (loading) {
<div class="spinner-wrapper-fullpage">
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
</div>
}
<div *ngIf="!loading" class="no-table-selected">
@if (!loading) {
<div class="no-table-selected">
<img
src="images/select-table.png"
class="select-table-icon"
alt="select table icon"
/>
<p
*ngIf="treeNodeLibraries?.length! > 0"
class="text-center color-gray mt-10"
cds-text="section"
>
@if (treeNodeLibraries?.length! > 0) {
<p class="text-center color-gray mt-10" cds-text="section">
Please select a table
</p>
<p
*ngIf="treeNodeLibraries?.length! < 1"
class="text-center color-gray mt-10"
cds-text="section"
>
}
@if (treeNodeLibraries?.length! < 1) {
<p class="text-center color-gray mt-10" cds-text="section">
No Editable Tables Configured
</p>
}
</div>
}
</div>
</main>
+77 -68
View File
@@ -1,6 +1,7 @@
<app-sidebar (scrolledToBottom)="loadMoreLibraries()">
<clr-tree>
<clr-tree-node *ngIf="libraryList" class="search-node">
@if (libraryList) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -12,35 +13,38 @@
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
<ng-container *ngFor="let library of libraryList">
@for (library of libraryList; track library) {
@if (!library['hidden'] && library['inForeground']) {
<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)"
(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">
@if (library['tables']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -52,12 +56,11 @@
(keyup)="treeOnFilter(library, 'tables.TABLENAME')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
@@ -65,12 +68,13 @@
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (libTable of library['tables']; track libTable) {
<clr-tree-node
(click)="treeNodeClicked($event, libTable, library['tables'])"
*ngFor="let libTable of library['tables']"
[(clrExpanded)]="libTable['expanded']"
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
[class.clr-expanded]="libTable['expanded']"
@@ -83,8 +87,8 @@
<clr-icon shape="table"></clr-icon>
{{ libTable.TABLENAME }}
</p>
<clr-tree-node *ngIf="libTable['columns']" class="search-node">
@if (libTable['columns']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -96,12 +100,11 @@
(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
*ngIf="searchTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
libTable['searchString'] = '';
@@ -109,33 +112,40 @@
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
<clr-tree-node *ngFor="let libColumn of libTable['columns']">
}
@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>
</ng-container>
}
}
</clr-tree>
<div *ngIf="librariesPaging" class="w-100 text-center">
@if (librariesPaging) {
<div class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div>
}
</app-sidebar>
<div class="content-area">
<div class="card">
<div *ngIf="!column && !table" class="no-table-selected">
@if (!column && !table) {
<div class="no-table-selected">
<clr-icon
shape="warning-standard"
size="60"
@@ -145,24 +155,22 @@
Please select a column or table
</p>
</div>
}
<ng-container *ngIf="column || table">
<div
*ngIf="!graphContainer"
class="card-header d-flex flex-column justify-content-center"
>
<h3
*ngIf="!currentLineagePathColumn && !currentLineagePathLibTable"
class="text-center pb-10"
>
@if (column || table) {
@if (!graphContainer) {
<div class="card-header d-flex flex-column justify-content-center">
@if (!currentLineagePathColumn && !currentLineagePathLibTable) {
<h3 class="text-center pb-10">
{{
currentLineagePathColumn
? currentLineagePathLibTable + '.' + currentLineagePathColumn
: currentLineagePathLibTable
}}
</h3>
}
</div>
}
<div class="card-block">
<section
class="form-block sw position-relative d-flex align-items-center"
@@ -170,18 +178,19 @@
<div
class="linage-title-wrapper d-flex align-items-center font-weight-bold position-absolute"
>
<span *ngIf="lineageTableName.length > 0">
@if (lineageTableName.length > 0) {
<span>
{{ lineageTableName.split('.')[0] }}.<a
[routerLink]="'/view/data/' + lineageTableName"
>{{ lineageTableName.split('.')[1] }}</a
>{{ lineageColumnName.length > 0 ? '.' + lineageColumnName : '' }}
>{{
lineageColumnName.length > 0 ? '.' + lineageColumnName : ''
}}
</span>
}
</div>
<div
*ngIf="graphContainer"
class="clr-col-md-12 text-center d-flex justify-content-end"
>
@if (graphContainer) {
<div class="clr-col-md-12 text-center d-flex justify-content-end">
<button
(click)="limitDotDepth = true"
type="button"
@@ -189,7 +198,6 @@
>
Limit depth
</button>
<!-- <button class="btn btn-outline" (click)='showSvg()'> Open in New Tab </button> -->
<div class="btn-group direction d-block">
<div
@@ -208,7 +216,6 @@
/>
<label>Backward</label>
</div>
<div
class="radio btn"
(click)="
@@ -226,7 +233,6 @@
<label>Forward</label>
</div>
</div>
<clr-dropdown class="mr-10">
<button
class="btn btn-info-outline"
@@ -236,21 +242,17 @@
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>
<div *ngIf="flatdata" (click)="downloadCSV()" clrDropdownItem>
CSV
</div>
@if (flatdata) {
<div (click)="downloadCSV()" clrDropdownItem>CSV</div>
}
</clr-dropdown-menu>
</clr-dropdown>
<clr-checkbox-wrapper
*ngIf="column"
class="d-flex align-items-center"
>
@if (column) {
<clr-checkbox-wrapper class="d-flex align-items-center">
<input
type="checkbox"
(change)="generateGraph()"
@@ -260,24 +262,31 @@
/>
<label>Refresh Cache</label>
</clr-checkbox-wrapper>
}
</div>
}
</section>
<div *ngIf="graphContainer" [class.mt-2]="tableFlag">
@if (graphContainer) {
<div [class.mt-2]="tableFlag">
<div class="text-center">
<span *ngIf="graphLoading" class="spinner"> Loading... </span>
<div *ngIf="!graphLoading" class="position-relative">
@if (graphLoading) {
<span class="spinner"> Loading... </span>
}
@if (!graphLoading) {
<div class="position-relative">
<div class="graph-render-spinner">
<span *ngIf="graphRendering" class="spinner spinner-sm"></span>
@if (graphRendering) {
<span class="spinner spinner-sm"></span>
}
</div>
<div id="graph"></div>
</div>
}
</div>
</div>
}
</div>
</ng-container>
}
</div>
</div>
+81 -51
View File
@@ -8,18 +8,18 @@
[(ngModel)]="repository"
(change)="updateSelectedRepository()"
>
<option
*ngFor="let repository of repositories"
value="{{ repository }}"
>
@for (repository of repositories; track repository) {
<option value="{{ repository }}">
{{ repository }}
</option>
}
</select>
</clr-select-container>
</div>
<clr-tree>
<clr-tree-node *ngIf="metaDataList" class="search-node">
@if (metaDataList) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -31,21 +31,22 @@
(keyup)="metaListOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="metaDataSearch = ''; metaListOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
<ng-container *ngFor="let metaData of metaDataList">
}
@for (metaData of metaDataList; track metaData) {
@if (!metaData['hidden']) {
<clr-tree-node
(click)="treeNodeClicked($event, metaData, metaDataList)"
*ngIf="!metaData['hidden']"
[(clrExpanded)]="metaData['expanded']"
[clrLoading]="metaData['loadingTables'] && !metaData.tables"
>
@@ -59,7 +60,8 @@
{{ metaData.ID }}
</p>
</clr-tree-node>
</ng-container>
}
}
</clr-tree>
</app-sidebar>
@@ -67,7 +69,8 @@
<div class="card background-transparent-i">
<h3 class="color-gray">{{ assoTypeSelected }}</h3>
<div *ngIf="!loading && !metaObjectList" class="no-table-selected">
@if (!loading && !metaObjectList) {
<div class="no-table-selected">
<clr-icon
shape="warning-standard"
size="60"
@@ -77,17 +80,28 @@
Please select a type
</p>
</div>
}
<div class="loadingSpinner" *ngIf="loading">
@if (loading) {
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
<div *ngIf="loading">
<h4 *ngIf="metatypesLoading">Loading metadata types</h4>
<h4 *ngIf="!metatypesLoading">Loading metadata objects</h4>
@if (loading) {
<div>
@if (metatypesLoading) {
<h4>Loading metadata types</h4>
}
@if (!metatypesLoading) {
<h4>Loading metadata objects</h4>
}
</div>
}
</div>
<div *ngIf="showData" class="clr-row clr-flex-grow-1">
}
@if (showData) {
<div class="clr-row clr-flex-grow-1">
<div class="clr-col-6">
<div *ngIf="metaObjectList && !objectRoute" class="search-input">
@if (metaObjectList && !objectRoute) {
<div class="search-input">
<input
clrInput
#searchObjTreeInput
@@ -98,15 +112,17 @@
autocomplete="off"
/>
</div>
}
<br />
<div *ngIf="!objectView" class="objects-col">
@if (!objectView) {
<div class="objects-col">
<clr-accordion>
<ng-container *ngFor="let metaObject of metaObjectShowList">
@for (metaObject of metaObjectShowList; track metaObject) {
@if (!metaObject['hidden']) {
<clr-accordion-panel
(clrAccordionPanelOpenChange)="
$event ? panelChange($event, metaObject) : ''
"
*ngIf="!metaObject['hidden']"
>
<clr-accordion-title
><clr-icon shape="rack-server"></clr-icon>
@@ -114,7 +130,8 @@
<p class="float-right">{{ metaObject.ID }}</p>
</clr-accordion-title>
<clr-accordion-content *clrIfExpanded>
<clr-tree *ngIf="showAcc" [clrLazy]="true">
@if (showAcc) {
<clr-tree [clrLazy]="true">
<clr-tree-node
*clrRecursiveFor="
let entry of root$ | async;
@@ -127,31 +144,33 @@
class="full-width"
>
<div>
<clr-icon
*ngIf="!entry.count"
shape="rack-server"
></clr-icon>
<clr-icon
*ngIf="entry.count"
shape="block"
></clr-icon>
@if (!entry.count) {
<clr-icon shape="rack-server"></clr-icon>
}
@if (entry.count) {
<clr-icon shape="block"></clr-icon>
}
{{ entry.display }}
</div>
<p class="float-right object-uri" *ngIf="!entry.count">
@if (!entry.count) {
<p class="float-right object-uri">
{{ entry.URI }}
</p>
}
</div>
</clr-tree-node>
</clr-tree>
}
</clr-accordion-content>
</clr-accordion-panel>
</ng-container>
}
}
</clr-accordion>
</div>
<div *ngIf="objectView" class="objects-col">
<ng-container *ngFor="let metaObject of metaObjectShowList">
}
@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">
@@ -163,7 +182,8 @@
</p>
</div>
</div>
<clr-tree *ngIf="showAcc" [clrLazy]="true">
@if (showAcc) {
<clr-tree [clrLazy]="true">
<clr-tree-node
*clrRecursiveFor="
let entry of root$ | async;
@@ -171,29 +191,36 @@
"
[clrExpandable]="true"
>
<div [class.object-header]="!entry.count" class="full-width">
<div
[class.object-header]="!entry.count"
class="full-width"
>
<div>
<clr-icon
*ngIf="!entry.count"
shape="rack-server"
></clr-icon>
<clr-icon *ngIf="entry.count" shape="block"></clr-icon>
@if (!entry.count) {
<clr-icon shape="rack-server"></clr-icon>
}
@if (entry.count) {
<clr-icon shape="block"></clr-icon>
}
{{ entry.display }}
</div>
<p class="float-right object-uri" *ngIf="!entry.count">
@if (!entry.count) {
<p class="float-right object-uri">
{{ entry.URI }}
</p>
}
</div>
</clr-tree-node>
</clr-tree>
</ng-container>
}
}
</div>
}
</div>
<div class="clr-col-6 text-center">
<h3>{{ assoObjectSelected }}</h3>
<clr-datagrid class="datagrid-custom-footer" *ngIf="showTable">
@if (showTable) {
<clr-datagrid class="datagrid-custom-footer">
<clr-dg-column>
TYPE
<clr-dg-string-filter
@@ -224,12 +251,15 @@
<clr-dg-page-size [clrPageSizeOptions]="[10, 20, 50, 100]"
>Attributes per page</clr-dg-page-size
>
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
{{ pagination.firstItem + 1 }} -
{{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} Attributes
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
}
</div>
</div>
}
</div>
</div>
+2 -1
View File
@@ -14,7 +14,8 @@ export interface ColumnDetail {
LENGTH: number
NAME: string
TYPE: string
VARNUM: number
// No longer sent by viewdata.sas — optional for legacy responses.
VARNUM?: number
}
export interface Approver {
@@ -1,9 +1,12 @@
<app-sidebar>
<div *ngIf="datasetsLoading" class="my-10-mx-auto text-center">
@if (datasetsLoading) {
<div class="my-10-mx-auto text-center">
<clr-spinner clrMedium></clr-spinner>
</div>
}
<div *ngIf="!parsedDatasets.length" class="text-center mb-10">
@if (!parsedDatasets.length) {
<div class="text-center mb-10">
<button
(click)="fileUploadInput.click()"
id="browse-file"
@@ -21,9 +24,11 @@
multiple
/>
</div>
}
<ng-container *ngIf="parsedDatasets.length && !submittedCsvDatasets.length">
<div *ngIf="!excelsSubmitted" class="text-center mb-10">
@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>
@@ -35,83 +40,79 @@
Submit All
</button>
</div>
}
<p cds-text="caption" class="ml-10 mb-10">Found tables:</p>
<clr-tree>
<clr-tree-node *ngFor="let dataset of parsedDatasets">
@for (dataset of parsedDatasets; track dataset) {
<clr-tree-node>
<button
(click)="onParsedDatasetClick(dataset)"
class="clr-treenode-link whitespace-nowrap d-flex clr-align-items-center"
[class.active]="dataset.active"
>
<ng-container *ngIf="dataset.submitResult">
@if (dataset.submitResult) {
@if (dataset.submitResult.error) {
<cds-icon status="danger" shape="exclamation-circle"></cds-icon>
}
@if (dataset.submitResult.success) {
<cds-icon status="success" shape="check-circle"></cds-icon>
}
}
@if (!dataset.submitResult) {
@if (dataset.datasource) {
@if (!(dataset.datasource.length && dataset.parseResult)) {
<cds-icon
*ngIf="dataset.submitResult.error"
status="danger"
shape="exclamation-circle"
></cds-icon>
<cds-icon
*ngIf="dataset.submitResult.success"
status="success"
shape="check-circle"
></cds-icon>
</ng-container>
<ng-container *ngIf="!dataset.submitResult">
<ng-container *ngIf="dataset.datasource">
<cds-icon
*ngIf="!(dataset.datasource.length && dataset.parseResult)"
status="danger"
shape="exclamation-circle"
></cds-icon>
<cds-icon
*ngIf="dataset.datasource.length && dataset.parseResult"
shape="table"
></cds-icon>
</ng-container>
<ng-container *ngIf="!dataset.datasource">
<cds-icon *ngIf="!dataset.parsingTable" shape="table"></cds-icon>
<clr-spinner *ngIf="dataset.parsingTable" clrSmall></clr-spinner>
</ng-container>
</ng-container>
}
@if (dataset.datasource.length && dataset.parseResult) {
<cds-icon shape="table"></cds-icon>
}
}
@if (!dataset.datasource) {
@if (!dataset.parsingTable) {
<cds-icon shape="table"></cds-icon>
}
@if (dataset.parsingTable) {
<clr-spinner clrSmall></clr-spinner>
}
}
}
<span class="ml-5"> {{ dataset.libds }} </span>
</button>
</clr-tree-node>
}
</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>
<clr-tree>
<clr-tree-node *ngFor="let dataset of submittedCsvDatasets">
@for (dataset of submittedCsvDatasets; track dataset) {
<clr-tree-node>
<button
(click)="onSubmittedCsvDatasetClick(dataset)"
class="clr-treenode-link whitespace-nowrap"
[class.active]="dataset.active"
>
<cds-icon
*ngIf="dataset.error"
status="danger"
shape="exclamation-circle"
></cds-icon>
<cds-icon
*ngIf="dataset.success"
status="success"
shape="check-circle"
></cds-icon>
@if (dataset.error) {
<cds-icon status="danger" shape="exclamation-circle"></cds-icon>
}
@if (dataset.success) {
<cds-icon status="success" shape="check-circle"></cds-icon>
}
<cds-icon shape="table"></cds-icon>
{{ dataset.libds }}
</button>
</clr-tree-node>
}
</clr-tree>
</ng-container>
}
<!-- <div *ngIf="librariesPaging" class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div> -->
</div> -->
</app-sidebar>
<div #contentArea class="content-area">
@@ -122,10 +123,8 @@
<p cds-text="section">Multi Dataset Load</p>
</div>
<div
*ngIf="selectedFile === null && !submittingCsv"
class="no-table-selected pointer-events-none"
>
@if (selectedFile === null && !submittingCsv) {
<div class="no-table-selected pointer-events-none">
<clr-icon
aria-hidden="true"
shape="upload-cloud"
@@ -136,9 +135,10 @@
Please upload a file
</p>
</div>
}
<ng-container *ngIf="selectedFile !== null || submittingCsv">
<ng-container *ngIf="!parsedDatasets.length && selectedFile !== null">
@if (selectedFile !== null || submittingCsv) {
@if (!parsedDatasets.length && selectedFile !== null) {
<div class="d-flex clr-justify-content-center mt-15">
<div class="dataset-input-wrapper">
<p cds-text="secondary regular" class="mb-5">
@@ -160,12 +160,10 @@
<p cds-text="secondary regular" class="mb-15">
Paste or type the list of datasets to upload:
</p>
<clr-control-helper class="mb-5"
>Each row is one dataset. We will automatically detect tables by
the sheetname and populate if any.</clr-control-helper
>
<hot-table
#hotInstanceUserDataset
id="hotTableUserDataset"
@@ -173,7 +171,6 @@
[settings]="hotUserDatasetsSettings"
>
</hot-table>
<div class="dataset-selection-actions text-right mt-10">
<button
(click)="onStartParsingFile()"
@@ -187,29 +184,22 @@
</div>
</div>
</div>
</ng-container>
<ng-container
*ngIf="parsedDatasets.length && !submittedCsvDatasets.length"
>
<div
*ngIf="!activeParsedDataset"
class="no-table-selected pointer-events-none"
>
<ng-container *ngIf="fileLoadingState !== FileLoadingState.parsed">
}
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
@if (!activeParsedDataset) {
<div class="no-table-selected pointer-events-none">
@if (fileLoadingState !== FileLoadingState.parsed) {
<clr-icon
aria-hidden="true"
shape="process-on-vm"
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">
}
@if (fileLoadingState === FileLoadingState.parsed) {
<clr-icon
aria-hidden="true"
shape="warning-standard"
@@ -219,47 +209,38 @@
<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">
}
@if (activeParsedDataset) {
@if (activeParsedDataset.submitResult) {
<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
*ngIf="activeParsedDataset.submitResult?.error"
cds-text="secondary regular"
>
Error details:
@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
*ngIf="
!submittingCsv && activeParsedDataset.submitResult?.error
"
(click)="reSubmitTable(activeParsedDataset)"
class="btn btn-primary mt-10"
[clrLoading]="submitLoading"
>
Resubmit
</button>
}
<button
(click)="
downloadFile(
@@ -274,22 +255,19 @@
</button>
</div>
</div>
<div
*ngIf="activeParsedDataset.submitResult?.error"
class="error-field mt-15"
>
}
@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>
<p cds-text="secondary regular" class="mb-10">
Found in range:
<ng-container *ngIf="activeParsedDataset.parseResult">
@if (activeParsedDataset.parseResult) {
<strong
>"{{
activeParsedDataset.parseResult.rangeSheetRes?.sheetName
@@ -298,21 +276,18 @@
?.rangeAddress
}}</strong
>
</ng-container>
<ng-container *ngIf="!activeParsedDataset.parseResult">
<strong *ngIf="!activeParsedDataset.parsingTable"
>No data found</strong
>
<span
*ngIf="activeParsedDataset.parsingTable"
class="d-flex clr-align-items-center"
>
}
@if (!activeParsedDataset.parseResult) {
@if (!activeParsedDataset.parsingTable) {
<strong>No data found</strong>
}
@if (activeParsedDataset.parsingTable) {
<span class="d-flex clr-align-items-center">
<strong>Searching for the data...</strong>
<clr-spinner class="ml-5" clrSmall></clr-spinner>
</span>
</ng-container>
}
}
</p>
<p cds-text="secondary regular">
Dataset:
@@ -333,7 +308,6 @@
</strong>
</p>
</div>
<div>
<clr-toggle-wrapper>
<input
@@ -354,11 +328,11 @@
</clr-toggle-wrapper>
</div>
</div>
<div *ngIf="isHotHidden" class="text-center w-100">
@if (isHotHidden) {
<div class="text-center w-100">
<clr-spinner class="spinner-md"></clr-spinner>
</div>
}
<hot-table
#hotInstanceMain
id="hotTable"
@@ -366,14 +340,11 @@
[settings]="hotMainTableSettings"
>
</hot-table>
</ng-container>
</ng-container>
<ng-container *ngIf="submittedCsvDatasets.length">
<div
*ngIf="!activeSubmittedCsvDataset"
class="no-table-selected pointer-events-none"
>
}
}
@if (submittedCsvDatasets.length) {
@if (!activeSubmittedCsvDataset) {
<div class="no-table-selected pointer-events-none">
<clr-icon
aria-hidden="true"
shape="warning-standard"
@@ -384,9 +355,9 @@
Please select a dataset on the left to review the submit results
</p>
</div>
</ng-container>
<ng-container *ngIf="activeSubmittedCsvDataset">
}
}
@if (activeSubmittedCsvDataset) {
<div class="d-flex clr-justify-content-between p-10">
<div>
<p cds-text="secondary regular" class="mb-10">
@@ -409,23 +380,17 @@
</p>
<p cds-text="secondary regular" class="mb-10">
Status:
<span
*ngIf="activeSubmittedCsvDataset.success"
class="color-green"
><strong>SUCCESS</strong></span
>
<span *ngIf="activeSubmittedCsvDataset.error" class="color-red"
><strong>ERROR</strong></span
>
</p>
<p
*ngIf="activeSubmittedCsvDataset.error"
cds-text="secondary regular"
>
Error details:
@if (activeSubmittedCsvDataset.success) {
<span class="color-green"><strong>SUCCESS</strong></span>
}
@if (activeSubmittedCsvDataset.error) {
<span class="color-red"><strong>ERROR</strong></span>
}
</p>
@if (activeSubmittedCsvDataset.error) {
<p cds-text="secondary regular">Error details:</p>
}
</div>
<div>
<button
(click)="
@@ -440,14 +405,15 @@
</button>
</div>
</div>
<div *ngIf="activeSubmittedCsvDataset.error" class="error-field mt-15">
@if (activeSubmittedCsvDataset.error) {
<div class="error-field mt-15">
<div class="log-wrapper">
{{ activeSubmittedCsvDataset.error | json }}
</div>
</div>
</ng-container>
</ng-container>
}
}
}
<!-- <div>
<p
@@ -471,16 +437,14 @@
{{ tablesToSubmit.length === 1 ? 'table' : 'tables' }} for approval
</h3>
<div class="modal-body">
<p
*ngIf="licenceState.value.submit_rows_limit !== Infinity"
cds-text="body"
class="licence-limit-notice mt-0 mb-15"
>
@if (licenceState.value.submit_rows_limit !== Infinity) {
<p cds-text="body" class="licence-limit-notice mt-0 mb-15">
Due to current licence, only
{{ licenceState.value.submit_rows_limit }} rows in each file will be
submitted. To remove the restriction, contact
support&#64;datacontroller.io.
</p>
}
<div class="text-area-full-width">
<label for="formFields_8" class="mb-5 d-block">Message</label>
+91 -81
View File
@@ -30,20 +30,21 @@
(ngModelChange)="setGroupLogic(groupLogic)"
clrSelect
>
<option
*ngFor="let logic of logicOperators"
[selected]="logicOperators[0]"
>
@for (logic of logicOperators; track logic) {
<option [selected]="logicOperators[0]">
{{ logic }}
</option>
}
</select>
</clr-select-container>
</div>
<div class="clr-col-md-10 mb-30">
<pre class="line-numbers language-markup">
<div *ngIf="whereClauseLoading" class="progresStatic progress loop">
@if (whereClauseLoading) {
<div class="progresStatic progress loop">
<progress></progress>
</div>
}
<code class="language-sql">{{whereClause}}</code>
</pre>
@@ -55,66 +56,65 @@
class="clauses-container clr-col-md-12"
[class.clr-col-md-10]="clauses?.queryObj?.length > 1"
>
<div *ngIf="clauses?.queryObj?.length > 1"></div>
@if (clauses?.queryObj?.length > 1) {
<div></div>
}
<div
class="clause-row"
*ngFor="let clause of clauses.queryObj; let clauseIndex = index"
>
@for (clause of clauses.queryObj; track clause; let clauseIndex = $index) {
<div class="clause-row">
<div class="clr-row" [class.invalid-clause]="clause.invalidClause">
<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]"
>
@for (logic of logicOperators; track logic) {
<option [selected]="logicOperators[0]">
{{ logic }}
</option>
}
</select>
</clr-select-container>
</div>
<br />
@if (innerWidth > 768) {
<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">
@if (clauses.queryObj.length > 1) {
<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"
>
}
@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"
[id]="
'select_vals_var_id' + queryIndex + '_' + clauseIndex
"
[inputId]="'vals_var_id' + queryIndex + '_' + clauseIndex"
[emitOnlySelected]="true"
[(value)]="query.variable"
@@ -127,13 +127,14 @@
)
"
>
<option *ngFor="let column of cols">
@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>
@@ -141,15 +142,20 @@
<select
[(ngModel)]="query.operator"
(ngModelChange)="
setVariableOperator(queryIndex, query.operator, clauseIndex)
setVariableOperator(
queryIndex,
query.operator,
clauseIndex
)
"
clrSelect
>
<option *ngFor="let opr of query.operators">{{ opr }}</option>
@for (opr of query.operators; track opr) {
<option>{{ opr }}</option>
}
</select>
</clr-select-container>
</div>
<!-- VALUE -->
<div
*ngVar="
@@ -159,10 +165,8 @@
"
class="value-col form-group clr-col-md-3"
>
<div
*ngIf="query.operator === 'IN' || query.operator === 'NOT IN'"
class="checkbox-vals"
>
@if (query.operator === 'IN' || query.operator === 'NOT IN') {
<div class="checkbox-vals">
<button
(click)="
currentQueryIndex = queryIndex;
@@ -185,18 +189,16 @@
>
</ng-container>
</div>
<div
*ngIf="
}
@if (
query.operator !== 'BETWEEN' &&
query.operator !== 'IN' &&
query.operator !== 'NOT IN' &&
query.operator !== 'LIKE' &&
query.operator !== 'CONTAINS' &&
query.operator !== 'BEGINS_WITH'
"
class="single-field-vals"
>
) {
<div class="single-field-vals">
<ng-container
*ngTemplateOutlet="
isDateTime && usePickers ? picker : notPicker;
@@ -211,8 +213,9 @@
<!-- 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">
}
@if (query.operator === 'BETWEEN') {
<div class="range-vals">
<div class="from">
<ng-container
*ngTemplateOutlet="
@@ -230,7 +233,6 @@
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
</ng-container>
</div>
<div class="to">
<ng-container
*ngTemplateOutlet="
@@ -249,17 +251,14 @@
</ng-container>
</div>
</div>
<div
*ngIf="
}
@if (
query.operator === 'LIKE' ||
query.operator === 'BEGINS_WITH' ||
query.operator === 'CONTAINS'
"
class="contains-vals"
>
) {
<div class="contains-vals">
<label class="clr-control-label">Value</label>
<input
class="input-val"
type="text"
@@ -269,18 +268,19 @@
"
/>
</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"
[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)"
@@ -290,10 +290,12 @@
</button>
</div>
</div>
}
</div>
</div>
<br />
</div>
}
</div>
</div>
@@ -305,8 +307,8 @@
let-queryIndex="queryIndex"
let-clauseIndex="clauseIndex"
>
<ng-container [ngSwitch]="query.ddtype">
<ng-container *ngSwitchCase="'DATE'">
@switch (query.ddtype) {
@case ('DATE') {
<app-soft-select
label="Value"
type="date"
@@ -328,9 +330,8 @@
</option>
</ng-container> -->
</app-soft-select>
</ng-container>
<ng-container *ngSwitchCase="'DATETIME'">
}
@case ('DATETIME') {
<app-soft-select
label="Value"
type="date"
@@ -353,7 +354,6 @@
</option>
</ng-container> -->
</app-soft-select>
<app-soft-select
type="time"
[disableSoftselect]="true"
@@ -375,9 +375,8 @@
</option>
</ng-container> -->
</app-soft-select>
</ng-container>
<ng-container *ngSwitchCase="'TIME'">
}
@case ('TIME') {
<app-soft-select
label="Value"
type="time"
@@ -399,8 +398,8 @@
</option>
</ng-container> -->
</app-soft-select>
</ng-container>
</ng-container>
}
}
</ng-template>
<ng-template
@@ -425,19 +424,27 @@
onAutocompleteLoadingMore($event, query.variable, queryIndex, clauseIndex)
"
>
<div *ngIf="!query.valueVariable">
<option [value]="column.unformatted" *ngFor="let column of query.values">
@if (!query.valueVariable) {
<div>
@for (column of query.values; track column) {
<option [value]="column.unformatted">
{{ column.formatted.trim() }}
</option>
}
</div>
}
<div *ngIf="query.valueVariable">
<ng-container *ngFor="let column of cols">
<option [value]="column.NAME" *ngIf="column.TYPE === query.type">
@if (query.valueVariable) {
<div>
@for (column of cols; track column) {
@if (column.TYPE === query.type) {
<option [value]="column.NAME">
{{ column.NAME }}
</option>
</ng-container>
}
}
</div>
}
</app-soft-select>
</ng-template>
@@ -466,9 +473,11 @@
: false
"
>
<option [value]="column.formatted" *ngFor="let column of query.values">
@for (column of query.values; track column) {
<option [value]="column.formatted">
{{ column.formatted }}
</option>
}
</app-soft-select>
</ng-template>
@@ -487,15 +496,15 @@
>
<h3 class="modal-title">Select values</h3>
<div class="modal-body">
<h5 *ngIf="!isArr(query.value)" class="no-values">
No values available.
</h5>
@if (!isArr(query.value)) {
<h5 class="no-values">No values available.</h5>
}
<section class="form-block" *ngIf="isArr(query.value)">
@if (isArr(query.value)) {
<section class="form-block">
<clr-checkbox-container>
<clr-checkbox-wrapper
*ngFor="let column of query.values; let i = index"
>
@for (column of query.values; track column; let i = $index) {
<clr-checkbox-wrapper>
<input
type="checkbox"
clrCheckbox
@@ -504,13 +513,14 @@
setVariableValues($event, queryIndex, clauseIndex)
"
/>
<label>
{{ column.formatted }}
</label>
</clr-checkbox-wrapper>
}
</clr-checkbox-container>
</section>
}
</div>
<div class="modal-footer">
<button
+11 -37
View File
@@ -22,6 +22,7 @@ import { QueryDateTime } from './models/QueryDateTime.model'
import { isSpecialMissing } from '@sasjs/utils/input/validators'
import { get } from 'lodash-es'
import { OnLoadingMoreEvent } from '../shared/autocomplete/autocomplete.component'
import { getFilterObjPath } from './utils/getFilterObjPath'
registerLocaleData(localeEnGB)
@Component({
@@ -251,17 +252,7 @@ export class QueryComponent
public setToGlobals() {
if (!this.caching) return
let objPath = ''
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
if (this.viewboxId) {
objPath = `viewboxes.${this.viewboxId}`
} else {
objPath = 'editor'
}
} else if (globals.rootParam === 'view') {
objPath = 'viewer'
}
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
get(globals, objPath).filter.groupLogic = this.groupLogic
if (typeof this.whereClause === 'string') {
@@ -280,17 +271,7 @@ export class QueryComponent
public getFromGlobals() {
if (!this.caching) return
let objPath = ''
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
if (this.viewboxId) {
objPath = `viewboxes.${this.viewboxId}`
} else {
objPath = 'editor'
}
} else if (globals.rootParam === 'view') {
objPath = 'viewer'
}
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
if (get(globals, objPath).filter.cols.length > 0) {
this.cols = JSON.parse(JSON.stringify(get(globals, objPath).filter.cols))
@@ -1051,22 +1032,23 @@ export class QueryComponent
this.columnsSub = this.sasStoreService.columns.subscribe(
(response: any) => {
let cols = response.data.cols
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
this.cols = cols
let some = cols[0].NAME
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.viewer.filter.cols.length < 1) {
if (get(globals, objPath).filter.cols.length < 1) {
this.cols = cols
let some = cols[0].NAME
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) => {
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
if (globals.editor.filter.vals.length < 1) {
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
if (get(globals, objPath).filter.vals.length < 1) {
this.vals = res.vals
globals.editor.filter.vals = JSON.parse(JSON.stringify(res.vals))
}
}
if (globals.rootParam === 'view') {
if (globals.viewer.filter.vals.length < 1) {
this.vals = res.vals
globals.viewer.filter.vals = JSON.parse(JSON.stringify(res.vals))
}
get(globals, objPath).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 ''
}
@@ -9,19 +9,21 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let key of keysArray">
@for (key of keysArray; track key) {
<tr>
<td class="left">{{ key }}</td>
<td
*ngIf="key.includes('TABLE_ID')"
class="left link-it"
[routerLink]="'/stage/' + jsParams[key]"
>
@if (key.includes('TABLE_ID')) {
<td class="left link-it" [routerLink]="'/stage/' + jsParams[key]">
{{ jsParams[key] }}
</td>
<td *ngIf="!key.includes('TABLE_ID')" class="left">
}
@if (!key.includes('TABLE_ID')) {
<td class="left">
{{ jsParams[key] }}
</td>
}
</tr>
}
</tbody>
</table>
</div>
@@ -39,7 +41,8 @@
<clr-modal [(clrModalOpen)]="tableFlag">
<h3 class="modal-title">All Details</h3>
<div class="modal-body">
<clr-tabs *ngIf="tableFlag">
@if (tableFlag) {
<clr-tabs>
<clr-tab>
<button clrTabLink>Submitted Table Details</button>
<clr-tab-content *clrIfActive="true">
@@ -52,10 +55,12 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let detail of submitArr">
@for (detail of submitArr; track detail) {
<tr>
<td class="left">{{ detail }}</td>
<td class="left">{{ submitDetails[detail] }}</td>
</tr>
}
</tbody>
</table>
</div>
@@ -73,16 +78,19 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let key of keysArray">
@for (key of keysArray; track key) {
<tr>
<td class="left">{{ key }}</td>
<td class="left">{{ jsParams[key] }}</td>
</tr>
}
</tbody>
</table>
</div>
</clr-tab-content>
</clr-tab>
</clr-tabs>
}
</div>
<div class="modal-footer">
<button
@@ -130,18 +138,21 @@
</clr-modal>
<div class="content-area">
<div class="card" *ngIf="!submitted">
<div
class="card-header d-flex flex-column justify-content-center"
*ngIf="loaded"
>
<div class="card" *ngIf="loaded" class="mt-0">
@if (!submitted) {
<div class="card">
@if (loaded) {
<div class="card-header d-flex flex-column justify-content-center">
@if (loaded) {
<div class="card" class="mt-0">
<div class="card-header p-0">
<div class="clr-row">
<div class="clr-col-md-4 approvalBack">
<span class="btn btn-outline m-0" (click)="goToApprovalsList()">
<clr-icon shape="caret" dir="left" size="20"></clr-icon>Back to
approvals list
<span
class="btn btn-outline m-0"
(click)="goToApprovalsList()"
>
<clr-icon shape="caret" dir="left" size="20"></clr-icon
>Back to approvals list
</span>
</div>
<div class="clr-col-md-4 d-flex justify-content-center">
@@ -164,14 +175,16 @@
</p>
</div>
<div class="card-block p-0">
<div class="card-text" *ngIf="loaded">
@if (loaded) {
<div class="card-text">
<div class="clr-row font-size-15">
<div class="clr-col-md-5">
<p *ngIf="!tableDetails" class="text-center font-size-18">
@if (!tableDetails) {
<p class="text-center font-size-18">
There are no details to show
</p>
<ng-container *ngIf="tableDetails">
}
@if (tableDetails) {
<div class="mt-15">
<span>Table Id:</span>
<strong
@@ -199,7 +212,7 @@
{{ tableDetails?.SUBMITTED_REASON_TXT }}
</span>
</div>
</ng-container>
}
</div>
<div class="clr-col-md-7">
<div class="card-block d-flex justify-content-center">
@@ -236,7 +249,9 @@
class="btn btn-sm btn-success mr-5i"
(click)="approveTable()"
[disabled]="
!loadingTable || params?.ISAPPROVER === 'NO' || noChanges
!loadingTable ||
params?.ISAPPROVER === 'NO' ||
noChanges
"
>
ACCEPT
@@ -246,12 +261,13 @@
class="btn btn-sm btn btn-danger mr-5i"
(click)="rejectOpen = true"
[disabled]="
!loadingTable || params?.ISAPPROVER === 'NO' || noChanges
!loadingTable ||
params?.ISAPPROVER === 'NO' ||
noChanges
"
>
REJECT
</button>
<clr-toggle-container class="m-0 ml-20i">
<clr-toggle-wrapper>
<input
@@ -267,17 +283,19 @@
</clr-toggle-wrapper>
</clr-toggle-container>
</div>
<div
class="card-block d-flex clr-align-items-center clr-flex-column"
>
<span *ngIf="diffsLimit" class="rows-notice">
@if (diffsLimit) {
<span class="rows-notice">
<clr-icon
class="mb-5 color-orange"
shape="exclamation-triangle"
></clr-icon>
Only the first 100 inserts, updates or deletes are displayed
Only the first 100 inserts, updates or deletes are
displayed
</span>
}
<div class="clr-row">
<div
class="d-flex clr-flex-column clr-gap-5 clr-flex-sm-row clr-gap-sm-0"
@@ -300,95 +318,127 @@
</div>
</div>
</div>
}
</div>
@if (!loadingTable) {
<div
*ngIf="!loadingTable"
class="h-24vh d-flex flex-column justify-content-center align-items-center"
>
<span class="spinner"> Loading... </span>
<div *ngIf="!loadingTable" class="mt-10">
@if (!loadingTable) {
<div class="mt-10">
<p cds-text="section">Loading table</p>
</div>
}
</div>
}
<div class="tableCont mt-0">
<p *ngIf="loadingTable && noChanges" class="text-center font-size-18">
@if (loadingTable && noChanges) {
<p class="text-center font-size-18">
There are no changes to show
</p>
}
<table class="table">
<thead>
<tr class="d-flex">
<th class="left" *ngFor="let col of rowHeader">{{ col }}</th>
@for (col of rowHeader; track col) {
<th class="left">{{ col }}</th>
}
</tr>
</thead>
<tbody>
@for (key of rowKeys; track key; let i = $index) {
<tr
class="d-flex"
*ngFor="let key of rowKeys; let i = index"
[ngClass]="{
addedRow: chArr[i] == 'added',
deletedRow: chArr[i] == 'deleted',
updatedRow: chArr[i] == 'updated'
}"
>
<ng-container *ngIf="chArr[i] === 'updated'">
@if (chArr[i] === 'updated') {
@for (
col of rowKeys[i];
track col;
let chIndex = $index
) {
<td
class="left"
*ngFor="let col of rowKeys[i]; let chIndex = index"
[ngClass]="{
'ch tooltip tooltip-md tooltip-top-right':
arrChanged[i][chIndex] == true && chArr[i] == 'updated'
arrChanged[i][chIndex] == true &&
chArr[i] == 'updated'
}"
>
{{ diffTable.data[i][col] }}
<span
*ngIf="
arrChanged[i][chIndex] == true && chArr[i] == 'updated'
"
class="tooltip-content"
>Original value is: {{ arrOfChanges[i][chIndex] }}</span
@if (
arrChanged[i][chIndex] == true &&
chArr[i] == 'updated'
) {
<span class="tooltip-content"
>Original value is:
{{ arrOfChanges[i][chIndex] }}</span
>
}
</td>
</ng-container>
<ng-container *ngIf="chArr[i] !== 'updated'">
<td
class="left"
*ngFor="let col of rowKeys[i]; let chIndex = index"
>
}
}
@if (chArr[i] !== 'updated') {
@for (
col of rowKeys[i];
track col;
let chIndex = $index
) {
<td class="left">
{{ diffTable.data[i][col] }}
</td>
</ng-container>
}
}
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
}
<div class="overflow-auto mr-12">
@if (!loaded) {
<div
*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">
@if (!loaded) {
<span class="spinner"> Loading... </span>
}
@if (!loaded) {
<div>
<h3>Loading preview</h3>
</div>
}
</div>
}
</div>
</div>
</div>
}
<!-- submitted page layout -->
<div *ngIf="submitted">
<div class="d-flex flex-column justify-content-center" *ngIf="loaded">
<div class="card m-0" *ngIf="loaded">
@if (submitted) {
<div>
@if (loaded) {
<div class="d-flex flex-column justify-content-center">
@if (loaded) {
<div class="card m-0">
<div class="card-header">
<div class="clr-row">
<div class="clr-col-md-4 approvalBack">
<span class="btn btn-outline" (click)="goToSubmitList()">
<cds-icon shape="angle" direction="left" size="20"></cds-icon
<cds-icon
shape="angle"
direction="left"
size="20"
></cds-icon
>Back to submitted list
</span>
</div>
@@ -414,7 +464,10 @@
<div class="clr-col-md-5">
<div class="mt-15">
<span>Table Id:</span>
<strong class="link-it" [routerLink]="'/stage/' + subObj.tableId">
<strong
class="link-it"
[routerLink]="'/stage/' + subObj.tableId"
>
<span> {{ subObj.tableId }}</span>
</strong>
</div>
@@ -456,17 +509,19 @@
</div>
</div>
</div>
<div
class="card-block d-flex clr-align-items-center clr-flex-column"
>
<span *ngIf="diffsLimit" class="rows-notice">
@if (diffsLimit) {
<span class="rows-notice">
<clr-icon
class="mb-5 color-orange"
shape="exclamation-triangle"
></clr-icon>
Only the first 100 inserts, updates or deletes are displayed
Only the first 100 inserts, updates or deletes are
displayed
</span>
}
<div class="clr-row">
<div
class="d-flex clr-flex-column clr-gap-5 clr-flex-sm-row clr-gap-sm-0"
@@ -486,7 +541,6 @@
</div>
</div>
</div>
<div class="card-block d-flex justify-content-center">
<clr-toggle-container class="m-0 ml-20-i">
<clr-toggle-wrapper>
@@ -505,77 +559,101 @@
</div>
</div>
</div>
<div class="card-block p-0 overflow-auto mr-12">
<div class="card-text" *ngIf="loaded"></div>
@if (loaded) {
<div class="card-text"></div>
}
</div>
@if (!loadingTable) {
<div
*ngIf="!loadingTable"
class="h-25vh d-flex flex-column justify-content-center align-items-center"
>
<span class="spinner"> Loading... </span>
<div *ngIf="!loadingTable" class="mt-10">
@if (!loadingTable) {
<div class="mt-10">
<p cds-text="section">Loading table</p>
</div>
}
</div>
}
<div class="tableCont">
<table class="table">
<thead>
<tr class="d-flex">
<th class="left" *ngFor="let col of rowHeader">{{ col }}</th>
@for (col of rowHeader; track col) {
<th class="left">{{ col }}</th>
}
</tr>
</thead>
<tbody>
@for (key of rowKeys; track key; let i = $index) {
<tr
class="d-flex"
*ngFor="let key of rowKeys; let i = index"
[ngClass]="{
addedRow: chArr[i] == 'added',
deletedRow: chArr[i] == 'deleted',
updatedRow: chArr[i] == 'updated'
}"
>
<ng-container *ngIf="chArr[i] === 'updated'">
@if (chArr[i] === 'updated') {
@for (
col of rowKeys[i];
track col;
let chIndex = $index
) {
<td
class="left"
*ngFor="let col of rowKeys[i]; let chIndex = index"
[ngClass]="{
'ch tooltip tooltip-md tooltip-top-right':
arrChanged[i][chIndex] == true && chArr[i] == 'updated'
arrChanged[i][chIndex] == true &&
chArr[i] == 'updated'
}"
>
{{ diffTable.data[i][col] }}
<span
*ngIf="
arrChanged[i][chIndex] == true && chArr[i] == 'updated'
"
class="tooltip-content"
>Original value is: {{ arrOfChanges[i][chIndex] }}</span
@if (
arrChanged[i][chIndex] == true &&
chArr[i] == 'updated'
) {
<span class="tooltip-content"
>Original value is:
{{ arrOfChanges[i][chIndex] }}</span
>
}
</td>
</ng-container>
<ng-container *ngIf="chArr[i] !== 'updated'">
<td
class="left"
*ngFor="let col of rowKeys[i]; let chIndex = index"
>
}
}
@if (chArr[i] !== 'updated') {
@for (
col of rowKeys[i];
track col;
let chIndex = $index
) {
<td class="left">
{{ diffTable.data[i][col] }}
</td>
</ng-container>
}
}
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
<div class="card-block" *ngIf="!loaded">
}
@if (!loaded) {
<div class="card-block">
<div class="loader">
<span class="spinner"> Loading... </span>
<div *ngIf="!loaded">
@if (!loaded) {
<div>
<h3>Loading submitted table</h3>
</div>
}
</div>
</div>
}
</div>
}
</div>
@@ -1,37 +1,44 @@
<div class="content-area">
<div class="card">
<div *ngIf="remained === 0" class="d-flex justify-content-center">
@if (remained === 0) {
<div class="d-flex justify-content-center">
<div class="card-block noapprovals-info-wrapper">
<clr-icon
shape="warning-standard"
size="60"
class="is-info icon-dc-fill"
></clr-icon>
<h3 class="text-center color-gray">There are no approvals remaining</h3>
</div>
</div>
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
<h3
class="center clr-col-md-12 text-center"
*ngIf="loaded && remained !== 0"
>
REVIEW
<h3 class="text-center color-gray">
There are no approvals remaining
</h3>
<p
class="text-center font-weight-700 color-dark-gray"
*ngIf="loaded && remained !== 0"
>
</div>
</div>
}
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
@if (loaded && remained !== 0) {
<h3 class="center clr-col-md-12 text-center">REVIEW</h3>
}
@if (loaded && remained !== 0) {
<p class="text-center font-weight-700 color-dark-gray">
You have <span>{{ remained }} </span>approvals remaining
</p>
}
</div>
<div *ngIf="!loaded" class="approvals-list-wrapper">
<span class="spinner" *ngIf="!loaded"> Loading... </span>
<div *ngIf="!loaded">
@if (!loaded) {
<div class="approvals-list-wrapper">
@if (!loaded) {
<span class="spinner"> Loading... </span>
}
@if (!loaded) {
<div>
<h3>Loading approvals list</h3>
</div>
}
</div>
}
<div class="clr-col-md-12" ng-if="loaded">
<div *ngIf="approveList && remained !== 0">
@if (approveList && remained !== 0) {
<div>
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
<clr-dg-column [clrDgField]="'submitter'">
SUBMITTER
@@ -63,7 +70,6 @@
</clr-dg-column>
<clr-dg-column>ACTION</clr-dg-column>
<clr-dg-column>DOWNLOAD</clr-dg-column>
<clr-dg-row
*clrDgItems="let approveItem of approveList; let i = index"
>
@@ -86,23 +92,27 @@
size="24"
aria-hidden="true"
></clr-icon>
<span class="tooltip-content">Go to review page screen</span>
<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
*ngIf="!approveItem.rejectLoading"
shape="ban"
size="22"
aria-hidden="true"
></clr-icon>
}
@if (approveItem.rejectLoading) {
<clr-spinner
*ngIf="approveItem.rejectLoading"
[clrSmall]="true"
aria-hidden="true"
></clr-spinner>
}
<span class="tooltip-content">Reject</span>
</a>
<a
@@ -114,7 +124,9 @@
size="28"
aria-hidden="true"
></clr-icon>
<span class="tooltip-content">Go to staged data screen</span>
<span class="tooltip-content"
>Go to staged data screen</span
>
</a>
</div>
</clr-dg-cell>
@@ -131,18 +143,19 @@
</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.firstItem + 1 }} -
{{ pagination.lastItem + 1 }} of
{{ pagination.totalItems }} approvals
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
}
</div>
</div>
</div>
@@ -1,12 +1,13 @@
<div class="content-area">
@if (noData) {
<div
*ngIf="noData"
id="noDataContainer"
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>
<h3 class="text-center color-gray">There is no history to show</h3>
</div>
}
<clr-modal [(clrModalOpen)]="openModal" [clrModalSize]="'xl'">
<h4 class="modal-title">Approval details</h4>
<div class="modal-body">
@@ -18,16 +19,19 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let col of tableTitles; let ind = index">
@for (col of tableTitles; track col; let ind = $index) {
<tr>
<td class="left">{{ col }}</td>
<td class="left">
@if (ind < 1) {
<a
*ngIf="ind < 1"
(click)="getTable(approveData[col])"
class="cursor-pointer table-link"
>{{ approveData[col] }}</a
>
<div *ngIf="ind < 2 && ind >= 1">
}
@if (ind < 2 && ind >= 1) {
<div>
<a
(click)="getBaseTable(approveData[col])"
class="cursor-pointer table-link"
@@ -40,9 +44,13 @@
>EDIT</a
>
</div>
<span *ngIf="ind >= 2">{{ approveData[col] }}</span>
}
@if (ind >= 2) {
<span>{{ approveData[col] }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
@@ -58,33 +66,37 @@
</div>
</clr-modal>
@if (!loaded) {
<div
*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">
@if (!loaded) {
<span class="spinner"> Loading... </span>
}
@if (!loaded) {
<div>
<h3>Loading history</h3>
</div>
}
</div>
}
<div *ngIf="!noData && loaded" class="card">
@if (!noData && loaded) {
<div 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"
>
@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>
<clr-datagrid
class="datagrid-history datagrid-custom-footer"
*ngIf="loaded"
>
@if (loaded) {
<clr-datagrid class="datagrid-history datagrid-custom-footer">
<clr-dg-column [clrDgField]="'basetable'">
BASE_TABLE
<clr-dg-string-filter
@@ -128,7 +140,6 @@
></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)"
@@ -148,27 +159,33 @@
}"
>{{ historyItem.status }}</clr-dg-cell
>
<clr-dg-cell class="verCenter">{{ historyItem.submitter }}</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">{{
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()"
(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>
@@ -185,21 +202,23 @@
</clr-dg-pagination>
</clr-dg-footer> -->
</clr-datagrid>
}
<div
class="load-more d-flex clr-justify-content-center clr-align-items-center"
>
<button
*ngIf="
@if (
this.licenceState.value.history_rows_allowed === Infinity &&
rowsLeftToLoad > 0
"
) {
<button
(click)="loadData()"
[clrLoading]="loadingMore"
class="btn btn-success"
>
Load {{ rowsLeftToLoad }} more
</button>
}
</div>
</div>
}
</div>
@@ -1,10 +1,10 @@
<div class="w-100">
<div *ngIf="!subReady" class="content-area">
<div *ngIf="!subReady" class="card">
<div
*ngIf="remained === 0 && loaded"
class="d-flex justify-content-center"
>
@if (!subReady) {
<div class="content-area">
@if (!subReady) {
<div class="card">
@if (remained === 0 && loaded) {
<div class="d-flex justify-content-center">
<div
class="no-submitted-tables card-block d-flex justify-content-center flex-column align-items-center"
>
@@ -18,30 +18,36 @@
</h3>
</div>
</div>
}
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
<h3 class="center clr-col-md-12 text-center" *ngIf="remained !== 0">
SUBMIT QUEUE
</h3>
<p
class="text-center font-weight-700 color-dark-gray"
*ngIf="loaded && remained !== 0"
>
@if (remained !== 0) {
<h3 class="center clr-col-md-12 text-center">SUBMIT QUEUE</h3>
}
@if (loaded && remained !== 0) {
<p class="text-center font-weight-700 color-dark-gray">
You have <span>{{ remained }} </span>submissions waiting to be
approved
</p>
}
</div>
@if (!loaded) {
<div
*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">
@if (!loaded) {
<span class="spinner"> Loading... </span>
}
@if (!loaded) {
<div>
<h3>Loading submitted list</h3>
</div>
}
</div>
<div class="clr-col-md-12" *ngIf="loaded">
<div *ngIf="submitterList && remained !== 0">
}
@if (loaded) {
<div class="clr-col-md-12">
@if (submitterList && remained !== 0) {
<div>
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
<clr-dg-column>BASE TABLE</clr-dg-column>
<clr-dg-column [clrDgField]="'submitted'">
@@ -94,13 +100,14 @@
<button
class="btn btn-success"
aria-label="Download audit file for table record"
(click)="download(sub.tableId); $event.stopPropagation()"
(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]"
@@ -113,11 +120,17 @@
</clr-dg-footer>
</clr-datagrid>
</div>
}
</div>
}
</div>
}
</div>
}
<div *ngIf="subReady">
@if (subReady) {
<div>
<app-approve-details></app-approve-details>
</div>
}
</div>
+34 -18
View File
@@ -1,6 +1,7 @@
<app-sidebar class="sidebar-height">
<clr-tree>
<clr-tree-node *ngIf="roles" class="search-node">
@if (roles) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -12,21 +13,22 @@
(keyup)="roleListOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="roleSearch = ''; roleListOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
<ng-container *ngFor="let role of roles">
}
@for (role of roles; track role) {
@if (!role['hidden']) {
<clr-tree-node
(click)="roleOnClick(role)"
*ngIf="!role['hidden']"
[class.active]="role.ROLEURI === roleUri"
>
<p class="m-0 cursor-pointer list-padding">
@@ -34,15 +36,19 @@
{{ role.ROLENAME }}
</p>
</clr-tree-node>
</ng-container>
}
}
</clr-tree>
</app-sidebar>
<div class="content-area">
<div *ngIf="loading" class="loadingSpinner">
@if (loading) {
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
<div *ngIf="roleMembers && !loading">
}
@if (roleMembers && !loading) {
<div>
<div class="clr-row">
<div class="clr-col-8">
<table class="table role-info">
@@ -63,15 +69,17 @@
</table>
</div>
</div>
<div class="clr-row">
<div class="clr-col-8">
<div class="card role-data">
<div>
<h3>MEMBERS ({{ roleMembersCount }})</h3>
<h5 *ngIf="roleMembersCount == 0">No Members Present</h5>
@if (roleMembersCount == 0) {
<h5>No Members Present</h5>
}
<div class="table-container">
<table *ngIf="roleMembersCount != 0" class="table member-table">
@if (roleMembersCount != 0) {
<table class="table member-table">
<thead>
<tr>
<td class="width-25"><b>NAME</b></td>
@@ -81,25 +89,30 @@
</tr>
</thead>
<tbody>
@for (member of roleMembers; track member) {
<tr
[routerLink]="'/view/usernav/users/' + member.URIMEM"
*ngFor="let member of roleMembers"
>
<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>
<hr />
<div>
<h3>Groups ({{ roleGroupsCount }})</h3>
<h5 *ngIf="roleGroupsCount == 0">No Groups Present !</h5>
@if (roleGroupsCount == 0) {
<h5>No Groups Present !</h5>
}
<div class="table-container">
<table *ngIf="roleGroupsCount != 0" class="table member-table">
@if (roleGroupsCount != 0) {
<table class="table member-table">
<thead>
<tr>
<td class="width-25"><b>NAME</b></td>
@@ -109,17 +122,19 @@
</tr>
</thead>
<tbody>
@for (group of roleGroups; track group) {
<tr
[routerLink]="'/view/usernav/groups/' + group.URIMEM"
*ngFor="let group of roleGroups"
>
<td class="">{{ group.MEMBERNAME }}</td>
<td class="">{{ group.EMAIL }}</td>
<td class="">{{ group.MEMBERCREATED }}</td>
<td class="">{{ group.MEMBERUPDATED }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
<br />
@@ -127,4 +142,5 @@
</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 { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { AppStoreService } from './app-store.service'
import { retryOnce, RetryOnceOptions } from '../shared/utils/retry-once'
@Injectable()
export class AppService {
public syssite = new BehaviorSubject<string[] | null>(null)
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(
private licenceService: LicenceService,
@@ -82,8 +86,13 @@ export class AppService {
public async startUpData() {
let startupServiceError = false
await this.sasService
.request('public/startupservice', null)
// Retry once on failure — multiple concurrent DC instances (e.g. VA
// 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) => {
this.syssite.next([res.adapterResponse.SYSSITE])
@@ -55,8 +55,8 @@ describe('VaFilterService', () => {
'SOME_DATETIME'
]
const cols = [
{ NAME: 'SOME_CHAR', DDTYPE: 'CHARACTER' },
{ NAME: 'SOME_NUM', DDTYPE: 'NUMERIC' },
{ NAME: 'SOME_CHAR', DDTYPE: 'C' },
{ NAME: 'SOME_NUM', DDTYPE: 'N' },
{ NAME: 'SOME_TIME', DDTYPE: 'TIME' },
{ NAME: 'SOME_DATE', DDTYPE: 'DATE' },
{ NAME: 'SOME_DATETIME', DDTYPE: 'DATETIME' }
+8 -2
View File
@@ -163,14 +163,20 @@ export class VaFilterService {
/**
* SAS data-type kind of a column spec. DDTYPE carries
* TIME/DATE/DATETIME/NUMERIC/CHARACTER; checked DATETIME-before-DATE (substring).
* 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()
if (ddtype.includes('DATETIME')) return 'datetime'
if (ddtype.includes('DATE')) return 'date'
if (ddtype.includes('TIME')) return 'time'
if (ddtype.includes('NUMERIC') || (col?.TYPE ?? '') === 'num') {
if (
ddtype === 'N' ||
ddtype.includes('NUMERIC') ||
(col?.TYPE ?? '') === 'num'
) {
return 'numeric'
}
return 'char'
@@ -7,66 +7,62 @@
>
<h3 class="modal-title">
{{ data.modalTitle }}
<p
*ngIf="data.sasService && data.sasService.length > 0"
class="sasService mt-0"
>
@if (data.sasService && data.sasService.length > 0) {
<p class="sasService mt-0">
SAS Service: <strong>{{ data.sasService }}</strong>
</p>
}
</h3>
<div class="modal-body">
<div [innerHTML]="data.message" class="abortMsg"></div>
<div *ngIf="data.details !== null" class="systext">
<p><strong>SYSWARNINGTEXT:</strong> {{ data.details.SYSWARNINGTEXT }}</p>
@if (data.details !== null) {
<div class="systext">
<p>
<strong>SYSWARNINGTEXT:</strong> {{ data.details.SYSWARNINGTEXT }}
</p>
<p><strong>SYSERRORTEXT:</strong> {{ data.details.SYSERRORTEXT }}</p>
<p><strong>MAC:</strong> {{ data.details.MAC }}</p>
</div>
}
</div>
<div class="modal-footer">
@if (showConfiguratorButton(data.sasService)) {
<button
*ngIf="showConfiguratorButton(data.sasService)"
type="button"
class="btn btn-primary"
(click)="openConfigurator()"
>
Open configurator
</button>
}
@if (data.details !== null) {
<button
*ngIf="data.details !== null"
type="button"
class="btn btn-primary"
(click)="openRequestsModal()"
>
Open requests modal
</button>
}
<button
*ngIf="data.details?.LOG && (data.details?.LOG?.trim())!.length > 0"
type="button"
class="btn btn-primary"
(click)="downloadLog()"
>
@if (data.details?.LOG && (data.details?.LOG?.trim())!.length > 0) {
<button type="button" class="btn btn-primary" (click)="downloadLog()">
Download log
</button>
}
<button
*ngIf="!forceReload"
type="button"
class="btn btn-primary"
(click)="closeAbortModal()"
>
@if (!forceReload) {
<button type="button" class="btn btn-primary" (click)="closeAbortModal()">
Close
</button>
}
<button
*ngIf="forceReload"
type="button"
class="btn btn-primary"
(click)="reload()"
>
@if (forceReload) {
<button type="button" class="btn btn-primary" (click)="reload()">
Reload
</button>
}
</div>
</clr-modal>
@@ -1,6 +1,7 @@
<clr-alerts *ngIf="hasOpenAlert">
@if (hasOpenAlert) {
<clr-alerts>
@for (alert of alerts; track alert) {
<clr-alert
*ngFor="let alert of alerts"
[clrAlertType]="alert.type"
[clrAlertAppLevel]="true"
[(clrAlertClosed)]="alert.closed"
@@ -12,4 +13,6 @@
</span>
</div>
</clr-alert>
</clr-alerts>
}
</clr-alerts>
}
@@ -41,12 +41,10 @@
<ng-content></ng-content>
</div>
<option
*ngIf="options.children.length > 0 && enableLoadMore"
data-type="load-more"
class="load-more"
>
@if (options.children.length > 0 && enableLoadMore) {
<option data-type="load-more" class="load-more">
{{ loadingMore ? 'Loading...' : 'LOAD MORE' }}
</option>
}
</div>
</div>
@@ -6,12 +6,13 @@
>
<h3 class="modal-title center text-center color-darker-gray">Dataset Meta</h3>
<div class="modal-body">
<p *ngIf="dsmetaTabs.length < 1" class="text-center">
No dataset meta to show.
</p>
@if (dsmetaTabs.length < 1) {
<p class="text-center">No dataset meta to show.</p>
}
<clr-tabs clrLayout="vertical">
<clr-tab *ngFor="let tab of tabs; let index = index">
@for (tab of tabs; track tab; let index = $index) {
<clr-tab>
<button clrTabLink id="link1">{{ tab.name }}</button>
<clr-tab-content
id="content1"
@@ -19,22 +20,23 @@
class="d-flex clr-justify-content-center w-100"
>
<clr-datagrid>
<ng-container *ngFor="let col of tab.colsToDisplay">
@for (col of tab.colsToDisplay; track col) {
<clr-dg-column>{{ col.colName || col.colKey }}</clr-dg-column>
</ng-container>
}
@for (info of tab.meta; track info) {
<clr-dg-row
(click)="tab.onRowClick ? tab.onRowClick(info) : ''"
class="clickable-row"
*ngFor="let info of tab.meta"
>
<ng-container *ngFor="let col of tab.colsToDisplay">
@for (col of tab.colsToDisplay; track col) {
<clr-dg-cell>{{ info[col.colKey] }}</clr-dg-cell>
</ng-container>
}
</clr-dg-row>
}
</clr-datagrid>
</clr-tab-content>
</clr-tab>
}
</clr-tabs>
</div>
<div class="modal-footer">
@@ -1,5 +1,6 @@
<clr-tree>
<clr-tree-node *ngIf="libraryList" class="search-node">
@if (libraryList) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -11,23 +12,24 @@
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="librariesSearch = ''; libraryOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
<ng-container *ngFor="let library of libraryList">
@for (library of libraryList; track library) {
@if (!library['hidden'] && library['inForeground']) {
<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']"
@@ -42,8 +44,8 @@
<clr-icon shape="rack-server"></clr-icon>
{{ library.LIBRARYNAME }}
</p>
<clr-tree-node *ngIf="library['tables']" class="search-node">
@if (library['tables']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -55,12 +57,11 @@
(keyup)="treeOnFilter(library, 'tables')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
@@ -68,12 +69,17 @@
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
@for (
libTable of library['tables'];
track libTable;
let index = $index
) {
<clr-tree-node
(click)="treeNodeClicked($event, libTable, library['tables'])"
*ngFor="let libTable of library['tables']; let index = index"
[(clrExpanded)]="libTable['expanded']"
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
[class.clr-expanded]="libTable['expanded']"
@@ -95,25 +101,30 @@
[class.dc-locked-control]="tableLocked"
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
>
<ng-container [ngSwitch]="libTable.includes('-FC')">
<clr-icon *ngSwitchCase="true" shape="bolt"></clr-icon>
<clr-icon *ngSwitchCase="false" shape="table"></clr-icon>
</ng-container>
@switch (libTable.includes('-FC')) {
@case (true) {
<clr-icon shape="bolt"></clr-icon>
}
@case (false) {
<clr-icon shape="table"></clr-icon>
}
}
{{ libTable.replace('-FC', '') }}
</button>
<clr-tooltip-content
clrPosition="bottom-right"
clrSize="lg"
*clrIfOpen
>
<span *ngIf="tableLocked">
@if (tableLocked) {
<span>
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">
@if (hasColumns) {
@if (libTable['columns']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -125,12 +136,11 @@
(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
*ngIf="searchTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
libTable['searchString'] = '';
@@ -138,27 +148,33 @@
"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
<clr-tree-node *ngFor="let libColumn of libTable['columns']">
}
@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>
</ng-container>
}
}
</clr-tooltip>
</clr-tree-node>
}
</clr-tree-node>
</ng-container>
}
}
</clr-tree>
<div *ngIf="librariesPaging" class="w-100 text-center">
@if (librariesPaging) {
<div class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div>
</div>
}
@@ -29,6 +29,9 @@ import { mapIntlCellTypes } from './utils/mapIntlCellTypes'
import { CustomAutocompleteEditor } from './editors/numericAutocomplete'
import { registerIntlCellTypes } from './cellTypes/intlCellTypes'
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 {
private rules: DcValidation[] = []
@@ -201,6 +204,31 @@ export class DcValidator {
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
}
}
/**
* Retrieves dropdown source for given dc validation rule
* The values comes from MPE_SELECTBOX table
@@ -239,6 +267,41 @@ export class DcValidator {
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
* This function fetches it's values and pushes in the DQ RULES array with SOFTSELECT type
@@ -395,6 +458,24 @@ export class DcValidator {
// editor/validator stay intact via `type`.
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
@@ -416,6 +497,27 @@ export class DcValidator {
const colType = self.rules[i].type || ''
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
// If return is true, we validate with our custom DQ validations
@@ -460,7 +562,11 @@ export class DcValidator {
}
if (self.isDqCol(col || '')) {
const dqValid = dqValidate(self.getDqDetails(col || ''), value)
const dqValid = dqValidate(
self.getDqDetails(col || ''),
value,
colType === 'numeric'
)
if (!dqValid) {
console.warn(`DQ Validation - invalid (Value: ${value})`)
@@ -1,6 +1,8 @@
export interface Col {
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
FMTNAME: string
DDTYPE: string
@@ -18,3 +18,5 @@ export type DQRuleTypes =
| 'HIDDEN'
| 'ROUND'
| 'NUMBER_FORMAT'
| 'HARDREGEX'
| 'SOFTREGEX'
@@ -414,6 +414,464 @@ describe('DC Validator', () => {
// 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
})
})
})
})
/** Minimal cols[] entry — only the fields rule ordering depends on. */
@@ -423,7 +881,7 @@ const makeCol = (name: string, varnum: number): Col =>
VARNUM: varnum,
LABEL: name,
FMTNAME: '',
DDTYPE: 'CHARACTER',
DDTYPE: 'C',
TYPE: '',
CLS_RULE: 'READ',
MEMLABEL: '',
@@ -545,7 +1003,7 @@ const example_dqRules: any = [
const example_cols = [
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DDTYPE: 'C',
DESC: 'dropdown_desc',
TYPE: '',
FMTNAME: '',
@@ -558,7 +1016,7 @@ const example_cols = [
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DDTYPE: 'N',
DESC: '',
TYPE: '',
FMTNAME: '',
@@ -571,7 +1029,7 @@ const example_cols = [
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DDTYPE: 'N',
DESC: '',
TYPE: '',
FMTNAME: '',
@@ -584,7 +1042,7 @@ const example_cols = [
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DDTYPE: 'C',
DESC: '',
TYPE: '',
FMTNAME: '',
@@ -597,7 +1055,7 @@ const example_cols = [
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DDTYPE: 'C',
DESC: '',
TYPE: '',
FMTNAME: '',
@@ -610,7 +1068,7 @@ const example_cols = [
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DDTYPE: 'C',
DESC: '',
TYPE: '',
FMTNAME: '',
@@ -623,7 +1081,7 @@ const example_cols = [
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DDTYPE: 'C',
DESC: '',
TYPE: '',
FMTNAME: '',
@@ -675,7 +1133,7 @@ const example_cols = [
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DDTYPE: 'N',
DESC: '',
TYPE: '',
FMTNAME: '',
@@ -688,7 +1146,7 @@ const example_cols = [
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DDTYPE: 'N',
DESC: '',
TYPE: '',
FMTNAME: '',
@@ -7,6 +7,10 @@ import { DcValidation } from '../models/dc-validation.model'
* Uses Intl.NumberFormat options (HOT 17+) instead of the deprecated numbro
* `pattern`/`culture`. `maximumFractionDigits: 20` preserves all natural
* 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
* 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[] => {
for (let rule of rules) {
if (rule.type === 'numeric') {
rule.numericFormat = { useGrouping: true, maximumFractionDigits: 20 }
rule.numericFormat = { useGrouping: false, maximumFractionDigits: 20 }
rule.locale = window.navigator.language
}
}
@@ -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 === '.'
}
@@ -0,0 +1,237 @@
import { parseRegexRule } from './parseRegexRule'
describe('parseRegexRule', () => {
it('treats a bare pattern (no delimiters) exactly as before', () => {
const regex = parseRegexRule('^[A-Z]{3}\\d{4}$')
expect(regex.test('ABC1234')).toBeTrue()
expect(regex.test('abc1234')).toBeFalse()
})
it('parses a delimited pattern with no flags, case-sensitively', () => {
const regex = parseRegexRule('/\\b(the|data)\\b/')
expect(regex.test('this is dummy data')).toBeTrue()
expect(regex.test('THE WIND WAS BLOWING')).toBeFalse()
})
it('parses a delimited pattern with the i flag, case-insensitively', () => {
const regex = parseRegexRule('/\\b(the|data)\\b/i')
expect(regex.test('THIS IS DUMMY DATA')).toBeTrue()
expect(regex.test('The wind was blowing')).toBeTrue()
expect(regex.test('nothing relevant here')).toBeFalse()
})
it('extracts source and flags correctly from a delimited pattern', () => {
const regex = parseRegexRule('/^[A-Z]+$/g')
expect(regex.source).toEqual('^[A-Z]+$')
expect(regex.flags).toEqual('g')
})
it('treats a bare pattern containing an internal slash as a literal, not a delimited form', () => {
const regex = parseRegexRule('a/b')
expect(regex.test('a/b')).toBeTrue()
expect(regex.test('ab')).toBeFalse()
})
it('throws for an unterminated delimited pattern, same as a bad bare pattern would', () => {
expect(() => parseRegexRule('/[unterminated/')).toThrow()
})
// parseRegexRule strips PRX's /pattern/flags delimiter wrapper and
// translates the three PRX-only constructs below to a JS equivalent. These
// three were picked because they're mechanical, low-risk translations with
// one unambiguous JS equivalent. Atomic groups and possessive quantifiers are
// NOT translated - both would need a lookahead+backreference rewrite that
// changes the pattern's capture-group numbering, which is a real
// correctness risk for comparatively rare constructs, so they're left as a
// documented limitation below instead.
describe('translated PRX-only syntax', () => {
it('hoists a leading (?i) inline modifier into the i flag', () => {
const regex = parseRegexRule('/(?i)abc/')
expect(regex.test('ABC')).toBeTrue()
expect(regex.test('abc')).toBeTrue()
})
it('merges a leading (?i) with flags already present after the closing delimiter', () => {
const regex = parseRegexRule('/(?i)^[a-z]+$/g')
expect(regex.flags).toContain('i')
expect(regex.flags).toContain('g')
expect(regex.test('ABC')).toBeTrue()
})
it('does not add a duplicate i flag when (?i) and the trailing /i are both present', () => {
expect(() => parseRegexRule('/(?i)abc/i')).not.toThrow()
})
it('treats \\Q...\\E as a literal, metacharacter-escaped sequence', () => {
const regex = parseRegexRule('/\\Qa+b*c\\E/')
expect(regex.test('a+b*c')).toBeTrue()
// Without the \Q...\E escaping, '+' and '*' would be quantifiers, so
// this would also match a literal "c" alone. It shouldn't.
expect(regex.test('c')).toBeFalse()
})
it('translates \\A/\\z to absolute start/end anchors equivalent to PRX semantics', () => {
const regex = parseRegexRule('/\\Afoo\\z/')
expect(regex.test('foo')).toBeTrue()
expect(regex.test('foobar')).toBeFalse()
expect(regex.test('barfoo')).toBeFalse()
// \z (unlike JS's own $) does not match immediately before a
// trailing newline - this is stricter, matching PRX/Perl semantics.
expect(regex.test('foo\n')).toBeFalse()
})
})
// Pre-existing gaps, not regressions from this fix - both fail safely:
// they throw, same as any other malformed pattern, and each call site
// already treats a throw as "always valid" / "never warn".
describe('known limitations - PRX-only syntax not translated', () => {
it('throws on free-spacing/extended (/x) mode (valid PRX, not a JS flag)', () => {
expect(() => parseRegexRule('/a+ \\s+ b/x')).toThrow()
})
it('throws on an atomic group (?>...) (valid PRX, unsupported in JS)', () => {
expect(() => parseRegexRule('/(?>a|ab)c/')).toThrow()
})
it('throws on a possessive quantifier, e.g. a++ (valid PRX, unsupported in JS)', () => {
expect(() => parseRegexRule('/a++b/')).toThrow()
})
})
// HARDREGEX/SOFTREGEX RULE_VALUEs collected from an existing
// MPE_VALIDATIONS table. None of these use PRX-only syntax (no inline
// (?i), \Q...\E, \A/\z, atomic groups, or possessive quantifiers), so
// they pass through parseRegexRule with only the /pattern/flags
// delimiter stripped - the same PCRE-compatible escape rules apply on
// both the SAS PRX side and the JS side, so behavior here is verified
// to match, not just "doesn't throw".
describe('RULE_VALUE corpus collected from an existing MPE_VALIDATIONS table', () => {
const collectedRuleValues = [
'/^-?[0-9]{1,13}([.]{0,1}[0-9]{0,3})$/',
'/^-?[0-9]{1,3}$/',
'/^(?:(?!31\\\\.12).)*$/',
'/^([0-9]{1,3}([.][0-9]{1,7})?)%$/',
'/^(100|[0-9]{1,2})%$/',
'/^[-,0-9]{1,13}([.]{0,1}[0-9]{0,3})$/',
'/^[-0-9]{1,19}([.]{0,1}[0-9]{0,3})$/',
'/^[-0-9]*([.]{0,1}[0-9]{0,2})$/',
'/^[-0-9]*([.]{0,1}[0-9]{0,3})$/',
'/^[-0-9]*([.]{0,1}[0-9]*)$/',
'/^[-0-9]*$/',
'/^[0-2]$/',
'/^[0-3]$/',
'/^[0-9]{1,19}([.]{0,1}[0-9]{0,3})$/',
'/^[0-9]{1,36}$/',
'/^[0-9]{1,72}$/',
'/^[0-9]{4}$/',
'/^[0-9]{5}$/',
'/^[0-9]*([.]{0,1}[0-9]{0,4})$/',
'/^[0,1]{1}([.]{0,1}[0-9]{0,4})$/',
'/^[1]{1,1}$/',
'/^[A-Z0-1]{5,5}$/',
'/^[a-zA-Z]{1,1}$/',
'/^[a-zA-Z]{1,3}$/',
'/^[a-zA-Z0-9_]{0,36}$/',
'/^[a-zA-Z0-9_=@\\/.:;,= -]{1,8}$/',
'/^[a-zA-Z0-9@_-]{1,100}$/',
'/^[a-zA-Z0-9@_-]{1,50}$/',
'/^[a-zA-Z0-9=@.:;,= -_]{1,50}$/',
'/^[a-zA-Z0-9=@.:;,= -_]{1,80}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,10}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,100}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,12}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,14}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,15}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,20}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,3}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,300}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,32}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,4}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,40}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,400}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,50}$/',
'/^[a-zA-Z0-9=@\\/.:;,= \\-_]{1,500}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,510}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,6}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,72}$/',
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,8}$/',
"/^[a-zA-Z0-9=@\\/.:;,= -_`']{1,100}$/",
"/^[a-zA-Z0-9=@\\/.:;,= -_`']{1,20}$/",
"/^[a-zA-Z0-9=@\\/.:;,= \\-_`']{1,500}$/",
"/^[a-zA-Z0-9=@\\/.:;,= -_`']*$/"
]
collectedRuleValues.forEach((ruleValue) => {
it(`compiles without throwing: ${ruleValue}`, () => {
expect(() => parseRegexRule(ruleValue)).not.toThrow()
})
})
it('preserves the exact pattern body and applies no flags for a plain numeric-range rule', () => {
const regex = parseRegexRule('/^[0-9]{1,13}([.]{0,1}[0-9]{0,3})$/')
expect(regex.source).toEqual('^[0-9]{1,13}([.]{0,1}[0-9]{0,3})$')
expect(regex.flags).toEqual('')
expect(regex.test('1234.56')).toBeTrue()
expect(regex.test('abc')).toBeFalse()
})
it('handles the percentage-with-alternation rule (100 or 0-99, optionally with a decimal)', () => {
const regex = parseRegexRule('/^(100|[0-9]{1,2})%$/')
expect(regex.test('100%')).toBeTrue()
expect(regex.test('42%')).toBeTrue()
expect(regex.test('101%')).toBeFalse()
})
it('handles a character class with an escaped delimiter slash and a non-edge escaped hyphen', () => {
const regex = parseRegexRule('/^[a-zA-Z0-9=@\\/.:;,= \\-_]{1,500}$/')
expect(regex.test('a/b-c d.e:f;g,h=i')).toBeTrue()
expect(regex.test('has a # in it')).toBeFalse()
})
it('handles a character class with literal backtick/single-quote characters', () => {
const regex = parseRegexRule("/^[a-zA-Z0-9=@\\/.:;,= -_`']*$/")
expect(regex.test("O'Brien`s value")).toBeTrue()
// The unescaped space-to-underscore run (` -_`) in this class is a
// range (space is code point 32, underscore 95), not three literal
// characters - so it also matches '#' (35), which falls inside that
// range. Same behavior in SAS PRX (identical character-class rules),
// so this is a faithful translation, not a parseRegexRule bug.
expect(regex.test('has a # in it')).toBeTrue()
expect(regex.test('has a ~ in it')).toBeFalse()
})
// This is the one inconsistency in the whole corpus: every other escaped
// '/' in this file uses a single backslash, but this rule has \\. (two
// backslashes) where every other sign points to a single \. having been
// intended (a literal dot, inside a negative lookahead meant to exclude
// "31.12" - e.g. a 31-December date - from matching). SAS PRX and JS
// regex use identical backslash-escape semantics for this (no PRX-only
// construct is involved), so parseRegexRule's output is a faithful,
// parity-preserving translation of whatever was actually authored -
// this test pins down what the corpus *actually* does today, verbatim,
// not what was likely intended. \\ (escaped backslash) followed by an
// unescaped . (any character) means the lookahead is functionally a
// no-op: it only fails to match text containing a literal backslash,
// which excludes nothing in practice, so every value - including dates
// that literally contain "31.12" - passes.
it('as authored (double backslash): the 31.12-exclusion lookahead is a no-op and matches everything', () => {
const regex = parseRegexRule('/^(?:(?!31\\\\.12).)*$/')
expect(regex.test('31.12.2024')).toBeTrue()
expect(regex.test('01.01.2024')).toBeTrue()
})
})
})
@@ -0,0 +1,46 @@
/**
* HARDREGEX/SOFTREGEX rule values are authored as SAS PRX patterns, which
* require the delimiter form `/pattern/flags` (prxparse's own syntax check
* in mpe_validations_postedit.sas requires it). A bare pattern with no
* delimiters is also accepted, for backwards compatibility with existing
* rules/tests. Passing the delimited form as-is to `new RegExp(string)`
* would treat the slashes and flags as literal pattern text instead of
* stripping/applying them, so this parses the PRX form before constructing
* the regex.
*
* PRX also accepts some Perl-only regex syntax with no direct JS equivalent.
* Three mechanical, unambiguous cases are translated below - a leading
* `(?i)` modifier, an escaped `\Q...\E` literal sequence, and `\A`/`\z`
* absolute anchors.
* Atomic groups (`(?>...)`) and possessive quantifiers (`a++`) are NOT
* translated: both would need a lookahead+backreference rewrite that
* changes the pattern's capture-group numbering, a real correctness risk
* for comparatively rare constructs, so they're left to fail (they already
* throw, which every caller already treats as "always valid" / "never
* warn", same as any other malformed pattern).
*/
const escapeRegExpMetacharacters = (text: string): string =>
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export const parseRegexRule = (ruleValue: string): RegExp => {
const delimited = /^\/(.*)\/([a-z]*)$/s.exec(ruleValue)
if (!delimited) return new RegExp(ruleValue)
let [, body, flags] = delimited
const inlineCaseInsensitive = /^\(\?i\)/.exec(body)
if (inlineCaseInsensitive) {
body = body.slice(inlineCaseInsensitive[0].length)
if (!flags.includes('i')) flags += 'i'
}
body = body.replace(/\\Q([\s\S]*?)\\E/g, (_match, literal) =>
escapeRegExpMetacharacters(literal)
)
body = body.replace(/\\A/g, '^').replace(/\\z/g, '(?![\\s\\S])')
return new RegExp(body, flags)
}
@@ -0,0 +1,119 @@
import { DQRule } from '../models/dq-rules.model'
import { dqValidate } from './dq-validation'
const rule = (overrides: Partial<DQRule>): DQRule => ({
BASE_COL: 'SOME_CHAR',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '',
X: 0,
...overrides
})
describe('dqValidate - HARDREGEX', () => {
it('accepts a value matching the pattern', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, 'ABC1234')).toBeTrue()
})
it('rejects a value not matching the pattern', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, 'abc1234')).toBeFalse()
})
it('treats blank/undefined/null as valid regardless of the pattern', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, '')).toBeTrue()
expect(dqValidate(rules, undefined)).toBeTrue()
expect(dqValidate(rules, null)).toBeTrue()
})
it('treats the plain SAS missing (".") as valid on numeric columns, regardless of the pattern', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, '.', true)).toBeTrue()
})
it('applies the pattern to special missings on numeric columns (deliberately-set values)', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, '.a', true)).toBeFalse()
expect(dqValidate(rules, '_', true)).toBeFalse()
expect(dqValidate(rules, 'd', true)).toBeFalse()
})
it('applies the pattern to special-missing-looking values on character columns', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, '.')).toBeFalse()
expect(dqValidate(rules, '.a')).toBeFalse()
expect(dqValidate(rules, '_')).toBeFalse()
// A bare single letter is a real character value and must be matched.
expect(dqValidate(rules, 'd')).toBeFalse()
})
it('fails open (treats as valid) when the pattern is malformed', () => {
const rules = [rule({ RULE_VALUE: '[unterminated' })]
expect(dqValidate(rules, 'anything')).toBeTrue()
})
it('validates a single letter on a character column against the pattern', () => {
// A bare single letter ("d", "z") is real character data, not a SAS
// special missing - it must reach the pattern, not be exempted.
const rules = [rule({ RULE_VALUE: '/the|data/i' })]
expect(dqValidate(rules, 'd')).toBeFalse()
expect(dqValidate(rules, 'z')).toBeFalse()
expect(dqValidate(rules, 'the')).toBeTrue()
expect(dqValidate(rules, 'DATA')).toBeTrue()
expect(dqValidate(rules, 'some data here')).toBeTrue()
expect(dqValidate(rules, '')).toBeTrue() // blank stays exempt
})
it('handles a more elaborate pattern (multiple character classes, quantifiers, an escaped literal dot)', () => {
// Same email pattern used in the getdata.js mock's REGEX_HARD_COL demo.
const rules = [rule({ RULE_VALUE: '[\\w.]+@[\\w]+\\.[a-z]{2,}' })]
expect(dqValidate(rules, 'user@example.com')).toBeTrue()
expect(dqValidate(rules, 'user.name@sub.example.co')).toBeTrue()
expect(dqValidate(rules, 'not-an-email')).toBeFalse()
// No TLD to satisfy [a-z]{2,} after the escaped dot.
expect(dqValidate(rules, 'user@example')).toBeFalse()
})
it('accepts a value matching a PRX-delimited pattern (/pattern/flags, as authored for prxparse)', () => {
const rules = [rule({ RULE_VALUE: '/^[A-Z]{3}\\d{4}$/' })]
expect(dqValidate(rules, 'ABC1234')).toBeTrue()
})
it('rejects a value not matching a PRX-delimited pattern', () => {
const rules = [rule({ RULE_VALUE: '/^[A-Z]{3}\\d{4}$/' })]
expect(dqValidate(rules, 'abc1234')).toBeFalse()
})
it('applies the i flag from a PRX-delimited pattern', () => {
const rules = [rule({ RULE_VALUE: '/^[a-z]{3}\\d{4}$/i' })]
expect(dqValidate(rules, 'ABC1234')).toBeTrue()
})
it('still evaluates a second, unrelated rule on the same column', () => {
const rules = [
rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' }),
rule({ RULE_TYPE: 'NOTNULL', RULE_VALUE: '' })
]
// Passes HARDREGEX (blank is exempt) but fails NOTNULL — the NOTNULL
// rule still runs even though HARDREGEX is earlier in the array.
expect(dqValidate(rules, '')).toBeFalse()
// Passes NOTNULL (non-blank) but fails HARDREGEX's pattern.
expect(dqValidate(rules, 'nope')).toBeFalse()
// Passes both.
expect(dqValidate(rules, 'ABC1234')).toBeTrue()
})
})
@@ -1,8 +1,14 @@
import { DQRule } from '../models/dq-rules.model'
import { specialMissingNumericValidator } from './hot-custom-validators'
import { isRegexRuleExempt } from '../utils/isRegexRuleExempt'
import { parseRegexRule } from '../utils/parseRegexRule'
const dqValidation: {
[key: string]: (value: any, ruleValue: string | number) => boolean
[key: string]: (
value: any,
ruleValue: string | number,
isNumeric?: boolean
) => boolean
} = {
CASE: (value: any, ruleValue: string | number): boolean => {
switch (ruleValue) {
@@ -46,13 +52,37 @@ const dqValidation: {
},
NOTNULL: (value: any, ruleValue: string | number): boolean => {
return value !== undefined && value !== null && value.toString().length > 0
},
// Pattern is used as authored, not auto-anchored — a rule author who
// wants a full-value match must write ^...$ themselves.
HARDREGEX: (
value: any,
ruleValue: string | number,
isNumeric: boolean = false
): boolean => {
if (isRegexRuleExempt(value, isNumeric)) return true
try {
return parseRegexRule(ruleValue.toString()).test(value.toString())
} catch (e) {
console.warn(
`HARDREGEX - invalid pattern, treated as always-valid: ${ruleValue}`
)
return true
}
}
}
export const dqValidate = (dqRules: DQRule[], value: any): boolean => {
export const dqValidate = (
dqRules: DQRule[],
value: any,
isNumeric: boolean = false
): boolean => {
for (let detail of dqRules) {
if (dqValidation[detail.RULE_TYPE]) {
if (!dqValidation[detail.RULE_TYPE](value, detail.RULE_VALUE)) {
if (
!dqValidation[detail.RULE_TYPE](value, detail.RULE_VALUE, isNumeric)
) {
console.warn(
`DQ Invalid Reason: ${
detail.RULE_TYPE
@@ -1,4 +1,5 @@
<div *ngIf="options$ | async as options" class="excel-password-root">
@if (options$ | async; as options) {
<div class="excel-password-root">
<clr-modal
[clrModalOpen]="options.open"
[clrModalSize]="'md'"
@@ -21,13 +22,16 @@
</div>
<div class="modal-footer">
<div>
<p *ngIf="options.error" class="m-0 color-red">
Sorry that didn't work, try again.
</p>
@if (options.error) {
<p class="m-0 color-red">Sorry that didn't work, try again.</p>
}
</div>
<div class="buttons">
<button type="button" class="btn btn-sm btn-outline" (click)="close()">
<button
type="button"
class="btn btn-sm btn-outline"
(click)="close()"
>
Cancel
</button>
<button
@@ -41,4 +45,5 @@
</div>
</div>
</clr-modal>
</div>
</div>
}
@@ -29,17 +29,21 @@
></clr-icon>
<clr-signpost-content [clrPosition]="'bottom-middle'" *clrIfOpen>
<p *ngFor="let request of requests">
@for (request of requests; track request) {
<p>
<code>{{ request.program }}</code>
&nbsp;
<span class="running" *ngIf="request.running">loading</span>
<span class="success" *ngIf="!request.running && request.successful"
>loaded</span
>
<span class="fail" *ngIf="!request.running && !request.successful"
>failed</span
>
@if (request.running) {
<span class="running">loading</span>
}
@if (!request.running && request.successful) {
<span class="success">loaded</span>
}
@if (!request.running && !request.successful) {
<span class="fail">failed</span>
}
</p>
}
</clr-signpost-content>
</clr-signpost>
</div>
@@ -9,33 +9,38 @@
<p class="mt-0 d-inline-block">App Location: {{ sasjsConfig.appLoc }}</p>
</h3>
<div class="modal-body h-70vh">
<p *ngIf="sasjsRequests?.length! < 1" class="no-reqs">No requests made</p>
@if (sasjsRequests.length < 1) {
<p class="no-reqs">No requests made</p>
}
<clr-stack-view *ngIf="opened && sasjsRequests?.length! > 0">
<clr-stack-block
*ngFor="let programLog of sasjsRequests; let i = index"
[id]="'request_' + i"
[clrStackViewLevel]="1"
>
@if (opened && sasjsRequests.length > 0) {
<clr-stack-view>
@for (programLog of sasjsRequests; track programLog; let i = $index) {
<clr-stack-block [id]="'request_' + i" [clrStackViewLevel]="1">
<clr-stack-label>
{{ programLog.serviceLink }}
<span class="ml-10">
<span *ngIf="programLog.logWarnings?.length! > 0">
@if (programLog.logWarnings?.length! > 0) {
<span>
<clr-icon
class="mb-5 color-green"
shape="exclamation-circle"
></clr-icon>
</span>
<span *ngIf="programLog.logErrors?.length! > 0">
}
@if (programLog.logErrors?.length! > 0) {
<span>
<clr-icon
class="mb-5 color-red"
shape="exclamation-triangle"
></clr-icon>
</span>
}
</span>
</clr-stack-label>
<clr-stack-content>{{ programLog.parsedTimestamp }}</clr-stack-content>
<clr-stack-content>{{
programLog.parsedTimestamp
}}</clr-stack-content>
<clr-stack-block class="content">
<clr-stack-content>
<clr-tabs>
@@ -44,88 +49,100 @@
<ng-template [(clrIfActive)]="sasLogActive">
<clr-tab-content>
<clr-tree>
<clr-tree-node
*ngIf="programLog.logErrors"
class="err-links"
>
@if (programLog.logErrors) {
<clr-tree-node class="err-links">
<clr-icon
class="color-red"
shape="exclamation-triangle"
></clr-icon>
Errors ({{ programLog.logErrors.length! }})
@for (
err of programLog.logErrors || [];
track err
) {
<clr-tree-node
(click)="goToLogLine(err, 'request_' + i, 'error')"
*ngFor="let err of programLog.logErrors || []"
(click)="
goToLogLine(err, 'request_' + i, 'error')
"
>
<button class="clr-treenode-link">
{{ err }}
</button>
</clr-tree-node>
}
</clr-tree-node>
<clr-tree-node
*ngIf="programLog.logWarnings"
class="err-links"
>
}
@if (programLog.logWarnings) {
<clr-tree-node class="err-links">
<clr-icon
class="color-green"
shape="exclamation-circle"
></clr-icon>
Warnings ({{ programLog.logWarnings.length! }})
@for (
warning of programLog.logWarnings || [];
track warning
) {
<clr-tree-node
(click)="
goToLogLine(warning, 'request_' + i, 'warning')
goToLogLine(
warning,
'request_' + i,
'warning'
)
"
*ngFor="let warning of programLog.logWarnings || []"
>
<button class="clr-treenode-link">
{{ warning }}
</button>
</clr-tree-node>
}
</clr-tree-node>
}
</clr-tree>
@if (serverType !== 'SASVIYA') {
<div
*ngIf="serverType !== 'SASVIYA'"
class="log-wrapper saslog"
[innerHTML]="programLog.logFile"
></div>
}
@if (serverType === 'SASVIYA') {
<div
*ngIf="serverType === 'SASVIYA'"
class="log-wrapper saslog viya"
[textContent]="programLog.logFile"
></div>
}
@if (programLog.logFile.length > 0) {
<button
*ngIf="programLog.logFile?.length! > 0"
(click)="downloadLog(programLog.logFile)"
type="button"
class="btn btn-link"
>
Download
</button>
}
</clr-tab-content>
</ng-template>
</clr-tab>
<clr-tab>
<button clrTabLink>SAS Source Code</button>
<ng-template [(clrIfActive)]="sasSourceCodeActive">
<clr-tab-content>
<div class="log-wrapper">{{ programLog.sourceCode }}</div>
<div class="log-wrapper">
{{ programLog.sourceCode }}
</div>
@if (programLog.sourceCode.length > 0) {
<button
*ngIf="programLog.sourceCode?.length! > 0"
(click)="downloadSourceCode(programLog.sourceCode)"
type="button"
class="btn btn-link"
>
Download
</button>
}
</clr-tab-content>
</ng-template>
</clr-tab>
<clr-tab>
<button clrTabLink>SAS Generated Code</button>
<ng-template [(clrIfActive)]="sasGeneratedCodeActive">
@@ -133,19 +150,22 @@
<div class="log-wrapper">
{{ programLog.generatedCode }}
</div>
@if (programLog.generatedCode.length > 0) {
<button
*ngIf="programLog.generatedCode?.length! > 0"
(click)="downloadGeneratedCode(programLog.generatedCode)"
(click)="
downloadGeneratedCode(programLog.generatedCode)
"
type="button"
class="btn btn-link"
>
Download
</button>
}
</clr-tab-content>
</ng-template>
</clr-tab>
<clr-tab *ngIf="programLog.SASWORK">
@if (programLog.SASWORK) {
<clr-tab>
<clr-dropdown
[clrCloseMenuOnItemClick]="true"
class="work-tables-dropdown"
@@ -156,72 +176,85 @@
<clr-icon shape="caret down"></clr-icon>
</button>
<clr-dropdown-menu *clrIfOpen>
@for (
table of programLog.SASWORK | keyvalue;
track table
) {
<div
*ngFor="let table of programLog.SASWORK | keyvalue"
(click)="programLog.selectedTable = $any(table).key"
[class.selected]="programLog.selectedTable === table.key"
(click)="
programLog.selectedTable = $any(table).key
"
[class.selected]="
programLog.selectedTable === table.key
"
class="dropdown-item"
clrDropdownItem
>
{{ table.key }}
</div>
}
</clr-dropdown-menu>
</clr-dropdown>
<ng-template [(clrIfActive)]="tablesActive">
<clr-tab-content>
<p *ngIf="programLog.selectedTable" class="mt-10">
@if (programLog.selectedTable) {
<p class="mt-10">
<strong
>{{ programLog.selectedTable }} (first 10 rows)
</strong>
</p>
}
<div class="min-h-50vh">
<table
*ngIf="programLog.selectedTable"
class="table mt-10"
>
@if (programLog.selectedTable) {
<table class="table mt-10">
<thead>
<tr>
<th
*ngFor="
let column of programLog.SASWORK[
@for (
column of programLog.SASWORK[
programLog.selectedTable
].first10rows[0] | keyvalue
"
>
].first10rows[0] | keyvalue;
track column
) {
<th>
{{ column.key }}
</th>
}
</tr>
</thead>
<tbody>
<tr
*ngFor="
let row of programLog.SASWORK[
@for (
row of programLog.SASWORK[
programLog.selectedTable
].first10rows
"
>
<td
*ngFor="
let column of programLog.SASWORK[
].first10rows;
track row
) {
<tr>
@for (
column of programLog.SASWORK[
programLog.selectedTable
].first10rows[0] | keyvalue
"
>
].first10rows[0] | keyvalue;
track column
) {
<td>
{{ row[$any(column).key] }}
</td>
}
</tr>
}
</tbody>
</table>
}
</div>
</clr-tab-content>
</ng-template>
</clr-tab>
}
</clr-tabs>
</clr-stack-content>
</clr-stack-block>
</clr-stack-block>
}
</clr-stack-view>
}
</div>
</clr-modal>
@@ -6,70 +6,77 @@
[style.max-width.px]="sidebarMaxWidth"
[class.resizing]="resizing"
>
<div
*ngIf="isMainRoute('view')"
class="d-flex justify-content-center sub-dropdown"
>
@if (isMainRoute('view')) {
<div class="d-flex justify-content-center sub-dropdown">
<clr-dropdown>
<button class="dropdown-toggle btn btn-primary" clrDropdownTrigger>
{{ getSubPage() }}
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
</button>
<clr-dropdown-menu *clrIfOpen>
@if (isMainRoute('view')) {
<a
*ngIf="isMainRoute('view')"
clrVerticalNavLink
(click)="navigateToViewer()"
routerLinkActive="active"
>Data</a
>
}
@if (isMainRoute('view') && serverType === 'SASVIYA') {
<a
*ngIf="isMainRoute('view') && serverType === 'SASVIYA'"
clrVerticalNavLink
routerLink="/view/viya-api-explorer"
routerLinkActive="active"
>API Explorer</a
>
}
@if (isMainRoute('view') && serverType === 'SAS9') {
<a
*ngIf="isMainRoute('view') && serverType === 'SAS9'"
clrVerticalNavLink
routerLink="/view/lineage"
routerLinkActive="active"
>Lineage</a
>
}
@if (isMainRoute('view') && serverType === 'SAS9') {
<a
*ngIf="isMainRoute('view') && serverType === 'SAS9'"
clrVerticalNavLink
routerLink="/view/metadata"
routerLinkActive="active"
>Metadata</a
>
}
@if (isMainRoute('view')) {
<a
*ngIf="isMainRoute('view')"
clrVerticalNavLink
routerLink="/view/usernav"
routerLinkActive="active"
>
Users</a
>
}
@if (isMainRoute('approve')) {
<a
*ngIf="isMainRoute('approve')"
clrVerticalNavLink
routerLink="/review/approve/submitted"
routerLinkActive="active"
>Submitted</a
>
}
@if (isMainRoute('approve')) {
<a
*ngIf="isMainRoute('approve')"
clrVerticalNavLink
routerLink="/review/approve/toapprove"
routerLinkActive="active"
>To Approve</a
>
}
</clr-dropdown-menu>
</clr-dropdown>
</div>
<clr-tabs class="p-10" *ngIf="isMainRoute('view/usernav')">
}
@if (isMainRoute('view/usernav')) {
<clr-tabs class="p-10">
<clr-tab>
<button
[routerLink]="['/view/usernav/users']"
@@ -82,7 +89,6 @@
</button>
<clr-tab-content> </clr-tab-content>
</clr-tab>
<clr-tab>
<button
[routerLink]="['/view/usernav/groups']"
@@ -96,26 +102,27 @@
<clr-tab-content *clrIfActive="isMainRoute('/view/usernav/groups')">
</clr-tab-content>
</clr-tab>
<clr-tab *ngIf="serverType === 'SAS9'">
@if (serverType === 'SAS9') {
<clr-tab>
<button
[routerLink]="['/view/usernav/roles']"
class="user-nav-btn"
clrTabLink
>
<p class="zero-margin">
<clr-icon aria-hidden="true" shape="blocks-group"></clr-icon> Roles
<clr-icon aria-hidden="true" shape="blocks-group"></clr-icon>
Roles
</p>
</button>
<clr-tab-content *clrIfActive="isMainRoute('/view/usernav/roles')">
</clr-tab-content>
</clr-tab>
}
</clr-tabs>
}
<div
*ngIf="isMainRoute('home')"
class="d-flex justify-content-center sub-dropdown"
>
@if (isMainRoute('home')) {
<div class="d-flex justify-content-center sub-dropdown">
<clr-dropdown>
<button class="dropdown-toggle btn btn-primary" clrDropdownTrigger>
{{ getSubPage() }}
@@ -143,6 +150,7 @@
</clr-dropdown-menu>
</clr-dropdown>
</div>
}
<div class="nav-divider"></div>
@@ -1,24 +1,25 @@
<label
*ngIf="label"
@if (label) {
<label
[class.secondLabelActive]="secondLabel && secondLabel.length > 0"
class="clr-control-label"
>
>
<span
(click)="onChangeLabel('first')"
[class.value-type-selected]="labelSelected === 'first'"
>{{ label }}</span
>
<ng-container *ngIf="secondLabel">
@if (secondLabel) {
/
<span
(click)="onChangeLabel('second')"
[class.value-type-selected]="labelSelected === 'second'"
>{{ secondLabel }}</span
>
</ng-container>
</label>
<ng-container [ngSwitch]="type">
<ng-container *ngSwitchCase="'date'">
}
</label>
}
@switch (type) {
@case ('date') {
<clr-date-container>
<input
type="date"
@@ -36,8 +37,8 @@
[disabled]="this.disabled"
/>
</clr-date-container>
</ng-container>
<ng-container *ngSwitchCase="'time'">
}
@case ('time') {
<input
type="time"
step="1"
@@ -53,8 +54,8 @@
(mouseout)="onInputMouseOut()"
[disabled]="this.disabled"
/>
</ng-container>
<ng-container *ngSwitchCase="'text'">
}
@case ('text') {
<app-autocomplete
#input
class="input-val"
@@ -71,5 +72,5 @@
>
<ng-content></ng-content>
</app-autocomplete>
</ng-container>
</ng-container>
}
}
@@ -28,9 +28,8 @@
<label>I have read and agree to the terms of above.</label>
</clr-checkbox-wrapper>
<clr-spinner
*ngIf="agreeBoxLocked && agreeClicked"
clrInline="small"
></clr-spinner>
@if (agreeBoxLocked && agreeClicked) {
<clr-spinner clrInline="small"></clr-spinner>
}
</div>
</div>
@@ -1,7 +1,7 @@
<div class="header-actions">
<div class="d-flex clr-align-items-center">
@if (!darkMode.value) {
<cds-icon
*ngIf="!darkMode.value"
(click)="toggleDarkMode(true)"
size="md"
class="theme-toggle-icon cursor-pointer"
@@ -9,8 +9,9 @@
inverse="false"
solid="false"
></cds-icon>
}
@if (!!darkMode.value) {
<cds-icon
*ngIf="!!darkMode.value"
(click)="toggleDarkMode(false)"
size="md"
class="theme-toggle-icon cursor-pointer"
@@ -18,6 +19,7 @@
inverse="false"
solid="false"
></cds-icon>
}
</div>
<app-loading-indicator></app-loading-indicator>
@@ -25,29 +27,31 @@
<clr-dropdown class="app-nav-dropdown">
<button class="nav-text color-white user-nav-toggle" clrDropdownToggle>
<span>{{ userName }}</span>
<span *ngIf="userName !== 'Not logged in' && isViya"
@if (userName !== 'Not logged in' && isViya) {
<span
><img
class="avatar-img"
src="{{ getPictureUrl() }}"
alt="profile picture"
/></span>
}
@if (!sasjsConfig.debug) {
<span
class="badge badge-danger"
*ngIf="!sasjsConfig.debug"
[class.hidden]="failedReqs.length === 0"
>{{ failedReqs.length }}</span
>
}
@if (sasjsConfig.debug) {
<span
class="badge badge-info"
*ngIf="sasjsConfig.debug"
[class.hidden]="debugLogs.length === 0"
>{{ debugLogs.length }}</span
>
<clr-icon
*ngIf="!isViya"
aria-hidden="true"
shape="caret down"
></clr-icon>
}
@if (!isViya) {
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
}
</button>
<clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
<div #dropdownItemDebug class="debug-switch-item" clrDropdownItem>
@@ -71,7 +75,7 @@
<span>SAS Requests</span>
</a>
<ng-container *ngIf="!isDeployPage">
@if (!isDeployPage) {
<a
target="_blank"
href="https://docs.datacontroller.io"
@@ -79,7 +83,7 @@
>
<span class="dropdown-text">Documentation</span>
</a>
</ng-container>
}
<div class="separator"></div>
<a href="..." routerLink="/system" clrDropdownItem>
@@ -18,4 +18,60 @@ describe('buildColInfoHtml', () => {
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.'
)
})
it('appends a HARDREGEX line when only a HARDREGEX value is provided', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml('SOME_CHAR', colInfo, '/^[A-Z]+$/i', undefined)
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>HARDREGEX: /^[A-Z]+$/i'
)
})
it('appends a SOFTREGEX line when only a SOFTREGEX value is provided', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml('SOME_CHAR', colInfo, undefined, '/^[a-z]+$/')
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>SOFTREGEX: /^[a-z]+$/'
)
})
it('appends only the HARDREGEX line when a column has both rules, since HARDREGEX is the one applied', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml('SOME_CHAR', colInfo, '/^[A-Z]+$/i', '/^.{5,10}$/')
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>HARDREGEX: /^[A-Z]+$/i'
)
})
it('omits the REGEX line when no regex rule value is provided', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(buildColInfoHtml('SOME_CHAR', colInfo)).not.toContain('REGEX:')
})
})
+16 -2
View File
@@ -7,9 +7,23 @@ import { DataFormat } from '../../models/sas/common/DateFormat'
*/
export function buildColInfoHtml(
colName: string,
colInfo?: DataFormat
colInfo?: DataFormat,
hardRegexValue?: string,
softRegexValue?: string
): string {
if (!colInfo) return 'No info found'
return `NAME: ${colName}<br>LABEL: ${colInfo.label}<br>TYPE: ${colInfo.type}<br>LENGTH: ${colInfo.length}<br>FORMAT: ${colInfo.format}`
let html = `NAME: ${colName}<br>LABEL: ${colInfo.label}<br>TYPE: ${colInfo.type}<br>LENGTH: ${colInfo.length}<br>FORMAT: ${colInfo.format}`
// Only ever one REGEX rule is applied per column: when both HARDREGEX
// and SOFTREGEX exist, SOFTREGEX is ignored entirely (same precedence as
// makeRegexWarningRenderer / DcValidator.failsSoftRegex). Show only the
// rule that is applied.
if (hardRegexValue) {
html += `<br>HARDREGEX: ${hardRegexValue}`
} else if (softRegexValue) {
html += `<br>SOFTREGEX: ${softRegexValue}`
}
return html
}
@@ -0,0 +1,63 @@
import { retryOnce } from './retry-once'
describe('retryOnce', () => {
it("resolves with the first attempt's value and never waits when it succeeds", async () => {
const requestFn = jasmine.createSpy('requestFn').and.resolveTo('ok')
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
const result = await retryOnce(requestFn, { wait })
expect(result).toBe('ok')
expect(requestFn).toHaveBeenCalledTimes(1)
expect(wait).not.toHaveBeenCalled()
})
it('retries exactly once after a rejection and resolves with the retry value', async () => {
const requestFn = jasmine
.createSpy('requestFn')
.and.returnValues(Promise.reject('first-error'), Promise.resolve('ok'))
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
const result = await retryOnce(requestFn, { wait })
expect(result).toBe('ok')
expect(requestFn).toHaveBeenCalledTimes(2)
expect(wait).toHaveBeenCalledTimes(1)
})
it('derives the pre-retry pause from random within [0, maxDelayMs)', async () => {
const requestFn = jasmine
.createSpy('requestFn')
.and.returnValues(Promise.reject('err'), Promise.resolve('ok'))
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
await retryOnce(requestFn, { wait, random: () => 0.5 })
expect(wait).toHaveBeenCalledWith(1500)
wait.calls.reset()
requestFn.and.returnValues(Promise.reject('err'), Promise.resolve('ok'))
await retryOnce(requestFn, { wait, random: () => 0 })
expect(wait).toHaveBeenCalledWith(0)
wait.calls.reset()
requestFn.and.returnValues(Promise.reject('err'), Promise.resolve('ok'))
await retryOnce(requestFn, { wait, random: () => 0.999999 })
expect(wait).toHaveBeenCalledWith(jasmine.any(Number))
expect(wait.calls.mostRecent().args[0]).toBeLessThan(3000)
})
it('rejects with the second error when both attempts fail, never trying a third time', async () => {
const requestFn = jasmine
.createSpy('requestFn')
.and.returnValues(
Promise.reject('first-error'),
Promise.reject('second-error')
)
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
await expectAsync(retryOnce(requestFn, { wait })).toBeRejectedWith(
'second-error'
)
expect(requestFn).toHaveBeenCalledTimes(2)
})
})
+33
View File
@@ -0,0 +1,33 @@
export interface RetryOnceOptions {
/** Upper bound of the random pre-retry delay, in ms. Default 3000. */
maxDelayMs?: number
/** Injectable for tests. Default Math.random. */
random?: () => number
/** Injectable for tests. Default setTimeout-based wait. */
wait?: (ms: number) => Promise<void>
}
const defaultWait = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
/**
* Runs requestFn; if it rejects, waits a random 0..maxDelayMs and tries
* exactly once more. A second rejection propagates as-is. Multiple
* concurrent DC instances (e.g. VA multi-page reports) can cause the
* startup request to time out even though a second attempt succeeds.
*/
export const retryOnce = async <T>(
requestFn: () => Promise<T>,
options?: RetryOnceOptions
): Promise<T> => {
const maxDelayMs = options?.maxDelayMs ?? 3000
const random = options?.random ?? Math.random
const wait = options?.wait ?? defaultWait
try {
return await requestFn()
} catch {
await wait(random() * maxDelayMs)
return requestFn()
}
}
@@ -8,13 +8,13 @@
>
<h3 class="modal-title">
Viewboxes
<span
*ngIf="licenceState.value.viewbox_limit !== Infinity"
class="licence-notice"
@if (licenceState.value.viewbox_limit !== Infinity) {
<span class="licence-notice"
>To unlock more then {{ licenceState.value.viewbox_limit }}
{{ licenceState.value.viewbox_limit === 1 ? 'viewbox' : 'viewboxes' }},
contact support&#64;datacontroller.io</span
>
}
</h3>
<div class="modal-body d-flex">
@@ -23,20 +23,23 @@
<hr class="w-100" />
<div *ngIf="librariesLoading" class="my-10-mx-auto text-center">
@if (librariesLoading) {
<div class="my-10-mx-auto text-center">
<clr-spinner clrMedium></clr-spinner>
</div>
}
<p *ngIf="viewboxLimitReached" class="m-0 viewbox-limit-notice">
Viewbox open limit reached
</p>
@if (viewboxLimitReached) {
<p class="m-0 viewbox-limit-notice">Viewbox open limit reached</p>
}
@if (viewboxModal) {
<dc-tree
*ngIf="viewboxModal"
[libraryList]="libraries"
(libraryOnClickEmitter)="libraryOnClick($event)"
(tableOnClickEmitter)="tableOnClick($event)"
></dc-tree>
}
</div>
<div class="currently-open">
@@ -82,25 +85,24 @@
<hr />
<p *ngIf="viewboxes.length < 1" class="text-center w-100 m-0">
@if (viewboxes.length < 1) {
<p class="text-center w-100 m-0">
Pick table from the list on the left
</p>
}
@for (viewbox of viewboxes; track viewbox) {
<div
*ngFor="let viewbox of viewboxes"
(click)="selectViewbox(viewbox)"
[class.selected]="selectedViewbox?.id === viewbox.id"
class="open-viewbox d-flex clr-justify-content-between clr-align-items-center"
>
<div class="d-flex clr-align-items-center">
#{{ viewbox.id }} {{ viewbox.library }}.{{ viewbox.table }}
<clr-spinner
*ngIf="viewbox.loadingData"
clrSmall
class="ml-3"
></clr-spinner>
@if (viewbox.loadingData) {
<clr-spinner clrSmall class="ml-3"></clr-spinner>
}
</div>
<div>
<a
(click)="resetPosSize(viewbox); stopPropagation($event)"
@@ -116,9 +118,8 @@
></clr-icon>
<span class="tooltip-content">Reset position and size</span>
</a>
@if (!viewbox.minimized) {
<a
*ngIf="!viewbox.minimized"
(click)="minimize(viewbox); stopPropagation($event)"
role="tooltip"
aria-haspopup="true"
@@ -127,9 +128,9 @@
<clr-icon aria-hidden="true" shape="minus" size="19"></clr-icon>
<span class="tooltip-content">Minimize viewbox</span>
</a>
}
@if (viewbox.minimized) {
<a
*ngIf="viewbox.minimized"
(click)="restore(viewbox); stopPropagation($event)"
role="tooltip"
aria-haspopup="true"
@@ -142,7 +143,7 @@
></clr-icon>
<span class="tooltip-content">Restore viewbox</span>
</a>
}
<a
(click)="close(viewbox); stopPropagation($event)"
role="tooltip"
@@ -154,6 +155,7 @@
</a>
</div>
</div>
}
<div
class="d-flex clr-justify-content-between clr-align-items-center mt-10"
@@ -186,7 +188,7 @@
} as hotColumns"
cdkDropListGroup
>
<ng-container *ngIf="hotColumns.colHeadersHidden">
@if (hotColumns.colHeadersHidden) {
<div class="cols-search">
<!-- <input #columnSearch clrInput (input)="columnSearchChange($event, selectedViewboxTable)" placeholder="Search columns"/> -->
<app-autocomplete
@@ -202,14 +204,16 @@
onColsearchChange(input, $event, hotColumns.colHeadersHidden)
"
>
<option *ngFor="let column of hotColumns.colHeadersHidden">
@for (column of hotColumns.colHeadersHidden; track column) {
<option>
{{ column }}
</option>
}
</app-autocomplete>
</div>
</ng-container>
}
<ng-container *ngIf="hotColumns.colHeadersVisible">
@if (hotColumns.colHeadersVisible) {
<p class="mt-0">Columns To Display (drag and re-order)</p>
<div
class="cols-list"
@@ -217,9 +221,9 @@
[cdkDropListData]="hotColumns.colHeadersVisible"
(cdkDropListDropped)="columnsDrop($event)"
>
@for (column of hotColumns.colHeadersVisible; track column) {
<div
class="col-box column-{{ column }}"
*ngFor="let column of hotColumns.colHeadersVisible"
[class.cursor-default-i]="hotColumns.headerPks.includes(column)"
[class.primaryKeyHeaderStyle]="
hotColumns.headerPks.includes(column)
@@ -231,28 +235,27 @@
<span>
{{ column }}
{{
selectedViewboxTable?.hotTable?.$dataformats.vars[column]
.type === 'num'
selectedViewboxTable?.hotTable?.$dataformats?.vars?.[column]
?.type === 'num'
? '(numeric)'
: ''
}}
<clr-icon
*ngIf="hotColumns.headerPks.includes(column)"
aria-hidden="true"
shape="key"
></clr-icon>
@if (hotColumns.headerPks.includes(column)) {
<clr-icon aria-hidden="true" shape="key"></clr-icon>
}
</span>
@if (!hotColumns.headerPks.includes(column)) {
<clr-icon
*ngIf="!hotColumns.headerPks.includes(column)"
(click)="onColRemove(column)"
aria-label="Remove column"
shape="trash"
class="cursor-pointer"
></clr-icon>
}
</div>
}
</div>
</ng-container>
}
</div>
</div>
</div>
@@ -268,8 +271,8 @@
</clr-modal>
<div class="viewboxes-container">
@for (viewbox of viewboxes; track viewbox; let index = $index) {
<div
*ngFor="let viewbox of viewboxes; let index = index"
(mousedown)="focusViewbox(viewbox)"
[class.focused]="viewbox.focused"
[style.display]="viewbox.minimized ? 'none' : ''"
@@ -293,20 +296,21 @@
<span class="table-title">
#{{ viewbox.id }} {{ viewbox.library }}.{{ viewbox.table }}
</span>
<div class="actions">
@if (!viewbox.collapsed) {
<clr-icon
*ngIf="!viewbox.collapsed"
(click)="collapse(viewbox)"
aria-label="Collapse"
shape="angle top"
></clr-icon>
}
@if (viewbox.collapsed) {
<clr-icon
*ngIf="viewbox.collapsed"
(click)="expand(viewbox)"
aria-label="Expand"
shape="angle down"
></clr-icon>
}
<clr-icon
(click)="minimize(viewbox)"
aria-label="Minimize"
@@ -341,17 +345,17 @@
placeholder="Search"
name="search-input"
/>
@if (!viewbox.searchLoading) {
<clr-icon
*ngIf="!viewbox.searchLoading"
(click)="searchTable(searchEl, viewbox)"
aria-label="Search"
shape="search"
></clr-icon>
<span *ngIf="viewbox.searchLoading" class="spinner spinner-inline">
Loading...
</span>
}
@if (viewbox.searchLoading) {
<span class="spinner spinner-inline"> Loading... </span>
}
</div>
<clr-checkbox-container>
<clr-checkbox-wrapper>
<input
@@ -364,18 +368,18 @@
</clr-checkbox-wrapper>
</clr-checkbox-container>
</div>
<div class="d-flex clr-align-items-center">
<span *ngIf="viewbox.loadingData" class="spinner spinner-sm"
>Loading...</span
>
@if (viewbox.loadingData) {
<span class="spinner spinner-sm">Loading...</span>
}
@if (!viewbox.loadingData) {
<clr-icon
*ngIf="!viewbox.loadingData"
(click)="reloadTableData(viewbox); searchEl.value = ''"
aria-label="Reload data"
shape="refresh"
class="click-icon"
></clr-icon>
}
<a
(click)="openFilter(viewbox); searchEl.value = ''"
role="tooltip"
@@ -406,20 +410,20 @@
*ngVar="getViewboxTableIndex(viewbox) as viewboxTableIndex"
[style.display]="viewbox.collapsed ? 'none' : ''"
>
<div
*ngIf="viewbox.loadingData && viewboxTableIndex < 0"
class="hot-spinner my-10-mx-auto text-center"
>
@if (viewbox.loadingData && viewboxTableIndex < 0) {
<div class="hot-spinner my-10-mx-auto text-center">
<clr-spinner clrMedium></clr-spinner>
</div>
}
@if (viewboxTableIndex > -1 && viewboxHotSettings.get(viewbox.id)) {
<hot-table
*ngIf="viewboxTableIndex > -1 && viewboxHotSettings.get(viewbox.id)"
[settings]="viewboxHotSettings.get(viewbox.id) || {}"
[id]="'hotTable_' + viewbox.id"
></hot-table>
}
</div>
</div>
}
</div>
<clr-modal
@@ -433,11 +437,9 @@
<span> #{{ filteringViewbox?.id }} {{ filterLibds }} </span>
</h3>
<div class="modal-body">
<app-query
#queryFilter
*ngIf="filter"
[viewboxId]="filteringViewbox?.id"
></app-query>
@if (filter) {
<app-query #queryFilter [viewboxId]="filteringViewbox?.id"></app-query>
}
</div>
<div class="modal-footer">
<button (click)="resetFilter()" type="button" class="btn btn-sm btn-link">
+15 -9
View File
@@ -1,15 +1,21 @@
<main class="content-area">
<div class="clr-col-md-12 card">
@if (!loaded) {
<div
*ngIf="!loaded"
class="h-80vh d-flex justify-content-center flex-column align-items-center"
>
<span class="spinner" *ngIf="!loaded"> Loading... </span>
<div *ngIf="!loaded">
@if (!loaded) {
<span class="spinner"> Loading... </span>
}
@if (!loaded) {
<div>
<h3>Loading submitted table</h3>
</div>
}
</div>
<div *ngIf="loaded">
}
@if (loaded) {
<div>
<div class="card-header" ng-if="loaded">
<div class="card-title text-center">
<h4 class="mt-0">Staged Data</h4>
@@ -69,8 +75,8 @@
>
View base table
</button>
@if (!(tableDetails?.['ALLOW_RESTORE'] === 'YES')) {
<button
*ngIf="!(tableDetails?.['ALLOW_RESTORE'] === 'YES')"
id="approval-btn"
class="btn btn-sm btn-success-outline text-center mr-5i"
[disabled]="
@@ -83,6 +89,7 @@
>
Approve
</button>
}
<button
class="btn btn-sm btn-info-outline text-center mr-5i"
(click)="goBack()"
@@ -98,10 +105,9 @@
>
<clr-icon shape="download" aria-hidden="true"></clr-icon>
</button>
<clr-tooltip>
@if (tableDetails?.['ALLOW_RESTORE'] === 'YES') {
<button
*ngIf="tableDetails?.['ALLOW_RESTORE'] === 'YES'"
(click)="revertChanges()"
clrTooltipTrigger
[clrLoading]="revertingChanges"
@@ -109,7 +115,6 @@
aria-label="Revert this and all subsequent changes"
>
REVERT
<clr-tooltip-content
clrPosition="bottom-left"
clrSize="lg"
@@ -118,6 +123,7 @@
<span> Revert this and all subsequent changes </span>
</clr-tooltip-content>
</button>
}
</clr-tooltip>
</div>
</div>
@@ -133,8 +139,8 @@
<!--[licenseKey]=null-->
</hot-table>
</div>
<div class="card-footer d-flex justify-content-center"></div>
</div>
}
</div>
</main>
+32 -41
View File
@@ -104,71 +104,68 @@
Free Tier:
<span class="dark">{{ licenceInfo?.demo ? 'YES' : 'NO' }}</span>
</p>
<p
*ngIf="licenceState.value.viewer_rows_allowed !== Infinity"
class="m-0"
>
@if (licenceState.value.viewer_rows_allowed !== Infinity) {
<p class="m-0">
Viewer rows limit:
<span class="dark">{{
licenceState.value.viewer_rows_allowed
}}</span>
</p>
<p
*ngIf="licenceState.value.editor_rows_allowed !== Infinity"
class="m-0"
>
}
@if (licenceState.value.editor_rows_allowed !== Infinity) {
<p class="m-0">
Editor rows limit:
<span class="dark">{{
licenceState.value.editor_rows_allowed
}}</span>
</p>
<p
*ngIf="licenceState.value.stage_rows_allowed !== Infinity"
class="m-0"
>
}
@if (licenceState.value.stage_rows_allowed !== Infinity) {
<p class="m-0">
Stage rows limit:
<span class="dark">{{
licenceState.value.stage_rows_allowed
}}</span>
</p>
<p
*ngIf="licenceState.value.history_rows_allowed !== Infinity"
class="m-0"
>
}
@if (licenceState.value.history_rows_allowed !== Infinity) {
<p class="m-0">
History rows limit:
<span class="dark">{{
licenceState.value.history_rows_allowed
}}</span>
</p>
<p
*ngIf="licenceState.value.submit_rows_limit !== Infinity"
class="m-0"
>
}
@if (licenceState.value.submit_rows_limit !== Infinity) {
<p class="m-0">
Submit rows limit:
<span class="dark">{{ licenceState.value.submit_rows_limit }}</span>
<span class="dark">{{
licenceState.value.submit_rows_limit
}}</span>
</p>
<p
*ngIf="licenceState.value.tables_in_library_limit !== Infinity"
class="m-0"
>
}
@if (licenceState.value.tables_in_library_limit !== Infinity) {
<p class="m-0">
Tables in library limit:
<span class="dark">{{
licenceState.value.tables_in_library_limit
}}</span>
</p>
<p *ngIf="licenceState.value.viewbox_limit !== Infinity" class="m-0">
}
@if (licenceState.value.viewbox_limit !== Infinity) {
<p class="m-0">
Viewboxes limit:
<span class="dark">{{ licenceState.value.viewbox_limit }}</span>
</p>
<p
*ngIf="licenceState.value.lineage_daily_limit !== Infinity"
class="m-0"
>
}
@if (licenceState.value.lineage_daily_limit !== Infinity) {
<p class="m-0">
Lineage daily limit:
<span class="dark">{{
licenceState.value.lineage_daily_limit
}}</span>
</p>
}
<p cds-text="label" class="m-0">
Viewboxes:
<span class="dark">{{
@@ -208,10 +205,10 @@
</clr-checkbox-wrapper>
</div> -->
<ng-container *ngIf="environmentInfo?.ISADMIN === 1">
<div *ngIf="serverType === 'SAS9'" class="admin-action">
@if (environmentInfo?.ISADMIN === 1) {
@if (serverType === 'SAS9') {
<div class="admin-action">
Refresh Data Lineage
<button
(click)="refreshDataLineage()"
[clrLoading]="refreshingDataLineage"
@@ -220,10 +217,9 @@
REFRESH
</button>
</div>
}
<div class="admin-action">
Refresh Data Catalog
<button
(click)="refreshDataCatalog()"
[clrLoading]="refreshingDataCatalog"
@@ -232,10 +228,8 @@
REFRESH
</button>
</div>
<div class="admin-action">
Download Configuration
<div class="libref-group">
<clr-tooltip class="libref-tooltip">
<label clrTooltipTrigger class="libref-label">
@@ -251,7 +245,6 @@
contain this, instead of the original.
</clr-tooltip-content>
</clr-tooltip>
<input
type="text"
class="clr-input libref-input"
@@ -269,15 +262,13 @@
DOWNLOAD
</button>
</div>
<div class="admin-action">
Update Licence Key
<button routerLink="/licensing/update" class="btn btn-info btn-sm">
UPDATE
</button>
</div>
</ng-container>
}
</div>
</div>
</div>
+86 -39
View File
@@ -1,6 +1,7 @@
<app-sidebar class="sidebar-height">
<clr-tree>
<clr-tree-node *ngIf="users" class="search-node">
@if (users) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -12,21 +13,22 @@
(keyup)="userListOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="userSearch = ''; userListOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
<ng-container *ngFor="let user of users">
}
@for (user of users; track user) {
@if (!user['hidden']) {
<clr-tree-node
(click)="userOnClick(user)"
*ngIf="!user['hidden']"
[class.active]="userInfo?.URI === user.URI"
>
<p class="m-0 cursor-pointer list-padding">
@@ -34,17 +36,22 @@
{{ user.NAME }}
</p>
</clr-tree-node>
</ng-container>
}
}
</clr-tree>
</app-sidebar>
<div class="content-area">
<div *ngIf="loading" class="loadingSpinner">
@if (loading) {
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
</div>
}
<div *ngIf="userData && !loading">
<div *ngIf="serverType !== ServerType.SasViya" class="clr-row">
@if (userData && !loading) {
<div>
@if (serverType !== ServerType.SasViya) {
<div class="clr-row">
<div class="clr-col-8">
<table class="table user-info">
<tbody>
@@ -62,7 +69,7 @@
</p>
</td>
</tr>
<ng-container *ngIf="serverType === ServerType.Sas9">
@if (serverType === ServerType.Sas9) {
<tr>
<td class="left">
Metadata Created: <b>{{ userInfo.METADATACREATED }}</b>
@@ -73,13 +80,14 @@
Metadata Updated: <b>{{ userInfo.METADATAUPDATED }}</b>
</td>
</tr>
</ng-container>
}
</tbody>
</table>
</div>
</div>
<div *ngIf="serverType === ServerType.SasViya && userInfo" class="clr-row">
}
@if (serverType === ServerType.SasViya && userInfo) {
<div class="clr-row">
<div class="clr-col-8">
<table class="table user-info">
<tbody>
@@ -104,15 +112,19 @@
</table>
</div>
</div>
}
<div class="clr-row">
<div class="clr-col-8">
<div class="card user-data">
<div *ngIf="serverType === ServerType.Sas9">
@if (serverType === ServerType.Sas9) {
<div>
<h3>EMAILS ({{ userEmailsCount || '0' }})</h3>
<h5 *ngIf="userEmailsCount == 0">No Emails Present</h5>
@if (userEmailsCount == 0) {
<h5>No Emails Present</h5>
}
<div class="table-container">
<table *ngIf="userEmailsCount != 0" class="table user-table">
@if (userEmailsCount != 0) {
<table class="table user-table">
<thead>
<tr>
<td class="width-50"><b>EMAIL</b></td>
@@ -120,31 +132,42 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let email of userEmails">
@for (email of userEmails; track email) {
<tr>
<td class="">{{ email.EMAIL }}</td>
<td class="">{{ email.TYPE }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
<hr *ngIf="serverType === ServerType.Sas9" />
}
@if (serverType === ServerType.Sas9) {
<hr />
}
<div>
<h3>GROUPS ({{ userGroupsCount || '0' }})</h3>
<h5 *ngIf="userGroupsCount == 0">No Groups Present</h5>
@if (userGroupsCount == 0) {
<h5>No Groups Present</h5>
}
<div class="table-container">
<table *ngIf="userGroupsCount != 0" class="table user-table">
@if (userGroupsCount != 0) {
<table class="table user-table">
<tbody>
<tr *ngFor="let group of userGroups">
@for (group of userGroups; track group) {
<tr>
@if (serverType === ServerType.SasViya) {
<td
*ngIf="serverType === ServerType.SasViya"
[routerLink]="'/view/usernav/groups/' + group.URI"
routerLinkActive="active"
class=""
>
{{ group.GROUPNAME }}
</td>
<ng-container *ngIf="serverType !== ServerType.SasViya">
}
@if (serverType !== ServerType.SasViya) {
<td
[routerLink]="
'/view/usernav/groups/' +
@@ -155,38 +178,57 @@
>
{{ group.GROUPNAME }}
</td>
</ng-container>
}
</tr>
}
</tbody>
</table>
}
</div>
</div>
<hr *ngIf="serverType === ServerType.Sas9" />
<div *ngIf="serverType === ServerType.Sas9">
@if (serverType === ServerType.Sas9) {
<hr />
}
@if (serverType === ServerType.Sas9) {
<div>
<h3>ROLES ({{ userRolesCount || '0' }})</h3>
<h5 *ngIf="userRolesCount == 0">No Roles Present</h5>
@if (userRolesCount == 0) {
<h5>No Roles Present</h5>
}
<div class="table-container">
<table *ngIf="userRolesCount != 0" class="table user-table">
@if (userRolesCount != 0) {
<table class="table user-table">
<tbody>
<tr *ngFor="let role of userRoles">
@for (role of userRoles; track role) {
<tr>
<td
[routerLink]="
'/view/usernav/roles/' + getRoleURI(role.ROLEURI)
'/view/usernav/roles/' +
getRoleURI(role.ROLEURI)
"
>
{{ role.ROLENAME }}
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
<hr *ngIf="serverType === ServerType.Sas9" />
<div *ngIf="serverType === ServerType.Sas9">
}
@if (serverType === ServerType.Sas9) {
<hr />
}
@if (serverType === ServerType.Sas9) {
<div>
<h3>LOGINS ({{ userLoginsCount || '0' }})</h3>
<h5 *ngIf="userLoginsCount == 0">No Logins Present</h5>
@if (userLoginsCount == 0) {
<h5>No Logins Present</h5>
}
<div class="table-container">
<table *ngIf="userLoginsCount != 0" class="table user-table">
@if (userLoginsCount != 0) {
<table class="table user-table">
<thead>
<tr>
<td class="width-50"><b>DOMAIN</b></td>
@@ -194,17 +236,22 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let login of userLogins">
@for (login of userLogins; track login) {
<tr>
<td class="">{{ login.DOMAIN }}</td>
<td class="">{{ login.USERID }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
<br />
</div>
</div>
</div>
</div>
}
</div>
+173 -136
View File
@@ -1,10 +1,13 @@
<app-sidebar (scrolledToBottom)="loadMoreLibraries()">
<div *ngIf="librariesLoading" class="my-10-mx-auto text-center">
@if (librariesLoading) {
<div class="my-10-mx-auto text-center">
<clr-spinner clrMedium></clr-spinner>
</div>
}
<clr-tree>
<clr-tree-node *ngIf="libraries" class="search-node">
@if (libraries) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -16,25 +19,24 @@
(keyup)="libraryOnFilter()"
autocomplete="off"
/>
@if (searchLibTreeInput.value.length < 1) {
<clr-icon aria-hidden="true" shape="search"></clr-icon>
}
@if (searchLibTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchLibTreeInput.value.length < 1"
aria-hidden="true"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLibTreeInput.value.length > 0"
(click)="librariesSearch = ''; libraryOnFilter()"
aria-label="Clear search"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
}
<ng-container *ngFor="let library of libraries">
@for (library of libraries; track library) {
@if (!library['hidden'] && library['inForeground']) {
<clr-tree-node
(click)="treeNodeClicked($event, library)"
*ngIf="!library['hidden'] && library['inForeground']"
[(clrExpanded)]="library['expanded']"
[clrLoading]="library['loadingTables'] && !library.tables"
[class.clr-expanded]="library['expanded']"
@@ -49,8 +51,8 @@
<clr-icon aria-hidden="true" shape="rack-server"></clr-icon>
{{ library.LIBRARYNAME }}
</p>
<clr-tree-node *ngIf="library['tables']" class="search-node">
@if (library['tables']) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -63,14 +65,11 @@
(keyup)="treeOnFilter(library, 'tables')"
autocomplete="off"
/>
@if (searchTreeInput.value.length < 1) {
<clr-icon aria-hidden="true" shape="search"></clr-icon>
}
@if (searchTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchTreeInput.value.length < 1"
aria-hidden="true"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchTreeInput.value.length > 0"
(click)="
searchTreeInput.value = '';
library['searchString'] = '';
@@ -79,65 +78,73 @@
aria-label="Clear search"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
<clr-tree-node
*ngFor="let libTable of library['tables']; let index = index"
>
}
@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
"
>
@if (libTable.length > 0) {
<button
clrTooltipTrigger
*ngIf="libTable.length > 0"
(click)="!tableLocked ? onTableClick(libTable, library) : ''"
(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"
aria-hidden="true"
shape="bolt"
></clr-icon>
<clr-icon
*ngSwitchCase="false"
aria-hidden="true"
shape="table"
></clr-icon>
</ng-container>
@switch (libTable.includes('-FC')) {
@case (true) {
<clr-icon aria-hidden="true" shape="bolt"></clr-icon>
}
@case (false) {
<clr-icon aria-hidden="true" shape="table"></clr-icon>
}
}
{{ libTable.replace('-FC', '') }}
</button>
<ng-container *ngIf="tableLocked">
}
@if (tableLocked) {
<clr-tooltip-content
clrPosition="bottom-right"
clrSize="lg"
*clrIfOpen
>
<span>
To unlock all tables, contact support&#64;datacontroller.io
To unlock all tables, contact
support&#64;datacontroller.io
</span>
</clr-tooltip-content>
</ng-container>
}
</clr-tooltip>
</clr-tree-node>
}
</clr-tree-node>
</ng-container>
}
}
</clr-tree>
<div *ngIf="librariesPaging" class="w-100 text-center">
@if (librariesPaging) {
<div class="w-100 text-center">
<span class="spinner spinner-sm"> Loading... </span>
</div>
}
</app-sidebar>
<div class="content-area">
<div class="modal z-index-highest" *ngIf="nullVariables">
@if (nullVariables) {
<div class="modal z-index-highest">
<div class="modal-dialog" role="dialog" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
@@ -161,8 +168,11 @@
</div>
</div>
</div>
}
<div *ngIf="nullVariables" class="modal-backdrop" aria-hidden="true"></div>
@if (nullVariables) {
<div class="modal-backdrop" aria-hidden="true"></div>
}
<clr-modal [(clrModalOpen)]="openDownload" [clrModalSize]="'md'">
<h3 class="modal-title center text-center color-darker-gray">Download</h3>
<div class="modal-body">
@@ -289,7 +299,9 @@
Filter for table:<span> {{ libTab }} </span>
</h3>
<div class="modal-body">
<app-query #queryFilter *ngIf="filter"></app-query>
@if (filter) {
<app-query #queryFilter></app-query>
}
</div>
<div class="modal-footer">
<button (click)="resetFilter()" type="button" class="btn btn-sm btn-link">
@@ -328,19 +340,19 @@
</div>
</clr-modal>
<div class="loadingSpinner" *ngIf="loadingTableView">
@if (loadingTableView) {
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
<div class="mt-10">
<p cds-text="section">Loading Table Viewer</p>
</div>
</div>
}
@if (!loadingTableView) {
<div class="card no-borders h-100 d-flex clr-flex-column">
@if (table) {
<div
class="card no-borders h-100 d-flex clr-flex-column"
*ngIf="!loadingTableView"
>
<div
*ngIf="table"
class="header-row clr-row justify-content-between clr-justify-content-center w-100 m-0"
>
<section class="form-block table-search-wrapper sw clr-col-md">
@@ -354,17 +366,17 @@
placeholder="Search"
name="search-input"
/>
@if (!searchLoading) {
<clr-icon
*ngIf="!searchLoading"
(click)="searchTable(searchEl)"
aria-label="Search"
shape="search"
></clr-icon>
<span *ngIf="searchLoading" class="spinner spinner-inline">
Loading...
</span>
}
@if (searchLoading) {
<span class="spinner spinner-inline"> Loading... </span>
}
</div>
<clr-checkbox-container>
<clr-checkbox-wrapper>
<input
@@ -378,7 +390,6 @@
</clr-checkbox-container>
</form>
</section>
<div class="title-col clr-col-auto clr-flex-column clr-flex-sm-row">
<p
cds-text="section"
@@ -393,19 +404,19 @@
class="is-highlight cursor-pointer"
size="24"
></clr-icon>
@if (tableTitle?.includes('-FC')) {
<clr-icon
*ngIf="tableTitle?.includes('-FC')"
aria-hidden="true"
shape="bolt"
class="color-yellow mr-5"
></clr-icon>
<span clrTooltipTrigger *ngIf="tableTitle && tableTitle.length > 0">
}
@if (tableTitle && tableTitle.length > 0) {
<span clrTooltipTrigger>
{{ tableTitle?.replace('-FC', '') }}
</span>
<ng-container *ngIf="this.dsNote && this.dsNote.length > 0">
}
@if (this.dsNote && this.dsNote.length > 0) {
<clr-tooltip-content
clrPosition="bottom-left"
clrSize="lg"
@@ -413,29 +424,29 @@
>
{{ this.dsNote }}
</clr-tooltip-content>
</ng-container>
}
</clr-tooltip>
<ng-container *ngIf="tableTitle && tableTitle.length > 0">
<span *ngIf="numberOfRows !== null">
@if (tableTitle && tableTitle.length > 0) {
@if (numberOfRows !== null) {
<span>
({{ numberOfRows | thousandSeparator: ',' }}
{{ numberOfRows! === 1 ? 'row' : 'rows' }}, {{ filterCols.length
{{ numberOfRows! === 1 ? 'row' : 'rows' }},
{{ filterCols.length
}}{{ filterCols.length === 1 ? ' col' : ' cols' }})
</span>
}
<clr-icon
(click)="reloadTableData()"
aria-label="Reload data"
class="refresh-table"
shape="refresh"
></clr-icon>
</ng-container>
}
</p>
</div>
<div class="options-col clr-col-md">
@if (tableTitle && !abortActive) {
<clr-dropdown
*ngIf="tableTitle && !abortActive"
class="options-dropdown"
[clrCloseMenuOnItemClick]="true"
>
@@ -452,18 +463,18 @@
<clr-icon aria-hidden="true" shape="view-cards"></clr-icon>
<span>Viewboxes</span>
</div>
<div
*ngIf="tableEditExists()"
(click)="editTable()"
clrDropdownItem
>
@if (tableEditExists()) {
<div (click)="editTable()" clrDropdownItem>
<clr-icon aria-hidden="true" shape="pencil"></clr-icon>
<span>Edit</span>
</div>
<div *ngIf="tableuri" (click)="goToLineage()" clrDropdownItem>
}
@if (tableuri) {
<div (click)="goToLineage()" clrDropdownItem>
<clr-icon aria-hidden="true" shape="switch"></clr-icon>
<span>Lineage</span>
</div>
}
<div (click)="openQb()" clrDropdownItem>
<clr-icon aria-hidden="true" shape="filter"></clr-icon>
<span>Filter</span>
@@ -473,29 +484,31 @@
<span>Download</span>
</div>
<div (click)="showWebQuery()" clrDropdownItem>
<clr-icon aria-hidden="true" shape="download-cloud"></clr-icon>
<clr-icon
aria-hidden="true"
shape="download-cloud"
></clr-icon>
<span>Web Query URL</span>
</div>
</clr-dropdown-menu>
</clr-dropdown>
}
</div>
<div
*ngIf="
queryText !== '1=1' && !['', ' '].includes(queryText) && !abortActive
"
class="clr-col-md-12 infoBar"
>
@if (
queryText !== '1=1' &&
!['', ' '].includes(queryText) &&
!abortActive
) {
<div class="clr-col-md-12 infoBar">
<span
>FILTER : <b>{{ queryText }}</b></span
>
</div>
}
</div>
<div
*ngIf="!lib && !table && !noDataReqErr && !noData"
class="no-table-selected"
>
}
@if (!lib && !table && !noDataReqErr && !noData) {
<div class="no-table-selected">
<img
src="images/select-library.png"
class="select-table-icon"
@@ -505,15 +518,14 @@
Please select a library
</h3>
</div>
<ng-container *ngIf="!noData && !noDataReqErr && !table && lib">
}
@if (!noData && !noDataReqErr && !table && lib) {
<div
class="header-row clr-row border-bottom-divider justify-content-between w-100 m-0"
>
<section
class="form-block table-search-wrapper sw clr-col-md"
></section>
<div class="title-col clr-col-auto mt-15 mb-15">
<p cds-text="section">
{{ lib }}
@@ -525,18 +537,15 @@
shape="refresh"
></clr-icon>
</div>
<div class="options-col clr-col-md"></div>
</div>
<div class="text-center mt-10">
<clr-spinner *ngIf="libinfo === null" clrMedium></clr-spinner>
@if (libinfo === null) {
<clr-spinner clrMedium></clr-spinner>
}
</div>
<div
*ngIf="libinfo !== null"
class="no-table-selected-info pointer-events-none"
>
@if (libinfo !== null) {
<div class="no-table-selected-info pointer-events-none">
<img
src="images/select-table.png"
class="select-table-icon"
@@ -546,9 +555,11 @@
Please select a table
</p>
</div>
<div *ngIf="libinfo !== null" class="libinfo m-0 clr-row">
<p *ngIf="libinfo.length < 1" class="text-center m-0 w-100">
}
@if (libinfo !== null) {
<div class="libinfo m-0 clr-row">
@if (libinfo.length < 1) {
<p class="text-center m-0 w-100">
No library info found. Click
<clr-icon
(click)="reloadLibInfo()"
@@ -558,91 +569,116 @@
></clr-icon>
button to refresh.
</p>
<ng-container *ngIf="libinfo.length > 0">
}
@if (libinfo.length > 0) {
<table>
<tr *ngIf="libinfo[0].ENGINE !== ''">
@if (libinfo[0].ENGINE !== '') {
<tr>
<td class="m-0">ENGINE:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].ENGINE : '' }}
</td>
</tr>
<tr *ngIf="libinfo[0].LIBID !== ''">
}
@if (libinfo[0].LIBID !== '') {
<tr>
<td class="m-0">LIBID:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].LIBID : '' }}
</td>
</tr>
<tr *ngIf="libinfo[0].LIBNAME !== ''">
}
@if (libinfo[0].LIBNAME !== '') {
<tr>
<td class="m-0">LIBNAME:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].LIBNAME : '' }}
</td>
</tr>
<tr *ngIf="libinfo[0].LIBSIZE !== null">
}
@if (libinfo[0].LIBSIZE !== null) {
<tr>
<td class="m-0">LIBSIZE:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? (libinfo[0].LIBSIZE | convertSize) : '' }}
</td>
</tr>
<tr *ngIf="libinfo[0].OWNERS !== ''">
}
@if (libinfo[0].OWNERS !== '') {
<tr>
<td class="m-0">OWNERS:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].OWNERS : '' }}
</td>
</tr>
<tr *ngIf="libinfo[0].PATHS !== ''">
}
@if (libinfo[0].PATHS !== '') {
<tr>
<td class="m-0">PATHS:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].PATHS : '' }}
</td>
</tr>
<tr *ngIf="libinfo[0].PERMS !== ''">
}
@if (libinfo[0].PERMS !== '') {
<tr>
<td class="m-0">PERMS:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].PERMS : '' }}
</td>
</tr>
<tr *ngIf="libinfo[0].SCHEMAS !== ''">
}
@if (libinfo[0].SCHEMAS !== '') {
<tr>
<td class="m-0">SCHEMAS:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].SCHEMAS : '' }}
</td>
</tr>
<tr *ngIf="libinfo[0].TABLE_CNT !== null">
}
@if (libinfo[0].TABLE_CNT !== null) {
<tr>
<td class="m-0">TABLE_CNT:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].TABLE_CNT : '' }}
</td>
</tr>
<tr *ngIf="libinfo[0].CATALOG_CNT !== null">
}
@if (libinfo[0].CATALOG_CNT !== null) {
<tr>
<td class="m-0">CATALOG_CNT:</td>
<td class="m-0 font-bold">
{{ libinfo[0] ? libinfo[0].CATALOG_CNT : '' }}
</td>
</tr>
}
</table>
</ng-container>
}
</div>
</ng-container>
<div *ngIf="noData || noDataReqErr" class="card-block noData">
}
}
@if (noData || noDataReqErr) {
<div class="card-block noData">
<clr-icon
aria-hidden="true"
shape="warning-standard"
size="60"
class="is-info"
></clr-icon>
<h3 *ngIf="noData" class="text-center color-gray">
@if (noData) {
<h3 class="text-center color-gray">
No data found with given conditions
</h3>
<h3 *ngIf="noDataReqErr" class="text-center color-gray">
}
@if (noDataReqErr) {
<h3 class="text-center color-gray">
No data found due to sas request error
</h3>
}
</div>
<div *ngIf="!noData && !noDataReqErr && table" class="clr-flex-1">
}
@if (!noData && !noDataReqErr && table) {
<div class="clr-flex-1">
<div class="hot-wrapper clr-flex-1">
<hot-table
#hotInstance
@@ -654,21 +690,22 @@
</hot-table>
</div>
</div>
}
<div>
<p
*ngIf="
@if (
licenceState.value.viewer_rows_allowed !== Infinity &&
hotTable.data &&
hotTable.data.length > licenceState.value.viewer_rows_allowed
"
class="mt-2-i w-100 text-center"
>
To display more than {{ licenceState.value.viewer_rows_allowed }} rows,
contact <contact-link />
) {
<p class="mt-2-i w-100 text-center">
To display more than
{{ licenceState.value.viewer_rows_allowed }} rows, contact
<contact-link />
</p>
}
</div>
</div>
}
</div>
<app-dataset-info
@@ -8,34 +8,35 @@
[(ngModel)]="collection"
(change)="updateSelectedCollection()"
>
<optgroup
*ngFor="let collection of collections | keyvalue"
[label]="collection.key"
>
<option
*ngFor="let endpoint of collections[collection.key] | keyvalue"
value="{{ endpoint.value }}"
>
@for (collection of collections | keyvalue; track collection) {
<optgroup [label]="collection.key">
@for (
endpoint of collections[collection.key] | keyvalue;
track endpoint
) {
<option value="{{ endpoint.value }}">
{{ endpoint.key }}
</option>
}
</optgroup>
}
</select>
</clr-select-container>
</div>
<p
*ngIf="endpointLinks.length < 1 && !collectionLoading"
class="w-100 text-center"
>
Select collection to start
</p>
@if (endpointLinks.length < 1 && !collectionLoading) {
<p class="w-100 text-center">Select collection to start</p>
}
<div class="d-flex clr-justify-content-center">
<clr-spinner *ngIf="collectionLoading" clrMedium></clr-spinner>
@if (collectionLoading) {
<clr-spinner clrMedium></clr-spinner>
}
</div>
<clr-tree>
<clr-tree-node *ngIf="endpointLinks.length > 0" class="search-node">
@if (endpointLinks.length > 0) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
appStealFocus
@@ -47,21 +48,22 @@
(keyup)="linksListOnFilter()"
autocomplete="off"
/>
@if (searchLinksTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchLinksTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchLinksTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchLinksTreeInput.value.length > 0"
(click)="linksSearch = ''; linksListOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
<ng-container *ngFor="let link of endpointLinks">
}
@for (link of endpointLinks; track link) {
@if (!link['hidden']) {
<clr-tree-node
(click)="treeNodeClicked($event, link, endpointLinks)"
*ngIf="!link['hidden']"
[(clrExpanded)]="link['expanded']"
[clrLoading]="!!link['loadingTables'] && !link.tables"
>
@@ -70,11 +72,13 @@
{{ link.method }} {{ link.rel }}
</p>
</clr-tree-node>
</ng-container>
}
}
</clr-tree>
<clr-tree>
<clr-tree-node *ngIf="endpointItems.length > 0" class="search-node">
@if (endpointItems.length > 0) {
<clr-tree-node class="search-node">
<div class="tree-search-wrapper">
<input
clrInput
@@ -85,21 +89,22 @@
(keyup)="itemsListOnFilter()"
autocomplete="off"
/>
@if (searchItemsTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchItemsTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchItemsTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchItemsTreeInput.value.length > 0"
(click)="itemsSearch = ''; itemsListOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
<ng-container *ngFor="let item of endpointItems">
}
@for (item of endpointItems; track item) {
@if (!item['hidden']) {
<clr-tree-node
(click)="treeNodeClicked($event, item, endpointItems)"
*ngIf="!item['hidden']"
[(clrExpanded)]="item['expanded']"
[clrLoading]="!!item['loadingTables'] && !item.tables"
>
@@ -108,7 +113,8 @@
{{ item.name || item.id }}
</p>
</clr-tree-node>
</ng-container>
}
}
</clr-tree>
</app-sidebar>
@@ -132,12 +138,14 @@
<h4 class="text-center m-0">
API Explorer
<clr-spinner *ngIf="endpointLoading" clrSmall></clr-spinner>
@if (endpointLoading) {
<clr-spinner clrSmall></clr-spinner>
}
</h4>
</div>
<hr class="w-100 light" />
<ng-container *ngIf="collection.length > 0">
@if (collection.length > 0) {
<div class="d-flex clr-align-items-center clr-justify-content-between">
<span class="d-flex clr-align-items-center">
GET
@@ -154,7 +162,6 @@
<clr-icon shape="copy"></clr-icon>
</button>
</span>
<div class="d-flex clr-align-items-center query-params">
<clr-input-container class="m-0">
<label>Start</label>
@@ -187,13 +194,15 @@
</div>
</div>
<pre class="json-area">
<ng-container *ngIf="endpointJson">
<ngx-json-viewer *ngIf="!rawJson" class="d-block" [json]="endpointJson" [expanded]="false" (segmentLinkClicked)="onJsonLinkClick($event)"></ngx-json-viewer>
<ng-container *ngIf="rawJson">
@if (endpointJson) {
@if (!rawJson) {
<ngx-json-viewer class="d-block" [json]="endpointJson" [expanded]="false" (segmentLinkClicked)="onJsonLinkClick($event)"></ngx-json-viewer>
}
@if (rawJson) {
{{ endpointJson | prettyjson }}
</ng-container>
</ng-container>
}
}
</pre>
</ng-container>
}
</div>
</div>
+36 -37
View File
@@ -1,7 +1,9 @@
<app-sidebar>
<div *ngIf="xlmapsLoading" class="my-10-mx-auto text-center">
@if (xlmapsLoading) {
<div class="my-10-mx-auto text-center">
<clr-spinner clrMedium></clr-spinner>
</div>
}
<clr-tree>
<clr-tree-node class="search-node">
@@ -16,19 +18,19 @@
(keyup)="xlmapListOnFilter()"
autocomplete="off"
/>
@if (searchXLMapTreeInput.value.length < 1) {
<clr-icon shape="search"></clr-icon>
}
@if (searchXLMapTreeInput.value.length > 0) {
<clr-icon
*ngIf="searchXLMapTreeInput.value.length < 1"
shape="search"
></clr-icon>
<clr-icon
*ngIf="searchXLMapTreeInput.value.length > 0"
(click)="searchString = ''; xlmapListOnFilter()"
shape="times"
></clr-icon>
}
</div>
</clr-tree-node>
<ng-container *ngFor="let xlmap of xlmaps">
@for (xlmap of xlmaps; track xlmap) {
<clr-tree-node>
<button
(click)="xlmapOnClick(xlmap)"
@@ -39,49 +41,51 @@
{{ xlmap.id }}
</button>
</clr-tree-node>
</ng-container>
}
</clr-tree>
</app-sidebar>
<div class="content-area">
<div *ngIf="!selectedXLMap" class="no-table-selected">
@if (!selectedXLMap) {
<div class="no-table-selected">
<img
src="images/select-table.png"
class="select-table-icon"
alt="select table icon"
/>
<p
*ngIf="xlmaps.length > 0"
class="text-center color-gray mt-10"
cds-text="section"
>
@if (xlmaps.length > 0) {
<p class="text-center color-gray mt-10" cds-text="section">
Please select a map
</p>
<p
*ngIf="xlmaps.length < 1"
class="text-center color-gray mt-10"
cds-text="section"
>
}
@if (xlmaps.length < 1) {
<p class="text-center color-gray mt-10" cds-text="section">
No excel map is found
</p>
}
</div>
}
<div class="loadingSpinner" *ngIf="isLoading">
@if (isLoading) {
<div class="loadingSpinner">
<span class="spinner"> Loading... </span>
<div>
<h4>{{ isLoadingDesc }}</h4>
</div>
</div>
}
@if (!isLoading && selectedXLMap) {
<div
appDragNdrop
(fileDraggedOver)="onShowUploadModal()"
class="card h-100 d-flex clr-flex-column"
*ngIf="!isLoading && selectedXLMap"
>
<clr-tabs>
<clr-tab>
<button clrTabLink (click)="selectedTab = TabsEnum.Rules">Rules</button>
<button clrTabLink (click)="selectedTab = TabsEnum.Rules">
Rules
</button>
<clr-tab-content *clrIfActive="selectedTab === TabsEnum.Rules">
</clr-tab-content>
</clr-tab>
@@ -91,9 +95,7 @@
</clr-tab-content>
</clr-tab>
</clr-tabs>
<ng-container *ngTemplateOutlet="actionButtons"></ng-container>
<div class="clr-row m-0 mb-10-i viewerTitle">
<h3 class="d-flex clr-col-12 clr-justify-content-center mt-5-i">
{{ selectedXLMap.id }}
@@ -122,7 +124,6 @@
</a>
</p>
</div>
<div class="clr-flex-1">
<hot-table
id="hot-table"
@@ -132,6 +133,7 @@
</hot-table>
</div>
</div>
}
<clr-modal
appFileDrop
@@ -207,10 +209,8 @@
<ng-template #actionButtons>
<div class="clr-row m-0 clr-justify-content-center">
<div
*ngIf="status === StatusEnum.ReadyToUpload"
class="d-flex clr-justify-content-center clr-col-12 clr-col-lg-4"
>
@if (status === StatusEnum.ReadyToUpload) {
<div class="d-flex clr-justify-content-center clr-col-12 clr-col-lg-4">
<button
type="button"
class="btn btn-sm btn-success btn-block mr-0"
@@ -220,10 +220,9 @@
<span>Upload</span>
</button>
</div>
<div
*ngIf="status === StatusEnum.ReadyToSubmit"
class="d-flex clr-justify-content-center clr-col-12 clr-col-lg-4"
>
}
@if (status === StatusEnum.ReadyToSubmit) {
<div class="d-flex clr-justify-content-center clr-col-12 clr-col-lg-4">
<button
type="button"
class="btn btn-sm btn-success btn-block mr-0"
@@ -233,10 +232,9 @@
<span>Submit</span>
</button>
</div>
<div
*ngIf="status === StatusEnum.ReadyToSubmit"
class="d-flex clr-justify-content-center clr-col-12 clr-col-lg-4"
>
}
@if (status === StatusEnum.ReadyToSubmit) {
<div class="d-flex clr-justify-content-center clr-col-12 clr-col-lg-4">
<button
type="button"
class="btn btn-sm btn-outline-danger btn-block mr-0"
@@ -246,5 +244,6 @@
<span>Discard</span>
</button>
</div>
}
</div>
</ng-template>
+23
View File
@@ -0,0 +1,23 @@
/**
* Type declarations for the untyped `browserify-cipher` package.
* Only the cipher functions used by `src/crypto-shim.ts` are declared.
*/
declare module 'browserify-cipher' {
export interface Cipheriv {
update(data: unknown): unknown
final(): unknown
setAutoPadding(autoPadding?: boolean): void
}
export function createCipheriv(
algorithm: string,
key: unknown,
iv: unknown
): Cipheriv
export function createDecipheriv(
algorithm: string,
key: unknown,
iv: unknown
): Cipheriv
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Minimal browser shim for the node `crypto` module.
*
* Replaces `crypto-browserify`, which pulls in `elliptic` (via
* `browserify-sign` and `create-ecdh`) - a package with open low-severity
* advisories and no fixed release. None of the signing/ECDH functionality
* is needed here: `@sheet/crypto` (see tsconfig `paths` mapping for
* "crypto") only uses hashing, HMAC, AES ciphers and random bytes.
*/
import createHash from 'create-hash'
import createHmac from 'create-hmac'
import { createCipheriv, createDecipheriv } from 'browserify-cipher'
import randomBytes from 'randombytes'
const HASHES = [
'md4',
'md5',
'ripemd160',
'sha1',
'sha224',
'sha256',
'sha384',
'sha512'
]
export function getHashes(): string[] {
return [...HASHES]
}
export { createHash, createHmac, createCipheriv, createDecipheriv, randomBytes }
+7 -10
View File
@@ -15,21 +15,18 @@
-->
<!-- meta tags -->
<!--
NOTE: Data Controller must run entirely offline / on-prem. Never reference
external assets (fonts, images, scripts, styles, CDN links, or remote
og:/itemprop URLs) here or anywhere in the built product. All assets must
be bundled and served locally.
-->
<meta name="description" content="Capture, Review, and Approve" />
<meta itemprop="name" content="Data Controller for SAS®" />
<meta itemprop="description" content="Capture, Review, and Approve" />
<meta
itemprop="image"
content="https://docs.datacontroller.io/img/dc_bg_Asset-5@2x.png"
/>
<meta property="og:url" content="http://demo.datacontroller.io" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Data Controller for SAS®" />
<meta property="og:description" content="Capture, Review, and Approve" />
<meta
property="og:image"
content="https://docs.datacontroller.io/img/dc_bg_Asset-5@2x.png"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
@@ -54,7 +51,7 @@
<sasjs
serverUrl=""
appLoc="/Public/app/devtest"
appLoc="/Public/app/dc"
serverType="SASJS"
loginMechanism="Redirected"
debug="false"
+350 -305
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -10,6 +10,6 @@
"outDir": "./app",
"types": []
},
"files": ["src/polyfills.ts", "src/main.ts", "src/app/app.d.ts"],
"files": ["src/polyfills.ts", "src/main.ts", "src/app/app.d.ts", "src/crypto-shim.ts"],
"include": ["src/**/*.d.ts"]
}
+2 -3
View File
@@ -9,16 +9,15 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"lib": ["ES2022", "dom"],
"skipLibCheck": true,
"module": "ES2022",
"importHelpers": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"sourceMap": true,
"resolveJsonModule": true,
"target": "ES2022",
"paths": {
"crypto": ["./node_modules/crypto-browserify"],
"crypto": ["./src/crypto-shim"],
"stream": ["./node_modules/stream-browserify"],
"assert": ["./node_modules/assert"],
"http": ["./node_modules/stream-http"],

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