Compare commits
2
Commits
6864c044dc
...
25c12f2b18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c12f2b18 | ||
|
|
005b616adb |
@@ -146,7 +146,7 @@ jobs:
|
||||
# Start frontend and run cypress
|
||||
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
|
||||
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts"
|
||||
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
|
||||
if: always()
|
||||
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||
cat ./cypress.config.ts
|
||||
# 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
|
||||
if: always()
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"Handsontable",
|
||||
"Licence",
|
||||
"SYSERRORTEXT",
|
||||
"SYSWARNINGTEXT",
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// 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`)
|
||||
cy.loginAndUpdateValidKey()
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
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')
|
||||
}
|
||||
@@ -23,21 +23,33 @@
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
||||
import 'cypress-file-upload';
|
||||
import 'cypress-file-upload'
|
||||
|
||||
import { arrayBufferToBase64 } from './../util/helper-functions'
|
||||
import * as moment from 'moment'
|
||||
import moment from 'moment'
|
||||
|
||||
const username = Cypress.env('username');
|
||||
const password = Cypress.env('password');
|
||||
const hostUrl = Cypress.env('hosturl');
|
||||
const appLocation = Cypress.env('appLocation');
|
||||
// These custom commands were added via Cypress.Commands.add() below but never
|
||||
// had a type augmentation, so every spec calling cy.loginAndUpdateValidKey()/
|
||||
// cy.isLoggedIn() had an unresolved-property error.
|
||||
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 site_id_SASJS = Cypress.env('site_id_SASJS')
|
||||
|
||||
Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find(".nav-tree").length > 0) {
|
||||
cy.get('body').then(($body) => {
|
||||
if ($body.find('.nav-tree').length > 0) {
|
||||
if (callback) callback(true)
|
||||
} else {
|
||||
if (callback) callback(false)
|
||||
@@ -45,76 +57,92 @@ Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
|
||||
})
|
||||
})
|
||||
|
||||
Cypress.Commands.add('loginAndUpdateValidKey', (forceLicenceKey: boolean = false) => {
|
||||
cy.visit(hostUrl + appLocation);
|
||||
Cypress.Commands.add(
|
||||
'loginAndUpdateValidKey',
|
||||
(forceLicenceKey: boolean = false) => {
|
||||
cy.visit(hostUrl + appLocation)
|
||||
|
||||
cy.wait(2000)
|
||||
cy.wait(2000)
|
||||
|
||||
cy.get('body').then($body =>{
|
||||
const usernameInput = $body.find("input.username")[0]
|
||||
cy.get('body').then(($body) => {
|
||||
const usernameInput = $body.find('input.username')[0]
|
||||
|
||||
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
|
||||
cy.get('input.username').type(username);
|
||||
cy.get('input.password').type(password);
|
||||
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
|
||||
cy.get('input.username').type(username)
|
||||
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.wait(2000)
|
||||
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
cy.wait(2000)
|
||||
|
||||
if ($body.find(".nav-tree").length > 0) {
|
||||
/**
|
||||
* If licence key is already working, then skip rest of the function
|
||||
*/
|
||||
return logout(() => {
|
||||
return
|
||||
})
|
||||
} else{
|
||||
const keyData = {
|
||||
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
|
||||
number_of_users: 10,
|
||||
hot_license_key: '',
|
||||
site_id: '',
|
||||
demo: false
|
||||
}
|
||||
|
||||
return generateKeys(keyData.valid_until, keyData.number_of_users, keyData.hot_license_key, keyData.demo, (keysGen: any) => {
|
||||
return acceptTermsIfPresented((result: boolean) => {
|
||||
if (result) {
|
||||
cy.wait(20000)
|
||||
if ($body.find('.nav-tree').length > 0) {
|
||||
/**
|
||||
* If licence key is already working, then skip rest of the function
|
||||
*/
|
||||
return logout(() => {
|
||||
return
|
||||
})
|
||||
} else {
|
||||
const keyData = {
|
||||
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
|
||||
number_of_users: 10,
|
||||
hot_license_key: '',
|
||||
site_id: '',
|
||||
demo: false
|
||||
}
|
||||
|
||||
if (!forceLicenceKey) {
|
||||
return logout(() => {
|
||||
return
|
||||
})
|
||||
} else {
|
||||
return updateLicenseKeyQuick(keysGen, () => {
|
||||
cy.wait(25000)
|
||||
return generateKeys(
|
||||
keyData.valid_until,
|
||||
keyData.number_of_users,
|
||||
keyData.hot_license_key,
|
||||
keyData.demo,
|
||||
(keysGen: any) => {
|
||||
return acceptTermsIfPresented((result: boolean) => {
|
||||
if (result) {
|
||||
cy.wait(20000)
|
||||
}
|
||||
return logout(() => {
|
||||
return
|
||||
})
|
||||
|
||||
if (!forceLicenceKey) {
|
||||
return logout(() => {
|
||||
return
|
||||
})
|
||||
} else {
|
||||
return updateLicenseKeyQuick(keysGen, () => {
|
||||
cy.wait(25000)
|
||||
return acceptTermsIfPresented((result: boolean) => {
|
||||
if (result) {
|
||||
cy.wait(20000)
|
||||
}
|
||||
return logout(() => {
|
||||
return
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
)
|
||||
|
||||
const logout = (callback?: any) => {
|
||||
cy.get('.header-actions .dropdown-toggle').click().then(() => {
|
||||
cy.get('.header-actions .dropdown-menu > .separator').next().click().then(() => {
|
||||
if (callback) callback()
|
||||
cy.get('.header-actions .dropdown-toggle')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('.header-actions .dropdown-menu > .separator')
|
||||
.next()
|
||||
.click()
|
||||
.then(() => {
|
||||
if (callback) callback()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const updateLicenseKeyQuick = (keys: any, callback: any) => {
|
||||
@@ -132,11 +160,15 @@ const updateLicenseKeyQuick = (keys: any, callback: any) => {
|
||||
const acceptTermsIfPresented = (callback?: any) => {
|
||||
cy.url().then((url: string) => {
|
||||
if (url.includes('licensing/register')) {
|
||||
cy.get('.card-block').scrollTo('bottom').then(() => {
|
||||
cy.get('#checkbox1').click().then(() => {
|
||||
if (callback) callback(true)
|
||||
cy.get('.card-block')
|
||||
.scrollTo('bottom')
|
||||
.then(() => {
|
||||
cy.get('#checkbox1')
|
||||
.click()
|
||||
.then(() => {
|
||||
if (callback) callback(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
if (callback) callback(false)
|
||||
}
|
||||
@@ -151,23 +183,36 @@ const isLicensingPage = (callback: any) => {
|
||||
|
||||
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
||||
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('.activation-key-form textarea', {timeout: longerCommandTimeout}).invoke('val', activationKey).trigger('input').should('not.be.undefined')
|
||||
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
|
||||
.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()
|
||||
}
|
||||
|
||||
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) => {
|
||||
let keyPair = await window.crypto.subtle.generateKey({
|
||||
name: "RSA-OAEP",
|
||||
modulusLength: 2024,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256"
|
||||
const generateKeys = async (
|
||||
valid_until: string,
|
||||
users_allowed: number,
|
||||
hot_license_key: string,
|
||||
demo: boolean,
|
||||
resultCallback?: any
|
||||
) => {
|
||||
let keyPair = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2024,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
|
||||
let licenseData = {
|
||||
@@ -184,30 +229,39 @@ const generateKeys = async (valid_until: string, users_allowed: number, hot_lice
|
||||
|
||||
console.log(encoded)
|
||||
|
||||
let cipher = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "RSA-OAEP"
|
||||
},
|
||||
keyPair.publicKey,
|
||||
encoded
|
||||
).then((value) => {
|
||||
return value
|
||||
}, (err) => {
|
||||
console.log('Encrpyt error', err)
|
||||
})
|
||||
let cipher = await window.crypto.subtle
|
||||
.encrypt(
|
||||
{
|
||||
name: 'RSA-OAEP'
|
||||
},
|
||||
keyPair.publicKey,
|
||||
encoded
|
||||
)
|
||||
.then(
|
||||
(value) => {
|
||||
return value
|
||||
},
|
||||
(err) => {
|
||||
console.log('Encrpyt error', err)
|
||||
}
|
||||
)
|
||||
|
||||
if (!cipher) {
|
||||
alert('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 licenseKey = await arrayBufferToBase64(cipher)
|
||||
|
||||
if (resultCallback) resultCallback({
|
||||
activationKey,
|
||||
licenseKey
|
||||
})
|
||||
}
|
||||
if (resultCallback)
|
||||
resultCallback({
|
||||
activationKey,
|
||||
licenseKey
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const check = (cwd) => {
|
||||
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;',
|
||||
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) => {
|
||||
if (error) {
|
||||
|
||||
Generated
+11
-722
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -54,7 +54,7 @@
|
||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||
"@clr/icons": "^13.0.2",
|
||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||
"@handsontable/angular-wrapper": "^17.1.0",
|
||||
"@handsontable/angular-wrapper": "^18.0.0",
|
||||
"@sasjs/adapter": "^4.17.0",
|
||||
"@sasjs/utils": "^3.5.3",
|
||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||
@@ -67,7 +67,7 @@
|
||||
"d3-graphviz": "^5.0.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"fs-extra": "^7.0.1",
|
||||
"handsontable": "^17.1.0",
|
||||
"handsontable": "^18.0.0",
|
||||
"https-browserify": "1.0.0",
|
||||
"hyperformula": "^2.5.0",
|
||||
"iconv-lite": "^0.5.0",
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ViewEncapsulation
|
||||
} from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import Handsontable from 'handsontable'
|
||||
import Handsontable, { CellRange } from 'handsontable'
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
import { sanitiseForSas } from '../shared/utils/sanitise'
|
||||
import { SasStoreService } from '../services/sas-store.service'
|
||||
@@ -21,7 +21,6 @@ type AOA = any[][]
|
||||
import { HotTableComponent } from '@handsontable/angular-wrapper'
|
||||
import { UploadFile } from '@sasjs/adapter'
|
||||
import { isSpecialMissing } from '@sasjs/utils/input/validators'
|
||||
import CellRange from 'handsontable/3rdparty/walkontable/src/cell/range'
|
||||
import { CellValidationSource } from '../models/CellValidationSource'
|
||||
import { FileUploader } from '../models/FileUploader.class'
|
||||
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 { excelRound } from '../shared/dc-validator/utils/excelRound'
|
||||
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 { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
|
||||
import {
|
||||
@@ -161,8 +163,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
items: {
|
||||
edit_row: {
|
||||
name: 'Edit row',
|
||||
hidden() {
|
||||
const hot: Handsontable.Core = this
|
||||
// HOT 18's MenuItemConfig types `hidden` as a plain `() => boolean` with
|
||||
// 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.
|
||||
if (hot.getSettings().readOnly) return true
|
||||
@@ -243,6 +248,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
redo: {
|
||||
name: 'Redo',
|
||||
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 queryFilter: any
|
||||
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 clauses: any
|
||||
@@ -1320,7 +1344,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
hot.updateSettings(
|
||||
{
|
||||
data: this.dataSource,
|
||||
colHeaders: this.headerColumns,
|
||||
colHeaders: getDisplayColHeaders(
|
||||
this.headerColumns,
|
||||
this.cols,
|
||||
this.useLabels
|
||||
),
|
||||
columns: this.cellValidation,
|
||||
modifyColWidth: function (width: number, col: number) {
|
||||
if (col === 0) {
|
||||
@@ -1399,7 +1427,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
hot.removeCellMeta(rowIndex, col, 'valid')
|
||||
hot.removeCellMeta(rowIndex, col, 'dupKey')
|
||||
// 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) {
|
||||
let cleanedClassName: string
|
||||
if (Array.isArray(cellMeta.className)) {
|
||||
@@ -1784,7 +1814,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
hot.updateSettings(
|
||||
{
|
||||
data: this.dataSource,
|
||||
colHeaders: this.headerColumns,
|
||||
colHeaders: getDisplayColHeaders(
|
||||
this.headerColumns,
|
||||
this.cols,
|
||||
this.useLabels
|
||||
),
|
||||
columns: this.cellValidation,
|
||||
modifyColWidth: function (width: number, col: number) {
|
||||
if (width > 500) return 500
|
||||
@@ -2246,8 +2280,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
validationSourceIndex
|
||||
].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 =
|
||||
(hot.getCellMeta(row, column).source || []).length < 1
|
||||
((hot.getCellMeta(row, column).source as unknown[]) || []).length < 1
|
||||
const cellHasValue = cellData !== ' '
|
||||
|
||||
hot.batch(() => {
|
||||
@@ -2638,6 +2674,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) => {
|
||||
if (query.libds === this.libds) {
|
||||
this.whereString = query.string
|
||||
@@ -2766,7 +2810,26 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}, 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() {
|
||||
this._queryParams?.unsubscribe()
|
||||
|
||||
// Clean up the MutationObserver
|
||||
if (this.ariaObserver) {
|
||||
this.ariaObserver.disconnect()
|
||||
@@ -3059,7 +3122,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
hot.updateSettings(
|
||||
{
|
||||
data: this.dataSource,
|
||||
colHeaders: this.headerColumns,
|
||||
colHeaders: getDisplayColHeaders(
|
||||
this.headerColumns,
|
||||
this.cols,
|
||||
this.useLabels
|
||||
),
|
||||
columns: this.cellValidation,
|
||||
height: this.hotTable.height,
|
||||
formulas: this.hotTable.formulas,
|
||||
@@ -3119,20 +3186,22 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
itemValue: string
|
||||
) => {
|
||||
const elem = document.createElement('span')
|
||||
let colName = ''
|
||||
let colInfo: DataFormat | undefined
|
||||
let textInfo = 'No info found'
|
||||
|
||||
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() || []
|
||||
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]
|
||||
|
||||
if (colInfo)
|
||||
textInfo = `LABEL: ${colInfo?.label}<br>TYPE: ${colInfo?.type}<br>LENGTH: ${colInfo?.length}<br>FORMAT: ${colInfo?.format}`
|
||||
textInfo = buildColInfoHtml(colName, colInfo)
|
||||
}
|
||||
|
||||
elem.innerHTML = textInfo
|
||||
|
||||
@@ -22,10 +22,9 @@ import { ExcelRule } from '../models/TableData'
|
||||
import { HotTableInterface } from '../models/HotTable.interface'
|
||||
import { Col } from '../shared/dc-validator/models/col.model'
|
||||
import { SpreadsheetService } from '../services/spreadsheet.service'
|
||||
import Handsontable from 'handsontable'
|
||||
import Handsontable, { CellChange, ChangeSource } from 'handsontable'
|
||||
import { HotTableComponent } from '@handsontable/angular-wrapper'
|
||||
import { EditorsStageDataSASResponse } from '../models/sas/editors-stagedata.model'
|
||||
import { CellChange, ChangeSource } from 'handsontable/common'
|
||||
import { baseAfterGetColHeader } from '../shared/utils/hot.utils'
|
||||
import { ColumnSettings } from 'handsontable/settings'
|
||||
import { UploadFile } from '@sasjs/adapter'
|
||||
@@ -502,7 +501,9 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
if (changes) {
|
||||
for (let change of changes) {
|
||||
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() {
|
||||
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) => {
|
||||
const library = row[0]
|
||||
@@ -573,10 +576,12 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
|
||||
if (dataAtRow && dataAtRow[0] && dataAtRow[1]) {
|
||||
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) => {
|
||||
this.hotInstanceUserDataset.setCellMeta(
|
||||
row,
|
||||
cellMeta.col,
|
||||
cellMeta.col as number,
|
||||
'className',
|
||||
'not-matched'
|
||||
)
|
||||
@@ -585,7 +590,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
cellMetaAtRow.forEach((cellMeta) => {
|
||||
this.hotInstanceUserDataset.setCellMeta(
|
||||
row,
|
||||
cellMeta.col,
|
||||
cellMeta.col as number,
|
||||
'className',
|
||||
''
|
||||
)
|
||||
@@ -595,7 +600,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
cellMetaAtRow.forEach((cellMeta) => {
|
||||
this.hotInstanceUserDataset.setCellMeta(
|
||||
row,
|
||||
cellMeta.col,
|
||||
cellMeta.col as number,
|
||||
'className',
|
||||
''
|
||||
)
|
||||
@@ -991,7 +996,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
private getDatasetsFromHot(): string[] {
|
||||
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
|
||||
.filter((row) => row[0]?.length && row[1]?.length)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import Handsontable from 'handsontable'
|
||||
import Core from 'handsontable/core'
|
||||
import Handsontable, { HotInstance } from 'handsontable'
|
||||
|
||||
export class CustomAutocompleteEditor
|
||||
extends Handsontable.editors.AutocompleteEditor
|
||||
{
|
||||
constructor(instance: Core) {
|
||||
constructor(instance: HotInstance) {
|
||||
super(instance)
|
||||
}
|
||||
|
||||
@@ -13,8 +12,9 @@ export class CustomAutocompleteEditor
|
||||
}
|
||||
|
||||
// Listbox open
|
||||
open(event?: Event | undefined): void {
|
||||
super.open(event)
|
||||
// HOT 18's AutocompleteEditor.open() takes no arguments (was `event?: Event`).
|
||||
open(): void {
|
||||
super.open()
|
||||
|
||||
if (this.isCellNumeric()) {
|
||||
this.htContainer.classList.add('numericListbox')
|
||||
|
||||
@@ -52,12 +52,14 @@ export async function exportGrid(
|
||||
// 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
|
||||
// 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) {
|
||||
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 bottom = Math.max(sel.from.row, sel.to.row)
|
||||
const right = Math.max(sel.from.col, sel.to.col)
|
||||
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 bottom = Math.max(sel.from.row!, sel.to.row!)
|
||||
const right = Math.max(sel.from.col!, sel.to.col!)
|
||||
opts['range'] = [top, Math.max(left, skipLeadingCols), bottom, right]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { LicenceService } from '../services/licence.service'
|
||||
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({
|
||||
selector: 'app-viewer',
|
||||
@@ -74,7 +78,14 @@ export class ViewerComponent
|
||||
public table: any
|
||||
public tableuri: string | null = null
|
||||
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 abortActive: boolean = false
|
||||
public queryFilter: any
|
||||
@@ -190,7 +201,21 @@ export class ViewerComponent
|
||||
items: {
|
||||
copy_with_column_headers: {},
|
||||
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: {
|
||||
@@ -219,6 +244,7 @@ export class ViewerComponent
|
||||
itemValue: string
|
||||
) => {
|
||||
const elem = document.createElement('span')
|
||||
let colName = ''
|
||||
let colInfo: DataFormat | undefined
|
||||
let textInfo = 'No info found'
|
||||
|
||||
@@ -228,18 +254,19 @@ export class ViewerComponent
|
||||
!this.isTableSwitching
|
||||
) {
|
||||
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() || []
|
||||
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]
|
||||
} catch (error) {
|
||||
// Ignore errors during table switching
|
||||
colInfo = undefined
|
||||
}
|
||||
|
||||
if (colInfo)
|
||||
textInfo = `LABEL: ${colInfo?.label}<br>TYPE: ${colInfo?.type}<br>LENGTH: ${colInfo?.length}<br>FORMAT: ${colInfo?.format}`
|
||||
textInfo = buildColInfoHtml(colName, colInfo)
|
||||
}
|
||||
|
||||
elem.innerHTML = textInfo
|
||||
@@ -252,6 +279,7 @@ export class ViewerComponent
|
||||
}
|
||||
|
||||
private _query!: Subscription
|
||||
private _queryParams!: Subscription
|
||||
|
||||
private hotInstance: Handsontable | null = null
|
||||
public hotInstanceClickListener: boolean = false
|
||||
@@ -299,6 +327,31 @@ export class ViewerComponent
|
||||
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)
|
||||
}
|
||||
|
||||
this.hotTable.colHeaders = colArr
|
||||
this.colNames = colArr
|
||||
this.hotTable.colHeaders = getDisplayColHeaders(
|
||||
colArr,
|
||||
res.cols,
|
||||
this.useLabels
|
||||
)
|
||||
this.hotTable.columns = columns
|
||||
this.hiddenViewColumns = hiddenColumnIndexes
|
||||
} else {
|
||||
// Set empty arrays if no data
|
||||
this.colNames = []
|
||||
this.hotTable.colHeaders = []
|
||||
this.hotTable.columns = []
|
||||
this.hiddenViewColumns = []
|
||||
@@ -1396,6 +1455,8 @@ export class ViewerComponent
|
||||
ngOnDestroy() {
|
||||
// Proper component destruction to prevent memory leaks and errors
|
||||
|
||||
this._queryParams?.unsubscribe()
|
||||
|
||||
// Prevent any new operations during cleanup
|
||||
this.isTableSwitching = true
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ let webouts = {
|
||||
{
|
||||
NAME: "SOME_CHAR",
|
||||
VARNUM: 2,
|
||||
LABEL: "SOME_CHAR",
|
||||
LABEL: "Some Character Column",
|
||||
FMTNAME: "",
|
||||
DDTYPE: "CHARACTER",
|
||||
CLS_RULE: "READ",
|
||||
@@ -100,7 +100,7 @@ let webouts = {
|
||||
{
|
||||
NAME: "SOME_DATE",
|
||||
VARNUM: 5,
|
||||
LABEL: "SOME_DATE",
|
||||
LABEL: "Some Date",
|
||||
FMTNAME: "DATE",
|
||||
DDTYPE: "DATE",
|
||||
CLS_RULE: "READ",
|
||||
@@ -144,7 +144,7 @@ let webouts = {
|
||||
{
|
||||
NAME: "SOME_NUM",
|
||||
VARNUM: 4,
|
||||
LABEL: "SOME_NUM",
|
||||
LABEL: "",
|
||||
FMTNAME: "",
|
||||
DDTYPE: "NUMERIC",
|
||||
CLS_RULE: "READ",
|
||||
|
||||
@@ -52,7 +52,7 @@ let webouts = {
|
||||
"NAME": "SOME_CHAR",
|
||||
"LENGTH": 32767,
|
||||
"VARNUM": 2,
|
||||
"LABEL": "SOME_CHAR",
|
||||
"LABEL": "Some Character Column",
|
||||
"FMTNAME": "",
|
||||
"FORMAT": "$32767.",
|
||||
"TYPE": "C",
|
||||
@@ -62,7 +62,7 @@ let webouts = {
|
||||
"NAME": "SOME_DATE",
|
||||
"LENGTH": 8,
|
||||
"VARNUM": 5,
|
||||
"LABEL": "SOME_DATE",
|
||||
"LABEL": "Some Date",
|
||||
"FMTNAME": "DATE",
|
||||
"FORMAT": "DATE9.",
|
||||
"TYPE": "N",
|
||||
@@ -92,7 +92,7 @@ let webouts = {
|
||||
"NAME": "SOME_NUM",
|
||||
"LENGTH": 8,
|
||||
"VARNUM": 4,
|
||||
"LABEL": "SOME_NUM",
|
||||
"LABEL": "",
|
||||
"FMTNAME": "",
|
||||
"FORMAT": "8.",
|
||||
"TYPE": "N",
|
||||
|
||||
Reference in New Issue
Block a user