Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2411443ec7 | ||
|
|
586b9c0f1d | ||
|
|
9f4b357108 | ||
|
|
8209c1cb49 | ||
|
|
e15f2c2a34 | ||
|
|
c0fec25c4d | ||
|
|
41680e2ecc | ||
|
|
20a007622c | ||
|
|
5615e6b0db | ||
|
|
dc0f6a7baa | ||
|
|
fabb9e5bfd | ||
|
|
d57ae03fc4 | ||
|
|
7c33839b99 | ||
|
|
26b55b1bde | ||
|
|
e5a5bf2144 | ||
|
|
76bb83d860 | ||
|
|
5a44b2804f | ||
|
|
daacb49c8a | ||
|
|
1807d66ea1 | ||
|
|
84cce5eb0f | ||
|
|
aa4b3b94cd | ||
|
|
72cae704df | ||
|
|
0815fcea2c | ||
|
|
c07a01a38d | ||
|
|
eea9fbd938 | ||
|
|
9a1b7d0f52 | ||
|
|
1e516f4012 | ||
|
|
d0a7561f1a | ||
|
|
07d586da52 | ||
|
|
93a513a15f | ||
|
|
3c9eba4df4 | ||
|
|
48d06ec6a7 | ||
|
|
ff8f10d533 | ||
|
|
7d6dc652be | ||
|
|
f9d061c489 | ||
|
|
4d1bfa6343 | ||
|
|
d841163dd0 | ||
|
|
44aac556b9 | ||
|
|
3bf3bf0dde | ||
|
|
a501903e6d | ||
|
|
b9e4b2733f | ||
|
|
24e6297187 | ||
|
|
257f69ccc6 | ||
|
|
b88b22684a | ||
|
|
a8237b2881 | ||
|
|
fc6c9f844f | ||
|
|
74c38e1641 | ||
|
|
28104f83e1 | ||
|
|
ea05f07180 | ||
|
|
acb97f4bfb | ||
|
|
bb808617f4 | ||
|
|
4a8c39b4c0 | ||
|
|
42e02cdb05 | ||
|
|
347923900f | ||
|
|
a4c3989c26 | ||
|
|
0392a81cbd | ||
|
|
f9ea53cf78 | ||
|
|
8fb58eb36e | ||
|
|
180c2477ed | ||
|
|
69cfccd565 | ||
|
|
f171375899 | ||
|
|
57db1179a9 | ||
|
|
d13fab267f | ||
|
|
44bc7f7fea | ||
|
|
f60bcef583 | ||
|
|
e7abb0a08a | ||
|
|
cfb60e5e4b | ||
|
|
33dcb989d3 | ||
|
|
aaf406b386 | ||
|
|
8efea8c744 | ||
|
|
39c8855f37 | ||
|
|
5c56c7579f | ||
|
|
92c1e20126 | ||
|
|
7050808d80 | ||
|
|
37a98e1d64 | ||
|
|
66f7b87b07 | ||
|
|
fae9496bbc | ||
|
|
cfe1e75be4 | ||
|
|
b51c770782 | ||
|
|
2a771bb91a | ||
|
|
54b8c78f86 | ||
|
|
ea74f95144 | ||
|
|
bd798b424a | ||
|
|
ee3c6c9e0f | ||
|
|
a3e46a968e | ||
|
|
ee4e9b9271 | ||
|
|
7a35cf4a45 | ||
|
|
d881290618 | ||
|
|
cbea04c8e1 | ||
|
|
994e7fc973 | ||
|
|
578c403994 | ||
|
|
56b7854db5 | ||
|
|
7ed3730ae3 | ||
|
|
6cd9b68581 | ||
|
|
7378f3ba30 | ||
|
|
cac9244f92 | ||
|
|
ea00c5afad | ||
|
|
359d833406 | ||
|
|
022981390e | ||
|
|
05fe4744d5 | ||
|
|
e22edf7ed3 | ||
|
|
62ff0aee4a | ||
|
|
3ed7cfdbee | ||
|
|
d2c93a46fa | ||
|
|
17e4802895 |
@@ -0,0 +1,147 @@
|
||||
# Bitemporal Dataloader — Technical Deep Dive
|
||||
|
||||
This document explains how the Data Controller backend loads staged data into target tables: the load-type dispatch, the internals of the `%bitemporal_dataloader` macro, and a detailed description of the REPLACE load type. For user-facing load type documentation, see [docs.datacontroller.io](https://docs.datacontroller.io/).
|
||||
|
||||
## Overview
|
||||
|
||||
Every table registered for loading has a current record in `&mpelib..MPE_TABLES` with a `LOADTYPE` (selectbox values are seeded in `mpe_makedata.sas`):
|
||||
|
||||
| LOADTYPE | Loader used | History kept |
|
||||
|---|---|---|
|
||||
| `UPDATE` | `%bitemporal_dataloader` (no temporal vars) | None — changed records are deleted and re-appended |
|
||||
| `REPLACE` | Inline code in `%mpe_targetloader` (does **not** use `%bitemporal_dataloader`) | None — entire table wiped and reloaded |
|
||||
| `TXTEMPORAL` | `%bitemporal_dataloader` (technical time only) | SCD2-style, technical (transaction) time |
|
||||
| `BITEMPORAL` | `%bitemporal_dataloader` (business + technical time) | Full two-dimensional history |
|
||||
| `FORMAT_CAT` | `%mp_loadformat` | Format catalog load (table suffix `-FC`) |
|
||||
|
||||
The relevant `MPE_TABLES` columns read by the loader are: `LOADTYPE`, `BUSKEY` (primary key, space-separated, excluding temporal columns), `VAR_TXFROM` / `VAR_TXTO` (technical validity), `VAR_BUSFROM` / `VAR_BUSTO` (business validity), `VAR_PROCESSED` (processed timestamp column), `RK_UNDERLYING` (retained-key generation), `CLOSE_VARS`, and `AUDIT_LIBDS` (defaults to `&dclib..MPE_AUDIT`).
|
||||
|
||||
## Request Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User submits changeset\neditors/stagedata.sas] --> B[Staging package written to\n&mpelocapprovals/&LOAD_REF\nCSV + jsdata]
|
||||
B --> C[Approval workflow\nMPE_SUBMIT / MPE_REVIEW]
|
||||
C --> D{auditors/postdata.sas}
|
||||
D -->|action=SHOW_DIFFS| E["%mpe_targetloader(LOADTARGET=NO)\nbuilds work.outds_add / outds_mod / outds_del\nfor the diff screen only"]
|
||||
D -->|action=APPROVE_TABLE| F["%mpe_targetloader(LOADTARGET=YES)\nactual load"]
|
||||
E --> G[Diff CSV + TEMPDIFFS stored\nin approval package]
|
||||
F --> H{LOADTYPE from\nMPE_TABLES}
|
||||
H -->|UPDATE / TXTEMPORAL / BITEMPORAL| I["%bitemporal_dataloader"]
|
||||
H -->|FORMAT_CAT| J["%mp_loadformat"]
|
||||
H -->|REPLACE| K[Inline delete-all + append\nin %mpe_targetloader]
|
||||
```
|
||||
|
||||
`%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`) is the single dispatch point. It reads the current `MPE_TABLES` record (`&dc_dttmtfmt. lt tx_to`), aborts if the table is not registered (or has duplicate config records), and routes to the loader. Note the two-phase design: `LOADTARGET=NO` prepares the intermediate `outds_*` tables so the approver can review diffs; `LOADTARGET=YES` performs the destructive load. Both phases run the same preparation logic, so the reviewed diffs correspond to what is actually applied.
|
||||
|
||||
## The Temporal Model
|
||||
|
||||
Bitemporal tables carry two independent time dimensions:
|
||||
|
||||
* **Business time** (`bus_from` / `bus_to`) — when the fact was true in the real world. Present on **both** staging and base tables.
|
||||
* **Technical time** (`tech_from` / `tech_to`, a.k.a. transaction time) — when the record was known to the database. Present on the **base table only**; the loader stamps these itself.
|
||||
|
||||
All validity is expressed with **half-open intervals** (`from <= t < to`). Queries against bitemporal tables need two conditions and must not use `BETWEEN` or `from LE t LE to` (the latter excludes boundary records — see the macro header for background):
|
||||
|
||||
```sas
|
||||
where &bus_from le [tstamp] lt &bus_to
|
||||
and &tx_from le [tstamp] lt &tx_to
|
||||
```
|
||||
|
||||
"Current" records have `tech_to` set to the high date (`'31DEC9999:23:59:59'dt` when called from `%mpe_targetloader`). Closing out a record means setting `tech_to = now` — records are never physically deleted from a temporal table, they are superseded.
|
||||
|
||||
## Key Components
|
||||
|
||||
| Component | Location | Role |
|
||||
|---|---|---|
|
||||
| `%mpe_targetloader` | `sas/sasjs/macros/mpe_targetloader.sas` | Reads `MPE_TABLES` config and dispatches per LOADTYPE; implements REPLACE inline |
|
||||
| `%bitemporal_dataloader` | `sas/sasjs/macros/bitemporal_dataloader.sas` | Generic loader for UPDATE / TXTEMPORAL / BITEMPORAL |
|
||||
| `%bitemporal_closeouts` | `sas/sasjs/macros/bitemporal_closeouts.sas` | Closes out (sets `tech_to=now`) live records matching a key |
|
||||
| `%mp_retainedkey` | SASjs core | Generates retained (surrogate) keys when `RK_UNDERLYING` is configured |
|
||||
| `%mp_rowhash` | SASjs core | MD5 hash of non-temporal columns, used for change detection |
|
||||
| `%mp_storediffs` | SASjs core | Writes row-level audit records to `AUDIT_LIBDS` |
|
||||
| `MPE_DATALOADS` | `&dclib` | Load log (counts, duration, macro version, user) — written only by `%bitemporal_dataloader` |
|
||||
| `MPE_LOCKANYTABLE` | `&dclib` | Lock control table used by `%mp_lockanytable` |
|
||||
|
||||
## `%bitemporal_dataloader` Execution Flow
|
||||
|
||||
The macro builds a series of `work.bitemp*` intermediate tables, then applies the changes in two target-table operations (closeout, then append) under a single lock.
|
||||
|
||||
### 1. Pre-checks and setup
|
||||
|
||||
* Early return if the staging table is empty; hard abort if `&syscc > 0`.
|
||||
* `CLOSE_VARS` is not supported on REDSHIFT / POSTGRES / SNOWFLAKE engines (returns with a NOTE).
|
||||
* A zero-row snapshot of the base table (`&basecopy`) is taken with `data ... set base; stop;` — this doubles as a lock check, since metadata functions fail against a locked table.
|
||||
* `proc contents` on the base table feeds column lists. Columns are split into character and numeric lists for hashing (`%mp_rowhash` hashes the two types via separate arrays). Temporal columns, the processed column, and the delete-flag column are excluded from the hash.
|
||||
* Table names containing `___TMP___` or `_____` are rejected (they would collide with generated temp columns).
|
||||
|
||||
### 2. Locking
|
||||
|
||||
For `LOADTARGET=YES`, the base table (and audit table, if configured) is locked via `%mp_lockanytable` before any staging prep, because the load is a two-part update (closeouts + append) and must not interleave with another load.
|
||||
|
||||
### 3. Staging preparation (`work.bitemp0_append`)
|
||||
|
||||
* If `RK_UNDERLYING` is configured, `%mp_retainedkey` maps business keys to retained keys (filtering the base lookup to live records, `&now < tech_to`, for temporal types); otherwise the staging table is used as-is.
|
||||
* `bus_from` / `bus_to` overrides are applied if provided; the processed column is stamped with the load timestamp (`now`). Even with `processed=0`, a column literally named `PROCESSED_DTTM` on the base table is used if present.
|
||||
* The MD5 change-detection hash is computed for every staged record.
|
||||
* If the staging table contains the delete-flag column (`_____DELETE__THIS__RECORD_____`), rows flagged `"Yes"` are diverted to `&outds_del` and closed out via `%bitemporal_closeouts` (PK is taken as `bus_from` + business key). The remaining rows continue as `bitemp0_append`.
|
||||
|
||||
### 4. CLOSE_VARS closeout
|
||||
|
||||
When `CLOSE_VARS` (a subset of the PK) is supplied, live base records whose CLOSE_VARS values appear in staging but whose full PK does **not** are closed out. This handles "this group was fully reloaded, so anything missing was removed" semantics without reloading the whole table.
|
||||
|
||||
### 5. Uniqueness check
|
||||
|
||||
If `CHECK_UNIQUENESS=YES` (the default) or business-date overrides are in play, the staging table is sorted `nodupkey` by the PK; a row-count mismatch aborts the load and releases the locks. The staging table must be a unique snapshot of the business key at one point in business time.
|
||||
|
||||
### 6. Base extract (`work.bitemp0_base`)
|
||||
|
||||
Only base records matching staged PKs are extracted — for temporal load types, only **currently live** records (`now < tech_to`). A left join from the staged keys to the base produces `___TMP___NEW_FLG` to identify brand-new keys. This is engine-specific:
|
||||
|
||||
* **OLEDB (SQL Server)** — staged keys are pushed to a `##global` temp table and joined via explicit pass-through.
|
||||
* **REDSHIFT / POSTGRES / SNOWFLAKE** — an in-database temp table is created `like` the base, stripped to PK + an added `md5 varchar(32)` column, loaded with staged keys, and joined via pass-through. Snowflake uses transient tables; Redshift gets `alter sortkey none` plus any `DCBL_REDSH` config options from `MPE_CONFIG`.
|
||||
* **CAS** — a FedSQL join against a CASUSER copy.
|
||||
* **BASE/other** — plain PROC SQL in SAS.
|
||||
|
||||
### 7. Change classification
|
||||
|
||||
* **`&outds_add`** — staged records flagged as new (no base match). They get `tech_from=now`, `tech_to=high_date`.
|
||||
* **`work.bitemp1_current`** — matched base records, re-hashed so hashes are comparable.
|
||||
* **Inserts (BITEMPORAL only)** — a staged record whose business range falls strictly *inside* an existing record's range splits the existing record into a "before" and "after" segment (`bitemp3_inserts` / `bitemp3a_inserts`). The split segments replace the original in the comparison set (`bitemp3b_newbase`).
|
||||
* **Updates (`bitemp4*`)** — staged records matching a base PK but with a different hash or different business dates. Base and staged versions are stacked, deduplicated, then aligned in **two passes** over the business timeline per key: a forward pass (carry `bus_from` forward across identical hashes; a staged record trims the preceding base record's range) and a reverse pass (carry `bus_to` back; records fully subsumed by the new version are deleted). Records that end up byte-identical to what is already stored are dropped via a hash lookup that includes the business dates (`bitemp5a_lkp` / `bitemp5b_updates`, BITEMPORAL only).
|
||||
|
||||
### 8. Closeout application
|
||||
|
||||
Changed records are closed out in the target before the new versions are appended. SAS SQL has no UPDATE-with-join, so a correlated `EXISTS` subquery against `work.bitemp5d_subquery` is used (pushed in-database as a temp table for OLEDB / REDSHIFT / POSTGRES / SNOWFLAKE):
|
||||
|
||||
* **BITEMPORAL** — per key, the closeout range is `min(bus_from)` / `max(bus_to)` of the changed records: `update base set tech_to=now (, processed=now) where tech_from <= now < tech_to and exists (key match and base.bus_from >= min and base.bus_to <= max)`.
|
||||
* **TXTEMPORAL** — same, on key only.
|
||||
* **UPDATE** — changed records are physically `delete`d (they are re-appended in the next step). On CAS this uses `table.deleteRows` with a whereTable; temporal types are not supported on CAS and abort.
|
||||
* **BUSTEMPORAL** — closeouts are not implemented; the macro aborts at this point ("BUSTEMPORAL NOT YET SUPPORTED").
|
||||
|
||||
### 9. Append, unlock, audit, log
|
||||
|
||||
* The union of modified + new records, deduplicated on all columns (`bitemp6_unique`), is appended to the base table (`proc append ... force nowarn`; CAS appends via a varchar-casting step; Redshift applies `DCBL_REDSH` options). Locks are then released.
|
||||
* If `outds_audit` is set (always, from `%mpe_targetloader` — `AUDIT_LIBDS` defaulting to `&dclib..MPE_AUDIT`), `%mp_storediffs` compares the pre-load snapshot with the applied changes and appends row-level audit records; modified-row entries with no actual value change are removed (`MOVE_TYPE="M" and IS_PK=0 and IS_DIFF=0`).
|
||||
* A summary row is inserted into `&dclib..MPE_DATALOADS` (libref, dsn, etlsource, loadtype, changed/new/deleted counts, duration, macro version, user, timestamp) unless `LOG=0`.
|
||||
|
||||
## REPLACE Load Type
|
||||
|
||||
REPLACE is the simplest load type and is deliberately **not** routed through `%bitemporal_dataloader` — it is implemented inline in `%mpe_targetloader` and simply wipes the target table (`delete * from`) and re-appends the staging table verbatim, with no history, change detection, key matching, audit rows, or `MPE_DATALOADS` logging. See [replace-load-type.md](replace-load-type.md) for the full deep dive.
|
||||
|
||||
## Supporting Tables
|
||||
|
||||
| Table | Role |
|
||||
|---|---|
|
||||
| `MPE_TABLES` | Per-table load configuration (loadtype, keys, temporal vars, audit target) |
|
||||
| `MPE_SELECTBOX` | Dropdown values, including the LOADTYPE list (seeded in `mpe_makedata.sas`) |
|
||||
| `MPE_SUBMIT` / `MPE_REVIEW` | Approval workflow state |
|
||||
| `MPE_LOADS` | Submission-level log of CSV package loads |
|
||||
| `MPE_DATALOADS` | Per-load statistics (temporal/UPDATE loads only) |
|
||||
| `MPE_AUDIT` (or `AUDIT_LIBDS`) | Row-level change audit (temporal/UPDATE/FORMAT_CAT loads only) |
|
||||
| `MPE_LOCKANYTABLE` | Lock control table |
|
||||
| `MPE_CONFIG` | Engine-specific config (e.g. `DCBL_REDSH` scope for Redshift bulk options) |
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests for the loaders live next to the macros: `sas/sasjs/macros/bitemporal_dataloader.test.[1-4].sas` and `sas/sasjs/macros/mpe_targetloader.test.sas` (REPLACE loadtype), executed as sasjs tests. See [testing.md](testing.md) for how to run them (`npm run 4gl` then `sasjs test -t 4gl` from the `sas/` directory), and remember `sasjs lint` after touching any `.sas` files.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Dependency Updates Checklist
|
||||
|
||||
Whenever any `package.json` (root, `client/`, or `sas/`) or lockfile is modified, run the same checks CI runs before pushing:
|
||||
|
||||
1. **npm audit** (must be clean for prod deps):
|
||||
```bash
|
||||
npm audit --omit=dev # in repo root
|
||||
cd sas && npm audit --omit=dev
|
||||
cd ../client && npm audit --omit=dev
|
||||
```
|
||||
Fix with `npm audit fix`, targeted `overrides` in `package.json`, or version bumps — never `npm audit fix --force` blindly, as it can introduce breaking changes.
|
||||
|
||||
2. **License checker** (client only):
|
||||
```bash
|
||||
cd client && npm run license-checker
|
||||
```
|
||||
If a new dependency fails, either add its SPDX id to the `onlyAllow` list in `client/licenseChecker.js` (if the license is acceptable, e.g. permissive ones like `BlueOak-1.0.0`) or add the specific package to `excludePackages` with justification. Data Controller ships on-prem, so only OSI-approved permissive licenses are acceptable for production dependencies.
|
||||
|
||||
Both checks run in `.gitea/workflows/build.yaml` (`Check audit` and `Licence checker` steps) and will fail the build if skipped locally.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Regex Validations (HARDREGEX / SOFTREGEX)
|
||||
|
||||
This document describes how the regex validation rules work internally. For user-facing documentation see `docs/dcc-validations.md` in the `docs.datacontroller.io` repo.
|
||||
|
||||
## Overview
|
||||
|
||||
Two validation rule types in `MPE_VALIDATIONS` validate cell values against a regular expression supplied in `RULE_VALUE`:
|
||||
|
||||
- `HARDREGEX` - submission-blocking. A failing value is rejected by the cell validator and painted red (HOT's own `htInvalid` class), so the row cannot be submitted.
|
||||
- `SOFTREGEX` - display-only warning. A failing value is painted yellow (`dc-warning-cell` class) with a tooltip, but submission is not blocked.
|
||||
|
||||
Both rule types are selectable in the MPE_VALIDATIONS RULE_TYPE dropdown; they were added to the selectbox seed data in `sas/sasjs/macros/mpe_makedata.sas` and via the optional migration `sas/sasjs/db/migrations/20260720_v7.12_release.sas`. `RULE_VALUE` is limited to 128 characters, which constrains very long patterns.
|
||||
|
||||
## Config-time validation (SAS side)
|
||||
|
||||
`sas/sasjs/services/hooks/mpe_validations_postedit.sas` runs `prxparse()` on any staged HARDREGEX/SOFTREGEX rule value and aborts the edit with a list of offending `libref.table.column` references if the pattern is invalid. This is a best-effort syntax check to catch typos at config time; an empty pattern is treated as valid (it matches everything in JS). Rows marked for delete are skipped.
|
||||
|
||||
Because patterns must pass `prxparse`, rule values are authored in the SAS PRX delimiter form `/pattern/flags` (although a bare pattern is also accepted for backwards compatibility).
|
||||
|
||||
## Frontend evaluation
|
||||
|
||||
All regex handling lives in the client; there is no server-side re-validation of data values. The per-cell decision flow:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Cell value] --> B{Row marked for delete<br/>and not a PK column?}
|
||||
B -- Yes --> Z[No validation / no warning]
|
||||
B -- No --> C{isRegexRuleExempt?<br/>blank, or "." on a numeric column}
|
||||
C -- Yes --> Z
|
||||
C -- No --> D{HARDREGEX rule on column?}
|
||||
D -- Yes --> E{Pattern matches?}
|
||||
E -- No --> F[Invalid: submission blocked,<br/>red htInvalid + REGEX tooltip]
|
||||
E -- Yes --> J[Valid]
|
||||
D -- No --> G{SOFTREGEX rule on column?}
|
||||
G -- Yes --> H{Pattern matches?}
|
||||
H -- No --> I[Warning: yellow dc-warning-cell<br/>+ REGEX tooltip, submission allowed]
|
||||
H -- Yes --> J[Valid]
|
||||
G -- No --> J
|
||||
```
|
||||
|
||||
A malformed pattern never reaches this flow: it is treated as always-valid (HARDREGEX) / never-warn (SOFTREGEX) with a `console.warn`, rather than breaking the editor.
|
||||
|
||||
### `client/src/app/shared/dc-validator/utils/parseRegexRule.ts`
|
||||
|
||||
Converts an authored SAS PRX pattern into a JavaScript `RegExp`:
|
||||
|
||||
1. If the value matches `/^\/(.*)\/([a-z]*)$/s`, the delimiters are stripped and the trailing flags are passed to `new RegExp(body, flags)`. Otherwise the value is used as-is (backwards compatibility with bare patterns).
|
||||
2. Three mechanical Perl→JS translations are applied to the body:
|
||||
- a leading `(?i)` inline modifier is removed and folded into the `i` flag;
|
||||
- `\Q...\E` literal sequences are replaced with escaped literal text;
|
||||
- `\A` → `^` and `\z` → `(?![\s\S])` (end-of-string anchor).
|
||||
3. Perl-only constructs that would need a capture-group-renumbering rewrite (atomic groups `(?>...)`, possessive quantifiers `a++`) are deliberately NOT translated. They throw from `new RegExp`, and every caller treats a throw as "always valid / never warn" (see below) rather than breaking the editor.
|
||||
|
||||
### `client/src/app/shared/dc-validator/utils/isRegexRuleExempt.ts`
|
||||
|
||||
Blank values (`undefined`, `null`, `''`) are exempt from pattern matching on any column type - use a separate NOTNULL rule if populated values must also be enforced. On numeric columns the plain SAS missing (`.`) is also exempt; special missings (`.A`-`.Z`, `._`, bare letters) are NOT exempt anywhere - being deliberately set, they are real values the pattern must match (and on a character column even `.` is real text). `isSpecialMissing` from `@sasjs/utils` is deliberately not used: its optional-dot regex would exempt any single-letter character value ("d", "z") before the regex ever ran. The check takes an `isNumeric` flag, passed by all callers (the dq validator via `dqValidate(rules, value, colType === 'numeric')`, the warning renderer via a `makeRegexWarningRenderer` argument, and `failsSoftRegex` via the column's HOT type).
|
||||
|
||||
### HARDREGEX - blocking validation
|
||||
|
||||
`HARDREGEX` is implemented as a cell validator in `client/src/app/shared/dc-validator/validations/dq-validation.ts`. It returns `true` (valid) for exempt values and for patterns that fail to compile (with a `console.warn`), and otherwise returns `parseRegexRule(ruleValue).test(value.toString())`. Returning `false` makes HOT mark the cell invalid, block submission, and paint it red via its standard `htInvalid` styling.
|
||||
|
||||
### SOFTREGEX - warning renderer
|
||||
|
||||
`client/src/app/editor/utils/regex-warning-renderer.ts` builds a display-only Handsontable renderer (registered per-column by `DcValidator.setupRules` in `client/src/app/shared/dc-validator/dc-validator.ts`). It never returns false; it only:
|
||||
|
||||
- adds a `REGEX: <pattern>` tooltip (`td.title`) when a rule fails;
|
||||
- adds the yellow `dc-warning-cell` class when only SOFTREGEX fails.
|
||||
|
||||
`DcValidator.failsSoftRegex(col, value)` provides the same logic outside the grid (e.g. the edit-record screen).
|
||||
|
||||
### Precedence: HARD and SOFT on the same column
|
||||
|
||||
Only one regex ever runs per column. If a HARDREGEX rule exists, SOFTREGEX is ignored entirely - never compiled, never evaluated - regardless of whether individual cell values pass or fail the hard rule. A value failing HARDREGEX gets the red invalid styling (blocking submission) plus a `REGEX: <pattern>` tooltip; a SOFTREGEX-only column warns in yellow without blocking. This holds in both the renderer and `failsSoftRegex`.
|
||||
|
||||
### Other behaviour
|
||||
|
||||
- Rows marked for delete (`_____DELETE__THIS__RECORD_____ = 'Yes'`) are not warned/validated by the renderer (except primary key columns, which still validate).
|
||||
- A malformed pattern never breaks the editor: the dq validator treats it as always-valid and the renderer disables the warning, logging to the console instead.
|
||||
- The pattern is used as authored - it is NOT auto-anchored. Authors must include `^`/`$` to match the entire cell value.
|
||||
- Column info: `client/src/app/shared/utils/col-info-html.ts` shows the applied pattern in the column-info dropdown - the HARDREGEX pattern if one exists (it is the rule actually applied when both are present), otherwise the SOFTREGEX pattern, otherwise nothing.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: `parseRegexRule.spec.ts`, `isRegexRuleExempt.spec.ts`, `dq-validation.spec.ts`, `dc-validator.spec.ts` (under `client/src/app/shared/dc-validator/`), `client/src/app/editor/utils/regex-warning-renderer.spec.ts`, `client/src/app/shared/utils/col-info-html.spec.ts`.
|
||||
- E2E: `client/cypress/e2e/editor.cy.ts`.
|
||||
- SAS side: `sas/sasjs/services/editors/stagedata.test.3.sas`, plus seed data in `mpe_makedata.sas`: HARDREGEX "SOME_CHAR must contain 'the' or 'data'" (`/the|data/i`), SOFTREGEX "SOME_CHAR should contain the letter 't'", SOFTREGEX on PRIMARY_KEY_FIELD (`/^\d+$/` - yellow if the key contains a decimal), and HARDREGEX on SOME_SHORTNUM (`/^(??$).*/` - values 1-5 blocked; generated data starts at 6 so demos aren't blocked accidentally).
|
||||
@@ -0,0 +1,28 @@
|
||||
# Releases and the CHANGELOG
|
||||
|
||||
**Do not edit `CHANGELOG.md` by hand, and do not bump the version in `package.json` yourself.** Both are generated automatically by the release pipeline. Manual edits produce duplicate/colliding version sections and merge conflicts, and they are pointless because the next release overwrites them anyway.
|
||||
|
||||
## How releases work
|
||||
|
||||
Releases are driven by [semantic-release](https://semantic-release.gitbook.io/) from the `release` job in `.gitea/workflows/release.yaml`, which runs on every push to `main` (after the build/test jobs pass). The configuration lives in `.releaserc` at the repo root.
|
||||
|
||||
The plugin chain (`.releaserc` `plugins`) does the following on each release:
|
||||
|
||||
1. `@semantic-release/commit-analyzer` - inspects Conventional Commit messages since the last tag to decide the next semantic version (`fix:` -> patch, `feat:` -> minor, breaking change -> major).
|
||||
2. `@semantic-release/release-notes-generator` - builds the release notes from those commits.
|
||||
3. `@semantic-release/changelog` - **writes the new section into `CHANGELOG.md`**.
|
||||
4. `@semantic-release/npm` - updates the `version` in `package.json` (it does not publish; the package is `private`).
|
||||
5. `@semantic-release/git` - commits `CHANGELOG.md` and `package.json` back to `main` as `chore(release): <version> [skip ci]` and tags it.
|
||||
6. `@saithodev/semantic-release-gitea` - creates the Gitea release.
|
||||
|
||||
The build assets (frontend zip, SAS 9 / Viya / SASjs Server deployment files) are attached to the release afterwards by the workflow's own `Upload assets to release` step using the Gitea API (`curl`), not by the plugin. Before creating the release the workflow first runs `semantic-release --dry-run` and aborts the job if there are no releasable changes since the last tag.
|
||||
|
||||
Because the changelog entry and version bump are committed by the pipeline (step 5), they must **not** exist in your working tree beforehand. If they do, the release commit collides with them.
|
||||
|
||||
## What this means for you
|
||||
|
||||
- Never add, remove, or reorder entries in `CHANGELOG.md`.
|
||||
- Never change `version` in `package.json`.
|
||||
- Control what appears in the changelog through your **commit messages** (Conventional Commits: `fix:`, `feat:`, `feat!:`/`BREAKING CHANGE:`, plus scopes like `fix(editor): ...`). The scope and description become the changelog line.
|
||||
- If you find hand-written entries in `CHANGELOG.md` in the working tree, revert them (`git checkout CHANGELOG.md`).
|
||||
- CI does not use any local release script - the pipeline installs and runs `semantic-release` directly.
|
||||
@@ -0,0 +1,97 @@
|
||||
# REPLACE Load Type — Technical Deep Dive
|
||||
|
||||
This document describes the REPLACE load type in detail. For the overall loader architecture, the temporal model, and the other load types, see [bitemporal-dataloader.md](bitemporal-dataloader.md).
|
||||
|
||||
## Overview
|
||||
|
||||
REPLACE is the simplest load type and is deliberately **not** routed through `%bitemporal_dataloader`. It is implemented inline in `%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`, search for `&loadtype=REPLACE`). There is no history, no change detection, no key matching — the target table is wiped and reloaded from the staging table verbatim.
|
||||
|
||||
## Actual load (`LOADTARGET=YES`)
|
||||
|
||||
```sas
|
||||
%mp_lockanytable(LOCK, lib=&lib, ds=&ds, ...)
|
||||
|
||||
data WORK.&STAGING_DS;
|
||||
set WORK.&STAGING_DS;
|
||||
/* only if the target contains the MPE_TABLES.VAR_PROCESSED variable: */
|
||||
&VAR_PROCESSED = &now;
|
||||
drop _____DELETE__THIS__RECORD_____;
|
||||
run;
|
||||
|
||||
%if &engine_type=CAS %then %do;
|
||||
/* prep first: cast varchar columns in a CASUSER copy of staging */
|
||||
proc contents noprint data=&libds out=work.rpl_base_cols(keep=name type);
|
||||
proc contents noprint data=WORK.&STAGING_DS out=work.rpl_stag_cols(keep=name type);
|
||||
/* work.rpl_vchars = columns that are varchar in target AND in staging */
|
||||
data casuser.&tmpds; /* temp copy with varchars casted */
|
||||
length <varchar col> varchar(*); ...
|
||||
set WORK.&STAGING_DS (rename=(<varchar col>=<tmp> ...));
|
||||
<varchar col>=<tmp>; ... drop <tmp> ...;
|
||||
run;
|
||||
/* unlock + abort if any error so far - nothing destructive done yet */
|
||||
%mp_abort(iftrue= (&syscc>0) ...)
|
||||
/* destructive step, deliberately last before the append */
|
||||
proc cas;
|
||||
table.deleteRows / table={caslib="&lib",name="&ds",where="1=1"};
|
||||
quit;
|
||||
data &libds (append=yes) / sessref=dcsession;
|
||||
set casuser.&tmpds;
|
||||
run;
|
||||
proc sql; drop table CASUSER.&tmpds; quit;
|
||||
%end;
|
||||
%else %do;
|
||||
/* unlock + abort if any error so far - nothing destructive done yet */
|
||||
%mp_abort(iftrue= (&syscc>0) ...)
|
||||
proc sql;
|
||||
delete * from &libds;
|
||||
quit;
|
||||
proc append base=&libds data=WORK.&STAGING_DS force nowarn; run;
|
||||
%end;
|
||||
|
||||
%mp_lockanytable(UNLOCK, lib=&lib, ds=&ds, ...)
|
||||
```
|
||||
|
||||
Step by step:
|
||||
|
||||
1. **Lock** — the target is locked via `%mp_lockanytable` (control table `&dclib..MPE_LOCKANYTABLE`). This is the only concurrency guard for the whole operation.
|
||||
2. **Staging recopy** — the staging dataset is rewritten in place: the processed-timestamp column is set to the approval timestamp **only if** the target table contains the variable named by `MPE_TABLES.VAR_PROCESSED` (unlike `%bitemporal_dataloader`, there is no `PROCESSED_DTTM` fallback). The delete-flag column `_____DELETE__THIS__RECORD_____` is unconditionally dropped — per-record delete semantics do not exist in REPLACE (everything is deleted anyway), so a delete flag submitted by the user is silently discarded (a `drop` of a non-existent variable just produces a warning).
|
||||
3. **Prepare the load (CAS only)** — fixed char variables cannot be appended to CAS varchar columns, so the staging table is first copied to a CASUSER temp table with every column that is varchar in the target (and present in staging) redeclared as `varchar(*)` via generated `length` / `rename` / assignment statements. This mirrors the CAS append in `%bitemporal_dataloader`.
|
||||
4. **Pre-destructive abort check** — if `&syscc > 0` after all preparation, the target is unlocked and the macro aborts via `%mp_abort`. Nothing destructive has happened at this point, so a failed prep leaves the target intact (and without a stale lock).
|
||||
5. **Delete all rows** — every row is removed from the target while preserving structure, indexes and metadata, deliberately as the last step before the append (to minimise the time the target sits empty). On most engines this is `proc sql; delete * from &libds;` (passed through as a `DELETE` on database libraries). CAS tables do not support SQL deletes, so on the CAS engine the table is truncated instead via the `table.deleteRows` action with `where="1=1"` (same approach as `%bitemporal_closeouts`), with the libref passed as the caslib.
|
||||
6. **Append staged rows** — the entire staging table is appended. On most engines this is `proc append ... force nowarn` (`force` allows the append to proceed despite attribute mismatches, so lengths/formats may be coerced or values truncated, and columns present in only one side are handled; `nowarn` suppresses the associated warnings). On CAS the pre-cast CASUSER temp table is appended with `data &libds (append=yes) / sessref=dcsession` and then dropped.
|
||||
7. **Unlock**.
|
||||
|
||||
## Diff screen (`LOADTARGET=NO`)
|
||||
|
||||
When the approver reviews a REPLACE submission, no real comparison is performed:
|
||||
|
||||
```sas
|
||||
/* is full replace so treat all staged records as new in diff screen */
|
||||
data work.outds_mod work.outds_add;
|
||||
set work.&staging_ds;
|
||||
output work.outds_add; /* every staged record is "NEW" */
|
||||
run; /* outds_mod stays empty */
|
||||
|
||||
/* previous table will be considered fully deleted */
|
||||
data work.outds_del;
|
||||
set &lib..&ds; /* every existing record is "DELETED" */
|
||||
run;
|
||||
```
|
||||
|
||||
The approval screen therefore always shows full-table turnover: the entire current table as deleted and the entire staging table as added, with no "modified" records and no original/current comparison.
|
||||
|
||||
## What REPLACE does not do
|
||||
|
||||
Because it bypasses `%bitemporal_dataloader`, none of the following apply to REPLACE:
|
||||
|
||||
* **No row-level audit** — nothing is written to `AUDIT_LIBDS` / `MPE_AUDIT`. The record of the change is the approval package itself (staged CSV and the `TEMPDIFFS` CSV stored under `&mpelocapprovals/&LOAD_REF`).
|
||||
* **No `MPE_DATALOADS` log entry** — the load-log insert lives inside `%bitemporal_dataloader`, so REPLACE loads do not appear in load history. (Consequently the SHOW_DIFFS timestamp lookup in `postdata.sas` finds no `MPE_DATALOADS` row for a REPLACE load and falls back to the current datetime.)
|
||||
* **No PK usage** — `BUSKEY` is ignored: no uniqueness check, no dedup, no join to existing data. Staged rows are loaded exactly as submitted, duplicates included.
|
||||
* **No temporal handling** — `VAR_TXFROM`/`VAR_TXTO`/`VAR_BUSFROM`/`VAR_BUSTO` and `CLOSE_VARS` are ignored. A REPLACE table should not be treated as temporal; querying it with the usual validity filters makes no sense.
|
||||
* **No retained-key handling** — `RK_UNDERLYING` is ignored.
|
||||
* **Minimal engine-specific handling** — unlike the temporal loaders, there is no pass-through/temp-table optimisation for OLEDB, Redshift, Postgres or Snowflake. The only engine conditional is CAS, where rows are removed via the `deleteRows` action and appended via a varchar-casting CASUSER temp table and a data-step append (SQL deletes and fixed-char-to-varchar appends are not possible on CAS).
|
||||
* **Not atomic** — the delete/truncate and append are separate steps with no transaction or rollback, so a session failure between them still leaves the target empty or partially loaded (recovery is a re-approval of the same or a previous staging package). The risk is mitigated by performing all preparation first, aborting on any error before the destructive step, and executing the delete/truncate immediately before the append.
|
||||
|
||||
## Interaction with Row Level Security
|
||||
|
||||
REPLACE is incompatible with `EDIT`-scope RLS rules (a full-table wipe cannot honour row-level write restrictions); this is enforced at edit time in both directions — see [row-level-security.md](row-level-security.md#incompatibility-with-replace-load-type) and [issue #211](https://git.datacontroller.io/dc/dc/issues/211). `VIEW`-scope rules remain compatible.
|
||||
@@ -0,0 +1,225 @@
|
||||
# Row Level Security — Technical Deep Dive
|
||||
|
||||
This document explains **how** Row Level Security (RLS) is implemented in the Data Controller backend. For the user-facing configuration guide, see [docs.datacontroller.io/row-level-security](https://docs.datacontroller.io/row-level-security/).
|
||||
|
||||
## Overview
|
||||
|
||||
RLS in Data Controller is implemented as **server-side WHERE clause generation**. No data leaves SAS without passing through a dynamically generated filter. The filter is built at runtime per request, based on:
|
||||
|
||||
1. The requesting user's group memberships (SAS metadata groups + DC groups)
|
||||
2. The active rules in the `MPE_ROW_LEVEL_SECURITY` control table
|
||||
3. The access mode (VIEW / EDIT / download / upload)
|
||||
|
||||
Because the filter is expressed as a standard SAS `WHERE` expression, it works against **any** engine — Base SAS datasets, database libraries (via implicit SQL pass-through pushdown), SPDE, CAS libnames, etc.
|
||||
|
||||
## Request Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Client request\nview / edit / download / upload] --> B{Service mode}
|
||||
B -->|VIEW / EDIT / DLOAD| C["%mpe_filtermaster(mode, libds)"]
|
||||
B -->|ULOAD stagedata.sas| C
|
||||
C --> D["%mpe_getgroups()\nmetadata groups + MPE_GROUPS"]
|
||||
D --> E{User in &mpeadmins?}
|
||||
E -->|Yes| F[No RLS filter\n1=1]
|
||||
E -->|No| G[Lookup active rules in\nMPE_ROW_LEVEL_SECURITY\nfor libref.table + user's groups]
|
||||
G --> H{Rules found?}
|
||||
H -->|No| F
|
||||
H -->|Yes| I["%mp_filtergenerate()\nper group, OR'd together"]
|
||||
I --> J[WHERE clause written\nto temp fileref]
|
||||
F --> J
|
||||
J --> K{Read or Write?}
|
||||
K -->|Read\nviewdata / getdata / getrawdata| L["where %inc filtref;;\nrows filtered server-side"]
|
||||
K -->|Write\nstagedata| M[Inverse filter\nwhere not( filtref )]
|
||||
M --> N{badrecords > 0?}
|
||||
N -->|Yes| O[Abort submission\nSecurity Problem]
|
||||
N -->|No| P[Staging proceeds\nto approval workflow]
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
| Component | Location | Role |
|
||||
|---|---|---|
|
||||
| `MPE_ROW_LEVEL_SECURITY` | `&mpelib` (DC control library) | The rule table (scope, group, libref, table, logic, subgroup, variable, operator, raw value, active flag) |
|
||||
| `%mpe_filtermaster()` | `sas/sasjs/macros/mpe_filtermaster.sas` | Master macro that assembles the full WHERE clause for a request |
|
||||
| `%mpe_getgroups()` | `sas/sasjs/macros/mpe_getgroups.sas` | Resolves group membership (metadata groups via `%dc_getusergroups` + `MPE_GROUPS` DC-internal groups) |
|
||||
| `%mp_filtergenerate()` | SASjs core | Converts a query table (logic/subgroup/variable/operator/value rows) into WHERE clause text |
|
||||
| `%mp_filtercheck()` | SASjs core | Validates rule syntax at *edit time* (defence against SAS code injection) |
|
||||
| `mpe_row_level_security_postedit.sas` | `sas/sasjs/services/hooks/` | Post-edit hook that runs `%mp_filtercheck` whenever the RLS table itself is edited |
|
||||
|
||||
## The Modes
|
||||
|
||||
`%mpe_filtermaster` accepts a `mode` parameter, and every service that surfaces data calls it with the appropriate mode:
|
||||
|
||||
| Mode | Caller | Purpose |
|
||||
|---|---|---|
|
||||
| `VIEW` | `services/public/viewdata.sas`, `getchangeinfo.sas` | Read-only table viewer |
|
||||
| `EDIT` | `services/editors/getdata.sas` | The EDIT grid (adds "current records only" validity filtering) |
|
||||
| `DLOAD` | `services/public/getrawdata.sas` | Raw file downloads (RLS scope treated as `VIEW`) |
|
||||
| `ULOAD` | `services/editors/stagedata.sas` | **Upload validation** (RLS scope treated as `EDIT`) |
|
||||
|
||||
Scope mapping: `DLOAD` requests match rules with `RLS_SCOPE in ('VIEW','ALL')`; `ULOAD` requests match rules with `RLS_SCOPE in ('EDIT','ALL')`.
|
||||
|
||||
## Execution Flow of `%mpe_filtermaster`
|
||||
|
||||
The macro writes the final WHERE expression to a temporary **fileref** (`outref`), line by line. (A fileref is used because a generated filter may exceed the 64k macro variable limit — and note that `%include` of a fileref is not allowed directly in a `proc sql` where clause, hence callers typically use it in a DATA step or data step view.)
|
||||
|
||||
### 1. User-supplied filter (FILTER_RK)
|
||||
|
||||
If the request includes a stored filter (`filter_rk > 0`), its clauses are read from `MPE_FILTERANYTABLE` / `MPE_FILTERSOURCE` and generated first via `%mp_filtergenerate`. RLS clauses are then **AND-ed on top** — a user filter can only ever narrow results, never widen them beyond RLS.
|
||||
|
||||
### 2. Validity-date filtering (EDIT / DLOAD only)
|
||||
|
||||
`MPE_TABLES` may define `VAR_TXFROM` / `VAR_TXTO` (SCD2-style validity variables) for the target table. Unless the user explicitly filtered on those variables, the macro appends:
|
||||
|
||||
```
|
||||
("<current datetime>"dt < VAR_TXTO)
|
||||
```
|
||||
|
||||
so that only current records are surfaced in the EDIT grid and downloads.
|
||||
|
||||
### 3. Group resolution and admin bypass
|
||||
|
||||
```sas
|
||||
%mpe_getgroups(user=%mf_getuser(), outds=work.groups)
|
||||
```
|
||||
|
||||
Groups come from two sources, concatenated:
|
||||
|
||||
* SAS metadata groups (Viya / EBI / Base-specific logic in `%dc_getusergroups`)
|
||||
* The `MPE_GROUPS` DC table (group assignments managed inside Data Controller)
|
||||
|
||||
If the user is a member of the `&mpeadmins` group, **RLS is skipped entirely** — admins always see all rows.
|
||||
|
||||
### 4. Rule extraction
|
||||
|
||||
Non-admin users trigger a lookup of active, current rules:
|
||||
|
||||
```sas
|
||||
create table work.&rlsds as
|
||||
select rls_group, rls_group_logic, rls_subgroup_logic, rls_subgroup_id,
|
||||
rls_variable_nm, rls_operator_nm, rls_raw_value
|
||||
from &mpelib..mpe_row_level_security
|
||||
where &dc_dttmtfmt. lt tx_to /* only current (non-deleted) rules */
|
||||
and rls_scope in ("&scopeval",'ALL')
|
||||
and upcase(rls_group) in (select upcase(groupname) from work.groups)
|
||||
and rls_libref = "<libref>" and rls_table = "<dsname>"
|
||||
and rls_active = 1
|
||||
order by rls_group, rls_subgroup_id;
|
||||
```
|
||||
|
||||
### 5. Clause assembly
|
||||
|
||||
If rules exist, they are appended to the fileref as `AND ( ... )`. Each **group** the user belongs to contributes one sub-filter, and the group-level sub-filters are joined with `OR`:
|
||||
|
||||
```
|
||||
AND ( <group 1 filter> OR <group 2 filter> ... )
|
||||
```
|
||||
|
||||
Each group's filter is produced by `%mp_filtergenerate`, which honours:
|
||||
|
||||
* `RLS_GROUP_LOGIC` — how subgroups (identified by `RLS_SUBGROUP_ID`) are joined
|
||||
* `RLS_SUBGROUP_LOGIC` — how individual clauses within a subgroup are joined (AND/OR)
|
||||
|
||||
So membership in multiple groups is **permissive** (OR): the user sees the union of rows permitted by each of their groups.
|
||||
|
||||
### 6. Empty-filter fallback
|
||||
|
||||
If nothing was written to the fileref (no stored filter, no validity vars, no matching RLS rules), the macro writes a literal `1=1` so the fileref can always be consumed as a valid WHERE expression. Absence of rules therefore means **no restriction** — RLS is opt-in per table/group.
|
||||
|
||||
## How Callers Apply the Filter
|
||||
|
||||
### Read path (viewdata.sas)
|
||||
|
||||
```sas
|
||||
%mpe_filtermaster(VIEW, &libds, dclib=&mpelib, filter_rk=&filter_rk,
|
||||
outref=filtref, outds=work.query)
|
||||
|
||||
data work.viewdata;
|
||||
set &libds;
|
||||
where %inc filtref;;
|
||||
if _n_ > &DC_MAXOBS_WEBVIEW then stop;
|
||||
run;
|
||||
```
|
||||
|
||||
The fileref is `%include`d directly inside the `where` statement — the filter never passes through client-visible state, so it cannot be tampered with. For database libraries the WHERE expression is pushed down to the database by the SAS engine.
|
||||
|
||||
### Write path (stagedata.sas) — inverse filter
|
||||
|
||||
Uploads cannot be filtered; instead the filter is **inverted** and any submitted row matching the inverse is rejected:
|
||||
|
||||
```sas
|
||||
%mpe_filtermaster(ULOAD, &libds, dclib=&mpelib, outref=filtref, ...)
|
||||
|
||||
/* prepare inverse query */
|
||||
data _null_;
|
||||
infile filtref end=eof;
|
||||
file &tempref;
|
||||
if _n_=1 then put 'where not(';
|
||||
input; put _infile_;
|
||||
if eof then put ')';
|
||||
run;
|
||||
|
||||
data work.badrecords;
|
||||
set work.jsdata; /* rows submitted by the user */
|
||||
%inc &tempref/source2;;
|
||||
run;
|
||||
|
||||
%mp_abort(iftrue=(%mf_nobs(work.badrecords)>0)
|
||||
,msg=%str(Security Problem - N unauthorised records submitted))
|
||||
```
|
||||
|
||||
If even one submitted row falls outside the user's permitted row set, the entire staging request is aborted before any approval/apply step.
|
||||
|
||||
## Incompatibility with REPLACE Load Type
|
||||
|
||||
RLS with `EDIT` scope is **incompatible** with tables configured with `LOAD_TYPE=REPLACE` in `MPE_TABLES`. A REPLACE load wipes and reloads the entire target table, so row-level filtering of submitted records cannot be enforced meaningfully (the rows a user is *not* allowed to see would also be deleted). Backend validations therefore abort in both directions (see [issue #211](https://git.datacontroller.io/dc/dc/issues/211)):
|
||||
|
||||
1. **`mpe_row_level_security_postedit.sas`** — aborts when activating a rule with `RLS_SCOPE in ('EDIT','ALL')` against a table whose current `MPE_TABLES` record has `LOADTYPE='REPLACE'`.
|
||||
2. **`mpe_tables_postedit.sas`** — aborts when setting `LOADTYPE='REPLACE'` on a table that already has active, current `RLS_SCOPE in ('EDIT','ALL')` rules in `MPE_ROW_LEVEL_SECURITY`.
|
||||
|
||||
`VIEW`-scope rules remain compatible with REPLACE loads, since they only affect read paths.
|
||||
|
||||
## Edit-Time Validation (Injection Defence)
|
||||
|
||||
Because `RLS_RAW_VALUE` is free text that ends up inside a generated WHERE clause, it is a potential SAS code-injection vector. Mitigations:
|
||||
|
||||
1. **`mpe_row_level_security_postedit.sas`** — a post-edit hook on the `MPE_ROW_LEVEL_SECURITY` table itself. Every newly staged rule (with `rls_active=1`) is grouped by target `libref.table` and run through `%mp_filtercheck(targetds=..., abort=YES)`, which compiles/tests each clause against the real target table and rejects the whole submission on invalid syntax (see [mp_filtercheck](https://core.sasjs.io/mp__filtercheck_8sas.html)).
|
||||
2. **Format rules** enforced by validation: character values must be single quoted, `IN`/`NOT IN` values must be bracketed, `BETWEEN` must contain `AND`.
|
||||
|
||||
Additionally, editing `MPE_ROW_LEVEL_SECURITY` is itself a Data Controller table edit, so it goes through the normal approval workflow, audit trail (`MPE_AUDIT`), and can be column-restricted via [Column Level Security](https://docs.datacontroller.io/column-level-security/).
|
||||
|
||||
## Worked Example
|
||||
|
||||
Given these active rules (all for `MYLIB.MYDS`, `RLS_ACTIVE=1`):
|
||||
|
||||
| SCOPE | GROUP | GROUP_LOGIC | SUBGRP_LOGIC | SUBGRP_ID | VAR | OP | VALUE |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| ALL | Group 1 | AND | AND | 1 | VAR_2 | IN | ('this','or') |
|
||||
| ALL | Group 1 | AND | AND | 1 | VAR_3 | < | 42 |
|
||||
| ALL | Group 2 | AND | AND | 1 | VAR_4 | CONTAINS | 'xyz' |
|
||||
|
||||
A non-admin user in **both** groups, opening the VIEW page, gets a fileref containing:
|
||||
|
||||
```
|
||||
AND ( (VAR_2 IN ('this','or') AND VAR_3 < 42) OR (VAR_4 CONTAINS 'xyz') )
|
||||
```
|
||||
|
||||
resulting in:
|
||||
|
||||
```sas
|
||||
data work.viewdata;
|
||||
set mylib.myds;
|
||||
where (VAR_2 IN ('this','or') AND VAR_3 < 42) OR (VAR_4 CONTAINS 'xyz');
|
||||
run;
|
||||
```
|
||||
|
||||
The same user submitting an EDIT upload has the inverse applied to their staged rows; any row not matching the expression above aborts the submission.
|
||||
|
||||
## Summary of Security Properties
|
||||
|
||||
* **Enforced server-side** in every read service (VIEW/EDIT/DLOAD) and every write service (ULOAD via inverse filter).
|
||||
* **Admin bypass** is explicit (`&mpeadmins` group membership check).
|
||||
* **Multi-group semantics are OR** (union of permitted rows); rules within a group/subgroup are AND/OR per configuration.
|
||||
* **Fail-open by design**: if no rules match a table, it is unrestricted — RLS must be opted into per table.
|
||||
* **Injection-resistant**: values are validated with `%mp_filtercheck` at the time rules are edited, not at query time.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Testing
|
||||
|
||||
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory.
|
||||
|
||||
## Commands
|
||||
|
||||
- Full deploy + test cycle: `npm run 4gl` (compiles, deploys to the 4gl target, runs makedata) then `sasjs test -t 4gl`.
|
||||
- Run tests only (no rebuild/redeploy): `sasjs test -t 4gl`.
|
||||
- Run a subset of tests: `sasjs test -t 4gl SOMESTRING` — the positional argument filters to matching tests (e.g. `sasjs test -t 4gl stagedata.test.3`).
|
||||
|
||||
## Results
|
||||
|
||||
- Test results: `sas/sasjsresults/testResults.json` / `.csv` / `.xml`
|
||||
- Individual test logs: `sas/sasjsresults/logs/<testsuite>.log` (e.g. `services_editors_stagedata.test.3.log`)
|
||||
- Coverage: `sas/sasjsresults/coverage.lcov`
|
||||
|
||||
## Notes
|
||||
|
||||
- Test source files live in `sas/sasjs/**`; `sas/sasjsbuild/` is generated build output — do not hand-edit it.
|
||||
- `sasjs test` executes tests against the deployed app, so run `npm run 4gl` first after changing any service/hook/test code.
|
||||
- Assertions are made with `%mp_assert()`; results are written to `work.test_results`.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: sas
|
||||
description: >
|
||||
Use this skill whenever writing, modifying, or debugging SAS code in this repository — SAS
|
||||
macros, sasjs services, hooks, or test files (*.sas). Triggers include: SAS macro compilation
|
||||
errors, parameter parsing issues, writing sasjs tests with mp_assert / mp_assertdsobs /
|
||||
mp_assertscope, macro-quoting questions (%str, %nrstr, %superq), %mp_abort usage, or unexpected
|
||||
behaviour when passing free-text values (descriptions, messages) to macros. Also covers repo
|
||||
conventions such as running `sasjs lint` after touching .sas files and the maximum line length
|
||||
rule.
|
||||
---
|
||||
|
||||
# SAS Development
|
||||
|
||||
Conventions and pitfalls for SAS code in this repository (macros in `sas/sasjs/macros/`, services
|
||||
in `sas/sasjs/services/`, tests alongside them as `*.test.sas`).
|
||||
|
||||
## Always `%str()` free-text macro parameters
|
||||
|
||||
A comma inside a macro parameter value is parsed as a **parameter delimiter**. Free-text
|
||||
parameters such as `desc=` (in `%mp_assert`, `%mp_assertdsobs`, `%mp_assertscope`) and `msg=` (in
|
||||
`%mp_abort`) must be wrapped in `%str()` — even when they currently contain no comma, so later
|
||||
edits cannot silently break the call.
|
||||
|
||||
Wrong — the text after the comma becomes an unexpected positional parameter and the compilation
|
||||
fails (or worse, is misparsed):
|
||||
|
||||
```sas
|
||||
%mp_assert(iftrue=(&oldrows=0),
|
||||
desc=Test 2 - all staged records loaded, delete flags ignored,
|
||||
outds=work.test_results
|
||||
)
|
||||
```
|
||||
|
||||
Right:
|
||||
|
||||
```sas
|
||||
%mp_assert(iftrue=(&oldrows=0),
|
||||
desc=%str(Test 2 - all staged records loaded, delete flags ignored),
|
||||
outds=work.test_results
|
||||
)
|
||||
```
|
||||
|
||||
The same applies to any macro parameter that carries user-facing text: `%mp_abort(msg=%str(...))`,
|
||||
`etlsource=` values containing punctuation (use `%superq()` when passing macro variables that may
|
||||
contain special characters), etc.
|
||||
|
||||
Related quoting rules of thumb:
|
||||
|
||||
- `%str()` masks commas, parentheses, semicolons and quotes at compile time — sufficient for
|
||||
static text like `desc=`.
|
||||
- Use `%nrstr()` if the text must also mask `%` and `&` (rare in descriptions).
|
||||
- Use `%superq(var)` when forwarding a macro **variable** whose value may contain special
|
||||
characters (e.g. `etlsource=` in `mpe_targetloader.sas`).
|
||||
|
||||
## Tests must be idempotent
|
||||
|
||||
A test file must pass when run repeatedly (including after a run that failed partway). Conventions:
|
||||
|
||||
- Start the file with `%let syscc=0;` — many macros (e.g. `%mp_lockanytable(LOCK)`) abort on entry if `&syscc>0`, and any `WARNING` in a previous test bumps `syscc` to 4.
|
||||
- Clean up **all** persistent state, not just the obvious one: `MPE_TABLES` registrations, `MPE_LOCKANYTABLE` lock records (a run dying between LOCK and UNLOCK leaves a stale `LOCKED` row), physical tables in `dctest` (`proc datasets ... delete`), and global macro variables created via `select ... into:` (`%symdel`).
|
||||
- Make prep defensive: delete-then-insert config records (handles leftovers from an aborted run), and recreate physical tables rather than assuming they are absent.
|
||||
|
||||
## Repo conventions for .sas files
|
||||
|
||||
- Run `npx sasjs lint` from the `sas/` directory after creating or modifying any `.sas` file; the
|
||||
files you touched must have zero warnings (pre-existing warnings in other files can be ignored).
|
||||
- Maximum line length is 80 characters (lint-enforced) — this is why long `msg=%str(...)` values
|
||||
sometimes need shortening.
|
||||
- Test files are named `<thing>.test.sas` (or `<thing>.test.N.sas`) and run via
|
||||
`npm run 4gl && sasjs test -t 4gl` from `sas/` — see `.agent/docs/testing.md`.
|
||||
- `sas/sasjsbuild/` is generated build output — never hand-edit it; edit sources under
|
||||
`sas/sasjs/` only.
|
||||
@@ -38,13 +38,13 @@ jobs:
|
||||
npm ci
|
||||
|
||||
- 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: |
|
||||
npm audit --audit-level=critical --omit=dev
|
||||
npm audit --omit=dev
|
||||
cd ./sas
|
||||
npm audit --audit-level=critical --omit=dev
|
||||
npm audit --omit=dev
|
||||
cd ../client
|
||||
npm audit --audit-level=critical --omit=dev
|
||||
npm audit --omit=dev
|
||||
|
||||
- name: Lint check
|
||||
run: npm run lint:check
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
# Start frontend and run cypress
|
||||
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
|
||||
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
|
||||
|
||||
- name: Zip Cypress videos
|
||||
if: always()
|
||||
|
||||
@@ -45,13 +45,13 @@ jobs:
|
||||
npm ci
|
||||
|
||||
- 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: |
|
||||
npm audit --audit-level=critical --omit=dev
|
||||
npm audit --omit=dev
|
||||
cd ./sas
|
||||
npm audit --audit-level=critical --omit=dev
|
||||
npm audit --omit=dev
|
||||
cd ../client
|
||||
npm audit --audit-level=critical --omit=dev
|
||||
npm audit --omit=dev
|
||||
|
||||
- name: Angular Tests
|
||||
run: |
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||
cat ./cypress.config.ts
|
||||
# Start frontend and run cypress
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.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,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
|
||||
|
||||
- name: Zip Cypress videos
|
||||
if: always()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Agent Instructions
|
||||
|
||||
Read **`CONTEXT.md`** at the repo root first - it is the domain glossary and orientation for Data Controller (roles, load types, MPE control tables, validations, security). Use its vocabulary in your output.
|
||||
|
||||
## Related repositories
|
||||
|
||||
Data Controller spans three sibling repos (usually checked out side by side):
|
||||
|
||||
- **`dc`** (this repo) - the product source (Angular client + SAS backend).
|
||||
- **`docs.datacontroller.io`** - the user-facing product documentation (MkDocs). User-facing behaviour is documented there; the `.agent/docs/` deep-dives here link out to it.
|
||||
- **`datacontroller.io`** - the marketing site, blog and feed (Gatsby).
|
||||
|
||||
## 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.
|
||||
|
||||
## CHANGELOG and versioning
|
||||
|
||||
Do NOT edit `CHANGELOG.md` by hand, and do NOT bump the `version` in `package.json`. The release pipeline (semantic-release, in `.gitea/workflows/release.yaml`) generates both automatically from Conventional Commit messages on every push to `main`. Manual entries collide with the pipeline's release commit and are overwritten anyway. Control the changelog through your commit messages instead. See `.agent/docs/releases-and-changelog.md`.
|
||||
|
||||
## Markdown files
|
||||
|
||||
Do NOT hard-wrap Markdown files (no fixed-width line wrapping / carriage returns inside paragraphs). Each paragraph, list item, and heading should be a single logical line, regardless of length. Let the editor/viewer soft-wrap. This includes AGENTS.md itself.
|
||||
|
||||
This is different from `.sas` files, where a maximum line length applies. The no-wrap rule applies to all `*.md` files in this repo (docs, READMEs, etc.).
|
||||
|
||||
Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and reflowed, and Markdown renderers already handle wrapping.
|
||||
|
||||
## Linting (required before "done")
|
||||
|
||||
Never consider a change complete until the relevant linters pass on the files you touched — do not rely on the user's pre-commit hooks to catch it:
|
||||
|
||||
- **Client (TypeScript/HTML/etc.)**: run `npm run lint:check` from the `client/` directory (prettier). Fix any failures with `npm run lint:fix`.
|
||||
- **SAS**: after creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored).
|
||||
|
||||
## No external assets
|
||||
|
||||
Data Controller must run entirely locally (offline / on-prem, no internet access). The built product must never fetch assets from remote servers — no external fonts, images, scripts, stylesheets, CDN links, or remote URLs in meta tags (e.g. `og:image`, `og:url`, `itemprop="image"`). All assets must be bundled and served locally.
|
||||
|
||||
## The .agent folder
|
||||
|
||||
Agent-related content lives in `.agent/`: technical/agent-facing documentation goes in `.agent/docs/` (not `docs/`), and skills in `.agent/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agent/docs/`.
|
||||
|
||||
## Code comments and test names
|
||||
|
||||
Never reference items that are not active parts of the repository — no "the original bug", "regression from this fix", "this session/PR/commit", or similar ephemeral context. Comments and test names must be self-contained: describe the behaviour being asserted, not the history of how it was discovered. The one exception is a literal link to a ticket/issue tracker.
|
||||
@@ -1,3 +1,37 @@
|
||||
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* adding REGEX validations to mpe_x_test ([bd798b4](https://git.datacontroller.io/dc/dc/commit/bd798b424a7c4b90d613425348d3f99eeb49b025))
|
||||
* default value for label ([a3e46a9](https://git.datacontroller.io/dc/dc/commit/a3e46a968ef08295347b230235f1e79dfa51c5a2))
|
||||
* **deps:** retarget Angular upgrade to 20, not 21 (CI install was broken) ([cac9244](https://git.datacontroller.io/dc/dc/commit/cac9244f92544f432078125c312d05c36ed4b855))
|
||||
* ensure only one REGEX applies at a time ([8fb58eb](https://git.datacontroller.io/dc/dc/commit/8fb58eb36e05fdf7262459b05e53643b3ca672f7))
|
||||
* ensure that no assets (including og links) ever fetch from external sources ([33dcb98](https://git.datacontroller.io/dc/dc/commit/33dcb989d3ae0c5ee0a13fe64195862a945c4348))
|
||||
* include peer dependencies in package-lock for npm ci in pipeline ([b51c770](https://git.datacontroller.io/dc/dc/commit/b51c770782a8b62816e5145496dbea9fb83059e0))
|
||||
* licensecheker ([f60bcef](https://git.datacontroller.io/dc/dc/commit/f60bcef58381ba269415640fa19a27305274939a))
|
||||
* **lint:** remove redundant optional chaining ([d881290](https://git.datacontroller.io/dc/dc/commit/d881290618f25ddb6335125f1a08b5aaf56a8c48))
|
||||
* optimisation, renamed values for DDTYPE to save space ([cfb60e5](https://git.datacontroller.io/dc/dc/commit/cfb60e5e4bfd37dc79fa4e9a6c6649ab1a61f2e4))
|
||||
* patch npm audit vulnerabilities in sas and client dependencies ([e22edf7](https://git.datacontroller.io/dc/dc/commit/e22edf7ed3aa54fc08411cf90d51df9ec30877c3))
|
||||
* **query:** isolate viewbox filter state from the base table's ([7a35cf4](https://git.datacontroller.io/dc/dc/commit/7a35cf4a458791b26ec8141c0c84d97fcb555145))
|
||||
* regenerate client lockfile to resolve Angular peer-dependency drift breaking npm ci ([05fe474](https://git.datacontroller.io/dc/dc/commit/05fe4744d58e7985ecc5ef5f9c1969dbab2d2efb))
|
||||
* **regex:** special missing handling ([180c247](https://git.datacontroller.io/dc/dc/commit/180c2477ed9ae44b4e479e81ec642f40580bcbc8))
|
||||
* removing low severity warning in npm audit ([2a771bb](https://git.datacontroller.io/dc/dc/commit/2a771bb91acaaa16aba44e2d7ddb51b8b7123c10))
|
||||
* removing thousand seperator from plain numerics in EDIT mode ([0392a81](https://git.datacontroller.io/dc/dc/commit/0392a81cbd9a634590ca7a60a316cd12c690a5a5))
|
||||
* **validations:** parse SAS PRX /pattern/flags syntax in HARDREGEX/SOFTREGEX ([7ed3730](https://git.datacontroller.io/dc/dc/commit/7ed3730ae3145dffd639ce3d2f831447d78f4e85))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **docs:** adding agents.md and docs for RLS ([359d833](https://git.datacontroller.io/dc/dc/commit/359d833406ace79f96d03c08a5c72fbd3df29442))
|
||||
* **editor:** add HARDREGEX/SOFTREGEX validation rules ([17e4802](https://git.datacontroller.io/dc/dc/commit/17e48028955d7b091e53bad9d7f9e3a089b53af9))
|
||||
* **editor:** evaluate HARDREGEX/SOFTREGEX independently instead of hard-wins precedence ([57db117](https://git.datacontroller.io/dc/dc/commit/57db1179a97973ecf0f711bda6057383de546dd3))
|
||||
* **editor:** show applied HARDREGEX/SOFTREGEX pattern in column info dropdown ([39c8855](https://git.datacontroller.io/dc/dc/commit/39c8855f37477f06d8b8cf5c97e04543070e8385))
|
||||
* **regex:** backend validations on regex strings ([d2c93a4](https://git.datacontroller.io/dc/dc/commit/d2c93a46facb386fd31661c02c0d3d34f38f6e43))
|
||||
* using ALL libraries as validation in MPE_SECURITY. Closes [#279](https://git.datacontroller.io/dc/dc/issues/279) ([62ff0ae](https://git.datacontroller.io/dc/dc/commit/62ff0aee4a976184de65d87ea3c8b8b7cb777938))
|
||||
* validation checks to prevent incompatible RLS rules (eg REPLACE load type). Closes [#211](https://git.datacontroller.io/dc/dc/issues/211) ([ea00c5a](https://git.datacontroller.io/dc/dc/commit/ea00c5afad0daf2a66cdb206f2a7481b992192bf))
|
||||
* validation on RLS for REPLACE, + docs + tests. Closes [#211](https://git.datacontroller.io/dc/dc/issues/211) ([7378f3b](https://git.datacontroller.io/dc/dc/commit/7378f3ba3014141008b4dbde0b89c21a6ce02f69))
|
||||
|
||||
# [7.11.0](https://git.datacontroller.io/dc/dc/compare/v7.10.1...v7.11.0) (2026-07-20)
|
||||
|
||||
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
# Context: Data Controller for SAS®
|
||||
|
||||
Data Controller for SAS® is a web application that lets users safely add, modify and delete data in SAS datasets and databases. Every change is **staged** and **approved** before being applied to the **target table**, and the full history of the change is retained. It runs on SAS Viya, SAS 9 EBI, and [SASjs Server](https://server.sasjs.io), and must run entirely **on-prem / offline** (no external assets or network calls at runtime).
|
||||
|
||||
This file is the shared glossary and orientation for the repo. When your output names a domain concept, use the term as defined here rather than a synonym. Related repos: `docs.datacontroller.io` (user-facing docs) and `datacontroller.io` (marketing site).
|
||||
|
||||
## Repository layout
|
||||
|
||||
- `client/` - the Angular frontend (TypeScript). Uses [Handsontable](.agent/skills/handsontable/SKILL.md) for the editable grid and [HyperFormula](.agent/skills/hyperformula/SKILL.md) for Excel-formula support. Lint with `npm run lint:check` from `client/`.
|
||||
- `sas/` - the SAS backend. Services, macros, hooks and tests live under `sas/sasjs/**`; `sas/sasjsbuild/` is generated output (never hand-edit). Lint `.sas` files with `sasjs lint` from `sas/`.
|
||||
- `.agent/docs/` - technical deep-dives (see below). `.agent/skills/` - task skills (handsontable, hyperformula, sas).
|
||||
|
||||
## Roles
|
||||
|
||||
The five user roles, in increasing privilege:
|
||||
|
||||
- **Viewer** - explores/links to data without locking datasets.
|
||||
- **Editor** - makes changes (add/modify/delete) and submits them for approval.
|
||||
- **Approver** - accepts or rejects staged changes; on acceptance the change is applied to the target table.
|
||||
- **Auditor** - reviews the history of changes to a table.
|
||||
- **Administrator** - registers tables and configures security (at metadata group level).
|
||||
|
||||
Admins are listed in `&mpeadmins`; admin membership bypasses Row Level Security.
|
||||
|
||||
## Core concepts
|
||||
|
||||
- **Target table** - the physical table (SAS dataset or database table) a user is changing. Its attributes (primary key, load type, library, SCD/temporal variables) are predefined by an administrator in `MPE_TABLES`.
|
||||
- **Submission** - the set of changed rows staged for approval. Submissions are *never* applied automatically; they always require one or more approvals. Three kinds: **Web submission** (only changed rows from the edited extract), **Excel submission** (whole Excel file staged with the file kept for audit; becomes a web submission if the rows are edited in the grid first), and **CSV submission** (all rows sent straight to staging, suitable for larger uploads).
|
||||
- **Staging area** - the secure location where submitted changes wait for approval before being loaded.
|
||||
- **Edit-Stage-Approve workflow** - the central flow: up to 500 rows edited in the web grid, staged, and applied to the target after approval.
|
||||
- **Changeset / diff** - new / modified / deleted rows computed for a submission; approvers review the diff before accepting.
|
||||
|
||||
## Load types
|
||||
|
||||
Set per table in `MPE_TABLES.LOADTYPE`. Determines the loader and how history is kept (see [.agent/docs/bitemporal-dataloader.md](.agent/docs/bitemporal-dataloader.md)):
|
||||
|
||||
- **UPDATE** - no history; changed records deleted and re-appended (via `%bitemporal_dataloader`).
|
||||
- **REPLACE** - no history; whole table wiped and reloaded. Implemented inline in `%mpe_targetloader`, deliberately *not* via `%bitemporal_dataloader` (see [.agent/docs/replace-load-type.md](.agent/docs/replace-load-type.md)).
|
||||
- **TXTEMPORAL** - SCD2-style history on technical (transaction) time.
|
||||
- **BITEMPORAL** - full two-dimensional history (business time + technical time).
|
||||
- **FORMAT_CAT** - format-catalog load (via `%mp_loadformat`).
|
||||
|
||||
**Bitemporal** = two independent time dimensions: **business time** (`VAR_BUSFROM`/`VAR_BUSTO`, when a fact is true in the real world) and **technical/transaction time** (`VAR_TXFROM`/`VAR_TXTO`, when the system knew it). **SCD2** = slowly changing dimension, type 2 (row-versioned history via open/close datetimes). **BUSKEY** = the business/primary key (space-separated columns, excluding temporal columns).
|
||||
|
||||
## MPE control tables
|
||||
|
||||
Configuration and state live in `MPE_*` tables (the "control tables") in the DC control library (`&mpelib` / `&dclib`). Key ones:
|
||||
|
||||
- `MPE_TABLES` - registered target tables and their load config (`LOADTYPE`, `BUSKEY`, temporal vars, etc.).
|
||||
- `MPE_VALIDATIONS` - point-of-entry data-quality rules (see below).
|
||||
- `MPE_ROW_LEVEL_SECURITY` - Row Level Security rules.
|
||||
- `MPE_COLUMN_LEVEL_SECURITY` - Column Level Security rules.
|
||||
- `MPE_SUBMIT` / `MPE_REVIEW` / `MPE_REQUESTS` - the approval workflow tables.
|
||||
- `MPE_AUDIT` - change history.
|
||||
- `MPE_XLMAP_INFO` / `MPE_XLMAP_RULES` / `MPE_XLMAP_DATA` - Excel-map (XLMAP) definitions for structured Excel uploads.
|
||||
- `MPE_CONFIG`, `MPE_GROUPS`, `MPE_SECURITY`, `MPE_EMAIL`, `MPE_LOCKANYTABLE`, and the `MPE_DATACATALOG_*` / `MPE_DATASTATUS_*` catalog tables.
|
||||
|
||||
Selectbox seed values for these tables are defined in `sas/sasjs/macros/mpe_makedata.sas`. User-facing documentation for each table is in the `docs.datacontroller.io` repo under `docs/tables/`.
|
||||
|
||||
## Validations
|
||||
|
||||
`MPE_VALIDATIONS` applies point-of-entry rules per `BASE_LIB`/`BASE_DS`/`BASE_COL` when `RULE_ACTIVE=1`. `RULE_TYPE` values include `CASE`, `NOTNULL`, `MINVAL`, `MAXVAL`, `READONLY`, `HIDDEN`, `ROUND`, `NUMBER_FORMAT`, `HARDREGEX`, `SOFTREGEX`, `HARDSELECT`, `SOFTSELECT`, `HARDSELECT_HOOK`, `SOFTSELECT_HOOK`.
|
||||
|
||||
- **HARDREGEX** - submission-blocking regex; failing cells are painted red and cannot be submitted.
|
||||
- **SOFTREGEX** - display-only warning regex; failing cells are painted yellow but submission is allowed.
|
||||
|
||||
Regex rule values are authored in SAS PRX form `/pattern/flags` and limited to 128 chars. See [.agent/docs/regex-validations.md](.agent/docs/regex-validations.md).
|
||||
|
||||
## Security
|
||||
|
||||
- **Row Level Security (RLS)** - server-side `WHERE`-clause generation via `%mpe_filtermaster` / `%mp_filtergenerate`; no data leaves SAS without passing the filter. Works on any engine because it's expressed as a standard SAS `WHERE`. On write, the *inverse* filter is applied to reject out-of-scope rows. See [.agent/docs/row-level-security.md](.agent/docs/row-level-security.md).
|
||||
- **Column Level Security (CLS)** - restricts visibility/editability at column level.
|
||||
|
||||
## Key macros / services
|
||||
|
||||
- `%mpe_targetloader` (`sas/sasjs/macros/mpe_targetloader.sas`) - the single load dispatch point; routes by `LOADTYPE`. Two-phase: `LOADTARGET=NO` builds diff tables for review, `LOADTARGET=YES` performs the destructive load.
|
||||
- `%bitemporal_dataloader` - the temporal loader for UPDATE / TXTEMPORAL / BITEMPORAL.
|
||||
- `%mpe_filtermaster` - builds the RLS filter for a request.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests run with the sasjs CLI from `sas/`. `npm run 4gl` compiles+deploys+seeds then `sasjs test -t 4gl` runs tests. Assertions use `%mp_assert()`. See [.agent/docs/testing.md](.agent/docs/testing.md).
|
||||
|
||||
## Conventions
|
||||
|
||||
- Do not hard-wrap Markdown (one logical line per paragraph/list-item/heading).
|
||||
- Use regular dashes, not em-dashes.
|
||||
- No external assets - everything must be bundled and served locally.
|
||||
- Comments and test names must be self-contained (no "original bug", "this PR", etc.); the only exception is a literal issue-tracker link.
|
||||
- Do not auto-commit or push.
|
||||
- Never edit `CHANGELOG.md` or bump `package.json` version by hand - the release pipeline generates both from Conventional Commit messages. See [.agent/docs/releases-and-changelog.md](.agent/docs/releases-and-changelog.md).
|
||||
|
||||
See `AGENTS.md` for the full set of enforced rules (git, linting, no-wrap, no-external-assets, `.agent/` layout).
|
||||
@@ -157,5 +157,31 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -244,6 +244,294 @@ context('licensing tests: ', function () {
|
||||
})
|
||||
})
|
||||
|
||||
it('6 | pasting an HTTP-format key (identical licence/activation text) on this secure connection warns before applying', (done) => {
|
||||
visitPage('licensing/update')
|
||||
|
||||
cy.get('button').contains('Paste licence').click()
|
||||
|
||||
// Defaults to the combined single-field form - switch to the legacy
|
||||
// two-field layout, since this test targets that path specifically.
|
||||
cy.get('button').contains('Paste as two separate keys instead').click()
|
||||
|
||||
// HTTP-format keys are generated as the exact same string for both
|
||||
// fields (see detectLicenceKeyProtocolMismatch's own doc comment) -
|
||||
// any non-empty matching pair triggers the warning, no real key needed.
|
||||
// Cypress always runs against localhost, which browsers treat as a
|
||||
// secure connection even over plain http, so this is reachable without
|
||||
// stubbing anything - unlike the reverse case (an HTTPS-format key
|
||||
// where WebCrypto is unavailable), which is covered by
|
||||
// detectLicenceKeyProtocolMismatch.spec.ts's Karma tests instead.
|
||||
const httpFormatKey = 'same-text-for-both-fields'
|
||||
|
||||
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
|
||||
.invoke('val', httpFormatKey)
|
||||
.trigger('input')
|
||||
cy.get('.activation-key-form textarea', { timeout: longerCommandTimeout })
|
||||
.invoke('val', httpFormatKey)
|
||||
.trigger('input')
|
||||
|
||||
cy.get('p.protocol-mismatch-warning')
|
||||
.should('contain', 'generated for an insecure (HTTP) connection')
|
||||
.then(() => {
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('7 | Combined single-string key (default paste format) activates successfully', (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
|
||||
}
|
||||
|
||||
generateKeys(keyData, (keysGen: any) => {
|
||||
cy.wait(2000)
|
||||
|
||||
// Navigate there explicitly rather than only acting when already on
|
||||
// the licensing page - earlier tests may have left the app on it (a
|
||||
// bad key) or already activated (a good one), and this test should
|
||||
// pass either way rather than silently no-op when it's the latter.
|
||||
isLicensingPage((result: boolean) => {
|
||||
const proceed = () =>
|
||||
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
|
||||
(combinedKey) => {
|
||||
inputCombinedKeyPage(combinedKey)
|
||||
|
||||
cy.wait(2000)
|
||||
|
||||
acceptTermsIfPresented((termsResult: boolean) => {
|
||||
if (termsResult) {
|
||||
cy.wait(10000)
|
||||
}
|
||||
|
||||
visitPage('home')
|
||||
|
||||
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
done()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
// Chained via .then() rather than called as the next plain
|
||||
// statement - generateCombinedKey is a raw async function that
|
||||
// itself invokes cy commands (cy.log), and Cypress can't
|
||||
// reconcile that promise with the preceding cy.visit()/cy.wait()
|
||||
// unless it's explicitly handed off through a real command chain.
|
||||
cy.wait(2000).then(() => proceed())
|
||||
} else {
|
||||
proceed()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('8 | Pasting a combined key into the legacy licence-key field alone still auto-detects and activates', (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
|
||||
}
|
||||
|
||||
generateKeys(keyData, (keysGen: any) => {
|
||||
cy.wait(2000)
|
||||
|
||||
// Navigate there explicitly rather than only acting when already on
|
||||
// the licensing page - see test 7's own comment for why.
|
||||
isLicensingPage((result: boolean) => {
|
||||
const proceed = () =>
|
||||
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
|
||||
(combinedKey) => {
|
||||
cy.get('button').contains('Paste licence').click()
|
||||
cy.get('button')
|
||||
.contains('Paste as two separate keys instead')
|
||||
.click()
|
||||
|
||||
// Only the licence-key field gets the combined string - the
|
||||
// activation-key field is left empty, proving the split
|
||||
// populates both fields from this one paste.
|
||||
cy.get('.license-key-form textarea', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.invoke('val', combinedKey)
|
||||
.trigger('input')
|
||||
.trigger('mouseleave')
|
||||
|
||||
cy.get('.activation-key-form textarea', {
|
||||
timeout: longerCommandTimeout
|
||||
}).should(($textarea) => {
|
||||
expect($textarea.val()).to.equal(keysGen.activationKey)
|
||||
})
|
||||
|
||||
cy.get('button.apply-keys').click()
|
||||
|
||||
cy.wait(2000)
|
||||
|
||||
acceptTermsIfPresented((termsResult: boolean) => {
|
||||
if (termsResult) {
|
||||
cy.wait(10000)
|
||||
}
|
||||
|
||||
visitPage('home')
|
||||
|
||||
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
done()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// See test 7's own comment for why this is chained via .then()
|
||||
// rather than called as the next plain statement.
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
cy.wait(2000).then(() => proceed())
|
||||
} else {
|
||||
proceed()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('9 | Uploading a single-line combined-format file activates successfully', (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
|
||||
}
|
||||
|
||||
generateKeys(keyData, (keysGen: any) => {
|
||||
cy.wait(2000)
|
||||
|
||||
// Navigate there explicitly rather than only acting when already on
|
||||
// the licensing page - see test 7's own comment for why.
|
||||
isLicensingPage((result: boolean) => {
|
||||
const proceed = () =>
|
||||
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
|
||||
(combinedKey) => {
|
||||
cy.get('input[type="file"]').attachFile({
|
||||
fileContent: new Blob([combinedKey], { type: 'text/plain' }),
|
||||
fileName: 'datacontroller-licence-combined.txt',
|
||||
mimeType: 'text/plain'
|
||||
})
|
||||
|
||||
cy.get('button.apply-keys', {
|
||||
timeout: longerCommandTimeout
|
||||
}).click()
|
||||
|
||||
cy.wait(2000)
|
||||
|
||||
acceptTermsIfPresented((termsResult: boolean) => {
|
||||
if (termsResult) {
|
||||
cy.wait(10000)
|
||||
}
|
||||
|
||||
visitPage('home')
|
||||
|
||||
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
done()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// See test 7's own comment for why this is chained via .then()
|
||||
// rather than called as the next plain statement.
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
cy.wait(2000).then(() => proceed())
|
||||
} else {
|
||||
proceed()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('10 | Key details preview shows for a validly-pasted key and disappears once the key input is cleared', (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,
|
||||
// Kept to just the two enabled toggles (relying on
|
||||
// decodeLicenceFeatures' fallback for the rest) to keep the
|
||||
// encrypted payload well under this suite's RSA-OAEP plaintext
|
||||
// ceiling - see generateKeys' modulusLength below.
|
||||
features: { vb: true, fu: true }
|
||||
}
|
||||
|
||||
generateKeys(keyData, (keysGen: any) => {
|
||||
cy.wait(2000)
|
||||
|
||||
// Navigate there explicitly rather than only acting when already on
|
||||
// the licensing page - see test 7's own comment for why.
|
||||
isLicensingPage((result: boolean) => {
|
||||
const proceed = () =>
|
||||
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
|
||||
(combinedKey) => {
|
||||
cy.get('button').contains('Paste licence').click()
|
||||
|
||||
cy.get('.combined-key-form textarea', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.invoke('val', combinedKey)
|
||||
.trigger('input')
|
||||
.trigger('mouseleave')
|
||||
|
||||
cy.get('.key-details')
|
||||
.should('contain', 'Valid until:')
|
||||
.and('contain', 'Allowed users:')
|
||||
.and('contain', '4')
|
||||
.and('contain', 'Site ID(s) in this key:')
|
||||
.and('contain', 'Enabled features:')
|
||||
.and('contain', 'Viewbox')
|
||||
.and('contain', 'File Upload')
|
||||
.invoke('text')
|
||||
.then((text) => {
|
||||
expect(text).to.not.contain('Edit Record')
|
||||
expect(text).to.not.contain('Add Record')
|
||||
})
|
||||
|
||||
cy.get('.combined-key-form textarea')
|
||||
.invoke('val', '')
|
||||
.trigger('input')
|
||||
.trigger('mouseleave')
|
||||
|
||||
cy.get('.key-details')
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
done()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// See test 7's own comment for why this is chained via .then()
|
||||
// rather than called as the next plain statement.
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
cy.wait(2000).then(() => proceed())
|
||||
} else {
|
||||
proceed()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if (testLicenceUserLimits) {
|
||||
it('4 | User try to register when limit is reached', (done) => {
|
||||
let keyData = {
|
||||
@@ -417,7 +705,7 @@ const verifyLicensingPage = (text: string, callback: any) => {
|
||||
cy.wait(1000)
|
||||
isLicensingPage((result: boolean) => {
|
||||
if (result) {
|
||||
cy.get('p.key-error')
|
||||
cy.get('.key-error')
|
||||
.should('contain', text)
|
||||
.then((treeNodes: any) => {
|
||||
callback(true)
|
||||
@@ -441,6 +729,10 @@ const verifyLicensingWarning = (text: string, callback: any) => {
|
||||
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
||||
cy.get('button').contains('Paste licence').click()
|
||||
|
||||
// Defaults to the combined single-field form - this helper exercises the
|
||||
// two-part path specifically, so switch to it first.
|
||||
cy.get('button').contains('Paste as two separate keys instead').click()
|
||||
|
||||
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
|
||||
.invoke('val', licenseKey)
|
||||
.trigger('input')
|
||||
@@ -452,6 +744,30 @@ const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
||||
cy.get('button.apply-keys').click()
|
||||
}
|
||||
|
||||
const inputCombinedKeyPage = (combinedKey: string) => {
|
||||
cy.get('button').contains('Paste licence').click()
|
||||
|
||||
// Combined is the default paste format - no toggle click needed here,
|
||||
// unlike inputLicenseKeyPage's legacy two-field path.
|
||||
cy.get('.combined-key-form textarea', { timeout: longerCommandTimeout })
|
||||
.invoke('val', combinedKey)
|
||||
.trigger('input')
|
||||
.trigger('mouseleave')
|
||||
.should('not.be.undefined')
|
||||
|
||||
// mouseleave's handler (onCombinedKeyInput) does async work - gzip
|
||||
// decompress the split, then decrypt for the key-details preview -
|
||||
// before licenceKeyValue/activationKeyValue are ready to submit.
|
||||
// Wait for that preview to land rather than racing "Apply licence
|
||||
// keys" against the still-in-flight promise.
|
||||
cy.get('.key-details', { timeout: longerCommandTimeout }).should(
|
||||
'contain',
|
||||
'Valid until:'
|
||||
)
|
||||
|
||||
cy.get('button.apply-keys').click()
|
||||
}
|
||||
|
||||
const updateUsersTable = (options: any, callback?: any) => {
|
||||
visitPage('home')
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_users')
|
||||
@@ -596,6 +912,62 @@ const generateKeys = async (licenseData: any, resultCallback?: any) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Mirrors dckey's own encodeCombinedKey()/gzipCompress() (main.js) and
|
||||
// DC's own splitCombinedLicenceKey() decode side, so this stays a genuine
|
||||
// end-to-end check of the real format rather than a fixture the app
|
||||
// happens to accept.
|
||||
const COMBINED_KEY_PREFIX = 'DCKEY1:'
|
||||
|
||||
// Reads a ReadableStream directly via its own reader, rather than through
|
||||
// `new Response(readable).arrayBuffer()` - Cypress patches fetch/Response
|
||||
// globally for its network-interception features, which appears to hang
|
||||
// a Response built locally around a stream that never touches the network
|
||||
// (writer.write()/close() resolve fine; only the Response-based read never
|
||||
// settles).
|
||||
const streamToArrayBuffer = async (
|
||||
readable: ReadableStream<Uint8Array>
|
||||
): Promise<ArrayBuffer> => {
|
||||
const reader = readable.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let totalLength = 0
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
totalLength += value.length
|
||||
}
|
||||
|
||||
const result = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
return result.buffer
|
||||
}
|
||||
|
||||
const generateCombinedKey = async (
|
||||
licenseKey: string,
|
||||
activationKey: string
|
||||
): Promise<string> => {
|
||||
const payloadBytes = new TextEncoder().encode(
|
||||
`${licenseKey} ${activationKey}`
|
||||
)
|
||||
|
||||
const compressionStream = new CompressionStream('gzip')
|
||||
const writer = compressionStream.writable.getWriter()
|
||||
const [, compressedBuffer] = await Promise.all([
|
||||
writer.write(payloadBytes).then(() => writer.close()),
|
||||
streamToArrayBuffer(compressionStream.readable)
|
||||
])
|
||||
|
||||
const compressedBase64 = await arrayBufferToBase64(compressedBuffer)
|
||||
|
||||
return COMBINED_KEY_PREFIX + compressedBase64
|
||||
}
|
||||
|
||||
const editTableField = (edits: EditConfigTableCells[], callback?: any) => {
|
||||
cy.get('td').then((tdNodes: any) => {
|
||||
for (let edit of edits) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const hostUrl = Cypress.env('hosturl')
|
||||
const appLocation = Cypress.env('appLocation')
|
||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||
|
||||
context('stage tests: ', function () {
|
||||
this.beforeAll(() => {
|
||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||
cy.loginAndUpdateValidKey()
|
||||
})
|
||||
|
||||
this.beforeEach(() => {
|
||||
cy.visit(hostUrl + appLocation)
|
||||
|
||||
visitPage('stage/DC20221007T122326121_612316_7259')
|
||||
})
|
||||
|
||||
// getstagetable's mock ignores the table_id param entirely, so any id in
|
||||
// the URL resolves to the same fixture row - no need to submit a real
|
||||
// table first just to reach this page.
|
||||
it('1 | Formatted/Unformatted toggle switches between fmt_stagetable and stagetable, defaulting to formatted', () => {
|
||||
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
||||
'not.exist'
|
||||
)
|
||||
|
||||
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '12FEB1960')
|
||||
|
||||
cy.get('.formatted-values-toggle').click()
|
||||
|
||||
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '42')
|
||||
|
||||
// Toggling back reverts to the formatted view.
|
||||
cy.get('.formatted-values-toggle').click()
|
||||
|
||||
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '12FEB1960')
|
||||
})
|
||||
})
|
||||
|
||||
const visitPage = (url: string) => {
|
||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||
}
|
||||
|
||||
// Locates a body cell by its column's header text rather than a hardcoded
|
||||
// childNodes index - same helper as editor.cy.ts's own.
|
||||
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))
|
||||
})
|
||||
}
|
||||
+572
-238
@@ -41,16 +41,13 @@ context('editor tests: ', function () {
|
||||
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
|
||||
}
|
||||
cy.contains('.open-viewbox', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
checkColumns(columns, () => {
|
||||
done()
|
||||
})
|
||||
}
|
||||
checkColumns(columns, () => {
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,48 +75,21 @@ context('editor tests: ', function () {
|
||||
libraryToOpenIncludes,
|
||||
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) {
|
||||
for (let viewbox of viewboxes) {
|
||||
if (
|
||||
viewboxNode.innerText.toLowerCase().includes(viewbox.viewbox_table)
|
||||
)
|
||||
found++
|
||||
}
|
||||
}
|
||||
let result = 0
|
||||
|
||||
if (found < viewboxes.length) return
|
||||
viewboxes.forEach((viewbox) => {
|
||||
checkColumns(viewbox.columns, () => {
|
||||
result++
|
||||
|
||||
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).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()
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
if (result === viewboxes.length) done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -133,22 +103,19 @@ context('editor tests: ', function () {
|
||||
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
|
||||
}
|
||||
cy.contains('.open-viewbox', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
openViewboxConfig(viewbox_table)
|
||||
openViewboxConfig(viewbox_table)
|
||||
|
||||
removeAllColumns()
|
||||
removeAllColumns()
|
||||
|
||||
addColumns(additionalColumns)
|
||||
addColumns(additionalColumns)
|
||||
|
||||
checkColumns([...columns, ...additionalColumns], () => {
|
||||
done()
|
||||
})
|
||||
}
|
||||
checkColumns([...columns, ...additionalColumns], () => {
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -162,35 +129,32 @@ context('editor tests: ', function () {
|
||||
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
|
||||
}
|
||||
cy.contains('.open-viewbox', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
openViewboxConfig(viewbox_table)
|
||||
openViewboxConfig(viewbox_table)
|
||||
|
||||
removeAllColumns()
|
||||
removeAllColumns()
|
||||
|
||||
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
|
||||
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)
|
||||
cy.wait(500)
|
||||
|
||||
checkColumns([...columns, ...additionalColumns.reverse()], () => {
|
||||
done()
|
||||
})
|
||||
})
|
||||
}
|
||||
checkColumns([...columns, ...additionalColumns.reverse()], () => {
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -204,53 +168,47 @@ context('editor tests: ', function () {
|
||||
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
|
||||
}
|
||||
cy.contains('.open-viewbox', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
viewboxNode.click()
|
||||
openViewboxConfig(viewbox_table)
|
||||
|
||||
removeAllColumns()
|
||||
removeAllColumns()
|
||||
|
||||
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
|
||||
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)
|
||||
cy.wait(500)
|
||||
|
||||
checkColumns([...columns, ...additionalColumns.reverse()], () => {
|
||||
const colToRemove = 'MOVE_TYPE'
|
||||
checkColumns([...columns, ...additionalColumns.reverse()], () => {
|
||||
const colToRemove = 'MOVE_TYPE'
|
||||
|
||||
removeColumn(colToRemove)
|
||||
checkColumns(
|
||||
[
|
||||
...columns,
|
||||
...additionalColumns.filter((col) => col !== colToRemove)
|
||||
],
|
||||
() => {
|
||||
addColumns([colToRemove], () => {
|
||||
checkColumns(
|
||||
[...columns, ...additionalColumns.reverse()],
|
||||
() => {
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
removeColumn(colToRemove)
|
||||
checkColumns(
|
||||
[
|
||||
...columns,
|
||||
...additionalColumns.filter((col) => col !== colToRemove)
|
||||
],
|
||||
() => {
|
||||
addColumns([colToRemove], () => {
|
||||
checkColumns([...columns, ...additionalColumns.reverse()], () => {
|
||||
done()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -333,68 +291,408 @@ context('editor tests: ', function () {
|
||||
})
|
||||
})
|
||||
|
||||
// We will enable this test when we figure out how to mock filtering
|
||||
// it('7 | Add viewboxes and filter', () => {
|
||||
// const viewboxes = ['mpe_x_test', 'mpe_validations']
|
||||
// The base VIEW table and a viewbox on a different table must never
|
||||
// share a filter dialog's cached state - each keeps its own entry in
|
||||
// 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()
|
||||
// openTableFromViewboxTree(libraryToOpenIncludes, viewboxes)
|
||||
// Filter the base table.
|
||||
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(
|
||||
// (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()
|
||||
// })
|
||||
cy.get('.filter-modal code.language-sql', {
|
||||
withinSubject: null
|
||||
}).should(($code) => {
|
||||
expect($code.text()).to.not.contain('SOME_CHAR')
|
||||
})
|
||||
|
||||
// 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]
|
||||
// Reverse direction: filter the viewbox, then confirm
|
||||
// re-opening the base table's own filter dialog still shows
|
||||
// the base table's filter, not the viewbox's.
|
||||
setFilterWithValue('LIBREF', 'sasdemo', 'value', () => {
|
||||
cy.get('app-query', { withinSubject: null }).should('not.exist')
|
||||
|
||||
// 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]
|
||||
openViewFilter()
|
||||
|
||||
// expect(rowNodes).to.have.length(1)
|
||||
// expect(tr.innerText).to.contain('ALERT_LIB')
|
||||
// })
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// })
|
||||
cy.get('.filter-modal code.language-sql', {
|
||||
withinSubject: null
|
||||
}).should(($code) => {
|
||||
expect($code.text()).to.contain('SOME_CHAR')
|
||||
expect($code.text()).to.not.contain('LIBREF')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('8 | Dragging a viewbox by its corner handle resizes it', () => {
|
||||
const viewbox_table = 'mpe_audit'
|
||||
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
cy.get('.viewbox-open').click()
|
||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
||||
|
||||
cy.contains('.open-viewbox', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
// The Viewboxes picker modal is still open at this point, its backdrop
|
||||
// sitting above .viewboxes-container - a real click (unlike cy.find(),
|
||||
// which doesn't care what's visually on top) would land on the modal
|
||||
// instead of the handle underneath it, silently no-opping the drag.
|
||||
closeViewboxModal()
|
||||
|
||||
// Re-query by the title text rather than caching a reference, since
|
||||
// the viewbox can still be re-rendering its content right after
|
||||
// opening.
|
||||
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.parents('.viewbox')
|
||||
.first()
|
||||
.as('viewboxEl')
|
||||
|
||||
// tableOnClick() calls snapToGrid() asynchronously right after its data
|
||||
// load resolves, which overwrites width/height/x/y for every open
|
||||
// viewbox via the [style.width.px]/[style.height.px] bindings - totally
|
||||
// independent of any drag. The table title above renders immediately
|
||||
// (before that data load finishes), so capturing initialRect right
|
||||
// after it appears races snapToGrid: the box can still silently resize
|
||||
// itself out from under the test between capturing initialRect and the
|
||||
// drag. Waiting for the table's own header cells (populated only once
|
||||
// that same data load - and so snapToGrid - has completed) closes that
|
||||
// race, same signal checkColumns() uses elsewhere in this file.
|
||||
cy.get('@viewboxEl').find('.ht_master.handsontable thead tr th', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
cy.get('@viewboxEl').then(($box) => {
|
||||
const initialRect = $box[0].getBoundingClientRect()
|
||||
|
||||
// Chained off one query, not re-queried between each step, so
|
||||
// cypress-real-events keeps operating on the same subject throughout.
|
||||
//
|
||||
// Dragging INWARD (negative offsets, shrinking) rather than outward:
|
||||
// a freshly opened viewbox starts flush against the right edge of the
|
||||
// viewport (x: window.innerWidth - defaultConfig.width, so
|
||||
// box.right === innerWidth exactly). realMouseMove's target
|
||||
// coordinate is computed as an absolute point on the page, so dragging
|
||||
// the corner outward/rightward to grow the box would target a point
|
||||
// past the viewport's right edge, outside the Cypress AUT iframe
|
||||
// entirely - Chrome never delivers a move event for a point outside
|
||||
// the iframe. Shrinking stays well within the box's own footprint
|
||||
// (500x300 default, 200x200 min per .viewbox's CSS), so the target
|
||||
// coordinate is always safely on-screen - same resize() code path,
|
||||
// just exercised in the direction that's actually simulatable.
|
||||
cy.get('@viewboxEl')
|
||||
.find('.dragHandle.corner')
|
||||
.realMouseDown({ button: 'left', position: 'center' })
|
||||
.realMouseMove(-100, -80, { position: 'topLeft' })
|
||||
.realMouseUp()
|
||||
|
||||
// A generous threshold, not an exact pixel match - real rendering has
|
||||
// its own small offsets (see setHandleTransform's "fine tune" +5).
|
||||
// The point is confirming the box actually resizes at all in response
|
||||
// to the drag.
|
||||
cy.get('@viewboxEl').should(($box) => {
|
||||
const rect = $box[0].getBoundingClientRect()
|
||||
|
||||
expect(rect.width, 'width after resize').to.be.lessThan(
|
||||
initialRect.width - 30
|
||||
)
|
||||
expect(rect.height, 'height after resize').to.be.lessThan(
|
||||
initialRect.height - 30
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('9 | Dragging a viewbox by its right-edge handle resizes width only', () => {
|
||||
const viewbox_table = 'mpe_audit'
|
||||
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
cy.get('.viewbox-open').click()
|
||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
||||
|
||||
cy.contains('.open-viewbox', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
closeViewboxModal()
|
||||
|
||||
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.parents('.viewbox')
|
||||
.first()
|
||||
.as('viewboxEl')
|
||||
|
||||
// See test 8's comment: snapToGrid() resizes every open viewbox
|
||||
// asynchronously right after its data load resolves, racing initialRect
|
||||
// capture if that happens right after the title (which renders before
|
||||
// the load finishes) appears. Wait for the table's own header cells
|
||||
// first, since those only populate once that same load has completed.
|
||||
cy.get('@viewboxEl').find('.ht_master.handsontable thead tr th', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
cy.get('@viewboxEl').then(($box) => {
|
||||
const initialRect = $box[0].getBoundingClientRect()
|
||||
|
||||
// Chained off one query - see test 8's comment on why re-querying
|
||||
// between down/move/up silently drops the move. Dragging INWARD
|
||||
// (negative offset, shrinking) rather than outward - see test 8's
|
||||
// comment: a freshly opened viewbox starts flush against the
|
||||
// viewport's right edge, so growing rightward would target a point
|
||||
// past the AUT iframe's edge, which Chrome never delivers a move
|
||||
// event for.
|
||||
cy.get('@viewboxEl')
|
||||
.find('.dragHandle.right')
|
||||
.realMouseDown({ button: 'left', position: 'center' })
|
||||
.realMouseMove(-100, 0, { position: 'topLeft' })
|
||||
.realMouseUp()
|
||||
|
||||
cy.get('@viewboxEl').should(($box) => {
|
||||
const rect = $box[0].getBoundingClientRect()
|
||||
|
||||
expect(rect.width, 'width after resize').to.be.lessThan(
|
||||
initialRect.width - 30
|
||||
)
|
||||
// This handle must only affect width - height (and the box's own
|
||||
// left edge) stay put.
|
||||
expect(rect.height, 'height unchanged').to.be.closeTo(
|
||||
initialRect.height,
|
||||
5
|
||||
)
|
||||
expect(rect.left, 'left edge unchanged').to.be.closeTo(
|
||||
initialRect.left,
|
||||
5
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('10 | Dragging a viewbox by its bottom-left corner handle resizes it and moves its left edge', () => {
|
||||
const viewbox_table = 'mpe_audit'
|
||||
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
cy.get('.viewbox-open').click()
|
||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
||||
|
||||
cy.contains('.open-viewbox', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
closeViewboxModal()
|
||||
|
||||
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.parents('.viewbox')
|
||||
.first()
|
||||
.as('viewboxEl')
|
||||
|
||||
// See test 8's comment: wait for snapToGrid()'s async resize to settle
|
||||
// before capturing initialRect, or the box can silently change size out
|
||||
// from under the test.
|
||||
cy.get('@viewboxEl').find('.ht_master.handsontable thead tr th', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
cy.get('@viewboxEl').then(($box) => {
|
||||
const initialRect = $box[0].getBoundingClientRect()
|
||||
|
||||
// Chained off one query - see test 8's comment on why re-querying
|
||||
// between down/move/up silently drops the move. Dragging this
|
||||
// handle right+down (only positive offsets are meaningful for
|
||||
// realMouseMove's position-anchored coordinates) shrinks width
|
||||
// while moving the box's own left edge right by the same amount,
|
||||
// and grows height - unlike every other handle here, this one is
|
||||
// expected to move the box (viewbox.x) as well as resize it, which
|
||||
// is the more error-prone half of this handler to cover.
|
||||
cy.get('@viewboxEl')
|
||||
.find('.dragHandle.corner-left')
|
||||
.realMouseDown({ button: 'left', position: 'center' })
|
||||
.realMouseMove(60, 80, { position: 'topLeft' })
|
||||
.realMouseUp()
|
||||
|
||||
cy.get('@viewboxEl').should(($box) => {
|
||||
const rect = $box[0].getBoundingClientRect()
|
||||
|
||||
expect(rect.width, 'width after resize').to.be.lessThan(
|
||||
initialRect.width - 30
|
||||
)
|
||||
expect(rect.left, 'left edge moved right').to.be.greaterThan(
|
||||
initialRect.left + 30
|
||||
)
|
||||
expect(rect.height, 'height after resize').to.be.greaterThan(
|
||||
initialRect.height + 30
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('11 | Dragging a viewbox by its left-edge handle resizes width and moves its left edge, leaving height alone', () => {
|
||||
const viewbox_table = 'mpe_audit'
|
||||
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
cy.get('.viewbox-open').click()
|
||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
||||
|
||||
cy.contains('.open-viewbox', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
closeViewboxModal()
|
||||
|
||||
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.parents('.viewbox')
|
||||
.first()
|
||||
.as('viewboxEl')
|
||||
|
||||
// See test 8's comment: wait for snapToGrid()'s async resize to settle
|
||||
// before capturing initialRect, or the box can silently change size out
|
||||
// from under the test.
|
||||
cy.get('@viewboxEl').find('.ht_master.handsontable thead tr th', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
cy.get('@viewboxEl').then(($box) => {
|
||||
const initialRect = $box[0].getBoundingClientRect()
|
||||
|
||||
// Chained off one query - see test 8's comment on why re-querying
|
||||
// between down/move/up silently drops the move. Dragging this
|
||||
// handle right (only positive offsets are meaningful for
|
||||
// realMouseMove's position-anchored coordinates) shrinks width
|
||||
// while moving the box's own left edge right by the same amount -
|
||||
// same viewbox.x-moving behavior as the bottom-left corner, minus
|
||||
// the height change.
|
||||
cy.get('@viewboxEl')
|
||||
.find('.dragHandle.left')
|
||||
.realMouseDown({ button: 'left', position: 'center' })
|
||||
.realMouseMove(60, 0, { position: 'topLeft' })
|
||||
.realMouseUp()
|
||||
|
||||
cy.get('@viewboxEl').should(($box) => {
|
||||
const rect = $box[0].getBoundingClientRect()
|
||||
|
||||
expect(rect.width, 'width after resize').to.be.lessThan(
|
||||
initialRect.width - 30
|
||||
)
|
||||
expect(rect.left, 'left edge moved right').to.be.greaterThan(
|
||||
initialRect.left + 30
|
||||
)
|
||||
expect(rect.height, 'height unchanged').to.be.closeTo(
|
||||
initialRect.height,
|
||||
5
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('12 | Dragging a viewbox by its bottom-edge handle resizes height only', () => {
|
||||
const viewbox_table = 'mpe_audit'
|
||||
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
cy.get('.viewbox-open').click()
|
||||
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
|
||||
|
||||
cy.contains('.open-viewbox', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
closeViewboxModal()
|
||||
|
||||
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.parents('.viewbox')
|
||||
.first()
|
||||
.as('viewboxEl')
|
||||
|
||||
// See test 8's comment: wait for snapToGrid()'s async resize to settle
|
||||
// before capturing initialRect, or the box can silently change size out
|
||||
// from under the test.
|
||||
cy.get('@viewboxEl').find('.ht_master.handsontable thead tr th', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
|
||||
cy.get('@viewboxEl').then(($box) => {
|
||||
const initialRect = $box[0].getBoundingClientRect()
|
||||
|
||||
// Chained off one query - see test 8's comment on why re-querying
|
||||
// between down/move/up silently drops the move.
|
||||
cy.get('@viewboxEl')
|
||||
.find('.dragHandle.bottom')
|
||||
.realMouseDown({ button: 'left', position: 'center' })
|
||||
.realMouseMove(0, 100, { position: 'topLeft' })
|
||||
.realMouseUp()
|
||||
|
||||
cy.get('@viewboxEl').should(($box) => {
|
||||
const rect = $box[0].getBoundingClientRect()
|
||||
|
||||
expect(rect.height, 'height after resize').to.be.greaterThan(
|
||||
initialRect.height + 30
|
||||
)
|
||||
// This handle must only affect height - width (and the box's own
|
||||
// left edge) stay put.
|
||||
expect(rect.width, 'width unchanged').to.be.closeTo(
|
||||
initialRect.width,
|
||||
5
|
||||
)
|
||||
expect(rect.left, 'left edge unchanged').to.be.closeTo(
|
||||
initialRect.left,
|
||||
5
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const removeAllColumns = () => {
|
||||
@@ -407,32 +705,40 @@ const removeAllColumns = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }).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]
|
||||
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)
|
||||
|
||||
if (
|
||||
!colNode.innerHTML.toLowerCase().includes(col.toLowerCase())
|
||||
)
|
||||
return
|
||||
}
|
||||
return columns.every((col, i) =>
|
||||
(headerCells[i]?.innerHTML || '')
|
||||
.toLowerCase()
|
||||
.includes(col.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
callback()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
expect(matches, `a viewbox showing columns: ${columns.join(', ')}`).to.be
|
||||
.true
|
||||
}
|
||||
)
|
||||
|
||||
cy.then(() => callback())
|
||||
}
|
||||
|
||||
const closeViewboxModal = () => {
|
||||
@@ -444,9 +750,7 @@ const removeColumn = (column: string) => {
|
||||
}
|
||||
|
||||
const addColumns = (columns: string[], callback?: () => void) => {
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const column = columns[i]
|
||||
|
||||
for (const column of columns) {
|
||||
cy.get('.cols-search input', { withinSubject: null }).type(column)
|
||||
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
|
||||
.first()
|
||||
@@ -454,51 +758,81 @@ const addColumns = (columns: string[], callback?: () => void) => {
|
||||
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
|
||||
.first()
|
||||
.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) => {
|
||||
cy.get('.open-viewbox').then((viewboxes: any) => {
|
||||
for (let openViewbox of viewboxes) {
|
||||
if (openViewbox.innerText.toLowerCase().includes(viewbox_tablename))
|
||||
openViewbox.click()
|
||||
}
|
||||
})
|
||||
cy.contains('.open-viewbox', viewbox_tablename, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
}).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
|
||||
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
||||
|
||||
for (let node of treeNodes) {
|
||||
if (new RegExp(libNameIncludes).test(node.innerText.toLowerCase())) {
|
||||
viyaLib = node
|
||||
break
|
||||
}
|
||||
}
|
||||
// Small settle wait: right after a fresh visit (an extra navigation on
|
||||
// top of beforeEach's own, e.g. test 7's visitPage('view/data')), the
|
||||
// tree component can still be re-rendering, causing a node to be found
|
||||
// then swapped out mid-click - same fix already proven in
|
||||
// viewer-labels.cy.ts.
|
||||
cy.wait(300)
|
||||
|
||||
cy.get(viyaLib).within(() => {
|
||||
cy.get('.clr-tree-node-content-container p').click()
|
||||
const libraryNodeSelector = '.nav-tree clr-tree > clr-tree-node'
|
||||
const libraryMatcher = new RegExp(libNameIncludes, 'i')
|
||||
|
||||
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
||||
for (let innerNode of innerNodes) {
|
||||
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
||||
innerNode.click()
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
cy.contains(libraryNodeSelector, libraryMatcher, {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.find('.clr-tree-node-content-container p')
|
||||
.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 = (
|
||||
|
||||
@@ -183,6 +183,12 @@ const isLicensingPage = (callback: any) => {
|
||||
|
||||
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
||||
cy.get('button').contains('Paste licence').click()
|
||||
|
||||
// Combined single-string paste is the default format now - switch to
|
||||
// the legacy two-field layout, since this helper fills licenseKey and
|
||||
// activationKey as two separate values.
|
||||
cy.get('button').contains('Paste as two separate keys instead').click()
|
||||
|
||||
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
|
||||
.invoke('val', licenseKey)
|
||||
.trigger('input')
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
import * as base64Converter from 'base64-arraybuffer'
|
||||
|
||||
export const base64ToArrayBuffer = (base64: string) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const dataUrl = "data:application/octet-binary;base64," + base64;
|
||||
|
||||
const dataUrl = 'data:application/octet-binary;base64,' + base64
|
||||
|
||||
fetch(dataUrl)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(buffer => {
|
||||
.then((res) => res.arrayBuffer())
|
||||
.then((buffer) => {
|
||||
resolve(new Uint8Array(buffer))
|
||||
}).catch((err) => {
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const arrayBufferToBase64 = (arrayBuffer: any) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blob = new Blob([arrayBuffer])
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async function(event){
|
||||
if (event.target) {
|
||||
var base64: any = event.target.result
|
||||
base64 = base64.substring(37, base64.length)
|
||||
|
||||
resolve(base64)
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
}
|
||||
// Blob + FileReader.readAsDataURL (a natural alternative here) is
|
||||
// unreliable in the Cypress runner's iframe - onload/onerror can simply
|
||||
// never fire, leaving the promise permanently unsettled (a silent hang,
|
||||
// surfacing only as Mocha's "done() was never invoked" 30s later with no
|
||||
// clue why). base64-arraybuffer's encode() is synchronous and
|
||||
// dependency-free - no browser API involved, so this class of hang isn't
|
||||
// possible - and it's the same library the app itself uses for this exact
|
||||
// conversion.
|
||||
export const arrayBufferToBase64 = (
|
||||
arrayBuffer: ArrayBuffer
|
||||
): Promise<string> => {
|
||||
return Promise.resolve(base64Converter.encode(arrayBuffer))
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ const check = (cwd) => {
|
||||
start: cwd,
|
||||
excludePrivatePackages: true,
|
||||
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:
|
||||
'@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'
|
||||
},
|
||||
|
||||
Generated
+6272
-6491
File diff suppressed because it is too large
Load Diff
+35
-23
@@ -41,28 +41,30 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.2.20",
|
||||
"@angular/cdk": "^19.2.19",
|
||||
"@angular/common": "^19.2.20",
|
||||
"@angular/compiler": "^19.2.20",
|
||||
"@angular/core": "^19.2.20",
|
||||
"@angular/forms": "^19.2.20",
|
||||
"@angular/platform-browser": "^19.2.20",
|
||||
"@angular/platform-browser-dynamic": "^19.2.20",
|
||||
"@angular/router": "^19.2.20",
|
||||
"@angular/animations": "^20.3.27",
|
||||
"@angular/cdk": "^20.2.14",
|
||||
"@angular/common": "^20.3.27",
|
||||
"@angular/compiler": "^20.3.27",
|
||||
"@angular/core": "^20.3.27",
|
||||
"@angular/forms": "^20.3.27",
|
||||
"@angular/platform-browser": "^20.3.27",
|
||||
"@angular/platform-browser-dynamic": "^20.3.27",
|
||||
"@angular/router": "^20.3.27",
|
||||
"@cds/core": "^6.15.1",
|
||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||
"@clr/icons": "^13.0.2",
|
||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||
"@handsontable/angular-wrapper": "^18.0.0",
|
||||
"@sasjs/adapter": "^4.17.0",
|
||||
"@sasjs/utils": "^3.5.3",
|
||||
"@sasjs/adapter": "^4.17.3",
|
||||
"@sasjs/utils": "^3.5.9",
|
||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||
"@types/d3-graphviz": "^2.6.7",
|
||||
"@types/text-encoding": "0.0.35",
|
||||
"base64-arraybuffer": "^0.2.0",
|
||||
"browserify-cipher": "^1.0.1",
|
||||
"buffer": "^5.4.3",
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"create-hash": "^1.2.0",
|
||||
"create-hmac": "^1.1.7",
|
||||
"crypto-js": "^4.2.0",
|
||||
"d3-graphviz": "^5.0.2",
|
||||
"exceljs": "^4.4.0",
|
||||
@@ -77,8 +79,8 @@
|
||||
"moment": "^2.30.1",
|
||||
"ngx-clipboard": "^16.0.0",
|
||||
"ngx-json-viewer": "file:libraries/ngx-json-viewer-3.2.1.tgz",
|
||||
"nodejs": "0.0.0",
|
||||
"os-browserify": "0.3.0",
|
||||
"randombytes": "^2.1.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"save-svg-as-png": "^1.4.17",
|
||||
"stream-browserify": "3.0.0",
|
||||
@@ -91,29 +93,32 @@
|
||||
"zone.js": "~0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.24",
|
||||
"@angular-devkit/build-angular": "^20.3.32",
|
||||
"@angular-eslint/builder": "19.8.1",
|
||||
"@angular-eslint/eslint-plugin": "19.8.1",
|
||||
"@angular-eslint/eslint-plugin-template": "19.8.1",
|
||||
"@angular-eslint/schematics": "19.8.1",
|
||||
"@angular-eslint/template-parser": "19.8.1",
|
||||
"@angular/cli": "^19.2.24",
|
||||
"@angular/compiler-cli": "^19.2.20",
|
||||
"@angular/cli": "^20.3.32",
|
||||
"@angular/compiler-cli": "^20.3.27",
|
||||
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||
"@compodoc/compodoc": "^1.2.1",
|
||||
"@compodoc/compodoc": "^2.0.0",
|
||||
"@cypress/webpack-preprocessor": "^5.17.1",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@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/es6-shim": "^0.31.39",
|
||||
"@types/jasmine": "~5.1.4",
|
||||
"@types/lodash-es": "^4.17.3",
|
||||
"@types/marked": "^4.3.0",
|
||||
"@types/node": "12.20.50",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.1",
|
||||
"@typescript-eslint/parser": "8.31.1",
|
||||
"@types/randombytes": "^2.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.65.0",
|
||||
"@typescript-eslint/parser": "8.65.0",
|
||||
"core-js": "^2.5.4",
|
||||
"cypress": "^15.14.2",
|
||||
"cypress": "^15.19.0",
|
||||
"cypress-file-upload": "^5.0.8",
|
||||
"cypress-plugin-tab": "^1.0.5",
|
||||
"cypress-real-events": "^1.8.1",
|
||||
@@ -135,13 +140,20 @@
|
||||
"rimraf": "3.0.2",
|
||||
"ts-loader": "^9.2.8",
|
||||
"ts-node": "^3.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"wait-on": "^6.0.1",
|
||||
"typescript": "~5.9.3",
|
||||
"wait-on": "^9.0.10",
|
||||
"watch": "^1.0.2"
|
||||
},
|
||||
"overrides": {
|
||||
"ajv": "8.18.0",
|
||||
"uuid": "11.1.1",
|
||||
"lighthouse": "13.4.0"
|
||||
"lighthouse": "13.4.0",
|
||||
"readdir-glob": {
|
||||
"brace-expansion": "^5.0.9"
|
||||
},
|
||||
"exceljs": {
|
||||
"archiver": "^8.0.0",
|
||||
"unzipper": "^0.12.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+281
-283
@@ -1,292 +1,287 @@
|
||||
<div class="main-container">
|
||||
<ng-container *ngIf="!router.url.includes('licensing')">
|
||||
<div
|
||||
*ngIf="
|
||||
freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)
|
||||
"
|
||||
class="alert alert-app-level alert-warning"
|
||||
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
|
||||
@if (!router.url.includes('licensing')) {
|
||||
@if (freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)) {
|
||||
<div
|
||||
class="alert alert-app-level alert-warning"
|
||||
id="demo-banner"
|
||||
role="alert"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="appOverCapacity"
|
||||
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>
|
||||
@if (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>
|
||||
|
||||
<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>
|
||||
|
||||
<a
|
||||
*ngIf="!licenseExpiringDays && !freeTierBanner"
|
||||
routerLink="/licensing/update"
|
||||
class="update-key"
|
||||
>Update Licence Key</a
|
||||
>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<header class="app-header" *ngIf="!embed">
|
||||
<!-- <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"
|
||||
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">
|
||||
<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"
|
||||
<a routerLink="/licensing/update" class="update-key"
|
||||
>Update Licence Key</a
|
||||
>
|
||||
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>
|
||||
}
|
||||
@if (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
|
||||
>
|
||||
}
|
||||
</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
|
||||
>
|
||||
}
|
||||
@if (licenseExpiringDays && !freeTierBanner) {
|
||||
<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">
|
||||
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>
|
||||
@if (!freeTierBanner) {
|
||||
<a routerLink="/licensing/update" class="update-key"
|
||||
>Update Licence Key</a
|
||||
>
|
||||
}
|
||||
</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>
|
||||
</header>
|
||||
<nav
|
||||
*ngIf="
|
||||
!embed &&
|
||||
(router.url.includes('submitted') ||
|
||||
router.url.includes('approve') ||
|
||||
router.url.includes('history'))
|
||||
"
|
||||
class="subnav"
|
||||
>
|
||||
<ul class="nav">
|
||||
<li class="nav-item">
|
||||
<a
|
||||
[routerLink]="['/review/submitted']"
|
||||
class="nav-link nav-text"
|
||||
routerLinkActive="active"
|
||||
>SUBMIT</a
|
||||
@if (!embed) {
|
||||
<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> -->
|
||||
@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"
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
[routerLink]="['/review/approve']"
|
||||
class="nav-link nav-text"
|
||||
[class.active]="router.url.includes('approve')"
|
||||
routerLinkActive="active"
|
||||
>APPROVE</a
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
[routerLink]="['/review/history']"
|
||||
class="nav-link nav-text"
|
||||
routerLinkActive="active"
|
||||
>HISTORY</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<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('history'))
|
||||
) {
|
||||
<nav class="subnav">
|
||||
<ul class="nav">
|
||||
<li class="nav-item">
|
||||
<a
|
||||
[routerLink]="['/review/submitted']"
|
||||
class="nav-link nav-text"
|
||||
routerLinkActive="active"
|
||||
>SUBMIT</a
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
[routerLink]="['/review/approve']"
|
||||
class="nav-link nav-text"
|
||||
[class.active]="router.url.includes('approve')"
|
||||
routerLinkActive="active"
|
||||
>APPROVE</a
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
[routerLink]="['/review/history']"
|
||||
class="nav-link nav-text"
|
||||
routerLinkActive="active"
|
||||
>HISTORY</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
|
||||
<app-alerts *ngIf="!errTop"></app-alerts>
|
||||
@if (!errTop) {
|
||||
<app-alerts></app-alerts>
|
||||
}
|
||||
<app-requests-modal [(opened)]="requestsModal"></app-requests-modal>
|
||||
<app-excel-password-modal></app-excel-password-modal>
|
||||
|
||||
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
|
||||
|
||||
<!-- VA embed, back to Editor button -->
|
||||
<div
|
||||
*ngIf="embed === 'va' && vaEditorLibds && !isMainRoute('/editor')"
|
||||
class="va-back-bar"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
(click)="backToEditor()"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="caret"
|
||||
dir="left"
|
||||
size="16"
|
||||
></clr-icon>
|
||||
Back to Edit table
|
||||
</button>
|
||||
</div>
|
||||
@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>
|
||||
}
|
||||
|
||||
<router-outlet *ngIf="startupDataLoaded"></router-outlet>
|
||||
@if (startupDataLoaded) {
|
||||
<router-outlet></router-outlet>
|
||||
}
|
||||
|
||||
<app-login></app-login>
|
||||
<app-alerts *ngIf="errTop"></app-alerts>
|
||||
<app-info-modal
|
||||
*ngFor="let abort of sasjsAborts"
|
||||
[data]="abort"
|
||||
[forceReload]="!startupDataLoaded && sasjsAborts.length === 1"
|
||||
(onConfirmModalClick)="closeAbortModal(abort.id!)"
|
||||
>
|
||||
</app-info-modal>
|
||||
@if (errTop) {
|
||||
<app-alerts></app-alerts>
|
||||
}
|
||||
@for (abort of sasjsAborts; track abort) {
|
||||
<app-info-modal
|
||||
[data]="abort"
|
||||
[forceReload]="!startupDataLoaded && sasjsAborts.length === 1"
|
||||
(onConfirmModalClick)="closeAbortModal(abort.id!)"
|
||||
>
|
||||
</app-info-modal>
|
||||
}
|
||||
|
||||
<clr-modal
|
||||
appDragNdrop
|
||||
@@ -309,17 +304,20 @@
|
||||
</div>
|
||||
|
||||
<!-- App Loading Page -->
|
||||
<div *ngIf="!startupDataLoaded" class="app-loading">
|
||||
<img
|
||||
class="loading-logo"
|
||||
src="images/datacontroller.svg"
|
||||
alt="datacontroller logo"
|
||||
/>
|
||||
|
||||
<div *ngIf="appActive === null" class="slider">
|
||||
<div class="line"></div>
|
||||
<div class="subline inc"></div>
|
||||
<div class="subline dec"></div>
|
||||
@if (!startupDataLoaded) {
|
||||
<div class="app-loading">
|
||||
<img
|
||||
class="loading-logo"
|
||||
src="images/datacontroller.svg"
|
||||
alt="datacontroller logo"
|
||||
/>
|
||||
@if (appActive === null) {
|
||||
<div class="slider">
|
||||
<div class="line"></div>
|
||||
<div class="subline inc"></div>
|
||||
<div class="subline dec"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- /App Loading Page -->
|
||||
|
||||
@@ -1,100 +1,101 @@
|
||||
<main class="content-area position-relative">
|
||||
<div class="clr-row">
|
||||
<!-- T&C section -->
|
||||
<div *ngIf="step === 0" id="TCS" class="card">
|
||||
<div class="card-header">Terms and Conditions</div>
|
||||
<div class="card-block">
|
||||
<div class="card-text">
|
||||
<p class="mt-0">
|
||||
The Demo version of Data Controller is free for EVALUATION purposes
|
||||
only. Before proceeding with configuration, please confirm that you
|
||||
have read, understood, and agreed to the
|
||||
<a
|
||||
href="https://docs.datacontroller.io/evaluation-licence-agreement"
|
||||
target="_blank"
|
||||
>Data Controller for SAS© Evaluation Agreement</a
|
||||
>.
|
||||
</p>
|
||||
@if (step === 0) {
|
||||
<div id="TCS" class="card">
|
||||
<div class="card-header">Terms and Conditions</div>
|
||||
<div class="card-block">
|
||||
<div class="card-text">
|
||||
<p class="mt-0">
|
||||
The Demo version of Data Controller is free for EVALUATION
|
||||
purposes only. Before proceeding with configuration, please
|
||||
confirm that you have read, understood, and agreed to the
|
||||
<a
|
||||
href="https://docs.datacontroller.io/evaluation-licence-agreement"
|
||||
target="_blank"
|
||||
>Data Controller for SAS© Evaluation Agreement</a
|
||||
>.
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<!-- T&C section end -->
|
||||
|
||||
<ng-container *ngIf="step > 0" [ngSwitch]="true">
|
||||
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.SasViya">
|
||||
<div *ngIf="autodeploy" class="autodeploy-section card">
|
||||
<app-automatic-deploy
|
||||
[sasJs]="sasJs"
|
||||
[sasJsConfig]="sasJsConfig"
|
||||
[dcAdapterSettings]="dcAdapterSettings"
|
||||
[appLoc]="appLoc"
|
||||
[dcPath]="dcPath"
|
||||
[selectedAdminGroup]="selectedAdminGroup"
|
||||
(onNavigateToHome)="onNavigateToHome()"
|
||||
></app-automatic-deploy>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!autodeploy" id="mainbody" class="card">
|
||||
<app-manual-deploy
|
||||
[sasJs]="sasJs"
|
||||
[sasJsConfig]="sasJsConfig"
|
||||
[dcAdapterSettings]="dcAdapterSettings"
|
||||
(onNavigateToHome)="onNavigateToHome()"
|
||||
></app-manual-deploy>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.Sasjs">
|
||||
<div class="autodeploy-section card">
|
||||
<app-sasjs-configurator
|
||||
[sasJs]="sasJs"
|
||||
[sasJsConfig]="sasJsConfig"
|
||||
[dcAdapterSettings]="dcAdapterSettings"
|
||||
(onNavigateToHome)="onNavigateToHome()"
|
||||
></app-sasjs-configurator>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="sasJsConfig.serverType === ServerType.Sas9">
|
||||
<div class="autodeploy-section card">
|
||||
<app-sasjs-configurator
|
||||
[sasJs]="sasJs"
|
||||
[sasJsConfig]="sasJsConfig"
|
||||
[dcAdapterSettings]="dcAdapterSettings"
|
||||
(onNavigateToHome)="onNavigateToHome()"
|
||||
></app-sasjs-configurator>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
@if (step > 0) {
|
||||
@switch (true) {
|
||||
@case (sasJsConfig.serverType === ServerType.SasViya) {
|
||||
@if (autodeploy) {
|
||||
<div class="autodeploy-section card">
|
||||
<app-automatic-deploy
|
||||
[sasJs]="sasJs"
|
||||
[sasJsConfig]="sasJsConfig"
|
||||
[dcAdapterSettings]="dcAdapterSettings"
|
||||
[appLoc]="appLoc"
|
||||
[dcPath]="dcPath"
|
||||
[selectedAdminGroup]="selectedAdminGroup"
|
||||
(onNavigateToHome)="onNavigateToHome()"
|
||||
></app-automatic-deploy>
|
||||
</div>
|
||||
}
|
||||
@if (!autodeploy) {
|
||||
<div id="mainbody" class="card">
|
||||
<app-manual-deploy
|
||||
[sasJs]="sasJs"
|
||||
[sasJsConfig]="sasJsConfig"
|
||||
[dcAdapterSettings]="dcAdapterSettings"
|
||||
(onNavigateToHome)="onNavigateToHome()"
|
||||
></app-manual-deploy>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@case (sasJsConfig.serverType === ServerType.Sasjs) {
|
||||
<div class="autodeploy-section card">
|
||||
<app-sasjs-configurator
|
||||
[sasJs]="sasJs"
|
||||
[sasJsConfig]="sasJsConfig"
|
||||
[dcAdapterSettings]="dcAdapterSettings"
|
||||
(onNavigateToHome)="onNavigateToHome()"
|
||||
></app-sasjs-configurator>
|
||||
</div>
|
||||
}
|
||||
@case (sasJsConfig.serverType === ServerType.Sas9) {
|
||||
<div class="autodeploy-section card">
|
||||
<app-sasjs-configurator
|
||||
[sasJs]="sasJs"
|
||||
[sasJsConfig]="sasJsConfig"
|
||||
[dcAdapterSettings]="dcAdapterSettings"
|
||||
(onNavigateToHome)="onNavigateToHome()"
|
||||
></app-sasjs-configurator>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</main>
|
||||
|
||||
@@ -1,91 +1,80 @@
|
||||
<div *ngIf="autodeploying" class="auto-deploy">
|
||||
<div class="spinner-box">
|
||||
<ng-container *ngIf="!autodeployDone">
|
||||
<span class="spinner spinner-md"> Loading... </span>
|
||||
<p>Deploying...</p>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="autodeployDone">
|
||||
<p class="m-0 align-self-start">Done</p>
|
||||
<hr class="w-100" />
|
||||
|
||||
<div
|
||||
*ngIf="autoDeployStatus.deployServicePack !== null"
|
||||
class="deploy-status-row"
|
||||
>
|
||||
<clr-icon
|
||||
*ngIf="autoDeployStatus.deployServicePack === true"
|
||||
class="deploy-success"
|
||||
shape="success-standard"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="!autoDeployStatus.deployServicePack === false"
|
||||
class="deploy-error"
|
||||
shape="times-circle"
|
||||
></clr-icon>
|
||||
<p>Deploy SAS Jobs</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="autoDeployStatus.runMakeData !== null"
|
||||
class="deploy-status-row"
|
||||
>
|
||||
<clr-icon
|
||||
*ngIf="autoDeployStatus.runMakeData"
|
||||
class="deploy-success"
|
||||
shape="success-standard"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="autoDeployStatus.runMakeData === false"
|
||||
class="deploy-error"
|
||||
shape="times-circle"
|
||||
></clr-icon>
|
||||
<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="
|
||||
@if (autodeploying) {
|
||||
<div class="auto-deploy">
|
||||
<div class="spinner-box">
|
||||
@if (!autodeployDone) {
|
||||
<span class="spinner spinner-md"> Loading... </span>
|
||||
<p>Deploying...</p>
|
||||
}
|
||||
@if (autodeployDone) {
|
||||
<p class="m-0 align-self-start">Done</p>
|
||||
<hr class="w-100" />
|
||||
@if (autoDeployStatus.deployServicePack !== null) {
|
||||
<div class="deploy-status-row">
|
||||
@if (autoDeployStatus.deployServicePack === true) {
|
||||
<clr-icon
|
||||
class="deploy-success"
|
||||
shape="success-standard"
|
||||
></clr-icon>
|
||||
}
|
||||
@if (!autoDeployStatus.deployServicePack === false) {
|
||||
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
||||
}
|
||||
<p>Deploy SAS Jobs</p>
|
||||
</div>
|
||||
}
|
||||
@if (autoDeployStatus.runMakeData !== null) {
|
||||
<div class="deploy-status-row">
|
||||
@if (autoDeployStatus.runMakeData) {
|
||||
<clr-icon
|
||||
class="deploy-success"
|
||||
shape="success-standard"
|
||||
></clr-icon>
|
||||
}
|
||||
@if (autoDeployStatus.runMakeData === false) {
|
||||
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
|
||||
}
|
||||
<p>Create database</p>
|
||||
</div>
|
||||
}
|
||||
<hr class="w-100" />
|
||||
<div class="buttons">
|
||||
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
|
||||
@if (
|
||||
autoDeployStatus.deployServicePack === 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"
|
||||
shape="times-circle"
|
||||
></clr-icon>
|
||||
LAUNCH
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
|
||||
class="btn btn-primary-outline mt-15 mr-0"
|
||||
>
|
||||
Download log
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr class="w-100" />
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
(click)="autodeploying = false; autodeployDone = false"
|
||||
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="openSasRequestsModal()"
|
||||
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
|
||||
>
|
||||
SAS Requests
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
class="btn btn-primary-outline mt-15 mr-0"
|
||||
>
|
||||
Download log
|
||||
</button>
|
||||
</div>
|
||||
<hr class="w-100" />
|
||||
<div class="buttons">
|
||||
<button
|
||||
(click)="autodeploying = false; autodeployDone = false"
|
||||
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
(click)="openSasRequestsModal()"
|
||||
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
|
||||
>
|
||||
SAS Requests
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<h4 class="text-center my-15">Viya Deploy</h4>
|
||||
<hr />
|
||||
@@ -106,21 +95,18 @@
|
||||
<label for="dcloc" class="mt-20 clr-control-label">SAS Admin group</label>
|
||||
<div class="mb-10 clr-control-container">
|
||||
<div class="clr-input-wrapper small-mt">
|
||||
<select
|
||||
*ngIf="!adminGroupsLoading"
|
||||
clrSelect
|
||||
name="options"
|
||||
[(ngModel)]="selectedAdminGroup"
|
||||
>
|
||||
<option *ngFor="let adminGroup of adminGroups" [value]="adminGroup.id">
|
||||
{{ adminGroup.name }}
|
||||
</option>
|
||||
</select>
|
||||
<clr-spinner
|
||||
clrInline
|
||||
class="spinner-sm"
|
||||
*ngIf="adminGroupsLoading"
|
||||
></clr-spinner>
|
||||
@if (!adminGroupsLoading) {
|
||||
<select clrSelect name="options" [(ngModel)]="selectedAdminGroup">
|
||||
@for (adminGroup of adminGroups; track adminGroup) {
|
||||
<option [value]="adminGroup.id">
|
||||
{{ adminGroup.name }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@if (adminGroupsLoading) {
|
||||
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -129,36 +115,34 @@
|
||||
>
|
||||
<div class="mb-10 clr-control-container">
|
||||
<div class="clr-input-wrapper small-mt">
|
||||
<select
|
||||
*ngIf="!computeContextsLoading"
|
||||
clrSelect
|
||||
name="options"
|
||||
(ngModelChange)="onComputeContextChange($event)"
|
||||
[(ngModel)]="selectedComputeContext"
|
||||
>
|
||||
<option
|
||||
*ngFor="let computeContext of computeContexts"
|
||||
[value]="computeContext.id"
|
||||
@if (!computeContextsLoading) {
|
||||
<select
|
||||
clrSelect
|
||||
name="options"
|
||||
(ngModelChange)="onComputeContextChange($event)"
|
||||
[(ngModel)]="selectedComputeContext"
|
||||
>
|
||||
{{ computeContext.name }}
|
||||
</option>
|
||||
</select>
|
||||
<clr-spinner
|
||||
clrInline
|
||||
class="spinner-sm"
|
||||
*ngIf="computeContextsLoading"
|
||||
></clr-spinner>
|
||||
@for (computeContext of computeContexts; track computeContext) {
|
||||
<option [value]="computeContext.id">
|
||||
{{ computeContext.name }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@if (computeContextsLoading) {
|
||||
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="runningAsUser">
|
||||
@if (runningAsUser) {
|
||||
<label for="dcloc" class="mt-20 clr-control-label">Running as user:</label>
|
||||
<div class="mb-10 clr-control-container">
|
||||
<div class="clr-input-wrapper">
|
||||
<p class="mt-0">{{ runningAsUser }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
|
||||
|
||||
@@ -169,7 +153,7 @@
|
||||
(click)="recreateDatabaseClicked($event)"
|
||||
type="checkbox"
|
||||
checked
|
||||
/>
|
||||
/>
|
||||
<label>Recreate database</label>
|
||||
</clr-checkbox-wrapper> -->
|
||||
|
||||
@@ -185,18 +169,18 @@
|
||||
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
|
||||
|
||||
<!-- <button
|
||||
(click)="executeJson()"
|
||||
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
|
||||
[disabled]="!jsonFile"
|
||||
(click)="executeJson()"
|
||||
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
|
||||
[disabled]="!jsonFile"
|
||||
>
|
||||
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
|
||||
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
|
||||
</button> -->
|
||||
|
||||
<!-- <button
|
||||
(click)="uploadJsonAuto.click()"
|
||||
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
|
||||
(click)="uploadJsonAuto.click()"
|
||||
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
|
||||
>
|
||||
Upload different file to deploy
|
||||
Upload different file to deploy
|
||||
</button>
|
||||
<input
|
||||
#uploadJsonAuto
|
||||
@@ -204,7 +188,7 @@
|
||||
hidden
|
||||
(click)="clearUploadInput($event)"
|
||||
(change)="onJsonFileChange($event)"
|
||||
/> -->
|
||||
/> -->
|
||||
|
||||
<clr-modal [(clrModalOpen)]="recreateDatabaseModal" [clrModalClosable]="false">
|
||||
<h3 class="modal-title">Warning</h3>
|
||||
|
||||
@@ -10,29 +10,29 @@
|
||||
</div>
|
||||
<div class="card-block">
|
||||
<!-- <div class="card-title">
|
||||
Client Details
|
||||
</div> -->
|
||||
Client Details
|
||||
</div> -->
|
||||
|
||||
<div *ngIf="needsLogin" id="loginForm" class="d-none">
|
||||
<p class="mb-10">Please log in first</p>
|
||||
<label for="username" class="clr-control-label">Username</label>
|
||||
<div class="mb-10 clr-control-container">
|
||||
<div class="clr-input-wrapper">
|
||||
<input type="text" id="username" class="clr-input" />
|
||||
@if (needsLogin) {
|
||||
<div id="loginForm" class="d-none">
|
||||
<p class="mb-10">Please log in first</p>
|
||||
<label for="username" class="clr-control-label">Username</label>
|
||||
<div class="mb-10 clr-control-container">
|
||||
<div class="clr-input-wrapper">
|
||||
<input type="text" id="username" class="clr-input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="password" class="clr-control-label">Password</label>
|
||||
<div class="mb-10 clr-control-container">
|
||||
<div class="clr-input-wrapper">
|
||||
<input type="password" id="password" class="clr-input" />
|
||||
<label for="password" class="clr-control-label">Password</label>
|
||||
<div class="mb-10 clr-control-container">
|
||||
<div class="clr-input-wrapper">
|
||||
<input type="password" id="password" class="clr-input" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!needsLogin">
|
||||
@if (!needsLogin) {
|
||||
<form>
|
||||
<div class="clr-form-control">
|
||||
<label for="select-full" class="clr-control-label">Admin group</label>
|
||||
@@ -45,17 +45,15 @@
|
||||
id="adminGroupsSelect"
|
||||
class="clr-select"
|
||||
>
|
||||
<option
|
||||
*ngFor="let adminGroup of adminGroups"
|
||||
[value]="adminGroup.id"
|
||||
>
|
||||
{{ adminGroup.name }}
|
||||
</option>
|
||||
@for (adminGroup of adminGroups; track adminGroup) {
|
||||
<option [value]="adminGroup.id">
|
||||
{{ adminGroup.name }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clr-form-control">
|
||||
<div [class.hidden]="contextsLoading">
|
||||
<label for="select-full" class="clr-control-label">Context</label>
|
||||
@@ -67,15 +65,14 @@
|
||||
name="selectedContext"
|
||||
class="clr-select"
|
||||
>
|
||||
<option
|
||||
*ngFor="let context of allContexts"
|
||||
[value]="context.name"
|
||||
>
|
||||
{{ context.name }}
|
||||
<span *ngIf="(context.attributes | json) != '{}'"
|
||||
>( {{ context.attributes.sysUserId }} )</span
|
||||
>
|
||||
</option>
|
||||
@for (context of allContexts; track context) {
|
||||
<option [value]="context.name">
|
||||
{{ context.name }}
|
||||
@if ((context.attributes | json) != '{}') {
|
||||
<span>( {{ context.attributes.sysUserId }} )</span>
|
||||
}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,14 +85,12 @@
|
||||
<clr-icon shape="play"></clr-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div [class.hidden]="!contextsLoading" class="d-flex">
|
||||
<span class="spinner spinner-inline mr-10">
|
||||
Loading contexts...
|
||||
</span>
|
||||
<span> Loading contexts... </span>
|
||||
</div>
|
||||
|
||||
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
|
||||
<div class="mb-10 clr-control-container">
|
||||
<div class="clr-input-wrapper">
|
||||
@@ -110,7 +105,6 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-10">
|
||||
Select JSON file to upload (json build file preloaded):
|
||||
</p>
|
||||
@@ -121,10 +115,9 @@
|
||||
(change)="onJsonFileChange($event)"
|
||||
/>
|
||||
<!-- <button *ngIf="downloadFileBtn" (click)="downloadSasPrecodeFile()" style="width: 40px; min-width: 0;" class="btn btn-sm btn-icon">
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button> -->
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button> -->
|
||||
</div>
|
||||
|
||||
<div class="mt-20 d-flex align-items-center">
|
||||
<button
|
||||
(click)="executeJson()"
|
||||
@@ -135,13 +128,13 @@
|
||||
>
|
||||
SUBMIT JSON
|
||||
</button>
|
||||
<span *ngIf="isJsonSubmitted">JSON Submitted Successfully</span>
|
||||
|
||||
@if (isJsonSubmitted) {
|
||||
<span>JSON Submitted Successfully</span>
|
||||
}
|
||||
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
|
||||
Loading...
|
||||
</span> -->
|
||||
Loading...
|
||||
</span> -->
|
||||
</div>
|
||||
|
||||
<p class="mt-10">Select SAS file to upload:</p>
|
||||
<div class="d-flex flex-column">
|
||||
<input
|
||||
@@ -149,15 +142,15 @@
|
||||
(click)="clearUploadInput($event)"
|
||||
(change)="onSasFileChange($event); downloadFileBtn = true"
|
||||
/>
|
||||
<button
|
||||
*ngIf="downloadFileBtn"
|
||||
(click)="downloadSasPrecodeFile()"
|
||||
class="btn btn-sm btn-icon min-w-0 w-40"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
@if (downloadFileBtn) {
|
||||
<button
|
||||
(click)="downloadSasPrecodeFile()"
|
||||
class="btn btn-sm btn-icon min-w-0 w-40"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-20 d-flex align-items-center">
|
||||
<button
|
||||
(click)="executeSAS()"
|
||||
@@ -168,17 +161,14 @@
|
||||
>
|
||||
SUBMIT
|
||||
</button>
|
||||
|
||||
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
|
||||
Loading...
|
||||
</span> -->
|
||||
Loading...
|
||||
</span> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="jobLog.length > 0">
|
||||
@if (jobLog.length > 0) {
|
||||
<p class="mb-0 mt-10">File execute completed</p>
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<button
|
||||
(click)="downloadFile(jobLog, 'execute-script-log', 'txt')"
|
||||
@@ -187,8 +177,7 @@
|
||||
Download log
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
}
|
||||
<button
|
||||
[clrLoading]="createDatabaseLoading"
|
||||
(click)="createDatabase()"
|
||||
@@ -196,52 +185,45 @@
|
||||
>
|
||||
Create Database
|
||||
</button>
|
||||
|
||||
<ng-container *ngIf="makeDataResponse.length > 0">
|
||||
@if (makeDataResponse.length > 0) {
|
||||
<p class="mb-0 mt-10">Create Database Completed</p>
|
||||
<hr />
|
||||
|
||||
<div *ngIf="makeDataResponse.length > 0" class="log-wrapper">
|
||||
{{ makeDataResponse }}
|
||||
</div>
|
||||
|
||||
@if (makeDataResponse.length > 0) {
|
||||
<div class="log-wrapper">
|
||||
{{ makeDataResponse }}
|
||||
</div>
|
||||
}
|
||||
<button (click)="navigateToHome()" class="btn btn-primary mt-15">
|
||||
Let's get started
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
|
||||
class="btn btn-primary mt-15"
|
||||
>
|
||||
Download log
|
||||
</button>
|
||||
|
||||
<button (click)="validateDeploy()" class="btn btn-primary mt-15">
|
||||
Validate
|
||||
</button>
|
||||
|
||||
<div
|
||||
*ngIf="validationState !== 'none' || isValidating"
|
||||
class="validation-bar"
|
||||
>
|
||||
<ng-container *ngIf="isValidating">
|
||||
<span class="spinner spinner-inline mr-10">
|
||||
Validating deploy...
|
||||
</span>
|
||||
<span> Validating deploy... </span>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!isValidating && validationState === 'error'">
|
||||
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
|
||||
<span> Validation failed </span>
|
||||
</ng-container>
|
||||
|
||||
<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>
|
||||
@if (validationState !== 'none' || isValidating) {
|
||||
<div class="validation-bar">
|
||||
@if (isValidating) {
|
||||
<span class="spinner spinner-inline mr-10">
|
||||
Validating deploy...
|
||||
</span>
|
||||
<span> Validating deploy... </span>
|
||||
}
|
||||
@if (!isValidating && validationState === 'error') {
|
||||
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
|
||||
<span> Validation failed </span>
|
||||
}
|
||||
@if (!isValidating && validationState === 'success') {
|
||||
<clr-icon shape="check-circle" class="is-success"></clr-icon>
|
||||
<span> Validation succeeded </span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</form>
|
||||
</ng-container>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ManualComponent } from './manual.component'
|
||||
|
||||
// validateDeploy() is fire-and-forget (doesn't return its own promise
|
||||
// chain), so tests need to wait for it to settle some other way. A
|
||||
// macrotask boundary guarantees every already-queued microtask (regardless
|
||||
// of how many .then() hops validateDeploy's chain has) has run first.
|
||||
const flushPromiseChain = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
/**
|
||||
* validateDeploy() only touches sasService.request, eventService.showInfoModal
|
||||
* and loggerService.log - these stubs cover exactly that surface, not the
|
||||
* full real services (no TestBed/DI needed, same plain-instantiation-with-
|
||||
* stubs precedent as app.service.spec.ts).
|
||||
*/
|
||||
const buildDeps = () => {
|
||||
const sasService: any = {
|
||||
request: jasmine.createSpy('request')
|
||||
}
|
||||
const eventService: any = {
|
||||
showInfoModal: jasmine.createSpy('showInfoModal')
|
||||
}
|
||||
const loggerService: any = {
|
||||
log: jasmine.createSpy('log')
|
||||
}
|
||||
const deployService: any = {}
|
||||
|
||||
return { sasService, eventService, loggerService, deployService }
|
||||
}
|
||||
|
||||
const buildManualComponent = (deps: ReturnType<typeof buildDeps>) =>
|
||||
new ManualComponent(
|
||||
deps.sasService,
|
||||
deps.eventService,
|
||||
deps.loggerService,
|
||||
deps.deployService
|
||||
)
|
||||
|
||||
describe('ManualComponent - validateDeploy', () => {
|
||||
it('sets validationState to success when saslibs is present on a normal object response', async () => {
|
||||
const deps = buildDeps()
|
||||
deps.sasService.request.and.resolveTo({
|
||||
adapterResponse: { saslibs: { SOME_LIB: ['SOME_TABLE'] } }
|
||||
})
|
||||
|
||||
const component = buildManualComponent(deps)
|
||||
component.validateDeploy()
|
||||
await flushPromiseChain()
|
||||
|
||||
expect(component.validationState).toBe('success')
|
||||
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets validationState to error with no modal when saslibs is missing from an otherwise normal object response', async () => {
|
||||
const deps = buildDeps()
|
||||
deps.sasService.request.and.resolveTo({
|
||||
adapterResponse: { SYSSITE: 'SITE1' }
|
||||
// saslibs deliberately omitted
|
||||
})
|
||||
|
||||
const component = buildManualComponent(deps)
|
||||
component.validateDeploy()
|
||||
await flushPromiseChain()
|
||||
|
||||
expect(component.validationState).toBe('error')
|
||||
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Same underlying bug as app.service.spec.ts's equivalent test: a
|
||||
// misconfigured Viya computeTasks deployment returns a raw "Job error"
|
||||
// text body, which the adapter resolves as adapterResponse verbatim.
|
||||
it('sets validationState to error and shows the real response text when adapterResponse is a raw string, not an object', async () => {
|
||||
const deps = buildDeps()
|
||||
const rawJobError = [
|
||||
'Job error',
|
||||
'The Compute service could not execute the task c74a707a-8b88-46a5-8f28-9a7694ad5e13 because the context 340cd3eb-72ae is not reusable. Please provide a reusable context or supply the ID of an existing session in the task request.',
|
||||
'path: /compute/tasks',
|
||||
'correlator: dc1a9fda-8f65-4f32-b4d4-2a70e02179b1;1bcb6a4c-8a16-4053-a790-292544c25a03'
|
||||
].join('\n')
|
||||
deps.sasService.request.and.resolveTo({ adapterResponse: rawJobError })
|
||||
|
||||
const component = buildManualComponent(deps)
|
||||
component.validateDeploy()
|
||||
await flushPromiseChain()
|
||||
|
||||
expect(component.validationState).toBe('error')
|
||||
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
|
||||
const [, message] = deps.eventService.showInfoModal.calls.mostRecent().args
|
||||
expect(message).toContain(rawJobError)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import { DeployService } from 'src/app/services/deploy.service'
|
||||
import { EventService } from 'src/app/services/event.service'
|
||||
import { LoggerService } from 'src/app/services/logger.service'
|
||||
import { SasService } from 'src/app/services/sas.service'
|
||||
import { getMalformedAdapterResponseMessage } from 'src/app/shared/utils/get-malformed-adapter-response-message'
|
||||
|
||||
@Component({
|
||||
selector: 'app-manual-deploy',
|
||||
@@ -316,7 +317,13 @@ export class ManualComponent implements OnInit {
|
||||
.then((res: RequestWrapperResponse) => {
|
||||
this.loggerService.log(res.adapterResponse)
|
||||
|
||||
if (res.adapterResponse.saslibs) {
|
||||
const malformedMessage = getMalformedAdapterResponseMessage(
|
||||
res.adapterResponse
|
||||
)
|
||||
if (malformedMessage) {
|
||||
this.validationState = 'error'
|
||||
this.eventService.showInfoModal('Error', malformedMessage)
|
||||
} else if (res.adapterResponse.saslibs) {
|
||||
this.validationState = 'success'
|
||||
} else {
|
||||
this.validationState = 'error'
|
||||
|
||||
+17
-16
@@ -1,6 +1,8 @@
|
||||
<div *ngIf="loading" class="thinProgress progresStatic progress loop">
|
||||
<progress></progress>
|
||||
</div>
|
||||
@if (loading) {
|
||||
<div class="thinProgress progresStatic progress loop">
|
||||
<progress></progress>
|
||||
</div>
|
||||
}
|
||||
|
||||
<h4 class="text-center my-15">Sasjs Deploy</h4>
|
||||
<hr class="light" />
|
||||
@@ -27,13 +29,13 @@
|
||||
</div>
|
||||
|
||||
<!-- <button
|
||||
(click)="directoryBrowse.click()"
|
||||
class="mt-15 text-center"
|
||||
class="btn btn-sm btn-outline"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
<input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> -->
|
||||
(click)="directoryBrowse.click()"
|
||||
class="mt-15 text-center"
|
||||
class="btn btn-sm btn-outline"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
<input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> -->
|
||||
</div>
|
||||
|
||||
<p class="m-0 mt-10">
|
||||
@@ -45,12 +47,11 @@
|
||||
<label class="mt-20 clr-control-label">Data Controller Admin group</label>
|
||||
<clr-select-container class="mb-10 mt-0 w-50vw">
|
||||
<select [(ngModel)]="dcAdminGroup" clrSelect>
|
||||
<option
|
||||
*ngFor="let adminGroup of dcAdminGroupList"
|
||||
[value]="adminGroup.GROUPNAME"
|
||||
>
|
||||
{{ adminGroup.GROUPNAME }} - {{ adminGroup.GROUPDESC }}
|
||||
</option>
|
||||
@for (adminGroup of dcAdminGroupList; track adminGroup) {
|
||||
<option [value]="adminGroup.GROUPNAME">
|
||||
{{ adminGroup.GROUPNAME }} - {{ adminGroup.GROUPDESC }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</clr-select-container>
|
||||
|
||||
|
||||
@@ -84,6 +84,20 @@
|
||||
status="warning"
|
||||
></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'">
|
||||
<clr-input-container
|
||||
*ngIf="
|
||||
|
||||
@@ -55,6 +55,7 @@ export class EditRecordComponent implements OnInit {
|
||||
@Output() onPreviousRecord: EventEmitter<any> = new EventEmitter<any>()
|
||||
|
||||
public currentRecordInvalidCols: string[] = []
|
||||
public currentRecordWarningCols: string[] = []
|
||||
public generateEditRecordUrlLoading: boolean = false
|
||||
public generatedRecordUrl: string | null = null
|
||||
public addRecordUrl: string | null = null
|
||||
@@ -120,9 +121,12 @@ export class EditRecordComponent implements OnInit {
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,6 +225,8 @@ export class EditRecordComponent implements OnInit {
|
||||
this.tryAutoPopulateNotNull(event, colName, colRules, value)
|
||||
}
|
||||
})
|
||||
|
||||
this.updateWarningState(colName, value)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -237,6 +243,22 @@ export class EditRecordComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -264,6 +286,8 @@ export class EditRecordComponent implements OnInit {
|
||||
this.validateRecordCol(colRules, defaultValue).then((isValid: boolean) => {
|
||||
this.updateValidationState(colName, isValid)
|
||||
})
|
||||
|
||||
this.updateWarningState(colName, defaultValue)
|
||||
}
|
||||
|
||||
onNextRecordClick() {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<div>
|
||||
<p class="m-0" *ngFor="let state of processedStates; let i = index">
|
||||
{{ state }}
|
||||
</p>
|
||||
@for (state of processedStates; track state; let i = $index) {
|
||||
<p class="m-0">
|
||||
{{ state }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<span class="spinner spinner-sm vertical-align-middle">Loading...</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,16 @@ import { EventService } from '../services/event.service'
|
||||
import { HelperService } from '../services/helper.service'
|
||||
import { LoggerService } from '../services/logger.service'
|
||||
import { SasService } from '../services/sas.service'
|
||||
import { UserService } from '../shared/user.service'
|
||||
import { applyFormulaRules } from '../shared/dc-validator/utils/applyFormulaRules'
|
||||
import { expandCellRanges } from './utils/expandCellRanges'
|
||||
import { preventMenuItemAutoClose } from './utils/preventMenuItemAutoClose'
|
||||
import { findOverwrittenCells } from '../shared/dc-validator/utils/findOverwrittenCells'
|
||||
import { getFormulaCellsToPreserveOnCancel } from '../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
|
||||
import { getRevertableCols } from '../shared/dc-validator/utils/getRevertableCols'
|
||||
import { getStableFormulaBaseCols } from '../shared/dc-validator/utils/getStableFormulaBaseCols'
|
||||
import { syncOverwrittenCellComment } from '../shared/dc-validator/utils/syncOverwrittenCellComment'
|
||||
import { parseFormulaRule } from '../shared/dc-validator/utils/parseFormulaRule'
|
||||
import { DcValidator } from '../shared/dc-validator/dc-validator'
|
||||
import { Col } from '../shared/dc-validator/models/col.model'
|
||||
import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
|
||||
@@ -46,6 +56,8 @@ import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
|
||||
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
|
||||
import { excelRound } from '../shared/dc-validator/utils/excelRound'
|
||||
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
|
||||
import { hasFormulaRules } from '../shared/dc-validator/utils/hasFormulaRules'
|
||||
import { HyperFormula } from 'hyperformula'
|
||||
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
|
||||
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
|
||||
import { buildColInfoHtml } from '../shared/utils/col-info-html'
|
||||
@@ -58,6 +70,10 @@ import {
|
||||
import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events'
|
||||
import { EditorRestrictions } from './models/editor-restrictions.model'
|
||||
import { parseTableColumns } from './utils/grid.utils'
|
||||
import { classifyRow } from './utils/classifyRow'
|
||||
import { withoutEditStatus } from './utils/withoutEditStatus'
|
||||
import { getEditStatusSymbol } from './utils/getEditStatusSymbol'
|
||||
import { EDIT_STATUS_COLUMN_NAME } from '../shared/dc-validator/utils/editStatusColumnRule'
|
||||
import {
|
||||
errorRenderer,
|
||||
noSpinnerRenderer,
|
||||
@@ -193,6 +209,65 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
},
|
||||
// Only ever shown when the selection contains at least one cell
|
||||
// markOverwrittenCells (or the live afterChange sync) attached a
|
||||
// comment to - i.e. a cell whose current value differs from what
|
||||
// SAS actually sent for it, formula-caused or a direct edit. The
|
||||
// comment's presence is a sufficient and exact signal, no need to
|
||||
// separately recompute "is this overwritten" here too. The
|
||||
// selection can be a single cell, a rectangular multi-cell range,
|
||||
// a whole row/column (header click), or several disjoint ranges
|
||||
// (ctrl-click) - expandCellRanges normalizes all of those into a
|
||||
// flat list of individual cells.
|
||||
revert_cells: {
|
||||
name: 'Revert',
|
||||
hidden(this: Handsontable.Core) {
|
||||
if (this.getSettings().readOnly) return true
|
||||
|
||||
const ranges: CellRange[] | undefined = this.getSelectedRange()
|
||||
if (!ranges || ranges.length === 0) return true
|
||||
|
||||
const commentsPlugin: any = this.getPlugin('comments')
|
||||
const cells = expandCellRanges(
|
||||
ranges,
|
||||
this.countRows(),
|
||||
this.countCols()
|
||||
)
|
||||
|
||||
return !cells.some(({ row, col }) =>
|
||||
commentsPlugin.getCommentAtCell(row, col)
|
||||
)
|
||||
},
|
||||
callback: (key: string, selection: any[]) => {
|
||||
const hot = this.hotInstance
|
||||
const commentsPlugin: any = hot.getPlugin('comments')
|
||||
const cells = expandCellRanges(
|
||||
selection.map((sel) => ({ from: sel.start, to: sel.end })),
|
||||
hot.countRows(),
|
||||
hot.countCols()
|
||||
)
|
||||
|
||||
for (const { row, col } of cells) {
|
||||
const comment: string | undefined =
|
||||
commentsPlugin.getCommentAtCell(row, col)
|
||||
if (!comment) continue
|
||||
|
||||
const prop = hot.colToProp(col) as string
|
||||
const rawValueText = comment.slice(
|
||||
EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX.length
|
||||
)
|
||||
const isNumericCol =
|
||||
this.dcValidator?.getRule(prop)?.type === 'numeric'
|
||||
|
||||
hot.setDataAtRowProp(
|
||||
row,
|
||||
prop,
|
||||
isNumericCol ? Number(rawValueText) : rawValueText
|
||||
)
|
||||
commentsPlugin.removeCommentAtCell(row, col)
|
||||
}
|
||||
}
|
||||
},
|
||||
row_above: {
|
||||
name: 'Insert Row above',
|
||||
hidden: () => this.hotTable.readOnly === true,
|
||||
@@ -349,6 +424,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
* with that cadence.
|
||||
*/
|
||||
private static readonly VA_DEBOUNCE_MS = 800
|
||||
// Shared between markOverwrittenCells/syncOverwrittenCommentForCell
|
||||
// (writes it) and the revert_cells context menu item (parses it back
|
||||
// out) - see findOverwrittenCells.
|
||||
private static readonly ORIGINAL_VALUE_COMMENT_PREFIX = 'Original value: '
|
||||
public tableTrue: boolean | undefined
|
||||
public saveLoading = false
|
||||
public approvers: string[] = []
|
||||
@@ -403,6 +482,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
dataSource!: any[]
|
||||
prevDataSource!: any[]
|
||||
dataSourceUnchanged!: any[]
|
||||
// Raw, as-received-from-SAS snapshot, captured before applyFormulaRules
|
||||
// overwrites HARDFORMULA/SOFTFORMULA columns - see findOverwrittenCells
|
||||
// and getRevertableColumnNames. Distinct from dataSourceUnchanged, which is a
|
||||
// per-editing-session baseline that gets reset on every editTable() call;
|
||||
// this stays fixed for the table's whole lifetime, since "what did the
|
||||
// real dataset actually have before any formula got involved" doesn't
|
||||
// change across edit sessions.
|
||||
dataSourceRaw!: any[]
|
||||
dataSourceBeforeSubmit!: any[]
|
||||
dataModified!: any[]
|
||||
|
||||
@@ -486,7 +573,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private cdf: ChangeDetectorRef,
|
||||
private spreadsheetService: SpreadsheetService,
|
||||
private vaMessaging: VaMessagingService,
|
||||
private vaFilter: VaFilterService
|
||||
private vaFilter: VaFilterService,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.parseRestrictions()
|
||||
this.setRestrictions()
|
||||
@@ -760,6 +848,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (!itemObject['_____DELETE__THIS__RECORD_____'])
|
||||
itemObject['_____DELETE__THIS__RECORD_____'] = 'No'
|
||||
|
||||
// EDIT_STATUS is never part of the uploaded file (client-only, see
|
||||
// editStatusColumnRule.ts) - default it the same as a fresh load.
|
||||
itemObject[EDIT_STATUS_COLUMN_NAME] = 'U'
|
||||
|
||||
previewDatasource.push(itemObject)
|
||||
})
|
||||
|
||||
@@ -1021,6 +1113,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (newRow) {
|
||||
this.dataSourceUnchanged.pop()
|
||||
}
|
||||
|
||||
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
|
||||
}
|
||||
this.hotTable.readOnly = false
|
||||
this.hotTable.data = this.dataSource
|
||||
@@ -1034,6 +1128,17 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
hot.render()
|
||||
|
||||
// Resync every revertable cell's "overwritten" comment against
|
||||
// dataSourceRaw on every entry into edit mode. Needed for the Excel
|
||||
// upload path in particular (previewTableEditConfirm -> editTable(true))
|
||||
// - the preview's bulk hot.updateSettings({data: ...}) fires afterChange
|
||||
// with source 'loadData', which the live sync hook deliberately ignores
|
||||
// (see its own comment), so an uploaded value that differs from the
|
||||
// original SAS data would otherwise never get flagged/commented. A
|
||||
// plain Edit-button click re-syncs the same (already-correct) state,
|
||||
// same as cancelEdit() does for the read-only return path.
|
||||
this.syncOverwrittenComments()
|
||||
|
||||
for (const sortConfig of sortConfigs) {
|
||||
columnSorting.sort(sortConfig)
|
||||
}
|
||||
@@ -1085,7 +1190,34 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
: [columnSortConfig]
|
||||
|
||||
if (this.dataSourceUnchanged) {
|
||||
// dataSourceUnchanged deliberately holds the RAW pre-formula value for
|
||||
// a HARDFORMULA/SOFTFORMULA column that still has its
|
||||
// markOverwrittenCells comment (see editTable's overlay) - needed
|
||||
// so classifyRow flags the row as modified, but it isn't a session
|
||||
// edit to discard: the formula recomputes on every load regardless.
|
||||
// Snapshot those cells' live computed value before the blind restore
|
||||
// below, then patch them back in - otherwise cancelling would freeze
|
||||
// the display at the raw value and erase the row's modified marker.
|
||||
const formulaBaseCols = this.getFormulaBaseCols()
|
||||
const commentsPlugin = hot.getPlugin('comments')
|
||||
const toPreserve = getFormulaCellsToPreserveOnCancel(
|
||||
this.dataSource.length,
|
||||
formulaBaseCols,
|
||||
(rowIndex, baseCol) =>
|
||||
!!commentsPlugin.getCommentAtCell(
|
||||
rowIndex,
|
||||
hot.propToCol(baseCol) as number
|
||||
),
|
||||
(rowIndex, prop) => hot.getDataAtRowProp(rowIndex, prop)
|
||||
)
|
||||
|
||||
this.dataSource = this.helperService.deepClone(this.dataSourceUnchanged)
|
||||
|
||||
for (const cell of toPreserve) {
|
||||
if (this.dataSource[cell.rowIndex]) {
|
||||
this.dataSource[cell.rowIndex][cell.prop] = cell.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.hotTable.data = this.dataSource
|
||||
@@ -1099,6 +1231,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
false
|
||||
)
|
||||
|
||||
// A cell's overwritten comment can be stale after this restore - e.g.
|
||||
// a direct edit (not a formula overwrite) just got discarded, and
|
||||
// dataSource now matches dataSourceRaw again for it. formula cells'
|
||||
// comments are already correct (preserved above), so this is mostly
|
||||
// about the general case, but running the same full resync either way
|
||||
// is simpler than trying to only check what might have changed.
|
||||
this.syncOverwrittenComments()
|
||||
|
||||
this.modifedRowsIndexes = []
|
||||
hot.validateCells()
|
||||
// this.editRecordListeners();
|
||||
@@ -1301,18 +1441,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
setTimeout(() => {
|
||||
const hot = this.hotInstance
|
||||
const newIndex = this.dataSource.length
|
||||
|
||||
// Create a new empty row object with proper structure
|
||||
const newRow = this.createEmptyRow()
|
||||
|
||||
// Add the new row to the data source
|
||||
this.dataSource.push(newRow)
|
||||
|
||||
// Update the hot table with the new data
|
||||
hot.updateSettings({ data: this.dataSource }, false)
|
||||
// hot.alter() (rather than splicing dataSource and calling
|
||||
// updateSettings) is what triggers the formulas plugin's own
|
||||
// insert-row hooks - without it, HyperFormula's sheet never learns
|
||||
// about the new row and HARDFORMULA/SOFTFORMULA columns silently
|
||||
// fall out of sync with the grid's own data.
|
||||
hot.alter('insert_row_below', newIndex - 1, 1)
|
||||
this.dataSource[newIndex].noLinkOption = true
|
||||
this.seedFormulaValuesForRow(newIndex)
|
||||
this.updateEditStatusForRow(newIndex)
|
||||
|
||||
// Select the newly added row
|
||||
hot.selectCell(this.dataSource.length - 1, 0)
|
||||
hot.selectCell(newIndex, 0)
|
||||
hot.render()
|
||||
|
||||
this.addingNewRow = false
|
||||
@@ -1321,39 +1463,298 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new empty row object with proper structure.
|
||||
* Columns with NOTNULL DQ rules are pre-populated with their RULE_VALUE.
|
||||
*/
|
||||
private createEmptyRow(): any {
|
||||
const newRow: any = {}
|
||||
this.cellValidation.forEach((rule: any) => {
|
||||
const dataKey = rule.data
|
||||
newRow[dataKey] = this.hotDataSchema.hasOwnProperty(dataKey)
|
||||
? this.hotDataSchema[dataKey]
|
||||
: ''
|
||||
})
|
||||
newRow['noLinkOption'] = true
|
||||
return newRow
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a new row at the specified position and updates the table
|
||||
*/
|
||||
private insertRowAtPosition(targetRow: number): void {
|
||||
const newRow = this.createEmptyRow()
|
||||
|
||||
// Insert the new row at the target position
|
||||
this.dataSource.splice(targetRow, 0, newRow)
|
||||
|
||||
// Update the hot table
|
||||
const hot = this.hotInstance
|
||||
hot.updateSettings({ data: this.dataSource }, false)
|
||||
hot.render()
|
||||
|
||||
// See addRow()'s comment - hot.alter() is required for HyperFormula to
|
||||
// learn about the new row. beforeCreateRow only allows this while
|
||||
// addingNewRow is set (see its own hook registration).
|
||||
this.addingNewRow = true
|
||||
hot.alter('insert_row_above', targetRow, 1)
|
||||
this.addingNewRow = false
|
||||
|
||||
// alter() builds the new row from the grid's configured dataSchema
|
||||
// (NOTNULL defaults included), which doesn't know about noLinkOption -
|
||||
// set it directly on the row alter() just spliced into dataSource.
|
||||
this.dataSource[targetRow].noLinkOption = true
|
||||
this.seedFormulaValuesForRow(targetRow)
|
||||
this.updateEditStatusForRow(targetRow)
|
||||
|
||||
this.reSetCellValidationValues()
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds HARDFORMULA/SOFTFORMULA columns on a newly-inserted row with
|
||||
* their computed formula string, the same way applyFormulaRules seeds
|
||||
* every row on initial load - a new row otherwise sits with a blank
|
||||
* formula cell until the next full reload. DC.ORIG_VALUE naturally
|
||||
* resolves to blank for this row (applyFormulaRules can't find a
|
||||
* dataSourceUnchanged match for a row that never existed before).
|
||||
*
|
||||
* Written via hot.setDataAtRowProp (not a bare dataSource mutation, which
|
||||
* is what applyFormulaRules itself does) so HyperFormula's engine
|
||||
* actually learns about the new cell content - the same class of fix as
|
||||
* hot.alter() above, just for a single cell instead of a row structure
|
||||
* change.
|
||||
*/
|
||||
private seedFormulaValuesForRow(rowIndex: number): void {
|
||||
const dqRules = this.dcValidator?.getDqDetails()
|
||||
if (!dqRules || !hasFormulaRules(dqRules)) return
|
||||
|
||||
const hot = this.hotInstance
|
||||
const userName = this.userService.user?.username ?? ''
|
||||
const formulaRules = dqRules.filter(
|
||||
(rule) =>
|
||||
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
|
||||
)
|
||||
|
||||
// Computes only THIS row's formula string (parseFormulaRule directly,
|
||||
// not applyFormulaRules on the full dataSource) - applyFormulaRules
|
||||
// would overwrite every other row's formula column too, silently
|
||||
// reverting any SOFTFORMULA override a user already typed elsewhere
|
||||
// and rewriting every shifted row's cell-reference string (its
|
||||
// spreadsheet position changed), both of which make classifyRow see a
|
||||
// false diff against dataSourceUnchanged and misreport those untouched
|
||||
// rows as modified.
|
||||
for (const rule of formulaRules) {
|
||||
const value = parseFormulaRule(rule.RULE_VALUE, {
|
||||
columnNames: this.headerColumns,
|
||||
rowIndex,
|
||||
userName,
|
||||
origValue: undefined
|
||||
})
|
||||
|
||||
hot.setDataAtRowProp(rowIndex, rule.BASE_COL, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BASE_COL names of every HARDFORMULA/SOFTFORMULA rule whose result is
|
||||
* expected to be *stable* for a given row (see getStableFormulaBaseCols
|
||||
* for why DC.USER_NAME/DC.ORIG_VALUE/DC.ROW_STATUS-based rules are
|
||||
* excluded). Used only by overlayFormulaRawValuesOnUnchanged and
|
||||
* cancelEdit's preserve-on-cancel patch, which exist specifically to work
|
||||
* around dataSourceUnchanged's formula-only raw overlay - see those
|
||||
* methods' own doc comments for why that stays narrower than
|
||||
* getRevertableColumnNames(). NOT the same filter seedFormulaValuesForRow
|
||||
* uses above, which seeds every formula column regardless of this
|
||||
* distinction.
|
||||
*/
|
||||
private getFormulaBaseCols(): string[] {
|
||||
const dqRules = this.dcValidator?.getDqDetails()
|
||||
if (!dqRules) return []
|
||||
|
||||
return getStableFormulaBaseCols(dqRules)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlays the true raw (pre-formula) value onto dataSourceUnchanged for
|
||||
* every HARDFORMULA/SOFTFORMULA base col that already had real data (see
|
||||
* markOverwrittenCells) - mutates in place. A HARDFORMULA/SOFTFORMULA
|
||||
* rule can silently overwrite a value that already existed in the real
|
||||
* dataset - that's a real change classifyRow's diff should pick up, not
|
||||
* something that only becomes visible once an edit session starts. Shared
|
||||
* by the initial load (so the row header agrees with EDIT_STATUS from the
|
||||
* very first render) and editTable() (every time the baseline is rebuilt).
|
||||
*/
|
||||
private overlayFormulaRawValuesOnUnchanged(dataSourceUnchanged: any[]): void {
|
||||
const formulaBaseCols = this.getFormulaBaseCols()
|
||||
if (formulaBaseCols.length === 0 || !this.dataSourceRaw) return
|
||||
|
||||
dataSourceUnchanged.forEach((row, rowIndex) => {
|
||||
const rawRow = this.dataSourceRaw[rowIndex]
|
||||
if (!rawRow) return
|
||||
|
||||
for (const baseCol of formulaBaseCols) {
|
||||
const rawValue = rawRow[baseCol]
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '')
|
||||
continue
|
||||
|
||||
row[baseCol] = rawValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Every column eligible to be checked/marked as "overwritten" - every
|
||||
* revertable column (see getRevertableCols), not just the narrower
|
||||
* formula-only set getFormulaBaseCols() returns. Used by
|
||||
* markOverwrittenCells and the live afterChange sync - NOT by
|
||||
* overlayFormulaRawValuesOnUnchanged/cancelEdit's preserve-on-cancel
|
||||
* patch, which stay scoped to getFormulaBaseCols() specifically (see
|
||||
* those methods' own doc comments for why).
|
||||
*/
|
||||
private getRevertableColumnNames(): string[] {
|
||||
const dqRules = this.dcValidator?.getDqDetails()
|
||||
if (!dqRules) return []
|
||||
|
||||
return getRevertableCols(dqRules, this.headerColumns)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or clears a single cell's "overwritten" comment, matching its
|
||||
* current value against dataSourceRaw (PK-matched, so this is safe to
|
||||
* call after a row insert/delete/sort has moved things around). No-op if
|
||||
* the row has no PK match in dataSourceRaw (a newly-inserted row) - see
|
||||
* findOverwrittenCells for why that's never revertable.
|
||||
*/
|
||||
private syncOverwrittenCommentForCell(rowIndex: number, prop: string): void {
|
||||
const hot = this.hotInstance
|
||||
const dataRow = this.dataSource[rowIndex]
|
||||
if (!dataRow || !this.dataSourceRaw) return
|
||||
|
||||
const rawRow = this.dataSourceRaw.find((candidate) =>
|
||||
this.headerPks.every((pk) => candidate[pk] === dataRow[pk])
|
||||
)
|
||||
if (!rawRow) return
|
||||
|
||||
const colIndex = hot.propToCol(prop) as number
|
||||
const commentsPlugin = hot.getPlugin('comments')
|
||||
const currentValue = hot.getDataAtRowProp(rowIndex, prop)
|
||||
const hasCommentAlready = !!commentsPlugin.getCommentAtCell(
|
||||
rowIndex,
|
||||
colIndex
|
||||
)
|
||||
|
||||
const action = syncOverwrittenCellComment(
|
||||
currentValue,
|
||||
rawRow[prop],
|
||||
hasCommentAlready
|
||||
)
|
||||
|
||||
if (action === 'set') {
|
||||
commentsPlugin.setCommentAtCell(
|
||||
rowIndex,
|
||||
colIndex,
|
||||
`${EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX}${rawRow[prop]}`
|
||||
)
|
||||
} else if (action === 'remove') {
|
||||
commentsPlugin.removeCommentAtCell(rowIndex, colIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full resync of every revertable cell's "overwritten" comment against
|
||||
* the current data. Used both at initial load (nothing has a comment
|
||||
* yet, so this is purely additive) and after Cancel (an edit may have
|
||||
* just been discarded, so a previously-set comment can now be stale) -
|
||||
* Handsontable's comments plugin has no way to enumerate its own
|
||||
* comments, so the only way to find a stale one is to re-check every
|
||||
* candidate cell.
|
||||
*/
|
||||
private syncOverwrittenComments(): void {
|
||||
const revertableCols = this.getRevertableColumnNames()
|
||||
if (revertableCols.length === 0 || !this.dataSourceRaw) return
|
||||
|
||||
this.dataSource.forEach((_row, rowIndex) => {
|
||||
for (const col of revertableCols) {
|
||||
this.syncOverwrittenCommentForCell(rowIndex, col)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks every cell whose current value differs from what SAS actually
|
||||
* sent for it (see findOverwrittenCells) with a read-only comment
|
||||
* showing the original value - a HARDFORMULA/SOFTFORMULA rule silently
|
||||
* overwriting real pre-existing data is one way this happens, but so is
|
||||
* any direct edit; both need the same "here's what it used to be, and a
|
||||
* way back" treatment. Must run after hot.updateSettings() has processed
|
||||
* the formulas: getDataAtRowProp only resolves a formula cell's live
|
||||
* computed value once HyperFormula has actually evaluated it, not the
|
||||
* raw formula string dataSource holds right after applyFormulaRules.
|
||||
*
|
||||
* Also flips each affected row's EDIT_STATUS to 'M' - this is a real,
|
||||
* permanent difference from the raw dataset (every future load
|
||||
* recomputes the same formula again, and a direct edit made before this
|
||||
* method ever ran isn't something afterChange would have seen), so
|
||||
* nothing else would otherwise mark these rows modified. Doing this once
|
||||
* here, before dataSourceUnchanged is ever cloned from dataSource, means
|
||||
* the 'M' baseline is already shared by both snapshots - no special
|
||||
* handling needed to keep it from being wiped out by a later
|
||||
* cancelEdit().
|
||||
*/
|
||||
private markOverwrittenCells(): void {
|
||||
this.syncOverwrittenComments()
|
||||
|
||||
const hot = this.hotInstance
|
||||
const revertableCols = this.getRevertableColumnNames()
|
||||
if (revertableCols.length === 0 || !this.dataSourceRaw) return
|
||||
|
||||
const currentRows = this.dataSource.map((row, rowIndex) => ({
|
||||
...row,
|
||||
...Object.fromEntries(
|
||||
revertableCols.map((col) => [col, hot.getDataAtRowProp(rowIndex, col)])
|
||||
)
|
||||
}))
|
||||
|
||||
const changes = findOverwrittenCells(
|
||||
currentRows,
|
||||
this.dataSourceRaw,
|
||||
revertableCols,
|
||||
this.headerPks
|
||||
)
|
||||
const changedRows = new Set(changes.map((change) => change.rowIndex))
|
||||
|
||||
// Not classifyRow/updateEditStatusForRow - dataSourceUnchanged doesn't
|
||||
// exist yet this early (only editTable() sets it), and these rows are
|
||||
// already known-modified from the comment sync above; no need to
|
||||
// reclassify.
|
||||
//
|
||||
// Deferred: setDataAtRowProp needs the Formulas plugin's hidden-column
|
||||
// index mapping to have completed at least one settle pass. Called
|
||||
// synchronously, right after the initial hot.updateSettings(), that
|
||||
// mapping isn't ready yet and HyperFormula throws
|
||||
// ExpectedValueOfTypeError (address.col resolves to undefined for the
|
||||
// trimmed/hidden dc.row_status column) - since ngOnInit's getdata
|
||||
// .catch() swallows anything thrown here, that leaves the whole table
|
||||
// silently hidden. editTable() defers its own post-render work the
|
||||
// same way, for the same index-mapping reason.
|
||||
if (changedRows.size > 0) {
|
||||
setTimeout(() => {
|
||||
for (const rowIndex of changedRows) {
|
||||
hot.setDataAtRowProp(
|
||||
rowIndex,
|
||||
EDIT_STATUS_COLUMN_NAME,
|
||||
'M',
|
||||
'editStatus'
|
||||
)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes this row's classification (M/A/D/U) and writes it into the
|
||||
* EDIT_STATUS cell - the same classification the row-header symbol shows,
|
||||
* but written into a real Handsontable cell so DC.ROW_STATUS-based
|
||||
* formulas can actually react to it. Written via hot.setDataAtRowProp
|
||||
* with a distinct source string (not a bare dataSource mutation) so
|
||||
* HyperFormula is notified and dependents recalculate - the source string
|
||||
* also lets the afterChange hook below recognise and ignore its own
|
||||
* writes, avoiding infinite recursion.
|
||||
*/
|
||||
private updateEditStatusForRow(rowIndex: number): void {
|
||||
const dataRow = this.dataSource[rowIndex]
|
||||
if (!dataRow) return
|
||||
|
||||
const status = classifyRow(
|
||||
dataRow,
|
||||
this.dataSourceUnchanged ?? this.dataSource,
|
||||
this.headerPks
|
||||
)
|
||||
|
||||
this.hotInstance.setDataAtRowProp(
|
||||
rowIndex,
|
||||
EDIT_STATUS_COLUMN_NAME,
|
||||
status,
|
||||
'editStatus'
|
||||
)
|
||||
}
|
||||
|
||||
public cancelSubmit() {
|
||||
this.dataSource = this.helperService.deepClone(this.dataSourceBeforeSubmit)
|
||||
this.dataSourceBeforeSubmit = []
|
||||
@@ -1402,31 +1803,23 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
for (let i = 0; i < this.dataSource.length; i++) {
|
||||
const dataRow = this.helperService.deepClone(this.dataSource[i])
|
||||
|
||||
if (dataRow._____DELETE__THIS__RECORD_____ === 'Yes') {
|
||||
this.dataModified.push(dataRow)
|
||||
rowsDeleted++
|
||||
} else {
|
||||
const dataRowUnchanged = this.dataSourceUnchanged.find((row: any) => {
|
||||
for (const pkCol of this.headerPks) {
|
||||
if (row[pkCol] !== dataRow[pkCol]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (dataRowUnchanged) {
|
||||
if (JSON.stringify(dataRow) !== JSON.stringify(dataRowUnchanged)) {
|
||||
this.dataModified.push(dataRow)
|
||||
this.modifedRowsIndexes.push(i)
|
||||
rowsUpdated++
|
||||
}
|
||||
} else {
|
||||
switch (classifyRow(dataRow, this.dataSourceUnchanged, this.headerPks)) {
|
||||
case 'D':
|
||||
this.dataModified.push(dataRow)
|
||||
rowsDeleted++
|
||||
break
|
||||
case 'A':
|
||||
this.dataModified.push(dataRow)
|
||||
this.modifedRowsIndexes.push(i)
|
||||
rowsAdded++
|
||||
}
|
||||
break
|
||||
case 'M':
|
||||
this.dataModified.push(dataRow)
|
||||
this.modifedRowsIndexes.push(i)
|
||||
rowsUpdated++
|
||||
break
|
||||
case 'U':
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1819,7 +2212,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
while (this.dataSource.length > 0) {
|
||||
const lastRow = this.dataSource[this.dataSource.length - 1]
|
||||
const isEmpty = Object.keys(lastRow).every((key) => {
|
||||
if (key === '_____DELETE__THIS__RECORD_____') return true
|
||||
if (
|
||||
key === '_____DELETE__THIS__RECORD_____' ||
|
||||
key === EDIT_STATUS_COLUMN_NAME
|
||||
)
|
||||
return true
|
||||
return !lastRow[key] || lastRow[key] === ''
|
||||
})
|
||||
|
||||
@@ -1884,7 +2281,34 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
public async saveTable(data: any) {
|
||||
const hot = this.hotInstance
|
||||
const hotData = hot.getData()
|
||||
|
||||
// HARDFORMULA/SOFTFORMULA columns hold a live '=...' formula string in
|
||||
// dataSource - HyperFormula recalculates the DISPLAYED value without
|
||||
// ever mutating that underlying string, so submitting dataSource as-is
|
||||
// would send the formula text itself instead of its computed result.
|
||||
// getDataAtRowProp reads through the formulas plugin's own modifyData
|
||||
// hook, which resolves the engine's live value for that cell. Must run
|
||||
// over the full, unfiltered `data` (physical/array order, same
|
||||
// reference as dataSource) - its index is what toVisualRow/
|
||||
// getDataAtRowProp need; the filtered/mapped array below no longer
|
||||
// lines up with actual grid rows.
|
||||
const formulaRules = (this.dcValidator?.getDqDetails() ?? []).filter(
|
||||
(rule) =>
|
||||
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
|
||||
)
|
||||
|
||||
if (formulaRules.length > 0) {
|
||||
data.forEach((row: any, physicalRowIndex: number) => {
|
||||
const visualRowIndex = hot.toVisualRow(physicalRowIndex)
|
||||
|
||||
for (const rule of formulaRules) {
|
||||
row[rule.BASE_COL] = hot.getDataAtRowProp(
|
||||
visualRowIndex,
|
||||
rule.BASE_COL
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
data = data.filter((dataRow: any) => {
|
||||
const elModified = this.dataModified.find((row) => {
|
||||
@@ -1906,6 +2330,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
delete row['_____DELETE__THIS__RECORD_____']
|
||||
row['_____DELETE__THIS__RECORD_____'] = deleteColValue
|
||||
|
||||
// EDIT_STATUS is client-only (see editStatusColumnRule.ts) - it must
|
||||
// never reach the backend.
|
||||
delete row[EDIT_STATUS_COLUMN_NAME]
|
||||
|
||||
// If cell is numeric and value is dot `.` we change it to `null`
|
||||
Object.keys(row).map((key: string) => {
|
||||
const colRule = this.dcValidator?.getRule(key)
|
||||
@@ -2395,7 +2823,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
variable_nm: clickedColumnKey
|
||||
}
|
||||
],
|
||||
source_row: [clickedRow]
|
||||
// EDIT_STATUS is client-only (see editStatusColumnRule.ts) - it
|
||||
// must never reach the backend.
|
||||
source_row: [withoutEditStatus(clickedRow)]
|
||||
}
|
||||
|
||||
const validationHook = this.dcValidator
|
||||
@@ -2759,6 +3189,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.initSetup(res)
|
||||
})
|
||||
.catch((err: any) => {
|
||||
// A synchronous throw inside initSetup (called from the .then()
|
||||
// above) lands here too, not just a network failure - log it, or
|
||||
// the table just renders hidden with no clue why.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('editors/getdata failed:', err)
|
||||
this.getdataError = true
|
||||
this.tableTrue = true
|
||||
})
|
||||
@@ -3125,6 +3560,13 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
this.headerArray = this.headerColumns.slice(1)
|
||||
|
||||
// EDIT_STATUS is never part of COLHEADERS (it's client-synthesized, see
|
||||
// editStatusColumnRule.ts) - appended after headerArray is derived so it
|
||||
// stays out of whatever headerArray drives, but before headerColumns is
|
||||
// used below for cell-reference math (applyFormulaRules/DcValidator's
|
||||
// rules must stay index-aligned with headerColumns).
|
||||
this.headerColumns.push(EDIT_STATUS_COLUMN_NAME)
|
||||
|
||||
if (response.data.sasparams[0].DTVARS !== '') {
|
||||
this.dateHeaders = response.data.sasparams[0].DTVARS.split(' ')
|
||||
}
|
||||
@@ -3146,12 +3588,58 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
response.data.dqdata
|
||||
)
|
||||
|
||||
// Only turn on Handsontable's formulas plugin (HyperFormula engine) for
|
||||
// tables that actually use HARDFORMULA/SOFTFORMULA - it's not free to
|
||||
// run for every grid. gpl-v3: this app embeds HyperFormula under its
|
||||
// GPLv3 free-tier terms, not a purchased commercial key.
|
||||
this.hotTable.formulas = hasFormulaRules(response.data.dqrules)
|
||||
? { engine: HyperFormula, licenseKey: 'gpl-v3' }
|
||||
: false
|
||||
|
||||
this.cellValidation = this.dcValidator.getRules()
|
||||
|
||||
// to take datasource
|
||||
this.dataSource = response.data.sasdata
|
||||
this.$dataFormats = response.data.$sasdata
|
||||
|
||||
// Raw, as-received snapshot - captured before applyFormulaRules below
|
||||
// overwrites HARDFORMULA/SOFTFORMULA columns, so markOverwrittenCells
|
||||
// can later tell "the formula filled in a blank" apart from "the
|
||||
// formula overwrote a value the real dataset already had".
|
||||
this.dataSourceRaw = this.helperService.deepClone(this.dataSource)
|
||||
|
||||
// Seed every row's EDIT_STATUS to 'U' before anything (HyperFormula
|
||||
// included) ever sees this data - nothing has been edited yet, and this
|
||||
// is also the baseline dataSourceUnchanged will later be cloned from, so
|
||||
// future diffs never see a spurious EDIT_STATUS mismatch (see
|
||||
// classifyRow's withoutEditStatus for why that would matter).
|
||||
for (const row of this.dataSource) {
|
||||
row[EDIT_STATUS_COLUMN_NAME] = 'U'
|
||||
}
|
||||
|
||||
// Seed HARDFORMULA/SOFTFORMULA columns with their computed formula
|
||||
// string per row. dataSourceUnchanged isn't populated yet this early in
|
||||
// a fresh load (only the excel-preview/add-row flows set it) - at this
|
||||
// point, before any edits exist, dataSource IS the unchanged baseline,
|
||||
// so DC.ORIG_VALUE resolves correctly either way.
|
||||
applyFormulaRules(
|
||||
this.dataSource,
|
||||
response.data.dqrules,
|
||||
this.headerColumns,
|
||||
this.dataSourceUnchanged ?? this.dataSource,
|
||||
this.headerPks,
|
||||
this.userService.user?.username ?? ''
|
||||
)
|
||||
|
||||
// Seeded here too (not just editTable()) so a HARDFORMULA/SOFTFORMULA
|
||||
// rule that silently overwrote real pre-existing data (see
|
||||
// markOverwrittenCells) already shows the row as modified - both the
|
||||
// '~' row header and EDIT_STATUS='M' - on the very first render, before
|
||||
// the user ever clicks Edit. editTable() rebuilds this fresh every time
|
||||
// it runs regardless, so setting it here doesn't affect that.
|
||||
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
|
||||
this.overlayFormulaRawValuesOnUnchanged(this.dataSourceUnchanged)
|
||||
|
||||
// Note: this.headerColumns and this.columnHeader contains same data
|
||||
// need to resolve redundancy
|
||||
|
||||
@@ -3183,6 +3671,26 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
columns: this.cellValidation,
|
||||
height: this.hotTable.height,
|
||||
formulas: this.hotTable.formulas,
|
||||
// The Formulas plugin resolves a =CELL_REF formula (e.g. what
|
||||
// DC.ROW_STATUS compiles to, see parseFormulaRule.ts) through its own
|
||||
// data-sync path into HyperFormula - a different path than
|
||||
// getDataAtRowProp/datamap.get() (see editStatusColumnRule.ts for
|
||||
// why a dotted `data` key is otherwise safe there). That path
|
||||
// doesn't check for an own flat property before falling back to
|
||||
// nested dot-notation, so a referenced cell whose `data` key
|
||||
// contains a literal '.' (like dc.row_status) resolves to
|
||||
// undefined/0 instead of its real value. This app never uses nested
|
||||
// `data` paths, so disabling dot-notation entirely lets
|
||||
// DC.ROW_STATUS (and anything that branches on it, e.g.
|
||||
// CHANGE_SUMMARY_COL's IF) resolve correctly without giving up the
|
||||
// collision-proof dotted name.
|
||||
dataDotNotation: false,
|
||||
// readOnly here means comments can never be added/edited/removed
|
||||
// through the UI - only markOverwrittenCells/
|
||||
// syncOverwrittenCommentForCell (via the plugin API) ever set one.
|
||||
// The context menu below only offers our own "Revert" item, never
|
||||
// the plugin's own add/edit/remove ones.
|
||||
comments: { readOnly: true },
|
||||
stretchH: 'all',
|
||||
readOnly: this.hotTable.readOnly,
|
||||
hiddenColumns: {
|
||||
@@ -3204,11 +3712,62 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
filters: false,
|
||||
manualRowResize: true,
|
||||
viewportRowRenderingOffset: 100,
|
||||
// show a bar on the left to enable users to select an entire row
|
||||
// Doubles as the edit-status indicator: +/-/~ for
|
||||
// added/deleted/modified rows. Handsontable re-invokes this on
|
||||
// every render, so it stays live as rows are edited/added/deleted
|
||||
// without any extra wiring. Falls back to a plain space (not an
|
||||
// empty string) for unchanged rows and before dataSource is
|
||||
// populated, keeping the original row-selection bar's click target
|
||||
// intact - a space and '' render identically, so this changes
|
||||
// nothing visually.
|
||||
//
|
||||
// `index` is the VISUAL row - once multiColumnSorting reorders the
|
||||
// grid, that no longer matches dataSource's physical order, so it
|
||||
// must be translated via toPhysicalRow() before indexing into
|
||||
// dataSource. Without this, a sorted grid shows every row's symbol
|
||||
// shifted to the wrong row.
|
||||
rowHeaders: (index: number) => {
|
||||
return ' '
|
||||
const physicalRow = hot.toPhysicalRow(index)
|
||||
const dataRow =
|
||||
physicalRow === null ? undefined : this.dataSource[physicalRow]
|
||||
if (!dataRow) return ' '
|
||||
|
||||
return (
|
||||
getEditStatusSymbol(
|
||||
classifyRow(
|
||||
dataRow,
|
||||
this.dataSourceUnchanged ?? this.dataSource,
|
||||
this.headerPks
|
||||
)
|
||||
) || ' '
|
||||
)
|
||||
},
|
||||
rowHeaderWidth: 15,
|
||||
// Colors the row-header cell to match its edit status (see
|
||||
// rowHeaders above) - same physicalRow translation for the same
|
||||
// sorted-grid reason.
|
||||
afterGetRowHeader: (row: number, th: any) => {
|
||||
const physicalRow = hot.toPhysicalRow(row)
|
||||
const dataRow =
|
||||
physicalRow === null ? undefined : this.dataSource[physicalRow]
|
||||
if (!dataRow) return
|
||||
|
||||
const status = classifyRow(
|
||||
dataRow,
|
||||
this.dataSourceUnchanged ?? this.dataSource,
|
||||
this.headerPks
|
||||
)
|
||||
|
||||
th.classList.remove(
|
||||
'rowStatusAdded',
|
||||
'rowStatusDeleted',
|
||||
'rowStatusModified'
|
||||
)
|
||||
|
||||
if (status === 'A') th.classList.add('rowStatusAdded')
|
||||
if (status === 'D') th.classList.add('rowStatusDeleted')
|
||||
if (status === 'M') th.classList.add('rowStatusModified')
|
||||
},
|
||||
rowHeaderWidth: 20,
|
||||
rowHeights: 24,
|
||||
maxRows: this.licenceState.value.editor_rows_allowed || Infinity,
|
||||
invalidCellClassName: 'htInvalid',
|
||||
@@ -3230,6 +3789,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
},
|
||||
info: {
|
||||
name: 'test info',
|
||||
// Purely informational (no callback) - without this, Handsontable
|
||||
// still treats a click landing anywhere inside it as "the item
|
||||
// was activated" (see preventMenuItemAutoClose's own comment),
|
||||
// auto-closing the menu and blocking the native right-click
|
||||
// "Copy" menu before the user can select any of the text.
|
||||
isCommand: false,
|
||||
renderer: (
|
||||
hot: Handsontable.Core,
|
||||
wrapper: HTMLElement,
|
||||
@@ -3254,10 +3819,22 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
colName = this.hotInstance?.colToProp(selectedCol) as string
|
||||
colInfo = this.$dataFormats?.vars[colName]
|
||||
|
||||
textInfo = buildColInfoHtml(colName, colInfo)
|
||||
const { hardRegexValue, softRegexValue } =
|
||||
this.dcValidator?.getRegexRuleValues(colName) || {}
|
||||
const formulaValue =
|
||||
this.dcValidator?.getFormulaRuleValue(colName)
|
||||
|
||||
textInfo = buildColInfoHtml(
|
||||
colName,
|
||||
colInfo,
|
||||
hardRegexValue,
|
||||
softRegexValue,
|
||||
formulaValue
|
||||
)
|
||||
}
|
||||
|
||||
elem.innerHTML = textInfo
|
||||
preventMenuItemAutoClose(elem)
|
||||
|
||||
return elem
|
||||
}
|
||||
@@ -3343,6 +3920,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
false
|
||||
)
|
||||
|
||||
// Must run after the updateSettings() call above: getDataAtRowProp only
|
||||
// resolves formulas' live computed values once HyperFormula has
|
||||
// actually evaluated them against the data/formulas settings just
|
||||
// applied.
|
||||
this.markOverwrittenCells()
|
||||
|
||||
this.hotTable.hidden = false
|
||||
// Keep the context menu enabled in view mode too so Copy/Export remain
|
||||
// available; editing items hide themselves when read-only.
|
||||
@@ -3467,6 +4050,41 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
})
|
||||
|
||||
// Keeps each edited row's EDIT_STATUS cell in sync live, so
|
||||
// DC.ROW_STATUS-based formulas recalculate immediately - not just on
|
||||
// insert/delete (already handled at their own call sites) but on any
|
||||
// direct edit, paste or autofill too. 'loadData' fires on every
|
||||
// hot.updateSettings({data: ...}) call this component already makes
|
||||
// (initial load, cancelSubmit, ...) where dataSource is already
|
||||
// consistent, so it's skipped; 'editStatus' is this same hook's own
|
||||
// writes (via updateEditStatusForRow), skipped to avoid recursion.
|
||||
//
|
||||
// Also keeps each edited cell's "overwritten" comment in sync live -
|
||||
// markOverwrittenCells only runs once, at initial load, so a direct
|
||||
// edit made afterward (to a cell that wasn't already overwritten by a
|
||||
// formula) needs its own comment set here; typing a value back to
|
||||
// match the original just as readily needs that comment removed again.
|
||||
hot.addHook('afterChange', (changes: any[], source: any) => {
|
||||
if (!changes || source === 'loadData' || source === 'editStatus') return
|
||||
|
||||
const revertableCols = this.getRevertableColumnNames()
|
||||
const changedRows = new Set<number>()
|
||||
for (const change of changes) {
|
||||
if (!change) continue
|
||||
|
||||
const [row, prop] = change
|
||||
if (prop === EDIT_STATUS_COLUMN_NAME) continue
|
||||
|
||||
changedRows.add(row)
|
||||
|
||||
if (revertableCols.includes(prop)) {
|
||||
this.syncOverwrittenCommentForCell(row, prop)
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of changedRows) this.updateEditStatusForRow(row)
|
||||
})
|
||||
|
||||
hot.addHook('afterPaste', async (_data: any, coords: any) => {
|
||||
// In read-only mode HOT discards the paste itself, so nothing to validate.
|
||||
if (this.hotTable.readOnly) return
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { classifyRow } from './classifyRow'
|
||||
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
|
||||
|
||||
describe('classifyRow', () => {
|
||||
const headerPks = ['PRIMARY_KEY_FIELD']
|
||||
|
||||
it('is D when the row is marked for delete, regardless of other field changes', () => {
|
||||
const dataRow = {
|
||||
PRIMARY_KEY_FIELD: 1,
|
||||
SOME_CHAR: 'changed',
|
||||
_____DELETE__THIS__RECORD_____: 'Yes'
|
||||
}
|
||||
const dataSourceUnchanged = [
|
||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||
]
|
||||
|
||||
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('D')
|
||||
})
|
||||
|
||||
it('is A when no row in dataSourceUnchanged matches the primary key(s)', () => {
|
||||
const dataRow = { PRIMARY_KEY_FIELD: 99, SOME_CHAR: 'new row' }
|
||||
const dataSourceUnchanged = [
|
||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||
]
|
||||
|
||||
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('A')
|
||||
})
|
||||
|
||||
it('is M when a matching row exists but a field differs', () => {
|
||||
const dataRow = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'changed' }
|
||||
const dataSourceUnchanged = [
|
||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||
]
|
||||
|
||||
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('M')
|
||||
})
|
||||
|
||||
it('is U when a matching row exists and nothing differs', () => {
|
||||
const dataRow = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||
const dataSourceUnchanged = [
|
||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||
]
|
||||
|
||||
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('U')
|
||||
})
|
||||
|
||||
it('matches on all primary key columns for a composite key', () => {
|
||||
const headerPksComposite = ['LIB', 'ID']
|
||||
const dataRow = { LIB: 'WORK', ID: 1, SOME_CHAR: 'original' }
|
||||
const dataSourceUnchanged = [
|
||||
{ LIB: 'OTHER', ID: 1, SOME_CHAR: 'original' },
|
||||
{ LIB: 'WORK', ID: 1, SOME_CHAR: 'original' }
|
||||
]
|
||||
|
||||
// Same ID=1 exists under a different LIB - must not match that one.
|
||||
expect(
|
||||
classifyRow(dataRow, dataSourceUnchanged, headerPksComposite)
|
||||
).toEqual('U')
|
||||
})
|
||||
|
||||
it('ignores its own EDIT_STATUS column value when diffing, so writing the classification back never self-perpetuates a stale M', () => {
|
||||
const dataRow = {
|
||||
PRIMARY_KEY_FIELD: 1,
|
||||
SOME_CHAR: 'original',
|
||||
[EDIT_STATUS_COLUMN_NAME]: 'M'
|
||||
}
|
||||
const dataSourceUnchanged = [
|
||||
{
|
||||
PRIMARY_KEY_FIELD: 1,
|
||||
SOME_CHAR: 'original',
|
||||
[EDIT_STATUS_COLUMN_NAME]: 'U'
|
||||
}
|
||||
]
|
||||
|
||||
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('U')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { withoutEditStatus } from './withoutEditStatus'
|
||||
|
||||
export type RowEditStatus = 'M' | 'A' | 'D' | 'U'
|
||||
|
||||
/**
|
||||
* Classifies a single row as Modified/Added/Deleted/Unchanged against the
|
||||
* pre-edit snapshot (`dataSourceUnchanged`), PK-matched via `headerPks`.
|
||||
*
|
||||
* Extracted from editor.component.ts's getRowsSubmittingCount(), which used
|
||||
* this same logic inline, only at submit time. Kept here as a pure function
|
||||
* so it can run live (e.g. on every afterChange, for the EDIT_STATUS column)
|
||||
* without duplicating the diffing rules.
|
||||
*/
|
||||
export const classifyRow = (
|
||||
dataRow: any,
|
||||
dataSourceUnchanged: any[],
|
||||
headerPks: string[]
|
||||
): RowEditStatus => {
|
||||
if (dataRow._____DELETE__THIS__RECORD_____ === 'Yes') return 'D'
|
||||
|
||||
const dataRowUnchanged = dataSourceUnchanged.find((row: any) => {
|
||||
for (const pkCol of headerPks) {
|
||||
if (row[pkCol] !== dataRow[pkCol]) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (!dataRowUnchanged) return 'A'
|
||||
|
||||
// EDIT_STATUS holds this very function's own prior output - comparing it
|
||||
// like any other field would self-perpetuate: once a row is written as
|
||||
// 'M', its EDIT_STATUS would forever differ from the unchanged snapshot's
|
||||
// 'U' even after the real edit is reverted, permanently stuck on 'M'.
|
||||
return JSON.stringify(withoutEditStatus(dataRow)) !==
|
||||
JSON.stringify(withoutEditStatus(dataRowUnchanged))
|
||||
? 'M'
|
||||
: 'U'
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expandCellRanges } from './expandCellRanges'
|
||||
|
||||
describe('expandCellRanges', () => {
|
||||
it('expands a single-cell range', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[{ from: { row: 1, col: 1 }, to: { row: 1, col: 1 } }],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([{ row: 1, col: 1 }])
|
||||
})
|
||||
|
||||
it('expands a rectangular multi-cell range', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[{ from: { row: 0, col: 0 }, to: { row: 1, col: 1 } }],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([
|
||||
{ row: 0, col: 0 },
|
||||
{ row: 0, col: 1 },
|
||||
{ row: 1, col: 0 },
|
||||
{ row: 1, col: 1 }
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes a whole-row selection (from.col === -1) to every real column', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[{ from: { row: 1, col: -1 }, to: { row: 1, col: 2 } }],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([
|
||||
{ row: 1, col: 0 },
|
||||
{ row: 1, col: 1 },
|
||||
{ row: 1, col: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes a whole-column selection (from.row === -1) to every real row', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[{ from: { row: -1, col: 1 }, to: { row: 2, col: 1 } }],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([
|
||||
{ row: 0, col: 1 },
|
||||
{ row: 1, col: 1 },
|
||||
{ row: 2, col: 1 }
|
||||
])
|
||||
})
|
||||
|
||||
it('expands every disjoint range when multiple are given (ctrl-click)', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[
|
||||
{ from: { row: 0, col: 0 }, to: { row: 0, col: 0 } },
|
||||
{ from: { row: 2, col: 2 }, to: { row: 2, col: 2 } }
|
||||
],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([
|
||||
{ row: 0, col: 0 },
|
||||
{ row: 2, col: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a null row/col the same as -1 (Handsontable.CellRange types these as number | null)', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[{ from: { row: 1, col: null }, to: { row: 1, col: 2 } }],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([
|
||||
{ row: 1, col: 0 },
|
||||
{ row: 1, col: 1 },
|
||||
{ row: 1, col: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles from/to given in reverse order (drag selection upward/leftward)', () => {
|
||||
expect(
|
||||
expandCellRanges(
|
||||
[{ from: { row: 2, col: 2 }, to: { row: 1, col: 1 } }],
|
||||
3,
|
||||
3
|
||||
)
|
||||
).toEqual([
|
||||
{ row: 1, col: 1 },
|
||||
{ row: 1, col: 2 },
|
||||
{ row: 2, col: 1 },
|
||||
{ row: 2, col: 2 }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface SimpleCellRange {
|
||||
from: { row: number | null; col: number | null }
|
||||
to: { row: number | null; col: number | null }
|
||||
}
|
||||
|
||||
export interface SimpleCell {
|
||||
row: number
|
||||
col: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands one or more Handsontable selection ranges (as returned by
|
||||
* getSelectedRange()/the context menu callback's selection argument) into
|
||||
* every individual (row, col) cell they cover. A whole-row selection (row
|
||||
* header click) reports col: -1 (Handsontable.CellRange itself types this
|
||||
* as number | null, so null is treated the same way here) on whichever end
|
||||
* is the "start" of the range; a whole-column selection reports row: -1
|
||||
* the same way - normalized here to the real 0..totalRows-1/0..totalCols-1
|
||||
* bounds, since neither -1 nor null is a usable index for anything
|
||||
* downstream (comments lookup, setDataAtRowProp, ...). Multiple ranges
|
||||
* (ctrl-click) are all expanded, not just the first.
|
||||
*/
|
||||
export const expandCellRanges = (
|
||||
ranges: SimpleCellRange[],
|
||||
totalRows: number,
|
||||
totalCols: number
|
||||
): SimpleCell[] => {
|
||||
const cells: SimpleCell[] = []
|
||||
|
||||
const normalizeRow = (row: number | null) =>
|
||||
row === -1 || row === null ? undefined : row
|
||||
const normalizeCol = (col: number | null) =>
|
||||
col === -1 || col === null ? undefined : col
|
||||
|
||||
for (const range of ranges) {
|
||||
const rowA = normalizeRow(range.from.row) ?? 0
|
||||
const rowB = normalizeRow(range.to.row) ?? totalRows - 1
|
||||
const colA = normalizeCol(range.from.col) ?? 0
|
||||
const colB = normalizeCol(range.to.col) ?? totalCols - 1
|
||||
|
||||
const startRow = Math.min(rowA, rowB)
|
||||
const endRow = Math.max(rowA, rowB)
|
||||
const startCol = Math.min(colA, colB)
|
||||
const endCol = Math.max(colA, colB)
|
||||
|
||||
for (let row = startRow; row <= endRow; row++) {
|
||||
for (let col = startCol; col <= endCol; col++) {
|
||||
cells.push({ row, col })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cells
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getEditStatusSymbol } from './getEditStatusSymbol'
|
||||
|
||||
describe('getEditStatusSymbol', () => {
|
||||
it("returns '+' for Added", () => {
|
||||
expect(getEditStatusSymbol('A')).toEqual('+')
|
||||
})
|
||||
|
||||
it("returns '-' for Deleted", () => {
|
||||
expect(getEditStatusSymbol('D')).toEqual('-')
|
||||
})
|
||||
|
||||
it("returns '±' for Modified", () => {
|
||||
expect(getEditStatusSymbol('M')).toEqual('±')
|
||||
})
|
||||
|
||||
it("returns '' for Unchanged", () => {
|
||||
expect(getEditStatusSymbol('U')).toEqual('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RowEditStatus } from './classifyRow'
|
||||
|
||||
/**
|
||||
* Maps a row's edit status to the symbol shown in the row-header gutter
|
||||
* (see editor.component.ts's rowHeaders callback). Unchanged renders as an
|
||||
* empty string rather than a letter, since it's the common case and would
|
||||
* otherwise clutter every untouched row.
|
||||
*/
|
||||
export const getEditStatusSymbol = (status: RowEditStatus): string => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
return '+'
|
||||
case 'D':
|
||||
return '-'
|
||||
case 'M':
|
||||
return '±'
|
||||
case 'U':
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { preventMenuItemAutoClose } from './preventMenuItemAutoClose'
|
||||
|
||||
describe('preventMenuItemAutoClose', () => {
|
||||
let parent: HTMLElement
|
||||
let child: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
parent = document.createElement('div')
|
||||
child = document.createElement('span')
|
||||
parent.appendChild(child)
|
||||
document.body.appendChild(parent)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(parent)
|
||||
})
|
||||
|
||||
const bubblingEvents = ['mousedown', 'mouseup', 'contextmenu', 'selectstart']
|
||||
|
||||
bubblingEvents.forEach((eventName) => {
|
||||
it(`stops a ${eventName} dispatched on the element from bubbling to its parent`, () => {
|
||||
let bubbledToParent = false
|
||||
parent.addEventListener(eventName, () => (bubbledToParent = true))
|
||||
|
||||
preventMenuItemAutoClose(child)
|
||||
child.dispatchEvent(new Event(eventName, { bubbles: true }))
|
||||
|
||||
expect(bubbledToParent).toBe(false)
|
||||
})
|
||||
|
||||
it(`still lets a ${eventName} dispatched directly on the parent reach the parent (propagation isn't globally broken)`, () => {
|
||||
let bubbledToParent = false
|
||||
parent.addEventListener(eventName, () => (bubbledToParent = true))
|
||||
|
||||
preventMenuItemAutoClose(child)
|
||||
parent.dispatchEvent(new Event(eventName, { bubbles: true }))
|
||||
|
||||
expect(bubbledToParent).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call preventDefault on contextmenu, so the browser can still show its own menu', () => {
|
||||
preventMenuItemAutoClose(child)
|
||||
|
||||
const event = new Event('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
child.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
// Handsontable's Menu widget (used by both dropdownMenu and contextMenu)
|
||||
// treats a click landing anywhere inside a non-passive item as "the item was
|
||||
// activated": mouseup auto-closes the menu, and contextmenu is
|
||||
// unconditionally preventDefault()'d, so the browser's own right-click menu
|
||||
// never appears - regardless of whether the item actually has a callback.
|
||||
// A custom-rendered, read-only item (plain informational text, nothing to
|
||||
// click) still gets this treatment, making its content impossible to select
|
||||
// or copy. Handsontable's own listeners are bubble-phase, attached on
|
||||
// ancestors of the rendered item element, so stopping propagation at the
|
||||
// item itself is enough to reach them before they run - no capture-phase
|
||||
// handling needed.
|
||||
const EVENTS_TO_ISOLATE = [
|
||||
'mousedown',
|
||||
'mouseup',
|
||||
'contextmenu',
|
||||
'selectstart'
|
||||
] as const
|
||||
|
||||
export const preventMenuItemAutoClose = (elem: HTMLElement): void => {
|
||||
for (const eventName of EVENTS_TO_ISOLATE) {
|
||||
elem.addEventListener(eventName, (event) => event.stopPropagation())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
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 }
|
||||
}
|
||||
|
||||
const buildHotNumeric = (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}$', undefined, true)
|
||||
},
|
||||
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
return { hot, container }
|
||||
}
|
||||
|
||||
it('adds dc-warning-cell and a REGEX: title 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()
|
||||
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
|
||||
|
||||
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 the plain SAS missing (".") on a numeric column', () => {
|
||||
const { hot, container } = buildHotNumeric([
|
||||
{ val: '.', _____DELETE__THIS__RECORD_____: 'No' }
|
||||
])
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('DOES add dc-warning-cell for a special missing on a numeric column (a deliberately-set value)', () => {
|
||||
const { hot, container } = buildHotNumeric([
|
||||
{ val: '.a', _____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 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()
|
||||
})
|
||||
|
||||
describe('HARDREGEX (second, optional pattern)', () => {
|
||||
// HARDREGEX already blocks submission via dqValidate/HOT's own
|
||||
// htInvalid (unchanged, untouched here) - this renderer only adds the
|
||||
// matching 'REGEX: <pattern>' title on top, so a column with only a
|
||||
// HARDREGEX rule (no SOFTREGEX at all) still tells the user why a cell
|
||||
// is red, not just that it is.
|
||||
const buildHardOnlyHot = (value: string) => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const hot = new Handsontable(container, {
|
||||
data: [{ val: value, _____DELETE__THIS__RECORD_____: 'No' }],
|
||||
columns: [
|
||||
{
|
||||
data: 'val',
|
||||
renderer: makeRegexWarningRenderer(undefined, '^[A-Z]{3}$')
|
||||
},
|
||||
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
return { hot, container }
|
||||
}
|
||||
|
||||
it('sets a REGEX: title (no dc-warning-cell) when only HARDREGEX fails', () => {
|
||||
const { hot, container } = buildHardOnlyHot('abc')
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('sets no title when the value passes HARDREGEX', () => {
|
||||
const { hot, container } = buildHardOnlyHot('ABC')
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
expect(td?.title).toEqual('')
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('suppresses the HARDREGEX title on a row marked for delete', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const hot = new Handsontable(container, {
|
||||
data: [{ val: 'abc', _____DELETE__THIS__RECORD_____: 'Yes' }],
|
||||
columns: [
|
||||
{
|
||||
data: 'val',
|
||||
renderer: makeRegexWarningRenderer(undefined, '^[A-Z]{3}$')
|
||||
},
|
||||
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
expect(hot.getCell(0, 0)?.title).toEqual('')
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('prefers the HARDREGEX title over SOFTREGEX when a value fails both', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const hot = new Handsontable(container, {
|
||||
data: [{ val: 'ab', _____DELETE__THIS__RECORD_____: 'No' }],
|
||||
columns: [
|
||||
{
|
||||
data: 'val',
|
||||
renderer: makeRegexWarningRenderer('^.{5,10}$', '^[A-Z]{3}$')
|
||||
},
|
||||
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
// 'ab' fails HARDREGEX (not 3 uppercase letters) AND SOFTREGEX (too
|
||||
// short) - HARDREGEX wins: its title shows, and no yellow class is
|
||||
// added (HOT's own red htInvalid governs this cell instead).
|
||||
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('never warns for SOFTREGEX when the column also has HARDREGEX, even for a value that passes HARDREGEX', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const hot = new Handsontable(container, {
|
||||
data: [{ val: 'AB', _____DELETE__THIS__RECORD_____: 'No' }],
|
||||
columns: [
|
||||
{
|
||||
data: 'val',
|
||||
renderer: makeRegexWarningRenderer('^.{5,10}$', '^[A-Z0-9]+$')
|
||||
},
|
||||
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
// 'AB' passes HARDREGEX (uppercase/digits) but fails SOFTREGEX (too
|
||||
// short) - only one regex runs per column, so the soft rule is
|
||||
// ignored entirely: no warning, no title.
|
||||
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||
expect(td?.title).toEqual('')
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('does not throw and never warns on a malformed HARDREGEX 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(undefined, '[unterminated')
|
||||
},
|
||||
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
|
||||
expect(() => hot.render()).not.toThrow()
|
||||
expect(hot.getCell(0, 0)?.title).toEqual('')
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import Handsontable from 'handsontable'
|
||||
import { isRegexRuleExempt } from '../../shared/dc-validator/utils/isRegexRuleExempt'
|
||||
import { parseRegexRule } from '../../shared/dc-validator/utils/parseRegexRule'
|
||||
|
||||
const compileRegex = (
|
||||
pattern: string | undefined,
|
||||
ruleType: 'SOFTREGEX' | 'HARDREGEX'
|
||||
): RegExp | null => {
|
||||
try {
|
||||
return pattern ? parseRegexRule(pattern) : null
|
||||
} catch (e) {
|
||||
console.warn(`${ruleType} - invalid pattern, warning disabled: ${pattern}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a display-only HOT renderer for HARDREGEX/SOFTREGEX: neither ever
|
||||
* returns false from the cell validator here (that's dqValidate's job for
|
||||
* HARDREGEX, which blocks submission and paints HOT's own red htInvalid
|
||||
* independently of this renderer) - this renderer only adds the matching
|
||||
* `REGEX: <pattern>` title, plus a yellow `dc-warning-cell` class when only
|
||||
* SOFTREGEX fails, same split as makeNumberFormatRenderer.
|
||||
*
|
||||
* Only one regex ever runs per column: when a HARDREGEX rule exists,
|
||||
* SOFTREGEX is ignored entirely (never compiled, never evaluated) -
|
||||
* regardless of whether individual cell values pass or fail the hard
|
||||
* rule. A value failing HARDREGEX gets its title (no yellow class - red
|
||||
* htInvalid already covers the color). This mirrors
|
||||
* DcValidator.failsSoftRegex's own precedence.
|
||||
*
|
||||
* 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 a pattern itself is malformed,
|
||||
* rather than breaking the cell.
|
||||
*/
|
||||
export const makeRegexWarningRenderer = (
|
||||
softPattern?: string,
|
||||
hardPattern?: string,
|
||||
isNumeric: boolean = false
|
||||
) => {
|
||||
const hardRegex = compileRegex(hardPattern, 'HARDREGEX')
|
||||
const softRegex = hardRegex ? null : compileRegex(softPattern, 'SOFTREGEX')
|
||||
|
||||
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 exempt = isRegexRuleExempt(value, isNumeric)
|
||||
|
||||
const failsHard =
|
||||
!!hardRegex && !exempt && !hardRegex.test(value.toString())
|
||||
const failsSoft =
|
||||
!!softRegex && !exempt && !softRegex.test(value.toString())
|
||||
|
||||
if (markedForDelete) return td
|
||||
|
||||
if (failsHard) {
|
||||
td.title = `REGEX: ${hardPattern}`
|
||||
} else if (failsSoft) {
|
||||
td.classList.add('dc-warning-cell')
|
||||
td.title = `REGEX: ${softPattern}`
|
||||
}
|
||||
|
||||
return td
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { withoutEditStatus } from './withoutEditStatus'
|
||||
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
|
||||
|
||||
describe('withoutEditStatus', () => {
|
||||
it('removes the EDIT_STATUS column from a row', () => {
|
||||
const row = {
|
||||
PRIMARY_KEY_FIELD: 1,
|
||||
SOME_CHAR: 'original',
|
||||
[EDIT_STATUS_COLUMN_NAME]: 'U'
|
||||
}
|
||||
|
||||
expect(withoutEditStatus(row)).toEqual({
|
||||
PRIMARY_KEY_FIELD: 1,
|
||||
SOME_CHAR: 'original'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a row without the EDIT_STATUS column unchanged', () => {
|
||||
const row = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||
|
||||
expect(withoutEditStatus(row)).toEqual(row)
|
||||
})
|
||||
|
||||
it('does not mutate the row passed in', () => {
|
||||
const row = { PRIMARY_KEY_FIELD: 1, [EDIT_STATUS_COLUMN_NAME]: 'U' }
|
||||
|
||||
withoutEditStatus(row)
|
||||
|
||||
expect(row[EDIT_STATUS_COLUMN_NAME]).toEqual('U')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
|
||||
|
||||
/**
|
||||
* Returns a copy of row without the client-only EDIT_STATUS column (see
|
||||
* editStatusColumnRule.ts) - shared by classifyRow (which must ignore it
|
||||
* when diffing, or writing its own prior output back would self-perpetuate)
|
||||
* and by anywhere a row is sent to the backend (which must never receive
|
||||
* it at all).
|
||||
*/
|
||||
export const withoutEditStatus = (row: any): any => {
|
||||
const { [EDIT_STATUS_COLUMN_NAME]: _editStatus, ...rest } = row
|
||||
|
||||
return rest
|
||||
}
|
||||
@@ -1,144 +1,167 @@
|
||||
<app-sidebar class="sidebar-height">
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngIf="groups" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="Filter by Groups"
|
||||
name="input"
|
||||
[(ngModel)]="groupSearch"
|
||||
(keyup)="groupListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length > 0"
|
||||
(click)="groupSearch = ''; groupListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
|
||||
<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>
|
||||
@if (groups) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="Filter by Groups"
|
||||
name="input"
|
||||
[(ngModel)]="groupSearch"
|
||||
(keyup)="groupListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchLibTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchLibTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
(click)="groupSearch = ''; groupListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
</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>
|
||||
</app-sidebar>
|
||||
|
||||
<div class="content-area">
|
||||
<div *ngIf="loading" class="loadingSpinner">
|
||||
<span class="spinner"> Loading... </span>
|
||||
</div>
|
||||
<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>
|
||||
@if (loading) {
|
||||
<div class="loadingSpinner">
|
||||
<span class="spinner"> Loading... </span>
|
||||
</div>
|
||||
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<div class="card group-data">
|
||||
<div *ngIf="serverType !== ServerType.SasViya">
|
||||
<h3>MEMBERS ({{ groupMemberCount }})</h3>
|
||||
<h5 *ngIf="groupMemberCount == 0">No Members Present</h5>
|
||||
<div class="table-container">
|
||||
<table *ngIf="groupMemberCount != 0" class="table member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<ng-container *ngIf="serverType === ServerType.Sas9">
|
||||
<td class="width-25"><b>NAME</b></td>
|
||||
<td class="width-25"><b>EMAIL</b></td>
|
||||
<td class="width-25"><b>CREATED</b></td>
|
||||
<td class=""><b>UPDATED</b></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="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>
|
||||
</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>
|
||||
}
|
||||
@if (groupMembers && !loading) {
|
||||
<div>
|
||||
@if (serverType !== ServerType.SasViya) {
|
||||
<div 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 *ngIf="serverType === ServerType.SasViya">
|
||||
<h3>{{ groupName }}</h3>
|
||||
<h5 *ngIf="groupMemberCount == 0">No Members Present</h5>
|
||||
<div class="table-container">
|
||||
<table *ngIf="groupMemberCount != 0" class="table member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
<b>MEMBERS ({{ groupMemberCount }})</b>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
[routerLink]="'/view/usernav/users/' + member.MEMBERID"
|
||||
*ngFor="let member of groupMembers"
|
||||
>
|
||||
<td class="">{{ member.MEMBERNAME }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<div class="card group-data">
|
||||
@if (serverType !== ServerType.SasViya) {
|
||||
<div>
|
||||
<h3>MEMBERS ({{ groupMemberCount }})</h3>
|
||||
@if (groupMemberCount == 0) {
|
||||
<h5>No Members Present</h5>
|
||||
}
|
||||
<div class="table-container">
|
||||
@if (groupMemberCount != 0) {
|
||||
<table class="table member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@if (serverType === ServerType.Sas9) {
|
||||
<td class="width-25"><b>NAME</b></td>
|
||||
<td class="width-25"><b>EMAIL</b></td>
|
||||
<td class="width-25"><b>CREATED</b></td>
|
||||
<td class=""><b>UPDATED</b></td>
|
||||
}
|
||||
@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>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,159 +1,158 @@
|
||||
<app-sidebar>
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngIf="treeNodeLibraries?.length! > 0" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
clrInput
|
||||
appStealFocus
|
||||
#searchLibTreeInput
|
||||
placeholder="Libraries"
|
||||
name="input"
|
||||
[(ngModel)]="librariesSearch"
|
||||
(keyup)="libraryOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length > 0"
|
||||
(click)="librariesSearch = ''; libraryOnFilter()"
|
||||
shape="times"
|
||||
aria-label="Clear libraries search"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
|
||||
<ng-container *ngFor="let library of treeNodeLibraries">
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, library); lib = library.LIBRARYREF"
|
||||
*ngIf="!library['hidden']"
|
||||
[(clrExpanded)]="library['expanded']"
|
||||
[clrLoading]="library['loadingTables'] && !library.tables"
|
||||
[class.clr-expanded]="library['expanded']"
|
||||
>
|
||||
<p
|
||||
(click)="
|
||||
lib = library.LIBRARYREF;
|
||||
libraryOnClick(library.LIBRARYREF, library)
|
||||
"
|
||||
class="m-0 cursor-pointer"
|
||||
>
|
||||
<clr-icon shape="rack-server" aria-hidden="true"></clr-icon>
|
||||
{{ library.LIBRARYREF }}
|
||||
</p>
|
||||
|
||||
<clr-tree-node *ngIf="library['tables']" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchTreeInput
|
||||
placeholder="Tables"
|
||||
name="input"
|
||||
[(ngModel)]="library['searchString']"
|
||||
(keyup)="treeOnFilter(library, 'tables')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
@if (treeNodeLibraries?.length! > 0) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
clrInput
|
||||
appStealFocus
|
||||
#searchLibTreeInput
|
||||
placeholder="Libraries"
|
||||
name="input"
|
||||
[(ngModel)]="librariesSearch"
|
||||
(keyup)="libraryOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchLibTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search" aria-hidden="true"></clr-icon>
|
||||
}
|
||||
@if (searchLibTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length > 0"
|
||||
(click)="
|
||||
searchTreeInput.value = '';
|
||||
library['searchString'] = '';
|
||||
treeOnFilter(library, 'tables')
|
||||
"
|
||||
(click)="librariesSearch = ''; libraryOnFilter()"
|
||||
shape="times"
|
||||
aria-label="Clear tables search"
|
||||
aria-label="Clear libraries search"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
|
||||
<clr-tree-node
|
||||
*ngFor="let libTable of library['tables']; let index = index"
|
||||
>
|
||||
<clr-tooltip
|
||||
*ngVar="
|
||||
index + 1 >
|
||||
licenceState.value.tables_in_library_limit as tableLocked
|
||||
"
|
||||
>
|
||||
<button
|
||||
clrTooltipTrigger
|
||||
(click)="!tableLocked ? onTableClick(libTable, library) : ''"
|
||||
class="clr-treenode-link"
|
||||
[class.dc-locked-control]="tableLocked"
|
||||
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
|
||||
>
|
||||
<ng-container [ngSwitch]="libTable.includes('-FC')">
|
||||
<clr-icon
|
||||
*ngSwitchCase="true"
|
||||
shape="bolt"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngSwitchCase="false"
|
||||
shape="table"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
</ng-container>
|
||||
{{ libTable.replace('-FC', '') }}
|
||||
</button>
|
||||
|
||||
<ng-container *ngIf="tableLocked">
|
||||
<clr-tooltip-content
|
||||
clrPosition="bottom-right"
|
||||
clrSize="lg"
|
||||
*clrIfOpen
|
||||
>
|
||||
<span>
|
||||
To unlock all tables, contact support@datacontroller.io
|
||||
</span>
|
||||
</clr-tooltip-content>
|
||||
</ng-container>
|
||||
</clr-tooltip>
|
||||
</clr-tree-node>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
@for (library of treeNodeLibraries; track library) {
|
||||
@if (!library['hidden']) {
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, library); lib = library.LIBRARYREF"
|
||||
[(clrExpanded)]="library['expanded']"
|
||||
[clrLoading]="library['loadingTables'] && !library.tables"
|
||||
[class.clr-expanded]="library['expanded']"
|
||||
>
|
||||
<p
|
||||
(click)="
|
||||
lib = library.LIBRARYREF;
|
||||
libraryOnClick(library.LIBRARYREF, library)
|
||||
"
|
||||
class="m-0 cursor-pointer"
|
||||
>
|
||||
<clr-icon shape="rack-server" aria-hidden="true"></clr-icon>
|
||||
{{ library.LIBRARYREF }}
|
||||
</p>
|
||||
@if (library['tables']) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchTreeInput
|
||||
placeholder="Tables"
|
||||
name="input"
|
||||
[(ngModel)]="library['searchString']"
|
||||
(keyup)="treeOnFilter(library, 'tables')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search" aria-hidden="true"></clr-icon>
|
||||
}
|
||||
@if (searchTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
(click)="
|
||||
searchTreeInput.value = '';
|
||||
library['searchString'] = '';
|
||||
treeOnFilter(library, 'tables')
|
||||
"
|
||||
shape="times"
|
||||
aria-label="Clear tables search"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
}
|
||||
@for (
|
||||
libTable of library['tables'];
|
||||
track libTable;
|
||||
let index = $index
|
||||
) {
|
||||
<clr-tree-node>
|
||||
<clr-tooltip
|
||||
*ngVar="
|
||||
index + 1 >
|
||||
licenceState.value.tables_in_library_limit as tableLocked
|
||||
"
|
||||
>
|
||||
<button
|
||||
clrTooltipTrigger
|
||||
(click)="!tableLocked ? onTableClick(libTable, library) : ''"
|
||||
class="clr-treenode-link"
|
||||
[class.dc-locked-control]="tableLocked"
|
||||
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
|
||||
>
|
||||
@switch (libTable.includes('-FC')) {
|
||||
@case (true) {
|
||||
<clr-icon shape="bolt" aria-hidden="true"></clr-icon>
|
||||
}
|
||||
@case (false) {
|
||||
<clr-icon shape="table" aria-hidden="true"></clr-icon>
|
||||
}
|
||||
}
|
||||
{{ libTable.replace('-FC', '') }}
|
||||
</button>
|
||||
@if (tableLocked) {
|
||||
<clr-tooltip-content
|
||||
clrPosition="bottom-right"
|
||||
clrSize="lg"
|
||||
*clrIfOpen
|
||||
>
|
||||
<span>
|
||||
To unlock all tables, contact
|
||||
support@datacontroller.io
|
||||
</span>
|
||||
</clr-tooltip-content>
|
||||
}
|
||||
</clr-tooltip>
|
||||
</clr-tree-node>
|
||||
}
|
||||
</clr-tree-node>
|
||||
}
|
||||
}
|
||||
</clr-tree>
|
||||
</app-sidebar>
|
||||
|
||||
<main class="content-area">
|
||||
<div class="card-block">
|
||||
<div *ngIf="loading" class="spinner-wrapper-fullpage">
|
||||
<div class="loadingSpinner">
|
||||
<span class="spinner"> Loading... </span>
|
||||
@if (loading) {
|
||||
<div class="spinner-wrapper-fullpage">
|
||||
<div class="loadingSpinner">
|
||||
<span class="spinner"> Loading... </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div *ngIf="!loading" class="no-table-selected">
|
||||
<img
|
||||
src="images/select-table.png"
|
||||
class="select-table-icon"
|
||||
alt="select table icon"
|
||||
/>
|
||||
<p
|
||||
*ngIf="treeNodeLibraries?.length! > 0"
|
||||
class="text-center color-gray mt-10"
|
||||
cds-text="section"
|
||||
>
|
||||
Please select a table
|
||||
</p>
|
||||
<p
|
||||
*ngIf="treeNodeLibraries?.length! < 1"
|
||||
class="text-center color-gray mt-10"
|
||||
cds-text="section"
|
||||
>
|
||||
No Editable Tables Configured
|
||||
</p>
|
||||
</div>
|
||||
@if (!loading) {
|
||||
<div class="no-table-selected">
|
||||
<img
|
||||
src="images/select-table.png"
|
||||
class="select-table-icon"
|
||||
alt="select table icon"
|
||||
/>
|
||||
@if (treeNodeLibraries?.length! > 0) {
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a table
|
||||
</p>
|
||||
}
|
||||
@if (treeNodeLibraries?.length! < 1) {
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
No Editable Tables Configured
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
<div class="card-text">
|
||||
<ng-container *ngSwitchCase="'key'">
|
||||
<p class="key-error" *ngIf="!keyError">
|
||||
<clr-icon
|
||||
class="is-error"
|
||||
shape="exclamation-circle"
|
||||
size="26"
|
||||
></clr-icon>
|
||||
Licence key is invalid. We can't provide you more details at the
|
||||
moment
|
||||
</p>
|
||||
@@ -20,6 +25,11 @@
|
||||
|
||||
<ng-container *ngSwitchCase="'limit'">
|
||||
<p class="key-error">
|
||||
<clr-icon
|
||||
class="is-error"
|
||||
shape="exclamation-circle"
|
||||
size="26"
|
||||
></clr-icon>
|
||||
The registered number of users reached the limit specified for your
|
||||
licence. Please contact
|
||||
<contact-link classes="color-green" />
|
||||
@@ -34,9 +44,9 @@
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<p><strong>Protocol:</strong> {{ protocol }}</p>
|
||||
<p class="m-0 mt-10"><strong>Protocol:</strong> {{ protocol }}</p>
|
||||
|
||||
<p>
|
||||
<p class="m-0">
|
||||
<strong>SYSSITE:</strong>
|
||||
<span
|
||||
*ngFor="let id of syssite.value; let i = index"
|
||||
@@ -59,11 +69,6 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p *ngIf="licenseKeyData && userCountLimitation" class="m-0">
|
||||
<strong>Allowed users:</strong>
|
||||
{{ licenseKeyData.users_allowed }}
|
||||
</p>
|
||||
|
||||
<clr-tabs>
|
||||
<clr-tab>
|
||||
<button clrTabLink>Upload licence</button>
|
||||
@@ -102,34 +107,102 @@
|
||||
<clr-tab>
|
||||
<button clrTabLink>Paste licence</button>
|
||||
<clr-tab-content>
|
||||
<form class="clr-form license-key-form">
|
||||
<p>Licence key:</p>
|
||||
<div class="clr-control-container">
|
||||
<textarea
|
||||
[(ngModel)]="licenceKeyValue"
|
||||
(mouseleave)="trimKeys()"
|
||||
name="license-key-area"
|
||||
placeholder="Paste licence key here"
|
||||
class="clr-textarea"
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<ng-container *ngIf="keyFormat === 'combined'">
|
||||
<form class="clr-form combined-key-form">
|
||||
<p>Licence key:</p>
|
||||
<div class="clr-control-container">
|
||||
<textarea
|
||||
[(ngModel)]="combinedKeyValue"
|
||||
(mouseleave)="onCombinedKeyInput()"
|
||||
name="combined-key-area"
|
||||
placeholder="Paste licence key here"
|
||||
class="clr-textarea"
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="clr-form activation-key-form">
|
||||
<p>Activation key:</p>
|
||||
<div class="clr-control-container">
|
||||
<textarea
|
||||
[(ngModel)]="activationKeyValue"
|
||||
(mouseleave)="trimKeys()"
|
||||
name="activation-key-area"
|
||||
placeholder="Paste activation key here"
|
||||
class="clr-textarea"
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link p-0"
|
||||
(click)="toggleKeyFormat()"
|
||||
>
|
||||
Paste as two separate keys instead
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="keyFormat === 'legacy'">
|
||||
<form class="clr-form license-key-form">
|
||||
<p>Licence key:</p>
|
||||
<div class="clr-control-container">
|
||||
<textarea
|
||||
[(ngModel)]="licenceKeyValue"
|
||||
(mouseleave)="trimKeys()"
|
||||
name="license-key-area"
|
||||
placeholder="Paste licence key here"
|
||||
class="clr-textarea"
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="clr-form activation-key-form">
|
||||
<p>Activation key:</p>
|
||||
<div class="clr-control-container">
|
||||
<textarea
|
||||
[(ngModel)]="activationKeyValue"
|
||||
(mouseleave)="trimKeys()"
|
||||
name="activation-key-area"
|
||||
placeholder="Paste activation key here"
|
||||
class="clr-textarea"
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link p-0"
|
||||
(click)="toggleKeyFormat()"
|
||||
>
|
||||
Paste as a single key instead
|
||||
</button>
|
||||
</ng-container>
|
||||
</clr-tab-content>
|
||||
</clr-tab>
|
||||
</clr-tabs>
|
||||
|
||||
<!--
|
||||
licenseKeyData starts as whatever key is currently active (set once
|
||||
in ngOnInit), then live-updates to preview a pasted-but-not-yet-
|
||||
applied key's own details instead - see refreshKeyPreview().
|
||||
-->
|
||||
<div *ngIf="licenseKeyData" class="key-details">
|
||||
<h6 cds-text="subsection" class="mt-15 mb-10">Key Details</h6>
|
||||
|
||||
<p class="m-0">
|
||||
<strong>Valid until:</strong>
|
||||
{{ licenseKeyData.valid_until }}
|
||||
<span *ngIf="licenseKeyData.demo">(demo/free tier key)</span>
|
||||
</p>
|
||||
|
||||
<p class="m-0">
|
||||
<strong>Allowed users:</strong>
|
||||
{{ licenseKeyData.users_allowed }}
|
||||
</p>
|
||||
|
||||
<p class="m-0">
|
||||
<strong>Site ID(s) in this key:</strong>
|
||||
{{
|
||||
(licenseKeyData.site_id_multiple?.length
|
||||
? licenseKeyData.site_id_multiple
|
||||
: [licenseKeyData.site_id]
|
||||
).join(', ')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<p *ngIf="enabledFeatures.length" class="m-0">
|
||||
<strong>Enabled features:</strong>
|
||||
{{ enabledFeatures.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer d-flex clr-align-items-center">
|
||||
@@ -150,6 +223,38 @@
|
||||
Continue with free tier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="key-error protocol-mismatch-warning"
|
||||
*ngIf="protocolMismatch === 'requiresHttps'"
|
||||
>
|
||||
<clr-icon
|
||||
class="color-orange"
|
||||
shape="exclamation-triangle"
|
||||
size="26"
|
||||
></clr-icon>
|
||||
This key was generated for a secure (HTTPS) connection, but DataController
|
||||
is currently running over HTTP - it will not activate here. Access
|
||||
DataController via HTTPS, or contact
|
||||
<contact-link classes="color-green" />
|
||||
for an HTTP-compatible key.
|
||||
</p>
|
||||
|
||||
<p
|
||||
class="key-error protocol-mismatch-warning"
|
||||
*ngIf="protocolMismatch === 'requiresHttp'"
|
||||
>
|
||||
<clr-icon
|
||||
class="color-orange"
|
||||
shape="exclamation-triangle"
|
||||
size="26"
|
||||
></clr-icon>
|
||||
This key was generated for an insecure (HTTP) connection, but this page is
|
||||
running in a secure browsing context (HTTPS, or localhost) - it will not
|
||||
activate here. Contact
|
||||
<contact-link classes="color-green" />
|
||||
for an HTTPS-compatible key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.key-error clr-icon {
|
||||
// clr-icon defaults to vertical-align: middle, which sits it noticeably
|
||||
// above the text baseline next to it - align its bottom edge with the
|
||||
// text's instead.
|
||||
vertical-align: bottom;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Component, OnInit, ViewEncapsulation } from '@angular/core'
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { AppService, LicenceService, SasService } from '../services'
|
||||
import { LicenseKeyData } from '../models/LicenseKeyData'
|
||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||
import {
|
||||
detectLicenceKeyProtocolMismatch,
|
||||
LicenceKeyProtocolMismatch
|
||||
} from './utils/detectLicenceKeyProtocolMismatch'
|
||||
import {
|
||||
isCombinedLicenceKey,
|
||||
splitCombinedLicenceKey
|
||||
} from './utils/combinedLicenceKey'
|
||||
import { getEnabledFeatureLabels } from './utils/getEnabledFeatureLabels'
|
||||
|
||||
enum LicenseActions {
|
||||
key = 'key',
|
||||
@@ -21,12 +31,19 @@ enum LicenseActions {
|
||||
export class LicensingComponent implements OnInit {
|
||||
public action: LicenseActions | null = null
|
||||
|
||||
public licenseErrors: { [key: string]: string } = {
|
||||
missing: `Licence key is missing - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
|
||||
expired: `Licence key is expired - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
|
||||
invalid: `Licence key is invalid - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
|
||||
missmatch: `Your SYSSITE (below) is not found in the licence key - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
|
||||
}
|
||||
// Each message carries its own icon markup, rather than relying on a
|
||||
// sibling element in the template - these render via [innerHTML], which
|
||||
// replaces all of the host element's children, so a real <clr-icon>
|
||||
// placed in the template around the binding would never survive.
|
||||
private readonly errorIcon = `<clr-icon class="is-error" shape="exclamation-circle" size="26"></clr-icon>`
|
||||
|
||||
// SafeHtml, built via bypassSecurityTrustHtml in the constructor (needs
|
||||
// DomSanitizer, not yet available in a field initializer) - Angular's
|
||||
// default [innerHTML] sanitizer strips unrecognised tags like <clr-icon>
|
||||
// (a custom element, not on its standard-tags allowlist), even though it
|
||||
// leaves the plain <a> in these same strings alone. Safe to bypass since
|
||||
// this content is entirely hardcoded here, never derived from user input.
|
||||
public licenseErrors: { [key: string]: SafeHtml }
|
||||
|
||||
public keyError: string | undefined
|
||||
public errorDetails: string | undefined
|
||||
@@ -34,6 +51,12 @@ export class LicensingComponent implements OnInit {
|
||||
public licenceKeyValue: string = ''
|
||||
public activationKeyValue: string = ''
|
||||
|
||||
// Purely which INPUT LAYOUT is shown (one field vs two) - not a strict
|
||||
// parsing gate. Recognising a combined key doesn't depend on this: see
|
||||
// maybeSplitCombinedKey(), called from both layouts' own input handlers.
|
||||
public keyFormat: 'combined' | 'legacy' = 'combined'
|
||||
public combinedKeyValue: string = ''
|
||||
|
||||
public applyingKeys: boolean = false
|
||||
public protocol: string =
|
||||
location.protocol === 'https:'
|
||||
@@ -44,7 +67,6 @@ export class LicensingComponent implements OnInit {
|
||||
public currentLicenceKey = this.licenceService.licenceKey
|
||||
public currentActivationKey = this.licenceService.activationKey
|
||||
public isAppFreeTier = this.licenceService.isAppFreeTier
|
||||
public userCountLimitation = this.licenceService.userCountLimitation
|
||||
|
||||
public licenseKeyData: LicenseKeyData | null = null
|
||||
|
||||
@@ -60,8 +82,24 @@ export class LicensingComponent implements OnInit {
|
||||
private router: Router,
|
||||
private licenceService: LicenceService,
|
||||
private sasService: SasService,
|
||||
private appService: AppService
|
||||
) {}
|
||||
private appService: AppService,
|
||||
private sanitizer: DomSanitizer
|
||||
) {
|
||||
this.licenseErrors = {
|
||||
missing: this.sanitizer.bypassSecurityTrustHtml(
|
||||
`${this.errorIcon} Licence key is missing - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
|
||||
),
|
||||
expired: this.sanitizer.bypassSecurityTrustHtml(
|
||||
`${this.errorIcon} Licence key is expired - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
|
||||
),
|
||||
invalid: this.sanitizer.bypassSecurityTrustHtml(
|
||||
`${this.errorIcon} Licence key is invalid - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
|
||||
),
|
||||
missmatch: this.sanitizer.bypassSecurityTrustHtml(
|
||||
`${this.errorIcon} Your SYSSITE (below) is not found in the licence key - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.licenceKeyValue = this.currentLicenceKey || ''
|
||||
@@ -89,9 +127,73 @@ export class LicensingComponent implements OnInit {
|
||||
this.licenseKeyData = this.licenceService.getLicenseKeyData()
|
||||
}
|
||||
|
||||
public trimKeys() {
|
||||
public async trimKeys() {
|
||||
this.licenceKeyValue = this.licenceKeyValue.trim()
|
||||
this.activationKeyValue = this.activationKeyValue.trim()
|
||||
|
||||
// Auto-detect regardless of keyFormat - a combined key pasted into the
|
||||
// legacy "Licence key" field on its own still gets recognised and
|
||||
// splits into both fields, rather than requiring the toggle to match.
|
||||
await this.maybeSplitCombinedKey(this.licenceKeyValue)
|
||||
await this.refreshKeyPreview()
|
||||
}
|
||||
|
||||
public async onCombinedKeyInput() {
|
||||
this.combinedKeyValue = this.combinedKeyValue.trim()
|
||||
|
||||
// The combined field is the sole source of truth for
|
||||
// licenceKeyValue/activationKeyValue in this layout - if it's been
|
||||
// cleared (or isn't a recognisable combined key), there's no key data
|
||||
// to preview, so clear rather than leave a stale split from an earlier
|
||||
// paste (which would otherwise keep decrypting to that old key).
|
||||
const wasSplit = await this.maybeSplitCombinedKey(this.combinedKeyValue)
|
||||
if (!wasSplit) {
|
||||
this.licenceKeyValue = ''
|
||||
this.activationKeyValue = ''
|
||||
}
|
||||
|
||||
await this.refreshKeyPreview()
|
||||
}
|
||||
|
||||
// Decrypts whatever's currently in licenceKeyValue/activationKeyValue
|
||||
// purely to preview its details (valid_until, users_allowed, ...) before
|
||||
// the user ever clicks Apply - decryptLicenseKey() has no side effects,
|
||||
// so this is safe to call speculatively on every input change. Clears
|
||||
// rather than leaves stale data on failure/incomplete input, since
|
||||
// showing a previous key's details next to a since-changed paste would
|
||||
// be actively misleading, not just uninformative.
|
||||
private async refreshKeyPreview(): Promise<void> {
|
||||
if (!this.licenceKeyValue || !this.activationKeyValue) {
|
||||
this.licenseKeyData = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.licenseKeyData = await this.licenceService.decryptLicenseKey(
|
||||
this.licenceKeyValue,
|
||||
this.activationKeyValue
|
||||
)
|
||||
} catch {
|
||||
this.licenseKeyData = null
|
||||
}
|
||||
}
|
||||
|
||||
public toggleKeyFormat() {
|
||||
this.keyFormat = this.keyFormat === 'combined' ? 'legacy' : 'combined'
|
||||
}
|
||||
|
||||
// Shared by both input layouts' own handlers above - populates
|
||||
// licenceKeyValue/activationKeyValue when value is recognisably a
|
||||
// combined key, regardless of which field it was typed/pasted into.
|
||||
private async maybeSplitCombinedKey(value: string): Promise<boolean> {
|
||||
if (!isCombinedLicenceKey(value)) return false
|
||||
|
||||
const split = await splitCombinedLicenceKey(value)
|
||||
if (!split) return false
|
||||
|
||||
this.licenceKeyValue = split.licenceKey
|
||||
this.activationKeyValue = split.activationKey
|
||||
return true
|
||||
}
|
||||
|
||||
public copySyssite(copyIconRef: any, copyTooltip: any, syssite: string[]) {
|
||||
@@ -150,7 +252,7 @@ export class LicensingComponent implements OnInit {
|
||||
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (evt) => {
|
||||
reader.onload = async (evt) => {
|
||||
this.licenceFileError = 'Error reading file.'
|
||||
|
||||
if (!evt || !evt.target) return
|
||||
@@ -160,9 +262,20 @@ export class LicensingComponent implements OnInit {
|
||||
|
||||
this.licenceFileLoading = false
|
||||
this.licenceFileError = undefined
|
||||
const fileArr = evt.target.result.toString().split('\n')
|
||||
this.activationKeyValue = fileArr[1]
|
||||
this.licenceKeyValue = fileArr[0]
|
||||
|
||||
const fileArr = evt.target.result.toString().trim().split('\n')
|
||||
|
||||
// A combined-key file is a single line - splits into both fields.
|
||||
// Anything else keeps the existing 2-line legacy file behaviour.
|
||||
if (
|
||||
fileArr.length !== 1 ||
|
||||
!(await this.maybeSplitCombinedKey(fileArr[0]))
|
||||
) {
|
||||
this.activationKeyValue = fileArr[1]
|
||||
this.licenceKeyValue = fileArr[0]
|
||||
}
|
||||
|
||||
await this.refreshKeyPreview()
|
||||
}
|
||||
|
||||
reader.readAsText(file)
|
||||
@@ -180,7 +293,29 @@ export class LicensingComponent implements OnInit {
|
||||
this.activationKeyValue === this.currentActivationKey
|
||||
)
|
||||
return true
|
||||
// A protocol mismatch is a deterministic predictor of decrypt failure
|
||||
// (see detectLicenceKeyProtocolMismatch's own doc comment) - submitting
|
||||
// anyway would just trade this specific warning for a generic "invalid
|
||||
// key" error after a round trip to the backend.
|
||||
if (this.protocolMismatch) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Warns before the user even submits - covers both the file-upload and
|
||||
// paste tabs, since both feed licenceKeyValue/activationKeyValue. Passes
|
||||
// actual WebCrypto availability, not location.protocol - see
|
||||
// detectLicenceKeyProtocolMismatch's own doc comment for why (localhost
|
||||
// is a secure context even over http).
|
||||
get protocolMismatch(): LicenceKeyProtocolMismatch {
|
||||
return detectLicenceKeyProtocolMismatch(
|
||||
this.licenceKeyValue,
|
||||
this.activationKeyValue,
|
||||
!!(window.crypto && window.crypto.subtle)
|
||||
)
|
||||
}
|
||||
|
||||
get enabledFeatures(): string[] {
|
||||
return getEnabledFeatureLabels(this.licenseKeyData?.features)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as base64Converter from 'base64-arraybuffer'
|
||||
import {
|
||||
COMBINED_KEY_PREFIX,
|
||||
isCombinedLicenceKey,
|
||||
splitCombinedLicenceKey
|
||||
} from './combinedLicenceKey'
|
||||
|
||||
// Mirrors dckey's own encodeCombinedKey() (main.js) exactly, so these tests
|
||||
// build fixtures the same way a real generated key would be produced,
|
||||
// rather than relying on a hardcoded string that could go stale if either
|
||||
// side's algorithm ever changes.
|
||||
const gzipCompress = async (bytes: Uint8Array): Promise<ArrayBuffer> => {
|
||||
const compressionStream = new CompressionStream('gzip')
|
||||
const writer = compressionStream.writable.getWriter()
|
||||
writer.write(new Uint8Array(bytes))
|
||||
writer.close()
|
||||
|
||||
return new Response(compressionStream.readable).arrayBuffer()
|
||||
}
|
||||
|
||||
const encodeCombinedLicenceKeyFixture = async (
|
||||
licenceKey: string,
|
||||
activationKey: string
|
||||
): Promise<string> => {
|
||||
const payloadBytes = new TextEncoder().encode(
|
||||
`${licenceKey} ${activationKey}`
|
||||
)
|
||||
const compressedBytes = await gzipCompress(payloadBytes)
|
||||
|
||||
return COMBINED_KEY_PREFIX + base64Converter.encode(compressedBytes)
|
||||
}
|
||||
|
||||
describe('isCombinedLicenceKey', () => {
|
||||
it('is true for prefixed text', () => {
|
||||
expect(isCombinedLicenceKey('DCKEY1:abc123')).toBeTrue()
|
||||
})
|
||||
|
||||
it('is false for a plain legacy key', () => {
|
||||
expect(isCombinedLicenceKey('some-legacy-licence-key')).toBeFalse()
|
||||
})
|
||||
|
||||
it('is false for an empty string', () => {
|
||||
expect(isCombinedLicenceKey('')).toBeFalse()
|
||||
})
|
||||
|
||||
it('is true even with leading/trailing whitespace around the prefix', () => {
|
||||
expect(isCombinedLicenceKey(' DCKEY1:abc123 ')).toBeTrue()
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitCombinedLicenceKey', () => {
|
||||
it('round-trips a fixture built the same way dckey encodes one', async () => {
|
||||
const combined = await encodeCombinedLicenceKeyFixture(
|
||||
'licence-key-value',
|
||||
'activation-key-value'
|
||||
)
|
||||
|
||||
expect(await splitCombinedLicenceKey(combined)).toEqual({
|
||||
licenceKey: 'licence-key-value',
|
||||
activationKey: 'activation-key-value'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for text without the prefix', async () => {
|
||||
expect(
|
||||
await splitCombinedLicenceKey('a-plain-legacy-licence-key')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects for prefixed text that is not valid compressed data', async () => {
|
||||
await expectAsync(
|
||||
splitCombinedLicenceKey('DCKEY1:not-valid-base64-gzip-data')
|
||||
).toBeRejected()
|
||||
})
|
||||
|
||||
it('splits on the first space only, not every space', async () => {
|
||||
// Not a real-world value today (licence/activation keys are base64,
|
||||
// which never contains a space) - guards the split logic itself rather
|
||||
// than that assumption.
|
||||
const combined = await encodeCombinedLicenceKeyFixture(
|
||||
'licence key with spaces',
|
||||
'activation-key-value'
|
||||
)
|
||||
|
||||
expect(await splitCombinedLicenceKey(combined)).toEqual({
|
||||
licenceKey: 'licence',
|
||||
activationKey: 'key with spaces activation-key-value'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as base64Converter from 'base64-arraybuffer'
|
||||
|
||||
/**
|
||||
* Format produced by dckey's own encodeCombinedKey() (main.js):
|
||||
* "DCKEY1:" + base64(gzip(licenceKey + " " + activationKey)). The prefix is
|
||||
* plain ASCII, not itself compressed/encoded - base64's alphabet never
|
||||
* contains ":", so it can never collide with the start of a legacy licence
|
||||
* key or activation key, making format detection unambiguous.
|
||||
*/
|
||||
export const COMBINED_KEY_PREFIX = 'DCKEY1:'
|
||||
|
||||
export const isCombinedLicenceKey = (text: string): boolean =>
|
||||
text.trim().startsWith(COMBINED_KEY_PREFIX)
|
||||
|
||||
const gzipDecompress = async (bytes: ArrayBuffer): Promise<ArrayBuffer> => {
|
||||
const decompressionStream = new DecompressionStream('gzip')
|
||||
const writer = decompressionStream.writable.getWriter()
|
||||
|
||||
// Awaited (unlike a fire-and-forget write) so invalid gzip data rejects
|
||||
// through this function's own returned promise, not as a separate
|
||||
// unhandled rejection racing the readable side below.
|
||||
const result = await Promise.all([
|
||||
writer.write(new Uint8Array(bytes)).then(() => writer.close()),
|
||||
new Response(decompressionStream.readable).arrayBuffer()
|
||||
])
|
||||
|
||||
return result[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a combined licence key back into its two parts. Returns null (not
|
||||
* a rejected promise) when text isn't a combined key at all, so callers can
|
||||
* use it as a "try this, then fall back to the legacy two-field input" check
|
||||
* without a try/catch for that common case - a prefixed-but-corrupted
|
||||
* string still rejects, since that's a real error, not a format mismatch.
|
||||
*/
|
||||
export const splitCombinedLicenceKey = async (
|
||||
text: string
|
||||
): Promise<{ licenceKey: string; activationKey: string } | null> => {
|
||||
const trimmed = text.trim()
|
||||
|
||||
if (!isCombinedLicenceKey(trimmed)) return null
|
||||
|
||||
const compressedBytes = base64Converter.decode(
|
||||
trimmed.slice(COMBINED_KEY_PREFIX.length)
|
||||
)
|
||||
const decompressedBytes = await gzipDecompress(compressedBytes)
|
||||
const payload = new TextDecoder().decode(decompressedBytes)
|
||||
const separatorIndex = payload.indexOf(' ')
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
throw new Error('Invalid combined licence key: missing separator')
|
||||
}
|
||||
|
||||
return {
|
||||
licenceKey: payload.slice(0, separatorIndex),
|
||||
activationKey: payload.slice(separatorIndex + 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { detectLicenceKeyProtocolMismatch } from './detectLicenceKeyProtocolMismatch'
|
||||
|
||||
describe('detectLicenceKeyProtocolMismatch', () => {
|
||||
// A key generated for an insecure connection always carries the same
|
||||
// text in both fields; a key generated for a secure connection always
|
||||
// carries two different values.
|
||||
const httpsFormatLicenceKey = 'licence-key-value-abc123'
|
||||
const httpsFormatActivationKey = 'activation-key-value-xyz789'
|
||||
const httpFormatKey = 'same-value-for-both-fields'
|
||||
|
||||
it('flags an HTTPS-format key used where WebCrypto is unavailable', () => {
|
||||
expect(
|
||||
detectLicenceKeyProtocolMismatch(
|
||||
httpsFormatLicenceKey,
|
||||
httpsFormatActivationKey,
|
||||
false
|
||||
)
|
||||
).toEqual('requiresHttps')
|
||||
})
|
||||
|
||||
it('does not flag an HTTPS-format key where WebCrypto is available', () => {
|
||||
expect(
|
||||
detectLicenceKeyProtocolMismatch(
|
||||
httpsFormatLicenceKey,
|
||||
httpsFormatActivationKey,
|
||||
true
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('flags an HTTP-format key where WebCrypto is available - e.g. localhost, which browsers always treat as a secure context even over plain http', () => {
|
||||
expect(
|
||||
detectLicenceKeyProtocolMismatch(httpFormatKey, httpFormatKey, true)
|
||||
).toEqual('requiresHttp')
|
||||
})
|
||||
|
||||
it('does not flag an HTTP-format key where WebCrypto is unavailable', () => {
|
||||
expect(
|
||||
detectLicenceKeyProtocolMismatch(httpFormatKey, httpFormatKey, false)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when either key is empty', () => {
|
||||
expect(
|
||||
detectLicenceKeyProtocolMismatch('', httpsFormatActivationKey, false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
detectLicenceKeyProtocolMismatch(httpsFormatLicenceKey, '', false)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('trims whitespace before comparing', () => {
|
||||
expect(
|
||||
detectLicenceKeyProtocolMismatch(
|
||||
` ${httpFormatKey} `,
|
||||
`${httpFormatKey}\n`,
|
||||
true
|
||||
)
|
||||
).toEqual('requiresHttp')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
export type LicenceKeyProtocolMismatch = 'requiresHttps' | 'requiresHttp' | null
|
||||
|
||||
/**
|
||||
* Detects a licence key generated for one protocol (http/https) being
|
||||
* pasted/uploaded on a page where the other one is actually in effect -
|
||||
* before any decrypt attempt, purely from the raw key text.
|
||||
*
|
||||
* A key generated for an insecure connection always carries the same text
|
||||
* in both the licence key and activation key fields; a key generated for a
|
||||
* secure connection always carries two different values. That distinction
|
||||
* alone tells us which format a pasted key is, without needing to attempt
|
||||
* decryption first.
|
||||
*
|
||||
* isSecureContext must reflect whether this browsing context can actually
|
||||
* decrypt a secure-connection key (mirrors the check licence.service.ts's
|
||||
* own decryptLicenseKey() makes) - not simply `location.protocol ===
|
||||
* 'https:'`. Browsers treat localhost as a secure context even over plain
|
||||
* http, so a key meant for an insecure connection still fails to decrypt
|
||||
* there, exactly like it would on a real https page.
|
||||
*/
|
||||
export const detectLicenceKeyProtocolMismatch = (
|
||||
licenceKey: string,
|
||||
activationKey: string,
|
||||
isSecureContext: boolean
|
||||
): LicenceKeyProtocolMismatch => {
|
||||
const trimmedLicenceKey = licenceKey.trim()
|
||||
const trimmedActivationKey = activationKey.trim()
|
||||
|
||||
if (!trimmedLicenceKey || !trimmedActivationKey) return null
|
||||
|
||||
const isHttpsFormatKey = trimmedLicenceKey !== trimmedActivationKey
|
||||
|
||||
if (isHttpsFormatKey && !isSecureContext) return 'requiresHttps'
|
||||
if (!isHttpsFormatKey && isSecureContext) return 'requiresHttp'
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getEnabledFeatureLabels } from './getEnabledFeatureLabels'
|
||||
|
||||
describe('getEnabledFeatureLabels', () => {
|
||||
it('returns an empty list when features is undefined', () => {
|
||||
expect(getEnabledFeatureLabels(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('lists only the enabled toggle features from a new-format object', () => {
|
||||
expect(
|
||||
getEnabledFeatureLabels({
|
||||
vra: null,
|
||||
era: null,
|
||||
sra: null,
|
||||
hra: null,
|
||||
srl: null,
|
||||
till: null,
|
||||
vb: true,
|
||||
vbl: null,
|
||||
ldl: null,
|
||||
fu: false,
|
||||
er: true,
|
||||
ar: false
|
||||
})
|
||||
).toEqual(['Viewbox', 'Edit Record'])
|
||||
})
|
||||
|
||||
it('lists all four when every toggle is enabled', () => {
|
||||
expect(
|
||||
getEnabledFeatureLabels({
|
||||
vra: null,
|
||||
era: null,
|
||||
sra: null,
|
||||
hra: null,
|
||||
srl: null,
|
||||
till: null,
|
||||
vb: true,
|
||||
vbl: null,
|
||||
ldl: null,
|
||||
fu: true,
|
||||
er: true,
|
||||
ar: true
|
||||
})
|
||||
).toEqual(['Viewbox', 'File Upload', 'Edit Record', 'Add Record'])
|
||||
})
|
||||
|
||||
it('returns an empty list when every toggle is disabled', () => {
|
||||
expect(
|
||||
getEnabledFeatureLabels({
|
||||
vra: null,
|
||||
era: null,
|
||||
sra: null,
|
||||
hra: null,
|
||||
srl: null,
|
||||
till: null,
|
||||
vb: false,
|
||||
vbl: null,
|
||||
ldl: null,
|
||||
fu: false,
|
||||
er: false,
|
||||
ar: false
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('decodes a legacy positional string the same way', () => {
|
||||
// viewbox=1 (position 6), fileUpload=0 (position 9), editRecord=1
|
||||
// (position 10), addRecord=0 (position 11) - see LicenceFeaturesMap.
|
||||
expect(getEnabledFeatureLabels('-,-,-,-,-,-,1,-,-,0,1,0')).toEqual([
|
||||
'Viewbox',
|
||||
'Edit Record'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
decodeLicenceFeatures,
|
||||
LicenceFeaturesObject
|
||||
} from '../../services/utils/decodeLicenceFeatures'
|
||||
import { LicenceState } from '../../models/LicenceState'
|
||||
|
||||
const TOGGLE_FEATURE_LABELS: { key: keyof LicenceState; label: string }[] = [
|
||||
{ key: 'viewbox', label: 'Viewbox' },
|
||||
{ key: 'fileUpload', label: 'File Upload' },
|
||||
{ key: 'editRecord', label: 'Edit Record' },
|
||||
{ key: 'addRecord', label: 'Add Record' }
|
||||
]
|
||||
|
||||
export const getEnabledFeatureLabels = (
|
||||
features: string | LicenceFeaturesObject | undefined
|
||||
): string[] => {
|
||||
if (!features) return []
|
||||
|
||||
const decoded = decodeLicenceFeatures(features)
|
||||
|
||||
return TOGGLE_FEATURE_LABELS.filter(({ key }) => decoded[key]).map(
|
||||
({ label }) => label
|
||||
)
|
||||
}
|
||||
@@ -1,168 +1,176 @@
|
||||
<app-sidebar (scrolledToBottom)="loadMoreLibraries()">
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngIf="libraryList" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="Libraries"
|
||||
name="input"
|
||||
[(ngModel)]="librariesSearch"
|
||||
(keyup)="libraryOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length > 0"
|
||||
(click)="librariesSearch = ''; libraryOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
|
||||
<ng-container *ngFor="let library of libraryList">
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, library, libraryList)"
|
||||
*ngIf="!library['hidden'] && library['inForeground']"
|
||||
[(clrExpanded)]="library['expanded']"
|
||||
[clrLoading]="library['loadingTables'] && !library.tables"
|
||||
[class.clr-expanded]="library['expanded']"
|
||||
>
|
||||
<p
|
||||
(click)="lib = library.LIBRARYID; libraryOnClick(lib || '', library)"
|
||||
class="m-0 cursor-pointer"
|
||||
>
|
||||
<clr-icon shape="rack-server"></clr-icon>
|
||||
{{ library.LIBRARYNAME }}
|
||||
</p>
|
||||
|
||||
<clr-tree-node *ngIf="library['tables']" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchTreeInput
|
||||
placeholder="Tables"
|
||||
name="input"
|
||||
[(ngModel)]="library['searchString']"
|
||||
(keyup)="treeOnFilter(library, 'tables.TABLENAME')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (libraryList) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="Libraries"
|
||||
name="input"
|
||||
[(ngModel)]="librariesSearch"
|
||||
(keyup)="libraryOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchLibTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchLibTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length > 0"
|
||||
(click)="
|
||||
searchTreeInput.value = '';
|
||||
library['searchString'] = '';
|
||||
treeOnFilter(library, 'tables.TABLENAME')
|
||||
"
|
||||
(click)="librariesSearch = ''; libraryOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
}
|
||||
|
||||
@for (library of libraryList; track library) {
|
||||
@if (!library['hidden'] && library['inForeground']) {
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, libTable, library['tables'])"
|
||||
*ngFor="let libTable of library['tables']"
|
||||
[(clrExpanded)]="libTable['expanded']"
|
||||
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
|
||||
[class.clr-expanded]="libTable['expanded']"
|
||||
(click)="treeNodeClicked($event, library, libraryList)"
|
||||
[(clrExpanded)]="library['expanded']"
|
||||
[clrLoading]="library['loadingTables'] && !library.tables"
|
||||
[class.clr-expanded]="library['expanded']"
|
||||
>
|
||||
<p
|
||||
(click)="tableOnClick(libTable.TABLEURI, libTable, library)"
|
||||
[id]="libTable.TABLEURI"
|
||||
(click)="
|
||||
lib = library.LIBRARYID; libraryOnClick(lib || '', library)
|
||||
"
|
||||
class="m-0 cursor-pointer"
|
||||
>
|
||||
<clr-icon shape="table"></clr-icon>
|
||||
{{ libTable.TABLENAME }}
|
||||
<clr-icon shape="rack-server"></clr-icon>
|
||||
{{ library.LIBRARYNAME }}
|
||||
</p>
|
||||
|
||||
<clr-tree-node *ngIf="libTable['columns']" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchTreeInput
|
||||
placeholder="Columns"
|
||||
name="input"
|
||||
[(ngModel)]="libTable['searchString']"
|
||||
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length > 0"
|
||||
(click)="
|
||||
searchTreeInput.value = '';
|
||||
libTable['searchString'] = '';
|
||||
treeOnFilter(libTable, 'columns.COLNAME')
|
||||
"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
|
||||
<clr-tree-node *ngFor="let libColumn of libTable['columns']">
|
||||
<button
|
||||
(click)="columnOnClick(libColumn, library, libTable)"
|
||||
class="clr-treenode-link"
|
||||
[class.column-active]="libColumnActive(libColumn.COLURI)"
|
||||
@if (library['tables']) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchTreeInput
|
||||
placeholder="Tables"
|
||||
name="input"
|
||||
[(ngModel)]="library['searchString']"
|
||||
(keyup)="treeOnFilter(library, 'tables.TABLENAME')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
(click)="
|
||||
searchTreeInput.value = '';
|
||||
library['searchString'] = '';
|
||||
treeOnFilter(library, 'tables.TABLENAME')
|
||||
"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
}
|
||||
@for (libTable of library['tables']; track libTable) {
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, libTable, library['tables'])"
|
||||
[(clrExpanded)]="libTable['expanded']"
|
||||
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
|
||||
[class.clr-expanded]="libTable['expanded']"
|
||||
>
|
||||
<clr-icon shape="objects"></clr-icon>
|
||||
|
||||
{{ libColumn.COLNAME }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
<p
|
||||
(click)="tableOnClick(libTable.TABLEURI, libTable, library)"
|
||||
[id]="libTable.TABLEURI"
|
||||
class="m-0 cursor-pointer"
|
||||
>
|
||||
<clr-icon shape="table"></clr-icon>
|
||||
{{ libTable.TABLENAME }}
|
||||
</p>
|
||||
@if (libTable['columns']) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchTreeInput
|
||||
placeholder="Columns"
|
||||
name="input"
|
||||
[(ngModel)]="libTable['searchString']"
|
||||
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
(click)="
|
||||
searchTreeInput.value = '';
|
||||
libTable['searchString'] = '';
|
||||
treeOnFilter(libTable, 'columns.COLNAME')
|
||||
"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
}
|
||||
@for (libColumn of libTable['columns']; track libColumn) {
|
||||
<clr-tree-node>
|
||||
<button
|
||||
(click)="columnOnClick(libColumn, library, libTable)"
|
||||
class="clr-treenode-link"
|
||||
[class.column-active]="libColumnActive(libColumn.COLURI)"
|
||||
>
|
||||
<clr-icon shape="objects"></clr-icon>
|
||||
{{ libColumn.COLNAME }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
}
|
||||
</clr-tree-node>
|
||||
}
|
||||
</clr-tree-node>
|
||||
</clr-tree-node>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
</clr-tree>
|
||||
|
||||
<div *ngIf="librariesPaging" class="w-100 text-center">
|
||||
<span class="spinner spinner-sm"> Loading... </span>
|
||||
</div>
|
||||
@if (librariesPaging) {
|
||||
<div class="w-100 text-center">
|
||||
<span class="spinner spinner-sm"> Loading... </span>
|
||||
</div>
|
||||
}
|
||||
</app-sidebar>
|
||||
|
||||
<div class="content-area">
|
||||
<div class="card">
|
||||
<div *ngIf="!column && !table" class="no-table-selected">
|
||||
<clr-icon
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a column or table
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="column || table">
|
||||
<div
|
||||
*ngIf="!graphContainer"
|
||||
class="card-header d-flex flex-column justify-content-center"
|
||||
>
|
||||
<h3
|
||||
*ngIf="!currentLineagePathColumn && !currentLineagePathLibTable"
|
||||
class="text-center pb-10"
|
||||
>
|
||||
{{
|
||||
currentLineagePathColumn
|
||||
? currentLineagePathLibTable + '.' + currentLineagePathColumn
|
||||
: currentLineagePathLibTable
|
||||
}}
|
||||
</h3>
|
||||
@if (!column && !table) {
|
||||
<div class="no-table-selected">
|
||||
<clr-icon
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a column or table
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (column || table) {
|
||||
@if (!graphContainer) {
|
||||
<div class="card-header d-flex flex-column justify-content-center">
|
||||
@if (!currentLineagePathColumn && !currentLineagePathLibTable) {
|
||||
<h3 class="text-center pb-10">
|
||||
{{
|
||||
currentLineagePathColumn
|
||||
? currentLineagePathLibTable + '.' + currentLineagePathColumn
|
||||
: currentLineagePathLibTable
|
||||
}}
|
||||
</h3>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="card-block">
|
||||
<section
|
||||
class="form-block sw position-relative d-flex align-items-center"
|
||||
@@ -170,114 +178,115 @@
|
||||
<div
|
||||
class="linage-title-wrapper d-flex align-items-center font-weight-bold position-absolute"
|
||||
>
|
||||
<span *ngIf="lineageTableName.length > 0">
|
||||
{{ lineageTableName.split('.')[0] }}.<a
|
||||
[routerLink]="'/view/data/' + lineageTableName"
|
||||
>{{ lineageTableName.split('.')[1] }}</a
|
||||
>{{ lineageColumnName.length > 0 ? '.' + lineageColumnName : '' }}
|
||||
</span>
|
||||
@if (lineageTableName.length > 0) {
|
||||
<span>
|
||||
{{ lineageTableName.split('.')[0] }}.<a
|
||||
[routerLink]="'/view/data/' + lineageTableName"
|
||||
>{{ lineageTableName.split('.')[1] }}</a
|
||||
>{{
|
||||
lineageColumnName.length > 0 ? '.' + lineageColumnName : ''
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="graphContainer"
|
||||
class="clr-col-md-12 text-center d-flex justify-content-end"
|
||||
>
|
||||
<button
|
||||
(click)="limitDotDepth = true"
|
||||
type="button"
|
||||
class="btn btn-outline mr-5"
|
||||
>
|
||||
Limit depth
|
||||
</button>
|
||||
|
||||
<!-- <button class="btn btn-outline" (click)='showSvg()'> Open in New Tab </button> -->
|
||||
<div class="btn-group direction d-block">
|
||||
<div
|
||||
class="radio btn"
|
||||
(click)="
|
||||
forwardLineage = false;
|
||||
router.url.includes('column')
|
||||
? onGenerateClick()
|
||||
: onGenerateGraphTableClick()
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="btn-group-demo-radios"
|
||||
[checked]="!forwardLineage"
|
||||
/>
|
||||
<label>Backward</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="radio btn"
|
||||
(click)="
|
||||
forwardLineage = true;
|
||||
router.url.includes('column')
|
||||
? onGenerateClick()
|
||||
: onGenerateGraphTableClick()
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="btn-group-demo-radios"
|
||||
[checked]="forwardLineage"
|
||||
/>
|
||||
<label>Forward</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<clr-dropdown class="mr-10">
|
||||
@if (graphContainer) {
|
||||
<div class="clr-col-md-12 text-center d-flex justify-content-end">
|
||||
<button
|
||||
class="btn btn-info-outline"
|
||||
clrDropdownTrigger
|
||||
[disabled]="!column && !table"
|
||||
(click)="limitDotDepth = true"
|
||||
type="button"
|
||||
class="btn btn-outline mr-5"
|
||||
>
|
||||
Download
|
||||
<clr-icon shape="caret down"></clr-icon>
|
||||
Limit depth
|
||||
</button>
|
||||
|
||||
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
|
||||
<div (click)="downloadSVG()" clrDropdownItem>SVG</div>
|
||||
<div (click)="downloadPNG()" clrDropdownItem>PNG</div>
|
||||
<div (click)="downloadDot()" clrDropdownItem>Dot</div>
|
||||
<div *ngIf="flatdata" (click)="downloadCSV()" clrDropdownItem>
|
||||
CSV
|
||||
<!-- <button class="btn btn-outline" (click)='showSvg()'> Open in New Tab </button> -->
|
||||
<div class="btn-group direction d-block">
|
||||
<div
|
||||
class="radio btn"
|
||||
(click)="
|
||||
forwardLineage = false;
|
||||
router.url.includes('column')
|
||||
? onGenerateClick()
|
||||
: onGenerateGraphTableClick()
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="btn-group-demo-radios"
|
||||
[checked]="!forwardLineage"
|
||||
/>
|
||||
<label>Backward</label>
|
||||
</div>
|
||||
<div
|
||||
class="radio btn"
|
||||
(click)="
|
||||
forwardLineage = true;
|
||||
router.url.includes('column')
|
||||
? onGenerateClick()
|
||||
: onGenerateGraphTableClick()
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="btn-group-demo-radios"
|
||||
[checked]="forwardLineage"
|
||||
/>
|
||||
<label>Forward</label>
|
||||
</div>
|
||||
</clr-dropdown-menu>
|
||||
</clr-dropdown>
|
||||
|
||||
<clr-checkbox-wrapper
|
||||
*ngIf="column"
|
||||
class="d-flex align-items-center"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
(change)="generateGraph()"
|
||||
clrCheckbox
|
||||
name="refreshCache"
|
||||
[(ngModel)]="refreshCache"
|
||||
/>
|
||||
<label>Refresh Cache</label>
|
||||
</clr-checkbox-wrapper>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div *ngIf="graphContainer" [class.mt-2]="tableFlag">
|
||||
<div class="text-center">
|
||||
<span *ngIf="graphLoading" class="spinner"> Loading... </span>
|
||||
|
||||
<div *ngIf="!graphLoading" class="position-relative">
|
||||
<div class="graph-render-spinner">
|
||||
<span *ngIf="graphRendering" class="spinner spinner-sm"></span>
|
||||
</div>
|
||||
|
||||
<div id="graph"></div>
|
||||
<clr-dropdown class="mr-10">
|
||||
<button
|
||||
class="btn btn-info-outline"
|
||||
clrDropdownTrigger
|
||||
[disabled]="!column && !table"
|
||||
>
|
||||
Download
|
||||
<clr-icon shape="caret down"></clr-icon>
|
||||
</button>
|
||||
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
|
||||
<div (click)="downloadSVG()" clrDropdownItem>SVG</div>
|
||||
<div (click)="downloadPNG()" clrDropdownItem>PNG</div>
|
||||
<div (click)="downloadDot()" clrDropdownItem>Dot</div>
|
||||
@if (flatdata) {
|
||||
<div (click)="downloadCSV()" clrDropdownItem>CSV</div>
|
||||
}
|
||||
</clr-dropdown-menu>
|
||||
</clr-dropdown>
|
||||
@if (column) {
|
||||
<clr-checkbox-wrapper class="d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
(change)="generateGraph()"
|
||||
clrCheckbox
|
||||
name="refreshCache"
|
||||
[(ngModel)]="refreshCache"
|
||||
/>
|
||||
<label>Refresh Cache</label>
|
||||
</clr-checkbox-wrapper>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@if (graphContainer) {
|
||||
<div [class.mt-2]="tableFlag">
|
||||
<div class="text-center">
|
||||
@if (graphLoading) {
|
||||
<span class="spinner"> Loading... </span>
|
||||
}
|
||||
@if (!graphLoading) {
|
||||
<div class="position-relative">
|
||||
<div class="graph-render-spinner">
|
||||
@if (graphRendering) {
|
||||
<span class="spinner spinner-sm"></span>
|
||||
}
|
||||
</div>
|
||||
<div id="graph"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,58 +8,60 @@
|
||||
[(ngModel)]="repository"
|
||||
(change)="updateSelectedRepository()"
|
||||
>
|
||||
<option
|
||||
*ngFor="let repository of repositories"
|
||||
value="{{ repository }}"
|
||||
>
|
||||
{{ repository }}
|
||||
</option>
|
||||
@for (repository of repositories; track repository) {
|
||||
<option value="{{ repository }}">
|
||||
{{ repository }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</clr-select-container>
|
||||
</div>
|
||||
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngIf="metaDataList" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="search SAS Types"
|
||||
name="input"
|
||||
[(ngModel)]="metaDataSearch"
|
||||
(keyup)="metaListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length > 0"
|
||||
(click)="metaDataSearch = ''; metaListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
<ng-container *ngFor="let metaData of metaDataList">
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, metaData, metaDataList)"
|
||||
*ngIf="!metaData['hidden']"
|
||||
[(clrExpanded)]="metaData['expanded']"
|
||||
[clrLoading]="metaData['loadingTables'] && !metaData.tables"
|
||||
>
|
||||
<p
|
||||
(click)="
|
||||
metaDataId = metaData.ID; metaDataOnClick(metaDataId, metaData)
|
||||
"
|
||||
class="m-0 cursor-pointer"
|
||||
>
|
||||
<clr-icon shape="block"></clr-icon>
|
||||
{{ metaData.ID }}
|
||||
</p>
|
||||
@if (metaDataList) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="search SAS Types"
|
||||
name="input"
|
||||
[(ngModel)]="metaDataSearch"
|
||||
(keyup)="metaListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchLibTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchLibTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
(click)="metaDataSearch = ''; metaListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
</ng-container>
|
||||
}
|
||||
@for (metaData of metaDataList; track metaData) {
|
||||
@if (!metaData['hidden']) {
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, metaData, metaDataList)"
|
||||
[(clrExpanded)]="metaData['expanded']"
|
||||
[clrLoading]="metaData['loadingTables'] && !metaData.tables"
|
||||
>
|
||||
<p
|
||||
(click)="
|
||||
metaDataId = metaData.ID; metaDataOnClick(metaDataId, metaData)
|
||||
"
|
||||
class="m-0 cursor-pointer"
|
||||
>
|
||||
<clr-icon shape="block"></clr-icon>
|
||||
{{ metaData.ID }}
|
||||
</p>
|
||||
</clr-tree-node>
|
||||
}
|
||||
}
|
||||
</clr-tree>
|
||||
</app-sidebar>
|
||||
|
||||
@@ -67,54 +69,121 @@
|
||||
<div class="card background-transparent-i">
|
||||
<h3 class="color-gray">{{ assoTypeSelected }}</h3>
|
||||
|
||||
<div *ngIf="!loading && !metaObjectList" class="no-table-selected">
|
||||
<clr-icon
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a type
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="loadingSpinner" *ngIf="loading">
|
||||
<span class="spinner"> Loading... </span>
|
||||
<div *ngIf="loading">
|
||||
<h4 *ngIf="metatypesLoading">Loading metadata types</h4>
|
||||
<h4 *ngIf="!metatypesLoading">Loading metadata objects</h4>
|
||||
@if (!loading && !metaObjectList) {
|
||||
<div class="no-table-selected">
|
||||
<clr-icon
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a type
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="showData" class="clr-row clr-flex-grow-1">
|
||||
<div class="clr-col-6">
|
||||
<div *ngIf="metaObjectList && !objectRoute" class="search-input">
|
||||
<input
|
||||
clrInput
|
||||
#searchObjTreeInput
|
||||
placeholder="search"
|
||||
name="input"
|
||||
[(ngModel)]="metaObjectSearch"
|
||||
(keyup)="metaObjectOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<div *ngIf="!objectView" class="objects-col">
|
||||
<clr-accordion>
|
||||
<ng-container *ngFor="let metaObject of metaObjectShowList">
|
||||
<clr-accordion-panel
|
||||
(clrAccordionPanelOpenChange)="
|
||||
$event ? panelChange($event, metaObject) : ''
|
||||
"
|
||||
*ngIf="!metaObject['hidden']"
|
||||
>
|
||||
<clr-accordion-title
|
||||
><clr-icon shape="rack-server"></clr-icon>
|
||||
{{ metaObject.NAME }}
|
||||
<p class="float-right">{{ metaObject.ID }}</p>
|
||||
</clr-accordion-title>
|
||||
<clr-accordion-content *clrIfExpanded>
|
||||
<clr-tree *ngIf="showAcc" [clrLazy]="true">
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="loadingSpinner">
|
||||
<span class="spinner"> Loading... </span>
|
||||
@if (loading) {
|
||||
<div>
|
||||
@if (metatypesLoading) {
|
||||
<h4>Loading metadata types</h4>
|
||||
}
|
||||
@if (!metatypesLoading) {
|
||||
<h4>Loading metadata objects</h4>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (showData) {
|
||||
<div class="clr-row clr-flex-grow-1">
|
||||
<div class="clr-col-6">
|
||||
@if (metaObjectList && !objectRoute) {
|
||||
<div class="search-input">
|
||||
<input
|
||||
clrInput
|
||||
#searchObjTreeInput
|
||||
placeholder="search"
|
||||
name="input"
|
||||
[(ngModel)]="metaObjectSearch"
|
||||
(keyup)="metaObjectOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<br />
|
||||
@if (!objectView) {
|
||||
<div class="objects-col">
|
||||
<clr-accordion>
|
||||
@for (metaObject of metaObjectShowList; track metaObject) {
|
||||
@if (!metaObject['hidden']) {
|
||||
<clr-accordion-panel
|
||||
(clrAccordionPanelOpenChange)="
|
||||
$event ? panelChange($event, metaObject) : ''
|
||||
"
|
||||
>
|
||||
<clr-accordion-title
|
||||
><clr-icon shape="rack-server"></clr-icon>
|
||||
{{ metaObject.NAME }}
|
||||
<p class="float-right">{{ metaObject.ID }}</p>
|
||||
</clr-accordion-title>
|
||||
<clr-accordion-content *clrIfExpanded>
|
||||
@if (showAcc) {
|
||||
<clr-tree [clrLazy]="true">
|
||||
<clr-tree-node
|
||||
*clrRecursiveFor="
|
||||
let entry of root$ | async;
|
||||
getChildren: getChildren
|
||||
"
|
||||
[clrExpandable]="true"
|
||||
>
|
||||
<div
|
||||
[class.object-header]="!entry.count"
|
||||
class="full-width"
|
||||
>
|
||||
<div>
|
||||
@if (!entry.count) {
|
||||
<clr-icon shape="rack-server"></clr-icon>
|
||||
}
|
||||
@if (entry.count) {
|
||||
<clr-icon shape="block"></clr-icon>
|
||||
}
|
||||
{{ entry.display }}
|
||||
</div>
|
||||
@if (!entry.count) {
|
||||
<p class="float-right object-uri">
|
||||
{{ entry.URI }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
</clr-tree>
|
||||
}
|
||||
</clr-accordion-content>
|
||||
</clr-accordion-panel>
|
||||
}
|
||||
}
|
||||
</clr-accordion>
|
||||
</div>
|
||||
}
|
||||
@if (objectView) {
|
||||
<div class="objects-col">
|
||||
@for (metaObject of metaObjectShowList; track metaObject) {
|
||||
<div class="cols-head">
|
||||
<clr-icon shape="rack-server"></clr-icon>
|
||||
<div class="object-text">
|
||||
<p class="m-0 word-break mr-20">
|
||||
{{ metaObject.NAME }}
|
||||
</p>
|
||||
<p class="float-right ml-3">
|
||||
{{ metaObject.ID }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@if (showAcc) {
|
||||
<clr-tree [clrLazy]="true">
|
||||
<clr-tree-node
|
||||
*clrRecursiveFor="
|
||||
let entry of root$ | async;
|
||||
@@ -127,109 +196,70 @@
|
||||
class="full-width"
|
||||
>
|
||||
<div>
|
||||
<clr-icon
|
||||
*ngIf="!entry.count"
|
||||
shape="rack-server"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="entry.count"
|
||||
shape="block"
|
||||
></clr-icon>
|
||||
@if (!entry.count) {
|
||||
<clr-icon shape="rack-server"></clr-icon>
|
||||
}
|
||||
@if (entry.count) {
|
||||
<clr-icon shape="block"></clr-icon>
|
||||
}
|
||||
{{ entry.display }}
|
||||
</div>
|
||||
|
||||
<p class="float-right object-uri" *ngIf="!entry.count">
|
||||
{{ entry.URI }}
|
||||
</p>
|
||||
@if (!entry.count) {
|
||||
<p class="float-right object-uri">
|
||||
{{ entry.URI }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
</clr-tree>
|
||||
</clr-accordion-content>
|
||||
</clr-accordion-panel>
|
||||
</ng-container>
|
||||
</clr-accordion>
|
||||
</div>
|
||||
|
||||
<div *ngIf="objectView" class="objects-col">
|
||||
<ng-container *ngFor="let metaObject of metaObjectShowList">
|
||||
<div class="cols-head">
|
||||
<clr-icon shape="rack-server"></clr-icon>
|
||||
<div class="object-text">
|
||||
<p class="m-0 word-break mr-20">
|
||||
{{ metaObject.NAME }}
|
||||
</p>
|
||||
<p class="float-right ml-3">
|
||||
{{ metaObject.ID }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<clr-tree *ngIf="showAcc" [clrLazy]="true">
|
||||
<clr-tree-node
|
||||
*clrRecursiveFor="
|
||||
let entry of root$ | async;
|
||||
getChildren: getChildren
|
||||
"
|
||||
[clrExpandable]="true"
|
||||
}
|
||||
</div>
|
||||
<div class="clr-col-6 text-center">
|
||||
<h3>{{ assoObjectSelected }}</h3>
|
||||
@if (showTable) {
|
||||
<clr-datagrid class="datagrid-custom-footer">
|
||||
<clr-dg-column>
|
||||
TYPE
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="typeFilter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column>
|
||||
NAME
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="nameFilter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column>
|
||||
VALUE
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="valueFilter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-row
|
||||
*clrDgItems="let metaObjectAttribute of metaObjectAttributes"
|
||||
>
|
||||
<div [class.object-header]="!entry.count" class="full-width">
|
||||
<div>
|
||||
<clr-icon
|
||||
*ngIf="!entry.count"
|
||||
shape="rack-server"
|
||||
></clr-icon>
|
||||
<clr-icon *ngIf="entry.count" shape="block"></clr-icon>
|
||||
{{ entry.display }}
|
||||
</div>
|
||||
|
||||
<p class="float-right object-uri" *ngIf="!entry.count">
|
||||
{{ entry.URI }}
|
||||
</p>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
</clr-tree>
|
||||
</ng-container>
|
||||
<clr-dg-cell>{{ metaObjectAttribute.TYPE }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ metaObjectAttribute.NAME }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ metaObjectAttribute.VALUE }}</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
<clr-dg-footer>
|
||||
<clr-dg-pagination #pagination [clrDgPageSize]="10">
|
||||
<clr-dg-page-size [clrPageSizeOptions]="[10, 20, 50, 100]"
|
||||
>Attributes per page</clr-dg-page-size
|
||||
>
|
||||
{{ pagination.firstItem + 1 }} -
|
||||
{{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} Attributes
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer>
|
||||
</clr-datagrid>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clr-col-6 text-center">
|
||||
<h3>{{ assoObjectSelected }}</h3>
|
||||
<clr-datagrid class="datagrid-custom-footer" *ngIf="showTable">
|
||||
<clr-dg-column>
|
||||
TYPE
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="typeFilter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column>
|
||||
NAME
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="nameFilter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column>
|
||||
VALUE
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="valueFilter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-row
|
||||
*clrDgItems="let metaObjectAttribute of metaObjectAttributes"
|
||||
>
|
||||
<clr-dg-cell>{{ metaObjectAttribute.TYPE }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ metaObjectAttribute.NAME }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ metaObjectAttribute.VALUE }}</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
<clr-dg-footer>
|
||||
<clr-dg-pagination #pagination [clrDgPageSize]="10">
|
||||
<clr-dg-page-size [clrPageSizeOptions]="[10, 20, 50, 100]"
|
||||
>Attributes per page</clr-dg-page-size
|
||||
>
|
||||
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} Attributes
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer>
|
||||
</clr-datagrid>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,11 @@ export interface LicenceState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy-format decode table only - newly-issued keys use a named
|
||||
* LicenceFeaturesObject instead (see decodeLicenceFeatures.ts). Kept
|
||||
* unchanged and permanently, since every already-issued key still relies
|
||||
* on these ordinal positions to decode.
|
||||
*
|
||||
* '-' means unset
|
||||
* '0' disabled
|
||||
* '1' enabled
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { LicenceFeaturesObject } from '../services/utils/decodeLicenceFeatures'
|
||||
|
||||
export interface LicenseKeyData {
|
||||
valid_until: string
|
||||
users_allowed: number
|
||||
@@ -5,5 +7,5 @@ export interface LicenseKeyData {
|
||||
site_id_multiple: string[]
|
||||
demo: boolean
|
||||
hot_license_key: string | undefined
|
||||
features?: string
|
||||
features?: string | LicenceFeaturesObject
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ export interface ColumnDetail {
|
||||
LENGTH: number
|
||||
NAME: string
|
||||
TYPE: string
|
||||
VARNUM: number
|
||||
// No longer sent by viewdata.sas — optional for legacy responses.
|
||||
VARNUM?: number
|
||||
}
|
||||
|
||||
export interface Approver {
|
||||
|
||||
@@ -1,117 +1,118 @@
|
||||
<app-sidebar>
|
||||
<div *ngIf="datasetsLoading" class="my-10-mx-auto text-center">
|
||||
<clr-spinner clrMedium></clr-spinner>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!parsedDatasets.length" class="text-center mb-10">
|
||||
<button
|
||||
(click)="fileUploadInput.click()"
|
||||
id="browse-file"
|
||||
class="btn btn-primary btn-sm"
|
||||
[disabled]="selectedFile !== null || submittingCsv"
|
||||
>
|
||||
Browse file
|
||||
</button>
|
||||
<input
|
||||
hidden
|
||||
#fileUploadInput
|
||||
id="file-upload"
|
||||
type="file"
|
||||
(change)="onFileChange($event)"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="parsedDatasets.length && !submittedCsvDatasets.length">
|
||||
<div *ngIf="!excelsSubmitted" class="text-center mb-10">
|
||||
<button (click)="onDiscard()" class="btn btn-danger btn-sm mr-10">
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
(click)="onSubmitAll()"
|
||||
id="submit-all"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
Submit All
|
||||
</button>
|
||||
@if (datasetsLoading) {
|
||||
<div class="my-10-mx-auto text-center">
|
||||
<clr-spinner clrMedium></clr-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!parsedDatasets.length) {
|
||||
<div class="text-center mb-10">
|
||||
<button
|
||||
(click)="fileUploadInput.click()"
|
||||
id="browse-file"
|
||||
class="btn btn-primary btn-sm"
|
||||
[disabled]="selectedFile !== null || submittingCsv"
|
||||
>
|
||||
Browse file
|
||||
</button>
|
||||
<input
|
||||
hidden
|
||||
#fileUploadInput
|
||||
id="file-upload"
|
||||
type="file"
|
||||
(change)="onFileChange($event)"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
|
||||
@if (!excelsSubmitted) {
|
||||
<div class="text-center mb-10">
|
||||
<button (click)="onDiscard()" class="btn btn-danger btn-sm mr-10">
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
(click)="onSubmitAll()"
|
||||
id="submit-all"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
Submit All
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<p cds-text="caption" class="ml-10 mb-10">Found tables:</p>
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngFor="let dataset of parsedDatasets">
|
||||
<button
|
||||
(click)="onParsedDatasetClick(dataset)"
|
||||
class="clr-treenode-link whitespace-nowrap d-flex clr-align-items-center"
|
||||
[class.active]="dataset.active"
|
||||
>
|
||||
<ng-container *ngIf="dataset.submitResult">
|
||||
<cds-icon
|
||||
*ngIf="dataset.submitResult.error"
|
||||
status="danger"
|
||||
shape="exclamation-circle"
|
||||
></cds-icon>
|
||||
<cds-icon
|
||||
*ngIf="dataset.submitResult.success"
|
||||
status="success"
|
||||
shape="check-circle"
|
||||
></cds-icon>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!dataset.submitResult">
|
||||
<ng-container *ngIf="dataset.datasource">
|
||||
<cds-icon
|
||||
*ngIf="!(dataset.datasource.length && dataset.parseResult)"
|
||||
status="danger"
|
||||
shape="exclamation-circle"
|
||||
></cds-icon>
|
||||
<cds-icon
|
||||
*ngIf="dataset.datasource.length && dataset.parseResult"
|
||||
shape="table"
|
||||
></cds-icon>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!dataset.datasource">
|
||||
<cds-icon *ngIf="!dataset.parsingTable" shape="table"></cds-icon>
|
||||
|
||||
<clr-spinner *ngIf="dataset.parsingTable" clrSmall></clr-spinner>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<span class="ml-5"> {{ dataset.libds }} </span>
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
@for (dataset of parsedDatasets; track dataset) {
|
||||
<clr-tree-node>
|
||||
<button
|
||||
(click)="onParsedDatasetClick(dataset)"
|
||||
class="clr-treenode-link whitespace-nowrap d-flex clr-align-items-center"
|
||||
[class.active]="dataset.active"
|
||||
>
|
||||
@if (dataset.submitResult) {
|
||||
@if (dataset.submitResult.error) {
|
||||
<cds-icon status="danger" shape="exclamation-circle"></cds-icon>
|
||||
}
|
||||
@if (dataset.submitResult.success) {
|
||||
<cds-icon status="success" shape="check-circle"></cds-icon>
|
||||
}
|
||||
}
|
||||
@if (!dataset.submitResult) {
|
||||
@if (dataset.datasource) {
|
||||
@if (!(dataset.datasource.length && dataset.parseResult)) {
|
||||
<cds-icon
|
||||
status="danger"
|
||||
shape="exclamation-circle"
|
||||
></cds-icon>
|
||||
}
|
||||
@if (dataset.datasource.length && dataset.parseResult) {
|
||||
<cds-icon shape="table"></cds-icon>
|
||||
}
|
||||
}
|
||||
@if (!dataset.datasource) {
|
||||
@if (!dataset.parsingTable) {
|
||||
<cds-icon shape="table"></cds-icon>
|
||||
}
|
||||
@if (dataset.parsingTable) {
|
||||
<clr-spinner clrSmall></clr-spinner>
|
||||
}
|
||||
}
|
||||
}
|
||||
<span class="ml-5"> {{ dataset.libds }} </span>
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
}
|
||||
</clr-tree>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<ng-container *ngIf="submittedCsvDatasets.length">
|
||||
@if (submittedCsvDatasets.length) {
|
||||
<p cds-text="caption" class="ml-10 mb-10 mt-10">Submitted tables:</p>
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngFor="let dataset of submittedCsvDatasets">
|
||||
<button
|
||||
(click)="onSubmittedCsvDatasetClick(dataset)"
|
||||
class="clr-treenode-link whitespace-nowrap"
|
||||
[class.active]="dataset.active"
|
||||
>
|
||||
<cds-icon
|
||||
*ngIf="dataset.error"
|
||||
status="danger"
|
||||
shape="exclamation-circle"
|
||||
></cds-icon>
|
||||
<cds-icon
|
||||
*ngIf="dataset.success"
|
||||
status="success"
|
||||
shape="check-circle"
|
||||
></cds-icon>
|
||||
<cds-icon shape="table"></cds-icon>
|
||||
{{ dataset.libds }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
@for (dataset of submittedCsvDatasets; track dataset) {
|
||||
<clr-tree-node>
|
||||
<button
|
||||
(click)="onSubmittedCsvDatasetClick(dataset)"
|
||||
class="clr-treenode-link whitespace-nowrap"
|
||||
[class.active]="dataset.active"
|
||||
>
|
||||
@if (dataset.error) {
|
||||
<cds-icon status="danger" shape="exclamation-circle"></cds-icon>
|
||||
}
|
||||
@if (dataset.success) {
|
||||
<cds-icon status="success" shape="check-circle"></cds-icon>
|
||||
}
|
||||
<cds-icon shape="table"></cds-icon>
|
||||
{{ dataset.libds }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
}
|
||||
</clr-tree>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<!-- <div *ngIf="librariesPaging" class="w-100 text-center">
|
||||
<span class="spinner spinner-sm"> Loading... </span>
|
||||
</div> -->
|
||||
<span class="spinner spinner-sm"> Loading... </span>
|
||||
</div> -->
|
||||
</app-sidebar>
|
||||
|
||||
<div #contentArea class="content-area">
|
||||
@@ -122,23 +123,22 @@
|
||||
<p cds-text="section">Multi Dataset Load</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="selectedFile === null && !submittingCsv"
|
||||
class="no-table-selected pointer-events-none"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="upload-cloud"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please upload a file
|
||||
</p>
|
||||
</div>
|
||||
@if (selectedFile === null && !submittingCsv) {
|
||||
<div class="no-table-selected pointer-events-none">
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="upload-cloud"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please upload a file
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-container *ngIf="selectedFile !== null || submittingCsv">
|
||||
<ng-container *ngIf="!parsedDatasets.length && selectedFile !== null">
|
||||
@if (selectedFile !== null || submittingCsv) {
|
||||
@if (!parsedDatasets.length && selectedFile !== null) {
|
||||
<div class="d-flex clr-justify-content-center mt-15">
|
||||
<div class="dataset-input-wrapper">
|
||||
<p cds-text="secondary regular" class="mb-5">
|
||||
@@ -160,12 +160,10 @@
|
||||
<p cds-text="secondary regular" class="mb-15">
|
||||
Paste or type the list of datasets to upload:
|
||||
</p>
|
||||
|
||||
<clr-control-helper class="mb-5"
|
||||
>Each row is one dataset. We will automatically detect tables by
|
||||
the sheetname and populate if any.</clr-control-helper
|
||||
>
|
||||
|
||||
<hot-table
|
||||
#hotInstanceUserDataset
|
||||
id="hotTableUserDataset"
|
||||
@@ -173,7 +171,6 @@
|
||||
[settings]="hotUserDatasetsSettings"
|
||||
>
|
||||
</hot-table>
|
||||
|
||||
<div class="dataset-selection-actions text-right mt-10">
|
||||
<button
|
||||
(click)="onStartParsingFile()"
|
||||
@@ -187,109 +184,90 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="parsedDatasets.length && !submittedCsvDatasets.length"
|
||||
>
|
||||
<div
|
||||
*ngIf="!activeParsedDataset"
|
||||
class="no-table-selected pointer-events-none"
|
||||
>
|
||||
<ng-container *ngIf="fileLoadingState !== FileLoadingState.parsed">
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="process-on-vm"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
{{ fileLoadingState }}...
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="fileLoadingState === FileLoadingState.parsed">
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="warning-standard"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a dataset on the left to review the data
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="activeParsedDataset">
|
||||
<div
|
||||
*ngIf="activeParsedDataset.submitResult"
|
||||
class="d-flex clr-justify-content-between p-10 mt-15 submission-results"
|
||||
>
|
||||
<div>
|
||||
<p cds-text="secondary regular" class="mb-10">
|
||||
Submit Status:
|
||||
<span
|
||||
*ngIf="activeParsedDataset.submitResult?.success"
|
||||
class="color-green"
|
||||
><strong>SUCCESS</strong></span
|
||||
>
|
||||
<span
|
||||
*ngIf="activeParsedDataset.submitResult?.error"
|
||||
class="color-red"
|
||||
><strong>ERROR</strong></span
|
||||
>
|
||||
}
|
||||
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
|
||||
@if (!activeParsedDataset) {
|
||||
<div class="no-table-selected pointer-events-none">
|
||||
@if (fileLoadingState !== FileLoadingState.parsed) {
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="process-on-vm"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
{{ fileLoadingState }}...
|
||||
</p>
|
||||
<p
|
||||
*ngIf="activeParsedDataset.submitResult?.error"
|
||||
cds-text="secondary regular"
|
||||
>
|
||||
Error details:
|
||||
}
|
||||
@if (fileLoadingState === FileLoadingState.parsed) {
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="warning-standard"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a dataset on the left to review the data
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
*ngIf="
|
||||
!submittingCsv && activeParsedDataset.submitResult?.error
|
||||
"
|
||||
(click)="reSubmitTable(activeParsedDataset)"
|
||||
class="btn btn-primary mt-10"
|
||||
[clrLoading]="submitLoading"
|
||||
>
|
||||
Resubmit
|
||||
</button>
|
||||
<button
|
||||
(click)="
|
||||
downloadFile(
|
||||
activeParsedDataset.submitResult.log ||
|
||||
activeParsedDataset.submitResult.success ||
|
||||
activeParsedDataset.submitResult.error
|
||||
)
|
||||
"
|
||||
class="btn btn-primary-outline mt-10"
|
||||
>
|
||||
Download log
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="activeParsedDataset.submitResult?.error"
|
||||
class="error-field mt-15"
|
||||
>
|
||||
<div class="log-wrapper">
|
||||
{{ activeParsedDataset.submitResult?.error | json }}
|
||||
}
|
||||
@if (activeParsedDataset) {
|
||||
@if (activeParsedDataset.submitResult) {
|
||||
<div
|
||||
class="d-flex clr-justify-content-between p-10 mt-15 submission-results"
|
||||
>
|
||||
<div>
|
||||
<p cds-text="secondary regular" class="mb-10">
|
||||
Submit Status:
|
||||
@if (activeParsedDataset.submitResult.success) {
|
||||
<span class="color-green"><strong>SUCCESS</strong></span>
|
||||
}
|
||||
@if (activeParsedDataset.submitResult.error) {
|
||||
<span class="color-red"><strong>ERROR</strong></span>
|
||||
}
|
||||
</p>
|
||||
@if (activeParsedDataset.submitResult.error) {
|
||||
<p cds-text="secondary regular">Error details:</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
@if (!submittingCsv && activeParsedDataset.submitResult.error) {
|
||||
<button
|
||||
(click)="reSubmitTable(activeParsedDataset)"
|
||||
class="btn btn-primary mt-10"
|
||||
[clrLoading]="submitLoading"
|
||||
>
|
||||
Resubmit
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
(click)="
|
||||
downloadFile(
|
||||
activeParsedDataset.submitResult.log ||
|
||||
activeParsedDataset.submitResult.success ||
|
||||
activeParsedDataset.submitResult.error
|
||||
)
|
||||
"
|
||||
class="btn btn-primary-outline mt-10"
|
||||
>
|
||||
Download log
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
@if (activeParsedDataset.submitResult?.error) {
|
||||
<div class="error-field mt-15">
|
||||
<div class="log-wrapper">
|
||||
{{ activeParsedDataset.submitResult?.error | json }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex clr-justify-content-between p-10 mt-15">
|
||||
<div>
|
||||
<p cds-text="secondary regular" class="mb-10">
|
||||
Found in range:
|
||||
|
||||
<ng-container *ngIf="activeParsedDataset.parseResult">
|
||||
@if (activeParsedDataset.parseResult) {
|
||||
<strong
|
||||
>"{{
|
||||
activeParsedDataset.parseResult.rangeSheetRes?.sheetName
|
||||
@@ -298,21 +276,18 @@
|
||||
?.rangeAddress
|
||||
}}</strong
|
||||
>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!activeParsedDataset.parseResult">
|
||||
<strong *ngIf="!activeParsedDataset.parsingTable"
|
||||
>No data found</strong
|
||||
>
|
||||
|
||||
<span
|
||||
*ngIf="activeParsedDataset.parsingTable"
|
||||
class="d-flex clr-align-items-center"
|
||||
>
|
||||
<strong>Searching for the data...</strong>
|
||||
<clr-spinner class="ml-5" clrSmall></clr-spinner>
|
||||
</span>
|
||||
</ng-container>
|
||||
}
|
||||
@if (!activeParsedDataset.parseResult) {
|
||||
@if (!activeParsedDataset.parsingTable) {
|
||||
<strong>No data found</strong>
|
||||
}
|
||||
@if (activeParsedDataset.parsingTable) {
|
||||
<span class="d-flex clr-align-items-center">
|
||||
<strong>Searching for the data...</strong>
|
||||
<clr-spinner class="ml-5" clrSmall></clr-spinner>
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</p>
|
||||
<p cds-text="secondary regular">
|
||||
Dataset:
|
||||
@@ -333,7 +308,6 @@
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<clr-toggle-wrapper>
|
||||
<input
|
||||
@@ -354,11 +328,11 @@
|
||||
</clr-toggle-wrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isHotHidden" class="text-center w-100">
|
||||
<clr-spinner class="spinner-md"></clr-spinner>
|
||||
</div>
|
||||
|
||||
@if (isHotHidden) {
|
||||
<div class="text-center w-100">
|
||||
<clr-spinner class="spinner-md"></clr-spinner>
|
||||
</div>
|
||||
}
|
||||
<hot-table
|
||||
#hotInstanceMain
|
||||
id="hotTable"
|
||||
@@ -366,27 +340,24 @@
|
||||
[settings]="hotMainTableSettings"
|
||||
>
|
||||
</hot-table>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="submittedCsvDatasets.length">
|
||||
<div
|
||||
*ngIf="!activeSubmittedCsvDataset"
|
||||
class="no-table-selected pointer-events-none"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="warning-standard"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a dataset on the left to review the submit results
|
||||
</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="activeSubmittedCsvDataset">
|
||||
}
|
||||
}
|
||||
@if (submittedCsvDatasets.length) {
|
||||
@if (!activeSubmittedCsvDataset) {
|
||||
<div class="no-table-selected pointer-events-none">
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="warning-standard"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a dataset on the left to review the submit results
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (activeSubmittedCsvDataset) {
|
||||
<div class="d-flex clr-justify-content-between p-10">
|
||||
<div>
|
||||
<p cds-text="secondary regular" class="mb-10">
|
||||
@@ -409,23 +380,17 @@
|
||||
</p>
|
||||
<p cds-text="secondary regular" class="mb-10">
|
||||
Status:
|
||||
<span
|
||||
*ngIf="activeSubmittedCsvDataset.success"
|
||||
class="color-green"
|
||||
><strong>SUCCESS</strong></span
|
||||
>
|
||||
<span *ngIf="activeSubmittedCsvDataset.error" class="color-red"
|
||||
><strong>ERROR</strong></span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
*ngIf="activeSubmittedCsvDataset.error"
|
||||
cds-text="secondary regular"
|
||||
>
|
||||
Error details:
|
||||
@if (activeSubmittedCsvDataset.success) {
|
||||
<span class="color-green"><strong>SUCCESS</strong></span>
|
||||
}
|
||||
@if (activeSubmittedCsvDataset.error) {
|
||||
<span class="color-red"><strong>ERROR</strong></span>
|
||||
}
|
||||
</p>
|
||||
@if (activeSubmittedCsvDataset.error) {
|
||||
<p cds-text="secondary regular">Error details:</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
(click)="
|
||||
@@ -440,28 +405,29 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="activeSubmittedCsvDataset.error" class="error-field mt-15">
|
||||
<div class="log-wrapper">
|
||||
{{ activeSubmittedCsvDataset.error | json }}
|
||||
@if (activeSubmittedCsvDataset.error) {
|
||||
<div class="error-field mt-15">
|
||||
<div class="log-wrapper">
|
||||
{{ activeSubmittedCsvDataset.error | json }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<!-- <div>
|
||||
<p
|
||||
<p
|
||||
*ngIf="
|
||||
licenceState.value.viewer_rows_allowed !== Infinity &&
|
||||
hotTable.data &&
|
||||
hotTable.data.length > licenceState.value.viewer_rows_allowed
|
||||
"
|
||||
class="mt-2-i w-100 text-center"
|
||||
>
|
||||
To display more than {{ licenceState.value.viewer_rows_allowed }} rows,
|
||||
contact <contact-link />
|
||||
</p>
|
||||
</div> -->
|
||||
class="mt-2-i w-100 text-center"
|
||||
>
|
||||
To display more than {{ licenceState.value.viewer_rows_allowed }} rows,
|
||||
contact <contact-link />
|
||||
</p>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -471,16 +437,14 @@
|
||||
{{ tablesToSubmit.length === 1 ? 'table' : 'tables' }} for approval
|
||||
</h3>
|
||||
<div class="modal-body">
|
||||
<p
|
||||
*ngIf="licenceState.value.submit_rows_limit !== Infinity"
|
||||
cds-text="body"
|
||||
class="licence-limit-notice mt-0 mb-15"
|
||||
>
|
||||
Due to current licence, only
|
||||
{{ licenceState.value.submit_rows_limit }} rows in each file will be
|
||||
submitted. To remove the restriction, contact
|
||||
support@datacontroller.io.
|
||||
</p>
|
||||
@if (licenceState.value.submit_rows_limit !== Infinity) {
|
||||
<p cds-text="body" class="licence-limit-notice mt-0 mb-15">
|
||||
Due to current licence, only
|
||||
{{ licenceState.value.submit_rows_limit }} rows in each file will be
|
||||
submitted. To remove the restriction, contact
|
||||
support@datacontroller.io.
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="text-area-full-width">
|
||||
<label for="formFields_8" class="mb-5 d-block">Message</label>
|
||||
|
||||
@@ -30,20 +30,21 @@
|
||||
(ngModelChange)="setGroupLogic(groupLogic)"
|
||||
clrSelect
|
||||
>
|
||||
<option
|
||||
*ngFor="let logic of logicOperators"
|
||||
[selected]="logicOperators[0]"
|
||||
>
|
||||
{{ logic }}
|
||||
</option>
|
||||
@for (logic of logicOperators; track logic) {
|
||||
<option [selected]="logicOperators[0]">
|
||||
{{ logic }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</clr-select-container>
|
||||
</div>
|
||||
<div class="clr-col-md-10 mb-30">
|
||||
<pre class="line-numbers language-markup">
|
||||
<div *ngIf="whereClauseLoading" class="progresStatic progress loop">
|
||||
<progress></progress>
|
||||
</div>
|
||||
@if (whereClauseLoading) {
|
||||
<div class="progresStatic progress loop">
|
||||
<progress></progress>
|
||||
</div>
|
||||
}
|
||||
|
||||
<code class="language-sql">{{whereClause}}</code>
|
||||
</pre>
|
||||
@@ -55,245 +56,246 @@
|
||||
class="clauses-container clr-col-md-12"
|
||||
[class.clr-col-md-10]="clauses?.queryObj?.length > 1"
|
||||
>
|
||||
<div *ngIf="clauses?.queryObj?.length > 1"></div>
|
||||
@if (clauses?.queryObj?.length > 1) {
|
||||
<div></div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="clause-row"
|
||||
*ngFor="let clause of clauses.queryObj; let clauseIndex = index"
|
||||
>
|
||||
<div class="clr-row" [class.invalid-clause]="clause.invalidClause">
|
||||
<div class="clause-logic clr-col-md-2">
|
||||
<div class="select">
|
||||
<clr-select-container>
|
||||
<label>Logic</label>
|
||||
|
||||
<select
|
||||
[(ngModel)]="clause.clauseLogic"
|
||||
(ngModelChange)="setLogic()"
|
||||
[disabled]="clause.elements.length < 2"
|
||||
clrSelect
|
||||
>
|
||||
<option
|
||||
*ngFor="let logic of logicOperators"
|
||||
[selected]="logicOperators[0]"
|
||||
>
|
||||
{{ logic }}
|
||||
</option>
|
||||
</select>
|
||||
</clr-select-container>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<button
|
||||
*ngIf="innerWidth > 768"
|
||||
class="btn btn-primary btn-block mt-10"
|
||||
(click)="addGroupClause()"
|
||||
>
|
||||
<clr-icon shape="plus"></clr-icon>
|
||||
<span>Group</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="clause-query clr-col-md-10">
|
||||
<clr-icon
|
||||
*ngIf="clauses.queryObj.length > 1"
|
||||
(click)="removeGroupClause(clauseIndex)"
|
||||
shape="times"
|
||||
size="36"
|
||||
class="remove-group-clause-button"
|
||||
></clr-icon>
|
||||
|
||||
<div
|
||||
class="clr-row"
|
||||
*ngFor="let query of clause.elements; let queryIndex = index"
|
||||
[class.invalid-clause]="query.invalidClause"
|
||||
>
|
||||
<!-- VARIABLE -->
|
||||
<div class="variable-col form-group clr-col-md-3">
|
||||
<div class="datalist-wrapper">
|
||||
<app-soft-select
|
||||
label="Variable"
|
||||
[id]="'select_vals_var_id' + queryIndex + '_' + clauseIndex"
|
||||
[inputId]="'vals_var_id' + queryIndex + '_' + clauseIndex"
|
||||
[emitOnlySelected]="true"
|
||||
[(value)]="query.variable"
|
||||
(onInputEvent)="
|
||||
variableInputChange(
|
||||
query.variable,
|
||||
queryIndex,
|
||||
clauseIndex,
|
||||
$event
|
||||
)
|
||||
"
|
||||
>
|
||||
<option *ngFor="let column of cols">
|
||||
{{ column.NAME }}
|
||||
</option>
|
||||
</app-soft-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OPERATOR -->
|
||||
<div class="operator-col form-group clr-col-md-3">
|
||||
@for (clause of clauses.queryObj; track clause; let clauseIndex = $index) {
|
||||
<div class="clause-row">
|
||||
<div class="clr-row" [class.invalid-clause]="clause.invalidClause">
|
||||
<div class="clause-logic clr-col-md-2">
|
||||
<div class="select">
|
||||
<clr-select-container>
|
||||
<label>Operator</label>
|
||||
<label>Logic</label>
|
||||
<select
|
||||
[(ngModel)]="query.operator"
|
||||
(ngModelChange)="
|
||||
setVariableOperator(queryIndex, query.operator, clauseIndex)
|
||||
"
|
||||
[(ngModel)]="clause.clauseLogic"
|
||||
(ngModelChange)="setLogic()"
|
||||
[disabled]="clause.elements.length < 2"
|
||||
clrSelect
|
||||
>
|
||||
<option *ngFor="let opr of query.operators">{{ opr }}</option>
|
||||
@for (logic of logicOperators; track logic) {
|
||||
<option [selected]="logicOperators[0]">
|
||||
{{ logic }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</clr-select-container>
|
||||
</div>
|
||||
|
||||
<!-- VALUE -->
|
||||
<div
|
||||
*ngVar="
|
||||
query.ddtype === 'DATE' ||
|
||||
query.ddtype === 'DATETIME' ||
|
||||
query.ddtype === 'TIME' as isDateTime
|
||||
"
|
||||
class="value-col form-group clr-col-md-3"
|
||||
>
|
||||
<div
|
||||
*ngIf="query.operator === 'IN' || query.operator === 'NOT IN'"
|
||||
class="checkbox-vals"
|
||||
>
|
||||
<button
|
||||
(click)="
|
||||
currentQueryIndex = queryIndex;
|
||||
currentClauseIndex = clauseIndex
|
||||
"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Choose values
|
||||
</button>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
checkboxValues;
|
||||
context: {
|
||||
query: query,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex
|
||||
}
|
||||
"
|
||||
>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="
|
||||
query.operator !== 'BETWEEN' &&
|
||||
query.operator !== 'IN' &&
|
||||
query.operator !== 'NOT IN' &&
|
||||
query.operator !== 'LIKE' &&
|
||||
query.operator !== 'CONTAINS' &&
|
||||
query.operator !== 'BEGINS_WITH'
|
||||
"
|
||||
class="single-field-vals"
|
||||
>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
isDateTime && usePickers ? picker : notPicker;
|
||||
context: {
|
||||
query: query,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex,
|
||||
isDateTime: isDateTime
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div *ngIf="query.operator === 'BETWEEN'" class="range-vals">
|
||||
<div class="from">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
isDateTime && usePickers ? picker : notPickerRange;
|
||||
context: {
|
||||
range: 'start',
|
||||
query: query,
|
||||
queryValueIndex: 0,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex,
|
||||
isDateTime: isDateTime
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="to">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
isDateTime && usePickers ? picker : notPickerRange;
|
||||
context: {
|
||||
range: 'end',
|
||||
query: query,
|
||||
queryValueIndex: 1,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex,
|
||||
isDateTime: isDateTime
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="
|
||||
query.operator === 'LIKE' ||
|
||||
query.operator === 'BEGINS_WITH' ||
|
||||
query.operator === 'CONTAINS'
|
||||
"
|
||||
class="contains-vals"
|
||||
>
|
||||
<label class="clr-control-label">Value</label>
|
||||
|
||||
<input
|
||||
class="input-val"
|
||||
type="text"
|
||||
[(ngModel)]="query.value"
|
||||
(ngModelChange)="
|
||||
setVariableValues($event, queryIndex, clauseIndex)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clause-buttons clr-col-md-2 btn-group">
|
||||
<br />
|
||||
@if (innerWidth > 768) {
|
||||
<button
|
||||
class="btn btn-warning btn-block"
|
||||
(click)="removeClause(queryIndex, clauseIndex)"
|
||||
[disabled]="clauses.queryObj[clauseIndex].elements.length === 1"
|
||||
>
|
||||
<clr-icon shape="minus"></clr-icon>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-success btn-block"
|
||||
(click)="addClause(clauseIndex)"
|
||||
class="btn btn-primary btn-block mt-10"
|
||||
(click)="addGroupClause()"
|
||||
>
|
||||
<clr-icon shape="plus"></clr-icon>
|
||||
<span></span>
|
||||
<span>Group</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="clause-query clr-col-md-10">
|
||||
@if (clauses.queryObj.length > 1) {
|
||||
<clr-icon
|
||||
(click)="removeGroupClause(clauseIndex)"
|
||||
shape="times"
|
||||
size="36"
|
||||
class="remove-group-clause-button"
|
||||
></clr-icon>
|
||||
}
|
||||
@for (
|
||||
query of clause.elements;
|
||||
track query;
|
||||
let queryIndex = $index
|
||||
) {
|
||||
<div class="clr-row" [class.invalid-clause]="query.invalidClause">
|
||||
<!-- VARIABLE -->
|
||||
<div class="variable-col form-group clr-col-md-3">
|
||||
<div class="datalist-wrapper">
|
||||
<app-soft-select
|
||||
label="Variable"
|
||||
[id]="
|
||||
'select_vals_var_id' + queryIndex + '_' + clauseIndex
|
||||
"
|
||||
[inputId]="'vals_var_id' + queryIndex + '_' + clauseIndex"
|
||||
[emitOnlySelected]="true"
|
||||
[(value)]="query.variable"
|
||||
(onInputEvent)="
|
||||
variableInputChange(
|
||||
query.variable,
|
||||
queryIndex,
|
||||
clauseIndex,
|
||||
$event
|
||||
)
|
||||
"
|
||||
>
|
||||
@for (column of cols; track column) {
|
||||
<option>
|
||||
{{ column.NAME }}
|
||||
</option>
|
||||
}
|
||||
</app-soft-select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- OPERATOR -->
|
||||
<div class="operator-col form-group clr-col-md-3">
|
||||
<clr-select-container>
|
||||
<label>Operator</label>
|
||||
<select
|
||||
[(ngModel)]="query.operator"
|
||||
(ngModelChange)="
|
||||
setVariableOperator(
|
||||
queryIndex,
|
||||
query.operator,
|
||||
clauseIndex
|
||||
)
|
||||
"
|
||||
clrSelect
|
||||
>
|
||||
@for (opr of query.operators; track opr) {
|
||||
<option>{{ opr }}</option>
|
||||
}
|
||||
</select>
|
||||
</clr-select-container>
|
||||
</div>
|
||||
<!-- VALUE -->
|
||||
<div
|
||||
*ngVar="
|
||||
query.ddtype === 'DATE' ||
|
||||
query.ddtype === 'DATETIME' ||
|
||||
query.ddtype === 'TIME' as isDateTime
|
||||
"
|
||||
class="value-col form-group clr-col-md-3"
|
||||
>
|
||||
@if (query.operator === 'IN' || query.operator === 'NOT IN') {
|
||||
<div class="checkbox-vals">
|
||||
<button
|
||||
(click)="
|
||||
currentQueryIndex = queryIndex;
|
||||
currentClauseIndex = clauseIndex
|
||||
"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Choose values
|
||||
</button>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
checkboxValues;
|
||||
context: {
|
||||
query: query,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex
|
||||
}
|
||||
"
|
||||
>
|
||||
</ng-container>
|
||||
</div>
|
||||
}
|
||||
@if (
|
||||
query.operator !== 'BETWEEN' &&
|
||||
query.operator !== 'IN' &&
|
||||
query.operator !== 'NOT IN' &&
|
||||
query.operator !== 'LIKE' &&
|
||||
query.operator !== 'CONTAINS' &&
|
||||
query.operator !== 'BEGINS_WITH'
|
||||
) {
|
||||
<div class="single-field-vals">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
isDateTime && usePickers ? picker : notPicker;
|
||||
context: {
|
||||
query: query,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex,
|
||||
isDateTime: isDateTime
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
|
||||
</ng-container>
|
||||
</div>
|
||||
}
|
||||
@if (query.operator === 'BETWEEN') {
|
||||
<div class="range-vals">
|
||||
<div class="from">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
isDateTime && usePickers ? picker : notPickerRange;
|
||||
context: {
|
||||
range: 'start',
|
||||
query: query,
|
||||
queryValueIndex: 0,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex,
|
||||
isDateTime: isDateTime
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="to">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
isDateTime && usePickers ? picker : notPickerRange;
|
||||
context: {
|
||||
range: 'end',
|
||||
query: query,
|
||||
queryValueIndex: 1,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex,
|
||||
isDateTime: isDateTime
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (
|
||||
query.operator === 'LIKE' ||
|
||||
query.operator === 'BEGINS_WITH' ||
|
||||
query.operator === 'CONTAINS'
|
||||
) {
|
||||
<div class="contains-vals">
|
||||
<label class="clr-control-label">Value</label>
|
||||
<input
|
||||
class="input-val"
|
||||
type="text"
|
||||
[(ngModel)]="query.value"
|
||||
(ngModelChange)="
|
||||
setVariableValues($event, queryIndex, clauseIndex)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="clause-buttons clr-col-md-2 btn-group">
|
||||
<button
|
||||
class="btn btn-warning btn-block"
|
||||
(click)="removeClause(queryIndex, clauseIndex)"
|
||||
[disabled]="
|
||||
clauses.queryObj[clauseIndex].elements.length === 1
|
||||
"
|
||||
>
|
||||
<clr-icon shape="minus"></clr-icon>
|
||||
<span></span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-success btn-block"
|
||||
(click)="addClause(clauseIndex)"
|
||||
>
|
||||
<clr-icon shape="plus"></clr-icon>
|
||||
<span></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -305,8 +307,8 @@
|
||||
let-queryIndex="queryIndex"
|
||||
let-clauseIndex="clauseIndex"
|
||||
>
|
||||
<ng-container [ngSwitch]="query.ddtype">
|
||||
<ng-container *ngSwitchCase="'DATE'">
|
||||
@switch (query.ddtype) {
|
||||
@case ('DATE') {
|
||||
<app-soft-select
|
||||
label="Value"
|
||||
type="date"
|
||||
@@ -323,14 +325,13 @@
|
||||
>
|
||||
<!-- In case we want to enable dropdown on the pickers, uncomment below -->
|
||||
<!-- <ng-container *ngFor="let value of query.values">
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'date' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'date' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
</app-soft-select>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="'DATETIME'">
|
||||
}
|
||||
@case ('DATETIME') {
|
||||
<app-soft-select
|
||||
label="Value"
|
||||
type="date"
|
||||
@@ -348,12 +349,11 @@
|
||||
>
|
||||
<!-- In case we want to enable dropdown on the pickers, uncomment below -->
|
||||
<!-- <ng-container *ngFor="let value of query.values">
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'date' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'date' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
</app-soft-select>
|
||||
|
||||
<app-soft-select
|
||||
type="time"
|
||||
[disableSoftselect]="true"
|
||||
@@ -370,14 +370,13 @@
|
||||
>
|
||||
<!-- In case we want to enable dropdown on the pickers, uncomment below -->
|
||||
<!-- <ng-container *ngFor="let value of query.values">
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'time' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'time' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
</app-soft-select>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="'TIME'">
|
||||
}
|
||||
@case ('TIME') {
|
||||
<app-soft-select
|
||||
label="Value"
|
||||
type="time"
|
||||
@@ -394,13 +393,13 @@
|
||||
>
|
||||
<!-- In case we want to enable dropdown on the pickers, uncomment below -->
|
||||
<!-- <ng-container *ngFor="let value of query.values">
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'time' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'time' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
</app-soft-select>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template
|
||||
@@ -425,19 +424,27 @@
|
||||
onAutocompleteLoadingMore($event, query.variable, queryIndex, clauseIndex)
|
||||
"
|
||||
>
|
||||
<div *ngIf="!query.valueVariable">
|
||||
<option [value]="column.unformatted" *ngFor="let column of query.values">
|
||||
{{ column.formatted.trim() }}
|
||||
</option>
|
||||
</div>
|
||||
@if (!query.valueVariable) {
|
||||
<div>
|
||||
@for (column of query.values; track column) {
|
||||
<option [value]="column.unformatted">
|
||||
{{ column.formatted.trim() }}
|
||||
</option>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div *ngIf="query.valueVariable">
|
||||
<ng-container *ngFor="let column of cols">
|
||||
<option [value]="column.NAME" *ngIf="column.TYPE === query.type">
|
||||
{{ column.NAME }}
|
||||
</option>
|
||||
</ng-container>
|
||||
</div>
|
||||
@if (query.valueVariable) {
|
||||
<div>
|
||||
@for (column of cols; track column) {
|
||||
@if (column.TYPE === query.type) {
|
||||
<option [value]="column.NAME">
|
||||
{{ column.NAME }}
|
||||
</option>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</app-soft-select>
|
||||
</ng-template>
|
||||
|
||||
@@ -466,9 +473,11 @@
|
||||
: false
|
||||
"
|
||||
>
|
||||
<option [value]="column.formatted" *ngFor="let column of query.values">
|
||||
{{ column.formatted }}
|
||||
</option>
|
||||
@for (column of query.values; track column) {
|
||||
<option [value]="column.formatted">
|
||||
{{ column.formatted }}
|
||||
</option>
|
||||
}
|
||||
</app-soft-select>
|
||||
</ng-template>
|
||||
|
||||
@@ -487,30 +496,31 @@
|
||||
>
|
||||
<h3 class="modal-title">Select values</h3>
|
||||
<div class="modal-body">
|
||||
<h5 *ngIf="!isArr(query.value)" class="no-values">
|
||||
No values available.
|
||||
</h5>
|
||||
@if (!isArr(query.value)) {
|
||||
<h5 class="no-values">No values available.</h5>
|
||||
}
|
||||
|
||||
<section class="form-block" *ngIf="isArr(query.value)">
|
||||
<clr-checkbox-container>
|
||||
<clr-checkbox-wrapper
|
||||
*ngFor="let column of query.values; let i = index"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
clrCheckbox
|
||||
[(ngModel)]="query.value[i].checked"
|
||||
(ngModelChange)="
|
||||
setVariableValues($event, queryIndex, clauseIndex)
|
||||
"
|
||||
/>
|
||||
|
||||
<label>
|
||||
{{ column.formatted }}
|
||||
</label>
|
||||
</clr-checkbox-wrapper>
|
||||
</clr-checkbox-container>
|
||||
</section>
|
||||
@if (isArr(query.value)) {
|
||||
<section class="form-block">
|
||||
<clr-checkbox-container>
|
||||
@for (column of query.values; track column; let i = $index) {
|
||||
<clr-checkbox-wrapper>
|
||||
<input
|
||||
type="checkbox"
|
||||
clrCheckbox
|
||||
[(ngModel)]="query.value[i].checked"
|
||||
(ngModelChange)="
|
||||
setVariableValues($event, queryIndex, clauseIndex)
|
||||
"
|
||||
/>
|
||||
<label>
|
||||
{{ column.formatted }}
|
||||
</label>
|
||||
</clr-checkbox-wrapper>
|
||||
}
|
||||
</clr-checkbox-container>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
|
||||
@@ -22,6 +22,7 @@ import { QueryDateTime } from './models/QueryDateTime.model'
|
||||
import { isSpecialMissing } from '@sasjs/utils/input/validators'
|
||||
import { get } from 'lodash-es'
|
||||
import { OnLoadingMoreEvent } from '../shared/autocomplete/autocomplete.component'
|
||||
import { getFilterObjPath } from './utils/getFilterObjPath'
|
||||
|
||||
registerLocaleData(localeEnGB)
|
||||
@Component({
|
||||
@@ -251,17 +252,7 @@ export class QueryComponent
|
||||
public setToGlobals() {
|
||||
if (!this.caching) return
|
||||
|
||||
let objPath = ''
|
||||
|
||||
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
|
||||
if (this.viewboxId) {
|
||||
objPath = `viewboxes.${this.viewboxId}`
|
||||
} else {
|
||||
objPath = 'editor'
|
||||
}
|
||||
} else if (globals.rootParam === 'view') {
|
||||
objPath = 'viewer'
|
||||
}
|
||||
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
|
||||
|
||||
get(globals, objPath).filter.groupLogic = this.groupLogic
|
||||
if (typeof this.whereClause === 'string') {
|
||||
@@ -280,17 +271,7 @@ export class QueryComponent
|
||||
public getFromGlobals() {
|
||||
if (!this.caching) return
|
||||
|
||||
let objPath = ''
|
||||
|
||||
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
|
||||
if (this.viewboxId) {
|
||||
objPath = `viewboxes.${this.viewboxId}`
|
||||
} else {
|
||||
objPath = 'editor'
|
||||
}
|
||||
} else if (globals.rootParam === 'view') {
|
||||
objPath = 'viewer'
|
||||
}
|
||||
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
|
||||
|
||||
if (get(globals, objPath).filter.cols.length > 0) {
|
||||
this.cols = JSON.parse(JSON.stringify(get(globals, objPath).filter.cols))
|
||||
@@ -1051,22 +1032,23 @@ export class QueryComponent
|
||||
this.columnsSub = this.sasStoreService.columns.subscribe(
|
||||
(response: any) => {
|
||||
let cols = response.data.cols
|
||||
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
|
||||
|
||||
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
|
||||
this.cols = cols
|
||||
let some = cols[0].NAME
|
||||
this.libds = response.libds
|
||||
|
||||
globals.editor.filter.cols = JSON.parse(JSON.stringify(cols))
|
||||
get(globals, objPath).filter.cols = JSON.parse(JSON.stringify(cols))
|
||||
}
|
||||
|
||||
if (globals.rootParam === 'view') {
|
||||
if (globals.viewer.filter.cols.length < 1) {
|
||||
if (get(globals, objPath).filter.cols.length < 1) {
|
||||
this.cols = cols
|
||||
let some = cols[0].NAME
|
||||
this.libds = response.libds
|
||||
|
||||
globals.viewer.filter.cols = JSON.parse(JSON.stringify(cols))
|
||||
get(globals, objPath).filter.cols = JSON.parse(JSON.stringify(cols))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1077,20 +1059,12 @@ export class QueryComponent
|
||||
)
|
||||
|
||||
this.valuesSub = this.sasStoreService.values.subscribe((res: any) => {
|
||||
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
|
||||
if (globals.editor.filter.vals.length < 1) {
|
||||
this.vals = res.vals
|
||||
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
|
||||
|
||||
globals.editor.filter.vals = JSON.parse(JSON.stringify(res.vals))
|
||||
}
|
||||
}
|
||||
if (get(globals, objPath).filter.vals.length < 1) {
|
||||
this.vals = res.vals
|
||||
|
||||
if (globals.rootParam === 'view') {
|
||||
if (globals.viewer.filter.vals.length < 1) {
|
||||
this.vals = res.vals
|
||||
|
||||
globals.viewer.filter.vals = JSON.parse(JSON.stringify(res.vals))
|
||||
}
|
||||
get(globals, objPath).filter.vals = JSON.parse(JSON.stringify(res.vals))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getFilterObjPath } from './getFilterObjPath'
|
||||
|
||||
describe('getFilterObjPath', () => {
|
||||
it("resolves to 'viewer' for the view page with no viewboxId (existing correct case)", () => {
|
||||
expect(getFilterObjPath('view', undefined)).toEqual('viewer')
|
||||
})
|
||||
|
||||
it("resolves to 'editor' for the editor page with no viewboxId", () => {
|
||||
expect(getFilterObjPath('editor', undefined)).toEqual('editor')
|
||||
})
|
||||
|
||||
it("resolves to 'editor' for the home page with no viewboxId (existing home/editor grouping)", () => {
|
||||
expect(getFilterObjPath('home', undefined)).toEqual('editor')
|
||||
})
|
||||
|
||||
it("resolves to 'viewboxes.<id>' when viewboxId is set on the view page - the actual bug (previously returned 'viewer')", () => {
|
||||
expect(getFilterObjPath('view', 42)).toEqual('viewboxes.42')
|
||||
})
|
||||
|
||||
it("resolves to 'viewboxes.<id>' when viewboxId is set on the editor page (already correct today)", () => {
|
||||
expect(getFilterObjPath('editor', 42)).toEqual('viewboxes.42')
|
||||
})
|
||||
|
||||
it("resolves to 'viewboxes.<id>' when viewboxId is set on the home page (already correct today)", () => {
|
||||
expect(getFilterObjPath('home', 42)).toEqual('viewboxes.42')
|
||||
})
|
||||
|
||||
it('resolves to an empty string for an unrecognized rootParam (matches existing fallthrough)', () => {
|
||||
expect(getFilterObjPath('', undefined)).toEqual('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Resolves which bucket of the `globals` filter cache (_globals.ts) a
|
||||
* QueryComponent instance should read/write. viewboxId is checked first,
|
||||
* unconditionally.
|
||||
*/
|
||||
export const getFilterObjPath = (
|
||||
rootParam: string,
|
||||
viewboxId?: number
|
||||
): string => {
|
||||
if (viewboxId) return `viewboxes.${viewboxId}`
|
||||
if (rootParam === 'home' || rootParam === 'editor') return 'editor'
|
||||
if (rootParam === 'view') return 'viewer'
|
||||
|
||||
return ''
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,148 +1,161 @@
|
||||
<div class="content-area">
|
||||
<div class="card">
|
||||
<div *ngIf="remained === 0" class="d-flex justify-content-center">
|
||||
<div class="card-block noapprovals-info-wrapper">
|
||||
<clr-icon
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<h3 class="text-center color-gray">There are no approvals remaining</h3>
|
||||
@if (remained === 0) {
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="card-block noapprovals-info-wrapper">
|
||||
<clr-icon
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<h3 class="text-center color-gray">
|
||||
There are no approvals remaining
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
|
||||
<h3
|
||||
class="center clr-col-md-12 text-center"
|
||||
*ngIf="loaded && remained !== 0"
|
||||
>
|
||||
REVIEW
|
||||
</h3>
|
||||
<p
|
||||
class="text-center font-weight-700 color-dark-gray"
|
||||
*ngIf="loaded && remained !== 0"
|
||||
>
|
||||
You have <span>{{ remained }} </span>approvals remaining
|
||||
</p>
|
||||
@if (loaded && remained !== 0) {
|
||||
<h3 class="center clr-col-md-12 text-center">REVIEW</h3>
|
||||
}
|
||||
@if (loaded && remained !== 0) {
|
||||
<p class="text-center font-weight-700 color-dark-gray">
|
||||
You have <span>{{ remained }} </span>approvals remaining
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div *ngIf="!loaded" class="approvals-list-wrapper">
|
||||
<span class="spinner" *ngIf="!loaded"> Loading... </span>
|
||||
<div *ngIf="!loaded">
|
||||
<h3>Loading approvals list</h3>
|
||||
@if (!loaded) {
|
||||
<div class="approvals-list-wrapper">
|
||||
@if (!loaded) {
|
||||
<span class="spinner"> Loading... </span>
|
||||
}
|
||||
@if (!loaded) {
|
||||
<div>
|
||||
<h3>Loading approvals list</h3>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="clr-col-md-12" ng-if="loaded">
|
||||
<div *ngIf="approveList && remained !== 0">
|
||||
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
|
||||
<clr-dg-column [clrDgField]="'submitter'">
|
||||
SUBMITTER
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitterFilter"
|
||||
aria-label="Filter submitter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'baseTable'">
|
||||
BASE TABLE
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="baseTableFilter"
|
||||
aria-label="Filter base table"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitted'">
|
||||
SUBMITTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submittedFilter"
|
||||
aria-label="Filter submitted date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitReason'">
|
||||
SUBMIT REASON
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitReasonFilter"
|
||||
aria-label="Filter submit reason"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column>ACTION</clr-dg-column>
|
||||
<clr-dg-column>DOWNLOAD</clr-dg-column>
|
||||
|
||||
<clr-dg-row
|
||||
*clrDgItems="let approveItem of approveList; let i = index"
|
||||
>
|
||||
<clr-dg-cell>{{ approveItem.submitter }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ approveItem.baseTable }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ approveItem.submitted }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ approveItem.submitReason }}</clr-dg-cell>
|
||||
<clr-dg-cell>
|
||||
<div
|
||||
class="clr-row d-flex justify-content-around"
|
||||
role="toolbar"
|
||||
aria-label="Table actions"
|
||||
>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-green"
|
||||
(click)="getClicked(i)"
|
||||
@if (approveList && remained !== 0) {
|
||||
<div>
|
||||
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
|
||||
<clr-dg-column [clrDgField]="'submitter'">
|
||||
SUBMITTER
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitterFilter"
|
||||
aria-label="Filter submitter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'baseTable'">
|
||||
BASE TABLE
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="baseTableFilter"
|
||||
aria-label="Filter base table"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitted'">
|
||||
SUBMITTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submittedFilter"
|
||||
aria-label="Filter submitted date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitReason'">
|
||||
SUBMIT REASON
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitReasonFilter"
|
||||
aria-label="Filter submit reason"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column>ACTION</clr-dg-column>
|
||||
<clr-dg-column>DOWNLOAD</clr-dg-column>
|
||||
<clr-dg-row
|
||||
*clrDgItems="let approveItem of approveList; let i = index"
|
||||
>
|
||||
<clr-dg-cell>{{ approveItem.submitter }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ approveItem.baseTable }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ approveItem.submitted }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ approveItem.submitReason }}</clr-dg-cell>
|
||||
<clr-dg-cell>
|
||||
<div
|
||||
class="clr-row d-flex justify-content-around"
|
||||
role="toolbar"
|
||||
aria-label="Table actions"
|
||||
>
|
||||
<clr-icon
|
||||
shape="check"
|
||||
size="24"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content">Go to review page screen</span>
|
||||
</a>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-red"
|
||||
(click)="!approveItem.rejectLoading ? rejecting(i) : ''"
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-green"
|
||||
(click)="getClicked(i)"
|
||||
>
|
||||
<clr-icon
|
||||
shape="check"
|
||||
size="24"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content"
|
||||
>Go to review page screen</span
|
||||
>
|
||||
</a>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-red"
|
||||
(click)="!approveItem.rejectLoading ? rejecting(i) : ''"
|
||||
>
|
||||
@if (!approveItem.rejectLoading) {
|
||||
<clr-icon
|
||||
shape="ban"
|
||||
size="22"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
}
|
||||
@if (approveItem.rejectLoading) {
|
||||
<clr-spinner
|
||||
[clrSmall]="true"
|
||||
aria-hidden="true"
|
||||
></clr-spinner>
|
||||
}
|
||||
<span class="tooltip-content">Reject</span>
|
||||
</a>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
|
||||
(click)="getTable(approveItem.tableId)"
|
||||
>
|
||||
<clr-icon
|
||||
shape="code"
|
||||
size="28"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content"
|
||||
>Go to staged data screen</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell class="p-0 d-flex justify-content-center">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
aria-label="Download audit file"
|
||||
[id]="approveItem.tableId"
|
||||
(click)="
|
||||
download(approveItem.tableId); $event.stopPropagation()
|
||||
"
|
||||
>
|
||||
<clr-icon
|
||||
*ngIf="!approveItem.rejectLoading"
|
||||
shape="ban"
|
||||
size="22"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<clr-spinner
|
||||
*ngIf="approveItem.rejectLoading"
|
||||
[clrSmall]="true"
|
||||
aria-hidden="true"
|
||||
></clr-spinner>
|
||||
<span class="tooltip-content">Reject</span>
|
||||
</a>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
|
||||
(click)="getTable(approveItem.tableId)"
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
<clr-dg-footer>
|
||||
<clr-dg-pagination #pagination [clrDgPageSize]="10">
|
||||
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
|
||||
>Items per page</clr-dg-page-size
|
||||
>
|
||||
<clr-icon
|
||||
shape="code"
|
||||
size="28"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content">Go to staged data screen</span>
|
||||
</a>
|
||||
</div>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell class="p-0 d-flex justify-content-center">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
aria-label="Download audit file"
|
||||
[id]="approveItem.tableId"
|
||||
(click)="
|
||||
download(approveItem.tableId); $event.stopPropagation()
|
||||
"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
|
||||
<clr-dg-footer>
|
||||
<clr-dg-pagination #pagination [clrDgPageSize]="10">
|
||||
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
|
||||
>Items per page</clr-dg-page-size
|
||||
>
|
||||
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} approvals
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer>
|
||||
</clr-datagrid>
|
||||
</div>
|
||||
{{ pagination.firstItem + 1 }} -
|
||||
{{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} approvals
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer>
|
||||
</clr-datagrid>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<div class="content-area">
|
||||
<div
|
||||
*ngIf="noData"
|
||||
id="noDataContainer"
|
||||
class="card-block d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<clr-icon shape="warning-standard" size="60" class="is-info"></clr-icon>
|
||||
<h3 class="text-center color-gray">There is no history to show</h3>
|
||||
</div>
|
||||
@if (noData) {
|
||||
<div
|
||||
id="noDataContainer"
|
||||
class="card-block d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<clr-icon shape="warning-standard" size="60" class="is-info"></clr-icon>
|
||||
<h3 class="text-center color-gray">There is no history to show</h3>
|
||||
</div>
|
||||
}
|
||||
<clr-modal [(clrModalOpen)]="openModal" [clrModalSize]="'xl'">
|
||||
<h4 class="modal-title">Approval details</h4>
|
||||
<div class="modal-body">
|
||||
@@ -18,31 +19,38 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let col of tableTitles; let ind = index">
|
||||
<td class="left">{{ col }}</td>
|
||||
<td class="left">
|
||||
<a
|
||||
*ngIf="ind < 1"
|
||||
(click)="getTable(approveData[col])"
|
||||
class="cursor-pointer table-link"
|
||||
>{{ approveData[col] }}</a
|
||||
>
|
||||
<div *ngIf="ind < 2 && ind >= 1">
|
||||
<a
|
||||
(click)="getBaseTable(approveData[col])"
|
||||
class="cursor-pointer table-link"
|
||||
>VIEW</a
|
||||
>
|
||||
<span> / </span>
|
||||
<a
|
||||
(click)="getEditTable(approveData[col])"
|
||||
class="cursor-pointer table-link"
|
||||
>EDIT</a
|
||||
>
|
||||
</div>
|
||||
<span *ngIf="ind >= 2">{{ approveData[col] }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
@for (col of tableTitles; track col; let ind = $index) {
|
||||
<tr>
|
||||
<td class="left">{{ col }}</td>
|
||||
<td class="left">
|
||||
@if (ind < 1) {
|
||||
<a
|
||||
(click)="getTable(approveData[col])"
|
||||
class="cursor-pointer table-link"
|
||||
>{{ approveData[col] }}</a
|
||||
>
|
||||
}
|
||||
@if (ind < 2 && ind >= 1) {
|
||||
<div>
|
||||
<a
|
||||
(click)="getBaseTable(approveData[col])"
|
||||
class="cursor-pointer table-link"
|
||||
>VIEW</a
|
||||
>
|
||||
<span> / </span>
|
||||
<a
|
||||
(click)="getEditTable(approveData[col])"
|
||||
class="cursor-pointer table-link"
|
||||
>EDIT</a
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@if (ind >= 2) {
|
||||
<span>{{ approveData[col] }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -58,148 +66,159 @@
|
||||
</div>
|
||||
</clr-modal>
|
||||
|
||||
<div
|
||||
*ngIf="!loaded"
|
||||
class="h-70vh d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<span class="spinner" *ngIf="!loaded"> Loading... </span>
|
||||
<div *ngIf="!loaded">
|
||||
<h3>Loading history</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!noData && loaded" class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="center clr-col-md-12 text-center" *ngIf="loaded">HISTORY</h3>
|
||||
<p
|
||||
class="text-center font-weight-700 color-dark-gray"
|
||||
*ngIf="licenceState.value.history_rows_allowed !== Infinity"
|
||||
>
|
||||
To unlock more than
|
||||
{{ licenceState.value.history_rows_allowed }} records, contact
|
||||
support@datacontroller.io
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<clr-datagrid
|
||||
class="datagrid-history datagrid-custom-footer"
|
||||
*ngIf="loaded"
|
||||
>
|
||||
<clr-dg-column [clrDgField]="'basetable'">
|
||||
BASE_TABLE
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="baseTableFilter"
|
||||
aria-label="Filter base table"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'status'">
|
||||
STATUS
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="statusFilter"
|
||||
aria-label="Filter status"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitter'">
|
||||
SUBMITTER
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitterFilter"
|
||||
aria-label="Filter submitter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submittedReason'">
|
||||
SUBMIT REASON
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitReasonFilter"
|
||||
aria-label="Filter submit reason"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitted'">
|
||||
SUBMITTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submittedFilter"
|
||||
aria-label="Filter submitted date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'reviewed'">
|
||||
APPROVED / REJECTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="reviewedFilter"
|
||||
aria-label="Filter reviewed date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column>DOWNLOAD</clr-dg-column>
|
||||
|
||||
<clr-dg-row
|
||||
*clrDgItems="let historyItem of history"
|
||||
(click)="getApprIndex(historyItem)"
|
||||
>
|
||||
<clr-dg-cell class="verCenter">
|
||||
<a
|
||||
class="btn btn-sm btn-link m-0"
|
||||
(click)="getBaseTable(historyItem.basetable)"
|
||||
>{{ historyItem.basetable }}</a
|
||||
>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell
|
||||
class="verCenter"
|
||||
[ngClass]="{
|
||||
rejected: historyItem.status === 'REJECTED',
|
||||
accepted: historyItem.status === 'APPROVED'
|
||||
}"
|
||||
>{{ historyItem.status }}</clr-dg-cell
|
||||
>
|
||||
<clr-dg-cell class="verCenter">{{ historyItem.submitter }}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter">{{
|
||||
historyItem.submittedReason
|
||||
}}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter">{{ historyItem.submitted }}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter">{{ historyItem.reviewed }}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter p-0 d-flex justify-content-center">
|
||||
<button
|
||||
aria-label="Download audit file"
|
||||
class="btn btn-success"
|
||||
(click)="download(historyItem.tableId); $event.stopPropagation()"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
|
||||
<!-- Let's keep this part if in future we decide to do the paging instead of `load more` approach -->
|
||||
<!-- <clr-dg-footer class="d-flex justify-content-start">
|
||||
<span>items per page</span>
|
||||
|
||||
<select class="mx-5" [(ngModel)]="itemsNum">
|
||||
<option [ngValue]="3">3</option>
|
||||
<option [ngValue]="5">5</option>
|
||||
<option [ngValue]="10">10</option>
|
||||
<option [ngValue]="15">15</option>
|
||||
</select>
|
||||
<clr-dg-pagination
|
||||
#pagination
|
||||
[clrDgPageSize]="itemsNum"
|
||||
class="center"
|
||||
>
|
||||
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} updates
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer> -->
|
||||
</clr-datagrid>
|
||||
|
||||
@if (!loaded) {
|
||||
<div
|
||||
class="load-more d-flex clr-justify-content-center clr-align-items-center"
|
||||
class="h-70vh d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<button
|
||||
*ngIf="
|
||||
@if (!loaded) {
|
||||
<span class="spinner"> Loading... </span>
|
||||
}
|
||||
@if (!loaded) {
|
||||
<div>
|
||||
<h3>Loading history</h3>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!noData && loaded) {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@if (loaded) {
|
||||
<h3 class="center clr-col-md-12 text-center">HISTORY</h3>
|
||||
}
|
||||
@if (licenceState.value.history_rows_allowed !== Infinity) {
|
||||
<p class="text-center font-weight-700 color-dark-gray">
|
||||
To unlock more than
|
||||
{{ licenceState.value.history_rows_allowed }} records, contact
|
||||
support@datacontroller.io
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@if (loaded) {
|
||||
<clr-datagrid class="datagrid-history datagrid-custom-footer">
|
||||
<clr-dg-column [clrDgField]="'basetable'">
|
||||
BASE_TABLE
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="baseTableFilter"
|
||||
aria-label="Filter base table"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'status'">
|
||||
STATUS
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="statusFilter"
|
||||
aria-label="Filter status"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitter'">
|
||||
SUBMITTER
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitterFilter"
|
||||
aria-label="Filter submitter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submittedReason'">
|
||||
SUBMIT REASON
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitReasonFilter"
|
||||
aria-label="Filter submit reason"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitted'">
|
||||
SUBMITTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submittedFilter"
|
||||
aria-label="Filter submitted date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'reviewed'">
|
||||
APPROVED / REJECTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="reviewedFilter"
|
||||
aria-label="Filter reviewed date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column>DOWNLOAD</clr-dg-column>
|
||||
<clr-dg-row
|
||||
*clrDgItems="let historyItem of history"
|
||||
(click)="getApprIndex(historyItem)"
|
||||
>
|
||||
<clr-dg-cell class="verCenter">
|
||||
<a
|
||||
class="btn btn-sm btn-link m-0"
|
||||
(click)="getBaseTable(historyItem.basetable)"
|
||||
>{{ historyItem.basetable }}</a
|
||||
>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell
|
||||
class="verCenter"
|
||||
[ngClass]="{
|
||||
rejected: historyItem.status === 'REJECTED',
|
||||
accepted: historyItem.status === 'APPROVED'
|
||||
}"
|
||||
>{{ historyItem.status }}</clr-dg-cell
|
||||
>
|
||||
<clr-dg-cell class="verCenter">{{
|
||||
historyItem.submitter
|
||||
}}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter">{{
|
||||
historyItem.submittedReason
|
||||
}}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter">{{
|
||||
historyItem.submitted
|
||||
}}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter">{{
|
||||
historyItem.reviewed
|
||||
}}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter p-0 d-flex justify-content-center">
|
||||
<button
|
||||
aria-label="Download audit file"
|
||||
class="btn btn-success"
|
||||
(click)="
|
||||
download(historyItem.tableId); $event.stopPropagation()
|
||||
"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
<!-- Let's keep this part if in future we decide to do the paging instead of `load more` approach -->
|
||||
<!-- <clr-dg-footer class="d-flex justify-content-start">
|
||||
<span>items per page</span>
|
||||
<select class="mx-5" [(ngModel)]="itemsNum">
|
||||
<option [ngValue]="3">3</option>
|
||||
<option [ngValue]="5">5</option>
|
||||
<option [ngValue]="10">10</option>
|
||||
<option [ngValue]="15">15</option>
|
||||
</select>
|
||||
<clr-dg-pagination
|
||||
#pagination
|
||||
[clrDgPageSize]="itemsNum"
|
||||
class="center"
|
||||
>
|
||||
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} updates
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer> -->
|
||||
</clr-datagrid>
|
||||
}
|
||||
<div
|
||||
class="load-more d-flex clr-justify-content-center clr-align-items-center"
|
||||
>
|
||||
@if (
|
||||
this.licenceState.value.history_rows_allowed === Infinity &&
|
||||
rowsLeftToLoad > 0
|
||||
"
|
||||
(click)="loadData()"
|
||||
[clrLoading]="loadingMore"
|
||||
class="btn btn-success"
|
||||
>
|
||||
Load {{ rowsLeftToLoad }} more
|
||||
</button>
|
||||
) {
|
||||
<button
|
||||
(click)="loadData()"
|
||||
[clrLoading]="loadingMore"
|
||||
class="btn btn-success"
|
||||
>
|
||||
Load {{ rowsLeftToLoad }} more
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,123 +1,136 @@
|
||||
<div class="w-100">
|
||||
<div *ngIf="!subReady" class="content-area">
|
||||
<div *ngIf="!subReady" class="card">
|
||||
<div
|
||||
*ngIf="remained === 0 && loaded"
|
||||
class="d-flex justify-content-center"
|
||||
>
|
||||
<div
|
||||
class="no-submitted-tables card-block d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<clr-icon
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info"
|
||||
></clr-icon>
|
||||
<h3 class="text-center color-gray">
|
||||
You have not submitted any tables
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
|
||||
<h3 class="center clr-col-md-12 text-center" *ngIf="remained !== 0">
|
||||
SUBMIT QUEUE
|
||||
</h3>
|
||||
<p
|
||||
class="text-center font-weight-700 color-dark-gray"
|
||||
*ngIf="loaded && remained !== 0"
|
||||
>
|
||||
You have <span>{{ remained }} </span>submissions waiting to be
|
||||
approved
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="!loaded"
|
||||
class="h-70vh d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<span class="spinner" *ngIf="!loaded"> Loading... </span>
|
||||
<div *ngIf="!loaded">
|
||||
<h3>Loading submitted list</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clr-col-md-12" *ngIf="loaded">
|
||||
<div *ngIf="submitterList && remained !== 0">
|
||||
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
|
||||
<clr-dg-column>BASE TABLE</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitted'">
|
||||
SUBMITTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submittedFilter"
|
||||
aria-label="Filter submitted date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitReason'">
|
||||
SUBMIT REASON
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitReasonFilter"
|
||||
aria-label="Filter submit reason"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column class="d-flex justify-content-center"
|
||||
>ACTION</clr-dg-column
|
||||
@if (!subReady) {
|
||||
<div class="content-area">
|
||||
@if (!subReady) {
|
||||
<div class="card">
|
||||
@if (remained === 0 && loaded) {
|
||||
<div class="d-flex justify-content-center">
|
||||
<div
|
||||
class="no-submitted-tables card-block d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<clr-icon
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info"
|
||||
></clr-icon>
|
||||
<h3 class="text-center color-gray">
|
||||
You have not submitted any tables
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
|
||||
@if (remained !== 0) {
|
||||
<h3 class="center clr-col-md-12 text-center">SUBMIT QUEUE</h3>
|
||||
}
|
||||
@if (loaded && remained !== 0) {
|
||||
<p class="text-center font-weight-700 color-dark-gray">
|
||||
You have <span>{{ remained }} </span>submissions waiting to be
|
||||
approved
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@if (!loaded) {
|
||||
<div
|
||||
class="h-70vh d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<clr-dg-column class="d-flex justify-content-center"
|
||||
>DOWNLOAD</clr-dg-column
|
||||
>
|
||||
<clr-dg-row
|
||||
*clrDgItems="let sub of submitterList; let i = index"
|
||||
(click)="goToDetails(sub.tableId)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<clr-dg-cell>{{ sub.base }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ sub.submitted }}</clr-dg-cell>
|
||||
<!-- <clr-dg-cell>{{sub.approver}}</clr-dg-cell> -->
|
||||
<clr-dg-cell>{{ sub.submitReason }}</clr-dg-cell>
|
||||
<clr-dg-cell>
|
||||
<div
|
||||
class="row justify-content-around"
|
||||
role="tooltip"
|
||||
aria-label="Go to staged data screen"
|
||||
>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
|
||||
(click)="goToStage(sub.tableId)"
|
||||
>
|
||||
<clr-icon shape="code" size="28"></clr-icon>
|
||||
<span class="tooltip-content"
|
||||
>Go to staged data screen</span
|
||||
>
|
||||
</a>
|
||||
@if (!loaded) {
|
||||
<span class="spinner"> Loading... </span>
|
||||
}
|
||||
@if (!loaded) {
|
||||
<div>
|
||||
<h3>Loading submitted list</h3>
|
||||
</div>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell class="p-0 d-flex justify-content-center">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
aria-label="Download audit file for table record"
|
||||
(click)="download(sub.tableId); $event.stopPropagation()"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
|
||||
<clr-dg-footer>
|
||||
<clr-dg-pagination #pagination [clrDgPageSize]="10">
|
||||
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
|
||||
>Items per page</clr-dg-page-size
|
||||
>
|
||||
{{ pagination.firstItem + 1 }} -
|
||||
{{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} submissions
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer>
|
||||
</clr-datagrid>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (loaded) {
|
||||
<div class="clr-col-md-12">
|
||||
@if (submitterList && remained !== 0) {
|
||||
<div>
|
||||
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
|
||||
<clr-dg-column>BASE TABLE</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitted'">
|
||||
SUBMITTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submittedFilter"
|
||||
aria-label="Filter submitted date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitReason'">
|
||||
SUBMIT REASON
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitReasonFilter"
|
||||
aria-label="Filter submit reason"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column class="d-flex justify-content-center"
|
||||
>ACTION</clr-dg-column
|
||||
>
|
||||
<clr-dg-column class="d-flex justify-content-center"
|
||||
>DOWNLOAD</clr-dg-column
|
||||
>
|
||||
<clr-dg-row
|
||||
*clrDgItems="let sub of submitterList; let i = index"
|
||||
(click)="goToDetails(sub.tableId)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<clr-dg-cell>{{ sub.base }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ sub.submitted }}</clr-dg-cell>
|
||||
<!-- <clr-dg-cell>{{sub.approver}}</clr-dg-cell> -->
|
||||
<clr-dg-cell>{{ sub.submitReason }}</clr-dg-cell>
|
||||
<clr-dg-cell>
|
||||
<div
|
||||
class="row justify-content-around"
|
||||
role="tooltip"
|
||||
aria-label="Go to staged data screen"
|
||||
>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
|
||||
(click)="goToStage(sub.tableId)"
|
||||
>
|
||||
<clr-icon shape="code" size="28"></clr-icon>
|
||||
<span class="tooltip-content"
|
||||
>Go to staged data screen</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell class="p-0 d-flex justify-content-center">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
aria-label="Download audit file for table record"
|
||||
(click)="
|
||||
download(sub.tableId); $event.stopPropagation()
|
||||
"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
<clr-dg-footer>
|
||||
<clr-dg-pagination #pagination [clrDgPageSize]="10">
|
||||
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
|
||||
>Items per page</clr-dg-page-size
|
||||
>
|
||||
{{ pagination.firstItem + 1 }} -
|
||||
{{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} submissions
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer>
|
||||
</clr-datagrid>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div *ngIf="subReady">
|
||||
<app-approve-details></app-approve-details>
|
||||
</div>
|
||||
@if (subReady) {
|
||||
<div>
|
||||
<app-approve-details></app-approve-details>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,130 +1,146 @@
|
||||
<app-sidebar class="sidebar-height">
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngIf="roles" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="Filter by Roles"
|
||||
name="input"
|
||||
[(ngModel)]="roleSearch"
|
||||
(keyup)="roleListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length > 0"
|
||||
(click)="roleSearch = ''; roleListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
<ng-container *ngFor="let role of roles">
|
||||
<clr-tree-node
|
||||
(click)="roleOnClick(role)"
|
||||
*ngIf="!role['hidden']"
|
||||
[class.active]="role.ROLEURI === roleUri"
|
||||
>
|
||||
<p class="m-0 cursor-pointer list-padding">
|
||||
<clr-icon shape="blocks-group"></clr-icon>
|
||||
{{ role.ROLENAME }}
|
||||
</p>
|
||||
@if (roles) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="Filter by Roles"
|
||||
name="input"
|
||||
[(ngModel)]="roleSearch"
|
||||
(keyup)="roleListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchLibTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchLibTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
(click)="roleSearch = ''; roleListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
</ng-container>
|
||||
}
|
||||
@for (role of roles; track role) {
|
||||
@if (!role['hidden']) {
|
||||
<clr-tree-node
|
||||
(click)="roleOnClick(role)"
|
||||
[class.active]="role.ROLEURI === roleUri"
|
||||
>
|
||||
<p class="m-0 cursor-pointer list-padding">
|
||||
<clr-icon shape="blocks-group"></clr-icon>
|
||||
{{ role.ROLENAME }}
|
||||
</p>
|
||||
</clr-tree-node>
|
||||
}
|
||||
}
|
||||
</clr-tree>
|
||||
</app-sidebar>
|
||||
|
||||
<div class="content-area">
|
||||
<div *ngIf="loading" class="loadingSpinner">
|
||||
<span class="spinner"> Loading... </span>
|
||||
</div>
|
||||
<div *ngIf="roleMembers && !loading">
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<table class="table role-info">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<p class="role-info-text">
|
||||
<b>{{ roleName }}</b>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<i>{{ roleDesc }}</i>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (loading) {
|
||||
<div class="loadingSpinner">
|
||||
<span class="spinner"> Loading... </span>
|
||||
</div>
|
||||
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<div class="card role-data">
|
||||
<div>
|
||||
<h3>MEMBERS ({{ roleMembersCount }})</h3>
|
||||
<h5 *ngIf="roleMembersCount == 0">No Members Present</h5>
|
||||
<div class="table-container">
|
||||
<table *ngIf="roleMembersCount != 0" class="table member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="width-25"><b>NAME</b></td>
|
||||
<td class="width-25"><b>EMAIL</b></td>
|
||||
<td class="width-25"><b>CREATED</b></td>
|
||||
<td class=""><b>UPDATED</b></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
[routerLink]="'/view/usernav/users/' + member.URIMEM"
|
||||
*ngFor="let member of roleMembers"
|
||||
>
|
||||
<td class="">{{ member.MEMBERNAME }}</td>
|
||||
<td class="">{{ member.EMAIL }}</td>
|
||||
<td class="">{{ member.MEMBERCREATED }}</td>
|
||||
<td class="">{{ member.MEMBERUPDATED }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
@if (roleMembers && !loading) {
|
||||
<div>
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<table class="table role-info">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<p class="role-info-text">
|
||||
<b>{{ roleName }}</b>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<i>{{ roleDesc }}</i>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<div class="card role-data">
|
||||
<div>
|
||||
<h3>MEMBERS ({{ roleMembersCount }})</h3>
|
||||
@if (roleMembersCount == 0) {
|
||||
<h5>No Members Present</h5>
|
||||
}
|
||||
<div class="table-container">
|
||||
@if (roleMembersCount != 0) {
|
||||
<table class="table member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="width-25"><b>NAME</b></td>
|
||||
<td class="width-25"><b>EMAIL</b></td>
|
||||
<td class="width-25"><b>CREATED</b></td>
|
||||
<td class=""><b>UPDATED</b></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (member of roleMembers; track member) {
|
||||
<tr
|
||||
[routerLink]="'/view/usernav/users/' + member.URIMEM"
|
||||
>
|
||||
<td class="">{{ member.MEMBERNAME }}</td>
|
||||
<td class="">{{ member.EMAIL }}</td>
|
||||
<td class="">{{ member.MEMBERCREATED }}</td>
|
||||
<td class="">{{ member.MEMBERUPDATED }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<h3>Groups ({{ roleGroupsCount }})</h3>
|
||||
<h5 *ngIf="roleGroupsCount == 0">No Groups Present !</h5>
|
||||
<div class="table-container">
|
||||
<table *ngIf="roleGroupsCount != 0" class="table member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="width-25"><b>NAME</b></td>
|
||||
<td class="width-25"><b>EMAIL</b></td>
|
||||
<td class="width-25"><b>CREATED</b></td>
|
||||
<td class=""><b>UPDATED</b></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
[routerLink]="'/view/usernav/groups/' + group.URIMEM"
|
||||
*ngFor="let group of roleGroups"
|
||||
>
|
||||
<td class="">{{ group.MEMBERNAME }}</td>
|
||||
<td class="">{{ group.EMAIL }}</td>
|
||||
<td class="">{{ group.MEMBERCREATED }}</td>
|
||||
<td class="">{{ group.MEMBERUPDATED }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<div>
|
||||
<h3>Groups ({{ roleGroupsCount }})</h3>
|
||||
@if (roleGroupsCount == 0) {
|
||||
<h5>No Groups Present !</h5>
|
||||
}
|
||||
<div class="table-container">
|
||||
@if (roleGroupsCount != 0) {
|
||||
<table class="table member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="width-25"><b>NAME</b></td>
|
||||
<td class="width-25"><b>EMAIL</b></td>
|
||||
<td class="width-25"><b>CREATED</b></td>
|
||||
<td class=""><b>UPDATED</b></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (group of roleGroups; track group) {
|
||||
<tr
|
||||
[routerLink]="'/view/usernav/groups/' + group.URIMEM"
|
||||
>
|
||||
<td class="">{{ group.MEMBERNAME }}</td>
|
||||
<td class="">{{ group.EMAIL }}</td>
|
||||
<td class="">{{ group.MEMBERCREATED }}</td>
|
||||
<td class="">{{ group.MEMBERUPDATED }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -138,4 +138,36 @@ describe('AppService - startup retry', () => {
|
||||
expect(deps.sasService.request).toHaveBeenCalledTimes(1)
|
||||
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Reproduces a misconfigured Viya computeTasks deployment: the Compute
|
||||
// service returns a plain-text "Job error" body instead of JSON: the
|
||||
// adapter can't parse it and resolves with the raw text as adapterResponse
|
||||
// itself (see getMalformedAdapterResponseMessage's own doc comment).
|
||||
// "Globvars, Sasdatasets, Saslibs, XLMaps are not present" would also be
|
||||
// technically true here, but hides the real cause.
|
||||
it('shows the real response text (not the generic missing-props message) when adapterResponse is a raw string, not an object', async () => {
|
||||
const deps = buildDeps()
|
||||
const rawJobError = [
|
||||
'Job error',
|
||||
'The Compute service could not execute the task c74a707a-8b88-46a5-8f28-9a7694ad5e13 because the context 340cd3eb-72ae is not reusable. Please provide a reusable context or supply the ID of an existing session in the task request.',
|
||||
'path: /compute/tasks',
|
||||
'correlator: dc1a9fda-8f65-4f32-b4d4-2a70e02179b1;1bcb6a4c-8a16-4053-a790-292544c25a03'
|
||||
].join('\n')
|
||||
deps.sasService.request.and.resolveTo({ adapterResponse: rawJobError })
|
||||
|
||||
const appService = buildAppService(deps)
|
||||
;(appService as any).retryOptions = {
|
||||
wait: () => Promise.resolve(),
|
||||
random: () => 0
|
||||
}
|
||||
|
||||
await appService.startUpData()
|
||||
|
||||
expect(deps.sasService.request).toHaveBeenCalledTimes(1)
|
||||
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
|
||||
const [, message] = deps.eventService.showInfoModal.calls.mostRecent().args
|
||||
expect(message).toContain(rawJobError)
|
||||
expect(message).not.toContain('Globvars, Sasdatasets, Saslibs, XLMaps')
|
||||
expect(deps.licenceService.isAppActivated.value).toBeFalse()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AppThemes } from '../models/AppSettings'
|
||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||
import { AppStoreService } from './app-store.service'
|
||||
import { retryOnce, RetryOnceOptions } from '../shared/utils/retry-once'
|
||||
import { getMalformedAdapterResponseMessage } from '../shared/utils/get-malformed-adapter-response-message'
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
@@ -94,6 +95,17 @@ export class AppService {
|
||||
this.retryOptions
|
||||
)
|
||||
.then(async (res: RequestWrapperResponse) => {
|
||||
const malformedMessage = getMalformedAdapterResponseMessage(
|
||||
res.adapterResponse
|
||||
)
|
||||
if (malformedMessage) {
|
||||
startupServiceError = true
|
||||
this.eventService.showInfoModal('Error', malformedMessage)
|
||||
this.licenceService.isAppActivated.next(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.syssite.next([res.adapterResponse.SYSSITE])
|
||||
|
||||
let missingProps: string[] = []
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Globvar } from '../models/sas/public-startupservice.model'
|
||||
import { freeTierConfig } from '../free-tier.config'
|
||||
import { AppStoreService } from './app-store.service'
|
||||
import { HelperService } from './helper.service'
|
||||
import { LicenceFeaturesMap, LicenceState } from '../models/LicenceState'
|
||||
import { LicenceState } from '../models/LicenceState'
|
||||
import { decodeLicenceFeatures as decodeFeatures } from './utils/decodeLicenceFeatures'
|
||||
import { LoggerService } from './logger.service'
|
||||
import { EventService } from './event.service'
|
||||
|
||||
@@ -255,9 +256,9 @@ export class LicenceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode and set features that are encoded in the key
|
||||
* featureValue - used as number/Infinity for limit set
|
||||
* featureToggle - used as boolean, based on 1 or 0 encoded in the key
|
||||
* Decode and set features that are encoded in the key - accepts either
|
||||
* an already-issued key's legacy positional string or a newly-issued
|
||||
* key's named object, see decodeLicenceFeatures for the format contract.
|
||||
* @param licenseData from the licence key
|
||||
*/
|
||||
private decodeLicenceFeatures(licenseData: LicenseKeyData) {
|
||||
@@ -270,72 +271,21 @@ export class LicenceService {
|
||||
}
|
||||
}
|
||||
|
||||
const featuresMap = licenseData.features.split(',')
|
||||
this._licenceState = {
|
||||
...this._licenceState,
|
||||
viewer_rows_allowed: this.parseFeatureValue(
|
||||
featuresMap[LicenceFeaturesMap.viewer_rows_allowed]
|
||||
),
|
||||
editor_rows_allowed: this.parseFeatureValue(
|
||||
featuresMap[LicenceFeaturesMap.editor_rows_allowed]
|
||||
),
|
||||
stage_rows_allowed: this.parseFeatureValue(
|
||||
featuresMap[LicenceFeaturesMap.stage_rows_allowed]
|
||||
),
|
||||
history_rows_allowed: this.parseFeatureValue(
|
||||
featuresMap[LicenceFeaturesMap.history_rows_allowed]
|
||||
),
|
||||
submit_rows_limit: this.parseFeatureValue(
|
||||
featuresMap[LicenceFeaturesMap.submit_rows_limit]
|
||||
),
|
||||
tables_in_library_limit: this.parseFeatureValue(
|
||||
featuresMap[LicenceFeaturesMap.tables_in_library_limit]
|
||||
),
|
||||
viewbox_limit: this.parseFeatureValue(
|
||||
featuresMap[LicenceFeaturesMap.viewbox_limit]
|
||||
),
|
||||
lineage_daily_limit: this.parseFeatureValue(
|
||||
featuresMap[LicenceFeaturesMap.lineage_daily_limit]
|
||||
),
|
||||
viewbox: this.parseFeatureToggle(featuresMap[LicenceFeaturesMap.viewbox]),
|
||||
fileUpload: this.parseFeatureToggle(
|
||||
featuresMap[LicenceFeaturesMap.fileUpload]
|
||||
),
|
||||
editRecord: this.parseFeatureToggle(
|
||||
featuresMap[LicenceFeaturesMap.editRecord]
|
||||
),
|
||||
addRecord: this.parseFeatureToggle(
|
||||
featuresMap[LicenceFeaturesMap.addRecord]
|
||||
)
|
||||
...decodeFeatures(licenseData.features)
|
||||
}
|
||||
|
||||
this.loggerService.log('Licence state:', this._licenceState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts licence key feature code value to the number or Infinity
|
||||
* Used for limiting rows, submits etc.
|
||||
* @param codeBit from licence key encoded features. Every bit is separated by comma(,) Eg. 5,10,15
|
||||
* @returns number
|
||||
*/
|
||||
private parseFeatureValue(codeBit: string): number {
|
||||
if (codeBit === '-') return Infinity
|
||||
return parseInt(codeBit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts licence key feature code value to the boolean. Depending on if it's 0 or 1
|
||||
* @param codeBit from licence key encoded features. Every bit is separated by comma(,) Eg. 1,1,0
|
||||
* @returns boolean to turn on or off the value
|
||||
*/
|
||||
private parseFeatureToggle(codeBit: string): boolean {
|
||||
return !!parseInt(codeBit)
|
||||
}
|
||||
|
||||
/**
|
||||
* If decryption fails, key will be marked as invalid and app not activated.
|
||||
* Public (not just used internally by licensing()) since it's a pure
|
||||
* decrypt with no side effects - also used to preview a pasted key's
|
||||
* details before it's actually applied.
|
||||
*/
|
||||
private decryptLicenseKey(
|
||||
public decryptLicenseKey(
|
||||
licenseKey: string,
|
||||
activationKey: string
|
||||
): Promise<LicenseKeyData> {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { decodeLicenceFeatures } from './decodeLicenceFeatures'
|
||||
|
||||
describe('decodeLicenceFeatures', () => {
|
||||
// Positional order per LicenceFeaturesMap: viewer_rows_allowed,
|
||||
// editor_rows_allowed, stage_rows_allowed, history_rows_allowed,
|
||||
// submit_rows_limit, tables_in_library_limit, viewbox, viewbox_limit,
|
||||
// lineage_daily_limit, fileUpload, editRecord, addRecord.
|
||||
const legacyFeaturesString = '10,15,20,-,5,35,1,3,7,1,0,1'
|
||||
|
||||
const newFeaturesObject = {
|
||||
vra: 10,
|
||||
era: 15,
|
||||
sra: 20,
|
||||
hra: null,
|
||||
srl: 5,
|
||||
till: 35,
|
||||
vb: true,
|
||||
vbl: 3,
|
||||
ldl: 7,
|
||||
fu: true,
|
||||
er: false,
|
||||
ar: true
|
||||
}
|
||||
|
||||
const expectedDecodedState = {
|
||||
viewer_rows_allowed: 10,
|
||||
editor_rows_allowed: 15,
|
||||
stage_rows_allowed: 20,
|
||||
history_rows_allowed: Infinity,
|
||||
submit_rows_limit: 5,
|
||||
tables_in_library_limit: 35,
|
||||
viewbox: true,
|
||||
viewbox_limit: 3,
|
||||
lineage_daily_limit: 7,
|
||||
fileUpload: true,
|
||||
editRecord: false,
|
||||
addRecord: true
|
||||
}
|
||||
|
||||
it('decodes a legacy positional string exactly as before', () => {
|
||||
expect(decodeLicenceFeatures(legacyFeaturesString)).toEqual(
|
||||
expectedDecodedState
|
||||
)
|
||||
})
|
||||
|
||||
it('decodes a new named object', () => {
|
||||
expect(decodeLicenceFeatures(newFeaturesObject)).toEqual(
|
||||
expectedDecodedState
|
||||
)
|
||||
})
|
||||
|
||||
it('produces an identical result for the same licence expressed in either format', () => {
|
||||
expect(decodeLicenceFeatures(legacyFeaturesString)).toEqual(
|
||||
decodeLicenceFeatures(newFeaturesObject)
|
||||
)
|
||||
})
|
||||
|
||||
it('maps "-" in a legacy limit position to Infinity', () => {
|
||||
const result = decodeLicenceFeatures(legacyFeaturesString)
|
||||
expect(result.history_rows_allowed).toBe(Infinity)
|
||||
})
|
||||
|
||||
it('maps null in a new-format limit field to Infinity', () => {
|
||||
const result = decodeLicenceFeatures(newFeaturesObject)
|
||||
expect(result.history_rows_allowed).toBe(Infinity)
|
||||
})
|
||||
|
||||
it('maps "1"/"0" in legacy toggle positions to true/false', () => {
|
||||
const result = decodeLicenceFeatures(legacyFeaturesString)
|
||||
expect(result.viewbox).toBe(true)
|
||||
expect(result.editRecord).toBe(false)
|
||||
})
|
||||
|
||||
it('passes new-format boolean toggle fields straight through', () => {
|
||||
const result = decodeLicenceFeatures(newFeaturesObject)
|
||||
expect(result.viewbox).toBe(true)
|
||||
expect(result.editRecord).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { LicenceFeaturesMap, LicenceState } from '../../models/LicenceState'
|
||||
|
||||
// Field names are abbreviated on the wire to keep the RSA-OAEP-encrypted
|
||||
// payload small - see "Why abbreviated field names" in
|
||||
// licence-features-object-plan.md for the full name each one stands for
|
||||
// and why this is a fixed, append-only contract (same invariant as
|
||||
// LicenceFeaturesMap's ordinals).
|
||||
export type LicenceFeaturesObject = {
|
||||
vra: number | null // viewer_rows_allowed
|
||||
era: number | null // editor_rows_allowed
|
||||
sra: number | null // stage_rows_allowed
|
||||
hra: number | null // history_rows_allowed
|
||||
srl: number | null // submit_rows_limit
|
||||
till: number | null // tables_in_library_limit
|
||||
vb: boolean // viewbox
|
||||
vbl: number | null // viewbox_limit
|
||||
ldl: number | null // lineage_daily_limit
|
||||
fu: boolean // fileUpload
|
||||
er: boolean // editRecord
|
||||
ar: boolean // addRecord
|
||||
}
|
||||
|
||||
// Accepts either an already-issued key's positional string
|
||||
// ("-,-,-,-,-,-,1,-,-,1,1,1", decoded via LicenceFeaturesMap) or a
|
||||
// newly-issued key's named object - see licence-features-object-plan.md
|
||||
// for why these two shapes are how format versioning is done here (no
|
||||
// separate version field: the shapes are already distinguishable).
|
||||
export const decodeLicenceFeatures = (
|
||||
features: string | LicenceFeaturesObject
|
||||
): Partial<LicenceState> => {
|
||||
if (typeof features === 'object') {
|
||||
return decodeFeaturesObject(features)
|
||||
}
|
||||
|
||||
return decodeFeaturesString(features)
|
||||
}
|
||||
|
||||
const decodeFeaturesObject = (
|
||||
features: LicenceFeaturesObject
|
||||
): Partial<LicenceState> => ({
|
||||
viewer_rows_allowed: features.vra ?? Infinity,
|
||||
editor_rows_allowed: features.era ?? Infinity,
|
||||
stage_rows_allowed: features.sra ?? Infinity,
|
||||
history_rows_allowed: features.hra ?? Infinity,
|
||||
submit_rows_limit: features.srl ?? Infinity,
|
||||
tables_in_library_limit: features.till ?? Infinity,
|
||||
viewbox: features.vb,
|
||||
viewbox_limit: features.vbl ?? Infinity,
|
||||
lineage_daily_limit: features.ldl ?? Infinity,
|
||||
fileUpload: features.fu,
|
||||
editRecord: features.er,
|
||||
addRecord: features.ar
|
||||
})
|
||||
|
||||
// Existing positional-string decode, moved here unchanged from
|
||||
// LicenceService.decodeLicenceFeatures/parseFeatureValue/parseFeatureToggle.
|
||||
const decodeFeaturesString = (features: string): Partial<LicenceState> => {
|
||||
const featuresMap = features.split(',')
|
||||
const parseValue = (codeBit: string) =>
|
||||
codeBit === '-' ? Infinity : parseInt(codeBit)
|
||||
const parseToggle = (codeBit: string) => !!parseInt(codeBit)
|
||||
|
||||
return {
|
||||
viewer_rows_allowed: parseValue(
|
||||
featuresMap[LicenceFeaturesMap.viewer_rows_allowed]
|
||||
),
|
||||
editor_rows_allowed: parseValue(
|
||||
featuresMap[LicenceFeaturesMap.editor_rows_allowed]
|
||||
),
|
||||
stage_rows_allowed: parseValue(
|
||||
featuresMap[LicenceFeaturesMap.stage_rows_allowed]
|
||||
),
|
||||
history_rows_allowed: parseValue(
|
||||
featuresMap[LicenceFeaturesMap.history_rows_allowed]
|
||||
),
|
||||
submit_rows_limit: parseValue(
|
||||
featuresMap[LicenceFeaturesMap.submit_rows_limit]
|
||||
),
|
||||
tables_in_library_limit: parseValue(
|
||||
featuresMap[LicenceFeaturesMap.tables_in_library_limit]
|
||||
),
|
||||
viewbox_limit: parseValue(featuresMap[LicenceFeaturesMap.viewbox_limit]),
|
||||
lineage_daily_limit: parseValue(
|
||||
featuresMap[LicenceFeaturesMap.lineage_daily_limit]
|
||||
),
|
||||
viewbox: parseToggle(featuresMap[LicenceFeaturesMap.viewbox]),
|
||||
fileUpload: parseToggle(featuresMap[LicenceFeaturesMap.fileUpload]),
|
||||
editRecord: parseToggle(featuresMap[LicenceFeaturesMap.editRecord]),
|
||||
addRecord: parseToggle(featuresMap[LicenceFeaturesMap.addRecord])
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,8 @@ describe('VaFilterService', () => {
|
||||
'SOME_DATETIME'
|
||||
]
|
||||
const cols = [
|
||||
{ NAME: 'SOME_CHAR', DDTYPE: 'CHARACTER' },
|
||||
{ NAME: 'SOME_NUM', DDTYPE: 'NUMERIC' },
|
||||
{ NAME: 'SOME_CHAR', DDTYPE: 'C' },
|
||||
{ NAME: 'SOME_NUM', DDTYPE: 'N' },
|
||||
{ NAME: 'SOME_TIME', DDTYPE: 'TIME' },
|
||||
{ NAME: 'SOME_DATE', DDTYPE: 'DATE' },
|
||||
{ NAME: 'SOME_DATETIME', DDTYPE: 'DATETIME' }
|
||||
|
||||
@@ -163,14 +163,20 @@ export class VaFilterService {
|
||||
|
||||
/**
|
||||
* SAS data-type kind of a column spec. DDTYPE carries
|
||||
* TIME/DATE/DATETIME/NUMERIC/CHARACTER; checked DATETIME-before-DATE (substring).
|
||||
* TIME/DATE/DATETIME in full, with C/N for CHARACTER/NUMERIC (legacy
|
||||
* responses may still carry the full strings); checked DATETIME-before-DATE
|
||||
* (substring).
|
||||
*/
|
||||
private columnKind(col: any): VaColumnKind {
|
||||
const ddtype = (col?.DDTYPE ?? '').toString().toUpperCase()
|
||||
if (ddtype.includes('DATETIME')) return 'datetime'
|
||||
if (ddtype.includes('DATE')) return 'date'
|
||||
if (ddtype.includes('TIME')) return 'time'
|
||||
if (ddtype.includes('NUMERIC') || (col?.TYPE ?? '') === 'num') {
|
||||
if (
|
||||
ddtype === 'N' ||
|
||||
ddtype.includes('NUMERIC') ||
|
||||
(col?.TYPE ?? '') === 'num'
|
||||
) {
|
||||
return 'numeric'
|
||||
}
|
||||
return 'char'
|
||||
|
||||
@@ -7,66 +7,62 @@
|
||||
>
|
||||
<h3 class="modal-title">
|
||||
{{ data.modalTitle }}
|
||||
<p
|
||||
*ngIf="data.sasService && data.sasService.length > 0"
|
||||
class="sasService mt-0"
|
||||
>
|
||||
SAS Service: <strong>{{ data.sasService }}</strong>
|
||||
</p>
|
||||
@if (data.sasService && data.sasService.length > 0) {
|
||||
<p class="sasService mt-0">
|
||||
SAS Service: <strong>{{ data.sasService }}</strong>
|
||||
</p>
|
||||
}
|
||||
</h3>
|
||||
<div class="modal-body">
|
||||
<div [innerHTML]="data.message" class="abortMsg"></div>
|
||||
|
||||
<div *ngIf="data.details !== null" class="systext">
|
||||
<p><strong>SYSWARNINGTEXT:</strong> {{ data.details.SYSWARNINGTEXT }}</p>
|
||||
<p><strong>SYSERRORTEXT:</strong> {{ data.details.SYSERRORTEXT }}</p>
|
||||
<p><strong>MAC:</strong> {{ data.details.MAC }}</p>
|
||||
</div>
|
||||
@if (data.details !== null) {
|
||||
<div class="systext">
|
||||
<p>
|
||||
<strong>SYSWARNINGTEXT:</strong> {{ data.details.SYSWARNINGTEXT }}
|
||||
</p>
|
||||
<p><strong>SYSERRORTEXT:</strong> {{ data.details.SYSERRORTEXT }}</p>
|
||||
<p><strong>MAC:</strong> {{ data.details.MAC }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
*ngIf="showConfiguratorButton(data.sasService)"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="openConfigurator()"
|
||||
>
|
||||
Open configurator
|
||||
</button>
|
||||
@if (showConfiguratorButton(data.sasService)) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="openConfigurator()"
|
||||
>
|
||||
Open configurator
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
*ngIf="data.details !== null"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="openRequestsModal()"
|
||||
>
|
||||
Open requests modal
|
||||
</button>
|
||||
@if (data.details !== null) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="openRequestsModal()"
|
||||
>
|
||||
Open requests modal
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
*ngIf="data.details?.LOG && (data.details?.LOG?.trim())!.length > 0"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="downloadLog()"
|
||||
>
|
||||
Download log
|
||||
</button>
|
||||
@if (data.details?.LOG && (data.details?.LOG?.trim())!.length > 0) {
|
||||
<button type="button" class="btn btn-primary" (click)="downloadLog()">
|
||||
Download log
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
*ngIf="!forceReload"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="closeAbortModal()"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@if (!forceReload) {
|
||||
<button type="button" class="btn btn-primary" (click)="closeAbortModal()">
|
||||
Close
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
*ngIf="forceReload"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="reload()"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
@if (forceReload) {
|
||||
<button type="button" class="btn btn-primary" (click)="reload()">
|
||||
Reload
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</clr-modal>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<clr-alerts *ngIf="hasOpenAlert">
|
||||
<clr-alert
|
||||
*ngFor="let alert of alerts"
|
||||
[clrAlertType]="alert.type"
|
||||
[clrAlertAppLevel]="true"
|
||||
[(clrAlertClosed)]="alert.closed"
|
||||
(clrAlertClosedChange)="onAlertClose()"
|
||||
>
|
||||
<div class="alert-item">
|
||||
<span class="alert-text">
|
||||
{{ alert.message }}
|
||||
</span>
|
||||
</div>
|
||||
</clr-alert>
|
||||
</clr-alerts>
|
||||
@if (hasOpenAlert) {
|
||||
<clr-alerts>
|
||||
@for (alert of alerts; track alert) {
|
||||
<clr-alert
|
||||
[clrAlertType]="alert.type"
|
||||
[clrAlertAppLevel]="true"
|
||||
[(clrAlertClosed)]="alert.closed"
|
||||
(clrAlertClosedChange)="onAlertClose()"
|
||||
>
|
||||
<div class="alert-item">
|
||||
<span class="alert-text">
|
||||
{{ alert.message }}
|
||||
</span>
|
||||
</div>
|
||||
</clr-alert>
|
||||
}
|
||||
</clr-alerts>
|
||||
}
|
||||
|
||||
@@ -41,12 +41,10 @@
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
|
||||
<option
|
||||
*ngIf="options.children.length > 0 && enableLoadMore"
|
||||
data-type="load-more"
|
||||
class="load-more"
|
||||
>
|
||||
{{ loadingMore ? 'Loading...' : 'LOAD MORE' }}
|
||||
</option>
|
||||
@if (options.children.length > 0 && enableLoadMore) {
|
||||
<option data-type="load-more" class="load-more">
|
||||
{{ loadingMore ? 'Loading...' : 'LOAD MORE' }}
|
||||
</option>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,35 +6,37 @@
|
||||
>
|
||||
<h3 class="modal-title center text-center color-darker-gray">Dataset Meta</h3>
|
||||
<div class="modal-body">
|
||||
<p *ngIf="dsmetaTabs.length < 1" class="text-center">
|
||||
No dataset meta to show.
|
||||
</p>
|
||||
@if (dsmetaTabs.length < 1) {
|
||||
<p class="text-center">No dataset meta to show.</p>
|
||||
}
|
||||
|
||||
<clr-tabs clrLayout="vertical">
|
||||
<clr-tab *ngFor="let tab of tabs; let index = index">
|
||||
<button clrTabLink id="link1">{{ tab.name }}</button>
|
||||
<clr-tab-content
|
||||
id="content1"
|
||||
*clrIfActive="index === 0"
|
||||
class="d-flex clr-justify-content-center w-100"
|
||||
>
|
||||
<clr-datagrid>
|
||||
<ng-container *ngFor="let col of tab.colsToDisplay">
|
||||
<clr-dg-column>{{ col.colName || col.colKey }}</clr-dg-column>
|
||||
</ng-container>
|
||||
|
||||
<clr-dg-row
|
||||
(click)="tab.onRowClick ? tab.onRowClick(info) : ''"
|
||||
class="clickable-row"
|
||||
*ngFor="let info of tab.meta"
|
||||
>
|
||||
<ng-container *ngFor="let col of tab.colsToDisplay">
|
||||
<clr-dg-cell>{{ info[col.colKey] }}</clr-dg-cell>
|
||||
</ng-container>
|
||||
</clr-dg-row>
|
||||
</clr-datagrid>
|
||||
</clr-tab-content>
|
||||
</clr-tab>
|
||||
@for (tab of tabs; track tab; let index = $index) {
|
||||
<clr-tab>
|
||||
<button clrTabLink id="link1">{{ tab.name }}</button>
|
||||
<clr-tab-content
|
||||
id="content1"
|
||||
*clrIfActive="index === 0"
|
||||
class="d-flex clr-justify-content-center w-100"
|
||||
>
|
||||
<clr-datagrid>
|
||||
@for (col of tab.colsToDisplay; track col) {
|
||||
<clr-dg-column>{{ col.colName || col.colKey }}</clr-dg-column>
|
||||
}
|
||||
@for (info of tab.meta; track info) {
|
||||
<clr-dg-row
|
||||
(click)="tab.onRowClick ? tab.onRowClick(info) : ''"
|
||||
class="clickable-row"
|
||||
>
|
||||
@for (col of tab.colsToDisplay; track col) {
|
||||
<clr-dg-cell>{{ info[col.colKey] }}</clr-dg-cell>
|
||||
}
|
||||
</clr-dg-row>
|
||||
}
|
||||
</clr-datagrid>
|
||||
</clr-tab-content>
|
||||
</clr-tab>
|
||||
}
|
||||
</clr-tabs>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -1,164 +1,180 @@
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngIf="libraryList" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="Libraries"
|
||||
name="input"
|
||||
[(ngModel)]="librariesSearch"
|
||||
(keyup)="libraryOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length > 0"
|
||||
(click)="librariesSearch = ''; libraryOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
|
||||
<ng-container *ngFor="let library of libraryList">
|
||||
<clr-tree-node
|
||||
#libTreeNode
|
||||
(click)="treeNodeClicked($event, library, libraryList)"
|
||||
*ngIf="!library['hidden'] && library['inForeground']"
|
||||
[(clrExpanded)]="library['expanded']"
|
||||
[clrLoading]="library['loadingTables'] && !library.tables"
|
||||
[class.clr-expanded]="library['expanded']"
|
||||
>
|
||||
<p
|
||||
(click)="
|
||||
lib = library.LIBRARYID;
|
||||
libraryOnClick(lib || '', library, libTreeNode)
|
||||
"
|
||||
class="m-0 cursor-pointer"
|
||||
>
|
||||
<clr-icon shape="rack-server"></clr-icon>
|
||||
{{ library.LIBRARYNAME }}
|
||||
</p>
|
||||
|
||||
<clr-tree-node *ngIf="library['tables']" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchTreeInput
|
||||
placeholder="Tables"
|
||||
name="input"
|
||||
[(ngModel)]="library['searchString']"
|
||||
(keyup)="treeOnFilter(library, 'tables')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (libraryList) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="Libraries"
|
||||
name="input"
|
||||
[(ngModel)]="librariesSearch"
|
||||
(keyup)="libraryOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchLibTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchLibTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length > 0"
|
||||
(click)="
|
||||
searchTreeInput.value = '';
|
||||
library['searchString'] = '';
|
||||
treeOnFilter(library, 'tables.TABLENAME')
|
||||
"
|
||||
(click)="librariesSearch = ''; libraryOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
}
|
||||
|
||||
@for (library of libraryList; track library) {
|
||||
@if (!library['hidden'] && library['inForeground']) {
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, libTable, library['tables'])"
|
||||
*ngFor="let libTable of library['tables']; let index = index"
|
||||
[(clrExpanded)]="libTable['expanded']"
|
||||
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
|
||||
[class.clr-expanded]="libTable['expanded']"
|
||||
#libTreeNode
|
||||
(click)="treeNodeClicked($event, library, libraryList)"
|
||||
[(clrExpanded)]="library['expanded']"
|
||||
[clrLoading]="library['loadingTables'] && !library.tables"
|
||||
[class.clr-expanded]="library['expanded']"
|
||||
>
|
||||
<clr-tooltip
|
||||
*ngVar="
|
||||
index + 1 >
|
||||
licenceState.value.tables_in_library_limit as tableLocked
|
||||
<p
|
||||
(click)="
|
||||
lib = library.LIBRARYID;
|
||||
libraryOnClick(lib || '', library, libTreeNode)
|
||||
"
|
||||
class="m-0 cursor-pointer"
|
||||
>
|
||||
<button
|
||||
clrTooltipTrigger
|
||||
(click)="
|
||||
!tableLocked
|
||||
? tableOnClick(libTable.TABLEURI, libTable, library)
|
||||
: ''
|
||||
"
|
||||
class="clr-treenode-link"
|
||||
[class.dc-locked-control]="tableLocked"
|
||||
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
|
||||
>
|
||||
<ng-container [ngSwitch]="libTable.includes('-FC')">
|
||||
<clr-icon *ngSwitchCase="true" shape="bolt"></clr-icon>
|
||||
<clr-icon *ngSwitchCase="false" shape="table"></clr-icon>
|
||||
</ng-container>
|
||||
{{ libTable.replace('-FC', '') }}
|
||||
</button>
|
||||
|
||||
<clr-tooltip-content
|
||||
clrPosition="bottom-right"
|
||||
clrSize="lg"
|
||||
*clrIfOpen
|
||||
>
|
||||
<span *ngIf="tableLocked">
|
||||
To unlock all tables, contact support@datacontroller.io
|
||||
</span>
|
||||
</clr-tooltip-content>
|
||||
|
||||
<ng-container *ngIf="hasColumns">
|
||||
<clr-tree-node *ngIf="libTable['columns']" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchTreeInput
|
||||
placeholder="Columns"
|
||||
name="input"
|
||||
[(ngModel)]="libTable['searchString']"
|
||||
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<clr-icon shape="rack-server"></clr-icon>
|
||||
{{ library.LIBRARYNAME }}
|
||||
</p>
|
||||
@if (library['tables']) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchTreeInput
|
||||
placeholder="Tables"
|
||||
name="input"
|
||||
[(ngModel)]="library['searchString']"
|
||||
(keyup)="treeOnFilter(library, 'tables')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchTreeInput.value.length > 0"
|
||||
(click)="
|
||||
searchTreeInput.value = '';
|
||||
libTable['searchString'] = '';
|
||||
treeOnFilter(libTable, 'columns.COLNAME')
|
||||
library['searchString'] = '';
|
||||
treeOnFilter(library, 'tables.TABLENAME')
|
||||
"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
|
||||
<clr-tree-node *ngFor="let libColumn of libTable['columns']">
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
}
|
||||
@for (
|
||||
libTable of library['tables'];
|
||||
track libTable;
|
||||
let index = $index
|
||||
) {
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, libTable, library['tables'])"
|
||||
[(clrExpanded)]="libTable['expanded']"
|
||||
[clrLoading]="libTable['loadingColumns'] && !libTable.columns"
|
||||
[class.clr-expanded]="libTable['expanded']"
|
||||
>
|
||||
<clr-tooltip
|
||||
*ngVar="
|
||||
index + 1 >
|
||||
licenceState.value.tables_in_library_limit as tableLocked
|
||||
"
|
||||
>
|
||||
<button
|
||||
(click)="columnOnClick(libColumn, library, libTable)"
|
||||
clrTooltipTrigger
|
||||
(click)="
|
||||
!tableLocked
|
||||
? tableOnClick(libTable.TABLEURI, libTable, library)
|
||||
: ''
|
||||
"
|
||||
class="clr-treenode-link"
|
||||
[class.column-active]="libColumnActive(libColumn.COLURI)"
|
||||
[class.dc-locked-control]="tableLocked"
|
||||
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
|
||||
>
|
||||
<clr-icon shape="objects"></clr-icon>
|
||||
|
||||
{{ libColumn.COLNAME }}
|
||||
@switch (libTable.includes('-FC')) {
|
||||
@case (true) {
|
||||
<clr-icon shape="bolt"></clr-icon>
|
||||
}
|
||||
@case (false) {
|
||||
<clr-icon shape="table"></clr-icon>
|
||||
}
|
||||
}
|
||||
{{ libTable.replace('-FC', '') }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
</ng-container>
|
||||
</clr-tooltip>
|
||||
<clr-tooltip-content
|
||||
clrPosition="bottom-right"
|
||||
clrSize="lg"
|
||||
*clrIfOpen
|
||||
>
|
||||
@if (tableLocked) {
|
||||
<span>
|
||||
To unlock all tables, contact support@datacontroller.io
|
||||
</span>
|
||||
}
|
||||
</clr-tooltip-content>
|
||||
@if (hasColumns) {
|
||||
@if (libTable['columns']) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchTreeInput
|
||||
placeholder="Columns"
|
||||
name="input"
|
||||
[(ngModel)]="libTable['searchString']"
|
||||
(keyup)="treeOnFilter(libTable, 'columns.COLNAME')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
(click)="
|
||||
searchTreeInput.value = '';
|
||||
libTable['searchString'] = '';
|
||||
treeOnFilter(libTable, 'columns.COLNAME')
|
||||
"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
}
|
||||
@for (libColumn of libTable['columns']; track libColumn) {
|
||||
<clr-tree-node>
|
||||
<button
|
||||
(click)="columnOnClick(libColumn, library, libTable)"
|
||||
class="clr-treenode-link"
|
||||
[class.column-active]="libColumnActive(libColumn.COLURI)"
|
||||
>
|
||||
<clr-icon shape="objects"></clr-icon>
|
||||
{{ libColumn.COLNAME }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
}
|
||||
}
|
||||
</clr-tooltip>
|
||||
</clr-tree-node>
|
||||
}
|
||||
</clr-tree-node>
|
||||
</clr-tree-node>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
</clr-tree>
|
||||
|
||||
<div *ngIf="librariesPaging" class="w-100 text-center">
|
||||
<span class="spinner spinner-sm"> Loading... </span>
|
||||
</div>
|
||||
@if (librariesPaging) {
|
||||
<div class="w-100 text-center">
|
||||
<span class="spinner spinner-sm"> Loading... </span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getNotNullDefault } from './utils/getNotNullDefault'
|
||||
import { mergeColsRules } from './utils/mergeColsRules'
|
||||
import { parseColTypeRow } from './utils/parseColTypeRow'
|
||||
import { DELETE_RECORD_COLUMN_RULE } from './utils/deleteRecordColumnRule'
|
||||
import { EDIT_STATUS_COLUMN_RULE } from './utils/editStatusColumnRule'
|
||||
import { dqValidate } from './validations/dq-validation'
|
||||
import {
|
||||
datetimeValidator,
|
||||
@@ -29,6 +30,9 @@ import { mapIntlCellTypes } from './utils/mapIntlCellTypes'
|
||||
import { CustomAutocompleteEditor } from './editors/numericAutocomplete'
|
||||
import { registerIntlCellTypes } from './cellTypes/intlCellTypes'
|
||||
import { makeNumberFormatRenderer } from '../../editor/utils/renderers.utils'
|
||||
import { makeRegexWarningRenderer } from '../../editor/utils/regex-warning-renderer'
|
||||
import { isRegexRuleExempt } from './utils/isRegexRuleExempt'
|
||||
import { parseRegexRule } from './utils/parseRegexRule'
|
||||
|
||||
export class DcValidator {
|
||||
private rules: DcValidation[] = []
|
||||
@@ -81,6 +85,13 @@ export class DcValidator {
|
||||
this.rules = mergeColsRules(cols, this.rules, $dataFormats)
|
||||
this.rules = applyNumericFormats(this.rules)
|
||||
this.rules = mapIntlCellTypes(this.rules)
|
||||
|
||||
// EDIT_STATUS is appended last, always hidden - it's a purely
|
||||
// client-synthesized column (never in COLHEADERS) that gives
|
||||
// DC.ROW_STATUS a real cell to reference. See its own doc comment.
|
||||
this.rules.push({ ...EDIT_STATUS_COLUMN_RULE })
|
||||
this.hiddenColumns.push(this.rules.length - 1)
|
||||
|
||||
this.dqrules = dqRules
|
||||
this.dqdata = dqData
|
||||
this.primaryKeys = sasparams.PK.split(' ')
|
||||
@@ -201,6 +212,50 @@ export class DcValidator {
|
||||
return isNaN(digits) ? undefined : digits
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the RULE_VALUEs of a HARDREGEX/SOFTREGEX rule on the given
|
||||
* column, for display in the column-header info dropdown. Both are
|
||||
* returned raw - the caller (buildColInfoHtml) decides which one is
|
||||
* actually applied (HARDREGEX wins when both exist).
|
||||
*
|
||||
* @param col column name
|
||||
*/
|
||||
getRegexRuleValues(col: string): {
|
||||
hardRegexValue: string | undefined
|
||||
softRegexValue: string | undefined
|
||||
} {
|
||||
const hardRegexRule = this.dqrules.find(
|
||||
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'HARDREGEX'
|
||||
)
|
||||
const softRegexRule = this.dqrules.find(
|
||||
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'SOFTREGEX'
|
||||
)
|
||||
|
||||
return {
|
||||
hardRegexValue: hardRegexRule?.RULE_VALUE,
|
||||
softRegexValue: softRegexRule?.RULE_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the RULE_VALUE of a HARDFORMULA/SOFTFORMULA rule on the given
|
||||
* column, for display in the column-header info dropdown. A column only
|
||||
* ever carries one of the two in practice (readonly-computed vs.
|
||||
* overridable-default aren't a meaningful combination), so unlike
|
||||
* getRegexRuleValues there's no need to distinguish which one this is.
|
||||
*
|
||||
* @param col column name
|
||||
*/
|
||||
getFormulaRuleValue(col: string): string | undefined {
|
||||
const formulaRule = this.dqrules.find(
|
||||
(rule: DQRule) =>
|
||||
rule.BASE_COL === col &&
|
||||
(rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA')
|
||||
)
|
||||
|
||||
return formulaRule?.RULE_VALUE
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves dropdown source for given dc validation rule
|
||||
* The values comes from MPE_SELECTBOX table
|
||||
@@ -239,6 +294,41 @@ export class DcValidator {
|
||||
return details
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value fails a SOFTREGEX rule on the given column, if one
|
||||
* exists. SOFTREGEX never goes through dqValidate/the cell validator (see
|
||||
* setupValidations — it's a display-only grid renderer instead), so the
|
||||
* edit-record modal, which has no grid renderer to hook into, uses this
|
||||
* directly to show the same warning outside the grid.
|
||||
*
|
||||
* A column can carry both HARDREGEX and SOFTREGEX at once, but only one
|
||||
* regex ever runs per column: if a HARDREGEX rule exists, SOFTREGEX is
|
||||
* ignored entirely - the cell is already governed by the blocking rule,
|
||||
* so a yellow warning on top would be redundant, even for values that
|
||||
* pass the hard rule. Same precedence as makeRegexWarningRenderer.
|
||||
*/
|
||||
failsSoftRegex(col: string, value: any): boolean {
|
||||
const isNumeric =
|
||||
this.rules.find((rule) => rule.data === col)?.type === 'numeric'
|
||||
if (isRegexRuleExempt(value, isNumeric)) return false
|
||||
|
||||
const hardRegexRule = this.dqrules.find(
|
||||
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'HARDREGEX'
|
||||
)
|
||||
if (hardRegexRule) return false
|
||||
|
||||
const softRegexRule = this.dqrules.find(
|
||||
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'SOFTREGEX'
|
||||
)
|
||||
if (!softRegexRule) return false
|
||||
|
||||
try {
|
||||
return !parseRegexRule(softRegexRule.RULE_VALUE).test(value.toString())
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SOFTSELECT is not defined in DQ RULES
|
||||
* This function fetches it's values and pushes in the DQ RULES array with SOFTSELECT type
|
||||
@@ -376,6 +466,14 @@ export class DcValidator {
|
||||
this.rules[i].readOnly = true
|
||||
}
|
||||
|
||||
// HARDFORMULA: same read-only treatment as an explicit READONLY rule
|
||||
// - its value is always computed (see applyFormulaRules), never
|
||||
// user-editable. SOFTFORMULA deliberately does not set this: its
|
||||
// computed value is only a default, the user can overwrite it.
|
||||
if (this.hasDqRules(ruleColName, ['HARDFORMULA'])) {
|
||||
this.rules[i].readOnly = true
|
||||
}
|
||||
|
||||
// HIDDEN: hide column in HOT but keep its data (still submitted via hot.getData())
|
||||
if (this.hasDqRules(ruleColName, ['HIDDEN'])) {
|
||||
this.hiddenColumns.push(i)
|
||||
@@ -395,6 +493,24 @@ export class DcValidator {
|
||||
// editor/validator stay intact via `type`.
|
||||
this.rules[i].numericFormat = undefined
|
||||
}
|
||||
|
||||
// HARDREGEX/SOFTREGEX: submission-blocking for HARDREGEX still goes
|
||||
// through the normal validator/dqValidate path (see dq-validation.ts)
|
||||
// and is unaffected by this renderer. This only wires the display
|
||||
// layer - a 'REGEX: <pattern>' title, plus a yellow dc-warning-cell
|
||||
// when only SOFTREGEX fails (never for HARDREGEX, which relies on
|
||||
// HOT's own red htInvalid instead). Last-wins against NUMBER_FORMAT
|
||||
// if a column somehow carried both (not expected in practice — one
|
||||
// formats numbers, the other pattern-matches text).
|
||||
if (this.hasDqRules(ruleColName, ['HARDREGEX', 'SOFTREGEX'])) {
|
||||
const { hardRegexValue, softRegexValue } =
|
||||
this.getRegexRuleValues(ruleColName)
|
||||
this.rules[i].renderer = makeRegexWarningRenderer(
|
||||
softRegexValue,
|
||||
hardRegexValue,
|
||||
this.rules[i].type === 'numeric'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const self = this
|
||||
@@ -481,7 +597,11 @@ export class DcValidator {
|
||||
}
|
||||
|
||||
if (self.isDqCol(col || '')) {
|
||||
const dqValid = dqValidate(self.getDqDetails(col || ''), value)
|
||||
const dqValid = dqValidate(
|
||||
self.getDqDetails(col || ''),
|
||||
value,
|
||||
colType === 'numeric'
|
||||
)
|
||||
|
||||
if (!dqValid) {
|
||||
console.warn(`DQ Validation - invalid (Value: ${value})`)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface Col {
|
||||
NAME: string
|
||||
VARNUM: number
|
||||
// No longer sent by getdata.sas (dropped to trim the COLS payload) —
|
||||
// column order in the array is authoritative. Optional for legacy responses.
|
||||
VARNUM?: number
|
||||
LABEL: string
|
||||
FMTNAME: string
|
||||
DDTYPE: string
|
||||
|
||||
@@ -18,3 +18,7 @@ export type DQRuleTypes =
|
||||
| 'HIDDEN'
|
||||
| 'ROUND'
|
||||
| 'NUMBER_FORMAT'
|
||||
| 'HARDREGEX'
|
||||
| 'SOFTREGEX'
|
||||
| 'HARDFORMULA'
|
||||
| 'SOFTFORMULA'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DQData, SASParam } from 'src/app/models/TableData'
|
||||
import { DcValidator } from '../dc-validator'
|
||||
import { Col } from '../models/col.model'
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { EDIT_STATUS_COLUMN_NAME } from '../utils/editStatusColumnRule'
|
||||
|
||||
describe('DC Validator', () => {
|
||||
it('should create an instance of validator with correct rules', () => {
|
||||
@@ -24,10 +25,10 @@ describe('DC Validator', () => {
|
||||
expect(cols[0].TYPE).toEqual('char')
|
||||
|
||||
// Get all — one rule per cols[] entry, plus the injected
|
||||
// DELETE_RECORD_COLUMN_RULE (never present in cols[], see its own
|
||||
// doc comment)
|
||||
// DELETE_RECORD_COLUMN_RULE and EDIT_STATUS_COLUMN_RULE (neither is ever
|
||||
// present in cols[], see their own doc comments)
|
||||
const validationRules = dcValidator.getRules()
|
||||
expect(validationRules).toHaveSize(example_cols.length + 1)
|
||||
expect(validationRules).toHaveSize(example_cols.length + 2)
|
||||
|
||||
// Get col with notnull validation
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
@@ -75,6 +76,17 @@ describe('DC Validator', () => {
|
||||
)
|
||||
expect(deleteRecordRule?.type).toEqual('dropdown')
|
||||
expect(deleteRecordRule?.source).toEqual(['No', 'Yes'])
|
||||
|
||||
// EDIT_STATUS is likewise never a cols[] entry - it's a purely
|
||||
// client-synthesized column (see editStatusColumnRule.ts) so
|
||||
// DC.ROW_STATUS has a real cell to reference. Always the last rule,
|
||||
// always read-only, always hidden.
|
||||
const editStatusRule = dcValidator.getRule(EDIT_STATUS_COLUMN_NAME)
|
||||
expect(editStatusRule?.readOnly).toBeTrue()
|
||||
expect(validationRules[validationRules.length - 1].data).toEqual(
|
||||
EDIT_STATUS_COLUMN_NAME
|
||||
)
|
||||
expect(dcValidator.getHiddenColumns()).toContain(validationRules.length - 1)
|
||||
})
|
||||
|
||||
it('should create an instance of validator and execute its functions', () => {
|
||||
@@ -305,9 +317,10 @@ describe('DC Validator', () => {
|
||||
example_dqData
|
||||
)
|
||||
|
||||
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
|
||||
example_sasparams.COLHEADERS.split(',')
|
||||
)
|
||||
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
|
||||
...example_sasparams.COLHEADERS.split(','),
|
||||
EDIT_STATUS_COLUMN_NAME
|
||||
])
|
||||
})
|
||||
|
||||
it("6 | keeps rules aligned with COLHEADERS when the PK is not the source table's first column", () => {
|
||||
@@ -344,9 +357,10 @@ describe('DC Validator', () => {
|
||||
[]
|
||||
)
|
||||
|
||||
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
|
||||
sasparams.COLHEADERS.split(',')
|
||||
)
|
||||
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
|
||||
...sasparams.COLHEADERS.split(','),
|
||||
EDIT_STATUS_COLUMN_NAME
|
||||
])
|
||||
})
|
||||
|
||||
it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => {
|
||||
@@ -373,9 +387,10 @@ describe('DC Validator', () => {
|
||||
[]
|
||||
)
|
||||
|
||||
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
|
||||
sasparams.COLHEADERS.split(',')
|
||||
)
|
||||
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
|
||||
...sasparams.COLHEADERS.split(','),
|
||||
EDIT_STATUS_COLUMN_NAME
|
||||
])
|
||||
})
|
||||
|
||||
it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => {
|
||||
@@ -524,6 +539,401 @@ describe('DC Validator', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('10 | blocks submission of a value that fails a HARDREGEX rule', () => {
|
||||
// SOME_CHAR already has its own CASE=UPCASE rule in example_dqRules —
|
||||
// 'AB1' passes that (it equals its own uppercase form) but fails a
|
||||
// letters-only HARDREGEX pattern, isolating HARDREGEX's own effect
|
||||
// rather than piggybacking on CASE rejecting the value too.
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
example_sasparams,
|
||||
example_dataformats,
|
||||
example_cols,
|
||||
[
|
||||
...example_dqRules,
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR',
|
||||
RULE_TYPE: 'HARDREGEX',
|
||||
RULE_VALUE: '^[A-Z]+$',
|
||||
X: 0
|
||||
}
|
||||
],
|
||||
example_dqData
|
||||
)
|
||||
const someCharRule = dcValidator.getRule('SOME_CHAR')
|
||||
|
||||
dcValidator.executeHotValidator(someCharRule!, 'ABC', (valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
})
|
||||
dcValidator.executeHotValidator(someCharRule!, 'AB1', (valid: boolean) => {
|
||||
expect(valid).toBeFalse()
|
||||
})
|
||||
})
|
||||
|
||||
it('11 | wires a function renderer for a HARDREGEX-only rule too (for the REGEX: tooltip)', () => {
|
||||
// HARDREGEX blocking itself is covered by test 10 above - this isolates
|
||||
// the newer addition: even with no SOFTREGEX at all, a renderer must
|
||||
// still be wired so a failing cell gets a 'REGEX: <pattern>' title on
|
||||
// top of HOT's own red htInvalid, not just silence.
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
example_sasparams,
|
||||
example_dataformats,
|
||||
example_cols,
|
||||
[
|
||||
...example_dqRules,
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'HARDREGEX',
|
||||
RULE_VALUE: '^[A-Z]+$',
|
||||
X: 0
|
||||
}
|
||||
],
|
||||
example_dqData
|
||||
)
|
||||
const someCharAnyRule = dcValidator.getRule('SOME_CHAR_ANY')
|
||||
|
||||
expect(typeof someCharAnyRule?.renderer).toEqual('function')
|
||||
})
|
||||
|
||||
it('12 | wires a function renderer for a SOFTREGEX rule, without blocking submission', () => {
|
||||
// SOME_CHAR_ANY carries no other DQ rules in the shared fixture, so this
|
||||
// isolates SOFTREGEX's own wiring. The renderer's own pass/fail/delete-
|
||||
// suppression behaviour is covered by regex-warning-renderer.spec.ts —
|
||||
// this only proves setupValidations() assigns it and that, unlike
|
||||
// HARDREGEX, a non-matching value still submits.
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
example_sasparams,
|
||||
example_dataformats,
|
||||
example_cols,
|
||||
[
|
||||
...example_dqRules,
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '^[A-Z]+$',
|
||||
X: 0
|
||||
}
|
||||
],
|
||||
example_dqData
|
||||
)
|
||||
const someCharAnyRule = dcValidator.getRule('SOME_CHAR_ANY')
|
||||
|
||||
expect(typeof someCharAnyRule?.renderer).toEqual('function')
|
||||
|
||||
dcValidator.executeHotValidator(
|
||||
someCharAnyRule!,
|
||||
'not uppercase',
|
||||
(valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('13 | wires a renderer for a dual-rule column, and HARDREGEX still blocks submission', () => {
|
||||
// Both rules on the same column: submission blocking is still governed
|
||||
// entirely by HARDREGEX/dqValidate (unchanged). A renderer is wired so
|
||||
// the cell gets a 'REGEX: <pattern>' title - its own hard-vs-soft
|
||||
// precedence and coloring are covered by regex-warning-renderer.spec.ts.
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
example_sasparams,
|
||||
example_dataformats,
|
||||
example_cols,
|
||||
[
|
||||
...example_dqRules,
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'HARDREGEX',
|
||||
RULE_VALUE: '^[A-Z]+$',
|
||||
X: 0
|
||||
},
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '^[A-Z]+$',
|
||||
X: 0
|
||||
}
|
||||
],
|
||||
example_dqData
|
||||
)
|
||||
const rule = dcValidator.getRule('SOME_CHAR_ANY')
|
||||
|
||||
dcValidator.executeHotValidator(
|
||||
rule!,
|
||||
'not uppercase',
|
||||
(valid: boolean) => {
|
||||
expect(valid).toBeFalse()
|
||||
}
|
||||
)
|
||||
expect(typeof rule?.renderer).toEqual('function')
|
||||
})
|
||||
|
||||
describe('14 | failsSoftRegex (edit-record modal support for SOFTREGEX)', () => {
|
||||
// SOFTREGEX never goes through dqValidate (see the wiring in
|
||||
// setupValidations), so the edit-record modal — which has no grid
|
||||
// renderer to hook into — calls this directly to show the same warning.
|
||||
const buildValidator = (dqRules: DQRule[]) =>
|
||||
new DcValidator(
|
||||
example_sasparams,
|
||||
example_dataformats,
|
||||
example_cols,
|
||||
dqRules,
|
||||
example_dqData
|
||||
)
|
||||
|
||||
it('is true for a value that fails the pattern', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '^[A-Z]+$',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
expect(
|
||||
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'lowercase')
|
||||
).toBeTrue()
|
||||
})
|
||||
|
||||
it('is false for a value that matches the pattern', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '^[A-Z]+$',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
expect(
|
||||
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'UPPERCASE')
|
||||
).toBeFalse()
|
||||
})
|
||||
|
||||
it('handles a PRX-delimited pattern with a case-insensitive flag, as authored for prxparse', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '/\\b(the|data)\\b/i',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
expect(
|
||||
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'this is dummy data')
|
||||
).toBeFalse()
|
||||
expect(
|
||||
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'THE WIND WAS BLOWING')
|
||||
).toBeFalse()
|
||||
expect(
|
||||
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'nothing relevant here')
|
||||
).toBeTrue()
|
||||
})
|
||||
|
||||
it('is false for a column with no SOFTREGEX rule', () => {
|
||||
const dcValidator = buildValidator([])
|
||||
|
||||
expect(
|
||||
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'anything')
|
||||
).toBeFalse()
|
||||
})
|
||||
|
||||
it('is false for blank values; special-missing-looking values are real text on a character column', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '^[A-Z]+$',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '')).toBeFalse()
|
||||
// ".a" is real text here and fails ^[A-Z]+$
|
||||
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '.a')).toBeTrue()
|
||||
})
|
||||
|
||||
it('exempts the plain missing (".") but not special missings on a numeric column', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_NUM',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '^[0-9]+$',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
expect(dcValidator.failsSoftRegex('SOME_NUM', '.')).toBeFalse()
|
||||
// Special missings are deliberately-set values and face the pattern.
|
||||
expect(dcValidator.failsSoftRegex('SOME_NUM', '.a')).toBeTrue()
|
||||
})
|
||||
|
||||
it('is false when a value fails both HARDREGEX and SOFTREGEX (precedence — no yellow on a red cell)', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'HARDREGEX',
|
||||
RULE_VALUE: '^[A-Z]{3}$',
|
||||
X: 0
|
||||
},
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '^.{5,10}$',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
// 'ab' fails both HARDREGEX (not 3 uppercase letters) and SOFTREGEX
|
||||
// (too short) - HARDREGEX wins, so this must stay false rather than
|
||||
// report a SOFTREGEX warning on a value that's already blocked.
|
||||
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'ab')).toBeFalse()
|
||||
})
|
||||
|
||||
it('is false for every value when the column has HARDREGEX - SOFTREGEX never runs, even if HARDREGEX passes', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'HARDREGEX',
|
||||
RULE_VALUE: '^[A-Z0-9]+$',
|
||||
X: 0
|
||||
},
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '^.{5,10}$',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
// 'AB' passes HARDREGEX (uppercase/digits) but fails SOFTREGEX (too
|
||||
// short) - only one regex runs per column, so the soft rule is
|
||||
// ignored entirely.
|
||||
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'AB')).toBeFalse()
|
||||
})
|
||||
})
|
||||
|
||||
describe('15 | getRegexRuleValues (column-header info display)', () => {
|
||||
const buildValidator = (dqRules: DQRule[]) =>
|
||||
new DcValidator(
|
||||
example_sasparams,
|
||||
example_dataformats,
|
||||
example_cols,
|
||||
dqRules,
|
||||
example_dqData
|
||||
)
|
||||
|
||||
it('returns only hardRegexValue for a column with just HARDREGEX', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'HARDREGEX',
|
||||
RULE_VALUE: '^[A-Z]+$',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
|
||||
hardRegexValue: '^[A-Z]+$',
|
||||
softRegexValue: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('returns only softRegexValue for a column with just SOFTREGEX', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '/\\b(the|data)\\b/i',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
|
||||
hardRegexValue: undefined,
|
||||
softRegexValue: '/\\b(the|data)\\b/i'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns both values for a column with both HARDREGEX and SOFTREGEX', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'HARDREGEX',
|
||||
RULE_VALUE: '^HARD$',
|
||||
X: 0
|
||||
},
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '^SOFT$',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
|
||||
hardRegexValue: '^HARD$',
|
||||
softRegexValue: '^SOFT$'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns both undefined for a column with no regex rule', () => {
|
||||
const dcValidator = buildValidator([])
|
||||
|
||||
expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
|
||||
hardRegexValue: undefined,
|
||||
softRegexValue: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('16 | getFormulaRuleValue (column-header info display)', () => {
|
||||
const buildValidator = (dqRules: DQRule[]) =>
|
||||
new DcValidator(
|
||||
example_sasparams,
|
||||
example_dataformats,
|
||||
example_cols,
|
||||
dqRules,
|
||||
example_dqData
|
||||
)
|
||||
|
||||
it('returns the RULE_VALUE for a HARDFORMULA rule', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'HARDFORMULA',
|
||||
RULE_VALUE: 'A_COL * B_COL',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toEqual(
|
||||
'A_COL * B_COL'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the RULE_VALUE for a SOFTFORMULA rule', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTFORMULA',
|
||||
RULE_VALUE: 'A_COL + B_COL',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toEqual(
|
||||
'A_COL + B_COL'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined for a column with no formula rule', () => {
|
||||
const dcValidator = buildValidator([])
|
||||
|
||||
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/** Minimal cols[] entry — only the fields rule ordering depends on. */
|
||||
@@ -533,7 +943,7 @@ const makeCol = (name: string, varnum: number): Col =>
|
||||
VARNUM: varnum,
|
||||
LABEL: name,
|
||||
FMTNAME: '',
|
||||
DDTYPE: 'CHARACTER',
|
||||
DDTYPE: 'C',
|
||||
TYPE: '',
|
||||
CLS_RULE: 'READ',
|
||||
MEMLABEL: '',
|
||||
@@ -655,7 +1065,7 @@ const example_dqRules: any = [
|
||||
const example_cols = [
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'CHARACTER',
|
||||
DDTYPE: 'C',
|
||||
DESC: 'dropdown_desc',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
@@ -668,7 +1078,7 @@ const example_cols = [
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'NUMERIC',
|
||||
DDTYPE: 'N',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
@@ -681,7 +1091,7 @@ const example_cols = [
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'NUMERIC',
|
||||
DDTYPE: 'N',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
@@ -694,7 +1104,7 @@ const example_cols = [
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'CHARACTER',
|
||||
DDTYPE: 'C',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
@@ -707,7 +1117,7 @@ const example_cols = [
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'CHARACTER',
|
||||
DDTYPE: 'C',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
@@ -720,7 +1130,7 @@ const example_cols = [
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'CHARACTER',
|
||||
DDTYPE: 'C',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
@@ -733,7 +1143,7 @@ const example_cols = [
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'CHARACTER',
|
||||
DDTYPE: 'C',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
@@ -785,7 +1195,7 @@ const example_cols = [
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'NUMERIC',
|
||||
DDTYPE: 'N',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
@@ -798,7 +1208,7 @@ const example_cols = [
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'NUMERIC',
|
||||
DDTYPE: 'N',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { applyFormulaRules } from './applyFormulaRules'
|
||||
|
||||
const rule = (overrides: Partial<DQRule>): DQRule => ({
|
||||
BASE_COL: 'REVENUE',
|
||||
RULE_TYPE: 'HARDFORMULA',
|
||||
RULE_VALUE: '=PRICE * VOLUME',
|
||||
X: 0,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const columnNames = ['ITEM', 'PRICE', 'VOLUME', 'REVENUE']
|
||||
const headerPks = ['ITEM']
|
||||
|
||||
describe('applyFormulaRules', () => {
|
||||
it('leaves the dataset untouched when there are no formula rules', () => {
|
||||
const dataSource = [{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, REVENUE: '' }]
|
||||
|
||||
const result = applyFormulaRules(
|
||||
dataSource,
|
||||
[],
|
||||
columnNames,
|
||||
dataSource,
|
||||
headerPks,
|
||||
'sasdemo'
|
||||
)
|
||||
|
||||
expect(result).toEqual(dataSource)
|
||||
})
|
||||
|
||||
it("injects the row-relative formula string for a HARDFORMULA column (issue's own PRICE*VOLUME example)", () => {
|
||||
const dataSource = [
|
||||
{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, REVENUE: '' },
|
||||
{ ITEM: 'PEN', PRICE: 61.02, VOLUME: 1971, REVENUE: '' }
|
||||
]
|
||||
|
||||
const result = applyFormulaRules(
|
||||
dataSource,
|
||||
[rule({})],
|
||||
columnNames,
|
||||
dataSource,
|
||||
headerPks,
|
||||
'sasdemo'
|
||||
)
|
||||
|
||||
expect(result[0].REVENUE).toEqual('=B1 * C1')
|
||||
expect(result[1].REVENUE).toEqual('=B2 * C2')
|
||||
})
|
||||
|
||||
it('injects the formula for a SOFTFORMULA column too (only readOnly-ness differs, handled elsewhere)', () => {
|
||||
const dataSource = [{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, REVENUE: '' }]
|
||||
|
||||
const result = applyFormulaRules(
|
||||
dataSource,
|
||||
[rule({ RULE_TYPE: 'SOFTFORMULA' })],
|
||||
columnNames,
|
||||
dataSource,
|
||||
headerPks,
|
||||
'sasdemo'
|
||||
)
|
||||
|
||||
expect(result[0].REVENUE).toEqual('=B1 * C1')
|
||||
})
|
||||
|
||||
it('resolves DC.ORIG_VALUE via the original (pre-edit) row, PK-matched', () => {
|
||||
const dataSourceUnchanged = [
|
||||
{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, NOTE: 'original note' }
|
||||
]
|
||||
const dataSource = [{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 999, NOTE: '' }]
|
||||
|
||||
const result = applyFormulaRules(
|
||||
dataSource,
|
||||
[rule({ BASE_COL: 'NOTE', RULE_VALUE: '=DC.ORIG_VALUE' })],
|
||||
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
|
||||
dataSourceUnchanged,
|
||||
headerPks,
|
||||
'sasdemo'
|
||||
)
|
||||
|
||||
expect(result[0].NOTE).toEqual('="original note"')
|
||||
})
|
||||
|
||||
it('resolves DC.ORIG_VALUE to an empty literal for a newly-added row with no PK match', () => {
|
||||
const dataSource = [{ ITEM: 'NEWITEM', PRICE: 1, VOLUME: 1, NOTE: '' }]
|
||||
|
||||
const result = applyFormulaRules(
|
||||
dataSource,
|
||||
[rule({ BASE_COL: 'NOTE', RULE_VALUE: '=DC.ORIG_VALUE' })],
|
||||
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
|
||||
[],
|
||||
headerPks,
|
||||
'sasdemo'
|
||||
)
|
||||
|
||||
expect(result[0].NOTE).toEqual('=""')
|
||||
})
|
||||
|
||||
it('resolves DC.USER_NAME to the current username for every row', () => {
|
||||
const dataSource = [
|
||||
{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, NOTE: '' },
|
||||
{ ITEM: 'PEN', PRICE: 61.02, VOLUME: 1971, NOTE: '' }
|
||||
]
|
||||
|
||||
const result = applyFormulaRules(
|
||||
dataSource,
|
||||
[rule({ BASE_COL: 'NOTE', RULE_VALUE: '=DC.USER_NAME' })],
|
||||
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
|
||||
dataSource,
|
||||
headerPks,
|
||||
'sasdemo'
|
||||
)
|
||||
|
||||
expect(result[0].NOTE).toEqual('="sasdemo"')
|
||||
expect(result[1].NOTE).toEqual('="sasdemo"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { parseFormulaRule } from './parseFormulaRule'
|
||||
|
||||
/**
|
||||
* Injects the computed `=...` formula string into each row's data for every
|
||||
* HARDFORMULA/SOFTFORMULA column, so Handsontable/HyperFormula displays the
|
||||
* evaluated result. HARDFORMULA's readOnly-ness is handled separately in
|
||||
* dc-validator.ts, alongside the existing READONLY rule - this only deals
|
||||
* with the actual computed value.
|
||||
*
|
||||
* Mutates and returns `dataSource` (same in-place-mutate convention as
|
||||
* applyNumericFormats).
|
||||
*/
|
||||
export const applyFormulaRules = (
|
||||
dataSource: any[],
|
||||
dqRules: DQRule[],
|
||||
columnNames: string[],
|
||||
dataSourceUnchanged: any[],
|
||||
headerPks: string[],
|
||||
userName: string
|
||||
): any[] => {
|
||||
const formulaRules = dqRules.filter(
|
||||
(rule) =>
|
||||
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
|
||||
)
|
||||
if (formulaRules.length === 0) return dataSource
|
||||
|
||||
dataSource.forEach((row, rowIndex) => {
|
||||
for (const formulaRule of formulaRules) {
|
||||
const origRow = dataSourceUnchanged.find((candidate) =>
|
||||
headerPks.every((pk) => candidate[pk] === row[pk])
|
||||
)
|
||||
|
||||
row[formulaRule.BASE_COL] = parseFormulaRule(formulaRule.RULE_VALUE, {
|
||||
columnNames,
|
||||
rowIndex,
|
||||
userName,
|
||||
origValue: origRow ? origRow[formulaRule.BASE_COL] : undefined
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return dataSource
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import { DcValidation } from '../models/dc-validation.model'
|
||||
* Uses Intl.NumberFormat options (HOT 17+) instead of the deprecated numbro
|
||||
* `pattern`/`culture`. `maximumFractionDigits: 20` preserves all natural
|
||||
* decimals (Intl's default of 3 would round); `locale` replaces `culture`.
|
||||
* `useGrouping: false` keeps raw digits (no thousands separator) - a
|
||||
* separator would leak into anything that pattern-matches the displayed
|
||||
* value (eg HARDREGEX/SOFTREGEX), and grouping can be opted into per-column
|
||||
* with the NUMBER_FORMAT rule.
|
||||
*
|
||||
* @param rules Cell Validation rules to be updated
|
||||
* Those rules are passed in the `columns` property Of handsontable settings.
|
||||
@@ -14,7 +18,7 @@ import { DcValidation } from '../models/dc-validation.model'
|
||||
export const applyNumericFormats = (rules: DcValidation[]): DcValidation[] => {
|
||||
for (let rule of rules) {
|
||||
if (rule.type === 'numeric') {
|
||||
rule.numericFormat = { useGrouping: true, maximumFractionDigits: 20 }
|
||||
rule.numericFormat = { useGrouping: false, maximumFractionDigits: 20 }
|
||||
rule.locale = window.navigator.language
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DcValidation } from '../models/dc-validation.model'
|
||||
|
||||
/**
|
||||
* EDIT_STATUS is a purely client-synthesized column (M/A/D/U from
|
||||
* classifyRow), added so DC.ROW_STATUS has a real, HyperFormula-addressable
|
||||
* cell to point at. It never comes from COLHEADERS, is always hidden (see
|
||||
* DcValidator's constructor) and is stripped before the submit payload
|
||||
* leaves the browser (see editor.component.ts's saveTable()).
|
||||
*
|
||||
* Named with a period rather than decorated with underscores (like
|
||||
* DELETE_RECORD_COLUMN_RULE) - a SAS variable name can never contain a
|
||||
* period, so this makes a collision with a real dataset column structurally
|
||||
* impossible, not just unlikely. Handsontable's own datamap.get()/set() only
|
||||
* treat a dot in a `data` key as a nested-path separator when the row has
|
||||
* no OWN flat property of that exact name - since every row always gets
|
||||
* this key from hotDataSchema (a flat copy, dots included, see
|
||||
* DataMap.createRow's deepExtend), that fallback never triggers here.
|
||||
*/
|
||||
export const EDIT_STATUS_COLUMN_NAME = 'dc.row_status'
|
||||
|
||||
export const EDIT_STATUS_COLUMN_RULE: DcValidation = {
|
||||
data: EDIT_STATUS_COLUMN_NAME,
|
||||
type: 'text',
|
||||
readOnly: true
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { findOverwrittenCells } from './findOverwrittenCells'
|
||||
|
||||
describe('findOverwrittenCells', () => {
|
||||
it('reports a change when the current value differs from a meaningful raw value', () => {
|
||||
const currentRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, FORMULA_HARD_COL: 20, FORMULA_SOFT_COL: 12 }
|
||||
]
|
||||
const rawRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, FORMULA_HARD_COL: 1111, FORMULA_SOFT_COL: 2222 }
|
||||
]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(
|
||||
currentRows,
|
||||
rawRows,
|
||||
['FORMULA_HARD_COL', 'FORMULA_SOFT_COL'],
|
||||
['PRIMARY_KEY_FIELD']
|
||||
)
|
||||
).toEqual([
|
||||
{ rowIndex: 0, col: 'FORMULA_HARD_COL', originalValue: 1111 },
|
||||
{ rowIndex: 0, col: 'FORMULA_SOFT_COL', originalValue: 2222 }
|
||||
])
|
||||
})
|
||||
|
||||
it('reports nothing when the current value matches the raw value', () => {
|
||||
const currentRows = [{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'unchanged' }]
|
||||
const rawRows = [{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'unchanged' }]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(
|
||||
currentRows,
|
||||
rawRows,
|
||||
['SOME_CHAR'],
|
||||
['PRIMARY_KEY_FIELD']
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('compares loosely (string vs number) so a "20" raw value matching a 20 current value is not reported', () => {
|
||||
const currentRows = [{ PRIMARY_KEY_FIELD: 1, SOME_NUM: 20 }]
|
||||
const rawRows = [{ PRIMARY_KEY_FIELD: 1, SOME_NUM: '20' }]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(
|
||||
currentRows,
|
||||
rawRows,
|
||||
['SOME_NUM'],
|
||||
['PRIMARY_KEY_FIELD']
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a column with no meaningful raw value (blank/null/undefined) - nothing to have changed from', () => {
|
||||
const currentRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'a' },
|
||||
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'b' },
|
||||
{ PRIMARY_KEY_FIELD: 3, SOME_CHAR: 'c' }
|
||||
]
|
||||
const rawRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: '' },
|
||||
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: null },
|
||||
{ PRIMARY_KEY_FIELD: 3, SOME_CHAR: undefined }
|
||||
]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(
|
||||
currentRows,
|
||||
rawRows,
|
||||
['SOME_CHAR'],
|
||||
['PRIMARY_KEY_FIELD']
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('only reports the rows/columns that actually changed, across multiple rows', () => {
|
||||
const currentRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, A: 20, B: 12 },
|
||||
{ PRIMARY_KEY_FIELD: 2, A: 30, B: 13 }
|
||||
]
|
||||
const rawRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, A: 20, B: 2222 },
|
||||
{ PRIMARY_KEY_FIELD: 2, A: 30, B: 13 }
|
||||
]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(
|
||||
currentRows,
|
||||
rawRows,
|
||||
['A', 'B'],
|
||||
['PRIMARY_KEY_FIELD']
|
||||
)
|
||||
).toEqual([{ rowIndex: 0, col: 'B', originalValue: 2222 }])
|
||||
})
|
||||
|
||||
it('matches rows by primary key, not array position', () => {
|
||||
const currentRows = [
|
||||
{ PRIMARY_KEY_FIELD: 2, A: 999 },
|
||||
{ PRIMARY_KEY_FIELD: 1, A: 20 }
|
||||
]
|
||||
// rawRows deliberately in a different order than currentRows
|
||||
const rawRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, A: 20 },
|
||||
{ PRIMARY_KEY_FIELD: 2, A: 30 }
|
||||
]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(currentRows, rawRows, ['A'], ['PRIMARY_KEY_FIELD'])
|
||||
).toEqual([{ rowIndex: 0, col: 'A', originalValue: 30 }])
|
||||
})
|
||||
|
||||
it('skips a row with no primary-key match in rawRows (e.g. a newly-inserted row)', () => {
|
||||
const currentRows = [
|
||||
{ PRIMARY_KEY_FIELD: 1, A: 20 },
|
||||
{ PRIMARY_KEY_FIELD: undefined, A: 999 }
|
||||
]
|
||||
const rawRows = [{ PRIMARY_KEY_FIELD: 1, A: 1111 }]
|
||||
|
||||
expect(
|
||||
findOverwrittenCells(currentRows, rawRows, ['A'], ['PRIMARY_KEY_FIELD'])
|
||||
).toEqual([{ rowIndex: 0, col: 'A', originalValue: 1111 }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface OverwrittenCell {
|
||||
rowIndex: number
|
||||
col: string
|
||||
originalValue: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every (row, column) pair where the current value differs from the
|
||||
* real, raw value SAS actually sent for it - i.e. something (a direct edit,
|
||||
* paste, a formula, ...) silently changed a value that pre-existed in the
|
||||
* actual data, not just filled in a blank. Rows are matched by primary key,
|
||||
* not array position, so detection stays correct across row insert/delete/
|
||||
* sort. A row with no PK match in rawRows (a newly-inserted row) is skipped
|
||||
* entirely, since there's no original value to have overwritten. Compared
|
||||
* loosely (via string coercion) since the raw value arrives as whatever
|
||||
* type SAS sent while the current value may be a different but equal
|
||||
* representation (e.g. HyperFormula's own numeric result).
|
||||
*/
|
||||
export const findOverwrittenCells = (
|
||||
currentRows: Record<string, unknown>[],
|
||||
rawRows: Record<string, unknown>[],
|
||||
revertableCols: string[],
|
||||
headerPks: string[]
|
||||
): OverwrittenCell[] => {
|
||||
const changes: OverwrittenCell[] = []
|
||||
|
||||
currentRows.forEach((row, rowIndex) => {
|
||||
const rawRow = rawRows.find((candidate) =>
|
||||
headerPks.every((pk) => candidate[pk] === row[pk])
|
||||
)
|
||||
|
||||
if (!rawRow) return
|
||||
|
||||
for (const col of revertableCols) {
|
||||
const rawValue = rawRow[col]
|
||||
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
||||
continue
|
||||
}
|
||||
|
||||
const currentValue = row[col]
|
||||
|
||||
if (String(rawValue) === String(currentValue)) continue
|
||||
|
||||
changes.push({ rowIndex, col, originalValue: rawValue })
|
||||
}
|
||||
})
|
||||
|
||||
return changes
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { getFormulaCellsToPreserveOnCancel } from './getFormulaCellsToPreserveOnCancel'
|
||||
|
||||
describe('getFormulaCellsToPreserveOnCancel', () => {
|
||||
it('preserves a formula column that still has its changed-value comment', () => {
|
||||
const hasComment = (rowIndex: number, baseCol: string) =>
|
||||
rowIndex === 0 && baseCol === 'FORMULA_HARD_COL'
|
||||
const getComputedValue = (rowIndex: number, prop: string) =>
|
||||
`computed-${rowIndex}-${prop}`
|
||||
|
||||
expect(
|
||||
getFormulaCellsToPreserveOnCancel(
|
||||
1,
|
||||
['FORMULA_HARD_COL'],
|
||||
hasComment,
|
||||
getComputedValue
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
rowIndex: 0,
|
||||
prop: 'FORMULA_HARD_COL',
|
||||
value: 'computed-0-FORMULA_HARD_COL'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('skips a formula column with no comment - either it never differed, or the user already reverted it', () => {
|
||||
expect(
|
||||
getFormulaCellsToPreserveOnCancel(
|
||||
1,
|
||||
['FORMULA_HARD_COL'],
|
||||
() => false,
|
||||
() => 'unused'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('checks every row and every formula base col independently', () => {
|
||||
const hasComment = (rowIndex: number, baseCol: string) =>
|
||||
(rowIndex === 0 && baseCol === 'FORMULA_HARD_COL') ||
|
||||
(rowIndex === 2 && baseCol === 'FORMULA_SOFT_COL')
|
||||
const getComputedValue = (rowIndex: number, prop: string) =>
|
||||
`computed-${rowIndex}-${prop}`
|
||||
|
||||
expect(
|
||||
getFormulaCellsToPreserveOnCancel(
|
||||
3,
|
||||
['FORMULA_HARD_COL', 'FORMULA_SOFT_COL'],
|
||||
hasComment,
|
||||
getComputedValue
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
rowIndex: 0,
|
||||
prop: 'FORMULA_HARD_COL',
|
||||
value: 'computed-0-FORMULA_HARD_COL'
|
||||
},
|
||||
{
|
||||
rowIndex: 2,
|
||||
prop: 'FORMULA_SOFT_COL',
|
||||
value: 'computed-2-FORMULA_SOFT_COL'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('returns nothing when there are no formula base cols', () => {
|
||||
expect(
|
||||
getFormulaCellsToPreserveOnCancel(
|
||||
5,
|
||||
[],
|
||||
() => true,
|
||||
() => 'unused'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface PreservedFormulaCell {
|
||||
rowIndex: number
|
||||
prop: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Cells that must keep their LIVE computed value when an edit session is
|
||||
* cancelled, rather than being blindly overwritten by dataSourceUnchanged's
|
||||
* raw baseline. dataSourceUnchanged deliberately holds the raw pre-formula
|
||||
* value for a HARDFORMULA/SOFTFORMULA column whenever it still has its
|
||||
* markFormulaChangedCells comment (see editTable's overlay) - that's needed
|
||||
* so classifyRow flags the row as modified, but it isn't a session edit to
|
||||
* discard: the formula recomputes on every load regardless of what happens
|
||||
* to be sitting in dataSourceUnchanged.
|
||||
*
|
||||
* A cell with NO comment doesn't need preserving either way: it never
|
||||
* differed from raw (dataSourceUnchanged already matches computed), or the
|
||||
* user already reverted it (dataSourceUnchanged's raw value already matches
|
||||
* the reverted plain value sitting in dataSource) - in both cases the
|
||||
* baseline is already correct.
|
||||
*/
|
||||
export const getFormulaCellsToPreserveOnCancel = (
|
||||
rowCount: number,
|
||||
formulaBaseCols: string[],
|
||||
hasComment: (rowIndex: number, baseCol: string) => boolean,
|
||||
getComputedValue: (rowIndex: number, prop: string) => unknown
|
||||
): PreservedFormulaCell[] => {
|
||||
const preserved: PreservedFormulaCell[] = []
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||
for (const baseCol of formulaBaseCols) {
|
||||
if (!hasComment(rowIndex, baseCol)) continue
|
||||
|
||||
preserved.push({
|
||||
rowIndex,
|
||||
prop: baseCol,
|
||||
value: getComputedValue(rowIndex, baseCol)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return preserved
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { getRevertableCols } from './getRevertableCols'
|
||||
|
||||
const rule = (overrides: Partial<DQRule>): DQRule => ({
|
||||
BASE_COL: 'SOME_COL',
|
||||
RULE_TYPE: 'SOFTFORMULA',
|
||||
RULE_VALUE: '=A_COL + B_COL',
|
||||
X: 0,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('getRevertableCols', () => {
|
||||
it('excludes the delete-flag column', () => {
|
||||
expect(
|
||||
getRevertableCols(
|
||||
[],
|
||||
['_____DELETE__THIS__RECORD_____', 'PRIMARY_KEY_FIELD']
|
||||
)
|
||||
).toEqual(['PRIMARY_KEY_FIELD'])
|
||||
})
|
||||
|
||||
it('excludes the delete-flag column even after the editor renames it to its display label', () => {
|
||||
// editor.component.ts renames headerColumns' delete-flag entry to
|
||||
// 'Delete?' in place (for colHeaders display) before this runs, while
|
||||
// the actual Handsontable column data prop stays the raw name - so
|
||||
// both forms must be excluded, or propToCol('Delete?') returns -1.
|
||||
expect(getRevertableCols([], ['Delete?', 'PRIMARY_KEY_FIELD'])).toEqual([
|
||||
'PRIMARY_KEY_FIELD'
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes the hidden dc.row_status column', () => {
|
||||
expect(
|
||||
getRevertableCols([], ['PRIMARY_KEY_FIELD', 'dc.row_status'])
|
||||
).toEqual(['PRIMARY_KEY_FIELD'])
|
||||
})
|
||||
|
||||
it('excludes a DC.*-referencing formula column', () => {
|
||||
expect(
|
||||
getRevertableCols(
|
||||
[
|
||||
rule({
|
||||
BASE_COL: 'CHANGE_SUMMARY_COL',
|
||||
RULE_VALUE: '=DC.ROW_STATUS'
|
||||
})
|
||||
],
|
||||
['PRIMARY_KEY_FIELD', 'CHANGE_SUMMARY_COL']
|
||||
)
|
||||
).toEqual(['PRIMARY_KEY_FIELD'])
|
||||
})
|
||||
|
||||
it('includes a plain column-arithmetic formula column', () => {
|
||||
expect(
|
||||
getRevertableCols(
|
||||
[
|
||||
rule({
|
||||
BASE_COL: 'FORMULA_SOFT_COL',
|
||||
RULE_TYPE: 'SOFTFORMULA',
|
||||
RULE_VALUE: '=A_COL + B_COL'
|
||||
})
|
||||
],
|
||||
['PRIMARY_KEY_FIELD', 'FORMULA_SOFT_COL']
|
||||
)
|
||||
).toEqual(['PRIMARY_KEY_FIELD', 'FORMULA_SOFT_COL'])
|
||||
})
|
||||
|
||||
it('includes an ordinary non-formula column', () => {
|
||||
expect(getRevertableCols([], ['PRIMARY_KEY_FIELD', 'SOME_CHAR'])).toEqual([
|
||||
'PRIMARY_KEY_FIELD',
|
||||
'SOME_CHAR'
|
||||
])
|
||||
})
|
||||
|
||||
it('includes a column governed only by an unrelated rule type (e.g. HARDSELECT)', () => {
|
||||
expect(
|
||||
getRevertableCols(
|
||||
[rule({ BASE_COL: 'SOME_HARDSELECT', RULE_TYPE: 'HARDSELECT' })],
|
||||
['PRIMARY_KEY_FIELD', 'SOME_HARDSELECT']
|
||||
)
|
||||
).toEqual(['PRIMARY_KEY_FIELD', 'SOME_HARDSELECT'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { DELETE_RECORD_COLUMN_RULE } from './deleteRecordColumnRule'
|
||||
import { EDIT_STATUS_COLUMN_NAME } from './editStatusColumnRule'
|
||||
import { getStableFormulaBaseCols } from './getStableFormulaBaseCols'
|
||||
|
||||
/**
|
||||
* Display label editor.component.ts renames the delete-flag column's
|
||||
* headerColumns entry to (see initSetup) - the actual Handsontable column
|
||||
* data prop stays DELETE_RECORD_COLUMN_RULE.data, so by the time
|
||||
* headerColumns reaches this function the raw name is already gone from
|
||||
* it and only this label is left to match against.
|
||||
*/
|
||||
const DELETE_RECORD_COLUMN_LABEL = 'Delete?'
|
||||
|
||||
/**
|
||||
* Every column eligible to be checked/marked as "overwritten" (see
|
||||
* findOverwrittenCells) - all of headerColumns except the delete-flag
|
||||
* column, the hidden EDIT_STATUS column, and any HARDFORMULA/SOFTFORMULA
|
||||
* column excluded by getStableFormulaBaseCols (DC.USER_NAME/DC.ORIG_VALUE/
|
||||
* DC.ROW_STATUS-referencing formulas are inherently session-dependent, so
|
||||
* comparing their live result against the raw SAS value is never a
|
||||
* meaningful "was this overwritten" signal).
|
||||
*/
|
||||
export const getRevertableCols = (
|
||||
dqRules: DQRule[],
|
||||
headerColumns: string[]
|
||||
): string[] => {
|
||||
const stableFormulaBaseCols = new Set(getStableFormulaBaseCols(dqRules))
|
||||
const unstableFormulaBaseCols = new Set(
|
||||
dqRules
|
||||
.filter(
|
||||
(rule) =>
|
||||
(rule.RULE_TYPE === 'HARDFORMULA' ||
|
||||
rule.RULE_TYPE === 'SOFTFORMULA') &&
|
||||
!stableFormulaBaseCols.has(rule.BASE_COL)
|
||||
)
|
||||
.map((rule) => rule.BASE_COL)
|
||||
)
|
||||
|
||||
return headerColumns.filter(
|
||||
(col) =>
|
||||
col !== DELETE_RECORD_COLUMN_RULE.data &&
|
||||
col !== DELETE_RECORD_COLUMN_LABEL &&
|
||||
col !== EDIT_STATUS_COLUMN_NAME &&
|
||||
!unstableFormulaBaseCols.has(col)
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user