Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dcae9060f | ||
|
|
f4525cc62a | ||
|
|
d4aa70ddbf | ||
|
|
4c2aca3495 | ||
|
|
36c9ef0e95 | ||
|
|
7511b64523 | ||
|
|
7b4b4ebeeb | ||
|
|
c7ba025d39 | ||
|
|
b5e9b25319 | ||
|
|
82a254d22c | ||
|
|
0d7fd34297 | ||
|
|
1beb3d490d | ||
|
|
1fb5e8eb37 | ||
|
|
78dd29ba78 | ||
|
|
ba7b61082d | ||
|
|
1a9231809e | ||
|
|
df5c975869 | ||
|
|
2728dac873 | ||
|
|
88f55b9d06 | ||
|
|
301675052f | ||
|
|
45c434271d | ||
|
|
f523c798c3 | ||
|
|
41fd618884 | ||
|
|
f368242cb0 | ||
|
|
636e4e5b4a | ||
|
|
49710eb62d | ||
|
|
25c12f2b18 | ||
|
|
3ba8858260 | ||
|
|
81014001ac | ||
|
|
691d6f277e | ||
|
|
e7453a9305 | ||
|
|
005b616adb | ||
|
|
0ae1781d28 | ||
|
|
fb94840016 |
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Vendored
+1
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
|
"Handsontable",
|
||||||
"Licence",
|
"Licence",
|
||||||
"SYSERRORTEXT",
|
"SYSERRORTEXT",
|
||||||
"SYSWARNINGTEXT",
|
"SYSWARNINGTEXT",
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Generated
+289
-197
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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'"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user