Compare commits
430
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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 | ||
|
|
9a2addc18e | ||
|
|
9264ce2a60 | ||
|
|
cbd69df708 | ||
|
|
ca7caa25b6 | ||
|
|
c10330627f | ||
|
|
d80c59afce | ||
|
|
abdbb67471 | ||
|
|
037a97b6ff | ||
|
|
a0a529ad38 | ||
|
|
72239558af | ||
|
|
d2097ad6dd | ||
|
|
1bd542cddb | ||
|
|
fb1c1ee874 | ||
|
|
624a7a8f37 | ||
|
|
381378f532 | ||
|
|
af05486c0e | ||
|
|
4b558948d9 |
@@ -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,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.
|
||||||
|
```
|
||||||
+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
|
||||||
+137
-71
@@ -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
|
||||||
|
|
||||||
@@ -237,20 +280,20 @@ jobs:
|
|||||||
cd sas
|
cd sas
|
||||||
sasjs c -t viya
|
sasjs c -t viya
|
||||||
rm -rf sasjsbuild/tests
|
rm -rf sasjsbuild/tests
|
||||||
sed -i -e 's/servertype="SASJS"/servertype="SASVIYA"/g' sasjsbuild/services/clickme.html
|
sed -i -e 's/servertype="SASJS"/servertype="SASVIYA"/g' sasjsbuild/services/DC.html
|
||||||
sasjs b -t viya
|
|
||||||
cp sasjsbuild/viya.sas ./demostream_viya.sas
|
|
||||||
# compile Viya Full deploy (without web)
|
|
||||||
rm -rf sasjsbuild/services/web
|
|
||||||
rm sasjsbuild/services/clickme.html
|
|
||||||
sasjs b -t viya
|
sasjs b -t viya
|
||||||
cp sasjsbuild/viya.sas ./viya.sas
|
cp sasjsbuild/viya.sas ./viya.sas
|
||||||
cp sasjsbuild/viya.json ./viya.json
|
# compile Viya Full deploy (without web)
|
||||||
|
rm -rf sasjsbuild/services/web
|
||||||
|
rm sasjsbuild/services/DC.html
|
||||||
|
sasjs b -t viya
|
||||||
|
cp sasjsbuild/viya.sas ./viya_noweb.sas
|
||||||
|
cp sasjsbuild/viya.json ./viya_noweb.json
|
||||||
|
|
||||||
- name: Zip Frontend (including viya.json for full viya deploy)
|
- name: Zip Frontend (including viya.json for full viya deploy)
|
||||||
run: |
|
run: |
|
||||||
cd sas
|
cd sas
|
||||||
cp sasjsbuild/viya.json ../client/dist
|
cp sasjsbuild/viya.json ../client/dist/viya.json
|
||||||
cd ..
|
cd ..
|
||||||
zip -r frontend.zip ./client/dist
|
zip -r frontend.zip ./client/dist
|
||||||
|
|
||||||
@@ -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/demostream_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.sas
|
sas/viya_noweb.sas \
|
||||||
curl -k $URL -F attachment=@sas/viya.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,25 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## SAS files
|
||||||
|
|
||||||
|
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/`.
|
||||||
+449
@@ -1,3 +1,452 @@
|
|||||||
|
# [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)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* adapter bump ([ca7caa2](https://git.datacontroller.io/dc/dc/commit/ca7caa25b6eea1bd4579fb8b67ec9b211a893079))
|
||||||
|
* automatic viya deploy timing issue ([037a97b](https://git.datacontroller.io/dc/dc/commit/037a97b6ffa27b40891531ae6812ebe5b5e71e34))
|
||||||
|
* bump core to ensure ff works on viya streaming deploy ([cbd69df](https://git.datacontroller.io/dc/dc/commit/cbd69df708edf3a8446115ca7315fac3557dcf97)), closes [#156](https://git.datacontroller.io/dc/dc/issues/156)
|
||||||
|
* viya deploy load data timing ([abdbb67](https://git.datacontroller.io/dc/dc/commit/abdbb674713796e5308eb4272197a5c253868a85))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* viya deploy, update the index.html contextname ([7223955](https://git.datacontroller.io/dc/dc/commit/72239558af2ee50cdfc71b7e185e6661ab568ba1))
|
||||||
|
|
||||||
|
## [6.15.2](https://git.datacontroller.io/dc/dc/compare/v6.15.1...v6.15.2) (2025-06-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* pipeline updates for DC.html ([624a7a8](https://git.datacontroller.io/dc/dc/commit/624a7a8f37f0265cf576da310ac330c75aa417cf))
|
||||||
|
|
||||||
|
## [6.15.1](https://git.datacontroller.io/dc/dc/compare/v6.15.0...v6.15.1) (2025-06-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* updating pipeline to default to streaming on viya ([4b55894](https://git.datacontroller.io/dc/dc/commit/4b558948d997f456ff25a12a58827fe0d2075493))
|
||||||
|
|
||||||
# [6.15.0](https://git.datacontroller.io/dc/dc/compare/v6.14.10...v6.15.0) (2025-06-04)
|
# [6.15.0](https://git.datacontroller.io/dc/dc/compare/v6.14.10...v6.15.0) (2025-06-04)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,271 @@ 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(() => {
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
|
||||||
|
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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('9 | 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('REGEX: /[\\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(
|
||||||
|
'REGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handsontable virtualizes columns — with 17 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 (the last two) 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')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 +480,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.11.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,28 +79,40 @@ 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 {
|
||||||
const promiseGetAadminGroups = this.getAdminGroups()
|
this.loadData()
|
||||||
const getCurrentUser = this.getCurrentUser()
|
}
|
||||||
const getComputeContexts = this.getComputeContexts()
|
|
||||||
|
|
||||||
Promise.all([
|
public async loadData() {
|
||||||
promiseGetAadminGroups,
|
await this.getAdminGroups()
|
||||||
getCurrentUser,
|
await this.getComputeContexts()
|
||||||
getComputeContexts
|
await this.getCurrentUser()
|
||||||
]).then(() => {
|
|
||||||
|
setTimeout(() => {
|
||||||
if (this.selectedComputeContext) {
|
if (this.selectedComputeContext) {
|
||||||
this.onComputeContextChange(this.selectedComputeContext)
|
this.onComputeContextChange(this.selectedComputeContext)
|
||||||
}
|
}
|
||||||
})
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getComputeContexts() {
|
public async getComputeContexts() {
|
||||||
@@ -144,6 +158,7 @@ export class AutomaticComponent implements OnInit {
|
|||||||
resolve()
|
resolve()
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
|
console.error('Error while getting current user', err)
|
||||||
reject(err)
|
reject(err)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -153,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
|
||||||
@@ -174,7 +188,7 @@ export class AutomaticComponent implements OnInit {
|
|||||||
this.eventService.showAbortModal('admin groups', err)
|
this.eventService.showAbortModal('admin groups', err)
|
||||||
|
|
||||||
reject(err)
|
reject(err)
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +204,14 @@ export class AutomaticComponent implements OnInit {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getComputeContextName(id: string): string | undefined {
|
||||||
|
return (
|
||||||
|
this.computeContexts.find(
|
||||||
|
(context: ComputeContextItem) => context.id === id
|
||||||
|
)?.name || undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes sas.json file to deploy the backend
|
* Executes sas.json file to deploy the backend
|
||||||
* Method will first try to run the `auto deploy`
|
* Method will first try to run the `auto deploy`
|
||||||
@@ -235,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()
|
||||||
@@ -244,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,13 +287,12 @@ export class AutomaticComponent implements OnInit {
|
|||||||
let selectedComputeContextName = this.sasJsConfig.contextName
|
let selectedComputeContextName = this.sasJsConfig.contextName
|
||||||
|
|
||||||
if (this.selectedComputeContext.length && this.computeContexts.length) {
|
if (this.selectedComputeContext.length && this.computeContexts.length) {
|
||||||
const computeContext = this.computeContexts.find(
|
const computeContextName = this.getComputeContextName(
|
||||||
(context: ComputeContextItem) =>
|
this.selectedComputeContext
|
||||||
context.id === this.selectedComputeContext
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (computeContext) {
|
if (computeContextName) {
|
||||||
selectedComputeContextName = computeContext.name
|
selectedComputeContextName = computeContextName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,52 +307,136 @@ export class AutomaticComponent implements OnInit {
|
|||||||
debug: true
|
debug: true
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sasJs
|
if (this.deployInNewWindow) {
|
||||||
.request(
|
this.runMakedataInNewWindow({
|
||||||
`services/admin/makedata&_contextname=${selectedComputeContextName}`,
|
contextName: selectedComputeContextName,
|
||||||
data,
|
admin: this.selectedAdminGroup,
|
||||||
overrideConfig,
|
dcPath: this.dcPath
|
||||||
() => {
|
|
||||||
this.sasService.shouldLogin.next(true)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then((res: any) => {
|
|
||||||
this.autodeployDone = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.makeDataResponse = JSON.stringify(res)
|
|
||||||
} catch {
|
|
||||||
this.makeDataResponse = res
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.result && res.result.length > 0) {
|
|
||||||
this.autoDeployStatus.runMakeData = true
|
|
||||||
} 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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
} else {
|
||||||
this.eventService.showAbortModal('makedata', JSON.stringify(err))
|
this.sasJs
|
||||||
this.autoDeployStatus.runMakeData = false
|
.request(`services/admin/makedata`, data, overrideConfig, () => {
|
||||||
this.autodeployDone = true
|
this.sasService.shouldLogin.next(true)
|
||||||
|
})
|
||||||
|
.then((res: any) => {
|
||||||
|
this.autodeployDone = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.makeDataResponse = JSON.stringify(err)
|
this.makeDataResponse = JSON.stringify(res)
|
||||||
} catch {
|
} catch {
|
||||||
this.makeDataResponse = err
|
this.makeDataResponse = res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (res.result && res.result.length > 0) {
|
||||||
|
this.autoDeployStatus.runMakeData = true
|
||||||
|
} 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.autodeployDone = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.makeDataResponse = JSON.stringify(err)
|
||||||
|
} catch {
|
||||||
|
this.makeDataResponse = err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public runMakedataInNewWindow(params: {
|
||||||
|
contextName: string
|
||||||
|
admin: string
|
||||||
|
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()
|
||||||
|
|
||||||
|
let programUrl =
|
||||||
|
serverUrl +
|
||||||
|
execPath +
|
||||||
|
'/?_program=' +
|
||||||
|
appLoc +
|
||||||
|
'/services/admin/makedata' +
|
||||||
|
contextname +
|
||||||
|
admin +
|
||||||
|
dcPath +
|
||||||
|
debug
|
||||||
|
|
||||||
|
window.open(programUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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(
|
||||||
|
`${this.appLoc}/services`,
|
||||||
|
filename
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!indexHtmlContent) {
|
||||||
|
this.loggerService.error(
|
||||||
|
`Failed to get ${filename} at ${this.appLoc}/services`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const computeContextName = this.getComputeContextName(
|
||||||
|
this.selectedComputeContext
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!computeContextName) {
|
||||||
|
this.loggerService.error(
|
||||||
|
`Compute context name not found for ID: ${this.selectedComputeContext} | List: ${JSON.stringify(this.computeContexts)}`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedContent = indexHtmlContent.replace(
|
||||||
|
/contextname="[^"]*"/g,
|
||||||
|
`contextname="${computeContextName}"`
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.sasService
|
||||||
|
.updateFileContent(`${this.appLoc}/services`, filename, updatedContent)
|
||||||
|
.catch((err: any) => {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,131 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
import { makeRegexWarningRenderer } from './regex-warning-renderer'
|
||||||
|
|
||||||
|
describe('makeRegexWarningRenderer', () => {
|
||||||
|
const buildHot = (data: any[]) => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const hot = new Handsontable(container, {
|
||||||
|
data,
|
||||||
|
columns: [
|
||||||
|
{ data: 'val', renderer: makeRegexWarningRenderer('^[A-Z]{3}$') },
|
||||||
|
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||||
|
],
|
||||||
|
licenseKey: 'non-commercial-and-evaluation'
|
||||||
|
})
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
return { hot, container }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('adds dc-warning-cell when the value fails the pattern', () => {
|
||||||
|
const { hot, container } = buildHot([
|
||||||
|
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const td = hot.getCell(0, 0)
|
||||||
|
expect(td?.classList.contains('dc-warning-cell')).toBeTrue()
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not add dc-warning-cell when the value matches the pattern', () => {
|
||||||
|
const { hot, container } = buildHot([
|
||||||
|
{ val: 'ABC', _____DELETE__THIS__RECORD_____: 'No' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const td = hot.getCell(0, 0)
|
||||||
|
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suppresses the warning on a row marked for delete, even though the value fails the pattern', () => {
|
||||||
|
const { hot, container } = buildHot([
|
||||||
|
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'Yes' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const td = hot.getCell(0, 0)
|
||||||
|
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not add dc-warning-cell for a SAS special missing value', () => {
|
||||||
|
const { hot, container } = buildHot([
|
||||||
|
{ val: '.a', _____DELETE__THIS__RECORD_____: 'No' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const td = hot.getCell(0, 0)
|
||||||
|
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw and never warns on a malformed pattern', () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const hot = new Handsontable(container, {
|
||||||
|
data: [{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }],
|
||||||
|
columns: [
|
||||||
|
{ data: 'val', renderer: makeRegexWarningRenderer('[unterminated') },
|
||||||
|
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||||
|
],
|
||||||
|
licenseKey: 'non-commercial-and-evaluation'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => hot.render()).not.toThrow()
|
||||||
|
|
||||||
|
const td = hot.getCell(0, 0)
|
||||||
|
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a PRX-delimited pattern with a case-insensitive flag, as authored for prxparse', () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const hot = new Handsontable(container, {
|
||||||
|
data: [
|
||||||
|
{ val: 'this is dummy data', _____DELETE__THIS__RECORD_____: 'No' },
|
||||||
|
{ val: 'THE WIND WAS BLOWING', _____DELETE__THIS__RECORD_____: 'No' },
|
||||||
|
{ val: 'nothing relevant here', _____DELETE__THIS__RECORD_____: 'No' }
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
data: 'val',
|
||||||
|
renderer: makeRegexWarningRenderer('/\\b(the|data)\\b/i')
|
||||||
|
},
|
||||||
|
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||||
|
],
|
||||||
|
licenseKey: 'non-commercial-and-evaluation'
|
||||||
|
})
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
expect(hot.getCell(0, 0)?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||||
|
expect(hot.getCell(1, 0)?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||||
|
expect(hot.getCell(2, 0)?.classList.contains('dc-warning-cell')).toBeTrue()
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is display-only — the stored value is untouched', () => {
|
||||||
|
const { hot, container } = buildHot([
|
||||||
|
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(hot.getDataAtCell(0, 0)).toEqual('abc')
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
import { isRegexRuleExempt } from '../../shared/dc-validator/utils/isRegexRuleExempt'
|
||||||
|
import { parseRegexRule } from '../../shared/dc-validator/utils/parseRegexRule'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a display-only HOT renderer for SOFTREGEX: a value that fails the
|
||||||
|
* pattern gets a yellow `dc-warning-cell` class (styles.scss) instead of
|
||||||
|
* blocking submission — SOFTREGEX must never return false from the cell
|
||||||
|
* validator (that would paint htInvalid and block submit), so the warning
|
||||||
|
* lives purely at the render layer, same split as makeNumberFormatRenderer.
|
||||||
|
*
|
||||||
|
* Suppressed on rows marked for delete (_____DELETE__THIS__RECORD_____ =
|
||||||
|
* 'Yes') — a warning about data about to be removed is just noise.
|
||||||
|
*
|
||||||
|
* Falls back to no warning ever showing when the pattern itself is
|
||||||
|
* malformed, rather than breaking the cell.
|
||||||
|
*/
|
||||||
|
export const makeRegexWarningRenderer = (pattern?: string) => {
|
||||||
|
let regex: RegExp | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pattern) regex = parseRegexRule(pattern)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`SOFTREGEX - invalid pattern, warning disabled: ${pattern}`)
|
||||||
|
regex = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseRenderer = Handsontable.renderers.getRenderer('text')
|
||||||
|
|
||||||
|
return (
|
||||||
|
instance: any,
|
||||||
|
td: any,
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
prop: string | number,
|
||||||
|
value: any,
|
||||||
|
cellProperties: any
|
||||||
|
) => {
|
||||||
|
baseRenderer(instance, td, row, col, prop, value, cellProperties)
|
||||||
|
|
||||||
|
const markedForDelete =
|
||||||
|
instance.getDataAtRowProp(row, '_____DELETE__THIS__RECORD_____') === 'Yes'
|
||||||
|
|
||||||
|
const failsPattern =
|
||||||
|
!!regex && !isRegexRuleExempt(value) && !regex.test(value.toString())
|
||||||
|
|
||||||
|
if (failsPattern && !markedForDelete) {
|
||||||
|
td.classList.add('dc-warning-cell')
|
||||||
|
td.title = 'Value does not match the expected pattern'
|
||||||
|
}
|
||||||
|
|
||||||
|
return td
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
import { makeNumberFormatRenderer } from './renderers.utils'
|
||||||
|
|
||||||
|
describe('makeNumberFormatRenderer', () => {
|
||||||
|
it('renders a numeric cell as EUR currency without changing the value', () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const hot = new Handsontable(container, {
|
||||||
|
data: [{ amt: 1025 }],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
data: 'amt',
|
||||||
|
type: 'numeric',
|
||||||
|
renderer: makeNumberFormatRenderer(
|
||||||
|
'{"style":"currency","currency":"EUR"}'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
licenseKey: 'non-commercial-and-evaluation'
|
||||||
|
})
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
const td = hot.getCell(0, 0)
|
||||||
|
// Display is formatted as currency...
|
||||||
|
expect(td?.textContent).toContain('€')
|
||||||
|
expect(td?.textContent).toContain('1,025')
|
||||||
|
// ...but the stored value is untouched
|
||||||
|
expect(hot.getDataAtCell(0, 0)).toEqual(1025)
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is overridden by numbro numericFormat (why DcValidator clears it for NUMBER_FORMAT cols)', () => {
|
||||||
|
// Regression note: on a `type: 'numeric'` column, a `numericFormat` makes
|
||||||
|
// HOT re-render via numbro and drop our currency symbol. DcValidator clears
|
||||||
|
// numericFormat on NUMBER_FORMAT columns so the Intl renderer wins.
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const hot = new Handsontable(container, {
|
||||||
|
data: [{ amt: 1025 }],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
data: 'amt',
|
||||||
|
type: 'numeric',
|
||||||
|
numericFormat: { pattern: '0,0', culture: 'en-US' },
|
||||||
|
renderer: makeNumberFormatRenderer(
|
||||||
|
'{"style":"currency","currency":"EUR"}'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
licenseKey: 'non-commercial-and-evaluation'
|
||||||
|
})
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
const td = hot.getCell(0, 0)
|
||||||
|
expect(td?.textContent).not.toContain('€')
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a plain number when options JSON is invalid', () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const hot = new Handsontable(container, {
|
||||||
|
data: [{ amt: 1025 }],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
data: 'amt',
|
||||||
|
type: 'numeric',
|
||||||
|
renderer: makeNumberFormatRenderer('not json')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
licenseKey: 'non-commercial-and-evaluation'
|
||||||
|
})
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
const td = hot.getCell(0, 0)
|
||||||
|
expect(td?.textContent).not.toContain('€')
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,57 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a display-only HOT renderer that formats numeric cell values using
|
||||||
|
* Intl.NumberFormat. The stored/submitted value is never changed — only the
|
||||||
|
* rendered text. `ruleValue` is a JSON string of Intl.NumberFormat options
|
||||||
|
* (e.g. '{"style":"currency","currency":"EUR","minimumFractionDigits":2}').
|
||||||
|
*
|
||||||
|
* Falls back to the plain text renderer when the JSON is invalid, the options
|
||||||
|
* are rejected by Intl.NumberFormat, or the value is not a finite number.
|
||||||
|
*/
|
||||||
|
export const makeNumberFormatRenderer = (ruleValue?: string) => {
|
||||||
|
let formatter: Intl.NumberFormat | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = ruleValue ? JSON.parse(ruleValue) : {}
|
||||||
|
formatter = new Intl.NumberFormat(window.navigator.language, options)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`NUMBER_FORMAT - invalid Intl.NumberFormat options: ${ruleValue}`
|
||||||
|
)
|
||||||
|
formatter = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseRenderer = Handsontable.renderers.getRenderer('text')
|
||||||
|
|
||||||
|
return (
|
||||||
|
instance: any,
|
||||||
|
td: any,
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
prop: string | number,
|
||||||
|
value: any,
|
||||||
|
cellProperties: any
|
||||||
|
) => {
|
||||||
|
// Render via the base text renderer first to preserve cell styling/classes
|
||||||
|
// (readOnly, alignment, etc.), then override the displayed text.
|
||||||
|
baseRenderer(instance, td, row, col, prop, value, cellProperties)
|
||||||
|
|
||||||
|
const num = Number(value)
|
||||||
|
if (
|
||||||
|
formatter &&
|
||||||
|
value !== null &&
|
||||||
|
value !== undefined &&
|
||||||
|
value !== '' &&
|
||||||
|
!isNaN(num)
|
||||||
|
) {
|
||||||
|
td.textContent = formatter.format(num)
|
||||||
|
}
|
||||||
|
|
||||||
|
return td
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom renderer for HOT cell
|
* Custom renderer for HOT cell
|
||||||
* Used to show error icon
|
* Used to show error icon
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const freeTierConfig: LicenceState = {
|
|||||||
lineage_daily_limit: 3,
|
lineage_daily_limit: 3,
|
||||||
tables_in_library_limit: 35,
|
tables_in_library_limit: 35,
|
||||||
viewbox: true,
|
viewbox: true,
|
||||||
fileUpload: true,
|
fileUpload: false,
|
||||||
editRecord: true,
|
editRecord: true,
|
||||||
addRecord: true
|
addRecord: true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,144 +1,167 @@
|
|||||||
<app-sidebar class="sidebar-height">
|
<app-sidebar class="sidebar-height">
|
||||||
<clr-tree>
|
<clr-tree>
|
||||||
<clr-tree-node *ngIf="groups" class="search-node">
|
@if (groups) {
|
||||||
<div class="tree-search-wrapper">
|
<clr-tree-node class="search-node">
|
||||||
<input
|
<div class="tree-search-wrapper">
|
||||||
appStealFocus
|
<input
|
||||||
clrInput
|
appStealFocus
|
||||||
#searchLibTreeInput
|
clrInput
|
||||||
placeholder="Filter by Groups"
|
#searchLibTreeInput
|
||||||
name="input"
|
placeholder="Filter by Groups"
|
||||||
[(ngModel)]="groupSearch"
|
name="input"
|
||||||
(keyup)="groupListOnFilter()"
|
[(ngModel)]="groupSearch"
|
||||||
autocomplete="off"
|
(keyup)="groupListOnFilter()"
|
||||||
/>
|
autocomplete="off"
|
||||||
<clr-icon
|
/>
|
||||||
*ngIf="searchLibTreeInput.value.length < 1"
|
@if (searchLibTreeInput.value.length < 1) {
|
||||||
shape="search"
|
<clr-icon shape="search"></clr-icon>
|
||||||
></clr-icon>
|
}
|
||||||
<clr-icon
|
@if (searchLibTreeInput.value.length > 0) {
|
||||||
*ngIf="searchLibTreeInput.value.length > 0"
|
<clr-icon
|
||||||
(click)="groupSearch = ''; groupListOnFilter()"
|
(click)="groupSearch = ''; groupListOnFilter()"
|
||||||
shape="times"
|
shape="times"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
</div>
|
}
|
||||||
</clr-tree-node>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngFor="let group of groups">
|
|
||||||
<clr-tree-node
|
|
||||||
(click)="groupOnClick(group)"
|
|
||||||
*ngIf="!group['hidden']"
|
|
||||||
[class.active]="group.GROUPURI === groupUri"
|
|
||||||
>
|
|
||||||
<p class="m-0 cursor-pointer list-padding">
|
|
||||||
<clr-icon shape="users"></clr-icon>
|
|
||||||
{{ group.GROUPNAME }}
|
|
||||||
</p>
|
|
||||||
</clr-tree-node>
|
</clr-tree-node>
|
||||||
</ng-container>
|
}
|
||||||
|
|
||||||
|
@for (group of groups; track group) {
|
||||||
|
@if (!group['hidden']) {
|
||||||
|
<clr-tree-node
|
||||||
|
(click)="groupOnClick(group)"
|
||||||
|
[class.active]="group.GROUPURI === groupUri"
|
||||||
|
>
|
||||||
|
<p class="m-0 cursor-pointer list-padding">
|
||||||
|
<clr-icon shape="users"></clr-icon>
|
||||||
|
{{ group.GROUPNAME }}
|
||||||
|
</p>
|
||||||
|
</clr-tree-node>
|
||||||
|
}
|
||||||
|
}
|
||||||
</clr-tree>
|
</clr-tree>
|
||||||
</app-sidebar>
|
</app-sidebar>
|
||||||
|
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<div *ngIf="loading" class="loadingSpinner">
|
@if (loading) {
|
||||||
<span class="spinner"> Loading... </span>
|
<div class="loadingSpinner">
|
||||||
</div>
|
<span class="spinner"> Loading... </span>
|
||||||
<div *ngIf="groupMembers && !loading">
|
|
||||||
<div *ngIf="serverType !== ServerType.SasViya" class="clr-row">
|
|
||||||
<div class="clr-col-8">
|
|
||||||
<table class="table group-info">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="left">
|
|
||||||
<p class="group-info-text">
|
|
||||||
<b>{{ groupName }}</b>
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="left">
|
|
||||||
<i>{{ groupDesc || 'no description' }}</i>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div class="clr-row">
|
@if (groupMembers && !loading) {
|
||||||
<div class="clr-col-8">
|
<div>
|
||||||
<div class="card group-data">
|
@if (serverType !== ServerType.SasViya) {
|
||||||
<div *ngIf="serverType !== ServerType.SasViya">
|
<div class="clr-row">
|
||||||
<h3>MEMBERS ({{ groupMemberCount }})</h3>
|
<div class="clr-col-8">
|
||||||
<h5 *ngIf="groupMemberCount == 0">No Members Present</h5>
|
<table class="table group-info">
|
||||||
<div class="table-container">
|
<tbody>
|
||||||
<table *ngIf="groupMemberCount != 0" class="table member-table">
|
<tr>
|
||||||
<thead>
|
<td class="left">
|
||||||
<tr>
|
<p class="group-info-text">
|
||||||
<ng-container *ngIf="serverType === ServerType.Sas9">
|
<b>{{ groupName }}</b>
|
||||||
<td class="width-25"><b>NAME</b></td>
|
</p>
|
||||||
<td class="width-25"><b>EMAIL</b></td>
|
</td>
|
||||||
<td class="width-25"><b>CREATED</b></td>
|
</tr>
|
||||||
<td class=""><b>UPDATED</b></td>
|
<tr>
|
||||||
</ng-container>
|
<td class="left">
|
||||||
|
<i>{{ groupDesc || 'no description' }}</i>
|
||||||
<ng-container *ngIf="serverType === ServerType.Sasjs">
|
</td>
|
||||||
<td class="width-25"><b>ID</b></td>
|
</tr>
|
||||||
<td class="width-25"><b>DISPLAY NAME</b></td>
|
</tbody>
|
||||||
<td class="width-25"><b>USER NAME</b></td>
|
</table>
|
||||||
</ng-container>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
[routerLink]="
|
|
||||||
'/view/usernav/users/' + (member.URIMEM || member.ID)
|
|
||||||
"
|
|
||||||
*ngFor="let member of groupMembers"
|
|
||||||
>
|
|
||||||
<ng-container *ngIf="serverType === ServerType.Sas9">
|
|
||||||
<td class="">{{ member.MEMBERNAME }}</td>
|
|
||||||
<td class="">{{ member.EMAIL }}</td>
|
|
||||||
<td class="">{{ member.MEMBERCREATED }}</td>
|
|
||||||
<td class="">{{ member.MEMBERUPDATED }}</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ngIf="serverType === ServerType.Sasjs">
|
|
||||||
<td class="">{{ member.ID }}</td>
|
|
||||||
<td class="">{{ member.DISPLAYNAME }}</td>
|
|
||||||
<td class="">{{ member.USERNAME }}</td>
|
|
||||||
</ng-container>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="serverType === ServerType.SasViya">
|
</div>
|
||||||
<h3>{{ groupName }}</h3>
|
}
|
||||||
<h5 *ngIf="groupMemberCount == 0">No Members Present</h5>
|
<div class="clr-row">
|
||||||
<div class="table-container">
|
<div class="clr-col-8">
|
||||||
<table *ngIf="groupMemberCount != 0" class="table member-table">
|
<div class="card group-data">
|
||||||
<thead>
|
@if (serverType !== ServerType.SasViya) {
|
||||||
<tr>
|
<div>
|
||||||
<td>
|
<h3>MEMBERS ({{ groupMemberCount }})</h3>
|
||||||
<b>MEMBERS ({{ groupMemberCount }})</b>
|
@if (groupMemberCount == 0) {
|
||||||
</td>
|
<h5>No Members Present</h5>
|
||||||
</tr>
|
}
|
||||||
</thead>
|
<div class="table-container">
|
||||||
<tbody>
|
@if (groupMemberCount != 0) {
|
||||||
<tr
|
<table class="table member-table">
|
||||||
[routerLink]="'/view/usernav/users/' + member.MEMBERID"
|
<thead>
|
||||||
*ngFor="let member of groupMembers"
|
<tr>
|
||||||
>
|
@if (serverType === ServerType.Sas9) {
|
||||||
<td class="">{{ member.MEMBERNAME }}</td>
|
<td class="width-25"><b>NAME</b></td>
|
||||||
</tr>
|
<td class="width-25"><b>EMAIL</b></td>
|
||||||
</tbody>
|
<td class="width-25"><b>CREATED</b></td>
|
||||||
</table>
|
<td class=""><b>UPDATED</b></td>
|
||||||
</div>
|
}
|
||||||
|
@if (serverType === ServerType.Sasjs) {
|
||||||
|
<td class="width-25"><b>ID</b></td>
|
||||||
|
<td class="width-25"><b>DISPLAY NAME</b></td>
|
||||||
|
<td class="width-25"><b>USER NAME</b></td>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (member of groupMembers; track member) {
|
||||||
|
<tr
|
||||||
|
[routerLink]="
|
||||||
|
'/view/usernav/users/' +
|
||||||
|
(member.URIMEM || member.ID)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
@if (serverType === ServerType.Sas9) {
|
||||||
|
<td class="">{{ member.MEMBERNAME }}</td>
|
||||||
|
<td class="">{{ member.EMAIL }}</td>
|
||||||
|
<td class="">{{ member.MEMBERCREATED }}</td>
|
||||||
|
<td class="">{{ member.MEMBERUPDATED }}</td>
|
||||||
|
}
|
||||||
|
@if (serverType === ServerType.Sasjs) {
|
||||||
|
<td class="">{{ member.ID }}</td>
|
||||||
|
<td class="">{{ member.DISPLAYNAME }}</td>
|
||||||
|
<td class="">{{ member.USERNAME }}</td>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (serverType === ServerType.SasViya) {
|
||||||
|
<div>
|
||||||
|
<h3>{{ groupName }}</h3>
|
||||||
|
@if (groupMemberCount == 0) {
|
||||||
|
<h5>No Members Present</h5>
|
||||||
|
}
|
||||||
|
<div class="table-container">
|
||||||
|
@if (groupMemberCount != 0) {
|
||||||
|
<table class="table member-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>MEMBERS ({{ groupMemberCount }})</b>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (member of groupMembers; track member) {
|
||||||
|
<tr
|
||||||
|
[routerLink]="
|
||||||
|
'/view/usernav/users/' + member.MEMBERID
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<td class="">{{ member.MEMBERNAME }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<br />
|
||||||
</div>
|
</div>
|
||||||
<br />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapper
|
|||||||
host: {
|
host: {
|
||||||
class: 'content-container'
|
class: 'content-container'
|
||||||
},
|
},
|
||||||
encapsulation: ViewEncapsulation.None
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class GroupComponent implements OnInit {
|
export class GroupComponent implements OnInit {
|
||||||
public groups: Array<any> | undefined
|
public groups: Array<any> | undefined
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user