Compare commits
52
Commits
v7.10.0
...
7050808d80
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7050808d80 | ||
|
|
37a98e1d64 | ||
|
|
54b8c78f86 | ||
|
|
ea74f95144 | ||
|
|
bd798b424a | ||
|
|
ee3c6c9e0f | ||
|
|
a3e46a968e | ||
|
|
ee4e9b9271 | ||
|
|
7a35cf4a45 | ||
|
|
d881290618 | ||
|
|
994e7fc973 | ||
|
|
578c403994 | ||
|
|
56b7854db5 | ||
|
|
7ed3730ae3 | ||
|
|
6cd9b68581 | ||
|
|
7378f3ba30 | ||
|
|
cac9244f92 | ||
|
|
ea00c5afad | ||
|
|
359d833406 | ||
|
|
022981390e | ||
|
|
05fe4744d5 | ||
|
|
e22edf7ed3 | ||
|
|
62ff0aee4a | ||
|
|
3ed7cfdbee | ||
|
|
9176296816 | ||
|
|
bac7b0a600 | ||
|
|
d2c93a46fa | ||
|
|
17e4802895 | ||
|
|
600937a471 | ||
|
|
f100a16c8b | ||
|
|
9e54504474 | ||
|
|
0285bc90fd | ||
|
|
be94c68f35 | ||
|
|
f73f5e47ac | ||
|
|
c5f0e4f106 | ||
|
|
8e4f045eb9 | ||
|
|
08947f0fc8 | ||
|
|
efcccd7d11 | ||
|
|
078da3d947 | ||
|
|
61012f80e6 | ||
|
|
65bcf08266 | ||
|
|
ad7efc90bc | ||
|
|
70c5805743 | ||
|
|
dfe80bb42a | ||
|
|
af27933e46 | ||
|
|
6a95067618 | ||
|
|
5391df8f04 | ||
|
|
6b16f9b364 | ||
|
|
2dcae9060f | ||
|
|
f4525cc62a | ||
|
|
d4aa70ddbf | ||
|
|
4c2aca3495 |
No files matched your search
@@ -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`.
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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 --audit-level=low --omit=dev
|
||||
|
||||
- name: Lint check
|
||||
run: npm run lint:check
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
# Start frontend and run cypress
|
||||
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
|
||||
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts"
|
||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts"
|
||||
|
||||
- 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 --audit-level=low --omit=dev
|
||||
|
||||
- name: Angular Tests
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Agent Instructions
|
||||
|
||||
## Markdown files
|
||||
|
||||
Do NOT hard-wrap Markdown files (no fixed-width line wrapping / carriage returns inside paragraphs). Each paragraph, list item, and heading should be a single logical line, regardless of length. Let the editor/viewer soft-wrap. This includes AGENTS.md itself.
|
||||
|
||||
This is different from `.sas` files, where a maximum line length applies. The no-wrap rule applies to all `*.md` files in this repo (docs, READMEs, etc.).
|
||||
|
||||
Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and reflowed, and Markdown renderers already handle wrapping.
|
||||
|
||||
## SAS files
|
||||
|
||||
After creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored).
|
||||
|
||||
## 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/`.
|
||||
@@ -1,3 +1,29 @@
|
||||
# [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)
|
||||
|
||||
|
||||
|
||||
@@ -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": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,8 +115,228 @@ context('editor tests: ', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// An invalid cell shouldn't block submission if its row
|
||||
// is marked for delete
|
||||
it('5 | Submits a delete-marked row with an invalid cell', (done) => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
cy.get('.ht_master tbody tr').then((rows: any) => {
|
||||
// childNodes[5] = SOME_NUM (numeric) — feed it a non-numeric value
|
||||
// to trigger HOT's native invalid mark.
|
||||
cy.get(rows[1].childNodes[5])
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused()
|
||||
.clear()
|
||||
.type('not a number{enter}')
|
||||
.then(() => {
|
||||
// childNodes[1] = _____DELETE__THIS__RECORD_____
|
||||
setDeleteFlag(rows[1].childNodes[1], 'Yes', () => {
|
||||
submitTable(() => {
|
||||
// Validation passed despite the invalid SOME_NUM cell:
|
||||
// the confirm-submit modal's Submit button is enabled
|
||||
// (validationDone === 1), not the "invalid values" abort.
|
||||
cy.get('#submitBtn', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.should('exist')
|
||||
.should('not.be.disabled')
|
||||
.then(() => done())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// The delete exemption must not affect the uniqueness (primary key) check.
|
||||
it('6 | Still blocks a duplicate primary key even on a delete-marked row', (done) => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
cy.get('.ht_master tbody tr').then((rows: any) => {
|
||||
cy.get(rows[0].childNodes[2]).then((firstPk) => {
|
||||
const duplicateValue = firstPk.text()
|
||||
|
||||
// childNodes[2] = PRIMARY_KEY_FIELD
|
||||
cy.get(rows[1].childNodes[2])
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused()
|
||||
.clear()
|
||||
.type(`${duplicateValue}{enter}`)
|
||||
.then(() => {
|
||||
setDeleteFlag(rows[1].childNodes[1], 'Yes', () => {
|
||||
submitTable()
|
||||
|
||||
cy.get('.duplicate-keys-modal', {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
.should('exist')
|
||||
.then(() => done())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// REGEX_HARD_COL/REGEX_SOFT_COL only exist un-stripped on MPE_X_NEW (see
|
||||
// RULE_DEMO_COLS in the getdata.js mock) — MPE_X_TEST stays clean to
|
||||
// match the excel fixtures.
|
||||
it('7 | Blocks submission of a value that fails HARDREGEX', (done) => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
scrollGridRight()
|
||||
|
||||
getCellByHeaderAndRow(1, 'REGEX_HARD_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused()
|
||||
.clear()
|
||||
.type('not-an-email{enter}')
|
||||
.then(() => {
|
||||
submitTable(() => {
|
||||
cy.get('.modal-body').then((modalBody: any) => {
|
||||
if (
|
||||
modalBody[0].innerHTML
|
||||
.toLowerCase()
|
||||
.includes(`invalid values are present`)
|
||||
) {
|
||||
done()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('8 | Warns (but still submits) a value that fails SOFTREGEX', (done) => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||
|
||||
clickOnEdit(() => {
|
||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then(() => {
|
||||
scrollGridRight()
|
||||
|
||||
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
|
||||
.dblclick({ force: true })
|
||||
.then(() => {
|
||||
cy.focused()
|
||||
.clear()
|
||||
.type('not a postcode{enter}')
|
||||
.then(() => {
|
||||
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL').should(
|
||||
'have.class',
|
||||
'dc-warning-cell'
|
||||
)
|
||||
|
||||
submitTable(() => {
|
||||
// Validation passed despite the SOFTREGEX warning: the
|
||||
// confirm-submit modal's Submit button is enabled
|
||||
// (validationDone === 1), not the "invalid values" abort.
|
||||
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
||||
.should('exist')
|
||||
.should('not.be.disabled')
|
||||
.then(() => done())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Handsontable virtualizes columns — with 17 columns on MPE_X_NEW, only
|
||||
// the ones in the current viewport actually exist in the DOM. Scroll all
|
||||
// the way right so REGEX_HARD_COL/REGEX_SOFT_COL (the last two) render at
|
||||
// all. Same technique already used in excel.cy.ts. Must be called (and
|
||||
// re-settle) before any header/body query below, since scrolling replaces
|
||||
// the previously-rendered column nodes.
|
||||
const scrollGridRight = () => {
|
||||
return cy
|
||||
.get('#hotTable')
|
||||
.find('div.ht_master.handsontable')
|
||||
.find('div.wtHolder')
|
||||
.scrollTo('right')
|
||||
}
|
||||
|
||||
// Locates a body cell by its column's header text rather than a hardcoded
|
||||
// childNodes index. Handsontable's hiddenColumns plugin (e.g. HIDDEN_COL,
|
||||
// used by several demo columns ahead of REGEX_HARD_COL/REGEX_SOFT_COL in
|
||||
// COLHEADERS) removes a hidden column's cells from BOTH the header row and
|
||||
// every body row identically, so the header row's th index always lines up
|
||||
// with the same-position childNode in any body row — this sidesteps having
|
||||
// to know how many hidden columns precede the target. Re-queries both the
|
||||
// header and body row fresh each call (rather than accepting a pre-fetched
|
||||
// row) since virtualization/scrolling can replace either pane's nodes.
|
||||
//
|
||||
// The `.should()` retries until headerText actually shows up: Handsontable
|
||||
// re-renders virtualized columns asynchronously after a scroll event, on
|
||||
// its own schedule outside Cypress's command queue, so scrollTo() settling
|
||||
// does not mean the target column has been rendered yet.
|
||||
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
|
||||
return cy
|
||||
.get('.ht_clone_top .htCore thead tr th')
|
||||
.should(($ths) => {
|
||||
const texts = [...$ths].map((th) => th.innerText.trim())
|
||||
expect(texts).to.include(headerText)
|
||||
})
|
||||
.then(($ths) => {
|
||||
const index = [...$ths].findIndex(
|
||||
(th) => th.innerText.trim() === headerText
|
||||
)
|
||||
|
||||
return cy
|
||||
.get('.ht_master tbody tr')
|
||||
.then((rows: any) => rows[rowIndex].childNodes[index])
|
||||
.then((cell) => cy.get(cell))
|
||||
})
|
||||
}
|
||||
|
||||
// Opens the _____DELETE__THIS__RECORD_____ dropdown editor on the given cell
|
||||
// and picks the given choice ('Yes'/'No') — same technique as
|
||||
// coltype-delete-record.cy.ts (arrow -> autocompleteEditor choice list).
|
||||
const setDeleteFlag = (
|
||||
deleteCell: any,
|
||||
value: 'Yes' | 'No',
|
||||
callback?: any
|
||||
) => {
|
||||
cy.get(deleteCell)
|
||||
.click({ force: true })
|
||||
.then(($td) => {
|
||||
cy.get('.htAutocompleteArrow', { withinSubject: $td })
|
||||
.should('exist')
|
||||
.click({ force: true })
|
||||
|
||||
cy.get('.autocompleteEditor .htCore tbody td')
|
||||
.contains(value)
|
||||
.click({ force: true })
|
||||
.then(() => {
|
||||
if (callback) callback()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const clickOnEdit = (callback?: any) => {
|
||||
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
|
||||
.click()
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Binary file not shown.
Generated
+5847
-5791
File diff suppressed because it is too large.
Load diff
+21
-22
@@ -41,22 +41,22 @@
|
||||
},
|
||||
"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": "^18.0.0",
|
||||
"@sasjs/adapter": "^4.17.0",
|
||||
"@sasjs/utils": "^3.5.3",
|
||||
"@sasjs/adapter": "^4.17.3",
|
||||
"@sasjs/utils": "^3.5.9",
|
||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||
"@types/d3-graphviz": "^2.6.7",
|
||||
"@types/text-encoding": "0.0.35",
|
||||
@@ -77,7 +77,6 @@
|
||||
"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",
|
||||
"rxjs": "^7.8.0",
|
||||
"save-svg-as-png": "^1.4.17",
|
||||
@@ -91,16 +90,16 @@
|
||||
"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",
|
||||
@@ -110,10 +109,10 @@
|
||||
"@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",
|
||||
"@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,8 +134,8 @@
|
||||
"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": {
|
||||
@@ -144,4 +143,4 @@
|
||||
"uuid": "11.1.1",
|
||||
"lighthouse": "13.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
+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
@@ -3455,6 +3455,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (cellMeta && cellMeta.filter === false)
|
||||
hot.setCellMeta(row, col, 'filter', true)
|
||||
|
||||
// Toggling Delete? changes every other cell's validation exemption
|
||||
// in this row (see DcValidator.setDefaultValidator) — re-validate so
|
||||
// invalid highlights clear/reappear immediately instead of only at
|
||||
// submit time.
|
||||
if (colProp === '_____DELETE__THIS__RECORD_____') {
|
||||
hot.validateRows([row], () => hot.render())
|
||||
}
|
||||
|
||||
this.executeDynamicCellValidationIfApplicable(colProp, col, row)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import Handsontable from 'handsontable'
|
||||
import { makeRegexWarningRenderer } from './regex-warning-renderer'
|
||||
|
||||
describe('makeRegexWarningRenderer', () => {
|
||||
const buildHot = (data: any[]) => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const hot = new Handsontable(container, {
|
||||
data,
|
||||
columns: [
|
||||
{ data: 'val', renderer: makeRegexWarningRenderer('^[A-Z]{3}$') },
|
||||
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
return { hot, container }
|
||||
}
|
||||
|
||||
it('adds dc-warning-cell when the value fails the pattern', () => {
|
||||
const { hot, container } = buildHot([
|
||||
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
|
||||
])
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
expect(td?.classList.contains('dc-warning-cell')).toBeTrue()
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('does not add dc-warning-cell when the value matches the pattern', () => {
|
||||
const { hot, container } = buildHot([
|
||||
{ val: 'ABC', _____DELETE__THIS__RECORD_____: 'No' }
|
||||
])
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('suppresses the warning on a row marked for delete, even though the value fails the pattern', () => {
|
||||
const { hot, container } = buildHot([
|
||||
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'Yes' }
|
||||
])
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('does not add dc-warning-cell for a SAS special missing value', () => {
|
||||
const { hot, container } = buildHot([
|
||||
{ val: '.a', _____DELETE__THIS__RECORD_____: 'No' }
|
||||
])
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('does not throw and never warns on a malformed pattern', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const hot = new Handsontable(container, {
|
||||
data: [{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }],
|
||||
columns: [
|
||||
{ data: 'val', renderer: makeRegexWarningRenderer('[unterminated') },
|
||||
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
|
||||
expect(() => hot.render()).not.toThrow()
|
||||
|
||||
const td = hot.getCell(0, 0)
|
||||
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('handles a PRX-delimited pattern with a case-insensitive flag, as authored for prxparse', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const hot = new Handsontable(container, {
|
||||
data: [
|
||||
{ val: 'this is dummy data', _____DELETE__THIS__RECORD_____: 'No' },
|
||||
{ val: 'THE WIND WAS BLOWING', _____DELETE__THIS__RECORD_____: 'No' },
|
||||
{ val: 'nothing relevant here', _____DELETE__THIS__RECORD_____: 'No' }
|
||||
],
|
||||
columns: [
|
||||
{
|
||||
data: 'val',
|
||||
renderer: makeRegexWarningRenderer('/\\b(the|data)\\b/i')
|
||||
},
|
||||
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
expect(hot.getCell(0, 0)?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||
expect(hot.getCell(1, 0)?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||
expect(hot.getCell(2, 0)?.classList.contains('dc-warning-cell')).toBeTrue()
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('is display-only — the stored value is untouched', () => {
|
||||
const { hot, container } = buildHot([
|
||||
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
|
||||
])
|
||||
|
||||
expect(hot.getDataAtCell(0, 0)).toEqual('abc')
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import Handsontable from 'handsontable'
|
||||
import { isRegexRuleExempt } from '../../shared/dc-validator/utils/isRegexRuleExempt'
|
||||
import { parseRegexRule } from '../../shared/dc-validator/utils/parseRegexRule'
|
||||
|
||||
/**
|
||||
* Builds a display-only HOT renderer for SOFTREGEX: a value that fails the
|
||||
* pattern gets a yellow `dc-warning-cell` class (styles.scss) instead of
|
||||
* blocking submission — SOFTREGEX must never return false from the cell
|
||||
* validator (that would paint htInvalid and block submit), so the warning
|
||||
* lives purely at the render layer, same split as makeNumberFormatRenderer.
|
||||
*
|
||||
* Suppressed on rows marked for delete (_____DELETE__THIS__RECORD_____ =
|
||||
* 'Yes') — a warning about data about to be removed is just noise.
|
||||
*
|
||||
* Falls back to no warning ever showing when the pattern itself is
|
||||
* malformed, rather than breaking the cell.
|
||||
*/
|
||||
export const makeRegexWarningRenderer = (pattern?: string) => {
|
||||
let regex: RegExp | null = null
|
||||
|
||||
try {
|
||||
if (pattern) regex = parseRegexRule(pattern)
|
||||
} catch (e) {
|
||||
console.warn(`SOFTREGEX - invalid pattern, warning disabled: ${pattern}`)
|
||||
regex = null
|
||||
}
|
||||
|
||||
const baseRenderer = Handsontable.renderers.getRenderer('text')
|
||||
|
||||
return (
|
||||
instance: any,
|
||||
td: any,
|
||||
row: number,
|
||||
col: number,
|
||||
prop: string | number,
|
||||
value: any,
|
||||
cellProperties: any
|
||||
) => {
|
||||
baseRenderer(instance, td, row, col, prop, value, cellProperties)
|
||||
|
||||
const markedForDelete =
|
||||
instance.getDataAtRowProp(row, '_____DELETE__THIS__RECORD_____') === 'Yes'
|
||||
|
||||
const failsPattern =
|
||||
!!regex && !isRegexRuleExempt(value) && !regex.test(value.toString())
|
||||
|
||||
if (failsPattern && !markedForDelete) {
|
||||
td.classList.add('dc-warning-cell')
|
||||
td.title = 'Value does not match the expected pattern'
|
||||
}
|
||||
|
||||
return td
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,117 +1,118 @@
|
||||
<app-sidebar>
|
||||
<div *ngIf="datasetsLoading" class="my-10-mx-auto text-center">
|
||||
<clr-spinner clrMedium></clr-spinner>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!parsedDatasets.length" class="text-center mb-10">
|
||||
<button
|
||||
(click)="fileUploadInput.click()"
|
||||
id="browse-file"
|
||||
class="btn btn-primary btn-sm"
|
||||
[disabled]="selectedFile !== null || submittingCsv"
|
||||
>
|
||||
Browse file
|
||||
</button>
|
||||
<input
|
||||
hidden
|
||||
#fileUploadInput
|
||||
id="file-upload"
|
||||
type="file"
|
||||
(change)="onFileChange($event)"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="parsedDatasets.length && !submittedCsvDatasets.length">
|
||||
<div *ngIf="!excelsSubmitted" class="text-center mb-10">
|
||||
<button (click)="onDiscard()" class="btn btn-danger btn-sm mr-10">
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
(click)="onSubmitAll()"
|
||||
id="submit-all"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
Submit All
|
||||
</button>
|
||||
@if (datasetsLoading) {
|
||||
<div class="my-10-mx-auto text-center">
|
||||
<clr-spinner clrMedium></clr-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!parsedDatasets.length) {
|
||||
<div class="text-center mb-10">
|
||||
<button
|
||||
(click)="fileUploadInput.click()"
|
||||
id="browse-file"
|
||||
class="btn btn-primary btn-sm"
|
||||
[disabled]="selectedFile !== null || submittingCsv"
|
||||
>
|
||||
Browse file
|
||||
</button>
|
||||
<input
|
||||
hidden
|
||||
#fileUploadInput
|
||||
id="file-upload"
|
||||
type="file"
|
||||
(change)="onFileChange($event)"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
|
||||
@if (!excelsSubmitted) {
|
||||
<div class="text-center mb-10">
|
||||
<button (click)="onDiscard()" class="btn btn-danger btn-sm mr-10">
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
(click)="onSubmitAll()"
|
||||
id="submit-all"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
Submit All
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<p cds-text="caption" class="ml-10 mb-10">Found tables:</p>
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngFor="let dataset of parsedDatasets">
|
||||
<button
|
||||
(click)="onParsedDatasetClick(dataset)"
|
||||
class="clr-treenode-link whitespace-nowrap d-flex clr-align-items-center"
|
||||
[class.active]="dataset.active"
|
||||
>
|
||||
<ng-container *ngIf="dataset.submitResult">
|
||||
<cds-icon
|
||||
*ngIf="dataset.submitResult.error"
|
||||
status="danger"
|
||||
shape="exclamation-circle"
|
||||
></cds-icon>
|
||||
<cds-icon
|
||||
*ngIf="dataset.submitResult.success"
|
||||
status="success"
|
||||
shape="check-circle"
|
||||
></cds-icon>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!dataset.submitResult">
|
||||
<ng-container *ngIf="dataset.datasource">
|
||||
<cds-icon
|
||||
*ngIf="!(dataset.datasource.length && dataset.parseResult)"
|
||||
status="danger"
|
||||
shape="exclamation-circle"
|
||||
></cds-icon>
|
||||
<cds-icon
|
||||
*ngIf="dataset.datasource.length && dataset.parseResult"
|
||||
shape="table"
|
||||
></cds-icon>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!dataset.datasource">
|
||||
<cds-icon *ngIf="!dataset.parsingTable" shape="table"></cds-icon>
|
||||
|
||||
<clr-spinner *ngIf="dataset.parsingTable" clrSmall></clr-spinner>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<span class="ml-5"> {{ dataset.libds }} </span>
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
@for (dataset of parsedDatasets; track dataset) {
|
||||
<clr-tree-node>
|
||||
<button
|
||||
(click)="onParsedDatasetClick(dataset)"
|
||||
class="clr-treenode-link whitespace-nowrap d-flex clr-align-items-center"
|
||||
[class.active]="dataset.active"
|
||||
>
|
||||
@if (dataset.submitResult) {
|
||||
@if (dataset.submitResult.error) {
|
||||
<cds-icon status="danger" shape="exclamation-circle"></cds-icon>
|
||||
}
|
||||
@if (dataset.submitResult.success) {
|
||||
<cds-icon status="success" shape="check-circle"></cds-icon>
|
||||
}
|
||||
}
|
||||
@if (!dataset.submitResult) {
|
||||
@if (dataset.datasource) {
|
||||
@if (!(dataset.datasource.length && dataset.parseResult)) {
|
||||
<cds-icon
|
||||
status="danger"
|
||||
shape="exclamation-circle"
|
||||
></cds-icon>
|
||||
}
|
||||
@if (dataset.datasource.length && dataset.parseResult) {
|
||||
<cds-icon shape="table"></cds-icon>
|
||||
}
|
||||
}
|
||||
@if (!dataset.datasource) {
|
||||
@if (!dataset.parsingTable) {
|
||||
<cds-icon shape="table"></cds-icon>
|
||||
}
|
||||
@if (dataset.parsingTable) {
|
||||
<clr-spinner clrSmall></clr-spinner>
|
||||
}
|
||||
}
|
||||
}
|
||||
<span class="ml-5"> {{ dataset.libds }} </span>
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
}
|
||||
</clr-tree>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<ng-container *ngIf="submittedCsvDatasets.length">
|
||||
@if (submittedCsvDatasets.length) {
|
||||
<p cds-text="caption" class="ml-10 mb-10 mt-10">Submitted tables:</p>
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngFor="let dataset of submittedCsvDatasets">
|
||||
<button
|
||||
(click)="onSubmittedCsvDatasetClick(dataset)"
|
||||
class="clr-treenode-link whitespace-nowrap"
|
||||
[class.active]="dataset.active"
|
||||
>
|
||||
<cds-icon
|
||||
*ngIf="dataset.error"
|
||||
status="danger"
|
||||
shape="exclamation-circle"
|
||||
></cds-icon>
|
||||
<cds-icon
|
||||
*ngIf="dataset.success"
|
||||
status="success"
|
||||
shape="check-circle"
|
||||
></cds-icon>
|
||||
<cds-icon shape="table"></cds-icon>
|
||||
{{ dataset.libds }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
@for (dataset of submittedCsvDatasets; track dataset) {
|
||||
<clr-tree-node>
|
||||
<button
|
||||
(click)="onSubmittedCsvDatasetClick(dataset)"
|
||||
class="clr-treenode-link whitespace-nowrap"
|
||||
[class.active]="dataset.active"
|
||||
>
|
||||
@if (dataset.error) {
|
||||
<cds-icon status="danger" shape="exclamation-circle"></cds-icon>
|
||||
}
|
||||
@if (dataset.success) {
|
||||
<cds-icon status="success" shape="check-circle"></cds-icon>
|
||||
}
|
||||
<cds-icon shape="table"></cds-icon>
|
||||
{{ dataset.libds }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
}
|
||||
</clr-tree>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<!-- <div *ngIf="librariesPaging" class="w-100 text-center">
|
||||
<span class="spinner spinner-sm"> Loading... </span>
|
||||
</div> -->
|
||||
<span class="spinner spinner-sm"> Loading... </span>
|
||||
</div> -->
|
||||
</app-sidebar>
|
||||
|
||||
<div #contentArea class="content-area">
|
||||
@@ -122,23 +123,22 @@
|
||||
<p cds-text="section">Multi Dataset Load</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="selectedFile === null && !submittingCsv"
|
||||
class="no-table-selected pointer-events-none"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="upload-cloud"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please upload a file
|
||||
</p>
|
||||
</div>
|
||||
@if (selectedFile === null && !submittingCsv) {
|
||||
<div class="no-table-selected pointer-events-none">
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="upload-cloud"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please upload a file
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-container *ngIf="selectedFile !== null || submittingCsv">
|
||||
<ng-container *ngIf="!parsedDatasets.length && selectedFile !== null">
|
||||
@if (selectedFile !== null || submittingCsv) {
|
||||
@if (!parsedDatasets.length && selectedFile !== null) {
|
||||
<div class="d-flex clr-justify-content-center mt-15">
|
||||
<div class="dataset-input-wrapper">
|
||||
<p cds-text="secondary regular" class="mb-5">
|
||||
@@ -160,12 +160,10 @@
|
||||
<p cds-text="secondary regular" class="mb-15">
|
||||
Paste or type the list of datasets to upload:
|
||||
</p>
|
||||
|
||||
<clr-control-helper class="mb-5"
|
||||
>Each row is one dataset. We will automatically detect tables by
|
||||
the sheetname and populate if any.</clr-control-helper
|
||||
>
|
||||
|
||||
<hot-table
|
||||
#hotInstanceUserDataset
|
||||
id="hotTableUserDataset"
|
||||
@@ -173,7 +171,6 @@
|
||||
[settings]="hotUserDatasetsSettings"
|
||||
>
|
||||
</hot-table>
|
||||
|
||||
<div class="dataset-selection-actions text-right mt-10">
|
||||
<button
|
||||
(click)="onStartParsingFile()"
|
||||
@@ -187,109 +184,90 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="parsedDatasets.length && !submittedCsvDatasets.length"
|
||||
>
|
||||
<div
|
||||
*ngIf="!activeParsedDataset"
|
||||
class="no-table-selected pointer-events-none"
|
||||
>
|
||||
<ng-container *ngIf="fileLoadingState !== FileLoadingState.parsed">
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="process-on-vm"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
{{ fileLoadingState }}...
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="fileLoadingState === FileLoadingState.parsed">
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="warning-standard"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a dataset on the left to review the data
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="activeParsedDataset">
|
||||
<div
|
||||
*ngIf="activeParsedDataset.submitResult"
|
||||
class="d-flex clr-justify-content-between p-10 mt-15 submission-results"
|
||||
>
|
||||
<div>
|
||||
<p cds-text="secondary regular" class="mb-10">
|
||||
Submit Status:
|
||||
<span
|
||||
*ngIf="activeParsedDataset.submitResult?.success"
|
||||
class="color-green"
|
||||
><strong>SUCCESS</strong></span
|
||||
>
|
||||
<span
|
||||
*ngIf="activeParsedDataset.submitResult?.error"
|
||||
class="color-red"
|
||||
><strong>ERROR</strong></span
|
||||
>
|
||||
}
|
||||
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
|
||||
@if (!activeParsedDataset) {
|
||||
<div class="no-table-selected pointer-events-none">
|
||||
@if (fileLoadingState !== FileLoadingState.parsed) {
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="process-on-vm"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
{{ fileLoadingState }}...
|
||||
</p>
|
||||
<p
|
||||
*ngIf="activeParsedDataset.submitResult?.error"
|
||||
cds-text="secondary regular"
|
||||
>
|
||||
Error details:
|
||||
}
|
||||
@if (fileLoadingState === FileLoadingState.parsed) {
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="warning-standard"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a dataset on the left to review the data
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
*ngIf="
|
||||
!submittingCsv && activeParsedDataset.submitResult?.error
|
||||
"
|
||||
(click)="reSubmitTable(activeParsedDataset)"
|
||||
class="btn btn-primary mt-10"
|
||||
[clrLoading]="submitLoading"
|
||||
>
|
||||
Resubmit
|
||||
</button>
|
||||
<button
|
||||
(click)="
|
||||
downloadFile(
|
||||
activeParsedDataset.submitResult.log ||
|
||||
activeParsedDataset.submitResult.success ||
|
||||
activeParsedDataset.submitResult.error
|
||||
)
|
||||
"
|
||||
class="btn btn-primary-outline mt-10"
|
||||
>
|
||||
Download log
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="activeParsedDataset.submitResult?.error"
|
||||
class="error-field mt-15"
|
||||
>
|
||||
<div class="log-wrapper">
|
||||
{{ activeParsedDataset.submitResult?.error | json }}
|
||||
}
|
||||
@if (activeParsedDataset) {
|
||||
@if (activeParsedDataset.submitResult) {
|
||||
<div
|
||||
class="d-flex clr-justify-content-between p-10 mt-15 submission-results"
|
||||
>
|
||||
<div>
|
||||
<p cds-text="secondary regular" class="mb-10">
|
||||
Submit Status:
|
||||
@if (activeParsedDataset.submitResult.success) {
|
||||
<span class="color-green"><strong>SUCCESS</strong></span>
|
||||
}
|
||||
@if (activeParsedDataset.submitResult.error) {
|
||||
<span class="color-red"><strong>ERROR</strong></span>
|
||||
}
|
||||
</p>
|
||||
@if (activeParsedDataset.submitResult.error) {
|
||||
<p cds-text="secondary regular">Error details:</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
@if (!submittingCsv && activeParsedDataset.submitResult.error) {
|
||||
<button
|
||||
(click)="reSubmitTable(activeParsedDataset)"
|
||||
class="btn btn-primary mt-10"
|
||||
[clrLoading]="submitLoading"
|
||||
>
|
||||
Resubmit
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
(click)="
|
||||
downloadFile(
|
||||
activeParsedDataset.submitResult.log ||
|
||||
activeParsedDataset.submitResult.success ||
|
||||
activeParsedDataset.submitResult.error
|
||||
)
|
||||
"
|
||||
class="btn btn-primary-outline mt-10"
|
||||
>
|
||||
Download log
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
@if (activeParsedDataset.submitResult?.error) {
|
||||
<div class="error-field mt-15">
|
||||
<div class="log-wrapper">
|
||||
{{ activeParsedDataset.submitResult?.error | json }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex clr-justify-content-between p-10 mt-15">
|
||||
<div>
|
||||
<p cds-text="secondary regular" class="mb-10">
|
||||
Found in range:
|
||||
|
||||
<ng-container *ngIf="activeParsedDataset.parseResult">
|
||||
@if (activeParsedDataset.parseResult) {
|
||||
<strong
|
||||
>"{{
|
||||
activeParsedDataset.parseResult.rangeSheetRes?.sheetName
|
||||
@@ -298,21 +276,18 @@
|
||||
?.rangeAddress
|
||||
}}</strong
|
||||
>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!activeParsedDataset.parseResult">
|
||||
<strong *ngIf="!activeParsedDataset.parsingTable"
|
||||
>No data found</strong
|
||||
>
|
||||
|
||||
<span
|
||||
*ngIf="activeParsedDataset.parsingTable"
|
||||
class="d-flex clr-align-items-center"
|
||||
>
|
||||
<strong>Searching for the data...</strong>
|
||||
<clr-spinner class="ml-5" clrSmall></clr-spinner>
|
||||
</span>
|
||||
</ng-container>
|
||||
}
|
||||
@if (!activeParsedDataset.parseResult) {
|
||||
@if (!activeParsedDataset.parsingTable) {
|
||||
<strong>No data found</strong>
|
||||
}
|
||||
@if (activeParsedDataset.parsingTable) {
|
||||
<span class="d-flex clr-align-items-center">
|
||||
<strong>Searching for the data...</strong>
|
||||
<clr-spinner class="ml-5" clrSmall></clr-spinner>
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</p>
|
||||
<p cds-text="secondary regular">
|
||||
Dataset:
|
||||
@@ -333,7 +308,6 @@
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<clr-toggle-wrapper>
|
||||
<input
|
||||
@@ -354,11 +328,11 @@
|
||||
</clr-toggle-wrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isHotHidden" class="text-center w-100">
|
||||
<clr-spinner class="spinner-md"></clr-spinner>
|
||||
</div>
|
||||
|
||||
@if (isHotHidden) {
|
||||
<div class="text-center w-100">
|
||||
<clr-spinner class="spinner-md"></clr-spinner>
|
||||
</div>
|
||||
}
|
||||
<hot-table
|
||||
#hotInstanceMain
|
||||
id="hotTable"
|
||||
@@ -366,27 +340,24 @@
|
||||
[settings]="hotMainTableSettings"
|
||||
>
|
||||
</hot-table>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="submittedCsvDatasets.length">
|
||||
<div
|
||||
*ngIf="!activeSubmittedCsvDataset"
|
||||
class="no-table-selected pointer-events-none"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="warning-standard"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a dataset on the left to review the submit results
|
||||
</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="activeSubmittedCsvDataset">
|
||||
}
|
||||
}
|
||||
@if (submittedCsvDatasets.length) {
|
||||
@if (!activeSubmittedCsvDataset) {
|
||||
<div class="no-table-selected pointer-events-none">
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="warning-standard"
|
||||
size="40"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a dataset on the left to review the submit results
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (activeSubmittedCsvDataset) {
|
||||
<div class="d-flex clr-justify-content-between p-10">
|
||||
<div>
|
||||
<p cds-text="secondary regular" class="mb-10">
|
||||
@@ -409,23 +380,17 @@
|
||||
</p>
|
||||
<p cds-text="secondary regular" class="mb-10">
|
||||
Status:
|
||||
<span
|
||||
*ngIf="activeSubmittedCsvDataset.success"
|
||||
class="color-green"
|
||||
><strong>SUCCESS</strong></span
|
||||
>
|
||||
<span *ngIf="activeSubmittedCsvDataset.error" class="color-red"
|
||||
><strong>ERROR</strong></span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
*ngIf="activeSubmittedCsvDataset.error"
|
||||
cds-text="secondary regular"
|
||||
>
|
||||
Error details:
|
||||
@if (activeSubmittedCsvDataset.success) {
|
||||
<span class="color-green"><strong>SUCCESS</strong></span>
|
||||
}
|
||||
@if (activeSubmittedCsvDataset.error) {
|
||||
<span class="color-red"><strong>ERROR</strong></span>
|
||||
}
|
||||
</p>
|
||||
@if (activeSubmittedCsvDataset.error) {
|
||||
<p cds-text="secondary regular">Error details:</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
(click)="
|
||||
@@ -440,28 +405,29 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="activeSubmittedCsvDataset.error" class="error-field mt-15">
|
||||
<div class="log-wrapper">
|
||||
{{ activeSubmittedCsvDataset.error | json }}
|
||||
@if (activeSubmittedCsvDataset.error) {
|
||||
<div class="error-field mt-15">
|
||||
<div class="log-wrapper">
|
||||
{{ activeSubmittedCsvDataset.error | json }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<!-- <div>
|
||||
<p
|
||||
<p
|
||||
*ngIf="
|
||||
licenceState.value.viewer_rows_allowed !== Infinity &&
|
||||
hotTable.data &&
|
||||
hotTable.data.length > licenceState.value.viewer_rows_allowed
|
||||
"
|
||||
class="mt-2-i w-100 text-center"
|
||||
>
|
||||
To display more than {{ licenceState.value.viewer_rows_allowed }} rows,
|
||||
contact <contact-link />
|
||||
</p>
|
||||
</div> -->
|
||||
class="mt-2-i w-100 text-center"
|
||||
>
|
||||
To display more than {{ licenceState.value.viewer_rows_allowed }} rows,
|
||||
contact <contact-link />
|
||||
</p>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -471,16 +437,14 @@
|
||||
{{ tablesToSubmit.length === 1 ? 'table' : 'tables' }} for approval
|
||||
</h3>
|
||||
<div class="modal-body">
|
||||
<p
|
||||
*ngIf="licenceState.value.submit_rows_limit !== Infinity"
|
||||
cds-text="body"
|
||||
class="licence-limit-notice mt-0 mb-15"
|
||||
>
|
||||
Due to current licence, only
|
||||
{{ licenceState.value.submit_rows_limit }} rows in each file will be
|
||||
submitted. To remove the restriction, contact
|
||||
support@datacontroller.io.
|
||||
</p>
|
||||
@if (licenceState.value.submit_rows_limit !== Infinity) {
|
||||
<p cds-text="body" class="licence-limit-notice mt-0 mb-15">
|
||||
Due to current licence, only
|
||||
{{ licenceState.value.submit_rows_limit }} rows in each file will be
|
||||
submitted. To remove the restriction, contact
|
||||
support@datacontroller.io.
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="text-area-full-width">
|
||||
<label for="formFields_8" class="mb-5 d-block">Message</label>
|
||||
|
||||
@@ -30,20 +30,21 @@
|
||||
(ngModelChange)="setGroupLogic(groupLogic)"
|
||||
clrSelect
|
||||
>
|
||||
<option
|
||||
*ngFor="let logic of logicOperators"
|
||||
[selected]="logicOperators[0]"
|
||||
>
|
||||
{{ logic }}
|
||||
</option>
|
||||
@for (logic of logicOperators; track logic) {
|
||||
<option [selected]="logicOperators[0]">
|
||||
{{ logic }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</clr-select-container>
|
||||
</div>
|
||||
<div class="clr-col-md-10 mb-30">
|
||||
<pre class="line-numbers language-markup">
|
||||
<div *ngIf="whereClauseLoading" class="progresStatic progress loop">
|
||||
<progress></progress>
|
||||
</div>
|
||||
@if (whereClauseLoading) {
|
||||
<div class="progresStatic progress loop">
|
||||
<progress></progress>
|
||||
</div>
|
||||
}
|
||||
|
||||
<code class="language-sql">{{whereClause}}</code>
|
||||
</pre>
|
||||
@@ -55,245 +56,246 @@
|
||||
class="clauses-container clr-col-md-12"
|
||||
[class.clr-col-md-10]="clauses?.queryObj?.length > 1"
|
||||
>
|
||||
<div *ngIf="clauses?.queryObj?.length > 1"></div>
|
||||
@if (clauses?.queryObj?.length > 1) {
|
||||
<div></div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="clause-row"
|
||||
*ngFor="let clause of clauses.queryObj; let clauseIndex = index"
|
||||
>
|
||||
<div class="clr-row" [class.invalid-clause]="clause.invalidClause">
|
||||
<div class="clause-logic clr-col-md-2">
|
||||
<div class="select">
|
||||
<clr-select-container>
|
||||
<label>Logic</label>
|
||||
|
||||
<select
|
||||
[(ngModel)]="clause.clauseLogic"
|
||||
(ngModelChange)="setLogic()"
|
||||
[disabled]="clause.elements.length < 2"
|
||||
clrSelect
|
||||
>
|
||||
<option
|
||||
*ngFor="let logic of logicOperators"
|
||||
[selected]="logicOperators[0]"
|
||||
>
|
||||
{{ logic }}
|
||||
</option>
|
||||
</select>
|
||||
</clr-select-container>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<button
|
||||
*ngIf="innerWidth > 768"
|
||||
class="btn btn-primary btn-block mt-10"
|
||||
(click)="addGroupClause()"
|
||||
>
|
||||
<clr-icon shape="plus"></clr-icon>
|
||||
<span>Group</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="clause-query clr-col-md-10">
|
||||
<clr-icon
|
||||
*ngIf="clauses.queryObj.length > 1"
|
||||
(click)="removeGroupClause(clauseIndex)"
|
||||
shape="times"
|
||||
size="36"
|
||||
class="remove-group-clause-button"
|
||||
></clr-icon>
|
||||
|
||||
<div
|
||||
class="clr-row"
|
||||
*ngFor="let query of clause.elements; let queryIndex = index"
|
||||
[class.invalid-clause]="query.invalidClause"
|
||||
>
|
||||
<!-- VARIABLE -->
|
||||
<div class="variable-col form-group clr-col-md-3">
|
||||
<div class="datalist-wrapper">
|
||||
<app-soft-select
|
||||
label="Variable"
|
||||
[id]="'select_vals_var_id' + queryIndex + '_' + clauseIndex"
|
||||
[inputId]="'vals_var_id' + queryIndex + '_' + clauseIndex"
|
||||
[emitOnlySelected]="true"
|
||||
[(value)]="query.variable"
|
||||
(onInputEvent)="
|
||||
variableInputChange(
|
||||
query.variable,
|
||||
queryIndex,
|
||||
clauseIndex,
|
||||
$event
|
||||
)
|
||||
"
|
||||
>
|
||||
<option *ngFor="let column of cols">
|
||||
{{ column.NAME }}
|
||||
</option>
|
||||
</app-soft-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OPERATOR -->
|
||||
<div class="operator-col form-group clr-col-md-3">
|
||||
@for (clause of clauses.queryObj; track clause; let clauseIndex = $index) {
|
||||
<div class="clause-row">
|
||||
<div class="clr-row" [class.invalid-clause]="clause.invalidClause">
|
||||
<div class="clause-logic clr-col-md-2">
|
||||
<div class="select">
|
||||
<clr-select-container>
|
||||
<label>Operator</label>
|
||||
<label>Logic</label>
|
||||
<select
|
||||
[(ngModel)]="query.operator"
|
||||
(ngModelChange)="
|
||||
setVariableOperator(queryIndex, query.operator, clauseIndex)
|
||||
"
|
||||
[(ngModel)]="clause.clauseLogic"
|
||||
(ngModelChange)="setLogic()"
|
||||
[disabled]="clause.elements.length < 2"
|
||||
clrSelect
|
||||
>
|
||||
<option *ngFor="let opr of query.operators">{{ opr }}</option>
|
||||
@for (logic of logicOperators; track logic) {
|
||||
<option [selected]="logicOperators[0]">
|
||||
{{ logic }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</clr-select-container>
|
||||
</div>
|
||||
|
||||
<!-- VALUE -->
|
||||
<div
|
||||
*ngVar="
|
||||
query.ddtype === 'DATE' ||
|
||||
query.ddtype === 'DATETIME' ||
|
||||
query.ddtype === 'TIME' as isDateTime
|
||||
"
|
||||
class="value-col form-group clr-col-md-3"
|
||||
>
|
||||
<div
|
||||
*ngIf="query.operator === 'IN' || query.operator === 'NOT IN'"
|
||||
class="checkbox-vals"
|
||||
>
|
||||
<button
|
||||
(click)="
|
||||
currentQueryIndex = queryIndex;
|
||||
currentClauseIndex = clauseIndex
|
||||
"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Choose values
|
||||
</button>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
checkboxValues;
|
||||
context: {
|
||||
query: query,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex
|
||||
}
|
||||
"
|
||||
>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="
|
||||
query.operator !== 'BETWEEN' &&
|
||||
query.operator !== 'IN' &&
|
||||
query.operator !== 'NOT IN' &&
|
||||
query.operator !== 'LIKE' &&
|
||||
query.operator !== 'CONTAINS' &&
|
||||
query.operator !== 'BEGINS_WITH'
|
||||
"
|
||||
class="single-field-vals"
|
||||
>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
isDateTime && usePickers ? picker : notPicker;
|
||||
context: {
|
||||
query: query,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex,
|
||||
isDateTime: isDateTime
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div *ngIf="query.operator === 'BETWEEN'" class="range-vals">
|
||||
<div class="from">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
isDateTime && usePickers ? picker : notPickerRange;
|
||||
context: {
|
||||
range: 'start',
|
||||
query: query,
|
||||
queryValueIndex: 0,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex,
|
||||
isDateTime: isDateTime
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="to">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
isDateTime && usePickers ? picker : notPickerRange;
|
||||
context: {
|
||||
range: 'end',
|
||||
query: query,
|
||||
queryValueIndex: 1,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex,
|
||||
isDateTime: isDateTime
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="
|
||||
query.operator === 'LIKE' ||
|
||||
query.operator === 'BEGINS_WITH' ||
|
||||
query.operator === 'CONTAINS'
|
||||
"
|
||||
class="contains-vals"
|
||||
>
|
||||
<label class="clr-control-label">Value</label>
|
||||
|
||||
<input
|
||||
class="input-val"
|
||||
type="text"
|
||||
[(ngModel)]="query.value"
|
||||
(ngModelChange)="
|
||||
setVariableValues($event, queryIndex, clauseIndex)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clause-buttons clr-col-md-2 btn-group">
|
||||
<br />
|
||||
@if (innerWidth > 768) {
|
||||
<button
|
||||
class="btn btn-warning btn-block"
|
||||
(click)="removeClause(queryIndex, clauseIndex)"
|
||||
[disabled]="clauses.queryObj[clauseIndex].elements.length === 1"
|
||||
>
|
||||
<clr-icon shape="minus"></clr-icon>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-success btn-block"
|
||||
(click)="addClause(clauseIndex)"
|
||||
class="btn btn-primary btn-block mt-10"
|
||||
(click)="addGroupClause()"
|
||||
>
|
||||
<clr-icon shape="plus"></clr-icon>
|
||||
<span></span>
|
||||
<span>Group</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="clause-query clr-col-md-10">
|
||||
@if (clauses.queryObj.length > 1) {
|
||||
<clr-icon
|
||||
(click)="removeGroupClause(clauseIndex)"
|
||||
shape="times"
|
||||
size="36"
|
||||
class="remove-group-clause-button"
|
||||
></clr-icon>
|
||||
}
|
||||
@for (
|
||||
query of clause.elements;
|
||||
track query;
|
||||
let queryIndex = $index
|
||||
) {
|
||||
<div class="clr-row" [class.invalid-clause]="query.invalidClause">
|
||||
<!-- VARIABLE -->
|
||||
<div class="variable-col form-group clr-col-md-3">
|
||||
<div class="datalist-wrapper">
|
||||
<app-soft-select
|
||||
label="Variable"
|
||||
[id]="
|
||||
'select_vals_var_id' + queryIndex + '_' + clauseIndex
|
||||
"
|
||||
[inputId]="'vals_var_id' + queryIndex + '_' + clauseIndex"
|
||||
[emitOnlySelected]="true"
|
||||
[(value)]="query.variable"
|
||||
(onInputEvent)="
|
||||
variableInputChange(
|
||||
query.variable,
|
||||
queryIndex,
|
||||
clauseIndex,
|
||||
$event
|
||||
)
|
||||
"
|
||||
>
|
||||
@for (column of cols; track column) {
|
||||
<option>
|
||||
{{ column.NAME }}
|
||||
</option>
|
||||
}
|
||||
</app-soft-select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- OPERATOR -->
|
||||
<div class="operator-col form-group clr-col-md-3">
|
||||
<clr-select-container>
|
||||
<label>Operator</label>
|
||||
<select
|
||||
[(ngModel)]="query.operator"
|
||||
(ngModelChange)="
|
||||
setVariableOperator(
|
||||
queryIndex,
|
||||
query.operator,
|
||||
clauseIndex
|
||||
)
|
||||
"
|
||||
clrSelect
|
||||
>
|
||||
@for (opr of query.operators; track opr) {
|
||||
<option>{{ opr }}</option>
|
||||
}
|
||||
</select>
|
||||
</clr-select-container>
|
||||
</div>
|
||||
<!-- VALUE -->
|
||||
<div
|
||||
*ngVar="
|
||||
query.ddtype === 'DATE' ||
|
||||
query.ddtype === 'DATETIME' ||
|
||||
query.ddtype === 'TIME' as isDateTime
|
||||
"
|
||||
class="value-col form-group clr-col-md-3"
|
||||
>
|
||||
@if (query.operator === 'IN' || query.operator === 'NOT IN') {
|
||||
<div class="checkbox-vals">
|
||||
<button
|
||||
(click)="
|
||||
currentQueryIndex = queryIndex;
|
||||
currentClauseIndex = clauseIndex
|
||||
"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Choose values
|
||||
</button>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
checkboxValues;
|
||||
context: {
|
||||
query: query,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex
|
||||
}
|
||||
"
|
||||
>
|
||||
</ng-container>
|
||||
</div>
|
||||
}
|
||||
@if (
|
||||
query.operator !== 'BETWEEN' &&
|
||||
query.operator !== 'IN' &&
|
||||
query.operator !== 'NOT IN' &&
|
||||
query.operator !== 'LIKE' &&
|
||||
query.operator !== 'CONTAINS' &&
|
||||
query.operator !== 'BEGINS_WITH'
|
||||
) {
|
||||
<div class="single-field-vals">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
isDateTime && usePickers ? picker : notPicker;
|
||||
context: {
|
||||
query: query,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex,
|
||||
isDateTime: isDateTime
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
|
||||
</ng-container>
|
||||
</div>
|
||||
}
|
||||
@if (query.operator === 'BETWEEN') {
|
||||
<div class="range-vals">
|
||||
<div class="from">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
isDateTime && usePickers ? picker : notPickerRange;
|
||||
context: {
|
||||
range: 'start',
|
||||
query: query,
|
||||
queryValueIndex: 0,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex,
|
||||
isDateTime: isDateTime
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="to">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
isDateTime && usePickers ? picker : notPickerRange;
|
||||
context: {
|
||||
range: 'end',
|
||||
query: query,
|
||||
queryValueIndex: 1,
|
||||
queryIndex: queryIndex,
|
||||
clauseIndex: clauseIndex,
|
||||
isDateTime: isDateTime
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Based on check above, here correct ng-template will be put (picker or dropdown) -->
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (
|
||||
query.operator === 'LIKE' ||
|
||||
query.operator === 'BEGINS_WITH' ||
|
||||
query.operator === 'CONTAINS'
|
||||
) {
|
||||
<div class="contains-vals">
|
||||
<label class="clr-control-label">Value</label>
|
||||
<input
|
||||
class="input-val"
|
||||
type="text"
|
||||
[(ngModel)]="query.value"
|
||||
(ngModelChange)="
|
||||
setVariableValues($event, queryIndex, clauseIndex)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="clause-buttons clr-col-md-2 btn-group">
|
||||
<button
|
||||
class="btn btn-warning btn-block"
|
||||
(click)="removeClause(queryIndex, clauseIndex)"
|
||||
[disabled]="
|
||||
clauses.queryObj[clauseIndex].elements.length === 1
|
||||
"
|
||||
>
|
||||
<clr-icon shape="minus"></clr-icon>
|
||||
<span></span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-success btn-block"
|
||||
(click)="addClause(clauseIndex)"
|
||||
>
|
||||
<clr-icon shape="plus"></clr-icon>
|
||||
<span></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -305,8 +307,8 @@
|
||||
let-queryIndex="queryIndex"
|
||||
let-clauseIndex="clauseIndex"
|
||||
>
|
||||
<ng-container [ngSwitch]="query.ddtype">
|
||||
<ng-container *ngSwitchCase="'DATE'">
|
||||
@switch (query.ddtype) {
|
||||
@case ('DATE') {
|
||||
<app-soft-select
|
||||
label="Value"
|
||||
type="date"
|
||||
@@ -323,14 +325,13 @@
|
||||
>
|
||||
<!-- In case we want to enable dropdown on the pickers, uncomment below -->
|
||||
<!-- <ng-container *ngFor="let value of query.values">
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'date' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'date' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
</app-soft-select>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="'DATETIME'">
|
||||
}
|
||||
@case ('DATETIME') {
|
||||
<app-soft-select
|
||||
label="Value"
|
||||
type="date"
|
||||
@@ -348,12 +349,11 @@
|
||||
>
|
||||
<!-- In case we want to enable dropdown on the pickers, uncomment below -->
|
||||
<!-- <ng-container *ngFor="let value of query.values">
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'date' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'date' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
</app-soft-select>
|
||||
|
||||
<app-soft-select
|
||||
type="time"
|
||||
[disableSoftselect]="true"
|
||||
@@ -370,14 +370,13 @@
|
||||
>
|
||||
<!-- In case we want to enable dropdown on the pickers, uncomment below -->
|
||||
<!-- <ng-container *ngFor="let value of query.values">
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'time' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'time' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
</app-soft-select>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="'TIME'">
|
||||
}
|
||||
@case ('TIME') {
|
||||
<app-soft-select
|
||||
label="Value"
|
||||
type="time"
|
||||
@@ -394,13 +393,13 @@
|
||||
>
|
||||
<!-- In case we want to enable dropdown on the pickers, uncomment below -->
|
||||
<!-- <ng-container *ngFor="let value of query.values">
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'time' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
<option *ngIf="value.unformatted !== null">
|
||||
{{ value.formatted | dateTimeFormatter: 'time' }}
|
||||
</option>
|
||||
</ng-container> -->
|
||||
</app-soft-select>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template
|
||||
@@ -425,19 +424,27 @@
|
||||
onAutocompleteLoadingMore($event, query.variable, queryIndex, clauseIndex)
|
||||
"
|
||||
>
|
||||
<div *ngIf="!query.valueVariable">
|
||||
<option [value]="column.unformatted" *ngFor="let column of query.values">
|
||||
{{ column.formatted.trim() }}
|
||||
</option>
|
||||
</div>
|
||||
@if (!query.valueVariable) {
|
||||
<div>
|
||||
@for (column of query.values; track column) {
|
||||
<option [value]="column.unformatted">
|
||||
{{ column.formatted.trim() }}
|
||||
</option>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div *ngIf="query.valueVariable">
|
||||
<ng-container *ngFor="let column of cols">
|
||||
<option [value]="column.NAME" *ngIf="column.TYPE === query.type">
|
||||
{{ column.NAME }}
|
||||
</option>
|
||||
</ng-container>
|
||||
</div>
|
||||
@if (query.valueVariable) {
|
||||
<div>
|
||||
@for (column of cols; track column) {
|
||||
@if (column.TYPE === query.type) {
|
||||
<option [value]="column.NAME">
|
||||
{{ column.NAME }}
|
||||
</option>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</app-soft-select>
|
||||
</ng-template>
|
||||
|
||||
@@ -466,9 +473,11 @@
|
||||
: false
|
||||
"
|
||||
>
|
||||
<option [value]="column.formatted" *ngFor="let column of query.values">
|
||||
{{ column.formatted }}
|
||||
</option>
|
||||
@for (column of query.values; track column) {
|
||||
<option [value]="column.formatted">
|
||||
{{ column.formatted }}
|
||||
</option>
|
||||
}
|
||||
</app-soft-select>
|
||||
</ng-template>
|
||||
|
||||
@@ -487,30 +496,31 @@
|
||||
>
|
||||
<h3 class="modal-title">Select values</h3>
|
||||
<div class="modal-body">
|
||||
<h5 *ngIf="!isArr(query.value)" class="no-values">
|
||||
No values available.
|
||||
</h5>
|
||||
@if (!isArr(query.value)) {
|
||||
<h5 class="no-values">No values available.</h5>
|
||||
}
|
||||
|
||||
<section class="form-block" *ngIf="isArr(query.value)">
|
||||
<clr-checkbox-container>
|
||||
<clr-checkbox-wrapper
|
||||
*ngFor="let column of query.values; let i = index"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
clrCheckbox
|
||||
[(ngModel)]="query.value[i].checked"
|
||||
(ngModelChange)="
|
||||
setVariableValues($event, queryIndex, clauseIndex)
|
||||
"
|
||||
/>
|
||||
|
||||
<label>
|
||||
{{ column.formatted }}
|
||||
</label>
|
||||
</clr-checkbox-wrapper>
|
||||
</clr-checkbox-container>
|
||||
</section>
|
||||
@if (isArr(query.value)) {
|
||||
<section class="form-block">
|
||||
<clr-checkbox-container>
|
||||
@for (column of query.values; track column; let i = $index) {
|
||||
<clr-checkbox-wrapper>
|
||||
<input
|
||||
type="checkbox"
|
||||
clrCheckbox
|
||||
[(ngModel)]="query.value[i].checked"
|
||||
(ngModelChange)="
|
||||
setVariableValues($event, queryIndex, clauseIndex)
|
||||
"
|
||||
/>
|
||||
<label>
|
||||
{{ column.formatted }}
|
||||
</label>
|
||||
</clr-checkbox-wrapper>
|
||||
}
|
||||
</clr-checkbox-container>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
|
||||
@@ -22,6 +22,7 @@ import { QueryDateTime } from './models/QueryDateTime.model'
|
||||
import { isSpecialMissing } from '@sasjs/utils/input/validators'
|
||||
import { get } from 'lodash-es'
|
||||
import { OnLoadingMoreEvent } from '../shared/autocomplete/autocomplete.component'
|
||||
import { getFilterObjPath } from './utils/getFilterObjPath'
|
||||
|
||||
registerLocaleData(localeEnGB)
|
||||
@Component({
|
||||
@@ -251,17 +252,7 @@ export class QueryComponent
|
||||
public setToGlobals() {
|
||||
if (!this.caching) return
|
||||
|
||||
let objPath = ''
|
||||
|
||||
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
|
||||
if (this.viewboxId) {
|
||||
objPath = `viewboxes.${this.viewboxId}`
|
||||
} else {
|
||||
objPath = 'editor'
|
||||
}
|
||||
} else if (globals.rootParam === 'view') {
|
||||
objPath = 'viewer'
|
||||
}
|
||||
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
|
||||
|
||||
get(globals, objPath).filter.groupLogic = this.groupLogic
|
||||
if (typeof this.whereClause === 'string') {
|
||||
@@ -280,17 +271,7 @@ export class QueryComponent
|
||||
public getFromGlobals() {
|
||||
if (!this.caching) return
|
||||
|
||||
let objPath = ''
|
||||
|
||||
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
|
||||
if (this.viewboxId) {
|
||||
objPath = `viewboxes.${this.viewboxId}`
|
||||
} else {
|
||||
objPath = 'editor'
|
||||
}
|
||||
} else if (globals.rootParam === 'view') {
|
||||
objPath = 'viewer'
|
||||
}
|
||||
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
|
||||
|
||||
if (get(globals, objPath).filter.cols.length > 0) {
|
||||
this.cols = JSON.parse(JSON.stringify(get(globals, objPath).filter.cols))
|
||||
@@ -1051,22 +1032,23 @@ export class QueryComponent
|
||||
this.columnsSub = this.sasStoreService.columns.subscribe(
|
||||
(response: any) => {
|
||||
let cols = response.data.cols
|
||||
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
|
||||
|
||||
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
|
||||
this.cols = cols
|
||||
let some = cols[0].NAME
|
||||
this.libds = response.libds
|
||||
|
||||
globals.editor.filter.cols = JSON.parse(JSON.stringify(cols))
|
||||
get(globals, objPath).filter.cols = JSON.parse(JSON.stringify(cols))
|
||||
}
|
||||
|
||||
if (globals.rootParam === 'view') {
|
||||
if (globals.viewer.filter.cols.length < 1) {
|
||||
if (get(globals, objPath).filter.cols.length < 1) {
|
||||
this.cols = cols
|
||||
let some = cols[0].NAME
|
||||
this.libds = response.libds
|
||||
|
||||
globals.viewer.filter.cols = JSON.parse(JSON.stringify(cols))
|
||||
get(globals, objPath).filter.cols = JSON.parse(JSON.stringify(cols))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1077,20 +1059,12 @@ export class QueryComponent
|
||||
)
|
||||
|
||||
this.valuesSub = this.sasStoreService.values.subscribe((res: any) => {
|
||||
if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
|
||||
if (globals.editor.filter.vals.length < 1) {
|
||||
this.vals = res.vals
|
||||
const objPath = getFilterObjPath(globals.rootParam, this.viewboxId)
|
||||
|
||||
globals.editor.filter.vals = JSON.parse(JSON.stringify(res.vals))
|
||||
}
|
||||
}
|
||||
if (get(globals, objPath).filter.vals.length < 1) {
|
||||
this.vals = res.vals
|
||||
|
||||
if (globals.rootParam === 'view') {
|
||||
if (globals.viewer.filter.vals.length < 1) {
|
||||
this.vals = res.vals
|
||||
|
||||
globals.viewer.filter.vals = JSON.parse(JSON.stringify(res.vals))
|
||||
}
|
||||
get(globals, objPath).filter.vals = JSON.parse(JSON.stringify(res.vals))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getFilterObjPath } from './getFilterObjPath'
|
||||
|
||||
describe('getFilterObjPath', () => {
|
||||
it("resolves to 'viewer' for the view page with no viewboxId (existing correct case)", () => {
|
||||
expect(getFilterObjPath('view', undefined)).toEqual('viewer')
|
||||
})
|
||||
|
||||
it("resolves to 'editor' for the editor page with no viewboxId", () => {
|
||||
expect(getFilterObjPath('editor', undefined)).toEqual('editor')
|
||||
})
|
||||
|
||||
it("resolves to 'editor' for the home page with no viewboxId (existing home/editor grouping)", () => {
|
||||
expect(getFilterObjPath('home', undefined)).toEqual('editor')
|
||||
})
|
||||
|
||||
it("resolves to 'viewboxes.<id>' when viewboxId is set on the view page - the actual bug (previously returned 'viewer')", () => {
|
||||
expect(getFilterObjPath('view', 42)).toEqual('viewboxes.42')
|
||||
})
|
||||
|
||||
it("resolves to 'viewboxes.<id>' when viewboxId is set on the editor page (already correct today)", () => {
|
||||
expect(getFilterObjPath('editor', 42)).toEqual('viewboxes.42')
|
||||
})
|
||||
|
||||
it("resolves to 'viewboxes.<id>' when viewboxId is set on the home page (already correct today)", () => {
|
||||
expect(getFilterObjPath('home', 42)).toEqual('viewboxes.42')
|
||||
})
|
||||
|
||||
it('resolves to an empty string for an unrecognized rootParam (matches existing fallthrough)', () => {
|
||||
expect(getFilterObjPath('', undefined)).toEqual('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Resolves which bucket of the `globals` filter cache (_globals.ts) a
|
||||
* QueryComponent instance should read/write. viewboxId is checked first,
|
||||
* unconditionally.
|
||||
*/
|
||||
export const getFilterObjPath = (
|
||||
rootParam: string,
|
||||
viewboxId?: number
|
||||
): string => {
|
||||
if (viewboxId) return `viewboxes.${viewboxId}`
|
||||
if (rootParam === 'home' || rootParam === 'editor') return 'editor'
|
||||
if (rootParam === 'view') return 'viewer'
|
||||
|
||||
return ''
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,148 +1,161 @@
|
||||
<div class="content-area">
|
||||
<div class="card">
|
||||
<div *ngIf="remained === 0" class="d-flex justify-content-center">
|
||||
<div class="card-block noapprovals-info-wrapper">
|
||||
<clr-icon
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<h3 class="text-center color-gray">There are no approvals remaining</h3>
|
||||
@if (remained === 0) {
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="card-block noapprovals-info-wrapper">
|
||||
<clr-icon
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info icon-dc-fill"
|
||||
></clr-icon>
|
||||
<h3 class="text-center color-gray">
|
||||
There are no approvals remaining
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
|
||||
<h3
|
||||
class="center clr-col-md-12 text-center"
|
||||
*ngIf="loaded && remained !== 0"
|
||||
>
|
||||
REVIEW
|
||||
</h3>
|
||||
<p
|
||||
class="text-center font-weight-700 color-dark-gray"
|
||||
*ngIf="loaded && remained !== 0"
|
||||
>
|
||||
You have <span>{{ remained }} </span>approvals remaining
|
||||
</p>
|
||||
@if (loaded && remained !== 0) {
|
||||
<h3 class="center clr-col-md-12 text-center">REVIEW</h3>
|
||||
}
|
||||
@if (loaded && remained !== 0) {
|
||||
<p class="text-center font-weight-700 color-dark-gray">
|
||||
You have <span>{{ remained }} </span>approvals remaining
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div *ngIf="!loaded" class="approvals-list-wrapper">
|
||||
<span class="spinner" *ngIf="!loaded"> Loading... </span>
|
||||
<div *ngIf="!loaded">
|
||||
<h3>Loading approvals list</h3>
|
||||
@if (!loaded) {
|
||||
<div class="approvals-list-wrapper">
|
||||
@if (!loaded) {
|
||||
<span class="spinner"> Loading... </span>
|
||||
}
|
||||
@if (!loaded) {
|
||||
<div>
|
||||
<h3>Loading approvals list</h3>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="clr-col-md-12" ng-if="loaded">
|
||||
<div *ngIf="approveList && remained !== 0">
|
||||
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
|
||||
<clr-dg-column [clrDgField]="'submitter'">
|
||||
SUBMITTER
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitterFilter"
|
||||
aria-label="Filter submitter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'baseTable'">
|
||||
BASE TABLE
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="baseTableFilter"
|
||||
aria-label="Filter base table"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitted'">
|
||||
SUBMITTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submittedFilter"
|
||||
aria-label="Filter submitted date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitReason'">
|
||||
SUBMIT REASON
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitReasonFilter"
|
||||
aria-label="Filter submit reason"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column>ACTION</clr-dg-column>
|
||||
<clr-dg-column>DOWNLOAD</clr-dg-column>
|
||||
|
||||
<clr-dg-row
|
||||
*clrDgItems="let approveItem of approveList; let i = index"
|
||||
>
|
||||
<clr-dg-cell>{{ approveItem.submitter }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ approveItem.baseTable }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ approveItem.submitted }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ approveItem.submitReason }}</clr-dg-cell>
|
||||
<clr-dg-cell>
|
||||
<div
|
||||
class="clr-row d-flex justify-content-around"
|
||||
role="toolbar"
|
||||
aria-label="Table actions"
|
||||
>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-green"
|
||||
(click)="getClicked(i)"
|
||||
@if (approveList && remained !== 0) {
|
||||
<div>
|
||||
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
|
||||
<clr-dg-column [clrDgField]="'submitter'">
|
||||
SUBMITTER
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitterFilter"
|
||||
aria-label="Filter submitter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'baseTable'">
|
||||
BASE TABLE
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="baseTableFilter"
|
||||
aria-label="Filter base table"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitted'">
|
||||
SUBMITTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submittedFilter"
|
||||
aria-label="Filter submitted date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitReason'">
|
||||
SUBMIT REASON
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitReasonFilter"
|
||||
aria-label="Filter submit reason"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column>ACTION</clr-dg-column>
|
||||
<clr-dg-column>DOWNLOAD</clr-dg-column>
|
||||
<clr-dg-row
|
||||
*clrDgItems="let approveItem of approveList; let i = index"
|
||||
>
|
||||
<clr-dg-cell>{{ approveItem.submitter }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ approveItem.baseTable }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ approveItem.submitted }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ approveItem.submitReason }}</clr-dg-cell>
|
||||
<clr-dg-cell>
|
||||
<div
|
||||
class="clr-row d-flex justify-content-around"
|
||||
role="toolbar"
|
||||
aria-label="Table actions"
|
||||
>
|
||||
<clr-icon
|
||||
shape="check"
|
||||
size="24"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content">Go to review page screen</span>
|
||||
</a>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-red"
|
||||
(click)="!approveItem.rejectLoading ? rejecting(i) : ''"
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-green"
|
||||
(click)="getClicked(i)"
|
||||
>
|
||||
<clr-icon
|
||||
shape="check"
|
||||
size="24"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content"
|
||||
>Go to review page screen</span
|
||||
>
|
||||
</a>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-red"
|
||||
(click)="!approveItem.rejectLoading ? rejecting(i) : ''"
|
||||
>
|
||||
@if (!approveItem.rejectLoading) {
|
||||
<clr-icon
|
||||
shape="ban"
|
||||
size="22"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
}
|
||||
@if (approveItem.rejectLoading) {
|
||||
<clr-spinner
|
||||
[clrSmall]="true"
|
||||
aria-hidden="true"
|
||||
></clr-spinner>
|
||||
}
|
||||
<span class="tooltip-content">Reject</span>
|
||||
</a>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
|
||||
(click)="getTable(approveItem.tableId)"
|
||||
>
|
||||
<clr-icon
|
||||
shape="code"
|
||||
size="28"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content"
|
||||
>Go to staged data screen</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell class="p-0 d-flex justify-content-center">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
aria-label="Download audit file"
|
||||
[id]="approveItem.tableId"
|
||||
(click)="
|
||||
download(approveItem.tableId); $event.stopPropagation()
|
||||
"
|
||||
>
|
||||
<clr-icon
|
||||
*ngIf="!approveItem.rejectLoading"
|
||||
shape="ban"
|
||||
size="22"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<clr-spinner
|
||||
*ngIf="approveItem.rejectLoading"
|
||||
[clrSmall]="true"
|
||||
aria-hidden="true"
|
||||
></clr-spinner>
|
||||
<span class="tooltip-content">Reject</span>
|
||||
</a>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
|
||||
(click)="getTable(approveItem.tableId)"
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
<clr-dg-footer>
|
||||
<clr-dg-pagination #pagination [clrDgPageSize]="10">
|
||||
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
|
||||
>Items per page</clr-dg-page-size
|
||||
>
|
||||
<clr-icon
|
||||
shape="code"
|
||||
size="28"
|
||||
aria-hidden="true"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content">Go to staged data screen</span>
|
||||
</a>
|
||||
</div>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell class="p-0 d-flex justify-content-center">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
aria-label="Download audit file"
|
||||
[id]="approveItem.tableId"
|
||||
(click)="
|
||||
download(approveItem.tableId); $event.stopPropagation()
|
||||
"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
|
||||
<clr-dg-footer>
|
||||
<clr-dg-pagination #pagination [clrDgPageSize]="10">
|
||||
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
|
||||
>Items per page</clr-dg-page-size
|
||||
>
|
||||
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} approvals
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer>
|
||||
</clr-datagrid>
|
||||
</div>
|
||||
{{ pagination.firstItem + 1 }} -
|
||||
{{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} approvals
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer>
|
||||
</clr-datagrid>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,12 +1,13 @@
|
||||
<div class="content-area">
|
||||
<div
|
||||
*ngIf="noData"
|
||||
id="noDataContainer"
|
||||
class="card-block d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<clr-icon shape="warning-standard" size="60" class="is-info"></clr-icon>
|
||||
<h3 class="text-center color-gray">There is no history to show</h3>
|
||||
</div>
|
||||
@if (noData) {
|
||||
<div
|
||||
id="noDataContainer"
|
||||
class="card-block d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<clr-icon shape="warning-standard" size="60" class="is-info"></clr-icon>
|
||||
<h3 class="text-center color-gray">There is no history to show</h3>
|
||||
</div>
|
||||
}
|
||||
<clr-modal [(clrModalOpen)]="openModal" [clrModalSize]="'xl'">
|
||||
<h4 class="modal-title">Approval details</h4>
|
||||
<div class="modal-body">
|
||||
@@ -18,31 +19,38 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let col of tableTitles; let ind = index">
|
||||
<td class="left">{{ col }}</td>
|
||||
<td class="left">
|
||||
<a
|
||||
*ngIf="ind < 1"
|
||||
(click)="getTable(approveData[col])"
|
||||
class="cursor-pointer table-link"
|
||||
>{{ approveData[col] }}</a
|
||||
>
|
||||
<div *ngIf="ind < 2 && ind >= 1">
|
||||
<a
|
||||
(click)="getBaseTable(approveData[col])"
|
||||
class="cursor-pointer table-link"
|
||||
>VIEW</a
|
||||
>
|
||||
<span> / </span>
|
||||
<a
|
||||
(click)="getEditTable(approveData[col])"
|
||||
class="cursor-pointer table-link"
|
||||
>EDIT</a
|
||||
>
|
||||
</div>
|
||||
<span *ngIf="ind >= 2">{{ approveData[col] }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
@for (col of tableTitles; track col; let ind = $index) {
|
||||
<tr>
|
||||
<td class="left">{{ col }}</td>
|
||||
<td class="left">
|
||||
@if (ind < 1) {
|
||||
<a
|
||||
(click)="getTable(approveData[col])"
|
||||
class="cursor-pointer table-link"
|
||||
>{{ approveData[col] }}</a
|
||||
>
|
||||
}
|
||||
@if (ind < 2 && ind >= 1) {
|
||||
<div>
|
||||
<a
|
||||
(click)="getBaseTable(approveData[col])"
|
||||
class="cursor-pointer table-link"
|
||||
>VIEW</a
|
||||
>
|
||||
<span> / </span>
|
||||
<a
|
||||
(click)="getEditTable(approveData[col])"
|
||||
class="cursor-pointer table-link"
|
||||
>EDIT</a
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@if (ind >= 2) {
|
||||
<span>{{ approveData[col] }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -58,148 +66,159 @@
|
||||
</div>
|
||||
</clr-modal>
|
||||
|
||||
<div
|
||||
*ngIf="!loaded"
|
||||
class="h-70vh d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<span class="spinner" *ngIf="!loaded"> Loading... </span>
|
||||
<div *ngIf="!loaded">
|
||||
<h3>Loading history</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!noData && loaded" class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="center clr-col-md-12 text-center" *ngIf="loaded">HISTORY</h3>
|
||||
<p
|
||||
class="text-center font-weight-700 color-dark-gray"
|
||||
*ngIf="licenceState.value.history_rows_allowed !== Infinity"
|
||||
>
|
||||
To unlock more than
|
||||
{{ licenceState.value.history_rows_allowed }} records, contact
|
||||
support@datacontroller.io
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<clr-datagrid
|
||||
class="datagrid-history datagrid-custom-footer"
|
||||
*ngIf="loaded"
|
||||
>
|
||||
<clr-dg-column [clrDgField]="'basetable'">
|
||||
BASE_TABLE
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="baseTableFilter"
|
||||
aria-label="Filter base table"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'status'">
|
||||
STATUS
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="statusFilter"
|
||||
aria-label="Filter status"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitter'">
|
||||
SUBMITTER
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitterFilter"
|
||||
aria-label="Filter submitter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submittedReason'">
|
||||
SUBMIT REASON
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitReasonFilter"
|
||||
aria-label="Filter submit reason"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitted'">
|
||||
SUBMITTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submittedFilter"
|
||||
aria-label="Filter submitted date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'reviewed'">
|
||||
APPROVED / REJECTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="reviewedFilter"
|
||||
aria-label="Filter reviewed date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column>DOWNLOAD</clr-dg-column>
|
||||
|
||||
<clr-dg-row
|
||||
*clrDgItems="let historyItem of history"
|
||||
(click)="getApprIndex(historyItem)"
|
||||
>
|
||||
<clr-dg-cell class="verCenter">
|
||||
<a
|
||||
class="btn btn-sm btn-link m-0"
|
||||
(click)="getBaseTable(historyItem.basetable)"
|
||||
>{{ historyItem.basetable }}</a
|
||||
>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell
|
||||
class="verCenter"
|
||||
[ngClass]="{
|
||||
rejected: historyItem.status === 'REJECTED',
|
||||
accepted: historyItem.status === 'APPROVED'
|
||||
}"
|
||||
>{{ historyItem.status }}</clr-dg-cell
|
||||
>
|
||||
<clr-dg-cell class="verCenter">{{ historyItem.submitter }}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter">{{
|
||||
historyItem.submittedReason
|
||||
}}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter">{{ historyItem.submitted }}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter">{{ historyItem.reviewed }}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter p-0 d-flex justify-content-center">
|
||||
<button
|
||||
aria-label="Download audit file"
|
||||
class="btn btn-success"
|
||||
(click)="download(historyItem.tableId); $event.stopPropagation()"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
|
||||
<!-- Let's keep this part if in future we decide to do the paging instead of `load more` approach -->
|
||||
<!-- <clr-dg-footer class="d-flex justify-content-start">
|
||||
<span>items per page</span>
|
||||
|
||||
<select class="mx-5" [(ngModel)]="itemsNum">
|
||||
<option [ngValue]="3">3</option>
|
||||
<option [ngValue]="5">5</option>
|
||||
<option [ngValue]="10">10</option>
|
||||
<option [ngValue]="15">15</option>
|
||||
</select>
|
||||
<clr-dg-pagination
|
||||
#pagination
|
||||
[clrDgPageSize]="itemsNum"
|
||||
class="center"
|
||||
>
|
||||
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} updates
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer> -->
|
||||
</clr-datagrid>
|
||||
|
||||
@if (!loaded) {
|
||||
<div
|
||||
class="load-more d-flex clr-justify-content-center clr-align-items-center"
|
||||
class="h-70vh d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<button
|
||||
*ngIf="
|
||||
@if (!loaded) {
|
||||
<span class="spinner"> Loading... </span>
|
||||
}
|
||||
@if (!loaded) {
|
||||
<div>
|
||||
<h3>Loading history</h3>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!noData && loaded) {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@if (loaded) {
|
||||
<h3 class="center clr-col-md-12 text-center">HISTORY</h3>
|
||||
}
|
||||
@if (licenceState.value.history_rows_allowed !== Infinity) {
|
||||
<p class="text-center font-weight-700 color-dark-gray">
|
||||
To unlock more than
|
||||
{{ licenceState.value.history_rows_allowed }} records, contact
|
||||
support@datacontroller.io
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@if (loaded) {
|
||||
<clr-datagrid class="datagrid-history datagrid-custom-footer">
|
||||
<clr-dg-column [clrDgField]="'basetable'">
|
||||
BASE_TABLE
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="baseTableFilter"
|
||||
aria-label="Filter base table"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'status'">
|
||||
STATUS
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="statusFilter"
|
||||
aria-label="Filter status"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitter'">
|
||||
SUBMITTER
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitterFilter"
|
||||
aria-label="Filter submitter"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submittedReason'">
|
||||
SUBMIT REASON
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitReasonFilter"
|
||||
aria-label="Filter submit reason"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitted'">
|
||||
SUBMITTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submittedFilter"
|
||||
aria-label="Filter submitted date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'reviewed'">
|
||||
APPROVED / REJECTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="reviewedFilter"
|
||||
aria-label="Filter reviewed date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column>DOWNLOAD</clr-dg-column>
|
||||
<clr-dg-row
|
||||
*clrDgItems="let historyItem of history"
|
||||
(click)="getApprIndex(historyItem)"
|
||||
>
|
||||
<clr-dg-cell class="verCenter">
|
||||
<a
|
||||
class="btn btn-sm btn-link m-0"
|
||||
(click)="getBaseTable(historyItem.basetable)"
|
||||
>{{ historyItem.basetable }}</a
|
||||
>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell
|
||||
class="verCenter"
|
||||
[ngClass]="{
|
||||
rejected: historyItem.status === 'REJECTED',
|
||||
accepted: historyItem.status === 'APPROVED'
|
||||
}"
|
||||
>{{ historyItem.status }}</clr-dg-cell
|
||||
>
|
||||
<clr-dg-cell class="verCenter">{{
|
||||
historyItem.submitter
|
||||
}}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter">{{
|
||||
historyItem.submittedReason
|
||||
}}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter">{{
|
||||
historyItem.submitted
|
||||
}}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter">{{
|
||||
historyItem.reviewed
|
||||
}}</clr-dg-cell>
|
||||
<clr-dg-cell class="verCenter p-0 d-flex justify-content-center">
|
||||
<button
|
||||
aria-label="Download audit file"
|
||||
class="btn btn-success"
|
||||
(click)="
|
||||
download(historyItem.tableId); $event.stopPropagation()
|
||||
"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
<!-- Let's keep this part if in future we decide to do the paging instead of `load more` approach -->
|
||||
<!-- <clr-dg-footer class="d-flex justify-content-start">
|
||||
<span>items per page</span>
|
||||
<select class="mx-5" [(ngModel)]="itemsNum">
|
||||
<option [ngValue]="3">3</option>
|
||||
<option [ngValue]="5">5</option>
|
||||
<option [ngValue]="10">10</option>
|
||||
<option [ngValue]="15">15</option>
|
||||
</select>
|
||||
<clr-dg-pagination
|
||||
#pagination
|
||||
[clrDgPageSize]="itemsNum"
|
||||
class="center"
|
||||
>
|
||||
{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} updates
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer> -->
|
||||
</clr-datagrid>
|
||||
}
|
||||
<div
|
||||
class="load-more d-flex clr-justify-content-center clr-align-items-center"
|
||||
>
|
||||
@if (
|
||||
this.licenceState.value.history_rows_allowed === Infinity &&
|
||||
rowsLeftToLoad > 0
|
||||
"
|
||||
(click)="loadData()"
|
||||
[clrLoading]="loadingMore"
|
||||
class="btn btn-success"
|
||||
>
|
||||
Load {{ rowsLeftToLoad }} more
|
||||
</button>
|
||||
) {
|
||||
<button
|
||||
(click)="loadData()"
|
||||
[clrLoading]="loadingMore"
|
||||
class="btn btn-success"
|
||||
>
|
||||
Load {{ rowsLeftToLoad }} more
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,123 +1,136 @@
|
||||
<div class="w-100">
|
||||
<div *ngIf="!subReady" class="content-area">
|
||||
<div *ngIf="!subReady" class="card">
|
||||
<div
|
||||
*ngIf="remained === 0 && loaded"
|
||||
class="d-flex justify-content-center"
|
||||
>
|
||||
<div
|
||||
class="no-submitted-tables card-block d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<clr-icon
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info"
|
||||
></clr-icon>
|
||||
<h3 class="text-center color-gray">
|
||||
You have not submitted any tables
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
|
||||
<h3 class="center clr-col-md-12 text-center" *ngIf="remained !== 0">
|
||||
SUBMIT QUEUE
|
||||
</h3>
|
||||
<p
|
||||
class="text-center font-weight-700 color-dark-gray"
|
||||
*ngIf="loaded && remained !== 0"
|
||||
>
|
||||
You have <span>{{ remained }} </span>submissions waiting to be
|
||||
approved
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="!loaded"
|
||||
class="h-70vh d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<span class="spinner" *ngIf="!loaded"> Loading... </span>
|
||||
<div *ngIf="!loaded">
|
||||
<h3>Loading submitted list</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clr-col-md-12" *ngIf="loaded">
|
||||
<div *ngIf="submitterList && remained !== 0">
|
||||
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
|
||||
<clr-dg-column>BASE TABLE</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitted'">
|
||||
SUBMITTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submittedFilter"
|
||||
aria-label="Filter submitted date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitReason'">
|
||||
SUBMIT REASON
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitReasonFilter"
|
||||
aria-label="Filter submit reason"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column class="d-flex justify-content-center"
|
||||
>ACTION</clr-dg-column
|
||||
@if (!subReady) {
|
||||
<div class="content-area">
|
||||
@if (!subReady) {
|
||||
<div class="card">
|
||||
@if (remained === 0 && loaded) {
|
||||
<div class="d-flex justify-content-center">
|
||||
<div
|
||||
class="no-submitted-tables card-block d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<clr-icon
|
||||
shape="warning-standard"
|
||||
size="60"
|
||||
class="is-info"
|
||||
></clr-icon>
|
||||
<h3 class="text-center color-gray">
|
||||
You have not submitted any tables
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="card-header" [ngClass]="{ noBorder: !loaded }">
|
||||
@if (remained !== 0) {
|
||||
<h3 class="center clr-col-md-12 text-center">SUBMIT QUEUE</h3>
|
||||
}
|
||||
@if (loaded && remained !== 0) {
|
||||
<p class="text-center font-weight-700 color-dark-gray">
|
||||
You have <span>{{ remained }} </span>submissions waiting to be
|
||||
approved
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@if (!loaded) {
|
||||
<div
|
||||
class="h-70vh d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<clr-dg-column class="d-flex justify-content-center"
|
||||
>DOWNLOAD</clr-dg-column
|
||||
>
|
||||
<clr-dg-row
|
||||
*clrDgItems="let sub of submitterList; let i = index"
|
||||
(click)="goToDetails(sub.tableId)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<clr-dg-cell>{{ sub.base }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ sub.submitted }}</clr-dg-cell>
|
||||
<!-- <clr-dg-cell>{{sub.approver}}</clr-dg-cell> -->
|
||||
<clr-dg-cell>{{ sub.submitReason }}</clr-dg-cell>
|
||||
<clr-dg-cell>
|
||||
<div
|
||||
class="row justify-content-around"
|
||||
role="tooltip"
|
||||
aria-label="Go to staged data screen"
|
||||
>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
|
||||
(click)="goToStage(sub.tableId)"
|
||||
>
|
||||
<clr-icon shape="code" size="28"></clr-icon>
|
||||
<span class="tooltip-content"
|
||||
>Go to staged data screen</span
|
||||
>
|
||||
</a>
|
||||
@if (!loaded) {
|
||||
<span class="spinner"> Loading... </span>
|
||||
}
|
||||
@if (!loaded) {
|
||||
<div>
|
||||
<h3>Loading submitted list</h3>
|
||||
</div>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell class="p-0 d-flex justify-content-center">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
aria-label="Download audit file for table record"
|
||||
(click)="download(sub.tableId); $event.stopPropagation()"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
|
||||
<clr-dg-footer>
|
||||
<clr-dg-pagination #pagination [clrDgPageSize]="10">
|
||||
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
|
||||
>Items per page</clr-dg-page-size
|
||||
>
|
||||
{{ pagination.firstItem + 1 }} -
|
||||
{{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} submissions
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer>
|
||||
</clr-datagrid>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (loaded) {
|
||||
<div class="clr-col-md-12">
|
||||
@if (submitterList && remained !== 0) {
|
||||
<div>
|
||||
<clr-datagrid class="datagrid-compact datagrid-custom-footer">
|
||||
<clr-dg-column>BASE TABLE</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitted'">
|
||||
SUBMITTED
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submittedFilter"
|
||||
aria-label="Filter submitted date"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column [clrDgField]="'submitReason'">
|
||||
SUBMIT REASON
|
||||
<clr-dg-string-filter
|
||||
[clrDgStringFilter]="submitReasonFilter"
|
||||
aria-label="Filter submit reason"
|
||||
></clr-dg-string-filter>
|
||||
</clr-dg-column>
|
||||
<clr-dg-column class="d-flex justify-content-center"
|
||||
>ACTION</clr-dg-column
|
||||
>
|
||||
<clr-dg-column class="d-flex justify-content-center"
|
||||
>DOWNLOAD</clr-dg-column
|
||||
>
|
||||
<clr-dg-row
|
||||
*clrDgItems="let sub of submitterList; let i = index"
|
||||
(click)="goToDetails(sub.tableId)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<clr-dg-cell>{{ sub.base }}</clr-dg-cell>
|
||||
<clr-dg-cell>{{ sub.submitted }}</clr-dg-cell>
|
||||
<!-- <clr-dg-cell>{{sub.approver}}</clr-dg-cell> -->
|
||||
<clr-dg-cell>{{ sub.submitReason }}</clr-dg-cell>
|
||||
<clr-dg-cell>
|
||||
<div
|
||||
class="row justify-content-around"
|
||||
role="tooltip"
|
||||
aria-label="Go to staged data screen"
|
||||
>
|
||||
<a
|
||||
class="column-center links tooltip tooltip-md tooltip-bottom-left color-blue"
|
||||
(click)="goToStage(sub.tableId)"
|
||||
>
|
||||
<clr-icon shape="code" size="28"></clr-icon>
|
||||
<span class="tooltip-content"
|
||||
>Go to staged data screen</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell class="p-0 d-flex justify-content-center">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
aria-label="Download audit file for table record"
|
||||
(click)="
|
||||
download(sub.tableId); $event.stopPropagation()
|
||||
"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
<clr-dg-footer>
|
||||
<clr-dg-pagination #pagination [clrDgPageSize]="10">
|
||||
<clr-dg-page-size [clrPageSizeOptions]="[3, 5, 10, 15]"
|
||||
>Items per page</clr-dg-page-size
|
||||
>
|
||||
{{ pagination.firstItem + 1 }} -
|
||||
{{ pagination.lastItem + 1 }} of
|
||||
{{ pagination.totalItems }} submissions
|
||||
</clr-dg-pagination>
|
||||
</clr-dg-footer>
|
||||
</clr-datagrid>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div *ngIf="subReady">
|
||||
<app-approve-details></app-approve-details>
|
||||
</div>
|
||||
@if (subReady) {
|
||||
<div>
|
||||
<app-approve-details></app-approve-details>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,130 +1,146 @@
|
||||
<app-sidebar class="sidebar-height">
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngIf="roles" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="Filter by Roles"
|
||||
name="input"
|
||||
[(ngModel)]="roleSearch"
|
||||
(keyup)="roleListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length > 0"
|
||||
(click)="roleSearch = ''; roleListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
<ng-container *ngFor="let role of roles">
|
||||
<clr-tree-node
|
||||
(click)="roleOnClick(role)"
|
||||
*ngIf="!role['hidden']"
|
||||
[class.active]="role.ROLEURI === roleUri"
|
||||
>
|
||||
<p class="m-0 cursor-pointer list-padding">
|
||||
<clr-icon shape="blocks-group"></clr-icon>
|
||||
{{ role.ROLENAME }}
|
||||
</p>
|
||||
@if (roles) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="Filter by Roles"
|
||||
name="input"
|
||||
[(ngModel)]="roleSearch"
|
||||
(keyup)="roleListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchLibTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchLibTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
(click)="roleSearch = ''; roleListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
</ng-container>
|
||||
}
|
||||
@for (role of roles; track role) {
|
||||
@if (!role['hidden']) {
|
||||
<clr-tree-node
|
||||
(click)="roleOnClick(role)"
|
||||
[class.active]="role.ROLEURI === roleUri"
|
||||
>
|
||||
<p class="m-0 cursor-pointer list-padding">
|
||||
<clr-icon shape="blocks-group"></clr-icon>
|
||||
{{ role.ROLENAME }}
|
||||
</p>
|
||||
</clr-tree-node>
|
||||
}
|
||||
}
|
||||
</clr-tree>
|
||||
</app-sidebar>
|
||||
|
||||
<div class="content-area">
|
||||
<div *ngIf="loading" class="loadingSpinner">
|
||||
<span class="spinner"> Loading... </span>
|
||||
</div>
|
||||
<div *ngIf="roleMembers && !loading">
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<table class="table role-info">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<p class="role-info-text">
|
||||
<b>{{ roleName }}</b>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<i>{{ roleDesc }}</i>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (loading) {
|
||||
<div class="loadingSpinner">
|
||||
<span class="spinner"> Loading... </span>
|
||||
</div>
|
||||
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<div class="card role-data">
|
||||
<div>
|
||||
<h3>MEMBERS ({{ roleMembersCount }})</h3>
|
||||
<h5 *ngIf="roleMembersCount == 0">No Members Present</h5>
|
||||
<div class="table-container">
|
||||
<table *ngIf="roleMembersCount != 0" class="table member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="width-25"><b>NAME</b></td>
|
||||
<td class="width-25"><b>EMAIL</b></td>
|
||||
<td class="width-25"><b>CREATED</b></td>
|
||||
<td class=""><b>UPDATED</b></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
[routerLink]="'/view/usernav/users/' + member.URIMEM"
|
||||
*ngFor="let member of roleMembers"
|
||||
>
|
||||
<td class="">{{ member.MEMBERNAME }}</td>
|
||||
<td class="">{{ member.EMAIL }}</td>
|
||||
<td class="">{{ member.MEMBERCREATED }}</td>
|
||||
<td class="">{{ member.MEMBERUPDATED }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
@if (roleMembers && !loading) {
|
||||
<div>
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<table class="table role-info">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<p class="role-info-text">
|
||||
<b>{{ roleName }}</b>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<i>{{ roleDesc }}</i>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<div class="card role-data">
|
||||
<div>
|
||||
<h3>MEMBERS ({{ roleMembersCount }})</h3>
|
||||
@if (roleMembersCount == 0) {
|
||||
<h5>No Members Present</h5>
|
||||
}
|
||||
<div class="table-container">
|
||||
@if (roleMembersCount != 0) {
|
||||
<table class="table member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="width-25"><b>NAME</b></td>
|
||||
<td class="width-25"><b>EMAIL</b></td>
|
||||
<td class="width-25"><b>CREATED</b></td>
|
||||
<td class=""><b>UPDATED</b></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (member of roleMembers; track member) {
|
||||
<tr
|
||||
[routerLink]="'/view/usernav/users/' + member.URIMEM"
|
||||
>
|
||||
<td class="">{{ member.MEMBERNAME }}</td>
|
||||
<td class="">{{ member.EMAIL }}</td>
|
||||
<td class="">{{ member.MEMBERCREATED }}</td>
|
||||
<td class="">{{ member.MEMBERUPDATED }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<h3>Groups ({{ roleGroupsCount }})</h3>
|
||||
<h5 *ngIf="roleGroupsCount == 0">No Groups Present !</h5>
|
||||
<div class="table-container">
|
||||
<table *ngIf="roleGroupsCount != 0" class="table member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="width-25"><b>NAME</b></td>
|
||||
<td class="width-25"><b>EMAIL</b></td>
|
||||
<td class="width-25"><b>CREATED</b></td>
|
||||
<td class=""><b>UPDATED</b></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
[routerLink]="'/view/usernav/groups/' + group.URIMEM"
|
||||
*ngFor="let group of roleGroups"
|
||||
>
|
||||
<td class="">{{ group.MEMBERNAME }}</td>
|
||||
<td class="">{{ group.EMAIL }}</td>
|
||||
<td class="">{{ group.MEMBERCREATED }}</td>
|
||||
<td class="">{{ group.MEMBERUPDATED }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<div>
|
||||
<h3>Groups ({{ roleGroupsCount }})</h3>
|
||||
@if (roleGroupsCount == 0) {
|
||||
<h5>No Groups Present !</h5>
|
||||
}
|
||||
<div class="table-container">
|
||||
@if (roleGroupsCount != 0) {
|
||||
<table class="table member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="width-25"><b>NAME</b></td>
|
||||
<td class="width-25"><b>EMAIL</b></td>
|
||||
<td class="width-25"><b>CREATED</b></td>
|
||||
<td class=""><b>UPDATED</b></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (group of roleGroups; track group) {
|
||||
<tr
|
||||
[routerLink]="'/view/usernav/groups/' + group.URIMEM"
|
||||
>
|
||||
<td class="">{{ group.MEMBERNAME }}</td>
|
||||
<td class="">{{ group.EMAIL }}</td>
|
||||
<td class="">{{ group.MEMBERCREATED }}</td>
|
||||
<td class="">{{ group.MEMBERUPDATED }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -29,6 +29,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[] = []
|
||||
@@ -51,24 +54,33 @@ export class DcValidator {
|
||||
|
||||
this.sasparams = sasparams
|
||||
this.hotInstance = hotInstance
|
||||
// Each cols[i].COLTYPE is now 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 prepended separately — see its own doc comment for why.
|
||||
// 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.
|
||||
//
|
||||
// cols[] is NOT guaranteed to arrive in COLHEADERS/display order — e.g.
|
||||
// %mp_getcols can return it sorted by NAME. editor.component.ts pairs
|
||||
// getRules()[i] with headerColumns[i] positionally, so this array must
|
||||
// be built in VARNUM order (which does match COLHEADERS) rather than
|
||||
// cols[]'s own order, or every rule after the first alphabetically-out-
|
||||
// of-place column ends up bound to the wrong header.
|
||||
const colsByVarnum = [...cols].sort((a, b) => a.VARNUM - b.VARNUM)
|
||||
this.rules = [
|
||||
DELETE_RECORD_COLUMN_RULE,
|
||||
...colsByVarnum
|
||||
.map((col) => parseColTypeRow(col.COLTYPE))
|
||||
.filter((rule): rule is DcValidation => rule !== undefined)
|
||||
]
|
||||
// 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)
|
||||
@@ -230,6 +242,32 @@ 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. HARDREGEX takes
|
||||
* precedence when both rules apply to the column, matching the grid's own
|
||||
* behaviour (setupValidations skips wiring the SOFTREGEX renderer there
|
||||
* too) — a dual-rule column should render red/blocked, never yellow.
|
||||
*/
|
||||
failsSoftRegex(col: string, value: any): boolean {
|
||||
if (this.hasDqRules(col, ['HARDREGEX'])) return false
|
||||
if (isRegexRuleExempt(value)) 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
|
||||
@@ -386,6 +424,28 @@ export class DcValidator {
|
||||
// editor/validator stay intact via `type`.
|
||||
this.rules[i].numericFormat = undefined
|
||||
}
|
||||
|
||||
// SOFTREGEX: display-only warning (yellow), never blocks submission —
|
||||
// unlike HARDREGEX (see dq-validation.ts), which goes through the
|
||||
// normal validator/dqValidate path instead. Last-wins against
|
||||
// NUMBER_FORMAT if a column somehow carried both (not expected in
|
||||
// practice — one formats numbers, the other pattern-matches text).
|
||||
//
|
||||
// HARDREGEX takes precedence when both apply to the same column: skip
|
||||
// wiring this renderer entirely, so a failing value stays HOT's own
|
||||
// red htInvalid (from HARDREGEX/dqValidate) rather than being
|
||||
// visually overridden by this renderer's yellow dc-warning-cell.
|
||||
if (
|
||||
this.hasDqRules(ruleColName, ['SOFTREGEX']) &&
|
||||
!this.hasDqRules(ruleColName, ['HARDREGEX'])
|
||||
) {
|
||||
const softRegexRule = this.getDqDetails(ruleColName).find(
|
||||
(rule: DQRule) => rule.RULE_TYPE === 'SOFTREGEX'
|
||||
)
|
||||
this.rules[i].renderer = makeRegexWarningRenderer(
|
||||
softRegexRule?.RULE_VALUE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const self = this
|
||||
@@ -407,6 +467,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
|
||||
|
||||
@@ -558,8 +639,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,3 +18,5 @@ export type DQRuleTypes =
|
||||
| 'HIDDEN'
|
||||
| 'ROUND'
|
||||
| 'NUMBER_FORMAT'
|
||||
| 'HARDREGEX'
|
||||
| 'SOFTREGEX'
|
||||
@@ -288,14 +288,11 @@ describe('DC Validator', () => {
|
||||
expect(dcValidator.getRoundDigits('SOME_NUM')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('5 | orders rules by VARNUM even when cols[] arrives in a different order', () => {
|
||||
// Regression test: %mp_getcols/cols1 can serialize cols[] alphabetically
|
||||
// by NAME (confirmed against a real getdata.sas response) rather than in
|
||||
// VARNUM/COLHEADERS order. editor.component.ts pairs
|
||||
// dcValidator.getRules()[i] with headerColumns[i] positionally, so if
|
||||
// DcValidator built rules in cols[]'s own (alphabetical) order, every
|
||||
// rule after the first out-of-place column would end up bound to the
|
||||
// wrong header — silently mis-rendering cell types/dropdowns.
|
||||
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)
|
||||
)
|
||||
@@ -308,17 +305,453 @@ describe('DC Validator', () => {
|
||||
example_dqData
|
||||
)
|
||||
|
||||
const rules = dcValidator.getRules()
|
||||
const colsByVarnum = [...example_cols].sort((a, b) => a.VARNUM - b.VARNUM)
|
||||
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
|
||||
example_sasparams.COLHEADERS.split(',')
|
||||
)
|
||||
})
|
||||
|
||||
// rules[0] is the injected DELETE_RECORD_COLUMN_RULE (no VARNUM of its
|
||||
// own — see its own doc comment); the rest must follow ascending VARNUM.
|
||||
for (let i = 0; i < colsByVarnum.length; i++) {
|
||||
expect(rules[i + 1].data).toEqual(colsByVarnum[i].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(',')
|
||||
)
|
||||
})
|
||||
|
||||
it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => {
|
||||
// A dropped rule would shorten getRules() and shift every later column
|
||||
// against headerColumns. getdata.sas LEFT JOINs COLTYPE in, so an
|
||||
// unmatched row can legitimately leave it unset.
|
||||
const cols: Col[] = [
|
||||
makeCol('PRIMARY_KEY_FIELD', 1),
|
||||
{ ...makeCol('SOME_CHAR', 2), COLTYPE: undefined },
|
||||
{ ...makeCol('SOME_NUM', 3), COLTYPE: 'not valid json' }
|
||||
]
|
||||
const sasparams: SASParam = {
|
||||
...example_sasparams,
|
||||
COLHEADERS:
|
||||
'_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_NUM',
|
||||
PK: 'PRIMARY_KEY_FIELD'
|
||||
} as SASParam
|
||||
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
sasparams,
|
||||
{ vars: {} } as $DataFormats,
|
||||
cols,
|
||||
[],
|
||||
[]
|
||||
)
|
||||
|
||||
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
|
||||
sasparams.COLHEADERS.split(',')
|
||||
)
|
||||
})
|
||||
|
||||
it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => {
|
||||
// hiddenColumns.indexOf() returns -1 for a column that isn't hidden, and
|
||||
// splice(-1, 1) drops the LAST element instead of no-op'ing — so an EDIT
|
||||
// column used to silently un-hide a column hidden by a HIDDEN DQ rule.
|
||||
//
|
||||
// The EDIT column must not be the PK: setColumnLevelSecurity forces PK
|
||||
// columns to clsRule 'READ' before the EDIT branch is reached, which would
|
||||
// skip the splice entirely and leave this test passing against the bug.
|
||||
const cols: Col[] = [
|
||||
{ ...makeCol('PK_COL', 1), CLS_RULE: 'READ' },
|
||||
{ ...makeCol('SECRET_COL', 2), CLS_RULE: 'READ' },
|
||||
{ ...makeCol('OPEN_COL', 3), CLS_RULE: 'EDIT' }
|
||||
]
|
||||
const sasparams: SASParam = {
|
||||
...example_sasparams,
|
||||
CLS_FLAG: 1,
|
||||
COLHEADERS: '_____DELETE__THIS__RECORD_____,PK_COL,SECRET_COL,OPEN_COL',
|
||||
PK: 'PK_COL'
|
||||
} as SASParam
|
||||
const dqRules: DQRule[] = [
|
||||
{ BASE_COL: 'SECRET_COL', RULE_TYPE: 'HIDDEN', RULE_VALUE: '', X: 0 }
|
||||
]
|
||||
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
sasparams,
|
||||
{ vars: {} } as $DataFormats,
|
||||
cols,
|
||||
dqRules,
|
||||
[]
|
||||
)
|
||||
|
||||
// SECRET_COL is rules[2]; CLS_FLAG also hides the delete column (index 0).
|
||||
// OPEN_COL (rules[3]) is EDIT and was never hidden, so the splice guard is
|
||||
// what keeps SECRET_COL hidden here.
|
||||
expect(dcValidator.getHiddenColumns()).toContain(2)
|
||||
})
|
||||
|
||||
describe('9 | ignores cell validation on rows marked for delete', () => {
|
||||
// An invalid cell shouldn't block submission if its row
|
||||
// is about to be deleted anyway. The PK is exempt from the exemption — it
|
||||
// identifies WHICH record to delete, and a blank/invalid PK on a delete
|
||||
// row would also slip past the separate uniqueness check (which skips
|
||||
// blank PKs).
|
||||
//
|
||||
// The validator is a plain `function` invoked with cellProperties as
|
||||
// `this` (row + instance), so a minimal fake stands in for a real HOT
|
||||
// instance here — only `instance.getDataAtRowProp` is read by the guard.
|
||||
const fakeCellProps = (markedForDelete: boolean): any => ({
|
||||
row: 0,
|
||||
editor: undefined,
|
||||
instance: {
|
||||
getDataAtRowProp: (_row: number, prop: string) =>
|
||||
prop === '_____DELETE__THIS__RECORD_____'
|
||||
? markedForDelete
|
||||
? 'Yes'
|
||||
: 'No'
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
|
||||
const buildValidator = () =>
|
||||
new DcValidator(
|
||||
example_sasparams,
|
||||
example_dataformats,
|
||||
example_cols,
|
||||
example_dqRules,
|
||||
example_dqData
|
||||
)
|
||||
|
||||
it('exempts an invalid non-PK cell on a delete-marked row', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
fakeCellProps(true),
|
||||
'not a number',
|
||||
(valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('exempts a NOTNULL-empty non-PK cell on a delete-marked row', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
fakeCellProps(true),
|
||||
null,
|
||||
(valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('still rejects an invalid non-PK cell when the row is not marked for delete', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
fakeCellProps(false),
|
||||
'not a number',
|
||||
(valid: boolean) => {
|
||||
expect(valid).toBeFalse()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('still accepts a valid non-PK cell when the row is not marked for delete', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
fakeCellProps(false),
|
||||
2,
|
||||
(valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('still rejects an invalid PK cell even when the row is marked for delete', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')
|
||||
|
||||
pkRule!.validator!.call(
|
||||
fakeCellProps(true),
|
||||
'not a number',
|
||||
(valid: boolean) => {
|
||||
expect(valid).toBeFalse()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('treats the edit-record modal path (dummy HOT instance, no data) as not marked for delete', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
// executeHotValidator builds cellProperties against a real but
|
||||
// data-less Handsontable instance — getDataAtRowProp() there returns
|
||||
// undefined, never 'Yes', so the exemption never applies in the modal.
|
||||
dcValidator.executeHotValidator(someNumRule!, null, (valid: boolean) => {
|
||||
expect(valid).toBeFalse()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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 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('12 | HARDREGEX takes precedence over SOFTREGEX on a dual-rule column', () => {
|
||||
// Both rules on the same column: HARDREGEX must win, so the cell
|
||||
// renders red (blocked) via HOT's own htInvalid, not yellow. No
|
||||
// SOFTREGEX renderer is wired at all here, so there's nothing that could
|
||||
// visually compete with htInvalid for that column.
|
||||
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(rule?.renderer).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('13 | 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/special-missing values, same exemption as HARDREGEX', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '^[A-Z]+$',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '')).toBeFalse()
|
||||
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '.a')).toBeFalse()
|
||||
})
|
||||
|
||||
it('is false when the column also has HARDREGEX (precedence — no yellow on a red cell)', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
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
|
||||
}
|
||||
])
|
||||
|
||||
expect(
|
||||
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'lowercase')
|
||||
).toBeFalse()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/** Minimal cols[] entry — only the fields rule ordering depends on. */
|
||||
const makeCol = (name: string, varnum: number): Col =>
|
||||
({
|
||||
NAME: name,
|
||||
VARNUM: varnum,
|
||||
LABEL: name,
|
||||
FMTNAME: '',
|
||||
DDTYPE: 'CHARACTER',
|
||||
TYPE: '',
|
||||
CLS_RULE: 'READ',
|
||||
MEMLABEL: '',
|
||||
DESC: '',
|
||||
LONGDESC: '',
|
||||
COLTYPE: `{"data":"${name}"}`
|
||||
}) as Col
|
||||
|
||||
const example_dqData = [
|
||||
{
|
||||
BASE_COL: 'SOME_DROPDOWN',
|
||||
@@ -588,15 +1021,22 @@ const example_cols = [
|
||||
}
|
||||
]
|
||||
|
||||
// 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',
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { isRegexRuleExempt } from './isRegexRuleExempt'
|
||||
|
||||
describe('isRegexRuleExempt', () => {
|
||||
it('exempts blank/undefined/null', () => {
|
||||
expect(isRegexRuleExempt('')).toBeTrue()
|
||||
expect(isRegexRuleExempt(undefined)).toBeTrue()
|
||||
expect(isRegexRuleExempt(null)).toBeTrue()
|
||||
})
|
||||
|
||||
it('exempts SAS special missing values', () => {
|
||||
expect(isRegexRuleExempt('.')).toBeTrue()
|
||||
expect(isRegexRuleExempt('.a')).toBeTrue()
|
||||
expect(isRegexRuleExempt('_')).toBeTrue()
|
||||
})
|
||||
|
||||
it('does not exempt an ordinary value', () => {
|
||||
expect(isRegexRuleExempt('ABC123')).toBeFalse()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { isSpecialMissing } from '@sasjs/utils/input/validators'
|
||||
|
||||
/**
|
||||
* HARDREGEX/SOFTREGEX both skip pattern-matching for blank and SAS special
|
||||
* missing values (., .a-.z, _) — the same exemption NOTNULL/MINVAL/MAXVAL
|
||||
* already apply elsewhere, since neither convention represents a real
|
||||
* formatted value the pattern is meant to check.
|
||||
*/
|
||||
export const isRegexRuleExempt = (value: any): boolean => {
|
||||
if (value === undefined || value === null || value === '') return true
|
||||
|
||||
return isSpecialMissing(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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* HARDREGEX/SOFTREGEX rule values are authored as SAS PRX patterns, which
|
||||
* require the delimiter form `/pattern/flags` (prxparse's own syntax check
|
||||
* in mpe_validations_postedit.sas requires it). A bare pattern with no
|
||||
* delimiters is also accepted, for backwards compatibility with existing
|
||||
* rules/tests. Passing the delimited form as-is to `new RegExp(string)`
|
||||
* would treat the slashes and flags as literal pattern text instead of
|
||||
* stripping/applying them, so this parses the PRX form before constructing
|
||||
* the regex.
|
||||
*
|
||||
* PRX also accepts some Perl-only regex syntax with no direct JS equivalent.
|
||||
* Three mechanical, unambiguous cases are translated below - a leading
|
||||
* `(?i)` modifier, an escaped `\Q...\E` literal sequence, and `\A`/`\z`
|
||||
* absolute anchors.
|
||||
* Atomic groups (`(?>...)`) and possessive quantifiers (`a++`) are NOT
|
||||
* translated: both would need a lookahead+backreference rewrite that
|
||||
* changes the pattern's capture-group numbering, a real correctness risk
|
||||
* for comparatively rare constructs, so they're left to fail (they already
|
||||
* throw, which every caller already treats as "always valid" / "never
|
||||
* warn", same as any other malformed pattern).
|
||||
*/
|
||||
const escapeRegExpMetacharacters = (text: string): string =>
|
||||
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
export const parseRegexRule = (ruleValue: string): RegExp => {
|
||||
const delimited = /^\/(.*)\/([a-z]*)$/s.exec(ruleValue)
|
||||
|
||||
if (!delimited) return new RegExp(ruleValue)
|
||||
|
||||
let [, body, flags] = delimited
|
||||
|
||||
const inlineCaseInsensitive = /^\(\?i\)/.exec(body)
|
||||
|
||||
if (inlineCaseInsensitive) {
|
||||
body = body.slice(inlineCaseInsensitive[0].length)
|
||||
if (!flags.includes('i')) flags += 'i'
|
||||
}
|
||||
|
||||
body = body.replace(/\\Q([\s\S]*?)\\E/g, (_match, literal) =>
|
||||
escapeRegExpMetacharacters(literal)
|
||||
)
|
||||
|
||||
body = body.replace(/\\A/g, '^').replace(/\\z/g, '(?![\\s\\S])')
|
||||
|
||||
return new RegExp(body, flags)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { dqValidate } from './dq-validation'
|
||||
|
||||
const rule = (overrides: Partial<DQRule>): DQRule => ({
|
||||
BASE_COL: 'SOME_CHAR',
|
||||
RULE_TYPE: 'HARDREGEX',
|
||||
RULE_VALUE: '',
|
||||
X: 0,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('dqValidate - HARDREGEX', () => {
|
||||
it('accepts a value matching the pattern', () => {
|
||||
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
|
||||
|
||||
expect(dqValidate(rules, 'ABC1234')).toBeTrue()
|
||||
})
|
||||
|
||||
it('rejects a value not matching the pattern', () => {
|
||||
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
|
||||
|
||||
expect(dqValidate(rules, 'abc1234')).toBeFalse()
|
||||
})
|
||||
|
||||
it('treats blank/undefined/null as valid regardless of the pattern', () => {
|
||||
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
|
||||
|
||||
expect(dqValidate(rules, '')).toBeTrue()
|
||||
expect(dqValidate(rules, undefined)).toBeTrue()
|
||||
expect(dqValidate(rules, null)).toBeTrue()
|
||||
})
|
||||
|
||||
it('treats SAS special missing values as valid regardless of the pattern', () => {
|
||||
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
|
||||
|
||||
expect(dqValidate(rules, '.')).toBeTrue()
|
||||
expect(dqValidate(rules, '.a')).toBeTrue()
|
||||
expect(dqValidate(rules, '_')).toBeTrue()
|
||||
})
|
||||
|
||||
it('fails open (treats as valid) when the pattern is malformed', () => {
|
||||
const rules = [rule({ RULE_VALUE: '[unterminated' })]
|
||||
|
||||
expect(dqValidate(rules, 'anything')).toBeTrue()
|
||||
})
|
||||
|
||||
it('handles a more elaborate pattern (multiple character classes, quantifiers, an escaped literal dot)', () => {
|
||||
// Same email pattern used in the getdata.js mock's REGEX_HARD_COL demo.
|
||||
const rules = [rule({ RULE_VALUE: '[\\w.]+@[\\w]+\\.[a-z]{2,}' })]
|
||||
|
||||
expect(dqValidate(rules, 'user@example.com')).toBeTrue()
|
||||
expect(dqValidate(rules, 'user.name@sub.example.co')).toBeTrue()
|
||||
expect(dqValidate(rules, 'not-an-email')).toBeFalse()
|
||||
// No TLD to satisfy [a-z]{2,} after the escaped dot.
|
||||
expect(dqValidate(rules, 'user@example')).toBeFalse()
|
||||
})
|
||||
|
||||
it('accepts a value matching a PRX-delimited pattern (/pattern/flags, as authored for prxparse)', () => {
|
||||
const rules = [rule({ RULE_VALUE: '/^[A-Z]{3}\\d{4}$/' })]
|
||||
|
||||
expect(dqValidate(rules, 'ABC1234')).toBeTrue()
|
||||
})
|
||||
|
||||
it('rejects a value not matching a PRX-delimited pattern', () => {
|
||||
const rules = [rule({ RULE_VALUE: '/^[A-Z]{3}\\d{4}$/' })]
|
||||
|
||||
expect(dqValidate(rules, 'abc1234')).toBeFalse()
|
||||
})
|
||||
|
||||
it('applies the i flag from a PRX-delimited pattern', () => {
|
||||
const rules = [rule({ RULE_VALUE: '/^[a-z]{3}\\d{4}$/i' })]
|
||||
|
||||
expect(dqValidate(rules, 'ABC1234')).toBeTrue()
|
||||
})
|
||||
|
||||
it('still evaluates a second, unrelated rule on the same column', () => {
|
||||
const rules = [
|
||||
rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' }),
|
||||
rule({ RULE_TYPE: 'NOTNULL', RULE_VALUE: '' })
|
||||
]
|
||||
|
||||
// Passes HARDREGEX (blank is exempt) but fails NOTNULL — the NOTNULL
|
||||
// rule still runs even though HARDREGEX is earlier in the array.
|
||||
expect(dqValidate(rules, '')).toBeFalse()
|
||||
// Passes NOTNULL (non-blank) but fails HARDREGEX's pattern.
|
||||
expect(dqValidate(rules, 'nope')).toBeFalse()
|
||||
// Passes both.
|
||||
expect(dqValidate(rules, 'ABC1234')).toBeTrue()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { specialMissingNumericValidator } from './hot-custom-validators'
|
||||
import { isRegexRuleExempt } from '../utils/isRegexRuleExempt'
|
||||
import { parseRegexRule } from '../utils/parseRegexRule'
|
||||
|
||||
const dqValidation: {
|
||||
[key: string]: (value: any, ruleValue: string | number) => boolean
|
||||
@@ -46,6 +48,20 @@ const dqValidation: {
|
||||
},
|
||||
NOTNULL: (value: any, ruleValue: string | number): boolean => {
|
||||
return value !== undefined && value !== null && value.toString().length > 0
|
||||
},
|
||||
// Pattern is used as authored, not auto-anchored — a rule author who
|
||||
// wants a full-value match must write ^...$ themselves.
|
||||
HARDREGEX: (value: any, ruleValue: string | number): boolean => {
|
||||
if (isRegexRuleExempt(value)) return true
|
||||
|
||||
try {
|
||||
return parseRegexRule(ruleValue.toString()).test(value.toString())
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`HARDREGEX - invalid pattern, treated as always-valid: ${ruleValue}`
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +1,49 @@
|
||||
<div *ngIf="options$ | async as options" class="excel-password-root">
|
||||
<clr-modal
|
||||
[clrModalOpen]="options.open"
|
||||
[clrModalSize]="'md'"
|
||||
[clrModalClosable]="false"
|
||||
>
|
||||
<h3 class="modal-title center text-center color-darker-gray">
|
||||
Password Protected File
|
||||
</h3>
|
||||
<div class="modal-body d-flex clr-justify-content-center">
|
||||
<p class="m-0">Please enter password:</p>
|
||||
<input
|
||||
#filePasswordInput
|
||||
[(ngModel)]="passwordInput"
|
||||
data-lpignore="true"
|
||||
autocomplete="off"
|
||||
id="filePasswordInput"
|
||||
type="text"
|
||||
class="clr-input disable-password-manager"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div>
|
||||
<p *ngIf="options.error" class="m-0 color-red">
|
||||
Sorry that didn't work, try again.
|
||||
</p>
|
||||
@if (options$ | async; as options) {
|
||||
<div class="excel-password-root">
|
||||
<clr-modal
|
||||
[clrModalOpen]="options.open"
|
||||
[clrModalSize]="'md'"
|
||||
[clrModalClosable]="false"
|
||||
>
|
||||
<h3 class="modal-title center text-center color-darker-gray">
|
||||
Password Protected File
|
||||
</h3>
|
||||
<div class="modal-body d-flex clr-justify-content-center">
|
||||
<p class="m-0">Please enter password:</p>
|
||||
<input
|
||||
#filePasswordInput
|
||||
[(ngModel)]="passwordInput"
|
||||
data-lpignore="true"
|
||||
autocomplete="off"
|
||||
id="filePasswordInput"
|
||||
type="text"
|
||||
class="clr-input disable-password-manager"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="button" class="btn btn-sm btn-outline" (click)="close()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-success-outline"
|
||||
[disabled]="filePasswordInput.value.length < 1"
|
||||
(click)="close(filePasswordInput.value)"
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
<div class="modal-footer">
|
||||
<div>
|
||||
@if (options.error) {
|
||||
<p class="m-0 color-red">Sorry that didn't work, try again.</p>
|
||||
}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
(click)="close()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-success-outline"
|
||||
[disabled]="filePasswordInput.value.length < 1"
|
||||
(click)="close(filePasswordInput.value)"
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</clr-modal>
|
||||
</div>
|
||||
</clr-modal>
|
||||
</div>
|
||||
}
|
||||
@@ -29,17 +29,21 @@
|
||||
></clr-icon>
|
||||
|
||||
<clr-signpost-content [clrPosition]="'bottom-middle'" *clrIfOpen>
|
||||
<p *ngFor="let request of requests">
|
||||
<code>{{ request.program }}</code>
|
||||
|
||||
<span class="running" *ngIf="request.running">loading</span>
|
||||
<span class="success" *ngIf="!request.running && request.successful"
|
||||
>loaded</span
|
||||
>
|
||||
<span class="fail" *ngIf="!request.running && !request.successful"
|
||||
>failed</span
|
||||
>
|
||||
</p>
|
||||
@for (request of requests; track request) {
|
||||
<p>
|
||||
<code>{{ request.program }}</code>
|
||||
|
||||
@if (request.running) {
|
||||
<span class="running">loading</span>
|
||||
}
|
||||
@if (!request.running && request.successful) {
|
||||
<span class="success">loaded</span>
|
||||
}
|
||||
@if (!request.running && !request.successful) {
|
||||
<span class="fail">failed</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</clr-signpost-content>
|
||||
</clr-signpost>
|
||||
</div>
|
||||
@@ -9,219 +9,252 @@
|
||||
<p class="mt-0 d-inline-block">App Location: {{ sasjsConfig.appLoc }}</p>
|
||||
</h3>
|
||||
<div class="modal-body h-70vh">
|
||||
<p *ngIf="sasjsRequests?.length! < 1" class="no-reqs">No requests made</p>
|
||||
@if (sasjsRequests.length < 1) {
|
||||
<p class="no-reqs">No requests made</p>
|
||||
}
|
||||
|
||||
<clr-stack-view *ngIf="opened && sasjsRequests?.length! > 0">
|
||||
<clr-stack-block
|
||||
*ngFor="let programLog of sasjsRequests; let i = index"
|
||||
[id]="'request_' + i"
|
||||
[clrStackViewLevel]="1"
|
||||
>
|
||||
<clr-stack-label>
|
||||
{{ programLog.serviceLink }}
|
||||
<span class="ml-10">
|
||||
<span *ngIf="programLog.logWarnings?.length! > 0">
|
||||
<clr-icon
|
||||
class="mb-5 color-green"
|
||||
shape="exclamation-circle"
|
||||
></clr-icon>
|
||||
</span>
|
||||
|
||||
<span *ngIf="programLog.logErrors?.length! > 0">
|
||||
<clr-icon
|
||||
class="mb-5 color-red"
|
||||
shape="exclamation-triangle"
|
||||
></clr-icon>
|
||||
</span>
|
||||
</span>
|
||||
</clr-stack-label>
|
||||
<clr-stack-content>{{ programLog.parsedTimestamp }}</clr-stack-content>
|
||||
<clr-stack-block class="content">
|
||||
<clr-stack-content>
|
||||
<clr-tabs>
|
||||
<clr-tab>
|
||||
<button clrTabLink>SAS Log</button>
|
||||
<ng-template [(clrIfActive)]="sasLogActive">
|
||||
<clr-tab-content>
|
||||
<clr-tree>
|
||||
<clr-tree-node
|
||||
*ngIf="programLog.logErrors"
|
||||
class="err-links"
|
||||
>
|
||||
<clr-icon
|
||||
class="color-red"
|
||||
shape="exclamation-triangle"
|
||||
></clr-icon>
|
||||
Errors ({{ programLog.logErrors.length! }})
|
||||
<clr-tree-node
|
||||
(click)="goToLogLine(err, 'request_' + i, 'error')"
|
||||
*ngFor="let err of programLog.logErrors || []"
|
||||
>
|
||||
<button class="clr-treenode-link">
|
||||
{{ err }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
</clr-tree-node>
|
||||
|
||||
<clr-tree-node
|
||||
*ngIf="programLog.logWarnings"
|
||||
class="err-links"
|
||||
>
|
||||
<clr-icon
|
||||
class="color-green"
|
||||
shape="exclamation-circle"
|
||||
></clr-icon>
|
||||
Warnings ({{ programLog.logWarnings.length! }})
|
||||
<clr-tree-node
|
||||
(click)="
|
||||
goToLogLine(warning, 'request_' + i, 'warning')
|
||||
"
|
||||
*ngFor="let warning of programLog.logWarnings || []"
|
||||
>
|
||||
<button class="clr-treenode-link">
|
||||
{{ warning }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
</clr-tree-node>
|
||||
</clr-tree>
|
||||
|
||||
<div
|
||||
*ngIf="serverType !== 'SASVIYA'"
|
||||
class="log-wrapper saslog"
|
||||
[innerHTML]="programLog.logFile"
|
||||
></div>
|
||||
|
||||
<div
|
||||
*ngIf="serverType === 'SASVIYA'"
|
||||
class="log-wrapper saslog viya"
|
||||
[textContent]="programLog.logFile"
|
||||
></div>
|
||||
|
||||
<button
|
||||
*ngIf="programLog.logFile?.length! > 0"
|
||||
(click)="downloadLog(programLog.logFile)"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</clr-tab-content>
|
||||
</ng-template>
|
||||
</clr-tab>
|
||||
|
||||
<clr-tab>
|
||||
<button clrTabLink>SAS Source Code</button>
|
||||
<ng-template [(clrIfActive)]="sasSourceCodeActive">
|
||||
<clr-tab-content>
|
||||
<div class="log-wrapper">{{ programLog.sourceCode }}</div>
|
||||
<button
|
||||
*ngIf="programLog.sourceCode?.length! > 0"
|
||||
(click)="downloadSourceCode(programLog.sourceCode)"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</clr-tab-content>
|
||||
</ng-template>
|
||||
</clr-tab>
|
||||
|
||||
<clr-tab>
|
||||
<button clrTabLink>SAS Generated Code</button>
|
||||
<ng-template [(clrIfActive)]="sasGeneratedCodeActive">
|
||||
<clr-tab-content>
|
||||
<div class="log-wrapper">
|
||||
{{ programLog.generatedCode }}
|
||||
</div>
|
||||
<button
|
||||
*ngIf="programLog.generatedCode?.length! > 0"
|
||||
(click)="downloadGeneratedCode(programLog.generatedCode)"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</clr-tab-content>
|
||||
</ng-template>
|
||||
</clr-tab>
|
||||
|
||||
<clr-tab *ngIf="programLog.SASWORK">
|
||||
<clr-dropdown
|
||||
[clrCloseMenuOnItemClick]="true"
|
||||
class="work-tables-dropdown"
|
||||
clrTabLink
|
||||
>
|
||||
<button clrDropdownTrigger>
|
||||
SAS Work Tables
|
||||
<clr-icon shape="caret down"></clr-icon>
|
||||
</button>
|
||||
<clr-dropdown-menu *clrIfOpen>
|
||||
<div
|
||||
*ngFor="let table of programLog.SASWORK | keyvalue"
|
||||
(click)="programLog.selectedTable = $any(table).key"
|
||||
[class.selected]="programLog.selectedTable === table.key"
|
||||
class="dropdown-item"
|
||||
clrDropdownItem
|
||||
>
|
||||
{{ table.key }}
|
||||
</div>
|
||||
</clr-dropdown-menu>
|
||||
</clr-dropdown>
|
||||
|
||||
<ng-template [(clrIfActive)]="tablesActive">
|
||||
<clr-tab-content>
|
||||
<p *ngIf="programLog.selectedTable" class="mt-10">
|
||||
<strong
|
||||
>{{ programLog.selectedTable }} (first 10 rows)
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<div class="min-h-50vh">
|
||||
<table
|
||||
*ngIf="programLog.selectedTable"
|
||||
class="table mt-10"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
*ngFor="
|
||||
let column of programLog.SASWORK[
|
||||
programLog.selectedTable
|
||||
].first10rows[0] | keyvalue
|
||||
"
|
||||
>
|
||||
{{ column.key }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="
|
||||
let row of programLog.SASWORK[
|
||||
programLog.selectedTable
|
||||
].first10rows
|
||||
"
|
||||
@if (opened && sasjsRequests.length > 0) {
|
||||
<clr-stack-view>
|
||||
@for (programLog of sasjsRequests; track programLog; let i = $index) {
|
||||
<clr-stack-block [id]="'request_' + i" [clrStackViewLevel]="1">
|
||||
<clr-stack-label>
|
||||
{{ programLog.serviceLink }}
|
||||
<span class="ml-10">
|
||||
@if (programLog.logWarnings?.length! > 0) {
|
||||
<span>
|
||||
<clr-icon
|
||||
class="mb-5 color-green"
|
||||
shape="exclamation-circle"
|
||||
></clr-icon>
|
||||
</span>
|
||||
}
|
||||
@if (programLog.logErrors?.length! > 0) {
|
||||
<span>
|
||||
<clr-icon
|
||||
class="mb-5 color-red"
|
||||
shape="exclamation-triangle"
|
||||
></clr-icon>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</clr-stack-label>
|
||||
<clr-stack-content>{{
|
||||
programLog.parsedTimestamp
|
||||
}}</clr-stack-content>
|
||||
<clr-stack-block class="content">
|
||||
<clr-stack-content>
|
||||
<clr-tabs>
|
||||
<clr-tab>
|
||||
<button clrTabLink>SAS Log</button>
|
||||
<ng-template [(clrIfActive)]="sasLogActive">
|
||||
<clr-tab-content>
|
||||
<clr-tree>
|
||||
@if (programLog.logErrors) {
|
||||
<clr-tree-node class="err-links">
|
||||
<clr-icon
|
||||
class="color-red"
|
||||
shape="exclamation-triangle"
|
||||
></clr-icon>
|
||||
Errors ({{ programLog.logErrors.length! }})
|
||||
@for (
|
||||
err of programLog.logErrors || [];
|
||||
track err
|
||||
) {
|
||||
<clr-tree-node
|
||||
(click)="
|
||||
goToLogLine(err, 'request_' + i, 'error')
|
||||
"
|
||||
>
|
||||
<button class="clr-treenode-link">
|
||||
{{ err }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
}
|
||||
</clr-tree-node>
|
||||
}
|
||||
@if (programLog.logWarnings) {
|
||||
<clr-tree-node class="err-links">
|
||||
<clr-icon
|
||||
class="color-green"
|
||||
shape="exclamation-circle"
|
||||
></clr-icon>
|
||||
Warnings ({{ programLog.logWarnings.length! }})
|
||||
@for (
|
||||
warning of programLog.logWarnings || [];
|
||||
track warning
|
||||
) {
|
||||
<clr-tree-node
|
||||
(click)="
|
||||
goToLogLine(
|
||||
warning,
|
||||
'request_' + i,
|
||||
'warning'
|
||||
)
|
||||
"
|
||||
>
|
||||
<button class="clr-treenode-link">
|
||||
{{ warning }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
}
|
||||
</clr-tree-node>
|
||||
}
|
||||
</clr-tree>
|
||||
@if (serverType !== 'SASVIYA') {
|
||||
<div
|
||||
class="log-wrapper saslog"
|
||||
[innerHTML]="programLog.logFile"
|
||||
></div>
|
||||
}
|
||||
@if (serverType === 'SASVIYA') {
|
||||
<div
|
||||
class="log-wrapper saslog viya"
|
||||
[textContent]="programLog.logFile"
|
||||
></div>
|
||||
}
|
||||
@if (programLog.logFile.length > 0) {
|
||||
<button
|
||||
(click)="downloadLog(programLog.logFile)"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
<td
|
||||
*ngFor="
|
||||
let column of programLog.SASWORK[
|
||||
programLog.selectedTable
|
||||
].first10rows[0] | keyvalue
|
||||
Download
|
||||
</button>
|
||||
}
|
||||
</clr-tab-content>
|
||||
</ng-template>
|
||||
</clr-tab>
|
||||
<clr-tab>
|
||||
<button clrTabLink>SAS Source Code</button>
|
||||
<ng-template [(clrIfActive)]="sasSourceCodeActive">
|
||||
<clr-tab-content>
|
||||
<div class="log-wrapper">
|
||||
{{ programLog.sourceCode }}
|
||||
</div>
|
||||
@if (programLog.sourceCode.length > 0) {
|
||||
<button
|
||||
(click)="downloadSourceCode(programLog.sourceCode)"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
}
|
||||
</clr-tab-content>
|
||||
</ng-template>
|
||||
</clr-tab>
|
||||
<clr-tab>
|
||||
<button clrTabLink>SAS Generated Code</button>
|
||||
<ng-template [(clrIfActive)]="sasGeneratedCodeActive">
|
||||
<clr-tab-content>
|
||||
<div class="log-wrapper">
|
||||
{{ programLog.generatedCode }}
|
||||
</div>
|
||||
@if (programLog.generatedCode.length > 0) {
|
||||
<button
|
||||
(click)="
|
||||
downloadGeneratedCode(programLog.generatedCode)
|
||||
"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
}
|
||||
</clr-tab-content>
|
||||
</ng-template>
|
||||
</clr-tab>
|
||||
@if (programLog.SASWORK) {
|
||||
<clr-tab>
|
||||
<clr-dropdown
|
||||
[clrCloseMenuOnItemClick]="true"
|
||||
class="work-tables-dropdown"
|
||||
clrTabLink
|
||||
>
|
||||
<button clrDropdownTrigger>
|
||||
SAS Work Tables
|
||||
<clr-icon shape="caret down"></clr-icon>
|
||||
</button>
|
||||
<clr-dropdown-menu *clrIfOpen>
|
||||
@for (
|
||||
table of programLog.SASWORK | keyvalue;
|
||||
track table
|
||||
) {
|
||||
<div
|
||||
(click)="
|
||||
programLog.selectedTable = $any(table).key
|
||||
"
|
||||
[class.selected]="
|
||||
programLog.selectedTable === table.key
|
||||
"
|
||||
class="dropdown-item"
|
||||
clrDropdownItem
|
||||
>
|
||||
{{ row[$any(column).key] }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</clr-tab-content>
|
||||
</ng-template>
|
||||
</clr-tab>
|
||||
</clr-tabs>
|
||||
</clr-stack-content>
|
||||
</clr-stack-block>
|
||||
</clr-stack-block>
|
||||
</clr-stack-view>
|
||||
{{ table.key }}
|
||||
</div>
|
||||
}
|
||||
</clr-dropdown-menu>
|
||||
</clr-dropdown>
|
||||
<ng-template [(clrIfActive)]="tablesActive">
|
||||
<clr-tab-content>
|
||||
@if (programLog.selectedTable) {
|
||||
<p class="mt-10">
|
||||
<strong
|
||||
>{{ programLog.selectedTable }} (first 10 rows)
|
||||
</strong>
|
||||
</p>
|
||||
}
|
||||
<div class="min-h-50vh">
|
||||
@if (programLog.selectedTable) {
|
||||
<table class="table mt-10">
|
||||
<thead>
|
||||
<tr>
|
||||
@for (
|
||||
column of programLog.SASWORK[
|
||||
programLog.selectedTable
|
||||
].first10rows[0] | keyvalue;
|
||||
track column
|
||||
) {
|
||||
<th>
|
||||
{{ column.key }}
|
||||
</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (
|
||||
row of programLog.SASWORK[
|
||||
programLog.selectedTable
|
||||
].first10rows;
|
||||
track row
|
||||
) {
|
||||
<tr>
|
||||
@for (
|
||||
column of programLog.SASWORK[
|
||||
programLog.selectedTable
|
||||
].first10rows[0] | keyvalue;
|
||||
track column
|
||||
) {
|
||||
<td>
|
||||
{{ row[$any(column).key] }}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</clr-tab-content>
|
||||
</ng-template>
|
||||
</clr-tab>
|
||||
}
|
||||
</clr-tabs>
|
||||
</clr-stack-content>
|
||||
</clr-stack-block>
|
||||
</clr-stack-block>
|
||||
}
|
||||
</clr-stack-view>
|
||||
}
|
||||
</div>
|
||||
</clr-modal>
|
||||
@@ -6,143 +6,151 @@
|
||||
[style.max-width.px]="sidebarMaxWidth"
|
||||
[class.resizing]="resizing"
|
||||
>
|
||||
<div
|
||||
*ngIf="isMainRoute('view')"
|
||||
class="d-flex justify-content-center sub-dropdown"
|
||||
>
|
||||
<clr-dropdown>
|
||||
<button class="dropdown-toggle btn btn-primary" clrDropdownTrigger>
|
||||
{{ getSubPage() }}
|
||||
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
|
||||
</button>
|
||||
<clr-dropdown-menu *clrIfOpen>
|
||||
<a
|
||||
*ngIf="isMainRoute('view')"
|
||||
clrVerticalNavLink
|
||||
(click)="navigateToViewer()"
|
||||
routerLinkActive="active"
|
||||
>Data</a
|
||||
@if (isMainRoute('view')) {
|
||||
<div class="d-flex justify-content-center sub-dropdown">
|
||||
<clr-dropdown>
|
||||
<button class="dropdown-toggle btn btn-primary" clrDropdownTrigger>
|
||||
{{ getSubPage() }}
|
||||
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
|
||||
</button>
|
||||
<clr-dropdown-menu *clrIfOpen>
|
||||
@if (isMainRoute('view')) {
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
(click)="navigateToViewer()"
|
||||
routerLinkActive="active"
|
||||
>Data</a
|
||||
>
|
||||
}
|
||||
@if (isMainRoute('view') && serverType === 'SASVIYA') {
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
routerLink="/view/viya-api-explorer"
|
||||
routerLinkActive="active"
|
||||
>API Explorer</a
|
||||
>
|
||||
}
|
||||
@if (isMainRoute('view') && serverType === 'SAS9') {
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
routerLink="/view/lineage"
|
||||
routerLinkActive="active"
|
||||
>Lineage</a
|
||||
>
|
||||
}
|
||||
@if (isMainRoute('view') && serverType === 'SAS9') {
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
routerLink="/view/metadata"
|
||||
routerLinkActive="active"
|
||||
>Metadata</a
|
||||
>
|
||||
}
|
||||
@if (isMainRoute('view')) {
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
routerLink="/view/usernav"
|
||||
routerLinkActive="active"
|
||||
>
|
||||
Users</a
|
||||
>
|
||||
}
|
||||
@if (isMainRoute('approve')) {
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
routerLink="/review/approve/submitted"
|
||||
routerLinkActive="active"
|
||||
>Submitted</a
|
||||
>
|
||||
}
|
||||
@if (isMainRoute('approve')) {
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
routerLink="/review/approve/toapprove"
|
||||
routerLinkActive="active"
|
||||
>To Approve</a
|
||||
>
|
||||
}
|
||||
</clr-dropdown-menu>
|
||||
</clr-dropdown>
|
||||
</div>
|
||||
}
|
||||
@if (isMainRoute('view/usernav')) {
|
||||
<clr-tabs class="p-10">
|
||||
<clr-tab>
|
||||
<button
|
||||
[routerLink]="['/view/usernav/users']"
|
||||
class="user-nav-btn"
|
||||
clrTabLink
|
||||
>
|
||||
<a
|
||||
*ngIf="isMainRoute('view') && serverType === 'SASVIYA'"
|
||||
clrVerticalNavLink
|
||||
routerLink="/view/viya-api-explorer"
|
||||
routerLinkActive="active"
|
||||
>API Explorer</a
|
||||
<p class="zero-margin">
|
||||
<clr-icon aria-hidden="true" shape="user"></clr-icon> Users
|
||||
</p>
|
||||
</button>
|
||||
<clr-tab-content> </clr-tab-content>
|
||||
</clr-tab>
|
||||
<clr-tab>
|
||||
<button
|
||||
[routerLink]="['/view/usernav/groups']"
|
||||
class="user-nav-btn"
|
||||
clrTabLink
|
||||
>
|
||||
<a
|
||||
*ngIf="isMainRoute('view') && serverType === 'SAS9'"
|
||||
clrVerticalNavLink
|
||||
routerLink="/view/lineage"
|
||||
routerLinkActive="active"
|
||||
>Lineage</a
|
||||
>
|
||||
<a
|
||||
*ngIf="isMainRoute('view') && serverType === 'SAS9'"
|
||||
clrVerticalNavLink
|
||||
routerLink="/view/metadata"
|
||||
routerLinkActive="active"
|
||||
>Metadata</a
|
||||
>
|
||||
<a
|
||||
*ngIf="isMainRoute('view')"
|
||||
clrVerticalNavLink
|
||||
routerLink="/view/usernav"
|
||||
routerLinkActive="active"
|
||||
>
|
||||
Users</a
|
||||
>
|
||||
<a
|
||||
*ngIf="isMainRoute('approve')"
|
||||
clrVerticalNavLink
|
||||
routerLink="/review/approve/submitted"
|
||||
routerLinkActive="active"
|
||||
>Submitted</a
|
||||
>
|
||||
<a
|
||||
*ngIf="isMainRoute('approve')"
|
||||
clrVerticalNavLink
|
||||
routerLink="/review/approve/toapprove"
|
||||
routerLinkActive="active"
|
||||
>To Approve</a
|
||||
>
|
||||
</clr-dropdown-menu>
|
||||
</clr-dropdown>
|
||||
</div>
|
||||
<clr-tabs class="p-10" *ngIf="isMainRoute('view/usernav')">
|
||||
<clr-tab>
|
||||
<button
|
||||
[routerLink]="['/view/usernav/users']"
|
||||
class="user-nav-btn"
|
||||
clrTabLink
|
||||
>
|
||||
<p class="zero-margin">
|
||||
<clr-icon aria-hidden="true" shape="user"></clr-icon> Users
|
||||
</p>
|
||||
</button>
|
||||
<clr-tab-content> </clr-tab-content>
|
||||
</clr-tab>
|
||||
<p class="zero-margin">
|
||||
<clr-icon aria-hidden="true" shape="users"></clr-icon> Groups
|
||||
</p>
|
||||
</button>
|
||||
<clr-tab-content *clrIfActive="isMainRoute('/view/usernav/groups')">
|
||||
</clr-tab-content>
|
||||
</clr-tab>
|
||||
@if (serverType === 'SAS9') {
|
||||
<clr-tab>
|
||||
<button
|
||||
[routerLink]="['/view/usernav/roles']"
|
||||
class="user-nav-btn"
|
||||
clrTabLink
|
||||
>
|
||||
<p class="zero-margin">
|
||||
<clr-icon aria-hidden="true" shape="blocks-group"></clr-icon>
|
||||
Roles
|
||||
</p>
|
||||
</button>
|
||||
<clr-tab-content *clrIfActive="isMainRoute('/view/usernav/roles')">
|
||||
</clr-tab-content>
|
||||
</clr-tab>
|
||||
}
|
||||
</clr-tabs>
|
||||
}
|
||||
|
||||
<clr-tab>
|
||||
<button
|
||||
[routerLink]="['/view/usernav/groups']"
|
||||
class="user-nav-btn"
|
||||
clrTabLink
|
||||
>
|
||||
<p class="zero-margin">
|
||||
<clr-icon aria-hidden="true" shape="users"></clr-icon> Groups
|
||||
</p>
|
||||
</button>
|
||||
<clr-tab-content *clrIfActive="isMainRoute('/view/usernav/groups')">
|
||||
</clr-tab-content>
|
||||
</clr-tab>
|
||||
|
||||
<clr-tab *ngIf="serverType === 'SAS9'">
|
||||
<button
|
||||
[routerLink]="['/view/usernav/roles']"
|
||||
class="user-nav-btn"
|
||||
clrTabLink
|
||||
>
|
||||
<p class="zero-margin">
|
||||
<clr-icon aria-hidden="true" shape="blocks-group"></clr-icon> Roles
|
||||
</p>
|
||||
</button>
|
||||
<clr-tab-content *clrIfActive="isMainRoute('/view/usernav/roles')">
|
||||
</clr-tab-content>
|
||||
</clr-tab>
|
||||
</clr-tabs>
|
||||
|
||||
<div
|
||||
*ngIf="isMainRoute('home')"
|
||||
class="d-flex justify-content-center sub-dropdown"
|
||||
>
|
||||
<clr-dropdown>
|
||||
<button class="dropdown-toggle btn btn-primary" clrDropdownTrigger>
|
||||
{{ getSubPage() }}
|
||||
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
|
||||
</button>
|
||||
<clr-dropdown-menu *clrIfOpen>
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
routerLink="/home/tables"
|
||||
routerLinkActive="active"
|
||||
>Tables</a
|
||||
>
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
routerLink="/home/excel-maps"
|
||||
routerLinkActive="active"
|
||||
>Excel Maps</a
|
||||
>
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
routerLink="/home/multi-load"
|
||||
routerLinkActive="active"
|
||||
>Multi Load</a
|
||||
>
|
||||
</clr-dropdown-menu>
|
||||
</clr-dropdown>
|
||||
</div>
|
||||
@if (isMainRoute('home')) {
|
||||
<div class="d-flex justify-content-center sub-dropdown">
|
||||
<clr-dropdown>
|
||||
<button class="dropdown-toggle btn btn-primary" clrDropdownTrigger>
|
||||
{{ getSubPage() }}
|
||||
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
|
||||
</button>
|
||||
<clr-dropdown-menu *clrIfOpen>
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
routerLink="/home/tables"
|
||||
routerLinkActive="active"
|
||||
>Tables</a
|
||||
>
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
routerLink="/home/excel-maps"
|
||||
routerLinkActive="active"
|
||||
>Excel Maps</a
|
||||
>
|
||||
<a
|
||||
clrVerticalNavLink
|
||||
routerLink="/home/multi-load"
|
||||
routerLinkActive="active"
|
||||
>Multi Load</a
|
||||
>
|
||||
</clr-dropdown-menu>
|
||||
</clr-dropdown>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="nav-divider"></div>
|
||||
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
<label
|
||||
*ngIf="label"
|
||||
[class.secondLabelActive]="secondLabel && secondLabel.length > 0"
|
||||
class="clr-control-label"
|
||||
>
|
||||
<span
|
||||
(click)="onChangeLabel('first')"
|
||||
[class.value-type-selected]="labelSelected === 'first'"
|
||||
>{{ label }}</span
|
||||
@if (label) {
|
||||
<label
|
||||
[class.secondLabelActive]="secondLabel && secondLabel.length > 0"
|
||||
class="clr-control-label"
|
||||
>
|
||||
<ng-container *ngIf="secondLabel">
|
||||
/
|
||||
<span
|
||||
(click)="onChangeLabel('second')"
|
||||
[class.value-type-selected]="labelSelected === 'second'"
|
||||
>{{ secondLabel }}</span
|
||||
(click)="onChangeLabel('first')"
|
||||
[class.value-type-selected]="labelSelected === 'first'"
|
||||
>{{ label }}</span
|
||||
>
|
||||
</ng-container>
|
||||
</label>
|
||||
<ng-container [ngSwitch]="type">
|
||||
<ng-container *ngSwitchCase="'date'">
|
||||
@if (secondLabel) {
|
||||
/
|
||||
<span
|
||||
(click)="onChangeLabel('second')"
|
||||
[class.value-type-selected]="labelSelected === 'second'"
|
||||
>{{ secondLabel }}</span
|
||||
>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
@switch (type) {
|
||||
@case ('date') {
|
||||
<clr-date-container>
|
||||
<input
|
||||
type="date"
|
||||
@@ -36,8 +37,8 @@
|
||||
[disabled]="this.disabled"
|
||||
/>
|
||||
</clr-date-container>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="'time'">
|
||||
}
|
||||
@case ('time') {
|
||||
<input
|
||||
type="time"
|
||||
step="1"
|
||||
@@ -53,8 +54,8 @@
|
||||
(mouseout)="onInputMouseOut()"
|
||||
[disabled]="this.disabled"
|
||||
/>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="'text'">
|
||||
}
|
||||
@case ('text') {
|
||||
<app-autocomplete
|
||||
#input
|
||||
class="input-val"
|
||||
@@ -71,5 +72,5 @@
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</app-autocomplete>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,8 @@
|
||||
<label>I have read and agree to the terms of above.</label>
|
||||
</clr-checkbox-wrapper>
|
||||
|
||||
<clr-spinner
|
||||
*ngIf="agreeBoxLocked && agreeClicked"
|
||||
clrInline="small"
|
||||
></clr-spinner>
|
||||
@if (agreeBoxLocked && agreeClicked) {
|
||||
<clr-spinner clrInline="small"></clr-spinner>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,23 +1,25 @@
|
||||
<div class="header-actions">
|
||||
<div class="d-flex clr-align-items-center">
|
||||
<cds-icon
|
||||
*ngIf="!darkMode.value"
|
||||
(click)="toggleDarkMode(true)"
|
||||
size="md"
|
||||
class="theme-toggle-icon cursor-pointer"
|
||||
shape="moon"
|
||||
inverse="false"
|
||||
solid="false"
|
||||
></cds-icon>
|
||||
<cds-icon
|
||||
*ngIf="!!darkMode.value"
|
||||
(click)="toggleDarkMode(false)"
|
||||
size="md"
|
||||
class="theme-toggle-icon cursor-pointer"
|
||||
shape="sun"
|
||||
inverse="false"
|
||||
solid="false"
|
||||
></cds-icon>
|
||||
@if (!darkMode.value) {
|
||||
<cds-icon
|
||||
(click)="toggleDarkMode(true)"
|
||||
size="md"
|
||||
class="theme-toggle-icon cursor-pointer"
|
||||
shape="moon"
|
||||
inverse="false"
|
||||
solid="false"
|
||||
></cds-icon>
|
||||
}
|
||||
@if (!!darkMode.value) {
|
||||
<cds-icon
|
||||
(click)="toggleDarkMode(false)"
|
||||
size="md"
|
||||
class="theme-toggle-icon cursor-pointer"
|
||||
shape="sun"
|
||||
inverse="false"
|
||||
solid="false"
|
||||
></cds-icon>
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-loading-indicator></app-loading-indicator>
|
||||
@@ -25,29 +27,31 @@
|
||||
<clr-dropdown class="app-nav-dropdown">
|
||||
<button class="nav-text color-white user-nav-toggle" clrDropdownToggle>
|
||||
<span>{{ userName }}</span>
|
||||
<span *ngIf="userName !== 'Not logged in' && isViya"
|
||||
><img
|
||||
class="avatar-img"
|
||||
src="{{ getPictureUrl() }}"
|
||||
alt="profile picture"
|
||||
/></span>
|
||||
<span
|
||||
class="badge badge-danger"
|
||||
*ngIf="!sasjsConfig.debug"
|
||||
[class.hidden]="failedReqs.length === 0"
|
||||
>{{ failedReqs.length }}</span
|
||||
>
|
||||
<span
|
||||
class="badge badge-info"
|
||||
*ngIf="sasjsConfig.debug"
|
||||
[class.hidden]="debugLogs.length === 0"
|
||||
>{{ debugLogs.length }}</span
|
||||
>
|
||||
<clr-icon
|
||||
*ngIf="!isViya"
|
||||
aria-hidden="true"
|
||||
shape="caret down"
|
||||
></clr-icon>
|
||||
@if (userName !== 'Not logged in' && isViya) {
|
||||
<span
|
||||
><img
|
||||
class="avatar-img"
|
||||
src="{{ getPictureUrl() }}"
|
||||
alt="profile picture"
|
||||
/></span>
|
||||
}
|
||||
@if (!sasjsConfig.debug) {
|
||||
<span
|
||||
class="badge badge-danger"
|
||||
[class.hidden]="failedReqs.length === 0"
|
||||
>{{ failedReqs.length }}</span
|
||||
>
|
||||
}
|
||||
@if (sasjsConfig.debug) {
|
||||
<span
|
||||
class="badge badge-info"
|
||||
[class.hidden]="debugLogs.length === 0"
|
||||
>{{ debugLogs.length }}</span
|
||||
>
|
||||
}
|
||||
@if (!isViya) {
|
||||
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
|
||||
}
|
||||
</button>
|
||||
<clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
|
||||
<div #dropdownItemDebug class="debug-switch-item" clrDropdownItem>
|
||||
@@ -71,7 +75,7 @@
|
||||
<span>SAS Requests</span>
|
||||
</a>
|
||||
|
||||
<ng-container *ngIf="!isDeployPage">
|
||||
@if (!isDeployPage) {
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://docs.datacontroller.io"
|
||||
@@ -79,7 +83,7 @@
|
||||
>
|
||||
<span class="dropdown-text">Documentation</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<div class="separator"></div>
|
||||
<a href="..." routerLink="/system" clrDropdownItem>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { retryOnce } from './retry-once'
|
||||
|
||||
describe('retryOnce', () => {
|
||||
it("resolves with the first attempt's value and never waits when it succeeds", async () => {
|
||||
const requestFn = jasmine.createSpy('requestFn').and.resolveTo('ok')
|
||||
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
|
||||
|
||||
const result = await retryOnce(requestFn, { wait })
|
||||
|
||||
expect(result).toBe('ok')
|
||||
expect(requestFn).toHaveBeenCalledTimes(1)
|
||||
expect(wait).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries exactly once after a rejection and resolves with the retry value', async () => {
|
||||
const requestFn = jasmine
|
||||
.createSpy('requestFn')
|
||||
.and.returnValues(Promise.reject('first-error'), Promise.resolve('ok'))
|
||||
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
|
||||
|
||||
const result = await retryOnce(requestFn, { wait })
|
||||
|
||||
expect(result).toBe('ok')
|
||||
expect(requestFn).toHaveBeenCalledTimes(2)
|
||||
expect(wait).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('derives the pre-retry pause from random within [0, maxDelayMs)', async () => {
|
||||
const requestFn = jasmine
|
||||
.createSpy('requestFn')
|
||||
.and.returnValues(Promise.reject('err'), Promise.resolve('ok'))
|
||||
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
|
||||
|
||||
await retryOnce(requestFn, { wait, random: () => 0.5 })
|
||||
expect(wait).toHaveBeenCalledWith(1500)
|
||||
|
||||
wait.calls.reset()
|
||||
requestFn.and.returnValues(Promise.reject('err'), Promise.resolve('ok'))
|
||||
await retryOnce(requestFn, { wait, random: () => 0 })
|
||||
expect(wait).toHaveBeenCalledWith(0)
|
||||
|
||||
wait.calls.reset()
|
||||
requestFn.and.returnValues(Promise.reject('err'), Promise.resolve('ok'))
|
||||
await retryOnce(requestFn, { wait, random: () => 0.999999 })
|
||||
expect(wait).toHaveBeenCalledWith(jasmine.any(Number))
|
||||
expect(wait.calls.mostRecent().args[0]).toBeLessThan(3000)
|
||||
})
|
||||
|
||||
it('rejects with the second error when both attempts fail, never trying a third time', async () => {
|
||||
const requestFn = jasmine
|
||||
.createSpy('requestFn')
|
||||
.and.returnValues(
|
||||
Promise.reject('first-error'),
|
||||
Promise.reject('second-error')
|
||||
)
|
||||
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
|
||||
|
||||
await expectAsync(retryOnce(requestFn, { wait })).toBeRejectedWith(
|
||||
'second-error'
|
||||
)
|
||||
expect(requestFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface RetryOnceOptions {
|
||||
/** Upper bound of the random pre-retry delay, in ms. Default 3000. */
|
||||
maxDelayMs?: number
|
||||
/** Injectable for tests. Default Math.random. */
|
||||
random?: () => number
|
||||
/** Injectable for tests. Default setTimeout-based wait. */
|
||||
wait?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
const defaultWait = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* Runs requestFn; if it rejects, waits a random 0..maxDelayMs and tries
|
||||
* exactly once more. A second rejection propagates as-is. Multiple
|
||||
* concurrent DC instances (e.g. VA multi-page reports) can cause the
|
||||
* startup request to time out even though a second attempt succeeds.
|
||||
*/
|
||||
export const retryOnce = async <T>(
|
||||
requestFn: () => Promise<T>,
|
||||
options?: RetryOnceOptions
|
||||
): Promise<T> => {
|
||||
const maxDelayMs = options?.maxDelayMs ?? 3000
|
||||
const random = options?.random ?? Math.random
|
||||
const wait = options?.wait ?? defaultWait
|
||||
|
||||
try {
|
||||
return await requestFn()
|
||||
} catch {
|
||||
await wait(random() * maxDelayMs)
|
||||
return requestFn()
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,13 @@
|
||||
>
|
||||
<h3 class="modal-title">
|
||||
Viewboxes
|
||||
<span
|
||||
*ngIf="licenceState.value.viewbox_limit !== Infinity"
|
||||
class="licence-notice"
|
||||
>To unlock more then {{ licenceState.value.viewbox_limit }}
|
||||
{{ licenceState.value.viewbox_limit === 1 ? 'viewbox' : 'viewboxes' }},
|
||||
contact support@datacontroller.io</span
|
||||
>
|
||||
@if (licenceState.value.viewbox_limit !== Infinity) {
|
||||
<span class="licence-notice"
|
||||
>To unlock more then {{ licenceState.value.viewbox_limit }}
|
||||
{{ licenceState.value.viewbox_limit === 1 ? 'viewbox' : 'viewboxes' }},
|
||||
contact support@datacontroller.io</span
|
||||
>
|
||||
}
|
||||
</h3>
|
||||
|
||||
<div class="modal-body d-flex">
|
||||
@@ -23,20 +23,23 @@
|
||||
|
||||
<hr class="w-100" />
|
||||
|
||||
<div *ngIf="librariesLoading" class="my-10-mx-auto text-center">
|
||||
<clr-spinner clrMedium></clr-spinner>
|
||||
</div>
|
||||
@if (librariesLoading) {
|
||||
<div class="my-10-mx-auto text-center">
|
||||
<clr-spinner clrMedium></clr-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p *ngIf="viewboxLimitReached" class="m-0 viewbox-limit-notice">
|
||||
Viewbox open limit reached
|
||||
</p>
|
||||
@if (viewboxLimitReached) {
|
||||
<p class="m-0 viewbox-limit-notice">Viewbox open limit reached</p>
|
||||
}
|
||||
|
||||
<dc-tree
|
||||
*ngIf="viewboxModal"
|
||||
[libraryList]="libraries"
|
||||
(libraryOnClickEmitter)="libraryOnClick($event)"
|
||||
(tableOnClickEmitter)="tableOnClick($event)"
|
||||
></dc-tree>
|
||||
@if (viewboxModal) {
|
||||
<dc-tree
|
||||
[libraryList]="libraries"
|
||||
(libraryOnClickEmitter)="libraryOnClick($event)"
|
||||
(tableOnClickEmitter)="tableOnClick($event)"
|
||||
></dc-tree>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="currently-open">
|
||||
@@ -82,78 +85,77 @@
|
||||
|
||||
<hr />
|
||||
|
||||
<p *ngIf="viewboxes.length < 1" class="text-center w-100 m-0">
|
||||
Pick table from the list on the left
|
||||
</p>
|
||||
@if (viewboxes.length < 1) {
|
||||
<p class="text-center w-100 m-0">
|
||||
Pick table from the list on the left
|
||||
</p>
|
||||
}
|
||||
|
||||
<div
|
||||
*ngFor="let viewbox of viewboxes"
|
||||
(click)="selectViewbox(viewbox)"
|
||||
[class.selected]="selectedViewbox?.id === viewbox.id"
|
||||
class="open-viewbox d-flex clr-justify-content-between clr-align-items-center"
|
||||
>
|
||||
<div class="d-flex clr-align-items-center">
|
||||
#{{ viewbox.id }} {{ viewbox.library }}.{{ viewbox.table }}
|
||||
<clr-spinner
|
||||
*ngIf="viewbox.loadingData"
|
||||
clrSmall
|
||||
class="ml-3"
|
||||
></clr-spinner>
|
||||
@for (viewbox of viewboxes; track viewbox) {
|
||||
<div
|
||||
(click)="selectViewbox(viewbox)"
|
||||
[class.selected]="selectedViewbox?.id === viewbox.id"
|
||||
class="open-viewbox d-flex clr-justify-content-between clr-align-items-center"
|
||||
>
|
||||
<div class="d-flex clr-align-items-center">
|
||||
#{{ viewbox.id }} {{ viewbox.library }}.{{ viewbox.table }}
|
||||
@if (viewbox.loadingData) {
|
||||
<clr-spinner clrSmall class="ml-3"></clr-spinner>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
(click)="resetPosSize(viewbox); stopPropagation($event)"
|
||||
role="tooltip"
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left"
|
||||
[class.disabled]="viewbox.x === 0 && viewbox.y === 0"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="cursor-move"
|
||||
size="13"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content">Reset position and size</span>
|
||||
</a>
|
||||
@if (!viewbox.minimized) {
|
||||
<a
|
||||
(click)="minimize(viewbox); stopPropagation($event)"
|
||||
role="tooltip"
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||
>
|
||||
<clr-icon aria-hidden="true" shape="minus" size="19"></clr-icon>
|
||||
<span class="tooltip-content">Minimize viewbox</span>
|
||||
</a>
|
||||
}
|
||||
@if (viewbox.minimized) {
|
||||
<a
|
||||
(click)="restore(viewbox); stopPropagation($event)"
|
||||
role="tooltip"
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="window-restore"
|
||||
size="19"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content">Restore viewbox</span>
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
(click)="close(viewbox); stopPropagation($event)"
|
||||
role="tooltip"
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||
>
|
||||
<clr-icon aria-hidden="true" shape="times" size="19"></clr-icon>
|
||||
<span class="tooltip-content">Close viewbox</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a
|
||||
(click)="resetPosSize(viewbox); stopPropagation($event)"
|
||||
role="tooltip"
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left"
|
||||
[class.disabled]="viewbox.x === 0 && viewbox.y === 0"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="cursor-move"
|
||||
size="13"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content">Reset position and size</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
*ngIf="!viewbox.minimized"
|
||||
(click)="minimize(viewbox); stopPropagation($event)"
|
||||
role="tooltip"
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||
>
|
||||
<clr-icon aria-hidden="true" shape="minus" size="19"></clr-icon>
|
||||
<span class="tooltip-content">Minimize viewbox</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
*ngIf="viewbox.minimized"
|
||||
(click)="restore(viewbox); stopPropagation($event)"
|
||||
role="tooltip"
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="window-restore"
|
||||
size="19"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content">Restore viewbox</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
(click)="close(viewbox); stopPropagation($event)"
|
||||
role="tooltip"
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||
>
|
||||
<clr-icon aria-hidden="true" shape="times" size="19"></clr-icon>
|
||||
<span class="tooltip-content">Close viewbox</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="d-flex clr-justify-content-between clr-align-items-center mt-10"
|
||||
@@ -186,7 +188,7 @@
|
||||
} as hotColumns"
|
||||
cdkDropListGroup
|
||||
>
|
||||
<ng-container *ngIf="hotColumns.colHeadersHidden">
|
||||
@if (hotColumns.colHeadersHidden) {
|
||||
<div class="cols-search">
|
||||
<!-- <input #columnSearch clrInput (input)="columnSearchChange($event, selectedViewboxTable)" placeholder="Search columns"/> -->
|
||||
<app-autocomplete
|
||||
@@ -202,14 +204,16 @@
|
||||
onColsearchChange(input, $event, hotColumns.colHeadersHidden)
|
||||
"
|
||||
>
|
||||
<option *ngFor="let column of hotColumns.colHeadersHidden">
|
||||
{{ column }}
|
||||
</option>
|
||||
@for (column of hotColumns.colHeadersHidden; track column) {
|
||||
<option>
|
||||
{{ column }}
|
||||
</option>
|
||||
}
|
||||
</app-autocomplete>
|
||||
</div>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<ng-container *ngIf="hotColumns.colHeadersVisible">
|
||||
@if (hotColumns.colHeadersVisible) {
|
||||
<p class="mt-0">Columns To Display (drag and re-order)</p>
|
||||
<div
|
||||
class="cols-list"
|
||||
@@ -217,42 +221,41 @@
|
||||
[cdkDropListData]="hotColumns.colHeadersVisible"
|
||||
(cdkDropListDropped)="columnsDrop($event)"
|
||||
>
|
||||
<div
|
||||
class="col-box column-{{ column }}"
|
||||
*ngFor="let column of hotColumns.colHeadersVisible"
|
||||
[class.cursor-default-i]="hotColumns.headerPks.includes(column)"
|
||||
[class.primaryKeyHeaderStyle]="
|
||||
hotColumns.headerPks.includes(column)
|
||||
"
|
||||
[cdkDragData]="column"
|
||||
[cdkDragDisabled]="hotColumns.headerPks.includes(column)"
|
||||
cdkDrag
|
||||
>
|
||||
<span>
|
||||
{{ column }}
|
||||
{{
|
||||
selectedViewboxTable?.hotTable?.$dataformats.vars[column]
|
||||
.type === 'num'
|
||||
? '(numeric)'
|
||||
: ''
|
||||
}}
|
||||
<clr-icon
|
||||
*ngIf="hotColumns.headerPks.includes(column)"
|
||||
aria-hidden="true"
|
||||
shape="key"
|
||||
></clr-icon>
|
||||
</span>
|
||||
|
||||
<clr-icon
|
||||
*ngIf="!hotColumns.headerPks.includes(column)"
|
||||
(click)="onColRemove(column)"
|
||||
aria-label="Remove column"
|
||||
shape="trash"
|
||||
class="cursor-pointer"
|
||||
></clr-icon>
|
||||
</div>
|
||||
@for (column of hotColumns.colHeadersVisible; track column) {
|
||||
<div
|
||||
class="col-box column-{{ column }}"
|
||||
[class.cursor-default-i]="hotColumns.headerPks.includes(column)"
|
||||
[class.primaryKeyHeaderStyle]="
|
||||
hotColumns.headerPks.includes(column)
|
||||
"
|
||||
[cdkDragData]="column"
|
||||
[cdkDragDisabled]="hotColumns.headerPks.includes(column)"
|
||||
cdkDrag
|
||||
>
|
||||
<span>
|
||||
{{ column }}
|
||||
{{
|
||||
selectedViewboxTable?.hotTable?.$dataformats?.vars?.[column]
|
||||
?.type === 'num'
|
||||
? '(numeric)'
|
||||
: ''
|
||||
}}
|
||||
@if (hotColumns.headerPks.includes(column)) {
|
||||
<clr-icon aria-hidden="true" shape="key"></clr-icon>
|
||||
}
|
||||
</span>
|
||||
@if (!hotColumns.headerPks.includes(column)) {
|
||||
<clr-icon
|
||||
(click)="onColRemove(column)"
|
||||
aria-label="Remove column"
|
||||
shape="trash"
|
||||
class="cursor-pointer"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,158 +271,159 @@
|
||||
</clr-modal>
|
||||
|
||||
<div class="viewboxes-container">
|
||||
<div
|
||||
*ngFor="let viewbox of viewboxes; let index = index"
|
||||
(mousedown)="focusViewbox(viewbox)"
|
||||
[class.focused]="viewbox.focused"
|
||||
[style.display]="viewbox.minimized ? 'none' : ''"
|
||||
[style.width.px]="viewbox.width"
|
||||
[style.height.px]="viewbox.height"
|
||||
[style.pointer-events]="viewbox.collapsed ? 'none' : ''"
|
||||
[style.box-shadow]="viewbox.collapsed ? 'none' : ''"
|
||||
class="viewbox"
|
||||
cdkDragBoundary=".viewboxes-container"
|
||||
cdkDrag
|
||||
[cdkDragFreeDragPosition]="{ x: viewbox.x, y: viewbox.y }"
|
||||
(cdkDragEnded)="viewboxDragEnded($event, viewbox)"
|
||||
tabindex="0"
|
||||
[id]="'viewbox_' + viewbox.id"
|
||||
#resizeBox
|
||||
>
|
||||
@for (viewbox of viewboxes; track viewbox; let index = $index) {
|
||||
<div
|
||||
class="drag-handle d-flex clr-justify-content-between clr-align-items-center"
|
||||
cdkDragHandle
|
||||
>
|
||||
<span class="table-title">
|
||||
#{{ viewbox.id }} {{ viewbox.library }}.{{ viewbox.table }}
|
||||
</span>
|
||||
|
||||
<div class="actions">
|
||||
<clr-icon
|
||||
*ngIf="!viewbox.collapsed"
|
||||
(click)="collapse(viewbox)"
|
||||
aria-label="Collapse"
|
||||
shape="angle top"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="viewbox.collapsed"
|
||||
(click)="expand(viewbox)"
|
||||
aria-label="Expand"
|
||||
shape="angle down"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
(click)="minimize(viewbox)"
|
||||
aria-label="Minimize"
|
||||
shape="minus"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
(click)="close(viewbox)"
|
||||
aria-label="Close"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
#dragHandleCorner
|
||||
[id]="'handle_viewbox_' + viewbox.id"
|
||||
class="dragHandle corner"
|
||||
(mousedown)="focusViewbox(viewbox)"
|
||||
[class.focused]="viewbox.focused"
|
||||
[style.display]="viewbox.minimized ? 'none' : ''"
|
||||
[style.width.px]="viewbox.width"
|
||||
[style.height.px]="viewbox.height"
|
||||
[style.pointer-events]="viewbox.collapsed ? 'none' : ''"
|
||||
[style.box-shadow]="viewbox.collapsed ? 'none' : ''"
|
||||
class="viewbox"
|
||||
cdkDragBoundary=".viewboxes-container"
|
||||
cdkDrag
|
||||
(cdkDragMoved)="dragMove(dragHandleCorner, resizeBox, viewbox, $event)"
|
||||
></span>
|
||||
<form
|
||||
class="d-flex align-items-center clr-justify-content-between clr-flex-wrap table-search-wrapper"
|
||||
[style.display]="viewbox.collapsed ? 'none' : ''"
|
||||
clrForm
|
||||
>
|
||||
<div class="d-flex align-items-center clr-flex-wrap">
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
#searchEl
|
||||
(keyup.enter)="searchTable(searchEl, viewbox)"
|
||||
clrInput
|
||||
[type]="viewbox.searchNumeric ? 'number' : 'text'"
|
||||
placeholder="Search"
|
||||
name="search-input"
|
||||
/>
|
||||
<clr-icon
|
||||
*ngIf="!viewbox.searchLoading"
|
||||
(click)="searchTable(searchEl, viewbox)"
|
||||
aria-label="Search"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<span *ngIf="viewbox.searchLoading" class="spinner spinner-inline">
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<clr-checkbox-container>
|
||||
<clr-checkbox-wrapper>
|
||||
<input
|
||||
type="checkbox"
|
||||
clrCheckbox
|
||||
[(ngModel)]="viewbox.searchNumeric"
|
||||
name="numeric_check"
|
||||
/>
|
||||
<label>Numeric</label>
|
||||
</clr-checkbox-wrapper>
|
||||
</clr-checkbox-container>
|
||||
</div>
|
||||
|
||||
<div class="d-flex clr-align-items-center">
|
||||
<span *ngIf="viewbox.loadingData" class="spinner spinner-sm"
|
||||
>Loading...</span
|
||||
>
|
||||
<clr-icon
|
||||
*ngIf="!viewbox.loadingData"
|
||||
(click)="reloadTableData(viewbox); searchEl.value = ''"
|
||||
aria-label="Reload data"
|
||||
shape="refresh"
|
||||
class="click-icon"
|
||||
></clr-icon>
|
||||
<a
|
||||
(click)="openFilter(viewbox); searchEl.value = ''"
|
||||
role="tooltip"
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-lg tooltip-bottom-left ml-5"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="filter"
|
||||
[class.filter-active]="viewbox.filter_pk !== '0'"
|
||||
class="click-icon"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content tooltip-long">{{
|
||||
viewbox.filterText
|
||||
}}</span>
|
||||
</a>
|
||||
<clr-icon
|
||||
(click)="openTableEdit(viewbox)"
|
||||
[class.disabled]="!tableEditExists(viewbox)"
|
||||
aria-label="Edit table"
|
||||
shape="pencil"
|
||||
class="click-icon ml-5"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
class="content"
|
||||
*ngVar="getViewboxTableIndex(viewbox) as viewboxTableIndex"
|
||||
[style.display]="viewbox.collapsed ? 'none' : ''"
|
||||
[cdkDragFreeDragPosition]="{ x: viewbox.x, y: viewbox.y }"
|
||||
(cdkDragEnded)="viewboxDragEnded($event, viewbox)"
|
||||
tabindex="0"
|
||||
[id]="'viewbox_' + viewbox.id"
|
||||
#resizeBox
|
||||
>
|
||||
<div
|
||||
*ngIf="viewbox.loadingData && viewboxTableIndex < 0"
|
||||
class="hot-spinner my-10-mx-auto text-center"
|
||||
class="drag-handle d-flex clr-justify-content-between clr-align-items-center"
|
||||
cdkDragHandle
|
||||
>
|
||||
<clr-spinner clrMedium></clr-spinner>
|
||||
<span class="table-title">
|
||||
#{{ viewbox.id }} {{ viewbox.library }}.{{ viewbox.table }}
|
||||
</span>
|
||||
<div class="actions">
|
||||
@if (!viewbox.collapsed) {
|
||||
<clr-icon
|
||||
(click)="collapse(viewbox)"
|
||||
aria-label="Collapse"
|
||||
shape="angle top"
|
||||
></clr-icon>
|
||||
}
|
||||
@if (viewbox.collapsed) {
|
||||
<clr-icon
|
||||
(click)="expand(viewbox)"
|
||||
aria-label="Expand"
|
||||
shape="angle down"
|
||||
></clr-icon>
|
||||
}
|
||||
<clr-icon
|
||||
(click)="minimize(viewbox)"
|
||||
aria-label="Minimize"
|
||||
shape="minus"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
(click)="close(viewbox)"
|
||||
aria-label="Close"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
#dragHandleCorner
|
||||
[id]="'handle_viewbox_' + viewbox.id"
|
||||
class="dragHandle corner"
|
||||
cdkDrag
|
||||
(cdkDragMoved)="dragMove(dragHandleCorner, resizeBox, viewbox, $event)"
|
||||
></span>
|
||||
<form
|
||||
class="d-flex align-items-center clr-justify-content-between clr-flex-wrap table-search-wrapper"
|
||||
[style.display]="viewbox.collapsed ? 'none' : ''"
|
||||
clrForm
|
||||
>
|
||||
<div class="d-flex align-items-center clr-flex-wrap">
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
#searchEl
|
||||
(keyup.enter)="searchTable(searchEl, viewbox)"
|
||||
clrInput
|
||||
[type]="viewbox.searchNumeric ? 'number' : 'text'"
|
||||
placeholder="Search"
|
||||
name="search-input"
|
||||
/>
|
||||
@if (!viewbox.searchLoading) {
|
||||
<clr-icon
|
||||
(click)="searchTable(searchEl, viewbox)"
|
||||
aria-label="Search"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
}
|
||||
@if (viewbox.searchLoading) {
|
||||
<span class="spinner spinner-inline"> Loading... </span>
|
||||
}
|
||||
</div>
|
||||
<clr-checkbox-container>
|
||||
<clr-checkbox-wrapper>
|
||||
<input
|
||||
type="checkbox"
|
||||
clrCheckbox
|
||||
[(ngModel)]="viewbox.searchNumeric"
|
||||
name="numeric_check"
|
||||
/>
|
||||
<label>Numeric</label>
|
||||
</clr-checkbox-wrapper>
|
||||
</clr-checkbox-container>
|
||||
</div>
|
||||
<div class="d-flex clr-align-items-center">
|
||||
@if (viewbox.loadingData) {
|
||||
<span class="spinner spinner-sm">Loading...</span>
|
||||
}
|
||||
@if (!viewbox.loadingData) {
|
||||
<clr-icon
|
||||
(click)="reloadTableData(viewbox); searchEl.value = ''"
|
||||
aria-label="Reload data"
|
||||
shape="refresh"
|
||||
class="click-icon"
|
||||
></clr-icon>
|
||||
}
|
||||
<a
|
||||
(click)="openFilter(viewbox); searchEl.value = ''"
|
||||
role="tooltip"
|
||||
aria-haspopup="true"
|
||||
class="tooltip tooltip-lg tooltip-bottom-left ml-5"
|
||||
>
|
||||
<clr-icon
|
||||
aria-hidden="true"
|
||||
shape="filter"
|
||||
[class.filter-active]="viewbox.filter_pk !== '0'"
|
||||
class="click-icon"
|
||||
></clr-icon>
|
||||
<span class="tooltip-content tooltip-long">{{
|
||||
viewbox.filterText
|
||||
}}</span>
|
||||
</a>
|
||||
<clr-icon
|
||||
(click)="openTableEdit(viewbox)"
|
||||
[class.disabled]="!tableEditExists(viewbox)"
|
||||
aria-label="Edit table"
|
||||
shape="pencil"
|
||||
class="click-icon ml-5"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
class="content"
|
||||
*ngVar="getViewboxTableIndex(viewbox) as viewboxTableIndex"
|
||||
[style.display]="viewbox.collapsed ? 'none' : ''"
|
||||
>
|
||||
@if (viewbox.loadingData && viewboxTableIndex < 0) {
|
||||
<div class="hot-spinner my-10-mx-auto text-center">
|
||||
<clr-spinner clrMedium></clr-spinner>
|
||||
</div>
|
||||
}
|
||||
@if (viewboxTableIndex > -1 && viewboxHotSettings.get(viewbox.id)) {
|
||||
<hot-table
|
||||
[settings]="viewboxHotSettings.get(viewbox.id) || {}"
|
||||
[id]="'hotTable_' + viewbox.id"
|
||||
></hot-table>
|
||||
}
|
||||
</div>
|
||||
|
||||
<hot-table
|
||||
*ngIf="viewboxTableIndex > -1 && viewboxHotSettings.get(viewbox.id)"
|
||||
[settings]="viewboxHotSettings.get(viewbox.id) || {}"
|
||||
[id]="'hotTable_' + viewbox.id"
|
||||
></hot-table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<clr-modal
|
||||
@@ -433,11 +437,9 @@
|
||||
<span> #{{ filteringViewbox?.id }} {{ filterLibds }} </span>
|
||||
</h3>
|
||||
<div class="modal-body">
|
||||
<app-query
|
||||
#queryFilter
|
||||
*ngIf="filter"
|
||||
[viewboxId]="filteringViewbox?.id"
|
||||
></app-query>
|
||||
@if (filter) {
|
||||
<app-query #queryFilter [viewboxId]="filteringViewbox?.id"></app-query>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button (click)="resetFilter()" type="button" class="btn btn-sm btn-link">
|
||||
|
||||
@@ -1,140 +1,146 @@
|
||||
<main class="content-area">
|
||||
<div class="clr-col-md-12 card">
|
||||
<div
|
||||
*ngIf="!loaded"
|
||||
class="h-80vh d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
<span class="spinner" *ngIf="!loaded"> Loading... </span>
|
||||
<div *ngIf="!loaded">
|
||||
<h3>Loading submitted table</h3>
|
||||
@if (!loaded) {
|
||||
<div
|
||||
class="h-80vh d-flex justify-content-center flex-column align-items-center"
|
||||
>
|
||||
@if (!loaded) {
|
||||
<span class="spinner"> Loading... </span>
|
||||
}
|
||||
@if (!loaded) {
|
||||
<div>
|
||||
<h3>Loading submitted table</h3>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="loaded">
|
||||
<div class="card-header" ng-if="loaded">
|
||||
<div class="card-title text-center">
|
||||
<h4 class="mt-0">Staged Data</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clr-row" ng-if="loaded">
|
||||
<div class="card-block clr-col-md-6 no-borders">
|
||||
<div class="card-header py-5">
|
||||
<div class="card-title text-center">Basic Submitted Details</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>Table Id:</span>
|
||||
<strong>
|
||||
<span> {{ tableDetails?.TABLE_ID }}</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Base Table:</span>
|
||||
<a (click)="viewerTableScreen()" class="baseTableLink">
|
||||
{{ tableDetails?.BASE_TABLE }}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span>Submit Date:</span>
|
||||
<span class="mt-10">
|
||||
{{ tableDetails?.SUBMITTED_ON_DTTM }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>Review Status:</span>
|
||||
<span
|
||||
class="mt-10"
|
||||
[ngClass]="{
|
||||
rejected: tableDetails?.REVIEW_STATUS_ID === 'REJECTED',
|
||||
accepted: tableDetails?.REVIEW_STATUS_ID === 'APPROVED'
|
||||
}"
|
||||
[attr.aria-label]="
|
||||
'Review status: ' + tableDetails?.REVIEW_STATUS_ID
|
||||
"
|
||||
role="status"
|
||||
>
|
||||
{{ tableDetails?.REVIEW_STATUS_ID }}
|
||||
</span>
|
||||
}
|
||||
@if (loaded) {
|
||||
<div>
|
||||
<div class="card-header" ng-if="loaded">
|
||||
<div class="card-title text-center">
|
||||
<h4 class="mt-0">Staged Data</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clr-col-md-6 card-block no-borders">
|
||||
<div class="card-header py-5">
|
||||
<div class="card-title text-center">Actions</div>
|
||||
</div>
|
||||
<div class="mt-20">
|
||||
<div class="row">
|
||||
<button
|
||||
class="btn btn-sm btn-outline text-center mr-5i"
|
||||
(click)="viewerTableScreen()"
|
||||
[disabled]="revertingChanges"
|
||||
aria-label="View base table"
|
||||
>
|
||||
View base table
|
||||
</button>
|
||||
<button
|
||||
*ngIf="!(tableDetails?.['ALLOW_RESTORE'] === 'YES')"
|
||||
id="approval-btn"
|
||||
class="btn btn-sm btn-success-outline text-center mr-5i"
|
||||
[disabled]="
|
||||
tableDetails?.REVIEW_STATUS_ID === 'APPROVED' ||
|
||||
tableDetails?.REVIEW_STATUS_ID === 'REJECTED'
|
||||
<div class="clr-row" ng-if="loaded">
|
||||
<div class="card-block clr-col-md-6 no-borders">
|
||||
<div class="card-header py-5">
|
||||
<div class="card-title text-center">Basic Submitted Details</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>Table Id:</span>
|
||||
<strong>
|
||||
<span> {{ tableDetails?.TABLE_ID }}</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Base Table:</span>
|
||||
<a (click)="viewerTableScreen()" class="baseTableLink">
|
||||
{{ tableDetails?.BASE_TABLE }}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span>Submit Date:</span>
|
||||
<span class="mt-10">
|
||||
{{ tableDetails?.SUBMITTED_ON_DTTM }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>Review Status:</span>
|
||||
<span
|
||||
class="mt-10"
|
||||
[ngClass]="{
|
||||
rejected: tableDetails?.REVIEW_STATUS_ID === 'REJECTED',
|
||||
accepted: tableDetails?.REVIEW_STATUS_ID === 'APPROVED'
|
||||
}"
|
||||
[attr.aria-label]="
|
||||
'Review status: ' + tableDetails?.REVIEW_STATUS_ID
|
||||
"
|
||||
(click)="approveTableScreen()"
|
||||
[disabled]="revertingChanges"
|
||||
aria-label="Approve table"
|
||||
role="status"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-info-outline text-center mr-5i"
|
||||
(click)="goBack()"
|
||||
[disabled]="revertingChanges"
|
||||
aria-label="Edit base table"
|
||||
>
|
||||
Edit base table
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-success text-center mr-5i min-w-0"
|
||||
(click)="download(tableDetails?.TABLE_ID)"
|
||||
aria-label="Download audit file"
|
||||
>
|
||||
<clr-icon shape="download" aria-hidden="true"></clr-icon>
|
||||
</button>
|
||||
|
||||
<clr-tooltip>
|
||||
{{ tableDetails?.REVIEW_STATUS_ID }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clr-col-md-6 card-block no-borders">
|
||||
<div class="card-header py-5">
|
||||
<div class="card-title text-center">Actions</div>
|
||||
</div>
|
||||
<div class="mt-20">
|
||||
<div class="row">
|
||||
<button
|
||||
*ngIf="tableDetails?.['ALLOW_RESTORE'] === 'YES'"
|
||||
(click)="revertChanges()"
|
||||
clrTooltipTrigger
|
||||
[clrLoading]="revertingChanges"
|
||||
class="btn btn-sm btn-danger text-center mt-20"
|
||||
aria-label="Revert this and all subsequent changes"
|
||||
class="btn btn-sm btn-outline text-center mr-5i"
|
||||
(click)="viewerTableScreen()"
|
||||
[disabled]="revertingChanges"
|
||||
aria-label="View base table"
|
||||
>
|
||||
REVERT
|
||||
|
||||
<clr-tooltip-content
|
||||
clrPosition="bottom-left"
|
||||
clrSize="lg"
|
||||
*clrIfOpen
|
||||
>
|
||||
<span> Revert this and all subsequent changes </span>
|
||||
</clr-tooltip-content>
|
||||
View base table
|
||||
</button>
|
||||
</clr-tooltip>
|
||||
@if (!(tableDetails?.['ALLOW_RESTORE'] === 'YES')) {
|
||||
<button
|
||||
id="approval-btn"
|
||||
class="btn btn-sm btn-success-outline text-center mr-5i"
|
||||
[disabled]="
|
||||
tableDetails?.REVIEW_STATUS_ID === 'APPROVED' ||
|
||||
tableDetails?.REVIEW_STATUS_ID === 'REJECTED'
|
||||
"
|
||||
(click)="approveTableScreen()"
|
||||
[disabled]="revertingChanges"
|
||||
aria-label="Approve table"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
class="btn btn-sm btn-info-outline text-center mr-5i"
|
||||
(click)="goBack()"
|
||||
[disabled]="revertingChanges"
|
||||
aria-label="Edit base table"
|
||||
>
|
||||
Edit base table
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-success text-center mr-5i min-w-0"
|
||||
(click)="download(tableDetails?.TABLE_ID)"
|
||||
aria-label="Download audit file"
|
||||
>
|
||||
<clr-icon shape="download" aria-hidden="true"></clr-icon>
|
||||
</button>
|
||||
<clr-tooltip>
|
||||
@if (tableDetails?.['ALLOW_RESTORE'] === 'YES') {
|
||||
<button
|
||||
(click)="revertChanges()"
|
||||
clrTooltipTrigger
|
||||
[clrLoading]="revertingChanges"
|
||||
class="btn btn-sm btn-danger text-center mt-20"
|
||||
aria-label="Revert this and all subsequent changes"
|
||||
>
|
||||
REVERT
|
||||
<clr-tooltip-content
|
||||
clrPosition="bottom-left"
|
||||
clrSize="lg"
|
||||
*clrIfOpen
|
||||
>
|
||||
<span> Revert this and all subsequent changes </span>
|
||||
</clr-tooltip-content>
|
||||
</button>
|
||||
}
|
||||
</clr-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-block">
|
||||
<hot-table
|
||||
id="hotTable"
|
||||
[data]="hotTable.data"
|
||||
[settings]="hotTableSettings()"
|
||||
aria-label="Staged data table"
|
||||
>
|
||||
<!--[licenseKey]=null-->
|
||||
</hot-table>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-center"></div>
|
||||
</div>
|
||||
<div class="card-block">
|
||||
<hot-table
|
||||
id="hotTable"
|
||||
[data]="hotTable.data"
|
||||
[settings]="hotTableSettings()"
|
||||
aria-label="Staged data table"
|
||||
>
|
||||
<!--[licenseKey]=null-->
|
||||
</hot-table>
|
||||
</div>
|
||||
|
||||
<div class="card-footer d-flex justify-content-center"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
@@ -104,71 +104,68 @@
|
||||
Free Tier:
|
||||
<span class="dark">{{ licenceInfo?.demo ? 'YES' : 'NO' }}</span>
|
||||
</p>
|
||||
<p
|
||||
*ngIf="licenceState.value.viewer_rows_allowed !== Infinity"
|
||||
class="m-0"
|
||||
>
|
||||
Viewer rows limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.viewer_rows_allowed
|
||||
}}</span>
|
||||
</p>
|
||||
<p
|
||||
*ngIf="licenceState.value.editor_rows_allowed !== Infinity"
|
||||
class="m-0"
|
||||
>
|
||||
Editor rows limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.editor_rows_allowed
|
||||
}}</span>
|
||||
</p>
|
||||
<p
|
||||
*ngIf="licenceState.value.stage_rows_allowed !== Infinity"
|
||||
class="m-0"
|
||||
>
|
||||
Stage rows limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.stage_rows_allowed
|
||||
}}</span>
|
||||
</p>
|
||||
<p
|
||||
*ngIf="licenceState.value.history_rows_allowed !== Infinity"
|
||||
class="m-0"
|
||||
>
|
||||
History rows limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.history_rows_allowed
|
||||
}}</span>
|
||||
</p>
|
||||
<p
|
||||
*ngIf="licenceState.value.submit_rows_limit !== Infinity"
|
||||
class="m-0"
|
||||
>
|
||||
Submit rows limit:
|
||||
<span class="dark">{{ licenceState.value.submit_rows_limit }}</span>
|
||||
</p>
|
||||
<p
|
||||
*ngIf="licenceState.value.tables_in_library_limit !== Infinity"
|
||||
class="m-0"
|
||||
>
|
||||
Tables in library limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.tables_in_library_limit
|
||||
}}</span>
|
||||
</p>
|
||||
<p *ngIf="licenceState.value.viewbox_limit !== Infinity" class="m-0">
|
||||
Viewboxes limit:
|
||||
<span class="dark">{{ licenceState.value.viewbox_limit }}</span>
|
||||
</p>
|
||||
<p
|
||||
*ngIf="licenceState.value.lineage_daily_limit !== Infinity"
|
||||
class="m-0"
|
||||
>
|
||||
Lineage daily limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.lineage_daily_limit
|
||||
}}</span>
|
||||
</p>
|
||||
@if (licenceState.value.viewer_rows_allowed !== Infinity) {
|
||||
<p class="m-0">
|
||||
Viewer rows limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.viewer_rows_allowed
|
||||
}}</span>
|
||||
</p>
|
||||
}
|
||||
@if (licenceState.value.editor_rows_allowed !== Infinity) {
|
||||
<p class="m-0">
|
||||
Editor rows limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.editor_rows_allowed
|
||||
}}</span>
|
||||
</p>
|
||||
}
|
||||
@if (licenceState.value.stage_rows_allowed !== Infinity) {
|
||||
<p class="m-0">
|
||||
Stage rows limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.stage_rows_allowed
|
||||
}}</span>
|
||||
</p>
|
||||
}
|
||||
@if (licenceState.value.history_rows_allowed !== Infinity) {
|
||||
<p class="m-0">
|
||||
History rows limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.history_rows_allowed
|
||||
}}</span>
|
||||
</p>
|
||||
}
|
||||
@if (licenceState.value.submit_rows_limit !== Infinity) {
|
||||
<p class="m-0">
|
||||
Submit rows limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.submit_rows_limit
|
||||
}}</span>
|
||||
</p>
|
||||
}
|
||||
@if (licenceState.value.tables_in_library_limit !== Infinity) {
|
||||
<p class="m-0">
|
||||
Tables in library limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.tables_in_library_limit
|
||||
}}</span>
|
||||
</p>
|
||||
}
|
||||
@if (licenceState.value.viewbox_limit !== Infinity) {
|
||||
<p class="m-0">
|
||||
Viewboxes limit:
|
||||
<span class="dark">{{ licenceState.value.viewbox_limit }}</span>
|
||||
</p>
|
||||
}
|
||||
@if (licenceState.value.lineage_daily_limit !== Infinity) {
|
||||
<p class="m-0">
|
||||
Lineage daily limit:
|
||||
<span class="dark">{{
|
||||
licenceState.value.lineage_daily_limit
|
||||
}}</span>
|
||||
</p>
|
||||
}
|
||||
<p cds-text="label" class="m-0">
|
||||
Viewboxes:
|
||||
<span class="dark">{{
|
||||
@@ -201,29 +198,28 @@
|
||||
<!-- Keep the logic in case we in future have more then 2 themes (light, dark) -->
|
||||
<!-- -->
|
||||
<!-- <div class="user-action">
|
||||
Keep selected theme after reload
|
||||
Keep selected theme after reload
|
||||
|
||||
<clr-checkbox-wrapper>
|
||||
<clr-checkbox-wrapper>
|
||||
<input [(ngModel)]="settings.persistSelectedTheme" (change)="settingChange($event)" value="persistDarkMode" type="checkbox" clrToggle />
|
||||
</clr-checkbox-wrapper>
|
||||
</div> -->
|
||||
|
||||
<ng-container *ngIf="environmentInfo?.ISADMIN === 1">
|
||||
<div *ngIf="serverType === 'SAS9'" class="admin-action">
|
||||
Refresh Data Lineage
|
||||
|
||||
<button
|
||||
(click)="refreshDataLineage()"
|
||||
[clrLoading]="refreshingDataLineage"
|
||||
class="btn btn-info btn-sm"
|
||||
>
|
||||
REFRESH
|
||||
</button>
|
||||
</div>
|
||||
</clr-checkbox-wrapper>
|
||||
</div> -->
|
||||
|
||||
@if (environmentInfo?.ISADMIN === 1) {
|
||||
@if (serverType === 'SAS9') {
|
||||
<div class="admin-action">
|
||||
Refresh Data Lineage
|
||||
<button
|
||||
(click)="refreshDataLineage()"
|
||||
[clrLoading]="refreshingDataLineage"
|
||||
class="btn btn-info btn-sm"
|
||||
>
|
||||
REFRESH
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="admin-action">
|
||||
Refresh Data Catalog
|
||||
|
||||
<button
|
||||
(click)="refreshDataCatalog()"
|
||||
[clrLoading]="refreshingDataCatalog"
|
||||
@@ -232,10 +228,8 @@
|
||||
REFRESH
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-action">
|
||||
Download Configuration
|
||||
|
||||
<div class="libref-group">
|
||||
<clr-tooltip class="libref-tooltip">
|
||||
<label clrTooltipTrigger class="libref-label">
|
||||
@@ -251,7 +245,6 @@
|
||||
contain this, instead of the original.
|
||||
</clr-tooltip-content>
|
||||
</clr-tooltip>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="clr-input libref-input"
|
||||
@@ -269,15 +262,13 @@
|
||||
DOWNLOAD
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-action">
|
||||
Update Licence Key
|
||||
|
||||
<button routerLink="/licensing/update" class="btn btn-info btn-sm">
|
||||
UPDATE
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,210 +1,257 @@
|
||||
<app-sidebar class="sidebar-height">
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngIf="users" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="Filter by Users"
|
||||
name="input"
|
||||
[(ngModel)]="userSearch"
|
||||
(keyup)="userListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchLibTreeInput.value.length > 0"
|
||||
(click)="userSearch = ''; userListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
<ng-container *ngFor="let user of users">
|
||||
<clr-tree-node
|
||||
(click)="userOnClick(user)"
|
||||
*ngIf="!user['hidden']"
|
||||
[class.active]="userInfo?.URI === user.URI"
|
||||
>
|
||||
<p class="m-0 cursor-pointer list-padding">
|
||||
<clr-icon shape="user"></clr-icon>
|
||||
{{ user.NAME }}
|
||||
</p>
|
||||
@if (users) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLibTreeInput
|
||||
placeholder="Filter by Users"
|
||||
name="input"
|
||||
[(ngModel)]="userSearch"
|
||||
(keyup)="userListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchLibTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchLibTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
(click)="userSearch = ''; userListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
</ng-container>
|
||||
}
|
||||
@for (user of users; track user) {
|
||||
@if (!user['hidden']) {
|
||||
<clr-tree-node
|
||||
(click)="userOnClick(user)"
|
||||
[class.active]="userInfo?.URI === user.URI"
|
||||
>
|
||||
<p class="m-0 cursor-pointer list-padding">
|
||||
<clr-icon shape="user"></clr-icon>
|
||||
{{ user.NAME }}
|
||||
</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="userData && !loading">
|
||||
<div *ngIf="serverType !== ServerType.SasViya" class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<table class="table user-info">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<p class="user-info-text">
|
||||
<b>{{ userInfo.NAME }}</b>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<p class="user-info-text">
|
||||
<b>{{ userInfo.DISPLAYNAME }}</b>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<ng-container *ngIf="serverType === ServerType.Sas9">
|
||||
<tr>
|
||||
<td class="left">
|
||||
Metadata Created: <b>{{ userInfo.METADATACREATED }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left">
|
||||
Metadata Updated: <b>{{ userInfo.METADATAUPDATED }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (loading) {
|
||||
<div class="loadingSpinner">
|
||||
<span class="spinner"> Loading... </span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div *ngIf="serverType === ServerType.SasViya && userInfo" class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<table class="table user-info">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<p class="user-info-text">
|
||||
<b>{{ userInfo.NAME }}</b>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left">
|
||||
User ID: <b>{{ userInfo.URI }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left">
|
||||
User provider ID: <b>{{ userInfo.PROVIDER }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<div class="card user-data">
|
||||
<div *ngIf="serverType === ServerType.Sas9">
|
||||
<h3>EMAILS ({{ userEmailsCount || '0' }})</h3>
|
||||
<h5 *ngIf="userEmailsCount == 0">No Emails Present</h5>
|
||||
<div class="table-container">
|
||||
<table *ngIf="userEmailsCount != 0" class="table user-table">
|
||||
<thead>
|
||||
@if (userData && !loading) {
|
||||
<div>
|
||||
@if (serverType !== ServerType.SasViya) {
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<table class="table user-info">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<p class="user-info-text">
|
||||
<b>{{ userInfo.NAME }}</b>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<p class="user-info-text">
|
||||
<b>{{ userInfo.DISPLAYNAME }}</b>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
@if (serverType === ServerType.Sas9) {
|
||||
<tr>
|
||||
<td class="width-50"><b>EMAIL</b></td>
|
||||
<td class=""><b>TYPE</b></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let email of userEmails">
|
||||
<td class="">{{ email.EMAIL }}</td>
|
||||
<td class="">{{ email.TYPE }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<hr *ngIf="serverType === ServerType.Sas9" />
|
||||
<div>
|
||||
<h3>GROUPS ({{ userGroupsCount || '0' }})</h3>
|
||||
<h5 *ngIf="userGroupsCount == 0">No Groups Present</h5>
|
||||
<div class="table-container">
|
||||
<table *ngIf="userGroupsCount != 0" class="table user-table">
|
||||
<tbody>
|
||||
<tr *ngFor="let group of userGroups">
|
||||
<td
|
||||
*ngIf="serverType === ServerType.SasViya"
|
||||
[routerLink]="'/view/usernav/groups/' + group.URI"
|
||||
routerLinkActive="active"
|
||||
class=""
|
||||
>
|
||||
{{ group.GROUPNAME }}
|
||||
</td>
|
||||
<ng-container *ngIf="serverType !== ServerType.SasViya">
|
||||
<td
|
||||
[routerLink]="
|
||||
'/view/usernav/groups/' +
|
||||
(group.GROUPURI || group.URI)
|
||||
"
|
||||
routerLinkActive="active"
|
||||
class=""
|
||||
>
|
||||
{{ group.GROUPNAME }}
|
||||
</td>
|
||||
</ng-container>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<hr *ngIf="serverType === ServerType.Sas9" />
|
||||
<div *ngIf="serverType === ServerType.Sas9">
|
||||
<h3>ROLES ({{ userRolesCount || '0' }})</h3>
|
||||
<h5 *ngIf="userRolesCount == 0">No Roles Present</h5>
|
||||
<div class="table-container">
|
||||
<table *ngIf="userRolesCount != 0" class="table user-table">
|
||||
<tbody>
|
||||
<tr *ngFor="let role of userRoles">
|
||||
<td
|
||||
[routerLink]="
|
||||
'/view/usernav/roles/' + getRoleURI(role.ROLEURI)
|
||||
"
|
||||
>
|
||||
{{ role.ROLENAME }}
|
||||
<td class="left">
|
||||
Metadata Created: <b>{{ userInfo.METADATACREATED }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<hr *ngIf="serverType === ServerType.Sas9" />
|
||||
<div *ngIf="serverType === ServerType.Sas9">
|
||||
<h3>LOGINS ({{ userLoginsCount || '0' }})</h3>
|
||||
<h5 *ngIf="userLoginsCount == 0">No Logins Present</h5>
|
||||
<div class="table-container">
|
||||
<table *ngIf="userLoginsCount != 0" class="table user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="width-50"><b>DOMAIN</b></td>
|
||||
<td><b>USER ID</b></td>
|
||||
<td class="left">
|
||||
Metadata Updated: <b>{{ userInfo.METADATAUPDATED }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let login of userLogins">
|
||||
<td class="">{{ login.DOMAIN }}</td>
|
||||
<td class="">{{ login.USERID }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (serverType === ServerType.SasViya && userInfo) {
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<table class="table user-info">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="left">
|
||||
<p class="user-info-text">
|
||||
<b>{{ userInfo.NAME }}</b>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left">
|
||||
User ID: <b>{{ userInfo.URI }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left">
|
||||
User provider ID: <b>{{ userInfo.PROVIDER }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="clr-row">
|
||||
<div class="clr-col-8">
|
||||
<div class="card user-data">
|
||||
@if (serverType === ServerType.Sas9) {
|
||||
<div>
|
||||
<h3>EMAILS ({{ userEmailsCount || '0' }})</h3>
|
||||
@if (userEmailsCount == 0) {
|
||||
<h5>No Emails Present</h5>
|
||||
}
|
||||
<div class="table-container">
|
||||
@if (userEmailsCount != 0) {
|
||||
<table class="table user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="width-50"><b>EMAIL</b></td>
|
||||
<td class=""><b>TYPE</b></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (email of userEmails; track email) {
|
||||
<tr>
|
||||
<td class="">{{ email.EMAIL }}</td>
|
||||
<td class="">{{ email.TYPE }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (serverType === ServerType.Sas9) {
|
||||
<hr />
|
||||
}
|
||||
<div>
|
||||
<h3>GROUPS ({{ userGroupsCount || '0' }})</h3>
|
||||
@if (userGroupsCount == 0) {
|
||||
<h5>No Groups Present</h5>
|
||||
}
|
||||
<div class="table-container">
|
||||
@if (userGroupsCount != 0) {
|
||||
<table class="table user-table">
|
||||
<tbody>
|
||||
@for (group of userGroups; track group) {
|
||||
<tr>
|
||||
@if (serverType === ServerType.SasViya) {
|
||||
<td
|
||||
[routerLink]="'/view/usernav/groups/' + group.URI"
|
||||
routerLinkActive="active"
|
||||
class=""
|
||||
>
|
||||
{{ group.GROUPNAME }}
|
||||
</td>
|
||||
}
|
||||
@if (serverType !== ServerType.SasViya) {
|
||||
<td
|
||||
[routerLink]="
|
||||
'/view/usernav/groups/' +
|
||||
(group.GROUPURI || group.URI)
|
||||
"
|
||||
routerLinkActive="active"
|
||||
class=""
|
||||
>
|
||||
{{ group.GROUPNAME }}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (serverType === ServerType.Sas9) {
|
||||
<hr />
|
||||
}
|
||||
@if (serverType === ServerType.Sas9) {
|
||||
<div>
|
||||
<h3>ROLES ({{ userRolesCount || '0' }})</h3>
|
||||
@if (userRolesCount == 0) {
|
||||
<h5>No Roles Present</h5>
|
||||
}
|
||||
<div class="table-container">
|
||||
@if (userRolesCount != 0) {
|
||||
<table class="table user-table">
|
||||
<tbody>
|
||||
@for (role of userRoles; track role) {
|
||||
<tr>
|
||||
<td
|
||||
[routerLink]="
|
||||
'/view/usernav/roles/' +
|
||||
getRoleURI(role.ROLEURI)
|
||||
"
|
||||
>
|
||||
{{ role.ROLENAME }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (serverType === ServerType.Sas9) {
|
||||
<hr />
|
||||
}
|
||||
@if (serverType === ServerType.Sas9) {
|
||||
<div>
|
||||
<h3>LOGINS ({{ userLoginsCount || '0' }})</h3>
|
||||
@if (userLoginsCount == 0) {
|
||||
<h5>No Logins Present</h5>
|
||||
}
|
||||
<div class="table-container">
|
||||
@if (userLoginsCount != 0) {
|
||||
<table class="table user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="width-50"><b>DOMAIN</b></td>
|
||||
<td><b>USER ID</b></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (login of userLogins; track login) {
|
||||
<tr>
|
||||
<td class="">{{ login.DOMAIN }}</td>
|
||||
<td class="">{{ login.USERID }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<br />
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,107 +8,113 @@
|
||||
[(ngModel)]="collection"
|
||||
(change)="updateSelectedCollection()"
|
||||
>
|
||||
<optgroup
|
||||
*ngFor="let collection of collections | keyvalue"
|
||||
[label]="collection.key"
|
||||
>
|
||||
<option
|
||||
*ngFor="let endpoint of collections[collection.key] | keyvalue"
|
||||
value="{{ endpoint.value }}"
|
||||
>
|
||||
{{ endpoint.key }}
|
||||
</option>
|
||||
</optgroup>
|
||||
@for (collection of collections | keyvalue; track collection) {
|
||||
<optgroup [label]="collection.key">
|
||||
@for (
|
||||
endpoint of collections[collection.key] | keyvalue;
|
||||
track endpoint
|
||||
) {
|
||||
<option value="{{ endpoint.value }}">
|
||||
{{ endpoint.key }}
|
||||
</option>
|
||||
}
|
||||
</optgroup>
|
||||
}
|
||||
</select>
|
||||
</clr-select-container>
|
||||
</div>
|
||||
|
||||
<p
|
||||
*ngIf="endpointLinks.length < 1 && !collectionLoading"
|
||||
class="w-100 text-center"
|
||||
>
|
||||
Select collection to start
|
||||
</p>
|
||||
@if (endpointLinks.length < 1 && !collectionLoading) {
|
||||
<p class="w-100 text-center">Select collection to start</p>
|
||||
}
|
||||
|
||||
<div class="d-flex clr-justify-content-center">
|
||||
<clr-spinner *ngIf="collectionLoading" clrMedium></clr-spinner>
|
||||
@if (collectionLoading) {
|
||||
<clr-spinner clrMedium></clr-spinner>
|
||||
}
|
||||
</div>
|
||||
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngIf="endpointLinks.length > 0" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLinksTreeInput
|
||||
placeholder="Search links"
|
||||
name="input"
|
||||
[(ngModel)]="linksSearch"
|
||||
(keyup)="linksListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<clr-icon
|
||||
*ngIf="searchLinksTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchLinksTreeInput.value.length > 0"
|
||||
(click)="linksSearch = ''; linksListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
<ng-container *ngFor="let link of endpointLinks">
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, link, endpointLinks)"
|
||||
*ngIf="!link['hidden']"
|
||||
[(clrExpanded)]="link['expanded']"
|
||||
[clrLoading]="!!link['loadingTables'] && !link.tables"
|
||||
>
|
||||
<p (click)="linkOnClick(link)" class="m-0 cursor-pointer">
|
||||
<clr-icon shape="block"></clr-icon>
|
||||
{{ link.method }} {{ link.rel }}
|
||||
</p>
|
||||
@if (endpointLinks.length > 0) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
appStealFocus
|
||||
clrInput
|
||||
#searchLinksTreeInput
|
||||
placeholder="Search links"
|
||||
name="input"
|
||||
[(ngModel)]="linksSearch"
|
||||
(keyup)="linksListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchLinksTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchLinksTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
(click)="linksSearch = ''; linksListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
</ng-container>
|
||||
}
|
||||
@for (link of endpointLinks; track link) {
|
||||
@if (!link['hidden']) {
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, link, endpointLinks)"
|
||||
[(clrExpanded)]="link['expanded']"
|
||||
[clrLoading]="!!link['loadingTables'] && !link.tables"
|
||||
>
|
||||
<p (click)="linkOnClick(link)" class="m-0 cursor-pointer">
|
||||
<clr-icon shape="block"></clr-icon>
|
||||
{{ link.method }} {{ link.rel }}
|
||||
</p>
|
||||
</clr-tree-node>
|
||||
}
|
||||
}
|
||||
</clr-tree>
|
||||
|
||||
<clr-tree>
|
||||
<clr-tree-node *ngIf="endpointItems.length > 0" class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
clrInput
|
||||
#searchItemsTreeInput
|
||||
placeholder="Search items"
|
||||
name="input"
|
||||
[(ngModel)]="itemsSearch"
|
||||
(keyup)="itemsListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<clr-icon
|
||||
*ngIf="searchItemsTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchItemsTreeInput.value.length > 0"
|
||||
(click)="itemsSearch = ''; itemsListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
<ng-container *ngFor="let item of endpointItems">
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, item, endpointItems)"
|
||||
*ngIf="!item['hidden']"
|
||||
[(clrExpanded)]="item['expanded']"
|
||||
[clrLoading]="!!item['loadingTables'] && !item.tables"
|
||||
>
|
||||
<p (click)="itemOnClick(item)" class="m-0 cursor-pointer">
|
||||
<clr-icon shape="block"></clr-icon>
|
||||
{{ item.name || item.id }}
|
||||
</p>
|
||||
@if (endpointItems.length > 0) {
|
||||
<clr-tree-node class="search-node">
|
||||
<div class="tree-search-wrapper">
|
||||
<input
|
||||
clrInput
|
||||
#searchItemsTreeInput
|
||||
placeholder="Search items"
|
||||
name="input"
|
||||
[(ngModel)]="itemsSearch"
|
||||
(keyup)="itemsListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchItemsTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchItemsTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
(click)="itemsSearch = ''; itemsListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
</ng-container>
|
||||
}
|
||||
@for (item of endpointItems; track item) {
|
||||
@if (!item['hidden']) {
|
||||
<clr-tree-node
|
||||
(click)="treeNodeClicked($event, item, endpointItems)"
|
||||
[(clrExpanded)]="item['expanded']"
|
||||
[clrLoading]="!!item['loadingTables'] && !item.tables"
|
||||
>
|
||||
<p (click)="itemOnClick(item)" class="m-0 cursor-pointer">
|
||||
<clr-icon shape="block"></clr-icon>
|
||||
{{ item.name || item.id }}
|
||||
</p>
|
||||
</clr-tree-node>
|
||||
}
|
||||
}
|
||||
</clr-tree>
|
||||
</app-sidebar>
|
||||
|
||||
@@ -132,12 +138,14 @@
|
||||
|
||||
<h4 class="text-center m-0">
|
||||
API Explorer
|
||||
<clr-spinner *ngIf="endpointLoading" clrSmall></clr-spinner>
|
||||
@if (endpointLoading) {
|
||||
<clr-spinner clrSmall></clr-spinner>
|
||||
}
|
||||
</h4>
|
||||
</div>
|
||||
<hr class="w-100 light" />
|
||||
|
||||
<ng-container *ngIf="collection.length > 0">
|
||||
@if (collection.length > 0) {
|
||||
<div class="d-flex clr-align-items-center clr-justify-content-between">
|
||||
<span class="d-flex clr-align-items-center">
|
||||
GET
|
||||
@@ -154,7 +162,6 @@
|
||||
<clr-icon shape="copy"></clr-icon>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div class="d-flex clr-align-items-center query-params">
|
||||
<clr-input-container class="m-0">
|
||||
<label>Start</label>
|
||||
@@ -187,13 +194,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<pre class="json-area">
|
||||
<ng-container *ngIf="endpointJson">
|
||||
<ngx-json-viewer *ngIf="!rawJson" class="d-block" [json]="endpointJson" [expanded]="false" (segmentLinkClicked)="onJsonLinkClick($event)"></ngx-json-viewer>
|
||||
<ng-container *ngIf="rawJson">
|
||||
@if (endpointJson) {
|
||||
@if (!rawJson) {
|
||||
<ngx-json-viewer class="d-block" [json]="endpointJson" [expanded]="false" (segmentLinkClicked)="onJsonLinkClick($event)"></ngx-json-viewer>
|
||||
}
|
||||
@if (rawJson) {
|
||||
{{ endpointJson | prettyjson }}
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
</pre>
|
||||
</ng-container>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,9 @@
|
||||
<app-sidebar>
|
||||
<div *ngIf="xlmapsLoading" class="my-10-mx-auto text-center">
|
||||
<clr-spinner clrMedium></clr-spinner>
|
||||
</div>
|
||||
@if (xlmapsLoading) {
|
||||
<div class="my-10-mx-auto text-center">
|
||||
<clr-spinner clrMedium></clr-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
<clr-tree>
|
||||
<clr-tree-node class="search-node">
|
||||
@@ -16,19 +18,19 @@
|
||||
(keyup)="xlmapListOnFilter()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<clr-icon
|
||||
*ngIf="searchXLMapTreeInput.value.length < 1"
|
||||
shape="search"
|
||||
></clr-icon>
|
||||
<clr-icon
|
||||
*ngIf="searchXLMapTreeInput.value.length > 0"
|
||||
(click)="searchString = ''; xlmapListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
@if (searchXLMapTreeInput.value.length < 1) {
|
||||
<clr-icon shape="search"></clr-icon>
|
||||
}
|
||||
@if (searchXLMapTreeInput.value.length > 0) {
|
||||
<clr-icon
|
||||
(click)="searchString = ''; xlmapListOnFilter()"
|
||||
shape="times"
|
||||
></clr-icon>
|
||||
}
|
||||
</div>
|
||||
</clr-tree-node>
|
||||
|
||||
<ng-container *ngFor="let xlmap of xlmaps">
|
||||
@for (xlmap of xlmaps; track xlmap) {
|
||||
<clr-tree-node>
|
||||
<button
|
||||
(click)="xlmapOnClick(xlmap)"
|
||||
@@ -39,99 +41,99 @@
|
||||
{{ xlmap.id }}
|
||||
</button>
|
||||
</clr-tree-node>
|
||||
</ng-container>
|
||||
}
|
||||
</clr-tree>
|
||||
</app-sidebar>
|
||||
|
||||
<div class="content-area">
|
||||
<div *ngIf="!selectedXLMap" class="no-table-selected">
|
||||
<img
|
||||
src="images/select-table.png"
|
||||
class="select-table-icon"
|
||||
alt="select table icon"
|
||||
/>
|
||||
<p
|
||||
*ngIf="xlmaps.length > 0"
|
||||
class="text-center color-gray mt-10"
|
||||
cds-text="section"
|
||||
@if (!selectedXLMap) {
|
||||
<div class="no-table-selected">
|
||||
<img
|
||||
src="images/select-table.png"
|
||||
class="select-table-icon"
|
||||
alt="select table icon"
|
||||
/>
|
||||
@if (xlmaps.length > 0) {
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
Please select a map
|
||||
</p>
|
||||
}
|
||||
@if (xlmaps.length < 1) {
|
||||
<p class="text-center color-gray mt-10" cds-text="section">
|
||||
No excel map is found
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isLoading) {
|
||||
<div class="loadingSpinner">
|
||||
<span class="spinner"> Loading... </span>
|
||||
<div>
|
||||
<h4>{{ isLoadingDesc }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isLoading && selectedXLMap) {
|
||||
<div
|
||||
appDragNdrop
|
||||
(fileDraggedOver)="onShowUploadModal()"
|
||||
class="card h-100 d-flex clr-flex-column"
|
||||
>
|
||||
Please select a map
|
||||
</p>
|
||||
<p
|
||||
*ngIf="xlmaps.length < 1"
|
||||
class="text-center color-gray mt-10"
|
||||
cds-text="section"
|
||||
>
|
||||
No excel map is found
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="loadingSpinner" *ngIf="isLoading">
|
||||
<span class="spinner"> Loading... </span>
|
||||
<div>
|
||||
<h4>{{ isLoadingDesc }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
appDragNdrop
|
||||
(fileDraggedOver)="onShowUploadModal()"
|
||||
class="card h-100 d-flex clr-flex-column"
|
||||
*ngIf="!isLoading && selectedXLMap"
|
||||
>
|
||||
<clr-tabs>
|
||||
<clr-tab>
|
||||
<button clrTabLink (click)="selectedTab = TabsEnum.Rules">Rules</button>
|
||||
<clr-tab-content *clrIfActive="selectedTab === TabsEnum.Rules">
|
||||
</clr-tab-content>
|
||||
</clr-tab>
|
||||
<clr-tab>
|
||||
<button clrTabLink (click)="selectedTab = TabsEnum.Data">Data</button>
|
||||
<clr-tab-content *clrIfActive="selectedTab === TabsEnum.Data">
|
||||
</clr-tab-content>
|
||||
</clr-tab>
|
||||
</clr-tabs>
|
||||
|
||||
<ng-container *ngTemplateOutlet="actionButtons"></ng-container>
|
||||
|
||||
<div class="clr-row m-0 mb-10-i viewerTitle">
|
||||
<h3 class="d-flex clr-col-12 clr-justify-content-center mt-5-i">
|
||||
{{ selectedXLMap.id }}
|
||||
</h3>
|
||||
<i class="d-flex clr-col-12 clr-justify-content-center mt-5-i">{{
|
||||
selectedXLMap.description
|
||||
}}</i>
|
||||
<p class="d-flex clr-col-12 clr-justify-content-center mt-5-i">
|
||||
Rules Source:
|
||||
<a
|
||||
cds-text="labelLink"
|
||||
class="ml-10"
|
||||
[routerLink]="'/view/data/' + rulesSource"
|
||||
<clr-tabs>
|
||||
<clr-tab>
|
||||
<button clrTabLink (click)="selectedTab = TabsEnum.Rules">
|
||||
Rules
|
||||
</button>
|
||||
<clr-tab-content *clrIfActive="selectedTab === TabsEnum.Rules">
|
||||
</clr-tab-content>
|
||||
</clr-tab>
|
||||
<clr-tab>
|
||||
<button clrTabLink (click)="selectedTab = TabsEnum.Data">Data</button>
|
||||
<clr-tab-content *clrIfActive="selectedTab === TabsEnum.Data">
|
||||
</clr-tab-content>
|
||||
</clr-tab>
|
||||
</clr-tabs>
|
||||
<ng-container *ngTemplateOutlet="actionButtons"></ng-container>
|
||||
<div class="clr-row m-0 mb-10-i viewerTitle">
|
||||
<h3 class="d-flex clr-col-12 clr-justify-content-center mt-5-i">
|
||||
{{ selectedXLMap.id }}
|
||||
</h3>
|
||||
<i class="d-flex clr-col-12 clr-justify-content-center mt-5-i">{{
|
||||
selectedXLMap.description
|
||||
}}</i>
|
||||
<p class="d-flex clr-col-12 clr-justify-content-center mt-5-i">
|
||||
Rules Source:
|
||||
<a
|
||||
cds-text="labelLink"
|
||||
class="ml-10"
|
||||
[routerLink]="'/view/data/' + rulesSource"
|
||||
>
|
||||
{{ rulesSource }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="d-flex clr-col-12 clr-justify-content-center mt-5-i">
|
||||
Target dataset:
|
||||
<a
|
||||
cds-text="labelLink"
|
||||
class="ml-10"
|
||||
[routerLink]="'/view/data/' + selectedXLMap.targetDS"
|
||||
>
|
||||
{{ selectedXLMap.targetDS }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="clr-flex-1">
|
||||
<hot-table
|
||||
id="hot-table"
|
||||
[data]="selectedTab === TabsEnum.Rules ? xlmapRules : xlData"
|
||||
[settings]="hotTableSettings()"
|
||||
>
|
||||
{{ rulesSource }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="d-flex clr-col-12 clr-justify-content-center mt-5-i">
|
||||
Target dataset:
|
||||
<a
|
||||
cds-text="labelLink"
|
||||
class="ml-10"
|
||||
[routerLink]="'/view/data/' + selectedXLMap.targetDS"
|
||||
>
|
||||
{{ selectedXLMap.targetDS }}
|
||||
</a>
|
||||
</p>
|
||||
</hot-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clr-flex-1">
|
||||
<hot-table
|
||||
id="hot-table"
|
||||
[data]="selectedTab === TabsEnum.Rules ? xlmapRules : xlData"
|
||||
[settings]="hotTableSettings()"
|
||||
>
|
||||
</hot-table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<clr-modal
|
||||
appFileDrop
|
||||
@@ -207,44 +209,41 @@
|
||||
|
||||
<ng-template #actionButtons>
|
||||
<div class="clr-row m-0 clr-justify-content-center">
|
||||
<div
|
||||
*ngIf="status === StatusEnum.ReadyToUpload"
|
||||
class="d-flex clr-justify-content-center clr-col-12 clr-col-lg-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-success btn-block mr-0"
|
||||
(click)="onShowUploadModal()"
|
||||
>
|
||||
<clr-icon shape="upload"></clr-icon>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="status === StatusEnum.ReadyToSubmit"
|
||||
class="d-flex clr-justify-content-center clr-col-12 clr-col-lg-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-success btn-block mr-0"
|
||||
(click)="submitExcel()"
|
||||
>
|
||||
<clr-icon shape="upload"></clr-icon>
|
||||
<span>Submit</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="status === StatusEnum.ReadyToSubmit"
|
||||
class="d-flex clr-justify-content-center clr-col-12 clr-col-lg-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-danger btn-block mr-0"
|
||||
(click)="discardExtractedData()"
|
||||
>
|
||||
<clr-icon shape="times"></clr-icon>
|
||||
<span>Discard</span>
|
||||
</button>
|
||||
</div>
|
||||
@if (status === StatusEnum.ReadyToUpload) {
|
||||
<div class="d-flex clr-justify-content-center clr-col-12 clr-col-lg-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-success btn-block mr-0"
|
||||
(click)="onShowUploadModal()"
|
||||
>
|
||||
<clr-icon shape="upload"></clr-icon>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (status === StatusEnum.ReadyToSubmit) {
|
||||
<div class="d-flex clr-justify-content-center clr-col-12 clr-col-lg-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-success btn-block mr-0"
|
||||
(click)="submitExcel()"
|
||||
>
|
||||
<clr-icon shape="upload"></clr-icon>
|
||||
<span>Submit</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (status === StatusEnum.ReadyToSubmit) {
|
||||
<div class="d-flex clr-justify-content-center clr-col-12 clr-col-lg-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-danger btn-block mr-0"
|
||||
(click)="discardExtractedData()"
|
||||
>
|
||||
<clr-icon shape="times"></clr-icon>
|
||||
<span>Discard</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
+971
-926
File diff suppressed because it is too large.
Load diff
@@ -9,11 +9,10 @@
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"lib": ["ES2022", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"module": "ES2022",
|
||||
"importHelpers": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "ES2022",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dcfrontend",
|
||||
"version": "7.10.0",
|
||||
"version": "7.11.0",
|
||||
"description": "Data Controller",
|
||||
"devDependencies": {
|
||||
"@saithodev/semantic-release-gitea": "^2.1.0",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"fromjs": [
|
||||
{
|
||||
"ADMIN": "AllUsers",
|
||||
"DCPATH": "/opt/data/DataController"
|
||||
"DCPATH": "/opt/data/DataController",
|
||||
"OUTFMT": "JSON"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
"fromjs": [
|
||||
{
|
||||
"ADMIN": "AllUsers",
|
||||
"DCPATH": "/tmp/dcdata"
|
||||
"DCPATH": "/tmp/dcdata",
|
||||
"OUTFMT": "JSON"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,9 @@ function makeRows(n) {
|
||||
READONLY_COL: "Readonly default",
|
||||
HIDDEN_COL: "Hidden default",
|
||||
ROUND_COL: Number((i + 1 + i / 7).toFixed(5)), // rounds on edit
|
||||
NUMFMT_COL: 1000 + i * 12.5 // shown as EUR
|
||||
NUMFMT_COL: 1000 + i * 12.5, // shown as EUR
|
||||
REGEX_HARD_COL: "user@example.com", // HARDREGEX: email — starts valid
|
||||
REGEX_SOFT_COL: "SW1A 1AA" // SOFTREGEX: UK postcode — starts valid
|
||||
})
|
||||
}
|
||||
return rows
|
||||
@@ -231,6 +233,30 @@ let webouts = {
|
||||
DESC: "Number format: displayed as EUR currency (value unchanged)",
|
||||
LONGDESC: "",
|
||||
COLTYPE: "{\"data\":\"NUMFMT_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
|
||||
},
|
||||
{
|
||||
NAME: "REGEX_HARD_COL",
|
||||
VARNUM: 15,
|
||||
LABEL: "REGEX_HARD_COL",
|
||||
FMTNAME: "",
|
||||
DDTYPE: "CHARACTER",
|
||||
CLS_RULE: "READ",
|
||||
MEMLABEL: "",
|
||||
DESC: "HARDREGEX: must be a valid email address or submission is blocked",
|
||||
LONGDESC: "",
|
||||
COLTYPE: "{\"data\":\"REGEX_HARD_COL\"}"
|
||||
},
|
||||
{
|
||||
NAME: "REGEX_SOFT_COL",
|
||||
VARNUM: 16,
|
||||
LABEL: "REGEX_SOFT_COL",
|
||||
FMTNAME: "",
|
||||
DDTYPE: "CHARACTER",
|
||||
CLS_RULE: "READ",
|
||||
MEMLABEL: "",
|
||||
DESC: "SOFTREGEX: should be a valid UK postcode, shown as a yellow warning if not, but can still submit",
|
||||
LONGDESC: "",
|
||||
COLTYPE: "{\"data\":\"REGEX_SOFT_COL\"}"
|
||||
}
|
||||
],
|
||||
dqdata: [
|
||||
@@ -254,7 +280,9 @@ let webouts = {
|
||||
{ BASE_COL: "READONLY_COL", RULE_TYPE: "READONLY", RULE_VALUE: "Readonly default" },
|
||||
{ BASE_COL: "HIDDEN_COL", RULE_TYPE: "HIDDEN", RULE_VALUE: "Hidden default" },
|
||||
{ BASE_COL: "ROUND_COL", RULE_TYPE: "ROUND", RULE_VALUE: "2" },
|
||||
{ BASE_COL: "NUMFMT_COL", RULE_TYPE: "NUMBER_FORMAT", RULE_VALUE: '{"style":"currency","currency":"EUR"}' }
|
||||
{ BASE_COL: "NUMFMT_COL", RULE_TYPE: "NUMBER_FORMAT", RULE_VALUE: '{"style":"currency","currency":"EUR"}' },
|
||||
{ BASE_COL: "REGEX_HARD_COL", RULE_TYPE: "HARDREGEX", RULE_VALUE: "/[\\w.]+@[\\w]+\\.[a-z]{2,}/" },
|
||||
{ BASE_COL: "REGEX_SOFT_COL", RULE_TYPE: "SOFTREGEX", RULE_VALUE: "/[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/" }
|
||||
],
|
||||
dsmeta: [
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Data Set Name", VALUE: "DC996664.MPE_X_TEST" },
|
||||
@@ -305,7 +333,9 @@ let webouts = {
|
||||
{ NAME: "readonly_col", MAXLEN: 16 },
|
||||
{ NAME: "hidden_col", MAXLEN: 14 },
|
||||
{ NAME: "round_col", MAXLEN: 8 },
|
||||
{ NAME: "numfmt_col", MAXLEN: 8 }
|
||||
{ NAME: "numfmt_col", MAXLEN: 8 },
|
||||
{ NAME: "regex_hard_col", MAXLEN: 128 },
|
||||
{ NAME: "regex_soft_col", MAXLEN: 128 }
|
||||
],
|
||||
query: [],
|
||||
sasdata: makeRows(100),
|
||||
@@ -325,12 +355,14 @@ let webouts = {
|
||||
READONLY_COL: { format: "$200.", label: "READONLY_COL", length: "200", type: "char" },
|
||||
HIDDEN_COL: { format: "$200.", label: "HIDDEN_COL", length: "200", type: "char" },
|
||||
ROUND_COL: { format: "best.", label: "ROUND_COL", length: "8", type: "num" },
|
||||
NUMFMT_COL: { format: "best.", label: "NUMFMT_COL", length: "8", type: "num" }
|
||||
NUMFMT_COL: { format: "best.", label: "NUMFMT_COL", length: "8", type: "num" },
|
||||
REGEX_HARD_COL: { format: "$128.", label: "REGEX_HARD_COL", length: "128", type: "char" },
|
||||
REGEX_SOFT_COL: { format: "$128.", label: "REGEX_SOFT_COL", length: "128", type: "char" }
|
||||
}
|
||||
},
|
||||
sasparams: [
|
||||
{
|
||||
COLHEADERS: "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,SOME_HARDSELECT,SOME_NUM,SOME_DATE,SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM,READONLY_COL,HIDDEN_COL,ROUND_COL,NUMFMT_COL",
|
||||
COLHEADERS: "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,SOME_HARDSELECT,SOME_NUM,SOME_DATE,SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM,READONLY_COL,HIDDEN_COL,ROUND_COL,NUMFMT_COL,REGEX_HARD_COL,REGEX_SOFT_COL",
|
||||
FILTER_TEXT: FILTER_TEXT,
|
||||
PKCNT: 1,
|
||||
PK: "PRIMARY_KEY_FIELD",
|
||||
@@ -1519,7 +1551,7 @@ let webouts = {
|
||||
// MPE_X_TEST keeps matching the excel upload fixtures (which predate these
|
||||
// columns). The demo tables are built lazily (below) so we don't clone the large
|
||||
// MPE_X_TEST payload unless MPE_X_NEW is actually requested.
|
||||
const RULE_DEMO_COLS = ['SOME_HARDSELECT', 'READONLY_COL', 'HIDDEN_COL', 'ROUND_COL', 'NUMFMT_COL']
|
||||
const RULE_DEMO_COLS = ['SOME_HARDSELECT', 'READONLY_COL', 'HIDDEN_COL', 'ROUND_COL', 'NUMFMT_COL', 'REGEX_HARD_COL', 'REGEX_SOFT_COL']
|
||||
|
||||
function stripRuleCols(t) {
|
||||
const uc = new Set(RULE_DEMO_COLS)
|
||||
|
||||
Generated
+95
-102
@@ -6,8 +6,8 @@
|
||||
"": {
|
||||
"name": "dc-sas",
|
||||
"dependencies": {
|
||||
"@sasjs/cli": "4.18.0",
|
||||
"@sasjs/core": "4.67.3"
|
||||
"@sasjs/cli": "4.18.4",
|
||||
"@sasjs/core": "4.68.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
@@ -124,7 +124,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -147,7 +146,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -202,65 +200,30 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/adapter": {
|
||||
"version": "4.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.17.1.tgz",
|
||||
"integrity": "sha512-MxTvAoVnr+sBmKtB+CHxDiwvsqfzBqQ3BT/Q2Of6g7WWzJRBvpJcpf6C9R80Uq/cxvEie9JAM3EfExtFEYlcLg==",
|
||||
"version": "4.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/adapter/-/adapter-4.17.2.tgz",
|
||||
"integrity": "sha512-VfQgTPgHxfnuGyXyPW1VaVwqY3KaUnllte212F2cxceXcUGfCVT6CpDn1GlYQgOyF7W1fsrmnrzc7j+xLxjlyA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@sasjs/utils": "^3.5.6",
|
||||
"axios": "1.16.0",
|
||||
"axios": "1.18.1",
|
||||
"axios-cookiejar-support": "5.0.5",
|
||||
"form-data": "4.0.6",
|
||||
"https": "1.0.0",
|
||||
"tough-cookie": "4.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/adapter/node_modules/@sasjs/utils": {
|
||||
"version": "3.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-3.5.8.tgz",
|
||||
"integrity": "sha512-rR2nCJG5AsuLj+nonpVO4PvA5OW8pUhDivY/25E4PCrQYLAmOaWQyIRne1gvSLL5ELzsOwRZWz6Zf6a02uNayA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fast-csv/format": "4.3.5",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/prompts": "2.0.13",
|
||||
"chalk": "4.1.1",
|
||||
"cli-table": "0.3.6",
|
||||
"consola": "2.15.0",
|
||||
"find": "0.3.0",
|
||||
"fs-extra": "11.3.0",
|
||||
"jwt-decode": "3.1.2",
|
||||
"prompts": "2.4.1",
|
||||
"valid-url": "1.0.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/adapter/node_modules/chalk": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
|
||||
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/cli": {
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.18.0.tgz",
|
||||
"integrity": "sha512-QFsLS9XXF64x9w51kYkmx3ZUWCA9ycVDeLIaNJpHcaTu6jgPRAVHQ61ShXBKlYp/Vrze9pNapW43iaXt7o6VEA==",
|
||||
"version": "4.18.4",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/cli/-/cli-4.18.4.tgz",
|
||||
"integrity": "sha512-ZxAycth1GDqTsWPjDIx0uifdhmXsgC5LFx4SdujpThPK1bf5MNGLn4h2zqlBKlLTEWKn5UPXVXMDD8ktVn9wtg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@sasjs/adapter": "4.17.1",
|
||||
"@sasjs/core": "4.67.3",
|
||||
"@sasjs/adapter": "4.17.2",
|
||||
"@sasjs/core": "4.68.1",
|
||||
"@sasjs/lint": "2.4.3",
|
||||
"@sasjs/utils": "3.5.8",
|
||||
"adm-zip": "0.5.10",
|
||||
"@sasjs/utils": "3.5.9",
|
||||
"adm-zip": "0.6.0",
|
||||
"chalk": "4.1.2",
|
||||
"dotenv": "16.0.3",
|
||||
"find": "0.3.0",
|
||||
@@ -281,10 +244,34 @@
|
||||
"sasjs": "build/index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/cli/node_modules/@sasjs/utils": {
|
||||
"version": "3.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-3.5.8.tgz",
|
||||
"integrity": "sha512-rR2nCJG5AsuLj+nonpVO4PvA5OW8pUhDivY/25E4PCrQYLAmOaWQyIRne1gvSLL5ELzsOwRZWz6Zf6a02uNayA==",
|
||||
"node_modules/@sasjs/cli/node_modules/@sasjs/core": {
|
||||
"version": "4.68.1",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.68.1.tgz",
|
||||
"integrity": "sha512-BjoOzV7h7guCShW4JsFHvB1NNLCKHhPI5708OtEk29itQbgVCMRlEXKFRbnMiuNklktDQDi8dR4XZZ0aK7/4Wg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sasjs/core": {
|
||||
"version": "4.68.2",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.68.2.tgz",
|
||||
"integrity": "sha512-flbx36sNN7PvEIjX8H6UuGIKMQ8Ra3ZHEze9O66Wtk6cU+T0RV1i199LYXVumkja/VXSW334HlNpiSVR4o9qqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sasjs/lint": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-2.4.3.tgz",
|
||||
"integrity": "sha512-J0fYJmDfnhXDeMkZI8vHo8zjIsByIPcKHSQbiQEuRMXbNJXjqgTqoCpN0L6SlhyrrXUtMQvk/XErJ3OQGVEjrA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sasjs/utils": "3.5.2",
|
||||
"ignore": "5.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/lint/node_modules/@sasjs/utils": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-3.5.2.tgz",
|
||||
"integrity": "sha512-LBpBDx0T7G/eO15Gb+r3DR1LfBnoqagWT3HiHabojFziA4ej4ePORo8chrk0zHLIzrjI2ljAnDabyJEwC5KtIA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fast-csv/format": "4.3.5",
|
||||
@@ -300,7 +287,7 @@
|
||||
"valid-url": "1.0.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/cli/node_modules/@sasjs/utils/node_modules/chalk": {
|
||||
"node_modules/@sasjs/lint/node_modules/chalk": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
|
||||
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
|
||||
@@ -316,28 +303,10 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/core": {
|
||||
"version": "4.67.3",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.67.3.tgz",
|
||||
"integrity": "sha512-7s4UeVN4slF1URo1C5/UnNVv+TonUg+vBktFRiFhcbFYr3RnfXyClBPED6nQOF8AFd2xdXEss58hsCeIebr4lg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sasjs/lint": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/lint/-/lint-2.4.3.tgz",
|
||||
"integrity": "sha512-J0fYJmDfnhXDeMkZI8vHo8zjIsByIPcKHSQbiQEuRMXbNJXjqgTqoCpN0L6SlhyrrXUtMQvk/XErJ3OQGVEjrA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sasjs/utils": "3.5.2",
|
||||
"ignore": "5.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@sasjs/utils": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-3.5.2.tgz",
|
||||
"integrity": "sha512-LBpBDx0T7G/eO15Gb+r3DR1LfBnoqagWT3HiHabojFziA4ej4ePORo8chrk0zHLIzrjI2ljAnDabyJEwC5KtIA==",
|
||||
"hasInstallScript": true,
|
||||
"version": "3.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-3.5.9.tgz",
|
||||
"integrity": "sha512-Qg4N8LoR51vN8WyYUap2OyvJi+6woJk7ph5ylbFVomO9CM+7KIvkMJLFAtwmKSccWPn+FSy2Lhi39pQ/HA1dug==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fast-csv/format": "4.3.5",
|
||||
@@ -414,12 +383,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz",
|
||||
"integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
|
||||
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
"node": ">=14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
@@ -462,14 +431,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
|
||||
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
@@ -887,9 +856,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -1248,16 +1217,28 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/human-signals": {
|
||||
@@ -1473,10 +1454,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
|
||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
@@ -1654,9 +1648,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2156,7 +2150,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
|
||||
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
@@ -2328,9 +2321,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
+2
-2
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@sasjs/cli": "4.18.0",
|
||||
"@sasjs/core": "4.67.3"
|
||||
"@sasjs/cli": "4.18.4",
|
||||
"@sasjs/core": "4.68.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
@file
|
||||
@brief migration script to move to v7.11 of Data Controller
|
||||
|
||||
OPTIONAL CHANGE - upload additional validation rules (HARDREGEX, SOFTREGEX)
|
||||
into the RULE_TYPE dropdown (data only)
|
||||
|
||||
**/
|
||||
|
||||
%let dclib=YOURDCLIB;
|
||||
|
||||
libname &dclib "/YOUR/DATACONTROLLER/LIBRARY/PATH";
|
||||
|
||||
|
||||
/* add new validation rules */
|
||||
proc sql noprint;
|
||||
select max(selectbox_rk) into: maxrk
|
||||
from &dclib..mpe_selectbox;
|
||||
insert into &dclib..mpe_selectbox set
|
||||
selectbox_rk=&maxrk+1
|
||||
,ver_from_dttm=0
|
||||
,select_lib="&dclib"
|
||||
,select_ds="MPE_VALIDATIONS"
|
||||
,base_column="RULE_TYPE"
|
||||
,selectbox_value='HARDREGEX'
|
||||
,selectbox_order=13
|
||||
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||
insert into &dclib..mpe_selectbox set
|
||||
selectbox_rk=&maxrk+2
|
||||
,ver_from_dttm=0
|
||||
,select_lib="&dclib"
|
||||
,select_ds="MPE_VALIDATIONS"
|
||||
,base_column="RULE_TYPE"
|
||||
,selectbox_value='SOFTREGEX'
|
||||
,selectbox_order=14
|
||||
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||
quit;
|
||||
|
||||
|
||||
/*
|
||||
Update the MPE_SECURITY LIBREF validation from HARDSELECT on MPE_TABLES
|
||||
(EDITABLE libraries only) to the SOFTSELECT_HOOK, which lists ALL libraries
|
||||
(services/validations/libraries_all)
|
||||
*/
|
||||
proc sql noprint;
|
||||
/* close out the current record */
|
||||
update &dclib..mpe_validations set
|
||||
tx_to=%sysfunc(datetime())
|
||||
where base_lib="&dclib"
|
||||
and base_ds="MPE_SECURITY"
|
||||
and base_col="LIBREF"
|
||||
and rule_type='HARDSELECT'
|
||||
and tx_to='31DEC5999:23:59:59'dt;
|
||||
/* insert the new version */
|
||||
insert into &dclib..mpe_validations set
|
||||
tx_from=%sysfunc(datetime())
|
||||
,base_lib="&dclib"
|
||||
,base_ds="MPE_SECURITY"
|
||||
,base_col="LIBREF"
|
||||
,rule_type='SOFTSELECT_HOOK'
|
||||
,rule_value="services/validations/libraries_all"
|
||||
,rule_active=1
|
||||
,tx_to='31DEC5999:23:59:59'dt;
|
||||
quit;
|
||||
@@ -35,6 +35,7 @@
|
||||
@li mf_getuniquename.sas
|
||||
@li mf_getuser.sas
|
||||
@li mf_getvartype.sas
|
||||
@li mf_mkdir.sas
|
||||
@li mp_lockanytable.sas
|
||||
@li dc_assignlib.sas
|
||||
|
||||
@@ -136,6 +137,7 @@ run;
|
||||
libname &tmplib (work);
|
||||
%end;
|
||||
%else %do;
|
||||
%mf_mkdir(&AUDITFOLDER)
|
||||
filename tmp "&AUDITFOLDER/deleterecords.sas" lrecl=10000;
|
||||
libname &tmplib "&AUDITFOLDER";
|
||||
%end;
|
||||
|
||||
@@ -111,6 +111,7 @@ Areas for optimisation
|
||||
@li mp_lockfilecheck.sas
|
||||
@li mp_retainedkey.sas
|
||||
@li mp_storediffs.sas
|
||||
@li mp_rowhash.sas
|
||||
|
||||
@version 9.3
|
||||
@author 4GL Apps Ltd.
|
||||
@@ -193,7 +194,7 @@ Areas for optimisation
|
||||
|
||||
/* when changing this macro, update the version num here */
|
||||
%local ver;
|
||||
%let ver=32;
|
||||
%let ver=33;
|
||||
%put &sysmacroname entry vars:;
|
||||
%put _local_;
|
||||
|
||||
@@ -246,7 +247,7 @@ run;
|
||||
)
|
||||
|
||||
|
||||
%local cols idx_pk md5_col ;
|
||||
%local cols idx_pk md5_col hash_char_vars hash_num_vars ;
|
||||
%let md5_col=___TMP___md5;
|
||||
%let check_uniqueness=%upcase(&check_uniqueness);
|
||||
%let RK_UPDATE_MAXKEYTABLE=%upcase(&RK_UPDATE_MAXKEYTABLE);
|
||||
@@ -321,14 +322,20 @@ select name into: cols separated by ','
|
||||
(%upcase("&bus_from","&bus_to"
|
||||
,"&tech_from","&tech_to"
|
||||
,"&processed","&delete_col")) ;
|
||||
select case when type in (2,6) then cats('put(md5(trim(',name,')),$hex32.)')
|
||||
/* multiply by 1 to strip precision errors (eg 0 != 0) */
|
||||
/* but ONLY if not missing, else will lose any special missing values */
|
||||
else cats('put(md5(trim(put(ifn(missing('
|
||||
,name,'),',name,',',name,'*1),binary64.))),$hex32.)') end
|
||||
into: stripcols separated by '||'
|
||||
/* Character variables are hashed separately from numerics so that the
|
||||
iterative hash can be built with arrays rather than by concatenating
|
||||
hundreds of 32-byte hex strings. */
|
||||
select name into: hash_char_vars separated by ' '
|
||||
from work.bitemp_cols
|
||||
where upcase(name) not in
|
||||
where type in (2,6)
|
||||
and upcase(name) not in
|
||||
(%upcase("&bus_from","&bus_to"
|
||||
,"&tech_from","&tech_to"
|
||||
,"&processed","&delete_col")) ;
|
||||
select name into: hash_num_vars separated by ' '
|
||||
from work.bitemp_cols
|
||||
where type not in (2,6)
|
||||
and upcase(name) not in
|
||||
(%upcase("&bus_from","&bus_to"
|
||||
,"&tech_from","&tech_to"
|
||||
,"&processed","&delete_col")) ;
|
||||
@@ -342,7 +349,8 @@ select case when type in (2,6) then cats('put(md5(trim(',name,')),$hex32.)')
|
||||
%let tech_to_fmt = format=datetime19.;
|
||||
|
||||
|
||||
%put &=stripcols;
|
||||
%put &=hash_char_vars;
|
||||
%put &=hash_num_vars;
|
||||
%put &=pk;
|
||||
|
||||
data _null_;
|
||||
@@ -368,8 +376,8 @@ data _null_;
|
||||
call symputx('processed_fmt',fmt,'L');
|
||||
run;
|
||||
|
||||
%if %index(%quote(&cols),___TMP___) %then %do;
|
||||
%let msg=%str(Table contains a variable name containing "___TMP___".%trim(
|
||||
%if %index(%quote(&cols),___TMP___) or %index(%quote(&cols),_____) %then %do;
|
||||
%let msg=%str(Table contains a variable name containing "___TMP___" or "_____".%trim(
|
||||
) This may conflict with temp variable generation!!);
|
||||
%mp_abort(msg=&msg,mac=bitemporal_dataloader);
|
||||
%let syscc=5;
|
||||
@@ -485,7 +493,10 @@ data work.bitemp0_append &keepvars &outds_del(drop=&md5_col )
|
||||
&bus_to= %unquote(&bus_to_override) ;
|
||||
%end;
|
||||
length &md5_col $32;
|
||||
&md5_col=put(md5(&stripcols),hex32.);
|
||||
%mp_rowhash(md5_col=&md5_col
|
||||
,cvars=&hash_char_vars
|
||||
,nvars=&hash_num_vars
|
||||
)
|
||||
%if %length(&processed)>0 %then %do;
|
||||
format &processed &processed_fmt;
|
||||
&processed=&now;
|
||||
@@ -772,13 +783,17 @@ or &engine_type=SNOW or &engine_type=SASIOSNF
|
||||
,msg=%str(syscc=&syscc sqlrc=&sqlrc)
|
||||
)
|
||||
|
||||
%put hashcols2=&stripcols;
|
||||
proc sql;
|
||||
create table work.bitemp1_current(drop=___TMP___NEW_FLG) as
|
||||
select *
|
||||
, put(md5(&stripcols),$hex32.) as &md5_col
|
||||
from work.bitemp0_base (drop=&md5_col)
|
||||
where ___TMP___NEW_FLG=0;
|
||||
%put hash_char_vars=&hash_char_vars;
|
||||
%put hash_num_vars=&hash_num_vars;
|
||||
data work.bitemp1_current(drop=___TMP___NEW_FLG);
|
||||
set work.bitemp0_base(drop=&md5_col);
|
||||
if ___TMP___NEW_FLG=0;
|
||||
length &md5_col $32;
|
||||
%mp_rowhash(md5_col=&md5_col
|
||||
,cvars=&hash_char_vars
|
||||
,nvars=&hash_num_vars
|
||||
)
|
||||
run;
|
||||
|
||||
/**
|
||||
* NEW records were identified in ___TMP___NEW_FLG in bitemp0_base
|
||||
@@ -1061,7 +1076,9 @@ run;
|
||||
*/
|
||||
|
||||
%if &loadtype=BITEMPORAL %then %do;
|
||||
%let cat_string=catx('|' ,&bus_from,&bus_to);
|
||||
/* For bitemporal we also include the business dates in the comparison hash.
|
||||
The business dates are passed as prefix numerics so that both lookup and
|
||||
update tables use exactly the same hashing order. */
|
||||
|
||||
data work.bitemp5a_lkp (keep=&md5_col)
|
||||
%if "%substr(&sysver,1,1)" ne "4" & "%substr(&sysver,1,1)" ne "5" %then %do;
|
||||
@@ -1069,8 +1086,10 @@ run;
|
||||
%end;
|
||||
;
|
||||
set work.bitemp0_base;
|
||||
/* for BITEMPORAL we need to compare business dates also */
|
||||
&md5_col=put(md5(&cat_string!!'|'!!&stripcols),$hex32.);
|
||||
%mp_rowhash(md5_col=&md5_col
|
||||
,cvars=&hash_char_vars
|
||||
,nvars=&bus_from &bus_to &hash_num_vars
|
||||
)
|
||||
run;
|
||||
|
||||
data bitemp5b_updates;
|
||||
@@ -1081,7 +1100,10 @@ run;
|
||||
md5_lkp.definedone();
|
||||
end;
|
||||
/* drop old md5 col as will rebuild with new business dates */
|
||||
&md5_col=put(md5(&cat_string!!'|'!!&stripcols),$hex32.) ;
|
||||
%mp_rowhash(md5_col=&md5_col
|
||||
,cvars=&hash_char_vars
|
||||
,nvars=&bus_from &bus_to &hash_num_vars
|
||||
)
|
||||
if md5_lkp.check()=0 then delete;
|
||||
run;
|
||||
|
||||
|
||||
@@ -1035,7 +1035,7 @@ run;
|
||||
set appendtable&testnum;
|
||||
/* delete even numbered observations */
|
||||
if mod(_n_,2)=0 and _n_ ne 2 then _____DELETE__THIS__RECORD_____='YES';
|
||||
else _____DELETE__THIS__RECORD_____=0;
|
||||
else _____DELETE__THIS__RECORD_____='NO';
|
||||
run;
|
||||
|
||||
%bitemporal_dataloader(dclib=work
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
*/
|
||||
%local audtab;
|
||||
proc sql noprint;
|
||||
select coalescec(audit_libds,"&dc_libref..MPE_AUDIT") into: audtab
|
||||
select coalescec(audit_libds,"&dc_libref..MPE_AUDIT") into: audtab trimmed
|
||||
from &dc_libref..MPE_TABLES
|
||||
where &dc_dttmtfmt. lt tx_to
|
||||
and libref="%scan(&libds,1,.)" and dsn="%scan(&libds,2,.)";
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
@li dc_assignlib.sas
|
||||
@li mf_getattrn.sas
|
||||
@li mf_getuser.sas
|
||||
@li mf_mkdir.sas
|
||||
@li mf_verifymacvars.sas
|
||||
@li mp_abort.sas
|
||||
@li mp_cntlout.sas
|
||||
|
||||
@@ -916,6 +916,24 @@ insert into &lib..mpe_selectbox set
|
||||
,selectbox_value="SOFTSELECT_HOOK"
|
||||
,selectbox_order=12
|
||||
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||
insert into &lib..mpe_selectbox set
|
||||
selectbox_rk=%mf_increment(rk)
|
||||
,ver_from_dttm=0
|
||||
,select_lib="&lib"
|
||||
,select_ds="MPE_VALIDATIONS"
|
||||
,base_column="RULE_TYPE"
|
||||
,selectbox_value="HARDREGEX"
|
||||
,selectbox_order=13
|
||||
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||
insert into &lib..mpe_selectbox set
|
||||
selectbox_rk=%mf_increment(rk)
|
||||
,ver_from_dttm=0
|
||||
,select_lib="&lib"
|
||||
,select_ds="MPE_VALIDATIONS"
|
||||
,base_column="RULE_TYPE"
|
||||
,selectbox_value="SOFTREGEX"
|
||||
,selectbox_order=14
|
||||
,ver_to_dttm='31DEC5999:23:59:59'dt;
|
||||
insert into &lib..mpe_selectbox set
|
||||
selectbox_rk=%mf_increment(rk)
|
||||
,ver_from_dttm=0
|
||||
@@ -1974,6 +1992,26 @@ insert into &lib..MPE_VALIDATIONS set
|
||||
,rule_value="&lib..MPE_X_TEST.SOME_BESTNUM"
|
||||
,rule_active=1
|
||||
,tx_to='31DEC5999:23:59:59'dt;
|
||||
/* test hard regex - SOME_CHAR must contain "the" or "data" */
|
||||
insert into &lib..MPE_VALIDATIONS set
|
||||
tx_from=0
|
||||
,base_lib="&lib"
|
||||
,base_ds="MPE_X_TEST"
|
||||
,base_col="SOME_CHAR"
|
||||
,rule_type='HARDREGEX'
|
||||
,rule_value='/the|data/i'
|
||||
,rule_active=1
|
||||
,tx_to='31DEC5999:23:59:59'dt;
|
||||
/* test soft regex - SOME_CHAR should contain the letter "t" */
|
||||
insert into &lib..MPE_VALIDATIONS set
|
||||
tx_from=0
|
||||
,base_lib="&lib"
|
||||
,base_ds="MPE_X_TEST"
|
||||
,base_col="SOME_CHAR"
|
||||
,rule_type='SOFTREGEX'
|
||||
,rule_value='/t/'
|
||||
,rule_active=1
|
||||
,tx_to='31DEC5999:23:59:59'dt;
|
||||
insert into &lib..MPE_VALIDATIONS set
|
||||
tx_from=0
|
||||
,base_lib="&lib"
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user