Compare commits

...
2 Commits
Author SHA1 Message Date
YuryShkoda 25c12f2b18 feat: viewer/editor column label display toggle (?labels=true)
Build / Build-and-ng-test (pull_request) Failing after 39s
Build / Build-and-test-development (pull_request) Has been skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 54s
Adds a URL-driven toggle (?labels=true, mirroring embed=va) that
swaps grid column headers between NAME and LABEL in both the Viewer
and Editor, with a "Show labels"/"Show names" item in each grid's
right-click context menu. The header-info popup now shows NAME first
regardless of display mode. Sorting/filtering/data binding stay
NAME-based throughout — only the displayed header text changes.

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

Also fixes a pre-existing gap in cypress/support/commands.ts: the
isLoggedIn/loginAndUpdateValidKey custom commands had no Cypress
Chainable type augmentation, and moment was imported as a namespace
import despite its `export =` typing making it uncallable that way.
2026-07-08 15:15:28 +03:00
YuryShkoda 005b616adb chore(deps): upgrade Handsontable to v18
Bump handsontable and @handsontable/angular-wrapper 17.1 -> 18.0 and
adapt to its stricter TypeScript-core types: moved handsontable/common
and deep walkontable imports to their new public export paths, added
explicit generics/casts where getData()/getCellMeta()/getSelected() are
now typed unknown/nullable instead of the loose v17 shapes, and updated
the AutocompleteEditor override to the new open() signature.

Also allowlists @handsontable/angular-wrapper@18.0.0 and
handsontable@18.0.0 in licenseChecker.js — both versions report the
same non-SPDX "SEE LICENSE IN LICENSE.txt" field that license-checker
mis-resolves as a disallowed custom license, same as prior versions.
2026-07-06 15:44:27 +03:00
21 changed files with 734 additions and 862 deletions
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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()
+1
View File
@@ -1,5 +1,6 @@
{
"cSpell.words": [
"Handsontable",
"Licence",
"SYSERRORTEXT",
"SYSWARNINGTEXT",
+231
View File
@@ -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')
}
+145 -91
View File
@@ -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
})
}
+1 -1
View File
@@ -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) {
+11 -722
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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",
+82 -13
View File
@@ -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'
}
+68 -7
View File
@@ -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
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",