Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28104f83e1 | ||
|
|
ea05f07180 | ||
|
|
acb97f4bfb | ||
|
|
bb808617f4 | ||
|
|
4a8c39b4c0 | ||
|
|
42e02cdb05 | ||
|
|
347923900f | ||
|
|
a4c3989c26 | ||
|
|
0392a81cbd | ||
|
|
f9ea53cf78 | ||
|
|
8fb58eb36e | ||
|
|
180c2477ed | ||
|
|
69cfccd565 | ||
|
|
f171375899 | ||
|
|
57db1179a9 | ||
|
|
d13fab267f | ||
|
|
44bc7f7fea | ||
|
|
f60bcef583 | ||
|
|
e7abb0a08a | ||
|
|
cfb60e5e4b | ||
|
|
33dcb989d3 | ||
|
|
aaf406b386 | ||
|
|
8efea8c744 | ||
|
|
39c8855f37 | ||
|
|
5c56c7579f | ||
|
|
92c1e20126 | ||
|
|
7050808d80 | ||
|
|
37a98e1d64 | ||
|
|
66f7b87b07 | ||
|
|
fae9496bbc | ||
|
|
cfe1e75be4 | ||
|
|
b51c770782 | ||
|
|
2a771bb91a | ||
|
|
54b8c78f86 | ||
|
|
ea74f95144 | ||
|
|
bd798b424a | ||
|
|
ee3c6c9e0f | ||
|
|
a3e46a968e | ||
|
|
ee4e9b9271 | ||
|
|
7a35cf4a45 | ||
|
|
d881290618 | ||
|
|
cbea04c8e1 | ||
|
|
994e7fc973 | ||
|
|
578c403994 | ||
|
|
56b7854db5 | ||
|
|
7ed3730ae3 | ||
|
|
6cd9b68581 | ||
|
|
7378f3ba30 | ||
|
|
cac9244f92 | ||
|
|
ea00c5afad | ||
|
|
359d833406 | ||
|
|
022981390e | ||
|
|
05fe4744d5 | ||
|
|
e22edf7ed3 | ||
|
|
62ff0aee4a | ||
|
|
3ed7cfdbee | ||
|
|
9176296816 | ||
|
|
bac7b0a600 | ||
|
|
d2c93a46fa | ||
|
|
17e4802895 | ||
|
|
600937a471 | ||
|
|
f100a16c8b | ||
|
|
9e54504474 | ||
|
|
0285bc90fd | ||
|
|
be94c68f35 | ||
|
|
f73f5e47ac | ||
|
|
c5f0e4f106 | ||
|
|
8e4f045eb9 | ||
|
|
08947f0fc8 | ||
|
|
efcccd7d11 | ||
|
|
078da3d947 | ||
|
|
61012f80e6 | ||
|
|
65bcf08266 | ||
|
|
ad7efc90bc | ||
|
|
70c5805743 | ||
|
|
dfe80bb42a | ||
|
|
af27933e46 | ||
|
|
6a95067618 | ||
|
|
5391df8f04 | ||
|
|
6b16f9b364 | ||
|
|
2dcae9060f | ||
|
|
f4525cc62a | ||
|
|
d4aa70ddbf | ||
|
|
4c2aca3495 | ||
|
|
36c9ef0e95 | ||
|
|
7511b64523 | ||
|
|
7b4b4ebeeb | ||
|
|
c7ba025d39 | ||
|
|
b5e9b25319 | ||
|
|
82a254d22c | ||
|
|
0d7fd34297 | ||
|
|
1beb3d490d | ||
|
|
1fb5e8eb37 | ||
|
|
78dd29ba78 | ||
|
|
ba7b61082d | ||
|
|
1a9231809e | ||
|
|
df5c975869 | ||
|
|
2728dac873 | ||
|
|
88f55b9d06 | ||
|
|
301675052f | ||
|
|
45c434271d | ||
|
|
f523c798c3 | ||
|
|
41fd618884 | ||
|
|
f368242cb0 | ||
|
|
636e4e5b4a | ||
|
|
49710eb62d | ||
|
|
25c12f2b18 | ||
|
|
3ba8858260 | ||
|
|
81014001ac | ||
|
|
691d6f277e | ||
|
|
e7453a9305 | ||
|
|
005b616adb | ||
|
|
0ae1781d28 | ||
|
|
fb94840016 | ||
|
|
6864c044dc | ||
|
|
b30ba3e76b | ||
|
|
a4583f2db0 | ||
|
|
a161670b86 | ||
|
|
e6cf5a8d25 | ||
|
|
7ebc26fc7f | ||
|
|
c9dd933cec | ||
|
|
716eba621f | ||
|
|
982d507ae3 | ||
|
|
27a12951e3 | ||
|
|
4c8e4ee1b2 | ||
|
|
a15fdc401e | ||
|
|
7019e39318 | ||
|
|
9ca57193b4 | ||
|
|
175eb9853a | ||
|
|
e859d3354e | ||
|
|
adc6424211 | ||
|
|
74442096ba | ||
|
|
9f84a580fb | ||
|
|
2f5d3dfc38 | ||
|
|
9b18a45d35 | ||
|
|
6a30de37ca | ||
|
|
31075a165f | ||
|
|
53b7ee0cb2 | ||
|
|
ebe6972dd7 | ||
|
|
2acea64589 | ||
|
|
ffa3ff9c10 | ||
|
|
0ac3ff4511 | ||
|
|
3429a7a2a6 | ||
|
|
56fb2c2a3a | ||
|
|
77b4bb310e | ||
|
|
47ba03ec63 | ||
|
|
e331e1bb21 | ||
|
|
40f285f295 | ||
|
|
2d29346cbf | ||
|
|
f70ea2fe71 | ||
|
|
29aaa72c60 | ||
|
|
12c7d30894 | ||
|
|
38cb1e207b | ||
|
|
4c879c614b | ||
|
|
8e012f760a | ||
|
|
8c09befd74 | ||
|
|
6af49cf1fd | ||
|
|
4ea604f9fb | ||
|
|
9d97bf7ea1 | ||
|
|
eb015d712b | ||
|
|
1d04f4a42c | ||
|
|
11ee49a57a | ||
|
|
609731ff99 | ||
|
|
d6cb32ed25 | ||
|
|
3668a7426f | ||
|
|
cc82dcaafe | ||
|
|
ea03bdecc5 | ||
|
|
51071b463b | ||
|
|
ac0bd10212 | ||
|
|
1b73e355b7 | ||
|
|
b661580c60 | ||
|
|
dc4e07a692 | ||
|
|
f2313b31f1 | ||
|
|
f8810ee7e9 | ||
|
|
8ab4af8397 | ||
|
|
2382a559a5 | ||
|
|
5d889d824c | ||
|
|
bed21122ce | ||
|
|
ea8cf71101 | ||
|
|
f1a26e132e | ||
|
|
1db6984de3 | ||
|
|
636ff237dd | ||
|
|
02963ab6d5 | ||
|
|
d40f61292a | ||
|
|
7d94cb2ae4 | ||
|
|
bb80476767 | ||
|
|
1635bc9c45 | ||
|
|
f031b4eb89 | ||
|
|
93d4ab65ac | ||
|
|
ce921a032a | ||
|
|
322f904b4b | ||
|
|
982eeac58c | ||
|
|
0ab9717556 | ||
|
|
24a85de8e1 | ||
|
|
65f0b979a4 | ||
|
|
947f34a0ad | ||
|
|
0f60fd7181 | ||
|
|
251062e42e | ||
|
|
05a328976e | ||
|
|
503cb08b2f | ||
|
|
f71be20476 | ||
|
|
e6397cecc1 | ||
|
|
80ce80ece4 | ||
|
|
9546fcd631 | ||
|
|
b79aaf4327 | ||
|
|
76f9198f73 | ||
|
|
d60029deae | ||
|
|
d26f7d2511 | ||
|
|
33efe09b50 | ||
|
|
e0aef9bf00 | ||
|
|
02d1a2e0b1 | ||
|
|
4e3154e929 | ||
|
|
32c0713256 | ||
|
|
defe15bcec | ||
|
|
6f8e471f16 | ||
|
|
dc35abfd85 | ||
|
|
04a8c5d52a | ||
|
|
2cb370053d | ||
|
|
1707f3802a | ||
|
|
c87ba660ca | ||
|
|
ef8a2dbc38 | ||
|
|
40d04a53c4 | ||
|
|
d5ebb01ce3 | ||
|
|
ec66631a33 | ||
|
|
d66eb5dfc2 | ||
|
|
731b589ed8 | ||
|
|
fe92d5fc36 | ||
|
|
a335b400f1 | ||
|
|
f63e507ddf | ||
|
|
991cc0567d | ||
|
|
52d58036a4 | ||
|
|
26bff85792 | ||
|
|
2ccf0d1100 | ||
|
|
3be33186bc | ||
|
|
1a7f950ae2 | ||
|
|
8924dc8ab1 | ||
|
|
2c2901b537 | ||
|
|
2cae7ea638 | ||
|
|
66e98a96cb | ||
|
|
0b0db1c543 | ||
|
|
80039f4876 | ||
|
|
326c26fddf | ||
|
|
e7b2ead0e2 | ||
|
|
a89657b0b8 | ||
|
|
4ee15e1b6e | ||
|
|
ed40df6295 | ||
|
|
6d590c050d | ||
|
|
47f9a54f97 | ||
|
|
17b0d72fbf | ||
|
|
0269c2421d | ||
|
|
5b260e4915 | ||
|
|
5290410a17 | ||
|
|
dc9041aaec | ||
|
|
b0dc441d68 | ||
|
|
b0fc3eb5af | ||
|
|
d9980e866d | ||
|
|
52ae3404ee | ||
|
|
eecb4f4f53 | ||
|
|
744345af81 | ||
|
|
7694d1b0fb | ||
|
|
d8010d4c0c | ||
|
|
a57b49c936 | ||
|
|
a84ba41ea9 | ||
|
|
dc200646f7 | ||
|
|
e273e870ef | ||
|
|
6fc34aca00 | ||
|
|
f97ac70678 | ||
|
|
6ceb681463 | ||
|
|
716ee6eba0 | ||
|
|
f6b0f6b0cd | ||
|
|
737a652ff0 | ||
|
|
2995e5c9dc | ||
|
|
338c7a2e41 | ||
|
|
ad27358deb | ||
|
|
495754816c | ||
|
|
96f2518af9 | ||
|
|
280bdeeb1b | ||
|
|
46cdeb0bab | ||
|
|
d41f88f8bf | ||
|
|
815d6e97a8 | ||
|
|
4e35aefe41 | ||
|
|
ca84915e43 | ||
|
|
31cc7e9e4d | ||
|
|
4ec107705e | ||
|
|
7740d2ac86 | ||
|
|
8c2aeacc85 | ||
|
|
8b8e8aec15 | ||
|
|
6ac3f660e9 | ||
|
|
7ee576a9c1 | ||
|
|
f4c8699aaf | ||
|
|
4273ca6e5c | ||
|
|
4ba043b77e | ||
|
|
0169415ea2 | ||
|
|
86791dbaca | ||
|
|
d5b58a3cbd | ||
|
|
3d8281d27e | ||
|
|
b1a014c7bc | ||
|
|
505d0af2b3 | ||
|
|
ece6bd1d78 | ||
|
|
8dc18b155a | ||
|
|
aecd597687 | ||
|
|
c99f106bae | ||
|
|
d2fc7ae6fe | ||
|
|
c1c1d0055a | ||
|
|
f37ec82d39 | ||
|
|
6e9e30e0f0 | ||
|
|
990ddb5cd3 | ||
|
|
c6ebbb48bb | ||
|
|
95cc0b1c91 | ||
|
|
81b282f1f1 | ||
|
|
8c5b357dd2 | ||
|
|
a13b2cbfd2 | ||
|
|
19617c2285 | ||
|
|
f9794a973f | ||
|
|
65efe62d19 | ||
|
|
683ddcaf53 | ||
|
|
113e0bbc3c | ||
|
|
2af97e40bf | ||
|
|
83cbe3aece | ||
|
|
ceac1ba614 | ||
|
|
765fdbdf9d | ||
|
|
43ae73c5f3 | ||
|
|
e57a0de8a9 | ||
|
|
3de491105b | ||
|
|
2fe690e962 | ||
|
|
b826d37086 | ||
|
|
bfbfd55fe7 | ||
|
|
15f38efd52 | ||
|
|
5d25681485 | ||
|
|
d26df376f8 | ||
|
|
cff3fb3bad | ||
|
|
fb3c49aa8b | ||
|
|
af1657e226 | ||
|
|
d7c7302c12 | ||
|
|
26ce95f7c1 | ||
|
|
4924df2ef3 | ||
|
|
2e141a5d52 | ||
|
|
cb1978bcaf | ||
|
|
387f5122f1 | ||
|
|
db5887de21 | ||
|
|
fe24d9bcbd | ||
|
|
6c6b1cbf46 | ||
|
|
4d65c9c999 | ||
|
|
4417279275 | ||
|
|
365f12996d | ||
|
|
ef1015f33b | ||
|
|
b43dfb5cf4 | ||
|
|
225e693d1f | ||
|
|
fda91770be | ||
|
|
d512876e0b | ||
|
|
2ba4b5383e | ||
|
|
ecc3184609 | ||
|
|
712b384848 | ||
|
|
26cdd73331 | ||
|
|
919aa6dcfe | ||
|
|
3bb3093b49 | ||
|
|
9c12250558 | ||
|
|
6c843f64fb | ||
|
|
3fda7dc5b0 | ||
|
|
22ec7f0340 | ||
|
|
378461dcbb | ||
|
|
905c7b9d3c | ||
|
|
670ec2c71c | ||
|
|
b419cd5078 | ||
|
|
b1db4ea590 | ||
|
|
822ddb1274 | ||
|
|
672dd6d4f1 | ||
|
|
b3ac73d903 | ||
|
|
f8554dd5e7 | ||
|
|
88679c0c9a | ||
|
|
a08a717ca8 | ||
|
|
c8b6fdbfdb | ||
|
|
b495c41626 | ||
|
|
7f4be474c6 | ||
|
|
7f6f68fcbb | ||
|
|
03fd7db033 | ||
|
|
9dc5c66f7b | ||
|
|
aa1b08632e | ||
|
|
6bbe354c9e | ||
|
|
8ff429793b | ||
|
|
70d010127a | ||
|
|
696717c509 | ||
|
|
71c308d052 | ||
|
|
bed5b320ad | ||
|
|
b0e827412e | ||
|
|
63e9af402e | ||
|
|
fd55105f62 | ||
|
|
c2e3b362e7 | ||
|
|
5d2d60d040 | ||
|
|
0e59f5406f | ||
|
|
e7cb471c0b | ||
|
|
0465089207 | ||
|
|
4f2f59907c | ||
|
|
7d85328d41 | ||
|
|
f2a9329196 | ||
|
|
7a8231615c | ||
|
|
0db6b25327 | ||
|
|
e91f6f01a6 | ||
|
|
0b4042af60 | ||
|
|
519d8953b5 | ||
|
|
14a616fc1b | ||
|
|
bfe5a8626f | ||
|
|
4ecd186e5c | ||
|
|
8c60473c15 | ||
|
|
bb126eba5b | ||
|
|
d1998422d2 | ||
|
|
69f687a85f | ||
|
|
2aa19d1dca | ||
|
|
e44a25dcc3 | ||
|
|
efb5ffa906 | ||
|
|
b4c586a859 | ||
|
|
e874143a95 | ||
|
|
e4dbab8b16 | ||
|
|
f6d7d6f90c | ||
|
|
063c90caf4 | ||
|
|
2011c2eee7 | ||
|
|
24545f2acd | ||
|
|
a7c81245ff | ||
|
|
4f2c993b2d | ||
|
|
e5f8e500c1 | ||
|
|
a61e2de140 | ||
|
|
881d2b060e | ||
|
|
4830c6d219 | ||
|
|
4c3c9ac88c | ||
|
|
7e1c610a4d | ||
|
|
8139f495ce | ||
|
|
89ab296151 | ||
|
|
a0dc92c403 | ||
|
|
86134f478a |
@@ -0,0 +1,147 @@
|
|||||||
|
# Bitemporal Dataloader — Technical Deep Dive
|
||||||
|
|
||||||
|
This document explains how the Data Controller backend loads staged data into target tables: the load-type dispatch, the internals of the `%bitemporal_dataloader` macro, and a detailed description of the REPLACE load type. For user-facing load type documentation, see [docs.datacontroller.io](https://docs.datacontroller.io/).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Every table registered for loading has a current record in `&mpelib..MPE_TABLES` with a `LOADTYPE` (selectbox values are seeded in `mpe_makedata.sas`):
|
||||||
|
|
||||||
|
| LOADTYPE | Loader used | History kept |
|
||||||
|
|---|---|---|
|
||||||
|
| `UPDATE` | `%bitemporal_dataloader` (no temporal vars) | None — changed records are deleted and re-appended |
|
||||||
|
| `REPLACE` | Inline code in `%mpe_targetloader` (does **not** use `%bitemporal_dataloader`) | None — entire table wiped and reloaded |
|
||||||
|
| `TXTEMPORAL` | `%bitemporal_dataloader` (technical time only) | SCD2-style, technical (transaction) time |
|
||||||
|
| `BITEMPORAL` | `%bitemporal_dataloader` (business + technical time) | Full two-dimensional history |
|
||||||
|
| `FORMAT_CAT` | `%mp_loadformat` | Format catalog load (table suffix `-FC`) |
|
||||||
|
|
||||||
|
The relevant `MPE_TABLES` columns read by the loader are: `LOADTYPE`, `BUSKEY` (primary key, space-separated, excluding temporal columns), `VAR_TXFROM` / `VAR_TXTO` (technical validity), `VAR_BUSFROM` / `VAR_BUSTO` (business validity), `VAR_PROCESSED` (processed timestamp column), `RK_UNDERLYING` (retained-key generation), `CLOSE_VARS`, and `AUDIT_LIBDS` (defaults to `&dclib..MPE_AUDIT`).
|
||||||
|
|
||||||
|
## Request Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[User submits changeset\neditors/stagedata.sas] --> B[Staging package written to\n&mpelocapprovals/&LOAD_REF\nCSV + jsdata]
|
||||||
|
B --> C[Approval workflow\nMPE_SUBMIT / MPE_REVIEW]
|
||||||
|
C --> D{auditors/postdata.sas}
|
||||||
|
D -->|action=SHOW_DIFFS| E["%mpe_targetloader(LOADTARGET=NO)\nbuilds work.outds_add / outds_mod / outds_del\nfor the diff screen only"]
|
||||||
|
D -->|action=APPROVE_TABLE| F["%mpe_targetloader(LOADTARGET=YES)\nactual load"]
|
||||||
|
E --> G[Diff CSV + TEMPDIFFS stored\nin approval package]
|
||||||
|
F --> H{LOADTYPE from\nMPE_TABLES}
|
||||||
|
H -->|UPDATE / TXTEMPORAL / BITEMPORAL| I["%bitemporal_dataloader"]
|
||||||
|
H -->|FORMAT_CAT| J["%mp_loadformat"]
|
||||||
|
H -->|REPLACE| K[Inline delete-all + append\nin %mpe_targetloader]
|
||||||
|
```
|
||||||
|
|
||||||
|
`%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`) is the single dispatch point. It reads the current `MPE_TABLES` record (`&dc_dttmtfmt. lt tx_to`), aborts if the table is not registered (or has duplicate config records), and routes to the loader. Note the two-phase design: `LOADTARGET=NO` prepares the intermediate `outds_*` tables so the approver can review diffs; `LOADTARGET=YES` performs the destructive load. Both phases run the same preparation logic, so the reviewed diffs correspond to what is actually applied.
|
||||||
|
|
||||||
|
## The Temporal Model
|
||||||
|
|
||||||
|
Bitemporal tables carry two independent time dimensions:
|
||||||
|
|
||||||
|
* **Business time** (`bus_from` / `bus_to`) — when the fact was true in the real world. Present on **both** staging and base tables.
|
||||||
|
* **Technical time** (`tech_from` / `tech_to`, a.k.a. transaction time) — when the record was known to the database. Present on the **base table only**; the loader stamps these itself.
|
||||||
|
|
||||||
|
All validity is expressed with **half-open intervals** (`from <= t < to`). Queries against bitemporal tables need two conditions and must not use `BETWEEN` or `from LE t LE to` (the latter excludes boundary records — see the macro header for background):
|
||||||
|
|
||||||
|
```sas
|
||||||
|
where &bus_from le [tstamp] lt &bus_to
|
||||||
|
and &tx_from le [tstamp] lt &tx_to
|
||||||
|
```
|
||||||
|
|
||||||
|
"Current" records have `tech_to` set to the high date (`'31DEC9999:23:59:59'dt` when called from `%mpe_targetloader`). Closing out a record means setting `tech_to = now` — records are never physically deleted from a temporal table, they are superseded.
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
| Component | Location | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `%mpe_targetloader` | `sas/sasjs/macros/mpe_targetloader.sas` | Reads `MPE_TABLES` config and dispatches per LOADTYPE; implements REPLACE inline |
|
||||||
|
| `%bitemporal_dataloader` | `sas/sasjs/macros/bitemporal_dataloader.sas` | Generic loader for UPDATE / TXTEMPORAL / BITEMPORAL |
|
||||||
|
| `%bitemporal_closeouts` | `sas/sasjs/macros/bitemporal_closeouts.sas` | Closes out (sets `tech_to=now`) live records matching a key |
|
||||||
|
| `%mp_retainedkey` | SASjs core | Generates retained (surrogate) keys when `RK_UNDERLYING` is configured |
|
||||||
|
| `%mp_rowhash` | SASjs core | MD5 hash of non-temporal columns, used for change detection |
|
||||||
|
| `%mp_storediffs` | SASjs core | Writes row-level audit records to `AUDIT_LIBDS` |
|
||||||
|
| `MPE_DATALOADS` | `&dclib` | Load log (counts, duration, macro version, user) — written only by `%bitemporal_dataloader` |
|
||||||
|
| `MPE_LOCKANYTABLE` | `&dclib` | Lock control table used by `%mp_lockanytable` |
|
||||||
|
|
||||||
|
## `%bitemporal_dataloader` Execution Flow
|
||||||
|
|
||||||
|
The macro builds a series of `work.bitemp*` intermediate tables, then applies the changes in two target-table operations (closeout, then append) under a single lock.
|
||||||
|
|
||||||
|
### 1. Pre-checks and setup
|
||||||
|
|
||||||
|
* Early return if the staging table is empty; hard abort if `&syscc > 0`.
|
||||||
|
* `CLOSE_VARS` is not supported on REDSHIFT / POSTGRES / SNOWFLAKE engines (returns with a NOTE).
|
||||||
|
* A zero-row snapshot of the base table (`&basecopy`) is taken with `data ... set base; stop;` — this doubles as a lock check, since metadata functions fail against a locked table.
|
||||||
|
* `proc contents` on the base table feeds column lists. Columns are split into character and numeric lists for hashing (`%mp_rowhash` hashes the two types via separate arrays). Temporal columns, the processed column, and the delete-flag column are excluded from the hash.
|
||||||
|
* Table names containing `___TMP___` or `_____` are rejected (they would collide with generated temp columns).
|
||||||
|
|
||||||
|
### 2. Locking
|
||||||
|
|
||||||
|
For `LOADTARGET=YES`, the base table (and audit table, if configured) is locked via `%mp_lockanytable` before any staging prep, because the load is a two-part update (closeouts + append) and must not interleave with another load.
|
||||||
|
|
||||||
|
### 3. Staging preparation (`work.bitemp0_append`)
|
||||||
|
|
||||||
|
* If `RK_UNDERLYING` is configured, `%mp_retainedkey` maps business keys to retained keys (filtering the base lookup to live records, `&now < tech_to`, for temporal types); otherwise the staging table is used as-is.
|
||||||
|
* `bus_from` / `bus_to` overrides are applied if provided; the processed column is stamped with the load timestamp (`now`). Even with `processed=0`, a column literally named `PROCESSED_DTTM` on the base table is used if present.
|
||||||
|
* The MD5 change-detection hash is computed for every staged record.
|
||||||
|
* If the staging table contains the delete-flag column (`_____DELETE__THIS__RECORD_____`), rows flagged `"Yes"` are diverted to `&outds_del` and closed out via `%bitemporal_closeouts` (PK is taken as `bus_from` + business key). The remaining rows continue as `bitemp0_append`.
|
||||||
|
|
||||||
|
### 4. CLOSE_VARS closeout
|
||||||
|
|
||||||
|
When `CLOSE_VARS` (a subset of the PK) is supplied, live base records whose CLOSE_VARS values appear in staging but whose full PK does **not** are closed out. This handles "this group was fully reloaded, so anything missing was removed" semantics without reloading the whole table.
|
||||||
|
|
||||||
|
### 5. Uniqueness check
|
||||||
|
|
||||||
|
If `CHECK_UNIQUENESS=YES` (the default) or business-date overrides are in play, the staging table is sorted `nodupkey` by the PK; a row-count mismatch aborts the load and releases the locks. The staging table must be a unique snapshot of the business key at one point in business time.
|
||||||
|
|
||||||
|
### 6. Base extract (`work.bitemp0_base`)
|
||||||
|
|
||||||
|
Only base records matching staged PKs are extracted — for temporal load types, only **currently live** records (`now < tech_to`). A left join from the staged keys to the base produces `___TMP___NEW_FLG` to identify brand-new keys. This is engine-specific:
|
||||||
|
|
||||||
|
* **OLEDB (SQL Server)** — staged keys are pushed to a `##global` temp table and joined via explicit pass-through.
|
||||||
|
* **REDSHIFT / POSTGRES / SNOWFLAKE** — an in-database temp table is created `like` the base, stripped to PK + an added `md5 varchar(32)` column, loaded with staged keys, and joined via pass-through. Snowflake uses transient tables; Redshift gets `alter sortkey none` plus any `DCBL_REDSH` config options from `MPE_CONFIG`.
|
||||||
|
* **CAS** — a FedSQL join against a CASUSER copy.
|
||||||
|
* **BASE/other** — plain PROC SQL in SAS.
|
||||||
|
|
||||||
|
### 7. Change classification
|
||||||
|
|
||||||
|
* **`&outds_add`** — staged records flagged as new (no base match). They get `tech_from=now`, `tech_to=high_date`.
|
||||||
|
* **`work.bitemp1_current`** — matched base records, re-hashed so hashes are comparable.
|
||||||
|
* **Inserts (BITEMPORAL only)** — a staged record whose business range falls strictly *inside* an existing record's range splits the existing record into a "before" and "after" segment (`bitemp3_inserts` / `bitemp3a_inserts`). The split segments replace the original in the comparison set (`bitemp3b_newbase`).
|
||||||
|
* **Updates (`bitemp4*`)** — staged records matching a base PK but with a different hash or different business dates. Base and staged versions are stacked, deduplicated, then aligned in **two passes** over the business timeline per key: a forward pass (carry `bus_from` forward across identical hashes; a staged record trims the preceding base record's range) and a reverse pass (carry `bus_to` back; records fully subsumed by the new version are deleted). Records that end up byte-identical to what is already stored are dropped via a hash lookup that includes the business dates (`bitemp5a_lkp` / `bitemp5b_updates`, BITEMPORAL only).
|
||||||
|
|
||||||
|
### 8. Closeout application
|
||||||
|
|
||||||
|
Changed records are closed out in the target before the new versions are appended. SAS SQL has no UPDATE-with-join, so a correlated `EXISTS` subquery against `work.bitemp5d_subquery` is used (pushed in-database as a temp table for OLEDB / REDSHIFT / POSTGRES / SNOWFLAKE):
|
||||||
|
|
||||||
|
* **BITEMPORAL** — per key, the closeout range is `min(bus_from)` / `max(bus_to)` of the changed records: `update base set tech_to=now (, processed=now) where tech_from <= now < tech_to and exists (key match and base.bus_from >= min and base.bus_to <= max)`.
|
||||||
|
* **TXTEMPORAL** — same, on key only.
|
||||||
|
* **UPDATE** — changed records are physically `delete`d (they are re-appended in the next step). On CAS this uses `table.deleteRows` with a whereTable; temporal types are not supported on CAS and abort.
|
||||||
|
* **BUSTEMPORAL** — closeouts are not implemented; the macro aborts at this point ("BUSTEMPORAL NOT YET SUPPORTED").
|
||||||
|
|
||||||
|
### 9. Append, unlock, audit, log
|
||||||
|
|
||||||
|
* The union of modified + new records, deduplicated on all columns (`bitemp6_unique`), is appended to the base table (`proc append ... force nowarn`; CAS appends via a varchar-casting step; Redshift applies `DCBL_REDSH` options). Locks are then released.
|
||||||
|
* If `outds_audit` is set (always, from `%mpe_targetloader` — `AUDIT_LIBDS` defaulting to `&dclib..MPE_AUDIT`), `%mp_storediffs` compares the pre-load snapshot with the applied changes and appends row-level audit records; modified-row entries with no actual value change are removed (`MOVE_TYPE="M" and IS_PK=0 and IS_DIFF=0`).
|
||||||
|
* A summary row is inserted into `&dclib..MPE_DATALOADS` (libref, dsn, etlsource, loadtype, changed/new/deleted counts, duration, macro version, user, timestamp) unless `LOG=0`.
|
||||||
|
|
||||||
|
## REPLACE Load Type
|
||||||
|
|
||||||
|
REPLACE is the simplest load type and is deliberately **not** routed through `%bitemporal_dataloader` — it is implemented inline in `%mpe_targetloader` and simply wipes the target table (`delete * from`) and re-appends the staging table verbatim, with no history, change detection, key matching, audit rows, or `MPE_DATALOADS` logging. See [replace-load-type.md](replace-load-type.md) for the full deep dive.
|
||||||
|
|
||||||
|
## Supporting Tables
|
||||||
|
|
||||||
|
| Table | Role |
|
||||||
|
|---|---|
|
||||||
|
| `MPE_TABLES` | Per-table load configuration (loadtype, keys, temporal vars, audit target) |
|
||||||
|
| `MPE_SELECTBOX` | Dropdown values, including the LOADTYPE list (seeded in `mpe_makedata.sas`) |
|
||||||
|
| `MPE_SUBMIT` / `MPE_REVIEW` | Approval workflow state |
|
||||||
|
| `MPE_LOADS` | Submission-level log of CSV package loads |
|
||||||
|
| `MPE_DATALOADS` | Per-load statistics (temporal/UPDATE loads only) |
|
||||||
|
| `MPE_AUDIT` (or `AUDIT_LIBDS`) | Row-level change audit (temporal/UPDATE/FORMAT_CAT loads only) |
|
||||||
|
| `MPE_LOCKANYTABLE` | Lock control table |
|
||||||
|
| `MPE_CONFIG` | Engine-specific config (e.g. `DCBL_REDSH` scope for Redshift bulk options) |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit tests for the loaders live next to the macros: `sas/sasjs/macros/bitemporal_dataloader.test.[1-4].sas` and `sas/sasjs/macros/mpe_targetloader.test.sas` (REPLACE loadtype), executed as sasjs tests. See [testing.md](testing.md) for how to run them (`npm run 4gl` then `sasjs test -t 4gl` from the `sas/` directory), and remember `sasjs lint` after touching any `.sas` files.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Dependency Updates Checklist
|
||||||
|
|
||||||
|
Whenever any `package.json` (root, `client/`, or `sas/`) or lockfile is modified, run the same checks CI runs before pushing:
|
||||||
|
|
||||||
|
1. **npm audit** (must be clean for prod deps):
|
||||||
|
```bash
|
||||||
|
npm audit --omit=dev # in repo root
|
||||||
|
cd sas && npm audit --omit=dev
|
||||||
|
cd ../client && npm audit --omit=dev
|
||||||
|
```
|
||||||
|
Fix with `npm audit fix`, targeted `overrides` in `package.json`, or version bumps — never `npm audit fix --force` blindly, as it can introduce breaking changes.
|
||||||
|
|
||||||
|
2. **License checker** (client only):
|
||||||
|
```bash
|
||||||
|
cd client && npm run license-checker
|
||||||
|
```
|
||||||
|
If a new dependency fails, either add its SPDX id to the `onlyAllow` list in `client/licenseChecker.js` (if the license is acceptable, e.g. permissive ones like `BlueOak-1.0.0`) or add the specific package to `excludePackages` with justification. Data Controller ships on-prem, so only OSI-approved permissive licenses are acceptable for production dependencies.
|
||||||
|
|
||||||
|
Both checks run in `.gitea/workflows/build.yaml` (`Check audit` and `Licence checker` steps) and will fail the build if skipped locally.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Regex Validations (HARDREGEX / SOFTREGEX)
|
||||||
|
|
||||||
|
This document describes how the regex validation rules work internally. For user-facing documentation see `docs/dcc-validations.md` in the `docs.datacontroller.io` repo.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Two validation rule types in `MPE_VALIDATIONS` validate cell values against a regular expression supplied in `RULE_VALUE`:
|
||||||
|
|
||||||
|
- `HARDREGEX` - submission-blocking. A failing value is rejected by the cell validator and painted red (HOT's own `htInvalid` class), so the row cannot be submitted.
|
||||||
|
- `SOFTREGEX` - display-only warning. A failing value is painted yellow (`dc-warning-cell` class) with a tooltip, but submission is not blocked.
|
||||||
|
|
||||||
|
Both rule types are selectable in the MPE_VALIDATIONS RULE_TYPE dropdown; they were added to the selectbox seed data in `sas/sasjs/macros/mpe_makedata.sas` and via the optional migration `sas/sasjs/db/migrations/20260720_v7.12_release.sas`. `RULE_VALUE` is limited to 128 characters, which constrains very long patterns.
|
||||||
|
|
||||||
|
## Config-time validation (SAS side)
|
||||||
|
|
||||||
|
`sas/sasjs/services/hooks/mpe_validations_postedit.sas` runs `prxparse()` on any staged HARDREGEX/SOFTREGEX rule value and aborts the edit with a list of offending `libref.table.column` references if the pattern is invalid. This is a best-effort syntax check to catch typos at config time; an empty pattern is treated as valid (it matches everything in JS). Rows marked for delete are skipped.
|
||||||
|
|
||||||
|
Because patterns must pass `prxparse`, rule values are authored in the SAS PRX delimiter form `/pattern/flags` (although a bare pattern is also accepted for backwards compatibility).
|
||||||
|
|
||||||
|
## Frontend evaluation
|
||||||
|
|
||||||
|
All regex handling lives in the client; there is no server-side re-validation of data values. The per-cell decision flow:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Cell value] --> B{Row marked for delete<br/>and not a PK column?}
|
||||||
|
B -- Yes --> Z[No validation / no warning]
|
||||||
|
B -- No --> C{isRegexRuleExempt?<br/>blank, or "." on a numeric column}
|
||||||
|
C -- Yes --> Z
|
||||||
|
C -- No --> D{HARDREGEX rule on column?}
|
||||||
|
D -- Yes --> E{Pattern matches?}
|
||||||
|
E -- No --> F[Invalid: submission blocked,<br/>red htInvalid + REGEX tooltip]
|
||||||
|
E -- Yes --> J[Valid]
|
||||||
|
D -- No --> G{SOFTREGEX rule on column?}
|
||||||
|
G -- Yes --> H{Pattern matches?}
|
||||||
|
H -- No --> I[Warning: yellow dc-warning-cell<br/>+ REGEX tooltip, submission allowed]
|
||||||
|
H -- Yes --> J[Valid]
|
||||||
|
G -- No --> J
|
||||||
|
```
|
||||||
|
|
||||||
|
A malformed pattern never reaches this flow: it is treated as always-valid (HARDREGEX) / never-warn (SOFTREGEX) with a `console.warn`, rather than breaking the editor.
|
||||||
|
|
||||||
|
### `client/src/app/shared/dc-validator/utils/parseRegexRule.ts`
|
||||||
|
|
||||||
|
Converts an authored SAS PRX pattern into a JavaScript `RegExp`:
|
||||||
|
|
||||||
|
1. If the value matches `/^\/(.*)\/([a-z]*)$/s`, the delimiters are stripped and the trailing flags are passed to `new RegExp(body, flags)`. Otherwise the value is used as-is (backwards compatibility with bare patterns).
|
||||||
|
2. Three mechanical Perl→JS translations are applied to the body:
|
||||||
|
- a leading `(?i)` inline modifier is removed and folded into the `i` flag;
|
||||||
|
- `\Q...\E` literal sequences are replaced with escaped literal text;
|
||||||
|
- `\A` → `^` and `\z` → `(?![\s\S])` (end-of-string anchor).
|
||||||
|
3. Perl-only constructs that would need a capture-group-renumbering rewrite (atomic groups `(?>...)`, possessive quantifiers `a++`) are deliberately NOT translated. They throw from `new RegExp`, and every caller treats a throw as "always valid / never warn" (see below) rather than breaking the editor.
|
||||||
|
|
||||||
|
### `client/src/app/shared/dc-validator/utils/isRegexRuleExempt.ts`
|
||||||
|
|
||||||
|
Blank values (`undefined`, `null`, `''`) are exempt from pattern matching on any column type - use a separate NOTNULL rule if populated values must also be enforced. On numeric columns the plain SAS missing (`.`) is also exempt; special missings (`.A`-`.Z`, `._`, bare letters) are NOT exempt anywhere - being deliberately set, they are real values the pattern must match (and on a character column even `.` is real text). `isSpecialMissing` from `@sasjs/utils` is deliberately not used: its optional-dot regex would exempt any single-letter character value ("d", "z") before the regex ever ran. The check takes an `isNumeric` flag, passed by all callers (the dq validator via `dqValidate(rules, value, colType === 'numeric')`, the warning renderer via a `makeRegexWarningRenderer` argument, and `failsSoftRegex` via the column's HOT type).
|
||||||
|
|
||||||
|
### HARDREGEX - blocking validation
|
||||||
|
|
||||||
|
`HARDREGEX` is implemented as a cell validator in `client/src/app/shared/dc-validator/validations/dq-validation.ts`. It returns `true` (valid) for exempt values and for patterns that fail to compile (with a `console.warn`), and otherwise returns `parseRegexRule(ruleValue).test(value.toString())`. Returning `false` makes HOT mark the cell invalid, block submission, and paint it red via its standard `htInvalid` styling.
|
||||||
|
|
||||||
|
### SOFTREGEX - warning renderer
|
||||||
|
|
||||||
|
`client/src/app/editor/utils/regex-warning-renderer.ts` builds a display-only Handsontable renderer (registered per-column by `DcValidator.setupRules` in `client/src/app/shared/dc-validator/dc-validator.ts`). It never returns false; it only:
|
||||||
|
|
||||||
|
- adds a `REGEX: <pattern>` tooltip (`td.title`) when a rule fails;
|
||||||
|
- adds the yellow `dc-warning-cell` class when only SOFTREGEX fails.
|
||||||
|
|
||||||
|
`DcValidator.failsSoftRegex(col, value)` provides the same logic outside the grid (e.g. the edit-record screen).
|
||||||
|
|
||||||
|
### Precedence: HARD and SOFT on the same column
|
||||||
|
|
||||||
|
Only one regex ever runs per column. If a HARDREGEX rule exists, SOFTREGEX is ignored entirely - never compiled, never evaluated - regardless of whether individual cell values pass or fail the hard rule. A value failing HARDREGEX gets the red invalid styling (blocking submission) plus a `REGEX: <pattern>` tooltip; a SOFTREGEX-only column warns in yellow without blocking. This holds in both the renderer and `failsSoftRegex`.
|
||||||
|
|
||||||
|
### Other behaviour
|
||||||
|
|
||||||
|
- Rows marked for delete (`_____DELETE__THIS__RECORD_____ = 'Yes'`) are not warned/validated by the renderer (except primary key columns, which still validate).
|
||||||
|
- A malformed pattern never breaks the editor: the dq validator treats it as always-valid and the renderer disables the warning, logging to the console instead.
|
||||||
|
- The pattern is used as authored - it is NOT auto-anchored. Authors must include `^`/`$` to match the entire cell value.
|
||||||
|
- Column info: `client/src/app/shared/utils/col-info-html.ts` shows the applied pattern in the column-info dropdown - the HARDREGEX pattern if one exists (it is the rule actually applied when both are present), otherwise the SOFTREGEX pattern, otherwise nothing.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit tests: `parseRegexRule.spec.ts`, `isRegexRuleExempt.spec.ts`, `dq-validation.spec.ts`, `dc-validator.spec.ts` (under `client/src/app/shared/dc-validator/`), `client/src/app/editor/utils/regex-warning-renderer.spec.ts`, `client/src/app/shared/utils/col-info-html.spec.ts`.
|
||||||
|
- E2E: `client/cypress/e2e/editor.cy.ts`.
|
||||||
|
- SAS side: `sas/sasjs/services/editors/stagedata.test.3.sas`, plus seed data in `mpe_makedata.sas`: HARDREGEX "SOME_CHAR must contain 'the' or 'data'" (`/the|data/i`), SOFTREGEX "SOME_CHAR should contain the letter 't'", SOFTREGEX on PRIMARY_KEY_FIELD (`/^\d+$/` - yellow if the key contains a decimal), and HARDREGEX on SOME_SHORTNUM (`/^(??$).*/` - values 1-5 blocked; generated data starts at 6 so demos aren't blocked accidentally).
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# REPLACE Load Type — Technical Deep Dive
|
||||||
|
|
||||||
|
This document describes the REPLACE load type in detail. For the overall loader architecture, the temporal model, and the other load types, see [bitemporal-dataloader.md](bitemporal-dataloader.md).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
REPLACE is the simplest load type and is deliberately **not** routed through `%bitemporal_dataloader`. It is implemented inline in `%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`, search for `&loadtype=REPLACE`). There is no history, no change detection, no key matching — the target table is wiped and reloaded from the staging table verbatim.
|
||||||
|
|
||||||
|
## Actual load (`LOADTARGET=YES`)
|
||||||
|
|
||||||
|
```sas
|
||||||
|
%mp_lockanytable(LOCK, lib=&lib, ds=&ds, ...)
|
||||||
|
|
||||||
|
data WORK.&STAGING_DS;
|
||||||
|
set WORK.&STAGING_DS;
|
||||||
|
/* only if the target contains the MPE_TABLES.VAR_PROCESSED variable: */
|
||||||
|
&VAR_PROCESSED = &now;
|
||||||
|
drop _____DELETE__THIS__RECORD_____;
|
||||||
|
run;
|
||||||
|
|
||||||
|
%if &engine_type=CAS %then %do;
|
||||||
|
/* prep first: cast varchar columns in a CASUSER copy of staging */
|
||||||
|
proc contents noprint data=&libds out=work.rpl_base_cols(keep=name type);
|
||||||
|
proc contents noprint data=WORK.&STAGING_DS out=work.rpl_stag_cols(keep=name type);
|
||||||
|
/* work.rpl_vchars = columns that are varchar in target AND in staging */
|
||||||
|
data casuser.&tmpds; /* temp copy with varchars casted */
|
||||||
|
length <varchar col> varchar(*); ...
|
||||||
|
set WORK.&STAGING_DS (rename=(<varchar col>=<tmp> ...));
|
||||||
|
<varchar col>=<tmp>; ... drop <tmp> ...;
|
||||||
|
run;
|
||||||
|
/* unlock + abort if any error so far - nothing destructive done yet */
|
||||||
|
%mp_abort(iftrue= (&syscc>0) ...)
|
||||||
|
/* destructive step, deliberately last before the append */
|
||||||
|
proc cas;
|
||||||
|
table.deleteRows / table={caslib="&lib",name="&ds",where="1=1"};
|
||||||
|
quit;
|
||||||
|
data &libds (append=yes) / sessref=dcsession;
|
||||||
|
set casuser.&tmpds;
|
||||||
|
run;
|
||||||
|
proc sql; drop table CASUSER.&tmpds; quit;
|
||||||
|
%end;
|
||||||
|
%else %do;
|
||||||
|
/* unlock + abort if any error so far - nothing destructive done yet */
|
||||||
|
%mp_abort(iftrue= (&syscc>0) ...)
|
||||||
|
proc sql;
|
||||||
|
delete * from &libds;
|
||||||
|
quit;
|
||||||
|
proc append base=&libds data=WORK.&STAGING_DS force nowarn; run;
|
||||||
|
%end;
|
||||||
|
|
||||||
|
%mp_lockanytable(UNLOCK, lib=&lib, ds=&ds, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Step by step:
|
||||||
|
|
||||||
|
1. **Lock** — the target is locked via `%mp_lockanytable` (control table `&dclib..MPE_LOCKANYTABLE`). This is the only concurrency guard for the whole operation.
|
||||||
|
2. **Staging recopy** — the staging dataset is rewritten in place: the processed-timestamp column is set to the approval timestamp **only if** the target table contains the variable named by `MPE_TABLES.VAR_PROCESSED` (unlike `%bitemporal_dataloader`, there is no `PROCESSED_DTTM` fallback). The delete-flag column `_____DELETE__THIS__RECORD_____` is unconditionally dropped — per-record delete semantics do not exist in REPLACE (everything is deleted anyway), so a delete flag submitted by the user is silently discarded (a `drop` of a non-existent variable just produces a warning).
|
||||||
|
3. **Prepare the load (CAS only)** — fixed char variables cannot be appended to CAS varchar columns, so the staging table is first copied to a CASUSER temp table with every column that is varchar in the target (and present in staging) redeclared as `varchar(*)` via generated `length` / `rename` / assignment statements. This mirrors the CAS append in `%bitemporal_dataloader`.
|
||||||
|
4. **Pre-destructive abort check** — if `&syscc > 0` after all preparation, the target is unlocked and the macro aborts via `%mp_abort`. Nothing destructive has happened at this point, so a failed prep leaves the target intact (and without a stale lock).
|
||||||
|
5. **Delete all rows** — every row is removed from the target while preserving structure, indexes and metadata, deliberately as the last step before the append (to minimise the time the target sits empty). On most engines this is `proc sql; delete * from &libds;` (passed through as a `DELETE` on database libraries). CAS tables do not support SQL deletes, so on the CAS engine the table is truncated instead via the `table.deleteRows` action with `where="1=1"` (same approach as `%bitemporal_closeouts`), with the libref passed as the caslib.
|
||||||
|
6. **Append staged rows** — the entire staging table is appended. On most engines this is `proc append ... force nowarn` (`force` allows the append to proceed despite attribute mismatches, so lengths/formats may be coerced or values truncated, and columns present in only one side are handled; `nowarn` suppresses the associated warnings). On CAS the pre-cast CASUSER temp table is appended with `data &libds (append=yes) / sessref=dcsession` and then dropped.
|
||||||
|
7. **Unlock**.
|
||||||
|
|
||||||
|
## Diff screen (`LOADTARGET=NO`)
|
||||||
|
|
||||||
|
When the approver reviews a REPLACE submission, no real comparison is performed:
|
||||||
|
|
||||||
|
```sas
|
||||||
|
/* is full replace so treat all staged records as new in diff screen */
|
||||||
|
data work.outds_mod work.outds_add;
|
||||||
|
set work.&staging_ds;
|
||||||
|
output work.outds_add; /* every staged record is "NEW" */
|
||||||
|
run; /* outds_mod stays empty */
|
||||||
|
|
||||||
|
/* previous table will be considered fully deleted */
|
||||||
|
data work.outds_del;
|
||||||
|
set &lib..&ds; /* every existing record is "DELETED" */
|
||||||
|
run;
|
||||||
|
```
|
||||||
|
|
||||||
|
The approval screen therefore always shows full-table turnover: the entire current table as deleted and the entire staging table as added, with no "modified" records and no original/current comparison.
|
||||||
|
|
||||||
|
## What REPLACE does not do
|
||||||
|
|
||||||
|
Because it bypasses `%bitemporal_dataloader`, none of the following apply to REPLACE:
|
||||||
|
|
||||||
|
* **No row-level audit** — nothing is written to `AUDIT_LIBDS` / `MPE_AUDIT`. The record of the change is the approval package itself (staged CSV and the `TEMPDIFFS` CSV stored under `&mpelocapprovals/&LOAD_REF`).
|
||||||
|
* **No `MPE_DATALOADS` log entry** — the load-log insert lives inside `%bitemporal_dataloader`, so REPLACE loads do not appear in load history. (Consequently the SHOW_DIFFS timestamp lookup in `postdata.sas` finds no `MPE_DATALOADS` row for a REPLACE load and falls back to the current datetime.)
|
||||||
|
* **No PK usage** — `BUSKEY` is ignored: no uniqueness check, no dedup, no join to existing data. Staged rows are loaded exactly as submitted, duplicates included.
|
||||||
|
* **No temporal handling** — `VAR_TXFROM`/`VAR_TXTO`/`VAR_BUSFROM`/`VAR_BUSTO` and `CLOSE_VARS` are ignored. A REPLACE table should not be treated as temporal; querying it with the usual validity filters makes no sense.
|
||||||
|
* **No retained-key handling** — `RK_UNDERLYING` is ignored.
|
||||||
|
* **Minimal engine-specific handling** — unlike the temporal loaders, there is no pass-through/temp-table optimisation for OLEDB, Redshift, Postgres or Snowflake. The only engine conditional is CAS, where rows are removed via the `deleteRows` action and appended via a varchar-casting CASUSER temp table and a data-step append (SQL deletes and fixed-char-to-varchar appends are not possible on CAS).
|
||||||
|
* **Not atomic** — the delete/truncate and append are separate steps with no transaction or rollback, so a session failure between them still leaves the target empty or partially loaded (recovery is a re-approval of the same or a previous staging package). The risk is mitigated by performing all preparation first, aborting on any error before the destructive step, and executing the delete/truncate immediately before the append.
|
||||||
|
|
||||||
|
## Interaction with Row Level Security
|
||||||
|
|
||||||
|
REPLACE is incompatible with `EDIT`-scope RLS rules (a full-table wipe cannot honour row-level write restrictions); this is enforced at edit time in both directions — see [row-level-security.md](row-level-security.md#incompatibility-with-replace-load-type) and [issue #211](https://git.datacontroller.io/dc/dc/issues/211). `VIEW`-scope rules remain compatible.
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# Row Level Security — Technical Deep Dive
|
||||||
|
|
||||||
|
This document explains **how** Row Level Security (RLS) is implemented in the Data Controller backend. For the user-facing configuration guide, see [docs.datacontroller.io/row-level-security](https://docs.datacontroller.io/row-level-security/).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
RLS in Data Controller is implemented as **server-side WHERE clause generation**. No data leaves SAS without passing through a dynamically generated filter. The filter is built at runtime per request, based on:
|
||||||
|
|
||||||
|
1. The requesting user's group memberships (SAS metadata groups + DC groups)
|
||||||
|
2. The active rules in the `MPE_ROW_LEVEL_SECURITY` control table
|
||||||
|
3. The access mode (VIEW / EDIT / download / upload)
|
||||||
|
|
||||||
|
Because the filter is expressed as a standard SAS `WHERE` expression, it works against **any** engine — Base SAS datasets, database libraries (via implicit SQL pass-through pushdown), SPDE, CAS libnames, etc.
|
||||||
|
|
||||||
|
## Request Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Client request\nview / edit / download / upload] --> B{Service mode}
|
||||||
|
B -->|VIEW / EDIT / DLOAD| C["%mpe_filtermaster(mode, libds)"]
|
||||||
|
B -->|ULOAD stagedata.sas| C
|
||||||
|
C --> D["%mpe_getgroups()\nmetadata groups + MPE_GROUPS"]
|
||||||
|
D --> E{User in &mpeadmins?}
|
||||||
|
E -->|Yes| F[No RLS filter\n1=1]
|
||||||
|
E -->|No| G[Lookup active rules in\nMPE_ROW_LEVEL_SECURITY\nfor libref.table + user's groups]
|
||||||
|
G --> H{Rules found?}
|
||||||
|
H -->|No| F
|
||||||
|
H -->|Yes| I["%mp_filtergenerate()\nper group, OR'd together"]
|
||||||
|
I --> J[WHERE clause written\nto temp fileref]
|
||||||
|
F --> J
|
||||||
|
J --> K{Read or Write?}
|
||||||
|
K -->|Read\nviewdata / getdata / getrawdata| L["where %inc filtref;;\nrows filtered server-side"]
|
||||||
|
K -->|Write\nstagedata| M[Inverse filter\nwhere not( filtref )]
|
||||||
|
M --> N{badrecords > 0?}
|
||||||
|
N -->|Yes| O[Abort submission\nSecurity Problem]
|
||||||
|
N -->|No| P[Staging proceeds\nto approval workflow]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
| Component | Location | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `MPE_ROW_LEVEL_SECURITY` | `&mpelib` (DC control library) | The rule table (scope, group, libref, table, logic, subgroup, variable, operator, raw value, active flag) |
|
||||||
|
| `%mpe_filtermaster()` | `sas/sasjs/macros/mpe_filtermaster.sas` | Master macro that assembles the full WHERE clause for a request |
|
||||||
|
| `%mpe_getgroups()` | `sas/sasjs/macros/mpe_getgroups.sas` | Resolves group membership (metadata groups via `%dc_getusergroups` + `MPE_GROUPS` DC-internal groups) |
|
||||||
|
| `%mp_filtergenerate()` | SASjs core | Converts a query table (logic/subgroup/variable/operator/value rows) into WHERE clause text |
|
||||||
|
| `%mp_filtercheck()` | SASjs core | Validates rule syntax at *edit time* (defence against SAS code injection) |
|
||||||
|
| `mpe_row_level_security_postedit.sas` | `sas/sasjs/services/hooks/` | Post-edit hook that runs `%mp_filtercheck` whenever the RLS table itself is edited |
|
||||||
|
|
||||||
|
## The Modes
|
||||||
|
|
||||||
|
`%mpe_filtermaster` accepts a `mode` parameter, and every service that surfaces data calls it with the appropriate mode:
|
||||||
|
|
||||||
|
| Mode | Caller | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `VIEW` | `services/public/viewdata.sas`, `getchangeinfo.sas` | Read-only table viewer |
|
||||||
|
| `EDIT` | `services/editors/getdata.sas` | The EDIT grid (adds "current records only" validity filtering) |
|
||||||
|
| `DLOAD` | `services/public/getrawdata.sas` | Raw file downloads (RLS scope treated as `VIEW`) |
|
||||||
|
| `ULOAD` | `services/editors/stagedata.sas` | **Upload validation** (RLS scope treated as `EDIT`) |
|
||||||
|
|
||||||
|
Scope mapping: `DLOAD` requests match rules with `RLS_SCOPE in ('VIEW','ALL')`; `ULOAD` requests match rules with `RLS_SCOPE in ('EDIT','ALL')`.
|
||||||
|
|
||||||
|
## Execution Flow of `%mpe_filtermaster`
|
||||||
|
|
||||||
|
The macro writes the final WHERE expression to a temporary **fileref** (`outref`), line by line. (A fileref is used because a generated filter may exceed the 64k macro variable limit — and note that `%include` of a fileref is not allowed directly in a `proc sql` where clause, hence callers typically use it in a DATA step or data step view.)
|
||||||
|
|
||||||
|
### 1. User-supplied filter (FILTER_RK)
|
||||||
|
|
||||||
|
If the request includes a stored filter (`filter_rk > 0`), its clauses are read from `MPE_FILTERANYTABLE` / `MPE_FILTERSOURCE` and generated first via `%mp_filtergenerate`. RLS clauses are then **AND-ed on top** — a user filter can only ever narrow results, never widen them beyond RLS.
|
||||||
|
|
||||||
|
### 2. Validity-date filtering (EDIT / DLOAD only)
|
||||||
|
|
||||||
|
`MPE_TABLES` may define `VAR_TXFROM` / `VAR_TXTO` (SCD2-style validity variables) for the target table. Unless the user explicitly filtered on those variables, the macro appends:
|
||||||
|
|
||||||
|
```
|
||||||
|
("<current datetime>"dt < VAR_TXTO)
|
||||||
|
```
|
||||||
|
|
||||||
|
so that only current records are surfaced in the EDIT grid and downloads.
|
||||||
|
|
||||||
|
### 3. Group resolution and admin bypass
|
||||||
|
|
||||||
|
```sas
|
||||||
|
%mpe_getgroups(user=%mf_getuser(), outds=work.groups)
|
||||||
|
```
|
||||||
|
|
||||||
|
Groups come from two sources, concatenated:
|
||||||
|
|
||||||
|
* SAS metadata groups (Viya / EBI / Base-specific logic in `%dc_getusergroups`)
|
||||||
|
* The `MPE_GROUPS` DC table (group assignments managed inside Data Controller)
|
||||||
|
|
||||||
|
If the user is a member of the `&mpeadmins` group, **RLS is skipped entirely** — admins always see all rows.
|
||||||
|
|
||||||
|
### 4. Rule extraction
|
||||||
|
|
||||||
|
Non-admin users trigger a lookup of active, current rules:
|
||||||
|
|
||||||
|
```sas
|
||||||
|
create table work.&rlsds as
|
||||||
|
select rls_group, rls_group_logic, rls_subgroup_logic, rls_subgroup_id,
|
||||||
|
rls_variable_nm, rls_operator_nm, rls_raw_value
|
||||||
|
from &mpelib..mpe_row_level_security
|
||||||
|
where &dc_dttmtfmt. lt tx_to /* only current (non-deleted) rules */
|
||||||
|
and rls_scope in ("&scopeval",'ALL')
|
||||||
|
and upcase(rls_group) in (select upcase(groupname) from work.groups)
|
||||||
|
and rls_libref = "<libref>" and rls_table = "<dsname>"
|
||||||
|
and rls_active = 1
|
||||||
|
order by rls_group, rls_subgroup_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Clause assembly
|
||||||
|
|
||||||
|
If rules exist, they are appended to the fileref as `AND ( ... )`. Each **group** the user belongs to contributes one sub-filter, and the group-level sub-filters are joined with `OR`:
|
||||||
|
|
||||||
|
```
|
||||||
|
AND ( <group 1 filter> OR <group 2 filter> ... )
|
||||||
|
```
|
||||||
|
|
||||||
|
Each group's filter is produced by `%mp_filtergenerate`, which honours:
|
||||||
|
|
||||||
|
* `RLS_GROUP_LOGIC` — how subgroups (identified by `RLS_SUBGROUP_ID`) are joined
|
||||||
|
* `RLS_SUBGROUP_LOGIC` — how individual clauses within a subgroup are joined (AND/OR)
|
||||||
|
|
||||||
|
So membership in multiple groups is **permissive** (OR): the user sees the union of rows permitted by each of their groups.
|
||||||
|
|
||||||
|
### 6. Empty-filter fallback
|
||||||
|
|
||||||
|
If nothing was written to the fileref (no stored filter, no validity vars, no matching RLS rules), the macro writes a literal `1=1` so the fileref can always be consumed as a valid WHERE expression. Absence of rules therefore means **no restriction** — RLS is opt-in per table/group.
|
||||||
|
|
||||||
|
## How Callers Apply the Filter
|
||||||
|
|
||||||
|
### Read path (viewdata.sas)
|
||||||
|
|
||||||
|
```sas
|
||||||
|
%mpe_filtermaster(VIEW, &libds, dclib=&mpelib, filter_rk=&filter_rk,
|
||||||
|
outref=filtref, outds=work.query)
|
||||||
|
|
||||||
|
data work.viewdata;
|
||||||
|
set &libds;
|
||||||
|
where %inc filtref;;
|
||||||
|
if _n_ > &DC_MAXOBS_WEBVIEW then stop;
|
||||||
|
run;
|
||||||
|
```
|
||||||
|
|
||||||
|
The fileref is `%include`d directly inside the `where` statement — the filter never passes through client-visible state, so it cannot be tampered with. For database libraries the WHERE expression is pushed down to the database by the SAS engine.
|
||||||
|
|
||||||
|
### Write path (stagedata.sas) — inverse filter
|
||||||
|
|
||||||
|
Uploads cannot be filtered; instead the filter is **inverted** and any submitted row matching the inverse is rejected:
|
||||||
|
|
||||||
|
```sas
|
||||||
|
%mpe_filtermaster(ULOAD, &libds, dclib=&mpelib, outref=filtref, ...)
|
||||||
|
|
||||||
|
/* prepare inverse query */
|
||||||
|
data _null_;
|
||||||
|
infile filtref end=eof;
|
||||||
|
file &tempref;
|
||||||
|
if _n_=1 then put 'where not(';
|
||||||
|
input; put _infile_;
|
||||||
|
if eof then put ')';
|
||||||
|
run;
|
||||||
|
|
||||||
|
data work.badrecords;
|
||||||
|
set work.jsdata; /* rows submitted by the user */
|
||||||
|
%inc &tempref/source2;;
|
||||||
|
run;
|
||||||
|
|
||||||
|
%mp_abort(iftrue=(%mf_nobs(work.badrecords)>0)
|
||||||
|
,msg=%str(Security Problem - N unauthorised records submitted))
|
||||||
|
```
|
||||||
|
|
||||||
|
If even one submitted row falls outside the user's permitted row set, the entire staging request is aborted before any approval/apply step.
|
||||||
|
|
||||||
|
## Incompatibility with REPLACE Load Type
|
||||||
|
|
||||||
|
RLS with `EDIT` scope is **incompatible** with tables configured with `LOAD_TYPE=REPLACE` in `MPE_TABLES`. A REPLACE load wipes and reloads the entire target table, so row-level filtering of submitted records cannot be enforced meaningfully (the rows a user is *not* allowed to see would also be deleted). Backend validations therefore abort in both directions (see [issue #211](https://git.datacontroller.io/dc/dc/issues/211)):
|
||||||
|
|
||||||
|
1. **`mpe_row_level_security_postedit.sas`** — aborts when activating a rule with `RLS_SCOPE in ('EDIT','ALL')` against a table whose current `MPE_TABLES` record has `LOADTYPE='REPLACE'`.
|
||||||
|
2. **`mpe_tables_postedit.sas`** — aborts when setting `LOADTYPE='REPLACE'` on a table that already has active, current `RLS_SCOPE in ('EDIT','ALL')` rules in `MPE_ROW_LEVEL_SECURITY`.
|
||||||
|
|
||||||
|
`VIEW`-scope rules remain compatible with REPLACE loads, since they only affect read paths.
|
||||||
|
|
||||||
|
## Edit-Time Validation (Injection Defence)
|
||||||
|
|
||||||
|
Because `RLS_RAW_VALUE` is free text that ends up inside a generated WHERE clause, it is a potential SAS code-injection vector. Mitigations:
|
||||||
|
|
||||||
|
1. **`mpe_row_level_security_postedit.sas`** — a post-edit hook on the `MPE_ROW_LEVEL_SECURITY` table itself. Every newly staged rule (with `rls_active=1`) is grouped by target `libref.table` and run through `%mp_filtercheck(targetds=..., abort=YES)`, which compiles/tests each clause against the real target table and rejects the whole submission on invalid syntax (see [mp_filtercheck](https://core.sasjs.io/mp__filtercheck_8sas.html)).
|
||||||
|
2. **Format rules** enforced by validation: character values must be single quoted, `IN`/`NOT IN` values must be bracketed, `BETWEEN` must contain `AND`.
|
||||||
|
|
||||||
|
Additionally, editing `MPE_ROW_LEVEL_SECURITY` is itself a Data Controller table edit, so it goes through the normal approval workflow, audit trail (`MPE_AUDIT`), and can be column-restricted via [Column Level Security](https://docs.datacontroller.io/column-level-security/).
|
||||||
|
|
||||||
|
## Worked Example
|
||||||
|
|
||||||
|
Given these active rules (all for `MYLIB.MYDS`, `RLS_ACTIVE=1`):
|
||||||
|
|
||||||
|
| SCOPE | GROUP | GROUP_LOGIC | SUBGRP_LOGIC | SUBGRP_ID | VAR | OP | VALUE |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| ALL | Group 1 | AND | AND | 1 | VAR_2 | IN | ('this','or') |
|
||||||
|
| ALL | Group 1 | AND | AND | 1 | VAR_3 | < | 42 |
|
||||||
|
| ALL | Group 2 | AND | AND | 1 | VAR_4 | CONTAINS | 'xyz' |
|
||||||
|
|
||||||
|
A non-admin user in **both** groups, opening the VIEW page, gets a fileref containing:
|
||||||
|
|
||||||
|
```
|
||||||
|
AND ( (VAR_2 IN ('this','or') AND VAR_3 < 42) OR (VAR_4 CONTAINS 'xyz') )
|
||||||
|
```
|
||||||
|
|
||||||
|
resulting in:
|
||||||
|
|
||||||
|
```sas
|
||||||
|
data work.viewdata;
|
||||||
|
set mylib.myds;
|
||||||
|
where (VAR_2 IN ('this','or') AND VAR_3 < 42) OR (VAR_4 CONTAINS 'xyz');
|
||||||
|
run;
|
||||||
|
```
|
||||||
|
|
||||||
|
The same user submitting an EDIT upload has the inverse applied to their staged rows; any row not matching the expression above aborts the submission.
|
||||||
|
|
||||||
|
## Summary of Security Properties
|
||||||
|
|
||||||
|
* **Enforced server-side** in every read service (VIEW/EDIT/DLOAD) and every write service (ULOAD via inverse filter).
|
||||||
|
* **Admin bypass** is explicit (`&mpeadmins` group membership check).
|
||||||
|
* **Multi-group semantics are OR** (union of permitted rows); rules within a group/subgroup are AND/OR per configuration.
|
||||||
|
* **Fail-open by design**: if no rules match a table, it is unrestricted — RLS must be opted into per table.
|
||||||
|
* **Injection-resistant**: values are validated with `%mp_filtercheck` at the time rules are edited, not at query time.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- Full deploy + test cycle: `npm run 4gl` (compiles, deploys to the 4gl target, runs makedata) then `sasjs test -t 4gl`.
|
||||||
|
- Run tests only (no rebuild/redeploy): `sasjs test -t 4gl`.
|
||||||
|
- Run a subset of tests: `sasjs test -t 4gl SOMESTRING` — the positional argument filters to matching tests (e.g. `sasjs test -t 4gl stagedata.test.3`).
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
- Test results: `sas/sasjsresults/testResults.json` / `.csv` / `.xml`
|
||||||
|
- Individual test logs: `sas/sasjsresults/logs/<testsuite>.log` (e.g. `services_editors_stagedata.test.3.log`)
|
||||||
|
- Coverage: `sas/sasjsresults/coverage.lcov`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Test source files live in `sas/sasjs/**`; `sas/sasjsbuild/` is generated build output — do not hand-edit it.
|
||||||
|
- `sasjs test` executes tests against the deployed app, so run `npm run 4gl` first after changing any service/hook/test code.
|
||||||
|
- Assertions are made with `%mp_assert()`; results are written to `work.test_results`.
|
||||||
@@ -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 (v17–19)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install handsontable @handsontable/angular-wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
The Angular wrapper was modernized in Handsontable v17.1 to align with Angular 17–19, 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 10k–100k+ 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 17–19; 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
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
```
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: sas
|
||||||
|
description: >
|
||||||
|
Use this skill whenever writing, modifying, or debugging SAS code in this repository — SAS
|
||||||
|
macros, sasjs services, hooks, or test files (*.sas). Triggers include: SAS macro compilation
|
||||||
|
errors, parameter parsing issues, writing sasjs tests with mp_assert / mp_assertdsobs /
|
||||||
|
mp_assertscope, macro-quoting questions (%str, %nrstr, %superq), %mp_abort usage, or unexpected
|
||||||
|
behaviour when passing free-text values (descriptions, messages) to macros. Also covers repo
|
||||||
|
conventions such as running `sasjs lint` after touching .sas files and the maximum line length
|
||||||
|
rule.
|
||||||
|
---
|
||||||
|
|
||||||
|
# SAS Development
|
||||||
|
|
||||||
|
Conventions and pitfalls for SAS code in this repository (macros in `sas/sasjs/macros/`, services
|
||||||
|
in `sas/sasjs/services/`, tests alongside them as `*.test.sas`).
|
||||||
|
|
||||||
|
## Always `%str()` free-text macro parameters
|
||||||
|
|
||||||
|
A comma inside a macro parameter value is parsed as a **parameter delimiter**. Free-text
|
||||||
|
parameters such as `desc=` (in `%mp_assert`, `%mp_assertdsobs`, `%mp_assertscope`) and `msg=` (in
|
||||||
|
`%mp_abort`) must be wrapped in `%str()` — even when they currently contain no comma, so later
|
||||||
|
edits cannot silently break the call.
|
||||||
|
|
||||||
|
Wrong — the text after the comma becomes an unexpected positional parameter and the compilation
|
||||||
|
fails (or worse, is misparsed):
|
||||||
|
|
||||||
|
```sas
|
||||||
|
%mp_assert(iftrue=(&oldrows=0),
|
||||||
|
desc=Test 2 - all staged records loaded, delete flags ignored,
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Right:
|
||||||
|
|
||||||
|
```sas
|
||||||
|
%mp_assert(iftrue=(&oldrows=0),
|
||||||
|
desc=%str(Test 2 - all staged records loaded, delete flags ignored),
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The same applies to any macro parameter that carries user-facing text: `%mp_abort(msg=%str(...))`,
|
||||||
|
`etlsource=` values containing punctuation (use `%superq()` when passing macro variables that may
|
||||||
|
contain special characters), etc.
|
||||||
|
|
||||||
|
Related quoting rules of thumb:
|
||||||
|
|
||||||
|
- `%str()` masks commas, parentheses, semicolons and quotes at compile time — sufficient for
|
||||||
|
static text like `desc=`.
|
||||||
|
- Use `%nrstr()` if the text must also mask `%` and `&` (rare in descriptions).
|
||||||
|
- Use `%superq(var)` when forwarding a macro **variable** whose value may contain special
|
||||||
|
characters (e.g. `etlsource=` in `mpe_targetloader.sas`).
|
||||||
|
|
||||||
|
## Tests must be idempotent
|
||||||
|
|
||||||
|
A test file must pass when run repeatedly (including after a run that failed partway). Conventions:
|
||||||
|
|
||||||
|
- Start the file with `%let syscc=0;` — many macros (e.g. `%mp_lockanytable(LOCK)`) abort on entry if `&syscc>0`, and any `WARNING` in a previous test bumps `syscc` to 4.
|
||||||
|
- Clean up **all** persistent state, not just the obvious one: `MPE_TABLES` registrations, `MPE_LOCKANYTABLE` lock records (a run dying between LOCK and UNLOCK leaves a stale `LOCKED` row), physical tables in `dctest` (`proc datasets ... delete`), and global macro variables created via `select ... into:` (`%symdel`).
|
||||||
|
- Make prep defensive: delete-then-insert config records (handles leftovers from an aborted run), and recreate physical tables rather than assuming they are absent.
|
||||||
|
|
||||||
|
## Repo conventions for .sas files
|
||||||
|
|
||||||
|
- Run `npx sasjs lint` from the `sas/` directory after creating or modifying any `.sas` file; the
|
||||||
|
files you touched must have zero warnings (pre-existing warnings in other files can be ignored).
|
||||||
|
- Maximum line length is 80 characters (lint-enforced) — this is why long `msg=%str(...)` values
|
||||||
|
sometimes need shortening.
|
||||||
|
- Test files are named `<thing>.test.sas` (or `<thing>.test.N.sas`) and run via
|
||||||
|
`npm run 4gl && sasjs test -t 4gl` from `sas/` — see `.agent/docs/testing.md`.
|
||||||
|
- `sas/sasjsbuild/` is generated build output — never hand-edit it; edit sources under
|
||||||
|
`sas/sasjs/` only.
|
||||||
+19
-7
@@ -1,11 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# Avoid commits to the master branch
|
# Using `--silent` helps for showing any errs in the first line of the response
|
||||||
BRANCH=`git rev-parse --abbrev-ref HEAD`
|
# The first line is picked up by the VS Code GIT UI popup when rc is not 0
|
||||||
REGEX="^(master|development)$"
|
|
||||||
|
|
||||||
if [[ "$BRANCH" =~ $REGEX ]]; then
|
if npm run --silent lint:check:silent ; then
|
||||||
echo "You are on branch $BRANCH. Are you sure you want to commit to this branch?"
|
exit 0
|
||||||
echo "If so, commit with -n to bypass the pre-commit hook."
|
else
|
||||||
exit 1
|
npm run --silent lint:fix:silent
|
||||||
|
echo "❌ Prettier check failed! We ran lint:fix for you. Please add & commit again."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
## Avoid large commits
|
||||||
|
# https://www.backblaze.com/blog/how-many-bytes-are-in-a-megabyte-really/
|
||||||
|
size_limit=$((2 * 2**20)) # 2mbs
|
||||||
|
# https://git-scm.com/docs/git-rev-list#Documentation/git-rev-list.txt---disk-usage
|
||||||
|
commit_size=$(git rev-list --disk-usage HEAD^..HEAD)
|
||||||
|
test "$commit_size" -lt "$size_limit" || (
|
||||||
|
echo "Commit size is too large: $commit_size > $size_limit"
|
||||||
|
echo "Force commit using --no-verify"
|
||||||
|
exit 1
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
+58
-37
@@ -2,39 +2,53 @@ name: Build
|
|||||||
run-name: Running Lint Check and Licence checker on Pull Request
|
run-name: Running Lint Check and Licence checker on Pull Request
|
||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '24.15.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
Build-and-ng-test:
|
Build-and-ng-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.14.0
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Install Google Chrome
|
- name: Install Google Chrome
|
||||||
run: |
|
run: |
|
||||||
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
|
|
||||||
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list
|
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y google-chrome-stable xvfb
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
|
apt install -y ./google-chrome*.deb
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: echo "$NPMRC" > client/.npmrc
|
run: echo "$NPMRC" >> client/.npmrc
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
|
|
||||||
- name: Lint check
|
|
||||||
run: npm run lint:check
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo "${{ secrets.SHEET_PWD }}" | \
|
||||||
|
gpg --batch --yes --passphrase-fd 0 \
|
||||||
|
--output ./libraries/sheet-crypto.tgz \
|
||||||
|
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
|
- name: Check audit
|
||||||
|
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
|
||||||
|
run: |
|
||||||
|
npm audit --omit=dev
|
||||||
|
cd ./sas
|
||||||
|
npm audit --omit=dev
|
||||||
|
cd ../client
|
||||||
|
npm audit --omit=dev
|
||||||
|
|
||||||
|
- name: Lint check
|
||||||
|
run: npm run lint:check
|
||||||
|
|
||||||
- name: Licence checker
|
- name: Licence checker
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
@@ -52,26 +66,33 @@ jobs:
|
|||||||
|
|
||||||
Build-and-test-development:
|
Build-and-test-development:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: Build-production-and-ng-test
|
needs: Build-and-ng-test
|
||||||
|
env:
|
||||||
|
CHROME_BIN: /usr/bin/google-chrome
|
||||||
|
# Pin OS locale
|
||||||
|
LANG: en_GB.UTF-8
|
||||||
|
LC_ALL: en_GB.UTF-8
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.14.0
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
touch client/.npmrc
|
touch client/.npmrc
|
||||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||||
|
|
||||||
- run: apt-get update
|
- name: Install system dependencies
|
||||||
- run: wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
run: |
|
||||||
- run: apt install -y ./google-chrome*.deb;
|
apt-get update
|
||||||
- run: export CHROME_BIN=/usr/bin/google-chrome
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
- run: apt-get update -y
|
apt install -y ./google-chrome*.deb
|
||||||
- run: apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb
|
apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb jq zip locales
|
||||||
- run: apt -y install jq
|
# Generate the en_GB.UTF-8 locale referenced by LANG/LC_ALL
|
||||||
|
locale-gen en_GB.UTF-8
|
||||||
|
update-locale LANG=en_GB.UTF-8 LC_ALL=en_GB.UTF-8
|
||||||
|
|
||||||
- name: Write cypress credentials
|
- name: Write cypress credentials
|
||||||
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
||||||
@@ -86,17 +107,18 @@ jobs:
|
|||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
# Install pm2 and prepare SASJS server
|
- name: Setup and start SASjs server
|
||||||
- run: npm i -g pm2
|
run: |
|
||||||
- run: curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
npm i -g pm2
|
||||||
- run: unzip linux.zip
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
- run: touch .env
|
unzip linux.zip
|
||||||
- run: echo RUN_TIMES=js >> .env
|
touch .env
|
||||||
- run: echo NODE_PATH=node >> .env
|
echo RUN_TIMES=js >> .env
|
||||||
- run: echo CORS=enable >> .env
|
echo NODE_PATH=node >> .env
|
||||||
- run: echo WHITELIST=http://localhost:4200 >> .env
|
echo CORS=enable >> .env
|
||||||
- run: cat .env
|
echo WHITELIST=http://localhost:4200 >> .env
|
||||||
- run: pm2 start api-linux --wait-ready
|
cat .env
|
||||||
|
pm2 start api-linux --wait-ready
|
||||||
|
|
||||||
- name: Deploy mocked services
|
- name: Deploy mocked services
|
||||||
run: |
|
run: |
|
||||||
@@ -106,12 +128,8 @@ jobs:
|
|||||||
sasjs cbd -t server-ci
|
sasjs cbd -t server-ci
|
||||||
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
|
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
|
||||||
|
|
||||||
- name: Install ZIP
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install zip
|
|
||||||
|
|
||||||
- name: Prepare and run frontend and cypress
|
- name: Prepare and run frontend and cypress
|
||||||
|
timeout-minutes: 35
|
||||||
run: |
|
run: |
|
||||||
cd ./client
|
cd ./client
|
||||||
mv ./cypress.env.example.json ./cypress.env.json
|
mv ./cypress.env.example.json ./cypress.env.json
|
||||||
@@ -126,11 +144,14 @@ jobs:
|
|||||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||||
cat ./cypress.config.ts
|
cat ./cypress.config.ts
|
||||||
# Start frontend and run cypress
|
# Start frontend and run cypress
|
||||||
npm start & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "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"
|
# 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,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts"
|
||||||
|
|
||||||
- name: Zip Cypress videos
|
- name: Zip Cypress videos
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p ./client/cypress/videos
|
||||||
zip -r cypress-videos ./client/cypress/videos
|
zip -r cypress-videos ./client/cypress/videos
|
||||||
|
|
||||||
- name: Add cypress videos artifacts
|
- name: Add cypress videos artifacts
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
name: Lighthouse Checks
|
||||||
|
run-name: Running Lighthouse Performance and Accessibility Checks on Pull Request
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '24.15.0'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lighthouse:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js ${{ env.NODE_VERSION }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Install Google Chrome
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
|
apt install -y ./google-chrome*.deb
|
||||||
|
|
||||||
|
- name: Install global packages
|
||||||
|
run: npm i -g pm2 @sasjs/cli wait-on
|
||||||
|
|
||||||
|
- name: Setup and start SASjs server
|
||||||
|
run: |
|
||||||
|
touch .env
|
||||||
|
echo RUN_TIMES=js >> .env
|
||||||
|
echo NODE_PATH=node >> .env
|
||||||
|
echo CORS=enable >> .env
|
||||||
|
echo WHITELIST=http://localhost:4200 >> .env
|
||||||
|
cat .env
|
||||||
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
|
unzip linux.zip
|
||||||
|
pm2 start api-linux --wait-ready
|
||||||
|
|
||||||
|
- name: Write .npmrc file
|
||||||
|
run: echo "$NPMRC" > client/.npmrc
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
|
|
||||||
|
- name: Install npm dependencies
|
||||||
|
run: |
|
||||||
|
cd client
|
||||||
|
# Decrypt and Install sheet
|
||||||
|
echo "${{ secrets.SHEET_PWD }}" | \
|
||||||
|
gpg --batch --yes --passphrase-fd 0 \
|
||||||
|
--output ./libraries/sheet-crypto.tgz \
|
||||||
|
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||||
|
npm ci
|
||||||
|
npm install -g replace-in-files-cli
|
||||||
|
|
||||||
|
- name: Update appLoc in index.html
|
||||||
|
run: |
|
||||||
|
cd client
|
||||||
|
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/proj/sasjs/genesis-mocks"' ./src/index.html
|
||||||
|
|
||||||
|
- name: Build Frontend
|
||||||
|
run: |
|
||||||
|
cd client
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Deploy JS mocked services and frontend to the local SASjs Server instance
|
||||||
|
run: |
|
||||||
|
cd sas/mocks
|
||||||
|
npm ci
|
||||||
|
sasjs cbd -t server-ci
|
||||||
|
|
||||||
|
- name: Start frontend server
|
||||||
|
run: |
|
||||||
|
cd client
|
||||||
|
npx ng serve --host 0.0.0.0 --port 4200 &
|
||||||
|
wait-on http://localhost:4200
|
||||||
|
|
||||||
|
- name: Run Lighthouse CI
|
||||||
|
run: |
|
||||||
|
cd client
|
||||||
|
npx lhci autorun
|
||||||
|
|
||||||
|
- name: Lighthouse Result Artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: Lighthouse results
|
||||||
|
path: client/lighthouse-reports
|
||||||
|
include-hidden-files: true
|
||||||
+129
-63
@@ -5,27 +5,31 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '24.5.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
Build-production-and-ng-test:
|
Build-production-and-ng-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CHROME_BIN: /usr/bin/google-chrome
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.14.0
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
touch client/.npmrc
|
touch client/.npmrc
|
||||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||||
|
|
||||||
- name: Install Chrome for Angular tests
|
- name: Install Chrome for Angular tests
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
apt install -y ./google-chrome*.deb;
|
apt install -y ./google-chrome*.deb
|
||||||
export CHROME_BIN=/usr/bin/google-chrome
|
|
||||||
|
|
||||||
- name: Write cypress credentials
|
- name: Write cypress credentials
|
||||||
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
||||||
@@ -41,13 +45,13 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
- name: Check audit
|
- name: Check audit
|
||||||
# Audit should fail and stop the CI if critical vulnerability found
|
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
|
||||||
run: |
|
run: |
|
||||||
npm audit --audit-level=critical --omit=dev
|
npm audit --omit=dev
|
||||||
cd ./sas
|
cd ./sas
|
||||||
npm audit --audit-level=critical --omit=dev
|
npm audit --omit=dev
|
||||||
cd ../client
|
cd ../client
|
||||||
npm audit --audit-level=critical --omit=dev
|
npm audit --omit=dev
|
||||||
|
|
||||||
- name: Angular Tests
|
- name: Angular Tests
|
||||||
run: |
|
run: |
|
||||||
@@ -63,25 +67,32 @@ jobs:
|
|||||||
Build-and-test-development:
|
Build-and-test-development:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: Build-production-and-ng-test
|
needs: Build-production-and-ng-test
|
||||||
|
env:
|
||||||
|
CHROME_BIN: /usr/bin/google-chrome
|
||||||
|
# Pin OS locale
|
||||||
|
LANG: en_GB.UTF-8
|
||||||
|
LC_ALL: en_GB.UTF-8
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.14.0
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
touch client/.npmrc
|
touch client/.npmrc
|
||||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||||
|
|
||||||
- run: apt-get update
|
- name: Install system dependencies
|
||||||
- run: wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
run: |
|
||||||
- run: apt install -y ./google-chrome*.deb;
|
apt-get update
|
||||||
- run: export CHROME_BIN=/usr/bin/google-chrome
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
- run: apt-get update -y
|
apt install -y ./google-chrome*.deb
|
||||||
- run: apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb
|
apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb jq zip locales
|
||||||
- run: apt -y install jq
|
# Generate the en_GB.UTF-8 locale referenced by LANG/LC_ALL
|
||||||
|
locale-gen en_GB.UTF-8
|
||||||
|
update-locale LANG=en_GB.UTF-8 LC_ALL=en_GB.UTF-8
|
||||||
|
|
||||||
- name: Write cypress credentials
|
- name: Write cypress credentials
|
||||||
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
||||||
@@ -96,17 +107,18 @@ jobs:
|
|||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
# Install pm2 and prepare SASJS server
|
- name: Setup and start SASjs server
|
||||||
- run: npm i -g pm2
|
run: |
|
||||||
- run: curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
npm i -g pm2
|
||||||
- run: unzip linux.zip
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
- run: touch .env
|
unzip linux.zip
|
||||||
- run: echo RUN_TIMES=js >> .env
|
touch .env
|
||||||
- run: echo NODE_PATH=node >> .env
|
echo RUN_TIMES=js >> .env
|
||||||
- run: echo CORS=enable >> .env
|
echo NODE_PATH=node >> .env
|
||||||
- run: echo WHITELIST=http://localhost:4200 >> .env
|
echo CORS=enable >> .env
|
||||||
- run: cat .env
|
echo WHITELIST=http://localhost:4200 >> .env
|
||||||
- run: pm2 start api-linux --wait-ready
|
cat .env
|
||||||
|
pm2 start api-linux --wait-ready
|
||||||
|
|
||||||
- name: Deploy mocked services
|
- name: Deploy mocked services
|
||||||
run: |
|
run: |
|
||||||
@@ -116,11 +128,6 @@ jobs:
|
|||||||
sasjs cbd -t server-ci
|
sasjs cbd -t server-ci
|
||||||
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
|
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
|
||||||
|
|
||||||
- name: Install ZIP
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install zip
|
|
||||||
|
|
||||||
- name: Prepare and run frontend and cypress
|
- name: Prepare and run frontend and cypress
|
||||||
run: |
|
run: |
|
||||||
cd ./client
|
cd ./client
|
||||||
@@ -136,11 +143,12 @@ jobs:
|
|||||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||||
cat ./cypress.config.ts
|
cat ./cypress.config.ts
|
||||||
# Start frontend and run cypress
|
# Start frontend and run cypress
|
||||||
npm start & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts"
|
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
|
||||||
|
|
||||||
- name: Zip Cypress videos
|
- name: Zip Cypress videos
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p ./client/cypress/videos
|
||||||
zip -r cypress-videos ./client/cypress/videos
|
zip -r cypress-videos ./client/cypress/videos
|
||||||
|
|
||||||
- name: Add cypress videos artifacts
|
- name: Add cypress videos artifacts
|
||||||
@@ -153,12 +161,22 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [Build-production-and-ng-test, Build-and-test-development]
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.14.0
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
@@ -168,17 +186,11 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
|
|
||||||
- name: Install packages
|
- name: Install system packages
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install zip -y
|
apt-get install -y zip jq doxygen
|
||||||
# sasjs cli is used to compile & build the SAS services
|
|
||||||
npm i -g @sasjs/cli
|
npm i -g @sasjs/cli
|
||||||
# jq is used to parse the release JSON
|
|
||||||
apt-get install jq -y
|
|
||||||
# doxygen is used for the SASJS docs
|
|
||||||
apt-get update
|
|
||||||
apt-get install doxygen -y
|
|
||||||
|
|
||||||
- name: Frontend Preliminary Build
|
- name: Frontend Preliminary Build
|
||||||
description: We want to prevent creating empty release if frontend fails
|
description: We want to prevent creating empty release if frontend fails
|
||||||
@@ -191,12 +203,41 @@ jobs:
|
|||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Create Empty Release (assets are posted later)
|
- 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: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
npm i
|
npm i
|
||||||
npm i -g semantic-release
|
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
|
# We do a semantic-release DRY RUN to make the job fail if there are
|
||||||
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.datacontroller.io semantic-release
|
# 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
|
- name: Frontend Build
|
||||||
description: Must be created AFTER the release as the version (git tag) is used in the interface
|
description: Must be created AFTER the release as the version (git tag) is used in the interface
|
||||||
@@ -228,6 +269,8 @@ jobs:
|
|||||||
cp sasjs/utils/favicon.ico ../client/dist/favicon.ico
|
cp sasjs/utils/favicon.ico ../client/dist/favicon.ico
|
||||||
sasjs c -t server
|
sasjs c -t server
|
||||||
rm -rf sasjsbuild/tests
|
rm -rf sasjsbuild/tests
|
||||||
|
server_apploc="/Public/app/dc"
|
||||||
|
sed -i "s|apploc=\"[^\"]*\"|apploc=\"${server_apploc}\"|g" sasjsbuild/services/web/index.html
|
||||||
sasjs b -t server
|
sasjs b -t server
|
||||||
cp sasjsbuild/server.json.zip ./sasjs_server.json.zip
|
cp sasjsbuild/server.json.zip ./sasjs_server.json.zip
|
||||||
|
|
||||||
@@ -268,17 +311,40 @@ jobs:
|
|||||||
surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* /
|
surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* /
|
||||||
|
|
||||||
- name: Upload assets to release
|
- 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: |
|
run: |
|
||||||
RELEASE_ID=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.id'`
|
set -euo pipefail
|
||||||
RELEASE_BODY=`curl -k 'https://git.datacontroller.io/api/v1/repos/dc/dc/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.body'`
|
# Send the token via Authorization header rather than ?access_token=
|
||||||
# Update body
|
# (the query-string form is deprecated and leaks into access logs).
|
||||||
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 }}
|
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
|
# Upload assets
|
||||||
URL="https://git.datacontroller.io/api/v1/repos/dc/dc/releases/$RELEASE_ID/assets?access_token=${{ secrets.RELEASE_TOKEN }}"
|
URL="$BASE/releases/$RELEASE_ID/assets"
|
||||||
curl -k $URL -F attachment=@frontend.zip
|
for f in frontend.zip \
|
||||||
curl -k $URL -F attachment=@sas/demostream_sas9.sas
|
sas/demostream_sas9.sas \
|
||||||
curl -k $URL -F attachment=@sas/viya.sas
|
sas/viya.sas \
|
||||||
curl -k $URL -F attachment=@sas/sasjs_server.json.zip
|
sas/sasjs_server.json.zip \
|
||||||
curl -k $URL -F attachment=@sas/sas9.sas
|
sas/sas9.sas \
|
||||||
curl -k $URL -F attachment=@sas/viya_noweb.sas
|
sas/viya_noweb.sas \
|
||||||
curl -k $URL -F attachment=@sas/viya_noweb.json
|
sas/viya_noweb.json; do
|
||||||
|
echo "Uploading $f ..."
|
||||||
|
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
|
||||||
|
done
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ client/documentation
|
|||||||
client/**/sheet-crypto.tgz
|
client/**/sheet-crypto.tgz
|
||||||
client/.nx
|
client/.nx
|
||||||
client/libraries/sheet-crypto.tgz
|
client/libraries/sheet-crypto.tgz
|
||||||
|
client/lighthouse-reports
|
||||||
cypress.env.json
|
cypress.env.json
|
||||||
sasjsbuild
|
sasjsbuild
|
||||||
sasjsresults
|
sasjsresults
|
||||||
@@ -21,3 +22,4 @@ sasjsresults
|
|||||||
.sasjsrc
|
.sasjsrc
|
||||||
client/.npmrc
|
client/.npmrc
|
||||||
*~
|
*~
|
||||||
|
.lighthouseci
|
||||||
@@ -1 +1,4 @@
|
|||||||
legacy-peer-deps=true
|
legacy-peer-deps=true
|
||||||
|
ignore-scripts=true
|
||||||
|
save-exact=true
|
||||||
|
fund=false
|
||||||
Vendored
+1
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
|
"Handsontable",
|
||||||
"Licence",
|
"Licence",
|
||||||
"SYSERRORTEXT",
|
"SYSERRORTEXT",
|
||||||
"SYSWARNINGTEXT",
|
"SYSWARNINGTEXT",
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
Do NOT auto-commit. Never run `git commit` (or `git push`) unless the user explicitly asks. Leave changes in the working tree for the user to review and commit themselves.
|
||||||
|
|
||||||
|
## Markdown files
|
||||||
|
|
||||||
|
Do NOT hard-wrap Markdown files (no fixed-width line wrapping / carriage returns inside paragraphs). Each paragraph, list item, and heading should be a single logical line, regardless of length. Let the editor/viewer soft-wrap. This includes AGENTS.md itself.
|
||||||
|
|
||||||
|
This is different from `.sas` files, where a maximum line length applies. The no-wrap rule applies to all `*.md` files in this repo (docs, READMEs, etc.).
|
||||||
|
|
||||||
|
Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and reflowed, and Markdown renderers already handle wrapping.
|
||||||
|
|
||||||
|
## Linting (required before "done")
|
||||||
|
|
||||||
|
Never consider a change complete until the relevant linters pass on the files you touched — do not rely on the user's pre-commit hooks to catch it:
|
||||||
|
|
||||||
|
- **Client (TypeScript/HTML/etc.)**: run `npm run lint:check` from the `client/` directory (prettier). Fix any failures with `npm run lint:fix`.
|
||||||
|
- **SAS**: after creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored).
|
||||||
|
|
||||||
|
## No external assets
|
||||||
|
|
||||||
|
Data Controller must run entirely locally (offline / on-prem, no internet access). The built product must never fetch assets from remote servers — no external fonts, images, scripts, stylesheets, CDN links, or remote URLs in meta tags (e.g. `og:image`, `og:url`, `itemprop="image"`). All assets must be bundled and served locally.
|
||||||
|
|
||||||
|
## The .agent folder
|
||||||
|
|
||||||
|
Agent-related content lives in `.agent/`: technical/agent-facing documentation goes in `.agent/docs/` (not `docs/`), and skills in `.agent/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agent/docs/`.
|
||||||
|
|
||||||
|
## Code comments and test names
|
||||||
|
|
||||||
|
Never reference items that are not active parts of the repository — no "the original bug", "regression from this fix", "this session/PR/commit", or similar ephemeral context. Comments and test names must be self-contained: describe the behaviour being asserted, not the history of how it was discovered. The one exception is a literal link to a ticket/issue tracker.
|
||||||
+454
@@ -1,3 +1,457 @@
|
|||||||
|
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* adding REGEX validations to mpe_x_test ([bd798b4](https://git.datacontroller.io/dc/dc/commit/bd798b424a7c4b90d613425348d3f99eeb49b025))
|
||||||
|
* default value for label ([a3e46a9](https://git.datacontroller.io/dc/dc/commit/a3e46a968ef08295347b230235f1e79dfa51c5a2))
|
||||||
|
* **deps:** retarget Angular upgrade to 20, not 21 (CI install was broken) ([cac9244](https://git.datacontroller.io/dc/dc/commit/cac9244f92544f432078125c312d05c36ed4b855))
|
||||||
|
* ensure only one REGEX applies at a time ([8fb58eb](https://git.datacontroller.io/dc/dc/commit/8fb58eb36e05fdf7262459b05e53643b3ca672f7))
|
||||||
|
* ensure that no assets (including og links) ever fetch from external sources ([33dcb98](https://git.datacontroller.io/dc/dc/commit/33dcb989d3ae0c5ee0a13fe64195862a945c4348))
|
||||||
|
* include peer dependencies in package-lock for npm ci in pipeline ([b51c770](https://git.datacontroller.io/dc/dc/commit/b51c770782a8b62816e5145496dbea9fb83059e0))
|
||||||
|
* licensecheker ([f60bcef](https://git.datacontroller.io/dc/dc/commit/f60bcef58381ba269415640fa19a27305274939a))
|
||||||
|
* **lint:** remove redundant optional chaining ([d881290](https://git.datacontroller.io/dc/dc/commit/d881290618f25ddb6335125f1a08b5aaf56a8c48))
|
||||||
|
* optimisation, renamed values for DDTYPE to save space ([cfb60e5](https://git.datacontroller.io/dc/dc/commit/cfb60e5e4bfd37dc79fa4e9a6c6649ab1a61f2e4))
|
||||||
|
* patch npm audit vulnerabilities in sas and client dependencies ([e22edf7](https://git.datacontroller.io/dc/dc/commit/e22edf7ed3aa54fc08411cf90d51df9ec30877c3))
|
||||||
|
* **query:** isolate viewbox filter state from the base table's ([7a35cf4](https://git.datacontroller.io/dc/dc/commit/7a35cf4a458791b26ec8141c0c84d97fcb555145))
|
||||||
|
* regenerate client lockfile to resolve Angular peer-dependency drift breaking npm ci ([05fe474](https://git.datacontroller.io/dc/dc/commit/05fe4744d58e7985ecc5ef5f9c1969dbab2d2efb))
|
||||||
|
* **regex:** special missing handling ([180c247](https://git.datacontroller.io/dc/dc/commit/180c2477ed9ae44b4e479e81ec642f40580bcbc8))
|
||||||
|
* removing low severity warning in npm audit ([2a771bb](https://git.datacontroller.io/dc/dc/commit/2a771bb91acaaa16aba44e2d7ddb51b8b7123c10))
|
||||||
|
* removing thousand seperator from plain numerics in EDIT mode ([0392a81](https://git.datacontroller.io/dc/dc/commit/0392a81cbd9a634590ca7a60a316cd12c690a5a5))
|
||||||
|
* **validations:** parse SAS PRX /pattern/flags syntax in HARDREGEX/SOFTREGEX ([7ed3730](https://git.datacontroller.io/dc/dc/commit/7ed3730ae3145dffd639ce3d2f831447d78f4e85))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **docs:** adding agents.md and docs for RLS ([359d833](https://git.datacontroller.io/dc/dc/commit/359d833406ace79f96d03c08a5c72fbd3df29442))
|
||||||
|
* **editor:** add HARDREGEX/SOFTREGEX validation rules ([17e4802](https://git.datacontroller.io/dc/dc/commit/17e48028955d7b091e53bad9d7f9e3a089b53af9))
|
||||||
|
* **editor:** evaluate HARDREGEX/SOFTREGEX independently instead of hard-wins precedence ([57db117](https://git.datacontroller.io/dc/dc/commit/57db1179a97973ecf0f711bda6057383de546dd3))
|
||||||
|
* **editor:** show applied HARDREGEX/SOFTREGEX pattern in column info dropdown ([39c8855](https://git.datacontroller.io/dc/dc/commit/39c8855f37477f06d8b8cf5c97e04543070e8385))
|
||||||
|
* **regex:** backend validations on regex strings ([d2c93a4](https://git.datacontroller.io/dc/dc/commit/d2c93a46facb386fd31661c02c0d3d34f38f6e43))
|
||||||
|
* using ALL libraries as validation in MPE_SECURITY. Closes [#279](https://git.datacontroller.io/dc/dc/issues/279) ([62ff0ae](https://git.datacontroller.io/dc/dc/commit/62ff0aee4a976184de65d87ea3c8b8b7cb777938))
|
||||||
|
* validation checks to prevent incompatible RLS rules (eg REPLACE load type). Closes [#211](https://git.datacontroller.io/dc/dc/issues/211) ([ea00c5a](https://git.datacontroller.io/dc/dc/commit/ea00c5afad0daf2a66cdb206f2a7481b992192bf))
|
||||||
|
* validation on RLS for REPLACE, + docs + tests. Closes [#211](https://git.datacontroller.io/dc/dc/issues/211) ([7378f3b](https://git.datacontroller.io/dc/dc/commit/7378f3ba3014141008b4dbde0b89c21a6ce02f69))
|
||||||
|
|
||||||
|
# [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)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bumping ws package ([2382a55](https://git.datacontroller.io/dc/dc/commit/2382a559a5ac32b0f815776a90207650d5809ba6))
|
||||||
|
* enabling version restore for non admin users ([5d889d8](https://git.datacontroller.io/dc/dc/commit/5d889d824cc2f8e4ea089cbb578453125dc4ba6c))
|
||||||
|
|
||||||
|
## [7.8.1](https://git.datacontroller.io/dc/dc/compare/v7.8.0...v7.8.1) (2026-05-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **sasjs:** enable runAsTask ([f1a26e1](https://git.datacontroller.io/dc/dc/commit/f1a26e132eba7fa2ac64754940b52ea46c6619b3))
|
||||||
|
|
||||||
|
# [7.8.0](https://git.datacontroller.io/dc/dc/compare/v7.7.3...v7.8.0) (2026-05-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* enabling DSN=*ALL* in MPE_SECURITY ([7d94cb2](https://git.datacontroller.io/dc/dc/commit/7d94cb2ae4a3f6c1fa1011ae0fced7083a2f2793))
|
||||||
|
* providing default values for RULE_ACTIVE on MPE_VALIDATIONS ([f031b4e](https://git.datacontroller.io/dc/dc/commit/f031b4eb8925397e60dcc739a721cfbbb6da8dff))
|
||||||
|
* switch away from api usage for CASLIB metadata ([ce921a0](https://git.datacontroller.io/dc/dc/commit/ce921a032a8970b8078a463a41da884e1fa71bc3))
|
||||||
|
* use correct debug param for runAsTask ([bb80476](https://git.datacontroller.io/dc/dc/commit/bb8047676749814d3b86eea666726dbe4bf5f270))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add runAsTask config attribute parser ([1635bc9](https://git.datacontroller.io/dc/dc/commit/1635bc9c451bc221f386241007f594096f114b4f))
|
||||||
|
* enabling *ALL* option by default in MPE_SECURITY (DSN col) ([93d4ab6](https://git.datacontroller.io/dc/dc/commit/93d4ab65acce7b5b35e448146f9893964ad2cca3))
|
||||||
|
|
||||||
|
## [7.7.3](https://git.datacontroller.io/dc/dc/compare/v7.7.2...v7.7.3) (2026-05-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* move cas session assign to settings.sas and abort when lib is unassigned ([65f0b97](https://git.datacontroller.io/dc/dc/commit/65f0b979a401277b3e070d409659ae3fae2ff8c0))
|
||||||
|
|
||||||
|
## [7.7.2](https://git.datacontroller.io/dc/dc/compare/v7.7.1...v7.7.2) (2026-05-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **client:** bundle Metropolis font locally to satisfy CSP ([9546fcd](https://git.datacontroller.io/dc/dc/commit/9546fcd6312f3e81f746ef6e32ef398810ed434a))
|
||||||
|
* **client:** clear angular build cache on font strip to avoid stale dist ([503cb08](https://git.datacontroller.io/dc/dc/commit/503cb08b2fa40397434189f9c20eff3358eb7010))
|
||||||
|
* **client:** postinstall removal of Metropolis [@font-face](https://git.datacontroller.io/font-face) from @clr/ui ([e6397ce](https://git.datacontroller.io/dc/dc/commit/e6397cecc13afe2a9238bdfb2b4b9b81f38d055c))
|
||||||
|
* **client:** serve text-security-disc font locally ([80ce80e](https://git.datacontroller.io/dc/dc/commit/80ce80ece40012e59c7cd0340b4aa9a9aca46443))
|
||||||
|
* **editor:** preserve numeric type for SAS num cols with static SOFTSELECT/HARDSELECT ([05a3289](https://git.datacontroller.io/dc/dc/commit/05a328976ea3d1d6ef7559850369aa580f0d067f))
|
||||||
|
|
||||||
|
## [7.7.1](https://git.datacontroller.io/dc/dc/compare/v7.7.0...v7.7.1) (2026-05-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **client:** bump adapter ([d26f7d2](https://git.datacontroller.io/dc/dc/commit/d26f7d2511008634124c7d6fde115abb43db9c43))
|
||||||
|
* **sas:** bump cli ([d60029d](https://git.datacontroller.io/dc/dc/commit/d60029deae0ec21f3b8570461e2a4ca041d58f72))
|
||||||
|
|
||||||
|
# [7.7.0](https://git.datacontroller.io/dc/dc/compare/v7.6.0...v7.7.0) (2026-05-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bump adapter to 4.16.6 ([1707f38](https://git.datacontroller.io/dc/dc/commit/1707f3802a97de8c659f1a88c92fc917e8a30615))
|
||||||
|
* remove data:image/svg+xml CSP violation, use class instead changing style directly ([d66eb5d](https://git.datacontroller.io/dc/dc/commit/d66eb5dfc2dbb01f1e6c0c7d15fc2ad2a39dd829))
|
||||||
|
* remove WORK, SASUSER and CASUSER as library options. [#224](https://git.datacontroller.io/dc/dc/issues/224) ([ec66631](https://git.datacontroller.io/dc/dc/commit/ec66631a33aabb8ab2f92fe22c15440127085782))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* auto-save CAS tables [#224](https://git.datacontroller.io/dc/dc/issues/224) ([40d04a5](https://git.datacontroller.io/dc/dc/commit/40d04a53c4c00183116bdbd08397e0f2ffb1f578))
|
||||||
|
* autoload CAS tables. [#224](https://git.datacontroller.io/dc/dc/issues/224) ([d5ebb01](https://git.datacontroller.io/dc/dc/commit/d5ebb01ce381f5f4ec06de041f3ab9e632c02e43))
|
||||||
|
|
||||||
|
# [7.6.0](https://git.datacontroller.io/dc/dc/compare/v7.5.0...v7.6.0) (2026-04-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add label and tooltip for libref download, sanitise input ([52d5803](https://git.datacontroller.io/dc/dc/commit/52d58036a40e25847e900f9b04a77dbcc409c12b))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* configurable email alerts. Closes [#217](https://git.datacontroller.io/dc/dc/issues/217) ([2ccf0d1](https://git.datacontroller.io/dc/dc/commit/2ccf0d11000129629a0665421135b7530af9892f))
|
||||||
|
|
||||||
|
# [7.5.0](https://git.datacontroller.io/dc/dc/compare/v7.4.1...v7.5.0) (2026-04-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add workflow audits, update deps ([66e98a9](https://git.datacontroller.io/dc/dc/commit/66e98a96cbd092e762b94a04660f8e17ca003ceb))
|
||||||
|
* allow CSV uploads with licence row limit ([5b260e4](https://git.datacontroller.io/dc/dc/commit/5b260e49153dd85bc0023ad94d8a5f57b8ffa6dc)), closes [#213](https://git.datacontroller.io/dc/dc/issues/213)
|
||||||
|
* bumping cli and pinning versions in .npmrc ([80039f4](https://git.datacontroller.io/dc/dc/commit/80039f4876c8e09dc477678e1eff58329094c9e9))
|
||||||
|
* guard CSV upload with fileUpload licence flag ([ed40df6](https://git.datacontroller.io/dc/dc/commit/ed40df62953c3055770b5cbf50738f4a48b943cd))
|
||||||
|
* parse embed param from window.location.hash for hash router compatibility ([0269c24](https://git.datacontroller.io/dc/dc/commit/0269c2421db245f7f5405678605cb4d4587e2a67))
|
||||||
|
* quote CSV char values. Closes [#215](https://git.datacontroller.io/dc/dc/issues/215) ([d9980e8](https://git.datacontroller.io/dc/dc/commit/d9980e866d1a2fe7a731ff279d73accd35003e67))
|
||||||
|
* resolve outer promise in parseCsvFile for non-WLATIN1 path ([4ee15e1](https://git.datacontroller.io/dc/dc/commit/4ee15e1b6e83f27f279fc345e6998452a8f64d7e))
|
||||||
|
* use XLSX for CSV row truncation to handle new lines in values ([6d590c0](https://git.datacontroller.io/dc/dc/commit/6d590c050dcd593a73464fae5604f774f016b10d))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add embed URL parameter to hide header and back button ([b0dc441](https://git.datacontroller.io/dc/dc/commit/b0dc441d681369e06eee58288dbdbb236f930bdc)), closes [#214](https://git.datacontroller.io/dc/dc/issues/214)
|
||||||
|
* add target libref input to config download ([a89657b](https://git.datacontroller.io/dc/dc/commit/a89657b0b81b9c531f64c0dda2714b4eb16c4bc9)), closes [#212](https://git.datacontroller.io/dc/dc/issues/212)
|
||||||
|
* export config service to allow dclib swapping. Closes [#212](https://git.datacontroller.io/dc/dc/issues/212) ([326c26f](https://git.datacontroller.io/dc/dc/commit/326c26fddfa88a0dc4ca79d3bd0c77c4d807f37c))
|
||||||
|
|
||||||
|
## [7.4.1](https://git.datacontroller.io/dc/dc/compare/v7.4.0...v7.4.1) (2026-03-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* support for SASIOSNF engine (SNOW alias) plus meta assignment ([7694d1b](https://git.datacontroller.io/dc/dc/commit/7694d1b0fb2bd0407c8598147fbae87a00d889a8))
|
||||||
|
|
||||||
|
# [7.4.0](https://git.datacontroller.io/dc/dc/compare/v7.3.0...v7.4.0) (2026-02-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* cli bump for mf_getscheme support ([a84ba41](https://git.datacontroller.io/dc/dc/commit/a84ba41ea9f0c97ae24f0a572b8cf5ec200f2132))
|
||||||
|
* missing upcase on SNOW section, plus local sasjs target ([dc20064](https://git.datacontroller.io/dc/dc/commit/dc200646f7df2fd1910841f392c314532aae7581))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* SAS code changes for snowflake support ([e273e87](https://git.datacontroller.io/dc/dc/commit/e273e870efbf7875db869b760f2c7b1f39d571ae))
|
||||||
|
|
||||||
|
# [7.3.0](https://git.datacontroller.io/dc/dc/compare/v7.2.8...v7.3.0) (2026-02-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bump xlsx, add crypto-shim ([8dc18b1](https://git.datacontroller.io/dc/dc/commit/8dc18b155abfc20fd0b043e0d70bbbc17e6b6811))
|
||||||
|
* correctly applying deletes on viya, also ([46cdeb0](https://git.datacontroller.io/dc/dc/commit/46cdeb0babee6870553a41877cbe85204e7099c4))
|
||||||
|
* crypto module requirement for sheetjs/crypto package ([505d0af](https://git.datacontroller.io/dc/dc/commit/505d0af2b3b3c1c79c65045dcaffc263e0f8e796))
|
||||||
|
* disable parsing excel in web worker beacuse it breaks in the stream apps ([280bdee](https://git.datacontroller.io/dc/dc/commit/280bdeeb1b82f00689f46c68a3cde3f2d24bc18f))
|
||||||
|
* Display all contexts when installing DC on Viya ([d41f88f](https://git.datacontroller.io/dc/dc/commit/d41f88f8bf5bb2c725ee3edba085e8961ab8c727))
|
||||||
|
* **edit:** use cellValidation keys and hotDataSchema to fill in defaults on add row ([4957548](https://git.datacontroller.io/dc/dc/commit/495754816c0e757b8f8b1c0ad51246dc7b65d957))
|
||||||
|
* enabling closeouts for UPDATE in CAS tables ([8b8e8ae](https://git.datacontroller.io/dc/dc/commit/8b8e8aec159ff2f50cfa4683bcd7a25aabb75bf8))
|
||||||
|
* enabling rollback when the table has formatted values ([815d6e9](https://git.datacontroller.io/dc/dc/commit/815d6e97a8e304d79d48cc949ba126e02a318dc1))
|
||||||
|
* improvements to validations ([6ceb681](https://git.datacontroller.io/dc/dc/commit/6ceb6814633691b6d4ac2cb898cfb75e9d609102))
|
||||||
|
* remove IE checks and conditions ([ece6bd1](https://git.datacontroller.io/dc/dc/commit/ece6bd1d787d722531334fc4f1396a94cf6d92ec))
|
||||||
|
* updates to demodata to enable auto CAS promote ([7740d2a](https://git.datacontroller.io/dc/dc/commit/7740d2ac8694295b33b40a30603d8239818896f5))
|
||||||
|
* upgrade angular core and compiler ([aecd597](https://git.datacontroller.io/dc/dc/commit/aecd5976875a7c01189248c5f5aa3478b28c1ab2))
|
||||||
|
* using fcopy instead of binary copy for file upload, for Viya 2026 compatibility ([716ee6e](https://git.datacontroller.io/dc/dc/commit/716ee6eba0a28f4f0a7a96b0719caf15da9b6e78))
|
||||||
|
* **viewer:** search causing blank Handsontable ([338c7a2](https://git.datacontroller.io/dc/dc/commit/338c7a2e418c47e34331bd04718cd816f978837c)), closes [#206](https://git.datacontroller.io/dc/dc/issues/206)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* adding demo data job ([8c2aeac](https://git.datacontroller.io/dc/dc/commit/8c2aeacc85da5c106c356709cefcb412ed0a71db))
|
||||||
|
* **dq rules:** notnull validation when invalid cell, will auto populate a default value ([96f2518](https://git.datacontroller.io/dc/dc/commit/96f2518af9e547956be5862a1322d9ab8e07369b))
|
||||||
|
|
||||||
|
## [7.2.8](https://git.datacontroller.io/dc/dc/compare/v7.2.7...v7.2.8) (2026-02-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bump adapter version ([f4c8699](https://git.datacontroller.io/dc/dc/commit/f4c8699aaf0b1e01b447296978a4f6dedc8903f9))
|
||||||
|
|
||||||
|
## [7.2.7](https://git.datacontroller.io/dc/dc/compare/v7.2.6...v7.2.7) (2026-02-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* dclib not found error in getchangeinfo job ([86791db](https://git.datacontroller.io/dc/dc/commit/86791dbaca39034a19bf8f34efbddf898c57f2f7))
|
||||||
|
|
||||||
|
## [7.2.6](https://git.datacontroller.io/dc/dc/compare/v7.2.5...v7.2.6) (2026-01-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **deps:** update angular and moment ([8c5b357](https://git.datacontroller.io/dc/dc/commit/8c5b357dd286db331a6dcdeb3fd499fe3b634288))
|
||||||
|
|
||||||
|
## [7.2.5](https://git.datacontroller.io/dc/dc/compare/v7.2.4...v7.2.5) (2025-12-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* (build) rebuilt package-lock files ([bfbfd55](https://git.datacontroller.io/dc/dc/commit/bfbfd55fe7e2dff3ce707763a2c7939ff365318b))
|
||||||
|
* (deps) bump @sasjs/cli and @sasjs/core ([d7c7302](https://git.datacontroller.io/dc/dc/commit/d7c7302c12ac60f355ab9b3b1b461fcf7d0719b8))
|
||||||
|
* (deps) bumped @sasjs/core, @sasjs/cli, @sasjs/utils and @sasjs/adapter ([af1657e](https://git.datacontroller.io/dc/dc/commit/af1657e226a4efd22cc87401a3850c4a665c2680))
|
||||||
|
* configurable audit table on restore check ([26ce95f](https://git.datacontroller.io/dc/dc/commit/26ce95f7c1d2260f81c240cd6b058db154d997e4)), closes [#193](https://git.datacontroller.io/dc/dc/issues/193)
|
||||||
|
* improved testing ([fb3c49a](https://git.datacontroller.io/dc/dc/commit/fb3c49aa8bfdc6acf2ae3034b885010dcdce32a6))
|
||||||
|
* output values to intended macro variables ([43ae73c](https://git.datacontroller.io/dc/dc/commit/43ae73c5f3ad919394201f54984b61bb2a52fcfe))
|
||||||
|
|
||||||
|
## [7.2.4](https://git.datacontroller.io/dc/dc/compare/v7.2.3...v7.2.4) (2025-10-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* ensure reload after applying licence key ([cb1978b](https://git.datacontroller.io/dc/dc/commit/cb1978bcaf23b0bf45b5d3b78b9707fd4e48a5f4))
|
||||||
|
* snyk report security patches ([387f512](https://git.datacontroller.io/dc/dc/commit/387f5122f1ea6dff55d23c9223f17737283a94d3))
|
||||||
|
|
||||||
|
## [7.2.3](https://git.datacontroller.io/dc/dc/compare/v7.2.2...v7.2.3) (2025-10-02)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* opening second table in viewer throws an error ([6c6b1cb](https://git.datacontroller.io/dc/dc/commit/6c6b1cbf460e5291ec746af017e764b894fff8d5))
|
||||||
|
|
||||||
|
## [7.2.2](https://git.datacontroller.io/dc/dc/compare/v7.2.1...v7.2.2) (2025-09-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* jsrsasign, @sasjs/cli bump ([365f129](https://git.datacontroller.io/dc/dc/commit/365f12996db3ef50a4f4f099d5af15696c43bb42))
|
||||||
|
|
||||||
|
## [7.2.1](https://git.datacontroller.io/dc/dc/compare/v7.2.0...v7.2.1) (2025-08-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* removing localhost from index.html ([225e693](https://git.datacontroller.io/dc/dc/commit/225e693d1fd4381f2b8ce42fecb508f0a9e9dad8))
|
||||||
|
|
||||||
|
# [7.2.0](https://git.datacontroller.io/dc/dc/compare/v7.1.1...v7.2.0) (2025-08-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **ci:** cypress dependency package not available anymore ([26cdd73](https://git.datacontroller.io/dc/dc/commit/26cdd733315ef8babe9498ce93f6eb29c587dabd))
|
||||||
|
* **hot v16 migration:** multi dataset fixed issues, and cypress tests adapted ([712b384](https://git.datacontroller.io/dc/dc/commit/712b3848480a8769d149e00b0d2de91396022b66))
|
||||||
|
* obsolete cypress deps ([2ba4b53](https://git.datacontroller.io/dc/dc/commit/2ba4b5383e23bff8dfeb82b0ef473e5871c94709))
|
||||||
|
* remaining hot migrations - handsontable/angular-wrapper ([b419cd5](https://git.datacontroller.io/dc/dc/commit/b419cd507837e846e9dfcc6b729254d56cc196e6))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* lighthouse accessibility check pipeline ([670ec2c](https://git.datacontroller.io/dc/dc/commit/670ec2c71cb2d24e9d79e297a8cbc6136aa315c8))
|
||||||
|
|
||||||
|
## [7.1.1](https://git.datacontroller.io/dc/dc/compare/v7.1.0...v7.1.1) (2025-07-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **viewboxes:** hot v16 fails to load because of relative height `100%` ([672dd6d](https://git.datacontroller.io/dc/dc/commit/672dd6d4f1fda27e3706dd7caa42b45922319497))
|
||||||
|
|
||||||
|
# [7.1.0](https://git.datacontroller.io/dc/dc/compare/v7.0.3...v7.1.0) (2025-07-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* adapter bump ([b495c41](https://git.datacontroller.io/dc/dc/commit/b495c41626c85b7c4141d9361e4d3a826efd6c05))
|
||||||
|
* bumping CLI to 4.12.10 ([a08a717](https://git.datacontroller.io/dc/dc/commit/a08a717ca8d49e8a7d63f3fd91c6a7d42a1d6d8b))
|
||||||
|
* bumping sasjs/core and sasjs/cli ([63e9af4](https://git.datacontroller.io/dc/dc/commit/63e9af402ed65f6be4426e76ee1376a40e6ed097))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* improving accessibility score up to 100, hot update to v16.0.1 ([71c308d](https://git.datacontroller.io/dc/dc/commit/71c308d052400ecedc03f8020a5a69471ac6b116))
|
||||||
|
|
||||||
|
## [7.0.3](https://git.datacontroller.io/dc/dc/compare/v7.0.2...v7.0.3) (2025-06-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* makedata vars ([e7cb471](https://git.datacontroller.io/dc/dc/commit/e7cb471c0b60058b03fe8cbed5e3e2e70dd72e26))
|
||||||
|
* viya deploy makedata missing params ([7a82316](https://git.datacontroller.io/dc/dc/commit/7a8231615cb56710351fae5868e8fdeed54d180c))
|
||||||
|
|
||||||
|
## [7.0.2](https://git.datacontroller.io/dc/dc/compare/v7.0.1...v7.0.2) (2025-06-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **viya deploy:** run makedata in new window to ensure logs are available for the user ([0b4042a](https://git.datacontroller.io/dc/dc/commit/0b4042af6011fdc65cfaaa5d4b1d8f48cd67f3b3))
|
||||||
|
|
||||||
|
## [7.0.1](https://git.datacontroller.io/dc/dc/compare/v7.0.0...v7.0.1) (2025-06-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* refresh process ([4ecd186](https://git.datacontroller.io/dc/dc/commit/4ecd186e5cb22dd436f2d7f1200956f4e3f27425))
|
||||||
|
|
||||||
|
# [7.0.0](https://git.datacontroller.io/dc/dc/compare/v6.16.2...v7.0.0) (2025-06-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bumping adapter to re-enable JES API method ([e874143](https://git.datacontroller.io/dc/dc/commit/e874143a95d0ac2e56c0793e04b979c27f96d74b))
|
||||||
|
* commit git hooks checking lint ([69f687a](https://git.datacontroller.io/dc/dc/commit/69f687a85f1cc562346b6167813d617cb9bd3404))
|
||||||
|
* ensuring apploc is not case sensitive. Closes [#171](https://git.datacontroller.io/dc/dc/issues/171) ([24545f2](https://git.datacontroller.io/dc/dc/commit/24545f2acdd5bd73cbe062526f2bd043269cc6a3))
|
||||||
|
* export unregistered formats ([f6d7d6f](https://git.datacontroller.io/dc/dc/commit/f6d7d6f90c978ac8c071471dfb67a60834424de5)), closes [#158](https://git.datacontroller.io/dc/dc/issues/158)
|
||||||
|
* reload startupservice after user approves the MPE_TABLES page ([e5f8e50](https://git.datacontroller.io/dc/dc/commit/e5f8e500c125ee233c6f7af5ad0077c0ed6abfcb))
|
||||||
|
* showing catalog_cnt in libinfo ([e44a25d](https://git.datacontroller.io/dc/dc/commit/e44a25dcc39ba4b9714257c60da84c2dfa613a85)), closes [#160](https://git.datacontroller.io/dc/dc/issues/160)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* adding 4 new tables for catalogs ([e4dbab8](https://git.datacontroller.io/dc/dc/commit/e4dbab8b1654b24e610e4b0603d1cf2b02a451e2))
|
||||||
|
* capturing catalog specific information, closes [#159](https://git.datacontroller.io/dc/dc/issues/159) ([b4c586a](https://git.datacontroller.io/dc/dc/commit/b4c586a859929e0122cd46449e43d4ca597b8b2b))
|
||||||
|
* viewer added catalog_cnt ([2aa19d1](https://git.datacontroller.io/dc/dc/commit/2aa19d1dca747f41274a032cde78d8ba73d66224))
|
||||||
|
|
||||||
|
|
||||||
|
### BREAKING CHANGES
|
||||||
|
|
||||||
|
* Introduction of 4 new tables for capturing information related to catalogs and their objects. Migration script prepared and available in the DB folder (usual place)
|
||||||
|
|
||||||
|
## [6.16.2](https://git.datacontroller.io/dc/dc/compare/v6.16.1...v6.16.2) (2025-06-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* streaming viya deploy `isStreaming` function stability fix ([4830c6d](https://git.datacontroller.io/dc/dc/commit/4830c6d2191cb47abcc7919bc1d49e55595e6121))
|
||||||
|
|
||||||
|
## [6.16.1](https://git.datacontroller.io/dc/dc/compare/v6.16.0...v6.16.1) (2025-06-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* viya deploy updating index html based on URL ([86134f4](https://git.datacontroller.io/dc/dc/commit/86134f478ae0b9426e01bfcc9ca4ee597ca733f7))
|
||||||
|
* viya streamed app deploy page flow fix ([89ab296](https://git.datacontroller.io/dc/dc/commit/89ab2961513b245eeea48d1867c6496d3261761e))
|
||||||
|
|
||||||
# [6.16.0](https://git.datacontroller.io/dc/dc/compare/v6.15.2...v6.16.0) (2025-06-05)
|
# [6.16.0](https://git.datacontroller.io/dc/dc/compare/v6.15.2...v6.16.0) (2025-06-05)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ Add `client/.npmrc` file with following content:
|
|||||||
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
|
//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)
|
[Handsontable](https://www.npmjs.com/package/handsontable)
|
||||||
|
|
||||||
Licence should be inserted in the `client/src/index.html` file:
|
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
|
# 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
|
## 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.
|
The dcpath folder should have its permissions set so that the system account (SYSUSERID) can both read and write to it.
|
||||||
|
|||||||
@@ -23,10 +23,42 @@ _Problems with the above include:_
|
|||||||
|
|
||||||
Data Controller for SAS® solves all these issues in a simple-to-install, user-friendly, secure, documented, battle-tested web application. Available on Viya, SAS 9 EBI, and [SASjs Server](https://server.sasjs.io).
|
Data Controller for SAS® solves all these issues in a simple-to-install, user-friendly, secure, documented, battle-tested web application. Available on Viya, SAS 9 EBI, and [SASjs Server](https://server.sasjs.io).
|
||||||
|
|
||||||
For more information:
|
An individual Viya deploy can be done in just 2 lines of #SAS code!
|
||||||
|
|
||||||
|
```sas
|
||||||
|
filename dc url "https://git.datacontroller.io/dc/dc/releases/download/latest/viya.sas";
|
||||||
|
%inc dc;
|
||||||
|
```
|
||||||
|
|
||||||
|
For a multi-user deploy, using a shared system account, please see [deploy docs](https://docs.datacontroller.io/deploy-viya/).
|
||||||
|
|
||||||
|
For further information:
|
||||||
|
|
||||||
* Main site: https://datacontroller.io
|
* Main site: https://datacontroller.io
|
||||||
* Docs: https://docs.datacontroller.io
|
* Docs: https://docs.datacontroller.io
|
||||||
* Code: https://code.datacontroller.io
|
* Code: https://code.datacontroller.io
|
||||||
|
|
||||||
For support, contact support@4gl.io or reach out on [Matrix](https://matrix.to/#/#dc:4gl.io)!
|
For support, contact support@4gl.io or reach out on [Matrix](https://matrix.to/#/#dc:4gl.io)!
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Lighthouse CI
|
||||||
|
|
||||||
|
This project includes automated Lighthouse performance and accessibility checks that run on pull requests. The checks ensure:
|
||||||
|
|
||||||
|
- **Accessibility Score**: Minimum 1.0 (100%) median score across all tested pages
|
||||||
|
|
||||||
|
The Lighthouse CI workflow:
|
||||||
|
1. Sets up the development environment with SASjs server and mocked services
|
||||||
|
2. Builds and serves the Angular frontend
|
||||||
|
3. Installs Chrome and runs `lhci autorun` (Lighthouse CI) against key pages
|
||||||
|
4. Uploads results as artifacts for review
|
||||||
|
|
||||||
|
To run Lighthouse checks locally:
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
npm run lighthouse
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration is in `client/lighthouserc.js` (URL list, `desktop` preset, Chrome flags, assertions).
|
||||||
|
|||||||
@@ -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
|
||||||
+49
-30
@@ -25,6 +25,7 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"handsontable",
|
"handsontable",
|
||||||
|
"exceljs",
|
||||||
"core-js",
|
"core-js",
|
||||||
"pikaday",
|
"pikaday",
|
||||||
"querystring",
|
"querystring",
|
||||||
@@ -41,6 +42,8 @@
|
|||||||
"zone.js",
|
"zone.js",
|
||||||
"text-encoding",
|
"text-encoding",
|
||||||
"crypto-js/md5",
|
"crypto-js/md5",
|
||||||
|
"crypto-js/sha1",
|
||||||
|
"crypto-js/sha512",
|
||||||
"buffer",
|
"buffer",
|
||||||
"numbro",
|
"numbro",
|
||||||
"@clr/icons",
|
"@clr/icons",
|
||||||
@@ -51,26 +54,30 @@
|
|||||||
"base64-arraybuffer",
|
"base64-arraybuffer",
|
||||||
"@handsontable/formulajs"
|
"@handsontable/formulajs"
|
||||||
],
|
],
|
||||||
"polyfills": [
|
"polyfills": ["src/polyfills.ts", "zone.js"],
|
||||||
"src/polyfills.ts",
|
|
||||||
"zone.js"
|
|
||||||
],
|
|
||||||
"outputPath": "dist",
|
"outputPath": "dist",
|
||||||
"resourcesOutputPath": "images",
|
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"main": "src/main.ts",
|
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/images"
|
{
|
||||||
],
|
"glob": "**/*",
|
||||||
"styles": [
|
"input": "src/images",
|
||||||
"src/styles.scss"
|
"output": "images",
|
||||||
|
"ignore": ["spinner.svg", "caret.svg"]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
|
"styles": ["src/styles.scss"],
|
||||||
"scripts": [
|
"scripts": [
|
||||||
"node_modules/marked/marked.min.js"
|
"node_modules/marked/marked.min.js",
|
||||||
|
{
|
||||||
|
"input": "src/assets/va-early.js",
|
||||||
|
"bundleName": "va-early",
|
||||||
|
"inject": true
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"webWorkerTsConfig": "tsconfig.worker.json"
|
"webWorkerTsConfig": "tsconfig.worker.json",
|
||||||
|
"main": "src/main.ts"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -103,9 +110,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"vendorChunk": true,
|
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"buildOptimizer": false,
|
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"namedChunks": true
|
"namedChunks": true
|
||||||
@@ -134,20 +139,11 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular-devkit/build-angular:karma",
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
"options": {
|
"options": {
|
||||||
"polyfills": [
|
"polyfills": ["src/polyfills.ts", "zone.js", "zone.js/testing"],
|
||||||
"src/polyfills.ts",
|
|
||||||
"zone.js",
|
|
||||||
"zone.js/testing"
|
|
||||||
],
|
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": ["src/favicon.ico", "src/assets"],
|
||||||
"src/favicon.ico",
|
"styles": ["src/styles.scss"],
|
||||||
"src/assets"
|
|
||||||
],
|
|
||||||
"styles": [
|
|
||||||
"src/styles.scss"
|
|
||||||
],
|
|
||||||
"scripts": [],
|
"scripts": [],
|
||||||
"karmaConfig": "karma.conf.js",
|
"karmaConfig": "karma.conf.js",
|
||||||
"webWorkerTsConfig": "tsconfig.worker.json"
|
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||||
@@ -156,13 +152,36 @@
|
|||||||
"lint": {
|
"lint": {
|
||||||
"builder": "@angular-eslint/builder:lint",
|
"builder": "@angular-eslint/builder:lint",
|
||||||
"options": {
|
"options": {
|
||||||
"lintFilePatterns": [
|
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.html"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"type": "component"
|
||||||
|
},
|
||||||
|
"@schematics/angular:directive": {
|
||||||
|
"type": "directive"
|
||||||
|
},
|
||||||
|
"@schematics/angular:service": {
|
||||||
|
"type": "service"
|
||||||
|
},
|
||||||
|
"@schematics/angular:guard": {
|
||||||
|
"typeSeparator": "."
|
||||||
|
},
|
||||||
|
"@schematics/angular:interceptor": {
|
||||||
|
"typeSeparator": "."
|
||||||
|
},
|
||||||
|
"@schematics/angular:module": {
|
||||||
|
"typeSeparator": "."
|
||||||
|
},
|
||||||
|
"@schematics/angular:pipe": {
|
||||||
|
"typeSeparator": "."
|
||||||
|
},
|
||||||
|
"@schematics/angular:resolver": {
|
||||||
|
"typeSeparator": "."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-18
@@ -1,13 +1,13 @@
|
|||||||
import { defineConfig } from "cypress";
|
import { defineConfig } from 'cypress'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
reporter: "mochawesome",
|
reporter: 'mochawesome',
|
||||||
|
|
||||||
reporterOptions: {
|
reporterOptions: {
|
||||||
reportDir: "cypress/results",
|
reportDir: 'cypress/results',
|
||||||
overwrite: false,
|
overwrite: false,
|
||||||
html: true,
|
html: true,
|
||||||
json: false,
|
json: false
|
||||||
},
|
},
|
||||||
viewportHeight: 900,
|
viewportHeight: 900,
|
||||||
viewportWidth: 1600,
|
viewportWidth: 1600,
|
||||||
@@ -16,24 +16,32 @@ export default defineConfig({
|
|||||||
defaultCommandTimeout: 30000,
|
defaultCommandTimeout: 30000,
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
hosturl: "http://localhost:4200",
|
hosturl: 'http://localhost:4200',
|
||||||
appLocation: "",
|
appLocation: '',
|
||||||
site_id_SAS9: "70221618",
|
site_id_SAS9: '70221618',
|
||||||
site_id_SASVIYA: "70253615",
|
site_id_SASVIYA: '70253615',
|
||||||
site_id_SASJS: "123",
|
site_id_SASJS: '123',
|
||||||
serverType: "SASJS",
|
serverType: 'SASJS',
|
||||||
libraryToOpenIncludes_SASVIYA: "viya",
|
libraryToOpenIncludes_SASVIYA: 'viya',
|
||||||
libraryToOpenIncludes_SAS9: "dc",
|
libraryToOpenIncludes_SAS9: 'dc',
|
||||||
libraryToOpenIncludes_SASJS: "dc",
|
libraryToOpenIncludes_SASJS: 'dc',
|
||||||
debug: false,
|
debug: false,
|
||||||
screenshotOnRunFailure: false,
|
screenshotOnRunFailure: false,
|
||||||
longerCommandTimeout: 50000,
|
longerCommandTimeout: 50000,
|
||||||
testLicenceUserLimits: false,
|
testLicenceUserLimits: false
|
||||||
},
|
},
|
||||||
|
|
||||||
e2e: {
|
e2e: {
|
||||||
|
video: true,
|
||||||
setupNodeEvents(on, config) {
|
setupNodeEvents(on, config) {
|
||||||
// implement node event listeners here
|
// Pin the browser locale so locale-formatted cells (intl-date/time/datetime)
|
||||||
},
|
// render deterministically regardless of the runner's system locale.
|
||||||
},
|
on('before:browser:launch', (browser, launchOptions) => {
|
||||||
});
|
if (browser.family === 'chromium' && browser.name !== 'electron') {
|
||||||
|
launchOptions.args.push('--lang=en-GB')
|
||||||
|
}
|
||||||
|
return launchOptions
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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}`)
|
||||||
|
const fixturePath = 'csvs/'
|
||||||
|
|
||||||
|
context('csv file upload restriction (free tier): ', function () {
|
||||||
|
this.beforeEach(() => {
|
||||||
|
cy.visit(hostUrl + appLocation)
|
||||||
|
|
||||||
|
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)
|
||||||
|
cy.get('.login-group button').click()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
||||||
|
'not.exist'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skip licensing page if presented - continue with free tier
|
||||||
|
cy.url().then((url) => {
|
||||||
|
if (url.includes('licensing')) {
|
||||||
|
cy.get('button').contains('Continue with free tier').click()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
visitPage('home')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1 | File upload is restricted on free tier', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
// Click upload button - should show feature locked modal
|
||||||
|
cy.get('.buttonBar button:last-child').should('exist').click()
|
||||||
|
|
||||||
|
cy.get('.modal-title').should('contain', 'Locked Feature (File Upload)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 targetLib
|
||||||
|
|
||||||
|
for (let node of treeNodes) {
|
||||||
|
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
|
||||||
|
targetLib = node
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.get(targetLib).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 attachFile = (filename: string, callback?: any) => {
|
||||||
|
cy.get('.buttonBar button:last-child')
|
||||||
|
.should('exist')
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
cy.get('input[type="file"]#file-upload')
|
||||||
|
.attachFile(`/${fixturePath}/${filename}`)
|
||||||
|
.then(() => {
|
||||||
|
if (callback) callback()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const visitPage = (url: string) => {
|
||||||
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||||
|
}
|
||||||
@@ -32,27 +32,26 @@ context('excel tests: ', function () {
|
|||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
attachExcelFile('regular.csv', () => {
|
attachExcelFile('regular.csv', () => {
|
||||||
cy.get('#approval-btn', { timeout: 60000 })
|
cy.get('#approval-btn', { timeout: 60000 }).should('be.visible')
|
||||||
.should('be.visible')
|
// .then(() => {
|
||||||
// .then(() => {
|
// cy.get('#hotInstance', { timeout: 30000 })
|
||||||
// cy.get('#hotInstance', { timeout: 30000 })
|
// .find('div.ht_master.handsontable')
|
||||||
// .find('div.ht_master.handsontable')
|
// .find('div.wtHolder')
|
||||||
// .find('div.wtHolder')
|
// .find('div.wtHider')
|
||||||
// .find('div.wtHider')
|
// .find('div.wtSpreader')
|
||||||
// .find('div.wtSpreader')
|
// .find('table.htCore')
|
||||||
// .find('table.htCore')
|
// .find('tbody')
|
||||||
// .find('tbody')
|
// .then((data) => {
|
||||||
// .then((data) => {
|
// let cell: any = data[0].children[0].children[1]
|
||||||
// let cell: any = data[0].children[0].children[1]
|
// expect(cell.innerText).to.equal('0')
|
||||||
// expect(cell.innerText).to.equal('0')
|
// cell = data[0].children[0].children[2]
|
||||||
// cell = data[0].children[0].children[2]
|
// expect(cell.innerText).to.equal('44')
|
||||||
// expect(cell.innerText).to.equal('44')
|
// cell = data[0].children[0].children[3]
|
||||||
// cell = data[0].children[0].children[3]
|
// expect(cell.innerText).to.equal('abc')
|
||||||
// expect(cell.innerText).to.equal('abc')
|
// cell = data[0].children[0].children[6]
|
||||||
// cell = data[0].children[0].children[6]
|
// expect(cell.innerText).to.equal('Option abc')
|
||||||
// expect(cell.innerText).to.equal('Option abc')
|
// })
|
||||||
// })
|
// })
|
||||||
// })
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -115,8 +115,791 @@ 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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// REGEX_HARD_COL/REGEX_SOFT_COL only exist un-stripped on MPE_X_NEW (see
|
||||||
|
// RULE_DEMO_COLS in the getdata.js mock) — MPE_X_TEST stays clean to
|
||||||
|
// match the excel fixtures.
|
||||||
|
it('7 | Blocks submission of a value that fails HARDREGEX', (done) => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
scrollGridRight()
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(1, 'REGEX_HARD_COL')
|
||||||
|
.dblclick({ force: true })
|
||||||
|
.then(() => {
|
||||||
|
cy.focused()
|
||||||
|
.clear()
|
||||||
|
.type('not-an-email{enter}')
|
||||||
|
.then(() => {
|
||||||
|
getCellByHeaderAndRow(1, 'REGEX_HARD_COL').should(
|
||||||
|
'have.attr',
|
||||||
|
'title',
|
||||||
|
'REGEX: /[\\w.]+@[\\w]+\\.[a-z]{2,}/'
|
||||||
|
)
|
||||||
|
|
||||||
|
submitTable(() => {
|
||||||
|
cy.get('.modal-body').then((modalBody: any) => {
|
||||||
|
if (
|
||||||
|
modalBody[0].innerHTML
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(`invalid values are present`)
|
||||||
|
) {
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('8 | Warns (but still submits) a value that fails SOFTREGEX', (done) => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
scrollGridRight()
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
|
||||||
|
.dblclick({ force: true })
|
||||||
|
.then(() => {
|
||||||
|
cy.focused()
|
||||||
|
.clear()
|
||||||
|
.type('not a postcode{enter}')
|
||||||
|
.then(() => {
|
||||||
|
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
|
||||||
|
.should('have.class', 'dc-warning-cell')
|
||||||
|
.and(
|
||||||
|
'have.attr',
|
||||||
|
'title',
|
||||||
|
'REGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
|
||||||
|
)
|
||||||
|
|
||||||
|
submitTable(() => {
|
||||||
|
// Validation passed despite the SOFTREGEX warning: 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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// MPE_X_FORMULA_TEST is a small, dedicated fixture for formula testing
|
||||||
|
// (kept separate from MPE_X_NEW, which carries no formula columns at
|
||||||
|
// all) - row index 1 (0-indexed, i=1 in the mock's row generator) has
|
||||||
|
// A_COL=2, B_COL=10: HARDFORMULA (A_COL * B_COL) computes to 20,
|
||||||
|
// SOFTFORMULA (A_COL + B_COL) computes to 12.
|
||||||
|
it('9 | HARDFORMULA shows the computed value and blocks direct edits', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '20')
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL')
|
||||||
|
.dblclick({ force: true })
|
||||||
|
.then(() => {
|
||||||
|
// readOnly cells don't open an editor - nothing is focused to
|
||||||
|
// type into, so this is a no-op if the column is truly readonly.
|
||||||
|
cy.focused().type('9999{enter}')
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should(
|
||||||
|
'have.text',
|
||||||
|
'20'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('10 | SOFTFORMULA shows the computed default but accepts an override', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '12')
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL')
|
||||||
|
.dblclick({ force: true })
|
||||||
|
.then(() => {
|
||||||
|
cy.focused().clear().type('override{enter}')
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should(
|
||||||
|
'have.text',
|
||||||
|
'override'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('11 | Info dropdown shows the applied formula as √x=<formula>', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
openColumnDropdown('FORMULA_HARD_COL')
|
||||||
|
cy.get('.htDropdownMenu').should(($menu) => {
|
||||||
|
expect($menu.text()).to.include('√x=A_COL * B_COL')
|
||||||
|
})
|
||||||
|
cy.get('body').click(0, 0) // close menu
|
||||||
|
|
||||||
|
openColumnDropdown('FORMULA_SOFT_COL')
|
||||||
|
cy.get('.htDropdownMenu').should(($menu) => {
|
||||||
|
expect($menu.text()).to.include('√x=A_COL + B_COL')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('12 | Info dropdown shows the applied HARDREGEX/SOFTREGEX pattern', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
scrollGridRight()
|
||||||
|
|
||||||
|
openColumnDropdown('REGEX_HARD_COL')
|
||||||
|
cy.get('.htDropdownMenu').should(($menu) => {
|
||||||
|
expect($menu.text()).to.include(
|
||||||
|
'HARDREGEX: /[\\w.]+@[\\w]+\\.[a-z]{2,}/'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
cy.get('body').click(0, 0) // close menu
|
||||||
|
|
||||||
|
openColumnDropdown('REGEX_SOFT_COL')
|
||||||
|
cy.get('.htDropdownMenu').should(($menu) => {
|
||||||
|
expect($menu.text()).to.include(
|
||||||
|
'SOFTREGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('13 | Info dropdown shows only the applied HARDREGEX pattern when a column has both', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
scrollGridRight()
|
||||||
|
|
||||||
|
openColumnDropdown('REGEX_BOTH_COL')
|
||||||
|
cy.get('.htDropdownMenu').should(($menu) => {
|
||||||
|
const text = $menu.text()
|
||||||
|
|
||||||
|
expect(text).to.include('HARDREGEX: /^[A-Z0-9_-]+$/')
|
||||||
|
expect(text).to.not.include('SOFTREGEX: /^.{5,10}$/')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('14 | REGEX_BOTH_COL: a HARDREGEX failure blocks submission and sets its own tooltip, not yellow', (done) => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
scrollGridRight()
|
||||||
|
|
||||||
|
// 'bad value' fails HARDREGEX (lowercase + space) but is 9 chars,
|
||||||
|
// within SOFTREGEX's 5-10 range - isolates the hard-only failure.
|
||||||
|
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
|
||||||
|
.dblclick({ force: true })
|
||||||
|
.then(() => {
|
||||||
|
cy.focused()
|
||||||
|
.clear()
|
||||||
|
.type('bad value{enter}')
|
||||||
|
.then(() => {
|
||||||
|
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
|
||||||
|
.should('not.have.class', 'dc-warning-cell')
|
||||||
|
.and('have.attr', 'title', 'REGEX: /^[A-Z0-9_-]+$/')
|
||||||
|
|
||||||
|
submitTable(() => {
|
||||||
|
cy.get('.modal-body').then((modalBody: any) => {
|
||||||
|
if (
|
||||||
|
modalBody[0].innerHTML
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(`invalid values are present`)
|
||||||
|
) {
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('15 | REGEX_BOTH_COL: SOFTREGEX is ignored entirely when HARDREGEX is present', (done) => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
scrollGridRight()
|
||||||
|
|
||||||
|
// 'AB' passes HARDREGEX (uppercase only) but fails SOFTREGEX (too
|
||||||
|
// short) - only one regex runs per column, so no warning is shown.
|
||||||
|
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
|
||||||
|
.dblclick({ force: true })
|
||||||
|
.then(() => {
|
||||||
|
cy.focused()
|
||||||
|
.clear()
|
||||||
|
.type('AB{enter}')
|
||||||
|
.then(() => {
|
||||||
|
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
|
||||||
|
.should('not.have.class', 'dc-warning-cell')
|
||||||
|
.and('not.have.attr', 'title')
|
||||||
|
|
||||||
|
submitTable(() => {
|
||||||
|
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
||||||
|
.should('exist')
|
||||||
|
.should('not.be.disabled')
|
||||||
|
.then(() => done())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("16 | Insert Row above keeps existing rows' formulas aligned with their own shifted data", () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
// Row index 2 (0-indexed): PRIMARY_KEY_FIELD=3, A_COL=3, B_COL=10 ->
|
||||||
|
// HARDFORMULA (A_COL*B_COL) = 30, SOFTFORMULA (A_COL+B_COL) = 13.
|
||||||
|
getCellByHeaderAndRow(2, 'PRIMARY_KEY_FIELD').should('have.text', '3')
|
||||||
|
|
||||||
|
insertRowViaContextMenu(2, 'Insert Row above')
|
||||||
|
|
||||||
|
// The new blank row lands at index 2 - its formula columns are
|
||||||
|
// seeded with their own row-relative formula (A_COL/B_COL are both
|
||||||
|
// still empty, so HyperFormula evaluates the arithmetic as 0).
|
||||||
|
getCellByHeaderAndRow(2, 'FORMULA_HARD_COL').should('have.text', '0')
|
||||||
|
getCellByHeaderAndRow(2, 'FORMULA_SOFT_COL').should('have.text', '0')
|
||||||
|
|
||||||
|
// The row that WAS at index 2 (PK=3) is now shifted down to index
|
||||||
|
// 3 - its formulas must recompute from ITS OWN data, not read
|
||||||
|
// whatever the engine had cached at index 3 before the insert
|
||||||
|
// (that would be PK=4's values: 40/14).
|
||||||
|
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').should('have.text', '3')
|
||||||
|
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').should('have.text', '30')
|
||||||
|
getCellByHeaderAndRow(3, 'FORMULA_SOFT_COL').should('have.text', '13')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("17 | Insert Row below keeps existing rows' formulas aligned with their own shifted data", () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
// Row index 3 (0-indexed): PRIMARY_KEY_FIELD=4, A_COL=4, B_COL=10 ->
|
||||||
|
// HARDFORMULA = 40, SOFTFORMULA = 14.
|
||||||
|
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').should('have.text', '4')
|
||||||
|
|
||||||
|
// 'Insert Row below' on row index 2 (PK=3) inserts the new blank
|
||||||
|
// row at index 3, pushing the old index-3 row (PK=4) to index 4.
|
||||||
|
insertRowViaContextMenu(2, 'Insert Row below')
|
||||||
|
|
||||||
|
// The new blank row lands at index 3 - seeded the same way as
|
||||||
|
// above, evaluating to 0 while A_COL/B_COL are still empty.
|
||||||
|
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').should('have.text', '0')
|
||||||
|
getCellByHeaderAndRow(3, 'FORMULA_SOFT_COL').should('have.text', '0')
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').should('have.text', '4')
|
||||||
|
getCellByHeaderAndRow(4, 'FORMULA_HARD_COL').should('have.text', '40')
|
||||||
|
getCellByHeaderAndRow(4, 'FORMULA_SOFT_COL').should('have.text', '14')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('18 | Insert Row seeds formulas that live-recalculate once A_COL/B_COL are filled in', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
insertRowViaContextMenu(0, 'Insert Row below')
|
||||||
|
|
||||||
|
// New row at index 1 - both formula columns evaluate the seeded
|
||||||
|
// formula (=C2*D2 / =C2+D2 in spreadsheet notation) against
|
||||||
|
// still-empty A_COL/B_COL, so HyperFormula treats them as 0.
|
||||||
|
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '0')
|
||||||
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '0')
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(1, 'A_COL')
|
||||||
|
.dblclick({ force: true })
|
||||||
|
.then(() => {
|
||||||
|
cy.focused().type('5{enter}')
|
||||||
|
})
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(1, 'B_COL')
|
||||||
|
.dblclick({ force: true })
|
||||||
|
.then(() => {
|
||||||
|
cy.focused().type('3{enter}')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Both formula columns must recompute live from THIS row's own
|
||||||
|
// A_COL/B_COL - proving the seeded formula is genuinely wired to
|
||||||
|
// HyperFormula, not a one-time static snapshot.
|
||||||
|
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '15')
|
||||||
|
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '8')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('19 | Row header shows blank for unchanged, ~ for a modified row', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
getRowHeaderSymbol(0).should('have.text', ' ')
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(0, 'A_COL')
|
||||||
|
.dblclick({ force: true })
|
||||||
|
.then(() => {
|
||||||
|
cy.focused().clear().type('999{enter}')
|
||||||
|
})
|
||||||
|
|
||||||
|
getRowHeaderSymbol(0).should('have.text', '~')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('20 | Row header shows - for a delete-marked row and + for a newly inserted row', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
getCellByHeaderAndRow(2, 'Delete?').then(($cell) => {
|
||||||
|
setDeleteFlag($cell[0], 'Yes', () => {
|
||||||
|
getRowHeaderSymbol(2).should('have.text', '-')
|
||||||
|
|
||||||
|
insertRowViaContextMenu(0, 'Insert Row below')
|
||||||
|
|
||||||
|
// New row lands at index 1, shifting the delete-marked row
|
||||||
|
// (was index 2) down to index 3 - its own status is unaffected
|
||||||
|
// by the shift, only its position.
|
||||||
|
getRowHeaderSymbol(1).should('have.text', '+')
|
||||||
|
getRowHeaderSymbol(3).should('have.text', '-')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('21 | Inserting a row does not falsely mark unrelated rows as modified or revert an existing override', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL')
|
||||||
|
.dblclick({ force: true })
|
||||||
|
.then(() => {
|
||||||
|
cy.focused().clear().type('999{enter}')
|
||||||
|
})
|
||||||
|
getRowHeaderSymbol(0).should('have.text', '~')
|
||||||
|
|
||||||
|
// Untouched row, well away from the insert point below.
|
||||||
|
getRowHeaderSymbol(5).should('have.text', ' ')
|
||||||
|
|
||||||
|
insertRowViaContextMenu(2, 'Insert Row below')
|
||||||
|
|
||||||
|
// The override and its '~' mark must survive the insert...
|
||||||
|
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL').should('have.text', '999')
|
||||||
|
getRowHeaderSymbol(0).should('have.text', '~')
|
||||||
|
|
||||||
|
// ...and a row that was never touched must stay unmarked, even
|
||||||
|
// though its position shifted (row 5 is now row 6).
|
||||||
|
getRowHeaderSymbol(6).should('have.text', ' ')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("22 | DC.ROW_STATUS resolves to a live cell reference, reacting to this row's own edit status", () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
// _____EDIT_STATUS_____ itself is a purely client-synthesized
|
||||||
|
// column (see editStatusColumnRule.ts) - it must never render as a
|
||||||
|
// visible header, only exist as something DC.ROW_STATUS can
|
||||||
|
// point a cell reference at.
|
||||||
|
cy.get('.ht_clone_top .htCore thead tr th').should(($ths) => {
|
||||||
|
const texts = [...$ths].map((th) => th.innerText.trim())
|
||||||
|
expect(texts).not.to.include('_____EDIT_STATUS_____')
|
||||||
|
})
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'U')
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(0, 'A_COL')
|
||||||
|
.dblclick({ force: true })
|
||||||
|
.then(() => {
|
||||||
|
cy.focused().clear().type('999{enter}')
|
||||||
|
})
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'M')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('23 | DC.USER_NAME resolves to the current logged-in user', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
getCellByHeaderAndRow(0, 'USER_NAME_COL').should('have.text', 'sasdemo')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("24 | DC.ORIG_VALUE echoes this row's pre-edit value, and is blank for a newly-inserted row", () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
getCellByHeaderAndRow(0, 'ORIG_VALUE_COL').should('have.text', 'orig-1')
|
||||||
|
|
||||||
|
insertRowViaContextMenu(0, 'Insert Row below')
|
||||||
|
|
||||||
|
// No dataSourceUnchanged match exists for a brand-new row, so
|
||||||
|
// DC.ORIG_VALUE falls back to blank rather than echoing anything.
|
||||||
|
getCellByHeaderAndRow(1, 'ORIG_VALUE_COL').should('have.text', '')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('25 | Row header symbols stay aligned with their own row after sorting by another column', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
// Mark row 2 (PRIMARY_KEY_FIELD=3) for delete before sorting, so
|
||||||
|
// there's a distinctive symbol ('-') to track across the reorder.
|
||||||
|
getCellByHeaderAndRow(2, 'Delete?').then(($cell) => {
|
||||||
|
setDeleteFlag($cell[0], 'Yes', () => {
|
||||||
|
getRowHeaderSymbol(2).should('have.text', '-')
|
||||||
|
|
||||||
|
sortByColumn('FORMULA_SOFT_COL')
|
||||||
|
|
||||||
|
// Wherever PRIMARY_KEY_FIELD=3 lands visually after sorting,
|
||||||
|
// its OWN row header must show '-' - not whatever symbol
|
||||||
|
// previously belonged to that visual position.
|
||||||
|
cy.get('.ht_clone_top .htCore thead tr th')
|
||||||
|
.should(($ths) => {
|
||||||
|
const texts = [...$ths].map((th) => th.innerText.trim())
|
||||||
|
expect(texts).to.include('PRIMARY_KEY_FIELD')
|
||||||
|
})
|
||||||
|
.then(($ths) => {
|
||||||
|
const pkColIndex = [...$ths].findIndex(
|
||||||
|
(th) => th.innerText.trim() === 'PRIMARY_KEY_FIELD'
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.get('.ht_master tbody tr').then((rows: any) => {
|
||||||
|
const sortedRowIndex = [...rows].findIndex(
|
||||||
|
(row: any) =>
|
||||||
|
row.childNodes[pkColIndex].innerText.trim() === '3'
|
||||||
|
)
|
||||||
|
|
||||||
|
getRowHeaderSymbol(sortedRowIndex).should('have.text', '-')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// CHANGE_SUMMARY_COL combines all three DC.* variables in one formula:
|
||||||
|
// IF( DC.ROW_STATUS ="U","unedited", DC.USER_NAME &" changed from "& DC.ORIG_VALUE ).
|
||||||
|
// DC.USER_NAME/DC.ORIG_VALUE are frozen literals baked in at load time,
|
||||||
|
// while DC.ROW_STATUS is a live cell reference - so editing the row
|
||||||
|
// doesn't recompute the "changed from" text, it just flips which
|
||||||
|
// already-computed branch IF() reveals.
|
||||||
|
it("26 | Combining DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE in one formula reveals the frozen 'changed from' text once the row is edited", () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
|
clickOnEdit(() => {
|
||||||
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then(() => {
|
||||||
|
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
|
||||||
|
'have.text',
|
||||||
|
'unedited'
|
||||||
|
)
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(0, 'A_COL')
|
||||||
|
.dblclick({ force: true })
|
||||||
|
.then(() => {
|
||||||
|
cy.focused().clear().type('999{enter}')
|
||||||
|
})
|
||||||
|
|
||||||
|
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
|
||||||
|
'have.text',
|
||||||
|
'sasdemo changed from orig-1'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
|
||||||
|
// the ones in the current viewport actually exist in the DOM. Scroll all
|
||||||
|
// the way right so REGEX_HARD_COL/REGEX_SOFT_COL/REGEX_BOTH_COL (the last
|
||||||
|
// three) render at all. Same technique already used in excel.cy.ts. Must
|
||||||
|
// be called (and re-settle) before any header/body query below, since
|
||||||
|
// scrolling replaces the previously-rendered column nodes.
|
||||||
|
const scrollGridRight = () => {
|
||||||
|
return cy
|
||||||
|
.get('#hotTable')
|
||||||
|
.find('div.ht_master.handsontable')
|
||||||
|
.find('div.wtHolder')
|
||||||
|
.scrollTo('right')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicks a column header's sort indicator (Handsontable's multiColumnSorting
|
||||||
|
// plugin decorates every sortable header with a `.columnSorting` element -
|
||||||
|
// HEADER_SORT_CLASS in Handsontable's own source) to sort ascending by that
|
||||||
|
// column. force: true for the same reason as openColumnDropdown - an
|
||||||
|
// overlay clone layer can sit over the real target.
|
||||||
|
const sortByColumn = (headerText: string) => {
|
||||||
|
cy.get('.ht_clone_top .htCore thead tr th')
|
||||||
|
.filter((_, th) => Cypress.$(th).text().includes(headerText))
|
||||||
|
.last()
|
||||||
|
.find('.columnSorting')
|
||||||
|
.click({ force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens a column header's dropdown menu to reach its `info` item, which
|
||||||
|
// has a custom renderer showing NAME/LABEL/TYPE/LENGTH/FORMAT and, when the
|
||||||
|
// column has a HARDREGEX/SOFTREGEX rule, the applied pattern. Clicking the
|
||||||
|
// header text first selects the column - the renderer reads
|
||||||
|
// hot.getSelected() to decide which column to describe.
|
||||||
|
const openColumnDropdown = (headerText: string) => {
|
||||||
|
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
|
.parents('th')
|
||||||
|
.filter((_, th) => Cypress.$(th).text().includes(headerText))
|
||||||
|
.last()
|
||||||
|
.as('targetHeader')
|
||||||
|
|
||||||
|
cy.get('@targetHeader').click()
|
||||||
|
cy.get('@targetHeader').find('button.changeType').click({ force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locates a body cell by its column's header text rather than a hardcoded
|
||||||
|
// childNodes index. Handsontable's hiddenColumns plugin (e.g. HIDDEN_COL,
|
||||||
|
// used by several demo columns ahead of REGEX_HARD_COL/REGEX_SOFT_COL in
|
||||||
|
// COLHEADERS) removes a hidden column's cells from BOTH the header row and
|
||||||
|
// every body row identically, so the header row's th index always lines up
|
||||||
|
// with the same-position childNode in any body row — this sidesteps having
|
||||||
|
// to know how many hidden columns precede the target. Re-queries both the
|
||||||
|
// header and body row fresh each call (rather than accepting a pre-fetched
|
||||||
|
// row) since virtualization/scrolling can replace either pane's nodes.
|
||||||
|
//
|
||||||
|
// The `.should()` retries until headerText actually shows up: Handsontable
|
||||||
|
// re-renders virtualized columns asynchronously after a scroll event, on
|
||||||
|
// its own schedule outside Cypress's command queue, so scrollTo() settling
|
||||||
|
// does not mean the target column has been rendered yet.
|
||||||
|
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
|
||||||
|
return cy
|
||||||
|
.get('.ht_clone_top .htCore thead tr th')
|
||||||
|
.should(($ths) => {
|
||||||
|
const texts = [...$ths].map((th) => th.innerText.trim())
|
||||||
|
expect(texts).to.include(headerText)
|
||||||
|
})
|
||||||
|
.then(($ths) => {
|
||||||
|
const index = [...$ths].findIndex(
|
||||||
|
(th) => th.innerText.trim() === headerText
|
||||||
|
)
|
||||||
|
|
||||||
|
return cy
|
||||||
|
.get('.ht_master tbody tr')
|
||||||
|
.then((rows: any) => rows[rowIndex].childNodes[index])
|
||||||
|
.then((cell) => cy.get(cell))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads the row-header gutter's text for the given row - this is the
|
||||||
|
// left-hand "select entire row" bar (rowHeaders callback in
|
||||||
|
// editor.component.ts), doubling as the edit-status indicator
|
||||||
|
// (+/-/~/blank). .ht_clone_left mirrors .ht_clone_top's frozen-clone role,
|
||||||
|
// just for the row axis instead of the column axis.
|
||||||
|
const getRowHeaderSymbol = (rowIndex: number) => {
|
||||||
|
return cy
|
||||||
|
.get('.ht_clone_left .htCore tbody tr')
|
||||||
|
.eq(rowIndex)
|
||||||
|
.find('th .rowHeader')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-clicks the given row's PRIMARY_KEY_FIELD cell to open the row's
|
||||||
|
// context menu, then clicks the given item. 'Insert Row above'/'below' are
|
||||||
|
// hidden when hotTable.readOnly is true, so this only works after
|
||||||
|
// clickOnEdit(). force: true - same reasoning as toggleColumnLabelsFromContextMenu
|
||||||
|
// in viewer-labels.cy.ts, the right-clicked cell can sit under an overlay
|
||||||
|
// clone layer that fails Cypress's actionability check.
|
||||||
|
const insertRowViaContextMenu = (
|
||||||
|
rowIndex: number,
|
||||||
|
menuItemText: 'Insert Row above' | 'Insert Row below'
|
||||||
|
) => {
|
||||||
|
getCellByHeaderAndRow(rowIndex, 'PRIMARY_KEY_FIELD').rightclick({
|
||||||
|
force: true
|
||||||
|
})
|
||||||
|
cy.get('.htContextMenu').contains(menuItemText).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) => {
|
const clickOnEdit = (callback?: any) => {
|
||||||
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
||||||
.click()
|
.click()
|
||||||
@@ -217,11 +1000,7 @@ const rejectExcel = (callback?: any) => {
|
|||||||
.should('contain', 'Approve')
|
.should('contain', 'Approve')
|
||||||
.then((allButtons: any) => {
|
.then((allButtons: any) => {
|
||||||
for (let approvalButton of allButtons) {
|
for (let approvalButton of allButtons) {
|
||||||
if (
|
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
||||||
approvalButton.innerText
|
|
||||||
.toLowerCase()
|
|
||||||
.includes('approve')
|
|
||||||
) {
|
|
||||||
approvalButton.click()
|
approvalButton.click()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -34,93 +34,162 @@ context('excel multi load tests: ', function () {
|
|||||||
|
|
||||||
it('1 | Uploads Excel file with multiple sheets, 3 sheets including data, 2 sheets matched with dataset', (done) => {
|
it('1 | Uploads Excel file with multiple sheets, 3 sheets including data, 2 sheets matched with dataset', (done) => {
|
||||||
attachExcelFile('multi_load_test_2.xlsx', () => {
|
attachExcelFile('multi_load_test_2.xlsx', () => {
|
||||||
checkHotUserDatasetTable('hotTableUserDataset', [
|
checkHotUserDatasetTable(
|
||||||
[library, mpeXTestTable],
|
'hotTableUserDataset',
|
||||||
[library, mpeTablesTable]
|
[
|
||||||
], () => {
|
[library, mpeXTestTable],
|
||||||
cy.get('#continue-btn').trigger('click').then(() => {
|
[library, mpeTablesTable]
|
||||||
checkIfTreeHasTables([`${library}.${mpeXTestTable}`, `${library}.${mpeTablesTable}`], undefined, (includes: boolean) => {
|
],
|
||||||
if (includes) {
|
() => {
|
||||||
// MPE_TABLES sheet does not have data so 1 error image must be shown
|
cy.get('#continue-btn')
|
||||||
hasErrorTables(1, (valid: boolean) => {
|
.trigger('click')
|
||||||
if (valid) done()
|
.then(() => {
|
||||||
})
|
checkIfTreeHasTables(
|
||||||
}
|
[`${library}.${mpeXTestTable}`, `${library}.${mpeTablesTable}`],
|
||||||
})
|
undefined,
|
||||||
})
|
(includes: boolean) => {
|
||||||
})
|
if (includes) {
|
||||||
|
// MPE_TABLES sheet does not have data so 1 error image must be shown
|
||||||
|
hasErrorTables(1, (valid: boolean) => {
|
||||||
|
if (valid) done()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('2 | Uploads Excel file with multiple sheets, 2 sheets matched with dataset, 1 matched sheet does not have data', (done) => {
|
it('2 | Uploads Excel file with multiple sheets, 2 sheets matched with dataset, 1 matched sheet does not have data', (done) => {
|
||||||
attachExcelFile('multi_load_test_1.xlsx', () => {
|
attachExcelFile('multi_load_test_1.xlsx', () => {
|
||||||
checkHotUserDatasetTable('hotTableUserDataset', [
|
checkHotUserDatasetTable(
|
||||||
[library, mpeXTestTable],
|
'hotTableUserDataset',
|
||||||
[library, mpeTablesTable]
|
[
|
||||||
], () => {
|
[library, mpeXTestTable],
|
||||||
cy.get('#continue-btn').trigger('click').then(() => {
|
[library, mpeTablesTable]
|
||||||
checkIfTreeHasTables([`${library}.${mpeXTestTable}`, `${library}.${mpeTablesTable}`], `${library}.${mpeXTestTable}`, (includes: boolean) => {
|
],
|
||||||
if (includes) {
|
() => {
|
||||||
cy.get('#hotTable').should('be.visible').then(() => {
|
cy.get('#continue-btn')
|
||||||
checkHotUserDatasetTable('hotTable', [
|
.trigger('click')
|
||||||
['No', '1', 'more dummy data'],
|
.then(() => {
|
||||||
['No', '1', 'It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told:'],
|
checkIfTreeHasTables(
|
||||||
['No', '1', 'if you can fill the unforgiving minute']
|
[`${library}.${mpeXTestTable}`, `${library}.${mpeTablesTable}`],
|
||||||
], () => {
|
`${library}.${mpeXTestTable}`,
|
||||||
submitTables()
|
(includes: boolean) => {
|
||||||
|
if (includes) {
|
||||||
|
cy.get('#hotTable')
|
||||||
|
.should('be.visible')
|
||||||
|
.then(() => {
|
||||||
|
checkHotUserDatasetTable(
|
||||||
|
'hotTable',
|
||||||
|
[
|
||||||
|
['No', '1', 'more dummy data'],
|
||||||
|
[
|
||||||
|
'No',
|
||||||
|
'1',
|
||||||
|
'It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told:'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'No',
|
||||||
|
'1',
|
||||||
|
'if you can fill the unforgiving minute'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
submitTables()
|
||||||
|
|
||||||
hasSuccessSubmits(2, (valid: boolean) => {
|
hasSuccessSubmits(2, (valid: boolean) => {
|
||||||
if (valid) done()
|
if (valid) done()
|
||||||
})
|
})
|
||||||
|
}
|
||||||
})
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
})
|
)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('3 | Uploads Excel file with multiple sheets, 1 sheets has 2 tables', (done) => {
|
it('3 | Uploads Excel file with multiple sheets, 1 sheets has 2 tables', (done) => {
|
||||||
attachExcelFile('multi_load_test_1.xlsx', () => {
|
attachExcelFile('multi_load_test_1.xlsx', () => {
|
||||||
checkHotUserDatasetTable('hotTableUserDataset', [
|
checkHotUserDatasetTable(
|
||||||
[library, mpeXTestTable],
|
'hotTableUserDataset',
|
||||||
[library, mpeTablesTable]
|
[
|
||||||
], () => {
|
[library, mpeXTestTable],
|
||||||
cy.get('#continue-btn').trigger('click').then(() => {
|
[library, mpeTablesTable]
|
||||||
checkIfTreeHasTables([`${library}.${mpeXTestTable}`, `${library}.${mpeTablesTable}`], `${library}.${mpeXTestTable}`, (includes: boolean) => {
|
],
|
||||||
if (includes) {
|
() => {
|
||||||
cy.get('#hotTable').should('be.visible').then(() => {
|
cy.get('#continue-btn')
|
||||||
checkHotUserDatasetTable('hotTable', [
|
.trigger('click')
|
||||||
['No', '1', 'more dummy data'],
|
.then(() => {
|
||||||
['No', '1', 'It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told:'],
|
checkIfTreeHasTables(
|
||||||
['No', '1', 'if you can fill the unforgiving minute']
|
[`${library}.${mpeXTestTable}`, `${library}.${mpeTablesTable}`],
|
||||||
], () => {
|
`${library}.${mpeXTestTable}`,
|
||||||
clickOnTreeNode('DC996664.MPE_TABLES', () => {
|
(includes: boolean) => {
|
||||||
cy.wait(1000).then(() => {
|
if (includes) {
|
||||||
cy.get('#hotTable').should('be.visible').then(() => {
|
cy.get('#hotTable')
|
||||||
checkHotUserDatasetTable('hotTable', [
|
.should('be.visible')
|
||||||
['No', 'DC914286', 'MPE_COLUMN_LEVEL_SECURITY'],
|
.then(() => {
|
||||||
['No', 'DC914286', 'MPE_XLMAP_INFO'],
|
checkHotUserDatasetTable(
|
||||||
['No', 'DC914286', 'MPE_XLMAP_RULES']
|
'hotTable',
|
||||||
], () => {
|
[
|
||||||
submitTables()
|
['No', '1', 'more dummy data'],
|
||||||
|
[
|
||||||
|
'No',
|
||||||
|
'1',
|
||||||
|
'It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told:'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'No',
|
||||||
|
'1',
|
||||||
|
'if you can fill the unforgiving minute'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
clickOnTreeNode('DC996664.MPE_TABLES', () => {
|
||||||
|
cy.wait(1000).then(() => {
|
||||||
|
cy.get('#hotTable')
|
||||||
|
.should('be.visible')
|
||||||
|
.then(() => {
|
||||||
|
checkHotUserDatasetTable(
|
||||||
|
'hotTable',
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'No',
|
||||||
|
'DC914286',
|
||||||
|
'MPE_COLUMN_LEVEL_SECURITY'
|
||||||
|
],
|
||||||
|
['No', 'DC914286', 'MPE_XLMAP_INFO'],
|
||||||
|
['No', 'DC914286', 'MPE_XLMAP_RULES']
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
submitTables()
|
||||||
|
|
||||||
hasSuccessSubmits(2, (valid: boolean) => {
|
hasSuccessSubmits(
|
||||||
if (valid) done()
|
2,
|
||||||
})
|
(valid: boolean) => {
|
||||||
|
if (valid) done()
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
})
|
}
|
||||||
})
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -142,25 +211,31 @@ const attachExcelFile = (excelFilename: string, callback?: any) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkHotUserDatasetTable = (hotId: string, dataToContain: any[][], callback?: () => void) => {
|
const checkHotUserDatasetTable = (
|
||||||
|
hotId: string,
|
||||||
|
dataToContain: any[][],
|
||||||
|
callback?: () => void
|
||||||
|
) => {
|
||||||
cy.get(`#${hotId}`, { timeout: longerCommandTimeout })
|
cy.get(`#${hotId}`, { timeout: longerCommandTimeout })
|
||||||
.find('div.ht_master.handsontable')
|
.find('div.ht_master.handsontable')
|
||||||
.find('div.wtHolder')
|
.find('div.wtHolder')
|
||||||
.find('div.wtHider')
|
.find('div.wtHider')
|
||||||
.find('div.wtSpreader')
|
.find('div.wtSpreader')
|
||||||
.find('table.htCore')
|
.find('table.htCore')
|
||||||
.find('tbody')
|
.find('tbody')
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
cy.wait(2000).then(() => {
|
cy.wait(2000).then(() => {
|
||||||
for (let rowI = 0; rowI < dataToContain.length; rowI++) {
|
for (let rowI = 0; rowI < dataToContain.length; rowI++) {
|
||||||
for (let colI = 0; colI < dataToContain[rowI].length; colI++) {
|
for (let colI = 0; colI < dataToContain[rowI].length; colI++) {
|
||||||
expect(data[0].children[rowI].children[colI]).to.contain(dataToContain[rowI][colI])
|
expect(data[0].children[rowI].children[colI]).to.contain(
|
||||||
|
dataToContain[rowI][colI]
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (callback) callback()
|
if (callback) callback()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const clickOnTreeNode = (clickOnNode: string, callback?: () => void) => {
|
const clickOnTreeNode = (clickOnNode: string, callback?: () => void) => {
|
||||||
@@ -174,7 +249,11 @@ const clickOnTreeNode = (clickOnNode: string, callback?: () => void) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkIfTreeHasTables = (tables: string[], clickOnNode?: string, callback?: (includes: boolean) => void) => {
|
const checkIfTreeHasTables = (
|
||||||
|
tables: string[],
|
||||||
|
clickOnNode?: string,
|
||||||
|
callback?: (includes: boolean) => void
|
||||||
|
) => {
|
||||||
cy.get('.nav-tree clr-tree > clr-tree-node').then((treeNodes: any) => {
|
cy.get('.nav-tree clr-tree > clr-tree-node').then((treeNodes: any) => {
|
||||||
let datasets = tables
|
let datasets = tables
|
||||||
let nodesCorrect = true
|
let nodesCorrect = true
|
||||||
@@ -207,16 +286,26 @@ const submitTables = () => {
|
|||||||
cy.wait(1000)
|
cy.wait(1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasSuccessSubmits = (expectedNoOfSubmits: number, callback: (valid: boolean) => void) => {
|
const hasSuccessSubmits = (
|
||||||
cy.get('.nav-tree clr-tree > clr-tree-node cds-icon[status="success"]').should('be.visible').then(($nodes) => {
|
expectedNoOfSubmits: number,
|
||||||
callback(expectedNoOfSubmits === $nodes.length)
|
callback: (valid: boolean) => void
|
||||||
})
|
) => {
|
||||||
|
cy.get('.nav-tree clr-tree > clr-tree-node cds-icon[status="success"]')
|
||||||
|
.should('be.visible')
|
||||||
|
.then(($nodes) => {
|
||||||
|
callback(expectedNoOfSubmits === $nodes.length)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasErrorTables = (expectedNoOfErrors: number, callback: (valid: boolean) => void) => {
|
const hasErrorTables = (
|
||||||
cy.get('.nav-tree clr-tree > clr-tree-node cds-icon[status="danger"]').should('be.visible').then(($nodes) => {
|
expectedNoOfErrors: number,
|
||||||
callback(expectedNoOfErrors === $nodes.length)
|
callback: (valid: boolean) => void
|
||||||
})
|
) => {
|
||||||
|
cy.get('.nav-tree clr-tree > clr-tree-node cds-icon[status="danger"]')
|
||||||
|
.should('be.visible')
|
||||||
|
.then(($nodes) => {
|
||||||
|
callback(expectedNoOfErrors === $nodes.length)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const visitPage = (url: string) => {
|
const visitPage = (url: string) => {
|
||||||
|
|||||||
+108
-23
@@ -234,7 +234,7 @@ context('excel tests: ', function () {
|
|||||||
cy.get('.btn-upload-preview', { timeout: 60000 })
|
cy.get('.btn-upload-preview', { timeout: 60000 })
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('#hotInstance', { timeout: 30000 })
|
cy.get('#hotTable', { timeout: 30000 })
|
||||||
.find('div.ht_master.handsontable')
|
.find('div.ht_master.handsontable')
|
||||||
.find('div.wtHolder')
|
.find('div.wtHolder')
|
||||||
.find('div.wtHider')
|
.find('div.wtHider')
|
||||||
@@ -283,7 +283,7 @@ context('excel tests: ', function () {
|
|||||||
cy.get('.btn-upload-preview', { timeout: 60000 })
|
cy.get('.btn-upload-preview', { timeout: 60000 })
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('#hotInstance', { timeout: 30000 })
|
cy.get('#hotTable', { timeout: 30000 })
|
||||||
.find('div.ht_master.handsontable')
|
.find('div.ht_master.handsontable')
|
||||||
.find('div.wtHolder')
|
.find('div.wtHolder')
|
||||||
.find('div.wtHider')
|
.find('div.wtHider')
|
||||||
@@ -309,6 +309,96 @@ context('excel tests: ', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('22 | Uploads password protected Excel and unlocks with correct password', (done) => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
cy.get('.buttonBar button:last-child')
|
||||||
|
.should('exist')
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
cy.get('input[type="file"]#file-upload')
|
||||||
|
.attachFile(`/${fixturePath}/regular_excel_password.xlsx`)
|
||||||
|
.then(() => {
|
||||||
|
// Wait for password modal to appear
|
||||||
|
cy.get('#filePasswordInput', { timeout: 10000 })
|
||||||
|
.should('be.visible')
|
||||||
|
.type('123123')
|
||||||
|
|
||||||
|
// Click Unlock button
|
||||||
|
cy.get('.btn.btn-success-outline').should('not.be.disabled').click()
|
||||||
|
|
||||||
|
// Click away the overlay
|
||||||
|
cy.get('.modal-footer .btn.btn-primary', { timeout: 5000 }).click()
|
||||||
|
|
||||||
|
// Verify file loads successfully
|
||||||
|
cy.get('.btn-upload-preview', { timeout: 60000 })
|
||||||
|
.should('be.visible')
|
||||||
|
.then(() => {
|
||||||
|
submitExcel()
|
||||||
|
rejectExcel(done)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('23 | Uploads password protected Excel and handles wrong password', (done) => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
cy.get('.buttonBar button:last-child')
|
||||||
|
.should('exist')
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
cy.get('input[type="file"]#file-upload')
|
||||||
|
.attachFile(`/${fixturePath}/regular_excel_password.xlsx`)
|
||||||
|
.then(() => {
|
||||||
|
// First attempt: Enter wrong password
|
||||||
|
cy.get('#filePasswordInput', { timeout: 10000 })
|
||||||
|
.should('be.visible')
|
||||||
|
.type('wrongpassword')
|
||||||
|
|
||||||
|
cy.get('.btn.btn-success-outline').should('not.be.disabled').click()
|
||||||
|
|
||||||
|
// Verify error message appears
|
||||||
|
cy.get('.modal-footer .color-red', { timeout: 10000 })
|
||||||
|
.should('be.visible')
|
||||||
|
.should('contain', "Sorry that didn't work, try again.")
|
||||||
|
|
||||||
|
// Modal should still be open for retry
|
||||||
|
cy.get('#filePasswordInput')
|
||||||
|
.should('be.visible')
|
||||||
|
.clear()
|
||||||
|
.type('123123')
|
||||||
|
|
||||||
|
// Second attempt: Enter correct password
|
||||||
|
cy.get('.btn.btn-success-outline').should('not.be.disabled').click()
|
||||||
|
|
||||||
|
// Click away the overlay
|
||||||
|
cy.get('.modal-footer .btn.btn-primary', { timeout: 5000 }).click()
|
||||||
|
|
||||||
|
// Verify file loads successfully
|
||||||
|
cy.get('.btn-upload-preview', { timeout: 60000 })
|
||||||
|
.should('be.visible')
|
||||||
|
.then(() => {
|
||||||
|
submitExcel()
|
||||||
|
rejectExcel(done)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('24 | Uploads Excel with leading whitespace in header row (should still succeed)', (done) => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
// Every header except the first has a leading space, matching a
|
||||||
|
// real-world defect from copy-pasting between spreadsheets/systems.
|
||||||
|
// Header matching must tolerate
|
||||||
|
// this rather than reporting the columns missing and aborting.
|
||||||
|
attachExcelFile('leading_whitespace_header_excel.xlsx', () => {
|
||||||
|
submitExcel()
|
||||||
|
rejectExcel(done)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Large files break Cypress
|
// Large files break Cypress
|
||||||
|
|
||||||
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
||||||
@@ -399,11 +489,7 @@ const rejectExcel = (callback?: any) => {
|
|||||||
.should('contain', 'Approve')
|
.should('contain', 'Approve')
|
||||||
.then((allButtons: any) => {
|
.then((allButtons: any) => {
|
||||||
for (let approvalButton of allButtons) {
|
for (let approvalButton of allButtons) {
|
||||||
if (
|
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
||||||
approvalButton.innerText
|
|
||||||
.toLowerCase()
|
|
||||||
.includes('approve')
|
|
||||||
) {
|
|
||||||
approvalButton.click()
|
approvalButton.click()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -432,11 +518,7 @@ const acceptExcel = (callback?: any) => {
|
|||||||
.should('contain', 'Approve')
|
.should('contain', 'Approve')
|
||||||
.then((allButtons: any) => {
|
.then((allButtons: any) => {
|
||||||
for (let approvalButton of allButtons) {
|
for (let approvalButton of allButtons) {
|
||||||
if (
|
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
||||||
approvalButton.innerText
|
|
||||||
.toLowerCase()
|
|
||||||
.includes('approve')
|
|
||||||
) {
|
|
||||||
approvalButton.click()
|
approvalButton.click()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -455,7 +537,7 @@ const acceptExcel = (callback?: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checkResultOfFormulaUpload = (callback?: any) => {
|
const checkResultOfFormulaUpload = (callback?: any) => {
|
||||||
cy.get('#hotInstance', { timeout: longerCommandTimeout })
|
cy.get('#hotTable', { timeout: longerCommandTimeout })
|
||||||
.find('div.ht_master.handsontable')
|
.find('div.ht_master.handsontable')
|
||||||
.find('div.wtHolder')
|
.find('div.wtHolder')
|
||||||
.find('div.wtHider')
|
.find('div.wtHider')
|
||||||
@@ -470,8 +552,10 @@ const checkResultOfFormulaUpload = (callback?: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checkResultOfXLSUpload = (callback?: any) => {
|
const checkResultOfXLSUpload = (callback?: any) => {
|
||||||
cy.viewport(1280, 720)
|
// Config-default width — wide enough that the date/datetime/time columns are
|
||||||
cy.get('#hotInstance', { timeout: 30000 })
|
// not virtualized away (the old 1280 width hid the trailing time columns).
|
||||||
|
cy.viewport(1600, 900)
|
||||||
|
cy.get('#hotTable', { timeout: 30000 })
|
||||||
.find('div.ht_master.handsontable')
|
.find('div.ht_master.handsontable')
|
||||||
.find('div.wtHolder')
|
.find('div.wtHolder')
|
||||||
.find('div.wtHider')
|
.find('div.wtHider')
|
||||||
@@ -488,19 +572,20 @@ const checkResultOfXLSUpload = (callback?: any) => {
|
|||||||
expect(cell.innerText).to.equal('▼\nOption 1')
|
expect(cell.innerText).to.equal('▼\nOption 1')
|
||||||
cell = data[0].children[0].children[5]
|
cell = data[0].children[0].children[5]
|
||||||
expect(cell.innerText).to.equal('42')
|
expect(cell.innerText).to.equal('42')
|
||||||
|
// 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]
|
cell = data[0].children[0].children[6]
|
||||||
expect(cell.innerText).to.equal('▼\n1960-02-12')
|
expect(cell.innerText).to.equal('1960-02-12')
|
||||||
// When CI detached browser screen is smaller, below cells are not visible so test fails
|
cell = data[0].children[0].children[7]
|
||||||
// Commenting it out now until we figure out workaround
|
expect(cell.innerText).to.equal('1960-01-01 00:00:42')
|
||||||
// cell = data[0].children[0].children[7]
|
cell = data[0].children[0].children[8]
|
||||||
// expect(cell.innerText).to.equal('▼\n1960-01-01 00:00:42')
|
expect(cell.innerText).to.equal('00:00:42')
|
||||||
// cell = data[0].children[0].children[8]
|
|
||||||
// expect(cell.innerText).to.equal('00:00:42')
|
|
||||||
|
|
||||||
if (callback) callback()
|
if (callback) callback()
|
||||||
})
|
})
|
||||||
|
|
||||||
cy.get('#hotInstance', { timeout: 30000 })
|
cy.get('#hotTable', { timeout: 30000 })
|
||||||
.find('div.ht_master.handsontable')
|
.find('div.ht_master.handsontable')
|
||||||
.find('div.wtHolder')
|
.find('div.wtHolder')
|
||||||
.scrollTo('right')
|
.scrollTo('right')
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ context('filtering tests: ', function () {
|
|||||||
this.beforeEach(() => {
|
this.beforeEach(() => {
|
||||||
cy.visit(hostUrl + appLocation, { timeout: longerCommandTimeout })
|
cy.visit(hostUrl + appLocation, { timeout: longerCommandTimeout })
|
||||||
|
|
||||||
|
|
||||||
visitPage('home')
|
visitPage('home')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -299,14 +298,16 @@ const setFilterWithValue = (
|
|||||||
cy.get('.no-values')
|
cy.get('.no-values')
|
||||||
.should('not.exist')
|
.should('not.exist')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cy.get('.in-values-modal clr-checkbox-wrapper input').then((inputs: any) => {
|
cy.get('.in-values-modal clr-checkbox-wrapper input').then(
|
||||||
inputs[0].click()
|
(inputs: any) => {
|
||||||
cy.get('.in-values-modal .modal-footer button').click()
|
inputs[0].click()
|
||||||
|
cy.get('.in-values-modal .modal-footer button').click()
|
||||||
|
|
||||||
cy.get('.modal-footer .btn-success-outline').click()
|
cy.get('.modal-footer .btn-success-outline').click()
|
||||||
|
|
||||||
if (callback) callback()
|
if (callback) callback()
|
||||||
})
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ interface EditConfigTableCells {
|
|||||||
|
|
||||||
context('licensing tests: ', function () {
|
context('licensing tests: ', function () {
|
||||||
this.beforeAll(() => {
|
this.beforeAll(() => {
|
||||||
|
|
||||||
cy.loginAndUpdateValidKey()
|
cy.loginAndUpdateValidKey()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -371,8 +370,6 @@ context('licensing tests: ', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const logout = (callback?: any) => {
|
const logout = (callback?: any) => {
|
||||||
@@ -697,11 +694,7 @@ const approveTable = (callback?: any) => {
|
|||||||
.should('contain', 'Approve')
|
.should('contain', 'Approve')
|
||||||
.then((allButtons: any) => {
|
.then((allButtons: any) => {
|
||||||
for (let approvalButton of allButtons) {
|
for (let approvalButton of allButtons) {
|
||||||
if (
|
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
||||||
approvalButton.innerText
|
|
||||||
.toLowerCase()
|
|
||||||
.includes('approve')
|
|
||||||
) {
|
|
||||||
approvalButton.click()
|
approvalButton.click()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ context('liveness tests: ', function () {
|
|||||||
this.beforeEach(() => {
|
this.beforeEach(() => {
|
||||||
cy.visit(hostUrl + appLocation)
|
cy.visit(hostUrl + appLocation)
|
||||||
|
|
||||||
|
|
||||||
visitPage('home')
|
visitPage('home')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -37,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
|
* Thist part will be needed if we add more tests in future
|
||||||
*/
|
*/
|
||||||
@@ -125,11 +156,7 @@ const rejectExcel = (callback?: any) => {
|
|||||||
.should('contain', 'Approve')
|
.should('contain', 'Approve')
|
||||||
.then((allButtons: any) => {
|
.then((allButtons: any) => {
|
||||||
for (let approvalButton of allButtons) {
|
for (let approvalButton of allButtons) {
|
||||||
if (
|
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
||||||
approvalButton.innerText
|
|
||||||
.toLowerCase()
|
|
||||||
.includes('approve')
|
|
||||||
) {
|
|
||||||
approvalButton.click()
|
approvalButton.click()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+244
-245
@@ -41,16 +41,13 @@ context('editor tests: ', function () {
|
|||||||
cy.get('.viewbox-open').click()
|
cy.get('.viewbox-open').click()
|
||||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
||||||
|
|
||||||
cy.get('.open-viewbox').then((viewboxNodes: any) => {
|
cy.contains('.open-viewbox', viewbox_table, {
|
||||||
for (let viewboxNode of viewboxNodes) {
|
matchCase: false,
|
||||||
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
|
timeout: longerCommandTimeout
|
||||||
return
|
})
|
||||||
}
|
|
||||||
|
|
||||||
checkColumns(columns, () => {
|
checkColumns(columns, () => {
|
||||||
done()
|
done()
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -76,46 +73,22 @@ context('editor tests: ', function () {
|
|||||||
cy.get('.viewbox-open').click()
|
cy.get('.viewbox-open').click()
|
||||||
openTableFromViewboxTree(
|
openTableFromViewboxTree(
|
||||||
libraryToOpenIncludes,
|
libraryToOpenIncludes,
|
||||||
viewboxes.map((viewbox) => viewbox.viewbox_table))
|
viewboxes.map((viewbox) => viewbox.viewbox_table)
|
||||||
cy.get('.open-viewbox').then((viewboxNodes: any) => {
|
)
|
||||||
let found = 0
|
viewboxes.forEach((viewbox) => {
|
||||||
|
cy.contains('.open-viewbox', viewbox.viewbox_table, {
|
||||||
|
matchCase: false,
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
for (let viewboxNode of viewboxNodes) {
|
let result = 0
|
||||||
for (let viewbox of viewboxes) {
|
|
||||||
if (
|
|
||||||
viewboxNode.innerText.toLowerCase().includes(viewbox.viewbox_table)
|
|
||||||
)
|
|
||||||
found++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (found < viewboxes.length) return
|
viewboxes.forEach((viewbox) => {
|
||||||
|
checkColumns(viewbox.columns, () => {
|
||||||
|
result++
|
||||||
|
|
||||||
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then((viewboxNodes: any) => {
|
if (result === viewboxes.length) done()
|
||||||
for (let viewboxNode of viewboxNodes) {
|
|
||||||
cy.get(viewboxNode).within(() => {
|
|
||||||
cy.get('.table-title').then((tableTitle) => {
|
|
||||||
const title = tableTitle[0].innerText
|
|
||||||
const viewbox = viewboxes.find((vb) =>
|
|
||||||
title.toLowerCase().includes(vb.viewbox_table)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (viewbox) {
|
|
||||||
cy.get('.ht_master.handsontable .htCore thead tr').then(
|
|
||||||
(viewboxColNodes: any) => {
|
|
||||||
let allColsHtml = viewboxColNodes[0].innerHTML
|
|
||||||
|
|
||||||
for (let col of viewbox?.columns) {
|
|
||||||
if (!allColsHtml.includes(col)) return
|
|
||||||
}
|
|
||||||
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -130,22 +103,19 @@ context('editor tests: ', function () {
|
|||||||
cy.get('.viewbox-open').click()
|
cy.get('.viewbox-open').click()
|
||||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
||||||
|
|
||||||
cy.get('.open-viewbox').then((viewboxNodes: any) => {
|
cy.contains('.open-viewbox', viewbox_table, {
|
||||||
for (let viewboxNode of viewboxNodes) {
|
matchCase: false,
|
||||||
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
|
timeout: longerCommandTimeout
|
||||||
return
|
})
|
||||||
}
|
|
||||||
|
|
||||||
openViewboxConfig(viewbox_table)
|
openViewboxConfig(viewbox_table)
|
||||||
|
|
||||||
removeAllColumns()
|
removeAllColumns()
|
||||||
|
|
||||||
addColumns(additionalColumns)
|
addColumns(additionalColumns)
|
||||||
|
|
||||||
checkColumns([...columns, ...additionalColumns], () => {
|
checkColumns([...columns, ...additionalColumns], () => {
|
||||||
done()
|
done()
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,35 +129,32 @@ context('editor tests: ', function () {
|
|||||||
cy.get('.viewbox-open').click()
|
cy.get('.viewbox-open').click()
|
||||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
||||||
|
|
||||||
cy.get('.open-viewbox').then((viewboxNodes: any) => {
|
cy.contains('.open-viewbox', viewbox_table, {
|
||||||
for (let viewboxNode of viewboxNodes) {
|
matchCase: false,
|
||||||
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
|
timeout: longerCommandTimeout
|
||||||
return
|
})
|
||||||
}
|
|
||||||
|
|
||||||
openViewboxConfig(viewbox_table)
|
openViewboxConfig(viewbox_table)
|
||||||
|
|
||||||
removeAllColumns()
|
removeAllColumns()
|
||||||
|
|
||||||
addColumns(additionalColumns, () => {
|
addColumns(additionalColumns, () => {
|
||||||
cy.wait(1000)
|
cy.wait(1000)
|
||||||
//reorder
|
//reorder
|
||||||
cy.get('.col-box.column-MOVE_TYPE')
|
cy.get('.col-box.column-MOVE_TYPE')
|
||||||
.realMouseDown({ button: 'left', position: 'center' })
|
.realMouseDown({ button: 'left', position: 'center' })
|
||||||
.realMouseMove(0, 10, { position: 'center' })
|
.realMouseMove(0, 10, { position: 'center' })
|
||||||
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
|
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
|
||||||
cy.get('.col-box.column-IS_PK')
|
cy.get('.col-box.column-IS_PK')
|
||||||
.realMouseMove(0, 0, { position: 'center' })
|
.realMouseMove(0, 0, { position: 'center' })
|
||||||
.realMouseUp()
|
.realMouseUp()
|
||||||
//reorder end
|
//reorder end
|
||||||
|
|
||||||
cy.wait(500)
|
cy.wait(500)
|
||||||
|
|
||||||
checkColumns([...columns, ...additionalColumns.reverse()], () => {
|
checkColumns([...columns, ...additionalColumns.reverse()], () => {
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -201,53 +168,47 @@ context('editor tests: ', function () {
|
|||||||
cy.get('.viewbox-open').click()
|
cy.get('.viewbox-open').click()
|
||||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
||||||
|
|
||||||
cy.get('.open-viewbox').then((viewboxNodes: any) => {
|
cy.contains('.open-viewbox', viewbox_table, {
|
||||||
for (let viewboxNode of viewboxNodes) {
|
matchCase: false,
|
||||||
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
|
timeout: longerCommandTimeout
|
||||||
return
|
})
|
||||||
}
|
|
||||||
|
|
||||||
viewboxNode.click()
|
openViewboxConfig(viewbox_table)
|
||||||
|
|
||||||
removeAllColumns()
|
removeAllColumns()
|
||||||
|
|
||||||
addColumns(additionalColumns, () => {
|
addColumns(additionalColumns, () => {
|
||||||
cy.wait(1000)
|
cy.wait(1000)
|
||||||
//reorder
|
//reorder
|
||||||
cy.get('.col-box.column-MOVE_TYPE')
|
cy.get('.col-box.column-MOVE_TYPE')
|
||||||
.realMouseDown({ button: 'left', position: 'center' })
|
.realMouseDown({ button: 'left', position: 'center' })
|
||||||
.realMouseMove(0, 10, { position: 'center' })
|
.realMouseMove(0, 10, { position: 'center' })
|
||||||
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
|
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
|
||||||
cy.get('.col-box.column-IS_PK')
|
cy.get('.col-box.column-IS_PK')
|
||||||
.realMouseMove(0, 0, { position: 'center' })
|
.realMouseMove(0, 0, { position: 'center' })
|
||||||
.realMouseUp()
|
.realMouseUp()
|
||||||
//reorder end
|
//reorder end
|
||||||
|
|
||||||
cy.wait(500)
|
cy.wait(500)
|
||||||
|
|
||||||
checkColumns([...columns, ...additionalColumns.reverse()], () => {
|
checkColumns([...columns, ...additionalColumns.reverse()], () => {
|
||||||
const colToRemove = 'MOVE_TYPE'
|
const colToRemove = 'MOVE_TYPE'
|
||||||
|
|
||||||
removeColumn(colToRemove)
|
removeColumn(colToRemove)
|
||||||
checkColumns(
|
checkColumns(
|
||||||
[
|
[
|
||||||
...columns,
|
...columns,
|
||||||
...additionalColumns.filter((col) => col !== colToRemove)
|
...additionalColumns.filter((col) => col !== colToRemove)
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
addColumns([colToRemove], () => {
|
addColumns([colToRemove], () => {
|
||||||
checkColumns(
|
checkColumns([...columns, ...additionalColumns.reverse()], () => {
|
||||||
[...columns, ...additionalColumns.reverse()],
|
done()
|
||||||
() => {
|
})
|
||||||
done()
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -330,106 +291,116 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// We will enable this test when we figure out how to mock filtering
|
// The base VIEW table and a viewbox on a different table must never
|
||||||
// it('7 | Add viewboxes and filter', () => {
|
// share a filter dialog's cached state - each keeps its own entry in
|
||||||
// const viewboxes = ['mpe_x_test', 'mpe_validations']
|
// the globals filter cache (getFilterObjPath), keyed by viewboxId where
|
||||||
|
// one is present. Opened from the VIEW page specifically
|
||||||
|
// (rootParam === 'view') - the one branch where this previously broke,
|
||||||
|
// since a 'view'-page QueryComponent was always treated as the base
|
||||||
|
// table regardless of viewboxId.
|
||||||
|
it('7 | Base table filter and viewbox filter stay isolated from each other', () => {
|
||||||
|
const viewbox_table = 'mpe_audit'
|
||||||
|
|
||||||
// openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
visitPage('view/data')
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
// cy.get('.viewbox-open').click()
|
// Filter the base table.
|
||||||
// openTableFromViewboxTree(libraryToOpenIncludes, viewboxes)
|
openViewFilter()
|
||||||
|
setFilterWithValue('SOME_CHAR', 'this is dummy data', 'value', () => {
|
||||||
|
cy.get('app-query', { withinSubject: null }).should('not.exist')
|
||||||
|
|
||||||
// cy.wait(1000)
|
// Open a viewbox on a *different* table and check its filter dialog
|
||||||
|
// does not show the base table's filter.
|
||||||
|
openViewViewboxes()
|
||||||
|
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
||||||
|
closeViewboxModal()
|
||||||
|
|
||||||
// closeViewboxModal()
|
// cy.contains retries until the viewbox's title actually shows the
|
||||||
|
// expected table name (it can render before its data/title loads),
|
||||||
|
// then .parents() walks back up to its container - avoids a
|
||||||
|
// one-shot .then() racing the title's own async update, which is
|
||||||
|
// what made this flaky in the first place.
|
||||||
|
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
|
||||||
|
matchCase: false,
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
})
|
||||||
|
.parents('.viewbox')
|
||||||
|
.first()
|
||||||
|
.within(() => {
|
||||||
|
cy.get('clr-icon[shape="filter"]').click()
|
||||||
|
|
||||||
// cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
|
cy.get('.filter-modal code.language-sql', {
|
||||||
// (viewboxNodes: any) => {
|
withinSubject: null
|
||||||
// for (let viewboxNode of viewboxNodes) {
|
}).should(($code) => {
|
||||||
// cy.get(viewboxNode).within(() => {
|
expect($code.text()).to.not.contain('SOME_CHAR')
|
||||||
// cy.get('.table-title').then((title: any) => {
|
})
|
||||||
// cy.get('.hot-spinner')
|
|
||||||
// .should('not.exist')
|
|
||||||
// .then(() => {
|
|
||||||
// cy.get('clr-icon[shape="filter"]').then((filterButton) => {
|
|
||||||
// filterButton[0].click()
|
|
||||||
// })
|
|
||||||
|
|
||||||
// if (title[0].innerText.includes('MPE_X_TEST')) {
|
// Reverse direction: filter the viewbox, then confirm
|
||||||
// setFilterWithValue(
|
// re-opening the base table's own filter dialog still shows
|
||||||
// 'SOME_CHAR',
|
// the base table's filter, not the viewbox's.
|
||||||
// 'this is dummy data',
|
setFilterWithValue('LIBREF', 'sasdemo', 'value', () => {
|
||||||
// 'value',
|
cy.get('app-query', { withinSubject: null }).should('not.exist')
|
||||||
// () => {
|
|
||||||
// cy.get('app-query', { withinSubject: null })
|
|
||||||
// .should('not.exist')
|
|
||||||
// .get('.ht_master.handsontable tbody tr')
|
|
||||||
// .then((rowNodes) => {
|
|
||||||
// const tr = rowNodes[0]
|
|
||||||
|
|
||||||
// expect(rowNodes).to.have.length(1)
|
openViewFilter()
|
||||||
// expect(tr.innerText).to.equal('0')
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
// } else if (title[0].innerText.includes('MPE_VALIDATIONS')) {
|
|
||||||
// setFilterWithValue('BASE_COL', 'ALERT_LIB', 'value', () => {
|
|
||||||
// cy.get('app-query', { withinSubject: null })
|
|
||||||
// .should('not.exist')
|
|
||||||
// .get('.ht_master.handsontable tbody tr')
|
|
||||||
// .then((rowNodes) => {
|
|
||||||
// const tr = rowNodes[0]
|
|
||||||
|
|
||||||
// expect(rowNodes).to.have.length(1)
|
cy.get('.filter-modal code.language-sql', {
|
||||||
// expect(tr.innerText).to.contain('ALERT_LIB')
|
withinSubject: null
|
||||||
// })
|
}).should(($code) => {
|
||||||
// })
|
expect($code.text()).to.contain('SOME_CHAR')
|
||||||
// }
|
expect($code.text()).to.not.contain('LIBREF')
|
||||||
// })
|
})
|
||||||
// })
|
})
|
||||||
// })
|
})
|
||||||
// }
|
})
|
||||||
// }
|
})
|
||||||
// )
|
|
||||||
// })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeAllColumns = () => {
|
const removeAllColumns = () => {
|
||||||
cy.get('.configuration-wrapper clr-icon[shape="trash"]').then(removeNodes => {
|
cy.get('.configuration-wrapper clr-icon[shape="trash"]').then(
|
||||||
for (let removeNode of removeNodes) {
|
(removeNodes) => {
|
||||||
removeNode.click()
|
for (let removeNode of removeNodes) {
|
||||||
}
|
removeNode.click()
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkColumns = (columns: string[], callback: () => void) => {
|
|
||||||
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
|
|
||||||
(viewboxNodes: any) => {
|
|
||||||
for (let viewboxNode of viewboxNodes) {
|
|
||||||
cy.get(viewboxNode).within(() => {
|
|
||||||
cy.get('.ht_master.handsontable thead tr th').then(
|
|
||||||
(viewboxColNodes: any) => {
|
|
||||||
console.log('viewboxColNode', viewboxColNodes)
|
|
||||||
console.log('columns', columns)
|
|
||||||
for (let i = 0; i < viewboxColNodes.length; i++) {
|
|
||||||
const col = columns[i]|| ''
|
|
||||||
const colNode = viewboxColNodes[i]
|
|
||||||
|
|
||||||
if (
|
|
||||||
!colNode.innerHTML.toLowerCase().includes(col.toLowerCase())
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asserts that at least one open viewbox currently shows exactly this
|
||||||
|
// column list, in order. Uses a single .should() callback (synchronous
|
||||||
|
// jQuery reads only, no nested cy.get()/.then()) so Cypress retries the
|
||||||
|
// whole check against a fresh DOM snapshot until it passes or times out -
|
||||||
|
// unlike .then(), which only ever sees one snapshot and, combined with an
|
||||||
|
// early return on a mismatch, would silently never invoke the caller's
|
||||||
|
// callback while Handsontable/the viewbox's data are still loading or
|
||||||
|
// re-rendering asynchronously.
|
||||||
|
const checkColumns = (columns: string[], callback: () => void) => {
|
||||||
|
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).should(
|
||||||
|
($viewboxNodes) => {
|
||||||
|
const matches = $viewboxNodes.toArray().some((viewboxNode) => {
|
||||||
|
// rowHeaders: true (viewboxes.component.ts) adds a leading, unlabeled
|
||||||
|
// corner <th> before the actual data columns - skip it, or every
|
||||||
|
// comparison is off by one against the row-header cell instead of
|
||||||
|
// the first real column.
|
||||||
|
const headerCells = Cypress.$(viewboxNode)
|
||||||
|
.find('.ht_master.handsontable thead tr th')
|
||||||
|
.toArray()
|
||||||
|
.slice(1)
|
||||||
|
|
||||||
|
return columns.every((col, i) =>
|
||||||
|
(headerCells[i]?.innerHTML || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(col.toLowerCase())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(matches, `a viewbox showing columns: ${columns.join(', ')}`).to.be
|
||||||
|
.true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.then(() => callback())
|
||||||
|
}
|
||||||
|
|
||||||
const closeViewboxModal = () => {
|
const closeViewboxModal = () => {
|
||||||
cy.get('app-viewboxes .close', { withinSubject: null }).click()
|
cy.get('app-viewboxes .close', { withinSubject: null }).click()
|
||||||
}
|
}
|
||||||
@@ -439,9 +410,7 @@ const removeColumn = (column: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addColumns = (columns: string[], callback?: () => void) => {
|
const addColumns = (columns: string[], callback?: () => void) => {
|
||||||
for (let i = 0; i < columns.length; i++) {
|
for (const column of columns) {
|
||||||
const column = columns[i]
|
|
||||||
|
|
||||||
cy.get('.cols-search input', { withinSubject: null }).type(column)
|
cy.get('.cols-search input', { withinSubject: null }).type(column)
|
||||||
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
|
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
|
||||||
.first()
|
.first()
|
||||||
@@ -449,51 +418,81 @@ const addColumns = (columns: string[], callback?: () => void) => {
|
|||||||
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
|
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
|
||||||
.first()
|
.first()
|
||||||
.trigger('keydown', { key: 'Enter' })
|
.trigger('keydown', { key: 'Enter' })
|
||||||
.then(() => {
|
|
||||||
if (i === columns.length - 1 && callback) callback()
|
// Wait for the actual column box to render before moving on to the
|
||||||
})
|
// next column (or firing the callback) - selecting it doesn't add it
|
||||||
|
// to the DOM synchronously, and cy.get()'s own retry covers that gap
|
||||||
|
// instead of relying on a fixed cy.wait().
|
||||||
|
cy.get(`.col-box.column-${column}`, { timeout: longerCommandTimeout })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (callback) cy.then(() => callback())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The VIEW page's "options" dropdown (viewer.component.html) - distinct
|
||||||
|
// from the editor page's dedicated .viewbox-open button used by the
|
||||||
|
// tests above, since test 7 runs on the VIEW page specifically.
|
||||||
|
const openViewOptionsDropdown = () => {
|
||||||
|
cy.get('.filterSide', { withinSubject: null }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openViewFilter = () => {
|
||||||
|
openViewOptionsDropdown()
|
||||||
|
cy.get('[clrDropdownItem]', { withinSubject: null })
|
||||||
|
.contains('Filter')
|
||||||
|
.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openViewViewboxes = () => {
|
||||||
|
openViewOptionsDropdown()
|
||||||
|
cy.get('[clrDropdownItem]', { withinSubject: null })
|
||||||
|
.contains('Viewboxes')
|
||||||
|
.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cy.contains retries until a matching node appears/updates - the open
|
||||||
|
// viewbox's own list entry can render before its table-name text has
|
||||||
|
// actually loaded, so a one-shot .then() lookup here would race it.
|
||||||
const openViewboxConfig = (viewbox_tablename: string) => {
|
const openViewboxConfig = (viewbox_tablename: string) => {
|
||||||
cy.get('.open-viewbox').then((viewboxes: any) => {
|
cy.contains('.open-viewbox', viewbox_tablename, {
|
||||||
for (let openViewbox of viewboxes) {
|
matchCase: false,
|
||||||
if (openViewbox.innerText.toLowerCase().includes(viewbox_tablename))
|
timeout: longerCommandTimeout
|
||||||
openViewbox.click()
|
}).click()
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
||||||
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
||||||
.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) {
|
// Small settle wait: right after a fresh visit (an extra navigation on
|
||||||
if (new RegExp(libNameIncludes).test(node.innerText.toLowerCase())) {
|
// top of beforeEach's own, e.g. test 7's visitPage('view/data')), the
|
||||||
viyaLib = node
|
// tree component can still be re-rendering, causing a node to be found
|
||||||
break
|
// then swapped out mid-click - same fix already proven in
|
||||||
}
|
// viewer-labels.cy.ts.
|
||||||
}
|
cy.wait(300)
|
||||||
|
|
||||||
cy.get(viyaLib).within(() => {
|
const libraryNodeSelector = '.nav-tree clr-tree > clr-tree-node'
|
||||||
cy.get('.clr-tree-node-content-container p').click()
|
const libraryMatcher = new RegExp(libNameIncludes, 'i')
|
||||||
|
|
||||||
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
cy.contains(libraryNodeSelector, libraryMatcher, {
|
||||||
for (let innerNode of innerNodes) {
|
timeout: longerCommandTimeout
|
||||||
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
})
|
||||||
innerNode.click()
|
.find('.clr-tree-node-content-container p')
|
||||||
break
|
.click()
|
||||||
}
|
|
||||||
}
|
// Re-query the library node fresh rather than reusing the reference
|
||||||
})
|
// from before the click above - expanding a library can replace its
|
||||||
})
|
// whole DOM subtree once its child tables load (that's what made the
|
||||||
})
|
// earlier cached-reference version of this helper flaky: Cypress
|
||||||
})
|
// detected the click's own side effect detaching the element it was
|
||||||
|
// still verifying actionability against).
|
||||||
|
cy.contains(libraryNodeSelector, libraryMatcher, {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).within(() => {
|
||||||
|
cy.contains('.clr-treenode-link', tablename, {
|
||||||
|
matchCase: false,
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).click()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const setFilterWithValue = (
|
const setFilterWithValue = (
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -4,7 +4,11 @@ PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,SOME_NUM,SOME_DATE,SOME_DATETIME,SOME_
|
|||||||
2,even more dummy data,Option 3,42,12FEB1960,01JAN1960:00:00:42,0:02:22,3,44
|
2,even more dummy data,Option 3,42,12FEB1960,01JAN1960:00:00:42,0:02:22,3,44
|
||||||
3,"It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told:",Option 2,1613.001,27FEB1961,01JAN1960:00:07:03,0:00:44,3,44
|
3,"It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told:",Option 2,1613.001,27FEB1961,01JAN1960:00:07:03,0:00:44,3,44
|
||||||
4,if you can fill the unforgiving minute,Option 1,1613.0011235,02AUG1971,29MAY1973:06:12:03,0:06:52,3,44
|
4,if you can fill the unforgiving minute,Option 1,1613.0011235,02AUG1971,29MAY1973:06:12:03,0:06:52,3,44
|
||||||
1010,10 bottles of beer on the wall,Option 1,0.9153696885,04MAR1962,01JAN1960:12:47:55,0:01:40,92,76
|
1010,"10 bottles of beer
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
on the wall",Option 1,0.9153696885,04MAR1962,01JAN1960:12:47:55,0:01:40,92,76
|
||||||
1011,11 bottles of beer on the wall,Option 1,0.3531217558,29MAR1960,01JAN1960:03:33:24,0:01:03,80,29
|
1011,11 bottles of beer on the wall,Option 1,0.3531217558,29MAR1960,01JAN1960:03:33:24,0:01:03,80,29
|
||||||
1012,12 bottles of beer on the wall,Option 1,0.6743748717,02AUG1962,01JAN1960:07:25:59,0:00:10,16,98
|
1012,12 bottles of beer on the wall,Option 1,0.6743748717,02AUG1962,01JAN1960:07:25:59,0:00:10,16,98
|
||||||
1013,13 bottles of beer on the wall,Option 1,0.1305445992,11SEP1960,01JAN1960:13:51:32,0:00:35,73,15
|
1013,13 bottles of beer on the wall,Option 1,0.1305445992,11SEP1960,01JAN1960:13:51:32,0:00:35,73,15
|
||||||
|
|||||||
|
Binary file not shown.
Binary file not shown.
@@ -1,255 +0,0 @@
|
|||||||
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}`)
|
|
||||||
const fixturePath = 'excels_general/'
|
|
||||||
const downloadsFolder = Cypress.config('downloadsFolder')
|
|
||||||
|
|
||||||
import { deleteDownloadsFolder } from '../util/deleteDownloadFolder'
|
|
||||||
|
|
||||||
context('download files test: ', function () {
|
|
||||||
this.beforeAll(() => {
|
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
|
||||||
cy.loginAndUpdateValidKey()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.beforeEach(() => {
|
|
||||||
cy.visit(hostUrl + appLocation)
|
|
||||||
cy.get('input.username').type(username)
|
|
||||||
cy.get('input.password').type(password)
|
|
||||||
cy.get('.login-group button').click()
|
|
||||||
|
|
||||||
visitPage('home')
|
|
||||||
})
|
|
||||||
|
|
||||||
this.afterEach(() => {
|
|
||||||
deleteDownloadsFolder()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('1 | downloads audit file', (done) => {
|
|
||||||
visitPage('approve/toapprove')
|
|
||||||
|
|
||||||
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
|
||||||
.should('not.exist')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.btn.btn-success')
|
|
||||||
.should('be.visible')
|
|
||||||
.then((buttons) => {
|
|
||||||
buttons[0].click()
|
|
||||||
const id = buttons[0].id
|
|
||||||
|
|
||||||
checkForFileDownloaded(id, 'zip', () => done())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('2 | downloads viewer csv', (done) => {
|
|
||||||
visitPage('view/data')
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openDownloadModal(() => {
|
|
||||||
cy.get('select')
|
|
||||||
.select('CSV')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
|
||||||
button.trigger('click')
|
|
||||||
|
|
||||||
const id = button[0].id
|
|
||||||
|
|
||||||
checkForFileDownloaded(id, 'csv', () => done())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('3 | downloads viewer excel', (done) => {
|
|
||||||
visitPage('view/data')
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openDownloadModal(() => {
|
|
||||||
cy.get('select')
|
|
||||||
.select('Excel')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
|
||||||
button.trigger('click')
|
|
||||||
|
|
||||||
const id = button[0].id
|
|
||||||
|
|
||||||
checkForFileDownloaded(id, 'xlsx', () => done())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('4 | downloads viewer SAS Datalines', (done) => {
|
|
||||||
visitPage('view/data')
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openDownloadModal(() => {
|
|
||||||
cy.get('select')
|
|
||||||
.select('SAS Datalines')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
|
||||||
button.trigger('click')
|
|
||||||
|
|
||||||
const id = button[0].id
|
|
||||||
|
|
||||||
checkForFileDownloaded(id, 'sas', () => done())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('5 | downloads viewer SAS DDL', (done) => {
|
|
||||||
visitPage('view/data')
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openDownloadModal(() => {
|
|
||||||
cy.get('select')
|
|
||||||
.select('SAS DDL')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
|
||||||
button.trigger('click')
|
|
||||||
|
|
||||||
const id = button[0].id
|
|
||||||
|
|
||||||
checkForFileDownloaded(id, 'ddl', () => done(), '_')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('6 | downloads viewer TSQL DDL', (done) => {
|
|
||||||
visitPage('view/data')
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openDownloadModal(() => {
|
|
||||||
cy.get('select')
|
|
||||||
.select('TSQL DDL')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
|
||||||
button.trigger('click')
|
|
||||||
|
|
||||||
const id = button[0].id
|
|
||||||
|
|
||||||
checkForFileDownloaded(id, 'ddl', () => done(), '_')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('7 | downloads viewer PGSQL DDL', (done) => {
|
|
||||||
visitPage('view/data')
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openDownloadModal(() => {
|
|
||||||
cy.get('select')
|
|
||||||
.select('PGSQL DDL')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
|
|
||||||
button.trigger('click')
|
|
||||||
|
|
||||||
const id = button[0].id
|
|
||||||
|
|
||||||
checkForFileDownloaded(id, 'ddl', () => done(), '_')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
this.afterEach(() => {
|
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.afterAll(() => {
|
|
||||||
cy.visit(`https://sas.4gl.io/mihmed/cypress_finish`)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const visitPage = (url: string) => {
|
|
||||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkForFileDownloaded = (
|
|
||||||
id: string,
|
|
||||||
extension: string,
|
|
||||||
callback?: any,
|
|
||||||
libDivider: string = '.'
|
|
||||||
) => {
|
|
||||||
cy.on('url:changed', (newUrl) => {
|
|
||||||
console.log('newUrl', newUrl)
|
|
||||||
})
|
|
||||||
|
|
||||||
id = id.replace('.', libDivider)
|
|
||||||
|
|
||||||
const filename = downloadsFolder + '/' + id + '.' + extension
|
|
||||||
// browser might take a while to download the file,
|
|
||||||
// so use "cy.readFile" to retry until the file exists
|
|
||||||
// and has length - and we assume that it has finished downloading then
|
|
||||||
cy.readFile(filename, { timeout: longerCommandTimeout })
|
|
||||||
.should('have.length.gt', 10)
|
|
||||||
.then((file) => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openDownloadModal = (callback?: any) => {
|
|
||||||
cy.get('.btn.btn-sm.btn-outline.filterSide.dropdown-toggle')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('clr-dropdown-menu button').then((buttons) => {
|
|
||||||
for (let button of buttons) {
|
|
||||||
if (button.innerText.toLowerCase().includes('download')) {
|
|
||||||
button.click()
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('viyaLib', viyaLib)
|
|
||||||
|
|
||||||
cy.get(viyaLib).within(() => {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
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}`)
|
|
||||||
const fixturePath = 'excels_general/'
|
|
||||||
|
|
||||||
context('editor tests: ', function () {
|
|
||||||
this.beforeAll(() => {
|
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
|
||||||
cy.loginAndUpdateValidKey()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.beforeEach(() => {
|
|
||||||
cy.visit(hostUrl + appLocation)
|
|
||||||
|
|
||||||
visitPage('home')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('1 | Submits duplicate primary keys', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_datadictionary')
|
|
||||||
|
|
||||||
attachExcelFile('MPE_DATADICTIONARY_duplicate_keys.xlsx', () => {
|
|
||||||
clickOnUploadPreview(() => {
|
|
||||||
confirmEditPreviewFile(() => {
|
|
||||||
submitTable(() => {
|
|
||||||
cy.get('.modal-body').then((modalBody: any) => {
|
|
||||||
if (modalBody[0].innerText.includes(`Duplicates found:`)) {
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('2 | Submits null cells which must not be null', (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[1].childNodes[2])
|
|
||||||
.dblclick({ force: true })
|
|
||||||
.then(() => {
|
|
||||||
cy.focused()
|
|
||||||
.clear()
|
|
||||||
.type('{enter}')
|
|
||||||
.then(() => {
|
|
||||||
submitTable(() => {
|
|
||||||
cy.get('.modal-body').then((modalBody: any) => {
|
|
||||||
if (
|
|
||||||
modalBody[0].innerHTML
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(`invalid values are present`)
|
|
||||||
) {
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('3 | Gets basic dynamic cell validation', () => {
|
|
||||||
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[1].childNodes[5])
|
|
||||||
.click({ force: true })
|
|
||||||
.then(($td) => {
|
|
||||||
cy.get('.htAutocompleteArrow', { withinSubject: $td }).should(
|
|
||||||
'exist'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('4 | Gets advanced dynamic cell validation', () => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_tables')
|
|
||||||
|
|
||||||
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[1].childNodes[3])
|
|
||||||
.click({ force: true })
|
|
||||||
.then(($td) => {
|
|
||||||
cy.get('.htAutocompleteArrow', { withinSubject: $td }).should(
|
|
||||||
'exist'
|
|
||||||
)
|
|
||||||
cy.get('.htAutocompleteArrow', {
|
|
||||||
withinSubject: rows[1].childNodes[7]
|
|
||||||
}).should('exist')
|
|
||||||
cy.get('.htAutocompleteArrow', {
|
|
||||||
withinSubject: rows[1].childNodes[8]
|
|
||||||
}).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 attachExcelFile = (excelFilename: string, callback?: any) => {
|
|
||||||
cy.get('.buttonBar button:last-child')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('input[type="file"]#file-upload')
|
|
||||||
.attachFile(`/${fixturePath}/${excelFilename}`)
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.modal-footer .btn.btn-primary').then((modalBtn) => {
|
|
||||||
modalBtn.click()
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const clickOnUploadPreview = (callback?: any) => {
|
|
||||||
cy.get('.buttonBar button.btn-primary.btn-upload-preview')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmEditPreviewFile = (callback?: any) => {
|
|
||||||
cy.get('.modal-footer button.btn-success-outline')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitTable = (callback?: any) => {
|
|
||||||
cy.get('.btnCtrl button.btn-primary')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitTableMessage = (callback?: any) => {
|
|
||||||
cy.get('.modal-footer .btn.btn-sm.btn-success-outline')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitExcel = (callback?: any) => {
|
|
||||||
cy.get('.buttonBar button.preview-submit')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const rejectExcel = (callback?: any) => {
|
|
||||||
cy.get('button', { timeout: longerCommandTimeout })
|
|
||||||
.should('contain', 'Approve')
|
|
||||||
.then((allButtons: any) => {
|
|
||||||
for (let approvalButton of allButtons) {
|
|
||||||
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
|
||||||
approvalButton.click()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get('button.btn-danger')
|
|
||||||
.should('exist')
|
|
||||||
.should('not.be.disabled')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.modal-footer button.btn-success-outline')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('app-history')
|
|
||||||
.should('exist')
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const visitPage = (url: string) => {
|
|
||||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
|
||||||
}
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
import { Callbacks } from 'cypress/types/jquery/index'
|
|
||||||
|
|
||||||
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}`)
|
|
||||||
const fixturePath = 'excels/'
|
|
||||||
|
|
||||||
// TODO: 4 and 9 failing
|
|
||||||
|
|
||||||
context('excel tests: ', function () {
|
|
||||||
this.beforeAll(() => {
|
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
|
||||||
cy.loginAndUpdateValidKey()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.beforeEach(() => {
|
|
||||||
cy.visit(hostUrl + appLocation)
|
|
||||||
|
|
||||||
visitPage('home')
|
|
||||||
|
|
||||||
colorLog(
|
|
||||||
`TEST START ---> ${
|
|
||||||
Cypress.mocha.getRunner().suite.ctx.currentTest.title
|
|
||||||
}`,
|
|
||||||
'#3498DB'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('1 | Uploads regular Excel file', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('regular_excel.xlsx', () => {
|
|
||||||
submitExcel()
|
|
||||||
rejectExcel(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('2 | Uploads Excel with data on the 7th tab', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('7th_tab_excel.xlsx', () => {
|
|
||||||
submitExcel()
|
|
||||||
rejectExcel(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('3 | Uploads Excel with missing columns (should fail)', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('missing_columns_excel.xlsx', () => {
|
|
||||||
cy.get('.abortMsg', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.then((elements: any) => {
|
|
||||||
if (elements[0]) {
|
|
||||||
if (elements[0].innerText.toLowerCase().includes('missing')) done()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('4 | Uploads Excel with formulas', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_datadictionary')
|
|
||||||
|
|
||||||
attachExcelFile('formulas_excel.xlsx', () => {
|
|
||||||
checkResultOfFormulaUpload(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('5 | Uploads Excel with no data rows', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('nodata_rows_excel.xlsx', () => {
|
|
||||||
cy.get('.abortMsg', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.then((elements: any) => {
|
|
||||||
if (elements[0]) {
|
|
||||||
if (
|
|
||||||
elements[0].innerText
|
|
||||||
.toLowerCase()
|
|
||||||
.includes('no relevant data found')
|
|
||||||
)
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('6 | Uploads Excel with a table that is surrounded by other data', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('surrounded_data_excel.xlsx', () => {
|
|
||||||
submitExcel()
|
|
||||||
rejectExcel(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('7 | Uploads Excel with a extra columns in the middle', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('extra_column_excel.xlsx', () => {
|
|
||||||
submitExcel()
|
|
||||||
rejectExcel(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('8 | Uploads Excel with a duplicate column', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('duplicate_column_excel.xlsx', () => {
|
|
||||||
cy.get('.abortMsg', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.then((elements: any) => {
|
|
||||||
if (elements[0]) {
|
|
||||||
if (elements[0].innerText.toLowerCase().includes('missing')) done()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// it('9 | Uploads Excel with a duplicate row', (done) => {
|
|
||||||
// openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
// attachExcelFile('duplicate_row_excel.xlsx', () => {
|
|
||||||
// submitExcel(() => {
|
|
||||||
// cy.get('.abortMsg', { timeout: longerCommandTimeout })
|
|
||||||
// .should('exist')
|
|
||||||
// .then((elements: any) => {
|
|
||||||
// if (elements[0]) {
|
|
||||||
// if (elements[0].innerText.toLowerCase().includes('duplicates'))
|
|
||||||
// done()
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
|
|
||||||
it('10 | Uploads Excel with a mixed content', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('mixed_content_excel.xlsx', () => {
|
|
||||||
submitExcel(() => {
|
|
||||||
cy.get('.modal-body').then((modalBody: any) => {
|
|
||||||
if (
|
|
||||||
modalBody[0].innerHTML
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(`invalid values are present`)
|
|
||||||
) {
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('11 | Uploads Excel with a blank columns', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('blank_columns_excel.xlsx', () => {
|
|
||||||
cy.get('.abortMsg', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.then((elements: any) => {
|
|
||||||
if (elements[0]) {
|
|
||||||
if (elements[0].innerText.toLowerCase().includes('missing')) done()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('12 | Uploads Excel xls extension', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('regular_excel_xls.xls', () => {
|
|
||||||
submitExcel()
|
|
||||||
rejectExcel(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// For some strange reason this file breaks cypress. When uploaded manually in DC it is working.
|
|
||||||
// it('13 | Uploads Excel xlsm extension', (done) => {
|
|
||||||
// openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
// attachExcelFile('regular_excel_macro.xlsm', () => {
|
|
||||||
// submitExcel()
|
|
||||||
// rejectExcel(done)
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
|
|
||||||
it('14 | Uploads Excel with composite primary key', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_datadictionary')
|
|
||||||
|
|
||||||
attachExcelFile('MPE_DATADICTIONARY_composite_keys.xlsx', () => {
|
|
||||||
submitExcel()
|
|
||||||
rejectExcel(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('15 | Uploads Excel with missing row (empty table)', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_datadictionary')
|
|
||||||
|
|
||||||
attachExcelFile('MPE_DATADICTIONARY_missing_row.xlsx', () => {
|
|
||||||
cy.get('.abortMsg', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.then((elements: any) => {
|
|
||||||
if (elements[0]) {
|
|
||||||
if (
|
|
||||||
elements[0].innerText
|
|
||||||
.toLowerCase()
|
|
||||||
.includes('no relevant data found')
|
|
||||||
)
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('16 | Uploads Excel with merged cells', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_datadictionary')
|
|
||||||
|
|
||||||
attachExcelFile('MPE_DATADICTIONARY_merged_cells.xlsx', () => {
|
|
||||||
submitExcel()
|
|
||||||
rejectExcel(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('17 | Check uploaded values from excel with xls extension', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('regular_excel_xls.xls', () => {
|
|
||||||
checkResultOfXLSUpload(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('18 | Uploads Excel with missing row (empty table)', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('blank_column_with_header.xlsx', () => {
|
|
||||||
cy.get('.btn-upload-preview', { timeout: 60000 })
|
|
||||||
.should('be.visible')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('#hotInstance', { timeout: 30000 })
|
|
||||||
.find('div.ht_master.handsontable')
|
|
||||||
.find('div.wtHolder')
|
|
||||||
.find('div.wtHider')
|
|
||||||
.find('div.wtSpreader')
|
|
||||||
.find('table.htCore')
|
|
||||||
.find('tbody')
|
|
||||||
.should((data) => {
|
|
||||||
let allEmpty = true
|
|
||||||
|
|
||||||
for (let col = 0; col < data[0].children.length; col++) {
|
|
||||||
const cell: any = data[0].children[col].children[5]
|
|
||||||
|
|
||||||
if (cell.innerText !== '') {
|
|
||||||
allEmpty = false
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allEmpty) done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('19 | Uploads Excel with data on random sheet surrounded with all empty cells', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('surrounded_data_all_cells_empty_excel.xlsx', () => {
|
|
||||||
checkResultOfXLSUpload(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('20 | Uploads Excel with data surrounded with empty cells ', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('surrounded_data_empty_cells_excel.xlsx', () => {
|
|
||||||
checkResultOfXLSUpload(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('21 | Uploads regular Excel file with first row marked for Delete (yes)', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('regular_excel_with_delete.xlsx', () => {
|
|
||||||
cy.get('.btn-upload-preview', { timeout: 60000 })
|
|
||||||
.should('be.visible')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('#hotInstance', { timeout: 30000 })
|
|
||||||
.find('div.ht_master.handsontable')
|
|
||||||
.find('div.wtHolder')
|
|
||||||
.find('div.wtHider')
|
|
||||||
.find('div.wtSpreader')
|
|
||||||
.find('table.htCore')
|
|
||||||
.find('tbody')
|
|
||||||
.should((data: JQuery<HTMLTableSectionElement>) => {
|
|
||||||
const firstRowFirstCol: Partial<HTMLElement> =
|
|
||||||
data[0].children[0].children[1]
|
|
||||||
|
|
||||||
if (
|
|
||||||
firstRowFirstCol.innerText &&
|
|
||||||
!firstRowFirstCol.innerText.toLowerCase().includes('yes')
|
|
||||||
) {
|
|
||||||
done('Delete? column from file not applied')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
submitExcel()
|
|
||||||
rejectExcel(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Large files break Cypress
|
|
||||||
|
|
||||||
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
|
||||||
// attachExcelFile('5mb_excel.xlsx', () => {
|
|
||||||
// submitExcel();
|
|
||||||
// rejectExcel(done);
|
|
||||||
// });
|
|
||||||
// })
|
|
||||||
|
|
||||||
// it ('? | Uploads Excel with size of 15MB', (done) => {
|
|
||||||
// attachExcelFile('15mb_excel.xlsx', () => {
|
|
||||||
// submitExcel();
|
|
||||||
// rejectExcel(done);
|
|
||||||
// });
|
|
||||||
// })
|
|
||||||
|
|
||||||
//Large files tests end
|
|
||||||
|
|
||||||
this.afterEach(() => {
|
|
||||||
colorLog(`TEST END -------------`, '#3498DB')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
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 attachExcelFile = (excelFilename: string, callback?: any) => {
|
|
||||||
cy.get('.buttonBar button:last-child')
|
|
||||||
.should('exist')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('input[type="file"]#file-upload')
|
|
||||||
.attachFile(`/${fixturePath}/${excelFilename}`)
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.clr-abort-modal .modal-title').then((modalTitle) => {
|
|
||||||
if (!modalTitle[0].innerHTML.includes('Abort Message')) {
|
|
||||||
cy.get('.modal-footer .btn.btn-primary').then((modalBtn) => {
|
|
||||||
modalBtn.click()
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
if (callback) callback()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitExcel = (callback?: any) => {
|
|
||||||
cy.get('.buttonBar button.preview-submit', { timeout: longerCommandTimeout })
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const rejectExcel = (callback?: any) => {
|
|
||||||
cy.get('button', { timeout: longerCommandTimeout })
|
|
||||||
.should('contain', 'Approve')
|
|
||||||
.then((allButtons: any) => {
|
|
||||||
for (let approvalButton of allButtons) {
|
|
||||||
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
|
||||||
approvalButton.click()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get('button.btn-danger')
|
|
||||||
.should('exist')
|
|
||||||
.should('not.be.disabled')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.modal-footer button.btn-success-outline')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('app-history')
|
|
||||||
.should('exist')
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const acceptExcel = (callback?: any) => {
|
|
||||||
cy.get('button', { timeout: longerCommandTimeout })
|
|
||||||
.should('contain', 'Approve')
|
|
||||||
.then((allButtons: any) => {
|
|
||||||
for (let approvalButton of allButtons) {
|
|
||||||
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
|
||||||
approvalButton.click()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get('#acceptBtn')
|
|
||||||
.should('exist')
|
|
||||||
.should('not.be.disabled')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
if (callback) {
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkResultOfFormulaUpload = (callback?: any) => {
|
|
||||||
cy.get('#hotInstance', { timeout: longerCommandTimeout })
|
|
||||||
.find('div.ht_master.handsontable')
|
|
||||||
.find('div.wtHolder')
|
|
||||||
.find('div.wtHider')
|
|
||||||
.find('div.wtSpreader')
|
|
||||||
.find('table.htCore')
|
|
||||||
.find('tbody')
|
|
||||||
.should((data) => {
|
|
||||||
const cell: any = data[0].children[0].children[5]
|
|
||||||
expect(cell.innerText).to.equal('=1+1')
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkResultOfXLSUpload = (callback?: any) => {
|
|
||||||
cy.viewport(1280, 720)
|
|
||||||
cy.get('#hotInstance', { timeout: 30000 })
|
|
||||||
.find('div.ht_master.handsontable')
|
|
||||||
.find('div.wtHolder')
|
|
||||||
.find('div.wtHider')
|
|
||||||
.find('div.wtSpreader')
|
|
||||||
.find('table.htCore')
|
|
||||||
.find('tbody')
|
|
||||||
.should((data) => {
|
|
||||||
let cell: any = data[0].children[0].children[2]
|
|
||||||
|
|
||||||
expect(cell.innerText).to.equal('0')
|
|
||||||
cell = data[0].children[0].children[3]
|
|
||||||
expect(cell.innerText).to.equal('this is dummy data changed in excel')
|
|
||||||
cell = data[0].children[0].children[4]
|
|
||||||
expect(cell.innerText).to.equal('▼\nOption 1')
|
|
||||||
cell = data[0].children[0].children[5]
|
|
||||||
expect(cell.innerText).to.equal('42')
|
|
||||||
cell = data[0].children[0].children[6]
|
|
||||||
expect(cell.innerText).to.equal('▼\n1960-02-12')
|
|
||||||
cell = data[0].children[0].children[7]
|
|
||||||
expect(cell.innerText).to.equal('▼\n1960-01-01 00:00:42')
|
|
||||||
cell = data[0].children[0].children[8]
|
|
||||||
expect(cell.innerText).to.equal('00:00:42')
|
|
||||||
cell = data[0].children[0].children[9]
|
|
||||||
expect(cell.innerText).to.equal('3')
|
|
||||||
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.get('#hotInstance', { timeout: 30000 })
|
|
||||||
.find('div.ht_master.handsontable')
|
|
||||||
.find('div.wtHolder')
|
|
||||||
.scrollTo('right')
|
|
||||||
.find('div.wtHider')
|
|
||||||
.find('div.wtSpreader')
|
|
||||||
.find('table.htCore')
|
|
||||||
.find('tbody')
|
|
||||||
.should((data) => {
|
|
||||||
let cell: any = data[0].children[0].children[1]
|
|
||||||
|
|
||||||
cell = data[0].children[0].children[9]
|
|
||||||
|
|
||||||
expect(cell.innerText).to.equal('44')
|
|
||||||
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const visitPage = (url: string) => {
|
|
||||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorLog = (msg: string, color: string) => {
|
|
||||||
console.log('%c' + msg, 'color:' + color + ';font-weight:bold;')
|
|
||||||
}
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
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}`)
|
|
||||||
const fixturePath = 'excels_general/'
|
|
||||||
|
|
||||||
context('filtering tests: ', function () {
|
|
||||||
this.beforeAll(() => {
|
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout })
|
|
||||||
cy.loginAndUpdateValidKey()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.beforeEach(() => {
|
|
||||||
cy.visit(hostUrl + appLocation, { timeout: longerCommandTimeout })
|
|
||||||
|
|
||||||
visitPage('home')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('1 | filter char field', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openFilterPopup(() => {
|
|
||||||
setFilterWithValue('SOME_CHAR', 'this is dummy data', 'value', () => {
|
|
||||||
checkInfoBarIncludes(
|
|
||||||
`AND,AND,0,SOME_CHAR,=,"'this is dummy data'"`,
|
|
||||||
(includes: boolean) => {
|
|
||||||
if (includes) done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('2 | filter number field', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openFilterPopup(() => {
|
|
||||||
setFilterWithValue('SOME_NUM', '42', 'value', () => {
|
|
||||||
checkInfoBarIncludes(`AND,AND,0,SOME_NUM,=,42`, (includes: boolean) => {
|
|
||||||
if (includes) done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.only('3 | filter time field', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openFilterPopup(() => {
|
|
||||||
setFilterWithValue('SOME_TIME', '00:00:42', 'time', () => {
|
|
||||||
checkInfoBarIncludes(
|
|
||||||
`AND,AND,0,SOME_TIME,=,42`,
|
|
||||||
(includes: boolean) => {
|
|
||||||
if (includes) done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('3.1 | Non picker - filter time field', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openFilterPopup(() => {
|
|
||||||
setFilterWithValue('SOME_TIME', '42', 'value', () => {
|
|
||||||
checkInfoBarIncludes(
|
|
||||||
`AND,AND,0,SOME_TIME,=,42`,
|
|
||||||
(includes: boolean) => {
|
|
||||||
if (includes) done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('4 | filter date field', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openFilterPopup(() => {
|
|
||||||
setFilterWithValue('SOME_DATE', '12/02/1960', 'date', () => {
|
|
||||||
checkInfoBarIncludes(
|
|
||||||
`AND,AND,0,SOME_DATE,=,42`,
|
|
||||||
(includes: boolean) => {
|
|
||||||
if (includes) done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('4.1 | Non picker - filter date field', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openFilterPopup(() => {
|
|
||||||
setFilterWithValue('SOME_DATE', '42', 'value', () => {
|
|
||||||
checkInfoBarIncludes(
|
|
||||||
`AND,AND,0,SOME_DATE,=,42`,
|
|
||||||
(includes: boolean) => {
|
|
||||||
if (includes) done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('5 | filter datetime field', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openFilterPopup(() => {
|
|
||||||
setFilterWithValue(
|
|
||||||
'SOME_DATETIME',
|
|
||||||
'01/01/1960 00:00:42',
|
|
||||||
'datetime',
|
|
||||||
() => {
|
|
||||||
checkInfoBarIncludes(
|
|
||||||
`AND,AND,0,SOME_DATETIME,=,42`,
|
|
||||||
(includes: boolean) => {
|
|
||||||
if (includes) done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('5.1 | Non picker - filter datetime field', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openFilterPopup(() => {
|
|
||||||
setFilterWithValue('SOME_DATETIME', '42', 'value', () => {
|
|
||||||
checkInfoBarIncludes(
|
|
||||||
`AND,AND,0,SOME_DATETIME,=,42`,
|
|
||||||
(includes: boolean) => {
|
|
||||||
if (includes) done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('6 | filter date field IN', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openFilterPopup(() => {
|
|
||||||
setFilterWithValue('SOME_DATE', '', 'in', () => {
|
|
||||||
checkInfoBarIncludes(
|
|
||||||
`AND,AND,0,SOME_DATE,IN,(0)`,
|
|
||||||
(includes: boolean) => {
|
|
||||||
if (includes) done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('7 | filter bestnum field BETWEEN', (done) => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
openFilterPopup(() => {
|
|
||||||
setFilterWithValue('SOME_BESTNUM', '0-10', 'between', () => {
|
|
||||||
checkInfoBarIncludes(
|
|
||||||
`AND,AND,0,SOME_BESTNUM,BETWEEN,0 AND 10`,
|
|
||||||
(includes: boolean) => {
|
|
||||||
if (includes) done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const checkInfoBarIncludes = (text: string, callback: any) => {
|
|
||||||
cy.get('.infoBar b', { timeout: longerCommandTimeout }).then((el: any) => {
|
|
||||||
const includes = el[0].innerText.toLowerCase().includes(text.toLowerCase())
|
|
||||||
|
|
||||||
if (callback) callback(includes)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openFilterPopup = (
|
|
||||||
callback?: any,
|
|
||||||
usePickers: boolean = true,
|
|
||||||
isViewerFiltering: boolean = false
|
|
||||||
) => {
|
|
||||||
const filterButton = isViewerFiltering
|
|
||||||
? '.btn-outline.filterSide'
|
|
||||||
: '.btnCtrl .btnView'
|
|
||||||
|
|
||||||
cy.get(filterButton, { timeout: longerCommandTimeout }).then(
|
|
||||||
(optionsButton: any) => {
|
|
||||||
optionsButton.click()
|
|
||||||
|
|
||||||
if (isViewerFiltering) {
|
|
||||||
cy.wait(300)
|
|
||||||
|
|
||||||
cy.get('.dropdown-menu button').then(async (dropdownButtons: any) => {
|
|
||||||
let filterButton = null
|
|
||||||
|
|
||||||
for (let btn of dropdownButtons) {
|
|
||||||
if (btn.innerText.toLowerCase().includes('filter')) {
|
|
||||||
filterButton = btn
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterButton) {
|
|
||||||
filterButton.click()
|
|
||||||
|
|
||||||
if (usePickers) turnOnPickers()
|
|
||||||
|
|
||||||
if (callback) callback()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usePickers) turnOnPickers()
|
|
||||||
if (callback) callback()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const turnOnPickers = () => {
|
|
||||||
cy.get('#usePickers')
|
|
||||||
.should('exist')
|
|
||||||
.then((picker: any) => {
|
|
||||||
picker[0].click()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const setFilterWithValue = (
|
|
||||||
variableValue: string,
|
|
||||||
valueString: string,
|
|
||||||
valueField: 'value' | 'time' | 'date' | 'datetime' | 'in' | 'between',
|
|
||||||
callback?: any
|
|
||||||
) => {
|
|
||||||
cy.wait(600)
|
|
||||||
|
|
||||||
cy.focused().type(variableValue)
|
|
||||||
cy.wait(100)
|
|
||||||
// cy.focused().trigger('input')
|
|
||||||
cy.get('.variable-col .autocomplete-wrapper', { withinSubject: null })
|
|
||||||
.first()
|
|
||||||
.trigger('keydown', { key: 'ArrowDown' })
|
|
||||||
cy.get('.variable-col .autocomplete-wrapper', {
|
|
||||||
withinSubject: null
|
|
||||||
}).trigger('keydown', { key: 'Enter' })
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.wait(100)
|
|
||||||
|
|
||||||
if (valueField === 'in') {
|
|
||||||
cy.focused().select(valueField.toUpperCase()).trigger('change')
|
|
||||||
} else if (valueField === 'between') {
|
|
||||||
cy.focused().select(valueField.toUpperCase()).trigger('change')
|
|
||||||
} else {
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.wait(100)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (valueField) {
|
|
||||||
case 'value': {
|
|
||||||
cy.focused().type(valueString)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'time': {
|
|
||||||
cy.focused().type(valueString)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'date': {
|
|
||||||
cy.focused().type(valueString)
|
|
||||||
cy.focused().tab()
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'datetime': {
|
|
||||||
const date = valueString.split(' ')[0]
|
|
||||||
const time = valueString.split(' ')[1]
|
|
||||||
|
|
||||||
cy.focused().type(date)
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.focused().type(time)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'in': {
|
|
||||||
cy.get('.checkbox-vals').then(() => {
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.focused().click()
|
|
||||||
cy.get('.no-values')
|
|
||||||
.should('not.exist')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('clr-checkbox-wrapper input').then((inputs: any) => {
|
|
||||||
inputs[0].click()
|
|
||||||
cy.get('.in-values-modal .modal-footer button').click()
|
|
||||||
|
|
||||||
cy.get('.modal-footer .btn-success-outline').click()
|
|
||||||
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'between': {
|
|
||||||
cy.focused().tab()
|
|
||||||
|
|
||||||
const start = valueString.split('-')[0]
|
|
||||||
const end = valueString.split('-')[1]
|
|
||||||
|
|
||||||
cy.focused().type(start)
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.focused().type(end)
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.wait(100)
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.wait(100)
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.wait(100)
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.wait(100)
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.wait(100)
|
|
||||||
cy.focused().click()
|
|
||||||
|
|
||||||
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 (new RegExp(libNameIncludes).test(node.innerText.toLowerCase())) {
|
|
||||||
viyaLib = node
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get(viyaLib).within(() => {
|
|
||||||
cy.get('.clr-tree-node-content-container p').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}`)
|
|
||||||
}
|
|
||||||
@@ -1,719 +0,0 @@
|
|||||||
import { arrayBufferToBase64 } from './../util/helper-functions'
|
|
||||||
import * as moment from 'moment'
|
|
||||||
|
|
||||||
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 fixturePath = 'excels_general/'
|
|
||||||
const serverType = Cypress.env('serverType')
|
|
||||||
const site_id = Cypress.env(`site_id_${serverType}`)
|
|
||||||
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
|
||||||
const testLicenceUserLimits = Cypress.env('testLicenceUserLimits')
|
|
||||||
|
|
||||||
/** IMPORTANT NOTICE
|
|
||||||
* Before running tests, make sure that table `MPE_USERS` is present
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface EditConfigTableCells {
|
|
||||||
varName: string
|
|
||||||
varValue: string
|
|
||||||
}
|
|
||||||
|
|
||||||
context('licensing tests: ', function () {
|
|
||||||
this.beforeAll(() => {
|
|
||||||
cy.loginAndUpdateValidKey()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.beforeEach(() => {
|
|
||||||
cy.visit(hostUrl + appLocation)
|
|
||||||
|
|
||||||
visitPage('home')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('1 | key valid, not expired', (done) => {
|
|
||||||
let keyData = {
|
|
||||||
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
|
|
||||||
users_allowed: 4,
|
|
||||||
hot_license_key: '',
|
|
||||||
demo: false,
|
|
||||||
site_id: site_id
|
|
||||||
}
|
|
||||||
|
|
||||||
let keys: { licenseKey: any; activationKey: any }
|
|
||||||
generateKeys(keyData, (keysGen: any) => {
|
|
||||||
keys = keysGen
|
|
||||||
|
|
||||||
cy.wait(2000)
|
|
||||||
|
|
||||||
isLicensingPage((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
|
|
||||||
|
|
||||||
cy.wait(2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(10000)
|
|
||||||
}
|
|
||||||
|
|
||||||
visitPage('home')
|
|
||||||
|
|
||||||
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then((treeNodes: any) => {
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('2 | Key will expire in less then 14 days, not free tier', (done) => {
|
|
||||||
// make 2 separate for this one
|
|
||||||
let keyData = {
|
|
||||||
valid_until: moment().add(10, 'day').format('YYYY-MM-DD'),
|
|
||||||
users_allowed: 4,
|
|
||||||
hot_license_key: '',
|
|
||||||
demo: false,
|
|
||||||
site_id: site_id
|
|
||||||
}
|
|
||||||
|
|
||||||
let keys: { licenseKey: any; activationKey: any }
|
|
||||||
generateKeys(keyData, (keysGen: any) => {
|
|
||||||
keys = keysGen
|
|
||||||
console.log('keys', keys)
|
|
||||||
|
|
||||||
cy.wait(2000)
|
|
||||||
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
updateLicenseKeyQuick(keysGen, () => {
|
|
||||||
cy.wait(2000)
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
verifyLicensingWarning('This license key will expire in ', () => {
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('3 | key expired, free tier works', (done) => {
|
|
||||||
let keyData = {
|
|
||||||
valid_until: moment().subtract(1, 'day').format('YYYY-MM-DD'),
|
|
||||||
users_allowed: 4,
|
|
||||||
hot_license_key: '',
|
|
||||||
demo: false,
|
|
||||||
site_id: site_id
|
|
||||||
}
|
|
||||||
|
|
||||||
let keys: { licenseKey: any; activationKey: any }
|
|
||||||
generateKeys(keyData, (keysGen: any) => {
|
|
||||||
keys = keysGen
|
|
||||||
|
|
||||||
cy.wait(2000)
|
|
||||||
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
cy.wait(2000)
|
|
||||||
updateLicenseKeyQuick(keysGen, () => {
|
|
||||||
cy.wait(2000)
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
verifyLicensingPage(
|
|
||||||
'Licence key is expired - please contact',
|
|
||||||
(success: boolean) => {
|
|
||||||
if (success) {
|
|
||||||
verifyLicensingWarning(
|
|
||||||
'(FREE Tier) - Problem with licence',
|
|
||||||
() => {
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('4 | key invalid, free tier works', (done) => {
|
|
||||||
let keyData = {
|
|
||||||
valid_until: moment().subtract(1, 'day').format('YYYY-MM-DD'),
|
|
||||||
users_allowed: 4,
|
|
||||||
hot_license_key: '',
|
|
||||||
demo: false,
|
|
||||||
site_id: site_id
|
|
||||||
}
|
|
||||||
|
|
||||||
let keys: { licenseKey: any; activationKey: any }
|
|
||||||
generateKeys(keyData, (keysGen: any) => {
|
|
||||||
keys = keysGen
|
|
||||||
keys.activationKey = 'invalid' + keys.activationKey
|
|
||||||
|
|
||||||
cy.wait(2000)
|
|
||||||
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
cy.wait(2000)
|
|
||||||
updateLicenseKeyQuick(keysGen, () => {
|
|
||||||
cy.wait(2000)
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
verifyLicensingPage(
|
|
||||||
'Licence key is invalid - please contact',
|
|
||||||
(success: boolean) => {
|
|
||||||
if (success) {
|
|
||||||
verifyLicensingWarning(
|
|
||||||
'(FREE Tier) - Problem with licence',
|
|
||||||
() => {
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('5 | key for wrong organisation, free tier works', (done) => {
|
|
||||||
let keyData = {
|
|
||||||
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
|
|
||||||
users_allowed: 4,
|
|
||||||
hot_license_key: '',
|
|
||||||
demo: false,
|
|
||||||
site_id: 100
|
|
||||||
}
|
|
||||||
|
|
||||||
let keys: { licenseKey: any; activationKey: any }
|
|
||||||
generateKeys(keyData, (keysGen: any) => {
|
|
||||||
keys = keysGen
|
|
||||||
keys.activationKey = keys.activationKey
|
|
||||||
|
|
||||||
cy.wait(2000)
|
|
||||||
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
cy.wait(2000)
|
|
||||||
updateLicenseKeyQuick(keysGen, () => {
|
|
||||||
cy.wait(2000)
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
verifyLicensingPage(
|
|
||||||
'SYSSITE (below) is not found',
|
|
||||||
(success: boolean) => {
|
|
||||||
if (success) {
|
|
||||||
verifyLicensingWarning(
|
|
||||||
'(FREE Tier) - Problem with licence',
|
|
||||||
() => {
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (testLicenceUserLimits) {
|
|
||||||
it('4 | User try to register when limit is reached', (done) => {
|
|
||||||
let keyData = {
|
|
||||||
valid_until: moment().add(1, 'month').format('YYYY-MM-DD'),
|
|
||||||
users_allowed: 10,
|
|
||||||
hot_license_key: '',
|
|
||||||
demo: false,
|
|
||||||
site_id: site_id
|
|
||||||
}
|
|
||||||
let keyData2 = {
|
|
||||||
valid_until: moment().add(1, 'month').format('YYYY-MM-DD'),
|
|
||||||
users_allowed: 1,
|
|
||||||
hot_license_key: '',
|
|
||||||
demo: false,
|
|
||||||
site_id: site_id
|
|
||||||
}
|
|
||||||
generateKeys(keyData, (keysGen: any) => {
|
|
||||||
generateKeys(keyData2, (keysGen2: any) => {
|
|
||||||
cy.wait(2000)
|
|
||||||
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
updateLicenseKeyQuick(keysGen, () => {
|
|
||||||
cy.wait(2000)
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateLicenseKeyQuick(keysGen2, () => {
|
|
||||||
cy.wait(2000)
|
|
||||||
|
|
||||||
const random = Cypress._.random(0, 1000)
|
|
||||||
const newUser = {
|
|
||||||
username: `randomusername${random}notregistered`,
|
|
||||||
last_seen_at: moment().add(1, 'month').format('YYYY-MM-DD'),
|
|
||||||
registered_at: moment().add(1, 'month').format('YYYY-MM-DD')
|
|
||||||
}
|
|
||||||
updateUsersTable(
|
|
||||||
{ deleteAll: true, newUsers: [newUser] },
|
|
||||||
() => {
|
|
||||||
logout(() => {
|
|
||||||
cy.visit(hostUrl + appLocation)
|
|
||||||
// cy.get('input.username').type(username)
|
|
||||||
// cy.get('input.password').type(password)
|
|
||||||
// cy.get('.login-group button').click()
|
|
||||||
|
|
||||||
visitPage('home')
|
|
||||||
|
|
||||||
cy.wait(2000)
|
|
||||||
|
|
||||||
verifyLicensingPage(
|
|
||||||
'The registered number of users reached the limit specified for your licence.',
|
|
||||||
(success: boolean) => {
|
|
||||||
if (success) done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('5 | Show warning banner when limit is exceeded', (done) => {
|
|
||||||
let keyData = {
|
|
||||||
valid_until: moment().add(1, 'month').format('YYYY-MM-DD'),
|
|
||||||
users_allowed: 10,
|
|
||||||
hot_license_key: '',
|
|
||||||
demo: false,
|
|
||||||
site_id: site_id
|
|
||||||
}
|
|
||||||
let keyData2 = {
|
|
||||||
valid_until: moment().add(1, 'month').format('YYYY-MM-DD'),
|
|
||||||
users_allowed: 1,
|
|
||||||
hot_license_key: '',
|
|
||||||
demo: false,
|
|
||||||
site_id: site_id
|
|
||||||
}
|
|
||||||
|
|
||||||
generateKeys(keyData, (keysGen: any) => {
|
|
||||||
generateKeys(keyData2, (keysGen2: any) => {
|
|
||||||
cy.wait(2000)
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
updateLicenseKeyQuick(keysGen, () => {
|
|
||||||
cy.wait(2000)
|
|
||||||
acceptTermsIfPresented((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.wait(20000)
|
|
||||||
}
|
|
||||||
const random = Cypress._.random(0, 1000)
|
|
||||||
const newUser = {
|
|
||||||
username: `randomusername${random}`,
|
|
||||||
last_seen_at: moment().add(1, 'month').format('YYYY-MM-DD'),
|
|
||||||
registered_at: moment().add(1, 'month').format('YYYY-MM-DD')
|
|
||||||
}
|
|
||||||
updateUsersTable(
|
|
||||||
{ deleteAll: true, keep: username, newUsers: [newUser] },
|
|
||||||
() => {
|
|
||||||
updateLicenseKeyQuick(keysGen2, () => {
|
|
||||||
cy.wait(2000)
|
|
||||||
verifyLicensingWarning(
|
|
||||||
'The registered number of users exceeds the limit specified for your license.',
|
|
||||||
() => {
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
if (callback) callback(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLicensingPage = (callback: any) => {
|
|
||||||
return cy.url().then((url: string) => {
|
|
||||||
callback(
|
|
||||||
url.includes('#/licensing/') && !url.includes('licensing/register')
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const verifyLicensingPage = (text: string, callback: any) => {
|
|
||||||
// visitPage('home')
|
|
||||||
|
|
||||||
cy.wait(1000)
|
|
||||||
isLicensingPage((result: boolean) => {
|
|
||||||
if (result) {
|
|
||||||
cy.get('p.key-error')
|
|
||||||
.should('contain', text)
|
|
||||||
.then((treeNodes: any) => {
|
|
||||||
callback(true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const verifyLicensingWarning = (text: string, callback: any) => {
|
|
||||||
visitPage('home')
|
|
||||||
|
|
||||||
cy.wait(1000)
|
|
||||||
cy.get("div[role='alert'] .alert-text")
|
|
||||||
.invoke('text')
|
|
||||||
.should('contain', text)
|
|
||||||
.then(() => {
|
|
||||||
callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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('button.apply-keys').click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateUsersTable = (options: any, callback?: any) => {
|
|
||||||
visitPage('home')
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_users')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.ht_master tbody tr').then((rows: any) => {
|
|
||||||
if (options.deleteAll) {
|
|
||||||
for (let row of rows) {
|
|
||||||
const user_id = row.childNodes[2]
|
|
||||||
if (!options.keep || user_id.innerText !== options.keep) {
|
|
||||||
cy.get(row.childNodes[1])
|
|
||||||
.dblclick()
|
|
||||||
.then(() => {
|
|
||||||
cy.focused().type('{selectall}').type('Yes').type('{enter}')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (options.newUsers && options.newUsers.length) {
|
|
||||||
for (let newUser of options.newUsers) {
|
|
||||||
clickOnAddRow(() => {
|
|
||||||
cy.get('#hotInstance tbody tr:last-child').then((rows: any) => {
|
|
||||||
cy.get(rows[0].childNodes[2])
|
|
||||||
.dblclick()
|
|
||||||
.then(() => {
|
|
||||||
cy.focused()
|
|
||||||
.type('{selectall}')
|
|
||||||
.type(newUser.username)
|
|
||||||
.type('{enter}')
|
|
||||||
})
|
|
||||||
// cy.get(rows[0].childNodes[3])
|
|
||||||
// .dblclick()
|
|
||||||
// .then(() => {
|
|
||||||
// cy.focused()
|
|
||||||
// .type('{selectall}')
|
|
||||||
// .type(newUser.last_seen_at)
|
|
||||||
// .type('{enter}')
|
|
||||||
// })
|
|
||||||
// cy.get(rows[0].childNodes[4])
|
|
||||||
// .dblclick()
|
|
||||||
// .then(() => {
|
|
||||||
// cy.focused()
|
|
||||||
// .type('{selectall}')
|
|
||||||
// .type(newUser.registered_at)
|
|
||||||
// .type('{enter}')
|
|
||||||
// })
|
|
||||||
submitTable(() => {
|
|
||||||
cy.wait(2000)
|
|
||||||
approveTable(callback)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeLicenseKeyTable = (keys: any, callback?: any) => {
|
|
||||||
visitPage('home')
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_config')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
editTableField(
|
|
||||||
[
|
|
||||||
{ varName: 'DC_ACTIVATION_KEY', varValue: keys.activationKey },
|
|
||||||
{ varName: 'DC_LICENCE_KEY', varValue: keys.licenseKey }
|
|
||||||
],
|
|
||||||
() => {
|
|
||||||
submitTable(() => {
|
|
||||||
cy.wait(2000)
|
|
||||||
approveTable(() => {
|
|
||||||
cy.reload()
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateLicenseKeyQuick = (keys: any, callback: any) => {
|
|
||||||
isLicensingPage((result: boolean) => {
|
|
||||||
if (!result) {
|
|
||||||
visitPage('licensing/update')
|
|
||||||
cy.wait(2000)
|
|
||||||
}
|
|
||||||
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
|
|
||||||
|
|
||||||
callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateKeys = async (licenseData: any, 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']
|
|
||||||
)
|
|
||||||
|
|
||||||
const encoded = new TextEncoder().encode(JSON.stringify(licenseData))
|
|
||||||
|
|
||||||
const 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')
|
|
||||||
}
|
|
||||||
|
|
||||||
const privateKeyBytes = await window.crypto.subtle.exportKey(
|
|
||||||
'pkcs8',
|
|
||||||
keyPair.privateKey
|
|
||||||
)
|
|
||||||
|
|
||||||
const activationKey = await arrayBufferToBase64(privateKeyBytes)
|
|
||||||
const licenseKey = await arrayBufferToBase64(cipher)
|
|
||||||
|
|
||||||
if (resultCallback)
|
|
||||||
resultCallback({
|
|
||||||
activationKey,
|
|
||||||
licenseKey
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const editTableField = (edits: EditConfigTableCells[], callback?: any) => {
|
|
||||||
cy.get('td').then((tdNodes: any) => {
|
|
||||||
for (let edit of edits) {
|
|
||||||
let correctRow = false
|
|
||||||
|
|
||||||
for (let node of tdNodes) {
|
|
||||||
if (correctRow) {
|
|
||||||
cy.get(node)
|
|
||||||
.dblclick()
|
|
||||||
.then(() => {
|
|
||||||
// textarea update on long keys
|
|
||||||
cy.focused().invoke('val', edit.varValue).type('{enter}')
|
|
||||||
})
|
|
||||||
|
|
||||||
correctRow = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.innerText.includes(edit.varName)) {
|
|
||||||
correctRow = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openTableFromTree = (
|
|
||||||
libNameIncludes: string,
|
|
||||||
tablename: string,
|
|
||||||
callback?: any
|
|
||||||
) => {
|
|
||||||
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()
|
|
||||||
if (callback) callback()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const clickOnAddRow = (callback?: any) => {
|
|
||||||
cy.get('.btnCtrl button.btn-success')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const clickOnEdit = (callback?: any) => {
|
|
||||||
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitTable = (callback?: any) => {
|
|
||||||
cy.get('.btnCtrl button.btn-primary', { timout: longerCommandTimeout })
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get(".modal.ng-star-inserted button[type='submit']")
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const approveTable = (callback?: any) => {
|
|
||||||
cy.get('button', { timeout: longerCommandTimeout })
|
|
||||||
.should('contain', 'Approve')
|
|
||||||
.then((allButtons: any) => {
|
|
||||||
for (let approvalButton of allButtons) {
|
|
||||||
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
|
||||||
approvalButton.click()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get('button#acceptBtn', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.should('not.be.disabled')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('app-history', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const visitPage = (url: string) => {
|
|
||||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
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}`)
|
|
||||||
const fixturePath = 'excels/'
|
|
||||||
|
|
||||||
context('liveness tests: ', function () {
|
|
||||||
this.beforeAll(() => {
|
|
||||||
if (serverType !== 'SASJS') {
|
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
|
||||||
}
|
|
||||||
cy.loginAndUpdateValidKey(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.beforeEach(() => {
|
|
||||||
cy.visit(hostUrl + appLocation)
|
|
||||||
|
|
||||||
visitPage('home')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('1 | Login and submit test', (done) => {
|
|
||||||
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then((treeNodes: any) => {
|
|
||||||
libraryExistsInTree('viya', treeNodes)
|
|
||||||
? openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
: openTableFromTree('dc', 'mpe_x_test')
|
|
||||||
|
|
||||||
attachExcelFile('regular_excel.xlsx', () => {
|
|
||||||
submitExcel()
|
|
||||||
rejectExcel(done)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thist part will be needed if we add more tests in future
|
|
||||||
*/
|
|
||||||
// this.afterEach(() => {
|
|
||||||
// cy.visit('https://sas.4gl.io/SASLogon/logout');
|
|
||||||
// })
|
|
||||||
})
|
|
||||||
|
|
||||||
const visitPage = (url: string) => {
|
|
||||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const libraryExistsInTree = (libName: string, nodes: any) => {
|
|
||||||
for (let node of nodes) {
|
|
||||||
if (node.innerText.toLowerCase().includes(libName.toLowerCase()))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const openTableFromTree = (
|
|
||||||
libNameIncludes: string,
|
|
||||||
tablename: string,
|
|
||||||
finish: any
|
|
||||||
) => {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!viyaLib && finish) finish(false)
|
|
||||||
|
|
||||||
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()
|
|
||||||
if (finish) finish(true)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachExcelFile = (excelFilename: string, callback?: any) => {
|
|
||||||
cy.get('.buttonBar button:last-child')
|
|
||||||
.should('exist')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('input[type="file"]#file-upload').attachFile(
|
|
||||||
`/${fixturePath}/${excelFilename}`
|
|
||||||
)
|
|
||||||
cy.get('.modal-footer .btn.btn-primary').then((modalBtn) => {
|
|
||||||
modalBtn.click()
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitExcel = (callback?: any) => {
|
|
||||||
cy.get('.buttonBar button.preview-submit', { timeout: longerCommandTimeout })
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const rejectExcel = (callback?: any) => {
|
|
||||||
cy.get('button', { timeout: longerCommandTimeout })
|
|
||||||
.should('contain', 'Approve')
|
|
||||||
.then((allButtons: any) => {
|
|
||||||
for (let approvalButton of allButtons) {
|
|
||||||
if (approvalButton.innerText.toLowerCase().includes('approve')) {
|
|
||||||
approvalButton.click()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get('button.btn-danger')
|
|
||||||
.should('exist')
|
|
||||||
.should('not.be.disabled')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.modal-footer button.btn-success-outline')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('app-history')
|
|
||||||
.should('exist')
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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}`)
|
|
||||||
const fixturePath = 'excels_general/'
|
|
||||||
|
|
||||||
context('metanav tests: ', function () {
|
|
||||||
this.beforeAll(() => {
|
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
|
||||||
cy.loginAndUpdateValidKey()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.beforeEach(() => {
|
|
||||||
cy.visit(hostUrl + appLocation)
|
|
||||||
cy.get('input.username').type(username)
|
|
||||||
cy.get('input.password').type(password)
|
|
||||||
cy.get('.login-group button').click()
|
|
||||||
|
|
||||||
visitPage('view/metadata')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('1 | Opens metadata object', (done) => {
|
|
||||||
openFirstMetadataFromTree(() => {
|
|
||||||
// BLOCKER
|
|
||||||
// For unkown reasons, .clr-accordion-header-button always null although it is present on the page.
|
|
||||||
cy.get('.clr-accordion-header-button').then((panelNodes: any) => {
|
|
||||||
panelNodes[0].querySelector('button').click()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
this.afterEach(() => {
|
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const openFirstMetadataFromTree = (callback?: any) => {
|
|
||||||
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 firstMetaNode
|
|
||||||
|
|
||||||
firstMetaNode = treeNodes[1]
|
|
||||||
|
|
||||||
cy.get(firstMetaNode).within(() => {
|
|
||||||
cy.get('.clr-treenode-content').click()
|
|
||||||
callback()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const visitPage = (url: string) => {
|
|
||||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
|
||||||
}
|
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
import { cloneDeep } from 'lodash-es'
|
|
||||||
|
|
||||||
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}`)
|
|
||||||
const fixturePath = 'excels_general/'
|
|
||||||
|
|
||||||
context('editor tests: ', function () {
|
|
||||||
this.beforeAll(() => {
|
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
|
||||||
cy.loginAndUpdateValidKey()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.beforeEach(() => {
|
|
||||||
cy.visit(hostUrl + appLocation)
|
|
||||||
cy.wait(2000)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
cy.get('.login-group button').click()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
visitPage('home')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('1 | Add one viewbox', (done) => {
|
|
||||||
const viewbox_table = 'mpe_audit'
|
|
||||||
const columns = ['LOAD_REF', 'LIBREF', 'DSN', 'KEY_HASH', 'TGTVAR_NM']
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
cy.get('.viewbox-open').click()
|
|
||||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
|
||||||
|
|
||||||
cy.get('.open-viewbox').then((viewboxNodes: any) => {
|
|
||||||
for (let viewboxNode of viewboxNodes) {
|
|
||||||
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
checkColumns(columns, () => {
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('2 | Add two viewboxes', (done) => {
|
|
||||||
const viewboxes = [
|
|
||||||
{
|
|
||||||
viewbox_table: 'mpe_audit',
|
|
||||||
columns: ['LOAD_REF', 'LIBREF', 'DSN', 'KEY_HASH', 'TGTVAR_NM']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
viewbox_table: 'mpe_alerts',
|
|
||||||
columns: [
|
|
||||||
'TX_FROM',
|
|
||||||
'ALERT_EVENT',
|
|
||||||
'ALERT_LIB',
|
|
||||||
'ALERT_DS',
|
|
||||||
'ALERT_USER'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
cy.get('.viewbox-open').click()
|
|
||||||
openTableFromViewboxTree(
|
|
||||||
libraryToOpenIncludes,
|
|
||||||
viewboxes.map((viewbox) => viewbox.viewbox_table)
|
|
||||||
)
|
|
||||||
|
|
||||||
cy.get('.open-viewbox').then((viewboxNodes: any) => {
|
|
||||||
let found = 0
|
|
||||||
|
|
||||||
for (let viewboxNode of viewboxNodes) {
|
|
||||||
for (let viewbox of viewboxes) {
|
|
||||||
if (
|
|
||||||
viewboxNode.innerText.toLowerCase().includes(viewbox.viewbox_table)
|
|
||||||
)
|
|
||||||
found++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (found < viewboxes.length) return
|
|
||||||
|
|
||||||
cy.get('.viewboxes-container .viewbox').then((viewboxNodes: any) => {
|
|
||||||
for (let viewboxNode of viewboxNodes) {
|
|
||||||
cy.get(viewboxNode).within(() => {
|
|
||||||
cy.get('.table-title').then((tableTitle) => {
|
|
||||||
const title = tableTitle[0].innerText
|
|
||||||
const viewbox = viewboxes.find((vb) =>
|
|
||||||
title.toLowerCase().includes(vb.viewbox_table)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (viewbox) {
|
|
||||||
cy.get('.ht_master.handsontable .htCore thead tr').then(
|
|
||||||
(viewboxColNodes: any) => {
|
|
||||||
let allColsHtml = viewboxColNodes[0].innerHTML
|
|
||||||
|
|
||||||
for (let col of viewbox?.columns) {
|
|
||||||
if (!allColsHtml.includes(col)) return
|
|
||||||
}
|
|
||||||
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('3 | Add viewbox, add columns', (done) => {
|
|
||||||
const viewbox_table = 'mpe_audit'
|
|
||||||
const columns = ['LOAD_REF', 'LIBREF', 'DSN', 'KEY_HASH', 'TGTVAR_NM']
|
|
||||||
const additionalColumns = ['IS_PK']
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
cy.get('.viewbox-open').click()
|
|
||||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
|
||||||
|
|
||||||
cy.get('.open-viewbox').then((viewboxNodes: any) => {
|
|
||||||
for (let viewboxNode of viewboxNodes) {
|
|
||||||
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
openViewboxConfig(viewbox_table)
|
|
||||||
|
|
||||||
addColumns(additionalColumns)
|
|
||||||
|
|
||||||
checkColumns([...columns, ...additionalColumns], () => {
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('4 | Add viewbox, add columns and reorder', (done) => {
|
|
||||||
const viewbox_table = 'mpe_audit'
|
|
||||||
const columns = ['LOAD_REF', 'LIBREF', 'DSN', 'KEY_HASH', 'TGTVAR_NM']
|
|
||||||
const additionalColumns = ['IS_PK', 'MOVE_TYPE']
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
cy.get('.viewbox-open').click()
|
|
||||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
|
||||||
|
|
||||||
cy.get('.open-viewbox').then((viewboxNodes: any) => {
|
|
||||||
for (let viewboxNode of viewboxNodes) {
|
|
||||||
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
openViewboxConfig(viewbox_table)
|
|
||||||
|
|
||||||
addColumns(additionalColumns, () => {
|
|
||||||
cy.wait(1000)
|
|
||||||
//reorder
|
|
||||||
cy.get('.col-box.column-MOVE_TYPE')
|
|
||||||
.realMouseDown({ button: 'left', position: 'center' })
|
|
||||||
.realMouseMove(0, 10, { position: 'center' })
|
|
||||||
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
|
|
||||||
cy.get('.col-box.column-IS_PK')
|
|
||||||
.realMouseMove(0, 0, { position: 'center' })
|
|
||||||
.realMouseUp()
|
|
||||||
//reorder end
|
|
||||||
|
|
||||||
cy.wait(500)
|
|
||||||
|
|
||||||
checkColumns([...columns, ...additionalColumns.reverse()], () => {
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('5 | Add viewbox, add columns, reorder, remove column, add again', (done) => {
|
|
||||||
const viewbox_table = 'mpe_audit'
|
|
||||||
const columns = ['LOAD_REF', 'LIBREF', 'DSN', 'KEY_HASH', 'TGTVAR_NM']
|
|
||||||
const additionalColumns = ['IS_PK', 'MOVE_TYPE']
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
cy.get('.viewbox-open').click()
|
|
||||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
|
||||||
|
|
||||||
cy.get('.open-viewbox').then((viewboxNodes: any) => {
|
|
||||||
for (let viewboxNode of viewboxNodes) {
|
|
||||||
if (!viewboxNode.innerText.toLowerCase().includes(viewbox_table)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
viewboxNode.click()
|
|
||||||
|
|
||||||
addColumns(additionalColumns, () => {
|
|
||||||
cy.wait(1000)
|
|
||||||
//reorder
|
|
||||||
cy.get('.col-box.column-MOVE_TYPE')
|
|
||||||
.realMouseDown({ button: 'left', position: 'center' })
|
|
||||||
.realMouseMove(0, 10, { position: 'center' })
|
|
||||||
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
|
|
||||||
cy.get('.col-box.column-IS_PK')
|
|
||||||
.realMouseMove(0, 0, { position: 'center' })
|
|
||||||
.realMouseUp()
|
|
||||||
//reorder end
|
|
||||||
|
|
||||||
cy.wait(500)
|
|
||||||
|
|
||||||
checkColumns([...columns, ...additionalColumns.reverse()], () => {
|
|
||||||
const colToRemove = 'MOVE_TYPE'
|
|
||||||
|
|
||||||
removeColumn(colToRemove)
|
|
||||||
checkColumns(
|
|
||||||
[
|
|
||||||
...columns,
|
|
||||||
...additionalColumns.filter((col) => col !== colToRemove)
|
|
||||||
],
|
|
||||||
() => {
|
|
||||||
addColumns([colToRemove], () => {
|
|
||||||
checkColumns(
|
|
||||||
[...columns, ...additionalColumns.reverse()],
|
|
||||||
() => {
|
|
||||||
done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('6 | Add viewboxes, reload and check url restored configuration', (done) => {
|
|
||||||
const viewboxes = [
|
|
||||||
{
|
|
||||||
viewbox_table: 'mpe_audit',
|
|
||||||
columns: ['LOAD_REF', 'LIBREF', 'DSN', 'KEY_HASH', 'TGTVAR_NM'],
|
|
||||||
additionalColumns: ['IS_PK', 'MOVE_TYPE']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
viewbox_table: 'mpe_alerts',
|
|
||||||
columns: [
|
|
||||||
'TX_FROM',
|
|
||||||
'ALERT_EVENT',
|
|
||||||
'ALERT_LIB',
|
|
||||||
'ALERT_DS',
|
|
||||||
'ALERT_USER'
|
|
||||||
],
|
|
||||||
additionalColumns: ['TX_TO']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
cy.get('.viewbox-open').click()
|
|
||||||
openTableFromViewboxTree(libraryToOpenIncludes, [
|
|
||||||
viewboxes[0].viewbox_table,
|
|
||||||
viewboxes[1].viewbox_table
|
|
||||||
])
|
|
||||||
|
|
||||||
openViewboxConfig(viewboxes[0].viewbox_table)
|
|
||||||
|
|
||||||
cy.wait(500)
|
|
||||||
|
|
||||||
addColumns(viewboxes[0].additionalColumns, () => {
|
|
||||||
cy.wait(1000)
|
|
||||||
|
|
||||||
if (viewboxes[0].viewbox_table === 'mpe_audit') {
|
|
||||||
cy.get('.col-box.column-MOVE_TYPE')
|
|
||||||
.realMouseDown({ button: 'left', position: 'center' })
|
|
||||||
.realMouseMove(0, 10, { position: 'center' })
|
|
||||||
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
|
|
||||||
cy.get('.col-box.column-IS_PK')
|
|
||||||
.realMouseMove(0, 0, { position: 'center' })
|
|
||||||
.realMouseUp()
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.wait(1000)
|
|
||||||
|
|
||||||
openViewboxConfig(viewboxes[1].viewbox_table)
|
|
||||||
addColumns(viewboxes[1].additionalColumns, () => {
|
|
||||||
cy.wait(1000).reload()
|
|
||||||
|
|
||||||
let result = 0
|
|
||||||
|
|
||||||
checkColumns(
|
|
||||||
[
|
|
||||||
...viewboxes[0].columns,
|
|
||||||
...cloneDeep(viewboxes[0].additionalColumns.reverse())
|
|
||||||
],
|
|
||||||
() => {
|
|
||||||
result++
|
|
||||||
|
|
||||||
if (result === 2) done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
checkColumns(
|
|
||||||
[...viewboxes[1].columns, ...viewboxes[1].additionalColumns],
|
|
||||||
() => {
|
|
||||||
result++
|
|
||||||
|
|
||||||
if (result === 2) done()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('7 | Add viewboxes and filter', () => {
|
|
||||||
const viewboxes = ['mpe_x_test', 'mpe_validations']
|
|
||||||
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
cy.get('.viewbox-open').click()
|
|
||||||
openTableFromViewboxTree(libraryToOpenIncludes, viewboxes)
|
|
||||||
|
|
||||||
cy.wait(1000)
|
|
||||||
|
|
||||||
closeViewboxModal()
|
|
||||||
|
|
||||||
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
|
|
||||||
(viewboxNodes: any) => {
|
|
||||||
for (let viewboxNode of viewboxNodes) {
|
|
||||||
cy.get(viewboxNode).within(() => {
|
|
||||||
cy.get('.table-title').then((title: any) => {
|
|
||||||
cy.get('.hot-spinner')
|
|
||||||
.should('not.exist')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('clr-icon[shape="filter"]').then((filterButton) => {
|
|
||||||
filterButton[0].click()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (title[0].innerText.includes('MPE_X_TEST')) {
|
|
||||||
setFilterWithValue(
|
|
||||||
'SOME_CHAR',
|
|
||||||
'this is dummy data',
|
|
||||||
'value',
|
|
||||||
() => {
|
|
||||||
cy.get('app-query', { withinSubject: null })
|
|
||||||
.should('not.exist')
|
|
||||||
.get('.ht_master.handsontable tbody tr')
|
|
||||||
.then((rowNodes) => {
|
|
||||||
const tr = rowNodes[0]
|
|
||||||
|
|
||||||
expect(rowNodes).to.have.length(1)
|
|
||||||
expect(tr.innerText).to.equal('0')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (title[0].innerText.includes('MPE_VALIDATIONS')) {
|
|
||||||
setFilterWithValue('BASE_COL', 'ALERT_LIB', 'value', () => {
|
|
||||||
cy.get('app-query', { withinSubject: null })
|
|
||||||
.should('not.exist')
|
|
||||||
.get('.ht_master.handsontable tbody tr')
|
|
||||||
.then((rowNodes) => {
|
|
||||||
const tr = rowNodes[0]
|
|
||||||
|
|
||||||
expect(rowNodes).to.have.length(1)
|
|
||||||
expect(tr.innerText).to.contain('ALERT_LIB')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const checkColumns = (columns: string[], callback: () => void) => {
|
|
||||||
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).then(
|
|
||||||
(viewboxNodes: any) => {
|
|
||||||
for (let viewboxNode of viewboxNodes) {
|
|
||||||
cy.get(viewboxNode).within(() => {
|
|
||||||
cy.get('.ht_master.handsontable thead tr th').then(
|
|
||||||
(viewboxColNodes: any) => {
|
|
||||||
for (let i = 0; i < viewboxColNodes.length; i++) {
|
|
||||||
const col = columns[i]
|
|
||||||
const colNode = viewboxColNodes[i]
|
|
||||||
|
|
||||||
if (
|
|
||||||
!colNode.innerHTML.toLowerCase().includes(col.toLowerCase())
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeViewboxModal = () => {
|
|
||||||
cy.get('app-viewboxes .close', { withinSubject: null }).click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeColumn = (column: string) => {
|
|
||||||
cy.get(`.col-box.column-${column} clr-icon`, { withinSubject: null }).click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const addColumns = (columns: string[], callback?: () => void) => {
|
|
||||||
for (let i = 0; i < columns.length; i++) {
|
|
||||||
const column = columns[i]
|
|
||||||
|
|
||||||
cy.get('.cols-search input', { withinSubject: null }).type(column)
|
|
||||||
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
|
|
||||||
.first()
|
|
||||||
.trigger('keydown', { key: 'ArrowDown' })
|
|
||||||
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
|
|
||||||
.first()
|
|
||||||
.trigger('keydown', { key: 'Enter' })
|
|
||||||
.then(() => {
|
|
||||||
if (i === columns.length - 1 && callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openViewboxConfig = (viewbox_tablename: string) => {
|
|
||||||
cy.get('.open-viewbox').then((viewboxes: any) => {
|
|
||||||
for (let openViewbox of viewboxes) {
|
|
||||||
if (openViewbox.innerText.toLowerCase().includes(viewbox_tablename))
|
|
||||||
openViewbox.click()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (new RegExp(libNameIncludes).test(node.innerText.toLowerCase())) {
|
|
||||||
viyaLib = node
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get(viyaLib).within(() => {
|
|
||||||
cy.get('.clr-tree-node-content-container p').click()
|
|
||||||
|
|
||||||
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
|
||||||
for (let innerNode of innerNodes) {
|
|
||||||
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
|
||||||
innerNode.click()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const setFilterWithValue = (
|
|
||||||
variableValue: string,
|
|
||||||
valueString: string,
|
|
||||||
valueField: 'value' | 'time' | 'date' | 'datetime' | 'in' | 'between',
|
|
||||||
callback?: any
|
|
||||||
) => {
|
|
||||||
cy.wait(600)
|
|
||||||
|
|
||||||
cy.focused().type(variableValue)
|
|
||||||
cy.wait(100)
|
|
||||||
// cy.focused().trigger('input')
|
|
||||||
cy.get('.variable-col .autocomplete-wrapper', { withinSubject: null })
|
|
||||||
.first()
|
|
||||||
.trigger('keydown', { key: 'ArrowDown' })
|
|
||||||
cy.get('.variable-col .autocomplete-wrapper', {
|
|
||||||
withinSubject: null
|
|
||||||
}).trigger('keydown', { key: 'Enter' })
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.wait(100)
|
|
||||||
|
|
||||||
if (valueField === 'in') {
|
|
||||||
cy.focused().select(valueField.toUpperCase()).trigger('change')
|
|
||||||
} else if (valueField === 'between') {
|
|
||||||
cy.focused().select(valueField.toUpperCase()).trigger('change')
|
|
||||||
} else {
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.wait(100)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (valueField) {
|
|
||||||
case 'value': {
|
|
||||||
cy.focused().type(valueString)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'time': {
|
|
||||||
cy.focused().type(valueString)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'date': {
|
|
||||||
cy.focused().type(valueString)
|
|
||||||
cy.focused().tab()
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'datetime': {
|
|
||||||
const date = valueString.split(' ')[0]
|
|
||||||
const time = valueString.split(' ')[1]
|
|
||||||
|
|
||||||
cy.focused().type(date)
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.focused().type(time)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'in': {
|
|
||||||
cy.get('.checkbox-vals').then(() => {
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.focused().click()
|
|
||||||
cy.get('.no-values')
|
|
||||||
.should('not.exist')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('clr-checkbox-wrapper input').then((inputs: any) => {
|
|
||||||
inputs[0].click()
|
|
||||||
cy.get('.in-values-modal .modal-footer button').click()
|
|
||||||
|
|
||||||
cy.get('.modal-footer .btn-success-outline').click()
|
|
||||||
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'between': {
|
|
||||||
cy.focused().tab()
|
|
||||||
|
|
||||||
const start = valueString.split('-')[0]
|
|
||||||
const end = valueString.split('-')[1]
|
|
||||||
|
|
||||||
cy.focused().type(start)
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.focused().type(end)
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.wait(100)
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.wait(100)
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.wait(100)
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.wait(100)
|
|
||||||
cy.focused().tab()
|
|
||||||
cy.wait(100)
|
|
||||||
cy.focused().click()
|
|
||||||
|
|
||||||
if (callback) callback()
|
|
||||||
}
|
|
||||||
|
|
||||||
const openTableFromViewboxTree = (
|
|
||||||
libNameIncludes: string,
|
|
||||||
tablenames: string[]
|
|
||||||
) => {
|
|
||||||
cy.get('.add-new 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('p')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
|
||||||
for (let innerNode of innerNodes) {
|
|
||||||
for (let tablename of tablenames) {
|
|
||||||
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
|
||||||
innerNode.click()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const visitPage = (url: string) => {
|
|
||||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
|
||||||
}
|
|
||||||
@@ -23,21 +23,33 @@
|
|||||||
//
|
//
|
||||||
// -- This will overwrite an existing command --
|
// -- This will overwrite an existing command --
|
||||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
||||||
import 'cypress-file-upload';
|
import 'cypress-file-upload'
|
||||||
|
|
||||||
import { arrayBufferToBase64 } from './../util/helper-functions'
|
import { arrayBufferToBase64 } from './../util/helper-functions'
|
||||||
import * as moment from 'moment'
|
import moment from 'moment'
|
||||||
|
|
||||||
const username = Cypress.env('username');
|
// These custom commands were added via Cypress.Commands.add() below but never
|
||||||
const password = Cypress.env('password');
|
// had a type augmentation, so every spec calling cy.loginAndUpdateValidKey()/
|
||||||
const hostUrl = Cypress.env('hosturl');
|
// cy.isLoggedIn() had an unresolved-property error.
|
||||||
const appLocation = Cypress.env('appLocation');
|
declare global {
|
||||||
|
namespace Cypress {
|
||||||
|
interface Chainable {
|
||||||
|
isLoggedIn(callback: (exist: boolean) => void): Chainable<void>
|
||||||
|
loginAndUpdateValidKey(forceLicenceKey?: boolean): Chainable<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = Cypress.env('username')
|
||||||
|
const password = Cypress.env('password')
|
||||||
|
const hostUrl = Cypress.env('hosturl')
|
||||||
|
const appLocation = Cypress.env('appLocation')
|
||||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||||
const site_id_SASJS = Cypress.env('site_id_SASJS')
|
const site_id_SASJS = Cypress.env('site_id_SASJS')
|
||||||
|
|
||||||
Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
|
Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
|
||||||
cy.get('body').then($body => {
|
cy.get('body').then(($body) => {
|
||||||
if ($body.find(".nav-tree").length > 0) {
|
if ($body.find('.nav-tree').length > 0) {
|
||||||
if (callback) callback(true)
|
if (callback) callback(true)
|
||||||
} else {
|
} else {
|
||||||
if (callback) callback(false)
|
if (callback) callback(false)
|
||||||
@@ -45,76 +57,92 @@ Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Cypress.Commands.add('loginAndUpdateValidKey', (forceLicenceKey: boolean = false) => {
|
Cypress.Commands.add(
|
||||||
cy.visit(hostUrl + appLocation);
|
'loginAndUpdateValidKey',
|
||||||
|
(forceLicenceKey: boolean = false) => {
|
||||||
|
cy.visit(hostUrl + appLocation)
|
||||||
|
|
||||||
cy.wait(2000)
|
cy.wait(2000)
|
||||||
|
|
||||||
cy.get('body').then($body =>{
|
cy.get('body').then(($body) => {
|
||||||
const usernameInput = $body.find("input.username")[0]
|
const usernameInput = $body.find('input.username')[0]
|
||||||
|
|
||||||
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
|
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
|
||||||
cy.get('input.username').type(username);
|
cy.get('input.username').type(username)
|
||||||
cy.get('input.password').type(password);
|
cy.get('input.password').type(password)
|
||||||
|
|
||||||
cy.get('.login-group button').click()
|
cy.get('.login-group button').click()
|
||||||
}
|
}
|
||||||
|
|
||||||
cy.get('.app-loading', {timeout: longerCommandTimeout}).should('not.exist').then(() => {
|
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
||||||
cy.wait(2000)
|
.should('not.exist')
|
||||||
|
.then(() => {
|
||||||
|
cy.wait(2000)
|
||||||
|
|
||||||
if ($body.find(".nav-tree").length > 0) {
|
if ($body.find('.nav-tree').length > 0) {
|
||||||
/**
|
/**
|
||||||
* If licence key is already working, then skip rest of the function
|
* If licence key is already working, then skip rest of the function
|
||||||
*/
|
*/
|
||||||
return logout(() => {
|
return logout(() => {
|
||||||
return
|
return
|
||||||
})
|
})
|
||||||
} else{
|
} else {
|
||||||
const keyData = {
|
const keyData = {
|
||||||
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
|
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
|
||||||
number_of_users: 10,
|
number_of_users: 10,
|
||||||
hot_license_key: '',
|
hot_license_key: '',
|
||||||
site_id: '',
|
site_id: '',
|
||||||
demo: false
|
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 (!forceLicenceKey) {
|
return generateKeys(
|
||||||
return logout(() => {
|
keyData.valid_until,
|
||||||
return
|
keyData.number_of_users,
|
||||||
})
|
keyData.hot_license_key,
|
||||||
} else {
|
keyData.demo,
|
||||||
return updateLicenseKeyQuick(keysGen, () => {
|
(keysGen: any) => {
|
||||||
cy.wait(25000)
|
|
||||||
return acceptTermsIfPresented((result: boolean) => {
|
return acceptTermsIfPresented((result: boolean) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
cy.wait(20000)
|
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) => {
|
const logout = (callback?: any) => {
|
||||||
cy.get('.header-actions .dropdown-toggle').click().then(() => {
|
cy.get('.header-actions .dropdown-toggle')
|
||||||
cy.get('.header-actions .dropdown-menu > .separator').next().click().then(() => {
|
.click()
|
||||||
if (callback) callback()
|
.then(() => {
|
||||||
|
cy.get('.header-actions .dropdown-menu > .separator')
|
||||||
|
.next()
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
if (callback) callback()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateLicenseKeyQuick = (keys: any, callback: any) => {
|
const updateLicenseKeyQuick = (keys: any, callback: any) => {
|
||||||
@@ -132,11 +160,15 @@ const updateLicenseKeyQuick = (keys: any, callback: any) => {
|
|||||||
const acceptTermsIfPresented = (callback?: any) => {
|
const acceptTermsIfPresented = (callback?: any) => {
|
||||||
cy.url().then((url: string) => {
|
cy.url().then((url: string) => {
|
||||||
if (url.includes('licensing/register')) {
|
if (url.includes('licensing/register')) {
|
||||||
cy.get('.card-block').scrollTo('bottom').then(() => {
|
cy.get('.card-block')
|
||||||
cy.get('#checkbox1').click().then(() => {
|
.scrollTo('bottom')
|
||||||
if (callback) callback(true)
|
.then(() => {
|
||||||
|
cy.get('#checkbox1')
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
if (callback) callback(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
if (callback) callback(false)
|
if (callback) callback(false)
|
||||||
}
|
}
|
||||||
@@ -151,23 +183,36 @@ const isLicensingPage = (callback: any) => {
|
|||||||
|
|
||||||
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
||||||
cy.get('button').contains('Paste licence').click()
|
cy.get('button').contains('Paste licence').click()
|
||||||
cy.get('.license-key-form textarea', {timeout: longerCommandTimeout}).invoke('val', licenseKey).trigger('input').should('not.be.undefined')
|
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
|
||||||
cy.get('.activation-key-form textarea', {timeout: longerCommandTimeout}).invoke('val', activationKey).trigger('input').should('not.be.undefined')
|
.invoke('val', licenseKey)
|
||||||
|
.trigger('input')
|
||||||
|
.should('not.be.undefined')
|
||||||
|
cy.get('.activation-key-form textarea', { timeout: longerCommandTimeout })
|
||||||
|
.invoke('val', activationKey)
|
||||||
|
.trigger('input')
|
||||||
|
.should('not.be.undefined')
|
||||||
cy.get('button.apply-keys').click()
|
cy.get('button.apply-keys').click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const visitPage = (url: string) => {
|
const visitPage = (url: string) => {
|
||||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`);
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||||
}
|
}
|
||||||
const generateKeys = async (valid_until: string, users_allowed: number, hot_license_key: string, demo: boolean, resultCallback?: any) => {
|
const generateKeys = async (
|
||||||
let keyPair = await window.crypto.subtle.generateKey({
|
valid_until: string,
|
||||||
name: "RSA-OAEP",
|
users_allowed: number,
|
||||||
modulusLength: 2024,
|
hot_license_key: string,
|
||||||
publicExponent: new Uint8Array([1, 0, 1]),
|
demo: boolean,
|
||||||
hash: "SHA-256"
|
resultCallback?: any
|
||||||
|
) => {
|
||||||
|
let keyPair = await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
modulusLength: 2024,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: 'SHA-256'
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
["encrypt", "decrypt"]
|
['encrypt', 'decrypt']
|
||||||
)
|
)
|
||||||
|
|
||||||
let licenseData = {
|
let licenseData = {
|
||||||
@@ -184,30 +229,39 @@ const generateKeys = async (valid_until: string, users_allowed: number, hot_lice
|
|||||||
|
|
||||||
console.log(encoded)
|
console.log(encoded)
|
||||||
|
|
||||||
let cipher = await window.crypto.subtle.encrypt(
|
let cipher = await window.crypto.subtle
|
||||||
{
|
.encrypt(
|
||||||
name: "RSA-OAEP"
|
{
|
||||||
},
|
name: 'RSA-OAEP'
|
||||||
keyPair.publicKey,
|
},
|
||||||
encoded
|
keyPair.publicKey,
|
||||||
).then((value) => {
|
encoded
|
||||||
return value
|
)
|
||||||
}, (err) => {
|
.then(
|
||||||
console.log('Encrpyt error', err)
|
(value) => {
|
||||||
})
|
return value
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
console.log('Encrpyt error', err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if (!cipher) {
|
if (!cipher) {
|
||||||
alert('Encryptin keys failed')
|
alert('Encryptin keys failed')
|
||||||
throw new Error('Encryptin keys failed')
|
throw new Error('Encryptin keys failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
let privateKeyBytes = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
|
let privateKeyBytes = await window.crypto.subtle.exportKey(
|
||||||
|
'pkcs8',
|
||||||
|
keyPair.privateKey
|
||||||
|
)
|
||||||
|
|
||||||
let activationKey = await arrayBufferToBase64(privateKeyBytes)
|
let activationKey = await arrayBufferToBase64(privateKeyBytes)
|
||||||
let licenseKey = await arrayBufferToBase64(cipher)
|
let licenseKey = await arrayBufferToBase64(cipher)
|
||||||
|
|
||||||
if (resultCallback) resultCallback({
|
if (resultCallback)
|
||||||
activationKey,
|
resultCallback({
|
||||||
licenseKey
|
activationKey,
|
||||||
})
|
licenseKey
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,4 +20,16 @@ import './commands'
|
|||||||
// require('./commands')
|
// require('./commands')
|
||||||
|
|
||||||
import 'cypress-plugin-tab'
|
import 'cypress-plugin-tab'
|
||||||
import "cypress-real-events"
|
import 'cypress-real-events'
|
||||||
|
|
||||||
|
// Pin the locale
|
||||||
|
Cypress.on('window:before:load', (win) => {
|
||||||
|
Object.defineProperty(win.navigator, 'language', {
|
||||||
|
value: 'en-GB',
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
Object.defineProperty(win.navigator, 'languages', {
|
||||||
|
value: ['en-GB'],
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -8,9 +8,9 @@ const check = (cwd) => {
|
|||||||
start: cwd,
|
start: cwd,
|
||||||
excludePrivatePackages: true,
|
excludePrivatePackages: true,
|
||||||
onlyAllow:
|
onlyAllow:
|
||||||
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
|
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;BlueOak-1.0.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
|
||||||
excludePackages:
|
excludePackages:
|
||||||
'@cds/city@1.1.0;@handsontable/angular@15.3.0;handsontable@15.3.0;hyperformula@2.7.1;hyperformula@3.0.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1'
|
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
|
||||||
},
|
},
|
||||||
(error, json) => {
|
(error, json) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
module.exports = {
|
||||||
|
ci: {
|
||||||
|
collect: {
|
||||||
|
settings: {
|
||||||
|
preset: 'desktop',
|
||||||
|
chromeFlags: '--no-sandbox --disable-dev-shm-usage'
|
||||||
|
},
|
||||||
|
url: [
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/home/tables',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/editor/DC996664.MPE_X_TEST',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/view/data',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/view/data/DC996664',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/view/data/DC996664.MPE_X_TEST',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups/1',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/view/usernav/users/1',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/home/excel-maps',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/home/excel-maps/BASEL-CR2',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/home/multi-load',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/review/submitted',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/review/approve',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/review/history',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/stage/DC20221006T142649516_059582_7169',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/review/submitted/DC20221006T142649516_059582_7169',
|
||||||
|
'http://localhost:5000/AppStream/clickme/#/system'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
assert: {
|
||||||
|
assertions: {
|
||||||
|
'categories:accessibility': [
|
||||||
|
'error',
|
||||||
|
{ minScore: 1, aggregationMethod: 'median' }
|
||||||
|
],
|
||||||
|
'categories:performance': [
|
||||||
|
'error',
|
||||||
|
{ minScore: 0.4, aggregationMethod: 'median' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
target: 'filesystem',
|
||||||
|
outputDir: './lighthouse-reports'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+12635
-7884
File diff suppressed because it is too large
Load Diff
+60
-37
@@ -17,13 +17,17 @@
|
|||||||
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
|
"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",
|
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
|
||||||
"viyabuild": "cd build; ./viyabuild.sh",
|
"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": "npx ng test",
|
||||||
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
|
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
|
||||||
"watch": "ng test watch=true",
|
"watch": "ng test watch=true",
|
||||||
"pree2e": "webdriver-manager update",
|
"pree2e": "webdriver-manager update",
|
||||||
"e2e": "protractor protractor.config.js",
|
"e2e": "protractor protractor.config.js",
|
||||||
"postinstall": "node ./src/version.ts && npm run add-githook",
|
"postinstall": "node ./src/version.ts && npm run add-githook && node ./scripts/strip-clr-base64-fonts.mjs && node ./scripts/gen-hot-icons.mjs",
|
||||||
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
|
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
|
||||||
"cypress": "cypress open",
|
"cypress": "cypress open",
|
||||||
"cy:run": "cypress run",
|
"cy:run": "cypress run",
|
||||||
@@ -31,47 +35,52 @@
|
|||||||
"sasdocs": "sasjs doc && ./sasjs/utils/deploydocs.sh",
|
"sasdocs": "sasjs doc && ./sasjs/utils/deploydocs.sh",
|
||||||
"compodoc:build": "compodoc -p tsconfig.doc.json --name 'Data Controller Client'",
|
"compodoc:build": "compodoc -p tsconfig.doc.json --name 'Data Controller Client'",
|
||||||
"compodoc:build-and-serve": "compodoc -p tsconfig.doc.json -s --name 'Data Controller Client'",
|
"compodoc:build-and-serve": "compodoc -p tsconfig.doc.json -s --name 'Data Controller Client'",
|
||||||
"compodoc:serve": "compodoc -s --name 'Data Controller Client'"
|
"compodoc:serve": "compodoc -s --name 'Data Controller Client'",
|
||||||
|
"lighthouse": "lhci autorun",
|
||||||
|
"ng": "ng"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.3.3",
|
"@angular/animations": "^20.3.26",
|
||||||
"@angular/cdk": "^17.3.3",
|
"@angular/cdk": "^20.2.14",
|
||||||
"@angular/common": "^17.3.3",
|
"@angular/common": "^20.3.26",
|
||||||
"@angular/compiler": "^17.3.3",
|
"@angular/compiler": "^20.3.26",
|
||||||
"@angular/core": "^17.3.3",
|
"@angular/core": "^20.3.26",
|
||||||
"@angular/forms": "^17.3.3",
|
"@angular/forms": "^20.3.26",
|
||||||
"@angular/platform-browser": "^17.3.3",
|
"@angular/platform-browser": "^20.3.26",
|
||||||
"@angular/platform-browser-dynamic": "^17.3.3",
|
"@angular/platform-browser-dynamic": "^20.3.26",
|
||||||
"@angular/router": "^17.3.3",
|
"@angular/router": "^20.3.26",
|
||||||
"@cds/core": "^6.15.1",
|
"@cds/core": "^6.15.1",
|
||||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||||
"@clr/icons": "^13.0.2",
|
"@clr/icons": "^13.0.2",
|
||||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||||
"@handsontable/angular": "^15.3.0",
|
"@handsontable/angular-wrapper": "^18.0.0",
|
||||||
"@sasjs/adapter": "^4.12.0",
|
"@sasjs/adapter": "^4.17.3",
|
||||||
"@sasjs/utils": "^3.4.0",
|
"@sasjs/utils": "^3.5.9",
|
||||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||||
"@types/d3-graphviz": "^2.6.7",
|
"@types/d3-graphviz": "^2.6.7",
|
||||||
"@types/text-encoding": "0.0.35",
|
"@types/text-encoding": "0.0.35",
|
||||||
"base64-arraybuffer": "^0.2.0",
|
"base64-arraybuffer": "^0.2.0",
|
||||||
|
"browserify-cipher": "^1.0.1",
|
||||||
"buffer": "^5.4.3",
|
"buffer": "^5.4.3",
|
||||||
"crypto-browserify": "^3.12.1",
|
"create-hash": "^1.2.0",
|
||||||
|
"create-hmac": "^1.1.7",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"d3-graphviz": "^5.0.2",
|
"d3-graphviz": "^5.0.2",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"fs-extra": "^7.0.1",
|
"fs-extra": "^7.0.1",
|
||||||
"handsontable": "^15.3.0",
|
"handsontable": "^18.0.0",
|
||||||
"https-browserify": "1.0.0",
|
"https-browserify": "1.0.0",
|
||||||
"hyperformula": "^2.5.0",
|
"hyperformula": "^2.5.0",
|
||||||
"iconv-lite": "^0.5.0",
|
"iconv-lite": "^0.5.0",
|
||||||
"jquery-datetimepicker": "^2.5.21",
|
"jquery-datetimepicker": "^2.5.21",
|
||||||
"jsrsasign": "^10.2.0",
|
"jsrsasign": "11.1.1",
|
||||||
"marked": "^5.0.0",
|
"marked": "^5.0.0",
|
||||||
"moment": "^2.26.0",
|
"moment": "^2.30.1",
|
||||||
"ngx-clipboard": "^16.0.0",
|
"ngx-clipboard": "^16.0.0",
|
||||||
"ngx-json-viewer": "file:libraries/ngx-json-viewer-3.2.1.tgz",
|
"ngx-json-viewer": "file:libraries/ngx-json-viewer-3.2.1.tgz",
|
||||||
"nodejs": "0.0.0",
|
|
||||||
"os-browserify": "0.3.0",
|
"os-browserify": "0.3.0",
|
||||||
|
"randombytes": "^2.1.0",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"save-svg-as-png": "^1.4.17",
|
"save-svg-as-png": "^1.4.17",
|
||||||
"stream-browserify": "3.0.0",
|
"stream-browserify": "3.0.0",
|
||||||
@@ -80,37 +89,41 @@
|
|||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"vm": "^0.1.0",
|
"vm": "^0.1.0",
|
||||||
"webpack": "^5.91.0",
|
"webpack": "^5.91.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "file:libraries/xlsx-0.20.3.tgz",
|
||||||
"zone.js": "~0.14.4"
|
"zone.js": "~0.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^17.3.3",
|
"@angular-devkit/build-angular": "^20.3.32",
|
||||||
"@angular-eslint/builder": "17.3.0",
|
"@angular-eslint/builder": "19.8.1",
|
||||||
"@angular-eslint/eslint-plugin": "17.3.0",
|
"@angular-eslint/eslint-plugin": "19.8.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "17.3.0",
|
"@angular-eslint/eslint-plugin-template": "19.8.1",
|
||||||
"@angular-eslint/schematics": "17.3.0",
|
"@angular-eslint/schematics": "19.8.1",
|
||||||
"@angular-eslint/template-parser": "17.3.0",
|
"@angular-eslint/template-parser": "19.8.1",
|
||||||
"@angular/cli": "^17.3.3",
|
"@angular/cli": "^20.3.32",
|
||||||
"@angular/compiler-cli": "^17.3.3",
|
"@angular/compiler-cli": "^20.3.26",
|
||||||
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||||
"@compodoc/compodoc": "^1.1.21",
|
"@compodoc/compodoc": "^2.0.0",
|
||||||
"@cypress/webpack-preprocessor": "^5.17.1",
|
"@cypress/webpack-preprocessor": "^5.17.1",
|
||||||
|
"@lhci/cli": "^0.15.1",
|
||||||
"@types/core-js": "^2.5.5",
|
"@types/core-js": "^2.5.5",
|
||||||
|
"@types/create-hash": "^1.2.6",
|
||||||
|
"@types/create-hmac": "^1.1.3",
|
||||||
"@types/crypto-js": "^4.2.1",
|
"@types/crypto-js": "^4.2.1",
|
||||||
"@types/es6-shim": "^0.31.39",
|
"@types/es6-shim": "^0.31.39",
|
||||||
"@types/jasmine": "~5.1.4",
|
"@types/jasmine": "~5.1.4",
|
||||||
"@types/lodash-es": "^4.17.3",
|
"@types/lodash-es": "^4.17.3",
|
||||||
"@types/marked": "^4.3.0",
|
"@types/marked": "^4.3.0",
|
||||||
"@types/node": "12.20.50",
|
"@types/node": "12.20.50",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.29.0",
|
"@types/randombytes": "^2.0.3",
|
||||||
"@typescript-eslint/parser": "^5.29.0",
|
"@typescript-eslint/eslint-plugin": "8.65.0",
|
||||||
|
"@typescript-eslint/parser": "8.65.0",
|
||||||
"core-js": "^2.5.4",
|
"core-js": "^2.5.4",
|
||||||
"cypress": "12.17.1",
|
"cypress": "^15.19.0",
|
||||||
"cypress-file-upload": "^5.0.8",
|
"cypress-file-upload": "^5.0.8",
|
||||||
"cypress-plugin-tab": "^1.0.5",
|
"cypress-plugin-tab": "^1.0.5",
|
||||||
"cypress-real-events": "^1.8.1",
|
"cypress-real-events": "^1.8.1",
|
||||||
"es6-shim": "^0.35.5",
|
"es6-shim": "^0.35.5",
|
||||||
"eslint": "^8.33.0",
|
"eslint": "8.57.1",
|
||||||
"git-describe": "^4.0.4",
|
"git-describe": "^4.0.4",
|
||||||
"jasmine-core": "~5.1.2",
|
"jasmine-core": "~5.1.2",
|
||||||
"karma": "~6.4.3",
|
"karma": "~6.4.3",
|
||||||
@@ -122,12 +135,22 @@
|
|||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"mochawesome": "^7.1.3",
|
"mochawesome": "^7.1.3",
|
||||||
"mutationobserver-shim": "^0.3.3",
|
"mutationobserver-shim": "^0.3.3",
|
||||||
|
"prettier": "3.8.4",
|
||||||
"replace-in-file": "^6.3.5",
|
"replace-in-file": "^6.3.5",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
"ts-loader": "^9.2.8",
|
"ts-loader": "^9.2.8",
|
||||||
"ts-node": "^3.3.0",
|
"ts-node": "^3.3.0",
|
||||||
"typescript": "~5.4.4",
|
"typescript": "~5.9.3",
|
||||||
"wait-on": "^6.0.1",
|
"wait-on": "^9.0.10",
|
||||||
"watch": "^1.0.2"
|
"watch": "^1.0.2"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"ajv": "8.18.0",
|
||||||
|
"uuid": "11.1.1",
|
||||||
|
"lighthouse": "13.4.0",
|
||||||
|
"exceljs": {
|
||||||
|
"archiver": "^8.0.0",
|
||||||
|
"unzipper": "^0.12.5"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { resolve, join } from 'path'
|
||||||
|
import { createRequire } from 'module'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate static SVG assets + an SCSS partial that re-applies HOT v17 classic
|
||||||
|
* theme icons via real URLs (not data: URIs).
|
||||||
|
*
|
||||||
|
* Why: deployed app runs under CSP `img-src 'self'`. HOT v17's classic theme
|
||||||
|
* embeds icons as `data:image/svg+xml,...` in `-webkit-mask-image` rules, which
|
||||||
|
* the CSP blocks. We switch to `ht-theme-classic-no-icons.min.css` and re-add
|
||||||
|
* the icon rules pointing at same-origin SVG files emitted from this script.
|
||||||
|
*
|
||||||
|
* Inputs (HOT's own modules, so semantic names + selector list track upstream):
|
||||||
|
* handsontable/themes/theme/classic → { classicTheme: { icons } }
|
||||||
|
* handsontable/themes/static/variables/helpers/iconsMap → iconsMap(icons, themePrefix)
|
||||||
|
*
|
||||||
|
* Outputs:
|
||||||
|
* client/src/assets/hot-icons/<kebab-name>.svg
|
||||||
|
* client/src/_hot-icons.scss
|
||||||
|
*
|
||||||
|
* Idempotent: clears the output dir and rewrites both outputs each run.
|
||||||
|
* Skips silently if handsontable isn't installed yet (pre-install runs).
|
||||||
|
*/
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
const ASSETS_DIR = resolve('src/assets/hot-icons')
|
||||||
|
const SCSS_OUT = resolve('src/_hot-icons.scss')
|
||||||
|
const ASSET_URL_PREFIX = './assets/hot-icons/'
|
||||||
|
|
||||||
|
const themePath = resolve('node_modules/handsontable/themes/theme/classic.js')
|
||||||
|
const mapPath = resolve('node_modules/handsontable/themes/static/variables/helpers/iconsMap.js')
|
||||||
|
|
||||||
|
if (!existsSync(themePath) || !existsSync(mapPath)) {
|
||||||
|
console.log('skip: handsontable theme modules not found (likely pre-install run)')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { classicTheme } = require(themePath)
|
||||||
|
const { iconsMap } = require(mapPath)
|
||||||
|
|
||||||
|
const icons = classicTheme.icons
|
||||||
|
const cssTemplate = iconsMap(icons, 'ht-theme-classic')
|
||||||
|
|
||||||
|
const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
||||||
|
|
||||||
|
rmSync(ASSETS_DIR, { recursive: true, force: true })
|
||||||
|
mkdirSync(ASSETS_DIR, { recursive: true })
|
||||||
|
|
||||||
|
const writeMap = {}
|
||||||
|
for (const [name, dataUri] of Object.entries(icons)) {
|
||||||
|
if (typeof dataUri !== 'string' || !dataUri.startsWith('data:image/svg+xml')) continue
|
||||||
|
const decoded = decodeURIComponent(dataUri.replace(/^data:image\/svg\+xml(;charset=utf-8)?,/, ''))
|
||||||
|
const fname = kebab(name) + '.svg'
|
||||||
|
writeFileSync(join(ASSETS_DIR, fname), decoded)
|
||||||
|
writeMap[dataUri] = ASSET_URL_PREFIX + fname
|
||||||
|
}
|
||||||
|
|
||||||
|
let scss = cssTemplate
|
||||||
|
for (const [uri, url] of Object.entries(writeMap)) {
|
||||||
|
scss = scss.split(`url("${uri}")`).join(`url("${url}")`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = '/* Auto-generated by scripts/gen-hot-icons.mjs — do not edit by hand.\n' +
|
||||||
|
' Regenerated on postinstall; rerun manually via `node scripts/gen-hot-icons.mjs`. */\n\n'
|
||||||
|
|
||||||
|
writeFileSync(SCSS_OUT, header + scss + '\n')
|
||||||
|
|
||||||
|
console.log(`hot-icons: wrote ${Object.keys(writeMap).length} SVGs + ${SCSS_OUT}`)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { readFileSync, writeFileSync, statSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove Clarity's Metropolis @font-face blocks from clr-ui.min.css.
|
||||||
|
*
|
||||||
|
* Why: Clarity ships Metropolis as base64 data: URLs. The deployed app
|
||||||
|
* runs under CSP `default-src 'self'` (no data: font-src), so every page
|
||||||
|
* logs a font-load failure for each weight. Firefox preemptively
|
||||||
|
* validates every parsed src against CSP even when a later @font-face
|
||||||
|
* supersedes the rule at render time, so the only way to silence the
|
||||||
|
* console is to remove the offending blocks from the parsed CSS.
|
||||||
|
*
|
||||||
|
* Our styles.scss declares the same family/weight/style with same-origin
|
||||||
|
* .woff files, so removing Clarity's blocks entirely is safe and leaves
|
||||||
|
* Metropolis fully functional.
|
||||||
|
*
|
||||||
|
* Idempotent: matches by font-family, so works on a fresh install or a
|
||||||
|
* file that's already been stripped on a previous run.
|
||||||
|
*/
|
||||||
|
const target = resolve('node_modules/@clr/ui/clr-ui.min.css')
|
||||||
|
|
||||||
|
let css
|
||||||
|
try {
|
||||||
|
css = readFileSync(target, 'utf8')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
console.log(`skip: ${target} not found (likely pre-install run)`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeBefore = statSync(target).size
|
||||||
|
const blockRe = /@font-face\{[^}]*Metropolis[^}]*\}/g
|
||||||
|
const matches = css.match(blockRe) ?? []
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
console.log(`already stripped: ${target}`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripped = css.replace(blockRe, '')
|
||||||
|
writeFileSync(target, stripped)
|
||||||
|
const sizeAfter = Buffer.byteLength(stripped)
|
||||||
|
console.log(
|
||||||
|
`removed ${matches.length} Metropolis @font-face block(s) from clr-ui.min.css ` +
|
||||||
|
`(${sizeBefore} -> ${sizeAfter} bytes, saved ${sizeBefore - sizeAfter})`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Webpack 5's persistent cache treats node_modules as immutable
|
||||||
|
// (snapshot.module.managedPaths default), so in-place edits don't
|
||||||
|
// invalidate cached entries. Drop the Angular build cache so the next
|
||||||
|
// build re-reads our stripped clr-ui.min.css.
|
||||||
|
const cacheDir = resolve('.angular/cache')
|
||||||
|
if (existsSync(cacheDir)) {
|
||||||
|
rmSync(cacheDir, { recursive: true, force: true })
|
||||||
|
console.log(`cleared ${cacheDir} (webpack persistent cache)`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/* Auto-generated by scripts/gen-hot-icons.mjs — do not edit by hand.
|
||||||
|
Regenerated on postinstall; rerun manually via `node scripts/gen-hot-icons.mjs`. */
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .htDropdownMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
||||||
|
[class*=ht-theme-classic] .htContextMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
||||||
|
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
||||||
|
[class*=ht-theme-classic] .pika-single .pika-next {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-right.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .pika-single .pika-prev {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-left.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-page-size-section__select-wrapper::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-down.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .changeType::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/select-arrow.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .htUISelectCaption::after,
|
||||||
|
.htAutocompleteArrow::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/select-arrow.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .columnSorting.sortAction.ascending::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-narrow-up.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .columnSorting.sortAction.descending::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-narrow-down.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-page-navigation-section .ht-page-first::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-left-with-bar.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] [dir="rtl"] .ht-page-navigation-section .ht-page-first::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-right-with-bar.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-page-navigation-section .ht-page-prev::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-left.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] [dir="rtl"] .ht-page-navigation-section .ht-page-prev::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-right.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-page-navigation-section .ht-page-next::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-right.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] [dir="rtl"] .ht-page-navigation-section .ht-page-next::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-left.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-page-navigation-section .ht-page-last::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-right-with-bar.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] [dir="rtl"] .ht-page-navigation-section .ht-page-last::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-left-with-bar.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .htDropdownMenu table tbody tr td .htItemWrapper span.selected::after,
|
||||||
|
[class*=ht-theme-classic] .htContextMenu table tbody tr td .htItemWrapper span.selected::after,
|
||||||
|
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td .htItemWrapper span.selected::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/check.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .htCheckboxRendererInput {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .htCheckboxRendererInput::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/checkbox.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] th.beforeHiddenColumn::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/caret-hidden-left.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] th.afterHiddenColumn::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/caret-hidden-right.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] th.beforeHiddenRow::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/caret-hidden-up.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] th.afterHiddenRow::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/caret-hidden-down.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .collapsibleIndicator::before,
|
||||||
|
[class*=ht-theme-classic] .ht_nestingButton::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/collapse-off.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .collapsibleIndicator.collapsed::before,
|
||||||
|
[class*=ht-theme-classic] .ht_nestingButton.ht_nestingExpand::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/collapse-on.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .htUIRadio > input[type="radio"]::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/radio.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-multi-select-chip-remove::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/chip-close.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-notification__close::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/chip-close.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-multi-select-editor-search-icon {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/search.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-multi-select-editor-item-selected input::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/checkbox.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
@@ -55,6 +55,12 @@ export interface HandsontableStaticConfig {
|
|||||||
* Cached viyaApi collections, search and selected endpoint
|
* Cached viyaApi collections, search and selected endpoint
|
||||||
*/
|
*/
|
||||||
export const globals: {
|
export const globals: {
|
||||||
|
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
|
rootParam: string
|
||||||
dcLib: string
|
dcLib: string
|
||||||
xlmaps: XLMapListItem[]
|
xlmaps: XLMapListItem[]
|
||||||
@@ -69,6 +75,8 @@ export const globals: {
|
|||||||
handsontable: HandsontableStaticConfig
|
handsontable: HandsontableStaticConfig
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = {
|
} = {
|
||||||
|
embed: false,
|
||||||
|
vaApplyMode: 'live',
|
||||||
rootParam: <string>'',
|
rootParam: <string>'',
|
||||||
dcLib: '',
|
dcLib: '',
|
||||||
xlmaps: [],
|
xlmaps: [],
|
||||||
|
|||||||
+283
-263
@@ -1,270 +1,287 @@
|
|||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<ng-container *ngIf="!router.url.includes('licensing')">
|
@if (!router.url.includes('licensing')) {
|
||||||
<div
|
@if (freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)) {
|
||||||
*ngIf="
|
<div
|
||||||
freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)
|
class="alert alert-app-level alert-warning"
|
||||||
"
|
id="demo-banner"
|
||||||
class="alert alert-app-level alert-warning"
|
role="alert"
|
||||||
id="demo-banner"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<ng-container *ngIf="licenceProblem.value === null">
|
|
||||||
<div class="alert-items">
|
|
||||||
<div class="alert-item static">
|
|
||||||
<div class="alert-icon-wrapper">
|
|
||||||
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
|
|
||||||
</div>
|
|
||||||
<div class="alert-text">
|
|
||||||
Data Controller (FREE Tier) - to upgrade contact
|
|
||||||
<contact-link classes="color-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a routerLink="/licensing/update" class="update-key"
|
|
||||||
>Update Licence Key</a
|
|
||||||
>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ngIf="licenceProblem.value !== null">
|
|
||||||
<div class="alert-items">
|
|
||||||
<div class="alert-item static">
|
|
||||||
<div class="alert-icon-wrapper">
|
|
||||||
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
|
|
||||||
</div>
|
|
||||||
<div class="alert-text">
|
|
||||||
Data Controller (FREE Tier) - Problem with licence
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
(click)="licenceProblemDetails(licenceProblem.value)"
|
|
||||||
class="update-key cursor-pointer"
|
|
||||||
>More details</a
|
|
||||||
>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
*ngIf="licenseExpiringDays && !freeTierBanner"
|
|
||||||
class="alert alert-app-level alert-danger"
|
|
||||||
id="demo-banner"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<div class="alert-items">
|
|
||||||
<div class="alert-item static">
|
|
||||||
<div class="alert-icon-wrapper">
|
|
||||||
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert-text">
|
|
||||||
This license key will expire in {{ licenseExpiringDays }}
|
|
||||||
{{ licenseExpiringDays === 1 ? 'day' : 'days' }}. Please contact
|
|
||||||
<contact-link classes="color-white" />
|
|
||||||
or your reseller to arrange additional licence for site id
|
|
||||||
{{ syssite.getValue() }}.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
*ngIf="!freeTierBanner"
|
|
||||||
routerLink="/licensing/update"
|
|
||||||
class="update-key"
|
|
||||||
>Update Licence Key</a
|
|
||||||
>
|
>
|
||||||
</div>
|
@if (licenceProblem.value === null) {
|
||||||
|
<div class="alert-items">
|
||||||
<div
|
<div class="alert-item static">
|
||||||
*ngIf="appOverCapacity"
|
<div class="alert-icon-wrapper">
|
||||||
class="alert alert-app-level alert-danger"
|
<cds-icon
|
||||||
id="demo-banner"
|
class="alert-icon"
|
||||||
role="alert"
|
shape="warning-standard"
|
||||||
>
|
></cds-icon>
|
||||||
<div class="alert-items">
|
</div>
|
||||||
<div class="alert-item static">
|
<div class="alert-text">
|
||||||
<div class="alert-icon-wrapper">
|
Data Controller (FREE Tier) - to upgrade contact
|
||||||
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
|
<contact-link classes="color-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<a routerLink="/licensing/update" class="update-key"
|
||||||
<div class="alert-text">
|
>Update Licence Key</a
|
||||||
The registered number of users exceeds the limit specified for your
|
|
||||||
license. Please contact
|
|
||||||
<contact-link classes="color-white" />
|
|
||||||
or your reseller to arrange additional licence for site id
|
|
||||||
{{ syssite.getValue() }}.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
*ngIf="!licenseExpiringDays && !freeTierBanner"
|
|
||||||
routerLink="/licensing/update"
|
|
||||||
class="update-key"
|
|
||||||
>Update Licence Key</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<header class="app-header">
|
|
||||||
<!-- <button
|
|
||||||
*ngIf="
|
|
||||||
isMainRoute('view') ||
|
|
||||||
(isMainRoute('home') && !router.url.includes('licensing'))
|
|
||||||
"
|
|
||||||
class="header-hamburger-trigger"
|
|
||||||
(click)="toggleSidebar()"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span></span>
|
|
||||||
</button> -->
|
|
||||||
|
|
||||||
<div
|
|
||||||
*ngIf="
|
|
||||||
isMainRoute('view') ||
|
|
||||||
(isMainRoute('home') && !router.url.includes('licensing'))
|
|
||||||
"
|
|
||||||
(click)="toggleSidebar()"
|
|
||||||
type="button"
|
|
||||||
class="cursor-pointer select-none ml-10 d-flex clr-justify-content-center clr-align-items-center"
|
|
||||||
>
|
|
||||||
<clr-icon size="24" shape="tree-view"></clr-icon>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="logo d-flex clr-align-items-center">
|
|
||||||
<a
|
|
||||||
*ngIf="!router.url.includes('deploy')"
|
|
||||||
href="#"
|
|
||||||
[routerLink]="['/']"
|
|
||||||
class="nav-link"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
class="without-text d-block d-md-none"
|
|
||||||
src="images/dc-logo.svg"
|
|
||||||
alt="datacontroller logo without text"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
class="with-text d-none d-md-block"
|
|
||||||
src="images/datacontroller.svg"
|
|
||||||
alt="datacontroller logo"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a *ngIf="router.url.includes('deploy')">
|
|
||||||
<span class="clr-icon header-logo ml-10"></span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ng-container
|
|
||||||
*ngIf="
|
|
||||||
!router.url.includes('deploy') && !router.url.includes('licensing')
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="header-nav d-flex d-sm-none">
|
|
||||||
<clr-dropdown>
|
|
||||||
<button
|
|
||||||
class="nav-icon color-white-i"
|
|
||||||
clrDropdownTrigger
|
|
||||||
aria-label="toggle settings menu"
|
|
||||||
>
|
>
|
||||||
Menu
|
}
|
||||||
<!-- <clr-icon size="20" shape="bars"></clr-icon> -->
|
@if (licenceProblem.value !== null) {
|
||||||
</button>
|
<div class="alert-items">
|
||||||
<clr-dropdown-menu *clrIfOpen clrPosition="bottom-left">
|
<div class="alert-item static">
|
||||||
<a [routerLink]="['/view']" clrDropdownItem>VIEW</a>
|
<div class="alert-icon-wrapper">
|
||||||
<a [routerLink]="['/home']" clrDropdownItem>LOAD</a>
|
<cds-icon
|
||||||
<a [routerLink]="['/review/submitted']" clrDropdownItem>REVIEW</a>
|
class="alert-icon"
|
||||||
</clr-dropdown-menu>
|
shape="warning-standard"
|
||||||
</clr-dropdown>
|
></cds-icon>
|
||||||
|
</div>
|
||||||
|
<div class="alert-text">
|
||||||
|
Data Controller (FREE Tier) - Problem with licence
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
(click)="licenceProblemDetails(licenceProblem.value)"
|
||||||
|
class="update-key cursor-pointer"
|
||||||
|
>More details</a
|
||||||
|
>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div class="header-nav d-none d-sm-flex">
|
@if (licenseExpiringDays && !freeTierBanner) {
|
||||||
<a
|
<div
|
||||||
[routerLink]="['/view']"
|
class="alert alert-app-level alert-danger"
|
||||||
class="nav-link nav-text"
|
id="demo-banner"
|
||||||
routerLinkActive="active"
|
role="alert"
|
||||||
>VIEW</a
|
>
|
||||||
>
|
<div class="alert-items">
|
||||||
<a
|
<div class="alert-item static">
|
||||||
[routerLink]="['/home']"
|
<div class="alert-icon-wrapper">
|
||||||
class="nav-link nav-text"
|
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
|
||||||
[class.active]="
|
</div>
|
||||||
router.url.includes('editor') ||
|
<div class="alert-text">
|
||||||
router.url.includes('edit-record') ||
|
This license key will expire in {{ licenseExpiringDays }}
|
||||||
router.url.includes('home')
|
{{ licenseExpiringDays === 1 ? 'day' : 'days' }}. Please contact
|
||||||
"
|
<contact-link classes="color-white" />
|
||||||
>LOAD</a
|
or your reseller to arrange additional licence for site id
|
||||||
>
|
{{ syssite.getValue() }}.
|
||||||
<a
|
</div>
|
||||||
[routerLink]="['/review/submitted']"
|
</div>
|
||||||
[class.active]="
|
</div>
|
||||||
router.url.includes('submitted') ||
|
@if (!freeTierBanner) {
|
||||||
router.url.includes('approve') ||
|
<a routerLink="/licensing/update" class="update-key"
|
||||||
router.url.includes('history')
|
>Update Licence Key</a
|
||||||
"
|
>
|
||||||
class="nav-link nav-text cursor-pointer"
|
}
|
||||||
>REVIEW</a
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
}
|
||||||
|
@if (appOverCapacity) {
|
||||||
|
<div
|
||||||
|
class="alert alert-app-level alert-danger"
|
||||||
|
id="demo-banner"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div class="alert-items">
|
||||||
|
<div class="alert-item static">
|
||||||
|
<div class="alert-icon-wrapper">
|
||||||
|
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
|
||||||
|
</div>
|
||||||
|
<div class="alert-text">
|
||||||
|
The registered number of users exceeds the limit specified for
|
||||||
|
your license. Please contact
|
||||||
|
<contact-link classes="color-white" />
|
||||||
|
or your reseller to arrange additional licence for site id
|
||||||
|
{{ syssite.getValue() }}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!licenseExpiringDays && !freeTierBanner) {
|
||||||
|
<a routerLink="/licensing/update" class="update-key"
|
||||||
|
>Update Licence Key</a
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<app-header-actions></app-header-actions>
|
@if (!embed) {
|
||||||
</header>
|
<header class="app-header">
|
||||||
<nav
|
<!-- <button
|
||||||
*ngIf="
|
*ngIf="
|
||||||
router.url.includes('submitted') ||
|
isMainRoute('view') ||
|
||||||
|
(isMainRoute('home') && !router.url.includes('licensing'))
|
||||||
|
"
|
||||||
|
class="header-hamburger-trigger"
|
||||||
|
(click)="toggleSidebar()"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span></span>
|
||||||
|
</button> -->
|
||||||
|
@if (
|
||||||
|
isMainRoute('view') ||
|
||||||
|
(isMainRoute('home') && !router.url.includes('licensing'))
|
||||||
|
) {
|
||||||
|
<div
|
||||||
|
(click)="toggleSidebar()"
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
class="cursor-pointer select-none ml-10 d-flex clr-justify-content-center clr-align-items-center"
|
||||||
|
>
|
||||||
|
<clr-icon size="24" shape="tree-view" aria-hidden="true"></clr-icon>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="logo d-flex clr-align-items-center">
|
||||||
|
@if (!router.url.includes('deploy')) {
|
||||||
|
<a href="#" [routerLink]="['/']" class="nav-link">
|
||||||
|
<img
|
||||||
|
class="without-text d-block d-md-none"
|
||||||
|
src="images/dc-logo.svg"
|
||||||
|
alt="datacontroller logo without text"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
class="with-text d-none d-md-block"
|
||||||
|
src="images/datacontroller.svg"
|
||||||
|
alt="datacontroller logo"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (router.url.includes('deploy')) {
|
||||||
|
<a>
|
||||||
|
<span class="clr-icon header-logo ml-10"></span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (
|
||||||
|
!router.url.includes('deploy') && !router.url.includes('licensing')
|
||||||
|
) {
|
||||||
|
<div class="header-nav d-flex d-sm-none">
|
||||||
|
<clr-dropdown>
|
||||||
|
<button
|
||||||
|
class="nav-icon color-white-i"
|
||||||
|
clrDropdownTrigger
|
||||||
|
aria-label="toggle settings menu"
|
||||||
|
>
|
||||||
|
Menu
|
||||||
|
<!-- <clr-icon size="20" shape="bars"></clr-icon> -->
|
||||||
|
</button>
|
||||||
|
<clr-dropdown-menu *clrIfOpen clrPosition="bottom-left">
|
||||||
|
<a [routerLink]="['/view']" clrDropdownItem>VIEW</a>
|
||||||
|
<a [routerLink]="['/home']" clrDropdownItem>LOAD</a>
|
||||||
|
<a [routerLink]="['/review/submitted']" clrDropdownItem>REVIEW</a>
|
||||||
|
</clr-dropdown-menu>
|
||||||
|
</clr-dropdown>
|
||||||
|
</div>
|
||||||
|
<div class="header-nav d-none d-sm-flex">
|
||||||
|
<a
|
||||||
|
[routerLink]="['/view']"
|
||||||
|
class="nav-link nav-text"
|
||||||
|
routerLinkActive="active"
|
||||||
|
>VIEW</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
[routerLink]="['/home']"
|
||||||
|
class="nav-link nav-text"
|
||||||
|
[class.active]="
|
||||||
|
router.url.includes('editor') ||
|
||||||
|
router.url.includes('edit-record') ||
|
||||||
|
router.url.includes('home')
|
||||||
|
"
|
||||||
|
>LOAD</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
[routerLink]="['/review/submitted']"
|
||||||
|
[class.active]="
|
||||||
|
router.url.includes('submitted') ||
|
||||||
|
router.url.includes('approve') ||
|
||||||
|
router.url.includes('history')
|
||||||
|
"
|
||||||
|
class="nav-link nav-text cursor-pointer"
|
||||||
|
>REVIEW</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<app-header-actions></app-header-actions>
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
@if (
|
||||||
|
!embed &&
|
||||||
|
(router.url.includes('submitted') ||
|
||||||
router.url.includes('approve') ||
|
router.url.includes('approve') ||
|
||||||
router.url.includes('history')
|
router.url.includes('history'))
|
||||||
"
|
) {
|
||||||
class="subnav"
|
<nav class="subnav">
|
||||||
>
|
<ul class="nav">
|
||||||
<ul class="nav">
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<a
|
||||||
<a
|
[routerLink]="['/review/submitted']"
|
||||||
[routerLink]="['/review/submitted']"
|
class="nav-link nav-text"
|
||||||
class="nav-link nav-text"
|
routerLinkActive="active"
|
||||||
routerLinkActive="active"
|
>SUBMIT</a
|
||||||
>SUBMIT</a
|
>
|
||||||
>
|
</li>
|
||||||
</li>
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<a
|
||||||
<a
|
[routerLink]="['/review/approve']"
|
||||||
[routerLink]="['/review/approve']"
|
class="nav-link nav-text"
|
||||||
class="nav-link nav-text"
|
[class.active]="router.url.includes('approve')"
|
||||||
[class.active]="router.url.includes('approve')"
|
routerLinkActive="active"
|
||||||
routerLinkActive="active"
|
>APPROVE</a
|
||||||
>APPROVE</a
|
>
|
||||||
>
|
</li>
|
||||||
</li>
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<a
|
||||||
<a
|
[routerLink]="['/review/history']"
|
||||||
[routerLink]="['/review/history']"
|
class="nav-link nav-text"
|
||||||
class="nav-link nav-text"
|
routerLinkActive="active"
|
||||||
routerLinkActive="active"
|
>HISTORY</a
|
||||||
>HISTORY</a
|
>
|
||||||
>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
</nav>
|
||||||
</nav>
|
}
|
||||||
|
|
||||||
<app-alerts *ngIf="!errTop"></app-alerts>
|
@if (!errTop) {
|
||||||
|
<app-alerts></app-alerts>
|
||||||
|
}
|
||||||
<app-requests-modal [(opened)]="requestsModal"></app-requests-modal>
|
<app-requests-modal [(opened)]="requestsModal"></app-requests-modal>
|
||||||
<app-excel-password-modal></app-excel-password-modal>
|
<app-excel-password-modal></app-excel-password-modal>
|
||||||
|
|
||||||
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
|
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
|
||||||
|
|
||||||
<router-outlet *ngIf="startupDataLoaded"></router-outlet>
|
<!-- VA embed, back to Editor button -->
|
||||||
|
@if (embed === 'va' && vaEditorLibds && !isMainRoute('/editor')) {
|
||||||
|
<div 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (startupDataLoaded) {
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
}
|
||||||
|
|
||||||
<app-login></app-login>
|
<app-login></app-login>
|
||||||
<app-alerts *ngIf="errTop"></app-alerts>
|
@if (errTop) {
|
||||||
<app-info-modal
|
<app-alerts></app-alerts>
|
||||||
*ngFor="let abort of sasjsAborts"
|
}
|
||||||
[data]="abort"
|
@for (abort of sasjsAborts; track abort) {
|
||||||
[forceReload]="!startupDataLoaded && sasjsAborts.length === 1"
|
<app-info-modal
|
||||||
(onConfirmModalClick)="closeAbortModal(abort.id!)"
|
[data]="abort"
|
||||||
>
|
[forceReload]="!startupDataLoaded && sasjsAborts.length === 1"
|
||||||
</app-info-modal>
|
(onConfirmModalClick)="closeAbortModal(abort.id!)"
|
||||||
|
>
|
||||||
|
</app-info-modal>
|
||||||
|
}
|
||||||
|
|
||||||
<clr-modal
|
<clr-modal
|
||||||
appDragNdrop
|
appDragNdrop
|
||||||
@@ -287,17 +304,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- App Loading Page -->
|
<!-- App Loading Page -->
|
||||||
<div *ngIf="!startupDataLoaded" class="app-loading">
|
@if (!startupDataLoaded) {
|
||||||
<img
|
<div class="app-loading">
|
||||||
class="loading-logo"
|
<img
|
||||||
src="images/datacontroller.svg"
|
class="loading-logo"
|
||||||
alt="datacontroller logo"
|
src="images/datacontroller.svg"
|
||||||
/>
|
alt="datacontroller logo"
|
||||||
|
/>
|
||||||
<div *ngIf="appActive === null" class="slider">
|
@if (appActive === null) {
|
||||||
<div class="line"></div>
|
<div class="slider">
|
||||||
<div class="subline inc"></div>
|
<div class="line"></div>
|
||||||
<div class="subline dec"></div>
|
<div class="subline inc"></div>
|
||||||
|
<div class="subline dec"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
<!-- /App Loading Page -->
|
<!-- /App Loading Page -->
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { Location } from '@angular/common'
|
|||||||
import '@clr/icons'
|
import '@clr/icons'
|
||||||
import '@clr/icons/shapes/all-shapes'
|
import '@clr/icons/shapes/all-shapes'
|
||||||
import { globals } from './_globals'
|
import { globals } from './_globals'
|
||||||
import * as moment from 'moment'
|
import { parseEmbedParam } from './shared/utils/parse-embed-param'
|
||||||
|
import moment from 'moment'
|
||||||
import { EventService } from './services/event.service'
|
import { EventService } from './services/event.service'
|
||||||
import { AppService } from './services/app.service'
|
import { AppService } from './services/app.service'
|
||||||
import { InfoModal } from './models/InfoModal'
|
import { InfoModal } from './models/InfoModal'
|
||||||
@@ -42,7 +43,8 @@ ClarityIcons.addIcons(
|
|||||||
selector: 'my-app',
|
selector: 'my-app',
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrls: ['./app.component.scss'],
|
styleUrls: ['./app.component.scss'],
|
||||||
encapsulation: ViewEncapsulation.None
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
private dcAdapterSettings: DcAdapterSettings | undefined
|
private dcAdapterSettings: DcAdapterSettings | undefined
|
||||||
@@ -69,6 +71,7 @@ export class AppComponent {
|
|||||||
|
|
||||||
public syssite = this.appService.syssite
|
public syssite = this.appService.syssite
|
||||||
public licenceState = this.licenceService.licenceState
|
public licenceState = this.licenceService.licenceState
|
||||||
|
public embed = globals.embed
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private appService: AppService,
|
private appService: AppService,
|
||||||
@@ -142,6 +145,13 @@ export class AppComponent {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hashQuery = window.location.hash.split('?')[1]
|
||||||
|
if (hashQuery && new URLSearchParams(hashQuery).get('embed') !== null) {
|
||||||
|
const embed = parseEmbedParam(hashQuery)
|
||||||
|
globals.embed = embed
|
||||||
|
this.embed = embed
|
||||||
|
}
|
||||||
|
|
||||||
this.subscribeToShowAbortModal()
|
this.subscribeToShowAbortModal()
|
||||||
this.subscribeToRequestsModal()
|
this.subscribeToRequestsModal()
|
||||||
this.subscribeToStartupData()
|
this.subscribeToStartupData()
|
||||||
@@ -197,6 +207,7 @@ export class AppComponent {
|
|||||||
dcPath: getAppAttribute('dcPath') || '',
|
dcPath: getAppAttribute('dcPath') || '',
|
||||||
debug: getAppAttribute('debug') === 'true' || false,
|
debug: getAppAttribute('debug') === 'true' || false,
|
||||||
useComputeApi: this.parseComputeApi(getAppAttribute('useComputeApi')),
|
useComputeApi: this.parseComputeApi(getAppAttribute('useComputeApi')),
|
||||||
|
runAsTask: getAppAttribute('runAsTask') === 'true' || false,
|
||||||
contextName: getAppAttribute('contextName') || '',
|
contextName: getAppAttribute('contextName') || '',
|
||||||
hotLicenceKey: getAppAttribute('hotLicenceKey') || ''
|
hotLicenceKey: getAppAttribute('hotLicenceKey') || ''
|
||||||
}
|
}
|
||||||
@@ -341,4 +352,23 @@ export class AppComponent {
|
|||||||
public openLicencingPage() {
|
public openLicencingPage() {
|
||||||
this.router.navigateByUrl('/licensing/update')
|
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' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-1
@@ -1,5 +1,4 @@
|
|||||||
declare module 'save-svg-as-png'
|
declare module 'save-svg-as-png'
|
||||||
declare module 'numbro/dist/languages.min'
|
|
||||||
declare interface Navigator {
|
declare interface Navigator {
|
||||||
msSaveBlob: (blob: any, defaultName?: string) => boolean
|
msSaveBlob: (blob: any, defaultName?: string) => boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HttpClientModule } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { BrowserModule } from '@angular/platform-browser'
|
import { BrowserModule } from '@angular/platform-browser'
|
||||||
@@ -36,12 +36,12 @@ import { AppSettingsService } from './services/app-settings.service'
|
|||||||
InfoModalComponent,
|
InfoModalComponent,
|
||||||
ViyaApiExplorerComponent
|
ViyaApiExplorerComponent
|
||||||
],
|
],
|
||||||
|
bootstrap: [AppComponent],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserAnimationsModule,
|
BrowserAnimationsModule,
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
HttpClientModule,
|
|
||||||
ROUTING,
|
ROUTING,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
ClarityModule,
|
ClarityModule,
|
||||||
@@ -50,7 +50,12 @@ import { AppSettingsService } from './services/app-settings.service'
|
|||||||
DirectivesModule,
|
DirectivesModule,
|
||||||
NgxJsonViewerModule
|
NgxJsonViewerModule
|
||||||
],
|
],
|
||||||
providers: [AppService, SasStoreService, LicensingGuard, AppSettingsService],
|
providers: [
|
||||||
bootstrap: [AppComponent]
|
AppService,
|
||||||
|
SasStoreService,
|
||||||
|
LicensingGuard,
|
||||||
|
AppSettingsService,
|
||||||
|
provideHttpClient(withInterceptorsFromDi())
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,100 +1,101 @@
|
|||||||
<div class="content-area position-relative">
|
<main class="content-area position-relative">
|
||||||
<div class="clr-row">
|
<div class="clr-row">
|
||||||
<!-- T&C section -->
|
<!-- T&C section -->
|
||||||
<div *ngIf="step === 0" id="TCS" class="card">
|
@if (step === 0) {
|
||||||
<div class="card-header">Terms and Conditions</div>
|
<div id="TCS" class="card">
|
||||||
<div class="card-block">
|
<div class="card-header">Terms and Conditions</div>
|
||||||
<div class="card-text">
|
<div class="card-block">
|
||||||
<p class="mt-0">
|
<div class="card-text">
|
||||||
The Demo version of Data Controller is free for EVALUATION purposes
|
<p class="mt-0">
|
||||||
only. Before proceeding with configuration, please confirm that you
|
The Demo version of Data Controller is free for EVALUATION
|
||||||
have read, understood, and agreed to the
|
purposes only. Before proceeding with configuration, please
|
||||||
<a
|
confirm that you have read, understood, and agreed to the
|
||||||
href="https://docs.datacontroller.io/evaluation-licence-agreement"
|
<a
|
||||||
target="_blank"
|
href="https://docs.datacontroller.io/evaluation-licence-agreement"
|
||||||
>Data Controller for SAS© Evaluation Agreement</a
|
target="_blank"
|
||||||
>.
|
>Data Controller for SAS© Evaluation Agreement</a
|
||||||
</p>
|
>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr class="light" />
|
||||||
|
<clr-checkbox-wrapper>
|
||||||
|
<input clrCheckbox type="checkbox" (change)="termsAgreeChange()" />
|
||||||
|
<label
|
||||||
|
>I have read and agree to the terms of the
|
||||||
|
<a
|
||||||
|
href="https://docs.datacontroller.io/evaluation-licence-agreement"
|
||||||
|
target="_blank"
|
||||||
|
>Data Controller for SAS© Evaluation Agreement</a
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
</clr-checkbox-wrapper>
|
||||||
|
<!-- <hr />
|
||||||
|
<div class="clr-checkbox-wrapper">
|
||||||
|
<input
|
||||||
|
[(ngModel)]="autodeploy"
|
||||||
|
type="checkbox"
|
||||||
|
id="checkbox2"
|
||||||
|
class="clr-checkbox"
|
||||||
|
checked
|
||||||
|
/>
|
||||||
|
<label for="checkbox2"
|
||||||
|
>Autodeploy
|
||||||
|
{{ !jsonFile ? '(json file is not available)' : '' }}</label
|
||||||
|
>
|
||||||
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="light" />
|
|
||||||
|
|
||||||
<clr-checkbox-wrapper>
|
|
||||||
<input clrCheckbox type="checkbox" (change)="termsAgreeChange()" />
|
|
||||||
<label
|
|
||||||
>I have read and agree to the terms of the
|
|
||||||
<a
|
|
||||||
href="https://docs.datacontroller.io/evaluation-licence-agreement"
|
|
||||||
target="_blank"
|
|
||||||
>Data Controller for SAS© Evaluation Agreement</a
|
|
||||||
></label
|
|
||||||
>
|
|
||||||
</clr-checkbox-wrapper>
|
|
||||||
|
|
||||||
<!-- <hr />
|
|
||||||
|
|
||||||
<div class="clr-checkbox-wrapper">
|
|
||||||
<input
|
|
||||||
[(ngModel)]="autodeploy"
|
|
||||||
type="checkbox"
|
|
||||||
id="checkbox2"
|
|
||||||
class="clr-checkbox"
|
|
||||||
checked
|
|
||||||
/>
|
|
||||||
<label for="checkbox2"
|
|
||||||
>Autodeploy
|
|
||||||
{{ !jsonFile ? '(json file is not available)' : '' }}</label
|
|
||||||
>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- T&C section end -->
|
<!-- T&C section end -->
|
||||||
|
|
||||||
<ng-container *ngIf="step > 0" [ngSwitch]="true">
|
@if (step > 0) {
|
||||||
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.SasViya">
|
@switch (true) {
|
||||||
<div *ngIf="autodeploy" class="autodeploy-section card">
|
@case (sasJsConfig.serverType === ServerType.SasViya) {
|
||||||
<app-automatic-deploy
|
@if (autodeploy) {
|
||||||
[sasJs]="sasJs"
|
<div class="autodeploy-section card">
|
||||||
[sasJsConfig]="sasJsConfig"
|
<app-automatic-deploy
|
||||||
[dcAdapterSettings]="dcAdapterSettings"
|
[sasJs]="sasJs"
|
||||||
[appLoc]="appLoc"
|
[sasJsConfig]="sasJsConfig"
|
||||||
[dcPath]="dcPath"
|
[dcAdapterSettings]="dcAdapterSettings"
|
||||||
[selectedAdminGroup]="selectedAdminGroup"
|
[appLoc]="appLoc"
|
||||||
(onNavigateToHome)="onNavigateToHome()"
|
[dcPath]="dcPath"
|
||||||
></app-automatic-deploy>
|
[selectedAdminGroup]="selectedAdminGroup"
|
||||||
</div>
|
(onNavigateToHome)="onNavigateToHome()"
|
||||||
|
></app-automatic-deploy>
|
||||||
<div *ngIf="!autodeploy" id="mainbody" class="card">
|
</div>
|
||||||
<app-manual-deploy
|
}
|
||||||
[sasJs]="sasJs"
|
@if (!autodeploy) {
|
||||||
[sasJsConfig]="sasJsConfig"
|
<div id="mainbody" class="card">
|
||||||
[dcAdapterSettings]="dcAdapterSettings"
|
<app-manual-deploy
|
||||||
(onNavigateToHome)="onNavigateToHome()"
|
[sasJs]="sasJs"
|
||||||
></app-manual-deploy>
|
[sasJsConfig]="sasJsConfig"
|
||||||
</div>
|
[dcAdapterSettings]="dcAdapterSettings"
|
||||||
</ng-container>
|
(onNavigateToHome)="onNavigateToHome()"
|
||||||
|
></app-manual-deploy>
|
||||||
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.Sasjs">
|
</div>
|
||||||
<div class="autodeploy-section card">
|
}
|
||||||
<app-sasjs-configurator
|
}
|
||||||
[sasJs]="sasJs"
|
@case (sasJsConfig.serverType === ServerType.Sasjs) {
|
||||||
[sasJsConfig]="sasJsConfig"
|
<div class="autodeploy-section card">
|
||||||
[dcAdapterSettings]="dcAdapterSettings"
|
<app-sasjs-configurator
|
||||||
(onNavigateToHome)="onNavigateToHome()"
|
[sasJs]="sasJs"
|
||||||
></app-sasjs-configurator>
|
[sasJsConfig]="sasJsConfig"
|
||||||
</div>
|
[dcAdapterSettings]="dcAdapterSettings"
|
||||||
</ng-container>
|
(onNavigateToHome)="onNavigateToHome()"
|
||||||
|
></app-sasjs-configurator>
|
||||||
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.Sas9">
|
</div>
|
||||||
<div class="autodeploy-section card">
|
}
|
||||||
<app-sasjs-configurator
|
@case (sasJsConfig.serverType === ServerType.Sas9) {
|
||||||
[sasJs]="sasJs"
|
<div class="autodeploy-section card">
|
||||||
[sasJsConfig]="sasJsConfig"
|
<app-sasjs-configurator
|
||||||
[dcAdapterSettings]="dcAdapterSettings"
|
[sasJs]="sasJs"
|
||||||
(onNavigateToHome)="onNavigateToHome()"
|
[sasJsConfig]="sasJsConfig"
|
||||||
></app-sasjs-configurator>
|
[dcAdapterSettings]="dcAdapterSettings"
|
||||||
</div>
|
(onNavigateToHome)="onNavigateToHome()"
|
||||||
</ng-container>
|
></app-sasjs-configurator>
|
||||||
</ng-container>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import { DcAdapterSettings } from '../models/DcAdapterSettings'
|
|||||||
host: {
|
host: {
|
||||||
class: 'content-container'
|
class: 'content-container'
|
||||||
},
|
},
|
||||||
encapsulation: ViewEncapsulation.None
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class DeployComponent implements OnInit {
|
export class DeployComponent implements OnInit {
|
||||||
public step: number = 0
|
public step: number = 0
|
||||||
|
|||||||
@@ -1,91 +1,80 @@
|
|||||||
<div *ngIf="autodeploying" class="auto-deploy">
|
@if (autodeploying) {
|
||||||
<div class="spinner-box">
|
<div class="auto-deploy">
|
||||||
<ng-container *ngIf="!autodeployDone">
|
<div class="spinner-box">
|
||||||
<span class="spinner spinner-md"> Loading... </span>
|
@if (!autodeployDone) {
|
||||||
<p>Deploying...</p>
|
<span class="spinner spinner-md"> Loading... </span>
|
||||||
</ng-container>
|
<p>Deploying...</p>
|
||||||
|
}
|
||||||
<ng-container *ngIf="autodeployDone">
|
@if (autodeployDone) {
|
||||||
<p class="m-0 align-self-start">Done</p>
|
<p class="m-0 align-self-start">Done</p>
|
||||||
<hr class="w-100" />
|
<hr class="w-100" />
|
||||||
|
@if (autoDeployStatus.deployServicePack !== null) {
|
||||||
<div
|
<div class="deploy-status-row">
|
||||||
*ngIf="autoDeployStatus.deployServicePack !== null"
|
@if (autoDeployStatus.deployServicePack === true) {
|
||||||
class="deploy-status-row"
|
<clr-icon
|
||||||
>
|
class="deploy-success"
|
||||||
<clr-icon
|
shape="success-standard"
|
||||||
*ngIf="autoDeployStatus.deployServicePack === true"
|
></clr-icon>
|
||||||
class="deploy-success"
|
}
|
||||||
shape="success-standard"
|
@if (!autoDeployStatus.deployServicePack === false) {
|
||||||
></clr-icon>
|
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
||||||
<clr-icon
|
}
|
||||||
*ngIf="!autoDeployStatus.deployServicePack === false"
|
<p>Deploy SAS Jobs</p>
|
||||||
class="deploy-error"
|
</div>
|
||||||
shape="times-circle"
|
}
|
||||||
></clr-icon>
|
@if (autoDeployStatus.runMakeData !== null) {
|
||||||
<p>Deploy SAS Jobs</p>
|
<div class="deploy-status-row">
|
||||||
</div>
|
@if (autoDeployStatus.runMakeData) {
|
||||||
|
<clr-icon
|
||||||
<div
|
class="deploy-success"
|
||||||
*ngIf="autoDeployStatus.runMakeData !== null"
|
shape="success-standard"
|
||||||
class="deploy-status-row"
|
></clr-icon>
|
||||||
>
|
}
|
||||||
<clr-icon
|
@if (autoDeployStatus.runMakeData === false) {
|
||||||
*ngIf="autoDeployStatus.runMakeData"
|
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
||||||
class="deploy-success"
|
}
|
||||||
shape="success-standard"
|
<p>Create database</p>
|
||||||
></clr-icon>
|
</div>
|
||||||
<clr-icon
|
}
|
||||||
*ngIf="autoDeployStatus.runMakeData === false"
|
<hr class="w-100" />
|
||||||
class="deploy-error"
|
<div class="buttons">
|
||||||
shape="times-circle"
|
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
|
||||||
></clr-icon>
|
@if (
|
||||||
<p>Create database</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="w-100" />
|
|
||||||
|
|
||||||
<div class="buttons">
|
|
||||||
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
|
|
||||||
<clr-icon
|
|
||||||
*ngIf="
|
|
||||||
autoDeployStatus.deployServicePack === false ||
|
autoDeployStatus.deployServicePack === false ||
|
||||||
autoDeployStatus.runMakeData === false
|
autoDeployStatus.runMakeData === false
|
||||||
|
) {
|
||||||
|
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
||||||
|
}
|
||||||
|
LAUNCH
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="
|
||||||
|
downloadFile(makeDataResponse, 'create-database-log', 'txt')
|
||||||
"
|
"
|
||||||
class="deploy-error"
|
class="btn btn-primary-outline mt-15 mr-0"
|
||||||
shape="times-circle"
|
>
|
||||||
></clr-icon>
|
Download log
|
||||||
LAUNCH
|
</button>
|
||||||
</button>
|
</div>
|
||||||
|
<hr class="w-100" />
|
||||||
<button
|
<div class="buttons">
|
||||||
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
|
<button
|
||||||
class="btn btn-primary-outline mt-15 mr-0"
|
(click)="autodeploying = false; autodeployDone = false"
|
||||||
>
|
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
|
||||||
Download log
|
>
|
||||||
</button>
|
Close
|
||||||
</div>
|
</button>
|
||||||
|
<button
|
||||||
<hr class="w-100" />
|
(click)="openSasRequestsModal()"
|
||||||
|
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
|
||||||
<div class="buttons">
|
>
|
||||||
<button
|
SAS Requests
|
||||||
(click)="autodeploying = false; autodeployDone = false"
|
</button>
|
||||||
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
|
</div>
|
||||||
>
|
}
|
||||||
Close
|
</div>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
(click)="openSasRequestsModal()"
|
|
||||||
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
|
|
||||||
>
|
|
||||||
SAS Requests
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
<h4 class="text-center my-15">Viya Deploy</h4>
|
<h4 class="text-center my-15">Viya Deploy</h4>
|
||||||
<hr />
|
<hr />
|
||||||
@@ -106,21 +95,18 @@
|
|||||||
<label for="dcloc" class="mt-20 clr-control-label">SAS Admin group</label>
|
<label for="dcloc" class="mt-20 clr-control-label">SAS Admin group</label>
|
||||||
<div class="mb-10 clr-control-container">
|
<div class="mb-10 clr-control-container">
|
||||||
<div class="clr-input-wrapper small-mt">
|
<div class="clr-input-wrapper small-mt">
|
||||||
<select
|
@if (!adminGroupsLoading) {
|
||||||
*ngIf="!adminGroupsLoading"
|
<select clrSelect name="options" [(ngModel)]="selectedAdminGroup">
|
||||||
clrSelect
|
@for (adminGroup of adminGroups; track adminGroup) {
|
||||||
name="options"
|
<option [value]="adminGroup.id">
|
||||||
[(ngModel)]="selectedAdminGroup"
|
{{ adminGroup.name }}
|
||||||
>
|
</option>
|
||||||
<option *ngFor="let adminGroup of adminGroups" [value]="adminGroup.id">
|
}
|
||||||
{{ adminGroup.name }}
|
</select>
|
||||||
</option>
|
}
|
||||||
</select>
|
@if (adminGroupsLoading) {
|
||||||
<clr-spinner
|
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
|
||||||
clrInline
|
}
|
||||||
class="spinner-sm"
|
|
||||||
*ngIf="adminGroupsLoading"
|
|
||||||
></clr-spinner>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,36 +115,34 @@
|
|||||||
>
|
>
|
||||||
<div class="mb-10 clr-control-container">
|
<div class="mb-10 clr-control-container">
|
||||||
<div class="clr-input-wrapper small-mt">
|
<div class="clr-input-wrapper small-mt">
|
||||||
<select
|
@if (!computeContextsLoading) {
|
||||||
*ngIf="!computeContextsLoading"
|
<select
|
||||||
clrSelect
|
clrSelect
|
||||||
name="options"
|
name="options"
|
||||||
(ngModelChange)="onComputeContextChange($event)"
|
(ngModelChange)="onComputeContextChange($event)"
|
||||||
[(ngModel)]="selectedComputeContext"
|
[(ngModel)]="selectedComputeContext"
|
||||||
>
|
|
||||||
<option
|
|
||||||
*ngFor="let computeContext of computeContexts"
|
|
||||||
[value]="computeContext.id"
|
|
||||||
>
|
>
|
||||||
{{ computeContext.name }}
|
@for (computeContext of computeContexts; track computeContext) {
|
||||||
</option>
|
<option [value]="computeContext.id">
|
||||||
</select>
|
{{ computeContext.name }}
|
||||||
<clr-spinner
|
</option>
|
||||||
clrInline
|
}
|
||||||
class="spinner-sm"
|
</select>
|
||||||
*ngIf="computeContextsLoading"
|
}
|
||||||
></clr-spinner>
|
@if (computeContextsLoading) {
|
||||||
|
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngIf="runningAsUser">
|
@if (runningAsUser) {
|
||||||
<label for="dcloc" class="mt-20 clr-control-label">Running as user:</label>
|
<label for="dcloc" class="mt-20 clr-control-label">Running as user:</label>
|
||||||
<div class="mb-10 clr-control-container">
|
<div class="mb-10 clr-control-container">
|
||||||
<div class="clr-input-wrapper">
|
<div class="clr-input-wrapper">
|
||||||
<p class="mt-0">{{ runningAsUser }}</p>
|
<p class="mt-0">{{ runningAsUser }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
}
|
||||||
|
|
||||||
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
|
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
|
||||||
|
|
||||||
@@ -169,7 +153,7 @@
|
|||||||
(click)="recreateDatabaseClicked($event)"
|
(click)="recreateDatabaseClicked($event)"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked
|
checked
|
||||||
/>
|
/>
|
||||||
<label>Recreate database</label>
|
<label>Recreate database</label>
|
||||||
</clr-checkbox-wrapper> -->
|
</clr-checkbox-wrapper> -->
|
||||||
|
|
||||||
@@ -185,18 +169,18 @@
|
|||||||
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
|
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
|
||||||
|
|
||||||
<!-- <button
|
<!-- <button
|
||||||
(click)="executeJson()"
|
(click)="executeJson()"
|
||||||
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
|
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
|
||||||
[disabled]="!jsonFile"
|
[disabled]="!jsonFile"
|
||||||
>
|
>
|
||||||
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
|
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
|
||||||
</button> -->
|
</button> -->
|
||||||
|
|
||||||
<!-- <button
|
<!-- <button
|
||||||
(click)="uploadJsonAuto.click()"
|
(click)="uploadJsonAuto.click()"
|
||||||
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
|
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
|
||||||
>
|
>
|
||||||
Upload different file to deploy
|
Upload different file to deploy
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
#uploadJsonAuto
|
#uploadJsonAuto
|
||||||
@@ -204,7 +188,7 @@
|
|||||||
hidden
|
hidden
|
||||||
(click)="clearUploadInput($event)"
|
(click)="clearUploadInput($event)"
|
||||||
(change)="onJsonFileChange($event)"
|
(change)="onJsonFileChange($event)"
|
||||||
/> -->
|
/> -->
|
||||||
|
|
||||||
<clr-modal [(clrModalOpen)]="recreateDatabaseModal" [clrModalClosable]="false">
|
<clr-modal [(clrModalOpen)]="recreateDatabaseModal" [clrModalClosable]="false">
|
||||||
<h3 class="modal-title">Warning</h3>
|
<h3 class="modal-title">Warning</h3>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import SASjs, { SASjsConfig } from '@sasjs/adapter'
|
import SASjs, { SASjsConfig } from '@sasjs/adapter'
|
||||||
import { DcAdapterSettings } from 'src/app/models/DcAdapterSettings'
|
import { DcAdapterSettings } from 'src/app/models/DcAdapterSettings'
|
||||||
|
import { HelperService } from 'src/app/services'
|
||||||
import { DeployService } from 'src/app/services/deploy.service'
|
import { DeployService } from 'src/app/services/deploy.service'
|
||||||
import { EventService } from 'src/app/services/event.service'
|
import { EventService } from 'src/app/services/event.service'
|
||||||
import { LoggerService } from 'src/app/services/logger.service'
|
import { LoggerService } from 'src/app/services/logger.service'
|
||||||
@@ -28,7 +29,8 @@ import {
|
|||||||
selector: 'app-automatic-deploy',
|
selector: 'app-automatic-deploy',
|
||||||
templateUrl: './automatic.component.html',
|
templateUrl: './automatic.component.html',
|
||||||
styleUrls: ['./automatic.component.scss'],
|
styleUrls: ['./automatic.component.scss'],
|
||||||
encapsulation: ViewEncapsulation.None
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class AutomaticComponent implements OnInit {
|
export class AutomaticComponent implements OnInit {
|
||||||
@Input() sasJs!: SASjs
|
@Input() sasJs!: SASjs
|
||||||
@@ -77,12 +79,24 @@ export class AutomaticComponent implements OnInit {
|
|||||||
runMakeData: null
|
runMakeData: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sasjsConfig = this.sasService.getSasjsConfig()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* makedata service will be run in a new window
|
||||||
|
* This is needed to ensure that the user can see the logs
|
||||||
|
* and the progress of the service execution.
|
||||||
|
* If this is set to `false`, the service will be run in the same window
|
||||||
|
* using the adapter request method.
|
||||||
|
*/
|
||||||
|
public deployInNewWindow: boolean = true
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private eventService: EventService,
|
private eventService: EventService,
|
||||||
private deployService: DeployService,
|
private deployService: DeployService,
|
||||||
private sasService: SasService,
|
private sasService: SasService,
|
||||||
private sasViyaService: SasViyaService,
|
private sasViyaService: SasViyaService,
|
||||||
private loggerService: LoggerService
|
private loggerService: LoggerService,
|
||||||
|
private helperService: HelperService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -154,8 +168,7 @@ export class AutomaticComponent implements OnInit {
|
|||||||
public async getAdminGroups() {
|
public async getAdminGroups() {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
this.adminGroupsLoading = true
|
this.adminGroupsLoading = true
|
||||||
|
;(this.sasViyaService
|
||||||
this.sasViyaService
|
|
||||||
.getAdminGroups()
|
.getAdminGroups()
|
||||||
.subscribe((res: ViyaApiIdentities) => {
|
.subscribe((res: ViyaApiIdentities) => {
|
||||||
this.adminGroupsLoading = false
|
this.adminGroupsLoading = false
|
||||||
@@ -175,7 +188,7 @@ export class AutomaticComponent implements OnInit {
|
|||||||
this.eventService.showAbortModal('admin groups', err)
|
this.eventService.showAbortModal('admin groups', err)
|
||||||
|
|
||||||
reject(err)
|
reject(err)
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +257,7 @@ export class AutomaticComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async runAutoDeploy(executeJson: boolean = false) {
|
public async runAutoDeploy(executeJson: boolean = false) {
|
||||||
this.autodeploying = true
|
if (!this.deployInNewWindow) this.autodeploying = true
|
||||||
|
|
||||||
if (executeJson) {
|
if (executeJson) {
|
||||||
this.executeJson()
|
this.executeJson()
|
||||||
@@ -253,7 +266,7 @@ export class AutomaticComponent implements OnInit {
|
|||||||
if (this.recreateDatabase) {
|
if (this.recreateDatabase) {
|
||||||
this.createDatabase()
|
this.createDatabase()
|
||||||
} else {
|
} else {
|
||||||
this.autodeployDone = true
|
if (!this.deployInNewWindow) this.autodeployDone = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,65 +307,112 @@ export class AutomaticComponent implements OnInit {
|
|||||||
debug: true
|
debug: true
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sasJs
|
if (this.deployInNewWindow) {
|
||||||
.request(`services/admin/makedata`, data, overrideConfig, () => {
|
this.runMakedataInNewWindow({
|
||||||
this.sasService.shouldLogin.next(true)
|
contextName: selectedComputeContextName,
|
||||||
|
admin: this.selectedAdminGroup,
|
||||||
|
dcPath: this.dcPath
|
||||||
})
|
})
|
||||||
.then((res: any) => {
|
} else {
|
||||||
this.autodeployDone = true
|
this.sasJs
|
||||||
|
.request(`services/admin/makedata`, data, overrideConfig, () => {
|
||||||
|
this.sasService.shouldLogin.next(true)
|
||||||
|
})
|
||||||
|
.then((res: any) => {
|
||||||
|
this.autodeployDone = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.makeDataResponse = JSON.stringify(res)
|
this.makeDataResponse = JSON.stringify(res)
|
||||||
} catch {
|
} catch {
|
||||||
this.makeDataResponse = res
|
this.makeDataResponse = res
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.result && res.result.length > 0) {
|
if (res.result && res.result.length > 0) {
|
||||||
this.autoDeployStatus.runMakeData = true
|
this.autoDeployStatus.runMakeData = true
|
||||||
} else {
|
} else {
|
||||||
|
this.autoDeployStatus.runMakeData = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof res.sasjsAbort !== 'undefined') {
|
||||||
|
const abortRes = res
|
||||||
|
const abortMsg = abortRes.sasjsAbort[0].MSG
|
||||||
|
const macMsg = abortRes.sasjsAbort[0].MAC
|
||||||
|
|
||||||
|
this.eventService.showAbortModal('makedata', abortMsg, {
|
||||||
|
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
|
||||||
|
SYSERRORTEXT: abortRes.SYSERRORTEXT,
|
||||||
|
MAC: macMsg
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.helperService.isStreamingViya())
|
||||||
|
this.updateIndexHtmlComputeContext()
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
this.eventService.showAbortModal('makedata', JSON.stringify(err))
|
||||||
this.autoDeployStatus.runMakeData = false
|
this.autoDeployStatus.runMakeData = false
|
||||||
}
|
this.autodeployDone = true
|
||||||
|
|
||||||
if (typeof res.sasjsAbort !== 'undefined') {
|
try {
|
||||||
const abortRes = res
|
this.makeDataResponse = JSON.stringify(err)
|
||||||
const abortMsg = abortRes.sasjsAbort[0].MSG
|
} catch {
|
||||||
const macMsg = abortRes.sasjsAbort[0].MAC
|
this.makeDataResponse = err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.eventService.showAbortModal('makedata', abortMsg, {
|
public runMakedataInNewWindow(params: {
|
||||||
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
|
contextName: string
|
||||||
SYSERRORTEXT: abortRes.SYSERRORTEXT,
|
admin: string
|
||||||
MAC: macMsg
|
dcPath: string
|
||||||
})
|
}) {
|
||||||
}
|
let serverUrl = this.sasjsConfig.serverUrl
|
||||||
|
let appLoc = this.sasjsConfig.appLoc
|
||||||
|
const execPath = this.sasService.getExecutionPath()
|
||||||
|
let contextname = `&_contextname=${params.contextName}`
|
||||||
|
let admin = `&admin=${params.admin}`
|
||||||
|
let dcPath = `&dcpath=${params.dcPath}`
|
||||||
|
let debug = this.sasService.getDebugUrlParam()
|
||||||
|
|
||||||
this.updateIndexHtmlComputeContext()
|
let programUrl =
|
||||||
})
|
serverUrl +
|
||||||
.catch((err: any) => {
|
execPath +
|
||||||
this.eventService.showAbortModal('makedata', JSON.stringify(err))
|
'/?_program=' +
|
||||||
this.autoDeployStatus.runMakeData = false
|
appLoc +
|
||||||
this.autodeployDone = true
|
'/services/admin/makedata' +
|
||||||
|
contextname +
|
||||||
|
admin +
|
||||||
|
dcPath +
|
||||||
|
debug
|
||||||
|
|
||||||
try {
|
window.open(programUrl)
|
||||||
this.makeDataResponse = JSON.stringify(err)
|
|
||||||
} catch {
|
|
||||||
this.makeDataResponse = err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only when on Viya, this method will update the `contextname` in the `DataController.html` on the SAS drive
|
* Only when on Viya streamed app, this method will update the `contextname` in the `index.html` on the SAS drive
|
||||||
* This is needed to ensure that the DC will use the same compute context `makedata` service used to run against.
|
* This is needed to ensure that the DC will use the same compute context `makedata` service used to run against.
|
||||||
*/
|
*/
|
||||||
public async updateIndexHtmlComputeContext() {
|
public async updateIndexHtmlComputeContext() {
|
||||||
|
const filenamePath = location.search.split('/').pop()
|
||||||
|
const filename = filenamePath?.includes('.') ? filenamePath : undefined
|
||||||
|
|
||||||
|
if (!filename) {
|
||||||
|
this.eventService.showAbortModal(
|
||||||
|
null,
|
||||||
|
'We could not figure out the file name of `index.html` based on the url.'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const indexHtmlContent = await this.sasService.getFileContent(
|
const indexHtmlContent = await this.sasService.getFileContent(
|
||||||
`${this.appLoc}/services`,
|
`${this.appLoc}/services`,
|
||||||
'DataController.html'
|
filename
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!indexHtmlContent) {
|
if (!indexHtmlContent) {
|
||||||
this.loggerService.error(
|
this.loggerService.error(
|
||||||
`Failed to get DataController.html at ${this.appLoc}/services`
|
`Failed to get ${filename} at ${this.appLoc}/services`
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -374,11 +434,7 @@ export class AutomaticComponent implements OnInit {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await this.sasService
|
await this.sasService
|
||||||
.updateFileContent(
|
.updateFileContent(`${this.appLoc}/services`, filename, updatedContent)
|
||||||
`${this.appLoc}/services`,
|
|
||||||
'DataController.html',
|
|
||||||
updatedContent
|
|
||||||
)
|
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
this.loggerService.error(`Failed to update DataController.html: ${err}`)
|
this.loggerService.error(`Failed to update DataController.html: ${err}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,29 +10,29 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-block">
|
<div class="card-block">
|
||||||
<!-- <div class="card-title">
|
<!-- <div class="card-title">
|
||||||
Client Details
|
Client Details
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
<div *ngIf="needsLogin" id="loginForm" class="d-none">
|
@if (needsLogin) {
|
||||||
<p class="mb-10">Please log in first</p>
|
<div id="loginForm" class="d-none">
|
||||||
<label for="username" class="clr-control-label">Username</label>
|
<p class="mb-10">Please log in first</p>
|
||||||
<div class="mb-10 clr-control-container">
|
<label for="username" class="clr-control-label">Username</label>
|
||||||
<div class="clr-input-wrapper">
|
<div class="mb-10 clr-control-container">
|
||||||
<input type="text" id="username" class="clr-input" />
|
<div class="clr-input-wrapper">
|
||||||
|
<input type="text" id="username" class="clr-input" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<label for="password" class="clr-control-label">Password</label>
|
||||||
|
<div class="mb-10 clr-control-container">
|
||||||
<label for="password" class="clr-control-label">Password</label>
|
<div class="clr-input-wrapper">
|
||||||
<div class="mb-10 clr-control-container">
|
<input type="password" id="password" class="clr-input" />
|
||||||
<div class="clr-input-wrapper">
|
</div>
|
||||||
<input type="password" id="password" class="clr-input" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
|
@if (!needsLogin) {
|
||||||
</div>
|
|
||||||
|
|
||||||
<ng-container *ngIf="!needsLogin">
|
|
||||||
<form>
|
<form>
|
||||||
<div class="clr-form-control">
|
<div class="clr-form-control">
|
||||||
<label for="select-full" class="clr-control-label">Admin group</label>
|
<label for="select-full" class="clr-control-label">Admin group</label>
|
||||||
@@ -45,17 +45,15 @@
|
|||||||
id="adminGroupsSelect"
|
id="adminGroupsSelect"
|
||||||
class="clr-select"
|
class="clr-select"
|
||||||
>
|
>
|
||||||
<option
|
@for (adminGroup of adminGroups; track adminGroup) {
|
||||||
*ngFor="let adminGroup of adminGroups"
|
<option [value]="adminGroup.id">
|
||||||
[value]="adminGroup.id"
|
{{ adminGroup.name }}
|
||||||
>
|
</option>
|
||||||
{{ adminGroup.name }}
|
}
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="clr-form-control">
|
<div class="clr-form-control">
|
||||||
<div [class.hidden]="contextsLoading">
|
<div [class.hidden]="contextsLoading">
|
||||||
<label for="select-full" class="clr-control-label">Context</label>
|
<label for="select-full" class="clr-control-label">Context</label>
|
||||||
@@ -67,15 +65,14 @@
|
|||||||
name="selectedContext"
|
name="selectedContext"
|
||||||
class="clr-select"
|
class="clr-select"
|
||||||
>
|
>
|
||||||
<option
|
@for (context of allContexts; track context) {
|
||||||
*ngFor="let context of allContexts"
|
<option [value]="context.name">
|
||||||
[value]="context.name"
|
{{ context.name }}
|
||||||
>
|
@if ((context.attributes | json) != '{}') {
|
||||||
{{ context.name }}
|
<span>( {{ context.attributes.sysUserId }} )</span>
|
||||||
<span *ngIf="(context.attributes | json) != '{}'"
|
}
|
||||||
>( {{ context.attributes.sysUserId }} )</span
|
</option>
|
||||||
>
|
}
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,14 +85,12 @@
|
|||||||
<clr-icon shape="play"></clr-icon>
|
<clr-icon shape="play"></clr-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div [class.hidden]="!contextsLoading" class="d-flex">
|
<div [class.hidden]="!contextsLoading" class="d-flex">
|
||||||
<span class="spinner spinner-inline mr-10">
|
<span class="spinner spinner-inline mr-10">
|
||||||
Loading contexts...
|
Loading contexts...
|
||||||
</span>
|
</span>
|
||||||
<span> Loading contexts... </span>
|
<span> Loading contexts... </span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
|
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
|
||||||
<div class="mb-10 clr-control-container">
|
<div class="mb-10 clr-control-container">
|
||||||
<div class="clr-input-wrapper">
|
<div class="clr-input-wrapper">
|
||||||
@@ -110,7 +105,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-10">
|
<p class="mt-10">
|
||||||
Select JSON file to upload (json build file preloaded):
|
Select JSON file to upload (json build file preloaded):
|
||||||
</p>
|
</p>
|
||||||
@@ -121,10 +115,9 @@
|
|||||||
(change)="onJsonFileChange($event)"
|
(change)="onJsonFileChange($event)"
|
||||||
/>
|
/>
|
||||||
<!-- <button *ngIf="downloadFileBtn" (click)="downloadSasPrecodeFile()" style="width: 40px; min-width: 0;" class="btn btn-sm btn-icon">
|
<!-- <button *ngIf="downloadFileBtn" (click)="downloadSasPrecodeFile()" style="width: 40px; min-width: 0;" class="btn btn-sm btn-icon">
|
||||||
<clr-icon shape="download"></clr-icon>
|
<clr-icon shape="download"></clr-icon>
|
||||||
</button> -->
|
</button> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-20 d-flex align-items-center">
|
<div class="mt-20 d-flex align-items-center">
|
||||||
<button
|
<button
|
||||||
(click)="executeJson()"
|
(click)="executeJson()"
|
||||||
@@ -135,13 +128,13 @@
|
|||||||
>
|
>
|
||||||
SUBMIT JSON
|
SUBMIT JSON
|
||||||
</button>
|
</button>
|
||||||
<span *ngIf="isJsonSubmitted">JSON Submitted Successfully</span>
|
@if (isJsonSubmitted) {
|
||||||
|
<span>JSON Submitted Successfully</span>
|
||||||
|
}
|
||||||
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
|
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
|
||||||
Loading...
|
Loading...
|
||||||
</span> -->
|
</span> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-10">Select SAS file to upload:</p>
|
<p class="mt-10">Select SAS file to upload:</p>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<input
|
<input
|
||||||
@@ -149,15 +142,15 @@
|
|||||||
(click)="clearUploadInput($event)"
|
(click)="clearUploadInput($event)"
|
||||||
(change)="onSasFileChange($event); downloadFileBtn = true"
|
(change)="onSasFileChange($event); downloadFileBtn = true"
|
||||||
/>
|
/>
|
||||||
<button
|
@if (downloadFileBtn) {
|
||||||
*ngIf="downloadFileBtn"
|
<button
|
||||||
(click)="downloadSasPrecodeFile()"
|
(click)="downloadSasPrecodeFile()"
|
||||||
class="btn btn-sm btn-icon min-w-0 w-40"
|
class="btn btn-sm btn-icon min-w-0 w-40"
|
||||||
>
|
>
|
||||||
<clr-icon shape="download"></clr-icon>
|
<clr-icon shape="download"></clr-icon>
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-20 d-flex align-items-center">
|
<div class="mt-20 d-flex align-items-center">
|
||||||
<button
|
<button
|
||||||
(click)="executeSAS()"
|
(click)="executeSAS()"
|
||||||
@@ -168,17 +161,14 @@
|
|||||||
>
|
>
|
||||||
SUBMIT
|
SUBMIT
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
|
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
|
||||||
Loading...
|
Loading...
|
||||||
</span> -->
|
</span> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (jobLog.length > 0) {
|
||||||
<ng-container *ngIf="jobLog.length > 0">
|
|
||||||
<p class="mb-0 mt-10">File execute completed</p>
|
<p class="mb-0 mt-10">File execute completed</p>
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
(click)="downloadFile(jobLog, 'execute-script-log', 'txt')"
|
(click)="downloadFile(jobLog, 'execute-script-log', 'txt')"
|
||||||
@@ -187,8 +177,7 @@
|
|||||||
Download log
|
Download log
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
[clrLoading]="createDatabaseLoading"
|
[clrLoading]="createDatabaseLoading"
|
||||||
(click)="createDatabase()"
|
(click)="createDatabase()"
|
||||||
@@ -196,52 +185,45 @@
|
|||||||
>
|
>
|
||||||
Create Database
|
Create Database
|
||||||
</button>
|
</button>
|
||||||
|
@if (makeDataResponse.length > 0) {
|
||||||
<ng-container *ngIf="makeDataResponse.length > 0">
|
|
||||||
<p class="mb-0 mt-10">Create Database Completed</p>
|
<p class="mb-0 mt-10">Create Database Completed</p>
|
||||||
<hr />
|
<hr />
|
||||||
|
@if (makeDataResponse.length > 0) {
|
||||||
<div *ngIf="makeDataResponse.length > 0" class="log-wrapper">
|
<div class="log-wrapper">
|
||||||
{{ makeDataResponse }}
|
{{ makeDataResponse }}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<button (click)="navigateToHome()" class="btn btn-primary mt-15">
|
<button (click)="navigateToHome()" class="btn btn-primary mt-15">
|
||||||
Let's get started
|
Let's get started
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
|
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
|
||||||
class="btn btn-primary mt-15"
|
class="btn btn-primary mt-15"
|
||||||
>
|
>
|
||||||
Download log
|
Download log
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button (click)="validateDeploy()" class="btn btn-primary mt-15">
|
<button (click)="validateDeploy()" class="btn btn-primary mt-15">
|
||||||
Validate
|
Validate
|
||||||
</button>
|
</button>
|
||||||
|
@if (validationState !== 'none' || isValidating) {
|
||||||
<div
|
<div class="validation-bar">
|
||||||
*ngIf="validationState !== 'none' || isValidating"
|
@if (isValidating) {
|
||||||
class="validation-bar"
|
<span class="spinner spinner-inline mr-10">
|
||||||
>
|
Validating deploy...
|
||||||
<ng-container *ngIf="isValidating">
|
</span>
|
||||||
<span class="spinner spinner-inline mr-10">
|
<span> Validating deploy... </span>
|
||||||
Validating deploy...
|
}
|
||||||
</span>
|
@if (!isValidating && validationState === 'error') {
|
||||||
<span> Validating deploy... </span>
|
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
|
||||||
</ng-container>
|
<span> Validation failed </span>
|
||||||
|
}
|
||||||
<ng-container *ngIf="!isValidating && validationState === 'error'">
|
@if (!isValidating && validationState === 'success') {
|
||||||
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
|
<clr-icon shape="check-circle" class="is-success"></clr-icon>
|
||||||
<span> Validation failed </span>
|
<span> Validation succeeded </span>
|
||||||
</ng-container>
|
}
|
||||||
|
</div>
|
||||||
<ng-container *ngIf="!isValidating && validationState === 'success'">
|
}
|
||||||
<clr-icon shape="check-circle" class="is-success"></clr-icon>
|
}
|
||||||
<span> Validation succeeded </span>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
</form>
|
</form>
|
||||||
</ng-container>
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import { SasService } from 'src/app/services/sas.service'
|
|||||||
selector: 'app-manual-deploy',
|
selector: 'app-manual-deploy',
|
||||||
templateUrl: './manual.component.html',
|
templateUrl: './manual.component.html',
|
||||||
styleUrls: ['./manual.component.scss'],
|
styleUrls: ['./manual.component.scss'],
|
||||||
encapsulation: ViewEncapsulation.None
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class ManualComponent implements OnInit {
|
export class ManualComponent implements OnInit {
|
||||||
@Input() sasJs!: SASjs
|
@Input() sasJs!: SASjs
|
||||||
@@ -250,7 +251,7 @@ export class ManualComponent implements OnInit {
|
|||||||
this.selectedAdminGroup +
|
this.selectedAdminGroup +
|
||||||
'&DCPATH=' +
|
'&DCPATH=' +
|
||||||
this.dcPath +
|
this.dcPath +
|
||||||
'&_debug=131'
|
this.sasService.getDebugUrlParam()
|
||||||
|
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
|
|
||||||
|
|||||||
+17
-16
@@ -1,6 +1,8 @@
|
|||||||
<div *ngIf="loading" class="thinProgress progresStatic progress loop">
|
@if (loading) {
|
||||||
<progress></progress>
|
<div class="thinProgress progresStatic progress loop">
|
||||||
</div>
|
<progress></progress>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<h4 class="text-center my-15">Sasjs Deploy</h4>
|
<h4 class="text-center my-15">Sasjs Deploy</h4>
|
||||||
<hr class="light" />
|
<hr class="light" />
|
||||||
@@ -27,13 +29,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <button
|
<!-- <button
|
||||||
(click)="directoryBrowse.click()"
|
(click)="directoryBrowse.click()"
|
||||||
class="mt-15 text-center"
|
class="mt-15 text-center"
|
||||||
class="btn btn-sm btn-outline"
|
class="btn btn-sm btn-outline"
|
||||||
>
|
>
|
||||||
Browse
|
Browse
|
||||||
</button>
|
</button>
|
||||||
<input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> -->
|
<input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="m-0 mt-10">
|
<p class="m-0 mt-10">
|
||||||
@@ -45,12 +47,11 @@
|
|||||||
<label class="mt-20 clr-control-label">Data Controller Admin group</label>
|
<label class="mt-20 clr-control-label">Data Controller Admin group</label>
|
||||||
<clr-select-container class="mb-10 mt-0 w-50vw">
|
<clr-select-container class="mb-10 mt-0 w-50vw">
|
||||||
<select [(ngModel)]="dcAdminGroup" clrSelect>
|
<select [(ngModel)]="dcAdminGroup" clrSelect>
|
||||||
<option
|
@for (adminGroup of dcAdminGroupList; track adminGroup) {
|
||||||
*ngFor="let adminGroup of dcAdminGroupList"
|
<option [value]="adminGroup.GROUPNAME">
|
||||||
[value]="adminGroup.GROUPNAME"
|
{{ adminGroup.GROUPNAME }} - {{ adminGroup.GROUPDESC }}
|
||||||
>
|
</option>
|
||||||
{{ adminGroup.GROUPNAME }} - {{ adminGroup.GROUPDESC }}
|
}
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</clr-select-container>
|
</clr-select-container>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import { SasjsService } from 'src/app/services/sasjs.service'
|
|||||||
selector: 'app-sasjs-configurator',
|
selector: 'app-sasjs-configurator',
|
||||||
templateUrl: './sasjs-configurator.component.html',
|
templateUrl: './sasjs-configurator.component.html',
|
||||||
styleUrls: ['./sasjs-configurator.component.scss'],
|
styleUrls: ['./sasjs-configurator.component.scss'],
|
||||||
encapsulation: ViewEncapsulation.None
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class SasjsConfiguratorComponent implements OnInit {
|
export class SasjsConfiguratorComponent implements OnInit {
|
||||||
@Input() sasJs!: SASjs
|
@Input() sasJs!: SASjs
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: '[appDragNdrop]'
|
selector: '[appDragNdrop]',
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class DragNdropDirective {
|
export class DragNdropDirective {
|
||||||
@HostBinding('class.fileover') fileOver: boolean = false
|
@HostBinding('class.fileover') fileOver: boolean = false
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
import { FileUploader } from '../models/FileUploader.class'
|
import { FileUploader } from '../models/FileUploader.class'
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: '[appFileDrop]'
|
selector: '[appFileDrop]',
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class FileDropDirective {
|
export class FileDropDirective {
|
||||||
@Input() uploader?: FileUploader
|
@Input() uploader?: FileUploader
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
import { FileUploader } from '../models/FileUploader.class'
|
import { FileUploader } from '../models/FileUploader.class'
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: '[appFileSelect]'
|
selector: '[appFileSelect]',
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class FileSelectDirective {
|
export class FileSelectDirective {
|
||||||
@Input() uploader?: FileUploader
|
@Input() uploader?: FileUploader
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'
|
|||||||
* Calling functions in html is bad for performance
|
* Calling functions in html is bad for performance
|
||||||
*/
|
*/
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: '[ngVar]'
|
selector: '[ngVar]',
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class NgVarDirective {
|
export class NgVarDirective {
|
||||||
@Input()
|
@Input()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Directive, HostListener } from '@angular/core'
|
import { Directive, HostListener } from '@angular/core'
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: '[appStealFocus]'
|
selector: '[appStealFocus]',
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class StealFocusDirective {
|
export class StealFocusDirective {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|||||||
@@ -84,6 +84,20 @@
|
|||||||
status="warning"
|
status="warning"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
|
|
||||||
|
<!-- SOFTREGEX: value doesn't match the expected pattern, but
|
||||||
|
submission is still allowed (unlike HARDREGEX, which is
|
||||||
|
surfaced via the existing invalid-data class below). -->
|
||||||
|
<clr-icon
|
||||||
|
*ngIf="
|
||||||
|
!currentRecordErrors.includes(colIndex) &&
|
||||||
|
currentRecordWarningCols.includes(col.key)
|
||||||
|
"
|
||||||
|
class="flex-unset position-absolute entry-input-left-offset"
|
||||||
|
shape="error-standard"
|
||||||
|
status="warning"
|
||||||
|
title="Value does not match the expected pattern"
|
||||||
|
></clr-icon>
|
||||||
|
|
||||||
<ng-container *ngSwitchCase="'numeric'">
|
<ng-container *ngSwitchCase="'numeric'">
|
||||||
<clr-input-container
|
<clr-input-container
|
||||||
*ngIf="
|
*ngIf="
|
||||||
@@ -177,45 +191,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<clr-textarea-container class="date-field" *ngSwitchCase="'time'">
|
<div class="date-field" *ngSwitchCase="'intl-time'">
|
||||||
<textarea
|
<input
|
||||||
clrTextarea
|
type="time"
|
||||||
(paste)="recordInputPaste($event)"
|
step="1"
|
||||||
(input)="recordInputChange($event, col.key)"
|
class="date-input"
|
||||||
|
[attr.aria-label]="col.key"
|
||||||
[class.invalid-data]="
|
[class.invalid-data]="
|
||||||
currentRecordInvalidCols.includes(col.key)
|
currentRecordInvalidCols.includes(col.key)
|
||||||
"
|
"
|
||||||
[rows]="col.value.length > 80 ? 6 : 1"
|
[value]="currentRecord[col.key]"
|
||||||
[(ngModel)]="currentRecord[col.key]"
|
(change)="recordTimeChange($event, col.key)"
|
||||||
[class.not-char]="
|
/>
|
||||||
currentRecordValidator?.getRule(col.key)?.type
|
</div>
|
||||||
"
|
|
||||||
></textarea>
|
|
||||||
<clr-control-helper>HH:mm:ss</clr-control-helper>
|
|
||||||
</clr-textarea-container>
|
|
||||||
|
|
||||||
<div class="date-field" *ngSwitchCase="'date'">
|
<div class="date-field" *ngSwitchCase="'intl-datetime'">
|
||||||
<textarea
|
<input
|
||||||
clrTextarea
|
type="datetime-local"
|
||||||
(paste)="recordInputPaste($event)"
|
step="1"
|
||||||
(input)="recordInputChange($event, col.key)"
|
class="date-input"
|
||||||
|
[attr.aria-label]="col.key"
|
||||||
[class.invalid-data]="
|
[class.invalid-data]="
|
||||||
currentRecordInvalidCols.includes(col.key)
|
currentRecordInvalidCols.includes(col.key)
|
||||||
"
|
"
|
||||||
rows="1"
|
[value]="toNativeDatetime(currentRecord[col.key])"
|
||||||
cols="auto"
|
(change)="recordDatetimeChange($event, col.key)"
|
||||||
class="not-char"
|
/>
|
||||||
[(ngModel)]="currentRecord[col.key]"
|
</div>
|
||||||
></textarea>
|
|
||||||
<clr-date-container class="date-picker">
|
<div class="date-field" *ngSwitchCase="'intl-date'">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
name="date"
|
class="date-input"
|
||||||
class="d-none"
|
[attr.aria-label]="col.key"
|
||||||
(clrDateChange)="recordDateChange($event, col.key)"
|
[class.invalid-data]="
|
||||||
clrDate
|
currentRecordInvalidCols.includes(col.key)
|
||||||
/>
|
"
|
||||||
</clr-date-container>
|
[value]="currentRecord[col.key]"
|
||||||
|
(change)="recordDateChange($event, col.key)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div *ngSwitchCase="'autocomplete'">
|
<div *ngSwitchCase="'autocomplete'">
|
||||||
<ng-container
|
<ng-container
|
||||||
@@ -255,6 +269,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline focusable"
|
class="btn btn-outline focusable"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
aria-label="Previous record"
|
||||||
(click)="onPreviousRecordClick()"
|
(click)="onPreviousRecordClick()"
|
||||||
[disabled]="currentRecordInvalidCols.length > 0"
|
[disabled]="currentRecordInvalidCols.length > 0"
|
||||||
>
|
>
|
||||||
@@ -267,6 +282,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline focusable"
|
class="btn btn-outline focusable"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
aria-label="Next record"
|
||||||
(click)="onNextRecordClick()"
|
(click)="onNextRecordClick()"
|
||||||
[disabled]="currentRecordInvalidCols.length > 0"
|
[disabled]="currentRecordInvalidCols.length > 0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,18 +2,19 @@ import { KeyValue } from '@angular/common'
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
Input,
|
Input,
|
||||||
OnInit,
|
OnInit,
|
||||||
Output,
|
Output,
|
||||||
ViewEncapsulation
|
ViewEncapsulation
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import moment from 'moment'
|
|
||||||
import { ValidateFilterSASResponse } from 'src/app/models/sas/validate-filter.model'
|
import { ValidateFilterSASResponse } from 'src/app/models/sas/validate-filter.model'
|
||||||
import { QueryClause } from 'src/app/models/TableData'
|
import { QueryClause } from 'src/app/models/TableData'
|
||||||
import { HelperService } from 'src/app/services/helper.service'
|
import { HelperService } from 'src/app/services/helper.service'
|
||||||
import { SasStoreService } from 'src/app/services/sas-store.service'
|
import { SasStoreService } from 'src/app/services/sas-store.service'
|
||||||
import { DcValidator } from 'src/app/shared/dc-validator/dc-validator'
|
import { DcValidator } from 'src/app/shared/dc-validator/dc-validator'
|
||||||
import { DcValidation } from 'src/app/shared/dc-validator/models/dc-validation.model'
|
import { DcValidation } from 'src/app/shared/dc-validator/models/dc-validation.model'
|
||||||
|
import { isEmpty } from 'src/app/shared/dc-validator/utils/isEmpty'
|
||||||
import {
|
import {
|
||||||
EditRecordDropdownChangeEvent,
|
EditRecordDropdownChangeEvent,
|
||||||
EditRecordInputFocusedEvent
|
EditRecordInputFocusedEvent
|
||||||
@@ -24,7 +25,8 @@ import { EditRecordModal } from '../../models/EditRecordModal'
|
|||||||
selector: 'app-edit-record',
|
selector: 'app-edit-record',
|
||||||
templateUrl: './edit-record.component.html',
|
templateUrl: './edit-record.component.html',
|
||||||
styleUrls: ['./edit-record.component.scss'],
|
styleUrls: ['./edit-record.component.scss'],
|
||||||
encapsulation: ViewEncapsulation.None
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class EditRecordComponent implements OnInit {
|
export class EditRecordComponent implements OnInit {
|
||||||
@Input() currentRecord!: EditRecordModal
|
@Input() currentRecord!: EditRecordModal
|
||||||
@@ -53,6 +55,7 @@ export class EditRecordComponent implements OnInit {
|
|||||||
@Output() onPreviousRecord: EventEmitter<any> = new EventEmitter<any>()
|
@Output() onPreviousRecord: EventEmitter<any> = new EventEmitter<any>()
|
||||||
|
|
||||||
public currentRecordInvalidCols: string[] = []
|
public currentRecordInvalidCols: string[] = []
|
||||||
|
public currentRecordWarningCols: string[] = []
|
||||||
public generateEditRecordUrlLoading: boolean = false
|
public generateEditRecordUrlLoading: boolean = false
|
||||||
public generatedRecordUrl: string | null = null
|
public generatedRecordUrl: string | null = null
|
||||||
public addRecordUrl: string | null = null
|
public addRecordUrl: string | null = null
|
||||||
@@ -89,17 +92,72 @@ export class EditRecordComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fired when date field in the record change
|
* Fired when the native date picker (intl-date) changes. A native
|
||||||
* Function will parse date and format to string
|
* `<input type="date">` value is always ISO `YYYY-MM-DD`, so it is stored
|
||||||
* @param date picker value
|
* as-is. Mirrors recordTimeChange / recordDatetimeChange.
|
||||||
|
* @param event native <input type="date"> change event
|
||||||
* @param colKey column name (key)
|
* @param colKey column name (key)
|
||||||
*/
|
*/
|
||||||
recordDateChange(date: Date, colKey: string) {
|
recordDateChange(event: Event, colKey: string) {
|
||||||
let cellValidation = this.currentRecordValidator?.getRule(colKey)
|
const value = (event.target as HTMLInputElement).value // YYYY-MM-DD
|
||||||
let format = cellValidation ? cellValidation.dateFormat : ''
|
if (!value || !this.currentRecord) return
|
||||||
|
this.currentRecord[colKey] = value
|
||||||
|
this.revalidateRecordCol(colKey, value)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.currentRecord)
|
/**
|
||||||
this.currentRecord[colKey] = moment(date).format(format)
|
* Stored `YYYY-MM-DD HH:mm:ss` → native `<input type="datetime-local">` value
|
||||||
|
* `YYYY-MM-DDTHH:mm:ss`, so the picker opens at the current value.
|
||||||
|
*/
|
||||||
|
toNativeDatetime(value: any): string {
|
||||||
|
if (typeof value !== 'string') return ''
|
||||||
|
return value.includes(' ') ? value.replace(' ', 'T') : value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-runs validation for a column after a native picker change (the picker
|
||||||
|
* handlers set the value programmatically, which does not fire the textarea's
|
||||||
|
* input handler). Mirrors recordInputChange's validate + state update.
|
||||||
|
*/
|
||||||
|
private revalidateRecordCol(colName: string, value: any) {
|
||||||
|
const colRules = this.currentRecordValidator?.getRule(colName)
|
||||||
|
|
||||||
|
this.validateRecordCol(colRules, value).then((valid: boolean) =>
|
||||||
|
this.updateValidationState(colName, valid)
|
||||||
|
)
|
||||||
|
|
||||||
|
this.updateWarningState(colName, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired when the native time picker (intl-time) changes. Stores 24h
|
||||||
|
* `HH:mm:ss`, padding the seconds the browser may omit. Mirrors the
|
||||||
|
* IntlDatetimeEditor conversion used by the HOT grid.
|
||||||
|
* @param event native <input type="time"> change event
|
||||||
|
* @param colKey column name (key)
|
||||||
|
*/
|
||||||
|
recordTimeChange(event: Event, colKey: string) {
|
||||||
|
const value = (event.target as HTMLInputElement).value // HH:mm[:ss]
|
||||||
|
if (!value || !this.currentRecord) return
|
||||||
|
const stored = value.length === 5 ? `${value}:00` : value
|
||||||
|
this.currentRecord[colKey] = stored
|
||||||
|
this.revalidateRecordCol(colKey, stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired when the native datetime picker (intl-datetime) changes. Converts the
|
||||||
|
* native `YYYY-MM-DDTHH:mm[:ss]` into SAS' stored `YYYY-MM-DD HH:mm:ss`,
|
||||||
|
* padding seconds. Mirrors the IntlDatetimeEditor conversion used by the grid.
|
||||||
|
* @param event native <input type="datetime-local"> change event
|
||||||
|
* @param colKey column name (key)
|
||||||
|
*/
|
||||||
|
recordDatetimeChange(event: Event, colKey: string) {
|
||||||
|
const value = (event.target as HTMLInputElement).value // YYYY-MM-DDTHH:mm[:ss]
|
||||||
|
if (!value || !this.currentRecord) return
|
||||||
|
const [date, time] = value.split('T')
|
||||||
|
const stored = `${date} ${time.length === 5 ? `${time}:00` : time}`
|
||||||
|
this.currentRecord[colKey] = stored
|
||||||
|
this.revalidateRecordCol(colKey, stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,6 +176,16 @@ export class EditRecordComponent implements OnInit {
|
|||||||
this.onRecordEditClose.emit()
|
this.onRecordEditClose.emit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the modal on Escape (cancel, like the Close button). A native picker
|
||||||
|
* swallows the first Escape to dismiss itself, so the modal only closes once
|
||||||
|
* focus is back on the form.
|
||||||
|
*/
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscapeKey() {
|
||||||
|
this.closeRecordEdit()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emitting output event when dropdown (autocomplete) input in any col change
|
* Emitting output event when dropdown (autocomplete) input in any col change
|
||||||
* @param colName column name (key)
|
* @param colName column name (key)
|
||||||
@@ -145,23 +213,83 @@ export class EditRecordComponent implements OnInit {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
async recordInputChange(event: any, colName: string) {
|
async recordInputChange(event: any, colName: string): Promise<void> {
|
||||||
const colRules = this.currentRecordValidator?.getRule(colName)
|
const colRules = this.currentRecordValidator?.getRule(colName)
|
||||||
const value = event.target.value
|
const value = event.target.value
|
||||||
|
|
||||||
this.helperService.debounceCall(300, () => {
|
this.helperService.debounceCall(300, () => {
|
||||||
this.validateRecordCol(colRules, value).then((valid: boolean) => {
|
this.validateRecordCol(colRules, value).then((valid: boolean) => {
|
||||||
const index = this.currentRecordInvalidCols.indexOf(colName)
|
this.updateValidationState(colName, valid)
|
||||||
|
|
||||||
if (valid) {
|
if (!valid) {
|
||||||
if (index > -1) this.currentRecordInvalidCols.splice(index, 1)
|
this.tryAutoPopulateNotNull(event, colName, colRules, value)
|
||||||
} else {
|
|
||||||
if (index < 0) this.currentRecordInvalidCols.push(colName)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.updateWarningState(colName, value)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the invalid columns list based on validation result
|
||||||
|
*/
|
||||||
|
private updateValidationState(colName: string, valid: boolean): void {
|
||||||
|
const index = this.currentRecordInvalidCols.indexOf(colName)
|
||||||
|
|
||||||
|
if (valid && index > -1) {
|
||||||
|
this.currentRecordInvalidCols.splice(index, 1)
|
||||||
|
} else if (!valid && index < 0) {
|
||||||
|
this.currentRecordInvalidCols.push(colName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the SOFTREGEX warning columns list — the modal's equivalent of
|
||||||
|
* makeRegexWarningRenderer in the grid (see DcValidator.failsSoftRegex).
|
||||||
|
*/
|
||||||
|
private updateWarningState(colName: string, value: any): void {
|
||||||
|
const failsSoftRegex =
|
||||||
|
this.currentRecordValidator?.failsSoftRegex(colName, value) ?? false
|
||||||
|
const index = this.currentRecordWarningCols.indexOf(colName)
|
||||||
|
|
||||||
|
if (!failsSoftRegex && index > -1) {
|
||||||
|
this.currentRecordWarningCols.splice(index, 1)
|
||||||
|
} else if (failsSoftRegex && index < 0) {
|
||||||
|
this.currentRecordWarningCols.push(colName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-populates NOTNULL default value when the field is empty and has a default
|
||||||
|
*/
|
||||||
|
private tryAutoPopulateNotNull(
|
||||||
|
event: any,
|
||||||
|
colName: string,
|
||||||
|
colRules: DcValidation | undefined,
|
||||||
|
value: any
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!isEmpty(value) ||
|
||||||
|
!this.currentRecordValidator ||
|
||||||
|
!this.currentRecord
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValue =
|
||||||
|
this.currentRecordValidator.getNotNullDefaultValue(colName)
|
||||||
|
if (defaultValue === undefined) return
|
||||||
|
|
||||||
|
this.currentRecord[colName] = defaultValue
|
||||||
|
event.target.value = defaultValue
|
||||||
|
|
||||||
|
this.validateRecordCol(colRules, defaultValue).then((isValid: boolean) => {
|
||||||
|
this.updateValidationState(colName, isValid)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.updateWarningState(colName, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
onNextRecordClick() {
|
onNextRecordClick() {
|
||||||
this.onNextRecord.emit()
|
this.onNextRecord.emit()
|
||||||
}
|
}
|
||||||
@@ -171,23 +299,8 @@ export class EditRecordComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public copyToClip(text: string) {
|
public copyToClip(text: string) {
|
||||||
const modalElement = document.querySelector('#recordModalRef .modal-title')
|
navigator.clipboard.writeText(text)
|
||||||
|
this.generatedRecordUrl = text
|
||||||
if (modalElement) {
|
|
||||||
const selBox = document.createElement('textarea')
|
|
||||||
selBox.style.position = 'fixed'
|
|
||||||
selBox.style.left = '0'
|
|
||||||
selBox.style.top = '0'
|
|
||||||
selBox.style.opacity = '0'
|
|
||||||
selBox.style.zIndex = '5000'
|
|
||||||
selBox.value = text
|
|
||||||
modalElement.appendChild(selBox)
|
|
||||||
selBox.focus()
|
|
||||||
selBox.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
modalElement.removeChild(selBox)
|
|
||||||
this.generatedRecordUrl = text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateEditRecordUrl() {
|
async generateEditRecordUrl() {
|
||||||
@@ -206,8 +319,9 @@ export class EditRecordComponent implements OnInit {
|
|||||||
if (obj.data === key) {
|
if (obj.data === key) {
|
||||||
if (
|
if (
|
||||||
obj.type === 'numeric' ||
|
obj.type === 'numeric' ||
|
||||||
obj.type === 'date' ||
|
obj.type === 'intl-date' ||
|
||||||
obj.type === 'time'
|
obj.type === 'intl-time' ||
|
||||||
|
obj.type === 'intl-datetime'
|
||||||
) {
|
) {
|
||||||
type = 'N'
|
type = 'N'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="m-0" *ngFor="let state of processedStates; let i = index">
|
@for (state of processedStates; track state; let i = $index) {
|
||||||
{{ state }}
|
<p class="m-0">
|
||||||
</p>
|
{{ state }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="spinner spinner-sm vertical-align-middle">Loading...</span>
|
<span class="spinner spinner-sm vertical-align-middle">Loading...</span>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { Component, OnInit, ViewEncapsulation } from '@angular/core'
|
|||||||
selector: 'app-upload-stater',
|
selector: 'app-upload-stater',
|
||||||
templateUrl: './upload-stater.component.html',
|
templateUrl: './upload-stater.component.html',
|
||||||
styleUrls: ['./upload-stater.component.scss'],
|
styleUrls: ['./upload-stater.component.scss'],
|
||||||
encapsulation: ViewEncapsulation.None
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class UploadStaterComponent implements OnInit {
|
export class UploadStaterComponent implements OnInit {
|
||||||
public statesList: string[] = [] //States appended to be displayed
|
public statesList: string[] = [] //States appended to be displayed
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common'
|
|||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { ClarityModule } from '@clr/angular'
|
import { ClarityModule } from '@clr/angular'
|
||||||
import { HotTableModule } from '@handsontable/angular'
|
import { HotTableModule } from '@handsontable/angular-wrapper'
|
||||||
import { registerAllModules } from 'handsontable/registry'
|
import { registerAllModules } from 'handsontable/registry'
|
||||||
import { AppSharedModule } from '../app-shared.module'
|
import { AppSharedModule } from '../app-shared.module'
|
||||||
import { DirectivesModule } from '../directives/directives.module'
|
import { DirectivesModule } from '../directives/directives.module'
|
||||||
@@ -28,7 +28,7 @@ registerAllModules()
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
EditorRoutingModule,
|
EditorRoutingModule,
|
||||||
ClarityModule,
|
ClarityModule,
|
||||||
HotTableModule.forRoot(),
|
HotTableModule,
|
||||||
AppSharedModule,
|
AppSharedModule,
|
||||||
DirectivesModule,
|
DirectivesModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Model for the dynamic cell validation in the editor
|
* Model for a row of `dynamic_values` returned by the getdynamiccolvals
|
||||||
* (sending whole row to the backend service and recieving data for the cell dropdown)
|
* 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 {
|
export interface DynamicExtendedCellValidation {
|
||||||
DISPLAY_INDEX: number
|
DISPLAY_INDEX: number
|
||||||
DISPLAY_TYPE: string
|
DISPLAY_TYPE: string
|
||||||
DISPLAY_VALUE: string
|
|
||||||
EXTRA_COL_NAME: string
|
EXTRA_COL_NAME: string
|
||||||
RAW_VALUE_CHAR: string
|
RAW_VALUE_CHAR: string
|
||||||
RAW_VALUE_NUM: number
|
RAW_VALUE_NUM: number
|
||||||
FORCE_FLAG: number
|
FORCED_VALUE: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { classifyRow } from './classifyRow'
|
||||||
|
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
|
||||||
|
|
||||||
|
describe('classifyRow', () => {
|
||||||
|
const headerPks = ['PRIMARY_KEY_FIELD']
|
||||||
|
|
||||||
|
it('is D when the row is marked for delete, regardless of other field changes', () => {
|
||||||
|
const dataRow = {
|
||||||
|
PRIMARY_KEY_FIELD: 1,
|
||||||
|
SOME_CHAR: 'changed',
|
||||||
|
_____DELETE__THIS__RECORD_____: 'Yes'
|
||||||
|
}
|
||||||
|
const dataSourceUnchanged = [
|
||||||
|
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('D')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is A when no row in dataSourceUnchanged matches the primary key(s)', () => {
|
||||||
|
const dataRow = { PRIMARY_KEY_FIELD: 99, SOME_CHAR: 'new row' }
|
||||||
|
const dataSourceUnchanged = [
|
||||||
|
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('A')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is M when a matching row exists but a field differs', () => {
|
||||||
|
const dataRow = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'changed' }
|
||||||
|
const dataSourceUnchanged = [
|
||||||
|
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('M')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is U when a matching row exists and nothing differs', () => {
|
||||||
|
const dataRow = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||||
|
const dataSourceUnchanged = [
|
||||||
|
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('U')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches on all primary key columns for a composite key', () => {
|
||||||
|
const headerPksComposite = ['LIB', 'ID']
|
||||||
|
const dataRow = { LIB: 'WORK', ID: 1, SOME_CHAR: 'original' }
|
||||||
|
const dataSourceUnchanged = [
|
||||||
|
{ LIB: 'OTHER', ID: 1, SOME_CHAR: 'original' },
|
||||||
|
{ LIB: 'WORK', ID: 1, SOME_CHAR: 'original' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Same ID=1 exists under a different LIB - must not match that one.
|
||||||
|
expect(
|
||||||
|
classifyRow(dataRow, dataSourceUnchanged, headerPksComposite)
|
||||||
|
).toEqual('U')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores its own EDIT_STATUS column value when diffing, so writing the classification back never self-perpetuates a stale M', () => {
|
||||||
|
const dataRow = {
|
||||||
|
PRIMARY_KEY_FIELD: 1,
|
||||||
|
SOME_CHAR: 'original',
|
||||||
|
[EDIT_STATUS_COLUMN_NAME]: 'M'
|
||||||
|
}
|
||||||
|
const dataSourceUnchanged = [
|
||||||
|
{
|
||||||
|
PRIMARY_KEY_FIELD: 1,
|
||||||
|
SOME_CHAR: 'original',
|
||||||
|
[EDIT_STATUS_COLUMN_NAME]: 'U'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('U')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
|
||||||
|
|
||||||
|
export type RowEditStatus = 'M' | 'A' | 'D' | 'U'
|
||||||
|
|
||||||
|
// EDIT_STATUS holds this very function's own prior output - comparing it
|
||||||
|
// like any other field would self-perpetuate: once a row is written as 'M',
|
||||||
|
// its EDIT_STATUS would forever differ from the unchanged snapshot's 'U'
|
||||||
|
// even after the real edit is reverted, permanently stuck on 'M'.
|
||||||
|
const withoutEditStatus = (row: any): any => {
|
||||||
|
const { [EDIT_STATUS_COLUMN_NAME]: _editStatus, ...rest } = row
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies a single row as Modified/Added/Deleted/Unchanged against the
|
||||||
|
* pre-edit snapshot (`dataSourceUnchanged`), PK-matched via `headerPks`.
|
||||||
|
*
|
||||||
|
* Extracted from editor.component.ts's getRowsSubmittingCount(), which used
|
||||||
|
* this same logic inline, only at submit time. Kept here as a pure function
|
||||||
|
* so it can run live (e.g. on every afterChange, for the EDIT_STATUS column)
|
||||||
|
* without duplicating the diffing rules.
|
||||||
|
*/
|
||||||
|
export const classifyRow = (
|
||||||
|
dataRow: any,
|
||||||
|
dataSourceUnchanged: any[],
|
||||||
|
headerPks: string[]
|
||||||
|
): RowEditStatus => {
|
||||||
|
if (dataRow._____DELETE__THIS__RECORD_____ === 'Yes') return 'D'
|
||||||
|
|
||||||
|
const dataRowUnchanged = dataSourceUnchanged.find((row: any) => {
|
||||||
|
for (const pkCol of headerPks) {
|
||||||
|
if (row[pkCol] !== dataRow[pkCol]) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!dataRowUnchanged) return 'A'
|
||||||
|
|
||||||
|
return JSON.stringify(withoutEditStatus(dataRow)) !==
|
||||||
|
JSON.stringify(withoutEditStatus(dataRowUnchanged))
|
||||||
|
? 'M'
|
||||||
|
: 'U'
|
||||||
|
}
|
||||||
@@ -18,6 +18,22 @@ export const dateToTime = (date: Date) => {
|
|||||||
return timeStr
|
return timeStr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises a 24-hour time STRING to zero-padded `HH:mm:ss`.
|
||||||
|
*
|
||||||
|
* The `intl-time` cell type (validator + native `<input type=time>` editor) is
|
||||||
|
* strict and rejects `H:mm[:ss]`, so such a value paints red and the editor
|
||||||
|
* opens empty. Pad the hour and default the seconds so the value validates.
|
||||||
|
*
|
||||||
|
* Non time-like input is returned unchanged.
|
||||||
|
*/
|
||||||
|
export const padTimeString = (value: string): string => {
|
||||||
|
const match = value.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
|
||||||
|
if (!match) return value
|
||||||
|
const [, hours, minutes, seconds] = match
|
||||||
|
return `${hours.padStart(2, '0')}:${minutes}:${seconds ?? '00'}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts date object to the YYYY-MM-DD
|
* Converts date object to the YYYY-MM-DD
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { getEditStatusSymbol } from './getEditStatusSymbol'
|
||||||
|
|
||||||
|
describe('getEditStatusSymbol', () => {
|
||||||
|
it("returns '+' for Added", () => {
|
||||||
|
expect(getEditStatusSymbol('A')).toEqual('+')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns '-' for Deleted", () => {
|
||||||
|
expect(getEditStatusSymbol('D')).toEqual('-')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns '~' for Modified", () => {
|
||||||
|
expect(getEditStatusSymbol('M')).toEqual('~')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns '' for Unchanged", () => {
|
||||||
|
expect(getEditStatusSymbol('U')).toEqual('')
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user