Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb97f4bfb | ||
|
|
bb808617f4 | ||
|
|
4a8c39b4c0 | ||
|
|
42e02cdb05 | ||
|
|
347923900f | ||
|
|
a4c3989c26 | ||
|
|
0392a81cbd | ||
|
|
f9ea53cf78 | ||
|
|
8fb58eb36e | ||
|
|
180c2477ed | ||
|
|
69cfccd565 | ||
|
|
f171375899 | ||
|
|
57db1179a9 | ||
|
|
d13fab267f | ||
|
|
44bc7f7fea | ||
|
|
f60bcef583 | ||
|
|
e7abb0a08a | ||
|
|
cfb60e5e4b | ||
|
|
33dcb989d3 | ||
|
|
aaf406b386 | ||
|
|
8efea8c744 | ||
|
|
39c8855f37 | ||
|
|
5c56c7579f | ||
|
|
92c1e20126 | ||
|
|
7050808d80 | ||
|
|
37a98e1d64 | ||
|
|
66f7b87b07 | ||
|
|
fae9496bbc | ||
|
|
cfe1e75be4 | ||
|
|
b51c770782 | ||
|
|
2a771bb91a | ||
|
|
54b8c78f86 | ||
|
|
ea74f95144 | ||
|
|
bd798b424a | ||
|
|
ee3c6c9e0f | ||
|
|
a3e46a968e | ||
|
|
ee4e9b9271 | ||
|
|
7a35cf4a45 | ||
|
|
d881290618 | ||
|
|
cbea04c8e1 | ||
|
|
994e7fc973 | ||
|
|
578c403994 | ||
|
|
56b7854db5 | ||
|
|
7ed3730ae3 | ||
|
|
6cd9b68581 | ||
|
|
7378f3ba30 | ||
|
|
cac9244f92 | ||
|
|
ea00c5afad | ||
|
|
359d833406 | ||
|
|
022981390e | ||
|
|
05fe4744d5 | ||
|
|
e22edf7ed3 | ||
|
|
62ff0aee4a | ||
|
|
3ed7cfdbee | ||
|
|
9176296816 | ||
|
|
bac7b0a600 | ||
|
|
d2c93a46fa | ||
|
|
17e4802895 | ||
|
|
600937a471 | ||
|
|
f100a16c8b | ||
|
|
9e54504474 | ||
|
|
0285bc90fd | ||
|
|
be94c68f35 | ||
|
|
f73f5e47ac | ||
|
|
c5f0e4f106 | ||
|
|
8e4f045eb9 | ||
|
|
08947f0fc8 | ||
|
|
efcccd7d11 | ||
|
|
078da3d947 | ||
|
|
61012f80e6 | ||
|
|
65bcf08266 | ||
|
|
ad7efc90bc | ||
|
|
70c5805743 | ||
|
|
dfe80bb42a | ||
|
|
af27933e46 | ||
|
|
6a95067618 | ||
|
|
5391df8f04 | ||
|
|
6b16f9b364 | ||
|
|
2dcae9060f | ||
|
|
f4525cc62a | ||
|
|
d4aa70ddbf | ||
|
|
4c2aca3495 | ||
|
|
36c9ef0e95 | ||
|
|
7511b64523 | ||
|
|
7b4b4ebeeb | ||
|
|
c7ba025d39 | ||
|
|
b5e9b25319 | ||
|
|
82a254d22c | ||
|
|
0d7fd34297 | ||
|
|
1beb3d490d | ||
|
|
1fb5e8eb37 | ||
|
|
78dd29ba78 | ||
|
|
ba7b61082d | ||
|
|
1a9231809e | ||
|
|
df5c975869 | ||
|
|
2728dac873 | ||
|
|
88f55b9d06 | ||
|
|
301675052f | ||
|
|
45c434271d | ||
|
|
f523c798c3 | ||
|
|
41fd618884 | ||
|
|
f368242cb0 | ||
|
|
636e4e5b4a | ||
|
|
49710eb62d | ||
|
|
25c12f2b18 | ||
|
|
3ba8858260 | ||
|
|
81014001ac | ||
|
|
691d6f277e | ||
|
|
e7453a9305 | ||
|
|
005b616adb | ||
|
|
0ae1781d28 | ||
|
|
fb94840016 |
@@ -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,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`.
|
||||
@@ -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"
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts"
|
||||
|
||||
- name: Zip Cypress videos
|
||||
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"
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
|
||||
|
||||
- name: Zip Cypress videos
|
||||
if: always()
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"Handsontable",
|
||||
"Licence",
|
||||
"SYSERRORTEXT",
|
||||
"SYSWARNINGTEXT",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Instructions
|
||||
|
||||
## Git
|
||||
|
||||
Do NOT auto-commit. Never run `git commit` (or `git push`) unless the user explicitly asks. Leave changes in the working tree for the user to review and commit themselves.
|
||||
|
||||
## Markdown files
|
||||
|
||||
Do NOT hard-wrap Markdown files (no fixed-width line wrapping / carriage returns inside paragraphs). Each paragraph, list item, and heading should be a single logical line, regardless of length. Let the editor/viewer soft-wrap. This includes AGENTS.md itself.
|
||||
|
||||
This is different from `.sas` files, where a maximum line length applies. The no-wrap rule applies to all `*.md` files in this repo (docs, READMEs, etc.).
|
||||
|
||||
Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and reflowed, and Markdown renderers already handle wrapping.
|
||||
|
||||
## Linting (required before "done")
|
||||
|
||||
Never consider a change complete until the relevant linters pass on the files you touched — do not rely on the user's pre-commit hooks to catch it:
|
||||
|
||||
- **Client (TypeScript/HTML/etc.)**: run `npm run lint:check` from the `client/` directory (prettier). Fix any failures with `npm run lint:fix`.
|
||||
- **SAS**: after creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored).
|
||||
|
||||
## No external assets
|
||||
|
||||
Data Controller must run entirely locally (offline / on-prem, no internet access). The built product must never fetch assets from remote servers — no external fonts, images, scripts, stylesheets, CDN links, or remote URLs in meta tags (e.g. `og:image`, `og:url`, `itemprop="image"`). All assets must be bundled and served locally.
|
||||
|
||||
## The .agent folder
|
||||
|
||||
Agent-related content lives in `.agent/`: technical/agent-facing documentation goes in `.agent/docs/` (not `docs/`), and skills in `.agent/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agent/docs/`.
|
||||
|
||||
## Code comments and test names
|
||||
|
||||
Never reference items that are not active parts of the repository — no "the original bug", "regression from this fix", "this session/PR/commit", or similar ephemeral context. Comments and test names must be self-contained: describe the behaviour being asserted, not the history of how it was discovered. The one exception is a literal link to a ticket/issue tracker.
|
||||
@@ -1,3 +1,92 @@
|
||||
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* adding REGEX validations to mpe_x_test ([bd798b4](https://git.datacontroller.io/dc/dc/commit/bd798b424a7c4b90d613425348d3f99eeb49b025))
|
||||
* default value for label ([a3e46a9](https://git.datacontroller.io/dc/dc/commit/a3e46a968ef08295347b230235f1e79dfa51c5a2))
|
||||
* **deps:** retarget Angular upgrade to 20, not 21 (CI install was broken) ([cac9244](https://git.datacontroller.io/dc/dc/commit/cac9244f92544f432078125c312d05c36ed4b855))
|
||||
* ensure only one REGEX applies at a time ([8fb58eb](https://git.datacontroller.io/dc/dc/commit/8fb58eb36e05fdf7262459b05e53643b3ca672f7))
|
||||
* ensure that no assets (including og links) ever fetch from external sources ([33dcb98](https://git.datacontroller.io/dc/dc/commit/33dcb989d3ae0c5ee0a13fe64195862a945c4348))
|
||||
* include peer dependencies in package-lock for npm ci in pipeline ([b51c770](https://git.datacontroller.io/dc/dc/commit/b51c770782a8b62816e5145496dbea9fb83059e0))
|
||||
* licensecheker ([f60bcef](https://git.datacontroller.io/dc/dc/commit/f60bcef58381ba269415640fa19a27305274939a))
|
||||
* **lint:** remove redundant optional chaining ([d881290](https://git.datacontroller.io/dc/dc/commit/d881290618f25ddb6335125f1a08b5aaf56a8c48))
|
||||
* optimisation, renamed values for DDTYPE to save space ([cfb60e5](https://git.datacontroller.io/dc/dc/commit/cfb60e5e4bfd37dc79fa4e9a6c6649ab1a61f2e4))
|
||||
* patch npm audit vulnerabilities in sas and client dependencies ([e22edf7](https://git.datacontroller.io/dc/dc/commit/e22edf7ed3aa54fc08411cf90d51df9ec30877c3))
|
||||
* **query:** isolate viewbox filter state from the base table's ([7a35cf4](https://git.datacontroller.io/dc/dc/commit/7a35cf4a458791b26ec8141c0c84d97fcb555145))
|
||||
* regenerate client lockfile to resolve Angular peer-dependency drift breaking npm ci ([05fe474](https://git.datacontroller.io/dc/dc/commit/05fe4744d58e7985ecc5ef5f9c1969dbab2d2efb))
|
||||
* **regex:** special missing handling ([180c247](https://git.datacontroller.io/dc/dc/commit/180c2477ed9ae44b4e479e81ec642f40580bcbc8))
|
||||
* removing low severity warning in npm audit ([2a771bb](https://git.datacontroller.io/dc/dc/commit/2a771bb91acaaa16aba44e2d7ddb51b8b7123c10))
|
||||
* removing thousand seperator from plain numerics in EDIT mode ([0392a81](https://git.datacontroller.io/dc/dc/commit/0392a81cbd9a634590ca7a60a316cd12c690a5a5))
|
||||
* **validations:** parse SAS PRX /pattern/flags syntax in HARDREGEX/SOFTREGEX ([7ed3730](https://git.datacontroller.io/dc/dc/commit/7ed3730ae3145dffd639ce3d2f831447d78f4e85))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **docs:** adding agents.md and docs for RLS ([359d833](https://git.datacontroller.io/dc/dc/commit/359d833406ace79f96d03c08a5c72fbd3df29442))
|
||||
* **editor:** add HARDREGEX/SOFTREGEX validation rules ([17e4802](https://git.datacontroller.io/dc/dc/commit/17e48028955d7b091e53bad9d7f9e3a089b53af9))
|
||||
* **editor:** evaluate HARDREGEX/SOFTREGEX independently instead of hard-wins precedence ([57db117](https://git.datacontroller.io/dc/dc/commit/57db1179a97973ecf0f711bda6057383de546dd3))
|
||||
* **editor:** show applied HARDREGEX/SOFTREGEX pattern in column info dropdown ([39c8855](https://git.datacontroller.io/dc/dc/commit/39c8855f37477f06d8b8cf5c97e04543070e8385))
|
||||
* **regex:** backend validations on regex strings ([d2c93a4](https://git.datacontroller.io/dc/dc/commit/d2c93a46facb386fd31661c02c0d3d34f38f6e43))
|
||||
* using ALL libraries as validation in MPE_SECURITY. Closes [#279](https://git.datacontroller.io/dc/dc/issues/279) ([62ff0ae](https://git.datacontroller.io/dc/dc/commit/62ff0aee4a976184de65d87ea3c8b8b7cb777938))
|
||||
* validation checks to prevent incompatible RLS rules (eg REPLACE load type). Closes [#211](https://git.datacontroller.io/dc/dc/issues/211) ([ea00c5a](https://git.datacontroller.io/dc/dc/commit/ea00c5afad0daf2a66cdb206f2a7481b992192bf))
|
||||
* validation on RLS for REPLACE, + docs + tests. Closes [#211](https://git.datacontroller.io/dc/dc/issues/211) ([7378f3b](https://git.datacontroller.io/dc/dc/commit/7378f3ba3014141008b4dbde0b89c21a6ce02f69))
|
||||
|
||||
# [7.11.0](https://git.datacontroller.io/dc/dc/compare/v7.10.1...v7.11.0) (2026-07-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* audit-skipping syntax correction ([ad7efc9](https://git.datacontroller.io/dc/dc/commit/ad7efc90bc33c93383e17359d14c0605cded69a9))
|
||||
* **editor:** ignore cell validation on rows marked for delete ([70c5805](https://git.datacontroller.io/dc/dc/commit/70c5805743c8c6e61bc216f37cbe8cd4f0614c98))
|
||||
* explicit use of .sashdat in dc_casload ([6a95067](https://git.datacontroller.io/dc/dc/commit/6a95067618600d7b03b6af46bf3d74721e42c66a))
|
||||
* refactor mf_wordsinstr1butnotstr2 and added a wide table to test suite ([be94c68](https://git.datacontroller.io/dc/dc/commit/be94c68f354d0f9b1eb52213807d1874a74d9b8a))
|
||||
* retry the startup service request once before failing ([5391df8](https://git.datacontroller.io/dc/dc/commit/5391df8f04cf40205a600b23c207f97b6fdf5913))
|
||||
* run dc_casload for dq tables in getdata. Closes [#264](https://git.datacontroller.io/dc/dc/issues/264). ([6b16f9b](https://git.datacontroller.io/dc/dc/commit/6b16f9b3647fca2e39d44544201ce972046e01a3))
|
||||
* updating tests, moving mpe_rowhash to core library ([c5f0e4f](https://git.datacontroller.io/dc/dc/commit/c5f0e4f106aa4e6a7c2a2fd0ebb2e85d1e7bee7b))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* bumping core to get the mp_rowhash definition, [#269](https://git.datacontroller.io/dc/dc/issues/269) ([f73f5e4](https://git.datacontroller.io/dc/dc/commit/f73f5e47ac38a305bac4ebd7eac48d1a3edf4a83))
|
||||
* new hash algorithm - allows 1000+ cols. Closes [#269](https://git.datacontroller.io/dc/dc/issues/269) ([08947f0](https://git.datacontroller.io/dc/dc/commit/08947f0fc8458be098e3aaf769b1da7fadf480cb))
|
||||
|
||||
## [7.10.1](https://git.datacontroller.io/dc/dc/compare/v7.10.0...v7.10.1) (2026-07-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** order validation rules by COLHEADERS, not cols[].VARNUM ([4c2aca3](https://git.datacontroller.io/dc/dc/commit/4c2aca34951d6d5c5cf2df69d5bf4256a9290d53))
|
||||
|
||||
# [7.10.0](https://git.datacontroller.io/dc/dc/compare/v7.9.1...v7.10.0) (2026-07-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* adapt column validation to vertical-array COLTYPE ([#253](https://git.datacontroller.io/dc/dc/issues/253)) ([3016750](https://git.datacontroller.io/dc/dc/commit/301675052fb5cbcb11f54c370ad7eda1d60461fd))
|
||||
* backend updates to bring back labels under [#240](https://git.datacontroller.io/dc/dc/issues/240) ([fb94840](https://git.datacontroller.io/dc/dc/commit/fb94840016f067fa3523a3539dd0616d8970eed0))
|
||||
* **editor:** normalise unpadded time strings on spreadsheet import ([7b4b4eb](https://git.datacontroller.io/dc/dc/commit/7b4b4ebeeb0e34ec5fcd5c485741d7bbddb43b7e))
|
||||
* **hot:** row rendering on resize, resolve blank whitespace ([c7ba025](https://git.datacontroller.io/dc/dc/commit/c7ba025d39aa9681ad51f239503e884fb26613e2))
|
||||
* including datadictionary descs in labels, [#240](https://git.datacontroller.io/dc/dc/issues/240) ([e7453a9](https://git.datacontroller.io/dc/dc/commit/e7453a9305263faf4d4f288d50977121353040c7))
|
||||
* pass variable formats to front-end in a vertical array instead of horizontal list ([691d6f2](https://git.datacontroller.io/dc/dc/commit/691d6f277ea87051f539681b34da2f39cade8fc3))
|
||||
* remove coltype from sasparams table, add it to cols table. ([8101400](https://git.datacontroller.io/dc/dc/commit/81014001ac4da6b8d262c72c65e0c657f186ec84))
|
||||
* trim leading/trailing whitespace from Excel header cells on upload ([2728dac](https://git.datacontroller.io/dc/dc/commit/2728dac873ab8c9fc44413f873e4730515ca0e21))
|
||||
* **va:** date filters refactor, support more dates ([df5c975](https://git.datacontroller.io/dc/dc/commit/df5c9758697d7481e67d20ed7ff81e9ce4e92ec0))
|
||||
* **va:** enable readOnly and hold filtering while editing ([82a254d](https://git.datacontroller.io/dc/dc/commit/82a254d22cb7aaf75aae7b332f0b07fc8bda3e44))
|
||||
* **va:** enable Upload button in va mode ([1beb3d4](https://git.datacontroller.io/dc/dc/commit/1beb3d490d0e7b8d5fa7985f121495ed62740cfe))
|
||||
* **va:** guard deferred contextMenu toggle against destroyed instance ([b5e9b25](https://git.datacontroller.io/dc/dc/commit/b5e9b2531924bec1a23c67d3ad0ca3f512a75655))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* adding DC_MAXOBS_WEBVIEW config item, closes [#258](https://git.datacontroller.io/dc/dc/issues/258) ([ba7b610](https://git.datacontroller.io/dc/dc/commit/ba7b61082d39767f9539a84de6742a1704573e81))
|
||||
* viewer/editor column label display toggle (?labels=true) ([25c12f2](https://git.datacontroller.io/dc/dc/commit/25c12f2b18adda09e6edc93eba263bd5ea0f7409))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* enable upload button in va mode ([0d7fd34](https://git.datacontroller.io/dc/dc/commit/0d7fd342970744489a2e6c6fb08f239fe853cb81))
|
||||
|
||||
## [7.9.1](https://git.datacontroller.io/dc/dc/compare/v7.9.0...v7.9.1) (2026-06-30)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,21 @@ Add `client/.npmrc` file with following content:
|
||||
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
|
||||
```
|
||||
|
||||
In addition to the registry auth, the `@sheet/crypto` package is installed from a local tarball
|
||||
(`client/libraries/sheet-crypto.tgz`) rather than the registry. This tarball is not committed to
|
||||
the repo directly - only an encrypted copy is: `client/libraries/sheet-crypto.tgz.gpg`.
|
||||
|
||||
Before running `npm i`/`npm ci` in `client`, you must decrypt it using the passphrase
|
||||
(stored as the `SHEET_PWD` CI secret - ask a repo maintainer if you don't have it):
|
||||
```bash
|
||||
cd client
|
||||
echo "YOUR_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
|
||||
--output ./libraries/sheet-crypto.tgz \
|
||||
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||
|
||||
npm i
|
||||
```
|
||||
|
||||
[Handsontable](https://www.npmjs.com/package/handsontable)
|
||||
|
||||
Licence should be inserted in the `client/src/index.html` file:
|
||||
@@ -66,6 +81,15 @@ To remove the release, you need to do it with repo administration over at [https
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## `npm i` fails with ENOENT on `sheet-crypto.tgz`
|
||||
|
||||
If `npm i`/`npm ci` in `client` fails with:
|
||||
```
|
||||
npm error enoent ENOENT: no such file or directory, open '.../client/libraries/sheet-crypto.tgz'
|
||||
```
|
||||
it means the encrypted `sheet-crypto.tgz.gpg` hasn't been decrypted yet. See
|
||||
[Dependencies that requires licences](#dependencies-that-requires-licences) above for how to decrypt it.
|
||||
|
||||
## Makedata service "could not create directory" error
|
||||
|
||||
The dcpath folder should have its permissions set so that the system account (SYSUSERID) can both read and write to it.
|
||||
|
||||
@@ -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": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Marks this file as an ES module (rather than a global script) so its
|
||||
// top-level consts don't collide, under the TS type-checker, with the same
|
||||
// names declared in other spec files — see viewer-labels.cy.ts for the same
|
||||
// pattern.
|
||||
export {}
|
||||
|
||||
const username = Cypress.env('username')
|
||||
const password = Cypress.env('password')
|
||||
const hostUrl = Cypress.env('hosturl')
|
||||
const appLocation = Cypress.env('appLocation')
|
||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||
const serverType = Cypress.env('serverType')
|
||||
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
||||
|
||||
// Regression coverage for issue #253's vertical-array COLTYPE fix — see
|
||||
// getdata.sas now sends one COLTYPE JSON-object string per cols[] row (via
|
||||
// a LEFT JOIN keyed off the real dataset's columns), instead of one
|
||||
// comma-joined sasparams.COLTYPE string covering every column. Two things
|
||||
// are specifically at risk from that change and aren't covered elsewhere:
|
||||
// 1. `_____DELETE__THIS__RECORD_____` (the delete checkbox column) is a
|
||||
// client-side-only concept — %mp_getcols never has a row for it, so
|
||||
// it can no longer travel via cols[].COLTYPE at all. Its Yes/No
|
||||
// dropdown rule is now hardcoded client-side (deleteRecordColumnRule.ts)
|
||||
// — test 1 proves that hardcode actually renders end to end.
|
||||
// 2. Ordinary columns (e.g. SOME_DROPDOWN) still get their rule via the
|
||||
// new per-row cols[].COLTYPE path — test 2 proves the migration didn't
|
||||
// silently drop real columns' rules either.
|
||||
// Column positions below come from COLHEADERS in
|
||||
// sas/mocks/sasjs/services/editors/getdata.js:
|
||||
// "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,..."
|
||||
// — childNodes[0] on a body <tr> is the row-header <th>, so childNodes[N+1]
|
||||
// is the Nth (0-indexed) column in that list.
|
||||
context('coltype vertical-array regression tests: ', function () {
|
||||
this.beforeAll(() => {
|
||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||
cy.loginAndUpdateValidKey(true)
|
||||
})
|
||||
|
||||
this.beforeEach(() => {
|
||||
cy.visit(hostUrl + appLocation)
|
||||
visitPage('home')
|
||||
})
|
||||
|
||||
it('1 | delete-record column renders its hardcoded Yes/No dropdown', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
clickOnEdit()
|
||||
|
||||
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).then(
|
||||
(rows: any) => {
|
||||
// childNodes[1] = _____DELETE__THIS__RECORD_____ (1st column)
|
||||
cy.get(rows[1].childNodes[1])
|
||||
.click({ force: true })
|
||||
.then(($td) => {
|
||||
cy.get('.htAutocompleteArrow', { withinSubject: $td })
|
||||
.should('exist')
|
||||
.click({ force: true })
|
||||
|
||||
cy.get('.autocompleteEditor .htCore tbody td').should(
|
||||
($choices) => {
|
||||
const texts = [...$choices].map((el) => el.innerText.trim())
|
||||
expect(texts).to.deep.equal(['No', 'Yes'])
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('2 | an ordinary dropdown column (SOME_DROPDOWN) still gets its rule from cols[].COLTYPE', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
clickOnEdit()
|
||||
|
||||
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).then(
|
||||
(rows: any) => {
|
||||
// childNodes[4] = SOME_DROPDOWN (4th column, 0-indexed as 3). This
|
||||
// only holds if DcValidator emits rules in VARNUM order — see the
|
||||
// "orders rules by VARNUM" regression test in dc-validator.spec.ts.
|
||||
cy.get(rows[1].childNodes[4])
|
||||
.click({ force: true })
|
||||
.then(($td) => {
|
||||
cy.get('.htAutocompleteArrow', { withinSubject: $td }).should(
|
||||
'exist'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const clickOnEdit = (callback?: any) => {
|
||||
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
||||
.click()
|
||||
.then(() => {
|
||||
if (callback) callback()
|
||||
})
|
||||
}
|
||||
|
||||
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
||||
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then((treeNodes: any) => {
|
||||
let viyaLib
|
||||
|
||||
for (let node of treeNodes) {
|
||||
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
|
||||
viyaLib = node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cy.get(viyaLib).within(() => {
|
||||
cy.get('.clr-tree-node-content-container > button').click()
|
||||
|
||||
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
||||
for (let innerNode of innerNodes) {
|
||||
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
||||
innerNode.click()
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const visitPage = (url: string) => {
|
||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||
}
|
||||
@@ -115,8 +115,791 @@ context('editor tests: ', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// An invalid cell shouldn't block submission if its row
|
||||
// is marked for delete
|
||||
it('5 | Submits a delete-marked row with an invalid cell', (done) => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
cy.get('.ht_master tbody tr').then((rows: any) => {
|
||||
// childNodes[5] = SOME_NUM (numeric) — feed it a non-numeric value
|
||||
// to trigger HOT's native invalid mark.
|
||||
cy.get(rows[1].childNodes[5])
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused()
|
||||
.clear()
|
||||
.type('not a number{enter}')
|
||||
.then(() => {
|
||||
// childNodes[1] = _____DELETE__THIS__RECORD_____
|
||||
setDeleteFlag(rows[1].childNodes[1], 'Yes', () => {
|
||||
submitTable(() => {
|
||||
// Validation passed despite the invalid SOME_NUM cell:
|
||||
// the confirm-submit modal's Submit button is enabled
|
||||
// (validationDone === 1), not the "invalid values" abort.
|
||||
cy.get('#submitBtn', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.should('exist')
|
||||
.should('not.be.disabled')
|
||||
.then(() => done())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// The delete exemption must not affect the uniqueness (primary key) check.
|
||||
it('6 | Still blocks a duplicate primary key even on a delete-marked row', (done) => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
cy.get('.ht_master tbody tr').then((rows: any) => {
|
||||
cy.get(rows[0].childNodes[2]).then((firstPk) => {
|
||||
const duplicateValue = firstPk.text()
|
||||
|
||||
// childNodes[2] = PRIMARY_KEY_FIELD
|
||||
cy.get(rows[1].childNodes[2])
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused()
|
||||
.clear()
|
||||
.type(`${duplicateValue}{enter}`)
|
||||
.then(() => {
|
||||
setDeleteFlag(rows[1].childNodes[1], 'Yes', () => {
|
||||
submitTable()
|
||||
|
||||
cy.get('.duplicate-keys-modal', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.should('exist')
|
||||
.then(() => done())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// REGEX_HARD_COL/REGEX_SOFT_COL only exist un-stripped on MPE_X_NEW (see
|
||||
// RULE_DEMO_COLS in the getdata.js mock) — MPE_X_TEST stays clean to
|
||||
// match the excel fixtures.
|
||||
it('7 | Blocks submission of a value that fails HARDREGEX', (done) => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
scrollGridRight()
|
||||
|
||||
getCellByHeaderAndRow(1, 'REGEX_HARD_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused()
|
||||
.clear()
|
||||
.type('not-an-email{enter}')
|
||||
.then(() => {
|
||||
getCellByHeaderAndRow(1, 'REGEX_HARD_COL').should(
|
||||
'have.attr',
|
||||
'title',
|
||||
'REGEX: /[\\w.]+@[\\w]+\\.[a-z]{2,}/'
|
||||
)
|
||||
|
||||
submitTable(() => {
|
||||
cy.get('.modal-body').then((modalBody: any) => {
|
||||
if (
|
||||
modalBody[0].innerHTML
|
||||
.toLowerCase()
|
||||
.includes(`invalid values are present`)
|
||||
) {
|
||||
done()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('8 | Warns (but still submits) a value that fails SOFTREGEX', (done) => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
scrollGridRight()
|
||||
|
||||
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused()
|
||||
.clear()
|
||||
.type('not a postcode{enter}')
|
||||
.then(() => {
|
||||
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
|
||||
.should('have.class', 'dc-warning-cell')
|
||||
.and(
|
||||
'have.attr',
|
||||
'title',
|
||||
'REGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
|
||||
)
|
||||
|
||||
submitTable(() => {
|
||||
// Validation passed despite the SOFTREGEX warning: the
|
||||
// confirm-submit modal's Submit button is enabled
|
||||
// (validationDone === 1), not the "invalid values" abort.
|
||||
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
||||
.should('exist')
|
||||
.should('not.be.disabled')
|
||||
.then(() => done())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// MPE_X_FORMULA_TEST is a small, dedicated fixture for formula testing
|
||||
// (kept separate from MPE_X_NEW, which carries no formula columns at
|
||||
// all) - row index 1 (0-indexed, i=1 in the mock's row generator) has
|
||||
// A_COL=2, B_COL=10: HARDFORMULA (A_COL * B_COL) computes to 20,
|
||||
// SOFTFORMULA (A_COL + B_COL) computes to 12.
|
||||
it('9 | HARDFORMULA shows the computed value and blocks direct edits', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '20')
|
||||
|
||||
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
// readOnly cells don't open an editor - nothing is focused to
|
||||
// type into, so this is a no-op if the column is truly readonly.
|
||||
cy.focused().type('9999{enter}')
|
||||
|
||||
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should(
|
||||
'have.text',
|
||||
'20'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('10 | SOFTFORMULA shows the computed default but accepts an override', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '12')
|
||||
|
||||
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused().clear().type('override{enter}')
|
||||
|
||||
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should(
|
||||
'have.text',
|
||||
'override'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('11 | Info dropdown shows the applied formula as √x=<formula>', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
openColumnDropdown('FORMULA_HARD_COL')
|
||||
cy.get('.htDropdownMenu').should(($menu) => {
|
||||
expect($menu.text()).to.include('√x=A_COL * B_COL')
|
||||
})
|
||||
cy.get('body').click(0, 0) // close menu
|
||||
|
||||
openColumnDropdown('FORMULA_SOFT_COL')
|
||||
cy.get('.htDropdownMenu').should(($menu) => {
|
||||
expect($menu.text()).to.include('√x=A_COL + B_COL')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('12 | Info dropdown shows the applied HARDREGEX/SOFTREGEX pattern', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
scrollGridRight()
|
||||
|
||||
openColumnDropdown('REGEX_HARD_COL')
|
||||
cy.get('.htDropdownMenu').should(($menu) => {
|
||||
expect($menu.text()).to.include(
|
||||
'HARDREGEX: /[\\w.]+@[\\w]+\\.[a-z]{2,}/'
|
||||
)
|
||||
})
|
||||
cy.get('body').click(0, 0) // close menu
|
||||
|
||||
openColumnDropdown('REGEX_SOFT_COL')
|
||||
cy.get('.htDropdownMenu').should(($menu) => {
|
||||
expect($menu.text()).to.include(
|
||||
'SOFTREGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('13 | Info dropdown shows only the applied HARDREGEX pattern when a column has both', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
scrollGridRight()
|
||||
|
||||
openColumnDropdown('REGEX_BOTH_COL')
|
||||
cy.get('.htDropdownMenu').should(($menu) => {
|
||||
const text = $menu.text()
|
||||
|
||||
expect(text).to.include('HARDREGEX: /^[A-Z0-9_-]+$/')
|
||||
expect(text).to.not.include('SOFTREGEX: /^.{5,10}$/')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('14 | REGEX_BOTH_COL: a HARDREGEX failure blocks submission and sets its own tooltip, not yellow', (done) => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
scrollGridRight()
|
||||
|
||||
// 'bad value' fails HARDREGEX (lowercase + space) but is 9 chars,
|
||||
// within SOFTREGEX's 5-10 range - isolates the hard-only failure.
|
||||
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused()
|
||||
.clear()
|
||||
.type('bad value{enter}')
|
||||
.then(() => {
|
||||
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
|
||||
.should('not.have.class', 'dc-warning-cell')
|
||||
.and('have.attr', 'title', 'REGEX: /^[A-Z0-9_-]+$/')
|
||||
|
||||
submitTable(() => {
|
||||
cy.get('.modal-body').then((modalBody: any) => {
|
||||
if (
|
||||
modalBody[0].innerHTML
|
||||
.toLowerCase()
|
||||
.includes(`invalid values are present`)
|
||||
) {
|
||||
done()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('15 | REGEX_BOTH_COL: SOFTREGEX is ignored entirely when HARDREGEX is present', (done) => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
scrollGridRight()
|
||||
|
||||
// 'AB' passes HARDREGEX (uppercase only) but fails SOFTREGEX (too
|
||||
// short) - only one regex runs per column, so no warning is shown.
|
||||
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused()
|
||||
.clear()
|
||||
.type('AB{enter}')
|
||||
.then(() => {
|
||||
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
|
||||
.should('not.have.class', 'dc-warning-cell')
|
||||
.and('not.have.attr', 'title')
|
||||
|
||||
submitTable(() => {
|
||||
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
||||
.should('exist')
|
||||
.should('not.be.disabled')
|
||||
.then(() => done())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("16 | Insert Row above keeps existing rows' formulas aligned with their own shifted data", () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
// Row index 2 (0-indexed): PRIMARY_KEY_FIELD=3, A_COL=3, B_COL=10 ->
|
||||
// HARDFORMULA (A_COL*B_COL) = 30, SOFTFORMULA (A_COL+B_COL) = 13.
|
||||
getCellByHeaderAndRow(2, 'PRIMARY_KEY_FIELD').should('have.text', '3')
|
||||
|
||||
insertRowViaContextMenu(2, 'Insert Row above')
|
||||
|
||||
// The new blank row lands at index 2 - its formula columns are
|
||||
// seeded with their own row-relative formula (A_COL/B_COL are both
|
||||
// still empty, so HyperFormula evaluates the arithmetic as 0).
|
||||
getCellByHeaderAndRow(2, 'FORMULA_HARD_COL').should('have.text', '0')
|
||||
getCellByHeaderAndRow(2, 'FORMULA_SOFT_COL').should('have.text', '0')
|
||||
|
||||
// The row that WAS at index 2 (PK=3) is now shifted down to index
|
||||
// 3 - its formulas must recompute from ITS OWN data, not read
|
||||
// whatever the engine had cached at index 3 before the insert
|
||||
// (that would be PK=4's values: 40/14).
|
||||
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').should('have.text', '3')
|
||||
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').should('have.text', '30')
|
||||
getCellByHeaderAndRow(3, 'FORMULA_SOFT_COL').should('have.text', '13')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("17 | Insert Row below keeps existing rows' formulas aligned with their own shifted data", () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
// Row index 3 (0-indexed): PRIMARY_KEY_FIELD=4, A_COL=4, B_COL=10 ->
|
||||
// HARDFORMULA = 40, SOFTFORMULA = 14.
|
||||
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').should('have.text', '4')
|
||||
|
||||
// 'Insert Row below' on row index 2 (PK=3) inserts the new blank
|
||||
// row at index 3, pushing the old index-3 row (PK=4) to index 4.
|
||||
insertRowViaContextMenu(2, 'Insert Row below')
|
||||
|
||||
// The new blank row lands at index 3 - seeded the same way as
|
||||
// above, evaluating to 0 while A_COL/B_COL are still empty.
|
||||
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').should('have.text', '0')
|
||||
getCellByHeaderAndRow(3, 'FORMULA_SOFT_COL').should('have.text', '0')
|
||||
|
||||
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').should('have.text', '4')
|
||||
getCellByHeaderAndRow(4, 'FORMULA_HARD_COL').should('have.text', '40')
|
||||
getCellByHeaderAndRow(4, 'FORMULA_SOFT_COL').should('have.text', '14')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('18 | Insert Row seeds formulas that live-recalculate once A_COL/B_COL are filled in', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
insertRowViaContextMenu(0, 'Insert Row below')
|
||||
|
||||
// New row at index 1 - both formula columns evaluate the seeded
|
||||
// formula (=C2*D2 / =C2+D2 in spreadsheet notation) against
|
||||
// still-empty A_COL/B_COL, so HyperFormula treats them as 0.
|
||||
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '0')
|
||||
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '0')
|
||||
|
||||
getCellByHeaderAndRow(1, 'A_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused().type('5{enter}')
|
||||
})
|
||||
|
||||
getCellByHeaderAndRow(1, 'B_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused().type('3{enter}')
|
||||
})
|
||||
|
||||
// Both formula columns must recompute live from THIS row's own
|
||||
// A_COL/B_COL - proving the seeded formula is genuinely wired to
|
||||
// HyperFormula, not a one-time static snapshot.
|
||||
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '15')
|
||||
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '8')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('19 | Row header shows blank for unchanged, ~ for a modified row', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getRowHeaderSymbol(0).should('have.text', ' ')
|
||||
|
||||
getCellByHeaderAndRow(0, 'A_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused().clear().type('999{enter}')
|
||||
})
|
||||
|
||||
getRowHeaderSymbol(0).should('have.text', '~')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('20 | Row header shows - for a delete-marked row and + for a newly inserted row', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getCellByHeaderAndRow(2, 'Delete?').then(($cell) => {
|
||||
setDeleteFlag($cell[0], 'Yes', () => {
|
||||
getRowHeaderSymbol(2).should('have.text', '-')
|
||||
|
||||
insertRowViaContextMenu(0, 'Insert Row below')
|
||||
|
||||
// New row lands at index 1, shifting the delete-marked row
|
||||
// (was index 2) down to index 3 - its own status is unaffected
|
||||
// by the shift, only its position.
|
||||
getRowHeaderSymbol(1).should('have.text', '+')
|
||||
getRowHeaderSymbol(3).should('have.text', '-')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('21 | Inserting a row does not falsely mark unrelated rows as modified or revert an existing override', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused().clear().type('999{enter}')
|
||||
})
|
||||
getRowHeaderSymbol(0).should('have.text', '~')
|
||||
|
||||
// Untouched row, well away from the insert point below.
|
||||
getRowHeaderSymbol(5).should('have.text', ' ')
|
||||
|
||||
insertRowViaContextMenu(2, 'Insert Row below')
|
||||
|
||||
// The override and its '~' mark must survive the insert...
|
||||
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL').should('have.text', '999')
|
||||
getRowHeaderSymbol(0).should('have.text', '~')
|
||||
|
||||
// ...and a row that was never touched must stay unmarked, even
|
||||
// though its position shifted (row 5 is now row 6).
|
||||
getRowHeaderSymbol(6).should('have.text', ' ')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("22 | DC.ROW_STATUS resolves to a live cell reference, reacting to this row's own edit status", () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
// _____EDIT_STATUS_____ itself is a purely client-synthesized
|
||||
// column (see editStatusColumnRule.ts) - it must never render as a
|
||||
// visible header, only exist as something DC.ROW_STATUS can
|
||||
// point a cell reference at.
|
||||
cy.get('.ht_clone_top .htCore thead tr th').should(($ths) => {
|
||||
const texts = [...$ths].map((th) => th.innerText.trim())
|
||||
expect(texts).not.to.include('_____EDIT_STATUS_____')
|
||||
})
|
||||
|
||||
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'U')
|
||||
|
||||
getCellByHeaderAndRow(0, 'A_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused().clear().type('999{enter}')
|
||||
})
|
||||
|
||||
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'M')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('23 | DC.USER_NAME resolves to the current logged-in user', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getCellByHeaderAndRow(0, 'USER_NAME_COL').should('have.text', 'sasdemo')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("24 | DC.ORIG_VALUE echoes this row's pre-edit value, and is blank for a newly-inserted row", () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getCellByHeaderAndRow(0, 'ORIG_VALUE_COL').should('have.text', 'orig-1')
|
||||
|
||||
insertRowViaContextMenu(0, 'Insert Row below')
|
||||
|
||||
// No dataSourceUnchanged match exists for a brand-new row, so
|
||||
// DC.ORIG_VALUE falls back to blank rather than echoing anything.
|
||||
getCellByHeaderAndRow(1, 'ORIG_VALUE_COL').should('have.text', '')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('25 | Row header symbols stay aligned with their own row after sorting by another column', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
// Mark row 2 (PRIMARY_KEY_FIELD=3) for delete before sorting, so
|
||||
// there's a distinctive symbol ('-') to track across the reorder.
|
||||
getCellByHeaderAndRow(2, 'Delete?').then(($cell) => {
|
||||
setDeleteFlag($cell[0], 'Yes', () => {
|
||||
getRowHeaderSymbol(2).should('have.text', '-')
|
||||
|
||||
sortByColumn('FORMULA_SOFT_COL')
|
||||
|
||||
// Wherever PRIMARY_KEY_FIELD=3 lands visually after sorting,
|
||||
// its OWN row header must show '-' - not whatever symbol
|
||||
// previously belonged to that visual position.
|
||||
cy.get('.ht_clone_top .htCore thead tr th')
|
||||
.should(($ths) => {
|
||||
const texts = [...$ths].map((th) => th.innerText.trim())
|
||||
expect(texts).to.include('PRIMARY_KEY_FIELD')
|
||||
})
|
||||
.then(($ths) => {
|
||||
const pkColIndex = [...$ths].findIndex(
|
||||
(th) => th.innerText.trim() === 'PRIMARY_KEY_FIELD'
|
||||
)
|
||||
|
||||
cy.get('.ht_master tbody tr').then((rows: any) => {
|
||||
const sortedRowIndex = [...rows].findIndex(
|
||||
(row: any) =>
|
||||
row.childNodes[pkColIndex].innerText.trim() === '3'
|
||||
)
|
||||
|
||||
getRowHeaderSymbol(sortedRowIndex).should('have.text', '-')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// CHANGE_SUMMARY_COL combines all three DC.* variables in one formula:
|
||||
// IF( DC.ROW_STATUS ="U","unedited", DC.USER_NAME &" changed from "& DC.ORIG_VALUE ).
|
||||
// DC.USER_NAME/DC.ORIG_VALUE are frozen literals baked in at load time,
|
||||
// while DC.ROW_STATUS is a live cell reference - so editing the row
|
||||
// doesn't recompute the "changed from" text, it just flips which
|
||||
// already-computed branch IF() reveals.
|
||||
it("26 | Combining DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE in one formula reveals the frozen 'changed from' text once the row is edited", () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
|
||||
'have.text',
|
||||
'unedited'
|
||||
)
|
||||
|
||||
getCellByHeaderAndRow(0, 'A_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused().clear().type('999{enter}')
|
||||
})
|
||||
|
||||
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
|
||||
'have.text',
|
||||
'sasdemo changed from orig-1'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
|
||||
// the ones in the current viewport actually exist in the DOM. Scroll all
|
||||
// the way right so REGEX_HARD_COL/REGEX_SOFT_COL/REGEX_BOTH_COL (the last
|
||||
// three) render at all. Same technique already used in excel.cy.ts. Must
|
||||
// be called (and re-settle) before any header/body query below, since
|
||||
// scrolling replaces the previously-rendered column nodes.
|
||||
const scrollGridRight = () => {
|
||||
return cy
|
||||
.get('#hotTable')
|
||||
.find('div.ht_master.handsontable')
|
||||
.find('div.wtHolder')
|
||||
.scrollTo('right')
|
||||
}
|
||||
|
||||
// Clicks a column header's sort indicator (Handsontable's multiColumnSorting
|
||||
// plugin decorates every sortable header with a `.columnSorting` element -
|
||||
// HEADER_SORT_CLASS in Handsontable's own source) to sort ascending by that
|
||||
// column. force: true for the same reason as openColumnDropdown - an
|
||||
// overlay clone layer can sit over the real target.
|
||||
const sortByColumn = (headerText: string) => {
|
||||
cy.get('.ht_clone_top .htCore thead tr th')
|
||||
.filter((_, th) => Cypress.$(th).text().includes(headerText))
|
||||
.last()
|
||||
.find('.columnSorting')
|
||||
.click({ force: true })
|
||||
}
|
||||
|
||||
// Opens a column header's dropdown menu to reach its `info` item, which
|
||||
// has a custom renderer showing NAME/LABEL/TYPE/LENGTH/FORMAT and, when the
|
||||
// column has a HARDREGEX/SOFTREGEX rule, the applied pattern. Clicking the
|
||||
// header text first selects the column - the renderer reads
|
||||
// hot.getSelected() to decide which column to describe.
|
||||
const openColumnDropdown = (headerText: string) => {
|
||||
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.parents('th')
|
||||
.filter((_, th) => Cypress.$(th).text().includes(headerText))
|
||||
.last()
|
||||
.as('targetHeader')
|
||||
|
||||
cy.get('@targetHeader').click()
|
||||
cy.get('@targetHeader').find('button.changeType').click({ force: true })
|
||||
}
|
||||
|
||||
// Locates a body cell by its column's header text rather than a hardcoded
|
||||
// childNodes index. Handsontable's hiddenColumns plugin (e.g. HIDDEN_COL,
|
||||
// used by several demo columns ahead of REGEX_HARD_COL/REGEX_SOFT_COL in
|
||||
// COLHEADERS) removes a hidden column's cells from BOTH the header row and
|
||||
// every body row identically, so the header row's th index always lines up
|
||||
// with the same-position childNode in any body row — this sidesteps having
|
||||
// to know how many hidden columns precede the target. Re-queries both the
|
||||
// header and body row fresh each call (rather than accepting a pre-fetched
|
||||
// row) since virtualization/scrolling can replace either pane's nodes.
|
||||
//
|
||||
// The `.should()` retries until headerText actually shows up: Handsontable
|
||||
// re-renders virtualized columns asynchronously after a scroll event, on
|
||||
// its own schedule outside Cypress's command queue, so scrollTo() settling
|
||||
// does not mean the target column has been rendered yet.
|
||||
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
|
||||
return cy
|
||||
.get('.ht_clone_top .htCore thead tr th')
|
||||
.should(($ths) => {
|
||||
const texts = [...$ths].map((th) => th.innerText.trim())
|
||||
expect(texts).to.include(headerText)
|
||||
})
|
||||
.then(($ths) => {
|
||||
const index = [...$ths].findIndex(
|
||||
(th) => th.innerText.trim() === headerText
|
||||
)
|
||||
|
||||
return cy
|
||||
.get('.ht_master tbody tr')
|
||||
.then((rows: any) => rows[rowIndex].childNodes[index])
|
||||
.then((cell) => cy.get(cell))
|
||||
})
|
||||
}
|
||||
|
||||
// Reads the row-header gutter's text for the given row - this is the
|
||||
// left-hand "select entire row" bar (rowHeaders callback in
|
||||
// editor.component.ts), doubling as the edit-status indicator
|
||||
// (+/-/~/blank). .ht_clone_left mirrors .ht_clone_top's frozen-clone role,
|
||||
// just for the row axis instead of the column axis.
|
||||
const getRowHeaderSymbol = (rowIndex: number) => {
|
||||
return cy
|
||||
.get('.ht_clone_left .htCore tbody tr')
|
||||
.eq(rowIndex)
|
||||
.find('th .rowHeader')
|
||||
}
|
||||
|
||||
// Right-clicks the given row's PRIMARY_KEY_FIELD cell to open the row's
|
||||
// context menu, then clicks the given item. 'Insert Row above'/'below' are
|
||||
// hidden when hotTable.readOnly is true, so this only works after
|
||||
// clickOnEdit(). force: true - same reasoning as toggleColumnLabelsFromContextMenu
|
||||
// in viewer-labels.cy.ts, the right-clicked cell can sit under an overlay
|
||||
// clone layer that fails Cypress's actionability check.
|
||||
const insertRowViaContextMenu = (
|
||||
rowIndex: number,
|
||||
menuItemText: 'Insert Row above' | 'Insert Row below'
|
||||
) => {
|
||||
getCellByHeaderAndRow(rowIndex, 'PRIMARY_KEY_FIELD').rightclick({
|
||||
force: true
|
||||
})
|
||||
cy.get('.htContextMenu').contains(menuItemText).click()
|
||||
}
|
||||
|
||||
// Opens the _____DELETE__THIS__RECORD_____ dropdown editor on the given cell
|
||||
// and picks the given choice ('Yes'/'No') — same technique as
|
||||
// coltype-delete-record.cy.ts (arrow -> autocompleteEditor choice list).
|
||||
const setDeleteFlag = (
|
||||
deleteCell: any,
|
||||
value: 'Yes' | 'No',
|
||||
callback?: any
|
||||
) => {
|
||||
cy.get(deleteCell)
|
||||
.click({ force: true })
|
||||
.then(($td) => {
|
||||
cy.get('.htAutocompleteArrow', { withinSubject: $td })
|
||||
.should('exist')
|
||||
.click({ force: true })
|
||||
|
||||
cy.get('.autocompleteEditor .htCore tbody td')
|
||||
.contains(value)
|
||||
.click({ force: true })
|
||||
.then(() => {
|
||||
if (callback) callback()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const clickOnEdit = (callback?: any) => {
|
||||
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
||||
.click()
|
||||
|
||||
@@ -386,6 +386,19 @@ context('excel tests: ', function () {
|
||||
})
|
||||
})
|
||||
|
||||
it('24 | Uploads Excel with leading whitespace in header row (should still succeed)', (done) => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
// Every header except the first has a leading space, matching a
|
||||
// real-world defect from copy-pasting between spreadsheets/systems.
|
||||
// Header matching must tolerate
|
||||
// this rather than reporting the columns missing and aborting.
|
||||
attachExcelFile('leading_whitespace_header_excel.xlsx', () => {
|
||||
submitExcel()
|
||||
rejectExcel(done)
|
||||
})
|
||||
})
|
||||
|
||||
// Large files break Cypress
|
||||
|
||||
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
||||
|
||||
@@ -36,6 +36,38 @@ context('liveness tests: ', function () {
|
||||
})
|
||||
})
|
||||
|
||||
// Multiple concurrent DC instances can cause the startupservice request
|
||||
// to time out even though a retry succeeds. Fail only the first
|
||||
// startupservice call (any later ones, e.g. from other tests/reloads,
|
||||
// pass through untouched) and confirm the app still reaches the nav tree
|
||||
// instead of showing the startup error modal.
|
||||
it('2 | Recovers from a failed startupservice request via the single retry', () => {
|
||||
let startupServiceCallCount = 0
|
||||
|
||||
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
|
||||
const isStartupService = JSON.stringify(req.body || '').includes(
|
||||
'startupservice'
|
||||
)
|
||||
|
||||
if (isStartupService) startupServiceCallCount++
|
||||
|
||||
if (isStartupService && startupServiceCallCount === 1) {
|
||||
req.reply({ statusCode: 503, body: 'Service unavailable' })
|
||||
} else {
|
||||
req.continue()
|
||||
}
|
||||
}).as('stpExecute')
|
||||
|
||||
visitPage('home')
|
||||
|
||||
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||
// Longer than the retry's own 0-3s pause on top of the normal timeout.
|
||||
timeout: longerCommandTimeout + 3000
|
||||
}).should('exist')
|
||||
|
||||
cy.get('.abortMsg').should('not.exist')
|
||||
})
|
||||
|
||||
/**
|
||||
* Thist part will be needed if we add more tests in future
|
||||
*/
|
||||
|
||||
+232
-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,68 @@ 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const removeAllColumns = () => {
|
||||
@@ -407,32 +365,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 +410,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 +418,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 = (
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// Marks this file as an ES module (rather than a global script) so its
|
||||
// top-level consts don't collide, under the TS type-checker, with the same
|
||||
// names declared in other spec files — see e.g. download.cy.ts, which gets
|
||||
// this for free via a real import.
|
||||
export {}
|
||||
|
||||
const username = Cypress.env('username')
|
||||
const password = Cypress.env('password')
|
||||
const hostUrl = Cypress.env('hosturl')
|
||||
const appLocation = Cypress.env('appLocation')
|
||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||
const serverType = Cypress.env('serverType')
|
||||
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
||||
|
||||
// Viewer column-label display toggle (`?labels=true`): clients surfacing DC in
|
||||
// SAS Visual Analytics want column LABELs shown instead of NAMEs. Default
|
||||
// behavior (no param) must stay unchanged; the right-click context menu's
|
||||
// "Show labels"/"Show names" item is the in-app affordance, and it drives the
|
||||
// same URL param so state is shareable/refreshable. See mock data in
|
||||
// sas/mocks/sasjs/services/public/viewdata.js: MPE_X_TEST's SOME_CHAR/SOME_DATE
|
||||
// have LABELs that differ from NAME, and SOME_NUM has a blank LABEL (fallback
|
||||
// to NAME) — this spec exercises exactly that fixture.
|
||||
context('viewer column labels toggle tests: ', function () {
|
||||
this.beforeAll(() => {
|
||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||
// forceLicenceKey=true: without it, loginAndUpdateValidKey() only
|
||||
// generates key data and logs back out if no valid license already
|
||||
// exists — it doesn't actually apply one (see commands.ts). Several
|
||||
// specs call it with no argument and rely on an *earlier* spec in the
|
||||
// same CI run having already forced a valid license onto the shared
|
||||
// server first (csv.cy.ts, excel.cy.ts, etc.). This spec shouldn't
|
||||
// depend on run order, so force it explicitly.
|
||||
cy.loginAndUpdateValidKey(true)
|
||||
})
|
||||
|
||||
this.beforeEach(() => {
|
||||
// No re-login here: beforeAll's loginAndUpdateValidKey() already
|
||||
// authenticated the session, and it persists across tests in this file
|
||||
// (see filtering.cy.ts/csv.cy.ts for the same pattern). Re-typing into
|
||||
// the login form would fail — it's hidden once already logged in.
|
||||
cy.visit(hostUrl + appLocation)
|
||||
|
||||
// Visit 'home' first, matching filtering.cy.ts/csv.cy.ts/download.cy.ts —
|
||||
// going straight to 'view/data' skips whatever startup-data fetch 'home'
|
||||
// triggers, which passed locally (fast round-trips) but left the
|
||||
// nav-tree empty in CI's slower environment.
|
||||
visitPage('home')
|
||||
visitPage('view/data')
|
||||
})
|
||||
|
||||
it('1 | default (no param): headers show NAMEs', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
|
||||
timeout: longerCommandTimeout
|
||||
}).should(($headerRow) => {
|
||||
expect($headerRow[0].innerHTML).to.include('SOME_CHAR')
|
||||
expect($headerRow[0].innerHTML).to.not.include('Some Character Column')
|
||||
})
|
||||
})
|
||||
|
||||
it('2 | ?labels=true: headers show LABELs, blank LABEL falls back to NAME', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
appendUrlParam('labels=true')
|
||||
|
||||
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
|
||||
timeout: longerCommandTimeout
|
||||
}).should(($headerRow) => {
|
||||
const html = $headerRow[0].innerHTML
|
||||
expect(html).to.include('Some Character Column')
|
||||
expect(html).to.include('Some Date')
|
||||
// SOME_NUM has a blank LABEL in the mock -> falls back to NAME
|
||||
expect(html).to.include('SOME_NUM')
|
||||
})
|
||||
})
|
||||
|
||||
it('3 | context menu "Show labels"/"Show names" toggles headers and the URL', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
toggleColumnLabelsFromContextMenu('Show labels')
|
||||
|
||||
cy.url().should('include', 'labels=true')
|
||||
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
|
||||
timeout: longerCommandTimeout
|
||||
}).should(($headerRow) => {
|
||||
expect($headerRow[0].innerHTML).to.include('Some Character Column')
|
||||
})
|
||||
|
||||
toggleColumnLabelsFromContextMenu('Show names')
|
||||
|
||||
cy.url().should('not.include', 'labels=true')
|
||||
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
|
||||
timeout: longerCommandTimeout
|
||||
}).should(($headerRow) => {
|
||||
expect($headerRow[0].innerHTML).to.include('SOME_CHAR')
|
||||
})
|
||||
})
|
||||
|
||||
it('4 | ?embed=va&labels=true: labels shown with embed chrome hidden', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
appendUrlParam('embed=va&labels=true')
|
||||
|
||||
cy.get('header.app-header').should('not.exist')
|
||||
cy.get('#hotTable .ht_clone_top .htCore thead tr', {
|
||||
timeout: longerCommandTimeout
|
||||
}).should(($headerRow) => {
|
||||
expect($headerRow[0].innerHTML).to.include('Some Character Column')
|
||||
})
|
||||
})
|
||||
|
||||
it('5 | info dropdown: first line is NAME (in both toggle states)', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
// No assertion on the item's label text here: `info` has a custom
|
||||
// `renderer`, so Handsontable never renders its `name` ("test info") as
|
||||
// text at all — the renderer's own output (checked below) is the only
|
||||
// real content.
|
||||
openColumnDropdown('SOME_CHAR')
|
||||
cy.get('.htDropdownMenu').should(($menu) => {
|
||||
expect($menu.text()).to.match(/NAME: SOME_CHAR/)
|
||||
})
|
||||
cy.get('body').click(0, 0) // close menu
|
||||
|
||||
appendUrlParam('labels=true')
|
||||
|
||||
openColumnDropdown('Some Character Column')
|
||||
cy.get('.htDropdownMenu').should(($menu) => {
|
||||
// NAME is always shown first, even while headers display as LABEL
|
||||
expect($menu.text()).to.match(/NAME: SOME_CHAR/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const visitPage = (url: string) => {
|
||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||
}
|
||||
|
||||
// Re-visits the current route with extra hash-query params appended, then
|
||||
// forces a real reload. Under hash routing (`useHash: true`), changing only
|
||||
// the URL's hash fragment is a same-document navigation — Angular Router
|
||||
// still reacts to it (which is enough for `useLabels`, read reactively from
|
||||
// `route.queryParams`), but `embed` is parsed once from `window.location.hash`
|
||||
// at app bootstrap (app.component.ts), so `?embed=va` only takes effect after
|
||||
// an actual reload.
|
||||
const appendUrlParam = (param: string) => {
|
||||
cy.url().then((url) => {
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
cy.visit(`${url}${separator}${param}`)
|
||||
cy.reload()
|
||||
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
||||
'not.exist'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const openColumnDropdown = (headerText: string) => {
|
||||
// Handsontable renders the sticky/frozen header via a separate clone pane
|
||||
// (.ht_clone_top) for scroll behavior; the header row inside the main
|
||||
// .ht_master pane is kept visibility:hidden (replaced visually by the
|
||||
// clone). .ht_clone_top is the real, interactive one — confirmed via
|
||||
// `document.querySelector('#hotTable .ht_clone_top thead').innerText` in
|
||||
// a live browser session.
|
||||
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.parents('th')
|
||||
.filter((_, th) => Cypress.$(th).text().includes(headerText))
|
||||
.last()
|
||||
.as('targetHeader')
|
||||
|
||||
// Click the header text first to select the column — the `info` item's
|
||||
// renderer reads hot.getSelected() to decide which column to describe,
|
||||
// so without an active selection it always shows "No info found".
|
||||
cy.get('@targetHeader').click()
|
||||
cy.get('@targetHeader').find('button.changeType').click({ force: true })
|
||||
}
|
||||
|
||||
const toggleColumnLabelsFromContextMenu = (
|
||||
menuItemText: 'Show labels' | 'Show names'
|
||||
) => {
|
||||
cy.get('#hotTable .ht_master.handsontable .htCore tbody tr td', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.first()
|
||||
// force: true — the first body row can sit under an overlay clone's
|
||||
// header/sort-icon layer, which fails Cypress's actionability check
|
||||
// even though the cell is the real rightclick target underneath.
|
||||
.rightclick({ force: true })
|
||||
|
||||
cy.get('.htContextMenu').contains(menuItemText).click()
|
||||
|
||||
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
||||
}
|
||||
|
||||
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
||||
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then((treeNodes: any) => {
|
||||
let viyaLib
|
||||
|
||||
for (let node of treeNodes) {
|
||||
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
|
||||
viyaLib = node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cy.get(viyaLib).within(() => {
|
||||
// Small settle wait: right after a fresh visit/reload, the tree
|
||||
// component can still be re-rendering, causing this node to be
|
||||
// found then swapped out mid-click ("disappeared from the page").
|
||||
cy.wait(300)
|
||||
|
||||
cy.get(
|
||||
'.clr-tree-node-content-container .clr-treenode-content p'
|
||||
).click()
|
||||
|
||||
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
||||
for (let innerNode of innerNodes) {
|
||||
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
||||
innerNode.click()
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Selecting the table triggers async SPA routing + a viewdata fetch;
|
||||
// wait for the grid to actually render before any subsequent action
|
||||
// (reading the URL, right-clicking, opening a header dropdown) — without
|
||||
// this, callers can act while still on the intermediate library-only
|
||||
// route or a not-yet-rendered grid. Waiting specifically for a header's
|
||||
// dropdown button (not just the <tr> shell) confirms Handsontable has
|
||||
// finished populating header cell contents, not just their DOM rows.
|
||||
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
|
||||
timeout: longerCommandTimeout
|
||||
}).should('exist')
|
||||
}
|
||||
Binary file not shown.
@@ -23,21 +23,33 @@
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
||||
import 'cypress-file-upload';
|
||||
import 'cypress-file-upload'
|
||||
|
||||
import { arrayBufferToBase64 } from './../util/helper-functions'
|
||||
import * as moment from 'moment'
|
||||
import moment from 'moment'
|
||||
|
||||
const username = Cypress.env('username');
|
||||
const password = Cypress.env('password');
|
||||
const hostUrl = Cypress.env('hosturl');
|
||||
const appLocation = Cypress.env('appLocation');
|
||||
// These custom commands were added via Cypress.Commands.add() below but never
|
||||
// had a type augmentation, so every spec calling cy.loginAndUpdateValidKey()/
|
||||
// cy.isLoggedIn() had an unresolved-property error.
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
isLoggedIn(callback: (exist: boolean) => void): Chainable<void>
|
||||
loginAndUpdateValidKey(forceLicenceKey?: boolean): Chainable<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const username = Cypress.env('username')
|
||||
const password = Cypress.env('password')
|
||||
const hostUrl = Cypress.env('hosturl')
|
||||
const appLocation = Cypress.env('appLocation')
|
||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||
const site_id_SASJS = Cypress.env('site_id_SASJS')
|
||||
|
||||
Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find(".nav-tree").length > 0) {
|
||||
cy.get('body').then(($body) => {
|
||||
if ($body.find('.nav-tree').length > 0) {
|
||||
if (callback) callback(true)
|
||||
} else {
|
||||
if (callback) callback(false)
|
||||
@@ -45,76 +57,92 @@ Cypress.Commands.add('isLoggedIn', (callback: (exist: boolean) => void) => {
|
||||
})
|
||||
})
|
||||
|
||||
Cypress.Commands.add('loginAndUpdateValidKey', (forceLicenceKey: boolean = false) => {
|
||||
cy.visit(hostUrl + appLocation);
|
||||
Cypress.Commands.add(
|
||||
'loginAndUpdateValidKey',
|
||||
(forceLicenceKey: boolean = false) => {
|
||||
cy.visit(hostUrl + appLocation)
|
||||
|
||||
cy.wait(2000)
|
||||
cy.wait(2000)
|
||||
|
||||
cy.get('body').then($body =>{
|
||||
const usernameInput = $body.find("input.username")[0]
|
||||
cy.get('body').then(($body) => {
|
||||
const usernameInput = $body.find('input.username')[0]
|
||||
|
||||
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
|
||||
cy.get('input.username').type(username);
|
||||
cy.get('input.password').type(password);
|
||||
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
|
||||
cy.get('input.username').type(username)
|
||||
cy.get('input.password').type(password)
|
||||
|
||||
cy.get('.login-group button').click()
|
||||
}
|
||||
cy.get('.login-group button').click()
|
||||
}
|
||||
|
||||
cy.get('.app-loading', {timeout: longerCommandTimeout}).should('not.exist').then(() => {
|
||||
cy.wait(2000)
|
||||
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
cy.wait(2000)
|
||||
|
||||
if ($body.find(".nav-tree").length > 0) {
|
||||
/**
|
||||
* If licence key is already working, then skip rest of the function
|
||||
*/
|
||||
return logout(() => {
|
||||
return
|
||||
})
|
||||
} else{
|
||||
const keyData = {
|
||||
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
|
||||
number_of_users: 10,
|
||||
hot_license_key: '',
|
||||
site_id: '',
|
||||
demo: false
|
||||
}
|
||||
|
||||
return generateKeys(keyData.valid_until, keyData.number_of_users, keyData.hot_license_key, keyData.demo, (keysGen: any) => {
|
||||
return acceptTermsIfPresented((result: boolean) => {
|
||||
if (result) {
|
||||
cy.wait(20000)
|
||||
if ($body.find('.nav-tree').length > 0) {
|
||||
/**
|
||||
* If licence key is already working, then skip rest of the function
|
||||
*/
|
||||
return logout(() => {
|
||||
return
|
||||
})
|
||||
} else {
|
||||
const keyData = {
|
||||
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
|
||||
number_of_users: 10,
|
||||
hot_license_key: '',
|
||||
site_id: '',
|
||||
demo: false
|
||||
}
|
||||
|
||||
if (!forceLicenceKey) {
|
||||
return logout(() => {
|
||||
return
|
||||
})
|
||||
} else {
|
||||
return updateLicenseKeyQuick(keysGen, () => {
|
||||
cy.wait(25000)
|
||||
return generateKeys(
|
||||
keyData.valid_until,
|
||||
keyData.number_of_users,
|
||||
keyData.hot_license_key,
|
||||
keyData.demo,
|
||||
(keysGen: any) => {
|
||||
return acceptTermsIfPresented((result: boolean) => {
|
||||
if (result) {
|
||||
cy.wait(20000)
|
||||
}
|
||||
return logout(() => {
|
||||
return
|
||||
})
|
||||
|
||||
if (!forceLicenceKey) {
|
||||
return logout(() => {
|
||||
return
|
||||
})
|
||||
} else {
|
||||
return updateLicenseKeyQuick(keysGen, () => {
|
||||
cy.wait(25000)
|
||||
return acceptTermsIfPresented((result: boolean) => {
|
||||
if (result) {
|
||||
cy.wait(20000)
|
||||
}
|
||||
return logout(() => {
|
||||
return
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
)
|
||||
|
||||
const logout = (callback?: any) => {
|
||||
cy.get('.header-actions .dropdown-toggle').click().then(() => {
|
||||
cy.get('.header-actions .dropdown-menu > .separator').next().click().then(() => {
|
||||
if (callback) callback()
|
||||
cy.get('.header-actions .dropdown-toggle')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('.header-actions .dropdown-menu > .separator')
|
||||
.next()
|
||||
.click()
|
||||
.then(() => {
|
||||
if (callback) callback()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const updateLicenseKeyQuick = (keys: any, callback: any) => {
|
||||
@@ -132,11 +160,15 @@ const updateLicenseKeyQuick = (keys: any, callback: any) => {
|
||||
const acceptTermsIfPresented = (callback?: any) => {
|
||||
cy.url().then((url: string) => {
|
||||
if (url.includes('licensing/register')) {
|
||||
cy.get('.card-block').scrollTo('bottom').then(() => {
|
||||
cy.get('#checkbox1').click().then(() => {
|
||||
if (callback) callback(true)
|
||||
cy.get('.card-block')
|
||||
.scrollTo('bottom')
|
||||
.then(() => {
|
||||
cy.get('#checkbox1')
|
||||
.click()
|
||||
.then(() => {
|
||||
if (callback) callback(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
if (callback) callback(false)
|
||||
}
|
||||
@@ -151,23 +183,36 @@ const isLicensingPage = (callback: any) => {
|
||||
|
||||
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
||||
cy.get('button').contains('Paste licence').click()
|
||||
cy.get('.license-key-form textarea', {timeout: longerCommandTimeout}).invoke('val', licenseKey).trigger('input').should('not.be.undefined')
|
||||
cy.get('.activation-key-form textarea', {timeout: longerCommandTimeout}).invoke('val', activationKey).trigger('input').should('not.be.undefined')
|
||||
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
|
||||
.invoke('val', licenseKey)
|
||||
.trigger('input')
|
||||
.should('not.be.undefined')
|
||||
cy.get('.activation-key-form textarea', { timeout: longerCommandTimeout })
|
||||
.invoke('val', activationKey)
|
||||
.trigger('input')
|
||||
.should('not.be.undefined')
|
||||
cy.get('button.apply-keys').click()
|
||||
}
|
||||
|
||||
const visitPage = (url: string) => {
|
||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`);
|
||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||
}
|
||||
const generateKeys = async (valid_until: string, users_allowed: number, hot_license_key: string, demo: boolean, resultCallback?: any) => {
|
||||
let keyPair = await window.crypto.subtle.generateKey({
|
||||
name: "RSA-OAEP",
|
||||
modulusLength: 2024,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256"
|
||||
const generateKeys = async (
|
||||
valid_until: string,
|
||||
users_allowed: number,
|
||||
hot_license_key: string,
|
||||
demo: boolean,
|
||||
resultCallback?: any
|
||||
) => {
|
||||
let keyPair = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2024,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
|
||||
let licenseData = {
|
||||
@@ -184,30 +229,39 @@ const generateKeys = async (valid_until: string, users_allowed: number, hot_lice
|
||||
|
||||
console.log(encoded)
|
||||
|
||||
let cipher = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "RSA-OAEP"
|
||||
},
|
||||
keyPair.publicKey,
|
||||
encoded
|
||||
).then((value) => {
|
||||
return value
|
||||
}, (err) => {
|
||||
console.log('Encrpyt error', err)
|
||||
})
|
||||
let cipher = await window.crypto.subtle
|
||||
.encrypt(
|
||||
{
|
||||
name: 'RSA-OAEP'
|
||||
},
|
||||
keyPair.publicKey,
|
||||
encoded
|
||||
)
|
||||
.then(
|
||||
(value) => {
|
||||
return value
|
||||
},
|
||||
(err) => {
|
||||
console.log('Encrpyt error', err)
|
||||
}
|
||||
)
|
||||
|
||||
if (!cipher) {
|
||||
alert('Encryptin keys failed')
|
||||
throw new Error('Encryptin keys failed')
|
||||
}
|
||||
|
||||
let privateKeyBytes = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
|
||||
let privateKeyBytes = await window.crypto.subtle.exportKey(
|
||||
'pkcs8',
|
||||
keyPair.privateKey
|
||||
)
|
||||
|
||||
let activationKey = await arrayBufferToBase64(privateKeyBytes)
|
||||
let licenseKey = await arrayBufferToBase64(cipher)
|
||||
|
||||
if (resultCallback) resultCallback({
|
||||
activationKey,
|
||||
licenseKey
|
||||
})
|
||||
}
|
||||
if (resultCallback)
|
||||
resultCallback({
|
||||
activationKey,
|
||||
licenseKey
|
||||
})
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -8,9 +8,9 @@ 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@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
|
||||
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
|
||||
},
|
||||
(error, json) => {
|
||||
if (error) {
|
||||
|
||||
Generated
+6286
-6413
File diff suppressed because it is too large
Load Diff
+34
-25
@@ -41,33 +41,35 @@
|
||||
},
|
||||
"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.26",
|
||||
"@angular/cdk": "^20.2.14",
|
||||
"@angular/common": "^20.3.26",
|
||||
"@angular/compiler": "^20.3.26",
|
||||
"@angular/core": "^20.3.26",
|
||||
"@angular/forms": "^20.3.26",
|
||||
"@angular/platform-browser": "^20.3.26",
|
||||
"@angular/platform-browser-dynamic": "^20.3.26",
|
||||
"@angular/router": "^20.3.26",
|
||||
"@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": "^17.1.0",
|
||||
"@sasjs/adapter": "^4.17.0",
|
||||
"@sasjs/utils": "^3.5.3",
|
||||
"@handsontable/angular-wrapper": "^18.0.0",
|
||||
"@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",
|
||||
"fs-extra": "^7.0.1",
|
||||
"handsontable": "^17.1.0",
|
||||
"handsontable": "^18.0.0",
|
||||
"https-browserify": "1.0.0",
|
||||
"hyperformula": "^2.5.0",
|
||||
"iconv-lite": "^0.5.0",
|
||||
@@ -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.26",
|
||||
"@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,17 @@
|
||||
"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",
|
||||
"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>
|
||||
|
||||
+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
@@ -1,4 +1,3 @@
|
||||
|
||||
.va-filter-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -23,3 +22,8 @@
|
||||
font-style: italic;
|
||||
color: #0072a3;
|
||||
}
|
||||
|
||||
.va-disabled-text {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ViewEncapsulation
|
||||
} from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import Handsontable from 'handsontable'
|
||||
import Handsontable, { CellRange } from 'handsontable'
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
import { sanitiseForSas } from '../shared/utils/sanitise'
|
||||
import { SasStoreService } from '../services/sas-store.service'
|
||||
@@ -21,7 +21,6 @@ type AOA = any[][]
|
||||
import { HotTableComponent } from '@handsontable/angular-wrapper'
|
||||
import { UploadFile } from '@sasjs/adapter'
|
||||
import { isSpecialMissing } from '@sasjs/utils/input/validators'
|
||||
import CellRange from 'handsontable/3rdparty/walkontable/src/cell/range'
|
||||
import { CellValidationSource } from '../models/CellValidationSource'
|
||||
import { FileUploader } from '../models/FileUploader.class'
|
||||
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
|
||||
@@ -40,6 +39,9 @@ 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 { 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'
|
||||
@@ -47,6 +49,11 @@ 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'
|
||||
import { globals } from '../_globals'
|
||||
import { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
|
||||
import {
|
||||
@@ -56,6 +63,9 @@ 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 { getEditStatusSymbol } from './utils/getEditStatusSymbol'
|
||||
import { EDIT_STATUS_COLUMN_NAME } from '../shared/dc-validator/utils/editStatusColumnRule'
|
||||
import {
|
||||
errorRenderer,
|
||||
noSpinnerRenderer,
|
||||
@@ -161,8 +171,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
items: {
|
||||
edit_row: {
|
||||
name: 'Edit row',
|
||||
hidden() {
|
||||
const hot: Handsontable.Core = this
|
||||
// HOT 18's MenuItemConfig types `hidden` as a plain `() => boolean` with
|
||||
// no `this` type, so an object-literal method here has `this` inferred
|
||||
// as the surrounding item config unless declared explicitly.
|
||||
hidden(this: Handsontable.Core) {
|
||||
const hot = this
|
||||
|
||||
// Hide editing actions in read-only (view) mode.
|
||||
if (hot.getSettings().readOnly) return true
|
||||
@@ -243,6 +256,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
redo: {
|
||||
name: 'Redo',
|
||||
hidden: () => this.hotTable.readOnly === true
|
||||
},
|
||||
// Navigates rather than just flipping `useLabels` locally, so the
|
||||
// URL stays the single source of truth (shareable/refreshable
|
||||
// state) — the queryParams subscription in ngOnInit picks up the
|
||||
// change and re-renders colHeaders.
|
||||
toggle_labels: {
|
||||
name: () => (this.useLabels ? 'Show names' : 'Show labels'),
|
||||
callback: () => {
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { labels: this.useLabels ? null : true },
|
||||
queryParamsHandling: 'merge'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +293,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
public libTab: string | undefined
|
||||
public queryFilter: any
|
||||
public _query: Subscription | undefined
|
||||
private _queryParams: Subscription | undefined
|
||||
|
||||
// `?labels=true` toggle (see parseLabelsParam) — URL is the source of
|
||||
// truth, kept in sync via the queryParams subscription in ngOnInit.
|
||||
public useLabels: boolean = false
|
||||
|
||||
public whereString: string | undefined
|
||||
public clauses: any
|
||||
@@ -448,6 +480,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
private ariaObserver: MutationObserver | undefined
|
||||
private ariaCheckInterval: any | undefined
|
||||
private gridResizeObserver: ResizeObserver | undefined
|
||||
|
||||
constructor(
|
||||
private licenceService: LicenceService,
|
||||
@@ -461,7 +494,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()
|
||||
@@ -735,6 +769,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)
|
||||
})
|
||||
|
||||
@@ -979,6 +1017,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const hot = this.hotInstance
|
||||
if (!hot) return
|
||||
|
||||
// Entering edit mode: cancel any scheduled VA filter apply so it can't fire
|
||||
// mid-edit and wipe the edits. A pending filter is re-applied on the return
|
||||
// to read-only (cancelEdit).
|
||||
if (this.vaDebounceTimer) {
|
||||
clearTimeout(this.vaDebounceTimer)
|
||||
this.vaDebounceTimer = undefined
|
||||
}
|
||||
|
||||
const columnSorting = hot.getPlugin('multiColumnSorting')
|
||||
const sortConfigs = this.getCurrentSortConfigs()
|
||||
|
||||
@@ -1074,6 +1120,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.checkRowLimit()
|
||||
|
||||
// Back to read-only: apply any VA filter change that was held pending while
|
||||
// editing (live mode). Confirm mode leaves it staged for the Apply button.
|
||||
if (
|
||||
this.isVaEmbed &&
|
||||
this.vaAutoApply &&
|
||||
this.vaFilterStatus === 'pending'
|
||||
) {
|
||||
this.applyPendingVaFilter()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1258,18 +1314,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
|
||||
@@ -1278,39 +1336,102 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = []
|
||||
@@ -1320,7 +1441,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
hot.updateSettings(
|
||||
{
|
||||
data: this.dataSource,
|
||||
colHeaders: this.headerColumns,
|
||||
colHeaders: getDisplayColHeaders(
|
||||
this.headerColumns,
|
||||
this.cols,
|
||||
this.useLabels
|
||||
),
|
||||
columns: this.cellValidation,
|
||||
modifyColWidth: function (width: number, col: number) {
|
||||
if (col === 0) {
|
||||
@@ -1355,31 +1480,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1399,7 +1516,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
hot.removeCellMeta(rowIndex, col, 'valid')
|
||||
hot.removeCellMeta(rowIndex, col, 'dupKey')
|
||||
// Remove our custom class from cell metadata
|
||||
const cellMeta = hot.getCellMeta(rowIndex, col)
|
||||
// getCellMeta<M>() defaults to Record<string, unknown> in HOT 18; pin it to
|
||||
// CellMeta so `.className` keeps its real string | string[] type below.
|
||||
const cellMeta = hot.getCellMeta<Handsontable.CellMeta>(rowIndex, col)
|
||||
if (cellMeta.className) {
|
||||
let cleanedClassName: string
|
||||
if (Array.isArray(cellMeta.className)) {
|
||||
@@ -1770,7 +1889,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] === ''
|
||||
})
|
||||
|
||||
@@ -1784,7 +1907,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
hot.updateSettings(
|
||||
{
|
||||
data: this.dataSource,
|
||||
colHeaders: this.headerColumns,
|
||||
colHeaders: getDisplayColHeaders(
|
||||
this.headerColumns,
|
||||
this.cols,
|
||||
this.useLabels
|
||||
),
|
||||
columns: this.cellValidation,
|
||||
modifyColWidth: function (width: number, col: number) {
|
||||
if (width > 500) return 500
|
||||
@@ -1853,6 +1980,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)
|
||||
@@ -2144,29 +2275,38 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
toggleHotPlugin(pluginName: string, enable: boolean) {
|
||||
const hot = this.hotInstance
|
||||
|
||||
hot.batch(() => {
|
||||
const contextMenuPlugin = hot.getPlugin<any>(pluginName)
|
||||
const contextMenuPlugin = hot.getPlugin<any>(pluginName)
|
||||
|
||||
if (!contextMenuPlugin) {
|
||||
console.warn(
|
||||
'Toggle Hot Plugin failed - Plugin named: ' +
|
||||
pluginName +
|
||||
' - could not be found.'
|
||||
)
|
||||
if (!contextMenuPlugin) {
|
||||
console.warn(
|
||||
'Toggle Hot Plugin failed - Plugin named: ' +
|
||||
pluginName +
|
||||
' - could not be found.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
// The instance may be destroyed/rebuilt within this 100ms window (e.g. a
|
||||
// VA filter reload repopulates the grid). A destroyed plugin has its `hot`
|
||||
// reference deleted, so enablePlugin()/disablePlugin() would throw on
|
||||
// `this.hot.getSettings()`. Bail if the instance is gone or was swapped.
|
||||
if (
|
||||
hot.isDestroyed ||
|
||||
this.hotInstance !== hot ||
|
||||
!contextMenuPlugin.hot
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (enable) {
|
||||
contextMenuPlugin.enablePlugin()
|
||||
return
|
||||
}
|
||||
|
||||
if (enable) {
|
||||
contextMenuPlugin.enablePlugin()
|
||||
} else {
|
||||
contextMenuPlugin.disablePlugin()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
hot.render()
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
private dynamicCellValidationDisabled(row: number, col: number) {
|
||||
@@ -2246,8 +2386,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
validationSourceIndex
|
||||
].values.map((el) => el.RAW_VALUE)
|
||||
|
||||
// `.source` (dropdown/autocomplete list) is typed unknown[] | Function here since
|
||||
// getCellMeta() isn't given a type param; cast to the array branch we actually use.
|
||||
const cellHadSource =
|
||||
(hot.getCellMeta(row, column).source || []).length < 1
|
||||
((hot.getCellMeta(row, column).source as unknown[]) || []).length < 1
|
||||
const cellHasValue = cellData !== ' '
|
||||
|
||||
hot.batch(() => {
|
||||
@@ -2638,6 +2780,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
)
|
||||
|
||||
// URL is the source of truth for the labels toggle: react to `?labels=`
|
||||
// changes (including same-page navigation from the dropdown menu item)
|
||||
// rather than reading it once from the route snapshot.
|
||||
this._queryParams = this.route.queryParams.subscribe((params) => {
|
||||
this.useLabels = parseLabelsParam(new URLSearchParams(params).toString())
|
||||
this.applyDisplayColHeaders()
|
||||
})
|
||||
|
||||
this._query = this.sasStoreService.query.subscribe((query: any) => {
|
||||
if (query.libds === this.libds) {
|
||||
this.whereString = query.string
|
||||
@@ -2766,7 +2916,26 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes `colHeaders` from `headerColumns`/`cols` for the current
|
||||
* `useLabels` state, and pushes it into a live grid via `updateSettings`
|
||||
* if one is mounted (no refetch needed).
|
||||
*/
|
||||
private applyDisplayColHeaders() {
|
||||
if (!this.hotInstance || !this.headerColumns.length) return
|
||||
|
||||
const colHeaders = getDisplayColHeaders(
|
||||
this.headerColumns,
|
||||
this.cols,
|
||||
this.useLabels
|
||||
)
|
||||
|
||||
this.hotInstance.updateSettings({ colHeaders }, false)
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._queryParams?.unsubscribe()
|
||||
|
||||
// Clean up the MutationObserver
|
||||
if (this.ariaObserver) {
|
||||
this.ariaObserver.disconnect()
|
||||
@@ -2779,6 +2948,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.ariaCheckInterval = undefined
|
||||
}
|
||||
|
||||
// Stop observing the grid container for resizes
|
||||
if (this.gridResizeObserver) {
|
||||
this.gridResizeObserver.disconnect()
|
||||
this.gridResizeObserver = undefined
|
||||
}
|
||||
|
||||
// Cancel any pending debounced VA apply
|
||||
if (this.vaDebounceTimer) {
|
||||
clearTimeout(this.vaDebounceTimer)
|
||||
@@ -2792,6 +2967,25 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run height+render when the grid container settles.
|
||||
*/
|
||||
private observeGridResize() {
|
||||
const el = this.hotInstance?.rootElement?.parentElement
|
||||
if (!el) return
|
||||
|
||||
this.gridResizeObserver?.disconnect()
|
||||
this.gridResizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const hot = this.hotInstance
|
||||
if (!hot || hot.isDestroyed) return
|
||||
hot.updateSettings({ height: this.hotTable.height }, false)
|
||||
hot.render()
|
||||
})
|
||||
})
|
||||
this.gridResizeObserver.observe(el)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes ARIA accessibility issues in the Handsontable component
|
||||
* This addresses the accessibility report issues with treegrid and presentation roles
|
||||
@@ -3009,6 +3203,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(' ')
|
||||
}
|
||||
@@ -3030,12 +3231,43 @@ 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
|
||||
|
||||
// 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 ?? ''
|
||||
)
|
||||
|
||||
// Note: this.headerColumns and this.columnHeader contains same data
|
||||
// need to resolve redundancy
|
||||
|
||||
@@ -3059,7 +3291,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
hot.updateSettings(
|
||||
{
|
||||
data: this.dataSource,
|
||||
colHeaders: this.headerColumns,
|
||||
colHeaders: getDisplayColHeaders(
|
||||
this.headerColumns,
|
||||
this.cols,
|
||||
this.useLabels
|
||||
),
|
||||
columns: this.cellValidation,
|
||||
height: this.hotTable.height,
|
||||
formulas: this.hotTable.formulas,
|
||||
@@ -3083,10 +3319,36 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
manualColumnResize: true,
|
||||
filters: false,
|
||||
manualRowResize: true,
|
||||
viewportRowRenderingOffset: 50,
|
||||
// show a bar on the left to enable users to select an entire row
|
||||
viewportRowRenderingOffset: 100,
|
||||
// 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,
|
||||
rowHeights: 24,
|
||||
@@ -3119,20 +3381,33 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
itemValue: string
|
||||
) => {
|
||||
const elem = document.createElement('span')
|
||||
let colName = ''
|
||||
let colInfo: DataFormat | undefined
|
||||
let textInfo = 'No info found'
|
||||
|
||||
if (this.hotInstance) {
|
||||
const hotSelected: [number, number, number, number][] =
|
||||
// getSelected() is typed number[][] in HOT 18 (was a 4-tuple
|
||||
// array before); loosen the annotation to match.
|
||||
const hotSelected: number[][] =
|
||||
this.hotInstance.getSelected() || []
|
||||
const selectedCol: number = hotSelected
|
||||
? hotSelected[0][1]
|
||||
: -1
|
||||
const colName = this.hotInstance?.colToProp(selectedCol)
|
||||
colName = this.hotInstance?.colToProp(selectedCol) as string
|
||||
colInfo = this.$dataFormats?.vars[colName]
|
||||
|
||||
if (colInfo)
|
||||
textInfo = `LABEL: ${colInfo?.label}<br>TYPE: ${colInfo?.type}<br>LENGTH: ${colInfo?.length}<br>FORMAT: ${colInfo?.format}`
|
||||
const { hardRegexValue, softRegexValue } =
|
||||
this.dcValidator?.getRegexRuleValues(colName) || {}
|
||||
const formulaValue =
|
||||
this.dcValidator?.getFormulaRuleValue(colName)
|
||||
|
||||
textInfo = buildColInfoHtml(
|
||||
colName,
|
||||
colInfo,
|
||||
hardRegexValue,
|
||||
softRegexValue,
|
||||
formulaValue
|
||||
)
|
||||
}
|
||||
|
||||
elem.innerHTML = textInfo
|
||||
@@ -3333,10 +3608,42 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (cellMeta && cellMeta.filter === false)
|
||||
hot.setCellMeta(row, col, 'filter', true)
|
||||
|
||||
// Toggling Delete? changes every other cell's validation exemption
|
||||
// in this row (see DcValidator.setDefaultValidator) — re-validate so
|
||||
// invalid highlights clear/reappear immediately instead of only at
|
||||
// submit time.
|
||||
if (colProp === '_____DELETE__THIS__RECORD_____') {
|
||||
hot.validateRows([row], () => hot.render())
|
||||
}
|
||||
|
||||
this.executeDynamicCellValidationIfApplicable(colProp, col, row)
|
||||
}
|
||||
})
|
||||
|
||||
// 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.
|
||||
hot.addHook('afterChange', (changes: any[], source: any) => {
|
||||
if (!changes || source === 'loadData' || source === 'editStatus') return
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -3370,8 +3677,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
)
|
||||
|
||||
hot.addHook('afterRender', (isForced: boolean) => {
|
||||
this.eventService.dispatchEvent('resize')
|
||||
|
||||
// Fix ARIA accessibility issues after each render
|
||||
this.fixAriaAccessibility()
|
||||
})
|
||||
@@ -3502,16 +3807,18 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
hot.render()
|
||||
|
||||
this.observeGridResize()
|
||||
|
||||
// Fix ARIA accessibility issues after table initialization
|
||||
setTimeout(() => {
|
||||
this.fixAriaAccessibility()
|
||||
}, 500)
|
||||
|
||||
// SAS Visual Analytics data-driven content mode: open editable immediately,
|
||||
// re-apply any column visibility chosen by VA before this (filter) reload,
|
||||
// and start receiving VA messages over the postMessage interface.
|
||||
// SAS Visual Analytics data-driven content mode: open in read-only view (NOT
|
||||
// immediate edit — the user clicks Edit to make changes), re-apply any column
|
||||
// visibility chosen by VA before this (filter) reload, and start receiving VA
|
||||
// messages over the postMessage interface.
|
||||
if (this.isVaEmbed) {
|
||||
this.editTable()
|
||||
if (this.vaMessaging.visibleColumns) {
|
||||
this.applyVaColumnVisibility(new Set(this.vaMessaging.visibleColumns))
|
||||
if (this.hotInstance) this.hotInstance.render()
|
||||
@@ -3604,9 +3911,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.vaPendingSignature = signature
|
||||
this.vaFilterStatus = 'pending'
|
||||
|
||||
// Live mode auto-advances after the debounce settles; confirm waits for the
|
||||
// Apply button. The pending indicator stays visible throughout the debounce.
|
||||
if (this.vaAutoApply) this.scheduleVaFilterApply()
|
||||
// Filtering repopulates the grid, so it must never run while the user is
|
||||
// editing (it would wipe their in-progress edits). While in edit mode we
|
||||
// only surface the pending indicator; the filter is applied on return to
|
||||
// read-only (see cancelEdit). Live mode auto-advances after the debounce
|
||||
// settles; confirm waits for the Apply button.
|
||||
if (this.vaAutoApply && this.hotTable.readOnly) this.scheduleVaFilterApply()
|
||||
}
|
||||
|
||||
/** Live mode: debounce a burst of VA changes into a single apply of the latest. */
|
||||
@@ -3669,6 +3979,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
/** Apply the staged VA filter (the debounce in live mode, or the Apply button). */
|
||||
public applyPendingVaFilter(): void {
|
||||
if (!this.vaPendingClauses) return
|
||||
// Never apply while editing — the reload would wipe in-progress edits. The
|
||||
// pending filter stays staged and is applied when the user leaves edit mode.
|
||||
if (!this.hotTable.readOnly) return
|
||||
const clauses = this.vaPendingClauses
|
||||
const signature = this.vaPendingSignature
|
||||
this.vaPendingClauses = null
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { classifyRow } from './classifyRow'
|
||||
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
|
||||
|
||||
describe('classifyRow', () => {
|
||||
const headerPks = ['PRIMARY_KEY_FIELD']
|
||||
|
||||
it('is D when the row is marked for delete, regardless of other field changes', () => {
|
||||
const dataRow = {
|
||||
PRIMARY_KEY_FIELD: 1,
|
||||
SOME_CHAR: 'changed',
|
||||
_____DELETE__THIS__RECORD_____: 'Yes'
|
||||
}
|
||||
const dataSourceUnchanged = [
|
||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||
]
|
||||
|
||||
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('D')
|
||||
})
|
||||
|
||||
it('is A when no row in dataSourceUnchanged matches the primary key(s)', () => {
|
||||
const dataRow = { PRIMARY_KEY_FIELD: 99, SOME_CHAR: 'new row' }
|
||||
const dataSourceUnchanged = [
|
||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||
]
|
||||
|
||||
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('A')
|
||||
})
|
||||
|
||||
it('is M when a matching row exists but a field differs', () => {
|
||||
const dataRow = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'changed' }
|
||||
const dataSourceUnchanged = [
|
||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||
]
|
||||
|
||||
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('M')
|
||||
})
|
||||
|
||||
it('is U when a matching row exists and nothing differs', () => {
|
||||
const dataRow = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||
const dataSourceUnchanged = [
|
||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
|
||||
]
|
||||
|
||||
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('U')
|
||||
})
|
||||
|
||||
it('matches on all primary key columns for a composite key', () => {
|
||||
const headerPksComposite = ['LIB', 'ID']
|
||||
const dataRow = { LIB: 'WORK', ID: 1, SOME_CHAR: 'original' }
|
||||
const dataSourceUnchanged = [
|
||||
{ LIB: 'OTHER', ID: 1, SOME_CHAR: 'original' },
|
||||
{ LIB: 'WORK', ID: 1, SOME_CHAR: 'original' }
|
||||
]
|
||||
|
||||
// Same ID=1 exists under a different LIB - must not match that one.
|
||||
expect(
|
||||
classifyRow(dataRow, dataSourceUnchanged, headerPksComposite)
|
||||
).toEqual('U')
|
||||
})
|
||||
|
||||
it('ignores its own EDIT_STATUS column value when diffing, so writing the classification back never self-perpetuates a stale M', () => {
|
||||
const dataRow = {
|
||||
PRIMARY_KEY_FIELD: 1,
|
||||
SOME_CHAR: 'original',
|
||||
[EDIT_STATUS_COLUMN_NAME]: 'M'
|
||||
}
|
||||
const dataSourceUnchanged = [
|
||||
{
|
||||
PRIMARY_KEY_FIELD: 1,
|
||||
SOME_CHAR: 'original',
|
||||
[EDIT_STATUS_COLUMN_NAME]: 'U'
|
||||
}
|
||||
]
|
||||
|
||||
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('U')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
|
||||
|
||||
export type RowEditStatus = 'M' | 'A' | 'D' | 'U'
|
||||
|
||||
// EDIT_STATUS holds this very function's own prior output - comparing it
|
||||
// like any other field would self-perpetuate: once a row is written as 'M',
|
||||
// its EDIT_STATUS would forever differ from the unchanged snapshot's 'U'
|
||||
// even after the real edit is reverted, permanently stuck on 'M'.
|
||||
const withoutEditStatus = (row: any): any => {
|
||||
const { [EDIT_STATUS_COLUMN_NAME]: _editStatus, ...rest } = row
|
||||
return rest
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a single row as Modified/Added/Deleted/Unchanged against the
|
||||
* pre-edit snapshot (`dataSourceUnchanged`), PK-matched via `headerPks`.
|
||||
*
|
||||
* Extracted from editor.component.ts's getRowsSubmittingCount(), which used
|
||||
* this same logic inline, only at submit time. Kept here as a pure function
|
||||
* so it can run live (e.g. on every afterChange, for the EDIT_STATUS column)
|
||||
* without duplicating the diffing rules.
|
||||
*/
|
||||
export const classifyRow = (
|
||||
dataRow: any,
|
||||
dataSourceUnchanged: any[],
|
||||
headerPks: string[]
|
||||
): RowEditStatus => {
|
||||
if (dataRow._____DELETE__THIS__RECORD_____ === 'Yes') return 'D'
|
||||
|
||||
const dataRowUnchanged = dataSourceUnchanged.find((row: any) => {
|
||||
for (const pkCol of headerPks) {
|
||||
if (row[pkCol] !== dataRow[pkCol]) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (!dataRowUnchanged) return 'A'
|
||||
|
||||
return JSON.stringify(withoutEditStatus(dataRow)) !==
|
||||
JSON.stringify(withoutEditStatus(dataRowUnchanged))
|
||||
? 'M'
|
||||
: 'U'
|
||||
}
|
||||
@@ -18,6 +18,22 @@ export const dateToTime = (date: Date) => {
|
||||
return timeStr
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises a 24-hour time STRING to zero-padded `HH:mm:ss`.
|
||||
*
|
||||
* The `intl-time` cell type (validator + native `<input type=time>` editor) is
|
||||
* strict and rejects `H:mm[:ss]`, so such a value paints red and the editor
|
||||
* opens empty. Pad the hour and default the seconds so the value validates.
|
||||
*
|
||||
* Non time-like input is returned unchanged.
|
||||
*/
|
||||
export const padTimeString = (value: string): string => {
|
||||
const match = value.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
|
||||
if (!match) return value
|
||||
const [, hours, minutes, seconds] = match
|
||||
return `${hours.padStart(2, '0')}:${minutes}:${seconds ?? '00'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts date object to the YYYY-MM-DD
|
||||
*/
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
@@ -37,7 +38,6 @@ export interface MaxVarLength {
|
||||
|
||||
export interface SASParam {
|
||||
COLHEADERS: string
|
||||
COLTYPE: string
|
||||
DTTMVARS: string
|
||||
DTVARS: string
|
||||
CLS_FLAG: number
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -22,10 +22,9 @@ import { ExcelRule } from '../models/TableData'
|
||||
import { HotTableInterface } from '../models/HotTable.interface'
|
||||
import { Col } from '../shared/dc-validator/models/col.model'
|
||||
import { SpreadsheetService } from '../services/spreadsheet.service'
|
||||
import Handsontable from 'handsontable'
|
||||
import Handsontable, { CellChange, ChangeSource } from 'handsontable'
|
||||
import { HotTableComponent } from '@handsontable/angular-wrapper'
|
||||
import { EditorsStageDataSASResponse } from '../models/sas/editors-stagedata.model'
|
||||
import { CellChange, ChangeSource } from 'handsontable/common'
|
||||
import { baseAfterGetColHeader } from '../shared/utils/hot.utils'
|
||||
import { ColumnSettings } from 'handsontable/settings'
|
||||
import { UploadFile } from '@sasjs/adapter'
|
||||
@@ -502,7 +501,9 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
if (changes) {
|
||||
for (let change of changes) {
|
||||
if (change && change[3]) {
|
||||
change[3] = change[3].toUpperCase()
|
||||
// CellValue is `unknown` in HOT 18 (was a concrete union before);
|
||||
// this column is always a dataset name string.
|
||||
change[3] = (change[3] as string).toUpperCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -552,7 +553,9 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
dynamicCellValidations() {
|
||||
if (!this.hotInstanceUserDataset) return
|
||||
|
||||
const hotData = this.hotInstanceUserDataset.getData()
|
||||
// getData() returns unknown[][] in HOT 18 (was CellValue[][]); this grid's
|
||||
// data is always library/table name strings.
|
||||
const hotData = this.hotInstanceUserDataset.getData() as string[][]
|
||||
|
||||
hotData.forEach((row, rowIndex) => {
|
||||
const library = row[0]
|
||||
@@ -573,10 +576,12 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
|
||||
if (dataAtRow && dataAtRow[0] && dataAtRow[1]) {
|
||||
if (!this.matchedDatasets.includes(dataset)) {
|
||||
// getCellMetaAtRow() returns Record<string, unknown>[] in HOT 18, so
|
||||
// `.col` needs casting back to number for setCellMeta() (same below).
|
||||
cellMetaAtRow.forEach((cellMeta) => {
|
||||
this.hotInstanceUserDataset.setCellMeta(
|
||||
row,
|
||||
cellMeta.col,
|
||||
cellMeta.col as number,
|
||||
'className',
|
||||
'not-matched'
|
||||
)
|
||||
@@ -585,7 +590,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
cellMetaAtRow.forEach((cellMeta) => {
|
||||
this.hotInstanceUserDataset.setCellMeta(
|
||||
row,
|
||||
cellMeta.col,
|
||||
cellMeta.col as number,
|
||||
'className',
|
||||
''
|
||||
)
|
||||
@@ -595,7 +600,7 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
cellMetaAtRow.forEach((cellMeta) => {
|
||||
this.hotInstanceUserDataset.setCellMeta(
|
||||
row,
|
||||
cellMeta.col,
|
||||
cellMeta.col as number,
|
||||
'className',
|
||||
''
|
||||
)
|
||||
@@ -991,7 +996,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
private getDatasetsFromHot(): string[] {
|
||||
if (!this.hotInstanceUserDataset) return []
|
||||
|
||||
const hotData = this.hotInstanceUserDataset.getData()
|
||||
// getData() returns unknown[][] in HOT 18; see dynamicCellValidations() above.
|
||||
const hotData = this.hotInstanceUserDataset.getData() as string[][]
|
||||
|
||||
return hotData
|
||||
.filter((row) => row[0]?.length && row[1]?.length)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { EventEmitter } from '@angular/core'
|
||||
import { BehaviorSubject, Subject } from 'rxjs'
|
||||
import { AppService } from './app.service'
|
||||
|
||||
/**
|
||||
* AppService's constructor subscribes to several emitters/subjects (see
|
||||
* subscribe() and the persistSelectedTheme check) and startUpData() touches
|
||||
* a handful of methods on each collaborator — these stubs cover exactly
|
||||
* that surface, not the full real services (no TestBed/DI needed, same
|
||||
* plain-instantiation-with-stubs precedent as va-filter.service.spec.ts).
|
||||
*/
|
||||
const buildDeps = () => {
|
||||
const licenceService: any = {
|
||||
isAppActivated: new BehaviorSubject<boolean | null>(null),
|
||||
activation: jasmine.createSpy('activation').and.resolveTo(undefined)
|
||||
}
|
||||
const eventService: any = {
|
||||
showInfoModal: jasmine.createSpy('showInfoModal'),
|
||||
startupDataLoaded: jasmine.createSpy('startupDataLoaded'),
|
||||
toggleDarkMode: jasmine.createSpy('toggleDarkMode')
|
||||
}
|
||||
const sasService: any = {
|
||||
loadStartupServiceEmitter: new EventEmitter<any>(),
|
||||
requestSiteIdEmitter: new EventEmitter<string>(),
|
||||
request: jasmine.createSpy('request')
|
||||
}
|
||||
const loggerService: any = {
|
||||
log: jasmine.createSpy('log')
|
||||
}
|
||||
const appSettingsService: any = {
|
||||
settings: new BehaviorSubject({ persistSelectedTheme: false } as any)
|
||||
}
|
||||
const router: any = {
|
||||
events: new Subject(),
|
||||
url: '',
|
||||
navigateByUrl: jasmine.createSpy('navigateByUrl')
|
||||
}
|
||||
const appStoreService: any = {
|
||||
getDcAdapterSettings: () => undefined
|
||||
}
|
||||
|
||||
return {
|
||||
licenceService,
|
||||
eventService,
|
||||
sasService,
|
||||
loggerService,
|
||||
appSettingsService,
|
||||
router,
|
||||
appStoreService
|
||||
}
|
||||
}
|
||||
|
||||
const buildAppService = (deps: ReturnType<typeof buildDeps>) =>
|
||||
new AppService(
|
||||
deps.licenceService,
|
||||
deps.eventService,
|
||||
deps.sasService,
|
||||
deps.loggerService,
|
||||
deps.appSettingsService,
|
||||
deps.router,
|
||||
deps.appStoreService
|
||||
)
|
||||
|
||||
// Minimal valid startupservice payload — just enough to pass startUpData()'s
|
||||
// missing-props check (Globvars/Sasdatasets/Saslibs/XLMaps).
|
||||
const validStartupResponse = () => ({
|
||||
adapterResponse: {
|
||||
SYSSITE: 'SITE1',
|
||||
globvars: [{ ISADMIN: false, DC_ADMIN_GROUP: '', DCLIB: 'DCLIB' }],
|
||||
sasdatasets: [],
|
||||
saslibs: {},
|
||||
xlmaps: []
|
||||
}
|
||||
})
|
||||
|
||||
describe('AppService - startup retry', () => {
|
||||
it('retries once and succeeds without showing the error modal when only the first attempt rejects', async () => {
|
||||
const deps = buildDeps()
|
||||
deps.sasService.request.and.returnValues(
|
||||
Promise.reject('timeout'),
|
||||
Promise.resolve(validStartupResponse())
|
||||
)
|
||||
|
||||
const appService = buildAppService(deps)
|
||||
// Keep the retry pause instant in tests — see STARTUP_RETRY_PLAN.md.
|
||||
;(appService as any).retryOptions = {
|
||||
wait: () => Promise.resolve(),
|
||||
random: () => 0
|
||||
}
|
||||
|
||||
await appService.startUpData()
|
||||
|
||||
expect(deps.sasService.request).toHaveBeenCalledTimes(2)
|
||||
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
|
||||
expect(deps.eventService.startupDataLoaded).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the error modal once (not twice) when both attempts reject', async () => {
|
||||
const deps = buildDeps()
|
||||
deps.sasService.request.and.returnValues(
|
||||
Promise.reject('timeout-1'),
|
||||
Promise.reject('timeout-2')
|
||||
)
|
||||
|
||||
const appService = buildAppService(deps)
|
||||
;(appService as any).retryOptions = {
|
||||
wait: () => Promise.resolve(),
|
||||
random: () => 0
|
||||
}
|
||||
|
||||
await appService.startUpData()
|
||||
|
||||
expect(deps.sasService.request).toHaveBeenCalledTimes(2)
|
||||
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
|
||||
expect(deps.licenceService.isAppActivated.value).toBeFalse()
|
||||
})
|
||||
|
||||
it('does not retry a response that arrives but is missing required properties', async () => {
|
||||
const deps = buildDeps()
|
||||
deps.sasService.request.and.resolveTo({
|
||||
adapterResponse: {
|
||||
SYSSITE: 'SITE1',
|
||||
// globvars deliberately omitted
|
||||
sasdatasets: [],
|
||||
saslibs: {},
|
||||
xlmaps: []
|
||||
}
|
||||
})
|
||||
|
||||
const appService = buildAppService(deps)
|
||||
;(appService as any).retryOptions = {
|
||||
wait: () => Promise.resolve(),
|
||||
random: () => 0
|
||||
}
|
||||
|
||||
await appService.startUpData()
|
||||
|
||||
expect(deps.sasService.request).toHaveBeenCalledTimes(1)
|
||||
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -11,11 +11,15 @@ import { AppSettingsService } from './app-settings.service'
|
||||
import { AppThemes } from '../models/AppSettings'
|
||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||
import { AppStoreService } from './app-store.service'
|
||||
import { retryOnce, RetryOnceOptions } from '../shared/utils/retry-once'
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
public syssite = new BehaviorSubject<string[] | null>(null)
|
||||
private environmentInfo: EnvironmentInfo = {}
|
||||
// Overridable in tests to keep the retry pause instant — see
|
||||
// retry-once.spec.ts and app.service.spec.ts.
|
||||
private retryOptions: RetryOnceOptions = {}
|
||||
|
||||
constructor(
|
||||
private licenceService: LicenceService,
|
||||
@@ -82,8 +86,13 @@ export class AppService {
|
||||
public async startUpData() {
|
||||
let startupServiceError = false
|
||||
|
||||
await this.sasService
|
||||
.request('public/startupservice', null)
|
||||
// Retry once on failure — multiple concurrent DC instances (e.g. VA
|
||||
// multi-page reports) can cause this request to time out even though a
|
||||
// second attempt succeeds. See STARTUP_RETRY_PLAN.md.
|
||||
await retryOnce(
|
||||
() => this.sasService.request('public/startupservice', null),
|
||||
this.retryOptions
|
||||
)
|
||||
.then(async (res: RequestWrapperResponse) => {
|
||||
this.syssite.next([res.adapterResponse.SYSSITE])
|
||||
|
||||
|
||||
@@ -55,11 +55,11 @@ describe('VaFilterService', () => {
|
||||
'SOME_DATETIME'
|
||||
]
|
||||
const cols = [
|
||||
{ NAME: 'SOME_CHAR', TYPE: 'char', DDTYPE: 'CHAR' },
|
||||
{ NAME: 'SOME_NUM', TYPE: 'num', DDTYPE: 'NUMERIC' },
|
||||
{ NAME: 'SOME_TIME', TYPE: 'num', DDTYPE: 'TIME' },
|
||||
{ NAME: 'SOME_DATE', TYPE: 'num', DDTYPE: 'DATE' },
|
||||
{ NAME: 'SOME_DATETIME', TYPE: 'num', DDTYPE: 'DATETIME' }
|
||||
{ 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' }
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -122,6 +122,90 @@ describe('VaFilterService', () => {
|
||||
).toBe('365')
|
||||
})
|
||||
|
||||
it('passes an already-internal (bare number) temporal value through raw', () => {
|
||||
// VA sends the raw SAS internal value when the data item has no format.
|
||||
expect(build([{ label: 'SOME_DATE', value: '182' }])[0].RAW_VALUE).toBe(
|
||||
'182'
|
||||
)
|
||||
expect(
|
||||
build([{ label: 'SOME_DATETIME', value: '365' }])[0].RAW_VALUE
|
||||
).toBe('365')
|
||||
expect(build([{ label: 'SOME_TIME', value: '2' }])[0].RAW_VALUE).toBe('2')
|
||||
})
|
||||
|
||||
it('parses ISO date shapes identically to DATE9 (VA sends no format)', () => {
|
||||
const date9 = build([{ label: 'SOME_DATE', value: '01AUG1962' }])[0]
|
||||
.RAW_VALUE
|
||||
// The production symptom: YYMMDDS "2025/03/31" style must parse, not drop.
|
||||
expect(
|
||||
build([{ label: 'SOME_DATE', value: '1962-08-01' }])[0].RAW_VALUE
|
||||
).toBe(date9)
|
||||
expect(
|
||||
build([{ label: 'SOME_DATE', value: '1962/08/01' }])[0].RAW_VALUE
|
||||
).toBe(date9)
|
||||
// A real production-format value must yield a clause (not be dropped).
|
||||
expect(build([{ label: 'SOME_DATE', value: '2025/03/31' }])).toHaveSize(1)
|
||||
})
|
||||
|
||||
it('resolves ambiguous slash dates day-first (never US), disambiguating by value', () => {
|
||||
const iso = build([{ label: 'SOME_DATE', value: '2025-03-31' }])[0]
|
||||
.RAW_VALUE
|
||||
// 31 can only be a day -> both orders resolve to 2025-03-31.
|
||||
expect(
|
||||
build([{ label: 'SOME_DATE', value: '31/03/2025' }])[0].RAW_VALUE
|
||||
).toBe(iso) // day-first
|
||||
expect(
|
||||
build([{ label: 'SOME_DATE', value: '03/31/2025' }])[0].RAW_VALUE
|
||||
).toBe(iso) // value forces month-first
|
||||
// Genuinely ambiguous (both <= 12): default DAY-first => 4th March, NOT 3rd April.
|
||||
expect(
|
||||
build([{ label: 'SOME_DATE', value: '04/03/2025' }])[0].RAW_VALUE
|
||||
).toBe(build([{ label: 'SOME_DATE', value: '2025-03-04' }])[0].RAW_VALUE)
|
||||
// Impossible date is dropped, not rolled over.
|
||||
expect(build([{ label: 'SOME_DATE', value: '31/02/2025' }])).toEqual([])
|
||||
})
|
||||
|
||||
it('uses the column FMTNAME to resolve ambiguous dates (MMDDYY reads US month-first)', () => {
|
||||
const iso = (v: string) =>
|
||||
service.buildClauses(
|
||||
msg([{ label: 'D', value: v }]),
|
||||
new Map([['d', 'D']]),
|
||||
[{ NAME: 'D', DDTYPE: 'DATE' }]
|
||||
)[0]?.RAW_VALUE
|
||||
const withFmt = (fmt: string, v: string) =>
|
||||
service.buildClauses(
|
||||
msg([{ label: 'D', value: v }]),
|
||||
new Map([['d', 'D']]),
|
||||
[{ NAME: 'D', DDTYPE: 'DATE', FMTNAME: fmt }]
|
||||
)[0]?.RAW_VALUE
|
||||
// 04/03/2025, both <= 12 (ambiguous): FMTNAME decides.
|
||||
expect(withFmt('MMDDYY', '04/03/2025')).toBe(iso('2025-04-03')) // 3 April
|
||||
expect(withFmt('DDMMYY', '04/03/2025')).toBe(iso('2025-03-04')) // 4 March
|
||||
// Value still overrides a wrong FMTNAME: 31 can only be the day.
|
||||
expect(withFmt('MMDDYY', '31/03/2025')).toBe(iso('2025-03-31'))
|
||||
// Falls back to the full FORMAT (e.g. MMDDYY10.) when FMTNAME is blank.
|
||||
const withFormat = service.buildClauses(
|
||||
msg([{ label: 'D', value: '04/03/2025' }]),
|
||||
new Map([['d', 'D']]),
|
||||
[{ NAME: 'D', DDTYPE: 'DATE', FMTNAME: '', FORMAT: 'MMDDYY10.' }]
|
||||
)[0]?.RAW_VALUE
|
||||
expect(withFormat).toBe(iso('2025-04-03')) // 3 April, from FORMAT
|
||||
})
|
||||
|
||||
it('parses ISO datetime shapes identically to SAS DATETIME', () => {
|
||||
const sas = build([
|
||||
{ label: 'SOME_DATETIME', value: '01JAN1960:00:06:05' }
|
||||
])[0].RAW_VALUE
|
||||
expect(
|
||||
build([{ label: 'SOME_DATETIME', value: '1960-01-01T00:06:05' }])[0]
|
||||
.RAW_VALUE
|
||||
).toBe(sas)
|
||||
expect(
|
||||
build([{ label: 'SOME_DATETIME', value: '1960-01-01 00:06:05' }])[0]
|
||||
.RAW_VALUE
|
||||
).toBe(sas)
|
||||
})
|
||||
|
||||
it('matches the column by label case-insensitively', () => {
|
||||
expect(build([{ label: 'some_char', value: 'x' }])[0].VARIABLE_NM).toBe(
|
||||
'SOME_CHAR'
|
||||
@@ -153,6 +237,21 @@ describe('VaFilterService', () => {
|
||||
['SOME_DATETIME', '365']
|
||||
])
|
||||
})
|
||||
|
||||
it('infers ISO date/datetime shapes by value shape', () => {
|
||||
const clauses = service.buildInitialClauses(
|
||||
msg([
|
||||
{ name: 'pr1', label: 'SOME_DATE', value: '1960-07-01' },
|
||||
{ name: 'pr2', label: 'SOME_DATE2', value: '1960/07/01' },
|
||||
{ name: 'pr3', label: 'SOME_DTTM', value: '1960-01-01T00:06:05' }
|
||||
])
|
||||
)
|
||||
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
|
||||
['SOME_DATE', '182'],
|
||||
['SOME_DATE2', '182'],
|
||||
['SOME_DTTM', '365']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('signature', () => {
|
||||
|
||||
@@ -72,7 +72,9 @@ export class VaFilterService {
|
||||
const varName = labelToVar.get(key)
|
||||
if (!varName) continue
|
||||
|
||||
const kind = this.columnKind(varName, cols)
|
||||
const col = this.findCol(varName, cols)
|
||||
const kind = this.columnKind(col)
|
||||
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
|
||||
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
|
||||
// Format/validate every value for the column's SAS type. Numeric columns
|
||||
// must receive a finite number (untrusted postMessage input is
|
||||
@@ -81,7 +83,7 @@ export class VaFilterService {
|
||||
// escaped. Invalid values are dropped.
|
||||
const formatted = rawValues
|
||||
.filter((v) => v !== undefined && v !== null && v !== '')
|
||||
.map((v) => this.formatValueByKind(v, kind))
|
||||
.map((v) => this.formatValueByKind(v, kind, order))
|
||||
.filter((v): v is string => v !== null)
|
||||
if (formatted.length === 0) continue
|
||||
clauses.push(this.clause(varName, formatted))
|
||||
@@ -151,34 +153,69 @@ export class VaFilterService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SAS data-type kind of a column, from the loaded column specs. DDTYPE carries
|
||||
* TIME/DATE/DATETIME; checked DATETIME-before-DATE (substring).
|
||||
*/
|
||||
private columnKind(varName: string, cols: any[]): VaColumnKind {
|
||||
const col = (cols || []).find(
|
||||
/** The loaded column spec whose NAME matches varName (case-insensitive). */
|
||||
private findCol(varName: string, cols: any[]): any {
|
||||
return (cols || []).find(
|
||||
(x: any) =>
|
||||
(x?.NAME ?? '').toString().toUpperCase() === varName.toUpperCase()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SAS data-type kind of a column spec. DDTYPE carries
|
||||
* TIME/DATE/DATETIME 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 ((col?.TYPE ?? '') === 'num') return 'numeric'
|
||||
if (
|
||||
ddtype === 'N' ||
|
||||
ddtype.includes('NUMERIC') ||
|
||||
(col?.TYPE ?? '') === 'num'
|
||||
) {
|
||||
return 'numeric'
|
||||
}
|
||||
return 'char'
|
||||
}
|
||||
|
||||
/**
|
||||
* Day/month/year order declared by a column's SAS format, used to resolve
|
||||
* ambiguous numeric dates. Accepts either the base FMTNAME (e.g. `DDMMYY`) or
|
||||
* the full FORMAT (e.g. `DDMMYY10.`) — the leading name is the same and is all
|
||||
* that matters for order (width is irrelevant). This is the SOURCE column's
|
||||
* format; the VA report can in principle display a different one, so
|
||||
* value-based disambiguation (a group > 12 is the day) still takes precedence —
|
||||
* this only decides the genuinely-ambiguous case. `undefined` when the format
|
||||
* is absent, month-name (DATE*), or locale-dependent (NLDATE).
|
||||
*/
|
||||
private dateOrderFromFmt(fmtname: any): 'YMD' | 'DMY' | 'MDY' | undefined {
|
||||
const f = (fmtname ?? '').toString().toUpperCase()
|
||||
if (/^MMDDYY/.test(f)) return 'MDY'
|
||||
if (/^(DDMMYY|EURDF)/.test(f)) return 'DMY'
|
||||
if (/^(YYMMDD|E8601DA|B8601DA)/.test(f)) return 'YMD'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers a column's SAS kind from a VA value string (deferred load, no
|
||||
* metadata). VA's temporal strings are distinctive — `ddMMMyyyy[:HH:MM:SS]`
|
||||
* and `H:MM:SS` — so they're detected by shape; otherwise fall back to the VA
|
||||
* metadata). VA's temporal strings are distinctive — DATE9 `ddMMMyyyy` and ISO
|
||||
* `yyyy-MM-dd`/`yyyy/MM/dd`, optionally with a `[ T:]HH:MM[:SS]` time part, and
|
||||
* `H:MM:SS` — so they're detected by shape; otherwise fall back to the VA
|
||||
* `dataType`.
|
||||
*/
|
||||
private inferKindFromValue(value: string, dataType?: string): VaColumnKind {
|
||||
if (/^\d{1,2}[A-Za-z]{3}\d{4}:\d{2}:\d{2}(:\d{2})?$/.test(value)) {
|
||||
const monName = String.raw`\d{1,2}[A-Za-z]{3}\d{2,4}`
|
||||
const ymd = String.raw`\d{4}[-/.]\d{1,2}[-/.]\d{1,2}` // 4-digit year first
|
||||
const dmy = String.raw`\d{1,2}[-/.]\d{1,2}[-/.]\d{4}` // 4-digit year last
|
||||
const date = `${monName}|${ymd}|${dmy}`
|
||||
if (new RegExp(`^(?:${date})[ T:]\\d{1,2}:\\d{2}(:\\d{2})?$`).test(value)) {
|
||||
return 'datetime'
|
||||
}
|
||||
if (/^\d{1,2}[A-Za-z]{3}\d{4}$/.test(value)) return 'date'
|
||||
if (new RegExp(`^(?:${date})$`).test(value)) return 'date'
|
||||
if (/^\d{1,2}:\d{2}(:\d{2})?(\.\d+)?$/.test(value)) return 'time'
|
||||
if (dataType === 'number') return 'numeric'
|
||||
return 'char'
|
||||
@@ -197,8 +234,18 @@ export class VaFilterService {
|
||||
* either yields a finite number or is dropped — also the injection guard.
|
||||
* Date/datetime reuse HelperService.convertJsDateToSasDate so the value is
|
||||
* byte-identical to what DC's own pickers produce.
|
||||
*
|
||||
* A temporal value that is already a bare number is the SAS internal value
|
||||
* itself (VA sends it unformatted when the data item carries no format / is
|
||||
* used as a measure), so it is passed through raw. This is unambiguous because
|
||||
* the only date formats in use (DATE9, ISO) always contain a letter or a
|
||||
* separator — a plain integer is never a formatted date.
|
||||
*/
|
||||
private formatValueByKind(value: any, kind: VaColumnKind): string | null {
|
||||
private formatValueByKind(
|
||||
value: any,
|
||||
kind: VaColumnKind,
|
||||
order?: 'YMD' | 'DMY' | 'MDY'
|
||||
): string | null {
|
||||
const s = String(value).trim()
|
||||
if (s === '') return null
|
||||
switch (kind) {
|
||||
@@ -207,17 +254,20 @@ export class VaFilterService {
|
||||
return Number.isFinite(num) ? `${num}` : null
|
||||
}
|
||||
case 'time': {
|
||||
if (this.isBareNumber(s)) return `${Number(s)}`
|
||||
const secs = this.timeToSeconds(s)
|
||||
return secs === null ? null : `${secs}`
|
||||
}
|
||||
case 'date': {
|
||||
const d = this.sasDateToJsDate(s)
|
||||
if (this.isBareNumber(s)) return `${Number(s)}`
|
||||
const d = this.parseDate(s, order)
|
||||
return d
|
||||
? `${this.helperService.convertJsDateToSasDate(d, 'days')}`
|
||||
: null
|
||||
}
|
||||
case 'datetime': {
|
||||
const d = this.sasDatetimeToJsDate(s)
|
||||
if (this.isBareNumber(s)) return `${Number(s)}`
|
||||
const d = this.parseDatetime(s, order)
|
||||
return d
|
||||
? `${this.helperService.convertJsDateToSasDate(d, 'seconds')}`
|
||||
: null
|
||||
@@ -238,29 +288,140 @@ export class VaFilterService {
|
||||
return h * 3600 + m * 60 + sec
|
||||
}
|
||||
|
||||
/** SAS "ddMMMyyyy" (e.g. 01JUL1960) -> local JS Date, or null. */
|
||||
private sasDateToJsDate(value: string): Date | null {
|
||||
const m = /^(\d{1,2})([A-Za-z]{3})(\d{4})$/.exec(value)
|
||||
if (!m) return null
|
||||
const monIdx = VaFilterService.SAS_MONTHS.indexOf(m[2].toUpperCase())
|
||||
if (monIdx < 0) return null
|
||||
return new Date(Number(m[3]), monIdx, Number(m[1]))
|
||||
/**
|
||||
* VA date string -> local JS Date, or null. VA sends no format metadata, so the
|
||||
* shape is sniffed. Accepted forms (separator may be `-`, `/`, or `.`):
|
||||
* - DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962)
|
||||
* - ISO/YMD "yyyy-MM-dd" (4-digit year first, e.g. 2025-03-31)
|
||||
* - year-last "dd/mm/yyyy" or "mm/dd/yyyy" (4-digit year last)
|
||||
*
|
||||
* Day/month order for the year-last form is resolved in priority order:
|
||||
* 1. by value — a group > 12 must be the day (ground truth);
|
||||
* 2. by `order` — the column's declared FMTNAME order (DMY/MDY/YMD);
|
||||
* 3. default DAY-FIRST — never US month-first — matching SAS's DDMMYY
|
||||
* default and non-US convention.
|
||||
* `new Date(y, m-1, d)` is local, matching HelperService.convertJsDateToSasDate
|
||||
* so the value is byte-identical to DC's own pickers. The constructed date is
|
||||
* round-trip validated to reject impossible values (e.g. 31/02) rather than
|
||||
* silently roll over.
|
||||
*/
|
||||
private parseDate(value: string, order?: 'YMD' | 'DMY' | 'MDY'): Date | null {
|
||||
return this.parseMonthNameDate(value) ?? this.parseNumericDate(value, order)
|
||||
}
|
||||
|
||||
/** SAS "ddMMMyyyy:HH:MM[:SS]" (e.g. 01JAN1960:00:06:05) -> local JS Date, or null. */
|
||||
private sasDatetimeToJsDate(value: string): Date | null {
|
||||
const m =
|
||||
/^(\d{1,2})([A-Za-z]{3})(\d{4}):(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value)
|
||||
/** DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962, 01AUG62) -> local Date, or null. */
|
||||
private parseMonthNameDate(value: string): Date | null {
|
||||
const m = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
|
||||
if (!m) return null
|
||||
const monIdx = VaFilterService.SAS_MONTHS.indexOf(m[2].toUpperCase())
|
||||
if (monIdx < 0) return null
|
||||
return new Date(
|
||||
Number(m[3]),
|
||||
monIdx,
|
||||
Number(m[1]),
|
||||
Number(m[4]),
|
||||
Number(m[5]),
|
||||
Number(m[6] || 0)
|
||||
const [, day, monthAbbr, year] = m
|
||||
const monthIndex = VaFilterService.SAS_MONTHS.indexOf(
|
||||
monthAbbr.toUpperCase()
|
||||
)
|
||||
if (monthIndex < 0) return null
|
||||
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric date with `-`, `/`, or `.` separators -> local Date, or null:
|
||||
* - year-first "yyyy-MM-dd" -> YMD directly;
|
||||
* - year-last "dd/mm/yyyy" or "mm/dd/yyyy" -> day/month resolved by
|
||||
* resolveDayMonth (value, then column order, then day-first default).
|
||||
* A 4-digit year (leading or trailing) is required to anchor the parse;
|
||||
* an all-2-digit value (e.g. "03/04/05") is too ambiguous and is dropped.
|
||||
*/
|
||||
private parseNumericDate(
|
||||
value: string,
|
||||
order?: 'YMD' | 'DMY' | 'MDY'
|
||||
): Date | null {
|
||||
const m = /^(\d{1,4})[-/.](\d{1,2})[-/.](\d{1,4})$/.exec(value)
|
||||
if (!m) return null
|
||||
const [, first, middle, last] = m
|
||||
const yearIsFirst = first.length === 4
|
||||
const yearIsLast = last.length === 4
|
||||
|
||||
if (yearIsFirst) {
|
||||
return this.buildDate(Number(first), Number(middle), Number(last))
|
||||
}
|
||||
if (!yearIsLast) return null
|
||||
|
||||
const { day, month } = this.resolveDayMonth(
|
||||
Number(first),
|
||||
Number(middle),
|
||||
order
|
||||
)
|
||||
return this.buildDate(Number(last), month, day)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the day/month of a year-last date whose two leading groups (`a`,
|
||||
* `b`) are in unknown order, in priority:
|
||||
* 1. by value — a group > 12 must be the day (ground truth);
|
||||
* 2. by `order` — only MDY flips to month-first; DMY/YMD keep day-first;
|
||||
* 3. default — DAY-first (`a` is the day), never US month-first.
|
||||
*/
|
||||
private resolveDayMonth(
|
||||
a: number,
|
||||
b: number,
|
||||
order?: 'YMD' | 'DMY' | 'MDY'
|
||||
): { day: number; month: number } {
|
||||
if (a > 12 && b <= 12) return { day: a, month: b }
|
||||
if (b > 12 && a <= 12) return { day: b, month: a }
|
||||
if (order === 'MDY') return { day: b, month: a }
|
||||
return { day: a, month: b }
|
||||
}
|
||||
|
||||
/** Local Date from 1-based month, round-trip validated (null if impossible). */
|
||||
private buildDate(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hours = 0,
|
||||
minutes = 0,
|
||||
seconds = 0
|
||||
): Date | null {
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null
|
||||
const d = new Date(year, month - 1, day, hours, minutes, seconds, 0)
|
||||
// Reject rollovers (e.g. 31 Feb -> 3 Mar) so a bad value is dropped, not
|
||||
// silently converted to the wrong SAS date.
|
||||
if (
|
||||
d.getFullYear() !== year ||
|
||||
d.getMonth() !== month - 1 ||
|
||||
d.getDate() !== day
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/**
|
||||
* VA datetime string -> local JS Date, or null. The date part is any shape
|
||||
* accepted by parseDate; the time part is "HH:MM[:SS]". They are joined by the
|
||||
* SAS ":" separator (e.g. 01JAN1960:05:13:03), an ISO "T", or a space
|
||||
* (e.g. 2025-03-31T05:13:03, 2025-03-31 05:13:03).
|
||||
*/
|
||||
private parseDatetime(
|
||||
value: string,
|
||||
order?: 'YMD' | 'DMY' | 'MDY'
|
||||
): Date | null {
|
||||
const m = /^(.+?)[ T:](\d{1,2}:\d{2}(?::\d{2})?)$/.exec(value)
|
||||
if (!m) return null
|
||||
const date = this.parseDate(m[1], order)
|
||||
if (!date) return null
|
||||
const t = m[2].split(':')
|
||||
date.setHours(Number(t[0]), Number(t[1]), Number(t[2] || 0), 0)
|
||||
return date
|
||||
}
|
||||
|
||||
/** True when the value is a bare (already-internal) SAS number: optional sign,
|
||||
* digits, optional fraction. No letters/separators — so never a formatted date. */
|
||||
private isBareNumber(value: string): boolean {
|
||||
return /^-?\d+(\.\d+)?$/.test(value) && Number.isFinite(Number(value))
|
||||
}
|
||||
|
||||
/** 2-digit SAS year -> 4-digit (SAS default cutoff: 00-19 -> 20xx, else 19xx). */
|
||||
private fullYear(year: string): number {
|
||||
if (year.length === 4) return Number(year)
|
||||
const y = Number(year)
|
||||
return y <= 19 ? 2000 + y : 1900 + y
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ import { DQRule, DQRuleTypes } from './models/dq-rules.model'
|
||||
import { getDqDataCols } from './utils/getDqDataCols'
|
||||
import { getNotNullDefault } from './utils/getNotNullDefault'
|
||||
import { mergeColsRules } from './utils/mergeColsRules'
|
||||
import { parseColType } from './utils/parseColType'
|
||||
import { parseColTypeRow } from './utils/parseColTypeRow'
|
||||
import { DELETE_RECORD_COLUMN_RULE } from './utils/deleteRecordColumnRule'
|
||||
import { EDIT_STATUS_COLUMN_RULE } from './utils/editStatusColumnRule'
|
||||
import { dqValidate } from './validations/dq-validation'
|
||||
import {
|
||||
datetimeValidator,
|
||||
@@ -28,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[] = []
|
||||
@@ -50,10 +55,43 @@ export class DcValidator {
|
||||
|
||||
this.sasparams = sasparams
|
||||
this.hotInstance = hotInstance
|
||||
this.rules = parseColType(sasparams.COLTYPE)
|
||||
// Each cols[i].COLTYPE is a single JSON-object string (one per column, as
|
||||
// of the vertical-array getdata.sas fix — issue #253), not the old
|
||||
// comma-joined sasparams.COLTYPE list. DELETE_RECORD_COLUMN_RULE is
|
||||
// injected separately — see its own doc comment for why.
|
||||
//
|
||||
// COLHEADERS is the authoritative grid order and the only safe key to
|
||||
// build against: it is the variable list of the *output* dataset, where
|
||||
// getdata.sas hoists the delete column and the PK to the front ("order
|
||||
// delete var and pk fields at start of table"), whereas cols[].VARNUM
|
||||
// describes the *source* table and cols[] itself can arrive sorted by
|
||||
// NAME. Those orders diverge whenever the PK isn't already the source's
|
||||
// first column, so rules are keyed by name and emitted in COLHEADERS
|
||||
// order — never paired positionally against cols[] or VARNUM.
|
||||
//
|
||||
// A column with no parseable COLTYPE falls back to a plain text rule
|
||||
// rather than being dropped: getRules() is consumed positionally against
|
||||
// COLHEADERS, so a missing entry would shift every later column.
|
||||
const ruleByName = new Map<string, DcValidation>([
|
||||
[DELETE_RECORD_COLUMN_RULE.data, { ...DELETE_RECORD_COLUMN_RULE }]
|
||||
])
|
||||
for (const col of cols) {
|
||||
const rule = parseColTypeRow(col.COLTYPE)
|
||||
if (rule) ruleByName.set(col.NAME, rule)
|
||||
}
|
||||
this.rules = sasparams.COLHEADERS.split(',').map(
|
||||
(name) => ruleByName.get(name) ?? { data: name }
|
||||
)
|
||||
this.rules = mergeColsRules(cols, this.rules, $dataFormats)
|
||||
this.rules = applyNumericFormats(this.rules)
|
||||
this.rules = mapIntlCellTypes(this.rules)
|
||||
|
||||
// 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(' ')
|
||||
@@ -174,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
|
||||
@@ -212,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
|
||||
@@ -349,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)
|
||||
@@ -368,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
|
||||
@@ -389,6 +532,27 @@ export class DcValidator {
|
||||
const colType = self.rules[i].type || ''
|
||||
let handsontableValid = null
|
||||
|
||||
// A row marked for delete is exempt from cell-level validation — its
|
||||
// values are about to be removed. Two deliberate exceptions:
|
||||
// - PK columns still validate: the key identifies WHICH record to
|
||||
// delete, and blank/invalid PKs are also skipped by the separate
|
||||
// duplicate-key check (validatePrimaryKeys), so this is the only
|
||||
// net that would otherwise catch them.
|
||||
// - Uniqueness checks are unaffected by construction: they run in a
|
||||
// separate pass (editor.component.ts validatePrimaryKeys) that
|
||||
// never goes through this validator.
|
||||
const markedForDelete =
|
||||
this.instance?.getDataAtRowProp?.(
|
||||
this.row,
|
||||
'_____DELETE__THIS__RECORD_____'
|
||||
) === 'Yes'
|
||||
|
||||
if (markedForDelete && !self.primaryKeys.includes(col || '')) {
|
||||
callback(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// We call handsontable predefined validators, if it returns false, we validate as FALSE
|
||||
// If return is true, we validate with our custom DQ validations
|
||||
|
||||
@@ -433,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})`)
|
||||
@@ -540,8 +708,11 @@ export class DcValidator {
|
||||
|
||||
if (rule.clsRule === 'EDIT') {
|
||||
rule.readOnly = false
|
||||
// Guard the -1: splice(-1, 1) drops the LAST hidden column rather than
|
||||
// no-op'ing, so an EDIT column that was never hidden would un-hide an
|
||||
// unrelated one (e.g. a column hidden by a HIDDEN DQ rule).
|
||||
const hiddenIndex = this.hiddenColumns.indexOf(index)
|
||||
this.hiddenColumns.splice(hiddenIndex, 1)
|
||||
if (hiddenIndex !== -1) this.hiddenColumns.splice(hiddenIndex, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import Handsontable from 'handsontable'
|
||||
import Core from 'handsontable/core'
|
||||
|
||||
export class CustomAutocompleteEditor
|
||||
extends Handsontable.editors.AutocompleteEditor
|
||||
{
|
||||
constructor(instance: Core) {
|
||||
constructor(instance: Handsontable.Core) {
|
||||
super(instance)
|
||||
}
|
||||
|
||||
@@ -13,8 +12,9 @@ export class CustomAutocompleteEditor
|
||||
}
|
||||
|
||||
// Listbox open
|
||||
open(event?: Event | undefined): void {
|
||||
super.open(event)
|
||||
// HOT 18's AutocompleteEditor.open() takes no arguments (was `event?: Event`).
|
||||
open(): void {
|
||||
super.open()
|
||||
|
||||
if (this.isCellNumeric()) {
|
||||
this.htContainer.classList.add('numericListbox')
|
||||
|
||||
@@ -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
|
||||
@@ -9,4 +11,8 @@ export interface Col {
|
||||
MEMLABEL: string
|
||||
DESC: string
|
||||
LONGDESC: string
|
||||
// Handsontable column-formatting spec (JSON-object string), one per column
|
||||
// — see parseColTypeRow.ts. Optional: getdata.sas LEFT JOINs this in, so
|
||||
// an unmatched row can leave it unset.
|
||||
COLTYPE?: string
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -23,11 +24,11 @@ describe('DC Validator', () => {
|
||||
// Check if COLS merged with FORMATS
|
||||
expect(cols[0].TYPE).toEqual('char')
|
||||
|
||||
// Get all
|
||||
// Get all — one rule per cols[] entry, plus the injected
|
||||
// DELETE_RECORD_COLUMN_RULE and EDIT_STATUS_COLUMN_RULE (neither is ever
|
||||
// present in cols[], see their own doc comments)
|
||||
const validationRules = dcValidator.getRules()
|
||||
expect(validationRules).toHaveSize(
|
||||
JSON.parse(`[${example_COLTYPE}]`).length
|
||||
)
|
||||
expect(validationRules).toHaveSize(example_cols.length + 2)
|
||||
|
||||
// Get col with notnull validation
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
@@ -66,6 +67,26 @@ describe('DC Validator', () => {
|
||||
const someDropdownHardRule = dcValidator.getRule('SOME_DROPDOWN_HARD')
|
||||
// Check strict - it is hardselect so strict should be true
|
||||
expect(someDropdownHardRule?.strict).toBeTrue()
|
||||
|
||||
// _____DELETE__THIS__RECORD_____ never has a cols[] entry (%mp_getcols
|
||||
// doesn't know about it — see DELETE_RECORD_COLUMN_RULE), but its rule
|
||||
// must still exist with its fixed dropdown shape, independent of cols
|
||||
const deleteRecordRule = dcValidator.getRule(
|
||||
'_____DELETE__THIS__RECORD_____'
|
||||
)
|
||||
expect(deleteRecordRule?.type).toEqual('dropdown')
|
||||
expect(deleteRecordRule?.source).toEqual(['No', 'Yes'])
|
||||
|
||||
// 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', () => {
|
||||
@@ -278,8 +299,659 @@ describe('DC Validator', () => {
|
||||
expect(dcValidator.getRoundDigits('SOME_SHORTNUM')).toEqual(2)
|
||||
expect(dcValidator.getRoundDigits('SOME_NUM')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('5 | orders rules by COLHEADERS when cols[] arrives in a different order', () => {
|
||||
// %mp_getcols/cols1 can serialize cols[] alphabetically by NAME rather
|
||||
// than in COLHEADERS order (confirmed against a real getdata.sas
|
||||
// response). getRules() is consumed positionally against headerColumns,
|
||||
// so rule order must follow COLHEADERS regardless of cols[]'s own order.
|
||||
const alphabeticalCols = [...example_cols].sort((a, b) =>
|
||||
a.NAME.localeCompare(b.NAME)
|
||||
)
|
||||
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
example_sasparams,
|
||||
example_dataformats,
|
||||
alphabeticalCols,
|
||||
example_dqRules,
|
||||
example_dqData
|
||||
)
|
||||
|
||||
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
|
||||
...example_sasparams.COLHEADERS.split(','),
|
||||
EDIT_STATUS_COLUMN_NAME
|
||||
])
|
||||
})
|
||||
|
||||
it("6 | keeps rules aligned with COLHEADERS when the PK is not the source table's first column", () => {
|
||||
// Regression test for the real-world misalignment: getdata.sas hoists the
|
||||
// delete column and PK to the front of the *output* dataset ("order
|
||||
// delete var and pk fields at start of table"), so COLHEADERS order and
|
||||
// cols[].VARNUM order (which describes the *source* table) diverge
|
||||
// whenever the PK isn't already the source's first column. Worst case (as
|
||||
// seen in the wild): the PK is the source's LAST column, so pairing rules
|
||||
// to headers by VARNUM shifts every single column by one.
|
||||
const cols: Col[] = [
|
||||
// deliberately alphabetical, as the backend serializes it
|
||||
makeCol('PRIMARY_KEY_FIELD', 6), // the PK — last in the source table
|
||||
makeCol('SOME_CHAR', 1),
|
||||
makeCol('SOME_DATE', 2),
|
||||
makeCol('SOME_DROPDOWN', 3),
|
||||
makeCol('SOME_NUM', 4),
|
||||
makeCol('SOME_TIME', 5)
|
||||
]
|
||||
const sasparams: SASParam = {
|
||||
...example_sasparams,
|
||||
// PK hoisted to the front by getdata.sas, ahead of VARNUM 1..5
|
||||
COLHEADERS:
|
||||
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,' +
|
||||
'SOME_DATE,SOME_DROPDOWN,SOME_NUM,SOME_TIME',
|
||||
PK: 'PRIMARY_KEY_FIELD'
|
||||
} as SASParam
|
||||
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
sasparams,
|
||||
{ vars: {} } as $DataFormats,
|
||||
cols,
|
||||
[],
|
||||
[]
|
||||
)
|
||||
|
||||
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
|
||||
...sasparams.COLHEADERS.split(','),
|
||||
EDIT_STATUS_COLUMN_NAME
|
||||
])
|
||||
})
|
||||
|
||||
it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => {
|
||||
// A dropped rule would shorten getRules() and shift every later column
|
||||
// against headerColumns. getdata.sas LEFT JOINs COLTYPE in, so an
|
||||
// unmatched row can legitimately leave it unset.
|
||||
const cols: Col[] = [
|
||||
makeCol('PRIMARY_KEY_FIELD', 1),
|
||||
{ ...makeCol('SOME_CHAR', 2), COLTYPE: undefined },
|
||||
{ ...makeCol('SOME_NUM', 3), COLTYPE: 'not valid json' }
|
||||
]
|
||||
const sasparams: SASParam = {
|
||||
...example_sasparams,
|
||||
COLHEADERS:
|
||||
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_NUM',
|
||||
PK: 'PRIMARY_KEY_FIELD'
|
||||
} as SASParam
|
||||
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
sasparams,
|
||||
{ vars: {} } as $DataFormats,
|
||||
cols,
|
||||
[],
|
||||
[]
|
||||
)
|
||||
|
||||
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
|
||||
...sasparams.COLHEADERS.split(','),
|
||||
EDIT_STATUS_COLUMN_NAME
|
||||
])
|
||||
})
|
||||
|
||||
it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => {
|
||||
// hiddenColumns.indexOf() returns -1 for a column that isn't hidden, and
|
||||
// splice(-1, 1) drops the LAST element instead of no-op'ing — so an EDIT
|
||||
// column used to silently un-hide a column hidden by a HIDDEN DQ rule.
|
||||
//
|
||||
// The EDIT column must not be the PK: setColumnLevelSecurity forces PK
|
||||
// columns to clsRule 'READ' before the EDIT branch is reached, which would
|
||||
// skip the splice entirely and leave this test passing against the bug.
|
||||
const cols: Col[] = [
|
||||
{ ...makeCol('PK_COL', 1), CLS_RULE: 'READ' },
|
||||
{ ...makeCol('SECRET_COL', 2), CLS_RULE: 'READ' },
|
||||
{ ...makeCol('OPEN_COL', 3), CLS_RULE: 'EDIT' }
|
||||
]
|
||||
const sasparams: SASParam = {
|
||||
...example_sasparams,
|
||||
CLS_FLAG: 1,
|
||||
COLHEADERS: '_____DELETE__THIS__RECORD_____,PK_COL,SECRET_COL,OPEN_COL',
|
||||
PK: 'PK_COL'
|
||||
} as SASParam
|
||||
const dqRules: DQRule[] = [
|
||||
{ BASE_COL: 'SECRET_COL', RULE_TYPE: 'HIDDEN', RULE_VALUE: '', X: 0 }
|
||||
]
|
||||
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
sasparams,
|
||||
{ vars: {} } as $DataFormats,
|
||||
cols,
|
||||
dqRules,
|
||||
[]
|
||||
)
|
||||
|
||||
// SECRET_COL is rules[2]; CLS_FLAG also hides the delete column (index 0).
|
||||
// OPEN_COL (rules[3]) is EDIT and was never hidden, so the splice guard is
|
||||
// what keeps SECRET_COL hidden here.
|
||||
expect(dcValidator.getHiddenColumns()).toContain(2)
|
||||
})
|
||||
|
||||
describe('9 | ignores cell validation on rows marked for delete', () => {
|
||||
// An invalid cell shouldn't block submission if its row
|
||||
// is about to be deleted anyway. The PK is exempt from the exemption — it
|
||||
// identifies WHICH record to delete, and a blank/invalid PK on a delete
|
||||
// row would also slip past the separate uniqueness check (which skips
|
||||
// blank PKs).
|
||||
//
|
||||
// The validator is a plain `function` invoked with cellProperties as
|
||||
// `this` (row + instance), so a minimal fake stands in for a real HOT
|
||||
// instance here — only `instance.getDataAtRowProp` is read by the guard.
|
||||
const fakeCellProps = (markedForDelete: boolean): any => ({
|
||||
row: 0,
|
||||
editor: undefined,
|
||||
instance: {
|
||||
getDataAtRowProp: (_row: number, prop: string) =>
|
||||
prop === '_____DELETE__THIS__RECORD_____'
|
||||
? markedForDelete
|
||||
? 'Yes'
|
||||
: 'No'
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
|
||||
const buildValidator = () =>
|
||||
new DcValidator(
|
||||
example_sasparams,
|
||||
example_dataformats,
|
||||
example_cols,
|
||||
example_dqRules,
|
||||
example_dqData
|
||||
)
|
||||
|
||||
it('exempts an invalid non-PK cell on a delete-marked row', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
fakeCellProps(true),
|
||||
'not a number',
|
||||
(valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('exempts a NOTNULL-empty non-PK cell on a delete-marked row', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
fakeCellProps(true),
|
||||
null,
|
||||
(valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('still rejects an invalid non-PK cell when the row is not marked for delete', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
fakeCellProps(false),
|
||||
'not a number',
|
||||
(valid: boolean) => {
|
||||
expect(valid).toBeFalse()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('still accepts a valid non-PK cell when the row is not marked for delete', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
fakeCellProps(false),
|
||||
2,
|
||||
(valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('still rejects an invalid PK cell even when the row is marked for delete', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')
|
||||
|
||||
pkRule!.validator!.call(
|
||||
fakeCellProps(true),
|
||||
'not a number',
|
||||
(valid: boolean) => {
|
||||
expect(valid).toBeFalse()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('treats the edit-record modal path (dummy HOT instance, no data) as not marked for delete', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
// executeHotValidator builds cellProperties against a real but
|
||||
// data-less Handsontable instance — getDataAtRowProp() there returns
|
||||
// undefined, never 'Yes', so the exemption never applies in the modal.
|
||||
dcValidator.executeHotValidator(someNumRule!, null, (valid: boolean) => {
|
||||
expect(valid).toBeFalse()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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. */
|
||||
const makeCol = (name: string, varnum: number): Col =>
|
||||
({
|
||||
NAME: name,
|
||||
VARNUM: varnum,
|
||||
LABEL: name,
|
||||
FMTNAME: '',
|
||||
DDTYPE: 'C',
|
||||
TYPE: '',
|
||||
CLS_RULE: 'READ',
|
||||
MEMLABEL: '',
|
||||
DESC: '',
|
||||
LONGDESC: '',
|
||||
COLTYPE: `{"data":"${name}"}`
|
||||
}) as Col
|
||||
|
||||
const example_dqData = [
|
||||
{
|
||||
BASE_COL: 'SOME_DROPDOWN',
|
||||
@@ -383,10 +1055,17 @@ const example_dqRules: any = [
|
||||
}
|
||||
]
|
||||
|
||||
// One row per column, each carrying its own COLTYPE JSON-object string — the
|
||||
// vertical-array shape from issue #253's getdata.sas fix. Order: SOME_DROPDOWN
|
||||
// then SOME_NUM first (the `cols[0].TYPE` assertion above is positional),
|
||||
// remaining columns in the same order the old concatenated
|
||||
// sasparams.COLTYPE used to list them. _____DELETE__THIS__RECORD_____ is
|
||||
// deliberately absent — DcValidator injects its rule directly (see
|
||||
// DELETE_RECORD_COLUMN_RULE), since %mp_getcols never has a row for it.
|
||||
const example_cols = [
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'CHARACTER',
|
||||
DDTYPE: 'C',
|
||||
DESC: 'dropdown_desc',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
@@ -394,11 +1073,12 @@ const example_cols = [
|
||||
LONGDESC: '',
|
||||
MEMLABEL: '',
|
||||
NAME: 'SOME_DROPDOWN',
|
||||
VARNUM: 3
|
||||
VARNUM: 3,
|
||||
COLTYPE: '{"data":"SOME_DROPDOWN"}'
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'NUMERIC',
|
||||
DDTYPE: 'N',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
@@ -406,77 +1086,157 @@ const example_cols = [
|
||||
LONGDESC: '',
|
||||
MEMLABEL: '',
|
||||
NAME: 'SOME_NUM',
|
||||
VARNUM: 4
|
||||
VARNUM: 4,
|
||||
COLTYPE: '{"data":"SOME_NUM","type":"numeric","format":"0"}'
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'N',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
LABEL: 'PRIMARY_KEY_FIELD',
|
||||
LONGDESC: '',
|
||||
MEMLABEL: '',
|
||||
NAME: 'PRIMARY_KEY_FIELD',
|
||||
VARNUM: 1,
|
||||
COLTYPE: '{"data":"PRIMARY_KEY_FIELD","type":"numeric","format":"0"}'
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'C',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
LABEL: 'SOME_CHAR',
|
||||
LONGDESC: '',
|
||||
MEMLABEL: '',
|
||||
NAME: 'SOME_CHAR',
|
||||
VARNUM: 2,
|
||||
COLTYPE: '{"data":"SOME_CHAR"}'
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'C',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
LABEL: 'SOME_CHAR_LOW',
|
||||
LONGDESC: '',
|
||||
MEMLABEL: '',
|
||||
NAME: 'SOME_CHAR_LOW',
|
||||
VARNUM: 5,
|
||||
COLTYPE: '{"data":"SOME_CHAR_LOW"}'
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'C',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
LABEL: 'SOME_CHAR_ANY',
|
||||
LONGDESC: '',
|
||||
MEMLABEL: '',
|
||||
NAME: 'SOME_CHAR_ANY',
|
||||
VARNUM: 6,
|
||||
COLTYPE: '{"data":"SOME_CHAR_ANY"}'
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'C',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
LABEL: 'SOME_DROPDOWN_HARD',
|
||||
LONGDESC: '',
|
||||
MEMLABEL: '',
|
||||
NAME: 'SOME_DROPDOWN_HARD',
|
||||
VARNUM: 7,
|
||||
COLTYPE: '{"data":"SOME_DROPDOWN_HARD"}'
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'DATE',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
LABEL: 'SOME_DATE',
|
||||
LONGDESC: '',
|
||||
MEMLABEL: '',
|
||||
NAME: 'SOME_DATE',
|
||||
VARNUM: 8,
|
||||
COLTYPE: '{"data":"SOME_DATE","type":"date"}'
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'DATETIME',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
LABEL: 'SOME_DATETIME',
|
||||
LONGDESC: '',
|
||||
MEMLABEL: '',
|
||||
NAME: 'SOME_DATETIME',
|
||||
VARNUM: 9,
|
||||
COLTYPE: '{"data":"SOME_DATETIME","type":"datetime"}'
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'TIME',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
LABEL: 'SOME_TIME',
|
||||
LONGDESC: '',
|
||||
MEMLABEL: '',
|
||||
NAME: 'SOME_TIME',
|
||||
VARNUM: 10,
|
||||
COLTYPE: '{"data":"SOME_TIME","type":"time"}'
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'N',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
LABEL: 'SOME_SHORTNUM',
|
||||
LONGDESC: '',
|
||||
MEMLABEL: '',
|
||||
NAME: 'SOME_SHORTNUM',
|
||||
VARNUM: 11,
|
||||
COLTYPE: '{"data":"SOME_SHORTNUM","type":"numeric","format":"0"}'
|
||||
},
|
||||
{
|
||||
CLS_RULE: 'READ',
|
||||
DDTYPE: 'N',
|
||||
DESC: '',
|
||||
TYPE: '',
|
||||
FMTNAME: '',
|
||||
LABEL: 'SOME_BESTNUM',
|
||||
LONGDESC: '',
|
||||
MEMLABEL: '',
|
||||
NAME: 'SOME_BESTNUM',
|
||||
VARNUM: 12,
|
||||
COLTYPE: '{"data":"SOME_BESTNUM","type":"numeric","format":"0"}'
|
||||
}
|
||||
]
|
||||
|
||||
const example_COLTYPE = `
|
||||
{
|
||||
"data":"_____DELETE__THIS__RECORD_____",
|
||||
"type":"dropdown",
|
||||
"source":[
|
||||
"No",
|
||||
"Yes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data":"PRIMARY_KEY_FIELD",
|
||||
"type":"numeric",
|
||||
"format":"0"
|
||||
},
|
||||
{
|
||||
"data":"SOME_CHAR"
|
||||
},
|
||||
{
|
||||
"data":"SOME_CHAR_LOW"
|
||||
},
|
||||
{
|
||||
"data":"SOME_CHAR_ANY"
|
||||
},
|
||||
{
|
||||
"data":"SOME_DROPDOWN"
|
||||
},
|
||||
{
|
||||
"data":"SOME_DROPDOWN_HARD"
|
||||
},
|
||||
{
|
||||
"data":"SOME_NUM",
|
||||
"type":"numeric",
|
||||
"format":"0"
|
||||
},
|
||||
{
|
||||
"data":"SOME_DATE",
|
||||
"type":"date"
|
||||
},
|
||||
{
|
||||
"data":"SOME_DATETIME",
|
||||
"type":"datetime"
|
||||
},
|
||||
{
|
||||
"data":"SOME_TIME",
|
||||
"type":"time"
|
||||
},
|
||||
{
|
||||
"data":"SOME_SHORTNUM",
|
||||
"type":"numeric",
|
||||
"format":"0"
|
||||
},
|
||||
{
|
||||
"data":"SOME_BESTNUM",
|
||||
"type":"numeric",
|
||||
"format":"0"
|
||||
}`
|
||||
|
||||
// COLHEADERS mirrors a real getdata.sas response: the *output* dataset's
|
||||
// variable list, i.e. the delete column and PK hoisted to the front, then the
|
||||
// remaining source columns in VARNUM order. It drives rule order, so it must
|
||||
// list every example_cols entry.
|
||||
const example_sasparams = {
|
||||
CLS_FLAG: 0,
|
||||
COLHEADERS: 'head1 head2',
|
||||
COLTYPE: example_COLTYPE,
|
||||
COLHEADERS:
|
||||
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,' +
|
||||
'SOME_NUM,SOME_CHAR_LOW,SOME_CHAR_ANY,SOME_DROPDOWN_HARD,SOME_DATE,' +
|
||||
'SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM',
|
||||
DTTMVARS: 'dttm vars',
|
||||
DTVARS: 'dt vars',
|
||||
FILTER_TEXT: 'filter text',
|
||||
LOADTYPE: 'load type',
|
||||
PK: 'primary key',
|
||||
PKCNT: 0,
|
||||
PK: 'PRIMARY_KEY_FIELD',
|
||||
PKCNT: 1,
|
||||
RK_FLAG: 0,
|
||||
TMVARS: 'string'
|
||||
}
|
||||
@@ -507,6 +1267,24 @@ const example_dataformats = {
|
||||
length: '128',
|
||||
type: 'char'
|
||||
},
|
||||
SOME_CHAR_LOW: {
|
||||
format: '$128.',
|
||||
label: 'SOME_CHAR_LOW',
|
||||
length: '128',
|
||||
type: 'char'
|
||||
},
|
||||
SOME_CHAR_ANY: {
|
||||
format: '$128.',
|
||||
label: 'SOME_CHAR_ANY',
|
||||
length: '128',
|
||||
type: 'char'
|
||||
},
|
||||
SOME_DROPDOWN_HARD: {
|
||||
format: '$128.',
|
||||
label: 'SOME_DROPDOWN_HARD',
|
||||
length: '128',
|
||||
type: 'char'
|
||||
},
|
||||
SOME_NUM: {
|
||||
format: 'best.',
|
||||
label: 'SOME_NUM',
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { HotColumnSettings } from '../models/dc-validation.model'
|
||||
import { parseColType } from '../utils/parseColType'
|
||||
|
||||
describe('DC Validator - parse col type', () => {
|
||||
it('should return array of parsed json', () => {
|
||||
const colTypeString =
|
||||
'{"data":"test","test2":"test2"}, {"data":"test3","test4":"test4"}'
|
||||
const expected: HotColumnSettings[] = [
|
||||
{ data: 'test', test2: 'test2' },
|
||||
{ data: 'test3', test4: 'test4' }
|
||||
]
|
||||
|
||||
expect(parseColType(colTypeString)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should return empty array for invalid json', () => {
|
||||
const colTypeString =
|
||||
'{"test":"test""test:"test2"}, {"test3":"test3","test4":"test4"}'
|
||||
const expected: HotColumnSettings[] = []
|
||||
|
||||
expect(parseColType(colTypeString)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { HotColumnSettings } from '../models/dc-validation.model'
|
||||
import { parseColTypeRow } from '../utils/parseColTypeRow'
|
||||
|
||||
describe('DC Validator - parse col type row', () => {
|
||||
it('parses a single JSON object', () => {
|
||||
const expected: HotColumnSettings = { data: 'test', type: 'numeric' }
|
||||
|
||||
expect(parseColTypeRow('{"data":"test","type":"numeric"}')).toEqual(
|
||||
expected
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined for undefined input (LEFT JOIN can leave it unset)', () => {
|
||||
expect(parseColTypeRow(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for an empty string', () => {
|
||||
expect(parseColTypeRow('')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for malformed JSON, without throwing', () => {
|
||||
expect(parseColTypeRow('{not valid')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,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,16 @@
|
||||
import { DcValidation } from '../models/dc-validation.model'
|
||||
|
||||
/**
|
||||
* `_____DELETE__THIS__RECORD_____` is a client-side UI concept (the delete
|
||||
* checkbox column) that getdata.sas synthesizes into vars3/vars4 for display
|
||||
* purposes only — it's never a real column of the underlying dataset, so
|
||||
* `%mp_getcols` (which builds `cols1`, the left side of the join into
|
||||
* `cols`) never has a row for it, and its rule can no longer travel via
|
||||
* `cols[].COLTYPE` (see issue #253's vertical-array fix). The rule is fixed
|
||||
* and data-independent, so it's injected directly here instead.
|
||||
*/
|
||||
export const DELETE_RECORD_COLUMN_RULE: DcValidation = {
|
||||
data: '_____DELETE__THIS__RECORD_____',
|
||||
type: 'dropdown',
|
||||
source: ['No', 'Yes']
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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()).
|
||||
*
|
||||
* Heavily decorated with underscores, same convention as
|
||||
* DELETE_RECORD_COLUMN_RULE - a plain 'EDIT_STATUS' could collide with a
|
||||
* real column of that name in the actual dataset, which would silently
|
||||
* overwrite that column's real data (see editor.component.ts's seeding
|
||||
* loop) and drop it from the submit payload entirely.
|
||||
*/
|
||||
export const EDIT_STATUS_COLUMN_NAME = '_____EDIT_STATUS_____'
|
||||
|
||||
export const EDIT_STATUS_COLUMN_RULE: DcValidation = {
|
||||
data: EDIT_STATUS_COLUMN_NAME,
|
||||
type: 'text',
|
||||
readOnly: true
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { hasFormulaRules } from './hasFormulaRules'
|
||||
|
||||
const rule = (overrides: Partial<DQRule>): DQRule => ({
|
||||
BASE_COL: 'SOME_COL',
|
||||
RULE_TYPE: 'NOTNULL',
|
||||
RULE_VALUE: '',
|
||||
X: 0,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('hasFormulaRules', () => {
|
||||
it('is false for an empty rule set', () => {
|
||||
expect(hasFormulaRules([])).toBeFalse()
|
||||
})
|
||||
|
||||
it('is false when no rule is HARDFORMULA/SOFTFORMULA', () => {
|
||||
const rules = [
|
||||
rule({ RULE_TYPE: 'NOTNULL' }),
|
||||
rule({ RULE_TYPE: 'HARDREGEX' }),
|
||||
rule({ RULE_TYPE: 'SOFTREGEX' })
|
||||
]
|
||||
|
||||
expect(hasFormulaRules(rules)).toBeFalse()
|
||||
})
|
||||
|
||||
it('is true when a HARDFORMULA rule is present', () => {
|
||||
const rules = [rule({ RULE_TYPE: 'HARDFORMULA' })]
|
||||
|
||||
expect(hasFormulaRules(rules)).toBeTrue()
|
||||
})
|
||||
|
||||
it('is true when a SOFTFORMULA rule is present', () => {
|
||||
const rules = [rule({ RULE_TYPE: 'SOFTFORMULA' })]
|
||||
|
||||
expect(hasFormulaRules(rules)).toBeTrue()
|
||||
})
|
||||
|
||||
it('is true when a formula rule is mixed in with unrelated rules', () => {
|
||||
const rules = [
|
||||
rule({ RULE_TYPE: 'NOTNULL' }),
|
||||
rule({ RULE_TYPE: 'SOFTFORMULA' }),
|
||||
rule({ RULE_TYPE: 'HARDREGEX' })
|
||||
]
|
||||
|
||||
expect(hasFormulaRules(rules)).toBeTrue()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
|
||||
/**
|
||||
* Whether any rule in the set is HARDFORMULA/SOFTFORMULA - used to gate
|
||||
* turning on Handsontable's `formulas` plugin (HyperFormula engine) only
|
||||
* when a table actually uses it, rather than for every grid.
|
||||
*/
|
||||
export const hasFormulaRules = (dqRules: DQRule[]): boolean =>
|
||||
dqRules.some(
|
||||
(rule) =>
|
||||
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
import { isRegexRuleExempt } from './isRegexRuleExempt'
|
||||
|
||||
describe('isRegexRuleExempt', () => {
|
||||
it('exempts blank/undefined/null on any column type', () => {
|
||||
expect(isRegexRuleExempt('')).toBeTrue()
|
||||
expect(isRegexRuleExempt(undefined)).toBeTrue()
|
||||
expect(isRegexRuleExempt(null)).toBeTrue()
|
||||
expect(isRegexRuleExempt('', true)).toBeTrue()
|
||||
})
|
||||
|
||||
it('exempts the plain SAS missing (".") on numeric columns', () => {
|
||||
expect(isRegexRuleExempt('.', true)).toBeTrue()
|
||||
})
|
||||
|
||||
it('does not exempt special missings on numeric columns (they are deliberately-set values)', () => {
|
||||
expect(isRegexRuleExempt('.a', true)).toBeFalse()
|
||||
expect(isRegexRuleExempt('._', true)).toBeFalse()
|
||||
expect(isRegexRuleExempt('_', true)).toBeFalse()
|
||||
expect(isRegexRuleExempt('d', true)).toBeFalse()
|
||||
})
|
||||
|
||||
it('exempts nothing but blank on character columns', () => {
|
||||
expect(isRegexRuleExempt('.')).toBeFalse()
|
||||
expect(isRegexRuleExempt('.a')).toBeFalse()
|
||||
expect(isRegexRuleExempt('_')).toBeFalse()
|
||||
})
|
||||
|
||||
it('does not exempt a bare single letter on character columns (real value, not a missing)', () => {
|
||||
// A bare single letter is real character data, not a SAS special
|
||||
// missing - it must reach the pattern, not be exempted.
|
||||
expect(isRegexRuleExempt('d')).toBeFalse()
|
||||
expect(isRegexRuleExempt('z')).toBeFalse()
|
||||
expect(isRegexRuleExempt('A')).toBeFalse()
|
||||
})
|
||||
|
||||
it('does not exempt an ordinary value', () => {
|
||||
expect(isRegexRuleExempt('ABC123')).toBeFalse()
|
||||
expect(isRegexRuleExempt('ABC123', true)).toBeFalse()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* HARDREGEX/SOFTREGEX both skip pattern-matching for:
|
||||
*
|
||||
* - blank values (undefined, null, '') on any column type - enforcing
|
||||
* populated values is NOTNULL's job, not the pattern's; and
|
||||
* - the plain SAS numeric missing (".") on numeric columns - it
|
||||
* represents the absence of a value, same as blank.
|
||||
*
|
||||
* SPECIAL missings (.a-.z, ._, bare letters) are NOT exempt, even on
|
||||
* numeric columns: being deliberately set, they are real values the
|
||||
* pattern is meant to check. This also means isSpecialMissing from
|
||||
* @sasjs/utils (which would match them, with an optional dot) is
|
||||
* deliberately not used here.
|
||||
*/
|
||||
export const isRegexRuleExempt = (
|
||||
value: any,
|
||||
isNumeric: boolean = false
|
||||
): boolean => {
|
||||
if (value === undefined || value === null || value === '') return true
|
||||
|
||||
return isNumeric && value === '.'
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export const mergeColsRules = (
|
||||
: colFormats.type
|
||||
|
||||
if (rule && col.DESC) rule.desc = col.DESC
|
||||
if (rule && colFormats.length) rule.length = parseInt(colFormats.length)
|
||||
if (rule && colFormats?.length) rule.length = parseInt(colFormats.length)
|
||||
if (rule && col.CLS_RULE) rule.clsRule = col.CLS_RULE
|
||||
if (rule && colFormats?.type) rule.sasType = colFormats.type
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { HotColumnSettings } from '../models/dc-validation.model'
|
||||
|
||||
/**
|
||||
* From sas we get a string instead of array of objects, in that string `[]` are missing so
|
||||
* before parsing JSON we need to add them.
|
||||
*
|
||||
* @param coltype string (objects) that comes from sas
|
||||
* @returns JSON Handsontable.ColumnSettings[]
|
||||
*/
|
||||
export const parseColType = (coltype: string): HotColumnSettings[] => {
|
||||
try {
|
||||
return JSON.parse(`[${coltype}]`)
|
||||
} catch (err: any) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { HotColumnSettings } from '../models/dc-validation.model'
|
||||
|
||||
/**
|
||||
* Parses a single column's `COLTYPE` JSON-object string (one per `cols` row,
|
||||
* as of the vertical-array getdata.sas fix — see issue #253). Unlike the
|
||||
* previous comma-joined `sasparams.COLTYPE` format, each row is independent:
|
||||
* a malformed or missing value here only drops that one column's rule,
|
||||
* rather than every column's.
|
||||
*
|
||||
* @param coltype a single column's COLTYPE JSON string, or undefined (the
|
||||
* LEFT JOIN in getdata.sas can leave it unset for unmatched rows)
|
||||
*/
|
||||
export const parseColTypeRow = (
|
||||
coltype: string | undefined
|
||||
): HotColumnSettings | undefined => {
|
||||
if (!coltype) return undefined
|
||||
|
||||
try {
|
||||
return JSON.parse(coltype)
|
||||
} catch (err: any) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { parseFormulaRule, FormulaVariableContext } from './parseFormulaRule'
|
||||
import { EDIT_STATUS_COLUMN_NAME } from './editStatusColumnRule'
|
||||
|
||||
const context = (overrides: Partial<FormulaVariableContext> = {}) => ({
|
||||
columnNames: ['ITEM', 'PRICE', 'VOLUME', 'REVENUE'],
|
||||
rowIndex: 0,
|
||||
userName: 'sasdemo',
|
||||
origValue: 'sasinstaller',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('parseFormulaRule', () => {
|
||||
it("substitutes column-name variables with this row's cell references (issue's own PRICE/VOLUME example)", () => {
|
||||
expect(parseFormulaRule('PRICE * VOLUME', context())).toEqual('=B1 * C1')
|
||||
})
|
||||
|
||||
it('uses the row-relative reference for a later row', () => {
|
||||
expect(
|
||||
parseFormulaRule('PRICE * VOLUME', context({ rowIndex: 1 }))
|
||||
).toEqual('=B2 * C2')
|
||||
})
|
||||
|
||||
it('requires a leading/trailing blank (or string edge) around a variable - no match without it', () => {
|
||||
// No spaces around '*' - PRICE/VOLUME here should NOT be recognized as
|
||||
// variables (the issue's own rule: a named variable must have a
|
||||
// leading and trailing blank to avoid clashing with function names).
|
||||
expect(parseFormulaRule('PRICE*VOLUME', context())).toEqual('=PRICE*VOLUME')
|
||||
})
|
||||
|
||||
it("does not substitute a variable name matched inside a function call with no surrounding blanks (the issue's MATCH() clash example)", () => {
|
||||
// MATCH is not one of our column names here, but this proves adjacency
|
||||
// to parens alone doesn't trigger substitution - only literal
|
||||
// surrounding whitespace (or string start/end) does.
|
||||
expect(parseFormulaRule('MATCH(PRICE)', context())).toEqual('=MATCH(PRICE)')
|
||||
})
|
||||
|
||||
it("leaves variable occurrences inside quoted strings untouched (the issue's own ITEM example)", () => {
|
||||
expect(parseFormulaRule('ITEM & " string ITEM "', context())).toEqual(
|
||||
'=A1 & " string ITEM "'
|
||||
)
|
||||
})
|
||||
|
||||
it('substitutes DC.USER_NAME with the quoted, literal current username', () => {
|
||||
expect(
|
||||
parseFormulaRule('DC.USER_NAME', context({ userName: 'sasdemo' }))
|
||||
).toEqual('=\"sasdemo\"')
|
||||
})
|
||||
|
||||
it('substitutes DC.ORIG_VALUE with the quoted, literal original cell value', () => {
|
||||
expect(
|
||||
parseFormulaRule('DC.ORIG_VALUE', context({ origValue: 'sasinstaller' }))
|
||||
).toEqual('=\"sasinstaller\"')
|
||||
})
|
||||
|
||||
it('does not substitute DC.USER_NAME/DC.ORIG_VALUE inside quoted strings either', () => {
|
||||
expect(parseFormulaRule('"DC.USER_NAME"', context())).toEqual(
|
||||
'=\"DC.USER_NAME\"'
|
||||
)
|
||||
})
|
||||
|
||||
it('substitutes DC.ROW_STATUS with a cell reference to the EDIT_STATUS column, not a quoted literal', () => {
|
||||
expect(
|
||||
parseFormulaRule(
|
||||
'IF( DC.ROW_STATUS ="U","unchanged","changed")',
|
||||
context({
|
||||
columnNames: ['ITEM', 'PRICE', 'VOLUME', EDIT_STATUS_COLUMN_NAME],
|
||||
rowIndex: 0
|
||||
})
|
||||
)
|
||||
).toEqual('=IF( D1 ="U","unchanged","changed")')
|
||||
})
|
||||
|
||||
it('uses the row-relative reference for DC.ROW_STATUS on a later row', () => {
|
||||
expect(
|
||||
parseFormulaRule(
|
||||
'DC.ROW_STATUS',
|
||||
context({
|
||||
columnNames: ['ITEM', EDIT_STATUS_COLUMN_NAME],
|
||||
rowIndex: 4
|
||||
})
|
||||
)
|
||||
).toEqual('=B5')
|
||||
})
|
||||
|
||||
it('leaves DC.ROW_STATUS untouched when the EDIT_STATUS column is not present', () => {
|
||||
expect(parseFormulaRule('DC.ROW_STATUS', context())).toEqual(
|
||||
'=DC.ROW_STATUS'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not substitute DC.ROW_STATUS inside quoted strings either', () => {
|
||||
expect(parseFormulaRule('"DC.ROW_STATUS"', context())).toEqual(
|
||||
'=\"DC.ROW_STATUS\"'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import Handsontable from 'handsontable'
|
||||
import { EDIT_STATUS_COLUMN_NAME } from './editStatusColumnRule'
|
||||
|
||||
export interface FormulaVariableContext {
|
||||
/** Column names in grid order - index drives the cell-reference letter. */
|
||||
columnNames: string[]
|
||||
/** 0-based physical row index - drives the cell-reference row number. */
|
||||
rowIndex: number
|
||||
/** Current logged-in user - DC.USER_NAME resolves to this, quoted. */
|
||||
userName: string
|
||||
/** Original (pre-edit) value of the cell this rule applies to - DC.ORIG_VALUE resolves to this, quoted. */
|
||||
origValue: string | number | undefined
|
||||
}
|
||||
|
||||
const escapeRegExpMetacharacters = (text: string): string =>
|
||||
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
/**
|
||||
* Replaces `token` with `replacement` wherever it has a leading and
|
||||
* trailing blank (a literal space, or the start/end of this span). This
|
||||
* lets `PRICE * VOLUME` substitute correctly while `MATCH(PRICE)` (no
|
||||
* surrounding blanks) does not, without needing to know anything about
|
||||
* function names.
|
||||
*/
|
||||
const substituteBoundedToken = (
|
||||
text: string,
|
||||
token: string,
|
||||
replacement: string
|
||||
): string => {
|
||||
const pattern = new RegExp(
|
||||
`(?<=^|\\s)${escapeRegExpMetacharacters(token)}(?=$|\\s)`,
|
||||
'g'
|
||||
)
|
||||
|
||||
return text.replace(pattern, replacement)
|
||||
}
|
||||
|
||||
const quoteLiteral = (value: string | number | undefined): string =>
|
||||
`"${value ?? ''}"`
|
||||
|
||||
export const parseFormulaRule = (
|
||||
ruleValue: string,
|
||||
context: FormulaVariableContext
|
||||
): string => {
|
||||
const quotedSpanPattern = /"[^"]*"|'[^']*'/g
|
||||
let result = ''
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
const substituteUnquotedSpan = (span: string): string => {
|
||||
let substituted = substituteBoundedToken(
|
||||
span,
|
||||
'DC.USER_NAME',
|
||||
quoteLiteral(context.userName)
|
||||
)
|
||||
substituted = substituteBoundedToken(
|
||||
substituted,
|
||||
'DC.ORIG_VALUE',
|
||||
quoteLiteral(context.origValue)
|
||||
)
|
||||
|
||||
// Unlike DC.USER_NAME/DC.ORIG_VALUE, DC.ROW_STATUS resolves to a real
|
||||
// cell reference (not a quoted literal) - it points at the EDIT_STATUS
|
||||
// column, which holds this row's live M/A/D/U classification, so other
|
||||
// formulas can react to it, e.g. `=if(A1!='U',"sasdemo","sasinstaller")`.
|
||||
const rowStatusColIndex = context.columnNames.indexOf(
|
||||
EDIT_STATUS_COLUMN_NAME
|
||||
)
|
||||
if (rowStatusColIndex !== -1) {
|
||||
const rowStatusCellRef = `${Handsontable.helper.spreadsheetColumnLabel(rowStatusColIndex)}${context.rowIndex + 1}`
|
||||
substituted = substituteBoundedToken(
|
||||
substituted,
|
||||
'DC.ROW_STATUS',
|
||||
rowStatusCellRef
|
||||
)
|
||||
}
|
||||
|
||||
context.columnNames.forEach((columnName, columnIndex) => {
|
||||
const cellRef = `${Handsontable.helper.spreadsheetColumnLabel(columnIndex)}${context.rowIndex + 1}`
|
||||
substituted = substituteBoundedToken(substituted, columnName, cellRef)
|
||||
})
|
||||
|
||||
return substituted
|
||||
}
|
||||
|
||||
while ((match = quotedSpanPattern.exec(ruleValue))) {
|
||||
result += substituteUnquotedSpan(ruleValue.slice(lastIndex, match.index))
|
||||
result += match[0]
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
result += substituteUnquotedSpan(ruleValue.slice(lastIndex))
|
||||
|
||||
return `=${result}`
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { parseRegexRule } from './parseRegexRule'
|
||||
|
||||
describe('parseRegexRule', () => {
|
||||
it('treats a bare pattern (no delimiters) exactly as before', () => {
|
||||
const regex = parseRegexRule('^[A-Z]{3}\\d{4}$')
|
||||
|
||||
expect(regex.test('ABC1234')).toBeTrue()
|
||||
expect(regex.test('abc1234')).toBeFalse()
|
||||
})
|
||||
|
||||
it('parses a delimited pattern with no flags, case-sensitively', () => {
|
||||
const regex = parseRegexRule('/\\b(the|data)\\b/')
|
||||
|
||||
expect(regex.test('this is dummy data')).toBeTrue()
|
||||
expect(regex.test('THE WIND WAS BLOWING')).toBeFalse()
|
||||
})
|
||||
|
||||
it('parses a delimited pattern with the i flag, case-insensitively', () => {
|
||||
const regex = parseRegexRule('/\\b(the|data)\\b/i')
|
||||
|
||||
expect(regex.test('THIS IS DUMMY DATA')).toBeTrue()
|
||||
expect(regex.test('The wind was blowing')).toBeTrue()
|
||||
expect(regex.test('nothing relevant here')).toBeFalse()
|
||||
})
|
||||
|
||||
it('extracts source and flags correctly from a delimited pattern', () => {
|
||||
const regex = parseRegexRule('/^[A-Z]+$/g')
|
||||
|
||||
expect(regex.source).toEqual('^[A-Z]+$')
|
||||
expect(regex.flags).toEqual('g')
|
||||
})
|
||||
|
||||
it('treats a bare pattern containing an internal slash as a literal, not a delimited form', () => {
|
||||
const regex = parseRegexRule('a/b')
|
||||
|
||||
expect(regex.test('a/b')).toBeTrue()
|
||||
expect(regex.test('ab')).toBeFalse()
|
||||
})
|
||||
|
||||
it('throws for an unterminated delimited pattern, same as a bad bare pattern would', () => {
|
||||
expect(() => parseRegexRule('/[unterminated/')).toThrow()
|
||||
})
|
||||
|
||||
// parseRegexRule strips PRX's /pattern/flags delimiter wrapper and
|
||||
// translates the three PRX-only constructs below to a JS equivalent. These
|
||||
// three were picked because they're mechanical, low-risk translations with
|
||||
// one unambiguous JS equivalent. Atomic groups and possessive quantifiers are
|
||||
// NOT translated - both would need a lookahead+backreference rewrite that
|
||||
// changes the pattern's capture-group numbering, which is a real
|
||||
// correctness risk for comparatively rare constructs, so they're left as a
|
||||
// documented limitation below instead.
|
||||
describe('translated PRX-only syntax', () => {
|
||||
it('hoists a leading (?i) inline modifier into the i flag', () => {
|
||||
const regex = parseRegexRule('/(?i)abc/')
|
||||
|
||||
expect(regex.test('ABC')).toBeTrue()
|
||||
expect(regex.test('abc')).toBeTrue()
|
||||
})
|
||||
|
||||
it('merges a leading (?i) with flags already present after the closing delimiter', () => {
|
||||
const regex = parseRegexRule('/(?i)^[a-z]+$/g')
|
||||
|
||||
expect(regex.flags).toContain('i')
|
||||
expect(regex.flags).toContain('g')
|
||||
expect(regex.test('ABC')).toBeTrue()
|
||||
})
|
||||
|
||||
it('does not add a duplicate i flag when (?i) and the trailing /i are both present', () => {
|
||||
expect(() => parseRegexRule('/(?i)abc/i')).not.toThrow()
|
||||
})
|
||||
|
||||
it('treats \\Q...\\E as a literal, metacharacter-escaped sequence', () => {
|
||||
const regex = parseRegexRule('/\\Qa+b*c\\E/')
|
||||
|
||||
expect(regex.test('a+b*c')).toBeTrue()
|
||||
// Without the \Q...\E escaping, '+' and '*' would be quantifiers, so
|
||||
// this would also match a literal "c" alone. It shouldn't.
|
||||
expect(regex.test('c')).toBeFalse()
|
||||
})
|
||||
|
||||
it('translates \\A/\\z to absolute start/end anchors equivalent to PRX semantics', () => {
|
||||
const regex = parseRegexRule('/\\Afoo\\z/')
|
||||
|
||||
expect(regex.test('foo')).toBeTrue()
|
||||
expect(regex.test('foobar')).toBeFalse()
|
||||
expect(regex.test('barfoo')).toBeFalse()
|
||||
// \z (unlike JS's own $) does not match immediately before a
|
||||
// trailing newline - this is stricter, matching PRX/Perl semantics.
|
||||
expect(regex.test('foo\n')).toBeFalse()
|
||||
})
|
||||
})
|
||||
|
||||
// Pre-existing gaps, not regressions from this fix - both fail safely:
|
||||
// they throw, same as any other malformed pattern, and each call site
|
||||
// already treats a throw as "always valid" / "never warn".
|
||||
describe('known limitations - PRX-only syntax not translated', () => {
|
||||
it('throws on free-spacing/extended (/x) mode (valid PRX, not a JS flag)', () => {
|
||||
expect(() => parseRegexRule('/a+ \\s+ b/x')).toThrow()
|
||||
})
|
||||
|
||||
it('throws on an atomic group (?>...) (valid PRX, unsupported in JS)', () => {
|
||||
expect(() => parseRegexRule('/(?>a|ab)c/')).toThrow()
|
||||
})
|
||||
|
||||
it('throws on a possessive quantifier, e.g. a++ (valid PRX, unsupported in JS)', () => {
|
||||
expect(() => parseRegexRule('/a++b/')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// HARDREGEX/SOFTREGEX RULE_VALUEs collected from an existing
|
||||
// MPE_VALIDATIONS table. None of these use PRX-only syntax (no inline
|
||||
// (?i), \Q...\E, \A/\z, atomic groups, or possessive quantifiers), so
|
||||
// they pass through parseRegexRule with only the /pattern/flags
|
||||
// delimiter stripped - the same PCRE-compatible escape rules apply on
|
||||
// both the SAS PRX side and the JS side, so behavior here is verified
|
||||
// to match, not just "doesn't throw".
|
||||
describe('RULE_VALUE corpus collected from an existing MPE_VALIDATIONS table', () => {
|
||||
const collectedRuleValues = [
|
||||
'/^-?[0-9]{1,13}([.]{0,1}[0-9]{0,3})$/',
|
||||
'/^-?[0-9]{1,3}$/',
|
||||
'/^(?:(?!31\\\\.12).)*$/',
|
||||
'/^([0-9]{1,3}([.][0-9]{1,7})?)%$/',
|
||||
'/^(100|[0-9]{1,2})%$/',
|
||||
'/^[-,0-9]{1,13}([.]{0,1}[0-9]{0,3})$/',
|
||||
'/^[-0-9]{1,19}([.]{0,1}[0-9]{0,3})$/',
|
||||
'/^[-0-9]*([.]{0,1}[0-9]{0,2})$/',
|
||||
'/^[-0-9]*([.]{0,1}[0-9]{0,3})$/',
|
||||
'/^[-0-9]*([.]{0,1}[0-9]*)$/',
|
||||
'/^[-0-9]*$/',
|
||||
'/^[0-2]$/',
|
||||
'/^[0-3]$/',
|
||||
'/^[0-9]{1,19}([.]{0,1}[0-9]{0,3})$/',
|
||||
'/^[0-9]{1,36}$/',
|
||||
'/^[0-9]{1,72}$/',
|
||||
'/^[0-9]{4}$/',
|
||||
'/^[0-9]{5}$/',
|
||||
'/^[0-9]*([.]{0,1}[0-9]{0,4})$/',
|
||||
'/^[0,1]{1}([.]{0,1}[0-9]{0,4})$/',
|
||||
'/^[1]{1,1}$/',
|
||||
'/^[A-Z0-1]{5,5}$/',
|
||||
'/^[a-zA-Z]{1,1}$/',
|
||||
'/^[a-zA-Z]{1,3}$/',
|
||||
'/^[a-zA-Z0-9_]{0,36}$/',
|
||||
'/^[a-zA-Z0-9_=@\\/.:;,= -]{1,8}$/',
|
||||
'/^[a-zA-Z0-9@_-]{1,100}$/',
|
||||
'/^[a-zA-Z0-9@_-]{1,50}$/',
|
||||
'/^[a-zA-Z0-9=@.:;,= -_]{1,50}$/',
|
||||
'/^[a-zA-Z0-9=@.:;,= -_]{1,80}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,10}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,100}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,12}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,14}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,15}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,20}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,3}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,300}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,32}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,4}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,40}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,400}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,50}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= \\-_]{1,500}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,510}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,6}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,72}$/',
|
||||
'/^[a-zA-Z0-9=@\\/.:;,= -_]{1,8}$/',
|
||||
"/^[a-zA-Z0-9=@\\/.:;,= -_`']{1,100}$/",
|
||||
"/^[a-zA-Z0-9=@\\/.:;,= -_`']{1,20}$/",
|
||||
"/^[a-zA-Z0-9=@\\/.:;,= \\-_`']{1,500}$/",
|
||||
"/^[a-zA-Z0-9=@\\/.:;,= -_`']*$/"
|
||||
]
|
||||
|
||||
collectedRuleValues.forEach((ruleValue) => {
|
||||
it(`compiles without throwing: ${ruleValue}`, () => {
|
||||
expect(() => parseRegexRule(ruleValue)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the exact pattern body and applies no flags for a plain numeric-range rule', () => {
|
||||
const regex = parseRegexRule('/^[0-9]{1,13}([.]{0,1}[0-9]{0,3})$/')
|
||||
|
||||
expect(regex.source).toEqual('^[0-9]{1,13}([.]{0,1}[0-9]{0,3})$')
|
||||
expect(regex.flags).toEqual('')
|
||||
expect(regex.test('1234.56')).toBeTrue()
|
||||
expect(regex.test('abc')).toBeFalse()
|
||||
})
|
||||
|
||||
it('handles the percentage-with-alternation rule (100 or 0-99, optionally with a decimal)', () => {
|
||||
const regex = parseRegexRule('/^(100|[0-9]{1,2})%$/')
|
||||
|
||||
expect(regex.test('100%')).toBeTrue()
|
||||
expect(regex.test('42%')).toBeTrue()
|
||||
expect(regex.test('101%')).toBeFalse()
|
||||
})
|
||||
|
||||
it('handles a character class with an escaped delimiter slash and a non-edge escaped hyphen', () => {
|
||||
const regex = parseRegexRule('/^[a-zA-Z0-9=@\\/.:;,= \\-_]{1,500}$/')
|
||||
|
||||
expect(regex.test('a/b-c d.e:f;g,h=i')).toBeTrue()
|
||||
expect(regex.test('has a # in it')).toBeFalse()
|
||||
})
|
||||
|
||||
it('handles a character class with literal backtick/single-quote characters', () => {
|
||||
const regex = parseRegexRule("/^[a-zA-Z0-9=@\\/.:;,= -_`']*$/")
|
||||
|
||||
expect(regex.test("O'Brien`s value")).toBeTrue()
|
||||
// The unescaped space-to-underscore run (` -_`) in this class is a
|
||||
// range (space is code point 32, underscore 95), not three literal
|
||||
// characters - so it also matches '#' (35), which falls inside that
|
||||
// range. Same behavior in SAS PRX (identical character-class rules),
|
||||
// so this is a faithful translation, not a parseRegexRule bug.
|
||||
expect(regex.test('has a # in it')).toBeTrue()
|
||||
expect(regex.test('has a ~ in it')).toBeFalse()
|
||||
})
|
||||
|
||||
// This is the one inconsistency in the whole corpus: every other escaped
|
||||
// '/' in this file uses a single backslash, but this rule has \\. (two
|
||||
// backslashes) where every other sign points to a single \. having been
|
||||
// intended (a literal dot, inside a negative lookahead meant to exclude
|
||||
// "31.12" - e.g. a 31-December date - from matching). SAS PRX and JS
|
||||
// regex use identical backslash-escape semantics for this (no PRX-only
|
||||
// construct is involved), so parseRegexRule's output is a faithful,
|
||||
// parity-preserving translation of whatever was actually authored -
|
||||
// this test pins down what the corpus *actually* does today, verbatim,
|
||||
// not what was likely intended. \\ (escaped backslash) followed by an
|
||||
// unescaped . (any character) means the lookahead is functionally a
|
||||
// no-op: it only fails to match text containing a literal backslash,
|
||||
// which excludes nothing in practice, so every value - including dates
|
||||
// that literally contain "31.12" - passes.
|
||||
it('as authored (double backslash): the 31.12-exclusion lookahead is a no-op and matches everything', () => {
|
||||
const regex = parseRegexRule('/^(?:(?!31\\\\.12).)*$/')
|
||||
|
||||
expect(regex.test('31.12.2024')).toBeTrue()
|
||||
expect(regex.test('01.01.2024')).toBeTrue()
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user