Compare commits

...
Author SHA1 Message Date
semantic-release-bot 9176296816 chore(release): 7.11.0 [skip ci]
# [7.11.0](https://git.datacontroller.io/dc/dc/compare/v7.10.1...v7.11.0) (2026-07-20)

### Bug Fixes

* audit-skipping syntax correction ([ad7efc9](ad7efc90bc))
* **editor:** ignore cell validation on rows marked for delete ([70c5805](70c5805743))
* explicit use of .sashdat in dc_casload ([6a95067](6a95067618))
* refactor mf_wordsinstr1butnotstr2 and added a wide table to test suite ([be94c68](be94c68f35))
* retry the startup service request once before failing ([5391df8](5391df8f04))
* run dc_casload for dq tables in getdata.  Closes [#264](#264). ([6b16f9b](6b16f9b364))
* updating tests, moving mpe_rowhash to core library ([c5f0e4f](c5f0e4f106))

### Features

* bumping core to get the mp_rowhash definition, [#269](#269) ([f73f5e4](f73f5e47ac))
* new hash algorithm - allows 1000+ cols.  Closes [#269](#269) ([08947f0](08947f0fc8))
2026-07-20 20:20:55 +00:00
allan bac7b0a600 Merge pull request 'Additional support for VA data control' (#265) from issue264 into main
Release / Build-production-and-ng-test (push) Successful in 5m28s
Release / Build-and-test-development (push) Successful in 16m7s
Release / release (push) Successful in 9m15s
Reviewed-on: #265
2026-07-20 19:55:17 +00:00
allan 600937a471 Merge pull request 'fix: audit-skipping syntax correction' (#275) from fix/unbug_audit_skipping_for_audit_libds_eq_0 into issue264
Build / Build-and-ng-test (pull_request) Successful in 5m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m21s
Build / Build-and-test-development (pull_request) Successful in 15m1s
Reviewed-on: #275
2026-07-20 14:15:31 +00:00
allan f100a16c8b Merge branch 'issue264' into fix/unbug_audit_skipping_for_audit_libds_eq_0
Build / Build-and-ng-test (pull_request) Successful in 5m47s
Build / Build-and-test-development (pull_request) Successful in 15m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m42s
2026-07-20 14:15:22 +00:00
allan 9e54504474 Merge pull request 'feat: new hash algorithm - allows 1000+ cols. Closes #269' (#271) from issue269 into issue264
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m46s
Build / Build-and-test-development (pull_request) Successful in 16m1s
Reviewed-on: #271
2026-07-19 23:26:25 +00:00
allan 0285bc90fd Merge pull request 'fix: refactor mf_wordsinstr1butnotstr2 and added a wide table to test suite' (#272) from issue269b into issue269
Build / Build-and-ng-test (pull_request) Successful in 5m54s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m12s
Build / Build-and-test-development (pull_request) Successful in 16m22s
Reviewed-on: #272
2026-07-19 23:25:06 +00:00
dcbot be94c68f35 fix: refactor mf_wordsinstr1butnotstr2 and added a wide table to test suite
Build / Build-and-ng-test (pull_request) Successful in 5m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m40s
Build / Build-and-test-development (pull_request) Successful in 16m0s
2026-07-20 00:24:02 +01:00
allan f73f5e47ac feat: bumping core to get the mp_rowhash definition, #269
Build / Build-and-ng-test (pull_request) Successful in 5m16s
Build / Build-and-test-development (pull_request) Successful in 14m54s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m17s
2026-07-18 22:58:29 +01:00
dcbot c5f0e4f106 fix: updating tests, moving mpe_rowhash to core library
Build / Build-and-ng-test (pull_request) Successful in 5m24s
Build / Build-and-test-development (pull_request) Successful in 14m57s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m22s
2026-07-18 20:42:44 +01:00
dcbot 8e4f045eb9 chore(docs): explaining the sheet crypto part
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Build / Build-and-test-development (pull_request) Successful in 15m1s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m29s
2026-07-18 14:00:40 +01:00
dcbot 08947f0fc8 feat: new hash algorithm - allows 1000+ cols. Closes #269
Build / Build-and-ng-test (pull_request) Successful in 5m49s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m52s
Build / Build-and-test-development (pull_request) Successful in 15m34s
2026-07-17 22:53:53 +01:00
dcbot efcccd7d11 Merge pull request 'chore(ai): moving .claude to .agent' (#270) from foldermove into issue264
Build / Build-and-ng-test (pull_request) Successful in 5m36s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m8s
Build / Build-and-test-development (pull_request) Successful in 16m20s
Reviewed-on: #270
2026-07-17 21:36:21 +00:00
dcbot 078da3d947 chore(ai): moving .claude to .agent
Build / Build-and-ng-test (pull_request) Successful in 5m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m1s
Build / Build-and-test-development (pull_request) Successful in 16m6s
2026-07-17 22:30:05 +01:00
allan 61012f80e6 Merge pull request 'fix(editor): ignore cell validation on rows marked for delete' (#267) from issue-260 into issue264
Build / Build-and-ng-test (pull_request) Successful in 5m24s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m21s
Build / Build-and-test-development (pull_request) Successful in 15m31s
Reviewed-on: #267
Reviewed-by: sead <307+sead@noreply.git.datacontroller.io>
2026-07-17 21:26:26 +00:00
allan 65bcf08266 Merge branch 'issue264' into issue-260
Build / Build-and-ng-test (pull_request) Successful in 5m37s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m4s
Build / Build-and-test-development (pull_request) Successful in 15m45s
2026-07-17 21:25:48 +00:00
Trevor Moody ad7efc90bc fix: audit-skipping syntax correction
Build / Build-and-ng-test (pull_request) Successful in 5m22s
Build / Build-and-test-development (pull_request) Successful in 14m37s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m31s
2026-07-17 18:13:55 +01:00
YuryShkoda 70c5805743 fix(editor): ignore cell validation on rows marked for delete
Build / Build-and-ng-test (pull_request) Successful in 5m17s
Build / Build-and-test-development (pull_request) Successful in 14m56s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m18s
An invalid cell no longer blocks submission if its row is marked for
delete, since those values are about to be removed anyway. Primary key
columns are exempt from the exemption: the key identifies which record
to delete, and a blank/invalid PK would otherwise also slip past the
separate duplicate-key check. Toggling the delete flag now re-validates
the row immediately so invalid highlights update without waiting for
submit.
2026-07-17 12:02:21 +03:00
allan dfe80bb42a Merge pull request 'fix: retry the startup service request once before failing' (#266) from issue-261 into issue264
Build / Build-and-ng-test (pull_request) Successful in 5m36s
Build / Build-and-test-development (pull_request) Successful in 14m54s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m26s
Reviewed-on: #266
Reviewed-by: sead <307+sead@noreply.git.datacontroller.io>
2026-07-17 08:14:20 +00:00
Yury af27933e46 Merge branch 'issue264' into issue-261
Build / Build-and-ng-test (pull_request) Successful in 5m25s
Build / Build-and-test-development (pull_request) Successful in 14m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m30s
2026-07-17 07:43:47 +00:00
allan 6a95067618 fix: explicit use of .sashdat in dc_casload
Build / Build-and-ng-test (pull_request) Successful in 5m28s
Build / Build-and-test-development (pull_request) Successful in 14m39s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m44s
2026-07-16 15:41:11 +01:00
YuryShkoda 5391df8f04 fix: retry the startup service request once before failing
Build / Build-and-ng-test (pull_request) Successful in 5m20s
Build / Build-and-test-development (pull_request) Successful in 14m42s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m51s
2026-07-16 17:12:04 +03:00
allan 6b16f9b364 fix: run dc_casload for dq tables in getdata. Closes #264.
Build / Build-and-ng-test (pull_request) Successful in 5m25s
Build / Build-and-test-development (pull_request) Successful in 14m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m42s
2026-07-16 10:20:28 +01:00
semantic-release-bot 2dcae9060f chore(release): 7.10.1 [skip ci]
## [7.10.1](https://git.datacontroller.io/dc/dc/compare/v7.10.0...v7.10.1) (2026-07-15)

### Bug Fixes

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

### Bug Fixes

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

### Features

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

### Reverts

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

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

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

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

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

Also allowlists @handsontable/angular-wrapper@18.0.0 and
handsontable@18.0.0 in licenseChecker.js — both versions report the
same non-SPDX "SEE LICENSE IN LICENSE.txt" field that license-checker
mis-resolves as a disallowed custom license, same as prior versions.
2026-07-06 15:44:27 +03:00
allan 0ae1781d28 Merge branch 'main' into issue240
Build / Build-and-ng-test (pull_request) Successful in 4m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m17s
Build / Build-and-test-development (pull_request) Successful in 9m23s
2026-07-05 10:51:11 +00:00
allan fb94840016 fix: backend updates to bring back labels under #240
Build / Build-and-ng-test (pull_request) Successful in 4m11s
Build / Build-and-test-development (pull_request) Successful in 9m31s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m31s
2026-07-05 11:49:36 +01:00
semantic-release-bot 6864c044dc chore(release): 7.9.1 [skip ci]
## [7.9.1](https://git.datacontroller.io/dc/dc/compare/v7.9.0...v7.9.1) (2026-06-30)

### Bug Fixes

* workflow file upload using action PAT ([a161670](a161670b86))
2026-06-30 07:29:21 +00:00
allan b30ba3e76b Merge pull request 'fix: workflow file upload using action PAT' (#250) from workflowfix into main
Release / Build-production-and-ng-test (push) Successful in 3m38s
Release / Build-and-test-development (push) Successful in 9m14s
Release / release (push) Successful in 7m4s
Reviewed-on: #250
Reviewed-by: sead <307+sead@noreply.git.datacontroller.io>
2026-06-30 07:12:54 +00:00
allan a4583f2db0 Merge branch 'main' into workflowfix
Build / Build-and-test-development (pull_request) Has been cancelled
Build / Build-and-ng-test (pull_request) Has been cancelled
Lighthouse Checks / lighthouse (pull_request) Has been cancelled
2026-06-30 07:12:40 +00:00
allan a161670b86 fix: workflow file upload using action PAT
Build / Build-and-test-development (pull_request) Has been cancelled
Build / Build-and-ng-test (pull_request) Has been cancelled
Lighthouse Checks / lighthouse (pull_request) Has been cancelled
2026-06-30 08:09:39 +01:00
semantic-release-bot e6cf5a8d25 chore(release): 7.9.0 [skip ci]
# [7.9.0](https://git.datacontroller.io/dc/dc/compare/v7.8.2...v7.9.0) (2026-06-29)

### Bug Fixes

* allow large retained key values.  Closes [#248](#248). ([6a30de3](6a30de37ca))
* build issues ([#245](#245)) ([77b4bb3](77b4bb310e))
* **ci:** bump Lighthouse to 13.4.0 ([47ba03e](47ba03ec63))
* **dc-validator:** display date/time/datetime cells as raw ISO, locale-independent ([9ca5719](9ca57193b4))
* **editor:** await dynamic validation on paste; defer spinner ([ea03bde](ea03bdecc5))
* **editor:** cancelEdit will reset cell's valid state ([51071b4](51071b463b))
* **editor:** preserve commas in dropdown source values ([6af49cf](6af49cf1fd))
* **editor:** retry initSetup when the Handsontable instance isn't ready ([53b7ee0](53b7ee0cb2))
* **handsontable:** add exceljs ([38cb1e2](38cb1e207b))
* **handsontable:** horizontal scrollbar in dropdown ([ac0bd10](ac0bd10212))
* **handsontable:** restore dark mode for v17 ([9d97bf7](9d97bf7ea1))
* **handsontable:** update licenseChecker exclude buffers - exceljs dep ([12c7d30](12c7d30894))
* incrementing rk val ([7444209](74442096ba))
* **licensing:** add protocol info ([3668a74](3668a7426f)), closes [#178](#178)
* migrate handsontables to v17 ([1b73e35](1b73e355b7))
* **mocks:** return dynamic cell values as OBJ to match new contract ([a15fdc4](a15fdc401e))
* removing display_value in SAS code ([9b18a45](9b18a45d35))
* rule_type ([adc6424](adc6424211))
* sending dynamic_values and dynamic_extended_values back as OBJ instead of ARR ([e859d33](e859d3354e))
* upcasing RAW_VALUE, bumping core, fixing tests ([982d507](982d507ae3))
* validate pasted values ([b661580](b661580c60))
* viewLibs now fires only once, libPromise shared between the calls ([dc4e07a](dc4e07a692))

### Features

* **edit-record:** use native date/time/datetime pickers ([2d29346](2d29346cbf))
* **editor:** add READONLY, HIDDEN, ROUND and NUMBER_FORMAT validation rules ([4ea604f](4ea604f9fb))
* **editor:** migrate date/time/datetime to HOT 17 Intl cell types ([f70ea2f](f70ea2fe71))
* **editor:** paste-validation overlay with cancel and confirm ([609731f](609731ff99))
* **editor:** SAS VA data-driven content embed mode (?embed=va) ([ffa3ff9](ffa3ff9c10))
* **editor:** VA embed filter modes with pending UX ([ebe6972](ebe6972dd7))
* **editor:** validate autofilled cells; fix paste validation lag ([11ee49a](11ee49a57a))
* **handsontable:** enable export plugin, add export menu items ([29aaa72](29aaa72c60))
* migration script for adding new validation rules under [#241](#241) ([9f84a58](9f84a580fb))
* new validation rules in mpe_selectbox.  [#241](#241) ([2f5d3df](2f5d3dfc38))

### Reverts

* eager-load feature modules instead of lazy-loading ([3429a7a](3429a7a2a6))
* **editor:** DISPLAY_VALUE change ([eb015d7](eb015d712b))
2026-06-29 18:11:12 +00:00
allan 7ebc26fc7f chore: pipeline updates for release
Release / Build-production-and-ng-test (push) Successful in 3m34s
Release / Build-and-test-development (push) Successful in 9m13s
Release / release (push) Successful in 7m3s
2026-06-29 18:55:19 +01:00
allan c9dd933cec chore: aligning release.yml with build.yml
Release / Build-production-and-ng-test (push) Successful in 3m38s
Release / Build-and-test-development (push) Successful in 9m18s
Release / release (push) Failing after 3m1s
2026-06-29 18:17:18 +01:00
allan 716eba621f Merge pull request 'Resolve multiple issues' (#236) from 127-235-issues into main
Release / Build-production-and-ng-test (push) Failing after 1m19s
Release / Build-and-test-development (push) Has been skipped
Release / release (push) Has been skipped
Reviewed-on: #236
2026-06-29 17:03:54 +00:00
allan 982d507ae3 fix: upcasing RAW_VALUE, bumping core, fixing tests
Build / Build-and-ng-test (pull_request) Successful in 3m52s
Build / Build-and-test-development (pull_request) Successful in 9m18s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m41s
2026-06-29 17:42:05 +01:00
sead 27a12951e3 build(lint): add .prettierignore to client
Build / Build-and-ng-test (pull_request) Successful in 3m54s
Build / Build-and-test-development (pull_request) Successful in 9m22s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m7s
2026-06-29 16:01:35 +02:00
sead 4c8e4ee1b2 build(lint): pin prettier to 3.8.4 in client to stop CI version drift
Build / Build-and-ng-test (pull_request) Failing after 1m35s
Build / Build-and-test-development (pull_request) Has been skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 17m35s
2026-06-29 15:08:24 +02:00
sead a15fdc401e fix(mocks): return dynamic cell values as OBJ to match new contract
Build / Build-and-ng-test (pull_request) Failing after 1m36s
Build / Build-and-test-development (pull_request) Has been skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 17m44s
2026-06-29 14:53:42 +02:00
sead 7019e39318 test(excel): update excel.cy.ts tests to reflect YYYY-MM-DD format
Build / Build-and-ng-test (pull_request) Failing after 1m37s
Build / Build-and-test-development (pull_request) Has been skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 17m36s
Ref 9ca5719
2026-06-29 14:40:17 +02:00
sead 9ca57193b4 fix(dc-validator): display date/time/datetime cells as raw ISO, locale-independent
Build / Build-and-ng-test (pull_request) Successful in 3m57s
Build / Build-and-test-development (pull_request) Failing after 9m21s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m42s
2026-06-26 10:03:45 +02:00
sead 175eb9853a refactor: consume dynamic_values/dynamic_extended_values as typed objects 2026-06-26 09:37:05 +02:00
allan e859d3354e fix: sending dynamic_values and dynamic_extended_values back as OBJ instead of ARR
Build / Build-and-ng-test (pull_request) Successful in 3m54s
Build / Build-and-test-development (pull_request) Successful in 9m38s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m44s
2026-06-25 19:17:22 +01:00
allan adc6424211 fix: rule_type
Build / Build-and-ng-test (pull_request) Successful in 4m1s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m26s
Build / Build-and-test-development (pull_request) Successful in 9m48s
2026-06-25 17:48:52 +01:00
allan 74442096ba fix: incrementing rk val
Build / Build-and-ng-test (pull_request) Successful in 4m6s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m52s
Build / Build-and-test-development (pull_request) Successful in 10m11s
2026-06-25 17:47:22 +01:00
allan 9f84a580fb feat: migration script for adding new validation rules under #241
Build / Build-and-ng-test (pull_request) Successful in 4m5s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m31s
Build / Build-and-test-development (pull_request) Successful in 10m2s
2026-06-25 17:46:16 +01:00
allan 2f5d3dfc38 feat: new validation rules in mpe_selectbox. #241 2026-06-25 17:40:59 +01:00
allan 9b18a45d35 fix: removing display_value in SAS code
Build / Build-and-ng-test (pull_request) Successful in 3m54s
Build / Build-and-test-development (pull_request) Successful in 9m39s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m49s
2026-06-25 17:24:35 +01:00
allan 6a30de37ca fix: allow large retained key values. Closes #248.
Build / Build-and-ng-test (pull_request) Successful in 3m56s
Build / Build-and-test-development (pull_request) Successful in 9m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m52s
The fix was actually in the core macro (mp_retainedkey)
2026-06-25 17:00:07 +01:00
sead 31075a165f chore(git): merge pr 'SAS VA data-driven content embed mode (?embed=va)' (#249) from 243-embed-va into 127-235-issues
Build / Build-and-ng-test (pull_request) Successful in 3m55s
Build / Build-and-test-development (pull_request) Successful in 9m42s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m41s
Reviewed-on: #249
2026-06-25 13:51:10 +00:00
sead 53b7ee0cb2 fix(editor): retry initSetup when the Handsontable instance isn't ready
Build / Build-and-ng-test (pull_request) Successful in 3m57s
Build / Build-and-test-development (pull_request) Successful in 9m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m58s
2026-06-25 15:26:33 +02:00
sead ebe6972dd7 feat(editor): VA embed filter modes with pending UX
Build / Build-and-ng-test (pull_request) Successful in 4m19s
Build / Build-and-test-development (pull_request) Successful in 9m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 18m8s
2026-06-25 14:53:24 +02:00
sead 2acea64589 refactor(editor): extract VA filter building into VaFilterService 2026-06-25 14:53:24 +02:00
sead ffa3ff9c10 feat(editor): SAS VA data-driven content embed mode (?embed=va) 2026-06-25 14:53:24 +02:00
sead 0ac3ff4511 docs(skills): add Handsontable and HyperFormula skills 2026-06-25 14:53:24 +02:00
sead 3429a7a2a6 revert: eager-load feature modules instead of lazy-loading
This reverts commit d4ae9e9084
2026-06-25 14:53:24 +02:00
sead 56fb2c2a3a chore(git): merge pr 'Fix HOT v17 migration issues, new validations, deprecate display_value' (#244) from 237-241-242-issues into 127-235-issues
Build / Build-and-ng-test (pull_request) Successful in 3m41s
Build / Build-and-test-development (pull_request) Successful in 9m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 17m48s
Reviewed-on: #244
2026-06-25 12:51:23 +00:00
117 changed files with 15296 additions and 2666 deletions
+606
View File
@@ -0,0 +1,606 @@
---
name: handsontable
description: >
Use this skill whenever the user asks about Handsontable — a JavaScript data grid component with
spreadsheet-like UX. Triggers include: mentions of "handsontable", "HotTable", "data grid",
"@handsontable/react-wrapper", spreadsheet component in React/Angular/Vue, HyperFormula formulas
inside a grid, cell types (dropdown, checkbox, date, numeric) in a grid context, or questions
about Handsontable theming, plugins, hooks, configuration options, or column/row features. Also
trigger when the user wants to add an editable spreadsheet-like table to a web app, or asks about
copy/paste, sorting, filtering, frozen rows/columns, merged cells, or context menus in a grid.
This skill covers Handsontable used with any framework (React, Angular, Vue 3, vanilla JS) and
HyperFormula as the Formulas plugin engine inside Handsontable. Do NOT use this skill for
headless/standalone HyperFormula usage without Handsontable — that is covered by the separate
"hyperformula" skill.
---
# Handsontable
Handsontable is a JavaScript **data grid component** (not a full spreadsheet application) that
brings spreadsheet-like UX to web apps: cell editing, copy/paste, sorting, filtering, formulas,
keyboard navigation, context menus, merged cells, frozen rows/columns, conditional formatting, data
validation, pagination, and 400+ built-in formulas via HyperFormula.
- **Latest version:** 17.1.0 (May 2026)
- **Frameworks:** Vanilla JS/TS, React (`@handsontable/react-wrapper`), Angular (`@handsontable/angular-wrapper`), Vue 3 (`@handsontable/vue3`)
- **React wrapper requires:** React 18+
- **License:** Dual — free for non-commercial use (`licenseKey: 'non-commercial-and-evaluation'`), paid for commercial. Per-developer annual license, offline validation (no server connection). Tiers: Hobby (free, non-commercial), Trial (free 45 days), Standard (from $999/yr), Priority (from $1,299/yr), Enterprise (custom). See [Pricing](https://handsontable.com/pricing).
Always check `references/docs-map.md` (in this skill folder) for the full organized link directory
when you need to point the user to specific documentation or need to look up more info.
### What this skill does NOT cover
- **Standalone HyperFormula** (headless formula engine without a grid UI) — use the "hyperformula" skill instead.
- **Other data grid libraries** (AG Grid, TanStack Table, etc.).
- **Full spreadsheet applications** — Handsontable is a component you embed, not a standalone app like Google Sheets.
---
## React Quick Start (Recommended)
### Install
```bash
npm install handsontable @handsontable/react-wrapper
```
### Minimal working example
```jsx
import { HotTable } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import 'handsontable/styles/handsontable.min.css';
import 'handsontable/styles/ht-theme-main.min.css';
registerAllModules();
const MyGrid = () => (
<HotTable
themeName="ht-theme-main"
data={[
['', 'Tesla', 'Volvo', 'Toyota', 'Ford'],
['2019', 10, 11, 12, 13],
['2020', 20, 11, 14, 13],
['2021', 30, 15, 12, 13],
]}
colHeaders={true}
rowHeaders={true}
height="auto"
autoWrapRow={true}
autoWrapCol={true}
licenseKey="non-commercial-and-evaluation"
/>
);
export default MyGrid;
```
Key points:
- `registerAllModules()` registers every built-in plugin. To reduce bundle size, import only what
you need — see [Modules guide](https://handsontable.com/docs/react-data-grid/modules/).
- `height` is required (or the grid won't render). Use `"auto"`, a pixel number, or a CSS string.
- All Handsontable configuration options are passed as props on `<HotTable>`.
- Full installation guide: https://handsontable.com/docs/react-data-grid/installation/
---
## Vanilla JS Quick Start
### Install
```bash
npm install handsontable
```
### CDN (jsDelivr)
```html
<script src="https://cdn.jsdelivr.net/npm/handsontable/dist/handsontable.full.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable/styles/handsontable.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-main.min.css" />
```
To pin a specific version, add `@17.1` after `handsontable` in the URL (e.g.,
`handsontable@17.1/dist/handsontable.full.min.js`).
### Minimal working example
```js
import Handsontable from 'handsontable';
import 'handsontable/styles/handsontable.min.css';
import 'handsontable/styles/ht-theme-main.min.css';
const container = document.getElementById('grid');
new Handsontable(container, {
data: [
['', 'Tesla', 'Volvo', 'Toyota', 'Ford'],
['2019', 10, 11, 12, 13],
['2020', 20, 11, 14, 13],
['2021', 30, 15, 12, 13],
],
colHeaders: true,
rowHeaders: true,
height: 'auto',
themeName: 'ht-theme-main',
autoWrapRow: true,
autoWrapCol: true,
licenseKey: 'non-commercial-and-evaluation',
});
```
Full JS docs: https://handsontable.com/docs/javascript-data-grid/installation/
---
## Other Frameworks
### Angular (v1719)
```bash
npm install handsontable @handsontable/angular-wrapper
```
The Angular wrapper was modernized in Handsontable v17.1 to align with Angular 1719, simplifying setup and reducing dependencies. Earlier Angular versions are no longer the target — upgrade Angular first if you're below v17.
Docs: https://handsontable.com/docs/angular-data-grid/installation/
### Vue 3
```bash
npm install handsontable @handsontable/vue3
```
Docs: https://handsontable.com/docs/react-data-grid/vue3-installation/
All framework wrappers share the same version number as the core library and expose the same
configuration options. The React examples in this skill translate directly — just use the
framework's component syntax. Note: Vue 2 (`@handsontable/vue`) is deprecated — use Vue 3.
---
## Theming: Light / Dark / Auto
Handsontable v15+ includes a built-in theme system. Three themes ship out of the box: **main**
(default, spreadsheet-like), **horizon** (clean, analytics-focused), **classic** (legacy
replacement).
### CSS file approach (simplest)
Import the base stylesheet plus a theme:
```js
import 'handsontable/styles/handsontable.min.css';
import 'handsontable/styles/ht-theme-main.min.css';
```
Then set the theme on the grid:
```jsx
<HotTable themeName="ht-theme-main" /* ... */ />
```
**Dark mode:**
- System preference (auto): `themeName="ht-theme-main-dark-auto"`
- Forced dark: `themeName="ht-theme-main-dark"`
Replace `main` with `horizon` or `classic` for other themes.
### Theme API approach (runtime switching)
```js
import { mainTheme, registerTheme } from 'handsontable/themes';
const theme = registerTheme(mainTheme)
.setColorScheme('auto') // 'light' | 'dark' | 'auto'
.setDensityType('comfortable'); // 'compact' | 'default' | 'comfortable'
```
Then pass it as a prop:
```jsx
<HotTable theme={theme} /* ... */ />
```
### CDN theme files
```html
<!-- Theme CSS (includes light and dark mode support) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-main.min.css" />
```
In v17, dark mode is controlled via `themeName` or the Theme API — there are no separate `-dark` or
`-dark-auto` CSS files. Load the base theme CSS above and set the mode at runtime.
For CSS variable customization, the Theme Builder, Figma design system, and 200+ design tokens, see:
- [Themes guide](https://handsontable.com/docs/react-data-grid/themes/)
- [Theme customization](https://handsontable.com/docs/react-data-grid/theme-customization/)
- [Theme Builder tool](https://handsontable.com/theme-builder)
---
## Common Configuration Patterns
All options below are passed as props on `<HotTable>` (React) or in the config object (vanilla JS).
For the full options reference: https://handsontable.com/docs/react-data-grid/api/options/
### Data binding
```jsx
// Array of arrays
data={[['A1', 'B1'], ['A2', 'B2']]}
// Array of objects
data={[{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]}
columns={[{ data: 'id' }, { data: 'name' }]}
```
Docs: https://handsontable.com/docs/react-data-grid/binding-to-data/
### Column & row headers
```jsx
colHeaders={['ID', 'Name', 'Price']} // custom labels, or {true} for A, B, C...
rowHeaders={true} // 1, 2, 3... or pass an array
```
### Column types (cell types)
Set via the `columns` array or `cells` function. Built-in types:
- `text` (default), `numeric`, `date`, `time`, `checkbox`, `select`, `dropdown`,
`autocomplete`, `password`, `handsontable` (nested grid), `multiselect` (v17+)
```jsx
columns={[
{ data: 'name', type: 'text' },
{ data: 'price', type: 'numeric', numericFormat: { pattern: '$0,0.00' } },
{ data: 'active', type: 'checkbox' },
{ data: 'category', type: 'dropdown', source: ['A', 'B', 'C'] },
]}
```
Full cell types reference: https://handsontable.com/docs/react-data-grid/cell-type/
### Sorting & filtering
```jsx
columnSorting={true} // single-column sort
// or
multiColumnSorting={true} // multi-column sort
filters={true} // enable column filters
dropdownMenu={true} // column header menu with filter UI
```
### Frozen rows/columns
```jsx
fixedRowsTop={1}
fixedRowsBottom={1}
fixedColumnsStart={2}
```
### Other common options
```jsx
readOnly={true} // entire grid read-only (or per-cell/column)
contextMenu={true} // right-click menu
mergeCells={[{ row: 0, col: 0, rowspan: 2, colspan: 2 }]}
manualColumnResize={true}
manualRowResize={true}
stretchH="all" // stretch columns to fill width: 'none' | 'last' | 'all'
```
### Per-column config with HotColumn (React)
```jsx
import { HotTable, HotColumn } from '@handsontable/react-wrapper';
<HotTable data={data} licenseKey="non-commercial-and-evaluation">
<HotColumn title="Name" data="name" />
<HotColumn title="Active" data="active" type="checkbox" />
</HotTable>
```
Docs: https://handsontable.com/docs/react-data-grid/hot-column/
---
## HyperFormula Integration (Formulas Plugin)
HyperFormula powers Handsontable's Formulas plugin, providing 400+ spreadsheet functions (SUM,
AVERAGE, IF, VLOOKUP, etc.). This section covers using HyperFormula **inside** Handsontable only.
### Install
```bash
npm install hyperformula
```
> **Note:** Starting in Handsontable v18, HyperFormula will no longer be bundled. Install it
> separately and pass it to the Formulas plugin.
### CDN
```html
<script src="https://cdn.jsdelivr.net/npm/hyperformula/dist/hyperformula.full.min.js"></script>
```
### Simple setup (auto-created instance)
Pass the `HyperFormula` class directly — Handsontable creates an instance automatically:
```jsx
import { HyperFormula } from 'hyperformula';
<HotTable
data={[
['10', '20', '=SUM(A1:B1)'],
['30', '40', '=SUM(A2:B2)'],
]}
formulas={{ engine: HyperFormula }}
colHeaders={true}
licenseKey="non-commercial-and-evaluation"
/>
```
### External instance (for multi-sheet or shared engine)
Create a HyperFormula instance with the `'internal-use-in-handsontable'` license key:
```jsx
import { HyperFormula } from 'hyperformula';
const hfInstance = HyperFormula.buildEmpty({
licenseKey: 'internal-use-in-handsontable',
});
// Sheet 1
<HotTable
data={data1}
formulas={{ engine: hfInstance, sheetName: 'Sheet1' }}
licenseKey="non-commercial-and-evaluation"
/>
// Sheet 2 — shares the same engine, enabling cross-sheet references
<HotTable
data={data2}
formulas={{ engine: hfInstance, sheetName: 'Sheet2' }}
licenseKey="non-commercial-and-evaluation"
/>
```
Cross-sheet formula example: `=SUM(Sheet1!A:A)`
### Named expressions
```jsx
formulas={{
engine: HyperFormula,
namedExpressions: [
{ name: 'TAX_RATE', expression: 0.21 },
],
}}
```
Use in cells: `=A1 * TAX_RATE`
### Listening for formula changes
```jsx
<HotTable
formulas={{ engine: HyperFormula }}
afterFormulasValuesUpdate={(changes) => {
changes.forEach((c) => console.log(c.address, c.newValue));
}}
/>
```
### Known limitations
- Formulas don't work with nested object data.
- Moving rows/columns with formulas requires the ManualRowMove / ManualColumnMove plugins (not IndexMapper).
- `getSourceData()` operates on physical indexes; formulas use visual indexes.
Full guide: https://handsontable.com/docs/react-data-grid/formula-calculation/
HyperFormula functions list: https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
HyperFormula version compatibility table: https://handsontable.com/docs/react-data-grid/formula-calculation/#hyperformula-version-support
---
## Server-side data with DataProvider (v17.1+)
The **DataProvider plugin** (new in v17.1) wires the grid up to a remote data source so rows are fetched, sorted, and mutated server-side instead of held in memory. Use it for datasets too large to load up front, or when the source of truth lives in a backend.
The `dataProvider` option is an object with five required keys: a row-id resolver, a paginated `fetchRows` callback, and three mutation callbacks. For a read-only grid, stub the mutation callbacks with `async () => {}` — they must still be present.
```jsx
<HotTable
colHeaders={['ID', 'Name', 'Email']}
pagination={{ pageSize: 25 }}
columnSorting={true}
emptyDataState={true}
height={360}
licenseKey="non-commercial-and-evaluation"
dataProvider={{
// Required: how to extract a stable id from each row
rowId: 'id',
// Required: paginated fetch. `sort` is null when no column is sorted.
// Second arg carries an AbortSignal — pass it to fetch() so the plugin
// can cancel superseded requests when the user pages/sorts quickly.
async fetchRows({ page, pageSize, sort }, { signal }) {
const params = new URLSearchParams({ page, pageSize });
if (sort) {
// sort = { prop: string, order: 'asc' | 'desc' }
params.set('sortBy', sort.prop);
params.set('order', sort.order);
}
const res = await fetch(`/api/users?${params}`, { signal });
const { rows, totalRows } = await res.json();
return { rows, totalRows };
},
// Required. Stub with async () => {} if your grid is read-only.
// The plugin auto-refetches the current page after each callback resolves.
// See the plugin API ref for the exact payload shapes.
onRowsCreate: async () => {},
onRowsUpdate: async () => {},
onRowsRemove: async () => {},
}}
/>
```
### Loading and error UI
The plugin fires three hooks you can wire up as `<HotTable>` props for loading indicators and error surfaces:
- `beforeDataProviderFetch(params)` — fires before each fetch. `params.skipLoading` is set when the plugin wants to suppress your loading indicator (e.g., during a quick refetch).
- `afterDataProviderFetch(result)` — fires after a successful fetch.
- `afterDataProviderFetchError(error)` — fires when `fetchRows` throws or returns a rejected promise.
### Companion options
The plugin is built to pair with `pagination` (paginates server-side), `columnSorting` (single-column server-side sort), and `emptyDataState` (loading + empty state UI). Enable all three when you use DataProvider.
### Authoritative references
- Guide: https://handsontable.com/docs/react-data-grid/server-side-data-fetching/
- Recipe (REST API): https://handsontable.com/docs/react-data-grid/recipes/data-management/load-data-rest-api/
- Plugin API: https://handsontable.com/docs/react-data-grid/api/data-provider/
## Notifications (v17.1+)
The **Notification plugin** (new in v17.1) shows non-blocking toast notifications anchored to the grid — useful for confirming saves, surfacing validation errors, or signaling background sync state. Enable with `notifications: true` and trigger via `hot.getPlugin('notifications').showMessage(...)`. See the plugin guide for placement, severity levels, and auto-dismiss timing: https://handsontable.com/docs/react-data-grid/notification/
---
## Events & Hooks
Handsontable hooks are passed as props on `<HotTable>`:
```jsx
<HotTable
afterChange={(changes, source) => {
if (source !== 'loadData') {
console.log('Cell changed:', changes);
}
}}
beforeChange={(changes, source) => {
// Return false to cancel the edit
}}
afterSelection={(row, col, row2, col2) => {
console.log('Selected:', row, col, 'to', row2, col2);
}}
/>
```
There are 100+ hooks available. Full reference: https://handsontable.com/docs/react-data-grid/api/hooks/
Guide: https://handsontable.com/docs/react-data-grid/events-and-hooks/
---
## Accessing the Instance (Ref)
Use a ref to call Handsontable's core methods:
```jsx
import { useRef } from 'react';
const MyGrid = () => {
const hotRef = useRef(null);
const handleClick = () => {
const hot = hotRef.current?.hotInstance;
if (hot) {
console.log(hot.getData()); // get all data
hot.selectCell(0, 0); // select a cell
}
};
return (
<>
<HotTable ref={hotRef} /* ...options */ />
<button onClick={handleClick}>Get Data</button>
</>
);
};
```
Core API reference: https://handsontable.com/docs/react-data-grid/api/core/
---
## Performance & Large Datasets
Handsontable virtualizes rendering automatically — only visible rows and columns are in the DOM, so
grids with 10k100k+ rows perform well out of the box. For bulk programmatic updates, wrap mutations
in `batch()` to defer re-rendering until all changes are applied:
```jsx
hotRef.current.hotInstance.batch(() => {
// set many cells, add rows, etc.
});
```
For further tuning (disabling auto-size, reducing plugin overhead), see:
- [Performance guide](https://handsontable.com/docs/react-data-grid/performance/)
- [Batch operations](https://handsontable.com/docs/react-data-grid/batch-operations/)
- [Bundle size optimization](https://handsontable.com/docs/react-data-grid/bundle-size/)
---
## Recipes (v17+)
Handsontable v17 introduced a Recipes section in the docs — ready-made patterns for common use cases
(data validation workflows, dynamic column generation, etc.). Check the recipes index before building
something from scratch: https://handsontable.com/docs/react-data-grid/recipes/
---
## Common Pitfalls
- **Forgetting `height`**: The grid won't render without a `height` prop. Use `"auto"`, a pixel value, or a CSS string.
- **Not filtering `loadData` in `afterChange`**: The `afterChange` hook fires on initial data load with `source === 'loadData'`. Always check the source to avoid infinite loops when syncing changes back to state.
- **Using the old wrapper packages**: v17 removed `@handsontable/react` and `@handsontable/angular`. Use `@handsontable/react-wrapper` and `@handsontable/angular-wrapper`.
- **Using legacy CSS imports**: `handsontable.full.min.css` was removed in v17. Use `handsontable/styles/handsontable.min.css` plus a theme file.
- **Formulas with nested object data**: HyperFormula formulas don't work when `data` is an array of nested objects — use flat objects or arrays of arrays.
- **ExportFile `columnHeaders` renamed**: In v17.1 the ExportFile plugin's `columnHeaders` option was renamed to `colHeaders` to match the table-level option. Update any `exportAsString` / `exportAsBlob` / `downloadFile` calls that pass `columnHeaders: ...`.
---
## Version Awareness
Handsontable docs are versioned. The latest docs live at `/docs/react-data-grid/` (which redirects
to the current version). To link to a specific version, use `/docs/17.0/react-data-grid/`.
When helping a user, check which version they are on — breaking changes between major versions are
common. Point them to the relevant migration guide if they're upgrading.
For the full organized directory of documentation links, read `references/docs-map.md` in this
skill's folder.
### v17.1 changes (latest, May 2026)
- **New plugins:** DataProvider (server-side row loading via `dataProvider` option), Notification (toast notifications).
- **NestedHeaders rowspan:** column headers can now span multiple header rows.
- **ExportFile:** XLSX export added; the `columnHeaders` option was renamed to `colHeaders` (see Common Pitfalls).
- **Angular wrapper:** modernized for Angular 1719; simpler setup, fewer deps.
- **Touch:** long-press now opens the context menu on touch devices.
- **TypeScript:** `dateFormat` option now accepts `Intl.DateTimeFormatOptions`.
- No removals or deprecations in v17.1.
### v17.0 Breaking Changes
- Removed legacy wrapper packages (`@handsontable/react`, `@handsontable/angular`). Use
`@handsontable/react-wrapper` and `@handsontable/angular-wrapper`.
- Removed legacy CSS (`handsontable.full.min.css`). Use `handsontable/styles/` imports.
- Removed core-js from dependencies.
- Removed the PersistentState plugin.
- Deprecated bundled HyperFormula (will require separate install in v18).
- Deprecated numbro.js, Pikaday, moment.js, DOMPurify — use native alternatives.
Migration guide: https://handsontable.com/docs/react-data-grid/migration-from-16.2-to-17.0/
@@ -0,0 +1,231 @@
# Handsontable Documentation Map
> Last verified: May 2026 · Aligned with Handsontable 17.1.0
Organized link directory for all Handsontable and HyperFormula (in-grid) documentation. Use these
to point users to the right page. Links default to the React docs; replace `react-data-grid` with
`javascript-data-grid`, `angular-data-grid`, or prefix with `vue3-` for other frameworks.
## Getting Started
- Installation: https://handsontable.com/docs/react-data-grid/installation/
- Demo: https://handsontable.com/docs/react-data-grid/demo/
- Binding to data: https://handsontable.com/docs/react-data-grid/binding-to-data/
- Server-side data fetching (DataProvider, v17.1+): https://handsontable.com/docs/react-data-grid/server-side-data-fetching/
- Saving data: https://handsontable.com/docs/react-data-grid/saving-data/
- Configuration options guide: https://handsontable.com/docs/react-data-grid/configuration-options/
- Grid size: https://handsontable.com/docs/react-data-grid/grid-size/
- Instance methods: https://handsontable.com/docs/react-data-grid/instance-methods/
- Events and hooks: https://handsontable.com/docs/react-data-grid/events-and-hooks/
- License key: https://handsontable.com/docs/react-data-grid/license-key/
- Redux integration: https://handsontable.com/docs/react-data-grid/redux/
## Styling & Themes
- Themes guide: https://handsontable.com/docs/react-data-grid/themes/
- Design system (Figma): https://handsontable.com/docs/react-data-grid/handsontable-design-system/
- Theme customization (CSS vars, tokens): https://handsontable.com/docs/react-data-grid/theme-customization/
- Legacy style (pre-v15): https://handsontable.com/docs/react-data-grid/legacy-style/
- Theme Builder tool: https://handsontable.com/theme-builder
- Figma Theme Generator: https://github.com/handsontable/handsontable-figma
## Columns
- HotColumn component (React): https://handsontable.com/docs/react-data-grid/hot-column/
- Column headers: https://handsontable.com/docs/react-data-grid/column-header/
- Column groups (nested headers): https://handsontable.com/docs/react-data-grid/column-groups/
- Column hiding: https://handsontable.com/docs/react-data-grid/column-hiding/
- Column moving: https://handsontable.com/docs/react-data-grid/column-moving/
- Column freezing: https://handsontable.com/docs/react-data-grid/column-freezing/
- Column widths: https://handsontable.com/docs/react-data-grid/column-width/
- Column summary: https://handsontable.com/docs/react-data-grid/column-summary/
- Column virtualization: https://handsontable.com/docs/react-data-grid/column-virtualization/
- Column menu (dropdown): https://handsontable.com/docs/react-data-grid/column-menu/
- Column filter: https://handsontable.com/docs/react-data-grid/column-filter/
## Rows
- Row headers: https://handsontable.com/docs/react-data-grid/row-header/
- Row parent-child (nested rows): https://handsontable.com/docs/react-data-grid/row-parent-child/
- Row hiding: https://handsontable.com/docs/react-data-grid/row-hiding/
- Row moving: https://handsontable.com/docs/react-data-grid/row-moving/
- Row freezing: https://handsontable.com/docs/react-data-grid/row-freezing/
- Row heights: https://handsontable.com/docs/react-data-grid/row-height/
- Row virtualization: https://handsontable.com/docs/react-data-grid/row-virtualization/
- Rows sorting: https://handsontable.com/docs/react-data-grid/rows-sorting/
- Rows pagination: https://handsontable.com/docs/react-data-grid/rows-pagination/
- Row trimming: https://handsontable.com/docs/react-data-grid/row-trimming/
- Row pre-populating: https://handsontable.com/docs/react-data-grid/row-prepopulating/
## Cell Features
- Clipboard (copy/paste): https://handsontable.com/docs/react-data-grid/basic-clipboard/
- Selection: https://handsontable.com/docs/react-data-grid/selection/
- Merge cells: https://handsontable.com/docs/react-data-grid/merge-cells/
- Conditional formatting: https://handsontable.com/docs/react-data-grid/conditional-formatting/
- Text alignment: https://handsontable.com/docs/react-data-grid/text-alignment/
- Disabled cells: https://handsontable.com/docs/react-data-grid/disabled-cells/
- Comments: https://handsontable.com/docs/react-data-grid/comments/
- Autofill values: https://handsontable.com/docs/react-data-grid/autofill-values/
- Formatting cells: https://handsontable.com/docs/react-data-grid/formatting-cells/
## Cell Functions
- Cell functions overview: https://handsontable.com/docs/react-data-grid/cell-function/
- Cell renderer: https://handsontable.com/docs/react-data-grid/cell-renderer/
- Cell editor: https://handsontable.com/docs/react-data-grid/cell-editor/
- Cell validator: https://handsontable.com/docs/react-data-grid/cell-validator/
- Custom cells (v17+): https://handsontable.com/docs/react-data-grid/custom-cells/
## Cell Types
- Cell type overview: https://handsontable.com/docs/react-data-grid/cell-type/
- Numeric: https://handsontable.com/docs/react-data-grid/numeric-cell-type/
- Date: https://handsontable.com/docs/react-data-grid/date-cell-type/
- Time: https://handsontable.com/docs/react-data-grid/time-cell-type/
- Checkbox: https://handsontable.com/docs/react-data-grid/checkbox-cell-type/
- Select: https://handsontable.com/docs/react-data-grid/select-cell-type/
- Dropdown: https://handsontable.com/docs/react-data-grid/dropdown-cell-type/
- Autocomplete: https://handsontable.com/docs/react-data-grid/autocomplete-cell-type/
- MultiSelect (v17+): https://handsontable.com/docs/react-data-grid/multiselect-cell-type/
- Password: https://handsontable.com/docs/react-data-grid/password-cell-type/
- Handsontable (nested grid): https://handsontable.com/docs/react-data-grid/handsontable-cell-type/
## Formulas (HyperFormula in Handsontable)
- Formula calculation guide: https://handsontable.com/docs/react-data-grid/formula-calculation/
- Formulas plugin API: https://handsontable.com/docs/react-data-grid/api/formulas/
- HyperFormula built-in functions list: https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
- HyperFormula configuration options: https://hyperformula.handsontable.com/docs/guide/configuration-options.html
- HyperFormula custom functions: https://hyperformula.handsontable.com/docs/guide/custom-functions.html
- HyperFormula named expressions: https://hyperformula.handsontable.com/docs/guide/cell-references.html#relative-references
- HyperFormula license key: https://hyperformula.handsontable.com/docs/guide/license-key.html
## Navigation
- Keyboard shortcuts: https://handsontable.com/docs/react-data-grid/keyboard-shortcuts/
- Custom shortcuts: https://handsontable.com/docs/react-data-grid/custom-shortcuts/
- Focus scopes: https://handsontable.com/docs/react-data-grid/focus-scopes/
- Searching values: https://handsontable.com/docs/react-data-grid/searching-values/
## Accessibility
- Accessibility guide: https://handsontable.com/docs/react-data-grid/accessibility/
## Menus & Accessories
- Context menu: https://handsontable.com/docs/react-data-grid/context-menu/
- Undo and redo: https://handsontable.com/docs/react-data-grid/undo-redo/
- Icon pack: https://handsontable.com/docs/react-data-grid/icon-pack/
- Export to CSV: https://handsontable.com/docs/react-data-grid/export-to-csv/
- Export to Excel/XLSX (v17.1+): https://handsontable.com/docs/react-data-grid/export-to-excel/
- Notification (v17.1+): https://handsontable.com/docs/react-data-grid/notification/
- Empty data state: https://handsontable.com/docs/react-data-grid/empty-data-state/
## Dialog & Loading
- Dialog: https://handsontable.com/docs/react-data-grid/dialog/
- Loading indicator: https://handsontable.com/docs/react-data-grid/loading/
## Internationalization
- Language: https://handsontable.com/docs/react-data-grid/language/
- Locale: https://handsontable.com/docs/react-data-grid/locale/
- Layout direction (RTL/LTR): https://handsontable.com/docs/react-data-grid/layout-direction/
- IME support: https://handsontable.com/docs/react-data-grid/ime-support/
## Tools & Building
- Modules (tree-shaking): https://handsontable.com/docs/react-data-grid/modules/
- Custom plugins: https://handsontable.com/docs/react-data-grid/custom-plugins/
- Custom builds: https://handsontable.com/docs/react-data-grid/custom-builds/
- Testing: https://handsontable.com/docs/react-data-grid/testing/
## Optimization
- Batch operations: https://handsontable.com/docs/react-data-grid/batch-operations/
- Performance: https://handsontable.com/docs/react-data-grid/performance/
- Bundle size: https://handsontable.com/docs/react-data-grid/bundle-size/
## Security
- Security guide: https://handsontable.com/docs/react-data-grid/security/
## API Reference
- API overview: https://handsontable.com/docs/react-data-grid/api/
- Core (instance methods): https://handsontable.com/docs/react-data-grid/api/core/
- Hooks (all events): https://handsontable.com/docs/react-data-grid/api/hooks/
- Configuration options (all props): https://handsontable.com/docs/react-data-grid/api/options/
- Plugins index: https://handsontable.com/docs/react-data-grid/api/plugins/
### Individual Plugin APIs
- AutoColumnSize: https://handsontable.com/docs/react-data-grid/api/auto-column-size/
- AutoRowSize: https://handsontable.com/docs/react-data-grid/api/auto-row-size/
- Autofill: https://handsontable.com/docs/react-data-grid/api/autofill/
- BindRowsWithHeaders: https://handsontable.com/docs/react-data-grid/api/bind-rows-with-headers/
- CollapsibleColumns: https://handsontable.com/docs/react-data-grid/api/collapsible-columns/
- ColumnSorting: https://handsontable.com/docs/react-data-grid/api/column-sorting/
- ColumnSummary: https://handsontable.com/docs/react-data-grid/api/column-summary/
- Comments: https://handsontable.com/docs/react-data-grid/api/comments/
- ContextMenu: https://handsontable.com/docs/react-data-grid/api/context-menu/
- CopyPaste: https://handsontable.com/docs/react-data-grid/api/copy-paste/
- CustomBorders: https://handsontable.com/docs/react-data-grid/api/custom-borders/
- DataProvider (v17.1+): https://handsontable.com/docs/react-data-grid/api/data-provider/
- Dialog: https://handsontable.com/docs/react-data-grid/api/dialog/
- DragToScroll: https://handsontable.com/docs/react-data-grid/api/drag-to-scroll/
- DropdownMenu: https://handsontable.com/docs/react-data-grid/api/dropdown-menu/
- EmptyDataState: https://handsontable.com/docs/react-data-grid/api/empty-data-state/
- ExportFile: https://handsontable.com/docs/react-data-grid/api/export-file/
- Filters: https://handsontable.com/docs/react-data-grid/api/filters/
- Formulas: https://handsontable.com/docs/react-data-grid/api/formulas/
- HiddenColumns: https://handsontable.com/docs/react-data-grid/api/hidden-columns/
- HiddenRows: https://handsontable.com/docs/react-data-grid/api/hidden-rows/
- Loading: https://handsontable.com/docs/react-data-grid/api/loading/
- ManualColumnFreeze: https://handsontable.com/docs/react-data-grid/api/manual-column-freeze/
- ManualColumnMove: https://handsontable.com/docs/react-data-grid/api/manual-column-move/
- ManualColumnResize: https://handsontable.com/docs/react-data-grid/api/manual-column-resize/
- ManualRowMove: https://handsontable.com/docs/react-data-grid/api/manual-row-move/
- ManualRowResize: https://handsontable.com/docs/react-data-grid/api/manual-row-resize/
- MergeCells: https://handsontable.com/docs/react-data-grid/api/merge-cells/
- MultiColumnSorting: https://handsontable.com/docs/react-data-grid/api/multi-column-sorting/
- NestedHeaders: https://handsontable.com/docs/react-data-grid/api/nested-headers/
- NestedRows: https://handsontable.com/docs/react-data-grid/api/nested-rows/
- Notification (v17.1+): https://handsontable.com/docs/react-data-grid/api/notification/
- Pagination: https://handsontable.com/docs/react-data-grid/api/pagination/
- Search: https://handsontable.com/docs/react-data-grid/api/search/
- StretchColumns: https://handsontable.com/docs/react-data-grid/api/stretch-columns/
- TrimRows: https://handsontable.com/docs/react-data-grid/api/trim-rows/
- UndoRedo: https://handsontable.com/docs/react-data-grid/api/undo-redo/
## Recipes (new in v17)
- Recipes index: https://handsontable.com/docs/react-data-grid/recipes/
- Load data from a REST API (DataProvider, v17.1+): https://handsontable.com/docs/react-data-grid/recipes/data-management/load-data-rest-api/
## Upgrade & Migration
- Changelog: https://handsontable.com/docs/react-data-grid/changelog/
- Versioning policy: https://handsontable.com/docs/react-data-grid/versioning-policy/
- Deprecation policy: https://handsontable.com/docs/react-data-grid/deprecation-policy/
- Long Term Support (LTS): https://handsontable.com/docs/react-data-grid/long-term-support/
- 16.2 → 17.0 migration: https://handsontable.com/docs/react-data-grid/migration-from-16.2-to-17.0/
- 16.0 → 16.1 migration: https://handsontable.com/docs/react-data-grid/migration-from-16.0-to-16.1/
- 15.3 → 16.0 migration: https://handsontable.com/docs/react-data-grid/migration-from-15.3-to-16.0/
- 14.6 → 15.0 migration: https://handsontable.com/docs/react-data-grid/migration-from-14.6-to-15.0/
## Other Framework Installation
- JavaScript: https://handsontable.com/docs/javascript-data-grid/installation/
- Angular: https://handsontable.com/docs/angular-data-grid/installation/
- Vue 3: https://handsontable.com/docs/react-data-grid/vue3-installation/
## SSR Examples (CodeSandbox)
- Next.js: https://codesandbox.io/p/sandbox/kwnjph?file=https://handsontable.com/codesandbox-vm?example-dir=next.js&handsontable-version=17.1preview=true
- Astro: https://codesandbox.io/p/sandbox/gnqcwn?file=https://handsontable.com/codesandbox-vm?example-dir=astro&handsontable-version=17.1preview=true
- Remix: https://codesandbox.io/p/sandbox/njcjlq?file=https://handsontable.com/codesandbox-vm?example-dir=remix&handsontable-version=17.1preview=true
- Nuxt: https://codesandbox.io/p/sandbox/r7qsjc?file=https://handsontable.com/codesandbox-vm?example-dir=nuxt&handsontable-version=17.1preview=true
## CDN Links (jsDelivr) — latest
- JS (full bundle): https://cdn.jsdelivr.net/npm/handsontable/dist/handsontable.full.min.js
- Base CSS: https://cdn.jsdelivr.net/npm/handsontable/styles/handsontable.min.css
- Theme Main: https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-main.min.css
- Theme Horizon: https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-horizon.min.css
- Theme Classic: https://cdn.jsdelivr.net/npm/handsontable/styles/ht-theme-classic.min.css
- React wrapper: https://cdn.jsdelivr.net/npm/@handsontable/react-wrapper/dist/react-handsontable.min.js
- HyperFormula: https://cdn.jsdelivr.net/npm/hyperformula/dist/hyperformula.full.min.js
Pin versions by adding `@17.1` after the package name (e.g., `handsontable@17.1`).
## Package Registries
- npm: https://www.npmjs.com/package/handsontable
- NuGet: https://www.nuget.org/packages/handsontable
## Community & Support
- Developer Forum: https://forum.handsontable.com/
- GitHub Issues: https://github.com/handsontable/handsontable/issues
- Stack Overflow: https://stackoverflow.com/tags/handsontable
- Commercial support: support@handsontable.com
- Contact form: https://handsontable.com/contact
- Blog / Release Notes: https://handsontable.com/blog/categories/release-notes
- Download page: https://handsontable.com/download
+85
View File
@@ -0,0 +1,85 @@
# @handsontable/hyperformula-skill
An agent skill for [**HyperFormula**](https://hyperformula.handsontable.com) — the headless, open-source TypeScript spreadsheet calculation engine. It gives any AI coding agent deep, task-oriented knowledge of HyperFormula so the agent can help you integrate, configure, and debug the engine faster.
This package is the npm distribution of the `hyperformula` skill maintained in [`handsontable/handsontable-skills`](https://github.com/handsontable/handsontable-skills). The package version tracks the HyperFormula product version the skill targets (e.g. `3.3.0` targets HyperFormula 3.3.0).
## What's in the package
A self-contained skill — plain markdown, no runtime dependencies:
```
SKILL.md ← entry point: concepts, routing, and a docs map (YAML frontmatter + body)
references/ ← task-oriented deep-dives the agent reads on demand
```
`SKILL.md` follows the [Agent Skills](https://claude.com/blog/skills) layout (a YAML frontmatter block with `name`/`description`, followed by markdown instructions). Any agent that can load a skill — or simply read a folder of markdown instructions — can use it.
## Install
```bash
npm install @handsontable/hyperformula-skill
```
The installed package lives at `node_modules/@handsontable/hyperformula-skill`. Resolve that path programmatically with:
```bash
node -p "require.resolve('@handsontable/hyperformula-skill/SKILL.md')"
```
Then make the skill available to your agent using whichever of the following matches it:
### Claude Code
Copy the package into a skills directory. User scope (available in every project):
```bash
cp -r node_modules/@handsontable/hyperformula-skill ~/.claude/skills/hyperformula
```
Or project scope, from your project root:
```bash
cp -r node_modules/@handsontable/hyperformula-skill .claude/skills/hyperformula
```
### Claude API
Upload the package contents (`SKILL.md` + `references/`) to the [Skills API](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview).
### OpenAI Codex
Codex discovers [skills](https://developers.openai.com/codex/skills) as folders containing a `SKILL.md`, under `~/.agents/skills` (available in every repo) or `.agents/skills` in a repo (checked in for your team). Copy the package into one of those locations:
```bash
# User scope — available in every repo
cp -r node_modules/@handsontable/hyperformula-skill ~/.agents/skills/hyperformula
# Repo scope — from your project root
cp -r node_modules/@handsontable/hyperformula-skill .agents/skills/hyperformula
```
Codex also follows symlinked skill folders, so you can instead link the installed package and let `npm install` keep it current:
```bash
ln -s "$PWD/node_modules/@handsontable/hyperformula-skill" ~/.agents/skills/hyperformula
```
### Any other agent
Most coding agents support a skills, rules, or knowledge directory. Copy (or symlink) the package folder into it, for example:
```bash
# Replace <agent-skills-dir> with your agent's skills/knowledge directory
cp -r node_modules/@handsontable/hyperformula-skill <agent-skills-dir>/hyperformula
```
If your agent reads a single instruction file rather than a folder, point it at `SKILL.md`; it links the files under `references/` so the agent can pull them in as needed. For RAG / vector-store setups, ingest `SKILL.md` together with every file under `references/` (the repository also publishes a pre-flattened `hyperformula-rag.md` for this case).
## Other distribution formats
The same skill source is also shipped as a drag-and-drop `.zip` (Cowork / Claude.ai web), a flattened `-rag.md` doc (RAG / vector stores), and a Claude Code plugin marketplace entry. See the [repository README](https://github.com/handsontable/handsontable-skills#readme) for details.
## License
MIT — see the [repository](https://github.com/handsontable/handsontable-skills/blob/main/LICENSE.txt). HyperFormula itself is separately licensed (GPLv3 or commercial); see the [licensing guide](https://hyperformula.handsontable.com/docs/guide/licensing.html).
+134
View File
@@ -0,0 +1,134 @@
---
name: hyperformula
description: >
Use this skill when the user works with HyperFormula (HF) — a headless,
open-source TypeScript spreadsheet calculation engine. Use it when the user:
mentions HyperFormula or HF; wants to integrate Excel-like formulas into a
JS/TS app; evaluate formulas programmatically in a browser or Node.js;
simulate spreadsheet or Excel calculation behavior server-side; run
calculations over data parsed from XLSX files (e.g. via SheetJS) in code;
build a pricing engine, financial model, or what-if analysis in code; define
custom spreadsheet functions or named expressions outside of a UI context. Do
NOT use this skill for: general Excel questions unrelated to HF; general
Google Sheets questions; generic spreadsheet formula-syntax questions outside
an HF context; formulas used inside Handsontable (use the "handsontable"
skill instead).
---
# HyperFormula
HyperFormula is a **headless, open-source TypeScript spreadsheet calculation engine** for embedding spreadsheet logic in any JavaScript/TypeScript application (browser or Node.js). ~420 built-in functions, dependency graph, undo/redo, i18n (17 languages). Dual-licensed: GPLv3 or commercial.
- Docs: https://hyperformula.handsontable.com/
- npm: https://www.npmjs.com/package/hyperformula
- GitHub: https://github.com/handsontable/hyperformula
## Where to go next
Task-oriented references in `references/` (open the one that matches what the user is doing):
- [`getting-started.md`](references/getting-started.md) — how to install, create an instance (`buildFromArray` / `buildFromSheets` / `buildEmpty`), and work with cell addresses. Open this for onboarding questions and first-run errors.
- [`api-quickref.md`](references/api-quickref.md) — runnable examples for CRUD, rows/columns, sheets, exporting data, batching, named expressions, events, undo/redo, clipboard. Open this when implementing or debugging HF API calls.
- [`custom-functions.md`](references/custom-functions.md) — extending HyperFormula via `FunctionPlugin`: minimal plugin, argument types, volatile functions, range arguments, returning arrays, error handling, aliases, localized names. Open this when adding or debugging custom spreadsheet functions.
- [`configuration.md`](references/configuration.md) — `ConfigParams` options, Excel-compatibility preset, Google-Sheets preset, and config-related pitfalls (separator collisions, Node ICU, `precisionRounding` change in v3). Open this when tuning behavior or diagnosing locale issues.
- [`vue3.md`](references/vue3.md) — Vue 3 integration: `markRaw` requirement, Composition API, Pinia/Vuex stores, `destroy()` on unmount. Open this when embedding HF in a Vue 3 app.
- [`error-handling.md`](references/error-handling.md) — inspecting `CellError`, `ErrorType` enum, `getCellValueDetailedType`, common error causes (`#NAME?`, `#CYCLE!`, `#REF!`, …). Open this when a cell returns an error or you need to branch on result type.
- [`general-pitfalls.md`](references/general-pitfalls.md) — cross-cutting gotchas: `destroy()` lifecycle, forcing literal strings, Excel-parity caveats, hard limits. Open this when results look wrong or memory grows unboundedly.
Below this section is the **Documentation map** — the canonical directory of links to the official HyperFormula docs. Use it when pointing the user to authoritative material.
## Documentation map
All links resolve to `hyperformula.handsontable.com` unless noted.
### Introduction
- Welcome / Homepage: https://hyperformula.handsontable.com/
- Demo: https://hyperformula.handsontable.com/docs/guide/demo.html
### Overview
- Quality (test coverage, CI): https://hyperformula.handsontable.com/docs/guide/quality.html
- Supported browsers: https://hyperformula.handsontable.com/docs/guide/supported-browsers.html
- Dependencies: https://hyperformula.handsontable.com/docs/guide/dependencies.html
- Licensing (GPLv3 vs commercial): https://hyperformula.handsontable.com/docs/guide/licensing.html
- Support: https://hyperformula.handsontable.com/docs/guide/support.html
### Getting Started
- Client-side installation: https://hyperformula.handsontable.com/docs/guide/client-side-installation.html
- Server-side installation (Node.js): https://hyperformula.handsontable.com/docs/guide/server-side-installation.html
- Basic usage: https://hyperformula.handsontable.com/docs/guide/basic-usage.html
- Advanced usage: https://hyperformula.handsontable.com/docs/guide/advanced-usage.html
- Configuration options guide: https://hyperformula.handsontable.com/docs/guide/configuration-options.html
- License key setup: https://hyperformula.handsontable.com/docs/guide/license-key.html
### Framework Integration
- React: https://hyperformula.handsontable.com/docs/guide/integration-with-react.html
- Vue: https://hyperformula.handsontable.com/docs/guide/integration-with-vue.html
- Angular: https://hyperformula.handsontable.com/docs/guide/integration-with-angular.html
- Svelte: https://hyperformula.handsontable.com/docs/guide/integration-with-svelte.html
### Data Operations
- Basic operations (CRUD): https://hyperformula.handsontable.com/docs/guide/basic-operations.html
- Batch operations: https://hyperformula.handsontable.com/docs/guide/batch-operations.html
- Clipboard operations: https://hyperformula.handsontable.com/docs/guide/clipboard-operations.html
- Undo-redo: https://hyperformula.handsontable.com/docs/guide/undo-redo.html
- Sorting data: https://hyperformula.handsontable.com/docs/guide/sorting-data.html
### Formulas
- Specifications and limits: https://hyperformula.handsontable.com/docs/guide/specifications-and-limits.html
- Cell references (relative, absolute, mixed, cross-sheet): https://hyperformula.handsontable.com/docs/guide/cell-references.html
- Types of values: https://hyperformula.handsontable.com/docs/guide/types-of-values.html
- Types of errors: https://hyperformula.handsontable.com/docs/guide/types-of-errors.html
- Types of operators: https://hyperformula.handsontable.com/docs/guide/types-of-operators.html
- Order of precedence: https://hyperformula.handsontable.com/docs/guide/order-of-precendece.html
- Built-in functions (full list, ~400): https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
- Volatile functions: https://hyperformula.handsontable.com/docs/guide/volatile-functions.html
- Named expressions: https://hyperformula.handsontable.com/docs/guide/named-expressions.html
- Array formulas / ARRAYFORMULA: https://hyperformula.handsontable.com/docs/guide/arrays.html
### Internationalization
- i18n features (17 languages): https://hyperformula.handsontable.com/docs/guide/i18n-features.html
- Localizing function names: https://hyperformula.handsontable.com/docs/guide/localizing-functions.html
- Date and time handling: https://hyperformula.handsontable.com/docs/guide/date-and-time-handling.html
### Compatibility
- Microsoft Excel compatibility: https://hyperformula.handsontable.com/docs/guide/compatibility-with-microsoft-excel.html
- Google Sheets compatibility: https://hyperformula.handsontable.com/docs/guide/compatibility-with-google-sheets.html
- Runtime differences (Excel & Sheets): https://hyperformula.handsontable.com/docs/guide/list-of-differences.html
### Advanced Topics
- Key concepts (AST, dependency graph, evaluation): https://hyperformula.handsontable.com/docs/guide/key-concepts.html
- Dependency graph: https://hyperformula.handsontable.com/docs/guide/dependency-graph.html
- Building & testing from source: https://hyperformula.handsontable.com/docs/guide/building.html
- Custom functions (FunctionPlugin): https://hyperformula.handsontable.com/docs/guide/custom-functions.html
- Performance: https://hyperformula.handsontable.com/docs/guide/performance.html
- Known limitations: https://hyperformula.handsontable.com/docs/guide/known-limitations.html
- File import: https://hyperformula.handsontable.com/docs/guide/file-import.html
### Upgrade & Migration
- Release notes / Changelog: https://hyperformula.handsontable.com/docs/guide/release-notes.html
- 0.6 → 1.0 migration: https://hyperformula.handsontable.com/docs/guide/migration-from-0.6-to-1.0.html
- 1.x → 2.0 migration: https://hyperformula.handsontable.com/docs/guide/migration-from-1.x-to-2.0.html
- 2.x → 3.0 migration: https://hyperformula.handsontable.com/docs/guide/migration-from-2.x-to-3.0.html
### API Reference
- API overview: https://hyperformula.handsontable.com/docs/api/
- HyperFormula class (all methods): https://hyperformula.handsontable.com/docs/api/classes/hyperformula.html
- ConfigParams interface (all options): https://hyperformula.handsontable.com/docs/api/interfaces/configparams.html
- Listeners interface (all events): https://hyperformula.handsontable.com/docs/api/interfaces/listeners.html
### npm
- npm page: https://www.npmjs.com/package/hyperformula
### Community & Support
- GitHub repo: https://github.com/handsontable/hyperformula
- GitHub Issues: https://github.com/handsontable/hyperformula/issues
- GitHub Discussions: https://github.com/handsontable/hyperformula/discussions
- Developer Forum: https://forum.handsontable.com/
- Commercial support: support@handsontable.com
- Sales (commercial license): sales@handsontable.com
- Contact form: https://handsontable.com/contact
---
> **Last updated: 2026-05-20 · Aligned with HyperFormula 3.3.0.**
> If the user is on a newer release, confirm API shape against the latest docs (see the **Release notes** link above) before relying on this file.
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@handsontable/hyperformula-skill",
"version": "3.3.0",
"description": "Agent skill for HyperFormula — the headless, open-source TypeScript spreadsheet calculation engine. Teaches AI coding agents how to integrate, configure, and debug HyperFormula.",
"license": "MIT",
"author": "Handsontable <hello@handsontable.com> (https://handsontable.com)",
"homepage": "https://github.com/handsontable/handsontable-skills#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/handsontable/handsontable-skills.git",
"directory": "skills/hyperformula"
},
"bugs": {
"url": "https://github.com/handsontable/handsontable-skills/issues"
},
"keywords": [
"agent-skill",
"claude-skill",
"claude",
"anthropic",
"ai",
"hyperformula",
"spreadsheet",
"formula-engine",
"handsontable"
],
"files": [
"SKILL.md",
"references/"
],
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,147 @@
# API Quick Reference
Runnable examples for the most-used HyperFormula APIs. Authoritative docs:
- Basic operations: https://hyperformula.handsontable.com/docs/guide/basic-operations.html
- Batch operations: https://hyperformula.handsontable.com/docs/guide/batch-operations.html
- Named expressions: https://hyperformula.handsontable.com/docs/guide/named-expressions.html
- Clipboard operations: https://hyperformula.handsontable.com/docs/guide/clipboard-operations.html
- Undo-redo: https://hyperformula.handsontable.com/docs/guide/undo-redo.html
- Sorting data: https://hyperformula.handsontable.com/docs/guide/sorting-data.html
- HyperFormula class (all methods): https://hyperformula.handsontable.com/docs/api/classes/hyperformula.html
- Listeners (all events): https://hyperformula.handsontable.com/docs/api/interfaces/listeners.html
## CRUD
```ts
// Write: string, number, or formula
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, 'Hello');
hf.setCellContents({ sheet: 0, col: 1, row: 0 }, '=A1 & " World"');
// Read
hf.getCellValue({ sheet: 0, col: 1, row: 0 }); // computed result
hf.getCellFormula({ sheet: 0, col: 1, row: 0 }); // '=A1 & " World"'
hf.getCellType({ sheet: 0, col: 0, row: 0 }); // CellType.FORMULA | VALUE | EMPTY
```
## Rows and columns
Second arg is `[startIndex, count]`, not a list of indexes.
```ts
hf.addRows(sheetId, [rowIndex, numberOfRows]);
hf.removeRows(sheetId, [rowIndex, numberOfRows]);
hf.addColumns(sheetId, [colIndex, numberOfColumns]);
hf.removeColumns(sheetId, [colIndex, numberOfColumns]);
```
## Sheets
```ts
hf.addSheet('NewSheet');
hf.removeSheet(sheetId);
hf.renameSheet(sheetId, 'BetterName');
hf.countSheets();
hf.getSheetName(sheetId);
hf.getSheetId('SheetName');
hf.getSheetDimensions(sheetId); // → { width, height }
```
## Export data
```ts
hf.getSheetValues(sheetId); // computed values as 2D array
hf.getSheetSerialized(sheetId); // formulas/raw values as 2D array
hf.getAllSheetsValues(); // { SheetName: values[][] }
hf.getAllSheetsSerialized(); // { SheetName: formulas[][] }
```
## Batch operations
Every `setCellContents` call triggers a full dependency-graph recalculation. Always batch when writing more than one cell.
```ts
// Preferred: batch() — returns ExportedChange[]
const changes = hf.batch(() => {
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, '100');
hf.setCellContents({ sheet: 0, col: 0, row: 1 }, '200');
hf.addRows(0, [2, 1]);
});
// `changes` contains all cells that were recalculated.
// For bulk imports (value writes only):
hf.suspendEvaluation();
// ... hundreds of setCellContents calls ...
hf.resumeEvaluation(); // single recalc
// PITFALL: structural ops (addRows/moveRows/...) during suspendEvaluation
// have degraded performance. Use batch() when mixing structural + value writes.
```
## Sorting
`setRowOrder` takes a **permutation array**, not a comparator. Compute the new index sequence externally, then pass it in.
```ts
// Sort rows by the value in column 0
const rowCount = hf.getSheetDimensions(0).height;
const indexes = Array.from({ length: rowCount }, (_, i) => i);
indexes.sort((a, b) => {
const va = hf.getCellValue({ sheet: 0, col: 0, row: a }) as number;
const vb = hf.getCellValue({ sheet: 0, col: 0, row: b }) as number;
return va - vb;
});
hf.setRowOrder(0, indexes); // e.g. [2, 0, 1, 3]
```
## Named expressions
Reusable names for values or formulas. Can be global or scoped to a sheet (local shadows global).
```ts
hf.addNamedExpression('TAX_RATE', '0.21');
hf.addNamedExpression('TOTAL_REVENUE', '=SUM(Revenue!A:A)');
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, '=1000 * TAX_RATE');
hf.listNamedExpressions(); // all registered names
```
## Custom functions
See [custom-functions.md](custom-functions.md) — `FunctionPlugin`, argument types, volatile functions, range arguments, returning arrays, error handling, aliases, localized names.
## Events
Subscribe via `.on()` on the instance. Full list: https://hyperformula.handsontable.com/docs/api/interfaces/listeners.html
```ts
hf.on('valuesUpdated', (changes) => {
changes.forEach((c) => console.log(c.address, c.newValue));
});
hf.on('sheetAdded', (addedSheetName) => { /* ... */ });
hf.on('sheetRemoved', (removedSheetName, removedSheetData) => { /* ... */ });
hf.on('sheetRenamed', (oldName, newName) => { /* ... */ });
```
## Undo / redo
```ts
hf.undo();
hf.redo();
hf.isThereSomethingToUndo();
hf.isThereSomethingToRedo();
hf.clearUndoStack();
```
Configure depth via `undoLimit` in `ConfigParams` (see [configuration.md](configuration.md)).
## Clipboard
```ts
hf.copy({ start: { sheet: 0, col: 0, row: 0 }, end: { sheet: 0, col: 1, row: 1 } });
hf.cut ({ start: { sheet: 0, col: 0, row: 0 }, end: { sheet: 0, col: 1, row: 1 } });
hf.paste({ sheet: 0, col: 5, row: 5 });
hf.isClipboardEmpty();
hf.clearClipboard();
```
@@ -0,0 +1,92 @@
# Configuration
`ConfigParams` + compatibility presets + config-specific pitfalls. Authoritative docs:
- Configuration options guide: https://hyperformula.handsontable.com/docs/guide/configuration-options.html
- ConfigParams (all options): https://hyperformula.handsontable.com/docs/api/interfaces/configparams.html
- Excel compatibility: https://hyperformula.handsontable.com/docs/guide/compatibility-with-microsoft-excel.html
- Google Sheets compatibility: https://hyperformula.handsontable.com/docs/guide/compatibility-with-google-sheets.html
## ConfigParams highlights
Pass as the second argument to any factory method.
```ts
const hf = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3', // required — 'gpl-v3' for open source
precisionRounding: 10, // decimal rounding precision (default 10; was 14 before v3)
functionArgSeparator: ',', // separator between function arguments
dateFormats: ['DD/MM/YYYY', 'DD-MM-YYYY'],
nullDate: { year: 1900, month: 1, day: 1 },
leapYear1900: false, // Excel bug compat: treat 1900 as leap year
maxRows: 40000, // max rows per sheet (default 40000)
maxColumns: 18278, // max columns per sheet (default 18278 = 'ZZZ')
undoLimit: 20, // undo history depth
maxPendingLazyTransformations: 1000, // v3.3+ — cap accumulated lazy transformations before cleanup
});
```
## Excel compatibility preset
```ts
const hf = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3',
useArrayArithmetic: true,
useWildcards: true,
evaluateNullToZero: true,
leapYear1900: true, // Excel's intentional 1900 leap-year bug
ignoreWhiteSpace: 'any',
caseSensitive: false,
accentSensitive: true,
});
```
## Google Sheets compatibility preset
Defaults already match Google Sheets behavior (`leapYear1900: false`, `useArrayArithmetic: false`).
```ts
const hf = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' });
```
## Config-specific pitfalls
### Vue 3
Vue 3 requires wrapping the instance with `markRaw` — see [vue3.md](vue3.md) for the full integration guide (React / Angular / Svelte need no special handling).
### `functionArgSeparator` vs `thousandSeparator` collision
These two options **cannot share a character**. European locales that use `,` as thousand separator must change the argument separator:
```ts
const hf = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3',
thousandSeparator: ',',
decimalSeparator: '.',
functionArgSeparator: ';', // must differ from thousandSeparator
});
```
### Node.js must have full ICU
String comparisons (used in `MATCH`, `VLOOKUP`, sorting) can silently produce wrong results on Node.js < 13 or builds without full ICU. Verify at startup:
```ts
// Must report 'full' (or a detailed ICU version), not 'small'.
console.log(process.versions.icu);
```
### Tuning `maxPendingLazyTransformations` (v3.3+)
HyperFormula defers some structural transformations (row/column insertions, moves) and applies them lazily. In long-lived instances with heavy mutation throughput — bulk imports, frequent undo/redo, scripted batch edits — the pending queue can grow before cleanup. v3.3 fixed the unbounded-growth leak; this option lets you cap the queue explicitly. Lower values trade memory for more frequent flush work; the default is suitable for typical UI workloads.
### `precisionRounding` default changed in v3
Before v3 the default was `14`. It is now `10`. Calculations relying on the old precision need an explicit override:
```ts
const hf = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3',
precisionRounding: 14, // pre-v3 behavior
});
```
@@ -0,0 +1,174 @@
# Custom Functions
Extend HyperFormula with your own functions via `FunctionPlugin`. Authoritative doc:
- Custom functions guide: https://hyperformula.handsontable.com/docs/guide/custom-functions.html
## Minimal plugin
Register **before** creating any instance — functions registered after `buildFromArray` / `buildFromSheets` / `buildEmpty` won't be available.
```ts
import { HyperFormula, FunctionPlugin, FunctionArgumentType } from 'hyperformula';
class MyPlugin extends FunctionPlugin {
greet(ast, state) {
return this.runFunction(
ast.args,
state,
this.metadata('GREET'),
(name) => `Hello, ${name}!`
);
}
}
MyPlugin.implementedFunctions = {
GREET: {
method: 'greet',
parameters: [{ argumentType: FunctionArgumentType.STRING }],
},
};
const translations = { enGB: { GREET: 'GREET' }, enUS: { GREET: 'GREET' } };
// PITFALL: registration MUST happen before any instance is built.
HyperFormula.registerFunctionPlugin(MyPlugin, translations);
const hf = HyperFormula.buildFromArray(
[['World', '=GREET(A1)']],
{ licenseKey: 'gpl-v3' }
);
console.log(hf.getCellValue({ sheet: 0, col: 1, row: 0 })); // "Hello, World!"
```
## Volatile functions
Mark a function as volatile so it recalculates on every change (like `RAND` or `NOW`). Volatile functions are expensive in large sheets — use sparingly.
```ts
MyPlugin.implementedFunctions = {
RAND_ID: {
method: 'randId',
parameters: [],
isVolatile: true,
},
};
```
## Argument types
```ts
import { FunctionArgumentType } from 'hyperformula';
// FunctionArgumentType.NUMBER
// FunctionArgumentType.STRING
// FunctionArgumentType.BOOLEAN
// FunctionArgumentType.NOERROR
// FunctionArgumentType.SCALAR
// FunctionArgumentType.RANGE // accepts a cell range
// FunctionArgumentType.ANY
```
Use `optionalArg: true`, `defaultValue`, and `minValue` / `maxValue` to validate arguments declaratively:
```ts
MyPlugin.implementedFunctions = {
CLAMP: {
method: 'clamp',
parameters: [
{ argumentType: FunctionArgumentType.NUMBER },
{ argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: 0 },
{ argumentType: FunctionArgumentType.NUMBER, optionalArg: true, defaultValue: 100 },
],
},
};
```
## Range arguments
Use `RANGE` argument type when the function takes a cell area. The callback receives the range as a 2D array.
```ts
class SumPositivesPlugin extends FunctionPlugin {
sumPositives(ast, state) {
return this.runFunction(
ast.args,
state,
this.metadata('SUM_POSITIVES'),
(range) => {
let total = 0;
for (const row of range.data) {
for (const v of row) if (typeof v === 'number' && v > 0) total += v;
}
return total;
}
);
}
}
SumPositivesPlugin.implementedFunctions = {
SUM_POSITIVES: {
method: 'sumPositives',
parameters: [{ argumentType: FunctionArgumentType.RANGE }],
},
};
```
## Returning arrays
Custom functions can return 2D arrays. **PITFALL:** result arrays don't auto-resize when upstream dependencies change — the footprint is fixed at first evaluation.
```ts
class SplitPlugin extends FunctionPlugin {
splitToRow(ast, state) {
return this.runFunction(
ast.args,
state,
this.metadata('SPLIT_ROW'),
(text, sep) => [text.split(sep)] // 2D array: one row, N cols
);
}
}
```
## Error handling
Return a `CellError` to signal a formula error:
```ts
import { CellError, ErrorType } from 'hyperformula';
class DividePlugin extends FunctionPlugin {
safeDivide(ast, state) {
return this.runFunction(
ast.args,
state,
this.metadata('SAFE_DIVIDE'),
(a, b) => (b === 0 ? new CellError(ErrorType.DIV_BY_ZERO) : a / b)
);
}
}
```
## Aliases and localized names
Expose one method under multiple function names, or localize:
```ts
MyPlugin.aliases = {
HELLO: 'GREET', // calling =HELLO(x) runs the greet() method
};
const translations = {
enGB: { GREET: 'GREET' },
plPL: { GREET: 'POWITAJ' }, // =POWITAJ("Świat") in Polish
};
HyperFormula.registerFunctionPlugin(MyPlugin, translations);
```
## Unregister / introspect
```ts
HyperFormula.unregisterFunctionPlugin(MyPlugin);
HyperFormula.unregisterFunction('GREET');
HyperFormula.getRegisteredFunctionNames('enGB');
```
@@ -0,0 +1,69 @@
# Error Handling
Inspect cell values that may be errors, tell error types apart, and check value shape. Authoritative docs:
- Types of errors: https://hyperformula.handsontable.com/docs/guide/types-of-errors.html
- Types of values: https://hyperformula.handsontable.com/docs/guide/types-of-values.html
## Always check `CellError` before using a result
Cell values can be errors (`#DIV/0!`, `#VALUE!`, `#REF!`, etc.). Always test before using a result.
```ts
import { CellError, ErrorType } from 'hyperformula';
const value = hf.getCellValue({ sheet: 0, col: 0, row: 0 });
if (value instanceof CellError) {
// ErrorType enum: DIV_BY_ZERO, VALUE, REF, NAME, NUM, NA, CYCLE, ERROR
switch (value.type) {
case ErrorType.CYCLE:
console.log('Circular reference');
break;
case ErrorType.NAME:
// Usually: function not registered (check plugin registration or i18n language)
console.log('Unknown name:', value.message);
break;
default:
console.log('Error:', value.type, value.message);
}
} else {
console.log('Value:', value);
}
```
## `#CYCLE!` is HyperFormula-specific
Standard spreadsheet apps report cycles differently. HyperFormula's `IF` also reports cycles for all branches, even unreachable ones — this can produce `#CYCLE!` in formulas that Excel or Sheets would evaluate.
## Inspect value shape without catching errors
```ts
import { CellValueDetailedType } from 'hyperformula';
hf.getCellValueDetailedType({ sheet: 0, col: 0, row: 0 });
// → CellValueDetailedType.NUMBER | STRING | BOOLEAN | ERROR | EMPTY | ...
```
Use the detailed type when you need to distinguish e.g. number vs empty without touching the value.
## Returning errors from custom functions
Custom `FunctionPlugin` methods can return a `CellError` to surface a formula error. See [custom-functions.md](custom-functions.md) for the full pattern.
```ts
import { CellError, ErrorType } from 'hyperformula';
return new CellError(ErrorType.DIV_BY_ZERO);
```
## Common causes
| Error | Common cause |
|---|---|
| `#NAME?` | Function not registered (check `registerFunctionPlugin` order or `language` config) |
| `#CYCLE!` | Circular reference — or an `IF` branch that *could* produce one |
| `#REF!` | Deleted row/column broke a formula reference |
| `#VALUE!` | Type mismatch — e.g. passing a string where a number is expected |
| `#DIV/0!` | Division by zero, including empty cells coerced to 0 |
| `#NUM!` | Out-of-range numeric result (e.g. `SQRT(-1)`) |
| `#N/A` | Lookup miss (`MATCH`, `VLOOKUP`) or propagated from an upstream `#N/A` |
@@ -0,0 +1,48 @@
# General Pitfalls
Cross-cutting gotchas that aren't tied to a specific API or config option. Authoritative docs:
- Known limitations: https://hyperformula.handsontable.com/docs/guide/known-limitations.html
- Built-in functions (full list): https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
- Runtime differences vs Excel/Sheets: https://hyperformula.handsontable.com/docs/guide/list-of-differences.html
## Error handling
See [error-handling.md](error-handling.md) — checking `CellError`, `ErrorType` enum, `getCellValueDetailedType`, common error causes.
## Always call `destroy()` in long-running apps
HyperFormula maintains internal data structures (dependency graph, address mapping) that are **not** garbage-collected until `destroy()` is called. Leaking instances in servers or SPAs accumulates memory.
```ts
hf.destroy();
// After destroy() the instance is unusable — create a new one if needed.
```
v3.3 fixed two longstanding leak sources inside live instances — pending lazy transformations and undo/redo history were not being trimmed. If you maintain very long-lived instances with heavy mutation throughput, also see `maxPendingLazyTransformations` in [configuration.md](configuration.md) to bound the lazy-transformation queue. `destroy()` is still mandatory at teardown.
## Force a string that looks like a formula
Prefix with `'` (apostrophe) to store the literal text instead of evaluating.
```ts
// Stored as the literal string "=SUM(1,2)", not a formula:
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, "'=SUM(1,2)");
```
## Don't assume Excel parity
~68% of Excel functions are covered. Runtime differences exist even for implemented functions. Before relying on behavior, check:
- Full built-in list: https://hyperformula.handsontable.com/docs/guide/built-in-functions.html
- Runtime differences: https://hyperformula.handsontable.com/docs/guide/list-of-differences.html
## `licenseKey` is always required
Every factory method (`buildFromArray`, `buildFromSheets`, `buildEmpty`) requires `licenseKey`. Use `'gpl-v3'` for open-source use or your commercial key.
## Known hard limits
- **Single workbook per instance** — no multi-workbook support.
- No 3D references, dynamic arrays, async functions, structured references ("Tables"), or relative named expressions.
- `IF` reports cycles for all branches, even unreachable ones.
- Custom function result arrays don't auto-resize when dependencies change.
@@ -0,0 +1,82 @@
# Getting Started
Install, create an instance, read/write cells. Authoritative docs:
- Client-side install: https://hyperformula.handsontable.com/docs/guide/client-side-installation.html
- Server-side install: https://hyperformula.handsontable.com/docs/guide/server-side-installation.html
- Basic usage: https://hyperformula.handsontable.com/docs/guide/basic-usage.html
## Install
### npm
```bash
npm install hyperformula
```
### CDN (jsDelivr, pinned version)
```html
<script src="https://cdn.jsdelivr.net/npm/hyperformula@3.2.0/dist/hyperformula.full.min.js"></script>
```
### Node.js
Same npm install. Requires Node.js 13+ with full ICU for locale-aware string comparison.
## Create an instance
Every factory method requires `licenseKey`. Use `'gpl-v3'` for open-source use or your commercial key.
### `buildFromArray` — single sheet from a 2D array
```ts
import { HyperFormula } from 'hyperformula';
const hf = HyperFormula.buildFromArray(
[
['10', '20', '=SUM(A1:B1)'],
['30', '40', '=SUM(A2:B2)'],
],
{ licenseKey: 'gpl-v3' }
);
console.log(hf.getCellValue({ sheet: 0, col: 2, row: 0 })); // 30
```
### `buildFromSheets` — multi-sheet workbook
```ts
const hf = HyperFormula.buildFromSheets(
{
Revenue: [['100', '200', '=SUM(A1:B1)']],
Expenses: [['50', '=Revenue!C1 - A1']],
},
{ licenseKey: 'gpl-v3' }
);
```
Cross-sheet references use `SheetName!CellRef` syntax.
### `buildEmpty` — start empty, add sheets later
```ts
const hf = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' });
const sheetName = hf.addSheet('Data');
const sheetId = hf.getSheetId(sheetName);
hf.setCellContents({ sheet: sheetId, col: 0, row: 0 }, [
['10', '20', '=SUM(A1:B1)'],
]);
```
## Cell addresses
All addresses use zero-indexed `{ sheet, col, row }`. Convert to/from A1 notation with built-in helpers:
```ts
hf.simpleCellAddressFromString('B3', 0);
// → { sheet: 0, col: 1, row: 2 }
hf.simpleCellAddressToString({ sheet: 0, col: 1, row: 2 }, 0);
// → 'B3'
```
@@ -0,0 +1,72 @@
# Vue 3
HyperFormula + Vue 3 integration. Authoritative doc:
- Vue integration guide: https://hyperformula.handsontable.com/docs/guide/integration-with-vue.html
## Always wrap the instance with `markRaw`
Vue 3's Composition API wraps objects in a reactive Proxy that intercepts property access and **corrupts HyperFormula's internal state**, causing silent data corruption or crashes. React, Angular, and Svelte need no special handling.
```ts
import { markRaw } from 'vue';
import { HyperFormula } from 'hyperformula';
const hf = markRaw(
HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' })
);
```
Use the same wrapper no matter where the instance is held — `ref`, `reactive`, component state, a store (Pinia/Vuex), or a composable. If the raw HyperFormula instance ever reaches Vue's reactivity system, it must be marked.
## Composition API example
```ts
import { onBeforeUnmount, ref } from 'vue';
import { markRaw } from 'vue';
import { HyperFormula } from 'hyperformula';
export function useHyperFormula() {
const hf = markRaw(
HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' })
);
// Free internal data structures when the component unmounts.
onBeforeUnmount(() => hf.destroy());
const result = ref<unknown>(null);
function evaluate(formula: string) {
hf.setCellContents({ sheet: 0, col: 0, row: 0 }, formula);
result.value = hf.getCellValue({ sheet: 0, col: 0, row: 0 });
}
return { evaluate, result };
}
```
## Pinia / Vuex store
```ts
import { defineStore } from 'pinia';
import { markRaw } from 'vue';
import { HyperFormula } from 'hyperformula';
export const useCalcStore = defineStore('calc', {
state: () => ({
hf: markRaw(
HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' })
),
}),
});
```
## Cleanup
HyperFormula's internal graph is **not** garbage-collected until `destroy()` is called. In SPAs this accumulates memory over time.
```ts
import { onBeforeUnmount } from 'vue';
onBeforeUnmount(() => hf.destroy());
// After destroy() the instance is unusable — create a new one if needed.
```
+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()
+80 -18
View File
@@ -47,9 +47,9 @@ jobs:
- name: Check audit
# Audit should fail and stop the CI if critical vulnerability found
run: |
npm audit --omit=dev
npm audit --audit-level=critical --omit=dev
cd ./sas
npm audit --omit=dev
npm audit --audit-level=critical --omit=dev
cd ../client
npm audit --audit-level=critical --omit=dev
@@ -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()
@@ -161,6 +161,16 @@ jobs:
release:
runs-on: ubuntu-latest
needs: [Build-production-and-ng-test, Build-and-test-development]
# The semantic-release step below uses the per-job token injected by Gitea
# Actions (secrets.GITEA_TOKEN). That token is scoped to THIS repository
# and its permissions are derived from this block.
# contents: write -> push CHANGELOG.md / package.json commit + tag, and
# create the Release via the Gitea API.
# The TSDOC_TOKEN and CODE_DATACONTROLLER_IO secrets continue to be used
# because they target *different* servers (webdoc.datacontroller.io and
# code.datacontroller.io), not the Gitea API.
permissions:
contents: write
steps:
- uses: actions/checkout@v4
@@ -193,12 +203,41 @@ jobs:
npm run build
- name: Create Empty Release (assets are posted later)
env:
# Use the ephemeral per-job token injected by Gitea Actions instead
# of a manually-managed PAT. This token always reports push=true on
# its own repo via the Gitea API, which is what the
# @saithodev/semantic-release-gitea verifyConditions step checks.
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: ${{ gitea.server_url }}
run: |
set -euo pipefail
npm i
npm i -g semantic-release
# We do a semantic-release DRY RUN to make the job fail if there are no changes to release
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.datacontroller.io semantic-release --dry-run | grep -q "There are no relevant changes, so no new version is released." && exit 1
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.datacontroller.io semantic-release
# We do a semantic-release DRY RUN to make the job fail if there are
# no changes to release. The previous implementation piped --dry-run
# into `grep`, which swallowed semantic-release's non-zero exit codes
# (e.g. auth/verifyConditions failures) — the real error then only
# surfaced from the second invocation. Capture output and inspect it
# so authentication failures fail the step cleanly with context.
echo "::group::semantic-release --dry-run"
DRY_EXIT=0
DRY_OUTPUT=$(semantic-release --dry-run 2>&1) || DRY_EXIT=$?
echo "$DRY_OUTPUT"
echo "::endgroup::"
if [ "$DRY_EXIT" -ne 0 ]; then
echo "::error::semantic-release dry-run failed (exit $DRY_EXIT). See log above; common cause is the Gitea token lacking push permission on this repo (verifyConditions of @saithodev/semantic-release-gitea)."
exit "$DRY_EXIT"
fi
if echo "$DRY_OUTPUT" | grep -q "There are no relevant changes, so no new version is released."; then
echo "::error::No releasable changes since last tag - aborting release job."
exit 1
fi
semantic-release
- name: Frontend Build
description: Must be created AFTER the release as the version (git tag) is used in the interface
@@ -272,17 +311,40 @@ jobs:
surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* /
- name: Upload assets to release
env:
# Use the same ephemeral per-job Gitea Actions token that the
# "Create Empty Release" step uses for semantic-release. It is scoped
# to this repo and is granted write access via the workflow's
# `permissions:` block at the top of the file (see contents: write).
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
RELEASE_ID=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.id'`
RELEASE_BODY=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.body'`
# Update body
curl --data '{"draft": false,"body":"'"$RELEASE_BODY\n\nFor installation instructions, please visit https://docs.datacontroller.io/"'"}' -X PATCH --header 'Content-Type: application/json' -k https://git.datacontroller.io/api/v1/repos/dc/dc/releases/$RELEASE_ID?access_token=${{ secrets.RELEASE_TOKEN }}
set -euo pipefail
# Send the token via Authorization header rather than ?access_token=
# (the query-string form is deprecated and leaks into access logs).
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
RELEASE_JSON=$(curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
# Update body (also confirms the token has contents:write on this repo)
curl -k --fail-with-body -sS -X PATCH \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg body "$RELEASE_BODY"$'\n\nFor installation instructions, please visit https://docs.datacontroller.io/' \
'{draft:false, body:$body}')" \
"$BASE/releases/$RELEASE_ID"
# Upload assets
URL="https://git.datacontroller.io/api/v1/repos/dc/dc/releases/$RELEASE_ID/assets?access_token=${{ secrets.RELEASE_TOKEN }}"
curl -k $URL -F attachment=@frontend.zip
curl -k $URL -F attachment=@sas/demostream_sas9.sas
curl -k $URL -F attachment=@sas/viya.sas
curl -k $URL -F attachment=@sas/sasjs_server.json.zip
curl -k $URL -F attachment=@sas/sas9.sas
curl -k $URL -F attachment=@sas/viya_noweb.sas
curl -k $URL -F attachment=@sas/viya_noweb.json
URL="$BASE/releases/$RELEASE_ID/assets"
for f in frontend.zip \
sas/demostream_sas9.sas \
sas/viya.sas \
sas/sasjs_server.json.zip \
sas/sas9.sas \
sas/viya_noweb.sas \
sas/viya_noweb.json; do
echo "Uploading $f ..."
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
done
+1
View File
@@ -1,5 +1,6 @@
{
"cSpell.words": [
"Handsontable",
"Licence",
"SYSERRORTEXT",
"SYSWARNINGTEXT",
+110
View File
@@ -1,3 +1,113 @@
# [7.11.0](https://git.datacontroller.io/dc/dc/compare/v7.10.1...v7.11.0) (2026-07-20)
### Bug Fixes
* audit-skipping syntax correction ([ad7efc9](https://git.datacontroller.io/dc/dc/commit/ad7efc90bc33c93383e17359d14c0605cded69a9))
* **editor:** ignore cell validation on rows marked for delete ([70c5805](https://git.datacontroller.io/dc/dc/commit/70c5805743c8c6e61bc216f37cbe8cd4f0614c98))
* explicit use of .sashdat in dc_casload ([6a95067](https://git.datacontroller.io/dc/dc/commit/6a95067618600d7b03b6af46bf3d74721e42c66a))
* refactor mf_wordsinstr1butnotstr2 and added a wide table to test suite ([be94c68](https://git.datacontroller.io/dc/dc/commit/be94c68f354d0f9b1eb52213807d1874a74d9b8a))
* retry the startup service request once before failing ([5391df8](https://git.datacontroller.io/dc/dc/commit/5391df8f04cf40205a600b23c207f97b6fdf5913))
* run dc_casload for dq tables in getdata. Closes [#264](https://git.datacontroller.io/dc/dc/issues/264). ([6b16f9b](https://git.datacontroller.io/dc/dc/commit/6b16f9b3647fca2e39d44544201ce972046e01a3))
* updating tests, moving mpe_rowhash to core library ([c5f0e4f](https://git.datacontroller.io/dc/dc/commit/c5f0e4f106aa4e6a7c2a2fd0ebb2e85d1e7bee7b))
### Features
* bumping core to get the mp_rowhash definition, [#269](https://git.datacontroller.io/dc/dc/issues/269) ([f73f5e4](https://git.datacontroller.io/dc/dc/commit/f73f5e47ac38a305bac4ebd7eac48d1a3edf4a83))
* new hash algorithm - allows 1000+ cols. Closes [#269](https://git.datacontroller.io/dc/dc/issues/269) ([08947f0](https://git.datacontroller.io/dc/dc/commit/08947f0fc8458be098e3aaf769b1da7fadf480cb))
## [7.10.1](https://git.datacontroller.io/dc/dc/compare/v7.10.0...v7.10.1) (2026-07-15)
### Bug Fixes
* **editor:** order validation rules by COLHEADERS, not cols[].VARNUM ([4c2aca3](https://git.datacontroller.io/dc/dc/commit/4c2aca34951d6d5c5cf2df69d5bf4256a9290d53))
# [7.10.0](https://git.datacontroller.io/dc/dc/compare/v7.9.1...v7.10.0) (2026-07-13)
### Bug Fixes
* adapt column validation to vertical-array COLTYPE ([#253](https://git.datacontroller.io/dc/dc/issues/253)) ([3016750](https://git.datacontroller.io/dc/dc/commit/301675052fb5cbcb11f54c370ad7eda1d60461fd))
* backend updates to bring back labels under [#240](https://git.datacontroller.io/dc/dc/issues/240) ([fb94840](https://git.datacontroller.io/dc/dc/commit/fb94840016f067fa3523a3539dd0616d8970eed0))
* **editor:** normalise unpadded time strings on spreadsheet import ([7b4b4eb](https://git.datacontroller.io/dc/dc/commit/7b4b4ebeeb0e34ec5fcd5c485741d7bbddb43b7e))
* **hot:** row rendering on resize, resolve blank whitespace ([c7ba025](https://git.datacontroller.io/dc/dc/commit/c7ba025d39aa9681ad51f239503e884fb26613e2))
* including datadictionary descs in labels, [#240](https://git.datacontroller.io/dc/dc/issues/240) ([e7453a9](https://git.datacontroller.io/dc/dc/commit/e7453a9305263faf4d4f288d50977121353040c7))
* pass variable formats to front-end in a vertical array instead of horizontal list ([691d6f2](https://git.datacontroller.io/dc/dc/commit/691d6f277ea87051f539681b34da2f39cade8fc3))
* remove coltype from sasparams table, add it to cols table. ([8101400](https://git.datacontroller.io/dc/dc/commit/81014001ac4da6b8d262c72c65e0c657f186ec84))
* trim leading/trailing whitespace from Excel header cells on upload ([2728dac](https://git.datacontroller.io/dc/dc/commit/2728dac873ab8c9fc44413f873e4730515ca0e21))
* **va:** date filters refactor, support more dates ([df5c975](https://git.datacontroller.io/dc/dc/commit/df5c9758697d7481e67d20ed7ff81e9ce4e92ec0))
* **va:** enable readOnly and hold filtering while editing ([82a254d](https://git.datacontroller.io/dc/dc/commit/82a254d22cb7aaf75aae7b332f0b07fc8bda3e44))
* **va:** enable Upload button in va mode ([1beb3d4](https://git.datacontroller.io/dc/dc/commit/1beb3d490d0e7b8d5fa7985f121495ed62740cfe))
* **va:** guard deferred contextMenu toggle against destroyed instance ([b5e9b25](https://git.datacontroller.io/dc/dc/commit/b5e9b2531924bec1a23c67d3ad0ca3f512a75655))
### Features
* adding DC_MAXOBS_WEBVIEW config item, closes [#258](https://git.datacontroller.io/dc/dc/issues/258) ([ba7b610](https://git.datacontroller.io/dc/dc/commit/ba7b61082d39767f9539a84de6742a1704573e81))
* viewer/editor column label display toggle (?labels=true) ([25c12f2](https://git.datacontroller.io/dc/dc/commit/25c12f2b18adda09e6edc93eba263bd5ea0f7409))
### Reverts
* enable upload button in va mode ([0d7fd34](https://git.datacontroller.io/dc/dc/commit/0d7fd342970744489a2e6c6fb08f239fe853cb81))
## [7.9.1](https://git.datacontroller.io/dc/dc/compare/v7.9.0...v7.9.1) (2026-06-30)
### Bug Fixes
* workflow file upload using action PAT ([a161670](https://git.datacontroller.io/dc/dc/commit/a161670b8656614f61d42ee673064ba8f010b71c))
# [7.9.0](https://git.datacontroller.io/dc/dc/compare/v7.8.2...v7.9.0) (2026-06-29)
### Bug Fixes
* allow large retained key values. Closes [#248](https://git.datacontroller.io/dc/dc/issues/248). ([6a30de3](https://git.datacontroller.io/dc/dc/commit/6a30de37cad21b7b94bf550ce7afbc804e6f0f90))
* build issues ([#245](https://git.datacontroller.io/dc/dc/issues/245)) ([77b4bb3](https://git.datacontroller.io/dc/dc/commit/77b4bb310e62adb450805488fc69a132435d0eb7))
* **ci:** bump Lighthouse to 13.4.0 ([47ba03e](https://git.datacontroller.io/dc/dc/commit/47ba03ec637113d8867372d64484927de9b645ff))
* **dc-validator:** display date/time/datetime cells as raw ISO, locale-independent ([9ca5719](https://git.datacontroller.io/dc/dc/commit/9ca57193b4aafa1e7bf216301cdf197cea6ec4c9))
* **editor:** await dynamic validation on paste; defer spinner ([ea03bde](https://git.datacontroller.io/dc/dc/commit/ea03bdecc5aaf917052add2c7e6fdc192b9974ce))
* **editor:** cancelEdit will reset cell's valid state ([51071b4](https://git.datacontroller.io/dc/dc/commit/51071b463bb311c6db48c301daa8cc7dfd92144a))
* **editor:** preserve commas in dropdown source values ([6af49cf](https://git.datacontroller.io/dc/dc/commit/6af49cf1fd8aa27cbf99f9514cca537339cdbf7e))
* **editor:** retry initSetup when the Handsontable instance isn't ready ([53b7ee0](https://git.datacontroller.io/dc/dc/commit/53b7ee0cb23329fa4f9d6211ffe6b102053fade9))
* **handsontable:** add exceljs ([38cb1e2](https://git.datacontroller.io/dc/dc/commit/38cb1e207b58822d71ef803e770a93bc7265bc1c))
* **handsontable:** horizontal scrollbar in dropdown ([ac0bd10](https://git.datacontroller.io/dc/dc/commit/ac0bd102127ced6b6a908a7244f35002f22cb669))
* **handsontable:** restore dark mode for v17 ([9d97bf7](https://git.datacontroller.io/dc/dc/commit/9d97bf7ea13199ab9dab10c5fc47e07b4751fb9a))
* **handsontable:** update licenseChecker exclude buffers - exceljs dep ([12c7d30](https://git.datacontroller.io/dc/dc/commit/12c7d30894b5c3709b683189da6e52255ad0a3e7))
* incrementing rk val ([7444209](https://git.datacontroller.io/dc/dc/commit/74442096bac1481a4992e5c97dac47c8d277cc3f))
* **licensing:** add protocol info ([3668a74](https://git.datacontroller.io/dc/dc/commit/3668a7426fec078ad7939b5bc815d7ad920c0d62)), closes [#178](https://git.datacontroller.io/dc/dc/issues/178)
* migrate handsontables to v17 ([1b73e35](https://git.datacontroller.io/dc/dc/commit/1b73e355b78a0039aa48f7a137095eb5470c08e2))
* **mocks:** return dynamic cell values as OBJ to match new contract ([a15fdc4](https://git.datacontroller.io/dc/dc/commit/a15fdc401e6cdcf934a5d599f8b7c4d197e9d2d4))
* removing display_value in SAS code ([9b18a45](https://git.datacontroller.io/dc/dc/commit/9b18a45d354ad617e590d62758ef761b01b3095a))
* rule_type ([adc6424](https://git.datacontroller.io/dc/dc/commit/adc6424211a83fb03b267bf8fa6b6d9fa72f185b))
* sending dynamic_values and dynamic_extended_values back as OBJ instead of ARR ([e859d33](https://git.datacontroller.io/dc/dc/commit/e859d3354e82f0faad53a0b8aa14ed66974cbffa))
* upcasing RAW_VALUE, bumping core, fixing tests ([982d507](https://git.datacontroller.io/dc/dc/commit/982d507ae3ad2ccc08e65678956199cd54b696f2))
* validate pasted values ([b661580](https://git.datacontroller.io/dc/dc/commit/b661580c604c2923b930911a1881aec7b06ecf23))
* viewLibs now fires only once, libPromise shared between the calls ([dc4e07a](https://git.datacontroller.io/dc/dc/commit/dc4e07a692a5e839986abb69b17e51024c4cdc03))
### Features
* **edit-record:** use native date/time/datetime pickers ([2d29346](https://git.datacontroller.io/dc/dc/commit/2d29346cbf09ad057743e6f5ebea12f3257f246e))
* **editor:** add READONLY, HIDDEN, ROUND and NUMBER_FORMAT validation rules ([4ea604f](https://git.datacontroller.io/dc/dc/commit/4ea604f9fbdd429108fe063b306ea376e756c107))
* **editor:** migrate date/time/datetime to HOT 17 Intl cell types ([f70ea2f](https://git.datacontroller.io/dc/dc/commit/f70ea2fe712ff9a42bf47387f9f0aaa6d1a1f511))
* **editor:** paste-validation overlay with cancel and confirm ([609731f](https://git.datacontroller.io/dc/dc/commit/609731ff99185a162614df13bde7afb1a2c3bbca))
* **editor:** SAS VA data-driven content embed mode (?embed=va) ([ffa3ff9](https://git.datacontroller.io/dc/dc/commit/ffa3ff9c103d6d80677f4f67e7d53418f201f491))
* **editor:** VA embed filter modes with pending UX ([ebe6972](https://git.datacontroller.io/dc/dc/commit/ebe6972dd7d38c5742ed7eb446e0448d98b2c8a7))
* **editor:** validate autofilled cells; fix paste validation lag ([11ee49a](https://git.datacontroller.io/dc/dc/commit/11ee49a57a0bc7ccd59e91d5fe37bb5db1bf7d69))
* **handsontable:** enable export plugin, add export menu items ([29aaa72](https://git.datacontroller.io/dc/dc/commit/29aaa72c6047bb309d325887ab602a9369a1ad74))
* migration script for adding new validation rules under [#241](https://git.datacontroller.io/dc/dc/issues/241) ([9f84a58](https://git.datacontroller.io/dc/dc/commit/9f84a580fb762da8c1c796a35eb4f531df1caf13))
* new validation rules in mpe_selectbox. [#241](https://git.datacontroller.io/dc/dc/issues/241) ([2f5d3df](https://git.datacontroller.io/dc/dc/commit/2f5d3dfc387f02353601aaa844aaeabde3e9f3e5))
### Reverts
* eager-load feature modules instead of lazy-loading ([3429a7a](https://git.datacontroller.io/dc/dc/commit/3429a7a2a6ecfa6115394e1c842061cc68bf2be3))
* **editor:** DISPLAY_VALUE change ([eb015d7](https://git.datacontroller.io/dc/dc/commit/eb015d712b01687f36ac92e38ab576a7c114b1ba))
## [7.8.2](https://git.datacontroller.io/dc/dc/compare/v7.8.1...v7.8.2) (2026-05-20)
+24
View File
@@ -16,6 +16,21 @@ Add `client/.npmrc` file with following content:
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
```
In addition to the registry auth, the `@sheet/crypto` package is installed from a local tarball
(`client/libraries/sheet-crypto.tgz`) rather than the registry. This tarball is not committed to
the repo directly - only an encrypted copy is: `client/libraries/sheet-crypto.tgz.gpg`.
Before running `npm i`/`npm ci` in `client`, you must decrypt it using the passphrase
(stored as the `SHEET_PWD` CI secret - ask a repo maintainer if you don't have it):
```bash
cd client
echo "YOUR_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
npm i
```
[Handsontable](https://www.npmjs.com/package/handsontable)
Licence should be inserted in the `client/src/index.html` file:
@@ -66,6 +81,15 @@ To remove the release, you need to do it with repo administration over at [https
# Troubleshooting
## `npm i` fails with ENOENT on `sheet-crypto.tgz`
If `npm i`/`npm ci` in `client` fails with:
```
npm error enoent ENOENT: no such file or directory, open '.../client/libraries/sheet-crypto.tgz'
```
it means the encrypted `sheet-crypto.tgz.gpg` hasn't been decrypted yet. See
[Dependencies that requires licences](#dependencies-that-requires-licences) above for how to decrypt it.
## Makedata service "could not create directory" error
The dcpath folder should have its permissions set so that the system account (SYSUSERID) can both read and write to it.
+4
View File
@@ -0,0 +1,4 @@
# Auto-generated at build time (node ./src/version.ts) — gitignored, never linted.
# Formerly skipped via root .gitignore when prettier ran from the repo root;
# now that lint runs from client/, prettier reads this CWD-local ignore instead.
src/environments/version.ts
+8 -1
View File
@@ -68,7 +68,14 @@
}
],
"styles": ["src/styles.scss"],
"scripts": ["node_modules/marked/marked.min.js"],
"scripts": [
"node_modules/marked/marked.min.js",
{
"input": "src/assets/va-early.js",
"bundleName": "va-early",
"inject": true
}
],
"webWorkerTsConfig": "tsconfig.worker.json",
"main": "src/main.ts"
},
@@ -0,0 +1,132 @@
// Marks this file as an ES module (rather than a global script) so its
// top-level consts don't collide, under the TS type-checker, with the same
// names declared in other spec files — see viewer-labels.cy.ts for the same
// pattern.
export {}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// Regression coverage for issue #253's vertical-array COLTYPE fix — see
// getdata.sas now sends one COLTYPE JSON-object string per cols[] row (via
// a LEFT JOIN keyed off the real dataset's columns), instead of one
// comma-joined sasparams.COLTYPE string covering every column. Two things
// are specifically at risk from that change and aren't covered elsewhere:
// 1. `_____DELETE__THIS__RECORD_____` (the delete checkbox column) is a
// client-side-only concept — %mp_getcols never has a row for it, so
// it can no longer travel via cols[].COLTYPE at all. Its Yes/No
// dropdown rule is now hardcoded client-side (deleteRecordColumnRule.ts)
// — test 1 proves that hardcode actually renders end to end.
// 2. Ordinary columns (e.g. SOME_DROPDOWN) still get their rule via the
// new per-row cols[].COLTYPE path — test 2 proves the migration didn't
// silently drop real columns' rules either.
// Column positions below come from COLHEADERS in
// sas/mocks/sasjs/services/editors/getdata.js:
// "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,..."
// — childNodes[0] on a body <tr> is the row-header <th>, so childNodes[N+1]
// is the Nth (0-indexed) column in that list.
context('coltype vertical-array regression tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
it('1 | delete-record column renders its hardcoded Yes/No dropdown', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit()
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).then(
(rows: any) => {
// childNodes[1] = _____DELETE__THIS__RECORD_____ (1st column)
cy.get(rows[1].childNodes[1])
.click({ force: true })
.then(($td) => {
cy.get('.htAutocompleteArrow', { withinSubject: $td })
.should('exist')
.click({ force: true })
cy.get('.autocompleteEditor .htCore tbody td').should(
($choices) => {
const texts = [...$choices].map((el) => el.innerText.trim())
expect(texts).to.deep.equal(['No', 'Yes'])
}
)
})
}
)
})
it('2 | an ordinary dropdown column (SOME_DROPDOWN) still gets its rule from cols[].COLTYPE', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit()
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).then(
(rows: any) => {
// childNodes[4] = SOME_DROPDOWN (4th column, 0-indexed as 3). This
// only holds if DcValidator emits rules in VARNUM order — see the
// "orders rules by VARNUM" regression test in dc-validator.spec.ts.
cy.get(rows[1].childNodes[4])
.click({ force: true })
.then(($td) => {
cy.get('.htAutocompleteArrow', { withinSubject: $td }).should(
'exist'
)
})
}
)
})
})
const clickOnEdit = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container > button').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
+101
View File
@@ -115,8 +115,109 @@ context('editor tests: ', function () {
})
})
})
// An invalid cell shouldn't block submission if its row
// is marked for delete
it('5 | Submits a delete-marked row with an invalid cell', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
cy.get('.ht_master tbody tr').then((rows: any) => {
// childNodes[5] = SOME_NUM (numeric) — feed it a non-numeric value
// to trigger HOT's native invalid mark.
cy.get(rows[1].childNodes[5])
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('not a number{enter}')
.then(() => {
// childNodes[1] = _____DELETE__THIS__RECORD_____
setDeleteFlag(rows[1].childNodes[1], 'Yes', () => {
submitTable(() => {
// Validation passed despite the invalid SOME_NUM cell:
// the confirm-submit modal's Submit button is enabled
// (validationDone === 1), not the "invalid values" abort.
cy.get('#submitBtn', {
timeout: longerCommandTimeout
})
.should('exist')
.should('not.be.disabled')
.then(() => done())
})
})
})
})
})
})
})
})
// The delete exemption must not affect the uniqueness (primary key) check.
it('6 | Still blocks a duplicate primary key even on a delete-marked row', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
cy.get('.ht_master tbody tr').then((rows: any) => {
cy.get(rows[0].childNodes[2]).then((firstPk) => {
const duplicateValue = firstPk.text()
// childNodes[2] = PRIMARY_KEY_FIELD
cy.get(rows[1].childNodes[2])
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type(`${duplicateValue}{enter}`)
.then(() => {
setDeleteFlag(rows[1].childNodes[1], 'Yes', () => {
submitTable()
cy.get('.duplicate-keys-modal', {
timeout: longerCommandTimeout
})
.should('exist')
.then(() => done())
})
})
})
})
})
})
})
})
})
// Opens the _____DELETE__THIS__RECORD_____ dropdown editor on the given cell
// and picks the given choice ('Yes'/'No') — same technique as
// coltype-delete-record.cy.ts (arrow -> autocompleteEditor choice list).
const setDeleteFlag = (
deleteCell: any,
value: 'Yes' | 'No',
callback?: any
) => {
cy.get(deleteCell)
.click({ force: true })
.then(($td) => {
cy.get('.htAutocompleteArrow', { withinSubject: $td })
.should('exist')
.click({ force: true })
cy.get('.autocompleteEditor .htCore tbody td')
.contains(value)
.click({ force: true })
.then(() => {
if (callback) callback()
})
})
}
const clickOnEdit = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
+130
View File
@@ -0,0 +1,130 @@
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}`)
// SAS Visual Analytics data-driven content embed mode (`?embed=va`):
// - chrome is hidden (same as embed=true),
// - the editor opens in edit mode immediately (not read-only),
// - the only action button is a single Submit below the grid (stubbed in v1),
// - the top button bar and Add Record are hidden.
//
// The detailed PK+label data-merge logic is covered by the VaMessagingService
// unit spec; here we only assert the deterministic UI layout and that pushing a
// VA postMessage does not break the page.
context('embed=va (VA data-driven content) tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
it('1 | opens the editor in VA mode: chrome hidden, editable, single bottom Submit', () => {
openEditorInVaMode('mpe_x_test', () => {
// Chrome hidden (same as embed=true)
cy.get('header.app-header').should('not.exist')
// Grid present and in edit mode (read-only Filter/Edit/Upload bar absent)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('.btnCtrl button').contains('Edit').should('not.exist')
cy.get('.btnCtrl button').contains('Filter').should('not.exist')
// Top edit-mode buttons hidden in VA mode
cy.get('.btnCtrl button.btn-outline-danger').should('not.exist') // Cancel
cy.get('.btnCtrl').contains('Add Row').should('not.exist')
// Bottom Add Record hidden
cy.contains('button', 'Add Record').should('not.exist')
// Exactly one Submit button, below the grid, disabled in v1
cy.get('button').filter(':contains("Submit")').should('have.length', 1)
cy.get('button').contains('Submit').should('be.disabled')
})
})
it('2 | accepts a VA postMessage and stays in VA mode', () => {
// A VA message drives column visibility + filtering; an empty/unmatched
// message reloads the editor (unfiltered) while preserving ?embed=va.
openEditorInVaMode('mpe_x_test', () => {
cy.window().then((win) => {
win.postMessage(
{
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: []
},
'*'
)
})
// Editor still loads in VA mode after the message (chrome hidden, grid up)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('header.app-header').should('not.exist')
cy.get('button').contains('Submit').should('exist')
})
})
})
// Opens a table from the tree (which routes to #/editor/LIB.TABLE), then
// re-visits the same route with ?embed=va so the app parses VA embed mode.
const openEditorInVaMode = (tablename: string, callback?: any) => {
openTableFromTree(libraryToOpenIncludes, tablename)
cy.get('#hotTable', { timeout: longerCommandTimeout })
.should('exist')
.then(() => {
cy.url().then((url) => {
const separator = url.includes('?') ? '&' : '?'
cy.visit(`${url}${separator}embed=va`)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
if (callback) callback()
})
})
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container > button').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
+18 -5
View File
@@ -386,6 +386,19 @@ context('excel tests: ', function () {
})
})
it('24 | Uploads Excel with leading whitespace in header row (should still succeed)', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// Every header except the first has a leading space, matching a
// real-world defect from copy-pasting between spreadsheets/systems.
// Header matching must tolerate
// this rather than reporting the columns missing and aborting.
attachExcelFile('leading_whitespace_header_excel.xlsx', () => {
submitExcel()
rejectExcel(done)
})
})
// Large files break Cypress
// it ('? | Uploads Excel with size of 5MB', (done) => {
@@ -559,13 +572,13 @@ const checkResultOfXLSUpload = (callback?: any) => {
expect(cell.innerText).to.equal('▼\nOption 1')
cell = data[0].children[0].children[5]
expect(cell.innerText).to.equal('42')
// Cells render locale-formatted (Cypress pins --lang=en-GB); stored value
// stays raw ISO. date -> dd/mm/yyyy, datetime -> dd/mm/yyyy, HH:mm:ss,
// time -> HH:mm:ss (en-GB 24h, identical to the raw ISO time).
// Cells render the raw stored ISO string verbatim (locale-independent;
// see registerIntlCellTypes). date -> YYYY-MM-DD, datetime ->
// YYYY-MM-DD HH:mm:ss, time -> HH:mm:ss.
cell = data[0].children[0].children[6]
expect(cell.innerText).to.equal('12/02/1960')
expect(cell.innerText).to.equal('1960-02-12')
cell = data[0].children[0].children[7]
expect(cell.innerText).to.equal('01/01/1960, 00:00:42')
expect(cell.innerText).to.equal('1960-01-01 00:00:42')
cell = data[0].children[0].children[8]
expect(cell.innerText).to.equal('00:00:42')
+32
View File
@@ -36,6 +36,38 @@ context('liveness tests: ', function () {
})
})
// Multiple concurrent DC instances can cause the startupservice request
// to time out even though a retry succeeds. Fail only the first
// startupservice call (any later ones, e.g. from other tests/reloads,
// pass through untouched) and confirm the app still reaches the nav tree
// instead of showing the startup error modal.
it('2 | Recovers from a failed startupservice request via the single retry', () => {
let startupServiceCallCount = 0
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
const isStartupService = JSON.stringify(req.body || '').includes(
'startupservice'
)
if (isStartupService) startupServiceCallCount++
if (isStartupService && startupServiceCallCount === 1) {
req.reply({ statusCode: 503, body: 'Service unavailable' })
} else {
req.continue()
}
}).as('stpExecute')
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
// Longer than the retry's own 0-3s pause on top of the normal timeout.
timeout: longerCommandTimeout + 3000
}).should('exist')
cy.get('.abortMsg').should('not.exist')
})
/**
* Thist part will be needed if we add more tests in future
*/
+243
View File
@@ -0,0 +1,243 @@
// Marks this file as an ES module (rather than a global script) so its
// top-level consts don't collide, under the TS type-checker, with the same
// names declared in other spec files — see e.g. download.cy.ts, which gets
// this for free via a real import.
export {}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// Viewer column-label display toggle (`?labels=true`): clients surfacing DC in
// SAS Visual Analytics want column LABELs shown instead of NAMEs. Default
// behavior (no param) must stay unchanged; the right-click context menu's
// "Show labels"/"Show names" item is the in-app affordance, and it drives the
// same URL param so state is shareable/refreshable. See mock data in
// sas/mocks/sasjs/services/public/viewdata.js: MPE_X_TEST's SOME_CHAR/SOME_DATE
// have LABELs that differ from NAME, and SOME_NUM has a blank LABEL (fallback
// to NAME) — this spec exercises exactly that fixture.
context('viewer column labels toggle tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
// forceLicenceKey=true: without it, loginAndUpdateValidKey() only
// generates key data and logs back out if no valid license already
// exists — it doesn't actually apply one (see commands.ts). Several
// specs call it with no argument and rely on an *earlier* spec in the
// same CI run having already forced a valid license onto the shared
// server first (csv.cy.ts, excel.cy.ts, etc.). This spec shouldn't
// depend on run order, so force it explicitly.
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
// No re-login here: beforeAll's loginAndUpdateValidKey() already
// authenticated the session, and it persists across tests in this file
// (see filtering.cy.ts/csv.cy.ts for the same pattern). Re-typing into
// the login form would fail — it's hidden once already logged in.
cy.visit(hostUrl + appLocation)
// Visit 'home' first, matching filtering.cy.ts/csv.cy.ts/download.cy.ts —
// going straight to 'view/data' skips whatever startup-data fetch 'home'
// triggers, which passed locally (fast round-trips) but left the
// nav-tree empty in CI's slower environment.
visitPage('home')
visitPage('view/data')
})
it('1 | default (no param): headers show NAMEs', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('SOME_CHAR')
expect($headerRow[0].innerHTML).to.not.include('Some Character Column')
})
})
it('2 | ?labels=true: headers show LABELs, blank LABEL falls back to NAME', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
appendUrlParam('labels=true')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
const html = $headerRow[0].innerHTML
expect(html).to.include('Some Character Column')
expect(html).to.include('Some Date')
// SOME_NUM has a blank LABEL in the mock -> falls back to NAME
expect(html).to.include('SOME_NUM')
})
})
it('3 | context menu "Show labels"/"Show names" toggles headers and the URL', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
toggleColumnLabelsFromContextMenu('Show labels')
cy.url().should('include', 'labels=true')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('Some Character Column')
})
toggleColumnLabelsFromContextMenu('Show names')
cy.url().should('not.include', 'labels=true')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('SOME_CHAR')
})
})
it('4 | ?embed=va&labels=true: labels shown with embed chrome hidden', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
appendUrlParam('embed=va&labels=true')
cy.get('header.app-header').should('not.exist')
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
timeout: longerCommandTimeout
}).should(($headerRow) => {
expect($headerRow[0].innerHTML).to.include('Some Character Column')
})
})
it('5 | info dropdown: first line is NAME (in both toggle states)', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// No assertion on the item's label text here: `info` has a custom
// `renderer`, so Handsontable never renders its `name` ("test info") as
// text at all — the renderer's own output (checked below) is the only
// real content.
openColumnDropdown('SOME_CHAR')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.match(/NAME: SOME_CHAR/)
})
cy.get('body').click(0, 0) // close menu
appendUrlParam('labels=true')
openColumnDropdown('Some Character Column')
cy.get('.htDropdownMenu').should(($menu) => {
// NAME is always shown first, even while headers display as LABEL
expect($menu.text()).to.match(/NAME: SOME_CHAR/)
})
})
})
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
// Re-visits the current route with extra hash-query params appended, then
// forces a real reload. Under hash routing (`useHash: true`), changing only
// the URL's hash fragment is a same-document navigation — Angular Router
// still reacts to it (which is enough for `useLabels`, read reactively from
// `route.queryParams`), but `embed` is parsed once from `window.location.hash`
// at app bootstrap (app.component.ts), so `?embed=va` only takes effect after
// an actual reload.
const appendUrlParam = (param: string) => {
cy.url().then((url) => {
const separator = url.includes('?') ? '&' : '?'
cy.visit(`${url}${separator}${param}`)
cy.reload()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
})
}
const openColumnDropdown = (headerText: string) => {
// Handsontable renders the sticky/frozen header via a separate clone pane
// (.ht_clone_top) for scroll behavior; the header row inside the main
// .ht_master pane is kept visibility:hidden (replaced visually by the
// clone). .ht_clone_top is the real, interactive one — confirmed via
// `document.querySelector('#hotTable .ht_clone_top thead').innerText` in
// a live browser session.
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
})
.parents('th')
.filter((_, th) => Cypress.$(th).text().includes(headerText))
.last()
.as('targetHeader')
// Click the header text first to select the column — the `info` item's
// renderer reads hot.getSelected() to decide which column to describe,
// so without an active selection it always shows "No info found".
cy.get('@targetHeader').click()
cy.get('@targetHeader').find('button.changeType').click({ force: true })
}
const toggleColumnLabelsFromContextMenu = (
menuItemText: 'Show labels' | 'Show names'
) => {
cy.get('#hotTable .ht_master.handsontable .htCore tbody tr td', {
timeout: longerCommandTimeout
})
.first()
// force: true — the first body row can sit under an overlay clone's
// header/sort-icon layer, which fails Cypress's actionability check
// even though the cell is the real rightclick target underneath.
.rightclick({ force: true })
cy.get('.htContextMenu').contains(menuItemText).click()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
// Small settle wait: right after a fresh visit/reload, the tree
// component can still be re-rendering, causing this node to be
// found then swapped out mid-click ("disappeared from the page").
cy.wait(300)
cy.get(
'.clr-tree-node-content-container .clr-treenode-content p'
).click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
// Selecting the table triggers async SPA routing + a viewdata fetch;
// wait for the grid to actually render before any subsequent action
// (reading the URL, right-clicking, opening a header dropdown) — without
// this, callers can act while still on the intermediate library-only
// route or a not-yet-rendered grid. Waiting specifically for a header's
// dropdown button (not just the <tr> shell) confirms Handsontable has
// finished populating header cell contents, not just their DOM rows.
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
}).should('exist')
}
+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) {
+306 -197
View File
@@ -20,7 +20,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",
@@ -33,7 +33,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",
@@ -96,6 +96,7 @@
"lodash-es": "^4.17.21",
"mochawesome": "^7.1.3",
"mutationobserver-shim": "^0.3.3",
"prettier": "3.8.4",
"replace-in-file": "^6.3.5",
"rimraf": "3.0.2",
"ts-loader": "^9.2.8",
@@ -4360,25 +4361,19 @@
}
},
"node_modules/@handsontable/angular-wrapper": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/@handsontable/angular-wrapper/-/angular-wrapper-17.1.0.tgz",
"integrity": "sha512-5jUCb4E1eZcy/CJ3T39/cxauy9NoQh+wNWTDW9ZhIIudr/00HvfJlcEozN5RWSYxfGNw71prVM2y16DSd+ct9A==",
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/@handsontable/angular-wrapper/-/angular-wrapper-18.0.0.tgz",
"integrity": "sha512-1DTV9lqE7vn2sZbB2hyrMcqhjnGWzYAHPGDGKuJlppzugjZcobYTPIFv5Dn9NOBUrKxRyPXI5dUb5SqwzYbJNw==",
"license": "SEE LICENSE IN LICENSE.txt",
"optionalDependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/core": ">=16.0.0 <22.0.0",
"handsontable": "^17.0.0",
"@angular/core": ">=16.0.0 <23.0.0",
"handsontable": "^18.0.0",
"rxjs": "^7.0.0"
}
},
"node_modules/@handsontable/pikaday": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@handsontable/pikaday/-/pikaday-1.0.0.tgz",
"integrity": "sha512-1VN6N38t5/DcjJ7y7XUYrDx1LuzvvzlrFdBdMG90Qo1xc8+LXHqbWbsTEm5Ec5gXTEbDEO53vUT35R+2COmOyg==",
"license": "(0BSD OR MIT)"
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -6087,9 +6082,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6107,9 +6099,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6127,9 +6116,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6147,9 +6133,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6167,9 +6150,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6187,9 +6167,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6207,9 +6184,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7345,9 +7319,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7369,9 +7340,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7393,9 +7361,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7417,9 +7382,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7441,9 +7403,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7465,9 +7424,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7693,9 +7649,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7710,9 +7663,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7727,9 +7677,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7744,9 +7691,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7761,9 +7705,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7778,9 +7719,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7795,9 +7733,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7812,9 +7747,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7829,9 +7761,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7846,9 +7775,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7863,9 +7789,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7880,9 +7803,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7897,9 +7817,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -9072,15 +8989,15 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz",
"integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz",
"integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.59.1",
"@typescript-eslint/types": "^8.59.1",
"@typescript-eslint/tsconfig-utils": "^8.63.0",
"@typescript-eslint/types": "^8.63.0",
"debug": "^4.4.3"
},
"engines": {
@@ -9127,9 +9044,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz",
"integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz",
"integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -9207,9 +9124,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
"integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -9289,17 +9206,17 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz",
"integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1"
"@typescript-eslint/scope-manager": "8.63.0",
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/typescript-estree": "8.63.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -9314,15 +9231,15 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz",
"integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1"
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -9333,17 +9250,17 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
"integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/project-service": "8.59.1",
"@typescript-eslint/tsconfig-utils": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"@typescript-eslint/project-service": "8.63.0",
"@typescript-eslint/tsconfig-utils": "8.63.0",
"@typescript-eslint/types": "8.63.0",
"@typescript-eslint/visitor-keys": "8.63.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -9362,14 +9279,14 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
"integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/types": "8.63.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -9392,9 +9309,9 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -9437,9 +9354,9 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"peer": true,
@@ -10568,15 +10485,6 @@
"node": "*"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/binary": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
@@ -13233,15 +13141,6 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -15404,30 +15303,10 @@
}
},
"node_modules/handsontable": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/handsontable/-/handsontable-17.1.0.tgz",
"integrity": "sha512-3afpoGQT04i11IYge4gZNHp4Io3RFoLi+vVk4yTc2oHGjuBJBhjVVlTnOcYM8N9Ay1ikjSh4T3ZiwFVYEsgJrA==",
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"@handsontable/pikaday": "^1.0.0",
"dompurify": "^3.1.7",
"moment": "2.30.1",
"numbro": "2.5.0"
},
"optionalDependencies": {
"hyperformula": "^3.0.0"
}
},
"node_modules/handsontable/node_modules/hyperformula": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/hyperformula/-/hyperformula-3.3.0.tgz",
"integrity": "sha512-Mkc7AxP9WQDM4xHo1/XpAwzcpMLkwMand4HO9rfRzP502TBzoR1Z96zoCzRrwXodLSVyh+MD7FvzRVALDxmRFQ==",
"license": "GPL-3.0-only",
"optional": true,
"dependencies": {
"chevrotain": "^6.5.0",
"tiny-emitter": "^2.1.0"
}
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/handsontable/-/handsontable-18.0.0.tgz",
"integrity": "sha512-U4H6gDrwGTfjahSyoGHhFHZvOZWB7ReQ3iBvJUIcZPdqdppdZ+FSYNovw+3MH1guJfGgRIhoLYnbSm8Gd6Ft8w==",
"license": "SEE LICENSE IN LICENSE.txt"
},
"node_modules/has-flag": {
"version": "4.0.0",
@@ -19192,9 +19071,9 @@
}
},
"node_modules/mocha": {
"version": "11.7.5",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz",
"integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==",
"version": "11.7.6",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz",
"integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -19238,9 +19117,9 @@
"peer": true
},
"node_modules/mocha/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -19297,10 +19176,20 @@
}
},
"node_modules/mocha/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
@@ -20170,18 +20059,6 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/numbro": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/numbro/-/numbro-2.5.0.tgz",
"integrity": "sha512-xDcctDimhzko/e+y+Q2/8i3qNC9Svw1QgOkSkQoO0kIPI473tR9QRbo2KP88Ty9p8WbPy+3OpTaAIzehtuHq+A==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^8 || ^9"
},
"engines": {
"node": "*"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -21323,6 +21200,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.8.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -21445,6 +21338,26 @@
"node": ">= 14"
}
},
"node_modules/proxy-agent-negotiate": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz",
"integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"kerberos": "^2.0.0"
},
"peerDependenciesMeta": {
"kerberos": {
"optional": true
}
}
},
"node_modules/proxy-agent/node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
@@ -21591,6 +21504,50 @@
}
}
},
"node_modules/puppeteer-core/node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/data-uri-to-buffer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz",
"integrity": "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/degenerator": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-7.0.1.tgz",
"integrity": "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"ast-types": "^0.13.4",
"escodegen": "^2.1.0",
"esprima": "^4.0.1"
},
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"quickjs-wasi": "^2.2.0"
}
},
"node_modules/puppeteer-core/node_modules/devtools-protocol": {
"version": "0.0.1624250",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz",
@@ -21598,6 +21555,149 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/puppeteer-core/node_modules/get-uri": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-uri/-/get-uri-8.0.1.tgz",
"integrity": "sha512-/5N/P4Lrh0p/mDwlDRi7Y1+P2o/OyzZI3l6Iz1Ov6XXwwm1y3RlZLuo3gVgML99djrEDtV980bBxSuOeHLk8ww==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"basic-ftp": "^5.3.1",
"data-uri-to-buffer": "8.0.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/http-proxy-agent": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz",
"integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"proxy-agent-negotiate": "1.1.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/https-proxy-agent": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
"integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"proxy-agent-negotiate": "1.1.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/puppeteer-core/node_modules/pac-proxy-agent": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-9.1.0.tgz",
"integrity": "sha512-1aU+1mpj3DrQPfo3gh+3Gap3G5x+axnMx1P/y0ZF2ch7kb2meyOCAH8K2k9d27ROsTE7TnAerzxqF9aon2jqnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"get-uri": "8.0.1",
"http-proxy-agent": "9.1.0",
"https-proxy-agent": "9.1.0",
"pac-resolver": "9.0.1",
"quickjs-wasi": "^2.2.0",
"socks-proxy-agent": "10.1.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/pac-resolver": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-9.0.1.tgz",
"integrity": "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"degenerator": "7.0.1",
"netmask": "^2.0.2"
},
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"quickjs-wasi": "^2.2.0"
}
},
"node_modules/puppeteer-core/node_modules/proxy-agent": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-8.0.2.tgz",
"integrity": "sha512-idLLRewuemWd7GH/BDJzGiB0dWGfT2SQs3jy6NtZtGWU9uPTTSdeC1/cdbqLwgzhfv027daGFuXX426e2Eg20A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"http-proxy-agent": "9.1.0",
"https-proxy-agent": "9.1.0",
"lru-cache": "^7.14.1",
"pac-proxy-agent": "9.1.0",
"proxy-from-env": "^2.0.0",
"socks-proxy-agent": "10.1.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/socks-proxy-agent": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz",
"integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/puppeteer-core/node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
@@ -21673,6 +21773,15 @@
],
"license": "MIT"
},
"node_modules/quickjs-wasi": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz",
"integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/ramda": {
"version": "0.29.1",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz",
@@ -25035,9 +25144,9 @@
"license": "MIT"
},
"node_modules/vis-data": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.3.tgz",
"integrity": "sha512-jhnb6rJNqkKR1Qmlay0VuDXY9ZlvAnYN1udsrP4U+krgZEq7C0yNSKdZqmnCe13mdnf9AdVcdDGFOzy2mpPoqw==",
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.4.tgz",
"integrity": "sha512-TsN0sMHqIRpdfg6TNPtfdINpkgxtnQP6JNWCaiSwvou5seXqKiP5eERkaBg+Y56wyJ4FZTeOEs/dEmWEPrpltQ==",
"dev": true,
"license": "(Apache-2.0 OR MIT)",
"peer": true,
@@ -25046,7 +25155,7 @@
"url": "https://opencollective.com/visjs"
},
"peerDependencies": {
"uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0",
"uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0 || ^14.0.0",
"vis-util": ">=6.0.0"
}
},
+8 -3
View File
@@ -17,7 +17,11 @@
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
"viyabuild": "cd build; ./viyabuild.sh",
"lint": "cd .. && npm run lint",
"lint": "npm run lint:fix",
"lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:fix:silent": "npx prettier --log-level silent --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check:silent": "npx prettier --log-level silent --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"test": "npx ng test",
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
"watch": "ng test watch=true",
@@ -50,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",
@@ -63,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",
@@ -126,6 +130,7 @@
"lodash-es": "^4.17.21",
"mochawesome": "^7.1.3",
"mutationobserver-shim": "^0.3.3",
"prettier": "3.8.4",
"replace-in-file": "^6.3.5",
"rimraf": "3.0.2",
"ts-loader": "^9.2.8",
+7 -1
View File
@@ -55,7 +55,12 @@ export interface HandsontableStaticConfig {
* Cached viyaApi collections, search and selected endpoint
*/
export const globals: {
embed: boolean
embed: boolean | 'va'
/**
* VA embed filter behavior: 'live' = auto-apply, 'confirm' = manual Apply.
* Defaults to 'live'; toggled by the Auto-apply checkbox in the editor.
*/
vaApplyMode: 'live' | 'confirm'
rootParam: string
dcLib: string
xlmaps: XLMapListItem[]
@@ -71,6 +76,7 @@ export const globals: {
[key: string]: any
} = {
embed: false,
vaApplyMode: 'live',
rootParam: <string>'',
dcLib: '',
xlmaps: [],
+20
View File
@@ -256,6 +256,26 @@
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
<!-- VA embed, back to Editor button -->
<div
*ngIf="embed === 'va' && vaEditorLibds && !isMainRoute('/editor')"
class="va-back-bar"
>
<button
type="button"
class="btn btn-sm btn-primary"
(click)="backToEditor()"
>
<clr-icon
aria-hidden="true"
shape="caret"
dir="left"
size="16"
></clr-icon>
Back to Edit table
</button>
</div>
<router-outlet *ngIf="startupDataLoaded"></router-outlet>
<app-login></app-login>
+10
View File
@@ -0,0 +1,10 @@
.va-back-bar {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
justify-content: flex-start;
padding: 0.35rem 0.6rem;
background: #fafafa;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
+24 -7
View File
@@ -11,6 +11,7 @@ import { Location } from '@angular/common'
import '@clr/icons'
import '@clr/icons/shapes/all-shapes'
import { globals } from './_globals'
import { parseEmbedParam } from './shared/utils/parse-embed-param'
import moment from 'moment'
import { EventService } from './services/event.service'
import { AppService } from './services/app.service'
@@ -145,13 +146,10 @@ export class AppComponent {
})
const hashQuery = window.location.hash.split('?')[1]
if (hashQuery) {
const embedParam = new URLSearchParams(hashQuery).get('embed')
if (embedParam !== null) {
const isEmbed = embedParam !== 'false'
globals.embed = isEmbed
this.embed = isEmbed
}
if (hashQuery && new URLSearchParams(hashQuery).get('embed') !== null) {
const embed = parseEmbedParam(hashQuery)
globals.embed = embed
this.embed = embed
}
this.subscribeToShowAbortModal()
@@ -354,4 +352,23 @@ export class AppComponent {
public openLicencingPage() {
this.router.navigateByUrl('/licensing/update')
}
/** "lib.table" of the last-opened editor table (for the VA embed back button). */
get vaEditorLibds(): string {
const e = globals.editor
return e?.library && e?.table ? `${e.library}.${e.table}` : ''
}
/**
* VA embed only: returns to the editor for the last-opened table. Lets users
* who land on the stage/approve/review pages get back without knowing DC's
* page paths. `globals.embed` (set at app init) keeps the editor in VA mode.
*/
public backToEditor(): void {
if (this.vaEditorLibds) {
this.router.navigate(['/editor/' + this.vaEditorLibds], {
queryParams: { embed: 'va' }
})
}
}
}
+16 -15
View File
@@ -8,7 +8,15 @@ import { RouterModule, Routes } from '@angular/router'
import { NotFoundComponent } from './not-found/not-found.component'
import { DeployModule } from './deploy/deploy.module'
import { EditorModule } from './editor/editor.module'
import { HomeModule } from './home/home.module'
import { LicensingModule } from './licensing/licensing.module'
import { ReviewModule } from './review/review.module'
import { ReviewRouteComponent } from './routes/review-route/review-route.component'
import { StageModule } from './stage/stage.module'
import { SystemModule } from './system/system.module'
import { ViewerModule } from './viewer/viewer.module'
/**
* Defining routes
@@ -17,8 +25,7 @@ export const ROUTES: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{
path: 'view',
loadChildren: () =>
import('./viewer/viewer.module').then((m) => m.ViewerModule)
loadChildren: () => ViewerModule
},
{
/**
@@ -30,42 +37,36 @@ export const ROUTES: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'toapprove' },
{
path: '',
loadChildren: () =>
import('./review/review.module').then((m) => m.ReviewModule)
loadChildren: () => ReviewModule
}
]
},
{
path: 'licensing',
loadChildren: () =>
import('./licensing/licensing.module').then((m) => m.LicensingModule)
loadChildren: () => LicensingModule
},
{
path: 'home',
loadChildren: () => import('./home/home.module').then((m) => m.HomeModule)
loadChildren: () => HomeModule
},
{
/**
* Load editor module with subroutes
*/
path: 'editor',
loadChildren: () =>
import('./editor/editor.module').then((m) => m.EditorModule)
loadChildren: () => EditorModule
},
{
path: 'stage',
loadChildren: () =>
import('./stage/stage.module').then((m) => m.StageModule)
loadChildren: () => StageModule
},
{
path: 'system',
loadChildren: () =>
import('./system/system.module').then((m) => m.SystemModule)
loadChildren: () => SystemModule
},
{
path: 'deploy',
loadChildren: () =>
import('./deploy/deploy.module').then((m) => m.DeployModule)
loadChildren: () => DeployModule
},
{ path: '**', component: NotFoundComponent }
]
+62 -1
View File
@@ -257,6 +257,7 @@
>
<ng-container *ngIf="hotTable.readOnly && !uploadPreview">
<button
*ngIf="!isVaEmbed"
type="button"
class="btnView btn icon-collapse btn-sm btn-icon btn-block btn-dimmed"
(click)="openQb()"
@@ -435,7 +436,9 @@
<div>
<clr-tooltip
*ngIf="tableTrue && !restrictions.removeAddRecordButton"
*ngIf="
tableTrue && !restrictions.removeAddRecordButton && !isVaEmbed
"
>
<button
clrTooltipTrigger
@@ -466,6 +469,64 @@
>
</clr-tooltip-content>
</clr-tooltip>
<!-- VA data-driven content mode: filter controls only. Edit/Submit
use the normal top toolbar (same as embed=true). VA opens
read-only and filters live with the report; filtering
repopulates the grid, so it is held while editing (change shown
pending) and resumes on return to read-only. -->
<ng-container *ngIf="isVaEmbed">
<div class="va-filter-controls w-100 mt-2-i">
<!-- Read-only: auto-apply toggle + manual Apply (confirm mode).
Hidden while editing (filtering is held). -->
<ng-container *ngIf="hotTable.readOnly">
<label class="va-toggle">
<input
type="checkbox"
[checked]="vaAutoApply"
(change)="toggleVaAutoApply()"
/>
Auto-apply VA filters
</label>
<button
*ngIf="!vaAutoApply"
[disabled]="
vaFilterStatus !== 'pending' &&
vaFilterStatus !== 'loading'
"
type="button"
class="btn btn-sm btn-primary"
(click)="applyPendingVaFilter()"
>
Apply filter
</button>
</ng-container>
<!-- Edit mode: filtering is held so edits aren't wiped. -->
<span *ngIf="!hotTable.readOnly" class="va-disabled-text">
VA filtering paused while editing
</span>
<!-- Persistent live region (shown in both modes) so a filter
change that arrives while editing is announced as pending. -->
<span class="va-filter-status" role="status" aria-live="polite">
<span
*ngIf="vaFilterStatus === 'pending'"
class="va-pending-text"
>
Filter change pending
</span>
<span
*ngIf="vaFilterStatus === 'loading'"
class="va-loading-text"
>
Loading filter…
</span>
</span>
</div>
</ng-container>
<p
*ngIf="
licenceState.value.editor_rows_allowed !== Infinity &&
@@ -0,0 +1,29 @@
.va-filter-controls {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
.va-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin: 0;
cursor: pointer;
}
.va-pending-text {
font-style: italic;
color: #8a5200;
}
}
.va-loading-text {
font-style: italic;
color: #0072a3;
}
.va-disabled-text {
font-style: italic;
opacity: 0.7;
}
+562 -97
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'
@@ -34,7 +33,7 @@ import {
Version
} from '../models/sas/editors-getdata.model'
import { DataFormat } from '../models/sas/common/DateFormat'
import { Approver, ExcelRule } from '../models/TableData'
import { Approver, ExcelRule, QueryClause } from '../models/TableData'
import { QueryComponent } from '../query/query.component'
import { EventService } from '../services/event.service'
import { HelperService } from '../services/helper.service'
@@ -47,9 +46,15 @@ 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 { DynamicExtendedCellValidation } from './models/dynamicExtendedCellValidation'
import {
DynamicCellValidation,
DynamicExtendedCellValidation
} from './models/dynamicExtendedCellValidation'
import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events'
import { EditorRestrictions } from './models/editor-restrictions.model'
import { parseTableColumns } from './utils/grid.utils'
@@ -61,6 +66,8 @@ import {
import { LicenceService } from '../services/licence.service'
import { FileUploadEncoding } from '../models/FileUploadEncoding'
import { SpreadsheetService } from '../services/spreadsheet.service'
import { VaMessagingService, VaMessage } from '../services/va-messaging.service'
import { VaFilterService } from '../services/va-filter.service'
import { UploadFileResponse } from '../models/UploadFile'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { ParseResult } from '../models/ParseResult.interface'
@@ -156,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
@@ -238,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'
})
}
}
}
}
@@ -261,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
@@ -282,6 +311,44 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
get embed() {
return globals.embed
}
/** True when running as a SAS Visual Analytics data-driven content object. */
get isVaEmbed() {
return globals.embed === 'va'
}
/**
* VA filter mode. Live = auto-apply each VA change; confirm = stage the change
* and wait for the user to click Apply. Backed by globals (toggled by the
* Auto-apply checkbox) so it persists across the editor reloads a filter apply
* triggers. Defaults to 'live'.
*/
get vaAutoApply(): boolean {
return globals.vaApplyMode !== 'confirm'
}
/**
* VA filter UI status (shown in both modes):
* - 'pending' : a VA change was received and not yet fetched (debouncing in
* live mode, or awaiting Apply in confirm mode);
* - 'loading' : the filter is being fetched (saveQuery), just before DC's
* native "Loading Table" takes over for the data;
* - 'idle' : nothing pending.
*/
public vaFilterStatus: 'idle' | 'pending' | 'loading' = 'idle'
/** The staged clauses/signature for the pending VA filter. */
private vaPendingClauses: QueryClause[] | null = null
private vaPendingSignature = ''
/** Removes the VA postMessage listener; set while subscribed. */
private vaUnsubscribe?: () => void
/** Guards against overlapping VA filter reloads. */
private vaApplyingFilter = false
/** Debounce timer coalescing a burst of VA messages into one apply. */
private vaDebounceTimer?: ReturnType<typeof setTimeout>
/**
* Debounce window (ms) for VA messages. Sized so a burst of rapid control
* clicks coalesces into a single apply (the last one) BEFORE any filter
* reload. Note VA itself also throttles how often it posts, so this stacks
* with that cadence.
*/
private static readonly VA_DEBOUNCE_MS = 800
public tableTrue: boolean | undefined
public saveLoading = false
public approvers: string[] = []
@@ -394,30 +461,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
message: string
} = { open: false, title: '', message: '' }
private confirmModalResolver: ((v: boolean) => void) | null = null
// Index maps into the positional arrays returned by getdynamiccolvals.
// The DISPLAY_VALUE column was dropped from the payload (it was never consumed
// here — the dropdown source uses RAW_VALUE and rows are matched via
// DISPLAY_INDEX), so these indices are contiguous.
public extendedCellValidationFields: {
DISPLAY_INDEX: number
EXTRA_COL_NAME: number
DISPLAY_TYPE: number
RAW_VALUE_NUM: number
RAW_VALUE_CHAR: number
FORCE_FLAG: number
} = {
DISPLAY_INDEX: 0,
EXTRA_COL_NAME: 1,
DISPLAY_TYPE: 2,
RAW_VALUE_NUM: 3,
RAW_VALUE_CHAR: 4,
FORCE_FLAG: 5
}
public cellValidationFields: {
DISPLAY_INDEX: number
RAW_VALUE: number
} = { DISPLAY_INDEX: 0, RAW_VALUE: 1 }
public disabledBasicDynamicCellValidationMap: {
row: number
@@ -429,6 +472,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
private ariaObserver: MutationObserver | undefined
private ariaCheckInterval: any | undefined
private gridResizeObserver: ResizeObserver | undefined
constructor(
private licenceService: LicenceService,
@@ -440,7 +484,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
private route: ActivatedRoute,
private sasService: SasService,
private cdf: ChangeDetectorRef,
private spreadsheetService: SpreadsheetService
private spreadsheetService: SpreadsheetService,
private vaMessaging: VaMessagingService,
private vaFilter: VaFilterService
) {
this.parseRestrictions()
this.setRestrictions()
@@ -934,16 +980,40 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
/**
* Reads the current multi-column sort config defensively. The sorting plugin
* may not be initialised yet (e.g. editTable() invoked right after load in VA
* embed mode, before the grid finished setup), in which case getSortConfig()
* throws on null internal state. There's no sort to preserve then, so [].
*/
private getCurrentSortConfigs(): any[] {
const hot = this.hotInstance
if (!hot) return []
try {
const plugin = hot.getPlugin('multiColumnSorting')
const cfg = plugin?.getSortConfig()
return Array.isArray(cfg) ? cfg : cfg ? [cfg] : []
} catch {
return []
}
}
editTable(previewEdit?: boolean, newRow?: boolean) {
this.toggleHotPlugin('contextMenu', true)
const hot = this.hotInstance
if (!hot) return
// Entering edit mode: cancel any scheduled VA filter apply so it can't fire
// mid-edit and wipe the edits. A pending filter is re-applied on the return
// to read-only (cancelEdit).
if (this.vaDebounceTimer) {
clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = undefined
}
const columnSorting = hot.getPlugin('multiColumnSorting')
const columnSortConfig = columnSorting.getSortConfig()
const sortConfigs = Array.isArray(columnSortConfig)
? columnSortConfig
: [columnSortConfig]
const sortConfigs = this.getCurrentSortConfigs()
setTimeout(() => {
if (!previewEdit) {
@@ -1037,6 +1107,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
this.checkRowLimit()
// Back to read-only: apply any VA filter change that was held pending while
// editing (live mode). Confirm mode leaves it staged for the Apply button.
if (
this.isVaEmbed &&
this.vaAutoApply &&
this.vaFilterStatus === 'pending'
) {
this.applyPendingVaFilter()
}
}
/**
@@ -1283,7 +1363,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) {
@@ -1362,7 +1446,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)) {
@@ -1464,7 +1550,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
for (const entry of this.cellValidationSource) {
const colSource = entry.values.map(
(el: any) => el[this.cellValidationFields.RAW_VALUE]
(el: DynamicCellValidation) => el.RAW_VALUE
)
hot.batch(() => {
@@ -1480,7 +1566,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (!cellSource) cellSource = []
const combinedSource = [...new Set([...cellSource, ...colSource])]
const combinedSource = [
...new Set([...cellSource, ...colSource])
] as string[]
this.currentEditRecordValidator?.updateRule(entry.col, {
source: combinedSource
@@ -1649,7 +1737,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
const forceValue = valuesForCol.find(
(x: DynamicExtendedCellValidation) => x.FORCE_FLAG === 1
(x: DynamicExtendedCellValidation) => x.FORCED_VALUE === 1
)
if (forceValue) {
@@ -1693,30 +1781,15 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
rowOverride ? rowOverride : cellValidationEntry.row,
cellValidationEntry.col
)
const valueIndex = (cellValidationEntry.values.find(
(x) => x[this.cellValidationFields.RAW_VALUE] === cellValue
) || [])[this.cellValidationFields.DISPLAY_INDEX]
const valueIndex = (
cellValidationEntry.values.find((x) => x.RAW_VALUE === cellValue) || {}
).DISPLAY_INDEX
const filteredValues = cellValidationEntry.extended_values?.filter(
(x) => x[0] === valueIndex
return (
cellValidationEntry.extended_values?.filter(
(x) => x.DISPLAY_INDEX === valueIndex
) || []
)
const prepObj = this.helperService.deepClone(
this.extendedCellValidationFields
)
const extendedValuesObject = []
for (const extendedValue of filteredValues || []) {
const tempObj: any = {}
for (const key of Object.keys(prepObj)) {
tempObj[key] = extendedValue[prepObj[key]]
}
extendedValuesObject.push(tempObj)
}
return extendedValuesObject
}
public checkSave() {
@@ -1760,7 +1833,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
@@ -2120,29 +2197,38 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
toggleHotPlugin(pluginName: string, enable: boolean) {
const hot = this.hotInstance
hot.batch(() => {
const contextMenuPlugin = hot.getPlugin<any>(pluginName)
const contextMenuPlugin = hot.getPlugin<any>(pluginName)
if (!contextMenuPlugin) {
console.warn(
'Toggle Hot Plugin failed - Plugin named: ' +
pluginName +
' - could not be found.'
)
if (!contextMenuPlugin) {
console.warn(
'Toggle Hot Plugin failed - Plugin named: ' +
pluginName +
' - could not be found.'
)
return
}
setTimeout(() => {
// The instance may be destroyed/rebuilt within this 100ms window (e.g. a
// VA filter reload repopulates the grid). A destroyed plugin has its `hot`
// reference deleted, so enablePlugin()/disablePlugin() would throw on
// `this.hot.getSettings()`. Bail if the instance is gone or was swapped.
if (
hot.isDestroyed ||
this.hotInstance !== hot ||
!contextMenuPlugin.hot
) {
return
}
setTimeout(() => {
if (enable) {
contextMenuPlugin.enablePlugin()
return
}
if (enable) {
contextMenuPlugin.enablePlugin()
} else {
contextMenuPlugin.disablePlugin()
}, 100)
}
hot.render()
})
}, 100)
}
private dynamicCellValidationDisabled(row: number, col: number) {
@@ -2220,10 +2306,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (validationSourceIndex > -1) {
let colSource = this.cellValidationSource[
validationSourceIndex
].values.map((el) => el[this.cellValidationFields.RAW_VALUE])
].values.map((el) => el.RAW_VALUE)
// `.source` (dropdown/autocomplete list) is typed unknown[] | Function here since
// getCellMeta() isn't given a type param; cast to the array branch we actually use.
const cellHadSource =
(hot.getCellMeta(row, column).source || []).length < 1
((hot.getCellMeta(row, column).source as unknown[]) || []).length < 1
const cellHasValue = cellData !== ' '
hot.batch(() => {
@@ -2260,7 +2348,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
) as string[]
}
const cellSourceCombined = [...new Set([...cellSource, ...colSource])]
const cellSourceCombined = [
...new Set([...cellSource, ...colSource])
] as string[]
hot.setCellMeta(row, column, 'source', cellSourceCombined)
@@ -2368,7 +2458,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
const colSource = res.adapterResponse.dynamic_values.map(
(el: any) => el[this.cellValidationFields.RAW_VALUE]
(el: DynamicCellValidation) => el.RAW_VALUE
)
this.currentEditRecordLoadings.splice(
@@ -2596,6 +2686,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
async ngOnInit() {
// VA data-driven content: register the postMessage listener as early as
// possible (like the SAS sample's load-time registration) so VA's initial
// data message isn't missed during the editor's getdata load. The message
// is buffered on the service and replayed once the grid is ready.
if (this.isVaEmbed) this.subscribeToVaMessages()
// Initialize hot table settings
this.updateHotTableSettings()
@@ -2606,6 +2702,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
)
// URL is the source of truth for the labels toggle: react to `?labels=`
// changes (including same-page navigation from the dropdown menu item)
// rather than reading it once from the route snapshot.
this._queryParams = this.route.queryParams.subscribe((params) => {
this.useLabels = parseLabelsParam(new URLSearchParams(params).toString())
this.applyDisplayColHeaders()
})
this._query = this.sasStoreService.query.subscribe((query: any) => {
if (query.libds === this.libds) {
this.whereString = query.string
@@ -2640,6 +2744,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
// VA initial load: if VA's first message already carries a filter, apply it
// BEFORE the first getdata so the initial fetch is filtered (no full-table
// load + reload). Navigates away on success; this instance aborts its load.
// Falls back to the normal unfiltered load on timeout/no-filter/failure.
if (await this.maybeDeferInitialVaLoad()) return
if (this.libds) {
this.getdataError = false
@@ -2655,6 +2765,51 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
/**
* VA mode, initial (unfiltered) route only: briefly wait for VA's buffered
* first message and, if it already carries a filter, save that filter and
* navigate to /editor/<table>/<FILTER_RK> so the very first getdata is
* filtered. Returns true if it navigated (caller must abort its own load),
* false to proceed with the normal unfiltered load.
*
* The filter is built directly from VA parameters (param label === DC column,
* which VA↔DC matching already requires; numeric inferred from the VA param
* dataType/value) because DC column metadata isn't available until getdata
* has run. Any failure falls back to the normal load; the post-load replay
* (with full metadata) reconciles to the same filter signature.
*/
private async maybeDeferInitialVaLoad(): Promise<boolean> {
if (!this.isVaEmbed) return false
// Only the initial, unfiltered route — never when a FILTER_RK is present.
if (typeof this.route.snapshot.params['filterId'] !== 'undefined')
return false
const libds = this.route.snapshot.params['libMem']
if (!libds) return false
// VA posts its first message before Angular boots, so it's already in the
// external buffer by now — read it directly (no polling). If it hasn't
// arrived yet, skip the optimisation and let the normal load + live message
// apply the filter.
const msg = this.vaMessaging.latestMessage()
if (!msg) return false
const clauses = this.vaFilter.buildInitialClauses(msg)
if (clauses.length === 0) return false
try {
const res: any = await this.sasStoreService.saveQuery(libds, clauses)
const id = res?.result?.[0]?.FILTER_RK
const table = res?.result?.[0]?.FILTER_TABLE
if (id === undefined || table === undefined) return false
// Seed the cross-reload signature so the post-reload replay is a no-op.
this.vaMessaging.filterSignature = this.vaFilter.signature(clauses)
await this.reloadEditorRoute('/editor/' + table + '/' + id)
return true
} catch {
return false
}
}
ngAfterViewInit() {
// Fix ARIA accessibility issues after table initialization
setTimeout(() => {
@@ -2683,7 +2838,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()
@@ -2695,6 +2869,43 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
clearInterval(this.ariaCheckInterval)
this.ariaCheckInterval = undefined
}
// Stop observing the grid container for resizes
if (this.gridResizeObserver) {
this.gridResizeObserver.disconnect()
this.gridResizeObserver = undefined
}
// Cancel any pending debounced VA apply
if (this.vaDebounceTimer) {
clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = undefined
}
// Remove the VA postMessage listener
if (this.vaUnsubscribe) {
this.vaUnsubscribe()
this.vaUnsubscribe = undefined
}
}
/**
* Re-run height+render when the grid container settles.
*/
private observeGridResize() {
const el = this.hotInstance?.rootElement?.parentElement
if (!el) return
this.gridResizeObserver?.disconnect()
this.gridResizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
const hot = this.hotInstance
if (!hot || hot.isDestroyed) return
hot.updateSettings({ height: this.hotTable.height }, false)
hot.render()
})
})
this.gridResizeObserver.observe(el)
}
/**
@@ -2844,13 +3055,18 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
initSetup(response: EditorsGetDataServiceResponse) {
this.hotInstance = this.hotTableComponent!.hotInstance!
initSetup(response: EditorsGetDataServiceResponse, attempt = 0) {
if (this.getdataError) return
if (!response) return
if (!response.data) return
if (!this.hotInstance) return
if (!response || !response.data) return
this.hotInstance = this.hotTableComponent?.hotInstance!
if (!this.hotInstance) {
// Retry init, don't permanently abort
if (attempt < 60) {
setTimeout(() => this.initSetup(response, attempt + 1), 50)
}
return
}
this.cols = response.data.cols
this.dsmeta = response.data.dsmeta
@@ -2959,7 +3175,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,
@@ -2983,7 +3203,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
manualColumnResize: true,
filters: false,
manualRowResize: true,
viewportRowRenderingOffset: 50,
viewportRowRenderingOffset: 100,
// show a bar on the left to enable users to select an entire row
rowHeaders: (index: number) => {
return ' '
@@ -3019,20 +3239,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
@@ -3233,6 +3455,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (cellMeta && cellMeta.filter === false)
hot.setCellMeta(row, col, 'filter', true)
// Toggling Delete? changes every other cell's validation exemption
// in this row (see DcValidator.setDefaultValidator) — re-validate so
// invalid highlights clear/reappear immediately instead of only at
// submit time.
if (colProp === '_____DELETE__THIS__RECORD_____') {
hot.validateRows([row], () => hot.render())
}
this.executeDynamicCellValidationIfApplicable(colProp, col, row)
}
})
@@ -3270,8 +3500,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
)
hot.addHook('afterRender', (isForced: boolean) => {
this.eventService.dispatchEvent('resize')
// Fix ARIA accessibility issues after each render
this.fixAriaAccessibility()
})
@@ -3402,9 +3630,246 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.render()
this.observeGridResize()
// Fix ARIA accessibility issues after table initialization
setTimeout(() => {
this.fixAriaAccessibility()
}, 500)
// SAS Visual Analytics data-driven content mode: open in read-only view (NOT
// immediate edit — the user clicks Edit to make changes), re-apply any column
// visibility chosen by VA before this (filter) reload, and start receiving VA
// messages over the postMessage interface.
if (this.isVaEmbed) {
if (this.vaMessaging.visibleColumns) {
this.applyVaColumnVisibility(new Set(this.vaMessaging.visibleColumns))
if (this.hotInstance) this.hotInstance.render()
}
// Listener is already registered early (ngOnInit) so VA's initial post is
// not missed; this is a guarded no-op if so. Now that the grid is ready,
// replay the buffered latest message to apply any filter/visibility that
// arrived during load.
this.subscribeToVaMessages()
// Reconcile to the true latest message after a reload — covers a message
// that arrived during the reload gap (caught by va-early, missed by the
// app listener). Signature dedup makes this a no-op if nothing changed.
this.handleVaMessage()
}
}
/**
* Subscribes to the SAS VA data-driven content postMessage channel. VA pushes
* a fresh message on every selection / parameter change. Registered early (in
* ngOnInit) so the initial message isn't missed; messages are buffered on the
* service and applied via a debounce so a burst of control clicks collapses
* into a single reload on the latest message.
*/
private subscribeToVaMessages() {
if (this.vaUnsubscribe) return
this.vaUnsubscribe = this.vaMessaging.onData(() => this.handleVaMessage())
}
/**
* Handles one VA message immediately (NOT debounced): updates column
* visibility and decides the filter. The cheap work (visibility, building the
* filter clauses, deciding pending) runs per message so the "pending"
* indicator can show the instant VA posts; only the expensive APPLY (saveQuery
* + reload) is debounced (live mode) via scheduleVaFilterApply.
*
* Filter data always comes from DC's getdata; VA only drives (a) column
* visibility and (b) the active filter (its params -> DC clauses, applied by
* reloading at /editor/<table>/<FILTER_RK>).
*/
private handleVaMessage() {
const msg = this.vaMessaging.latestMessage()
if (!msg || !this.libds || !this.cellValidation) return
// Grid not ready yet (cellValidation only has the delete column, or none):
// the label->var map would be empty, so every param would fail to map and
// produce an empty filter that wrongly CLEARS the active one — the infinite
// reload loop. Bail; the load-complete replay re-runs once metadata is in.
if (this.cellValidation.length <= 1) return
const labelToVar = this.vaFilter.buildLabelToVarMap(
this.cellValidation,
this.columnHeader
)
// Columns present in the message -> show these, hide the rest. A message
// with no data columns carries no column info, so leave visibility as-is
// rather than hiding everything.
const dataColumns = this.vaMessaging.dataColumns(msg)
let visibilityChanged = false
if (dataColumns.length > 0) {
const matchedVars = new Set<string>()
for (const { column } of dataColumns) {
const key = (column.label ?? column.name ?? '')
.toString()
.trim()
.toLowerCase()
const varName = labelToVar.get(key)
if (varName) matchedVars.add(varName)
}
this.vaMessaging.visibleColumns = Array.from(matchedVars)
visibilityChanged = this.applyVaColumnVisibility(matchedVars)
}
if (visibilityChanged && this.hotInstance) this.hotInstance.render()
this.updateVaFilterText(msg)
const clauses = this.vaFilter.buildClauses(msg, labelToVar, this.cols)
const signature = this.vaFilter.signature(clauses)
if (signature === this.vaMessaging.filterSignature) {
// Selection matches the applied filter — nothing pending. (Don't clear a
// 'loading' status; that belongs to an apply already in flight.)
if (this.vaFilterStatus === 'pending') this.vaFilterStatus = 'idle'
this.vaPendingClauses = null
return
}
// A real change arrived -> pending (shown in both modes, immediately).
this.vaPendingClauses = clauses
this.vaPendingSignature = signature
this.vaFilterStatus = 'pending'
// Filtering repopulates the grid, so it must never run while the user is
// editing (it would wipe their in-progress edits). While in edit mode we
// only surface the pending indicator; the filter is applied on return to
// read-only (see cancelEdit). Live mode auto-advances after the debounce
// settles; confirm waits for the Apply button.
if (this.vaAutoApply && this.hotTable.readOnly) this.scheduleVaFilterApply()
}
/** Live mode: debounce a burst of VA changes into a single apply of the latest. */
private scheduleVaFilterApply() {
if (this.vaDebounceTimer) clearTimeout(this.vaDebounceTimer)
this.vaDebounceTimer = setTimeout(() => {
this.vaDebounceTimer = undefined
this.applyPendingVaFilter()
}, EditorComponent.VA_DEBOUNCE_MS)
}
/**
* Saves the VA-derived filter through DC's validatefilter flow and reloads the
* editor at /editor/<table>/<FILTER_RK> (preserving ?embed=va) so the filter
* key is visible in the URL. Empty clauses clear the filter.
*/
private async applyVaFilter(clauses: QueryClause[], signature: string) {
// A previous filter is still saving/reloading: drop this call WITHOUT
// advancing the signature. The reloaded editor's load-complete replays the
// latest buffered message, which re-evaluates against the still-accurate
// last-applied signature and applies whatever the newest selection is.
if (this.vaApplyingFilter) return
this.vaApplyingFilter = true
// Now actually fetching: surface the "loading filter" status (until DC's
// native "Loading Table" takes over after the reload navigates).
this.vaFilterStatus = 'loading'
// Record the signature only now that we're actually applying it.
this.vaMessaging.filterSignature = signature
try {
if (clauses.length === 0) {
// Already unfiltered (no FILTER_RK in route)? Do nothing — reloading
// would be a pointless round-trip and re-trigger the drain/replay cycle.
// Only reload to clear a filter that is currently applied.
if (typeof this.filter_pk === 'undefined') {
this.vaFilterStatus = 'idle'
return
}
await this.reloadEditorRoute('/editor/' + this.libds)
return
}
const res: any = await this.sasStoreService.saveQuery(
this.libds!,
clauses
)
const id = res?.result?.[0]?.FILTER_RK
const table = res?.result?.[0]?.FILTER_TABLE
if (id === undefined || table === undefined) {
this.vaFilterStatus = 'idle'
return
}
await this.reloadEditorRoute('/editor/' + table + '/' + id)
} catch {
// Keep the current view on failure; the next message can retry.
this.vaFilterStatus = 'idle'
} finally {
this.vaApplyingFilter = false
}
}
/** Apply the staged VA filter (the debounce in live mode, or the Apply button). */
public applyPendingVaFilter(): void {
if (!this.vaPendingClauses) return
// Never apply while editing — the reload would wipe in-progress edits. The
// pending filter stays staged and is applied when the user leaves edit mode.
if (!this.hotTable.readOnly) return
const clauses = this.vaPendingClauses
const signature = this.vaPendingSignature
this.vaPendingClauses = null
void this.applyVaFilter(clauses, signature)
}
/**
* Toggles between live (auto-apply) and confirm filter modes. The choice is
* stored on globals so it survives editor reloads. Switching to live with a
* change still pending applies it immediately.
*/
public toggleVaAutoApply(): void {
globals.vaApplyMode = this.vaAutoApply ? 'confirm' : 'live'
if (this.vaAutoApply && this.vaFilterStatus === 'pending') {
this.applyPendingVaFilter()
}
}
/** Forces a full editor reload of `target`, preserving the embed query param. */
private async reloadEditorRoute(target: string) {
await this.router.navigate(['/'], {
skipLocationChange: true,
queryParamsHandling: 'preserve'
})
await this.router.navigate([target], { queryParamsHandling: 'preserve' })
}
/**
* Shows the DC data columns present in the latest VA message and hides the
* rest (primary-key and validator-hidden columns are always preserved). VA
* parameters can add/remove columns between pushes.
*/
private applyVaColumnVisibility(matchedVars: Set<string>): boolean {
const hot = this.hotInstance
if (!hot) return false
const plugin: any = hot.getPlugin('hiddenColumns')
if (!plugin) return false
const validatorHidden: number[] = this.dcValidator?.getHiddenColumns() || []
const toHide: number[] = []
const toShow: number[] = []
for (let i = 1; i < this.cellValidation.length; i++) {
const varName = this.cellValidation[i]?.data
if (!varName || this.isColPk(varName)) continue
if (validatorHidden.includes(i)) continue
if (matchedVars.has(varName)) {
if (plugin.isHidden(i)) toShow.push(i)
} else if (!plugin.isHidden(i)) {
toHide.push(i)
}
}
if (toShow.length) plugin.showColumns(toShow)
if (toHide.length) plugin.hideColumns(toHide)
return toShow.length > 0 || toHide.length > 0
}
/** Logs the active VA filter summary (kept out of the embedded grid UI). */
private updateVaFilterText(msg: VaMessage) {
const summary = (msg.parameters || [])
.filter((p) => p && p.name !== undefined && p.value !== undefined)
.map((p) => `${p.label ?? p.name}=${p.value}`)
.join(', ')
// eslint-disable-next-line no-console
console.log('VA filters active:', summary)
}
}
@@ -1,6 +1,16 @@
/**
* Model for the dynamic cell validation in the editor
* (sending whole row to the backend service and recieving data for the cell dropdown)
* Model for a row of `dynamic_values` returned by the getdynamiccolvals
* service (the dropdown source for the edited cell itself).
*/
export interface DynamicCellValidation {
DISPLAY_INDEX: number
RAW_VALUE: string | number
}
/**
* Model for a row of `dynamic_extended_values` returned by the
* getdynamiccolvals service (dropdown values for _other_ cells in the _same_
* row, mapped via DISPLAY_INDEX).
*/
export interface DynamicExtendedCellValidation {
DISPLAY_INDEX: number
@@ -8,5 +18,5 @@ export interface DynamicExtendedCellValidation {
EXTRA_COL_NAME: string
RAW_VALUE_CHAR: string
RAW_VALUE_NUM: number
FORCE_FLAG: number
FORCED_VALUE: number
}
+16
View File
@@ -18,6 +18,22 @@ export const dateToTime = (date: Date) => {
return timeStr
}
/**
* Normalises a 24-hour time STRING to zero-padded `HH:mm:ss`.
*
* The `intl-time` cell type (validator + native `<input type=time>` editor) is
* strict and rejects `H:mm[:ss]`, so such a value paints red and the editor
* opens empty. Pad the hour and default the seconds so the value validates.
*
* Non time-like input is returned unchanged.
*/
export const padTimeString = (value: string): string => {
const match = value.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
if (!match) return value
const [, hours, minutes, seconds] = match
return `${hours.padStart(2, '0')}:${minutes}:${seconds ?? '00'}`
}
/**
* Converts date object to the YYYY-MM-DD
*/
@@ -1,9 +1,14 @@
import {
DynamicCellValidation,
DynamicExtendedCellValidation
} from '../editor/models/dynamicExtendedCellValidation'
export interface CellValidationSource {
col: number
row: number
strict: boolean
values: any[]
extended_values?: string[]
values: DynamicCellValidation[]
extended_values?: DynamicExtendedCellValidation[]
hash: string
count: number
pending?: Promise<void>
-1
View File
@@ -37,7 +37,6 @@ export interface MaxVarLength {
export interface SASParam {
COLHEADERS: string
COLTYPE: string
DTTMVARS: string
DTVARS: string
CLS_FLAG: number
@@ -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)
+141
View File
@@ -0,0 +1,141 @@
import { EventEmitter } from '@angular/core'
import { BehaviorSubject, Subject } from 'rxjs'
import { AppService } from './app.service'
/**
* AppService's constructor subscribes to several emitters/subjects (see
* subscribe() and the persistSelectedTheme check) and startUpData() touches
* a handful of methods on each collaborator — these stubs cover exactly
* that surface, not the full real services (no TestBed/DI needed, same
* plain-instantiation-with-stubs precedent as va-filter.service.spec.ts).
*/
const buildDeps = () => {
const licenceService: any = {
isAppActivated: new BehaviorSubject<boolean | null>(null),
activation: jasmine.createSpy('activation').and.resolveTo(undefined)
}
const eventService: any = {
showInfoModal: jasmine.createSpy('showInfoModal'),
startupDataLoaded: jasmine.createSpy('startupDataLoaded'),
toggleDarkMode: jasmine.createSpy('toggleDarkMode')
}
const sasService: any = {
loadStartupServiceEmitter: new EventEmitter<any>(),
requestSiteIdEmitter: new EventEmitter<string>(),
request: jasmine.createSpy('request')
}
const loggerService: any = {
log: jasmine.createSpy('log')
}
const appSettingsService: any = {
settings: new BehaviorSubject({ persistSelectedTheme: false } as any)
}
const router: any = {
events: new Subject(),
url: '',
navigateByUrl: jasmine.createSpy('navigateByUrl')
}
const appStoreService: any = {
getDcAdapterSettings: () => undefined
}
return {
licenceService,
eventService,
sasService,
loggerService,
appSettingsService,
router,
appStoreService
}
}
const buildAppService = (deps: ReturnType<typeof buildDeps>) =>
new AppService(
deps.licenceService,
deps.eventService,
deps.sasService,
deps.loggerService,
deps.appSettingsService,
deps.router,
deps.appStoreService
)
// Minimal valid startupservice payload — just enough to pass startUpData()'s
// missing-props check (Globvars/Sasdatasets/Saslibs/XLMaps).
const validStartupResponse = () => ({
adapterResponse: {
SYSSITE: 'SITE1',
globvars: [{ ISADMIN: false, DC_ADMIN_GROUP: '', DCLIB: 'DCLIB' }],
sasdatasets: [],
saslibs: {},
xlmaps: []
}
})
describe('AppService - startup retry', () => {
it('retries once and succeeds without showing the error modal when only the first attempt rejects', async () => {
const deps = buildDeps()
deps.sasService.request.and.returnValues(
Promise.reject('timeout'),
Promise.resolve(validStartupResponse())
)
const appService = buildAppService(deps)
// Keep the retry pause instant in tests — see STARTUP_RETRY_PLAN.md.
;(appService as any).retryOptions = {
wait: () => Promise.resolve(),
random: () => 0
}
await appService.startUpData()
expect(deps.sasService.request).toHaveBeenCalledTimes(2)
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
expect(deps.eventService.startupDataLoaded).toHaveBeenCalled()
})
it('shows the error modal once (not twice) when both attempts reject', async () => {
const deps = buildDeps()
deps.sasService.request.and.returnValues(
Promise.reject('timeout-1'),
Promise.reject('timeout-2')
)
const appService = buildAppService(deps)
;(appService as any).retryOptions = {
wait: () => Promise.resolve(),
random: () => 0
}
await appService.startUpData()
expect(deps.sasService.request).toHaveBeenCalledTimes(2)
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
expect(deps.licenceService.isAppActivated.value).toBeFalse()
})
it('does not retry a response that arrives but is missing required properties', async () => {
const deps = buildDeps()
deps.sasService.request.and.resolveTo({
adapterResponse: {
SYSSITE: 'SITE1',
// globvars deliberately omitted
sasdatasets: [],
saslibs: {},
xlmaps: []
}
})
const appService = buildAppService(deps)
;(appService as any).retryOptions = {
wait: () => Promise.resolve(),
random: () => 0
}
await appService.startUpData()
expect(deps.sasService.request).toHaveBeenCalledTimes(1)
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
})
})
+11 -2
View File
@@ -11,11 +11,15 @@ import { AppSettingsService } from './app-settings.service'
import { AppThemes } from '../models/AppSettings'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { AppStoreService } from './app-store.service'
import { retryOnce, RetryOnceOptions } from '../shared/utils/retry-once'
@Injectable()
export class AppService {
public syssite = new BehaviorSubject<string[] | null>(null)
private environmentInfo: EnvironmentInfo = {}
// Overridable in tests to keep the retry pause instant — see
// retry-once.spec.ts and app.service.spec.ts.
private retryOptions: RetryOnceOptions = {}
constructor(
private licenceService: LicenceService,
@@ -82,8 +86,13 @@ export class AppService {
public async startUpData() {
let startupServiceError = false
await this.sasService
.request('public/startupservice', null)
// Retry once on failure — multiple concurrent DC instances (e.g. VA
// multi-page reports) can cause this request to time out even though a
// second attempt succeeds. See STARTUP_RETRY_PLAN.md.
await retryOnce(
() => this.sasService.request('public/startupservice', null),
this.retryOptions
)
.then(async (res: RequestWrapperResponse) => {
this.syssite.next([res.adapterResponse.SYSSITE])
@@ -0,0 +1,274 @@
import { VaFilterService } from './va-filter.service'
import { VaMessage } from './va-messaging.service'
/**
* Minimal stand-in for HelperService.convertJsDateToSasDate using the real
* epoch math, so date/datetime assertions use the actual SAS internal values
* without pulling in HelperService's dependency graph (SasService etc.).
*/
class HelperServiceStub {
convertJsDateToSasDate(jsDate: Date, unit = 'days'): number {
const ms = Date.UTC(
jsDate.getFullYear(),
jsDate.getMonth(),
jsDate.getDate(),
jsDate.getHours(),
jsDate.getMinutes(),
jsDate.getSeconds()
)
const sasMs = ms + 315619200000
return unit === 'days'
? Math.floor(Math.abs(sasMs / 86400000))
: sasMs / 1000
}
}
function msg(parameters: any[]): VaMessage {
return {
resultName: 'r',
rowCount: 0,
data: [],
columns: [],
parameters
} as VaMessage
}
describe('VaFilterService', () => {
let service: VaFilterService
// DC column metadata used by the metadata path. cellValidation[0] is the
// delete column and must be skipped by buildLabelToVarMap.
const cellValidation = [
{ data: 'DEL' },
{ data: 'SOME_CHAR' },
{ data: 'SOME_NUM' },
{ data: 'SOME_TIME' },
{ data: 'SOME_DATE' },
{ data: 'SOME_DATETIME' }
]
const columnHeader = [
'Delete?',
'SOME_CHAR',
'SOME_NUM',
'SOME_TIME',
'SOME_DATE',
'SOME_DATETIME'
]
const cols = [
{ NAME: 'SOME_CHAR', DDTYPE: 'CHARACTER' },
{ NAME: 'SOME_NUM', DDTYPE: 'NUMERIC' },
{ NAME: 'SOME_TIME', DDTYPE: 'TIME' },
{ NAME: 'SOME_DATE', DDTYPE: 'DATE' },
{ NAME: 'SOME_DATETIME', DDTYPE: 'DATETIME' }
]
beforeEach(() => {
service = new VaFilterService(new HelperServiceStub() as any)
})
const labelToVar = () =>
service.buildLabelToVarMap(cellValidation, columnHeader)
describe('buildLabelToVarMap', () => {
it('maps header and var name (case-insensitive), skipping the delete column', () => {
const map = labelToVar()
expect(map.get('some_char')).toBe('SOME_CHAR')
expect(map.get('some_num')).toBe('SOME_NUM')
expect(map.get('del')).toBeUndefined()
expect(map.get('delete?')).toBeUndefined()
})
})
describe('buildClauses (metadata path)', () => {
const build = (parameters: any[]) =>
service.buildClauses(msg(parameters), labelToVar(), cols)
it('builds a char equality clause, single-quote escaped', () => {
expect(build([{ label: 'SOME_CHAR', value: "O'Brien" }])).toEqual([
{
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: 'SOME_CHAR',
OPERATOR_NM: '=',
RAW_VALUE: "'O''Brien'"
}
])
})
it('builds an IN clause for multiple values', () => {
const [clause] = build([{ label: 'SOME_CHAR', value: ['A', 'B'] }])
expect(clause.OPERATOR_NM).toBe('IN')
expect(clause.RAW_VALUE).toBe("('A','B')")
})
it('passes numeric values raw and drops non-numeric (injection guard)', () => {
expect(build([{ label: 'SOME_NUM', value: '10' }])[0].RAW_VALUE).toBe(
'10'
)
expect(build([{ label: 'SOME_NUM', value: 'abc' }])).toEqual([])
})
it('converts temporal values to SAS internal numbers', () => {
expect(
build([{ label: 'SOME_TIME', value: '0:00:02' }])[0].RAW_VALUE
).toBe('2')
expect(
build([{ label: 'SOME_DATE', value: '01JUL1960' }])[0].RAW_VALUE
).toBe('182')
expect(
build([{ label: 'SOME_DATETIME', value: '01JAN1960:00:06:05' }])[0]
.RAW_VALUE
).toBe('365')
})
it('passes an already-internal (bare number) temporal value through raw', () => {
// VA sends the raw SAS internal value when the data item has no format.
expect(build([{ label: 'SOME_DATE', value: '182' }])[0].RAW_VALUE).toBe(
'182'
)
expect(
build([{ label: 'SOME_DATETIME', value: '365' }])[0].RAW_VALUE
).toBe('365')
expect(build([{ label: 'SOME_TIME', value: '2' }])[0].RAW_VALUE).toBe('2')
})
it('parses ISO date shapes identically to DATE9 (VA sends no format)', () => {
const date9 = build([{ label: 'SOME_DATE', value: '01AUG1962' }])[0]
.RAW_VALUE
// The production symptom: YYMMDDS "2025/03/31" style must parse, not drop.
expect(
build([{ label: 'SOME_DATE', value: '1962-08-01' }])[0].RAW_VALUE
).toBe(date9)
expect(
build([{ label: 'SOME_DATE', value: '1962/08/01' }])[0].RAW_VALUE
).toBe(date9)
// A real production-format value must yield a clause (not be dropped).
expect(build([{ label: 'SOME_DATE', value: '2025/03/31' }])).toHaveSize(1)
})
it('resolves ambiguous slash dates day-first (never US), disambiguating by value', () => {
const iso = build([{ label: 'SOME_DATE', value: '2025-03-31' }])[0]
.RAW_VALUE
// 31 can only be a day -> both orders resolve to 2025-03-31.
expect(
build([{ label: 'SOME_DATE', value: '31/03/2025' }])[0].RAW_VALUE
).toBe(iso) // day-first
expect(
build([{ label: 'SOME_DATE', value: '03/31/2025' }])[0].RAW_VALUE
).toBe(iso) // value forces month-first
// Genuinely ambiguous (both <= 12): default DAY-first => 4th March, NOT 3rd April.
expect(
build([{ label: 'SOME_DATE', value: '04/03/2025' }])[0].RAW_VALUE
).toBe(build([{ label: 'SOME_DATE', value: '2025-03-04' }])[0].RAW_VALUE)
// Impossible date is dropped, not rolled over.
expect(build([{ label: 'SOME_DATE', value: '31/02/2025' }])).toEqual([])
})
it('uses the column FMTNAME to resolve ambiguous dates (MMDDYY reads US month-first)', () => {
const iso = (v: string) =>
service.buildClauses(
msg([{ label: 'D', value: v }]),
new Map([['d', 'D']]),
[{ NAME: 'D', DDTYPE: 'DATE' }]
)[0]?.RAW_VALUE
const withFmt = (fmt: string, v: string) =>
service.buildClauses(
msg([{ label: 'D', value: v }]),
new Map([['d', 'D']]),
[{ NAME: 'D', DDTYPE: 'DATE', FMTNAME: fmt }]
)[0]?.RAW_VALUE
// 04/03/2025, both <= 12 (ambiguous): FMTNAME decides.
expect(withFmt('MMDDYY', '04/03/2025')).toBe(iso('2025-04-03')) // 3 April
expect(withFmt('DDMMYY', '04/03/2025')).toBe(iso('2025-03-04')) // 4 March
// Value still overrides a wrong FMTNAME: 31 can only be the day.
expect(withFmt('MMDDYY', '31/03/2025')).toBe(iso('2025-03-31'))
// Falls back to the full FORMAT (e.g. MMDDYY10.) when FMTNAME is blank.
const withFormat = service.buildClauses(
msg([{ label: 'D', value: '04/03/2025' }]),
new Map([['d', 'D']]),
[{ NAME: 'D', DDTYPE: 'DATE', FMTNAME: '', FORMAT: 'MMDDYY10.' }]
)[0]?.RAW_VALUE
expect(withFormat).toBe(iso('2025-04-03')) // 3 April, from FORMAT
})
it('parses ISO datetime shapes identically to SAS DATETIME', () => {
const sas = build([
{ label: 'SOME_DATETIME', value: '01JAN1960:00:06:05' }
])[0].RAW_VALUE
expect(
build([{ label: 'SOME_DATETIME', value: '1960-01-01T00:06:05' }])[0]
.RAW_VALUE
).toBe(sas)
expect(
build([{ label: 'SOME_DATETIME', value: '1960-01-01 00:06:05' }])[0]
.RAW_VALUE
).toBe(sas)
})
it('matches the column by label case-insensitively', () => {
expect(build([{ label: 'some_char', value: 'x' }])[0].VARIABLE_NM).toBe(
'SOME_CHAR'
)
})
it('skips params that are empty or do not map to a column', () => {
expect(build([{ label: 'SOME_CHAR', value: '' }])).toEqual([])
expect(build([{ label: 'NOT_A_COLUMN', value: 'x' }])).toEqual([])
})
})
describe('buildInitialClauses (no metadata, infer kind from value)', () => {
it('uses the VA label as the column and infers kinds by value shape', () => {
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'SOME_CHAR', dataType: 'string', value: 'x' },
{ name: 'pr2', label: 'SOME_NUM', dataType: 'number', value: '10' },
{ name: 'pr3', label: 'SOME_TIME', value: '0:00:02' },
{ name: 'pr4', label: 'SOME_DATE', value: '01JUL1960' },
{ name: 'pr5', label: 'SOME_DATETIME', value: '01JAN1960:00:06:05' }
])
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_CHAR', "'x'"],
['SOME_NUM', '10'],
['SOME_TIME', '2'],
['SOME_DATE', '182'],
['SOME_DATETIME', '365']
])
})
it('infers ISO date/datetime shapes by value shape', () => {
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'SOME_DATE', value: '1960-07-01' },
{ name: 'pr2', label: 'SOME_DATE2', value: '1960/07/01' },
{ name: 'pr3', label: 'SOME_DTTM', value: '1960-01-01T00:06:05' }
])
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_DATE', '182'],
['SOME_DATE2', '182'],
['SOME_DTTM', '365']
])
})
})
describe('signature', () => {
it('is case-insensitive on the column name (no reconcile reload)', () => {
const a = [
{ VARIABLE_NM: 'some_char', OPERATOR_NM: '=', RAW_VALUE: "'x'" }
] as any
const b = [
{ VARIABLE_NM: 'SOME_CHAR', OPERATOR_NM: '=', RAW_VALUE: "'x'" }
] as any
expect(service.signature(a)).toBe(service.signature(b))
})
it('changes when a value changes', () => {
const a = [{ VARIABLE_NM: 'SOME_CHAR', RAW_VALUE: "'x'" }] as any
const b = [{ VARIABLE_NM: 'SOME_CHAR', RAW_VALUE: "'y'" }] as any
expect(service.signature(a)).not.toBe(service.signature(b))
})
})
})
@@ -0,0 +1,421 @@
import { Injectable } from '@angular/core'
import { HelperService } from './helper.service'
import { VaMessage } from './va-messaging.service'
import { QueryClause } from '../models/TableData'
/** SAS data-type kind used to format a VA filter value into a DC RAW_VALUE. */
type VaColumnKind = 'char' | 'numeric' | 'time' | 'date' | 'datetime'
/**
* Translates SAS Visual Analytics data-driven content `parameters` into DC
* filter clauses. Independent of editor/HOT state — the editor passes the
* loaded column metadata in — so it is unit-testable on its own. Temporal
* values are converted to SAS internal numbers (matching DC's native filters)
* via the shared HelperService.
*/
@Injectable({ providedIn: 'root' })
export class VaFilterService {
private static readonly SAS_MONTHS = [
'JAN',
'FEB',
'MAR',
'APR',
'MAY',
'JUN',
'JUL',
'AUG',
'SEP',
'OCT',
'NOV',
'DEC'
]
constructor(private helperService: HelperService) {}
/** label/name (lower-cased) -> DC variable (prop) name, skipping the delete col. */
buildLabelToVarMap(
cellValidation: any[],
columnHeader: string[]
): Map<string, string> {
const map = new Map<string, string>()
for (let i = 1; i < cellValidation.length; i++) {
const varName = cellValidation[i]?.data
if (!varName) continue
map.set(
(columnHeader[i] ?? varName).toString().trim().toLowerCase(),
varName
)
map.set(varName.toString().trim().toLowerCase(), varName)
}
return map
}
/**
* Translates VA `parameters` into DC filter clauses. Each parameter is matched
* to a DC column by label; a single value becomes `col = value`, multiple
* values become `col IN (...)`. Values are formatted for the column's SAS kind.
*/
buildClauses(
msg: VaMessage,
labelToVar: Map<string, string>,
cols: any[]
): QueryClause[] {
const clauses: QueryClause[] = []
for (const param of msg.parameters || []) {
if (!param || param.value === undefined || param.value === null) continue
const key = (param.label ?? param.name ?? '')
.toString()
.trim()
.toLowerCase()
// A VA param whose label isn't a DC column (beyond case, which is handled)
// simply doesn't map — skip it.
const varName = labelToVar.get(key)
if (!varName) continue
const col = this.findCol(varName, cols)
const kind = this.columnKind(col)
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
// Format/validate every value for the column's SAS type. Numeric columns
// must receive a finite number (untrusted postMessage input is
// interpolated into a SAS WHERE clause, so a non-numeric value here would
// be an injection vector); temporal -> SAS internal number; char ->
// escaped. Invalid values are dropped.
const formatted = rawValues
.filter((v) => v !== undefined && v !== null && v !== '')
.map((v) => this.formatValueByKind(v, kind, order))
.filter((v): v is string => v !== null)
if (formatted.length === 0) continue
clauses.push(this.clause(varName, formatted))
}
return clauses
}
/**
* Pre-getdata variant used on the deferred initial load, when DC column
* metadata isn't available. Uses the VA `label` as the column name and infers
* the SAS kind from the value shape, so the clause matches what the metadata
* build later produces (no reconcile reload).
*/
buildInitialClauses(msg: VaMessage): QueryClause[] {
const clauses: QueryClause[] = []
for (const param of msg.parameters || []) {
if (!param || param.value === undefined || param.value === null) continue
// The DC column name is the VA `label`; `name` is VA's internal id (pr###).
const varName = (param.label ?? param.name ?? '').toString().trim()
if (!varName) continue
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
const nonEmpty = rawValues.filter(
(v) => v !== undefined && v !== null && v !== ''
)
if (nonEmpty.length === 0) continue
const formatted = nonEmpty
.map((v) =>
this.formatValueByKind(
v,
this.inferKindFromValue(String(v).trim(), param.dataType)
)
)
.filter((v): v is string => v !== null)
if (formatted.length === 0) continue
clauses.push(this.clause(varName, formatted))
}
return clauses
}
/**
* Stable signature of filter clauses for cross-reload dedup. VARIABLE_NM is
* upper-cased because the metadata-less deferred build uses VA's label case
* while the metadata build uses DC's stored column case — SAS WHERE clauses
* are case-insensitive, so those are the same filter and must not trigger a
* reconcile reload. A genuine value difference still changes the signature.
*/
signature(clauses: QueryClause[]): string {
return JSON.stringify(
clauses.map((c) => ({
...c,
VARIABLE_NM: (c.VARIABLE_NM ?? '').toString().toUpperCase()
}))
)
}
private clause(varName: string, formatted: string[]): QueryClause {
return {
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: varName,
OPERATOR_NM: formatted.length === 1 ? '=' : 'IN',
RAW_VALUE:
formatted.length === 1 ? formatted[0] : `(${formatted.join(',')})`
}
}
/** The loaded column spec whose NAME matches varName (case-insensitive). */
private findCol(varName: string, cols: any[]): any {
return (cols || []).find(
(x: any) =>
(x?.NAME ?? '').toString().toUpperCase() === varName.toUpperCase()
)
}
/**
* SAS data-type kind of a column spec. DDTYPE carries
* TIME/DATE/DATETIME/NUMERIC/CHARACTER; checked DATETIME-before-DATE (substring).
*/
private columnKind(col: any): VaColumnKind {
const ddtype = (col?.DDTYPE ?? '').toString().toUpperCase()
if (ddtype.includes('DATETIME')) return 'datetime'
if (ddtype.includes('DATE')) return 'date'
if (ddtype.includes('TIME')) return 'time'
if (ddtype.includes('NUMERIC') || (col?.TYPE ?? '') === 'num') {
return 'numeric'
}
return 'char'
}
/**
* Day/month/year order declared by a column's SAS format, used to resolve
* ambiguous numeric dates. Accepts either the base FMTNAME (e.g. `DDMMYY`) or
* the full FORMAT (e.g. `DDMMYY10.`) — the leading name is the same and is all
* that matters for order (width is irrelevant). This is the SOURCE column's
* format; the VA report can in principle display a different one, so
* value-based disambiguation (a group > 12 is the day) still takes precedence —
* this only decides the genuinely-ambiguous case. `undefined` when the format
* is absent, month-name (DATE*), or locale-dependent (NLDATE).
*/
private dateOrderFromFmt(fmtname: any): 'YMD' | 'DMY' | 'MDY' | undefined {
const f = (fmtname ?? '').toString().toUpperCase()
if (/^MMDDYY/.test(f)) return 'MDY'
if (/^(DDMMYY|EURDF)/.test(f)) return 'DMY'
if (/^(YYMMDD|E8601DA|B8601DA)/.test(f)) return 'YMD'
return undefined
}
/**
* Infers a column's SAS kind from a VA value string (deferred load, no
* metadata). VA's temporal strings are distinctive — DATE9 `ddMMMyyyy` and ISO
* `yyyy-MM-dd`/`yyyy/MM/dd`, optionally with a `[ T:]HH:MM[:SS]` time part, and
* `H:MM:SS` — so they're detected by shape; otherwise fall back to the VA
* `dataType`.
*/
private inferKindFromValue(value: string, dataType?: string): VaColumnKind {
const monName = String.raw`\d{1,2}[A-Za-z]{3}\d{2,4}`
const ymd = String.raw`\d{4}[-/.]\d{1,2}[-/.]\d{1,2}` // 4-digit year first
const dmy = String.raw`\d{1,2}[-/.]\d{1,2}[-/.]\d{4}` // 4-digit year last
const date = `${monName}|${ymd}|${dmy}`
if (new RegExp(`^(?:${date})[ T:]\\d{1,2}:\\d{2}(:\\d{2})?$`).test(value)) {
return 'datetime'
}
if (new RegExp(`^(?:${date})$`).test(value)) return 'date'
if (/^\d{1,2}:\d{2}(:\d{2})?(\.\d+)?$/.test(value)) return 'time'
if (dataType === 'number') return 'numeric'
return 'char'
}
/**
* Formats one VA filter value into a DC RAW_VALUE for the column kind, or null
* when invalid (dropped). Matches DC's native filters, which send the NUMERIC
* INTERNAL value (not SAS literals — those error out in the backend):
* - numeric -> finite number, raw (else null — injection guard)
* - time -> seconds since midnight (VA: "0:00:02" -> 2)
* - date -> days since 1960-01-01 (VA: "01JUL1960" -> 182)
* - datetime -> seconds since 1960-01-01 (VA: "01JAN1960:00:06:05" -> 365)
* - char -> single-quote escaped
* Temporal strings are regex-parsed (digits/letters/colons only), so a value
* either yields a finite number or is dropped — also the injection guard.
* Date/datetime reuse HelperService.convertJsDateToSasDate so the value is
* byte-identical to what DC's own pickers produce.
*
* A temporal value that is already a bare number is the SAS internal value
* itself (VA sends it unformatted when the data item carries no format / is
* used as a measure), so it is passed through raw. This is unambiguous because
* the only date formats in use (DATE9, ISO) always contain a letter or a
* separator — a plain integer is never a formatted date.
*/
private formatValueByKind(
value: any,
kind: VaColumnKind,
order?: 'YMD' | 'DMY' | 'MDY'
): string | null {
const s = String(value).trim()
if (s === '') return null
switch (kind) {
case 'numeric': {
const num = Number(s)
return Number.isFinite(num) ? `${num}` : null
}
case 'time': {
if (this.isBareNumber(s)) return `${Number(s)}`
const secs = this.timeToSeconds(s)
return secs === null ? null : `${secs}`
}
case 'date': {
if (this.isBareNumber(s)) return `${Number(s)}`
const d = this.parseDate(s, order)
return d
? `${this.helperService.convertJsDateToSasDate(d, 'days')}`
: null
}
case 'datetime': {
if (this.isBareNumber(s)) return `${Number(s)}`
const d = this.parseDatetime(s, order)
return d
? `${this.helperService.convertJsDateToSasDate(d, 'seconds')}`
: null
}
default:
return `'${s.replace(/'/g, "''")}'`
}
}
/** "H:MM[:SS][.f]" -> seconds since midnight, or null if not a valid time. */
private timeToSeconds(value: string): number | null {
if (!/^\d{1,2}:\d{2}(:\d{2})?(\.\d+)?$/.test(value)) return null
const p = value.split('.')[0].split(':')
const h = Number(p[0])
const m = Number(p[1])
const sec = p.length === 3 ? Number(p[2]) : 0
if (![h, m, sec].every(Number.isFinite)) return null
return h * 3600 + m * 60 + sec
}
/**
* VA date string -> local JS Date, or null. VA sends no format metadata, so the
* shape is sniffed. Accepted forms (separator may be `-`, `/`, or `.`):
* - DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962)
* - ISO/YMD "yyyy-MM-dd" (4-digit year first, e.g. 2025-03-31)
* - year-last "dd/mm/yyyy" or "mm/dd/yyyy" (4-digit year last)
*
* Day/month order for the year-last form is resolved in priority order:
* 1. by value — a group > 12 must be the day (ground truth);
* 2. by `order` — the column's declared FMTNAME order (DMY/MDY/YMD);
* 3. default DAY-FIRST — never US month-first — matching SAS's DDMMYY
* default and non-US convention.
* `new Date(y, m-1, d)` is local, matching HelperService.convertJsDateToSasDate
* so the value is byte-identical to DC's own pickers. The constructed date is
* round-trip validated to reject impossible values (e.g. 31/02) rather than
* silently roll over.
*/
private parseDate(value: string, order?: 'YMD' | 'DMY' | 'MDY'): Date | null {
return this.parseMonthNameDate(value) ?? this.parseNumericDate(value, order)
}
/** DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962, 01AUG62) -> local Date, or null. */
private parseMonthNameDate(value: string): Date | null {
const m = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
if (!m) return null
const [, day, monthAbbr, year] = m
const monthIndex = VaFilterService.SAS_MONTHS.indexOf(
monthAbbr.toUpperCase()
)
if (monthIndex < 0) return null
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
}
/**
* Numeric date with `-`, `/`, or `.` separators -> local Date, or null:
* - year-first "yyyy-MM-dd" -> YMD directly;
* - year-last "dd/mm/yyyy" or "mm/dd/yyyy" -> day/month resolved by
* resolveDayMonth (value, then column order, then day-first default).
* A 4-digit year (leading or trailing) is required to anchor the parse;
* an all-2-digit value (e.g. "03/04/05") is too ambiguous and is dropped.
*/
private parseNumericDate(
value: string,
order?: 'YMD' | 'DMY' | 'MDY'
): Date | null {
const m = /^(\d{1,4})[-/.](\d{1,2})[-/.](\d{1,4})$/.exec(value)
if (!m) return null
const [, first, middle, last] = m
const yearIsFirst = first.length === 4
const yearIsLast = last.length === 4
if (yearIsFirst) {
return this.buildDate(Number(first), Number(middle), Number(last))
}
if (!yearIsLast) return null
const { day, month } = this.resolveDayMonth(
Number(first),
Number(middle),
order
)
return this.buildDate(Number(last), month, day)
}
/**
* Resolves the day/month of a year-last date whose two leading groups (`a`,
* `b`) are in unknown order, in priority:
* 1. by value — a group > 12 must be the day (ground truth);
* 2. by `order` — only MDY flips to month-first; DMY/YMD keep day-first;
* 3. default — DAY-first (`a` is the day), never US month-first.
*/
private resolveDayMonth(
a: number,
b: number,
order?: 'YMD' | 'DMY' | 'MDY'
): { day: number; month: number } {
if (a > 12 && b <= 12) return { day: a, month: b }
if (b > 12 && a <= 12) return { day: b, month: a }
if (order === 'MDY') return { day: b, month: a }
return { day: a, month: b }
}
/** Local Date from 1-based month, round-trip validated (null if impossible). */
private buildDate(
year: number,
month: number,
day: number,
hours = 0,
minutes = 0,
seconds = 0
): Date | null {
if (month < 1 || month > 12 || day < 1 || day > 31) return null
const d = new Date(year, month - 1, day, hours, minutes, seconds, 0)
// Reject rollovers (e.g. 31 Feb -> 3 Mar) so a bad value is dropped, not
// silently converted to the wrong SAS date.
if (
d.getFullYear() !== year ||
d.getMonth() !== month - 1 ||
d.getDate() !== day
) {
return null
}
return d
}
/**
* VA datetime string -> local JS Date, or null. The date part is any shape
* accepted by parseDate; the time part is "HH:MM[:SS]". They are joined by the
* SAS ":" separator (e.g. 01JAN1960:05:13:03), an ISO "T", or a space
* (e.g. 2025-03-31T05:13:03, 2025-03-31 05:13:03).
*/
private parseDatetime(
value: string,
order?: 'YMD' | 'DMY' | 'MDY'
): Date | null {
const m = /^(.+?)[ T:](\d{1,2}:\d{2}(?::\d{2})?)$/.exec(value)
if (!m) return null
const date = this.parseDate(m[1], order)
if (!date) return null
const t = m[2].split(':')
date.setHours(Number(t[0]), Number(t[1]), Number(t[2] || 0), 0)
return date
}
/** True when the value is a bare (already-internal) SAS number: optional sign,
* digits, optional fraction. No letters/separators — so never a formatted date. */
private isBareNumber(value: string): boolean {
return /^-?\d+(\.\d+)?$/.test(value) && Number.isFinite(Number(value))
}
/** 2-digit SAS year -> 4-digit (SAS default cutoff: 00-19 -> 20xx, else 19xx). */
private fullYear(year: string): number {
if (year.length === 4) return Number(year)
const y = Number(year)
return y <= 19 ? 2000 + y : 1900 + y
}
}
@@ -0,0 +1,102 @@
import { VaMessagingService, VaMessage } from './va-messaging.service'
// Sample message taken from the official SAS DDC docs (brush column included).
const SAMPLE = {
version: '1',
resultName: 'dd40',
rowCount: 3,
availableRowCount: 3,
data: [
['Finch', 95000.0, 0.4285],
['Jones', 26000.0, 0.0],
['Smith', 108000.0, 0.5]
],
columns: [
{ name: 'bi184', label: 'dealer', type: 'string' },
{
name: 'bi258',
label: 'sales',
type: 'number',
usage: 'quantitative',
aggregation: 'sum'
},
{ name: 'ri1', type: 'number', usage: 'brush' }
]
}
describe('VaMessagingService', () => {
let service: VaMessagingService
beforeEach(() => {
service = new VaMessagingService()
})
describe('parseMessage', () => {
it('parses a valid DDC message and defaults parameters to []', () => {
const msg = service.parseMessage({ data: SAMPLE } as MessageEvent)
expect(msg).not.toBeNull()
expect(msg!.resultName).toBe('dd40')
expect(msg!.rowCount).toBe(3)
expect(msg!.parameters).toEqual([])
})
it('returns null for unrelated / malformed messages', () => {
expect(service.parseMessage({ data: null } as MessageEvent)).toBeNull()
expect(
service.parseMessage({ data: 'hello' } as unknown as MessageEvent)
).toBeNull()
expect(
service.parseMessage({ data: { columns: [] } } as MessageEvent)
).toBeNull()
})
})
describe('dataColumns', () => {
it('excludes brush columns and keeps original indices', () => {
const msg = service.parseMessage({ data: SAMPLE } as MessageEvent)!
const cols = service.dataColumns(msg)
expect(cols.map((c) => c.column.name)).toEqual(['bi184', 'bi258'])
expect(cols.map((c) => c.index)).toEqual([0, 1])
})
})
describe('onData', () => {
const sameOrigin = window.location.origin
it('invokes the callback for valid messages and unsubscribes cleanly', () => {
const received: VaMessage[] = []
const off = service.onData((m) => received.push(m))
window.dispatchEvent(
new MessageEvent('message', { data: SAMPLE, origin: sameOrigin })
)
expect(received.length).toBe(1)
expect(received[0].resultName).toBe('dd40')
// unrelated traffic is ignored
window.dispatchEvent(
new MessageEvent('message', { data: { foo: 1 }, origin: sameOrigin })
)
expect(received.length).toBe(1)
off()
window.dispatchEvent(
new MessageEvent('message', { data: SAMPLE, origin: sameOrigin })
)
expect(received.length).toBe(1)
})
it('rejects messages from an untrusted origin', () => {
const received: VaMessage[] = []
service.onData((m) => received.push(m))
window.dispatchEvent(
new MessageEvent('message', {
data: SAMPLE,
origin: 'https://evil.example.com'
})
)
expect(received.length).toBe(0)
})
})
})
@@ -0,0 +1,218 @@
import { Injectable } from '@angular/core'
/**
* SAS Visual Analytics data-driven content (DDC) postMessage interface.
*
* Contract (from the official SAS docs and the sample repo
* https://github.com/sassoftware/sas-visualanalytics-thirdpartyvisualizations):
* VA shares data with the data-driven content object via window.postMessage().
* The content registers a `message` listener; `event.data` is a JSON object.
* To act as the source of an action (selection / brushing) or to show an
* instructional message, the content posts a message back to `window.parent`,
* echoing the `resultName`.
*/
export interface VaColumn {
/** Internal VA column id, e.g. "bi184". */
name: string
/** Human label, e.g. "dealer". May be absent (e.g. on brush columns). */
label?: string
type: string
/** "categorical" | "quantitative" | "brush" | ... */
usage?: string
aggregation?: string
format?: any
}
export interface VaParameter {
/** VA internal id, e.g. "pr273". NOT the DC column. */
name: string
/** Human label — equals the DC column name in our setup, e.g. "some_dropdown". */
label?: string
/** "number" | "string" — VA's value type (used to decide numeric vs char). */
dataType?: string
type?: string
format?: any
value: any
}
export interface VaMessage {
version?: string
/** Required to echo back on any message to VA. */
resultName: string
rowCount: number
availableRowCount?: number
/** Row-major 2-D array; cell order matches `columns`. Measures are unformatted. */
data: any[][]
columns: VaColumn[]
/** Only the parameters used by the query are returned. */
parameters: VaParameter[]
}
@Injectable({ providedIn: 'root' })
export class VaMessagingService {
/** Result name from the most recent message, needed for the back-channel. */
private resultName: string | null = null
/**
* Origin of the last trusted inbound message — used as the explicit
* targetOrigin for the back-channel so payloads are never broadcast to an
* unintended parent.
*/
private parentOrigin: string | null = null
/**
* Validates that a message comes from a trusted source: our hosting frame
* (the normal SAS VA data-driven content case) or the same origin (local dev /
* tests). Untrusted messages — e.g. from an unrelated window or a malicious
* iframe — are rejected.
*/
private isTrustedSource(event: MessageEvent): boolean {
if (window.parent !== window && event.source === window.parent) return true
return !!event.origin && event.origin === window.location.origin
}
/**
* Cross-reload state. Applying a VA filter navigates to
* /editor/<table>/<FILTER_RK>, which recreates the editor component, so the
* desired filter + column visibility are held on this root singleton and
* re-applied after the reload.
*/
filterSignature: string | null = null
/** DC variable names that should be visible (the rest are hidden). */
visibleColumns: string[] | null = null
/** True once the pre-bootstrap early buffer has been drained (drain-once). */
private earlyDrained = false
/**
* Registers a window `message` listener and invokes `callback` for every
* valid DDC message. The latest message itself is read via `latestMessage()`
* from the always-on `window.__vaLastMessage` buffer, not stored here.
* Returns an unsubscribe function that removes the listener.
*/
onData(callback: (msg: VaMessage) => void): () => void {
const handler = (event: MessageEvent) => {
if (!this.isTrustedSource(event)) return
const parsed = this.parseMessage(event)
if (parsed) {
this.resultName = parsed.resultName
if (event.origin) this.parentOrigin = event.origin
callback(parsed)
}
}
window.addEventListener('message', handler, false)
// Replay any DDC message captured by the early listener before
// the app (and this listener) existed. VA pushes its message once on iframe
// load — which happens before Angular bootstraps — so without this the very
// first message is lost entirely and the editor loads unfiltered.
this.replayEarlyMessages(callback)
return () => window.removeEventListener('message', handler, false)
}
/**
* The true latest DDC message, from the always-on `window.__vaLastMessage`
* buffer (set in va-early.js). That listener is registered at page parse and
* never removed, so it has NO gaps — including the window during an editor
* reload when the app's own listener is briefly absent. So a message that
* arrives mid-reload (e.g. a rapid filter change) is still here.
*/
latestMessage(): VaMessage | null {
const captured = (window as unknown as { __vaLastMessage?: any })
.__vaLastMessage
return captured ? this.parseData(captured.data) : null
}
/**
* Replays the latest VA message captured by the early `window.__vaLastMessage`
* listener (set up in va-early.js), closing the pre-bootstrap race. Only runs
* when actually framed (DDC context).
*/
private replayEarlyMessages(callback: (msg: VaMessage) => void): void {
if (window.parent === window) return
// Drain only once per page lifetime. This service is a root singleton, so
// the flag persists across editor reloads — without it every reload would
// re-replay the captured message and fight the live filter (loop).
if (this.earlyDrained) return
this.earlyDrained = true
const captured = (window as unknown as { __vaLastMessage?: any })
.__vaLastMessage
const parsed = this.parseData(captured && captured.data)
if (!parsed) return
this.resultName = parsed.resultName
if (captured.origin) this.parentOrigin = captured.origin
callback(parsed)
}
/**
* Parses a raw window MessageEvent into a VaMessage, or null when it is not a
* recognisable DDC message (e.g. unrelated postMessage traffic).
*/
parseMessage(event: MessageEvent): VaMessage | null {
return this.parseData(event && event.data)
}
/**
* Parses a raw message payload (from a MessageEvent or the early buffer) into
* a VaMessage, or null when it is not a recognisable DDC message.
*/
private parseData(data: any): VaMessage | null {
if (!data || typeof data !== 'object') return null
if (typeof data.resultName !== 'string') return null
if (!Array.isArray(data.columns) || !Array.isArray(data.data)) return null
return {
version: data.version,
resultName: data.resultName,
rowCount: data.rowCount,
availableRowCount: data.availableRowCount,
data: data.data,
columns: data.columns,
parameters: Array.isArray(data.parameters) ? data.parameters : []
}
}
/**
* Columns that carry grid data — excludes `usage: "brush"` columns, which
* encode linked-selection state rather than data.
*/
dataColumns(msg: VaMessage): { column: VaColumn; index: number }[] {
return msg.columns
.map((column, index) => ({ column, index }))
.filter(({ column }) => column.usage !== 'brush')
}
/**
* Back-channel (DDC → VA) — selection / source-of-action. Designed per the
* SAS contract; not wired into the UI in v1.
*/
sendSelection(rows: number[]): void {
if (this.resultName === null) return
this.sendMessage({
resultName: this.resultName,
selections: rows.map((row) => ({ row }))
})
}
/**
* Back-channel (DDC → VA) — custom instructional message, shown in the report
* designer when the assigned data does not meet the visual's requirements.
*/
sendInstructionalMessage(message: string): void {
if (this.resultName === null) return
this.sendMessage({ resultName: this.resultName, message })
}
private sendMessage(message: any): void {
// Prefer the validated origin captured from the inbound VA message; only
// fall back to the referrer/own location when we have not yet received one.
const target =
this.parentOrigin ||
(window.location !== window.parent.location
? document.referrer
: document.location.href)
if (!target) return
window.parent.postMessage(message, target)
}
}
@@ -1,23 +1,26 @@
import Handsontable from 'handsontable'
import { datetimeValidator } from '../validations/hot-custom-validators'
import { DATETIME_FORMAT } from '../utils/mapIntlCellTypes'
/**
* Cell types for the date/time/datetime columns after the HOT 17 migration.
*
* `date` and `time` use Handsontable's BUILT-IN `intl-date` / `intl-time` cell
* types as-is — a native (browser/OS) picker editor plus the Intl renderer that
* formats the displayed value per the cell's `locale` + `dateFormat`/`timeFormat`
* (set as cell meta in mapIntlCellTypes). They are intentionally NOT re-registered
* here, so their localizing `valueFormatter` stays active. The stored/submitted
* value remains the raw ISO string; only the display is localized.
* All three DISPLAY the raw stored ISO string verbatim — `YYYY-MM-DD`,
* `HH:mm:ss`, `YYYY-MM-DD HH:mm:ss` — via an identity `valueFormatter`, so the
* cell text is stable and locale-independent. The native picker editors still
* follow the OS regional format while open; only the painted cell is ISO.
* The stored/submitted value is unchanged.
*
* Only `intl-datetime` needs registering — HOT has no built-in datetime type. It
* pairs a native `datetime-local` picker with a `valueFormatter` that formats the
* stored `YYYY-MM-DD HH:mm:ss` per the cell's locale (mirroring how the built-in
* intl-date/intl-time types localize, via tableView's `renderer.valueFormatter`).
* `intl-date` and `intl-time` are HOT built-ins; we re-register them by spreading
* the built-in definition (keeping its native picker editor + ISO validator) and
* swapping only the `valueFormatter` for the identity one. `intl-datetime` has no
* built-in — it pairs a native `datetime-local` picker with the identity
* formatter. valueFormatter is applied render-only (tableView), so copy/paste and
* the stored value are untouched.
*/
/** Identity formatter — display the stored ISO string as-is (no localization). */
const isoPassthrough = (value: any): any => value
const SPACE_ISO = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2})?)$/
const BaseTextEditor = Handsontable.editors.getEditor('text') as any
@@ -63,39 +66,34 @@ class IntlDatetimeEditor extends BaseTextEditor {
}
/**
* Localizes a stored `YYYY-MM-DD HH:mm:ss` datetime per the cell's locale.
* Invoked by Handsontable via `renderer.valueFormatter` (tableView) before the
* text renderer paints the cell, so the stored value is never mutated. Falls
* back to the raw value when it is empty or unparseable.
* Re-registers a built-in HOT cell type (`intl-date` / `intl-time`) with only its
* `valueFormatter` swapped for the ISO passthrough — the native picker editor and
* ISO validator are reused from the built-in definition. Idempotent: a second
* call reads back our already-overridden type, which still carries the original
* editor/validator.
*/
function intlDatetimeValueFormatter(value: any, cellProperties: any): any {
if (typeof value !== 'string') return value
const match = value.match(SPACE_ISO)
if (!match) return value
const date = new Date(`${match[1]}T${match[2]}`)
if (isNaN(date.getTime())) return value
const options: Intl.DateTimeFormatOptions =
cellProperties.datetimeFormat || DATETIME_FORMAT
const locale = cellProperties.locale || window.navigator.language
return new Intl.DateTimeFormat(locale, options).format(date)
const overrideWithPassthrough = (type: string) => {
const builtin = Handsontable.cellTypes.getCellType(type) as any
Handsontable.cellTypes.registerCellType(type, {
...builtin,
valueFormatter: isoPassthrough
})
}
/**
* Registers the `intl-datetime` cell type. Idempotent — safe to call per
* DcValidator construction. `intl-date`/`intl-time` are not registered here;
* the built-in HOT types are used directly.
* Registers the Intl date/time/datetime cell types. Idempotent — safe to call
* per DcValidator construction. All three display the stored ISO value verbatim.
*/
export const registerIntlCellTypes = () => {
overrideWithPassthrough('intl-date')
overrideWithPassthrough('intl-time')
Handsontable.cellTypes.registerCellType('intl-datetime', {
// IntlDatetimeEditor extends the (loosely-typed) text editor returned by
// getEditor('text'); cast to satisfy registerCellType's editor signature.
editor: IntlDatetimeEditor as any,
renderer: baseTextRenderer,
validator: datetimeValidator,
valueFormatter: intlDatetimeValueFormatter
valueFormatter: isoPassthrough
} as any)
}
@@ -17,7 +17,8 @@ import { DQRule, DQRuleTypes } from './models/dq-rules.model'
import { getDqDataCols } from './utils/getDqDataCols'
import { getNotNullDefault } from './utils/getNotNullDefault'
import { mergeColsRules } from './utils/mergeColsRules'
import { parseColType } from './utils/parseColType'
import { parseColTypeRow } from './utils/parseColTypeRow'
import { DELETE_RECORD_COLUMN_RULE } from './utils/deleteRecordColumnRule'
import { dqValidate } from './validations/dq-validation'
import {
datetimeValidator,
@@ -50,7 +51,33 @@ export class DcValidator {
this.sasparams = sasparams
this.hotInstance = hotInstance
this.rules = parseColType(sasparams.COLTYPE)
// Each cols[i].COLTYPE is a single JSON-object string (one per column, as
// of the vertical-array getdata.sas fix — issue #253), not the old
// comma-joined sasparams.COLTYPE list. DELETE_RECORD_COLUMN_RULE is
// injected separately — see its own doc comment for why.
//
// COLHEADERS is the authoritative grid order and the only safe key to
// build against: it is the variable list of the *output* dataset, where
// getdata.sas hoists the delete column and the PK to the front ("order
// delete var and pk fields at start of table"), whereas cols[].VARNUM
// describes the *source* table and cols[] itself can arrive sorted by
// NAME. Those orders diverge whenever the PK isn't already the source's
// first column, so rules are keyed by name and emitted in COLHEADERS
// order — never paired positionally against cols[] or VARNUM.
//
// A column with no parseable COLTYPE falls back to a plain text rule
// rather than being dropped: getRules() is consumed positionally against
// COLHEADERS, so a missing entry would shift every later column.
const ruleByName = new Map<string, DcValidation>([
[DELETE_RECORD_COLUMN_RULE.data, { ...DELETE_RECORD_COLUMN_RULE }]
])
for (const col of cols) {
const rule = parseColTypeRow(col.COLTYPE)
if (rule) ruleByName.set(col.NAME, rule)
}
this.rules = sasparams.COLHEADERS.split(',').map(
(name) => ruleByName.get(name) ?? { data: name }
)
this.rules = mergeColsRules(cols, this.rules, $dataFormats)
this.rules = applyNumericFormats(this.rules)
this.rules = mapIntlCellTypes(this.rules)
@@ -389,6 +416,27 @@ export class DcValidator {
const colType = self.rules[i].type || ''
let handsontableValid = null
// A row marked for delete is exempt from cell-level validation — its
// values are about to be removed. Two deliberate exceptions:
// - PK columns still validate: the key identifies WHICH record to
// delete, and blank/invalid PKs are also skipped by the separate
// duplicate-key check (validatePrimaryKeys), so this is the only
// net that would otherwise catch them.
// - Uniqueness checks are unaffected by construction: they run in a
// separate pass (editor.component.ts validatePrimaryKeys) that
// never goes through this validator.
const markedForDelete =
this.instance?.getDataAtRowProp?.(
this.row,
'_____DELETE__THIS__RECORD_____'
) === 'Yes'
if (markedForDelete && !self.primaryKeys.includes(col || '')) {
callback(true)
return
}
// We call handsontable predefined validators, if it returns false, we validate as FALSE
// If return is true, we validate with our custom DQ validations
@@ -540,8 +588,11 @@ export class DcValidator {
if (rule.clsRule === 'EDIT') {
rule.readOnly = false
// Guard the -1: splice(-1, 1) drops the LAST hidden column rather than
// no-op'ing, so an EDIT column that was never hidden would un-hide an
// unrelated one (e.g. a column hidden by a HIDDEN DQ rule).
const hiddenIndex = this.hiddenColumns.indexOf(index)
this.hiddenColumns.splice(hiddenIndex, 1)
if (hiddenIndex !== -1) this.hiddenColumns.splice(hiddenIndex, 1)
}
}
}
@@ -1,10 +1,9 @@
import Handsontable from 'handsontable'
import Core from 'handsontable/core'
export class CustomAutocompleteEditor
extends Handsontable.editors.AutocompleteEditor
{
constructor(instance: Core) {
constructor(instance: Handsontable.Core) {
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')
@@ -9,4 +9,8 @@ export interface Col {
MEMLABEL: string
DESC: string
LONGDESC: string
// Handsontable column-formatting spec (JSON-object string), one per column
// — see parseColTypeRow.ts. Optional: getdata.sas LEFT JOINs this in, so
// an unmatched row can leave it unset.
COLTYPE?: string
}
@@ -23,11 +23,11 @@ describe('DC Validator', () => {
// Check if COLS merged with FORMATS
expect(cols[0].TYPE).toEqual('char')
// Get all
// Get all — one rule per cols[] entry, plus the injected
// DELETE_RECORD_COLUMN_RULE (never present in cols[], see its own
// doc comment)
const validationRules = dcValidator.getRules()
expect(validationRules).toHaveSize(
JSON.parse(`[${example_COLTYPE}]`).length
)
expect(validationRules).toHaveSize(example_cols.length + 1)
// Get col with notnull validation
const someNumRule = dcValidator.getRule('SOME_NUM')
@@ -35,26 +35,21 @@ describe('DC Validator', () => {
expect(someNumRule?.allowEmpty).toBeFalse()
// SAS sends semantic date/time/datetime column types; the frontend maps them
// to the HOT 17 Intl cell types and attaches locale + Intl format meta so the
// cell display is localized (see mapIntlCellTypes). The deprecated
// correctFormat + moment format strings are no longer sent in the payload.
// to the HOT 17 Intl cell-type names (see mapIntlCellTypes). The cell display
// is the raw stored ISO string (no locale/format meta) — see
// registerIntlCellTypes. The deprecated correctFormat + moment format strings
// are no longer sent in the payload.
const someDateRule = dcValidator.getRule('SOME_DATE')
expect(someDateRule?.correctFormat).toBeUndefined()
expect(someDateRule?.type).toBe('intl-date')
expect(someDateRule?.dateFormat).toBeDefined()
expect((someDateRule as any)?.locale).toBeTruthy()
const someDatetimeRule = dcValidator.getRule('SOME_DATETIME')
expect(someDatetimeRule?.type).toBe('intl-datetime')
expect(someDatetimeRule?.correctFormat).toBeUndefined()
expect((someDatetimeRule as any)?.datetimeFormat).toBeDefined()
expect((someDatetimeRule as any)?.locale).toBeTruthy()
const someTimeRule = dcValidator.getRule('SOME_TIME')
expect(someTimeRule?.type).toBe('intl-time')
expect(someTimeRule?.correctFormat).toBeUndefined()
expect((someTimeRule as any)?.timeFormat).toBeDefined()
expect((someTimeRule as any)?.locale).toBeTruthy()
// Get col with dropdown, check if merging sas data is correct
const someDropdownRule = dcValidator.getRule('SOME_DROPDOWN')
@@ -71,6 +66,15 @@ describe('DC Validator', () => {
const someDropdownHardRule = dcValidator.getRule('SOME_DROPDOWN_HARD')
// Check strict - it is hardselect so strict should be true
expect(someDropdownHardRule?.strict).toBeTrue()
// _____DELETE__THIS__RECORD_____ never has a cols[] entry (%mp_getcols
// doesn't know about it — see DELETE_RECORD_COLUMN_RULE), but its rule
// must still exist with its fixed dropdown shape, independent of cols
const deleteRecordRule = dcValidator.getRule(
'_____DELETE__THIS__RECORD_____'
)
expect(deleteRecordRule?.type).toEqual('dropdown')
expect(deleteRecordRule?.source).toEqual(['No', 'Yes'])
})
it('should create an instance of validator and execute its functions', () => {
@@ -283,8 +287,261 @@ describe('DC Validator', () => {
expect(dcValidator.getRoundDigits('SOME_SHORTNUM')).toEqual(2)
expect(dcValidator.getRoundDigits('SOME_NUM')).toBeUndefined()
})
it('5 | orders rules by COLHEADERS when cols[] arrives in a different order', () => {
// %mp_getcols/cols1 can serialize cols[] alphabetically by NAME rather
// than in COLHEADERS order (confirmed against a real getdata.sas
// response). getRules() is consumed positionally against headerColumns,
// so rule order must follow COLHEADERS regardless of cols[]'s own order.
const alphabeticalCols = [...example_cols].sort((a, b) =>
a.NAME.localeCompare(b.NAME)
)
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
alphabeticalCols,
example_dqRules,
example_dqData
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
example_sasparams.COLHEADERS.split(',')
)
})
it("6 | keeps rules aligned with COLHEADERS when the PK is not the source table's first column", () => {
// Regression test for the real-world misalignment: getdata.sas hoists the
// delete column and PK to the front of the *output* dataset ("order
// delete var and pk fields at start of table"), so COLHEADERS order and
// cols[].VARNUM order (which describes the *source* table) diverge
// whenever the PK isn't already the source's first column. Worst case (as
// seen in the wild): the PK is the source's LAST column, so pairing rules
// to headers by VARNUM shifts every single column by one.
const cols: Col[] = [
// deliberately alphabetical, as the backend serializes it
makeCol('PRIMARY_KEY_FIELD', 6), // the PK — last in the source table
makeCol('SOME_CHAR', 1),
makeCol('SOME_DATE', 2),
makeCol('SOME_DROPDOWN', 3),
makeCol('SOME_NUM', 4),
makeCol('SOME_TIME', 5)
]
const sasparams: SASParam = {
...example_sasparams,
// PK hoisted to the front by getdata.sas, ahead of VARNUM 1..5
COLHEADERS:
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,' +
'SOME_DATE,SOME_DROPDOWN,SOME_NUM,SOME_TIME',
PK: 'PRIMARY_KEY_FIELD'
} as SASParam
const dcValidator: DcValidator = new DcValidator(
sasparams,
{ vars: {} } as $DataFormats,
cols,
[],
[]
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
sasparams.COLHEADERS.split(',')
)
})
it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => {
// A dropped rule would shorten getRules() and shift every later column
// against headerColumns. getdata.sas LEFT JOINs COLTYPE in, so an
// unmatched row can legitimately leave it unset.
const cols: Col[] = [
makeCol('PRIMARY_KEY_FIELD', 1),
{ ...makeCol('SOME_CHAR', 2), COLTYPE: undefined },
{ ...makeCol('SOME_NUM', 3), COLTYPE: 'not valid json' }
]
const sasparams: SASParam = {
...example_sasparams,
COLHEADERS:
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_NUM',
PK: 'PRIMARY_KEY_FIELD'
} as SASParam
const dcValidator: DcValidator = new DcValidator(
sasparams,
{ vars: {} } as $DataFormats,
cols,
[],
[]
)
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
sasparams.COLHEADERS.split(',')
)
})
it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => {
// hiddenColumns.indexOf() returns -1 for a column that isn't hidden, and
// splice(-1, 1) drops the LAST element instead of no-op'ing — so an EDIT
// column used to silently un-hide a column hidden by a HIDDEN DQ rule.
//
// The EDIT column must not be the PK: setColumnLevelSecurity forces PK
// columns to clsRule 'READ' before the EDIT branch is reached, which would
// skip the splice entirely and leave this test passing against the bug.
const cols: Col[] = [
{ ...makeCol('PK_COL', 1), CLS_RULE: 'READ' },
{ ...makeCol('SECRET_COL', 2), CLS_RULE: 'READ' },
{ ...makeCol('OPEN_COL', 3), CLS_RULE: 'EDIT' }
]
const sasparams: SASParam = {
...example_sasparams,
CLS_FLAG: 1,
COLHEADERS: '_____DELETE__THIS__RECORD_____,PK_COL,SECRET_COL,OPEN_COL',
PK: 'PK_COL'
} as SASParam
const dqRules: DQRule[] = [
{ BASE_COL: 'SECRET_COL', RULE_TYPE: 'HIDDEN', RULE_VALUE: '', X: 0 }
]
const dcValidator: DcValidator = new DcValidator(
sasparams,
{ vars: {} } as $DataFormats,
cols,
dqRules,
[]
)
// SECRET_COL is rules[2]; CLS_FLAG also hides the delete column (index 0).
// OPEN_COL (rules[3]) is EDIT and was never hidden, so the splice guard is
// what keeps SECRET_COL hidden here.
expect(dcValidator.getHiddenColumns()).toContain(2)
})
describe('9 | ignores cell validation on rows marked for delete', () => {
// An invalid cell shouldn't block submission if its row
// is about to be deleted anyway. The PK is exempt from the exemption — it
// identifies WHICH record to delete, and a blank/invalid PK on a delete
// row would also slip past the separate uniqueness check (which skips
// blank PKs).
//
// The validator is a plain `function` invoked with cellProperties as
// `this` (row + instance), so a minimal fake stands in for a real HOT
// instance here — only `instance.getDataAtRowProp` is read by the guard.
const fakeCellProps = (markedForDelete: boolean): any => ({
row: 0,
editor: undefined,
instance: {
getDataAtRowProp: (_row: number, prop: string) =>
prop === '_____DELETE__THIS__RECORD_____'
? markedForDelete
? 'Yes'
: 'No'
: undefined
}
})
const buildValidator = () =>
new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
example_dqRules,
example_dqData
)
it('exempts an invalid non-PK cell on a delete-marked row', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
fakeCellProps(true),
'not a number',
(valid: boolean) => {
expect(valid).toBeTrue()
}
)
})
it('exempts a NOTNULL-empty non-PK cell on a delete-marked row', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
fakeCellProps(true),
null,
(valid: boolean) => {
expect(valid).toBeTrue()
}
)
})
it('still rejects an invalid non-PK cell when the row is not marked for delete', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
fakeCellProps(false),
'not a number',
(valid: boolean) => {
expect(valid).toBeFalse()
}
)
})
it('still accepts a valid non-PK cell when the row is not marked for delete', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
someNumRule!.validator!.call(
fakeCellProps(false),
2,
(valid: boolean) => {
expect(valid).toBeTrue()
}
)
})
it('still rejects an invalid PK cell even when the row is marked for delete', () => {
const dcValidator = buildValidator()
const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')
pkRule!.validator!.call(
fakeCellProps(true),
'not a number',
(valid: boolean) => {
expect(valid).toBeFalse()
}
)
})
it('treats the edit-record modal path (dummy HOT instance, no data) as not marked for delete', () => {
const dcValidator = buildValidator()
const someNumRule = dcValidator.getRule('SOME_NUM')
// executeHotValidator builds cellProperties against a real but
// data-less Handsontable instance — getDataAtRowProp() there returns
// undefined, never 'Yes', so the exemption never applies in the modal.
dcValidator.executeHotValidator(someNumRule!, null, (valid: boolean) => {
expect(valid).toBeFalse()
})
})
})
})
/** Minimal cols[] entry — only the fields rule ordering depends on. */
const makeCol = (name: string, varnum: number): Col =>
({
NAME: name,
VARNUM: varnum,
LABEL: name,
FMTNAME: '',
DDTYPE: 'CHARACTER',
TYPE: '',
CLS_RULE: 'READ',
MEMLABEL: '',
DESC: '',
LONGDESC: '',
COLTYPE: `{"data":"${name}"}`
}) as Col
const example_dqData = [
{
BASE_COL: 'SOME_DROPDOWN',
@@ -388,6 +645,13 @@ const example_dqRules: any = [
}
]
// One row per column, each carrying its own COLTYPE JSON-object string — the
// vertical-array shape from issue #253's getdata.sas fix. Order: SOME_DROPDOWN
// then SOME_NUM first (the `cols[0].TYPE` assertion above is positional),
// remaining columns in the same order the old concatenated
// sasparams.COLTYPE used to list them. _____DELETE__THIS__RECORD_____ is
// deliberately absent — DcValidator injects its rule directly (see
// DELETE_RECORD_COLUMN_RULE), since %mp_getcols never has a row for it.
const example_cols = [
{
CLS_RULE: 'READ',
@@ -399,7 +663,8 @@ const example_cols = [
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DROPDOWN',
VARNUM: 3
VARNUM: 3,
COLTYPE: '{"data":"SOME_DROPDOWN"}'
},
{
CLS_RULE: 'READ',
@@ -411,77 +676,157 @@ const example_cols = [
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_NUM',
VARNUM: 4
VARNUM: 4,
COLTYPE: '{"data":"SOME_NUM","type":"numeric","format":"0"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'PRIMARY_KEY_FIELD',
LONGDESC: '',
MEMLABEL: '',
NAME: 'PRIMARY_KEY_FIELD',
VARNUM: 1,
COLTYPE: '{"data":"PRIMARY_KEY_FIELD","type":"numeric","format":"0"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_CHAR',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_CHAR',
VARNUM: 2,
COLTYPE: '{"data":"SOME_CHAR"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_CHAR_LOW',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_CHAR_LOW',
VARNUM: 5,
COLTYPE: '{"data":"SOME_CHAR_LOW"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_CHAR_ANY',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_CHAR_ANY',
VARNUM: 6,
COLTYPE: '{"data":"SOME_CHAR_ANY"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'CHARACTER',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_DROPDOWN_HARD',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DROPDOWN_HARD',
VARNUM: 7,
COLTYPE: '{"data":"SOME_DROPDOWN_HARD"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'DATE',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_DATE',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DATE',
VARNUM: 8,
COLTYPE: '{"data":"SOME_DATE","type":"date"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'DATETIME',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_DATETIME',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_DATETIME',
VARNUM: 9,
COLTYPE: '{"data":"SOME_DATETIME","type":"datetime"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'TIME',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_TIME',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_TIME',
VARNUM: 10,
COLTYPE: '{"data":"SOME_TIME","type":"time"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_SHORTNUM',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_SHORTNUM',
VARNUM: 11,
COLTYPE: '{"data":"SOME_SHORTNUM","type":"numeric","format":"0"}'
},
{
CLS_RULE: 'READ',
DDTYPE: 'NUMERIC',
DESC: '',
TYPE: '',
FMTNAME: '',
LABEL: 'SOME_BESTNUM',
LONGDESC: '',
MEMLABEL: '',
NAME: 'SOME_BESTNUM',
VARNUM: 12,
COLTYPE: '{"data":"SOME_BESTNUM","type":"numeric","format":"0"}'
}
]
const example_COLTYPE = `
{
"data":"_____DELETE__THIS__RECORD_____",
"type":"dropdown",
"source":[
"No",
"Yes"
]
},
{
"data":"PRIMARY_KEY_FIELD",
"type":"numeric",
"format":"0"
},
{
"data":"SOME_CHAR"
},
{
"data":"SOME_CHAR_LOW"
},
{
"data":"SOME_CHAR_ANY"
},
{
"data":"SOME_DROPDOWN"
},
{
"data":"SOME_DROPDOWN_HARD"
},
{
"data":"SOME_NUM",
"type":"numeric",
"format":"0"
},
{
"data":"SOME_DATE",
"type":"date"
},
{
"data":"SOME_DATETIME",
"type":"datetime"
},
{
"data":"SOME_TIME",
"type":"time"
},
{
"data":"SOME_SHORTNUM",
"type":"numeric",
"format":"0"
},
{
"data":"SOME_BESTNUM",
"type":"numeric",
"format":"0"
}`
// COLHEADERS mirrors a real getdata.sas response: the *output* dataset's
// variable list, i.e. the delete column and PK hoisted to the front, then the
// remaining source columns in VARNUM order. It drives rule order, so it must
// list every example_cols entry.
const example_sasparams = {
CLS_FLAG: 0,
COLHEADERS: 'head1 head2',
COLTYPE: example_COLTYPE,
COLHEADERS:
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,' +
'SOME_NUM,SOME_CHAR_LOW,SOME_CHAR_ANY,SOME_DROPDOWN_HARD,SOME_DATE,' +
'SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM',
DTTMVARS: 'dttm vars',
DTVARS: 'dt vars',
FILTER_TEXT: 'filter text',
LOADTYPE: 'load type',
PK: 'primary key',
PKCNT: 0,
PK: 'PRIMARY_KEY_FIELD',
PKCNT: 1,
RK_FLAG: 0,
TMVARS: 'string'
}
@@ -512,6 +857,24 @@ const example_dataformats = {
length: '128',
type: 'char'
},
SOME_CHAR_LOW: {
format: '$128.',
label: 'SOME_CHAR_LOW',
length: '128',
type: 'char'
},
SOME_CHAR_ANY: {
format: '$128.',
label: 'SOME_CHAR_ANY',
length: '128',
type: 'char'
},
SOME_DROPDOWN_HARD: {
format: '$128.',
label: 'SOME_DROPDOWN_HARD',
length: '128',
type: 'char'
},
SOME_NUM: {
format: 'best.',
label: 'SOME_NUM',
@@ -1,23 +0,0 @@
import { HotColumnSettings } from '../models/dc-validation.model'
import { parseColType } from '../utils/parseColType'
describe('DC Validator - parse col type', () => {
it('should return array of parsed json', () => {
const colTypeString =
'{"data":"test","test2":"test2"}, {"data":"test3","test4":"test4"}'
const expected: HotColumnSettings[] = [
{ data: 'test', test2: 'test2' },
{ data: 'test3', test4: 'test4' }
]
expect(parseColType(colTypeString)).toEqual(expected)
})
it('should return empty array for invalid json', () => {
const colTypeString =
'{"test":"test""test:"test2"}, {"test3":"test3","test4":"test4"}'
const expected: HotColumnSettings[] = []
expect(parseColType(colTypeString)).toEqual(expected)
})
})
@@ -0,0 +1,24 @@
import { HotColumnSettings } from '../models/dc-validation.model'
import { parseColTypeRow } from '../utils/parseColTypeRow'
describe('DC Validator - parse col type row', () => {
it('parses a single JSON object', () => {
const expected: HotColumnSettings = { data: 'test', type: 'numeric' }
expect(parseColTypeRow('{"data":"test","type":"numeric"}')).toEqual(
expected
)
})
it('returns undefined for undefined input (LEFT JOIN can leave it unset)', () => {
expect(parseColTypeRow(undefined)).toBeUndefined()
})
it('returns undefined for an empty string', () => {
expect(parseColTypeRow('')).toBeUndefined()
})
it('returns undefined for malformed JSON, without throwing', () => {
expect(parseColTypeRow('{not valid')).toBeUndefined()
})
})
@@ -0,0 +1,16 @@
import { DcValidation } from '../models/dc-validation.model'
/**
* `_____DELETE__THIS__RECORD_____` is a client-side UI concept (the delete
* checkbox column) that getdata.sas synthesizes into vars3/vars4 for display
* purposes only — it's never a real column of the underlying dataset, so
* `%mp_getcols` (which builds `cols1`, the left side of the join into
* `cols`) never has a row for it, and its rule can no longer travel via
* `cols[].COLTYPE` (see issue #253's vertical-array fix). The rule is fixed
* and data-independent, so it's injected directly here instead.
*/
export const DELETE_RECORD_COLUMN_RULE: DcValidation = {
data: '_____DELETE__THIS__RECORD_____',
type: 'dropdown',
source: ['No', 'Yes']
}
@@ -2,58 +2,31 @@ import { DcValidation } from '../models/dc-validation.model'
/**
* Maps SAS' semantic date/time/datetime column types to the HOT 17 Intl cell
* types and attaches the locale + Intl format meta that drive their display.
* type names. SAS sends a HOT-agnostic flag (`date`/`time`/`datetime`); the
* frontend owns the HOT-specific cell-type names:
*
* SAS sends a HOT-agnostic flag (`date`/`time`/`datetime`); the frontend owns
* the HOT-specific cell-type names and the locale-aware rendering:
* - `date` → `intl-date` (native date picker)
* - `time` → `intl-time` (native time picker)
* - `datetime` → `intl-datetime` (native datetime-local picker)
*
* - `date` → `intl-date` (native date picker; display via Intl `dateFormat`)
* - `time` → `intl-time` (native time picker; display via Intl `timeFormat`)
* - `datetime` → `intl-datetime` (native datetime-local picker; display via `datetimeFormat`)
*
* The cell display is localized to `navigator.language` (the browser/OS locale);
* the stored/submitted value stays the raw ISO string. Second precision is kept
* for time/datetime so SAS time/datetime values are not truncated. Runs in the
* DcValidator constructor, before the rules reach Handsontable. See
* registerIntlCellTypes for the editors/validators these resolve to.
* The cell display is the raw stored ISO string (locale-independent) — see
* registerIntlCellTypes, where these types' `valueFormatter` is the identity
* passthrough. No locale/format meta is attached; it is unused for display, and
* the validators are ISO-based and locale-agnostic. Runs in the DcValidator
* constructor, before the rules reach Handsontable.
*
* @param rules Cell validation rules passed to Handsontable's `columns`.
*/
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}
const TIME_FORMAT: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}
export const DATETIME_FORMAT: Intl.DateTimeFormatOptions = {
...DATE_FORMAT,
...TIME_FORMAT
}
const INTL_TYPE_MAP: Record<
string,
{ type: string; meta: Record<string, unknown> }
> = {
date: { type: 'intl-date', meta: { dateFormat: DATE_FORMAT } },
time: { type: 'intl-time', meta: { timeFormat: TIME_FORMAT } },
datetime: { type: 'intl-datetime', meta: { datetimeFormat: DATETIME_FORMAT } }
const INTL_TYPE_MAP: Record<string, string> = {
date: 'intl-date',
time: 'intl-time',
datetime: 'intl-datetime'
}
export const mapIntlCellTypes = (rules: DcValidation[]): DcValidation[] => {
for (const rule of rules) {
const cfg = rule.type ? INTL_TYPE_MAP[rule.type as string] : undefined
if (!cfg) continue
rule.type = cfg.type
const meta = rule as Record<string, unknown>
meta.locale = window.navigator.language
Object.assign(meta, cfg.meta)
const mapped = rule.type ? INTL_TYPE_MAP[rule.type as string] : undefined
if (mapped) rule.type = mapped
}
return rules
@@ -27,7 +27,7 @@ export const mergeColsRules = (
: colFormats.type
if (rule && col.DESC) rule.desc = col.DESC
if (rule && colFormats.length) rule.length = parseInt(colFormats.length)
if (rule && colFormats?.length) rule.length = parseInt(colFormats.length)
if (rule && col.CLS_RULE) rule.clsRule = col.CLS_RULE
if (rule && colFormats?.type) rule.sasType = colFormats.type
}
@@ -1,16 +0,0 @@
import { HotColumnSettings } from '../models/dc-validation.model'
/**
* From sas we get a string instead of array of objects, in that string `[]` are missing so
* before parsing JSON we need to add them.
*
* @param coltype string (objects) that comes from sas
* @returns JSON Handsontable.ColumnSettings[]
*/
export const parseColType = (coltype: string): HotColumnSettings[] => {
try {
return JSON.parse(`[${coltype}]`)
} catch (err: any) {
return []
}
}
@@ -0,0 +1,23 @@
import { HotColumnSettings } from '../models/dc-validation.model'
/**
* Parses a single column's `COLTYPE` JSON-object string (one per `cols` row,
* as of the vertical-array getdata.sas fix — see issue #253). Unlike the
* previous comma-joined `sasparams.COLTYPE` format, each row is independent:
* a malformed or missing value here only drops that one column's rule,
* rather than every column's.
*
* @param coltype a single column's COLTYPE JSON string, or undefined (the
* LEFT JOIN in getdata.sas can leave it unset for unmatched rows)
*/
export const parseColTypeRow = (
coltype: string | undefined
): HotColumnSettings | undefined => {
if (!coltype) return undefined
try {
return JSON.parse(coltype)
} catch (err: any) {
return undefined
}
}
@@ -52,12 +52,14 @@ export async function exportGrid(
// Mirror HOT's own export item: only honor a selection that spans more than
// 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,151 @@
import { BehaviorSubject } from 'rxjs'
import * as XLSX from '@sheet/crypto'
import { SpreadsheetUtil } from './spreadsheet-util'
import { LicenceState } from 'src/app/models/LicenceState'
import { ParseParams } from 'src/app/models/ParseParams.interface'
import { SearchDataExcelResult } from 'src/app/models/SearchDataExcelResult.interface'
describe('SpreadsheetUtil - header matching', () => {
// licenceState is unused by searchDataInExcel, so an empty stub is fine.
const buildSpreadsheetUtil = () =>
new SpreadsheetUtil({
licenceState: new BehaviorSubject<LicenceState>({} as LicenceState)
})
const buildWorkbook = (headerRow: string[], dataRow: any[]) => {
const ws = XLSX.utils.aoa_to_sheet([headerRow, dataRow])
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')
return wb
}
// searchDataInExcel only reads headerArray/headerPks off ParseParams; the
// other required fields (dcValidator, etc.) are irrelevant here, so a
// minimal cast object stands in for a full ParseParams.
const buildParseParams = (): ParseParams =>
({
headerArray: ['PRIMARY_KEY_FIELD', 'SOME_CHAR', 'SOME_NUM'],
headerPks: ['PRIMARY_KEY_FIELD']
}) as any as ParseParams
// searchDataInExcel is private; there's no existing precedent in this
// codebase for testing private methods, so this casts through `any` to
// reach it directly rather than exercising it via the public
// parseSpreadsheetFile (which would need a real File/FileReader).
const searchDataInExcel = (
spreadsheetUtil: SpreadsheetUtil,
wb: XLSX.WorkBook,
parseParams: ParseParams
): SearchDataExcelResult =>
(spreadsheetUtil as any).searchDataInExcel(wb, parseParams)
it('matches headers with leading whitespace against the expected clean header names', () => {
const wb = buildWorkbook(
['PRIMARY_KEY_FIELD', ' SOME_CHAR', ' SOME_NUM'],
[1, 'a value', 42]
)
const result = searchDataInExcel(
buildSpreadsheetUtil(),
wb,
buildParseParams()
)
expect(result.missing).toBeUndefined()
expect(result.found).toBeDefined()
expect(result.found?.headers).toContain('some_char')
expect(result.found?.headers).toContain('some_num')
})
it('still reports a genuinely different header as missing (no over-broadening)', () => {
const wb = buildWorkbook(
['PRIMARY_KEY_FIELD', ' SOME_CHARX', ' SOME_NUM'],
[1, 'a value', 42]
)
const result = searchDataInExcel(
buildSpreadsheetUtil(),
wb,
buildParseParams()
)
expect(result.found).toBeUndefined()
expect(result.missing).toBeDefined()
expect(result.missing?.[0].missingHeaders).toContain('SOME_CHAR')
})
})
describe('SpreadsheetUtil - updateDateTimeCols time normalization', () => {
const buildSpreadsheetUtil = () =>
new SpreadsheetUtil({
licenceState: new BehaviorSubject<LicenceState>({} as LicenceState)
})
// updateDateTimeCols is private; cast through `any` (same precedent as above).
const updateDateTimeCols = (
spreadsheetUtil: SpreadsheetUtil,
headers: string[],
data: any[],
parseParams: ParseParams
): any[] =>
(spreadsheetUtil as any).updateDateTimeCols(headers, data, parseParams)
const timeParseParams = (): ParseParams =>
({
dateHeaders: [],
dateTimeHeaders: [],
timeHeaders: ['SOME_TIME']
}) as any as ParseParams
it('pads unpadded-hour time strings to canonical HH:mm:ss and leaves canonical values alone', () => {
// The core bug: SAS TIME8. / Excel render a single-digit hour (`0:00:42`),
// arriving as a plain string (not an Excel serial). Already-canonical values
// must pass through untouched.
const data = [[{ v: '0:00:42' }], [{ v: '9:05:03' }], [{ v: '23:00:00' }]]
const result = updateDateTimeCols(
buildSpreadsheetUtil(),
['SOME_TIME'],
data,
timeParseParams()
)
expect(result.map((row) => row[0].v)).toEqual([
'00:00:42',
'09:05:03',
'23:00:00'
])
})
it('reads a 2-part value as HOURS-first and defaults seconds (HHMM/TIME5.): 0:42 -> 00:42:00', () => {
// TIME5.-7. and HHMM. columns render `HH:mm`. Such values are never `mm:ss`
// — DC only classifies TIME/HHMM formats as time columns — so the hour is
// padded and minutes kept, with seconds defaulted to :00.
const data = [[{ v: '0:42' }], [{ v: '12:25' }]]
const result = updateDateTimeCols(
buildSpreadsheetUtil(),
['SOME_TIME'],
data,
timeParseParams()
)
expect(result.map((row) => row[0].v)).toEqual(['00:42:00', '12:25:00'])
})
it('reconstructs an Excel serial time to zero-padded HH:mm:ss', () => {
// 0.0004861111 of a day = 42 seconds past midnight.
const data = [[{ v: 0.0004861111 }]]
const result = updateDateTimeCols(
buildSpreadsheetUtil(),
['SOME_TIME'],
data,
timeParseParams()
)
expect(result[0][0].v).toBe('00:00:42')
})
})
@@ -2,7 +2,8 @@ import { isSpecialMissing } from '@sasjs/utils/input/validators'
import {
dateFormat,
dateToUtcTime,
dateToTime
dateToTime,
padTimeString
} from 'src/app/editor/utils/date.utils'
import {
getMissingHeaders,
@@ -608,7 +609,11 @@ export class SpreadsheetUtil {
// If the cell does not have `v` property we ignore it, those are metadata properties
if (cellValue && typeof cellValue === 'string') {
const potentialHeader = cellValue.toLowerCase()
// .trim(): header cells can pick up incidental leading/trailing
// whitespace from copy-pasting between spreadsheets/systems — treat
// that the same as the existing case-insensitive matching below,
// not as a genuinely different (missing) column.
const potentialHeader = cellValue.trim().toLowerCase()
const headerIndex = csvArrayHeadersLower.indexOf(potentialHeader)
if (headerIndex > -1) {
@@ -852,11 +857,14 @@ export class SpreadsheetUtil {
if (
isStringNumber(obj.v) ||
isStringDecimal(obj.v) ||
obj.v.includes('E-')
(typeof obj.v === 'string' && obj.v.includes('E-'))
) {
const date = excelDateToJSDate(Number(obj.v))
obj.v = dateToUtcTime(date)
} else if (typeof obj.v === 'string') {
// `0:00:42`— pad the hour for `intl-time` cell type
obj.v = padTimeString(obj.v)
}
row[element] = obj
})
@@ -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 { parseEmbedParam } from './parse-embed-param'
describe('parseEmbedParam', () => {
it('returns false when there is no hash query', () => {
expect(parseEmbedParam('')).toBe(false)
})
it('returns false when the embed param is absent', () => {
expect(parseEmbedParam('foo=bar')).toBe(false)
})
it("returns 'va' for embed=va", () => {
expect(parseEmbedParam('embed=va')).toBe('va')
expect(parseEmbedParam('foo=bar&embed=va')).toBe('va')
})
it('returns false for embed=false', () => {
expect(parseEmbedParam('embed=false')).toBe(false)
})
it('returns true for embed=true and other truthy values', () => {
expect(parseEmbedParam('embed=true')).toBe(true)
expect(parseEmbedParam('embed=1')).toBe(true)
expect(parseEmbedParam('embed=')).toBe(true)
})
})
@@ -0,0 +1,22 @@
/**
* Parses the `embed` value out of a hash query string (the part after `?` in
* `window.location.hash`).
*
* Returns:
* - `'va'` when `embed=va` (SAS Visual Analytics data-driven content mode)
* - `false` when `embed=false`, or when the param is absent
* - `true` for any other non-empty value (legacy boolean embed)
*
* `'va'` is intentionally truthy so existing `!embed` chrome checks keep hiding
* the header/back/subnav exactly as they do for `embed=true`.
*/
export function parseEmbedParam(hashQuery: string): boolean | 'va' {
if (!hashQuery) return false
const embedParam = new URLSearchParams(hashQuery).get('embed')
if (embedParam === null) return false
if (embedParam === 'va') return 'va'
return embedParam !== 'false'
}
@@ -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'
}
@@ -0,0 +1,63 @@
import { retryOnce } from './retry-once'
describe('retryOnce', () => {
it("resolves with the first attempt's value and never waits when it succeeds", async () => {
const requestFn = jasmine.createSpy('requestFn').and.resolveTo('ok')
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
const result = await retryOnce(requestFn, { wait })
expect(result).toBe('ok')
expect(requestFn).toHaveBeenCalledTimes(1)
expect(wait).not.toHaveBeenCalled()
})
it('retries exactly once after a rejection and resolves with the retry value', async () => {
const requestFn = jasmine
.createSpy('requestFn')
.and.returnValues(Promise.reject('first-error'), Promise.resolve('ok'))
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
const result = await retryOnce(requestFn, { wait })
expect(result).toBe('ok')
expect(requestFn).toHaveBeenCalledTimes(2)
expect(wait).toHaveBeenCalledTimes(1)
})
it('derives the pre-retry pause from random within [0, maxDelayMs)', async () => {
const requestFn = jasmine
.createSpy('requestFn')
.and.returnValues(Promise.reject('err'), Promise.resolve('ok'))
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
await retryOnce(requestFn, { wait, random: () => 0.5 })
expect(wait).toHaveBeenCalledWith(1500)
wait.calls.reset()
requestFn.and.returnValues(Promise.reject('err'), Promise.resolve('ok'))
await retryOnce(requestFn, { wait, random: () => 0 })
expect(wait).toHaveBeenCalledWith(0)
wait.calls.reset()
requestFn.and.returnValues(Promise.reject('err'), Promise.resolve('ok'))
await retryOnce(requestFn, { wait, random: () => 0.999999 })
expect(wait).toHaveBeenCalledWith(jasmine.any(Number))
expect(wait.calls.mostRecent().args[0]).toBeLessThan(3000)
})
it('rejects with the second error when both attempts fail, never trying a third time', async () => {
const requestFn = jasmine
.createSpy('requestFn')
.and.returnValues(
Promise.reject('first-error'),
Promise.reject('second-error')
)
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
await expectAsync(retryOnce(requestFn, { wait })).toBeRejectedWith(
'second-error'
)
expect(requestFn).toHaveBeenCalledTimes(2)
})
})
+33
View File
@@ -0,0 +1,33 @@
export interface RetryOnceOptions {
/** Upper bound of the random pre-retry delay, in ms. Default 3000. */
maxDelayMs?: number
/** Injectable for tests. Default Math.random. */
random?: () => number
/** Injectable for tests. Default setTimeout-based wait. */
wait?: (ms: number) => Promise<void>
}
const defaultWait = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
/**
* Runs requestFn; if it rejects, waits a random 0..maxDelayMs and tries
* exactly once more. A second rejection propagates as-is. Multiple
* concurrent DC instances (e.g. VA multi-page reports) can cause the
* startup request to time out even though a second attempt succeeds.
*/
export const retryOnce = async <T>(
requestFn: () => Promise<T>,
options?: RetryOnceOptions
): Promise<T> => {
const maxDelayMs = options?.maxDelayMs ?? 3000
const random = options?.random ?? Math.random
const wait = options?.wait ?? defaultWait
try {
return await requestFn()
} catch {
await wait(random() * maxDelayMs)
return requestFn()
}
}
+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
+47
View File
@@ -0,0 +1,47 @@
/*
* SAS Visual Analytics data-driven content: earliest-possible message capture.
*
* Loaded as an external script from index.html (CSP-safe under a strict policy
* — no inline script needed) and executed at HTML parse time, long before the
* Angular app bootstraps. SAS VA posts its DDC data message once on iframe
* load, which happens before Angular is up, so without this the very first
* message (and any pre-set filter it carries) is lost.
*
* The latest captured message is stored on window.__vaLastMessage;
* VaMessagingService reads it when it subscribes (closing the startup race) and
* to recover the latest message across editor reloads.
*
* Self-guards on ?embed=va so it is completely inert in normal (non-VA) use.
*/
;(function () {
'use strict'
try {
var hash = window.location.hash || ''
var query = hash.indexOf('?') >= 0 ? hash.split('?')[1] : ''
if (new URLSearchParams(query).get('embed') !== 'va') return
} catch (e) {
return
}
window.addEventListener(
'message',
function (event) {
var data = event && event.data
if (
data &&
typeof data === 'object' &&
typeof data.resultName === 'string'
) {
// Store ONLY the latest message — readers just need the most recent, so
// a single value keeps this bounded across a long session of filters.
window.__vaLastMessage = {
t: Date.now(),
origin: event.origin,
data: data
}
}
},
false
)
})()
+9
View File
@@ -5,6 +5,15 @@
<title>Data Controller</title>
<!-- <base href="/"> -->
<!--
SAS VA data-driven content: earliest-possible message capture runs before
Angular bootstraps. It is the `va-early` global script bundled via
angular.json (src/assets/va-early.js) and auto-injected here at build time
— external (not inline) so it passes a strict CSP, and it self-guards on
?embed=va so it is inert in normal use. See
VaMessagingService.replayEarlyMessages.
-->
<!-- meta tags -->
<meta name="description" content="Capture, Review, and Approve" />
<meta itemprop="name" content="Data Controller for SAS®" />
+3 -20
View File
@@ -1,12 +1,12 @@
{
"name": "dcfrontend",
"version": "7.7.2",
"version": "7.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dcfrontend",
"version": "7.7.2",
"version": "7.8.2",
"hasInstallScript": true,
"devDependencies": {
"@saithodev/semantic-release-gitea": "^2.1.0",
@@ -15,8 +15,7 @@
"@semantic-release/git": "^10.0.1",
"@semantic-release/npm": "13.1.5",
"@semantic-release/release-notes-generator": "14.1.0",
"commit-and-tag-version": "12.7.1",
"prettier": "^3.7.4"
"commit-and-tag-version": "12.7.1"
}
},
"node_modules/@actions/core": {
@@ -5962,22 +5961,6 @@
"node": ">=0.10.0"
}
},
"node_modules/prettier": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-ms": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
+7 -8
View File
@@ -1,6 +1,6 @@
{
"name": "dcfrontend",
"version": "7.8.2",
"version": "7.11.0",
"description": "Data Controller",
"devDependencies": {
"@saithodev/semantic-release-gitea": "^2.1.0",
@@ -9,8 +9,7 @@
"@semantic-release/git": "^10.0.1",
"@semantic-release/npm": "13.1.5",
"@semantic-release/release-notes-generator": "14.1.0",
"commit-and-tag-version": "12.7.1",
"prettier": "^3.7.4"
"commit-and-tag-version": "12.7.1"
},
"overrides": {
"got": "11.8.6"
@@ -19,11 +18,11 @@
"install": "cd client && npm i && cd ../sas && npm i",
"build-frontend": "cd client && npm run build",
"release": "commit-and-tag-version",
"lint": "npm run lint:fix",
"lint:fix": "npx prettier --write \"client/{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"client/cypress/e2e/*.cy.ts\"",
"lint:fix:silent": "npx prettier --log-level silent --write \"client/{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"client/cypress/e2e/*.cy.ts\"",
"lint:check": "npx prettier --check \"client/{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"client/cypress/e2e/*.cy.ts\"",
"lint:check:silent": "npx prettier --log-level silent --check \"client/{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"client/cypress/e2e/*.cy.ts\"",
"lint": "cd client && npm run lint",
"lint:fix": "cd client && npm run lint:fix",
"lint:fix:silent": "cd client && npm run lint:fix:silent",
"lint:check": "cd client && npm run lint:check",
"lint:check:silent": "cd client && npm run lint:check:silent",
"jo": "echo",
"prepare": "git rev-parse --git-dir && git config core.hooksPath ./.git-hooks && git config core.autocrlf false || true"
},
File diff suppressed because it is too large Load Diff
+88 -54
View File
@@ -73,7 +73,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"PRIMARY_KEY_FIELD\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "SOME_BESTNUM",
@@ -84,29 +85,32 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_BESTNUM\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "SOME_CHAR",
VARNUM: 2,
LABEL: "SOME_CHAR",
LABEL: "Some Character Column",
FMTNAME: "",
DDTYPE: "CHARACTER",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_CHAR\"}"
},
{
NAME: "SOME_DATE",
VARNUM: 5,
LABEL: "SOME_DATE",
LABEL: "Some Date",
FMTNAME: "DATE",
DDTYPE: "DATE",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_DATE\",\"type\":\"date\"}"
},
{
NAME: "SOME_DATETIME",
@@ -117,7 +121,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_DATETIME\",\"type\":\"datetime\"}"
},
{
NAME: "SOME_DROPDOWN",
@@ -128,7 +133,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_DROPDOWN\"}"
},
{
NAME: "SOME_HARDSELECT",
@@ -139,18 +145,20 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_HARDSELECT\"}"
},
{
NAME: "SOME_NUM",
VARNUM: 4,
LABEL: "SOME_NUM",
LABEL: "",
FMTNAME: "",
DDTYPE: "NUMERIC",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_NUM\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "SOME_SHORTNUM",
@@ -161,7 +169,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_SHORTNUM\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "SOME_TIME",
@@ -172,7 +181,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"SOME_TIME\",\"type\":\"time\"}"
},
{
NAME: "READONLY_COL",
@@ -183,7 +193,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "Read-only: default value inserted on add-row, not editable",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"READONLY_COL\"}"
},
{
NAME: "HIDDEN_COL",
@@ -194,7 +205,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "Hidden: invisible in grid but submitted; default on add-row",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"HIDDEN_COL\"}"
},
{
NAME: "ROUND_COL",
@@ -205,7 +217,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "Round: edited values rounded Excel-style to 2 decimals",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"ROUND_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "NUMFMT_COL",
@@ -216,7 +229,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "Number format: displayed as EUR currency (value unchanged)",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"NUMFMT_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
}
],
dqdata: [
@@ -323,7 +337,6 @@ let webouts = {
DTVARS: " SOME_DATE",
DTTMVARS: " SOME_DATETIME",
TMVARS: " SOME_TIME",
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"PRIMARY_KEY_FIELD\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_CHAR\"},{\"data\":\"SOME_DROPDOWN\"},{\"data\":\"SOME_HARDSELECT\"},{\"data\":\"SOME_NUM\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_DATE\",\"type\":\"date\"},{\"data\":\"SOME_DATETIME\",\"type\":\"datetime\"},{\"data\":\"SOME_TIME\",\"type\":\"time\"},{\"data\":\"SOME_SHORTNUM\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"SOME_BESTNUM\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"READONLY_COL\"},{\"data\":\"HIDDEN_COL\"},{\"data\":\"ROUND_COL\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"NUMFMT_COL\",\"type\":\"numeric\",\"format\":\"0\"}",
LOADTYPE: "UPDATE",
RK_FLAG: 0,
CLS_FLAG: 0
@@ -366,7 +379,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"DD_LONGDESC\"}"
},
{
NAME: "DD_OWNER",
@@ -377,7 +391,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"DD_OWNER\"}"
},
{
NAME: "DD_RESPONSIBLE",
@@ -388,7 +403,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"DD_RESPONSIBLE\"}"
},
{
NAME: "DD_SENSITIVITY",
@@ -399,7 +415,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"DD_SENSITIVITY\"}"
},
{
NAME: "DD_SHORTDESC",
@@ -410,7 +427,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"DD_SHORTDESC\"}"
},
{
NAME: "DD_SOURCE",
@@ -421,7 +439,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"DD_SOURCE\"}"
},
{
NAME: "DD_TYPE",
@@ -432,7 +451,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"DD_TYPE\"}"
},
{
NAME: "TX_FROM",
@@ -593,7 +613,6 @@ let webouts = {
DTVARS: "",
DTTMVARS: "",
TMVARS: "",
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"DD_TYPE\"},{\"data\":\"DD_SOURCE\"},{\"data\":\"DD_SHORTDESC\"},{\"data\":\"DD_LONGDESC\"},{\"data\":\"DD_OWNER\"},{\"data\":\"DD_RESPONSIBLE\"},{\"data\":\"DD_SENSITIVITY\"}",
LOADTYPE: "TXTEMPORAL",
RK_FLAG: 0,
CLS_FLAG: 0
@@ -660,7 +679,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"USER_ID\"}"
}
],
dqdata: [],
@@ -722,7 +742,6 @@ let webouts = {
DTVARS: "",
DTTMVARS: "",
TMVARS: "",
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"USER_ID\"}",
LOADTYPE: "UPDATE",
RK_FLAG: 0,
CLS_FLAG: 0
@@ -765,7 +784,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"AUDIT_LIBDS\"}"
},
{
NAME: "BUSKEY",
@@ -776,7 +796,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"BUSKEY\"}"
},
{
NAME: "CLOSE_VARS",
@@ -787,7 +808,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"CLOSE_VARS\"}"
},
{
NAME: "DSN",
@@ -798,7 +820,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"DSN\"}"
},
{
NAME: "LIBREF",
@@ -809,7 +832,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"LIBREF\"}"
},
{
NAME: "LOADTYPE",
@@ -820,7 +844,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"LOADTYPE\"}"
},
{
NAME: "NOTES",
@@ -831,7 +856,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"NOTES\"}"
},
{
NAME: "NUM_OF_APPROVALS_REQUIRED",
@@ -842,7 +868,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"NUM_OF_APPROVALS_REQUIRED\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "POST_APPROVE_HOOK",
@@ -853,7 +880,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"POST_APPROVE_HOOK\"}"
},
{
NAME: "POST_EDIT_HOOK",
@@ -864,7 +892,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"POST_EDIT_HOOK\"}"
},
{
NAME: "PRE_APPROVE_HOOK",
@@ -875,7 +904,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"PRE_APPROVE_HOOK\"}"
},
{
NAME: "PRE_EDIT_HOOK",
@@ -886,7 +916,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"PRE_EDIT_HOOK\"}"
},
{
NAME: "RK_UNDERLYING",
@@ -897,7 +928,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"RK_UNDERLYING\"}"
},
{
NAME: "SIGNOFF_COLS",
@@ -908,7 +940,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"SIGNOFF_COLS\"}"
},
{
NAME: "SIGNOFF_HOOK",
@@ -919,7 +952,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"SIGNOFF_HOOK\"}"
},
{
NAME: "TX_FROM",
@@ -952,7 +986,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"VAR_BUSFROM\"}"
},
{
NAME: "VAR_BUSTO",
@@ -963,7 +998,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"VAR_BUSTO\"}"
},
{
NAME: "VAR_PROCESSED",
@@ -974,7 +1010,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"VAR_PROCESSED\"}"
},
{
NAME: "VAR_TXFROM",
@@ -985,7 +1022,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"VAR_TXFROM\"}"
},
{
NAME: "VAR_TXTO",
@@ -996,7 +1034,8 @@ let webouts = {
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: ""
LONGDESC: "",
COLTYPE: "{\"data\":\"VAR_TXTO\"}"
}
],
dqdata: [
@@ -1445,7 +1484,6 @@ let webouts = {
DTVARS: "",
DTTMVARS: "",
TMVARS: "",
COLTYPE: "{\"data\":\"_____DELETE__THIS__RECORD_____\",\"type\":\"dropdown\",\"source\":[\"No\",\"Yes\"]},{\"data\":\"LIBREF\"},{\"data\":\"DSN\"},{\"data\":\"NUM_OF_APPROVALS_REQUIRED\",\"type\":\"numeric\",\"format\":\"0\"},{\"data\":\"LOADTYPE\"},{\"data\":\"BUSKEY\"},{\"data\":\"VAR_TXFROM\"},{\"data\":\"VAR_TXTO\"},{\"data\":\"VAR_BUSFROM\"},{\"data\":\"VAR_BUSTO\"},{\"data\":\"VAR_PROCESSED\"},{\"data\":\"CLOSE_VARS\"},{\"data\":\"PRE_EDIT_HOOK\"},{\"data\":\"POST_EDIT_HOOK\"},{\"data\":\"PRE_APPROVE_HOOK\"},{\"data\":\"POST_APPROVE_HOOK\"},{\"data\":\"SIGNOFF_COLS\"},{\"data\":\"SIGNOFF_HOOK\"},{\"data\":\"NOTES\"},{\"data\":\"RK_UNDERLYING\"},{\"data\":\"AUDIT_LIBDS\"}",
LOADTYPE: "TXTEMPORAL",
RK_FLAG: 0,
CLS_FLAG: 0
@@ -1499,11 +1537,7 @@ function stripRuleCols(t) {
})
t.sasparams = t.sasparams.map((p) => {
const headers = p.COLHEADERS.split(',').filter((h) => !uc.has(h))
const coltype = JSON.parse('[' + p.COLTYPE + ']')
.filter((o) => !uc.has(o.data))
.map((o) => JSON.stringify(o))
.join(',')
return { ...p, COLHEADERS: headers.join(','), COLTYPE: coltype }
return { ...p, COLHEADERS: headers.join(',') }
})
return t
}
File diff suppressed because it is too large Load Diff
+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",
+28 -38
View File
@@ -6,8 +6,8 @@
"": {
"name": "dc-sas",
"dependencies": {
"@sasjs/cli": "4.17.4",
"@sasjs/core": "4.67.1"
"@sasjs/cli": "4.18.0",
"@sasjs/core": "4.68.1"
}
},
"node_modules/@asamuzakjp/css-color": {
@@ -200,15 +200,15 @@
}
},
"node_modules/@sasjs/adapter": {
"version": "4.16.7",
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.16.7.tgz",
"integrity": "sha512-pOKAhOPijr663PFWGx+JEcWaMOGwICMA0w8LeXHPHQwvbHVGPCJKV5QroGqrjTO62HlhsHzow4Vb+DjIui1jgQ==",
"version": "4.17.1",
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.17.1.tgz",
"integrity": "sha512-MxTvAoVnr+sBmKtB+CHxDiwvsqfzBqQ3BT/Q2Of6g7WWzJRBvpJcpf6C9R80Uq/cxvEie9JAM3EfExtFEYlcLg==",
"license": "ISC",
"dependencies": {
"@sasjs/utils": "^3.5.6",
"axios": "1.16.0",
"axios-cookiejar-support": "5.0.5",
"form-data": "4.0.4",
"form-data": "4.0.6",
"https": "1.0.0",
"tough-cookie": "4.1.3"
}
@@ -249,13 +249,13 @@
}
},
"node_modules/@sasjs/cli": {
"version": "4.17.4",
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.17.4.tgz",
"integrity": "sha512-lNkIy6sojgT5mRQ/tkh8lyrIxNhrREw+CTkfEmy+mDiOh9COfGWMoSpBjRn2iqHX+dwOKaUlq6UwU2zHDLOmqQ==",
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.18.0.tgz",
"integrity": "sha512-QFsLS9XXF64x9w51kYkmx3ZUWCA9ycVDeLIaNJpHcaTu6jgPRAVHQ61ShXBKlYp/Vrze9pNapW43iaXt7o6VEA==",
"license": "ISC",
"dependencies": {
"@sasjs/adapter": "4.16.7",
"@sasjs/core": "4.67.1",
"@sasjs/adapter": "4.17.1",
"@sasjs/core": "4.67.3",
"@sasjs/lint": "2.4.3",
"@sasjs/utils": "3.5.8",
"adm-zip": "0.5.10",
@@ -279,6 +279,12 @@
"sasjs": "build/index.js"
}
},
"node_modules/@sasjs/cli/node_modules/@sasjs/core": {
"version": "4.67.3",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.67.3.tgz",
"integrity": "sha512-7s4UeVN4slF1URo1C5/UnNVv+TonUg+vBktFRiFhcbFYr3RnfXyClBPED6nQOF8AFd2xdXEss58hsCeIebr4lg==",
"license": "MIT"
},
"node_modules/@sasjs/cli/node_modules/@sasjs/utils": {
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-3.5.8.tgz",
@@ -315,9 +321,9 @@
}
},
"node_modules/@sasjs/core": {
"version": "4.67.1",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.67.1.tgz",
"integrity": "sha512-yb4xW8JxsWWY3ZTN4yj/mP8ZasPIHSQVjHESLui3xS0HNatBw96H9ZYGJO3LcrkDx+X5xJz45f6sdHAJYxVeSA==",
"version": "4.68.1",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.68.1.tgz",
"integrity": "sha512-BjoOzV7h7guCShW4JsFHvB1NNLCKHhPI5708OtEk29itQbgVCMRlEXKFRbnMiuNklktDQDi8dR4XZZ0aK7/4Wg==",
"license": "MIT"
},
"node_modules/@sasjs/lint": {
@@ -489,22 +495,6 @@
"tough-cookie": ">=4.0.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -1031,16 +1021,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -1194,9 +1184,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
+2 -2
View File
@@ -28,7 +28,7 @@
},
"private": true,
"dependencies": {
"@sasjs/cli": "4.17.4",
"@sasjs/core": "4.67.1"
"@sasjs/cli": "4.18.0",
"@sasjs/core": "4.68.1"
}
}
@@ -0,0 +1,54 @@
/**
@file
@brief migration script to move to v7.9 of Data Controller
OPTIONAL CHANGE - upload additional validation rules in dropdown (data only)
**/
%let dclib=YOURDCLIB;
libname &dclib "/YOUR/DATACONTROLLER/LIBRARY/PATH";
/* add new validation rules */
proc sql noprint;
select max(selectbox_rk) into: maxrk
from &dclib..mpe_selectbox;
insert into &dclib..mpe_selectbox set
selectbox_rk=&maxrk+1
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value='NUMBER_FORMAT'
,selectbox_order=9
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &dclib..mpe_selectbox set
selectbox_rk=&maxrk+2
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value='READONLY'
,selectbox_order=10
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &dclib..mpe_selectbox set
selectbox_rk=&maxrk+3
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value='HIDDEN'
,selectbox_order=11
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &dclib..mpe_selectbox set
selectbox_rk=&maxrk+4
,ver_from_dttm=0
,select_lib="&dclib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value='ROUND'
,selectbox_order=12
,ver_to_dttm='31DEC5999:23:59:59'dt;
@@ -0,0 +1,21 @@
/**
@file
@brief migration script
**/
%let dclib=YOURDCLIB;
libname &dclib "/YOUR/DATACONTROLLER/LIBRARY/PATH";
/* add new validation rules */
proc sql noprint;
insert into &dclib..mpe_config set
tx_from=%sysfunc(datetime())
,tx_to='31DEC9999:23:59:59'dt
,var_scope="DC"
,var_name="DC_MAXOBS_WEBVIEW"
,var_value='500'
,var_active=1
,var_desc='Default rows displayed for tables in VIEW mode.';
@@ -35,6 +35,7 @@
@li mf_getuniquename.sas
@li mf_getuser.sas
@li mf_getvartype.sas
@li mf_mkdir.sas
@li mp_lockanytable.sas
@li dc_assignlib.sas
@@ -136,6 +137,7 @@ run;
libname &tmplib (work);
%end;
%else %do;
%mf_mkdir(&AUDITFOLDER)
filename tmp "&AUDITFOLDER/deleterecords.sas" lrecl=10000;
libname &tmplib "&AUDITFOLDER";
%end;
+46 -24
View File
@@ -111,6 +111,7 @@ Areas for optimisation
@li mp_lockfilecheck.sas
@li mp_retainedkey.sas
@li mp_storediffs.sas
@li mp_rowhash.sas
@version 9.3
@author 4GL Apps Ltd.
@@ -193,7 +194,7 @@ Areas for optimisation
/* when changing this macro, update the version num here */
%local ver;
%let ver=32;
%let ver=33;
%put &sysmacroname entry vars:;
%put _local_;
@@ -246,7 +247,7 @@ run;
)
%local cols idx_pk md5_col ;
%local cols idx_pk md5_col hash_char_vars hash_num_vars ;
%let md5_col=___TMP___md5;
%let check_uniqueness=%upcase(&check_uniqueness);
%let RK_UPDATE_MAXKEYTABLE=%upcase(&RK_UPDATE_MAXKEYTABLE);
@@ -321,14 +322,20 @@ select name into: cols separated by ','
(%upcase("&bus_from","&bus_to"
,"&tech_from","&tech_to"
,"&processed","&delete_col")) ;
select case when type in (2,6) then cats('put(md5(trim(',name,')),$hex32.)')
/* multiply by 1 to strip precision errors (eg 0 != 0) */
/* but ONLY if not missing, else will lose any special missing values */
else cats('put(md5(trim(put(ifn(missing('
,name,'),',name,',',name,'*1),binary64.))),$hex32.)') end
into: stripcols separated by '||'
/* Character variables are hashed separately from numerics so that the
iterative hash can be built with arrays rather than by concatenating
hundreds of 32-byte hex strings. */
select name into: hash_char_vars separated by ' '
from work.bitemp_cols
where upcase(name) not in
where type in (2,6)
and upcase(name) not in
(%upcase("&bus_from","&bus_to"
,"&tech_from","&tech_to"
,"&processed","&delete_col")) ;
select name into: hash_num_vars separated by ' '
from work.bitemp_cols
where type not in (2,6)
and upcase(name) not in
(%upcase("&bus_from","&bus_to"
,"&tech_from","&tech_to"
,"&processed","&delete_col")) ;
@@ -342,7 +349,8 @@ select case when type in (2,6) then cats('put(md5(trim(',name,')),$hex32.)')
%let tech_to_fmt = format=datetime19.;
%put &=stripcols;
%put &=hash_char_vars;
%put &=hash_num_vars;
%put &=pk;
data _null_;
@@ -368,8 +376,8 @@ data _null_;
call symputx('processed_fmt',fmt,'L');
run;
%if %index(%quote(&cols),___TMP___) %then %do;
%let msg=%str(Table contains a variable name containing "___TMP___".%trim(
%if %index(%quote(&cols),___TMP___) or %index(%quote(&cols),_____) %then %do;
%let msg=%str(Table contains a variable name containing "___TMP___" or "_____".%trim(
) This may conflict with temp variable generation!!);
%mp_abort(msg=&msg,mac=bitemporal_dataloader);
%let syscc=5;
@@ -485,7 +493,10 @@ data work.bitemp0_append &keepvars &outds_del(drop=&md5_col )
&bus_to= %unquote(&bus_to_override) ;
%end;
length &md5_col $32;
&md5_col=put(md5(&stripcols),hex32.);
%mp_rowhash(md5_col=&md5_col
,cvars=&hash_char_vars
,nvars=&hash_num_vars
)
%if %length(&processed)>0 %then %do;
format &processed &processed_fmt;
&processed=&now;
@@ -772,13 +783,17 @@ or &engine_type=SNOW or &engine_type=SASIOSNF
,msg=%str(syscc=&syscc sqlrc=&sqlrc)
)
%put hashcols2=&stripcols;
proc sql;
create table work.bitemp1_current(drop=___TMP___NEW_FLG) as
select *
, put(md5(&stripcols),$hex32.) as &md5_col
from work.bitemp0_base (drop=&md5_col)
where ___TMP___NEW_FLG=0;
%put hash_char_vars=&hash_char_vars;
%put hash_num_vars=&hash_num_vars;
data work.bitemp1_current(drop=___TMP___NEW_FLG);
set work.bitemp0_base(drop=&md5_col);
if ___TMP___NEW_FLG=0;
length &md5_col $32;
%mp_rowhash(md5_col=&md5_col
,cvars=&hash_char_vars
,nvars=&hash_num_vars
)
run;
/**
* NEW records were identified in ___TMP___NEW_FLG in bitemp0_base
@@ -1061,7 +1076,9 @@ run;
*/
%if &loadtype=BITEMPORAL %then %do;
%let cat_string=catx('|' ,&bus_from,&bus_to);
/* For bitemporal we also include the business dates in the comparison hash.
The business dates are passed as prefix numerics so that both lookup and
update tables use exactly the same hashing order. */
data work.bitemp5a_lkp (keep=&md5_col)
%if "%substr(&sysver,1,1)" ne "4" & "%substr(&sysver,1,1)" ne "5" %then %do;
@@ -1069,8 +1086,10 @@ run;
%end;
;
set work.bitemp0_base;
/* for BITEMPORAL we need to compare business dates also */
&md5_col=put(md5(&cat_string!!'|'!!&stripcols),$hex32.);
%mp_rowhash(md5_col=&md5_col
,cvars=&hash_char_vars
,nvars=&bus_from &bus_to &hash_num_vars
)
run;
data bitemp5b_updates;
@@ -1081,7 +1100,10 @@ run;
md5_lkp.definedone();
end;
/* drop old md5 col as will rebuild with new business dates */
&md5_col=put(md5(&cat_string!!'|'!!&stripcols),$hex32.) ;
%mp_rowhash(md5_col=&md5_col
,cvars=&hash_char_vars
,nvars=&bus_from &bus_to &hash_num_vars
)
if md5_lkp.check()=0 then delete;
run;
@@ -1035,7 +1035,7 @@ run;
set appendtable&testnum;
/* delete even numbered observations */
if mod(_n_,2)=0 and _n_ ne 2 then _____DELETE__THIS__RECORD_____='YES';
else _____DELETE__THIS__RECORD_____=0;
else _____DELETE__THIS__RECORD_____='NO';
run;
%bitemporal_dataloader(dclib=work
+1 -1
View File
@@ -54,7 +54,7 @@
*/
%local audtab;
proc sql noprint;
select coalescec(audit_libds,"&dc_libref..MPE_AUDIT") into: audtab
select coalescec(audit_libds,"&dc_libref..MPE_AUDIT") into: audtab trimmed
from &dc_libref..MPE_TABLES
where &dc_dttmtfmt. lt tx_to
and libref="%scan(&libds,1,.)" and dsn="%scan(&libds,2,.)";
+49 -13
View File
@@ -1,7 +1,29 @@
/**
@file mpe_getlabels.sas
@brief Gets the table and column labels for a particular table
@details Takes labels first from mpe_datadictionary then from table metadata.
@details Builds a labels dataset by combining three inputs:
@li NAME - taken from `source`. This is typically a WORK subset of
the underlying table (e.g. after column-level security has been
applied) and dictates which columns appear in the output.
@li MEMLABEL / LABEL - taken from `tgt_ds` (the two-part
libref.dataset). This ensures the "true" table and column
labels are used rather than any that may have been stripped
during WORK subsetting.
@li DD_SHORTDESC / DD_LONGDESC - taken from `mpe_datadictionary`
where DD_SOURCE contains the `LIB.DSN` reference and DD_TYPE is
`COLUMN`.
If `tgt_ds` is not supplied it defaults to `source`.
@param [in] type The type of labels to fetch (only COLUMNS is
currently supported).
@param [in] source The dataset from which column NAMES are read.
@param [in] tgt_ds= (&source) The two-part libref.dataset from which
the table/column LABELS are read, and used when joining to
`mpe_datadictionary` for short/long descriptions.
@param [out] outds= (mpe_getlabels) The output dataset with columns
`name`, `memlabel`, `desc`, `longdesc`.
<h4> SAS Macros </h4>
@li mf_getuniquename.sas
@@ -13,26 +35,40 @@
4GL Apps Ltd.
**/
%macro mpe_getlabels(type,source,outds=mpe_getlabels);
%local tmpds;
%macro mpe_getlabels(type,source,tgt_ds=,outds=mpe_getlabels);
%local src_cols tgt_cols;
%if %superq(tgt_ds)= %then %let tgt_ds=&source;
%if &type=COLUMNS %then %do;
%let tmpds=%mf_getuniquename();
%let src_cols=%mf_getuniquename();
%let tgt_cols=%mf_getuniquename();
/* NAMES from the (potentially filtered) source dataset */
proc contents noprint data=&source
out=&tmpds(keep=name memlabel label);
out=&src_cols(keep=name);
run;
proc sql ;
/* LABELS from the underlying target dataset */
proc contents noprint data=&tgt_ds
out=&tgt_cols(keep=name memlabel label);
run;
proc sql;
create table &outds as
select upcase(a.name) as name
,a.memlabel
,coalesce(b.dd_shortdesc,a.label) as desc
,b.dd_longdesc as longdesc
from &tmpds a
,b.memlabel
,coalesce(c.dd_shortdesc,b.label) as desc
,c.dd_longdesc as longdesc
from &src_cols a
inner join &tgt_cols b
on upcase(a.name)=upcase(b.name)
left join &mpelib..mpe_datadictionary
(where=(&dc_dttmtfmt. < tx_to
and dd_source ? %upcase("&source")
and dd_type='COLUMN')) b
on scan(b.dd_source,-1,'.')=upcase(a.name);
and dd_source ? %upcase("&tgt_ds")
and dd_type='COLUMN')) c
on scan(c.dd_source,-1,'.')=upcase(a.name);
quit;
%end;
%mend mpe_getlabels;
+204
View File
@@ -0,0 +1,204 @@
/**
@file
@brief Testing mpe_getlabels macro
@details Verifies that the output dataset combines:
@li NAME from `source`
@li LABEL / MEMLABEL from `tgt_ds`
@li DD_SHORTDESC / DD_LONGDESC from MPE_DATADICTIONARY where DD_SOURCE
contains the `LIB.DSN` reference and DD_TYPE='COLUMN'.
Scenarios covered:
1. `source` is a two-part `LIB.DSN` - labels and DD entries are
joined directly.
2. `source` is a WORK subset without `tgt_ds` - DD entries do not
match.
3. `source` is a WORK subset with `tgt_ds` set to the underlying
`LIB.DSN` - labels come from the target and DD entries match.
4. `source` is a WORK subset that contains only a *subset* of the
target columns - the output contains only those columns while
labels still come from the target.
<h4> SAS Macros </h4>
@li mf_getuniquename.sas
@li mf_nobs.sas
@li mp_assert.sas
@li mp_assertdsobs.sas
@li mp_assertscope.sas
@li mpe_getlabels.sas
@author 4GL Apps Ltd
@copyright 4GL Apps Ltd. This code may only be used within Data Controller
and may not be re-distributed or re-sold without the express permission of
4GL Apps Ltd.
**/
/* teardown - remove any seeded rows */
proc sql;
delete from &mpelib..mpe_datadictionary
where dd_source in (
"&mpelib..MPE_X_TEST.PRIMARY_KEY_FIELD",
"&mpelib..MPE_X_TEST.SOME_CHAR"
);
quit;
/* seed MPE_DATADICTIONARY with column-level entries keyed on
LIB.DSNAME.COLUMN. &mpelib..MPE_X_TEST is created by testsetup.sas. */
proc sql;
insert into &mpelib..mpe_datadictionary set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,dd_type='COLUMN'
,dd_source="&mpelib..MPE_X_TEST.PRIMARY_KEY_FIELD"
,dd_shortdesc='SHORT PK DESCRIPTION'
,dd_longdesc='LONG PK DESCRIPTION'
,dd_owner=''
,dd_responsible=''
,dd_sensitivity='';
insert into &mpelib..mpe_datadictionary set
tx_from=0
,tx_to='31DEC5999:23:59:59'dt
,dd_type='COLUMN'
,dd_source="&mpelib..MPE_X_TEST.SOME_CHAR"
,dd_shortdesc='SHORT CHAR DESCRIPTION'
,dd_longdesc='LONG CHAR DESCRIPTION'
,dd_owner=''
,dd_responsible=''
,dd_sensitivity='';
quit;
/* Test 1 - two-part libref.dataset as source */
%mp_assertscope(SNAPSHOT)
%mpe_getlabels(COLUMNS,&mpelib..MPE_X_TEST,outds=work.test1)
%mp_assertscope(COMPARE,
desc=Checking macro variables against previous snapshot
)
data _null_;
set work.test1;
putlog (_all_)(=);
run;
%mp_assertdsobs(work.test1,
desc=Test 1 - output dataset contains rows (one per column),
test=ATLEAST 1,
outds=work.test_results
)
%let shortdesc_hit=0;
%let longdesc_hit=0;
data _null_;
set work.test1;
where upcase(name)='PRIMARY_KEY_FIELD';
if desc='SHORT PK DESCRIPTION' then call symputx('shortdesc_hit',1);
if longdesc='LONG PK DESCRIPTION' then call symputx('longdesc_hit',1);
run;
%mp_assert(
iftrue=(&shortdesc_hit=1),
desc=Test 1 - DD_SHORTDESC returned as desc,
outds=work.test_results
)
%mp_assert(
iftrue=(&longdesc_hit=1),
desc=Test 1 - DD_LONGDESC returned as longdesc,
outds=work.test_results
)
/* Test 2 - WORK subset as source, no tgt_ds. DD_SOURCE values do not
contain `WORK.SASDATA1`, so DD entries are not applied and desc
falls back to whatever LABEL exists on the WORK dataset. */
data work.sasdata1;
set &mpelib..mpe_x_test;
run;
%mpe_getlabels(COLUMNS,work.sasdata1,outds=work.test2)
data _null_;
set work.test2;
putlog (_all_)(=);
run;
%let shortdesc_hit2=0;
data _null_;
set work.test2;
where upcase(name)='PRIMARY_KEY_FIELD';
if desc='SHORT PK DESCRIPTION' then call symputx('shortdesc_hit2',1);
run;
%mp_assert(
iftrue=(&shortdesc_hit2=0),
desc=Test 2 - Without tgt_ds a WORK source does not pick up DD_SHORTDESC,
outds=work.test_results
)
/* Test 3 - WORK subset as source with tgt_ds pointing at the
underlying LIB.DSN. Column names are taken from the WORK subset,
labels come from the target, DD entries are applied. */
%mpe_getlabels(COLUMNS,work.sasdata1,tgt_ds=&mpelib..MPE_X_TEST,outds=work.test3)
data _null_;
set work.test3;
putlog (_all_)(=);
run;
%mp_assertdsobs(work.test3,
desc=Test 3 - output dataset contains rows,
test=ATLEAST 1,
outds=work.test_results
)
%let shortdesc_hit3=0;
%let longdesc_hit3=0;
data _null_;
set work.test3;
where upcase(name)='PRIMARY_KEY_FIELD';
if desc='SHORT PK DESCRIPTION' then call symputx('shortdesc_hit3',1);
if longdesc='LONG PK DESCRIPTION' then call symputx('longdesc_hit3',1);
run;
%mp_assert(
iftrue=(&shortdesc_hit3=1),
desc=Test 3 - DD_SHORTDESC returned as desc when tgt_ds is supplied,
outds=work.test_results
)
%mp_assert(
iftrue=(&longdesc_hit3=1),
desc=Test 3 - DD_LONGDESC returned as longdesc when tgt_ds is supplied,
outds=work.test_results
)
/* Test 4 - WORK source with a strict subset of the target columns.
The output should contain only the columns present in `source`,
but labels still come from `tgt_ds`. */
data work.sasdata2;
set &mpelib..mpe_x_test (keep=primary_key_field);
run;
%mpe_getlabels(COLUMNS,work.sasdata2,tgt_ds=&mpelib..MPE_X_TEST,outds=work.test4)
data _null_;
set work.test4;
putlog (_all_)(=);
run;
%mp_assertdsobs(work.test4,
desc=Test 4 - output contains exactly one row (matching the source subset),
test=EQUALS 1,
outds=work.test_results
)
%let shortdesc_hit4=0;
data _null_;
set work.test4;
where upcase(name)='PRIMARY_KEY_FIELD';
if desc='SHORT PK DESCRIPTION' then call symputx('shortdesc_hit4',1);
run;
%mp_assert(
iftrue=(&shortdesc_hit4=1),
desc=Test 4 - DD_SHORTDESC returned for the retained column,
outds=work.test_results
)
-1
View File
@@ -16,7 +16,6 @@
@li dc_assignlib.sas
@li mf_getattrn.sas
@li mf_getuser.sas
@li mf_mkdir.sas
@li mf_verifymacvars.sas
@li mp_abort.sas
@li mp_cntlout.sas
+44 -8
View File
@@ -823,7 +823,7 @@ insert into &lib..mpe_selectbox set
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="MINVAL"
,selectbox_value="HARDSELECT"
,selectbox_order=2
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
@@ -832,7 +832,7 @@ insert into &lib..mpe_selectbox set
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="MAXVAL"
,selectbox_value="HARDSELECT_HOOK"
,selectbox_order=3
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
@@ -841,7 +841,7 @@ insert into &lib..mpe_selectbox set
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="NOTNULL"
,selectbox_value="HIDDEN"
,selectbox_order=4
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
@@ -850,7 +850,7 @@ insert into &lib..mpe_selectbox set
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="HARDSELECT"
,selectbox_value="MAXVAL"
,selectbox_order=5
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
@@ -859,16 +859,16 @@ insert into &lib..mpe_selectbox set
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="HARDSELECT_HOOK"
,selectbox_value="MINVAL"
,selectbox_order=6
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="SOFTSELECT"
,selectbox_value="NOTNULL"
,selectbox_order=7
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
@@ -877,9 +877,45 @@ insert into &lib..mpe_selectbox set
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="SOFTSELECT_HOOK"
,selectbox_value="NUMBER_FORMAT"
,selectbox_order=8
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="READONLY"
,selectbox_order=9
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="ROUND"
,selectbox_order=10
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="SOFTSELECT"
,selectbox_order=11
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
,select_lib="&lib"
,select_ds="MPE_VALIDATIONS"
,base_column="RULE_TYPE"
,selectbox_value="SOFTSELECT_HOOK"
,selectbox_order=12
,ver_to_dttm='31DEC5999:23:59:59'dt;
insert into &lib..mpe_selectbox set
selectbox_rk=%mf_increment(rk)
,ver_from_dttm=0
+2 -2
View File
@@ -37,12 +37,12 @@ create table work.libinfo as
from &mpelib..mpe_datacatalog_libs(where=(&dc_dttmtfmt. lt tx_to)) a
left join &mpelib..mpe_datastatus_libs(where=(&dc_dttmtfmt. lt tx_to)) b
on a.libref=b.libref
where a.libref="&libref";
where a.libref="FMTONLY";
%let test1=0;
data _null_;
set work.libinfo;
call symputx('test1',table_cnt);
call symputx('test1',catalog_cnt);
run;
%mp_assert(
iftrue=(&test1>0),
+1 -1
View File
@@ -280,7 +280,7 @@
},
{
"name": "server-mihajlo",
"serverUrl": "https://sas9.4gl.io",
"serverUrl": "https://sas.4gl.io",
"serverType": "SASJS",
"httpsAgentOptions": {
"rejectUnauthorized": false,
+3 -4
View File
@@ -270,15 +270,14 @@ data _null_;
file vldtr ;
put 'proc sql;';
put 'create table work.vals as';
put ' select distinct ORIGIN as display_value,';
put ' ORIGIN as raw_value';
put ' select distinct ORIGIN as raw_value';
put " from &demolib..COUNTRIES";
put ' order by 1;';
put 'data work.DYNAMIC_VALUES; set work.vals;display_index=_n_;run;';
put ' ';
put 'proc sql;';
put 'create table work.dev as ';
put ' select a.display_index,b.country as display_value';
put ' select a.display_index, a.raw_value';
put ' from work.DYNAMIC_VALUES as a';
put " left join &demolib..countries as b";
put " on a.raw_value=b.origin";
@@ -286,7 +285,7 @@ data _null_;
put 'data work.DYNAMIC_EXTENDED_VALUES; set work.dev;by display_index;';
put ' EXTRA_COL_NAME="COUNTRY";';
put ' DISPLAY_TYPE="C";';
put ' RAW_VALUE_CHAR=DISPLAY_VALUE;';
put ' RAW_VALUE_CHAR=raw_value;';
put ' RAW_VALUE_NUM=.;';
put ' if first.display_index then forced_value=1;';
put 'run;';
+14 -15
View File
@@ -35,6 +35,7 @@
@li memlabel
@li desc- augmented with MPE_DATADICTIONARY if exists, else label
@li longdesc - from MPE_DATADICTIONARY
@li coltype - front-end column formatting spec
<h5> maxvarlengths </h5>
@@ -333,12 +334,11 @@ select upcase(loadtype)
)
%global jsdttmvars jsdtvars jstmvars;
data _null_;
set vars3 end=last;
if _n_>1 then comma=',';
data vars4;
set vars3;
length coltype $500.;
format=upcase(format);
coltype=cats(comma,'{"data":"',name,'"');
coltype=cats('{"data":"',name,'"');
if ctrlOptions ne '' then
colType=cats(coltype,',"type":"dropdown","source":',ctrlOptions,"}");
else if type='num' then do;
@@ -372,10 +372,7 @@ select upcase(loadtype)
end;
end;
else colType=cats(coltype,'}');
length concatcoltype $32767;
retain concatcoltype;
concatcoltype=cats(concatcoltype,coltype);
if last then call symputx('colType',strip(concatcoltype),'g');
output;
putlog (_all_)(=);
run;
@@ -480,8 +477,9 @@ select upcase(loadtype)
,outmeta=work.cls_rules
)
/* get labels */
%mpe_getlabels(COLUMNS,sasdata1,outds=spec)
/* get labels - pull column names from the CLS-filtered WORK dataset but
join to mpe_datadictionary using the underlying LIB.DSN reference */
%mpe_getlabels(COLUMNS,sasdata1,tgt_ds=&libds,outds=spec)
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program
,msg=%str(syscc=&syscc extracting spec info)
@@ -495,7 +493,7 @@ proc sql;
create table work.cols as
select a.NAME
,a.VARNUM
,a.LABEL
,c.desc as LABEL
,a.FMTNAME
,a.DDTYPE
,case b.cls_hide
@@ -503,13 +501,15 @@ create table work.cols as
when 0 then 'EDIT'
else 'READ' end as CLS_RULE
,c.memlabel
,c.desc
,c.longdesc
,d.colType
from work.cols1 a
left join work.cls_rules b
on a.NAME=b.CLS_VARIABLE_NM
left join work.spec c
on a.NAME=c.NAME;
on a.NAME=c.NAME
left join work.vars4 d
on a.NAME=d.NAME;
proc sql;
create table approvers as select distinct membername as personname
@@ -550,8 +550,6 @@ data sasparams;
dtvars=compbl("&jsdtvars");
dttmvars=compbl("&jsdttmvars");
tmvars=compbl("&jstmvars");
length coltype $32000;
coltype=symget('coltype');
loadtype=symget('loadtype');
if trim(symget('rk_underlying')) ne '' then rk_flag=1;
else rk_flag=0;
@@ -628,6 +626,7 @@ create table dqdata as
%put &=source;
%put &=lib;
%dc_assignlib(READ,&lib)
%dc_casload(&lib..&ds)
proc sql;
create table dqdata&x as
select distinct "&&base_col&x" as base_col length=32
@@ -22,8 +22,7 @@
<h5>DYNAMIC_VALUES</h5>
The RAW_VALUE column may be charactor or numeric. If DISPLAY_INDEX is not
provided, it is added automatically. A DISPLAY_VALUE column may be supplied
by the hook but is ignored - only DISPLAY_INDEX and RAW_VALUE are returned.
provided, it is added automatically.
|DISPLAY_INDEX:best.|RAW_VALUE|
|---|---|
@@ -38,9 +37,6 @@
Should be used sparingly! The use of large tables here can slow down the
browser.
A DISPLAY_VALUE column may be supplied by the hook but is ignored - it is not
returned to the client.
|DISPLAY_INDEX:best.|EXTRA_COL_NAME:$32.|DISPLAY_TYPE:$1.|RAW_VALUE_NUM|RAW_VALUE_CHAR:$5000|
|---|---|---|
|1|DISCOUNT_RT|N|0.5||
@@ -171,29 +167,26 @@ run;
%mp_include(sascode)
%mp_abort(mode=INCLUDE)
/* ensure that the DISPLAY_INDEX variable exists. DISPLAY_VALUE is accepted
from the hook for backwards compatibility but is no longer sent to the client */
/* ensure that the DISPLAY_INDEX variable exists. */
data work.dynamic_values;
length DISPLAY_INDEX 8 DISPLAY_VALUE $32767;
length DISPLAY_INDEX 8;
if _n_=1 then call missing(of _all_);
set work.dynamic_values;
display_index=coalesce(display_index,_n_);
keep DISPLAY_INDEX RAW_VALUE;
run;
/* ensure that work.dynamic_extended_values exists with correct types.
DISPLAY_VALUE is dropped - it is not consumed by the client */
/* ensure that work.dynamic_extended_values exists with correct types.*/
data work.dynamic_extended_values;
length DISPLAY_INDEX 8 EXTRA_COL_NAME $32 DISPLAY_VALUE $5000 DISPLAY_TYPE $1
length DISPLAY_INDEX 8 EXTRA_COL_NAME $32 DISPLAY_TYPE $1
RAW_VALUE_NUM 8 RAW_VALUE_CHAR $5000 FORCED_VALUE 8;
if _n_=1 then call missing(of _all_);
set work.dynamic_extended_values;
drop DISPLAY_VALUE;
run;
%webout(OPEN)
%webout(ARR,dynamic_values,fmt=N)
%webout(ARR,dynamic_extended_values,fmt=N)
%webout(OBJ,dynamic_values,fmt=N)
%webout(OBJ,dynamic_extended_values,fmt=N)
%webout(CLOSE)
%mpeterm()

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