Compare commits

..
34 Commits
Author SHA1 Message Date
semantic-release-bot 2dcae9060f chore(release): 7.10.1 [skip ci]
## [7.10.1](https://git.datacontroller.io/dc/dc/compare/v7.10.0...v7.10.1) (2026-07-15)

### Bug Fixes

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

### Bug Fixes

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

### Features

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

### Reverts

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

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

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

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

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

Also allowlists @handsontable/angular-wrapper@18.0.0 and
handsontable@18.0.0 in licenseChecker.js — both versions report the
same non-SPDX "SEE LICENSE IN LICENSE.txt" field that license-checker
mis-resolves as a disallowed custom license, same as prior versions.
2026-07-06 15:44:27 +03:00
allan 0ae1781d28 Merge branch 'main' into issue240
Build / Build-and-ng-test (pull_request) Successful in 4m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m17s
Build / Build-and-test-development (pull_request) Successful in 9m23s
2026-07-05 10:51:11 +00:00
allan fb94840016 fix: backend updates to bring back labels under #240
Build / Build-and-ng-test (pull_request) Successful in 4m11s
Build / Build-and-test-development (pull_request) Successful in 9m31s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m31s
2026-07-05 11:49:36 +01:00
49 changed files with 2629 additions and 650 deletions
+1 -1
View File
@@ -146,7 +146,7 @@ jobs:
# Start frontend and run cypress # Start frontend and run cypress
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots # timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs) # before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts" 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"
- name: Zip Cypress videos - name: Zip Cypress videos
if: always() if: always()
+1 -1
View File
@@ -143,7 +143,7 @@ jobs:
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
cat ./cypress.config.ts cat ./cypress.config.ts
# Start frontend and run cypress # Start frontend and run cypress
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts" npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
- name: Zip Cypress videos - name: Zip Cypress videos
if: always() if: always()
+1
View File
@@ -1,5 +1,6 @@
{ {
"cSpell.words": [ "cSpell.words": [
"Handsontable",
"Licence", "Licence",
"SYSERRORTEXT", "SYSERRORTEXT",
"SYSWARNINGTEXT", "SYSWARNINGTEXT",
+36
View File
@@ -1,3 +1,39 @@
## [7.10.1](https://git.datacontroller.io/dc/dc/compare/v7.10.0...v7.10.1) (2026-07-15)
### Bug Fixes
* **editor:** order validation rules by COLHEADERS, not cols[].VARNUM ([4c2aca3](https://git.datacontroller.io/dc/dc/commit/4c2aca34951d6d5c5cf2df69d5bf4256a9290d53))
# [7.10.0](https://git.datacontroller.io/dc/dc/compare/v7.9.1...v7.10.0) (2026-07-13)
### Bug Fixes
* adapt column validation to vertical-array COLTYPE ([#253](https://git.datacontroller.io/dc/dc/issues/253)) ([3016750](https://git.datacontroller.io/dc/dc/commit/301675052fb5cbcb11f54c370ad7eda1d60461fd))
* backend updates to bring back labels under [#240](https://git.datacontroller.io/dc/dc/issues/240) ([fb94840](https://git.datacontroller.io/dc/dc/commit/fb94840016f067fa3523a3539dd0616d8970eed0))
* **editor:** normalise unpadded time strings on spreadsheet import ([7b4b4eb](https://git.datacontroller.io/dc/dc/commit/7b4b4ebeeb0e34ec5fcd5c485741d7bbddb43b7e))
* **hot:** row rendering on resize, resolve blank whitespace ([c7ba025](https://git.datacontroller.io/dc/dc/commit/c7ba025d39aa9681ad51f239503e884fb26613e2))
* including datadictionary descs in labels, [#240](https://git.datacontroller.io/dc/dc/issues/240) ([e7453a9](https://git.datacontroller.io/dc/dc/commit/e7453a9305263faf4d4f288d50977121353040c7))
* pass variable formats to front-end in a vertical array instead of horizontal list ([691d6f2](https://git.datacontroller.io/dc/dc/commit/691d6f277ea87051f539681b34da2f39cade8fc3))
* remove coltype from sasparams table, add it to cols table. ([8101400](https://git.datacontroller.io/dc/dc/commit/81014001ac4da6b8d262c72c65e0c657f186ec84))
* trim leading/trailing whitespace from Excel header cells on upload ([2728dac](https://git.datacontroller.io/dc/dc/commit/2728dac873ab8c9fc44413f873e4730515ca0e21))
* **va:** date filters refactor, support more dates ([df5c975](https://git.datacontroller.io/dc/dc/commit/df5c9758697d7481e67d20ed7ff81e9ce4e92ec0))
* **va:** enable readOnly and hold filtering while editing ([82a254d](https://git.datacontroller.io/dc/dc/commit/82a254d22cb7aaf75aae7b332f0b07fc8bda3e44))
* **va:** enable Upload button in va mode ([1beb3d4](https://git.datacontroller.io/dc/dc/commit/1beb3d490d0e7b8d5fa7985f121495ed62740cfe))
* **va:** guard deferred contextMenu toggle against destroyed instance ([b5e9b25](https://git.datacontroller.io/dc/dc/commit/b5e9b2531924bec1a23c67d3ad0ca3f512a75655))
### Features
* adding DC_MAXOBS_WEBVIEW config item, closes [#258](https://git.datacontroller.io/dc/dc/issues/258) ([ba7b610](https://git.datacontroller.io/dc/dc/commit/ba7b61082d39767f9539a84de6742a1704573e81))
* viewer/editor column label display toggle (?labels=true) ([25c12f2](https://git.datacontroller.io/dc/dc/commit/25c12f2b18adda09e6edc93eba263bd5ea0f7409))
### Reverts
* enable upload button in va mode ([0d7fd34](https://git.datacontroller.io/dc/dc/commit/0d7fd342970744489a2e6c6fb08f239fe853cb81))
## [7.9.1](https://git.datacontroller.io/dc/dc/compare/v7.9.0...v7.9.1) (2026-06-30) ## [7.9.1](https://git.datacontroller.io/dc/dc/compare/v7.9.0...v7.9.1) (2026-06-30)
@@ -0,0 +1,132 @@
// Marks this file as an ES module (rather than a global script) so its
// top-level consts don't collide, under the TS type-checker, with the same
// names declared in other spec files — see viewer-labels.cy.ts for the same
// pattern.
export {}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// Regression coverage for issue #253's vertical-array COLTYPE fix — see
// getdata.sas now sends one COLTYPE JSON-object string per cols[] row (via
// a LEFT JOIN keyed off the real dataset's columns), instead of one
// comma-joined sasparams.COLTYPE string covering every column. Two things
// are specifically at risk from that change and aren't covered elsewhere:
// 1. `_____DELETE__THIS__RECORD_____` (the delete checkbox column) is a
// client-side-only concept — %mp_getcols never has a row for it, so
// it can no longer travel via cols[].COLTYPE at all. Its Yes/No
// dropdown rule is now hardcoded client-side (deleteRecordColumnRule.ts)
// — test 1 proves that hardcode actually renders end to end.
// 2. Ordinary columns (e.g. SOME_DROPDOWN) still get their rule via the
// new per-row cols[].COLTYPE path — test 2 proves the migration didn't
// silently drop real columns' rules either.
// Column positions below come from COLHEADERS in
// sas/mocks/sasjs/services/editors/getdata.js:
// "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,..."
// — childNodes[0] on a body <tr> is the row-header <th>, so childNodes[N+1]
// is the Nth (0-indexed) column in that list.
context('coltype vertical-array regression tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
it('1 | delete-record column renders its hardcoded Yes/No dropdown', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit()
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).then(
(rows: any) => {
// childNodes[1] = _____DELETE__THIS__RECORD_____ (1st column)
cy.get(rows[1].childNodes[1])
.click({ force: true })
.then(($td) => {
cy.get('.htAutocompleteArrow', { withinSubject: $td })
.should('exist')
.click({ force: true })
cy.get('.autocompleteEditor .htCore tbody td').should(
($choices) => {
const texts = [...$choices].map((el) => el.innerText.trim())
expect(texts).to.deep.equal(['No', 'Yes'])
}
)
})
}
)
})
it('2 | an ordinary dropdown column (SOME_DROPDOWN) still gets its rule from cols[].COLTYPE', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit()
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).then(
(rows: any) => {
// childNodes[4] = SOME_DROPDOWN (4th column, 0-indexed as 3). This
// only holds if DcValidator emits rules in VARNUM order — see the
// "orders rules by VARNUM" regression test in dc-validator.spec.ts.
cy.get(rows[1].childNodes[4])
.click({ force: true })
.then(($td) => {
cy.get('.htAutocompleteArrow', { withinSubject: $td }).should(
'exist'
)
})
}
)
})
})
const clickOnEdit = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container > button').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
+13
View File
@@ -386,6 +386,19 @@ context('excel tests: ', function () {
}) })
}) })
it('24 | Uploads Excel with leading whitespace in header row (should still succeed)', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// Every header except the first has a leading space, matching a
// real-world defect from copy-pasting between spreadsheets/systems.
// Header matching must tolerate
// this rather than reporting the columns missing and aborting.
attachExcelFile('leading_whitespace_header_excel.xlsx', () => {
submitExcel()
rejectExcel(done)
})
})
// Large files break Cypress // Large files break Cypress
// it ('? | Uploads Excel with size of 5MB', (done) => { // it ('? | Uploads Excel with size of 5MB', (done) => {
+243
View File
@@ -0,0 +1,243 @@
// Marks this file as an ES module (rather than a global script) so its
// top-level consts don't collide, under the TS type-checker, with the same
// names declared in other spec files — see e.g. download.cy.ts, which gets
// this for free via a real import.
export {}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// Viewer column-label display toggle (`?labels=true`): clients surfacing DC in
// SAS Visual Analytics want column LABELs shown instead of NAMEs. Default
// behavior (no param) must stay unchanged; the right-click context menu's
// "Show labels"/"Show names" item is the in-app affordance, and it drives the
// same URL param so state is shareable/refreshable. See mock data in
// sas/mocks/sasjs/services/public/viewdata.js: MPE_X_TEST's SOME_CHAR/SOME_DATE
// have LABELs that differ from NAME, and SOME_NUM has a blank LABEL (fallback
// to NAME) — this spec exercises exactly that fixture.
context('viewer column labels toggle tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
// forceLicenceKey=true: without it, loginAndUpdateValidKey() only
// generates key data and logs back out if no valid license already
// exists — it doesn't actually apply one (see commands.ts). Several
// specs call it with no argument and rely on an *earlier* spec in the
// same CI run having already forced a valid license onto the shared
// server first (csv.cy.ts, excel.cy.ts, etc.). This spec shouldn't
// depend on run order, so force it explicitly.
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
// No re-login here: beforeAll's loginAndUpdateValidKey() already
// authenticated the session, and it persists across tests in this file
// (see filtering.cy.ts/csv.cy.ts for the same pattern). Re-typing into
// the login form would fail — it's hidden once already logged in.
cy.visit(hostUrl + appLocation)
// Visit 'home' first, matching filtering.cy.ts/csv.cy.ts/download.cy.ts —
// going straight to 'view/data' skips whatever startup-data fetch 'home'
// triggers, which passed locally (fast round-trips) but left the
// nav-tree empty in CI's slower environment.
visitPage('home')
visitPage('view/data')
})
it('1 | default (no param): headers show NAMEs', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('SOME_CHAR')
expect($headerRow[0].innerHTML).to.not.include('Some Character Column')
})
})
it('2 | ?labels=true: headers show LABELs, blank LABEL falls back to NAME', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
appendUrlParam('labels=true')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
const html = $headerRow[0].innerHTML
expect(html).to.include('Some Character Column')
expect(html).to.include('Some Date')
// SOME_NUM has a blank LABEL in the mock -> falls back to NAME
expect(html).to.include('SOME_NUM')
})
})
it('3 | context menu "Show labels"/"Show names" toggles headers and the URL', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
toggleColumnLabelsFromContextMenu('Show labels')
cy.url().should('include', 'labels=true')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('Some Character Column')
})
toggleColumnLabelsFromContextMenu('Show names')
cy.url().should('not.include', 'labels=true')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('SOME_CHAR')
})
})
it('4 | ?embed=va&labels=true: labels shown with embed chrome hidden', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
appendUrlParam('embed=va&labels=true')
cy.get('header.app-header').should('not.exist')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('Some Character Column')
})
})
it('5 | info dropdown: first line is NAME (in both toggle states)', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// No assertion on the item's label text here: `info` has a custom
// `renderer`, so Handsontable never renders its `name` ("test info") as
// text at all — the renderer's own output (checked below) is the only
// real content.
openColumnDropdown('SOME_CHAR')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.match(/NAME: SOME_CHAR/)
})
cy.get('body').click(0, 0) // close menu
appendUrlParam('labels=true')
openColumnDropdown('Some Character Column')
cy.get('.htDropdownMenu').should(($menu) => {
// NAME is always shown first, even while headers display as LABEL
expect($menu.text()).to.match(/NAME: SOME_CHAR/)
})
})
})
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
// Re-visits the current route with extra hash-query params appended, then
// forces a real reload. Under hash routing (`useHash: true`), changing only
// the URL's hash fragment is a same-document navigation — Angular Router
// still reacts to it (which is enough for `useLabels`, read reactively from
// `route.queryParams`), but `embed` is parsed once from `window.location.hash`
// at app bootstrap (app.component.ts), so `?embed=va` only takes effect after
// an actual reload.
const appendUrlParam = (param: string) => {
cy.url().then((url) => {
const separator = url.includes('?') ? '&' : '?'
cy.visit(`${url}${separator}${param}`)
cy.reload()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
})
}
const openColumnDropdown = (headerText: string) => {
// Handsontable renders the sticky/frozen header via a separate clone pane
// (.ht_clone_top) for scroll behavior; the header row inside the main
// .ht_master pane is kept visibility:hidden (replaced visually by the
// clone). .ht_clone_top is the real, interactive one — confirmed via
// `document.querySelector('#hotTable .ht_clone_top thead').innerText` in
// a live browser session.
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
})
.parents('th')
.filter((_, th) => Cypress.$(th).text().includes(headerText))
.last()
.as('targetHeader')
// Click the header text first to select the column — the `info` item's
// renderer reads hot.getSelected() to decide which column to describe,
// so without an active selection it always shows "No info found".
cy.get('@targetHeader').click()
cy.get('@targetHeader').find('button.changeType').click({ force: true })
}
const toggleColumnLabelsFromContextMenu = (
menuItemText: 'Show labels' | 'Show names'
) => {
cy.get('#hotTable .ht_master.handsontable .htCore tbody tr td', {
timeout: longerCommandTimeout
})
.first()
// force: true — the first body row can sit under an overlay clone's
// header/sort-icon layer, which fails Cypress's actionability check
// even though the cell is the real rightclick target underneath.
.rightclick({ force: true })
cy.get('.htContextMenu').contains(menuItemText).click()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
// Small settle wait: right after a fresh visit/reload, the tree
// component can still be re-rendering, causing this node to be
// found then swapped out mid-click ("disappeared from the page").
cy.wait(300)
cy.get(
'.clr-tree-node-content-container .clr-treenode-content p'
).click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
// Selecting the table triggers async SPA routing + a viewdata fetch;
// wait for the grid to actually render before any subsequent action
// (reading the URL, right-clicking, opening a header dropdown) — without
// this, callers can act while still on the intermediate library-only
// route or a not-yet-rendered grid. Waiting specifically for a header's
// dropdown button (not just the <tr> shell) confirms Handsontable has
// finished populating header cell contents, not just their DOM rows.
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
}).should('exist')
}
+93 -39
View File
@@ -23,21 +23,33 @@
// //
// -- This will overwrite an existing command -- // -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
import 'cypress-file-upload'; import 'cypress-file-upload'
import { arrayBufferToBase64 } from './../util/helper-functions' import { arrayBufferToBase64 } from './../util/helper-functions'
import * as moment from 'moment' import moment from 'moment'
const username = Cypress.env('username'); // These custom commands were added via Cypress.Commands.add() below but never
const password = Cypress.env('password'); // had a type augmentation, so every spec calling cy.loginAndUpdateValidKey()/
const hostUrl = Cypress.env('hosturl'); // cy.isLoggedIn() had an unresolved-property error.
const appLocation = Cypress.env('appLocation'); declare global {
namespace Cypress {
interface Chainable {
isLoggedIn(callback: (exist: boolean) => void): Chainable<void>
loginAndUpdateValidKey(forceLicenceKey?: boolean): Chainable<void>
}
}
}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout') const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const site_id_SASJS = Cypress.env('site_id_SASJS') const site_id_SASJS = Cypress.env('site_id_SASJS')
Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => { Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
cy.get('body').then($body => { cy.get('body').then(($body) => {
if ($body.find(".nav-tree").length > 0) { if ($body.find('.nav-tree').length > 0) {
if (callback) callback(true) if (callback) callback(true)
} else { } else {
if (callback) callback(false) if (callback) callback(false)
@@ -45,25 +57,29 @@ Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
}) })
}) })
Cypress.Commands.add('loginAndUpdateValidKey', (forceLicenceKey: boolean = false) => { Cypress.Commands.add(
cy.visit(hostUrl + appLocation); 'loginAndUpdateValidKey',
(forceLicenceKey: boolean = false) => {
cy.visit(hostUrl + appLocation)
cy.wait(2000) cy.wait(2000)
cy.get('body').then($body =>{ cy.get('body').then(($body) => {
const usernameInput = $body.find("input.username")[0] const usernameInput = $body.find('input.username')[0]
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) { if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
cy.get('input.username').type(username); cy.get('input.username').type(username)
cy.get('input.password').type(password); cy.get('input.password').type(password)
cy.get('.login-group button').click() cy.get('.login-group button').click()
} }
cy.get('.app-loading', {timeout: longerCommandTimeout}).should('not.exist').then(() => { cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.wait(2000) cy.wait(2000)
if ($body.find(".nav-tree").length > 0) { if ($body.find('.nav-tree').length > 0) {
/** /**
* If licence key is already working, then skip rest of the function * If licence key is already working, then skip rest of the function
*/ */
@@ -79,7 +95,12 @@ Cypress.Commands.add('loginAndUpdateValidKey', (forceLicenceKey: boolean = false
demo: false demo: false
} }
return generateKeys(keyData.valid_until, keyData.number_of_users, keyData.hot_license_key, keyData.demo, (keysGen: any) => { return generateKeys(
keyData.valid_until,
keyData.number_of_users,
keyData.hot_license_key,
keyData.demo,
(keysGen: any) => {
return acceptTermsIfPresented((result: boolean) => { return acceptTermsIfPresented((result: boolean) => {
if (result) { if (result) {
cy.wait(20000) cy.wait(20000)
@@ -103,15 +124,22 @@ Cypress.Commands.add('loginAndUpdateValidKey', (forceLicenceKey: boolean = false
}) })
} }
}) })
}) }
)
} }
}) })
}); })
}); }
)
const logout = (callback?: any) => { const logout = (callback?: any) => {
cy.get('.header-actions .dropdown-toggle').click().then(() => { cy.get('.header-actions .dropdown-toggle')
cy.get('.header-actions .dropdown-menu > .separator').next().click().then(() => { .click()
.then(() => {
cy.get('.header-actions .dropdown-menu > .separator')
.next()
.click()
.then(() => {
if (callback) callback() if (callback) callback()
}) })
}) })
@@ -132,8 +160,12 @@ const updateLicenseKeyQuick = (keys: any, callback: any) => {
const acceptTermsIfPresented = (callback?: any) => { const acceptTermsIfPresented = (callback?: any) => {
cy.url().then((url: string) => { cy.url().then((url: string) => {
if (url.includes('licensing/register')) { if (url.includes('licensing/register')) {
cy.get('.card-block').scrollTo('bottom').then(() => { cy.get('.card-block')
cy.get('#checkbox1').click().then(() => { .scrollTo('bottom')
.then(() => {
cy.get('#checkbox1')
.click()
.then(() => {
if (callback) callback(true) if (callback) callback(true)
}) })
}) })
@@ -151,23 +183,36 @@ const isLicensingPage = (callback: any) => {
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => { const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button').contains('Paste licence').click() cy.get('button').contains('Paste licence').click()
cy.get('.license-key-form textarea', {timeout: longerCommandTimeout}).invoke('val', licenseKey).trigger('input').should('not.be.undefined') cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
cy.get('.activation-key-form textarea', {timeout: longerCommandTimeout}).invoke('val', activationKey).trigger('input').should('not.be.undefined') .invoke('val', licenseKey)
.trigger('input')
.should('not.be.undefined')
cy.get('.activation-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', activationKey)
.trigger('input')
.should('not.be.undefined')
cy.get('button.apply-keys').click() cy.get('button.apply-keys').click()
} }
const visitPage = (url: string) => { const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`); cy.visit(`${hostUrl}${appLocation}/#/${url}`)
} }
const generateKeys = async (valid_until: string, users_allowed: number, hot_license_key: string, demo: boolean, resultCallback?: any) => { const generateKeys = async (
let keyPair = await window.crypto.subtle.generateKey({ valid_until: string,
name: "RSA-OAEP", users_allowed: number,
hot_license_key: string,
demo: boolean,
resultCallback?: any
) => {
let keyPair = await window.crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2024, modulusLength: 2024,
publicExponent: new Uint8Array([1, 0, 1]), publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256" hash: 'SHA-256'
}, },
true, true,
["encrypt", "decrypt"] ['encrypt', 'decrypt']
) )
let licenseData = { let licenseData = {
@@ -184,29 +229,38 @@ const generateKeys = async (valid_until: string, users_allowed: number, hot_lice
console.log(encoded) console.log(encoded)
let cipher = await window.crypto.subtle.encrypt( let cipher = await window.crypto.subtle
.encrypt(
{ {
name: "RSA-OAEP" name: 'RSA-OAEP'
}, },
keyPair.publicKey, keyPair.publicKey,
encoded encoded
).then((value) => { )
.then(
(value) => {
return value return value
}, (err) => { },
(err) => {
console.log('Encrpyt error', err) console.log('Encrpyt error', err)
}) }
)
if (!cipher) { if (!cipher) {
alert('Encryptin keys failed') alert('Encryptin keys failed')
throw new Error('Encryptin keys failed') throw new Error('Encryptin keys failed')
} }
let privateKeyBytes = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey) let privateKeyBytes = await window.crypto.subtle.exportKey(
'pkcs8',
keyPair.privateKey
)
let activationKey = await arrayBufferToBase64(privateKeyBytes) let activationKey = await arrayBufferToBase64(privateKeyBytes)
let licenseKey = await arrayBufferToBase64(cipher) let licenseKey = await arrayBufferToBase64(cipher)
if (resultCallback) resultCallback({ if (resultCallback)
resultCallback({
activationKey, activationKey,
licenseKey licenseKey
}) })
+1 -1
View File
@@ -10,7 +10,7 @@ const check = (cwd) => {
onlyAllow: onlyAllow:
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;', 'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
excludePackages: excludePackages:
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1' '@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
}, },
(error, json) => { (error, json) => {
if (error) { if (error) {
+289 -197
View File
@@ -20,7 +20,7 @@
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz", "@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
"@clr/icons": "^13.0.2", "@clr/icons": "^13.0.2",
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz", "@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular-wrapper": "^17.1.0", "@handsontable/angular-wrapper": "^18.0.0",
"@sasjs/adapter": "^4.17.0", "@sasjs/adapter": "^4.17.0",
"@sasjs/utils": "^3.5.3", "@sasjs/utils": "^3.5.3",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz", "@sheet/crypto": "file:libraries/sheet-crypto.tgz",
@@ -33,7 +33,7 @@
"d3-graphviz": "^5.0.2", "d3-graphviz": "^5.0.2",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"fs-extra": "^7.0.1", "fs-extra": "^7.0.1",
"handsontable": "^17.1.0", "handsontable": "^18.0.0",
"https-browserify": "1.0.0", "https-browserify": "1.0.0",
"hyperformula": "^2.5.0", "hyperformula": "^2.5.0",
"iconv-lite": "^0.5.0", "iconv-lite": "^0.5.0",
@@ -4361,25 +4361,19 @@
} }
}, },
"node_modules/@handsontable/angular-wrapper": { "node_modules/@handsontable/angular-wrapper": {
"version": "17.1.0", "version": "18.0.0",
"resolved": "https://registry.npmjs.org/@handsontable/angular-wrapper/-/angular-wrapper-17.1.0.tgz", "resolved": "https://registry.npmjs.org/@handsontable/angular-wrapper/-/angular-wrapper-18.0.0.tgz",
"integrity": "sha512-5jUCb4E1eZcy/CJ3T39/cxauy9NoQh+wNWTDW9ZhIIudr/00HvfJlcEozN5RWSYxfGNw71prVM2y16DSd+ct9A==", "integrity": "sha512-1DTV9lqE7vn2sZbB2hyrMcqhjnGWzYAHPGDGKuJlppzugjZcobYTPIFv5Dn9NOBUrKxRyPXI5dUb5SqwzYbJNw==",
"license": "SEE LICENSE IN LICENSE.txt", "license": "SEE LICENSE IN LICENSE.txt",
"optionalDependencies": { "optionalDependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/core": ">=16.0.0 <22.0.0", "@angular/core": ">=16.0.0 <23.0.0",
"handsontable": "^17.0.0", "handsontable": "^18.0.0",
"rxjs": "^7.0.0" "rxjs": "^7.0.0"
} }
}, },
"node_modules/@handsontable/pikaday": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@handsontable/pikaday/-/pikaday-1.0.0.tgz",
"integrity": "sha512-1VN6N38t5/DcjJ7y7XUYrDx1LuzvvzlrFdBdMG90Qo1xc8+LXHqbWbsTEm5Ec5gXTEbDEO53vUT35R+2COmOyg==",
"license": "(0BSD OR MIT)"
},
"node_modules/@hapi/hoek": { "node_modules/@hapi/hoek": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -6088,9 +6082,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6108,9 +6099,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6128,9 +6116,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6148,9 +6133,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6168,9 +6150,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6188,9 +6167,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6208,9 +6184,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7346,9 +7319,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7370,9 +7340,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7394,9 +7361,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7418,9 +7382,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7442,9 +7403,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7466,9 +7424,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7694,9 +7649,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7711,9 +7663,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7728,9 +7677,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7745,9 +7691,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7762,9 +7705,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7779,9 +7719,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7796,9 +7733,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7813,9 +7747,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7830,9 +7761,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7847,9 +7775,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7864,9 +7789,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7881,9 +7803,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7898,9 +7817,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -9073,15 +8989,15 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.59.1", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz",
"integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.59.1", "@typescript-eslint/tsconfig-utils": "^8.63.0",
"@typescript-eslint/types": "^8.59.1", "@typescript-eslint/types": "^8.63.0",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -9128,9 +9044,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.1", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz",
"integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
@@ -9208,9 +9124,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.59.1", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
@@ -9290,17 +9206,17 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.59.1", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz",
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/scope-manager": "8.63.0",
"@typescript-eslint/types": "8.59.1", "@typescript-eslint/types": "8.63.0",
"@typescript-eslint/typescript-estree": "8.59.1" "@typescript-eslint/typescript-estree": "8.63.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -9315,15 +9231,15 @@
} }
}, },
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.1", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz",
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.59.1", "@typescript-eslint/types": "8.63.0",
"@typescript-eslint/visitor-keys": "8.59.1" "@typescript-eslint/visitor-keys": "8.63.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -9334,17 +9250,17 @@
} }
}, },
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.1", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.59.1", "@typescript-eslint/project-service": "8.63.0",
"@typescript-eslint/tsconfig-utils": "8.59.1", "@typescript-eslint/tsconfig-utils": "8.63.0",
"@typescript-eslint/types": "8.59.1", "@typescript-eslint/types": "8.63.0",
"@typescript-eslint/visitor-keys": "8.59.1", "@typescript-eslint/visitor-keys": "8.63.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"minimatch": "^10.2.2", "minimatch": "^10.2.2",
"semver": "^7.7.3", "semver": "^7.7.3",
@@ -9363,14 +9279,14 @@
} }
}, },
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.1", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.59.1", "@typescript-eslint/types": "8.63.0",
"eslint-visitor-keys": "^5.0.0" "eslint-visitor-keys": "^5.0.0"
}, },
"engines": { "engines": {
@@ -9393,9 +9309,9 @@
} }
}, },
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
@@ -9438,9 +9354,9 @@
} }
}, },
"node_modules/@typescript-eslint/utils/node_modules/semver": { "node_modules/@typescript-eslint/utils/node_modules/semver": {
"version": "7.7.4", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true, "peer": true,
@@ -10569,15 +10485,6 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/binary": { "node_modules/binary": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
@@ -13234,15 +13141,6 @@
"url": "https://github.com/fb55/domhandler?sponsor=1" "url": "https://github.com/fb55/domhandler?sponsor=1"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": { "node_modules/domutils": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -15405,30 +15303,10 @@
} }
}, },
"node_modules/handsontable": { "node_modules/handsontable": {
"version": "17.1.0", "version": "18.0.0",
"resolved": "https://registry.npmjs.org/handsontable/-/handsontable-17.1.0.tgz", "resolved": "https://registry.npmjs.org/handsontable/-/handsontable-18.0.0.tgz",
"integrity": "sha512-3afpoGQT04i11IYge4gZNHp4Io3RFoLi+vVk4yTc2oHGjuBJBhjVVlTnOcYM8N9Ay1ikjSh4T3ZiwFVYEsgJrA==", "integrity": "sha512-U4H6gDrwGTfjahSyoGHhFHZvOZWB7ReQ3iBvJUIcZPdqdppdZ+FSYNovw+3MH1guJfGgRIhoLYnbSm8Gd6Ft8w==",
"license": "SEE LICENSE IN LICENSE.txt", "license": "SEE LICENSE IN LICENSE.txt"
"dependencies": {
"@handsontable/pikaday": "^1.0.0",
"dompurify": "^3.1.7",
"moment": "2.30.1",
"numbro": "2.5.0"
},
"optionalDependencies": {
"hyperformula": "^3.0.0"
}
},
"node_modules/handsontable/node_modules/hyperformula": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/hyperformula/-/hyperformula-3.3.0.tgz",
"integrity": "sha512-Mkc7AxP9WQDM4xHo1/XpAwzcpMLkwMand4HO9rfRzP502TBzoR1Z96zoCzRrwXodLSVyh+MD7FvzRVALDxmRFQ==",
"license": "GPL-3.0-only",
"optional": true,
"dependencies": {
"chevrotain": "^6.5.0",
"tiny-emitter": "^2.1.0"
}
}, },
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
@@ -19193,9 +19071,9 @@
} }
}, },
"node_modules/mocha": { "node_modules/mocha": {
"version": "11.7.5", "version": "11.7.6",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz",
"integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
@@ -19239,9 +19117,9 @@
"peer": true "peer": true
}, },
"node_modules/mocha/node_modules/brace-expansion": { "node_modules/mocha/node_modules/brace-expansion": {
"version": "2.1.0", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
@@ -19298,10 +19176,20 @@
} }
}, },
"node_modules/mocha/node_modules/js-yaml": { "node_modules/mocha/node_modules/js-yaml": {
"version": "4.1.1", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true, "dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -20171,18 +20059,6 @@
"url": "https://github.com/fb55/nth-check?sponsor=1" "url": "https://github.com/fb55/nth-check?sponsor=1"
} }
}, },
"node_modules/numbro": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/numbro/-/numbro-2.5.0.tgz",
"integrity": "sha512-xDcctDimhzko/e+y+Q2/8i3qNC9Svw1QgOkSkQoO0kIPI473tR9QRbo2KP88Ty9p8WbPy+3OpTaAIzehtuHq+A==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^8 || ^9"
},
"engines": {
"node": "*"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -21462,6 +21338,26 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/proxy-agent-negotiate": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz",
"integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"kerberos": "^2.0.0"
},
"peerDependenciesMeta": {
"kerberos": {
"optional": true
}
}
},
"node_modules/proxy-agent/node_modules/lru-cache": { "node_modules/proxy-agent/node_modules/lru-cache": {
"version": "7.18.3", "version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
@@ -21608,6 +21504,50 @@
} }
} }
}, },
"node_modules/puppeteer-core/node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/data-uri-to-buffer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz",
"integrity": "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/degenerator": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-7.0.1.tgz",
"integrity": "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"ast-types": "^0.13.4",
"escodegen": "^2.1.0",
"esprima": "^4.0.1"
},
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"quickjs-wasi": "^2.2.0"
}
},
"node_modules/puppeteer-core/node_modules/devtools-protocol": { "node_modules/puppeteer-core/node_modules/devtools-protocol": {
"version": "0.0.1624250", "version": "0.0.1624250",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz",
@@ -21615,6 +21555,149 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/puppeteer-core/node_modules/get-uri": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-uri/-/get-uri-8.0.1.tgz",
"integrity": "sha512-/5N/P4Lrh0p/mDwlDRi7Y1+P2o/OyzZI3l6Iz1Ov6XXwwm1y3RlZLuo3gVgML99djrEDtV980bBxSuOeHLk8ww==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"basic-ftp": "^5.3.1",
"data-uri-to-buffer": "8.0.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/http-proxy-agent": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz",
"integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"proxy-agent-negotiate": "1.1.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/https-proxy-agent": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
"integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"proxy-agent-negotiate": "1.1.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/puppeteer-core/node_modules/pac-proxy-agent": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-9.1.0.tgz",
"integrity": "sha512-1aU+1mpj3DrQPfo3gh+3Gap3G5x+axnMx1P/y0ZF2ch7kb2meyOCAH8K2k9d27ROsTE7TnAerzxqF9aon2jqnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"get-uri": "8.0.1",
"http-proxy-agent": "9.1.0",
"https-proxy-agent": "9.1.0",
"pac-resolver": "9.0.1",
"quickjs-wasi": "^2.2.0",
"socks-proxy-agent": "10.1.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/pac-resolver": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-9.0.1.tgz",
"integrity": "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"degenerator": "7.0.1",
"netmask": "^2.0.2"
},
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"quickjs-wasi": "^2.2.0"
}
},
"node_modules/puppeteer-core/node_modules/proxy-agent": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-8.0.2.tgz",
"integrity": "sha512-idLLRewuemWd7GH/BDJzGiB0dWGfT2SQs3jy6NtZtGWU9uPTTSdeC1/cdbqLwgzhfv027daGFuXX426e2Eg20A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"http-proxy-agent": "9.1.0",
"https-proxy-agent": "9.1.0",
"lru-cache": "^7.14.1",
"pac-proxy-agent": "9.1.0",
"proxy-from-env": "^2.0.0",
"socks-proxy-agent": "10.1.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/socks-proxy-agent": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz",
"integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/ws": { "node_modules/puppeteer-core/node_modules/ws": {
"version": "8.21.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
@@ -21690,6 +21773,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/quickjs-wasi": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz",
"integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/ramda": { "node_modules/ramda": {
"version": "0.29.1", "version": "0.29.1",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz", "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz",
@@ -25052,9 +25144,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vis-data": { "node_modules/vis-data": {
"version": "8.0.3", "version": "8.0.4",
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.3.tgz", "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.4.tgz",
"integrity": "sha512-jhnb6rJNqkKR1Qmlay0VuDXY9ZlvAnYN1udsrP4U+krgZEq7C0yNSKdZqmnCe13mdnf9AdVcdDGFOzy2mpPoqw==", "integrity": "sha512-TsN0sMHqIRpdfg6TNPtfdINpkgxtnQP6JNWCaiSwvou5seXqKiP5eERkaBg+Y56wyJ4FZTeOEs/dEmWEPrpltQ==",
"dev": true, "dev": true,
"license": "(Apache-2.0 OR MIT)", "license": "(Apache-2.0 OR MIT)",
"peer": true, "peer": true,
@@ -25063,7 +25155,7 @@
"url": "https://opencollective.com/visjs" "url": "https://opencollective.com/visjs"
}, },
"peerDependencies": { "peerDependencies": {
"uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0", "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0 || ^14.0.0",
"vis-util": ">=6.0.0" "vis-util": ">=6.0.0"
} }
}, },
+2 -2
View File
@@ -54,7 +54,7 @@
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz", "@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
"@clr/icons": "^13.0.2", "@clr/icons": "^13.0.2",
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz", "@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular-wrapper": "^17.1.0", "@handsontable/angular-wrapper": "^18.0.0",
"@sasjs/adapter": "^4.17.0", "@sasjs/adapter": "^4.17.0",
"@sasjs/utils": "^3.5.3", "@sasjs/utils": "^3.5.3",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz", "@sheet/crypto": "file:libraries/sheet-crypto.tgz",
@@ -67,7 +67,7 @@
"d3-graphviz": "^5.0.2", "d3-graphviz": "^5.0.2",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"fs-extra": "^7.0.1", "fs-extra": "^7.0.1",
"handsontable": "^17.1.0", "handsontable": "^18.0.0",
"https-browserify": "1.0.0", "https-browserify": "1.0.0",
"hyperformula": "^2.5.0", "hyperformula": "^2.5.0",
"iconv-lite": "^0.5.0", "iconv-lite": "^0.5.0",
+20 -26
View File
@@ -257,6 +257,7 @@
> >
<ng-container *ngIf="hotTable.readOnly && !uploadPreview"> <ng-container *ngIf="hotTable.readOnly && !uploadPreview">
<button <button
*ngIf="!isVaEmbed"
type="button" type="button"
class="btnView btn icon-collapse btn-sm btn-icon btn-block btn-dimmed" class="btnView btn icon-collapse btn-sm btn-icon btn-block btn-dimmed"
(click)="openQb()" (click)="openQb()"
@@ -285,9 +286,7 @@
</button> </button>
</ng-container> </ng-container>
<ng-container <ng-container *ngIf="!hotTable.readOnly && !uploadPreview">
*ngIf="!hotTable.readOnly && !uploadPreview && !isVaEmbed"
>
<button <button
type="button" type="button"
class="btn btn-sm btn-icon btn-outline-danger" class="btn btn-sm btn-icon btn-outline-danger"
@@ -471,28 +470,16 @@
</clr-tooltip-content> </clr-tooltip-content>
</clr-tooltip> </clr-tooltip>
<!-- VA data-driven content mode: single Submit below the grid. <!-- VA data-driven content mode: filter controls only. Edit/Submit
Routes through the same checkSave() pipeline as the normal use the normal top toolbar (same as embed=true). VA opens
editor (validation + approval modal + saveTable). --> 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"> <ng-container *ngIf="isVaEmbed">
<!-- Single VA action bar: Submit | Auto-apply toggle | filter
status (pending/loading), all on one line. -->
<div class="va-filter-controls w-100 mt-2-i"> <div class="va-filter-controls w-100 mt-2-i">
<button <!-- Read-only: auto-apply toggle + manual Apply (confirm mode).
type="button" Hidden while editing (filtering is held). -->
class="btn btn-sm btn-primary" <ng-container *ngIf="hotTable.readOnly">
[disabled]="submitLoading"
(click)="checkSave()"
title="Submit changes for approval"
>
<clr-icon
aria-hidden="true"
shape="check"
size="20"
></clr-icon>
Submit
</button>
<label class="va-toggle"> <label class="va-toggle">
<input <input
type="checkbox" type="checkbox"
@@ -505,7 +492,8 @@
<button <button
*ngIf="!vaAutoApply" *ngIf="!vaAutoApply"
[disabled]=" [disabled]="
vaFilterStatus !== 'pending' && vaFilterStatus !== 'loading' vaFilterStatus !== 'pending' &&
vaFilterStatus !== 'loading'
" "
type="button" type="button"
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary"
@@ -513,9 +501,15 @@
> >
Apply filter Apply filter
</button> </button>
</ng-container>
<!-- Persistent live region so screen readers announce the <!-- Edit mode: filtering is held so edits aren't wiped. -->
pending/loading transitions as they happen. --> <span *ngIf="!hotTable.readOnly" 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 class="va-filter-status" role="status" aria-live="polite">
<span <span
*ngIf="vaFilterStatus === 'pending'" *ngIf="vaFilterStatus === 'pending'"
+5 -1
View File
@@ -1,4 +1,3 @@
.va-filter-controls { .va-filter-controls {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -23,3 +22,8 @@
font-style: italic; font-style: italic;
color: #0072a3; color: #0072a3;
} }
.va-disabled-text {
font-style: italic;
opacity: 0.7;
}
+156 -28
View File
@@ -11,7 +11,7 @@ import {
ViewEncapsulation ViewEncapsulation
} from '@angular/core' } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import Handsontable from 'handsontable' import Handsontable, { CellRange } from 'handsontable'
import { Subject, Subscription } from 'rxjs' import { Subject, Subscription } from 'rxjs'
import { sanitiseForSas } from '../shared/utils/sanitise' import { sanitiseForSas } from '../shared/utils/sanitise'
import { SasStoreService } from '../services/sas-store.service' import { SasStoreService } from '../services/sas-store.service'
@@ -21,7 +21,6 @@ type AOA = any[][]
import { HotTableComponent } from '@handsontable/angular-wrapper' import { HotTableComponent } from '@handsontable/angular-wrapper'
import { UploadFile } from '@sasjs/adapter' import { UploadFile } from '@sasjs/adapter'
import { isSpecialMissing } from '@sasjs/utils/input/validators' import { isSpecialMissing } from '@sasjs/utils/input/validators'
import CellRange from 'handsontable/3rdparty/walkontable/src/cell/range'
import { CellValidationSource } from '../models/CellValidationSource' import { CellValidationSource } from '../models/CellValidationSource'
import { FileUploader } from '../models/FileUploader.class' import { FileUploader } from '../models/FileUploader.class'
import { FilterGroup, FilterQuery } from '../models/FilterQuery' import { FilterGroup, FilterQuery } from '../models/FilterQuery'
@@ -47,6 +46,9 @@ import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema' import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
import { excelRound } from '../shared/dc-validator/utils/excelRound' import { excelRound } from '../shared/dc-validator/utils/excelRound'
import { isEmpty } from '../shared/dc-validator/utils/isEmpty' import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
import { buildColInfoHtml } from '../shared/utils/col-info-html'
import { globals } from '../_globals' import { globals } from '../_globals'
import { UploadStaterComponent } from './components/upload-stater/upload-stater.component' import { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
import { import {
@@ -161,8 +163,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
items: { items: {
edit_row: { edit_row: {
name: 'Edit row', name: 'Edit row',
hidden() { // HOT 18's MenuItemConfig types `hidden` as a plain `() => boolean` with
const hot: Handsontable.Core = this // no `this` type, so an object-literal method here has `this` inferred
// as the surrounding item config unless declared explicitly.
hidden(this: Handsontable.Core) {
const hot = this
// Hide editing actions in read-only (view) mode. // Hide editing actions in read-only (view) mode.
if (hot.getSettings().readOnly) return true if (hot.getSettings().readOnly) return true
@@ -243,6 +248,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
redo: { redo: {
name: 'Redo', name: 'Redo',
hidden: () => this.hotTable.readOnly === true hidden: () => this.hotTable.readOnly === true
},
// Navigates rather than just flipping `useLabels` locally, so the
// URL stays the single source of truth (shareable/refreshable
// state) — the queryParams subscription in ngOnInit picks up the
// change and re-renders colHeaders.
toggle_labels: {
name: () => (this.useLabels ? 'Show names' : 'Show labels'),
callback: () => {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { labels: this.useLabels ? null : true },
queryParamsHandling: 'merge'
})
}
} }
} }
} }
@@ -266,6 +285,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
public libTab: string | undefined public libTab: string | undefined
public queryFilter: any public queryFilter: any
public _query: Subscription | undefined public _query: Subscription | undefined
private _queryParams: Subscription | undefined
// `?labels=true` toggle (see parseLabelsParam) — URL is the source of
// truth, kept in sync via the queryParams subscription in ngOnInit.
public useLabels: boolean = false
public whereString: string | undefined public whereString: string | undefined
public clauses: any public clauses: any
@@ -448,6 +472,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
private ariaObserver: MutationObserver | undefined private ariaObserver: MutationObserver | undefined
private ariaCheckInterval: any | undefined private ariaCheckInterval: any | undefined
private gridResizeObserver: ResizeObserver | undefined
constructor( constructor(
private licenceService: LicenceService, private licenceService: LicenceService,
@@ -979,6 +1004,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
const hot = this.hotInstance const hot = this.hotInstance
if (!hot) return if (!hot) return
// Entering edit mode: cancel any scheduled VA filter apply so it can't fire
// mid-edit and wipe the edits. A pending filter is re-applied on the return
// to read-only (cancelEdit).
if (this.vaDebounceTimer) {
clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = undefined
}
const columnSorting = hot.getPlugin('multiColumnSorting') const columnSorting = hot.getPlugin('multiColumnSorting')
const sortConfigs = this.getCurrentSortConfigs() const sortConfigs = this.getCurrentSortConfigs()
@@ -1074,6 +1107,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
this.checkRowLimit() this.checkRowLimit()
// Back to read-only: apply any VA filter change that was held pending while
// editing (live mode). Confirm mode leaves it staged for the Apply button.
if (
this.isVaEmbed &&
this.vaAutoApply &&
this.vaFilterStatus === 'pending'
) {
this.applyPendingVaFilter()
}
} }
/** /**
@@ -1320,7 +1363,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.updateSettings( hot.updateSettings(
{ {
data: this.dataSource, data: this.dataSource,
colHeaders: this.headerColumns, colHeaders: getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
),
columns: this.cellValidation, columns: this.cellValidation,
modifyColWidth: function (width: number, col: number) { modifyColWidth: function (width: number, col: number) {
if (col === 0) { if (col === 0) {
@@ -1399,7 +1446,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.removeCellMeta(rowIndex, col, 'valid') hot.removeCellMeta(rowIndex, col, 'valid')
hot.removeCellMeta(rowIndex, col, 'dupKey') hot.removeCellMeta(rowIndex, col, 'dupKey')
// Remove our custom class from cell metadata // Remove our custom class from cell metadata
const cellMeta = hot.getCellMeta(rowIndex, col) // getCellMeta<M>() defaults to Record<string, unknown> in HOT 18; pin it to
// CellMeta so `.className` keeps its real string | string[] type below.
const cellMeta = hot.getCellMeta<Handsontable.CellMeta>(rowIndex, col)
if (cellMeta.className) { if (cellMeta.className) {
let cleanedClassName: string let cleanedClassName: string
if (Array.isArray(cellMeta.className)) { if (Array.isArray(cellMeta.className)) {
@@ -1784,7 +1833,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.updateSettings( hot.updateSettings(
{ {
data: this.dataSource, data: this.dataSource,
colHeaders: this.headerColumns, colHeaders: getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
),
columns: this.cellValidation, columns: this.cellValidation,
modifyColWidth: function (width: number, col: number) { modifyColWidth: function (width: number, col: number) {
if (width > 500) return 500 if (width > 500) return 500
@@ -2144,7 +2197,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
toggleHotPlugin(pluginName: string, enable: boolean) { toggleHotPlugin(pluginName: string, enable: boolean) {
const hot = this.hotInstance const hot = this.hotInstance
hot.batch(() => {
const contextMenuPlugin = hot.getPlugin<any>(pluginName) const contextMenuPlugin = hot.getPlugin<any>(pluginName)
if (!contextMenuPlugin) { if (!contextMenuPlugin) {
@@ -2157,16 +2209,26 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
setTimeout(() => { setTimeout(() => {
if (enable) { // The instance may be destroyed/rebuilt within this 100ms window (e.g. a
contextMenuPlugin.enablePlugin() // VA filter reload repopulates the grid). A destroyed plugin has its `hot`
// reference deleted, so enablePlugin()/disablePlugin() would throw on
// `this.hot.getSettings()`. Bail if the instance is gone or was swapped.
if (
hot.isDestroyed ||
this.hotInstance !== hot ||
!contextMenuPlugin.hot
) {
return return
} }
if (enable) {
contextMenuPlugin.enablePlugin()
} else {
contextMenuPlugin.disablePlugin() contextMenuPlugin.disablePlugin()
}, 100) }
hot.render() hot.render()
}) }, 100)
} }
private dynamicCellValidationDisabled(row: number, col: number) { private dynamicCellValidationDisabled(row: number, col: number) {
@@ -2246,8 +2308,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
validationSourceIndex validationSourceIndex
].values.map((el) => el.RAW_VALUE) ].values.map((el) => el.RAW_VALUE)
// `.source` (dropdown/autocomplete list) is typed unknown[] | Function here since
// getCellMeta() isn't given a type param; cast to the array branch we actually use.
const cellHadSource = const cellHadSource =
(hot.getCellMeta(row, column).source || []).length < 1 ((hot.getCellMeta(row, column).source as unknown[]) || []).length < 1
const cellHasValue = cellData !== ' ' const cellHasValue = cellData !== ' '
hot.batch(() => { hot.batch(() => {
@@ -2638,6 +2702,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
) )
// URL is the source of truth for the labels toggle: react to `?labels=`
// changes (including same-page navigation from the dropdown menu item)
// rather than reading it once from the route snapshot.
this._queryParams = this.route.queryParams.subscribe((params) => {
this.useLabels = parseLabelsParam(new URLSearchParams(params).toString())
this.applyDisplayColHeaders()
})
this._query = this.sasStoreService.query.subscribe((query: any) => { this._query = this.sasStoreService.query.subscribe((query: any) => {
if (query.libds === this.libds) { if (query.libds === this.libds) {
this.whereString = query.string this.whereString = query.string
@@ -2766,7 +2838,26 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}, 100) }, 100)
} }
/**
* Recomputes `colHeaders` from `headerColumns`/`cols` for the current
* `useLabels` state, and pushes it into a live grid via `updateSettings`
* if one is mounted (no refetch needed).
*/
private applyDisplayColHeaders() {
if (!this.hotInstance || !this.headerColumns.length) return
const colHeaders = getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
)
this.hotInstance.updateSettings({ colHeaders }, false)
}
ngOnDestroy() { ngOnDestroy() {
this._queryParams?.unsubscribe()
// Clean up the MutationObserver // Clean up the MutationObserver
if (this.ariaObserver) { if (this.ariaObserver) {
this.ariaObserver.disconnect() this.ariaObserver.disconnect()
@@ -2779,6 +2870,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
this.ariaCheckInterval = undefined this.ariaCheckInterval = undefined
} }
// Stop observing the grid container for resizes
if (this.gridResizeObserver) {
this.gridResizeObserver.disconnect()
this.gridResizeObserver = undefined
}
// Cancel any pending debounced VA apply // Cancel any pending debounced VA apply
if (this.vaDebounceTimer) { if (this.vaDebounceTimer) {
clearTimeout(this.vaDebounceTimer) clearTimeout(this.vaDebounceTimer)
@@ -2792,6 +2889,25 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
} }
/**
* Re-run height+render when the grid container settles.
*/
private observeGridResize() {
const el = this.hotInstance?.rootElement?.parentElement
if (!el) return
this.gridResizeObserver?.disconnect()
this.gridResizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
const hot = this.hotInstance
if (!hot || hot.isDestroyed) return
hot.updateSettings({ height: this.hotTable.height }, false)
hot.render()
})
})
this.gridResizeObserver.observe(el)
}
/** /**
* Fixes ARIA accessibility issues in the Handsontable component * Fixes ARIA accessibility issues in the Handsontable component
* This addresses the accessibility report issues with treegrid and presentation roles * This addresses the accessibility report issues with treegrid and presentation roles
@@ -3059,7 +3175,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.updateSettings( hot.updateSettings(
{ {
data: this.dataSource, data: this.dataSource,
colHeaders: this.headerColumns, colHeaders: getDisplayColHeaders(
this.headerColumns,
this.cols,
this.useLabels
),
columns: this.cellValidation, columns: this.cellValidation,
height: this.hotTable.height, height: this.hotTable.height,
formulas: this.hotTable.formulas, formulas: this.hotTable.formulas,
@@ -3083,7 +3203,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
manualColumnResize: true, manualColumnResize: true,
filters: false, filters: false,
manualRowResize: true, manualRowResize: true,
viewportRowRenderingOffset: 50, viewportRowRenderingOffset: 100,
// show a bar on the left to enable users to select an entire row // show a bar on the left to enable users to select an entire row
rowHeaders: (index: number) => { rowHeaders: (index: number) => {
return ' ' return ' '
@@ -3119,20 +3239,22 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
itemValue: string itemValue: string
) => { ) => {
const elem = document.createElement('span') const elem = document.createElement('span')
let colName = ''
let colInfo: DataFormat | undefined let colInfo: DataFormat | undefined
let textInfo = 'No info found' let textInfo = 'No info found'
if (this.hotInstance) { if (this.hotInstance) {
const hotSelected: [number, number, number, number][] = // getSelected() is typed number[][] in HOT 18 (was a 4-tuple
// array before); loosen the annotation to match.
const hotSelected: number[][] =
this.hotInstance.getSelected() || [] this.hotInstance.getSelected() || []
const selectedCol: number = hotSelected const selectedCol: number = hotSelected
? hotSelected[0][1] ? hotSelected[0][1]
: -1 : -1
const colName = this.hotInstance?.colToProp(selectedCol) colName = this.hotInstance?.colToProp(selectedCol) as string
colInfo = this.$dataFormats?.vars[colName] colInfo = this.$dataFormats?.vars[colName]
if (colInfo) textInfo = buildColInfoHtml(colName, colInfo)
textInfo = `LABEL: ${colInfo?.label}<br>TYPE: ${colInfo?.type}<br>LENGTH: ${colInfo?.length}<br>FORMAT: ${colInfo?.format}`
} }
elem.innerHTML = textInfo elem.innerHTML = textInfo
@@ -3370,8 +3492,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
) )
hot.addHook('afterRender', (isForced: boolean) => { hot.addHook('afterRender', (isForced: boolean) => {
this.eventService.dispatchEvent('resize')
// Fix ARIA accessibility issues after each render // Fix ARIA accessibility issues after each render
this.fixAriaAccessibility() this.fixAriaAccessibility()
}) })
@@ -3502,16 +3622,18 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.render() hot.render()
this.observeGridResize()
// Fix ARIA accessibility issues after table initialization // Fix ARIA accessibility issues after table initialization
setTimeout(() => { setTimeout(() => {
this.fixAriaAccessibility() this.fixAriaAccessibility()
}, 500) }, 500)
// SAS Visual Analytics data-driven content mode: open editable immediately, // SAS Visual Analytics data-driven content mode: open in read-only view (NOT
// re-apply any column visibility chosen by VA before this (filter) reload, // immediate edit — the user clicks Edit to make changes), re-apply any column
// and start receiving VA messages over the postMessage interface. // visibility chosen by VA before this (filter) reload, and start receiving VA
// messages over the postMessage interface.
if (this.isVaEmbed) { if (this.isVaEmbed) {
this.editTable()
if (this.vaMessaging.visibleColumns) { if (this.vaMessaging.visibleColumns) {
this.applyVaColumnVisibility(new Set(this.vaMessaging.visibleColumns)) this.applyVaColumnVisibility(new Set(this.vaMessaging.visibleColumns))
if (this.hotInstance) this.hotInstance.render() if (this.hotInstance) this.hotInstance.render()
@@ -3604,9 +3726,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
this.vaPendingSignature = signature this.vaPendingSignature = signature
this.vaFilterStatus = 'pending' this.vaFilterStatus = 'pending'
// Live mode auto-advances after the debounce settles; confirm waits for the // Filtering repopulates the grid, so it must never run while the user is
// Apply button. The pending indicator stays visible throughout the debounce. // editing (it would wipe their in-progress edits). While in edit mode we
if (this.vaAutoApply) this.scheduleVaFilterApply() // only surface the pending indicator; the filter is applied on return to
// read-only (see cancelEdit). Live mode auto-advances after the debounce
// settles; confirm waits for the Apply button.
if (this.vaAutoApply && this.hotTable.readOnly) this.scheduleVaFilterApply()
} }
/** Live mode: debounce a burst of VA changes into a single apply of the latest. */ /** Live mode: debounce a burst of VA changes into a single apply of the latest. */
@@ -3669,6 +3794,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
/** Apply the staged VA filter (the debounce in live mode, or the Apply button). */ /** Apply the staged VA filter (the debounce in live mode, or the Apply button). */
public applyPendingVaFilter(): void { public applyPendingVaFilter(): void {
if (!this.vaPendingClauses) return if (!this.vaPendingClauses) return
// Never apply while editing — the reload would wipe in-progress edits. The
// pending filter stays staged and is applied when the user leaves edit mode.
if (!this.hotTable.readOnly) return
const clauses = this.vaPendingClauses const clauses = this.vaPendingClauses
const signature = this.vaPendingSignature const signature = this.vaPendingSignature
this.vaPendingClauses = null this.vaPendingClauses = null
+16
View File
@@ -18,6 +18,22 @@ export const dateToTime = (date: Date) => {
return timeStr return timeStr
} }
/**
* Normalises a 24-hour time STRING to zero-padded `HH:mm:ss`.
*
* The `intl-time` cell type (validator + native `<input type=time>` editor) is
* strict and rejects `H:mm[:ss]`, so such a value paints red and the editor
* opens empty. Pad the hour and default the seconds so the value validates.
*
* Non time-like input is returned unchanged.
*/
export const padTimeString = (value: string): string => {
const match = value.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
if (!match) return value
const [, hours, minutes, seconds] = match
return `${hours.padStart(2, '0')}:${minutes}:${seconds ?? '00'}`
}
/** /**
* Converts date object to the YYYY-MM-DD * Converts date object to the YYYY-MM-DD
*/ */
-1
View File
@@ -37,7 +37,6 @@ export interface MaxVarLength {
export interface SASParam { export interface SASParam {
COLHEADERS: string COLHEADERS: string
COLTYPE: string
DTTMVARS: string DTTMVARS: string
DTVARS: string DTVARS: string
CLS_FLAG: number CLS_FLAG: number
@@ -22,10 +22,9 @@ import { ExcelRule } from '../models/TableData'
import { HotTableInterface } from '../models/HotTable.interface' import { HotTableInterface } from '../models/HotTable.interface'
import { Col } from '../shared/dc-validator/models/col.model' import { Col } from '../shared/dc-validator/models/col.model'
import { SpreadsheetService } from '../services/spreadsheet.service' import { SpreadsheetService } from '../services/spreadsheet.service'
import Handsontable from 'handsontable' import Handsontable, { CellChange, ChangeSource } from 'handsontable'
import { HotTableComponent } from '@handsontable/angular-wrapper' import { HotTableComponent } from '@handsontable/angular-wrapper'
import { EditorsStageDataSASResponse } from '../models/sas/editors-stagedata.model' import { EditorsStageDataSASResponse } from '../models/sas/editors-stagedata.model'
import { CellChange, ChangeSource } from 'handsontable/common'
import { baseAfterGetColHeader } from '../shared/utils/hot.utils' import { baseAfterGetColHeader } from '../shared/utils/hot.utils'
import { ColumnSettings } from 'handsontable/settings' import { ColumnSettings } from 'handsontable/settings'
import { UploadFile } from '@sasjs/adapter' import { UploadFile } from '@sasjs/adapter'
@@ -502,7 +501,9 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
if (changes) { if (changes) {
for (let change of changes) { for (let change of changes) {
if (change && change[3]) { if (change && change[3]) {
change[3] = change[3].toUpperCase() // CellValue is `unknown` in HOT 18 (was a concrete union before);
// this column is always a dataset name string.
change[3] = (change[3] as string).toUpperCase()
} }
} }
} }
@@ -552,7 +553,9 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
dynamicCellValidations() { dynamicCellValidations() {
if (!this.hotInstanceUserDataset) return if (!this.hotInstanceUserDataset) return
const hotData = this.hotInstanceUserDataset.getData() // getData() returns unknown[][] in HOT 18 (was CellValue[][]); this grid's
// data is always library/table name strings.
const hotData = this.hotInstanceUserDataset.getData() as string[][]
hotData.forEach((row, rowIndex) => { hotData.forEach((row, rowIndex) => {
const library = row[0] const library = row[0]
@@ -573,10 +576,12 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
if (dataAtRow && dataAtRow[0] && dataAtRow[1]) { if (dataAtRow && dataAtRow[0] && dataAtRow[1]) {
if (!this.matchedDatasets.includes(dataset)) { if (!this.matchedDatasets.includes(dataset)) {
// getCellMetaAtRow() returns Record<string, unknown>[] in HOT 18, so
// `.col` needs casting back to number for setCellMeta() (same below).
cellMetaAtRow.forEach((cellMeta) => { cellMetaAtRow.forEach((cellMeta) => {
this.hotInstanceUserDataset.setCellMeta( this.hotInstanceUserDataset.setCellMeta(
row, row,
cellMeta.col, cellMeta.col as number,
'className', 'className',
'not-matched' 'not-matched'
) )
@@ -585,7 +590,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
cellMetaAtRow.forEach((cellMeta) => { cellMetaAtRow.forEach((cellMeta) => {
this.hotInstanceUserDataset.setCellMeta( this.hotInstanceUserDataset.setCellMeta(
row, row,
cellMeta.col, cellMeta.col as number,
'className', 'className',
'' ''
) )
@@ -595,7 +600,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
cellMetaAtRow.forEach((cellMeta) => { cellMetaAtRow.forEach((cellMeta) => {
this.hotInstanceUserDataset.setCellMeta( this.hotInstanceUserDataset.setCellMeta(
row, row,
cellMeta.col, cellMeta.col as number,
'className', 'className',
'' ''
) )
@@ -991,7 +996,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
private getDatasetsFromHot(): string[] { private getDatasetsFromHot(): string[] {
if (!this.hotInstanceUserDataset) return [] if (!this.hotInstanceUserDataset) return []
const hotData = this.hotInstanceUserDataset.getData() // getData() returns unknown[][] in HOT 18; see dynamicCellValidations() above.
const hotData = this.hotInstanceUserDataset.getData() as string[][]
return hotData return hotData
.filter((row) => row[0]?.length && row[1]?.length) .filter((row) => row[0]?.length && row[1]?.length)
@@ -55,11 +55,11 @@ describe('VaFilterService', () => {
'SOME_DATETIME' 'SOME_DATETIME'
] ]
const cols = [ const cols = [
{ NAME: 'SOME_CHAR', TYPE: 'char', DDTYPE: 'CHAR' }, { NAME: 'SOME_CHAR', DDTYPE: 'CHARACTER' },
{ NAME: 'SOME_NUM', TYPE: 'num', DDTYPE: 'NUMERIC' }, { NAME: 'SOME_NUM', DDTYPE: 'NUMERIC' },
{ NAME: 'SOME_TIME', TYPE: 'num', DDTYPE: 'TIME' }, { NAME: 'SOME_TIME', DDTYPE: 'TIME' },
{ NAME: 'SOME_DATE', TYPE: 'num', DDTYPE: 'DATE' }, { NAME: 'SOME_DATE', DDTYPE: 'DATE' },
{ NAME: 'SOME_DATETIME', TYPE: 'num', DDTYPE: 'DATETIME' } { NAME: 'SOME_DATETIME', DDTYPE: 'DATETIME' }
] ]
beforeEach(() => { beforeEach(() => {
@@ -122,6 +122,90 @@ describe('VaFilterService', () => {
).toBe('365') ).toBe('365')
}) })
it('passes an already-internal (bare number) temporal value through raw', () => {
// VA sends the raw SAS internal value when the data item has no format.
expect(build([{ label: 'SOME_DATE', value: '182' }])[0].RAW_VALUE).toBe(
'182'
)
expect(
build([{ label: 'SOME_DATETIME', value: '365' }])[0].RAW_VALUE
).toBe('365')
expect(build([{ label: 'SOME_TIME', value: '2' }])[0].RAW_VALUE).toBe('2')
})
it('parses ISO date shapes identically to DATE9 (VA sends no format)', () => {
const date9 = build([{ label: 'SOME_DATE', value: '01AUG1962' }])[0]
.RAW_VALUE
// The production symptom: YYMMDDS "2025/03/31" style must parse, not drop.
expect(
build([{ label: 'SOME_DATE', value: '1962-08-01' }])[0].RAW_VALUE
).toBe(date9)
expect(
build([{ label: 'SOME_DATE', value: '1962/08/01' }])[0].RAW_VALUE
).toBe(date9)
// A real production-format value must yield a clause (not be dropped).
expect(build([{ label: 'SOME_DATE', value: '2025/03/31' }])).toHaveSize(1)
})
it('resolves ambiguous slash dates day-first (never US), disambiguating by value', () => {
const iso = build([{ label: 'SOME_DATE', value: '2025-03-31' }])[0]
.RAW_VALUE
// 31 can only be a day -> both orders resolve to 2025-03-31.
expect(
build([{ label: 'SOME_DATE', value: '31/03/2025' }])[0].RAW_VALUE
).toBe(iso) // day-first
expect(
build([{ label: 'SOME_DATE', value: '03/31/2025' }])[0].RAW_VALUE
).toBe(iso) // value forces month-first
// Genuinely ambiguous (both <= 12): default DAY-first => 4th March, NOT 3rd April.
expect(
build([{ label: 'SOME_DATE', value: '04/03/2025' }])[0].RAW_VALUE
).toBe(build([{ label: 'SOME_DATE', value: '2025-03-04' }])[0].RAW_VALUE)
// Impossible date is dropped, not rolled over.
expect(build([{ label: 'SOME_DATE', value: '31/02/2025' }])).toEqual([])
})
it('uses the column FMTNAME to resolve ambiguous dates (MMDDYY reads US month-first)', () => {
const iso = (v: string) =>
service.buildClauses(
msg([{ label: 'D', value: v }]),
new Map([['d', 'D']]),
[{ NAME: 'D', DDTYPE: 'DATE' }]
)[0]?.RAW_VALUE
const withFmt = (fmt: string, v: string) =>
service.buildClauses(
msg([{ label: 'D', value: v }]),
new Map([['d', 'D']]),
[{ NAME: 'D', DDTYPE: 'DATE', FMTNAME: fmt }]
)[0]?.RAW_VALUE
// 04/03/2025, both <= 12 (ambiguous): FMTNAME decides.
expect(withFmt('MMDDYY', '04/03/2025')).toBe(iso('2025-04-03')) // 3 April
expect(withFmt('DDMMYY', '04/03/2025')).toBe(iso('2025-03-04')) // 4 March
// Value still overrides a wrong FMTNAME: 31 can only be the day.
expect(withFmt('MMDDYY', '31/03/2025')).toBe(iso('2025-03-31'))
// Falls back to the full FORMAT (e.g. MMDDYY10.) when FMTNAME is blank.
const withFormat = service.buildClauses(
msg([{ label: 'D', value: '04/03/2025' }]),
new Map([['d', 'D']]),
[{ NAME: 'D', DDTYPE: 'DATE', FMTNAME: '', FORMAT: 'MMDDYY10.' }]
)[0]?.RAW_VALUE
expect(withFormat).toBe(iso('2025-04-03')) // 3 April, from FORMAT
})
it('parses ISO datetime shapes identically to SAS DATETIME', () => {
const sas = build([
{ label: 'SOME_DATETIME', value: '01JAN1960:00:06:05' }
])[0].RAW_VALUE
expect(
build([{ label: 'SOME_DATETIME', value: '1960-01-01T00:06:05' }])[0]
.RAW_VALUE
).toBe(sas)
expect(
build([{ label: 'SOME_DATETIME', value: '1960-01-01 00:06:05' }])[0]
.RAW_VALUE
).toBe(sas)
})
it('matches the column by label case-insensitively', () => { it('matches the column by label case-insensitively', () => {
expect(build([{ label: 'some_char', value: 'x' }])[0].VARIABLE_NM).toBe( expect(build([{ label: 'some_char', value: 'x' }])[0].VARIABLE_NM).toBe(
'SOME_CHAR' 'SOME_CHAR'
@@ -153,6 +237,21 @@ describe('VaFilterService', () => {
['SOME_DATETIME', '365'] ['SOME_DATETIME', '365']
]) ])
}) })
it('infers ISO date/datetime shapes by value shape', () => {
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'SOME_DATE', value: '1960-07-01' },
{ name: 'pr2', label: 'SOME_DATE2', value: '1960/07/01' },
{ name: 'pr3', label: 'SOME_DTTM', value: '1960-01-01T00:06:05' }
])
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_DATE', '182'],
['SOME_DATE2', '182'],
['SOME_DTTM', '365']
])
})
}) })
describe('signature', () => { describe('signature', () => {
+191 -36
View File
@@ -72,7 +72,9 @@ export class VaFilterService {
const varName = labelToVar.get(key) const varName = labelToVar.get(key)
if (!varName) continue if (!varName) continue
const kind = this.columnKind(varName, cols) const col = this.findCol(varName, cols)
const kind = this.columnKind(col)
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
const rawValues = Array.isArray(param.value) ? param.value : [param.value] const rawValues = Array.isArray(param.value) ? param.value : [param.value]
// Format/validate every value for the column's SAS type. Numeric columns // Format/validate every value for the column's SAS type. Numeric columns
// must receive a finite number (untrusted postMessage input is // must receive a finite number (untrusted postMessage input is
@@ -81,7 +83,7 @@ export class VaFilterService {
// escaped. Invalid values are dropped. // escaped. Invalid values are dropped.
const formatted = rawValues const formatted = rawValues
.filter((v) => v !== undefined && v !== null && v !== '') .filter((v) => v !== undefined && v !== null && v !== '')
.map((v) => this.formatValueByKind(v, kind)) .map((v) => this.formatValueByKind(v, kind, order))
.filter((v): v is string => v !== null) .filter((v): v is string => v !== null)
if (formatted.length === 0) continue if (formatted.length === 0) continue
clauses.push(this.clause(varName, formatted)) clauses.push(this.clause(varName, formatted))
@@ -151,34 +153,63 @@ export class VaFilterService {
} }
} }
/** /** The loaded column spec whose NAME matches varName (case-insensitive). */
* SAS data-type kind of a column, from the loaded column specs. DDTYPE carries private findCol(varName: string, cols: any[]): any {
* TIME/DATE/DATETIME; checked DATETIME-before-DATE (substring). return (cols || []).find(
*/
private columnKind(varName: string, cols: any[]): VaColumnKind {
const col = (cols || []).find(
(x: any) => (x: any) =>
(x?.NAME ?? '').toString().toUpperCase() === varName.toUpperCase() (x?.NAME ?? '').toString().toUpperCase() === varName.toUpperCase()
) )
}
/**
* SAS data-type kind of a column spec. DDTYPE carries
* TIME/DATE/DATETIME/NUMERIC/CHARACTER; checked DATETIME-before-DATE (substring).
*/
private columnKind(col: any): VaColumnKind {
const ddtype = (col?.DDTYPE ?? '').toString().toUpperCase() const ddtype = (col?.DDTYPE ?? '').toString().toUpperCase()
if (ddtype.includes('DATETIME')) return 'datetime' if (ddtype.includes('DATETIME')) return 'datetime'
if (ddtype.includes('DATE')) return 'date' if (ddtype.includes('DATE')) return 'date'
if (ddtype.includes('TIME')) return 'time' if (ddtype.includes('TIME')) return 'time'
if ((col?.TYPE ?? '') === 'num') return 'numeric' if (ddtype.includes('NUMERIC') || (col?.TYPE ?? '') === 'num') {
return 'numeric'
}
return 'char' return 'char'
} }
/**
* Day/month/year order declared by a column's SAS format, used to resolve
* ambiguous numeric dates. Accepts either the base FMTNAME (e.g. `DDMMYY`) or
* the full FORMAT (e.g. `DDMMYY10.`) — the leading name is the same and is all
* that matters for order (width is irrelevant). This is the SOURCE column's
* format; the VA report can in principle display a different one, so
* value-based disambiguation (a group > 12 is the day) still takes precedence —
* this only decides the genuinely-ambiguous case. `undefined` when the format
* is absent, month-name (DATE*), or locale-dependent (NLDATE).
*/
private dateOrderFromFmt(fmtname: any): 'YMD' | 'DMY' | 'MDY' | undefined {
const f = (fmtname ?? '').toString().toUpperCase()
if (/^MMDDYY/.test(f)) return 'MDY'
if (/^(DDMMYY|EURDF)/.test(f)) return 'DMY'
if (/^(YYMMDD|E8601DA|B8601DA)/.test(f)) return 'YMD'
return undefined
}
/** /**
* Infers a column's SAS kind from a VA value string (deferred load, no * Infers a column's SAS kind from a VA value string (deferred load, no
* metadata). VA's temporal strings are distinctive — `ddMMMyyyy[:HH:MM:SS]` * metadata). VA's temporal strings are distinctive — DATE9 `ddMMMyyyy` and ISO
* and `H:MM:SS` — so they're detected by shape; otherwise fall back to the VA * `yyyy-MM-dd`/`yyyy/MM/dd`, optionally with a `[ T:]HH:MM[:SS]` time part, and
* `H:MM:SS` — so they're detected by shape; otherwise fall back to the VA
* `dataType`. * `dataType`.
*/ */
private inferKindFromValue(value: string, dataType?: string): VaColumnKind { private inferKindFromValue(value: string, dataType?: string): VaColumnKind {
if (/^\d{1,2}[A-Za-z]{3}\d{4}:\d{2}:\d{2}(:\d{2})?$/.test(value)) { const monName = String.raw`\d{1,2}[A-Za-z]{3}\d{2,4}`
const ymd = String.raw`\d{4}[-/.]\d{1,2}[-/.]\d{1,2}` // 4-digit year first
const dmy = String.raw`\d{1,2}[-/.]\d{1,2}[-/.]\d{4}` // 4-digit year last
const date = `${monName}|${ymd}|${dmy}`
if (new RegExp(`^(?:${date})[ T:]\\d{1,2}:\\d{2}(:\\d{2})?$`).test(value)) {
return 'datetime' return 'datetime'
} }
if (/^\d{1,2}[A-Za-z]{3}\d{4}$/.test(value)) return 'date' if (new RegExp(`^(?:${date})$`).test(value)) return 'date'
if (/^\d{1,2}:\d{2}(:\d{2})?(\.\d+)?$/.test(value)) return 'time' if (/^\d{1,2}:\d{2}(:\d{2})?(\.\d+)?$/.test(value)) return 'time'
if (dataType === 'number') return 'numeric' if (dataType === 'number') return 'numeric'
return 'char' return 'char'
@@ -197,8 +228,18 @@ export class VaFilterService {
* either yields a finite number or is dropped — also the injection guard. * either yields a finite number or is dropped — also the injection guard.
* Date/datetime reuse HelperService.convertJsDateToSasDate so the value is * Date/datetime reuse HelperService.convertJsDateToSasDate so the value is
* byte-identical to what DC's own pickers produce. * byte-identical to what DC's own pickers produce.
*
* A temporal value that is already a bare number is the SAS internal value
* itself (VA sends it unformatted when the data item carries no format / is
* used as a measure), so it is passed through raw. This is unambiguous because
* the only date formats in use (DATE9, ISO) always contain a letter or a
* separator — a plain integer is never a formatted date.
*/ */
private formatValueByKind(value: any, kind: VaColumnKind): string | null { private formatValueByKind(
value: any,
kind: VaColumnKind,
order?: 'YMD' | 'DMY' | 'MDY'
): string | null {
const s = String(value).trim() const s = String(value).trim()
if (s === '') return null if (s === '') return null
switch (kind) { switch (kind) {
@@ -207,17 +248,20 @@ export class VaFilterService {
return Number.isFinite(num) ? `${num}` : null return Number.isFinite(num) ? `${num}` : null
} }
case 'time': { case 'time': {
if (this.isBareNumber(s)) return `${Number(s)}`
const secs = this.timeToSeconds(s) const secs = this.timeToSeconds(s)
return secs === null ? null : `${secs}` return secs === null ? null : `${secs}`
} }
case 'date': { case 'date': {
const d = this.sasDateToJsDate(s) if (this.isBareNumber(s)) return `${Number(s)}`
const d = this.parseDate(s, order)
return d return d
? `${this.helperService.convertJsDateToSasDate(d, 'days')}` ? `${this.helperService.convertJsDateToSasDate(d, 'days')}`
: null : null
} }
case 'datetime': { case 'datetime': {
const d = this.sasDatetimeToJsDate(s) if (this.isBareNumber(s)) return `${Number(s)}`
const d = this.parseDatetime(s, order)
return d return d
? `${this.helperService.convertJsDateToSasDate(d, 'seconds')}` ? `${this.helperService.convertJsDateToSasDate(d, 'seconds')}`
: null : null
@@ -238,29 +282,140 @@ export class VaFilterService {
return h * 3600 + m * 60 + sec return h * 3600 + m * 60 + sec
} }
/** SAS "ddMMMyyyy" (e.g. 01JUL1960) -> local JS Date, or null. */ /**
private sasDateToJsDate(value: string): Date | null { * VA date string -> local JS Date, or null. VA sends no format metadata, so the
const m = /^(\d{1,2})([A-Za-z]{3})(\d{4})$/.exec(value) * shape is sniffed. Accepted forms (separator may be `-`, `/`, or `.`):
if (!m) return null * - DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962)
const monIdx = VaFilterService.SAS_MONTHS.indexOf(m[2].toUpperCase()) * - ISO/YMD "yyyy-MM-dd" (4-digit year first, e.g. 2025-03-31)
if (monIdx < 0) return null * - year-last "dd/mm/yyyy" or "mm/dd/yyyy" (4-digit year last)
return new Date(Number(m[3]), monIdx, Number(m[1])) *
* Day/month order for the year-last form is resolved in priority order:
* 1. by value — a group > 12 must be the day (ground truth);
* 2. by `order` — the column's declared FMTNAME order (DMY/MDY/YMD);
* 3. default DAY-FIRST — never US month-first — matching SAS's DDMMYY
* default and non-US convention.
* `new Date(y, m-1, d)` is local, matching HelperService.convertJsDateToSasDate
* so the value is byte-identical to DC's own pickers. The constructed date is
* round-trip validated to reject impossible values (e.g. 31/02) rather than
* silently roll over.
*/
private parseDate(value: string, order?: 'YMD' | 'DMY' | 'MDY'): Date | null {
return this.parseMonthNameDate(value) ?? this.parseNumericDate(value, order)
} }
/** SAS "ddMMMyyyy:HH:MM[:SS]" (e.g. 01JAN1960:00:06:05) -> local JS Date, or null. */ /** DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962, 01AUG62) -> local Date, or null. */
private sasDatetimeToJsDate(value: string): Date | null { private parseMonthNameDate(value: string): Date | null {
const m = const m = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
/^(\d{1,2})([A-Za-z]{3})(\d{4}):(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value)
if (!m) return null if (!m) return null
const monIdx = VaFilterService.SAS_MONTHS.indexOf(m[2].toUpperCase()) const [, day, monthAbbr, year] = m
if (monIdx < 0) return null const monthIndex = VaFilterService.SAS_MONTHS.indexOf(
return new Date( monthAbbr.toUpperCase()
Number(m[3]),
monIdx,
Number(m[1]),
Number(m[4]),
Number(m[5]),
Number(m[6] || 0)
) )
if (monthIndex < 0) return null
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
}
/**
* Numeric date with `-`, `/`, or `.` separators -> local Date, or null:
* - year-first "yyyy-MM-dd" -> YMD directly;
* - year-last "dd/mm/yyyy" or "mm/dd/yyyy" -> day/month resolved by
* resolveDayMonth (value, then column order, then day-first default).
* A 4-digit year (leading or trailing) is required to anchor the parse;
* an all-2-digit value (e.g. "03/04/05") is too ambiguous and is dropped.
*/
private parseNumericDate(
value: string,
order?: 'YMD' | 'DMY' | 'MDY'
): Date | null {
const m = /^(\d{1,4})[-/.](\d{1,2})[-/.](\d{1,4})$/.exec(value)
if (!m) return null
const [, first, middle, last] = m
const yearIsFirst = first.length === 4
const yearIsLast = last.length === 4
if (yearIsFirst) {
return this.buildDate(Number(first), Number(middle), Number(last))
}
if (!yearIsLast) return null
const { day, month } = this.resolveDayMonth(
Number(first),
Number(middle),
order
)
return this.buildDate(Number(last), month, day)
}
/**
* Resolves the day/month of a year-last date whose two leading groups (`a`,
* `b`) are in unknown order, in priority:
* 1. by value — a group > 12 must be the day (ground truth);
* 2. by `order` — only MDY flips to month-first; DMY/YMD keep day-first;
* 3. default — DAY-first (`a` is the day), never US month-first.
*/
private resolveDayMonth(
a: number,
b: number,
order?: 'YMD' | 'DMY' | 'MDY'
): { day: number; month: number } {
if (a > 12 && b <= 12) return { day: a, month: b }
if (b > 12 && a <= 12) return { day: b, month: a }
if (order === 'MDY') return { day: b, month: a }
return { day: a, month: b }
}
/** Local Date from 1-based month, round-trip validated (null if impossible). */
private buildDate(
year: number,
month: number,
day: number,
hours = 0,
minutes = 0,
seconds = 0
): Date | null {
if (month < 1 || month > 12 || day < 1 || day > 31) return null
const d = new Date(year, month - 1, day, hours, minutes, seconds, 0)
// Reject rollovers (e.g. 31 Feb -> 3 Mar) so a bad value is dropped, not
// silently converted to the wrong SAS date.
if (
d.getFullYear() !== year ||
d.getMonth() !== month - 1 ||
d.getDate() !== day
) {
return null
}
return d
}
/**
* VA datetime string -> local JS Date, or null. The date part is any shape
* accepted by parseDate; the time part is "HH:MM[:SS]". They are joined by the
* SAS ":" separator (e.g. 01JAN1960:05:13:03), an ISO "T", or a space
* (e.g. 2025-03-31T05:13:03, 2025-03-31 05:13:03).
*/
private parseDatetime(
value: string,
order?: 'YMD' | 'DMY' | 'MDY'
): Date | null {
const m = /^(.+?)[ T:](\d{1,2}:\d{2}(?::\d{2})?)$/.exec(value)
if (!m) return null
const date = this.parseDate(m[1], order)
if (!date) return null
const t = m[2].split(':')
date.setHours(Number(t[0]), Number(t[1]), Number(t[2] || 0), 0)
return date
}
/** True when the value is a bare (already-internal) SAS number: optional sign,
* digits, optional fraction. No letters/separators — so never a formatted date. */
private isBareNumber(value: string): boolean {
return /^-?\d+(\.\d+)?$/.test(value) && Number.isFinite(Number(value))
}
/** 2-digit SAS year -> 4-digit (SAS default cutoff: 00-19 -> 20xx, else 19xx). */
private fullYear(year: string): number {
if (year.length === 4) return Number(year)
const y = Number(year)
return y <= 19 ? 2000 + y : 1900 + y
} }
} }
@@ -17,7 +17,8 @@ import { DQRule, DQRuleTypes } from './models/dq-rules.model'
import { getDqDataCols } from './utils/getDqDataCols' import { getDqDataCols } from './utils/getDqDataCols'
import { getNotNullDefault } from './utils/getNotNullDefault' import { getNotNullDefault } from './utils/getNotNullDefault'
import { mergeColsRules } from './utils/mergeColsRules' import { mergeColsRules } from './utils/mergeColsRules'
import { parseColType } from './utils/parseColType' import { parseColTypeRow } from './utils/parseColTypeRow'
import { DELETE_RECORD_COLUMN_RULE } from './utils/deleteRecordColumnRule'
import { dqValidate } from './validations/dq-validation' import { dqValidate } from './validations/dq-validation'
import { import {
datetimeValidator, datetimeValidator,
@@ -50,7 +51,33 @@ export class DcValidator {
this.sasparams = sasparams this.sasparams = sasparams
this.hotInstance = hotInstance this.hotInstance = hotInstance
this.rules = parseColType(sasparams.COLTYPE) // Each cols[i].COLTYPE is a single JSON-object string (one per column, as
// of the vertical-array getdata.sas fix — issue #253), not the old
// comma-joined sasparams.COLTYPE list. DELETE_RECORD_COLUMN_RULE is
// injected separately — see its own doc comment for why.
//
// COLHEADERS is the authoritative grid order and the only safe key to
// build against: it is the variable list of the *output* dataset, where
// getdata.sas hoists the delete column and the PK to the front ("order
// delete var and pk fields at start of table"), whereas cols[].VARNUM
// describes the *source* table and cols[] itself can arrive sorted by
// NAME. Those orders diverge whenever the PK isn't already the source's
// first column, so rules are keyed by name and emitted in COLHEADERS
// order — never paired positionally against cols[] or VARNUM.
//
// A column with no parseable COLTYPE falls back to a plain text rule
// rather than being dropped: getRules() is consumed positionally against
// COLHEADERS, so a missing entry would shift every later column.
const ruleByName = new Map<string, DcValidation>([
[DELETE_RECORD_COLUMN_RULE.data, { ...DELETE_RECORD_COLUMN_RULE }]
])
for (const col of cols) {
const rule = parseColTypeRow(col.COLTYPE)
if (rule) ruleByName.set(col.NAME, rule)
}
this.rules = sasparams.COLHEADERS.split(',').map(
(name) => ruleByName.get(name) ?? { data: name }
)
this.rules = mergeColsRules(cols, this.rules, $dataFormats) this.rules = mergeColsRules(cols, this.rules, $dataFormats)
this.rules = applyNumericFormats(this.rules) this.rules = applyNumericFormats(this.rules)
this.rules = mapIntlCellTypes(this.rules) this.rules = mapIntlCellTypes(this.rules)
@@ -540,8 +567,11 @@ export class DcValidator {
if (rule.clsRule === 'EDIT') { if (rule.clsRule === 'EDIT') {
rule.readOnly = false rule.readOnly = false
// Guard the -1: splice(-1, 1) drops the LAST hidden column rather than
// no-op'ing, so an EDIT column that was never hidden would un-hide an
// unrelated one (e.g. a column hidden by a HIDDEN DQ rule).
const hiddenIndex = this.hiddenColumns.indexOf(index) const hiddenIndex = this.hiddenColumns.indexOf(index)
this.hiddenColumns.splice(hiddenIndex, 1) if (hiddenIndex !== -1) this.hiddenColumns.splice(hiddenIndex, 1)
} }
} }
} }
@@ -1,10 +1,9 @@
import Handsontable from 'handsontable' import Handsontable from 'handsontable'
import Core from 'handsontable/core'
export class CustomAutocompleteEditor export class CustomAutocompleteEditor
extends Handsontable.editors.AutocompleteEditor extends Handsontable.editors.AutocompleteEditor
{ {
constructor(instance: Core) { constructor(instance: Handsontable.Core) {
super(instance) super(instance)
} }
@@ -13,8 +12,9 @@ export class CustomAutocompleteEditor
} }
// Listbox open // Listbox open
open(event?: Event | undefined): void { // HOT 18's AutocompleteEditor.open() takes no arguments (was `event?: Event`).
super.open(event) open(): void {
super.open()
if (this.isCellNumeric()) { if (this.isCellNumeric()) {
this.htContainer.classList.add('numericListbox') this.htContainer.classList.add('numericListbox')
@@ -9,4 +9,8 @@ export interface Col {
MEMLABEL: string MEMLABEL: string
DESC: string DESC: string
LONGDESC: string LONGDESC: string
// Handsontable column-formatting spec (JSON-object string), one per column
// — see parseColTypeRow.ts. Optional: getdata.sas LEFT JOINs this in, so
// an unmatched row can leave it unset.
COLTYPE?: string
} }
@@ -23,11 +23,11 @@ describe('DC Validator', () => {
// Check if COLS merged with FORMATS // Check if COLS merged with FORMATS
expect(cols[0].TYPE).toEqual('char') expect(cols[0].TYPE).toEqual('char')
// Get all // Get all — one rule per cols[] entry, plus the injected
// DELETE_RECORD_COLUMN_RULE (never present in cols[], see its own
// doc comment)
const validationRules = dcValidator.getRules() const validationRules = dcValidator.getRules()
expect(validationRules).toHaveSize( expect(validationRules).toHaveSize(example_cols.length + 1)
JSON.parse(`[${example_COLTYPE}]`).length
)
// Get col with notnull validation // Get col with notnull validation
const someNumRule = dcValidator.getRule('SOME_NUM') const someNumRule = dcValidator.getRule('SOME_NUM')
@@ -66,6 +66,15 @@ describe('DC Validator', () => {
const someDropdownHardRule = dcValidator.getRule('SOME_DROPDOWN_HARD') const someDropdownHardRule = dcValidator.getRule('SOME_DROPDOWN_HARD')
// Check strict - it is hardselect so strict should be true // Check strict - it is hardselect so strict should be true
expect(someDropdownHardRule?.strict).toBeTrue() expect(someDropdownHardRule?.strict).toBeTrue()
// _____DELETE__THIS__RECORD_____ never has a cols[] entry (%mp_getcols
// doesn't know about it — see DELETE_RECORD_COLUMN_RULE), but its rule
// must still exist with its fixed dropdown shape, independent of cols
const deleteRecordRule = dcValidator.getRule(
'_____DELETE__THIS__RECORD_____'
)
expect(deleteRecordRule?.type).toEqual('dropdown')
expect(deleteRecordRule?.source).toEqual(['No', 'Yes'])
}) })
it('should create an instance of validator and execute its functions', () => { it('should create an instance of validator and execute its functions', () => {
@@ -278,8 +287,151 @@ describe('DC Validator', () => {
expect(dcValidator.getRoundDigits('SOME_SHORTNUM')).toEqual(2) expect(dcValidator.getRoundDigits('SOME_SHORTNUM')).toEqual(2)
expect(dcValidator.getRoundDigits('SOME_NUM')).toBeUndefined() expect(dcValidator.getRoundDigits('SOME_NUM')).toBeUndefined()
}) })
it('5 | orders rules by COLHEADERS when cols[] arrives in a different order', () => {
// %mp_getcols/cols1 can serialize cols[] alphabetically by NAME rather
// than in COLHEADERS order (confirmed against a real getdata.sas
// response). getRules() is consumed positionally against headerColumns,
// so rule order must follow COLHEADERS regardless of cols[]'s own order.
const alphabeticalCols = [...example_cols].sort((a, b) =>
a.NAME.localeCompare(b.NAME)
)
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
alphabeticalCols,
example_dqRules,
example_dqData
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
example_sasparams.COLHEADERS.split(',')
)
}) })
it("6 | keeps rules aligned with COLHEADERS when the PK is not the source table's first column", () => {
// Regression test for the real-world misalignment: getdata.sas hoists the
// delete column and PK to the front of the *output* dataset ("order
// delete var and pk fields at start of table"), so COLHEADERS order and
// cols[].VARNUM order (which describes the *source* table) diverge
// whenever the PK isn't already the source's first column. Worst case (as
// seen in the wild): the PK is the source's LAST column, so pairing rules
// to headers by VARNUM shifts every single column by one.
const cols: Col[] = [
// deliberately alphabetical, as the backend serializes it
makeCol('PRIMARY_KEY_FIELD', 6), // the PK — last in the source table
makeCol('SOME_CHAR', 1),
makeCol('SOME_DATE', 2),
makeCol('SOME_DROPDOWN', 3),
makeCol('SOME_NUM', 4),
makeCol('SOME_TIME', 5)
]
const sasparams: SASParam = {
...example_sasparams,
// PK hoisted to the front by getdata.sas, ahead of VARNUM 1..5
COLHEADERS:
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,' +
'SOME_DATE,SOME_DROPDOWN,SOME_NUM,SOME_TIME',
PK: 'PRIMARY_KEY_FIELD'
} as SASParam
const dcValidator: DcValidator = new DcValidator(
sasparams,
{ vars: {} } as $DataFormats,
cols,
[],
[]
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
sasparams.COLHEADERS.split(',')
)
})
it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => {
// A dropped rule would shorten getRules() and shift every later column
// against headerColumns. getdata.sas LEFT JOINs COLTYPE in, so an
// unmatched row can legitimately leave it unset.
const cols: Col[] = [
makeCol('PRIMARY_KEY_FIELD', 1),
{ ...makeCol('SOME_CHAR', 2), COLTYPE: undefined },
{ ...makeCol('SOME_NUM', 3), COLTYPE: 'not valid json' }
]
const sasparams: SASParam = {
...example_sasparams,
COLHEADERS:
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_NUM',
PK: 'PRIMARY_KEY_FIELD'
} as SASParam
const dcValidator: DcValidator = new DcValidator(
sasparams,
{ vars: {} } as $DataFormats,
cols,
[],
[]
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
sasparams.COLHEADERS.split(',')
)
})
it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => {
// hiddenColumns.indexOf() returns -1 for a column that isn't hidden, and
// splice(-1, 1) drops the LAST element instead of no-op'ing — so an EDIT
// column used to silently un-hide a column hidden by a HIDDEN DQ rule.
//
// The EDIT column must not be the PK: setColumnLevelSecurity forces PK
// columns to clsRule 'READ' before the EDIT branch is reached, which would
// skip the splice entirely and leave this test passing against the bug.
const cols: Col[] = [
{ ...makeCol('PK_COL', 1), CLS_RULE: 'READ' },
{ ...makeCol('SECRET_COL', 2), CLS_RULE: 'READ' },
{ ...makeCol('OPEN_COL', 3), CLS_RULE: 'EDIT' }
]
const sasparams: SASParam = {
...example_sasparams,
CLS_FLAG: 1,
COLHEADERS: '_____DELETE__THIS__RECORD_____,PK_COL,SECRET_COL,OPEN_COL',
PK: 'PK_COL'
} as SASParam
const dqRules: DQRule[] = [
{ BASE_COL: 'SECRET_COL', RULE_TYPE: 'HIDDEN', RULE_VALUE: '', X: 0 }
]
const dcValidator: DcValidator = new DcValidator(
sasparams,
{ vars: {} } as $DataFormats,
cols,
dqRules,
[]
)
// SECRET_COL is rules[2]; CLS_FLAG also hides the delete column (index 0).
// OPEN_COL (rules[3]) is EDIT and was never hidden, so the splice guard is
// what keeps SECRET_COL hidden here.
expect(dcValidator.getHiddenColumns()).toContain(2)
})
})
/** Minimal cols[] entry — only the fields rule ordering depends on. */
const makeCol = (name: string, varnum: number): Col =>
({
NAME: name,
VARNUM: varnum,
LABEL: name,
FMTNAME: '',
DDTYPE: 'CHARACTER',
TYPE: '',
CLS_RULE: 'READ',
MEMLABEL: '',
DESC: '',
LONGDESC: '',
COLTYPE: `{"data":"${name}"}`
}) as Col
const example_dqData = [ const example_dqData = [
{ {
BASE_COL: 'SOME_DROPDOWN', BASE_COL: 'SOME_DROPDOWN',
@@ -383,6 +535,13 @@ const example_dqRules: any = [
} }
] ]
// One row per column, each carrying its own COLTYPE JSON-object string — the
// vertical-array shape from issue #253's getdata.sas fix. Order: SOME_DROPDOWN
// then SOME_NUM first (the `cols[0].TYPE` assertion above is positional),
// remaining columns in the same order the old concatenated
// sasparams.COLTYPE used to list them. _____DELETE__THIS__RECORD_____ is
// deliberately absent — DcValidator injects its rule directly (see
// DELETE_RECORD_COLUMN_RULE), since %mp_getcols never has a row for it.
const example_cols = [ const example_cols = [
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
@@ -394,7 +553,8 @@ const example_cols = [
LONGDESC: '', LONGDESC: '',
MEMLABEL: '', MEMLABEL: '',
NAME: 'SOME_DROPDOWN', NAME: 'SOME_DROPDOWN',
VARNUM: 3 VARNUM: 3,
COLTYPE: '{"data":"SOME_DROPDOWN"}'
}, },
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
@@ -406,77 +566,157 @@ const example_cols = [
LONGDESC: '', LONGDESC: '',
MEMLABEL: '', MEMLABEL: '',
NAME: 'SOME_NUM', NAME: 'SOME_NUM',
VARNUM: 4 VARNUM: 4,
COLTYPE: '{"data":"SOME_NUM","type":"numeric","format":"0"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'PRIMARY_KEY_FIELD',
LONGDESC: '',
MEMLABEL: '',
NAME: 'PRIMARY_KEY_FIELD',
VARNUM: 1,
COLTYPE: '{"data":"PRIMARY_KEY_FIELD","type":"numeric","format":"0"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_CHAR',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_CHAR',
VARNUM: 2,
COLTYPE: '{"data":"SOME_CHAR"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_CHAR_LOW',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_CHAR_LOW',
VARNUM: 5,
COLTYPE: '{"data":"SOME_CHAR_LOW"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_CHAR_ANY',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_CHAR_ANY',
VARNUM: 6,
COLTYPE: '{"data":"SOME_CHAR_ANY"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_DROPDOWN_HARD',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DROPDOWN_HARD',
VARNUM: 7,
COLTYPE: '{"data":"SOME_DROPDOWN_HARD"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'DATE',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_DATE',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DATE',
VARNUM: 8,
COLTYPE: '{"data":"SOME_DATE","type":"date"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'DATETIME',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_DATETIME',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DATETIME',
VARNUM: 9,
COLTYPE: '{"data":"SOME_DATETIME","type":"datetime"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'TIME',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_TIME',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_TIME',
VARNUM: 10,
COLTYPE: '{"data":"SOME_TIME","type":"time"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_SHORTNUM',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_SHORTNUM',
VARNUM: 11,
COLTYPE: '{"data":"SOME_SHORTNUM","type":"numeric","format":"0"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_BESTNUM',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_BESTNUM',
VARNUM: 12,
COLTYPE: '{"data":"SOME_BESTNUM","type":"numeric","format":"0"}'
} }
] ]
const example_COLTYPE = ` // COLHEADERS mirrors a real getdata.sas response: the *output* dataset's
{ // variable list, i.e. the delete column and PK hoisted to the front, then the
"data":"_____DELETE__THIS__RECORD_____", // remaining source columns in VARNUM order. It drives rule order, so it must
"type":"dropdown", // list every example_cols entry.
"source":[
"No",
"Yes"
]
},
{
"data":"PRIMARY_KEY_FIELD",
"type":"numeric",
"format":"0"
},
{
"data":"SOME_CHAR"
},
{
"data":"SOME_CHAR_LOW"
},
{
"data":"SOME_CHAR_ANY"
},
{
"data":"SOME_DROPDOWN"
},
{
"data":"SOME_DROPDOWN_HARD"
},
{
"data":"SOME_NUM",
"type":"numeric",
"format":"0"
},
{
"data":"SOME_DATE",
"type":"date"
},
{
"data":"SOME_DATETIME",
"type":"datetime"
},
{
"data":"SOME_TIME",
"type":"time"
},
{
"data":"SOME_SHORTNUM",
"type":"numeric",
"format":"0"
},
{
"data":"SOME_BESTNUM",
"type":"numeric",
"format":"0"
}`
const example_sasparams = { const example_sasparams = {
CLS_FLAG: 0, CLS_FLAG: 0,
COLHEADERS: 'head1 head2', COLHEADERS:
COLTYPE: example_COLTYPE, '_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,' +
'SOME_NUM,SOME_CHAR_LOW,SOME_CHAR_ANY,SOME_DROPDOWN_HARD,SOME_DATE,' +
'SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM',
DTTMVARS: 'dttm vars', DTTMVARS: 'dttm vars',
DTVARS: 'dt vars', DTVARS: 'dt vars',
FILTER_TEXT: 'filter text', FILTER_TEXT: 'filter text',
LOADTYPE: 'load type', LOADTYPE: 'load type',
PK: 'primary key', PK: 'PRIMARY_KEY_FIELD',
PKCNT: 0, PKCNT: 1,
RK_FLAG: 0, RK_FLAG: 0,
TMVARS: 'string' TMVARS: 'string'
} }
@@ -507,6 +747,24 @@ const example_dataformats = {
length: '128', length: '128',
type: 'char' type: 'char'
}, },
SOME_CHAR_LOW: {
format: '$128.',
label: 'SOME_CHAR_LOW',
length: '128',
type: 'char'
},
SOME_CHAR_ANY: {
format: '$128.',
label: 'SOME_CHAR_ANY',
length: '128',
type: 'char'
},
SOME_DROPDOWN_HARD: {
format: '$128.',
label: 'SOME_DROPDOWN_HARD',
length: '128',
type: 'char'
},
SOME_NUM: { SOME_NUM: {
format: 'best.', format: 'best.',
label: 'SOME_NUM', label: 'SOME_NUM',
@@ -1,23 +0,0 @@
import { HotColumnSettings } from '../models/dc-validation.model'
import { parseColType } from '../utils/parseColType'
describe('DC Validator - parse col type', () => {
it('should return array of parsed json', () => {
const colTypeString =
'{"data":"test","test2":"test2"}, {"data":"test3","test4":"test4"}'
const expected: HotColumnSettings[] = [
{ data: 'test', test2: 'test2' },
{ data: 'test3', test4: 'test4' }
]
expect(parseColType(colTypeString)).toEqual(expected)
})
it('should return empty array for invalid json', () => {
const colTypeString =
'{"test":"test""test:"test2"}, {"test3":"test3","test4":"test4"}'
const expected: HotColumnSettings[] = []
expect(parseColType(colTypeString)).toEqual(expected)
})
})
@@ -0,0 +1,24 @@
import { HotColumnSettings } from '../models/dc-validation.model'
import { parseColTypeRow } from '../utils/parseColTypeRow'
describe('DC Validator - parse col type row', () => {
it('parses a single JSON object', () => {
const expected: HotColumnSettings = { data: 'test', type: 'numeric' }
expect(parseColTypeRow('{"data":"test","type":"numeric"}')).toEqual(
expected
)
})
it('returns undefined for undefined input (LEFT JOIN can leave it unset)', () => {
expect(parseColTypeRow(undefined)).toBeUndefined()
})
it('returns undefined for an empty string', () => {
expect(parseColTypeRow('')).toBeUndefined()
})
it('returns undefined for malformed JSON, without throwing', () => {
expect(parseColTypeRow('{not valid')).toBeUndefined()
})
})
@@ -0,0 +1,16 @@
import { DcValidation } from '../models/dc-validation.model'
/**
* `_____DELETE__THIS__RECORD_____` is a client-side UI concept (the delete
* checkbox column) that getdata.sas synthesizes into vars3/vars4 for display
* purposes only — it's never a real column of the underlying dataset, so
* `%mp_getcols` (which builds `cols1`, the left side of the join into
* `cols`) never has a row for it, and its rule can no longer travel via
* `cols[].COLTYPE` (see issue #253's vertical-array fix). The rule is fixed
* and data-independent, so it's injected directly here instead.
*/
export const DELETE_RECORD_COLUMN_RULE: DcValidation = {
data: '_____DELETE__THIS__RECORD_____',
type: 'dropdown',
source: ['No', 'Yes']
}
@@ -27,7 +27,7 @@ export const mergeColsRules = (
: colFormats.type : colFormats.type
if (rule && col.DESC) rule.desc = col.DESC if (rule && col.DESC) rule.desc = col.DESC
if (rule && colFormats.length) rule.length = parseInt(colFormats.length) if (rule && colFormats?.length) rule.length = parseInt(colFormats.length)
if (rule && col.CLS_RULE) rule.clsRule = col.CLS_RULE if (rule && col.CLS_RULE) rule.clsRule = col.CLS_RULE
if (rule && colFormats?.type) rule.sasType = colFormats.type if (rule && colFormats?.type) rule.sasType = colFormats.type
} }
@@ -1,16 +0,0 @@
import { HotColumnSettings } from '../models/dc-validation.model'
/**
* From sas we get a string instead of array of objects, in that string `[]` are missing so
* before parsing JSON we need to add them.
*
* @param coltype string (objects) that comes from sas
* @returns JSON Handsontable.ColumnSettings[]
*/
export const parseColType = (coltype: string): HotColumnSettings[] => {
try {
return JSON.parse(`[${coltype}]`)
} catch (err: any) {
return []
}
}
@@ -0,0 +1,23 @@
import { HotColumnSettings } from '../models/dc-validation.model'
/**
* Parses a single column's `COLTYPE` JSON-object string (one per `cols` row,
* as of the vertical-array getdata.sas fix — see issue #253). Unlike the
* previous comma-joined `sasparams.COLTYPE` format, each row is independent:
* a malformed or missing value here only drops that one column's rule,
* rather than every column's.
*
* @param coltype a single column's COLTYPE JSON string, or undefined (the
* LEFT JOIN in getdata.sas can leave it unset for unmatched rows)
*/
export const parseColTypeRow = (
coltype: string | undefined
): HotColumnSettings | undefined => {
if (!coltype) return undefined
try {
return JSON.parse(coltype)
} catch (err: any) {
return undefined
}
}
@@ -52,12 +52,14 @@ export async function exportGrid(
// Mirror HOT's own export item: only honor a selection that spans more than // Mirror HOT's own export item: only honor a selection that spans more than
// one cell. A right-click places a single-cell cursor, and the corner click // one cell. A right-click places a single-cell cursor, and the corner click
// is select-all (negative coords) — both mean "export the whole table". // is select-all (negative coords) — both mean "export the whole table".
const isCornerSelectAll = !!sel && sel.from.row < 0 && sel.from.col < 0 // sel.from/to.row/col are typed nullable (CellCoords allows an unset state),
// but a range returned by getSelectedRangeLast() always has real coordinates.
const isCornerSelectAll = !!sel && sel.from.row! < 0 && sel.from.col! < 0
if (sel && !sel.isSingleCell() && !isCornerSelectAll) { if (sel && !sel.isSingleCell() && !isCornerSelectAll) {
const top = Math.max(0, Math.min(sel.from.row, sel.to.row)) const top = Math.max(0, Math.min(sel.from.row!, sel.to.row!))
const left = Math.max(0, Math.min(sel.from.col, sel.to.col)) const left = Math.max(0, Math.min(sel.from.col!, sel.to.col!))
const bottom = Math.max(sel.from.row, sel.to.row) const bottom = Math.max(sel.from.row!, sel.to.row!)
const right = Math.max(sel.from.col, sel.to.col) const right = Math.max(sel.from.col!, sel.to.col!)
opts['range'] = [top, Math.max(left, skipLeadingCols), bottom, right] opts['range'] = [top, Math.max(left, skipLeadingCols), bottom, right]
} }
@@ -0,0 +1,151 @@
import { BehaviorSubject } from 'rxjs'
import * as XLSX from '@sheet/crypto'
import { SpreadsheetUtil } from './spreadsheet-util'
import { LicenceState } from 'src/app/models/LicenceState'
import { ParseParams } from 'src/app/models/ParseParams.interface'
import { SearchDataExcelResult } from 'src/app/models/SearchDataExcelResult.interface'
describe('SpreadsheetUtil - header matching', () => {
// licenceState is unused by searchDataInExcel, so an empty stub is fine.
const buildSpreadsheetUtil = () =>
new SpreadsheetUtil({
licenceState: new BehaviorSubject<LicenceState>({} as LicenceState)
})
const buildWorkbook = (headerRow: string[], dataRow: any[]) => {
const ws = XLSX.utils.aoa_to_sheet([headerRow, dataRow])
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')
return wb
}
// searchDataInExcel only reads headerArray/headerPks off ParseParams; the
// other required fields (dcValidator, etc.) are irrelevant here, so a
// minimal cast object stands in for a full ParseParams.
const buildParseParams = (): ParseParams =>
({
headerArray: ['PRIMARY_KEY_FIELD', 'SOME_CHAR', 'SOME_NUM'],
headerPks: ['PRIMARY_KEY_FIELD']
}) as any as ParseParams
// searchDataInExcel is private; there's no existing precedent in this
// codebase for testing private methods, so this casts through `any` to
// reach it directly rather than exercising it via the public
// parseSpreadsheetFile (which would need a real File/FileReader).
const searchDataInExcel = (
spreadsheetUtil: SpreadsheetUtil,
wb: XLSX.WorkBook,
parseParams: ParseParams
): SearchDataExcelResult =>
(spreadsheetUtil as any).searchDataInExcel(wb, parseParams)
it('matches headers with leading whitespace against the expected clean header names', () => {
const wb = buildWorkbook(
['PRIMARY_KEY_FIELD', ' SOME_CHAR', ' SOME_NUM'],
[1, 'a value', 42]
)
const result = searchDataInExcel(
buildSpreadsheetUtil(),
wb,
buildParseParams()
)
expect(result.missing).toBeUndefined()
expect(result.found).toBeDefined()
expect(result.found?.headers).toContain('some_char')
expect(result.found?.headers).toContain('some_num')
})
it('still reports a genuinely different header as missing (no over-broadening)', () => {
const wb = buildWorkbook(
['PRIMARY_KEY_FIELD', ' SOME_CHARX', ' SOME_NUM'],
[1, 'a value', 42]
)
const result = searchDataInExcel(
buildSpreadsheetUtil(),
wb,
buildParseParams()
)
expect(result.found).toBeUndefined()
expect(result.missing).toBeDefined()
expect(result.missing?.[0].missingHeaders).toContain('SOME_CHAR')
})
})
describe('SpreadsheetUtil - updateDateTimeCols time normalization', () => {
const buildSpreadsheetUtil = () =>
new SpreadsheetUtil({
licenceState: new BehaviorSubject<LicenceState>({} as LicenceState)
})
// updateDateTimeCols is private; cast through `any` (same precedent as above).
const updateDateTimeCols = (
spreadsheetUtil: SpreadsheetUtil,
headers: string[],
data: any[],
parseParams: ParseParams
): any[] =>
(spreadsheetUtil as any).updateDateTimeCols(headers, data, parseParams)
const timeParseParams = (): ParseParams =>
({
dateHeaders: [],
dateTimeHeaders: [],
timeHeaders: ['SOME_TIME']
}) as any as ParseParams
it('pads unpadded-hour time strings to canonical HH:mm:ss and leaves canonical values alone', () => {
// The core bug: SAS TIME8. / Excel render a single-digit hour (`0:00:42`),
// arriving as a plain string (not an Excel serial). Already-canonical values
// must pass through untouched.
const data = [[{ v: '0:00:42' }], [{ v: '9:05:03' }], [{ v: '23:00:00' }]]
const result = updateDateTimeCols(
buildSpreadsheetUtil(),
['SOME_TIME'],
data,
timeParseParams()
)
expect(result.map((row) => row[0].v)).toEqual([
'00:00:42',
'09:05:03',
'23:00:00'
])
})
it('reads a 2-part value as HOURS-first and defaults seconds (HHMM/TIME5.): 0:42 -> 00:42:00', () => {
// TIME5.-7. and HHMM. columns render `HH:mm`. Such values are never `mm:ss`
// — DC only classifies TIME/HHMM formats as time columns — so the hour is
// padded and minutes kept, with seconds defaulted to :00.
const data = [[{ v: '0:42' }], [{ v: '12:25' }]]
const result = updateDateTimeCols(
buildSpreadsheetUtil(),
['SOME_TIME'],
data,
timeParseParams()
)
expect(result.map((row) => row[0].v)).toEqual(['00:42:00', '12:25:00'])
})
it('reconstructs an Excel serial time to zero-padded HH:mm:ss', () => {
// 0.0004861111 of a day = 42 seconds past midnight.
const data = [[{ v: 0.0004861111 }]]
const result = updateDateTimeCols(
buildSpreadsheetUtil(),
['SOME_TIME'],
data,
timeParseParams()
)
expect(result[0][0].v).toBe('00:00:42')
})
})
@@ -2,7 +2,8 @@ import { isSpecialMissing } from '@sasjs/utils/input/validators'
import { import {
dateFormat, dateFormat,
dateToUtcTime, dateToUtcTime,
dateToTime dateToTime,
padTimeString
} from 'src/app/editor/utils/date.utils' } from 'src/app/editor/utils/date.utils'
import { import {
getMissingHeaders, getMissingHeaders,
@@ -608,7 +609,11 @@ export class SpreadsheetUtil {
// If the cell does not have `v` property we ignore it, those are metadata properties // If the cell does not have `v` property we ignore it, those are metadata properties
if (cellValue && typeof cellValue === 'string') { if (cellValue && typeof cellValue === 'string') {
const potentialHeader = cellValue.toLowerCase() // .trim(): header cells can pick up incidental leading/trailing
// whitespace from copy-pasting between spreadsheets/systems — treat
// that the same as the existing case-insensitive matching below,
// not as a genuinely different (missing) column.
const potentialHeader = cellValue.trim().toLowerCase()
const headerIndex = csvArrayHeadersLower.indexOf(potentialHeader) const headerIndex = csvArrayHeadersLower.indexOf(potentialHeader)
if (headerIndex > -1) { if (headerIndex > -1) {
@@ -852,11 +857,14 @@ export class SpreadsheetUtil {
if ( if (
isStringNumber(obj.v) || isStringNumber(obj.v) ||
isStringDecimal(obj.v) || isStringDecimal(obj.v) ||
obj.v.includes('E-') (typeof obj.v === 'string' && obj.v.includes('E-'))
) { ) {
const date = excelDateToJSDate(Number(obj.v)) const date = excelDateToJSDate(Number(obj.v))
obj.v = dateToUtcTime(date) obj.v = dateToUtcTime(date)
} else if (typeof obj.v === 'string') {
// `0:00:42`— pad the hour for `intl-time` cell type
obj.v = padTimeString(obj.v)
} }
row[element] = obj row[element] = obj
}) })
@@ -0,0 +1,21 @@
import { buildColInfoHtml } from './col-info-html'
import { DataFormat } from '../../models/sas/common/DateFormat'
describe('buildColInfoHtml', () => {
it('returns "No info found" when colInfo is undefined', () => {
expect(buildColInfoHtml('SOME_CHAR', undefined)).toBe('No info found')
})
it('renders NAME first, then LABEL/TYPE/LENGTH/FORMAT', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(buildColInfoHtml('SOME_CHAR', colInfo)).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.'
)
})
})
@@ -0,0 +1,15 @@
import { DataFormat } from '../../models/sas/common/DateFormat'
/**
* Builds the HTML shown in a column-header "info" dropdown item (viewer and
* editor). NAME is listed first so it's visible regardless of whether
* headers are currently displayed as NAME or LABEL.
*/
export function buildColInfoHtml(
colName: string,
colInfo?: DataFormat
): 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}`
}
@@ -0,0 +1,59 @@
import { getDisplayColHeaders } from './display-col-headers'
import { Col } from '../dc-validator/models/col.model'
const makeCol = (overrides: Partial<Col>): Col => ({
NAME: '',
VARNUM: 0,
LABEL: '',
FMTNAME: '',
DDTYPE: '',
TYPE: '',
CLS_RULE: '',
MEMLABEL: '',
DESC: '',
LONGDESC: '',
...overrides
})
describe('getDisplayColHeaders', () => {
const colNames = ['SOME_CHAR', 'SOME_NUM', 'SOME_DATE']
const cols: Col[] = [
makeCol({ NAME: 'SOME_CHAR', LABEL: 'Some Character Column' }),
makeCol({ NAME: 'SOME_NUM', LABEL: '' }),
makeCol({ NAME: 'SOME_DATE', LABEL: 'Some Date' })
]
it('returns colNames unchanged when useLabels is false', () => {
expect(getDisplayColHeaders(colNames, cols, false)).toEqual(colNames)
})
it('returns matching LABELs when useLabels is true', () => {
expect(getDisplayColHeaders(colNames, cols, true)).toEqual([
'Some Character Column',
'SOME_NUM',
'Some Date'
])
})
it('falls back to NAME when LABEL is blank', () => {
const result = getDisplayColHeaders(['SOME_NUM'], cols, true)
expect(result).toEqual(['SOME_NUM'])
})
it('falls back to NAME when the column is missing from cols', () => {
const result = getDisplayColHeaders(
['SOME_CHAR', 'UNKNOWN_COL'],
cols,
true
)
expect(result).toEqual(['Some Character Column', 'UNKNOWN_COL'])
})
it('preserves colNames order, not cols order', () => {
const reordered = ['SOME_DATE', 'SOME_CHAR']
expect(getDisplayColHeaders(reordered, cols, true)).toEqual([
'Some Date',
'Some Character Column'
])
})
})
@@ -0,0 +1,20 @@
import { Col } from '../dc-validator/models/col.model'
/**
* Maps grid column names to their display headers.
*
* When `useLabels` is true, each name is swapped for its `LABEL` from `cols`
* (falling back to the NAME itself when a column has no LABEL, or isn't
* present in `cols` at all). Order always follows `colNames` (grid order).
*/
export function getDisplayColHeaders(
colNames: string[],
cols: Col[],
useLabels: boolean
): string[] {
if (!useLabels) return colNames
const labelsByName = new Map(cols.map((col) => [col.NAME, col.LABEL]))
return colNames.map((name) => labelsByName.get(name) || name)
}
@@ -0,0 +1,26 @@
import { parseLabelsParam } from './parse-labels-param'
describe('parseLabelsParam', () => {
it('returns false when there is no hash query', () => {
expect(parseLabelsParam('')).toBe(false)
})
it('returns false when the labels param is absent', () => {
expect(parseLabelsParam('foo=bar')).toBe(false)
})
it('returns false for labels=false', () => {
expect(parseLabelsParam('labels=false')).toBe(false)
})
it('returns true for labels=true and other truthy values', () => {
expect(parseLabelsParam('labels=true')).toBe(true)
expect(parseLabelsParam('labels=1')).toBe(true)
expect(parseLabelsParam('labels=yes')).toBe(true)
})
it('coexists with other params, including embed', () => {
expect(parseLabelsParam('embed=va&labels=true')).toBe(true)
expect(parseLabelsParam('foo=bar&labels=false&baz=qux')).toBe(false)
})
})
@@ -0,0 +1,18 @@
/**
* Parses the `labels` value out of a hash query string (the part after `?` in
* `window.location.hash`).
*
* Returns `true` for any non-`'false'` value (e.g. `labels=true`, `labels=1`),
* and `false` when `labels=false`, or when the param is absent.
*
* Modeled on `parseEmbedParam` — coexists with `embed` and other params.
*/
export function parseLabelsParam(hashQuery: string): boolean {
if (!hashQuery) return false
const labelsParam = new URLSearchParams(hashQuery).get('labels')
if (labelsParam === null) return false
return labelsParam !== 'false'
}
+68 -7
View File
@@ -42,6 +42,10 @@ import { DataFormat } from '../models/sas/common/DateFormat'
import { Libinfo } from '../models/sas/common/Libinfo' import { Libinfo } from '../models/sas/common/Libinfo'
import { LicenceService } from '../services/licence.service' import { LicenceService } from '../services/licence.service'
import { Location } from '@angular/common' import { Location } from '@angular/common'
import { Col } from '../shared/dc-validator/models/col.model'
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
import { buildColInfoHtml } from '../shared/utils/col-info-html'
@Component({ @Component({
selector: 'app-viewer', selector: 'app-viewer',
@@ -74,7 +78,14 @@ export class ViewerComponent
public table: any public table: any
public tableuri: string | null = null public tableuri: string | null = null
public filter: boolean = false public filter: boolean = false
public filterCols: any = [] public filterCols: Col[] = []
// NAMEs of the currently loaded columns, in grid order — the canonical
// source `getDisplayColHeaders` maps against; `hotTable.colHeaders` holds
// whichever of NAME/LABEL is currently displayed.
public colNames: string[] = []
// `?labels=true` toggle (see parseLabelsParam) — URL is the source of
// truth, kept in sync via the queryParams subscription below.
public useLabels: boolean = false
public nullVariables: boolean = false public nullVariables: boolean = false
public abortActive: boolean = false public abortActive: boolean = false
public queryFilter: any public queryFilter: any
@@ -190,7 +201,21 @@ export class ViewerComponent
items: { items: {
copy_with_column_headers: {}, copy_with_column_headers: {},
copy_column_headers_only: {}, copy_column_headers_only: {},
export_file: buildExportMenuItem(() => this.tableTitle || 'export') export_file: buildExportMenuItem(() => this.tableTitle || 'export'),
// Navigates rather than just flipping `useLabels` locally, so the URL
// stays the single source of truth (shareable/refreshable state) —
// the queryParams subscription in ngOnInit picks up the change and
// re-renders colHeaders.
toggle_labels: {
name: () => (this.useLabels ? 'Show names' : 'Show labels'),
callback: () => {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { labels: this.useLabels ? null : true },
queryParamsHandling: 'merge'
})
}
}
} }
}, },
copyPaste: { copyPaste: {
@@ -219,6 +244,7 @@ export class ViewerComponent
itemValue: string itemValue: string
) => { ) => {
const elem = document.createElement('span') const elem = document.createElement('span')
let colName = ''
let colInfo: DataFormat | undefined let colInfo: DataFormat | undefined
let textInfo = 'No info found' let textInfo = 'No info found'
@@ -228,18 +254,19 @@ export class ViewerComponent
!this.isTableSwitching !this.isTableSwitching
) { ) {
try { try {
const hotSelected: [number, number, number, number][] = // getSelected() is typed number[][] in HOT 18 (was a 4-tuple
// array before); loosen the annotation to match.
const hotSelected: number[][] =
this.hotInstance.getSelected() || [] this.hotInstance.getSelected() || []
const selectedCol: number = hotSelected ? hotSelected[0][1] : -1 const selectedCol: number = hotSelected ? hotSelected[0][1] : -1
const colName = this.hotInstance.colToProp(selectedCol) colName = this.hotInstance.colToProp(selectedCol) as string
colInfo = this.$dataFormats?.vars[colName] colInfo = this.$dataFormats?.vars[colName]
} catch (error) { } catch (error) {
// Ignore errors during table switching // Ignore errors during table switching
colInfo = undefined colInfo = undefined
} }
if (colInfo) textInfo = buildColInfoHtml(colName, colInfo)
textInfo = `LABEL: ${colInfo?.label}<br>TYPE: ${colInfo?.type}<br>LENGTH: ${colInfo?.length}<br>FORMAT: ${colInfo?.format}`
} }
elem.innerHTML = textInfo elem.innerHTML = textInfo
@@ -252,6 +279,7 @@ export class ViewerComponent
} }
private _query!: Subscription private _query!: Subscription
private _queryParams!: Subscription
private hotInstance: Handsontable | null = null private hotInstance: Handsontable | null = null
public hotInstanceClickListener: boolean = false public hotInstanceClickListener: boolean = false
@@ -299,6 +327,31 @@ export class ViewerComponent
this.updateHotTableSettings() // Update settings when license key changes this.updateHotTableSettings() // Update settings when license key changes
} }
) )
// URL is the source of truth for the labels toggle: react to `?labels=`
// changes (including same-page navigation from the dropdown menu item)
// rather than reading it once from the route snapshot.
this._queryParams = this.route.queryParams.subscribe((params) => {
this.useLabels = parseLabelsParam(new URLSearchParams(params).toString())
this.applyDisplayColHeaders()
})
}
/**
* Recomputes `hotTable.colHeaders` from `colNames`/`filterCols` for the
* current `useLabels` state, and pushes it into a live grid via
* `updateSettings` if one is mounted (no refetch needed).
*/
private applyDisplayColHeaders() {
this.hotTable.colHeaders = getDisplayColHeaders(
this.colNames,
this.filterCols,
this.useLabels
)
if (this.hotInstance && !this.hotInstance.isDestroyed) {
this.hotInstance.updateSettings({ colHeaders: this.hotTable.colHeaders })
}
} }
/** /**
@@ -943,11 +996,17 @@ export class ViewerComponent
columns.push(colDef) columns.push(colDef)
} }
this.hotTable.colHeaders = colArr this.colNames = colArr
this.hotTable.colHeaders = getDisplayColHeaders(
colArr,
res.cols,
this.useLabels
)
this.hotTable.columns = columns this.hotTable.columns = columns
this.hiddenViewColumns = hiddenColumnIndexes this.hiddenViewColumns = hiddenColumnIndexes
} else { } else {
// Set empty arrays if no data // Set empty arrays if no data
this.colNames = []
this.hotTable.colHeaders = [] this.hotTable.colHeaders = []
this.hotTable.columns = [] this.hotTable.columns = []
this.hiddenViewColumns = [] this.hiddenViewColumns = []
@@ -1396,6 +1455,8 @@ export class ViewerComponent
ngOnDestroy() { ngOnDestroy() {
// Proper component destruction to prevent memory leaks and errors // Proper component destruction to prevent memory leaks and errors
this._queryParams?.unsubscribe()
// Prevent any new operations during cleanup // Prevent any new operations during cleanup
this.isTableSwitching = true this.isTableSwitching = true
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dcfrontend", "name": "dcfrontend",
"version": "7.9.1", "version": "7.10.1",
"description": "Data Controller", "description": "Data Controller",
"devDependencies": { "devDependencies": {
"@saithodev/semantic-release-gitea": "^2.1.0", "@saithodev/semantic-release-gitea": "^2.1.0",
+88 -54
View File
@@ -73,7 +73,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"PRIMARY_KEY_FIELD\",\"type\":\"numeric\",\"format\":\"0\"}"
}, },
{ {
NAME: "SOME_BESTNUM", NAME: "SOME_BESTNUM",
@@ -84,29 +85,32 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_BESTNUM\",\"type\":\"numeric\",\"format\":\"0\"}"
}, },
{ {
NAME: "SOME_CHAR", NAME: "SOME_CHAR",
VARNUM: 2, VARNUM: 2,
LABEL: "SOME_CHAR", LABEL: "Some Character Column",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "CHARACTER",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_CHAR\"}"
}, },
{ {
NAME: "SOME_DATE", NAME: "SOME_DATE",
VARNUM: 5, VARNUM: 5,
LABEL: "SOME_DATE", LABEL: "Some Date",
FMTNAME: "DATE", FMTNAME: "DATE",
DDTYPE: "DATE", DDTYPE: "DATE",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_DATE\",\"type\":\"date\"}"
}, },
{ {
NAME: "SOME_DATETIME", NAME: "SOME_DATETIME",
@@ -117,7 +121,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_DATETIME\",\"type\":\"datetime\"}"
}, },
{ {
NAME: "SOME_DROPDOWN", NAME: "SOME_DROPDOWN",
@@ -128,7 +133,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_DROPDOWN\"}"
}, },
{ {
NAME: "SOME_HARDSELECT", NAME: "SOME_HARDSELECT",
@@ -139,18 +145,20 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_HARDSELECT\"}"
}, },
{ {
NAME: "SOME_NUM", NAME: "SOME_NUM",
VARNUM: 4, VARNUM: 4,
LABEL: "SOME_NUM", LABEL: "",
FMTNAME: "", FMTNAME: "",
DDTYPE: "NUMERIC", DDTYPE: "NUMERIC",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_NUM\",\"type\":\"numeric\",\"format\":\"0\"}"
}, },
{ {
NAME: "SOME_SHORTNUM", NAME: "SOME_SHORTNUM",
@@ -161,7 +169,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_SHORTNUM\",\"type\":\"numeric\",\"format\":\"0\"}"
}, },
{ {
NAME: "SOME_TIME", NAME: "SOME_TIME",
@@ -172,7 +181,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_TIME\",\"type\":\"time\"}"
}, },
{ {
NAME: "READONLY_COL", NAME: "READONLY_COL",
@@ -183,7 +193,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "Read-only: default value inserted on add-row, not editable", DESC: "Read-only: default value inserted on add-row, not editable",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"READONLY_COL\"}"
}, },
{ {
NAME: "HIDDEN_COL", NAME: "HIDDEN_COL",
@@ -194,7 +205,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "Hidden: invisible in grid but submitted; default on add-row", DESC: "Hidden: invisible in grid but submitted; default on add-row",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"HIDDEN_COL\"}"
}, },
{ {
NAME: "ROUND_COL", NAME: "ROUND_COL",
@@ -205,7 +217,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "Round: edited values rounded Excel-style to 2 decimals", DESC: "Round: edited values rounded Excel-style to 2 decimals",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"ROUND_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
}, },
{ {
NAME: "NUMFMT_COL", NAME: "NUMFMT_COL",
@@ -216,7 +229,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "Number format: displayed as EUR currency (value unchanged)", DESC: "Number format: displayed as EUR currency (value unchanged)",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"NUMFMT_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
} }
], ],
dqdata: [ dqdata: [
@@ -323,7 +337,6 @@ let webouts = {
DTVARS: " SOME_DATE", DTVARS: " SOME_DATE",
DTTMVARS: " SOME_DATETIME", DTTMVARS: " SOME_DATETIME",
TMVARS: " SOME_TIME", TMVARS: " SOME_TIME",
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"PRIMARY_KEY_FIELD\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_CHAR\"},{\"data\":\"SOME_DROPDOWN\"},{\"data\":\"SOME_HARDSELECT\"},{\"data\":\"SOME_NUM\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_DATE\",\"type\":\"date\"},{\"data\":\"SOME_DATETIME\",\"type\":\"datetime\"},{\"data\":\"SOME_TIME\",\"type\":\"time\"},{\"data\":\"SOME_SHORTNUM\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_BESTNUM\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"READONLY_COL\"},{\"data\":\"HIDDEN_COL\"},{\"data\":\"ROUND_COL\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"NUMFMT_COL\",\"type\":\"numeric\",\"format\":\"0\"}",
LOADTYPE: "UPDATE", LOADTYPE: "UPDATE",
RK_FLAG: 0, RK_FLAG: 0,
CLS_FLAG: 0 CLS_FLAG: 0
@@ -366,7 +379,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"DD_LONGDESC\"}"
}, },
{ {
NAME: "DD_OWNER", NAME: "DD_OWNER",
@@ -377,7 +391,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"DD_OWNER\"}"
}, },
{ {
NAME: "DD_RESPONSIBLE", NAME: "DD_RESPONSIBLE",
@@ -388,7 +403,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"DD_RESPONSIBLE\"}"
}, },
{ {
NAME: "DD_SENSITIVITY", NAME: "DD_SENSITIVITY",
@@ -399,7 +415,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"DD_SENSITIVITY\"}"
}, },
{ {
NAME: "DD_SHORTDESC", NAME: "DD_SHORTDESC",
@@ -410,7 +427,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"DD_SHORTDESC\"}"
}, },
{ {
NAME: "DD_SOURCE", NAME: "DD_SOURCE",
@@ -421,7 +439,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"DD_SOURCE\"}"
}, },
{ {
NAME: "DD_TYPE", NAME: "DD_TYPE",
@@ -432,7 +451,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"DD_TYPE\"}"
}, },
{ {
NAME: "TX_FROM", NAME: "TX_FROM",
@@ -593,7 +613,6 @@ let webouts = {
DTVARS: "", DTVARS: "",
DTTMVARS: "", DTTMVARS: "",
TMVARS: "", TMVARS: "",
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"DD_TYPE\"},{\"data\":\"DD_SOURCE\"},{\"data\":\"DD_SHORTDESC\"},{\"data\":\"DD_LONGDESC\"},{\"data\":\"DD_OWNER\"},{\"data\":\"DD_RESPONSIBLE\"},{\"data\":\"DD_SENSITIVITY\"}",
LOADTYPE: "TXTEMPORAL", LOADTYPE: "TXTEMPORAL",
RK_FLAG: 0, RK_FLAG: 0,
CLS_FLAG: 0 CLS_FLAG: 0
@@ -660,7 +679,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"USER_ID\"}"
} }
], ],
dqdata: [], dqdata: [],
@@ -722,7 +742,6 @@ let webouts = {
DTVARS: "", DTVARS: "",
DTTMVARS: "", DTTMVARS: "",
TMVARS: "", TMVARS: "",
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"USER_ID\"}",
LOADTYPE: "UPDATE", LOADTYPE: "UPDATE",
RK_FLAG: 0, RK_FLAG: 0,
CLS_FLAG: 0 CLS_FLAG: 0
@@ -765,7 +784,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"AUDIT_LIBDS\"}"
}, },
{ {
NAME: "BUSKEY", NAME: "BUSKEY",
@@ -776,7 +796,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"BUSKEY\"}"
}, },
{ {
NAME: "CLOSE_VARS", NAME: "CLOSE_VARS",
@@ -787,7 +808,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"CLOSE_VARS\"}"
}, },
{ {
NAME: "DSN", NAME: "DSN",
@@ -798,7 +820,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"DSN\"}"
}, },
{ {
NAME: "LIBREF", NAME: "LIBREF",
@@ -809,7 +832,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"LIBREF\"}"
}, },
{ {
NAME: "LOADTYPE", NAME: "LOADTYPE",
@@ -820,7 +844,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"LOADTYPE\"}"
}, },
{ {
NAME: "NOTES", NAME: "NOTES",
@@ -831,7 +856,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"NOTES\"}"
}, },
{ {
NAME: "NUM_OF_APPROVALS_REQUIRED", NAME: "NUM_OF_APPROVALS_REQUIRED",
@@ -842,7 +868,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"NUM_OF_APPROVALS_REQUIRED\",\"type\":\"numeric\",\"format\":\"0\"}"
}, },
{ {
NAME: "POST_APPROVE_HOOK", NAME: "POST_APPROVE_HOOK",
@@ -853,7 +880,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"POST_APPROVE_HOOK\"}"
}, },
{ {
NAME: "POST_EDIT_HOOK", NAME: "POST_EDIT_HOOK",
@@ -864,7 +892,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"POST_EDIT_HOOK\"}"
}, },
{ {
NAME: "PRE_APPROVE_HOOK", NAME: "PRE_APPROVE_HOOK",
@@ -875,7 +904,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"PRE_APPROVE_HOOK\"}"
}, },
{ {
NAME: "PRE_EDIT_HOOK", NAME: "PRE_EDIT_HOOK",
@@ -886,7 +916,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"PRE_EDIT_HOOK\"}"
}, },
{ {
NAME: "RK_UNDERLYING", NAME: "RK_UNDERLYING",
@@ -897,7 +928,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"RK_UNDERLYING\"}"
}, },
{ {
NAME: "SIGNOFF_COLS", NAME: "SIGNOFF_COLS",
@@ -908,7 +940,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"SIGNOFF_COLS\"}"
}, },
{ {
NAME: "SIGNOFF_HOOK", NAME: "SIGNOFF_HOOK",
@@ -919,7 +952,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"SIGNOFF_HOOK\"}"
}, },
{ {
NAME: "TX_FROM", NAME: "TX_FROM",
@@ -952,7 +986,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"VAR_BUSFROM\"}"
}, },
{ {
NAME: "VAR_BUSTO", NAME: "VAR_BUSTO",
@@ -963,7 +998,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"VAR_BUSTO\"}"
}, },
{ {
NAME: "VAR_PROCESSED", NAME: "VAR_PROCESSED",
@@ -974,7 +1010,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"VAR_PROCESSED\"}"
}, },
{ {
NAME: "VAR_TXFROM", NAME: "VAR_TXFROM",
@@ -985,7 +1022,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"VAR_TXFROM\"}"
}, },
{ {
NAME: "VAR_TXTO", NAME: "VAR_TXTO",
@@ -996,7 +1034,8 @@ let webouts = {
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
LONGDESC: "" LONGDESC: "",
COLTYPE: "{\"data\":\"VAR_TXTO\"}"
} }
], ],
dqdata: [ dqdata: [
@@ -1445,7 +1484,6 @@ let webouts = {
DTVARS: "", DTVARS: "",
DTTMVARS: "", DTTMVARS: "",
TMVARS: "", TMVARS: "",
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"LIBREF\"},{\"data\":\"DSN\"},{\"data\":\"NUM_OF_APPROVALS_REQUIRED\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"LOADTYPE\"},{\"data\":\"BUSKEY\"},{\"data\":\"VAR_TXFROM\"},{\"data\":\"VAR_TXTO\"},{\"data\":\"VAR_BUSFROM\"},{\"data\":\"VAR_BUSTO\"},{\"data\":\"VAR_PROCESSED\"},{\"data\":\"CLOSE_VARS\"},{\"data\":\"PRE_EDIT_HOOK\"},{\"data\":\"POST_EDIT_HOOK\"},{\"data\":\"PRE_APPROVE_HOOK\"},{\"data\":\"POST_APPROVE_HOOK\"},{\"data\":\"SIGNOFF_COLS\"},{\"data\":\"SIGNOFF_HOOK\"},{\"data\":\"NOTES\"},{\"data\":\"RK_UNDERLYING\"},{\"data\":\"AUDIT_LIBDS\"}",
LOADTYPE: "TXTEMPORAL", LOADTYPE: "TXTEMPORAL",
RK_FLAG: 0, RK_FLAG: 0,
CLS_FLAG: 0 CLS_FLAG: 0
@@ -1499,11 +1537,7 @@ function stripRuleCols(t) {
}) })
t.sasparams = t.sasparams.map((p) => { t.sasparams = t.sasparams.map((p) => {
const headers = p.COLHEADERS.split(',').filter((h) => !uc.has(h)) const headers = p.COLHEADERS.split(',').filter((h) => !uc.has(h))
const coltype = JSON.parse('[' + p.COLTYPE + ']') return { ...p, COLHEADERS: headers.join(',') }
.filter((o) => !uc.has(o.data))
.map((o) => JSON.stringify(o))
.join(',')
return { ...p, COLHEADERS: headers.join(','), COLTYPE: coltype }
}) })
return t return t
} }
+3 -3
View File
@@ -52,7 +52,7 @@ let webouts = {
"NAME": "SOME_CHAR", "NAME": "SOME_CHAR",
"LENGTH": 32767, "LENGTH": 32767,
"VARNUM": 2, "VARNUM": 2,
"LABEL": "SOME_CHAR", "LABEL": "Some Character Column",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$32767.", "FORMAT": "$32767.",
"TYPE": "C", "TYPE": "C",
@@ -62,7 +62,7 @@ let webouts = {
"NAME": "SOME_DATE", "NAME": "SOME_DATE",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 5, "VARNUM": 5,
"LABEL": "SOME_DATE", "LABEL": "Some Date",
"FMTNAME": "DATE", "FMTNAME": "DATE",
"FORMAT": "DATE9.", "FORMAT": "DATE9.",
"TYPE": "N", "TYPE": "N",
@@ -92,7 +92,7 @@ let webouts = {
"NAME": "SOME_NUM", "NAME": "SOME_NUM",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 4, "VARNUM": 4,
"LABEL": "SOME_NUM", "LABEL": "",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "8.", "FORMAT": "8.",
"TYPE": "N", "TYPE": "N",
@@ -0,0 +1,21 @@
/**
@file
@brief migration script
**/
%let dclib=YOURDCLIB;
libname &dclib "/YOUR/DATACONTROLLER/LIBRARY/PATH";
/* add new validation rules */
proc sql noprint;
insert into &dclib..mpe_config set
tx_from=%sysfunc(datetime())
,tx_to='31DEC9999:23:59:59'dt
,var_scope="DC"
,var_name="DC_MAXOBS_WEBVIEW"
,var_value='500'
,var_active=1
,var_desc='Default rows displayed for tables in VIEW mode.';
+48 -12
View File
@@ -1,7 +1,29 @@
/** /**
@file mpe_getlabels.sas @file mpe_getlabels.sas
@brief Gets the table and column labels for a particular table @brief Gets the table and column labels for a particular table
@details Takes labels first from mpe_datadictionary then from table metadata. @details Builds a labels dataset by combining three inputs:
@li NAME - taken from `source`. This is typically a WORK subset of
the underlying table (e.g. after column-level security has been
applied) and dictates which columns appear in the output.
@li MEMLABEL / LABEL - taken from `tgt_ds` (the two-part
libref.dataset). This ensures the "true" table and column
labels are used rather than any that may have been stripped
during WORK subsetting.
@li DD_SHORTDESC / DD_LONGDESC - taken from `mpe_datadictionary`
where DD_SOURCE contains the `LIB.DSN` reference and DD_TYPE is
`COLUMN`.
If `tgt_ds` is not supplied it defaults to `source`.
@param [in] type The type of labels to fetch (only COLUMNS is
currently supported).
@param [in] source The dataset from which column NAMES are read.
@param [in] tgt_ds= (&source) The two-part libref.dataset from which
the table/column LABELS are read, and used when joining to
`mpe_datadictionary` for short/long descriptions.
@param [out] outds= (mpe_getlabels) The output dataset with columns
`name`, `memlabel`, `desc`, `longdesc`.
<h4> SAS Macros </h4> <h4> SAS Macros </h4>
@li mf_getuniquename.sas @li mf_getuniquename.sas
@@ -13,26 +35,40 @@
4GL Apps Ltd. 4GL Apps Ltd.
**/ **/
%macro mpe_getlabels(type,source,outds=mpe_getlabels); %macro mpe_getlabels(type,source,tgt_ds=,outds=mpe_getlabels);
%local tmpds; %local src_cols tgt_cols;
%if %superq(tgt_ds)= %then %let tgt_ds=&source;
%if &type=COLUMNS %then %do; %if &type=COLUMNS %then %do;
%let tmpds=%mf_getuniquename(); %let src_cols=%mf_getuniquename();
%let tgt_cols=%mf_getuniquename();
/* NAMES from the (potentially filtered) source dataset */
proc contents noprint data=&source proc contents noprint data=&source
out=&tmpds(keep=name memlabel label); out=&src_cols(keep=name);
run; run;
/* LABELS from the underlying target dataset */
proc contents noprint data=&tgt_ds
out=&tgt_cols(keep=name memlabel label);
run;
proc sql; proc sql;
create table &outds as create table &outds as
select upcase(a.name) as name select upcase(a.name) as name
,a.memlabel ,b.memlabel
,coalesce(b.dd_shortdesc,a.label) as desc ,coalesce(c.dd_shortdesc,b.label) as desc
,b.dd_longdesc as longdesc ,c.dd_longdesc as longdesc
from &tmpds a from &src_cols a
inner join &tgt_cols b
on upcase(a.name)=upcase(b.name)
left join &mpelib..mpe_datadictionary left join &mpelib..mpe_datadictionary
(where=(&dc_dttmtfmt. < tx_to (where=(&dc_dttmtfmt. < tx_to
and dd_source ? %upcase("&source") and dd_source ? %upcase("&tgt_ds")
and dd_type='COLUMN')) b and dd_type='COLUMN')) c
on scan(b.dd_source,-1,'.')=upcase(a.name); on scan(c.dd_source,-1,'.')=upcase(a.name);
quit;
%end; %end;
%mend mpe_getlabels; %mend mpe_getlabels;
+204
View File
@@ -0,0 +1,204 @@
/**
@file
@brief Testing mpe_getlabels macro
@details Verifies that the output dataset combines:
@li NAME from `source`
@li LABEL / MEMLABEL from `tgt_ds`
@li DD_SHORTDESC / DD_LONGDESC from MPE_DATADICTIONARY where DD_SOURCE
contains the `LIB.DSN` reference and DD_TYPE='COLUMN'.
Scenarios covered:
1. `source` is a two-part `LIB.DSN` - labels and DD entries are
joined directly.
2. `source` is a WORK subset without `tgt_ds` - DD entries do not
match.
3. `source` is a WORK subset with `tgt_ds` set to the underlying
`LIB.DSN` - labels come from the target and DD entries match.
4. `source` is a WORK subset that contains only a *subset* of the
target columns - the output contains only those columns while
labels still come from the target.
<h4> SAS Macros </h4>
@li mf_getuniquename.sas
@li mf_nobs.sas
@li mp_assert.sas
@li mp_assertdsobs.sas
@li mp_assertscope.sas
@li mpe_getlabels.sas
@author 4GL Apps Ltd
@copyright 4GL Apps Ltd. This code may only be used within Data Controller
and may not be re-distributed or re-sold without the express permission of
4GL Apps Ltd.
**/
/* teardown - remove any seeded rows */
proc sql;
delete from &mpelib..mpe_datadictionary
where dd_source in (
"&mpelib..MPE_X_TEST.PRIMARY_KEY_FIELD",
"&mpelib..MPE_X_TEST.SOME_CHAR"
);
quit;
/* seed MPE_DATADICTIONARY with column-level entries keyed on
LIB.DSNAME.COLUMN. &mpelib..MPE_X_TEST is created by testsetup.sas. */
proc sql;
insert into &mpelib..mpe_datadictionary set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,dd_type='COLUMN'
,dd_source="&mpelib..MPE_X_TEST.PRIMARY_KEY_FIELD"
,dd_shortdesc='SHORT PK DESCRIPTION'
,dd_longdesc='LONG PK DESCRIPTION'
,dd_owner=''
,dd_responsible=''
,dd_sensitivity='';
insert into &mpelib..mpe_datadictionary set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,dd_type='COLUMN'
,dd_source="&mpelib..MPE_X_TEST.SOME_CHAR"
,dd_shortdesc='SHORT CHAR DESCRIPTION'
,dd_longdesc='LONG CHAR DESCRIPTION'
,dd_owner=''
,dd_responsible=''
,dd_sensitivity='';
quit;
/* Test 1 - two-part libref.dataset as source */
%mp_assertscope(SNAPSHOT)
%mpe_getlabels(COLUMNS,&mpelib..MPE_X_TEST,outds=work.test1)
%mp_assertscope(COMPARE,
desc=Checking macro variables against previous snapshot
)
data _null_;
set work.test1;
putlog (_all_)(=);
run;
%mp_assertdsobs(work.test1,
desc=Test 1 - output dataset contains rows (one per column),
test=ATLEAST 1,
outds=work.test_results
)
%let shortdesc_hit=0;
%let longdesc_hit=0;
data _null_;
set work.test1;
where upcase(name)='PRIMARY_KEY_FIELD';
if desc='SHORT PK DESCRIPTION' then call symputx('shortdesc_hit',1);
if longdesc='LONG PK DESCRIPTION' then call symputx('longdesc_hit',1);
run;
%mp_assert(
iftrue=(&shortdesc_hit=1),
desc=Test 1 - DD_SHORTDESC returned as desc,
outds=work.test_results
)
%mp_assert(
iftrue=(&longdesc_hit=1),
desc=Test 1 - DD_LONGDESC returned as longdesc,
outds=work.test_results
)
/* Test 2 - WORK subset as source, no tgt_ds. DD_SOURCE values do not
contain `WORK.SASDATA1`, so DD entries are not applied and desc
falls back to whatever LABEL exists on the WORK dataset. */
data work.sasdata1;
set &mpelib..mpe_x_test;
run;
%mpe_getlabels(COLUMNS,work.sasdata1,outds=work.test2)
data _null_;
set work.test2;
putlog (_all_)(=);
run;
%let shortdesc_hit2=0;
data _null_;
set work.test2;
where upcase(name)='PRIMARY_KEY_FIELD';
if desc='SHORT PK DESCRIPTION' then call symputx('shortdesc_hit2',1);
run;
%mp_assert(
iftrue=(&shortdesc_hit2=0),
desc=Test 2 - Without tgt_ds a WORK source does not pick up DD_SHORTDESC,
outds=work.test_results
)
/* Test 3 - WORK subset as source with tgt_ds pointing at the
underlying LIB.DSN. Column names are taken from the WORK subset,
labels come from the target, DD entries are applied. */
%mpe_getlabels(COLUMNS,work.sasdata1,tgt_ds=&mpelib..MPE_X_TEST,outds=work.test3)
data _null_;
set work.test3;
putlog (_all_)(=);
run;
%mp_assertdsobs(work.test3,
desc=Test 3 - output dataset contains rows,
test=ATLEAST 1,
outds=work.test_results
)
%let shortdesc_hit3=0;
%let longdesc_hit3=0;
data _null_;
set work.test3;
where upcase(name)='PRIMARY_KEY_FIELD';
if desc='SHORT PK DESCRIPTION' then call symputx('shortdesc_hit3',1);
if longdesc='LONG PK DESCRIPTION' then call symputx('longdesc_hit3',1);
run;
%mp_assert(
iftrue=(&shortdesc_hit3=1),
desc=Test 3 - DD_SHORTDESC returned as desc when tgt_ds is supplied,
outds=work.test_results
)
%mp_assert(
iftrue=(&longdesc_hit3=1),
desc=Test 3 - DD_LONGDESC returned as longdesc when tgt_ds is supplied,
outds=work.test_results
)
/* Test 4 - WORK source with a strict subset of the target columns.
The output should contain only the columns present in `source`,
but labels still come from `tgt_ds`. */
data work.sasdata2;
set &mpelib..mpe_x_test (keep=primary_key_field);
run;
%mpe_getlabels(COLUMNS,work.sasdata2,tgt_ds=&mpelib..MPE_X_TEST,outds=work.test4)
data _null_;
set work.test4;
putlog (_all_)(=);
run;
%mp_assertdsobs(work.test4,
desc=Test 4 - output contains exactly one row (matching the source subset),
test=EQUALS 1,
outds=work.test_results
)
%let shortdesc_hit4=0;
data _null_;
set work.test4;
where upcase(name)='PRIMARY_KEY_FIELD';
if desc='SHORT PK DESCRIPTION' then call symputx('shortdesc_hit4',1);
run;
%mp_assert(
iftrue=(&shortdesc_hit4=1),
desc=Test 4 - DD_SHORTDESC returned for the retained column,
outds=work.test_results
)
+1 -1
View File
@@ -280,7 +280,7 @@
}, },
{ {
"name": "server-mihajlo", "name": "server-mihajlo",
"serverUrl": "https://sas9.4gl.io", "serverUrl": "https://sas.4gl.io",
"serverType": "SASJS", "serverType": "SASJS",
"httpsAgentOptions": { "httpsAgentOptions": {
"rejectUnauthorized": false, "rejectUnauthorized": false,
+13 -15
View File
@@ -35,6 +35,7 @@
@li memlabel @li memlabel
@li desc- augmented with MPE_DATADICTIONARY if exists, else label @li desc- augmented with MPE_DATADICTIONARY if exists, else label
@li longdesc - from MPE_DATADICTIONARY @li longdesc - from MPE_DATADICTIONARY
@li coltype - front-end column formatting spec
<h5> maxvarlengths </h5> <h5> maxvarlengths </h5>
@@ -333,12 +334,11 @@ select upcase(loadtype)
) )
%global jsdttmvars jsdtvars jstmvars; %global jsdttmvars jsdtvars jstmvars;
data _null_; data vars4;
set vars3 end=last; set vars3;
if _n_>1 then comma=',';
length coltype $500.; length coltype $500.;
format=upcase(format); format=upcase(format);
coltype=cats(comma,'{"data":"',name,'"'); coltype=cats('{"data":"',name,'"');
if ctrlOptions ne '' then if ctrlOptions ne '' then
colType=cats(coltype,',"type":"dropdown","source":',ctrlOptions,"}"); colType=cats(coltype,',"type":"dropdown","source":',ctrlOptions,"}");
else if type='num' then do; else if type='num' then do;
@@ -372,10 +372,7 @@ select upcase(loadtype)
end; end;
end; end;
else colType=cats(coltype,'}'); else colType=cats(coltype,'}');
length concatcoltype $32767; output;
retain concatcoltype;
concatcoltype=cats(concatcoltype,coltype);
if last then call symputx('colType',strip(concatcoltype),'g');
putlog (_all_)(=); putlog (_all_)(=);
run; run;
@@ -480,8 +477,9 @@ select upcase(loadtype)
,outmeta=work.cls_rules ,outmeta=work.cls_rules
) )
/* get labels */ /* get labels - pull column names from the CLS-filtered WORK dataset but
%mpe_getlabels(COLUMNS,sasdata1,outds=spec) join to mpe_datadictionary using the underlying LIB.DSN reference */
%mpe_getlabels(COLUMNS,sasdata1,tgt_ds=&libds,outds=spec)
%mp_abort(iftrue= (&syscc ne 0) %mp_abort(iftrue= (&syscc ne 0)
,mac=&_program ,mac=&_program
,msg=%str(syscc=&syscc extracting spec info) ,msg=%str(syscc=&syscc extracting spec info)
@@ -495,7 +493,7 @@ proc sql;
create table work.cols as create table work.cols as
select a.NAME select a.NAME
,a.VARNUM ,a.VARNUM
,a.LABEL ,c.desc as LABEL
,a.FMTNAME ,a.FMTNAME
,a.DDTYPE ,a.DDTYPE
,case b.cls_hide ,case b.cls_hide
@@ -503,13 +501,15 @@ create table work.cols as
when 0 then 'EDIT' when 0 then 'EDIT'
else 'READ' end as CLS_RULE else 'READ' end as CLS_RULE
,c.memlabel ,c.memlabel
,c.desc
,c.longdesc ,c.longdesc
,d.colType
from work.cols1 a from work.cols1 a
left join work.cls_rules b left join work.cls_rules b
on a.NAME=b.CLS_VARIABLE_NM on a.NAME=b.CLS_VARIABLE_NM
left join work.spec c left join work.spec c
on a.NAME=c.NAME; on a.NAME=c.NAME
left join work.vars4 d
on a.NAME=d.NAME;
proc sql; proc sql;
create table approvers as select distinct membername as personname create table approvers as select distinct membername as personname
@@ -550,8 +550,6 @@ data sasparams;
dtvars=compbl("&jsdtvars"); dtvars=compbl("&jsdtvars");
dttmvars=compbl("&jsdttmvars"); dttmvars=compbl("&jsdttmvars");
tmvars=compbl("&jstmvars"); tmvars=compbl("&jstmvars");
length coltype $32000;
coltype=symget('coltype');
loadtype=symget('loadtype'); loadtype=symget('loadtype');
if trim(symget('rk_underlying')) ne '' then rk_flag=1; if trim(symget('rk_underlying')) ne '' then rk_flag=1;
else rk_flag=0; else rk_flag=0;
+25 -8
View File
@@ -71,8 +71,6 @@
/* configure macvars */ /* configure macvars */
%global LIBDS FILTER_RK SEARCHVAL SEARCHTYPE FMT_IND; %global LIBDS FILTER_RK SEARCHVAL SEARCHTYPE FMT_IND;
%let maxrows=250;
/* avoid code injection */ /* avoid code injection */
%let FMT_IND=0; %let FMT_IND=0;
%let SEARCHTYPE=; %let SEARCHTYPE=;
@@ -173,6 +171,13 @@ run;
%global dsobs; %global dsobs;
%let dsobs=0; %let dsobs=0;
%macro x(); %macro x();
%if not %symexist(DC_MAXOBS_WEBVIEW) %then %do;
%put NOTE:;%put NOTE- DC_MAXOBS_WEBVIEW not found!;
%put NOTE- Please add to &mpelib..MPE_CONFIG table;
%put NOTE-;%put NOTE-;
%global DC_MAXOBS_WEBVIEW;
%let DC_MAXOBS_WEBVIEW=500;
%end;
%if &existds>0 %then %do; %if &existds>0 %then %do;
%if &fmt_ind=1 %then %do; %if &fmt_ind=1 %then %do;
@@ -234,7 +239,7 @@ run;
data work.viewdata; data work.viewdata;
set &libds; set &libds;
where %inc filtref;; where %inc filtref;;
if _n_>&maxrows then stop; if _n_>&DC_MAXOBS_WEBVIEW then stop;
run; run;
%if %mf_nobs(work.viewdata)=0 %then %do; %if %mf_nobs(work.viewdata)=0 %then %do;
data work.viewdata; data work.viewdata;
@@ -253,21 +258,21 @@ run;
%mp_searchdata(lib=work %mp_searchdata(lib=work
,ds=vwsearch ,ds=vwsearch
,string=%superq(searchval) ,string=%superq(searchval)
,outobs=&maxrows ,outobs=&DC_MAXOBS_WEBVIEW
) )
%end; %end;
%else %if %upcase(&searchtype)=NUM %then %do; %else %if %upcase(&searchtype)=NUM %then %do;
%mp_searchdata(lib=work %mp_searchdata(lib=work
,ds=vwsearch ,ds=vwsearch
,numval=%superq(searchval) ,numval=%superq(searchval)
,outobs=&maxrows ,outobs=&DC_MAXOBS_WEBVIEW
) )
%end; %end;
%if %mf_existds(libds=MPSEARCH.vwsearch) %then %do; %if %mf_existds(libds=MPSEARCH.vwsearch) %then %do;
%let dsobs=%mf_nobs(MPSEARCH.vwsearch); %let dsobs=%mf_nobs(MPSEARCH.vwsearch);
data viewdata; data viewdata;
set MPSEARCH.vwsearch; set MPSEARCH.vwsearch;
if _n_<&maxrows; if _n_<&DC_MAXOBS_WEBVIEW;
run; run;
%end; %end;
%else %do; %else %do;
@@ -347,7 +352,7 @@ data work.sasparams;
PK_FIELDS=symget('PK_FIELDS'); PK_FIELDS=symget('PK_FIELDS');
nobs=&dsobs; nobs=&dsobs;
vars=%mf_getvarcount(viewdata); vars=%mf_getvarcount(viewdata);
maxrows=&maxrows; maxrows=&DC_MAXOBS_WEBVIEW;
run; run;
%mp_abort(iftrue= (&syscc ne 0) %mp_abort(iftrue= (&syscc ne 0)
@@ -355,7 +360,19 @@ run;
,msg=%str(syscc=&syscc) ,msg=%str(syscc=&syscc)
) )
%mp_getcols(&libds, outds=cols) %mp_getcols(&libds, outds=cols1)
proc sql;
create table cols(drop=srclabel) as
select a.*
,coalesce(b.dd_shortdesc,a.srclabel) as label
from cols1(rename=(label=srclabel)) a
left join &mpelib..mpe_datadictionary
(where=(&dc_dttmtfmt. < tx_to
and dd_source ? %upcase("&orig_libds")
and dd_type='COLUMN')) b
on scan(b.dd_source,-1,'.')=upcase(a.name);
quit;
%mpe_dsmeta(&orig_libds, outds=dsmeta) %mpe_dsmeta(&orig_libds, outds=dsmeta)