Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2411443ec7 | ||
|
|
586b9c0f1d |
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: sas
|
||||||
|
description: >
|
||||||
|
Use this skill whenever writing, modifying, or debugging SAS code in this repository — SAS
|
||||||
|
macros, sasjs services, hooks, or test files (*.sas). Triggers include: SAS macro compilation
|
||||||
|
errors, parameter parsing issues, writing sasjs tests with mp_assert / mp_assertdsobs /
|
||||||
|
mp_assertscope, macro-quoting questions (%str, %nrstr, %superq), %mp_abort usage, or unexpected
|
||||||
|
behaviour when passing free-text values (descriptions, messages) to macros. Also covers repo
|
||||||
|
conventions such as running `sasjs lint` after touching .sas files and the maximum line length
|
||||||
|
rule.
|
||||||
|
---
|
||||||
|
|
||||||
|
# SAS Development
|
||||||
|
|
||||||
|
Conventions and pitfalls for SAS code in this repository (macros in `sas/sasjs/macros/`, services
|
||||||
|
in `sas/sasjs/services/`, tests alongside them as `*.test.sas`).
|
||||||
|
|
||||||
|
## Always `%str()` free-text macro parameters
|
||||||
|
|
||||||
|
A comma inside a macro parameter value is parsed as a **parameter delimiter**. Free-text
|
||||||
|
parameters such as `desc=` (in `%mp_assert`, `%mp_assertdsobs`, `%mp_assertscope`) and `msg=` (in
|
||||||
|
`%mp_abort`) must be wrapped in `%str()` — even when they currently contain no comma, so later
|
||||||
|
edits cannot silently break the call.
|
||||||
|
|
||||||
|
Wrong — the text after the comma becomes an unexpected positional parameter and the compilation
|
||||||
|
fails (or worse, is misparsed):
|
||||||
|
|
||||||
|
```sas
|
||||||
|
%mp_assert(iftrue=(&oldrows=0),
|
||||||
|
desc=Test 2 - all staged records loaded, delete flags ignored,
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Right:
|
||||||
|
|
||||||
|
```sas
|
||||||
|
%mp_assert(iftrue=(&oldrows=0),
|
||||||
|
desc=%str(Test 2 - all staged records loaded, delete flags ignored),
|
||||||
|
outds=work.test_results
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The same applies to any macro parameter that carries user-facing text: `%mp_abort(msg=%str(...))`,
|
||||||
|
`etlsource=` values containing punctuation (use `%superq()` when passing macro variables that may
|
||||||
|
contain special characters), etc.
|
||||||
|
|
||||||
|
Related quoting rules of thumb:
|
||||||
|
|
||||||
|
- `%str()` masks commas, parentheses, semicolons and quotes at compile time — sufficient for
|
||||||
|
static text like `desc=`.
|
||||||
|
- Use `%nrstr()` if the text must also mask `%` and `&` (rare in descriptions).
|
||||||
|
- Use `%superq(var)` when forwarding a macro **variable** whose value may contain special
|
||||||
|
characters (e.g. `etlsource=` in `mpe_targetloader.sas`).
|
||||||
|
|
||||||
|
## Tests must be idempotent
|
||||||
|
|
||||||
|
A test file must pass when run repeatedly (including after a run that failed partway). Conventions:
|
||||||
|
|
||||||
|
- Start the file with `%let syscc=0;` — many macros (e.g. `%mp_lockanytable(LOCK)`) abort on entry if `&syscc>0`, and any `WARNING` in a previous test bumps `syscc` to 4.
|
||||||
|
- Clean up **all** persistent state, not just the obvious one: `MPE_TABLES` registrations, `MPE_LOCKANYTABLE` lock records (a run dying between LOCK and UNLOCK leaves a stale `LOCKED` row), physical tables in `dctest` (`proc datasets ... delete`), and global macro variables created via `select ... into:` (`%symdel`).
|
||||||
|
- Make prep defensive: delete-then-insert config records (handles leftovers from an aborted run), and recreate physical tables rather than assuming they are absent.
|
||||||
|
|
||||||
|
## Repo conventions for .sas files
|
||||||
|
|
||||||
|
- Run `npx sasjs lint` from the `sas/` directory after creating or modifying any `.sas` file; the
|
||||||
|
files you touched must have zero warnings (pre-existing warnings in other files can be ignored).
|
||||||
|
- Maximum line length is 80 characters (lint-enforced) — this is why long `msg=%str(...)` values
|
||||||
|
sometimes need shortening.
|
||||||
|
- Test files are named `<thing>.test.sas` (or `<thing>.test.N.sas`) and run via
|
||||||
|
`npm run 4gl && sasjs test -t 4gl` from `sas/` — see `.agent/docs/testing.md`.
|
||||||
|
- `sas/sasjsbuild/` is generated build output — never hand-edit it; edit sources under
|
||||||
|
`sas/sasjs/` only.
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
---
|
|
||||||
name: dc-sas
|
|
||||||
description: >
|
|
||||||
Use this skill alongside the sasjs/skills collection (sas, sasjs-core, sasjs-cli) when working on
|
|
||||||
Data Controller SAS code. Covers the repo-specific SAS knowledge that is NOT in those skills —
|
|
||||||
DC test-state conventions (MPE control tables, dctest, %let syscc) and the chunked-deploy
|
|
||||||
strategy for the huge generated build script (sasjsbuild/viya.sas). For general SAS language,
|
|
||||||
@sasjs/core macro, and @sasjs/cli guidance, rely on the sas, sasjs-core and sasjs-cli skills.
|
|
||||||
---
|
|
||||||
|
|
||||||
# SAS Development in Data Controller
|
|
||||||
|
|
||||||
The sas, sasjs-core and sasjs-cli skills cover general SAS language and SASjs conventions (macro
|
|
||||||
quoting, `%mp_assert*` testing style, `sasjs lint`, `sasjs run`, `sasjsbuild/` being generated
|
|
||||||
output). These notes only cover the parts specific to Data Controller.
|
|
||||||
|
|
||||||
|
|
||||||
## Tests must be idempotent
|
|
||||||
|
|
||||||
A test file must pass when run repeatedly (including after a run that failed partway).
|
|
||||||
|
|
||||||
- Clean up **all** persistent DC state, not just the obvious one: `MPE_TABLES` registrations,
|
|
||||||
`MPE_LOCKANYTABLE` lock records (a run dying between LOCK and UNLOCK leaves a stale `LOCKED`
|
|
||||||
row), physical tables in `dctest` (`proc datasets ... delete`), and global macro variables
|
|
||||||
created via `select ... into:` (`%symdel`).
|
|
||||||
|
|
||||||
## Chunked deploy to Viya
|
|
||||||
|
|
||||||
The full DC build script (`sasjsbuild/viya.sas`) is very large (11+ MB / 300k+ lines). The Viya
|
|
||||||
compute API can hang or fail when submitting a file this size in a single request.
|
|
||||||
|
|
||||||
**Chunked deploy.** Split the build script at `%let path=<folder>` boundaries — these are clean
|
|
||||||
section breaks between service/test folders (always preceded by blank lines, never inside a data
|
|
||||||
step). Each sub-chunk must be prepended with the shared header (the first ~3827 lines containing
|
|
||||||
macro definitions, `appLoc` setup, and logging options:
|
|
||||||
|
|
||||||
`options ps=max nonotes nosgen nomprint nomlogic nosource2 nosource noquotelenmax;`
|
|
||||||
|
|
||||||
Logging is already suppressed in the header — no extra action needed. Keep each chunk under ~1.5
|
|
||||||
MB. The `tests/macros`, `tests/services`, and streaming-app tail sections are the largest and may
|
|
||||||
need further sub-splitting at `%let service=` boundaries.
|
|
||||||
|
|
||||||
When splitting mid-folder (between services in the same folder), the sub-chunk that starts
|
|
||||||
mid-folder must re-assert `%let path=<folder>;` before its first `%let service=` line — otherwise
|
|
||||||
`&path` is unresolved and the `%mv_createwebservice(path=&appLoc/&path, ...)` call will abort with
|
|
||||||
a recursive reference error.
|
|
||||||
|
|
||||||
Deploy each chunk in order with `sasjs run <chunk>.sas -t <target>`. After all chunks are
|
|
||||||
deployed, run `sasjs request services/admin/makedata -d deploy/makeDataViya.json -t <target>` to
|
|
||||||
create the database.
|
|
||||||
|
|
||||||
**Non-fatal errors.** `ERROR: Unauthorized` on `/dataSources/providers/Compute/` lines in the
|
|
||||||
log are expected for non-admin Viya users — they come from the Data Sources API during
|
|
||||||
lineage/catalog refresh and do not affect the deploy.
|
|
||||||
|
|
||||||
## Repo conventions for .sas files
|
|
||||||
|
|
||||||
- Test files are named `<thing>.test.sas` (or `<thing>.test.N.sas`) and run via
|
|
||||||
`npm run 4gl && sasjs test -t 4gl` from `sas/` — see `.agents/docs/testing.md`.
|
|
||||||
- `sas/sasjsbuild/` is generated build output — never hand-edit it; edit sources under
|
|
||||||
`sas/sasjs/` only.
|
|
||||||
|
|
||||||
## Temp and scratch files stay out of the repo
|
|
||||||
|
|
||||||
Transient artifacts from manual test runs against sasjs-server — captured `_webout` JSON
|
|
||||||
responses (e.g. `*_out.json`), request/response logs (e.g. `*.log`), and iteration snapshots
|
|
||||||
(`vd_out2.json`, `stage_check2.json`, etc.) — must not be left in the working tree. They
|
|
||||||
clutter `git status`, are never referenced by source or tests, and easily leak local
|
|
||||||
filesystem paths (the sasjs-server session dir, `/home/.../sasjs_root/sessions/.../`) into
|
|
||||||
committed history.
|
|
||||||
|
|
||||||
Write them to a gitignored tmp location instead. The repo already provides two:
|
|
||||||
|
|
||||||
- `tmp/` (gitignored at repo root) — general scratch space.
|
|
||||||
- `sas/tmp/` (gitignored via the root `tmp/` rule) — for SAS-side artifacts.
|
|
||||||
|
|
||||||
If a helper script dumps captured output for comparison during a debug session, point it at
|
|
||||||
`tmp/` (or `sas/tmp/` for SAS-local paths) rather than alongside the tracked mocks in
|
|
||||||
`sas/mocks/`. The tracked mock fixtures live under `sas/mocks/sas9/` and
|
|
||||||
`sas/mocks/sasjs/services/`; loose `*_out.json` / `*.log` files at the `sas/mocks/` root are
|
|
||||||
not mocks, they are leftover captures — remove them with `git clean -fd sas/mocks/` or
|
|
||||||
write to `tmp/` in the first place.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
---
|
|
||||||
name: sas
|
|
||||||
description: Expert guidance for the SAS programming language — DATA step, PROC SQL, macro language, formats, ODS, and common procedures. Pure SAS syntax only, no SASjs-framework content. Use when writing, reviewing, or debugging .sas programs, or answering SAS language questions.
|
|
||||||
---
|
|
||||||
|
|
||||||
# SAS Language
|
|
||||||
|
|
||||||
Write idiomatic, production-quality SAS code. This skill covers the SAS language itself, independent of any framework.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- DATA step programming (SET/MERGE/BY, RETAIN, arrays, DO loops, hash objects, first./last. processing)
|
|
||||||
- PROC SQL (joins, subqueries, views, pass-through with CONNECT TO)
|
|
||||||
- Macro language (%macro/%mend, macro variables, %local/%global, conditional %IF logic, quoting functions %STR/%NRSTR/%BQUOTE, CALL SYMPUTX)
|
|
||||||
- Common PROCs: SORT, MEANS/SUMMARY, FREQ, TRANSPOSE, REPORT, PRINT, CONTENTS, DATASETS, FORMAT, IMPORT/EXPORT
|
|
||||||
- Formats and informats (user-defined via PROC FORMAT, picture formats)
|
|
||||||
- ODS output (HTML, PDF, RTF, Excel, OUTPUT destination)
|
|
||||||
- File I/O: LIBNAME, FILENAME, INFILE/INPUT, FILE/PUT
|
|
||||||
|
|
||||||
## Non-negotiable: no WARNINGs
|
|
||||||
|
|
||||||
Generated SAS code must run cleanly — **zero WARNINGs (and zero ERRORs) in the log**. Treat every `WARNING:` as a defect: uninitialized variables, implicit type conversions, truncation notes that should be warnings, "no observations", unresolved macro references, etc. If a warning is truly unavoidable, suppress it deliberately (e.g. an explicit option) and comment why. Likewise avoid superfluous NOTEs where reasonable.
|
|
||||||
|
|
||||||
## Style rules
|
|
||||||
|
|
||||||
These follow the @sasjs/core coding standards — apply them to all SAS code:
|
|
||||||
|
|
||||||
- One statement per line; indentation = 2 spaces, no tabs, no trailing whitespace
|
|
||||||
- Lines no longer than 80 characters; unix (LF) line endings; UTF-8
|
|
||||||
- Avoid non-ASCII / special characters entirely — maximum compatibility across SAS installations and encodings
|
|
||||||
- Always end steps with `run;`; for `proc sql` (and CAS-connected procs) `quit;` is essential to avoid `WARNING: You cannot disconnect or terminate session ...` on Viya
|
|
||||||
- All dataset references must be 2-level (`work.blah`, not `blah`) — protects against `DATASTMTCHK=ALLKEYWORDS` and an active `USER` library
|
|
||||||
- Explicit `length` / `attrib` for character variables rather than relying on defaults (avoids implicit length=8 truncation)
|
|
||||||
- Use literal suffixes for clarity (`'01JAN2020'd`, `'12:30't`)
|
|
||||||
- Prefer `proc sort` with `nodupkey` over manual dedup logic
|
|
||||||
- Macros:
|
|
||||||
- Define with parentheses, even with no parameters: `%macro x();` not `%macro x;`
|
|
||||||
- Closing `%mend;` must repeat the macro name
|
|
||||||
- Macro calls are not terminated with a semicolon: `%my_macro()` not `%my_macro();`
|
|
||||||
- Mandatory parameters positional; optional parameters keyword (`var=`) style
|
|
||||||
- Macro names lowercase, verb-noun convention
|
|
||||||
- Macro variables without trailing dot (`&var` not `&var.`) unless needed to prevent incorrect resolution
|
|
||||||
- Macro variable NAMES are case-insensitive: `&Foo`, `&FOO`, and `&foo` all resolve to the same symbol (same for `%symexist`/`%superq`/`symget` arguments, which take a NAME not a value). Don't chase case mismatches as a bug — it's never the cause.
|
|
||||||
- ALL macro variables must be `%local` unless deliberately global (globals should use an application prefix to avoid collisions); use `call symputx` (not `symput`) in DATA steps
|
|
||||||
- Comment with `/* */` inside macros (asterisk comments are compiled into the macro)
|
|
||||||
- Guard macro logic with `%length(&var)=0` checks rather than `&var=` (empty comparisons are unsafe)
|
|
||||||
- Avoid naming collisions: use `%sysfunc`-/`&syslast`-based work tables (e.g. `data &output; set &syslast; run;`) rather than hard-coded names
|
|
||||||
- No open (non-macro) conditional code: wrap platform-branching or conditionally-executed blocks (e.g. `%if %mf_getplatform()=VIYA %then %do; ... %end;`) in a `%macro ... %mend` and invoke the macro. Open `%if` at program level fails in some execution contexts (job/scheduler/test harnesses) and hides scope leaks.
|
|
||||||
|
|
||||||
## Portability awareness
|
|
||||||
|
|
||||||
- Note when code differs between SAS 9.4 and Viya (e.g. CAS actions vs procs, `proc casutil` for sashdat loading, no X command on locked-down servers)
|
|
||||||
- Avoid hard-coded physical paths and engine-specific options unless asked
|
|
||||||
- No open macro code with if/else logic: wrap branching blocks in `%macro ... %mend` and call them — open `%if`/`%else` does not behave as expected in all SAS environments.
|
|
||||||
|
|
||||||
## Common pitfalls to flag when reviewing
|
|
||||||
|
|
||||||
- Unintended many-to-many MERGEs
|
|
||||||
- Automatic variable `_ERROR_` / implicit RETAIN surprises
|
|
||||||
- Macro timing issues: referencing `¯ovar` before it exists, `%if` evaluating data-step variables (use `if`/`symget` instead)
|
|
||||||
- Automatic macro variables (`&syscc`, `&syswarningtext`, `&syserrortext`, `&sysdate`, `&sysuserid`, etc.) are READ-ONLY — attempting to overwrite them (e.g. `%let syswarningtext=;` or `call symput('syscc',...)`) raises `ERROR: Unable to assign value to a macro variable that is read only` (or similar). Never try to "clear" them.
|
|
||||||
- Truncation from implicit length=8 on first assignment
|
|
||||||
- `proc sql` cartesian product warnings
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
---
|
|
||||||
name: sasjs-adapter
|
|
||||||
description: Frontend/Node integration with SAS backends using @sasjs/adapter — configuring the SASjs class, authentication (SAS 9, Viya, SASjs server), requests with input/output tables, file upload, and session management. Use when writing TypeScript/JavaScript that calls SAS services or jobs.
|
|
||||||
---
|
|
||||||
|
|
||||||
# @sasjs/adapter
|
|
||||||
|
|
||||||
`@sasjs/adapter` is the TypeScript library for calling SAS services/jobs from browsers or Node, with a unified API across three server types: `SAS9`, `SASVIYA`, `SASJS`.
|
|
||||||
|
|
||||||
## Basic setup
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import SASjs from '@sasjs/adapter'
|
|
||||||
|
|
||||||
const sasjs = new SASjs({
|
|
||||||
serverUrl: 'https://sas.example.com',
|
|
||||||
serverType: 'SASVIYA', // SAS9 | SASVIYA | SASJS
|
|
||||||
appLoc: '/Public/app/myapp', // root folder of deployed services
|
|
||||||
contextName: 'SAS Job Execution compute context', // Viya only
|
|
||||||
debug: false
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Executing a request
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const response = await sasjs.request('services/common/getdata', {
|
|
||||||
mytable: [{ col1: 'value', col2: 42 }] // input tables as JS arrays of objects
|
|
||||||
})
|
|
||||||
// response.result contains output tables sent back from SAS (_webout JSON)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Input tables become SAS datasets via the `sasjs_tables` mechanism (work tables named after the JS keys).
|
|
||||||
- The SAS service must write JSON to `_webout` — conventionally with the `mp_jsonout` macro from @sasjs/core, wrapped in `proc stp`-style begin/end macros.
|
|
||||||
- Responses follow the `SASjsRequest`/`SASjsResponse` types; check `response.result` for tables and `response.log` where available.
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
- **SAS 9**: `sasjs.logIn(username, password)` (form-based against the stored process server). Session cookie is managed automatically.
|
|
||||||
- **Viya**: OAuth client/secret (client credentials grant) or authorization code flow; tokens are refreshed automatically. Configure via CLI (`sasjs add cred`) for Node usage.
|
|
||||||
- **SASJS server**: token-based auth against the sasjs/server API.
|
|
||||||
|
|
||||||
## Key classes / modules
|
|
||||||
|
|
||||||
- `SASjs` — main facade: `request()`, `logIn()/logOut()`, `uploadFile()`, `executeScript()`
|
|
||||||
- `SessionManager` — Viya compute session lifecycle
|
|
||||||
- `ContextManager` — Viya compute context selection
|
|
||||||
- `SASViyaApiClient` / `SAS9ApiClient` / `SASjsApiClient` — low-level per-platform clients (rarely needed directly)
|
|
||||||
- `file/` utilities — file upload to SAS (binary content handling)
|
|
||||||
|
|
||||||
## Tips
|
|
||||||
|
|
||||||
- Set `debug: true` to surface the SAS log in responses while developing.
|
|
||||||
- Always handle `response.status` / error responses — SAS-side errors (e.g. from `%mp_abort`) come back in the JSON, not necessarily as HTTP errors.
|
|
||||||
- For large payloads prefer CSV upload or streamed files over JSON input tables.
|
|
||||||
- Keep `appLoc` consistent with the `appLoc` in `sasjsconfig.json` used to deploy.
|
|
||||||
|
|
||||||
## Important: request() inputs are ALWAYS tables
|
|
||||||
|
|
||||||
Every key in the `data` object of `sasjs.request(path, data)` is serialized via the `sasjs_tables` CSV mechanism and arrives in SAS as a **work dataset named after the key** — even scalar values. You cannot pass ad-hoc macro variables this way; services must read inputs from the work table (e.g. `data _null_; set work.config; call symputx('rootdir', rootdir); run;`). Output column names in `response.result.<table>` come back UPPERCASE (SAS dataset semantics).
|
|
||||||
|
|
||||||
## Using the adapter without a bundler (zero-build / strict CSP frontends)
|
|
||||||
|
|
||||||
The package root `index.js` is a UMD bundle exposing a global `SASjs`. Pattern (from the minimal seed app):
|
|
||||||
|
|
||||||
1. `"prepare": "cp node_modules/@sasjs/adapter/index.js src/sasjs.js"` in package.json (runs on `npm i`).
|
|
||||||
2. `<script src="sasjs.js"></script>` before your app script.
|
|
||||||
3. Configure via a hidden custom element: `<sasjs serverType="SASJS" appLoc="/Public/app/myapp" debug="false"></sasjs>` and read attributes with `document.querySelector('sasjs')`. When the app is streamed by SAS itself, omit `serverUrl` — same-origin requests just work (CSP `default-src 'self'` safe).
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
---
|
|
||||||
name: sasjs-cli
|
|
||||||
description: Using the SASjs CLI (@sasjs/cli) to create, compile, build, deploy, run, and test SASjs projects against SAS 9, Viya, and SASjs server targets. Use for any `sasjs <command>` usage, CI/CD pipelines, target/auth config, sasjsconfig.json, service packs, or frontend streaming builds.
|
|
||||||
---
|
|
||||||
|
|
||||||
# @sasjs/cli
|
|
||||||
|
|
||||||
The SASjs CLI (`npm i -g @sasjs/cli`, invoked as `sasjs`) automates compiling, building, and deploying SAS projects. All commands support `-t <target>` to select a target from `sasjsconfig.json`.
|
|
||||||
|
|
||||||
## Targets and auth
|
|
||||||
|
|
||||||
- A **target** = `{ name, serverUrl, serverType, appLoc }`. `serverType` is one of `SAS9`, `SASVIYA`, `SASJS`.
|
|
||||||
- Credentials: `sasjs add cred` (or `.env` file). Viya uses client/secret **or** `sasjs auth login` (user/pass, no client/secret needed — see below); SAS 9 uses user/pass; SASJS server uses an access token.
|
|
||||||
- `sasjs context` manages Viya compute contexts; `sasjs add target` adds a new target.
|
|
||||||
|
|
||||||
## Core workflow
|
|
||||||
|
|
||||||
```
|
|
||||||
sasjs create myapp # scaffold a new app (templates available)
|
|
||||||
sasjs compile # gather macros/services/jobs into per-file build outputs (sasjsbuild/)
|
|
||||||
sasjs build # produce deployable artefacts: JSON + .sas per target
|
|
||||||
sasjs deploy # deploy compiled/built artefacts to the target server
|
|
||||||
sasjs cbd # compile + build + deploy in one step (-t viya etc.)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Command reference
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `sasjs create / init` | Scaffold new app or add SASjs to existing repo |
|
|
||||||
| `sasjs compile` | Resolve dependencies (`<h4> SAS Macros </h4>` etc.) into `sasjsbuild/` |
|
|
||||||
| `sasjs build` | Create build JSON / service pack per target |
|
|
||||||
| `sasjs deploy` / `cbd` | Deploy to server (servicepack or direct) |
|
|
||||||
| `sasjs run <file.sas>` | Execute an arbitrary SAS file on the server, return log |
|
|
||||||
| `sasjs request <path>` | Execute a deployed service/job with input data (`-d`) |
|
|
||||||
| `sasjs job execute` | Run a deployed job |
|
|
||||||
| `sasjs flow execute` | Run a sequence of jobs with dependencies (CSV-defined flows) |
|
|
||||||
| `sasjs servicepack deploy` | Deploy from a JSON service pack |
|
|
||||||
| `sasjs web` | Build the frontend and stream it into the SAS web root (streamConfig) |
|
|
||||||
| `sasjs db` | Build database DDL/data load scripts from `sasjs/db` |
|
|
||||||
| `sasjs doc` | Generate doxygen documentation + lineage |
|
|
||||||
| `sasjs folder create/delete/move` | Manage folders in SAS metadata / SAS Drive |
|
|
||||||
| `sasjs fs` | File-system operations against the server (sync folders) |
|
|
||||||
| `sasjs test` | Execute tests defined in `sasjs/tests` with init/term programs |
|
|
||||||
| `sasjs lint` | Lint `.sas` files per `.sasjslint` config |
|
|
||||||
| `sasjs version` | Print/set version info |
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- Test coverage is generated only from a `sasjs compile` (or `sasjs c`). It accepts a target (`-t <target>`), but nothing is deployed to that target — compilation and coverage are fully local/offline, so no server needs to be available or reachable. Missing macro dependencies (e.g. `mp_ds2csv.sas`) mean `@sasjs/core` isn't installed — run `npm i` first.
|
|
||||||
|
|
||||||
- Dependencies are declared in doxygen headers: `<h4> SAS Macros </h4>`, `<h4> SAS Files </h4>`, `<h4> SAS Folders </h4>`, and `@li item` entries — the CLI builds the dependency tree from these.
|
|
||||||
- `sasjs compile` output goes to the `sasjsbuild/` folder (git-ignore it); `sasjsresults/` holds test/run outputs.
|
|
||||||
- CI/CD: `sasjs cbd -t viya` is the standard deploy step; combine with `sasjs servicepack deploy` for artefact-based releases.
|
|
||||||
- Exit codes are non-zero on failure — safe for pipelines.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- Run `npm i` before `sasjs cb` — macro dependency resolution needs `node_modules/@sasjs/core` present, and `@sasjs/core` (and `@sasjs/adapter` if used) must be listed in `package.json`.
|
|
||||||
- Credentials files are per-target: `.env.<targetname>` (e.g. `.env.server`) with `CLIENT`, `ACCESS_TOKEN`, `REFRESH_TOKEN`. Never commit them — gitignore `.env*`.
|
|
||||||
|
|
||||||
## Viya auth without a client/secret (`sasjs auth login`)
|
|
||||||
|
|
||||||
`sasjs auth login -t <target>` authenticates with a regular SAS username/password via the OAuth2 password grant against the built-in, secret-less `sas.cli` public client. No admin-registered OAuth client is needed — the fastest way to get `sasjs run`/`deploy` working on dev/demo estates. The password is never stored; the minted ACCESS_TOKEN/REFRESH_TOKEN pair is persisted to `.env.<target>` (local) or `~/.sasjsrc` (global) and verified via `/identities/users/@currentUser` (`Logged in as <id> (<name>)`). Bare `sasjs auth` is still an alias for `sasjs add cred`.
|
|
||||||
|
|
||||||
- Token expiry: the CLI silently refreshes via the stored refresh token (works with and without a client/secret), and re-persists the rotated pair — Viya refresh tokens are **single-use/rotating**, so this persistence is what keeps later invocations working. If refresh fails, re-run `sasjs auth login`.
|
|
||||||
- Some estates give `sas.cli` a short access-token TTL (e.g. 1h); a refresh on most invocations is normal.
|
|
||||||
- Opaque (non-JWT) tokens are treated as usable — the server is the authority on expiry.
|
|
||||||
- Limitations: local/LDAP accounts only (no SSO/SAML/MFA estates); password grant must be enabled for `sas.cli` (default on Viya 3.5+/4); ROPC is deprecated in OAuth 2.1 — use a registered client/secret for CI/production.
|
|
||||||
- `sasjs run` 403 on session creation = the account isn't authorised for the configured compute context — set `contextName: "SAS Studio compute context"` on the target. First run on a cold estate can take many minutes (compute pod spin-up) and may appear to hang.
|
|
||||||
- Self-signed estates: use `--insecure` on `auth login`, or configure `httpsAgentOptions` on the target.
|
|
||||||
|
|
||||||
## Viya streaming apps (streamConfig.streamWeb)
|
|
||||||
|
|
||||||
With `streamWeb: true`, `sasjs web`/`cbd -t viya` deploys the frontend **into SAS Files Service**: a streaming job at `<appLoc>/services/<streamServiceName>.html` serves `index.html`, and assets land in `<appLoc>/services/web/...`. At build time every asset/script/css URL in the HTML is rewritten to `/SASJobExecution?_FILE=<appLoc>/services/web/...` and the adapter config element (`<sasjs apploc=...>`) is stamped with the target `appLoc`.
|
|
||||||
|
|
||||||
**Consequence: the app only works when served from the exact `appLoc` it was deployed against.** If the streaming HTML is placed anywhere else (e.g. manually uploaded to a user home folder like `/Users/<id>/myapp/...`), all rewritten `/Public/app/...` asset links 404 and the adapter calls the wrong service paths — the page renders with no CSS/JS and no backend. Fix by redeploying. The original build (`sasjs cb`) has the apploc from the sasjsconfig.json - when the app is deployed as a SAS program, the supplied `%let apploc = ` (runtime value) is swapped with the `compiled_apploc` (build time value) to allow apps to be dynamically deployed to a given apploc at deploy time.
|
|
||||||
|
|
||||||
### Verifying a Viya deployment headlessly (no browser)
|
|
||||||
|
|
||||||
1. Get a token (password grant works out of the box with the built-in `sas.ec` client, empty secret):
|
|
||||||
`curl -X POST <server>/SASLogon/oauth/token -u 'sas.ec:' -d 'grant_type=password&username=U&password=P'`
|
|
||||||
2. Fetch the app: `GET /SASJobExecution/?_FILE=<appLoc>/services/<name>.html` with `Authorization: Bearer` → expect `200` and the full `index.html`. `401` = auth, anything else = not deployed there.
|
|
||||||
3. Fetch each asset the HTML references. Response tells you what's wrong:
|
|
||||||
- `200` with file content → asset deployed correctly.
|
|
||||||
- `202` + a tiny plain-text body (e.g. `Parameter Error\nFile error`) → **file does not exist at that Drive path** (classic appLoc-mismatch symptom). Note `_FILE` responses can be async: add `&_action=wait` to get content synchronously.
|
|
||||||
- Services: `POST /SASJobExecution/?_program=<appLoc>/services/<svc>&_action=wait` → `Parameter Error / Unable to get job definition` means the service was never deployed as a JES job (files on Drive alone are not enough — only `sasjs deploy`/`cbd`/`servicepack deploy` registers them).
|
|
||||||
|
|
||||||
### Talking to SAS — ALWAYS use the adapter or the CLI
|
|
||||||
|
|
||||||
When executing a SAS service or job for any purpose (debugging, reproduction, CI), **always go through the `@sasjs/adapter` or `sasjs` CLI** — never hand-roll curl against `SASJobExecution`. The adapter and CLI handle the things that are trivial to get wrong by hand: the input-data CSV format (space-separated `name:$format.` headers, CRLF, double-quoted special values, `%nrstr(...)` wrapping), the execution-mode routing (`_executionTasks=true` reads `sasjs<N>data` as **macro variables**, not file uploads), the `_debug`/`>>weboutBEGIN<<`/`>>weboutEND<<` wrapper parsing, the token refresh, and the `_contextname` URL param. A hand-built comma-separated CSV will read as blanks, silently skip guarded macro blocks, and send you down a rabbit hole of phantom bugs.
|
|
||||||
|
|
||||||
- Build input data as a JSON file: `{ "<table>": [{ "<col>": "<val>", ... }] }`, e.g. `{ "config": [{ "rootdir": "/export/...", "runastask": "true", "usecomputeapi": "null", "contextname": "Compute Reusable" }] }`.
|
|
||||||
- Run: `sasjs request '<appLoc>/services/common/<svc>' -t viya -d data.json -l <path>.log -o <path>.json`. The CLI hardcodes `debug: true`, so the `-l` flag always captures the full MPRINT/NOTE/`&syscc` log; `-o` saves the parsed webout. **Always pass `-l`** — reproducing a bug without the log means re-running the whole thing.
|
|
||||||
- The Viya Folders/JES REST APIs ARE fine to hit directly with curl (token + `Authorization: Bearer`) for folder/file/member management and for `GET /SASJobExecution/?_FILE=...` asset checks — just not for *executing your own services with input data*.
|
|
||||||
|
|
||||||
#### Adapter execution-context and input-data facts
|
|
||||||
|
|
||||||
- The adapter automatically appends `_contextname=<value>` as a **URL parameter** to every Viya JES request (you do NOT need to pass it yourself). BUT JES request params are **NOT auto-promoted to SAS macro variables** — `%symexist(_contextname)` is false inside the service. If the service needs the chosen context name (e.g. to stamp it into the streamed HTML), pass it in the **input data table** (a `contextname` column) and read it with `call symputx` — that is the reliable channel.
|
|
||||||
- The target's `contextName` in `sasjsconfig.json` decides which compute context the service runs under (and thus the `runAs` identity). The adapter's URL `_contextname` param is the same value. To reproduce a service under a batch/reusable context via `sasjs request`, set the target's `contextName` to that reusable context (e.g. `Compute Reusable`, runAs=sasbatch) — otherwise it runs as your own identity and write-test steps to batch-owned folders fail with `User does not have appropriate authorization level`.
|
|
||||||
- Adapter CSV format (so you can read the `NOTE: The infile ... is:` RULE in the log correctly): row 1 is the header with `name:$informat.` pairs **space-separated**; data rows are **comma-separated** (CRLF), with values double-quoted only if they contain a special char (`,`, `"`, tab, newline). The sasjs/core webout reader reads it back with `dsd` + `firstobs=2` + an `input <name>:$informat.;` statement derived from the header.
|
|
||||||
- `_executionTasks=true` (runAsTask) changes how input data arrives: as `sasjs<N>data` **macro variables** (chunked into `sasjs<N>data0..N`), NOT as `_WEBIN_FILE` uploads. The core webout `mv_webout` macro handles both, but it branches on `_EXECUTIONTASKS` — be aware when reading logs.
|
|
||||||
|
|
||||||
### Redeploying cleanly on Viya (the 409 Conflict problem)
|
|
||||||
|
|
||||||
Re-running `sasjs cbd`/`sasjs run viya.sas` against an **existing** appLoc often fails mid-deploy with `409 Conflict` (and an `mp_abort` → `abort cancel`): `mv_createfile` DELETEs the old file id then tries to recreate it, but when an intermediate **folder** already exists (e.g. `<appLoc>/services/web/js`) the recreate step conflicts and the whole deploy aborts — leaving a half-deployed app (services present, some assets missing). The `?recursive=true` folder DELETE also returns `409 You cannot delete the folder because it is not empty`, and individual member/folder DELETEs can return `403` even as the owner, so you cannot easily tear the tree down by hand.
|
|
||||||
|
|
||||||
The reliable workaround is to **move the top appLoc folder out of the way** and redeploy to the original path — the deploy creates a fresh folder tree with no conflicts, and the old folder is retained as a backup. This is far faster and more reliable than fighting per-member deletes, and is the recommended pre-deploy step for any non-CI redeploy on Viya. Two ways to move it:
|
|
||||||
|
|
||||||
- **Rename in place** (simplest): `PATCH /folders/folders/{id}` with `{"name":"<old>.bak.<ts>","version":2}` — frees the original name, keeps the old folder as a sibling backup.
|
|
||||||
- **MOVE into a backup parent** (tidier — keeps all backups in one place): create (once) a backup parent folder e.g. `/Users/<id>/macrodash-backups`, then `POST /folders/folders/{backupParentId}/members/{appLocFolderId}?action=move` moves the whole tree (with contents) into it, freeing the original path for the fresh deploy.
|
|
||||||
|
|
||||||
(On sasjs/server just `sasjs fs delete` the appLoc first — Drive there supports clean recursive deletes.)
|
|
||||||
|
|
||||||
JES applies its own CSP header when streaming (includes `unsafe-inline`/`unsafe-eval`); a strict CSP meta tag in the app's HTML still applies and is the one that matters for the app code.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
---
|
|
||||||
name: sasjs-core
|
|
||||||
description: Standards and conventions for the @sasjs/core SAS macro library (mf_*, mp_*, mm*, ms_*, mv_* macros). Use when writing or editing SAS macros in a sasjs/core-style repo, picking an existing macro over reinventing one, or the sasjs/core build, lint, doxygen, and testing conventions.
|
|
||||||
---
|
|
||||||
|
|
||||||
# @sasjs/core — SAS Macro Library
|
|
||||||
|
|
||||||
@sasjs/core is an MIT-licensed library of production-quality SAS macros for SAS application development, portable across SAS 9 (meta), Viya, and SASjs server.
|
|
||||||
|
|
||||||
## Coding standards (mandatory)
|
|
||||||
|
|
||||||
- One macro per file; filename must match the macro name (lowercase, no spaces)
|
|
||||||
- Macro definitions must use parentheses: `%macro x();` not `%macro x;`
|
|
||||||
- Macro *calls* are NOT terminated with a semicolon: `%my_macro()` not `%my_macro();`
|
|
||||||
- All macro variables must be declared `%local` to prevent scope leakage
|
|
||||||
- Always use `mf_getuniquefileref` when assigning filerefs, and `mf_getuniquelibref` when assigning librefs (never hardcode or hand-roll unique references)
|
|
||||||
- 2-space indentation, no tabs, no trailing spaces, no invisible characters, max line length 300 (hard lint limit) but keep lines to 80 chars max where possible
|
|
||||||
- Every file must have a Doxygen header:
|
|
||||||
|
|
||||||
```sas
|
|
||||||
/**
|
|
||||||
@file
|
|
||||||
@brief One-line description of the macro
|
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
|
||||||
@li mf_othermacro.sas
|
|
||||||
|
|
||||||
@param [in] paramname Description
|
|
||||||
@param [out] outparam Description
|
|
||||||
|
|
||||||
<h4> Related Macros </h4>
|
|
||||||
@li mp_related.sas
|
|
||||||
|
|
||||||
@version 9.4
|
|
||||||
@author Your Name
|
|
||||||
**/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Folder / prefix conventions
|
|
||||||
|
|
||||||
| Folder | Prefix | Platform |
|
|
||||||
|---|---|---|
|
|
||||||
| `base/` | `mf_` (function-style), `mp_` (procedure-style) | All platforms |
|
|
||||||
| `meta/` | `mm_` | SAS 9 metadata |
|
|
||||||
| `metax/` | `mmx_` | SAS 9 metadata (OS command dependent) |
|
|
||||||
| `viya/` | `mv_` | Viya |
|
|
||||||
| `server/` | `ms_` | SASjs server |
|
|
||||||
| `xplatform/` | `mx_` | Runtime platform detection |
|
|
||||||
| `fcmp/`, `lua/`, `ddl/` | — | PROC FCMP functions, LUA wrappers, DDL |
|
|
||||||
|
|
||||||
Use `mf_` macros when the macro returns a value usable in an expression; use `mp_` for procedural macros that generate code/statements.
|
|
||||||
|
|
||||||
**Cross-suite rule:** `mp_` macros must never reference `mx_` macros. Platform dispatching (SAS 9 / Viya / SASjs server) belongs in the `mx_` suite, which delegates to `ms_`/`mv_`/PROC STP per platform. If an `mp_` macro seems to need platform-specific behaviour, the macro itself belongs in `xplatform/` as an `mx_` macro instead.
|
|
||||||
|
|
||||||
## Reuse before writing
|
|
||||||
|
|
||||||
Before writing a new macro, check the library for an existing one — common utilities already exist, e.g. `mp_abort` (the deprecated `mf_abort` is retained for backwards compatibility — don't use it in new code), `mf_existds`, `mf_existvar`, `mf_existfileref`, `mf_getuser`, `mp_jsonout` (SAS datasets → JSON for `_webout`), `mp_ds2ddl`, `mp_hashdataset`. Platform-specific variants exist under `meta/`, `viya/`, `server/` and are selected at compile time by the CLI.
|
|
||||||
|
|
||||||
## Aborting safely
|
|
||||||
|
|
||||||
Never invoke `%mp_abort` from inside an `%if/%else` block — as a procedural macro, the macro processor can continue executing statements after it before the abort takes effect. Use the `iftrue=` condition parameter instead:
|
|
||||||
|
|
||||||
```sas
|
|
||||||
%mp_abort(iftrue= (&syscc ne 0)
|
|
||||||
,mac=&_program
|
|
||||||
,msg=%str(Something went wrong)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
When `%mp_abort` is called from within a `%include` block, SAS cannot exit cleanly (e.g. to `_webout`). Call `%mp_abort(mode=INCLUDE)` after the include (OUTSIDE any macro wrapper) — it checks `work.mp_abort_errds` for an abort status:
|
|
||||||
|
|
||||||
```sas
|
|
||||||
%mp_abort(mode=INCLUDE)
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `%include`s inside macros should be performed with `%mp_include()` so the `_SYSINCLUDEFILEDEVICE` indicator is set and the abort dataset (`work.mp_abort_errds`) is passed back to the calling program.
|
|
||||||
|
|
||||||
## Testing macros (mandatory conventions)
|
|
||||||
|
|
||||||
- **Always apply `%mp_assertscope` around the macro under test** to catch scope leakage (macro variables must stay `%local`):
|
|
||||||
|
|
||||||
```sas
|
|
||||||
%mp_assertscope(SNAPSHOT)
|
|
||||||
%mx_foo(args)
|
|
||||||
%mp_assertscope(COMPARE,
|
|
||||||
desc=Test 1: mx_foo does not leak scope,
|
|
||||||
outds=work.test_results
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Assertions go to `work.test_results` via `%mp_assert(iftrue=(...), desc=..., outds=work.test_results)`.
|
|
||||||
|
|
||||||
## Lint and build
|
|
||||||
|
|
||||||
- Run `sasjs lint` after every change; do not consider work done until it passes
|
|
||||||
- NEVER bump the version in `package.json` (semantic-release handles it)
|
|
||||||
- Do NOT edit generated files by hand: `all.sas`, `mc_*.sas`, the `lua/` wrappers, and `sasjsbuild/` outputs are produced by the CI build
|
|
||||||
- Markdown files: never hard-wrap; one paragraph per line
|
|
||||||
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
---
|
|
||||||
name: sasjs-framework
|
|
||||||
description: Building full SASjs applications — project structure, sasjsconfig.json, services/jobs/macros folders, multi-target (SAS 9 / Viya / SASjs server) configuration, streaming frontends, mocks and tests. Use when creating or modifying a SASjs app, editing sasjsconfig.json, or writing backend services returning JSON to a web frontend.
|
|
||||||
---
|
|
||||||
|
|
||||||
# SASjs Framework — Building SASjs Applications
|
|
||||||
|
|
||||||
A SASjs app = a web frontend (any framework: Angular, React, vanilla) + SAS backend code organised in a standard layout, compiled and deployed by `@sasjs/cli` to SAS 9, Viya, or SASjs server. Frontend talks to SAS via `@sasjs/adapter`; backend services return JSON via `_webout`.
|
|
||||||
|
|
||||||
## Standard project layout
|
|
||||||
|
|
||||||
```
|
|
||||||
sasjs/
|
|
||||||
sasjsconfig.json # project + target configuration
|
|
||||||
macros/ # project-specific macros (macroFolders)
|
|
||||||
services/ # web services called from the frontend
|
|
||||||
jobs/ # jobs (scheduled / flow / long-running)
|
|
||||||
programs/ # plain programs (initProgram, termProgram, utilities)
|
|
||||||
db/ # DDL + static data per library (sasjs db)
|
|
||||||
tests/ # tests run by `sasjs test`
|
|
||||||
mocks/ # mock responses for offline frontend dev (syncFolder)
|
|
||||||
doxy/ # extra doxygen content for `sasjs doc`
|
|
||||||
```
|
|
||||||
|
|
||||||
## sasjsconfig.json
|
|
||||||
|
|
||||||
Root config holds defaults; each entry in `targets[]` can override them. Key sections:
|
|
||||||
|
|
||||||
- `macroFolders`, `binaryFolders` — where the CLI finds macros/binaries
|
|
||||||
- `serviceConfig.serviceFolders` — service source folders; `initProgram` runs before every service (set up libnames, options)
|
|
||||||
- `jobConfig.jobFolders` — job source folders
|
|
||||||
- `programFolders` — programs compiled/deployed with the app
|
|
||||||
- `streamConfig` — `streamWeb: true` streams the built frontend into SAS so it is served by the platform itself (no separate web server needed); `webSourcePath` points at the frontend build output
|
|
||||||
- `syncFolder` — folder synced to the server (e.g. mocks)
|
|
||||||
- `testConfig` — init/term programs for `sasjs test`
|
|
||||||
- `targets[]` — per-environment overrides: `serverUrl`, `serverType` (`SAS9`/`SASVIYA`/`SASJS`), `appLoc` (deploy root, e.g. `/Public/app/myapp`), target-specific macroFolders (e.g. `targets/viya/macros_viya` for platform shims), `httpsAgentOptions`, `deployConfig`
|
|
||||||
|
|
||||||
The full JSON schema is bundled at `sasjsconfig-schema.json` next to this file — validate config changes against it. Reference it with `"$schema": "https://cli.sasjs.io/sasjsconfig-schema.json"`.
|
|
||||||
|
|
||||||
## Streamed frontend files on Viya (mime types)
|
|
||||||
|
|
||||||
When `streamWeb: true`, the CLI uploads the frontend (`index.html`, renamed per `streamServiceName`, plus css/js) to the Viya Files service using the `%mv_createfile` macro. That macro creates the file in a very particular way to ensure it streams correctly:
|
|
||||||
|
|
||||||
- POSTs to `/files/files` with the content type derived from the extension (`%mf_mimetype`)
|
|
||||||
- sets `typeDefName=file_html` (via `%mv_getViyaFileExtParms`) so the file is recognised as HTML
|
|
||||||
- sends `Content-Disposition` **without** `attachment` for HTML/SVG so it renders in the browser
|
|
||||||
|
|
||||||
**Never update a streamed frontend file in place** with a `filename filesrvc` fileref + data step rewrite — the Files service then treats it as a generic blob and the mime type is lost, so the app no longer streams (browser downloads it or shows raw text). To modify a streamed file at runtime (eg patching the compute `contextname` in the html), read it (a `filesrvc` fileref is fine for *reading*), write the modified content to a temp fileref, and **re-create the file with `%mv_createfile(path=..., name=..., inref=...)`** (it deletes the old file and re-POSTs with the correct mime type).
|
|
||||||
|
|
||||||
## Service contract (frontend ↔ SAS)
|
|
||||||
|
|
||||||
1. Adapter POSTs to `services/<folder>/<name>` with input tables (arrays of objects) → work datasets named after the JS keys.
|
|
||||||
2. Service SAS code runs after `initProgram`; it reads inputs, does work, and writes output JSON to `_webout`.
|
|
||||||
3. Conventional pattern using @sasjs/core macros:
|
|
||||||
|
|
||||||
```sas
|
|
||||||
/**
|
|
||||||
@file
|
|
||||||
@brief Example service returning data
|
|
||||||
<h4> SAS Macros </h4>
|
|
||||||
@li mp_jsonout.sas
|
|
||||||
@li mp_abort.sas
|
|
||||||
**/
|
|
||||||
|
|
||||||
/* validation / logic here */
|
|
||||||
|
|
||||||
%mp_jsonout(OPEN)
|
|
||||||
%mp_jsonout(OBJ,results,dslabel=results)
|
|
||||||
%mp_jsonout(CLOSE)
|
|
||||||
```
|
|
||||||
|
|
||||||
4. On error, abort cleanly with `%mp_abort(...)` (`mf_abort` is deprecated) so the adapter receives a structured error in the JSON, not a half-written response. Do **not** call `%mp_abort` inside an `%if/%else` block — the macro processor may keep executing beyond the abort. Use the conditional `iftrue=` parameter instead, e.g.:
|
|
||||||
|
|
||||||
```sas
|
|
||||||
%mp_abort(iftrue= (%mf_existds(work.results)=0)
|
|
||||||
,mac=&_program
|
|
||||||
,msg=%str(No results found)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
If the abort happens inside a `%include` block, SAS cannot exit to `_webout` cleanly — after the include, call `%mp_abort(mode=INCLUDE)` (outside any macro wrapper), which checks `work.mp_abort_errds` for an abort status.
|
|
||||||
|
|
||||||
## Multi-target discipline
|
|
||||||
|
|
||||||
- Keep backend code platform-neutral in shared folders; put platform-specific shims in `targets/<name>/macros_*` folders and register them only on that target.
|
|
||||||
- Platform capability macros exist in @sasjs/core (`mm_*` metadata, `mv_*` Viya, `ms_*` server) — don't branch on server type by hand.
|
|
||||||
|
|
||||||
## Quality gates (follow the conventions of mature apps like Data Controller)
|
|
||||||
|
|
||||||
- Run `sasjs lint` after touching any `.sas` file; fix all warnings in files you touched.
|
|
||||||
- The linter enforces 2-space indentation everywhere, including continuation lines inside `/* ... */` block comments — never align comment text with 3+ spaces.
|
|
||||||
- Add tests and run `sasjs test` for backend logic changes. When testing macros, always wrap the macro under test with `%mp_assertscope(SNAPSHOT)` / `%mp_assertscope(COMPARE, ...)` to catch macro-variable scope leakage, and wrap any platform-branching code in `%macro` wrappers (no open conditional macro code in test programs).
|
|
||||||
- Provide mocks in `sasjs/mocks` so the frontend can be developed without a live SAS server.
|
|
||||||
- Never auto-commit or bump versions; releases are pipeline-driven (conventional commits).
|
|
||||||
- Markdown files: no hard wrapping — one paragraph per line.
|
|
||||||
- Apps must work offline/on-prem: no external CDN assets in the frontend bundle.
|
|
||||||
|
|
||||||
## Reference implementations
|
|
||||||
|
|
||||||
Look at existing apps for patterns: folder layouts, `sasjsconfig.json` multi-target setups, service structure, streaming builds, and test/mock conventions, eg:
|
|
||||||
* https://git.datacontroller.io/dc/dc
|
|
||||||
* https://github.com/sasjs/react-seed-app
|
|
||||||
* https://github.com/sasjs/macro-dash
|
|
||||||
@@ -1,966 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$id": "https://github.com/sasjs/utils/blob/main/src/types/sasjsconfig-schema.json",
|
|
||||||
"type": "object",
|
|
||||||
"title": "SASjs Config File",
|
|
||||||
"description": "The SASjs Config file provides the settings and structure for your SASjs project. ",
|
|
||||||
"default": {},
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"macroFolders": ["macros"],
|
|
||||||
"programFolders": ["programs"],
|
|
||||||
"binaryFolders": ["binaries"],
|
|
||||||
"defaultTarget": "viya",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"name": "viya",
|
|
||||||
"serverType": "SASVIYA",
|
|
||||||
"serverUrl": "https://sas.sasjs.com",
|
|
||||||
"appLoc": "/Public/app",
|
|
||||||
"contextName": "SAS Job Execution compute context",
|
|
||||||
"deployConfig": {
|
|
||||||
"deployServicePack": true,
|
|
||||||
"deployScripts": ["sasjsbuild/myviyadeploy.sas"]
|
|
||||||
},
|
|
||||||
"serviceConfig": {
|
|
||||||
"serviceFolders": ["targets/viya/services/admin"],
|
|
||||||
"initProgram": "build/serviceinit.sas",
|
|
||||||
"termProgram": "build/serviceinit.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"name": "viyavalue",
|
|
||||||
"extravar": "this too"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"jobConfig": {
|
|
||||||
"jobFolders": [],
|
|
||||||
"initProgram": "",
|
|
||||||
"termProgram": "",
|
|
||||||
"macroVars": {}
|
|
||||||
},
|
|
||||||
"streamConfig": {
|
|
||||||
"assetPaths": [],
|
|
||||||
"streamWeb": false,
|
|
||||||
"streamWebFolder": "webv",
|
|
||||||
"webSourcePath": "dist"
|
|
||||||
},
|
|
||||||
"testConfig": {
|
|
||||||
"initProgram": "sasjs/tests/testinit.sas",
|
|
||||||
"termProgram": "sasjs/tests/testterm.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"testVar": "testValue"
|
|
||||||
},
|
|
||||||
"testSetUp": "sasjs/tests/testsetup.sas",
|
|
||||||
"testTearDown": "sasjs/tests/testteardown.sas"
|
|
||||||
},
|
|
||||||
"macroFolders": ["targets/viya/macros"],
|
|
||||||
"programFolders": [],
|
|
||||||
"binaryFolders": ["binaries"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sas9",
|
|
||||||
"serverType": "SAS9",
|
|
||||||
"serverUrl": "https://sas.sasjs.com:7980",
|
|
||||||
"appLoc": "/User Folders/&sysuserid/My Folder",
|
|
||||||
"serverName": "Foundation",
|
|
||||||
"repositoryName": "SASApp",
|
|
||||||
"buildConfig": {
|
|
||||||
"buildOutputFileName": "mysas9deploy.sas",
|
|
||||||
"initProgram": "",
|
|
||||||
"termProgram": "",
|
|
||||||
"macroVars": {}
|
|
||||||
},
|
|
||||||
"deployConfig": {
|
|
||||||
"deployScripts": ["build/deploysas9.sh"],
|
|
||||||
"deployServicePack": false
|
|
||||||
},
|
|
||||||
"serviceConfig": {
|
|
||||||
"serviceFolders": ["targets/sas9/services/admin"],
|
|
||||||
"initProgram": "",
|
|
||||||
"termProgram": "build/servicetermother.sas",
|
|
||||||
"macroVars": {}
|
|
||||||
},
|
|
||||||
"streamConfig": {
|
|
||||||
"assetPaths": [],
|
|
||||||
"streamWeb": false,
|
|
||||||
"streamWebFolder": "web9",
|
|
||||||
"webSourcePath": "dist"
|
|
||||||
},
|
|
||||||
"testConfig": {
|
|
||||||
"initProgram": "sasjs/tests/testinit.sas",
|
|
||||||
"termProgram": "sasjs/tests/testterm.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"testVar": "testValue"
|
|
||||||
},
|
|
||||||
"testSetUp": "sasjs/tests/testsetup.sas",
|
|
||||||
"testTearDown": "sasjs/tests/testteardown.sas"
|
|
||||||
},
|
|
||||||
"macroFolders": ["targets/sas9/macros"],
|
|
||||||
"programFolders": [],
|
|
||||||
"binaryFolders": ["binaries"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"binaryFolders": {
|
|
||||||
"$id": "#/properties/binaryFolders",
|
|
||||||
"type": "array",
|
|
||||||
"title": "The binaryFolders array",
|
|
||||||
"description": "These local folders are searched for Binary Files when running sasjs compile. Folders are relative to the sasjs/sasjsconfig.json file.",
|
|
||||||
"examples": [["binaries", "../../more_binaries"]]
|
|
||||||
},
|
|
||||||
"sasjsBuildFolder": {
|
|
||||||
"$id": "#/properties/sasjsBuildFolder",
|
|
||||||
"type": "string",
|
|
||||||
"title": "sasjsBuildFolder",
|
|
||||||
"description": "The name of the folder containing the compiled output. The `sasjs build` command will take all of the subfolders here as inputs to create the build pack. By default this will be named `sasjsbuild`. In global, the default is `~/.sasjsbuild`.",
|
|
||||||
"default": "sasjsbuild",
|
|
||||||
"examples": ["sasjsbuild", ".sasjsbuild"]
|
|
||||||
},
|
|
||||||
"sasjsResultsFolder": {
|
|
||||||
"$id": "#/properties/sasjsResultsFolder",
|
|
||||||
"type": "string",
|
|
||||||
"title": "sasjsResultsFolder",
|
|
||||||
"description": "The name of the folder containing the output (eg logs, ODS output) from `sasjs run`. By default this will be named `sasjsresults`. In global, the default is `~/.sasjsresults`.",
|
|
||||||
"default": "sasjsresults",
|
|
||||||
"examples": ["sasjsresults", ".sasjsresults"]
|
|
||||||
},
|
|
||||||
"defaultTarget": {
|
|
||||||
"$id": "#/properties/defaultTarget",
|
|
||||||
"type": "string",
|
|
||||||
"title": "Default Target",
|
|
||||||
"description": "If a target is not specified, this target is used by default. The default target must exist in the (local) targets array.",
|
|
||||||
"default": "viya",
|
|
||||||
"examples": ["viya"]
|
|
||||||
},
|
|
||||||
"docConfig": {
|
|
||||||
"$id": "#/properties/docConfig",
|
|
||||||
"type": "object",
|
|
||||||
"title": "The docConfig schema",
|
|
||||||
"description": "SASjs uses doxygen to auto-generate HTML documentation using the headers in your SAS programs, macros, services & jobs. For more info, see [https://cli.sasjs.io/doc](https://cli.sasjs.io/doc). Some properties are taken from package.json (such as the project Name).",
|
|
||||||
"default": {
|
|
||||||
"dataControllerUrl": "https://mysasserver.com/web/datacontroller/#",
|
|
||||||
"enableLineage": true,
|
|
||||||
"doxyContent": {
|
|
||||||
"readMe": "../../README.md"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"displayMacroCore": true,
|
|
||||||
"outDirectory": "/some/output/directory",
|
|
||||||
"dataControllerUrl": "https://mysasserver.com/web/datacontroller/#",
|
|
||||||
"enableLineage": true,
|
|
||||||
"doxyContent": {
|
|
||||||
"readMe": "../../my/custom/homepage.md",
|
|
||||||
"path": "my/custom/doxy/folder"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"displayMacroCore": {
|
|
||||||
"$id": "#/properties/docConfig/properties/displayMacroCore",
|
|
||||||
"type": "boolean",
|
|
||||||
"title": "The displayMacroCore docConfig option",
|
|
||||||
"description": "The CLI will autocompile macro dependencies that exist in the SASjs Macro Core library. These will also show in the documentation under 'node_modules'. If you'd prefer not to show these in the rendered docs, set this value to false.",
|
|
||||||
"default": true,
|
|
||||||
"examples": [true]
|
|
||||||
},
|
|
||||||
"outDirectory": {
|
|
||||||
"$id": "#/properties/docConfig/properties/outDirectory",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The outDirectory docConfig option",
|
|
||||||
"description": "The location to which the generated HTML SAS documentation is written. If missing, or left blank, the files will be written to the `sasjsbuild/doc` directory (default behaviour).",
|
|
||||||
"default": "sasjsbuild/doc",
|
|
||||||
"examples": ["/my/preferred/docs/directory"]
|
|
||||||
},
|
|
||||||
"dataControllerUrl": {
|
|
||||||
"$id": "#/properties/docConfig/properties/dataControllerUrl",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The dataControllerUrl docConfig option",
|
|
||||||
"description": "Provide the full URL to Data Controller so that `sasjs doc` can link the lineage diagram directly to the table viewer in [Data Controller](https://datacontroller.io).\nIf left blank, or undefined, no links will be generated.",
|
|
||||||
"default": "",
|
|
||||||
"examples": ["https://yourserver.co.uk/dcviya/#"]
|
|
||||||
},
|
|
||||||
"enableLineage": {
|
|
||||||
"$id": "#/properties/docConfig/properties/enableLineage",
|
|
||||||
"type": "boolean",
|
|
||||||
"title": "Enable Lineage",
|
|
||||||
"description": "If true, sasjs doc will generate and display Data Lineage from Jobs and Services.",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
"doxyContent": {
|
|
||||||
"$id": "#/properties/docConfig/properties/doxyContent",
|
|
||||||
"type": "object",
|
|
||||||
"title": "doxyContent",
|
|
||||||
"description": "Configuration of the Doxyfile variables",
|
|
||||||
"default": {},
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"favIcon": "favicon.ico",
|
|
||||||
"footer": "new_footer.html",
|
|
||||||
"header": "new_header.html",
|
|
||||||
"layout": "DoxygenLayout.xml",
|
|
||||||
"logo": "logo.png",
|
|
||||||
"readMe": "../../README.md",
|
|
||||||
"stylesheet": "new_stylesheet.css",
|
|
||||||
"path": "sasjs/doxy"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"favIcon": {
|
|
||||||
"$id": "#/properties/docConfig/properties/doxyContent/properties/favIcon",
|
|
||||||
"type": "string",
|
|
||||||
"title": "Doxygen favicon",
|
|
||||||
"description": "The favicon used in the doxygen documentation",
|
|
||||||
"default": "favicon.ico"
|
|
||||||
},
|
|
||||||
"footer": {
|
|
||||||
"$id": "#/properties/docConfig/properties/doxyContent/properties/footer",
|
|
||||||
"type": "string",
|
|
||||||
"title": "Doxygen footer",
|
|
||||||
"description": "The footer HTML file used in the doxygen documentation",
|
|
||||||
"default": "new_footer.html"
|
|
||||||
},
|
|
||||||
"header": {
|
|
||||||
"$id": "#/properties/docConfig/properties/doxyContent/properties/header",
|
|
||||||
"type": "string",
|
|
||||||
"title": "Doxygen header",
|
|
||||||
"description": "The header HTML file used in the doxygen documentation",
|
|
||||||
"default": "new_header.html"
|
|
||||||
},
|
|
||||||
"layout": {
|
|
||||||
"$id": "#/properties/docConfig/properties/doxyContent/properties/layout",
|
|
||||||
"type": "string",
|
|
||||||
"title": "Doxygen Layout XML File",
|
|
||||||
"description": "The layout XML file used to build the doxygen documentation",
|
|
||||||
"default": "DoxygenLayout.xml"
|
|
||||||
},
|
|
||||||
"logo": {
|
|
||||||
"$id": "#/properties/docConfig/properties/doxyContent/properties/logo",
|
|
||||||
"type": "string",
|
|
||||||
"title": "Doxygen Logo",
|
|
||||||
"description": "The logo file used by doxygen",
|
|
||||||
"default": "logo.png"
|
|
||||||
},
|
|
||||||
"readMe": {
|
|
||||||
"$id": "#/properties/docConfig/properties/doxyContent/properties/readMe",
|
|
||||||
"type": "string",
|
|
||||||
"title": "Doxygen Homepage",
|
|
||||||
"description": "The file used to generate the doxygen homepage (defaults to the readme.md)",
|
|
||||||
"default": "../../README.md"
|
|
||||||
},
|
|
||||||
"stylesheet": {
|
|
||||||
"$id": "#/properties/docConfig/properties/doxyContent/properties/stylesheet",
|
|
||||||
"type": "string",
|
|
||||||
"title": "Doxygen CSS",
|
|
||||||
"description": "The CSS file used to extend Doxygen",
|
|
||||||
"default": "new_stylesheet.css"
|
|
||||||
},
|
|
||||||
"path": {
|
|
||||||
"$id": "#/properties/docConfig/properties/doxyContent/properties/path",
|
|
||||||
"type": "string",
|
|
||||||
"title": "Doxygen Path",
|
|
||||||
"description": "The path to the Doxygen configuration files (relative to sasjs/sasjsconfig.json)",
|
|
||||||
"default": "sasjs/doxy"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"httpsAgentOptions": {
|
|
||||||
"$id": "#/properties/httpsAgentOptions",
|
|
||||||
"type": "object",
|
|
||||||
"title": "httpsAgentOptions",
|
|
||||||
"description": "Configure https agent by setting all supported attribute such as `key`, `cert`, `ca`, `rejectUnauthorized` and `requestCert`",
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"allowInsecureRequests": false,
|
|
||||||
"caPath": "path/to/caFile",
|
|
||||||
"keyPath": "path/to/keyFile",
|
|
||||||
"certPath": "path/to/certFile",
|
|
||||||
"requestCert": false,
|
|
||||||
"rejectUnauthorized": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"allowInsecureRequests": {
|
|
||||||
"$id": "#/properties/httpsAgentOptions/properties/allowInsecureRequests",
|
|
||||||
"type": "boolean",
|
|
||||||
"title": "allowInsecureRequests",
|
|
||||||
"description": "If you are having certificate errors connecting to SAS, that cannot be properly resolved, try setting this value to true. This option only has an effect if rejectUnauthorized is not present.",
|
|
||||||
"default": false,
|
|
||||||
"examples": [true, false]
|
|
||||||
},
|
|
||||||
"caPath": {
|
|
||||||
"$id": "#/properties/httpsAgentOptions/properties/caPath",
|
|
||||||
"type": "string",
|
|
||||||
"title": "caPath",
|
|
||||||
"description": "Optionally override the trusted CA certificates. Default is to trust the well-known CAs curated by Mozilla. Mozilla's CAs are completely replaced when CAs are explicitly specified using this option.",
|
|
||||||
"examples": ["path/to/caFile"]
|
|
||||||
},
|
|
||||||
"keyFile": {
|
|
||||||
"$id": "#/properties/httpsAgentOptions/properties/keyFile",
|
|
||||||
"type": "string",
|
|
||||||
"title": "keyFile",
|
|
||||||
"description": "Private keys in PEM format. PEM allows the option of private keys being encrypted. Encrypted keys will be decrypted with options.passphrase. Multiple keys using different algorithms can be provided either as an array of unencrypted key strings or buffers, or an array of objects in the form {pem: <string|buffer>[, passphrase: <string>]}. The object form can only occur in an array. object.passphrase is optional. Encrypted keys will be decrypted with object.passphrase if provided, or options.passphrase if it is not.",
|
|
||||||
"examples": ["path/to/keyFile"]
|
|
||||||
},
|
|
||||||
"certFile": {
|
|
||||||
"$id": "#/properties/httpsAgentOptions/properties/certFile",
|
|
||||||
"type": "string",
|
|
||||||
"title": "certFile",
|
|
||||||
"description": "Cert chains in PEM format. One cert chain should be provided per private key. Each cert chain should consist of the PEM formatted certificate for a provided private key, followed by the PEM formatted intermediate certificates (if any), in order, and not including the root CA (the root CA must be pre-known to the peer, see ca). When providing multiple cert chains, they do not have to be in the same order as their private keys in key. If the intermediate certificates are not provided, the peer will not be able to validate the certificate, and the handshake will fail.",
|
|
||||||
"examples": ["path/to/certFile"]
|
|
||||||
},
|
|
||||||
"requestCert": {
|
|
||||||
"$id": "#/properties/httpsAgentOptions/properties/requestCert",
|
|
||||||
"type": "boolean",
|
|
||||||
"title": "requestCert",
|
|
||||||
"description": "If true the server will request a certificate from clients that connect and attempt to verify that certificate. Defaults to false.",
|
|
||||||
"default": false,
|
|
||||||
"examples": [true, false]
|
|
||||||
},
|
|
||||||
"rejectUnauthorized": {
|
|
||||||
"$id": "#/properties/httpsAgentOptions/properties/rejectUnauthorized",
|
|
||||||
"type": "boolean",
|
|
||||||
"title": "rejectUnauthorized",
|
|
||||||
"description": "If true the server will reject any connection which is not authorized with the list of supplied CAs. This option only has an effect if requestCert is true.",
|
|
||||||
"default": true,
|
|
||||||
"examples": [true, false]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"buildConfig": {
|
|
||||||
"$id": "#/properties/buildConfig",
|
|
||||||
"type": "object",
|
|
||||||
"title": "buildConfig",
|
|
||||||
"description": "Dictates which files get compiled into the build program (.sas), used to deploy services into SAS 9 or Viya environments (without a client/secret). You may use this config to include build specific macros, programs or macro variables - which is run a single time, on deployment - for things like database creation, or exporting a SAS 9 SPK after service creation.",
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"initProgram": "build/buildinit.sas",
|
|
||||||
"termProgram": "build/buildterm.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"name": "value",
|
|
||||||
"numvar": "42"
|
|
||||||
},
|
|
||||||
"buildOutputFileName": "buildpack.sas"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"buildOutputFileName": {
|
|
||||||
"$id": "#/properties/buildConfig/properties/buildOutputFileName",
|
|
||||||
"type": "string",
|
|
||||||
"title": "buildOutputFileName",
|
|
||||||
"description": "The name of the generated .sas program, which can be used to deploy the app using only SAS Studio. By default, this will be the name of the target.",
|
|
||||||
"examples": ["viya.sas", "sas9.sas"]
|
|
||||||
},
|
|
||||||
"initProgram": {
|
|
||||||
"$id": "#/properties/buildConfig/properties/initProgram",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The buildConfig initProgram",
|
|
||||||
"description": "The path to a .sas program that will be inserted at the start of the build .sas program (created when running `sasjs build`).",
|
|
||||||
"default": "sasjs/buildinit.sas"
|
|
||||||
},
|
|
||||||
"termProgram": {
|
|
||||||
"$id": "#/properties/buildConfig/properties/termProgram",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The buildConfig termProgram",
|
|
||||||
"description": "The path to a .sas program that will be inserted at the end of the build .sas program (created when running `sasjs build`).",
|
|
||||||
"default": "sasjs/buildterm.sas"
|
|
||||||
},
|
|
||||||
"macroVars": {
|
|
||||||
"$id": "#/properties/buildConfig/properties/macroVars",
|
|
||||||
"type": "object",
|
|
||||||
"title": "The buildConfig macro variables",
|
|
||||||
"description": "A series of name value pairs that will be turned into SAS macro variables in the build .sas program (generated when running `sasjs build`). The example provided will generate sas code as follows:\n```\n%let name=value;\n%let numvar=42;\n```",
|
|
||||||
"default": {},
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"name": "value",
|
|
||||||
"numvar": "42"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deployConfig": {
|
|
||||||
"$id": "#/properties/deployConfig",
|
|
||||||
"type": "object",
|
|
||||||
"title": "The deployConfig schema",
|
|
||||||
"description": "The deployConfig object enables settings that relate to the deployment of a SAS app - be that Viya, or SAS 9, or a pure Base environment.",
|
|
||||||
"default": {},
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"deployScripts": ["build/deployscript.sh"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deployScripts": ["build/deployscript.sh"],
|
|
||||||
"deployServicePack": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"deployScripts": {
|
|
||||||
"$id": "#/properties/deployConfig/properties/deployScripts",
|
|
||||||
"type": "array",
|
|
||||||
"title": "The deployConfig deployScripts array",
|
|
||||||
"description": "These scripts are executed when running `sasjs deploy`. If the file is a .sas file, it is executed on the SAS server (Viya only). Otherwise it is executed locally. These scripts are run AFTER the deployment of the servicepack, if `deployServicePack:true` (Viya only).",
|
|
||||||
"default": [],
|
|
||||||
"examples": [["build/deployscript.sh", "build/myprogram.sas"]]
|
|
||||||
},
|
|
||||||
"deployServicePack": {
|
|
||||||
"$id": "#/properties/deployConfig/properties/deployServicePack",
|
|
||||||
"type": "boolean",
|
|
||||||
"title": "The deployConfig deployServicePack flag",
|
|
||||||
"description": "If set to `true` the json pack produced by `sasjs build` will be auto-deployed to the `appLoc` of the specified target (creating all jobs and services in the SAS folder tree). Currently only Viya is supported for this flag.",
|
|
||||||
"default": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"serviceConfig": {
|
|
||||||
"$id": "#/properties/serviceConfig",
|
|
||||||
"type": "object",
|
|
||||||
"title": "The serviceConfig schema",
|
|
||||||
"description": "The serviceConfig object defines how SASjs web services are compiled. Web services differ from jobs in that they include some fixed pre-code (eg the macros to stream out the result json).",
|
|
||||||
"default": {},
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"serviceFolders": ["services/common", "services/admin"],
|
|
||||||
"initProgram": "build/serviceinit.sas",
|
|
||||||
"termProgram": "build/serviceterm.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"mac1": "value",
|
|
||||||
"mac2": "42"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"required": [],
|
|
||||||
"properties": {
|
|
||||||
"serviceFolders": {
|
|
||||||
"$id": "#/properties/serviceConfig/properties/serviceFolders",
|
|
||||||
"type": "array",
|
|
||||||
"title": "The serviceConfig serviceFolders array",
|
|
||||||
"description": "When running `sasjs compile`, all programs in the folders defined in this array are compiled and placed into same-named folders under `sasjsbuild/services`. They will be compiled as services (so, with the service pre-code). Folders can be absolute, or relative to the `sasjs` folder.",
|
|
||||||
"default": [],
|
|
||||||
"examples": [["services/common", "services/admin"]]
|
|
||||||
},
|
|
||||||
"initProgram": {
|
|
||||||
"$id": "#/properties/serviceConfig/properties/initProgram",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The serviceConfig initProgram",
|
|
||||||
"description": "The serviceConfig `initProgram` is a .sas file that is inserted at the start of every SAS service (after compiled macros and any `macroVars`, and before the service itself). ",
|
|
||||||
"default": "",
|
|
||||||
"examples": ["build/serviceinit.sas"]
|
|
||||||
},
|
|
||||||
"termProgram": {
|
|
||||||
"$id": "#/properties/serviceConfig/properties/termProgram",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The serviceConfig termProgram",
|
|
||||||
"description": "The serviceConfig termProgram is inserted at the end of every service as part of `sasjs compile`.",
|
|
||||||
"default": "",
|
|
||||||
"examples": ["build/serviceterm.sas"]
|
|
||||||
},
|
|
||||||
"macroVars": {
|
|
||||||
"$id": "#/properties/serviceConfig/properties/macroVars",
|
|
||||||
"type": "object",
|
|
||||||
"title": "The `serviceConfig` macroVars",
|
|
||||||
"description": "This object allows `sasjs compile` to insert specific macro variables at the start of every service. In this case, the code generated would be:\n```\n%let mac1=value;\n%let mac2=42;\n```",
|
|
||||||
"default": {},
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"mac1": "value",
|
|
||||||
"mac2": "42"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"jobConfig": {
|
|
||||||
"$id": "#/properties/jobConfig",
|
|
||||||
"type": "object",
|
|
||||||
"title": "The jobConfig schema",
|
|
||||||
"description": "The jobConfig object defines how SASjs Jobs are compiled. ",
|
|
||||||
"default": {},
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"jobFolders": ["jobs/extract", "jobs/load"],
|
|
||||||
"initProgram": "jobs/jobinit.sas",
|
|
||||||
"termProgram": "jobs/jobterm.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"mac1": "value",
|
|
||||||
"mac2": "42"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"jobFolders": {
|
|
||||||
"$id": "#/properties/jobConfig/properties/jobFolders",
|
|
||||||
"type": "array",
|
|
||||||
"title": "The jobConfig jobFolders array",
|
|
||||||
"description": "When running `sasjs compile`, all programs in the local folders defined in this array are compiled and placed into same-named folders under `sasjsbuild/jobs`. Folders can be absolute, or relative to the local project `/sasjs` folder.",
|
|
||||||
"default": [],
|
|
||||||
"examples": [["jobs/extract", "jobs/transform", "jobs/load"]]
|
|
||||||
},
|
|
||||||
"initProgram": {
|
|
||||||
"$id": "#/properties/jobConfig/properties/initProgram",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The jobConfig initProgram",
|
|
||||||
"description": "The jobConfig `initProgram` is a local .sas file that is inserted at the start of every SAS Job (after compiled macros and any `macroVars`, and before the Job itself). ",
|
|
||||||
"default": "",
|
|
||||||
"examples": ["jobs/jobinit.sas"]
|
|
||||||
},
|
|
||||||
"termProgram": {
|
|
||||||
"$id": "#/properties/jobConfig/properties/termProgram",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The jobConfig termProgram",
|
|
||||||
"description": "The jobConfig termProgram is inserted at the end of every Job as part of `sasjs compile`.",
|
|
||||||
"default": "",
|
|
||||||
"examples": ["jobs/jobterm.sas"]
|
|
||||||
},
|
|
||||||
"macroVars": {
|
|
||||||
"$id": "#/properties/jobConfig/properties/macroVars",
|
|
||||||
"type": "object",
|
|
||||||
"title": "The `jobConfig` macroVars",
|
|
||||||
"description": "This object allows `sasjs compile` to insert specific macro variables at the start of every Job. In this case, the code generated would be:\n```\n%let mac1=value;\n%let mac2=42;\n```",
|
|
||||||
"default": {},
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"mac1": "value",
|
|
||||||
"mac2": "42"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"streamConfig": {
|
|
||||||
"$id": "#/properties/streamConfig",
|
|
||||||
"type": "object",
|
|
||||||
"title": "streamConfig",
|
|
||||||
"description": "SASjs allows a local web app to be compiled such that all html, css, javascript, and other assets such as png or mp4 are converted into web services and streamed directly from SAS.\n\nThis approach is convenient as it bypasses the need to deploy to a web server. ",
|
|
||||||
"default": {},
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"assetPaths": [],
|
|
||||||
"streamWeb": false,
|
|
||||||
"streamWebFolder": "webv",
|
|
||||||
"webSourcePath": "dist",
|
|
||||||
"streamLogo": "logo.png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"required": ["streamWeb"],
|
|
||||||
"properties": {
|
|
||||||
"assetPaths": {
|
|
||||||
"$id": "#/properties/streamConfig/properties/assetPaths",
|
|
||||||
"type": "array",
|
|
||||||
"title": "The streamConfig assetPaths array",
|
|
||||||
"description": "An array of local folders. All assets placed in these folders are converted into web services - example file types could be png, svg, mp3, mp4, excel - anything really.",
|
|
||||||
"default": [],
|
|
||||||
"examples": [["/myassets"]]
|
|
||||||
},
|
|
||||||
"streamLogo": {
|
|
||||||
"$id": "#/properties/streamConfig/properties/streamLogo",
|
|
||||||
"type": "string",
|
|
||||||
"title": "Icon shown in AppStream (sasjs/server)",
|
|
||||||
"description": "Provide the location of a square image, under the webSourcePath. Used as the display icon on the appStream page.",
|
|
||||||
"default": "logo.png",
|
|
||||||
"examples": ["logo.png", "favicon.ico"]
|
|
||||||
},
|
|
||||||
"streamWeb": {
|
|
||||||
"$id": "#/properties/streamConfig/properties/streamWeb",
|
|
||||||
"type": "boolean",
|
|
||||||
"title": "The streamConfig streamWeb flag",
|
|
||||||
"description": "When set to `true`, frontend files saved in the `webSourcePath` will be converted to streaming services in the `streamWebFolder` in SAS.",
|
|
||||||
"default": false,
|
|
||||||
"examples": [true]
|
|
||||||
},
|
|
||||||
"streamWebFolder": {
|
|
||||||
"$id": "#/properties/streamConfig/properties/streamWebFolder",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The streamConfig streamWebFolder",
|
|
||||||
"description": "This is the target SAS folder (relative to the appLoc) where the compiled web assets will be created.",
|
|
||||||
"default": "webv"
|
|
||||||
},
|
|
||||||
"webSourcePath": {
|
|
||||||
"$id": "#/properties/streamConfig/properties/webSourcePath",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The webSourcePath schema",
|
|
||||||
"description": "Active when `streamConfig` is `true`. Is the source (or build, or dist) LOCAL folder, relative to the `sasjs` folder, which contains the frontend to be deployed. All assets (PNG, JS, CSS, HTML etc) are taken from here and converted to streaming services in `streamWebFolder`. In Viya and SAS 9, any relative URLS will be modified such that the links still work and the assets still load.",
|
|
||||||
"default": "dist",
|
|
||||||
"examples": ["dist", "build"]
|
|
||||||
},
|
|
||||||
"streamServiceName": {
|
|
||||||
"$id": "#/properties/streamConfig/properties/streamServiceName",
|
|
||||||
"type": "string",
|
|
||||||
"title": "streamServiceName Schema",
|
|
||||||
"description": "The name of the service containing the `index.html` for a streaming web app. Defaults to `clickme` and is always deployed under the appLoc/services SAS Folder.",
|
|
||||||
"default": "clickme.sas"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"testConfig": {
|
|
||||||
"$id": "#/properties/testConfig",
|
|
||||||
"type": "object",
|
|
||||||
"title": "testConfig",
|
|
||||||
"description": "Create tests for Macros, Services & Jobs by simply adding a '.test.sas' extension. ",
|
|
||||||
"default": {
|
|
||||||
"initProgram": "sasjs/tests/testinit.sas",
|
|
||||||
"termProgram": "sasjs/tests/testterm.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"testVar": "testValue"
|
|
||||||
},
|
|
||||||
"testSetUp": "sasjs/tests/testsetup.sas",
|
|
||||||
"testTearDown": "sasjs/tests/testteardown.sas"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"initProgram": {
|
|
||||||
"$id": "#/properties/testConfig/properties/initProgram",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The jobConfig initProgram",
|
|
||||||
"description": "The testConfig `initProgram` is a local .sas file that is inserted at the start of every Test (after compiled macros and any `macroVars`, and before the Test itself). ",
|
|
||||||
"default": "",
|
|
||||||
"examples": ["sasjs/tests/testinit.sas"]
|
|
||||||
},
|
|
||||||
"termProgram": {
|
|
||||||
"$id": "#/properties/testConfig/properties/termProgram",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The jobConfig termProgram",
|
|
||||||
"description": "The testConfig termProgram is inserted at the end of every Test as part of `sasjs compile`.",
|
|
||||||
"default": "",
|
|
||||||
"examples": ["jobs/jobterm.sas"]
|
|
||||||
},
|
|
||||||
"macroVars": {
|
|
||||||
"$id": "#/properties/testConfig/properties/macroVars",
|
|
||||||
"type": "object",
|
|
||||||
"title": "The `jobConfig` macroVars",
|
|
||||||
"description": "This object allows `sasjs compile` to insert specific macro variables at the start of every Test. In this case, the code generated would be:\n```\n%let mac1=value;\n%let mac2=42;\n```",
|
|
||||||
"default": {},
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"mac1": "value",
|
|
||||||
"mac2": "42"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"testSetUp": {
|
|
||||||
"$id": "#/properties/testConfig/properties/testsetup",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The jobConfig testSetUp",
|
|
||||||
"description": "This program is the first to execute as part of 'sasjs test'. It does not contain the testInit, testTerm or macroVariables. It IS compiled.",
|
|
||||||
"default": "sasjs/tests/testsetup.sas",
|
|
||||||
"examples": ["sasjs/tests/testsetup.sas"]
|
|
||||||
},
|
|
||||||
"testTearDown": {
|
|
||||||
"$id": "#/properties/testConfig/properties/testTearDown",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The jobConfig testTearDown",
|
|
||||||
"description": "The last program to execute as part of 'sasjs test'.",
|
|
||||||
"default": "sasjs/tests/testteardown.sas",
|
|
||||||
"examples": ["sasjs/tests/testteardown.sas"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"macroFolders": {
|
|
||||||
"$id": "#/properties/macroFolders",
|
|
||||||
"type": "array",
|
|
||||||
"title": "The macroFolders array",
|
|
||||||
"description": "These local folders are searched for SAS Macros when running `sasjs compile`. Folders are relative to the `sasjs/sasjsconfig.json` file.",
|
|
||||||
"default": [],
|
|
||||||
"examples": [["macros", "../../more_macros"]]
|
|
||||||
},
|
|
||||||
"programFolders": {
|
|
||||||
"$id": "#/properties/programFolders",
|
|
||||||
"type": "array",
|
|
||||||
"title": "The programFolders array",
|
|
||||||
"description": "These local folders are searched for SAS Programs when running `sasjs compile`. Folders are relative to the `sasjs/sasjsconfig.json` file.",
|
|
||||||
"default": [],
|
|
||||||
"examples": [["programs", "../../more_programs"]]
|
|
||||||
},
|
|
||||||
"syncFolder": {
|
|
||||||
"$id": "#/properties/syncFolder",
|
|
||||||
"type": "string",
|
|
||||||
"title": "Sync Folder",
|
|
||||||
"description": "The contents of this folder are simply copied to the sasjsbuild directory AFTER the rest of the project is compiled. Useful for synchronising random / generic content with SAS logical folders.",
|
|
||||||
"default": "sasjs/static_files"
|
|
||||||
},
|
|
||||||
"syncDirectories": {
|
|
||||||
"$id": "#/properties/syncDirectories",
|
|
||||||
"type": "array",
|
|
||||||
"title": "syncDirectories",
|
|
||||||
"description": "Maps the local filesystem to remote (SAS) physical directories.",
|
|
||||||
"examples": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"local": "C:\\temp\\local\\fs1",
|
|
||||||
"remote": "/opt/data/fs1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"local": "C:\\temp\\elsewhere",
|
|
||||||
"remote": "/opt/somewhere"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"targets": {
|
|
||||||
"$id": "#/properties/targets",
|
|
||||||
"type": "array",
|
|
||||||
"title": "The targets array",
|
|
||||||
"description": "A target is an alias for a deployment location, and includes at a minimum, the `serverUrl`, `serverType` and `appLoc`. This array allows multiple targets to be defined (eg dev / test / prod). Any properties defined here will override same-named properties in the sasjsconfig root.",
|
|
||||||
"default": [],
|
|
||||||
"examples": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "sas9target",
|
|
||||||
"serverType": "SAS9",
|
|
||||||
"serverUrl": "https://mysas9server",
|
|
||||||
"appLoc": "/Shared Folders/myApp"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "viya",
|
|
||||||
"serverType": "SASVIYA",
|
|
||||||
"serverUrl": "https://sas.sasjs.com",
|
|
||||||
"appLoc": "/Public/app",
|
|
||||||
"contextName": "SAS Job Execution compute context",
|
|
||||||
"buildConfig": {
|
|
||||||
"buildOutputFileName": "myviyadeploy.sas",
|
|
||||||
"initProgram": "build/buildinitviya.sas",
|
|
||||||
"termProgram": "targets/viya/viyabuildterm.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"name": "viyavalue",
|
|
||||||
"extravar": "this too"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deployConfig": {
|
|
||||||
"deployServicePack": true,
|
|
||||||
"deployScripts": ["sasjsbuild/myviyadeploy.sas"]
|
|
||||||
},
|
|
||||||
"serviceConfig": {
|
|
||||||
"serviceFolders": ["targets/viya/services/admin"],
|
|
||||||
"initProgram": "build/serviceinit.sas",
|
|
||||||
"termProgram": "build/serviceinit.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"name": "viyavalue",
|
|
||||||
"extravar": "this too"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"streamConfig": {
|
|
||||||
"assetPaths": [],
|
|
||||||
"streamWeb": false,
|
|
||||||
"streamWebFolder": "webv",
|
|
||||||
"webSourcePath": "dist"
|
|
||||||
},
|
|
||||||
"testConfig": {
|
|
||||||
"initProgram": "sasjs/tests/testinit.sas",
|
|
||||||
"termProgram": "sasjs/tests/testterm.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"testVar": "testValue"
|
|
||||||
},
|
|
||||||
"testSetUp": "sasjs/tests/testsetup.sas",
|
|
||||||
"testTearDown": "sasjs/tests/testteardown.sas"
|
|
||||||
},
|
|
||||||
"macroFolders": ["targets/viya/macros"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sas9",
|
|
||||||
"serverType": "SAS9",
|
|
||||||
"serverUrl": "https://sas.sasjs.com:7980",
|
|
||||||
"appLoc": "/User Folders/&sysuserid/My Folder",
|
|
||||||
"serverName": "Foundation",
|
|
||||||
"repositoryName": "SASApp",
|
|
||||||
"buildConfig": {
|
|
||||||
"buildOutputFileName": "mysas9deploy.sas",
|
|
||||||
"initProgram": "",
|
|
||||||
"termProgram": "",
|
|
||||||
"macroVars": {}
|
|
||||||
},
|
|
||||||
"deployConfig": {
|
|
||||||
"deployScripts": ["build/deploysas9.sh"],
|
|
||||||
"deployServicePack": false
|
|
||||||
},
|
|
||||||
"serviceConfig": {
|
|
||||||
"serviceFolders": ["targets/sas9/services/admin"],
|
|
||||||
"initProgram": "",
|
|
||||||
"termProgram": "build/servicetermother.sas",
|
|
||||||
"macroVars": {}
|
|
||||||
},
|
|
||||||
"streamConfig": {
|
|
||||||
"assetPaths": [],
|
|
||||||
"streamWeb": false,
|
|
||||||
"streamWebFolder": "web9",
|
|
||||||
"webSourcePath": "dist"
|
|
||||||
},
|
|
||||||
"testConfig": {
|
|
||||||
"initProgram": "sasjs/tests/testinit.sas",
|
|
||||||
"termProgram": "sasjs/tests/testterm.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"testVar": "testValue"
|
|
||||||
},
|
|
||||||
"testSetUp": "sasjs/tests/testsetup.sas",
|
|
||||||
"testTearDown": "sasjs/tests/testteardown.sas"
|
|
||||||
},
|
|
||||||
"macroFolders": ["targets/sas9/macros"],
|
|
||||||
"programFolders": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"items": {
|
|
||||||
"$id": "#/properties/targets/items",
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"$id": "#/properties/targets/items/anyOf/0",
|
|
||||||
"type": "object",
|
|
||||||
"title": "SASjs Targets",
|
|
||||||
"description": "A target provides the configuration specific to a particular deployment, eg DEV / TEST, or SAS9 / SASVIYA.",
|
|
||||||
"default": {},
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"name": "viya",
|
|
||||||
"serverType": "SASVIYA",
|
|
||||||
"serverUrl": "https://sas.sasjs.com",
|
|
||||||
"appLoc": "/Public/app",
|
|
||||||
"contextName": "SAS Job Execution compute context",
|
|
||||||
"buildConfig": {
|
|
||||||
"buildOutputFileName": "myviyadeploy.sas",
|
|
||||||
"initProgram": "build/buildinitviya.sas",
|
|
||||||
"termProgram": "targets/viya/viyabuildterm.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"name": "viyavalue",
|
|
||||||
"extravar": "this too"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deployConfig": {
|
|
||||||
"deployServicePack": true,
|
|
||||||
"deployScripts": ["sasjsbuild/myviyadeploy.sas"]
|
|
||||||
},
|
|
||||||
"serviceConfig": {
|
|
||||||
"serviceFolders": ["targets/viya/services/admin"],
|
|
||||||
"initProgram": "build/serviceinit.sas",
|
|
||||||
"termProgram": "build/serviceinit.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"name": "viyavalue",
|
|
||||||
"extravar": "this too"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"jobConfig": {
|
|
||||||
"jobFolders": [],
|
|
||||||
"initProgram": "",
|
|
||||||
"termProgram": "",
|
|
||||||
"macroVars": {}
|
|
||||||
},
|
|
||||||
"streamConfig": {
|
|
||||||
"assetPaths": [],
|
|
||||||
"streamWeb": false,
|
|
||||||
"streamWebFolder": "webv",
|
|
||||||
"webSourcePath": "dist"
|
|
||||||
},
|
|
||||||
"testConfig": {
|
|
||||||
"initProgram": "sasjs/tests/testinit.sas",
|
|
||||||
"termProgram": "sasjs/tests/testterm.sas",
|
|
||||||
"macroVars": {
|
|
||||||
"testVar": "testValue"
|
|
||||||
},
|
|
||||||
"testSetUp": "sasjs/tests/testsetup.sas",
|
|
||||||
"testTearDown": "sasjs/tests/testteardown.sas"
|
|
||||||
},
|
|
||||||
"macroFolders": ["targets/viya/macros"],
|
|
||||||
"programFolders": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"required": ["name", "serverType", "appLoc"],
|
|
||||||
"properties": {
|
|
||||||
"name": {
|
|
||||||
"$id": "#/properties/targets/items/anyOf/0/properties/name",
|
|
||||||
"type": "string",
|
|
||||||
"title": "Target name property",
|
|
||||||
"description": "A target name can only contain alphanumeric characters and dashes. It cannot contain spaces. It is used as the alias when referencing the target using the `-t` attribute in many of the SASjs commands.",
|
|
||||||
"default": "",
|
|
||||||
"examples": ["viya"]
|
|
||||||
},
|
|
||||||
"serverType": {
|
|
||||||
"$id": "#/properties/targets/items/anyOf/0/properties/serverType",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The Target serverType",
|
|
||||||
"description": "The serverType can be either SAS9, SASVIYA or SASJS.",
|
|
||||||
"default": "",
|
|
||||||
"examples": ["SASVIYA"]
|
|
||||||
},
|
|
||||||
"appLoc": {
|
|
||||||
"$id": "#/properties/targets/items/anyOf/0/properties/appLoc",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The Target appLoc",
|
|
||||||
"description": "The appLoc provides the root SAS folder location under which all jobs and services are deployed and executed. The SAS folder could be metadata in SAS 9, or SAS Drive in Viya. ",
|
|
||||||
"default": "",
|
|
||||||
"examples": ["/Public/app"]
|
|
||||||
},
|
|
||||||
"binaryFolders": {
|
|
||||||
"$ref": "#/properties/binaryFolders"
|
|
||||||
},
|
|
||||||
"buildConfig": {
|
|
||||||
"$ref": "#/properties/buildConfig"
|
|
||||||
},
|
|
||||||
"contextName": {
|
|
||||||
"$id": "#/properties/targets/items/anyOf/0/properties/contextName",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The Target contextName",
|
|
||||||
"description": "The name of the compute context used to execute SAS code. The context determines the way in which the SAS session is spawned (eg user credentials, autoexec code, system options etc).\nContexts can be created / modified / deleted using the `sasjs context` command.",
|
|
||||||
"default": "SAS Job Execution compute context",
|
|
||||||
"examples": ["SAS Job Execution compute context"]
|
|
||||||
},
|
|
||||||
"deployConfig": {
|
|
||||||
"$ref": "#/properties/deployConfig"
|
|
||||||
},
|
|
||||||
"httpsAgentOptions": {
|
|
||||||
"$ref": "#/properties/httpsAgentOptions"
|
|
||||||
},
|
|
||||||
"serverUrl": {
|
|
||||||
"$id": "#/properties/targets/items/anyOf/0/properties/serverUrl",
|
|
||||||
"type": "string",
|
|
||||||
"title": "The Target serverUrl",
|
|
||||||
"description": "The serverUrl is the location to which the app is deployed, and against which any server based operations are performed. If SAS is served from a particular port, that port should also be included here.",
|
|
||||||
"default": "",
|
|
||||||
"examples": [
|
|
||||||
"https://sas.sasjs.com",
|
|
||||||
"https://sas.sasjs.com:8080"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"serviceConfig": {
|
|
||||||
"$ref": "#/properties/serviceConfig"
|
|
||||||
},
|
|
||||||
"jobConfig": {
|
|
||||||
"$ref": "#/properties/jobConfig"
|
|
||||||
},
|
|
||||||
"docConfig": {
|
|
||||||
"$ref": "#/properties/docConfig"
|
|
||||||
},
|
|
||||||
"streamConfig": {
|
|
||||||
"$ref": "#/properties/streamConfig"
|
|
||||||
},
|
|
||||||
"syncDirectories": {
|
|
||||||
"$ref": "#/properties/syncDirectories"
|
|
||||||
},
|
|
||||||
"syncFolder": {
|
|
||||||
"$ref": "#/properties/syncFolder"
|
|
||||||
},
|
|
||||||
"testConfig": {
|
|
||||||
"$ref": "#/properties/testConfig"
|
|
||||||
},
|
|
||||||
"macroFolders": {
|
|
||||||
"$ref": "#/properties/macroFolders"
|
|
||||||
},
|
|
||||||
"programFolders": {
|
|
||||||
"$ref": "#/properties/programFolders"
|
|
||||||
},
|
|
||||||
"sasjsBuildFolder": {
|
|
||||||
"$ref": "#properties/sasjsBuildFolder"
|
|
||||||
},
|
|
||||||
"sasjsResultsFolder": {
|
|
||||||
"$ref": "#properties/sasjsResultsFolder"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
---
|
|
||||||
name: sasjs-server
|
|
||||||
description: Installing, configuring, and running @sasjs/server — the open-source NodeJS wrapper around the SAS binary that provides a REST API, filesystem (SASjs Drive), Stored Program execution, and web app streaming. Covers desktop vs server modes, runtimes (SAS/JS/Python/R), env vars, auth (tokens, LDAP), and mock servers. Use when deploying, troubleshooting, or developing against sasjs/server.
|
|
||||||
---
|
|
||||||
|
|
||||||
# @sasjs/server
|
|
||||||
|
|
||||||
SASjs Server is an open-source NodeJS wrapper for calling the SAS binary executable. It runs on a real SAS server or a local desktop and provides:
|
|
||||||
|
|
||||||
- A filesystem (SASjs Drive) for storing SAS programs and content
|
|
||||||
- Execution of Stored Programs from a URL (equivalent to SAS 9 Stored Processes / Viya Jobs)
|
|
||||||
- Web app streaming (serve frontend apps straight from SAS content)
|
|
||||||
- A REST API with Swagger docs
|
|
||||||
- Portability: apps built for SASjs Server deploy unchanged to SAS 9 / Viya via @sasjs/cli
|
|
||||||
|
|
||||||
## Modes
|
|
||||||
|
|
||||||
- **Desktop mode** (`MODE=desktop`, default): single-user, no authentication, no database. CORS enabled by default.
|
|
||||||
- **Server mode** (`MODE=server`): multi-user with authentication, requires a database (`DB_CONNECT`, `DB_TYPE=mongodb|cosmos_mongodb`). CORS disabled by default — configure `WHITELIST` if enabling.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Download the relevant zip from GitHub releases and run the packaged executable (`api-linux`, etc.):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
|
||||||
unzip linux.zip && ./api-linux
|
|
||||||
```
|
|
||||||
|
|
||||||
On first run it prompts (unless set as env vars) for the SAS executable path and the filesystem location for Stored Programs/temp files. Docker is also supported (`DockerfileApi`, docker-compose files in the repo).
|
|
||||||
|
|
||||||
## Configuration via environment variables
|
|
||||||
|
|
||||||
Set in `/etc/environment`, exported, prepended to the command, or in a `.env` file alongside the executable. Key variables:
|
|
||||||
|
|
||||||
| Variable | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `MODE` | `desktop` (default) or `server` |
|
|
||||||
| `SAS_PATH` | Path to `sas.exe` / `sas.sh` |
|
|
||||||
| `RUN_TIMES` | Comma-separated runtime priority, e.g. `sas,js,py` — options: `sas`, `js`, `py`, `r`. Each needs its path: `SAS_PATH`, `NODE_PATH`, `PYTHON_PATH`, `R_PATH` |
|
|
||||||
| `SASJS_ROOT` | Working directory: SAS WORK, staged files, drive, config |
|
|
||||||
| `DRIVE_LOCATION` | Location for files, sasjs packages, `appStreamConfig.json` |
|
|
||||||
| `PROTOCOL` / `PORT` | `http` (default) or `https` (needs `PRIVATE_KEY`, `CERT_CHAIN`, optional `CA_ROOT`); default port 5000 |
|
|
||||||
| `SAS_OPTIONS` / `SASV9_OPTIONS` | Extra SAS system options auto-applied to sessions (Windows vs Unix), e.g. `-NOXCMD` |
|
|
||||||
| `DB_CONNECT` / `DB_TYPE` | MongoDB connection string / type — required for server mode |
|
|
||||||
| `AUTH_PROVIDERS` + `LDAP_*` | LDAP auth: `LDAP_URL`, `LDAP_BIND_DN`, `LDAP_BIND_PASSWORD`, `LDAP_USERS_BASE_DN`, `LDAP_GROUPS_BASE_DN` |
|
|
||||||
| `CORS` / `WHITELIST` | CORS is only applied when `CORS=enable`, and only origins in `WHITELIST` (space-separated) receive `Access-Control-Allow-Origin` — an empty whitelist means NO cross-origin calls work |
|
|
||||||
| `MOCK_SERVERTYPE` / `STATIC_MOCK_LOCATION` | Emulate `sas9`/`sasviya` API responses for frontend testing against a sasjs server (static canned files only — no logic) |
|
|
||||||
|
|
||||||
## Developing against the API
|
|
||||||
|
|
||||||
- Server type for @sasjs/adapter / CLI targets is `SASJS` (`serverType: 'SASJS'`); auth is token-based.
|
|
||||||
- The REST API is self-documented via Swagger on the running instance.
|
|
||||||
- Server-side execution uses `ms_*` macros from @sasjs/core (e.g. `ms_createfile`, `ms_adduser2group`) — services/jobs deployed by the CLI work as on other platforms.
|
|
||||||
- Repo layout (for contributors): `api/` (Express/TypeScript backend: controllers, routes, middlewares, model), `web/` (frontend), `restClient/` (REST examples), `mongo-seed/` (server-mode DB seed).
|
|
||||||
|
|
||||||
## Mock services with the JS runtime (no SAS required)
|
|
||||||
|
|
||||||
With `RUN_TIMES=js` (and `NODE_PATH` set), any `.js` file on SASjs Drive is an executable Stored Program — this is how react-seed-app / Data Controller provide **mock backends** for frontend development. Desktop mode (`MODE=desktop`) has no auth, which makes local mocking trivial.
|
|
||||||
|
|
||||||
Writing a JS stored program (docs: https://server.sasjs.io/storedprograms/#js-programs):
|
|
||||||
|
|
||||||
- The runtime template predeclares `const fs = require('fs')`, `_program`, `weboutPath`, `_SASJS_TOKENFILE`, `_SASJS_WEBOUT_HEADERS`, `_SASJS_USERNAME` / `_SASJS_USERID` / `_SASJS_DISPLAYNAME`, `_METAPERSON`, `_METAUSER`, `SASJSPROCESSMODE`. **Do NOT redeclare `fs`** — `const fs = require('fs')` in your program crashes it with `Identifier 'fs' has already been declared`.
|
|
||||||
- Output: assign a JSON **string** to `_webout` (e.g. `_webout = JSON.stringify({...})`); it is written back only if non-empty. `console.log()` output is returned in the response `log` (like a SAS log). Custom response headers can be written as lines to the `_SASJS_WEBOUT_HEADERS` file.
|
|
||||||
- Mimic real services by including the standard SASjs automatic fields in the JSON: `_PROGRAM` (from `_program`), `SYSDATE` / `SYSTIME` (format `DDMMMYY` / `HH:mm`), `_METAUSER`, `SASJSPROCESSMODE`.
|
|
||||||
- URL/body parameters arrive as `const <name> = \`<value>\`` strings.
|
|
||||||
- Input tables (the `sasjs_tables` mechanism) arrive **either** as an inline CSV const **or** — when the adapter sends multipart — as an uploaded `<name>.csv` file in the session folder, referenced by generated module-scope consts (handle BOTH):
|
|
||||||
- `_WEBIN_FILE_COUNT` (always created), `_WEBIN_NAME<n>` (table/field name), `_WEBIN_FILENAME<n>` (original filename), `_WEBIN_FILEREF<n>` (file **contents**, a Buffer from `fs.readFileSync` — call `.toString('utf8')`)
|
|
||||||
- these consts are **not on `globalThis`** — look them up with `typeof` guards or direct `eval()` in module scope (server-side JS, no CSP)
|
|
||||||
- adapter CSV quirks: header row is **space-separated** `name:format.` entries (e.g. `rootdir:$char256.`) — strip the `:format` suffix; lines end CRLF; values containing special characters are wrapped in double quotes with `""` escaping
|
|
||||||
- Adapter response shape: `sasjs.request()` resolves with the webout JSON **already unwrapped** — output tables are arrays of row objects directly on the response (`res.mytable[0].COL`). A table named `result` is perfectly fine (`res.result` is then that array); do NOT add your own `res.result`-unwrapping layer, it breaks exactly that case.
|
|
||||||
- Third-party npm packages are NOT resolvable at runtime — bundle the service first (e.g. `npx webpack --mode none --target node --entry <file> --output-path sasjsbuild/... --output-filename <name>.js`), then `sasjs build` / `sasjs deploy`.
|
|
||||||
|
|
||||||
Deploying mocks:
|
|
||||||
|
|
||||||
- `sasjs fs sync` does NOT work on a JS-only server (it generates and executes SAS code to hash remote files). Upload files directly via the Drive API instead: `DELETE` then `POST /SASjsApi/drive/file?_filePath=<appLoc>/services/<folder>/<name>.js` (multipart `file` field). In desktop mode no auth headers are needed; in server mode read the `Authorization` header line from `_SASJS_TOKENFILE`.
|
|
||||||
- Mocks can be stateful with the predeclared `fs`. Prefer real locations over `/tmp`: the SASjs Drive root is derivable from `weboutPath` (`<root>/sessions/<id>/webout.txt` → `path.resolve(weboutPath, '..', '..', '..', 'drive')`), and a mock `configure`-style service can treat a configured folder as a real local path (the server IS local). `require('path')` and other core modules work (only `fs` is predeclared).
|
|
||||||
- A JS program can even call the server's own REST API (`http://127.0.0.1:$PORT/SASjsApi/...`) — e.g. to rewrite a streamed `index.html` on the Drive (`GET` + `PATCH /SASjsApi/drive/file`).
|
|
||||||
|
|
||||||
Gotchas:
|
|
||||||
|
|
||||||
- The packaged binaries (`api-linux` etc.) reject some globally-exported `NODE_OPTIONS` (e.g. `--network-family-autoselection`) — start with `NODE_OPTIONS="" ./api-linux`.
|
|
||||||
- AppStream URLs redirect to a trailing slash (`/AppStream/MyApp` → 301 → `/AppStream/MyApp/`) — test/automation scripts should use the trailing-slash URL directly.
|
|
||||||
@@ -125,16 +125,8 @@ jobs:
|
|||||||
cd ./sas/mocks/sasjs
|
cd ./sas/mocks/sasjs
|
||||||
npm install -g @sasjs/cli
|
npm install -g @sasjs/cli
|
||||||
npm install -g replace-in-files-cli
|
npm install -g replace-in-files-cli
|
||||||
# Remove any previous deployment (drive folder delete API) so the
|
|
||||||
# deploy and makedata start from a clean appLoc
|
|
||||||
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
|
|
||||||
sasjs cbd -t server-ci
|
sasjs cbd -t server-ci
|
||||||
# Seed the DC database (mock data files on the drive).
|
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
|
||||||
# makedata replies with HTML (it is normally called as a URL redirect),
|
|
||||||
# which the CLI reports as "invalid Json string" - ignore that.
|
|
||||||
# NOTE: -d paths resolve from the sasjs project root (sas/mocks),
|
|
||||||
# not the cwd (sas/mocks/sasjs).
|
|
||||||
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
|
|
||||||
|
|
||||||
- name: Prepare and run frontend and cypress
|
- name: Prepare and run frontend and cypress
|
||||||
timeout-minutes: 35
|
timeout-minutes: 35
|
||||||
@@ -147,7 +139,7 @@ jobs:
|
|||||||
npm run postinstall
|
npm run postinstall
|
||||||
# Prepare index.html to SASJS local
|
# Prepare index.html to SASJS local
|
||||||
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
|
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
|
||||||
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/dc"' ./src/index.html
|
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/devtest"' ./src/index.html
|
||||||
replace-in-files --regex='serverType=".*?"' --replacement='serverType="SASJS"' ./src/index.html
|
replace-in-files --regex='serverType=".*?"' --replacement='serverType="SASJS"' ./src/index.html
|
||||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||||
cat ./cypress.config.ts
|
cat ./cypress.config.ts
|
||||||
|
|||||||
@@ -125,16 +125,8 @@ jobs:
|
|||||||
cd ./sas/mocks/sasjs
|
cd ./sas/mocks/sasjs
|
||||||
npm install -g @sasjs/cli
|
npm install -g @sasjs/cli
|
||||||
npm install -g replace-in-files-cli
|
npm install -g replace-in-files-cli
|
||||||
# Remove any previous deployment (drive folder delete API) so the
|
|
||||||
# deploy and makedata start from a clean appLoc
|
|
||||||
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
|
|
||||||
sasjs cbd -t server-ci
|
sasjs cbd -t server-ci
|
||||||
# Seed the DC database (mock data files on the drive).
|
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
|
||||||
# makedata replies with HTML (it is normally called as a URL redirect),
|
|
||||||
# which the CLI reports as "invalid Json string" - ignore that.
|
|
||||||
# NOTE: -d paths resolve from the sasjs project root (sas/mocks),
|
|
||||||
# not the cwd (sas/mocks/sasjs).
|
|
||||||
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
|
|
||||||
|
|
||||||
- name: Prepare and run frontend and cypress
|
- name: Prepare and run frontend and cypress
|
||||||
run: |
|
run: |
|
||||||
@@ -146,7 +138,7 @@ jobs:
|
|||||||
npm run postinstall
|
npm run postinstall
|
||||||
# Prepare index.html to SASJS local
|
# Prepare index.html to SASJS local
|
||||||
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
|
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
|
||||||
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/dc"' ./src/index.html
|
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/devtest"' ./src/index.html
|
||||||
replace-in-files --regex='serverType=".*?"' --replacement='serverType="SASJS"' ./src/index.html
|
replace-in-files --regex='serverType=".*?"' --replacement='serverType="SASJS"' ./src/index.html
|
||||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||||
cat ./cypress.config.ts
|
cat ./cypress.config.ts
|
||||||
|
|||||||
+1
-3
@@ -22,6 +22,4 @@ sasjsresults
|
|||||||
.sasjsrc
|
.sasjsrc
|
||||||
client/.npmrc
|
client/.npmrc
|
||||||
*~
|
*~
|
||||||
.lighthouseci
|
.lighthouseci
|
||||||
audit.log
|
|
||||||
tmp/
|
|
||||||
@@ -7,7 +7,7 @@ Read **`CONTEXT.md`** at the repo root first - it is the domain glossary and ori
|
|||||||
Data Controller spans three sibling repos (usually checked out side by side):
|
Data Controller spans three sibling repos (usually checked out side by side):
|
||||||
|
|
||||||
- **`dc`** (this repo) - the product source (Angular client + SAS backend).
|
- **`dc`** (this repo) - the product source (Angular client + SAS backend).
|
||||||
- **`docs.datacontroller.io`** - the user-facing product documentation (MkDocs). User-facing behaviour is documented there; the `.agents/docs/` deep-dives here link out to it.
|
- **`docs.datacontroller.io`** - the user-facing product documentation (MkDocs). User-facing behaviour is documented there; the `.agent/docs/` deep-dives here link out to it.
|
||||||
- **`datacontroller.io`** - the marketing site, blog and feed (Gatsby).
|
- **`datacontroller.io`** - the marketing site, blog and feed (Gatsby).
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
@@ -16,7 +16,7 @@ Do NOT auto-commit. Never run `git commit` (or `git push`) unless the user expli
|
|||||||
|
|
||||||
## CHANGELOG and versioning
|
## CHANGELOG and versioning
|
||||||
|
|
||||||
Do NOT edit `CHANGELOG.md` by hand, and do NOT bump the `version` in `package.json`. The release pipeline (semantic-release, in `.gitea/workflows/release.yaml`) generates both automatically from Conventional Commit messages on every push to `main`. Manual entries collide with the pipeline's release commit and are overwritten anyway. Control the changelog through your commit messages instead. See `.agents/docs/releases-and-changelog.md`.
|
Do NOT edit `CHANGELOG.md` by hand, and do NOT bump the `version` in `package.json`. The release pipeline (semantic-release, in `.gitea/workflows/release.yaml`) generates both automatically from Conventional Commit messages on every push to `main`. Manual entries collide with the pipeline's release commit and are overwritten anyway. Control the changelog through your commit messages instead. See `.agent/docs/releases-and-changelog.md`.
|
||||||
|
|
||||||
## Markdown files
|
## Markdown files
|
||||||
|
|
||||||
@@ -37,11 +37,9 @@ Never consider a change complete until the relevant linters pass on the files yo
|
|||||||
|
|
||||||
Data Controller must run entirely locally (offline / on-prem, no internet access). The built product must never fetch assets from remote servers — no external fonts, images, scripts, stylesheets, CDN links, or remote URLs in meta tags (e.g. `og:image`, `og:url`, `itemprop="image"`). All assets must be bundled and served locally.
|
Data Controller must run entirely locally (offline / on-prem, no internet access). The built product must never fetch assets from remote servers — no external fonts, images, scripts, stylesheets, CDN links, or remote URLs in meta tags (e.g. `og:image`, `og:url`, `itemprop="image"`). All assets must be bundled and served locally.
|
||||||
|
|
||||||
## The .agents folder
|
## The .agent folder
|
||||||
|
|
||||||
Agent-related content lives in `.agents/`: technical/agent-facing documentation goes in `.agents/docs/` (not `docs/`), and skills in `.agents/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agents/docs/`.
|
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/`.
|
||||||
|
|
||||||
Skills installed via `npx skills add` are tracked in `skills-lock.json` and live under `.agents/skills/`. Repo-local custom skills (e.g. `dc-sas` for Data Controller SAS development, `handsontable`, `hyperformula`) are kept here too. Avoid editing npx-managed skill files directly — use `npx skills update` instead.
|
|
||||||
|
|
||||||
## Code comments and test names
|
## Code comments and test names
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +1,3 @@
|
|||||||
# [7.14.0](https://git.datacontroller.io/dc/dc/compare/v7.13.0...v7.14.0) (2026-09-04)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **system:** show session timezone on system information screen ([966dc07](https://git.datacontroller.io/dc/dc/commit/966dc072e0cb8bb44bd93a7682c3d7335428d688))
|
|
||||||
|
|
||||||
# [7.13.0](https://git.datacontroller.io/dc/dc/compare/v7.12.0...v7.13.0) (2026-09-03)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* dynamic js mocks ([3b9344c](https://git.datacontroller.io/dc/dc/commit/3b9344cb602b609774737dcbb6df6756a2a08edb))
|
|
||||||
* adding formula types ([c07a01a](https://git.datacontroller.io/dc/dc/commit/c07a01a38da6a6e4edaf014ef2fd4d2691b29698))
|
|
||||||
* address hermes review feedback (formula quoting, cell revert, addRow guard) ([36963aa](https://git.datacontroller.io/dc/dc/commit/36963aa74627b861aee9ec91428a8dbee0946dfc))
|
|
||||||
* agent skills and nextviya deploys ([f7db871](https://git.datacontroller.io/dc/dc/commit/f7db8719f5fe860605f4e6da81bad925d7412599))
|
|
||||||
* bump core for mp_validate fix ([5a44b28](https://git.datacontroller.io/dc/dc/commit/5a44b2804feddbd616ece1d721865e02bfbedda8))
|
|
||||||
* CAS support for REPLACE type plus docs ([ea05f07](https://git.datacontroller.io/dc/dc/commit/ea05f0718047a0ac54cffc58938b6d06f5a112e2))
|
|
||||||
* clarify debug comment is permanent, extend ComputeContextDetails with Viya response fields ([72484ae](https://git.datacontroller.io/dc/dc/commit/72484ae8441715813983faa221be348cf421b735))
|
|
||||||
* core major bump plus autofix of viya context on deploy ([6527c10](https://git.datacontroller.io/dc/dc/commit/6527c10f262249187c27723d983edd32c3848d72))
|
|
||||||
* **core:** bump to v5 (breaking change) ([59e9e96](https://git.datacontroller.io/dc/dc/commit/59e9e96f5a6a6c9c0560cbb8387982611a0c8ca4))
|
|
||||||
* **cypress:** correct mock data, test expectations, and abort modal ([13c10be](https://git.datacontroller.io/dc/dc/commit/13c10be30faae736d27b584e0507eaab702d2d32))
|
|
||||||
* **cypress:** make licensing combined-key tests order-independent ([4db8d62](https://git.datacontroller.io/dc/dc/commit/4db8d62dca9257764438b00ef034103a0703111a))
|
|
||||||
* **deploy:** Viya deploy checks, startup diagnostics, and chunked deploy script ([917925f](https://git.datacontroller.io/dc/dc/commit/917925f55393aca919c2a7c7cd7dbecadbc24ace)), closes [#303](https://git.datacontroller.io/dc/dc/issues/303) [#200](https://git.datacontroller.io/dc/dc/issues/200) [#125](https://git.datacontroller.io/dc/dc/issues/125)
|
|
||||||
* **deps:** align @angular/* packages to the same lockstep version ([24e6297](https://git.datacontroller.io/dc/dc/commit/24e62971879e61eef0fc3ea9f07796b92231dbe3))
|
|
||||||
* **deps:** override nanoid to 3.3.18 in sas/ ([d57ae03](https://git.datacontroller.io/dc/dc/commit/d57ae03fc44770f41de5dbe0389d33cd11fc0971))
|
|
||||||
* **deps:** pin @handsontable/angular-wrapper to 18.0.0 ([2d7df66](https://git.datacontroller.io/dc/dc/commit/2d7df66fc28ffe07e859920c052b9818ed58647f))
|
|
||||||
* **deps:** pin babel-loader to resolve an unresolvable peer conflict ([3bf3bf0](https://git.datacontroller.io/dc/dc/commit/3bf3bf0dde943246380528d5af7c46ad1f518c79))
|
|
||||||
* **deps:** regenerate lockfile with Node 24 and pin handsontable to 18.0.0 ([7da29bd](https://git.datacontroller.io/dc/dc/commit/7da29bdb1180a9f50055380c1df3649283e9cdf6))
|
|
||||||
* **deps:** regenerate lockfile with strict peer-dep resolution to fix npm ci in CI ([efd2e18](https://git.datacontroller.io/dc/dc/commit/efd2e18c7b09f80655528a2f6985034f5a41dcf3))
|
|
||||||
* **deps:** regenerate package-lock.json to resolve npm ci sync errors ([a501903](https://git.datacontroller.io/dc/dc/commit/a501903e6d4276f1b4eba8f8ae0feccafcfbcf86))
|
|
||||||
* **deps:** resolve npm audit findings and align @angular/* to the same lockstep version ([b9e4b27](https://git.datacontroller.io/dc/dc/commit/b9e4b2733fa01e2b8060dab54277000a2c6252b8))
|
|
||||||
* **deps:** resolve npm audit findings via direct lockfile patch, not regeneration ([4d1bfa6](https://git.datacontroller.io/dc/dc/commit/4d1bfa6343342a167bdc315204866a18bcbca8ed))
|
|
||||||
* **deps:** resolve npm audit vulnerabilities in production dependencies ([257f69c](https://git.datacontroller.io/dc/dc/commit/257f69ccc6dd070daf8c73f597a08d01e8ea6888))
|
|
||||||
* **deps:** scope the brace-expansion override to its actual vulnerable chain ([f9d061c](https://git.datacontroller.io/dc/dc/commit/f9d061c48901d9ef131303940f0c2e2948b12fdf))
|
|
||||||
* dynamic getdynamiccolvals.js ([d59ef44](https://git.datacontroller.io/dc/dc/commit/d59ef442266bd4823f5cad3f7f4985e3df9afb48))
|
|
||||||
* **editor:** clear sort before reading cells to preserve on cancel ([4137ebd](https://git.datacontroller.io/dc/dc/commit/4137ebdf6b6563eeac4f805708c3b4ffcd099fb1))
|
|
||||||
* **editor:** escape formula-looking values in uploaded Excel data ([e69df3d](https://git.datacontroller.io/dc/dc/commit/e69df3deb22d80b1371b63a5ae1c6a44538b00bc))
|
|
||||||
* **editor:** fix formula $-substitution corruption and sorted-grid row/formula desync ([e139c37](https://git.datacontroller.io/dc/dc/commit/e139c37fbfe46123cce2cbfcf7e919bc68e19a2f))
|
|
||||||
* **editor:** let the column info dropdown's text be selected and copied ([1e516f4](https://git.datacontroller.io/dc/dc/commit/1e516f4012947c3f42dec1f3a46fa364c39bfe3e))
|
|
||||||
* **editor:** re-mark a reverted cell as auto-escaped so "Apply as formula" works again ([12091c4](https://git.datacontroller.io/dc/dc/commit/12091c4044b0982c36b7d6011b51625bfb6b8cb5))
|
|
||||||
* **editor:** resolve a primary key's live formula to its computed value, not the raw formula text ([d72e19a](https://git.datacontroller.io/dc/dc/commit/d72e19a3086ef578a1304fe032e1088c6ef60cf7))
|
|
||||||
* **editor:** resolve computed value for a live formula in numeric columns too ([2cc8489](https://git.datacontroller.io/dc/dc/commit/2cc8489892638aaf97fd5999ebc6086ffd674a76))
|
|
||||||
* **editor:** restore multi-column sort with one call, not a loop ([8fafeb7](https://git.datacontroller.io/dc/dc/commit/8fafeb7923bc7dae178c4fed0898195d5b5825a6))
|
|
||||||
* **editor:** size table-header buttons and title to content, not fixed grid thirds ([d0a7561](https://git.datacontroller.io/dc/dc/commit/d0a7561f1aeb67b214e6a473cc6235509daa80bb))
|
|
||||||
* **editor:** stop dc.row_status from being submitted to the backend ([dc0f6a7](https://git.datacontroller.io/dc/dc/commit/dc0f6a7baa128e0b75d7341ac7c03dcd4b1ffee5))
|
|
||||||
* **editor:** stop turning missing columns into undefined, and fix column lookup for sparse rows ([a104f78](https://git.datacontroller.io/dc/dc/commit/a104f78645bc7777fee672956d3f028ff52ed9ba))
|
|
||||||
* **editor:** submit a live formula's computed value, not its raw text ([b7d3106](https://git.datacontroller.io/dc/dc/commit/b7d31066ae5272856ea442d0f76d2f0752015dbb))
|
|
||||||
* ensure cleanup of casuser temp table in error condition ([c4123e5](https://git.datacontroller.io/dc/dc/commit/c4123e5c965ba3f78ec8cf32f915f263d218ca56))
|
|
||||||
* ensuring formats arrive from backend in getstagetable.sas ([daacb49](https://git.datacontroller.io/dc/dc/commit/daacb49c8a2c6a266dd842a8b7ccdebaa992a1d7))
|
|
||||||
* escape regex metacharacters in context name before prxchange ([9023eb2](https://git.datacontroller.io/dc/dc/commit/9023eb2a393ee8441bab5b9c5ff4f53b500e0e91))
|
|
||||||
* failing test ([f213d24](https://git.datacontroller.io/dc/dc/commit/f213d24f096d814d286bfdac5a28748e97e7293f))
|
|
||||||
* fixed npm vuln ([26b55b1](https://git.datacontroller.io/dc/dc/commit/26b55b1bde5e28afde2d4f9889cb452985410c1d))
|
|
||||||
* **formulas:** avoid EDIT_STATUS colliding with a real column of that name ([acb97f4](https://git.datacontroller.io/dc/dc/commit/acb97f4bfb1c7d6a0b38dd33207170f6ec2bbd1d))
|
|
||||||
* hardening following PR review feedback ([1f4aa6f](https://git.datacontroller.io/dc/dc/commit/1f4aa6fceafc4b51ecc5a19f4789cf5bd018a5d3))
|
|
||||||
* history mock ([0082828](https://git.datacontroller.io/dc/dc/commit/00828285435a412e3e5dccb045b053c1096e8876))
|
|
||||||
* **licensing:** bridge async proceed() through cy.then and replace Response.arrayBuffer with manual stream reader ([8e51fde](https://git.datacontroller.io/dc/dc/commit/8e51fde524e799652fbd1f0b0763d667cb3a4845))
|
|
||||||
* **mocks:** add fixture staged data and MPE_SUBMIT row for stage.cy.ts ([6e2548d](https://git.datacontroller.io/dc/dc/commit/6e2548d78c613d684fc8d874ba11b30fc9b4e32e))
|
|
||||||
* **mocks:** add LIBRARYNAME to startupservice, fix viewbox column order ([c0af86e](https://git.datacontroller.io/dc/dc/commit/c0af86ef297334ee531e979754d4d289ed3900ea))
|
|
||||||
* **mocks:** limit stage fixture to 1 row, fix column order ([f25ecd8](https://git.datacontroller.io/dc/dc/commit/f25ecd88283433770f56167774617f0e2089c6ef))
|
|
||||||
* **mocks:** look up PK_FIELDS from MPE_TABLES in viewdata, fallback for MPE_AUDIT ([94d2a79](https://git.datacontroller.io/dc/dc/commit/94d2a7970e49845b235c71523418e4a5d2a70396))
|
|
||||||
* **mocks:** remove MPE_VALIDATIONS and MPE_ALERTS from DC996664 libref ([4f7b845](https://git.datacontroller.io/dc/dc/commit/4f7b845390d50db50114afa7fa851ddf3e853ed7))
|
|
||||||
* **mocks:** remove shadowed saveTableData in rejection.js ([fed0396](https://git.datacontroller.io/dc/dc/commit/fed039673777659594feb9a92a5d9c2dca2cc544))
|
|
||||||
* **mocks:** remove SOME_SHORTNUM HARDREGEX from MPE_VALIDATIONS ([34bd660](https://git.datacontroller.io/dc/dc/commit/34bd6607c547c3d40c1129395a28e4cbf0fa1475))
|
|
||||||
* **mocks:** rename saveTableData in postdata.js to avoid shadowing ([e4b04b1](https://git.datacontroller.io/dc/dc/commit/e4b04b1d447640542c122dce93d65219719cb094))
|
|
||||||
* **mocks:** SAS datetime format in getchangeinfo, login in stage.cy.ts ([c180934](https://git.datacontroller.io/dc/dc/commit/c1809341672ad51baa603f104176fdc302e4c0c1))
|
|
||||||
* more mock improvements ([a80927c](https://git.datacontroller.io/dc/dc/commit/a80927cbb6c8a5ce26a1d251dc10cae4bae1eedd))
|
|
||||||
* mp_execute dep ([023c29f](https://git.datacontroller.io/dc/dc/commit/023c29f00f74f964825e0ee01768baf44aead332))
|
|
||||||
* pin fast-uri to 3.1.7 to close high-severity SSRF/host-confusion advisories ([f4325b2](https://git.datacontroller.io/dc/dc/commit/f4325b205699bf02158c232ffd8c279489778512)), closes [hi#severity](https://git.datacontroller.io/hi/issues/severity)
|
|
||||||
* spy for startup ([69b4155](https://git.datacontroller.io/dc/dc/commit/69b41559f7aa66a537b685e26e00c44f1f2568c4))
|
|
||||||
* **stage:** submit data via editor flow, use dynamic date assertion ([490d013](https://git.datacontroller.io/dc/dc/commit/490d0137b921c150b431083b847d471aa379bc42))
|
|
||||||
* **startup:** show the real startupservice response text on a malformed reply ([9a1b7d0](https://git.datacontroller.io/dc/dc/commit/9a1b7d0f52eebe6bea229dedbcb8f5c19bc96141))
|
|
||||||
* tests and realistic getcolvals.js ([5d03164](https://git.datacontroller.io/dc/dc/commit/5d03164b6a3245b47f1278fe962c537f72a8f473))
|
|
||||||
* **tests:** new libref ([c0bc87d](https://git.datacontroller.io/dc/dc/commit/c0bc87d331b081d787b4ed3d04c639c139d0fa1b))
|
|
||||||
* unnecessary getsubmits call removed ([cc2ff87](https://git.datacontroller.io/dc/dc/commit/cc2ff873fae58f41332dcbe725a49f0815b219b3))
|
|
||||||
* using utility macro for webout ([3595bb9](https://git.datacontroller.io/dc/dc/commit/3595bb94993aa054235cce3f41b9d83711907c46))
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **editor:** color row-header status cells and switch modified symbol to ± ([e15f2c2](https://git.datacontroller.io/dc/dc/commit/e15f2c2a3488f43ed4e5814fdc22527961cc5c22))
|
|
||||||
* **editor:** generalize cell revert to any overwritten value, not just formulas ([07d586d](https://git.datacontroller.io/dc/dc/commit/07d586da5235a2d94b2b689c774c40851b9a57e2))
|
|
||||||
* **editor:** scope live formulas to character columns, not just HARDFORMULA/SOFTFORMULA ones ([92d0d91](https://git.datacontroller.io/dc/dc/commit/92d0d91440ff6d58d10f1af291386a1813cb07d4))
|
|
||||||
* **editor:** translate column names to cell references on formula paste ([1031ea7](https://git.datacontroller.io/dc/dc/commit/1031ea7ed7b1a74ecc77bf155f6d329feeaf4b13))
|
|
||||||
* **formulas:** flag formula-overwritten cells with revert, harden dc.row_status against SAS name collisions ([a8237b2](https://git.datacontroller.io/dc/dc/commit/a8237b2881b85854bb52c85ca2f17e3368f91f4a))
|
|
||||||
* **formulas:** live DC.ROW_STATUS, insert-row formula fixes, UserService singleton fix ([4a8c39b](https://git.datacontroller.io/dc/dc/commit/4a8c39b4c0d6e0edb3493ac3d26d86788ebfdf90))
|
|
||||||
* **formulas:** wire HyperFormula into HARDFORMULA/SOFTFORMULA rules ([cbea04c](https://git.datacontroller.io/dc/dc/commit/cbea04c8e129d7d28a626215aae3b67735a036d5))
|
|
||||||
* functional JS mocks ([ee52bb8](https://git.datacontroller.io/dc/dc/commit/ee52bb8967e5bcdcfd17500678295e6bffbe2cf0))
|
|
||||||
* **licensing:** support combined-key paste, object-format features, and a live key preview ([20ba633](https://git.datacontroller.io/dc/dc/commit/20ba63308717dbde929dbe5974b76f160343de29))
|
|
||||||
* **licensing:** warn and block applying a licence key generated for the wrong protocol ([41680e2](https://git.datacontroller.io/dc/dc/commit/41680e2eccad82584bf9fd71a1efd1fd2d8f1a39))
|
|
||||||
* **stage:** add Formatted/Unformatted toggle to the staging page ([74c38e1](https://git.datacontroller.io/dc/dc/commit/74c38e16416dcd8be20faa66d9f282a6438522b5))
|
|
||||||
* updated configurator for Viya deploy ([df2027d](https://git.datacontroller.io/dc/dc/commit/df2027dab6ae51fb9cabf21e35d1fa5d6e1f7957))
|
|
||||||
* **viewboxes:** add drag-to-resize on all four edges and corners ([e5a5bf2](https://git.datacontroller.io/dc/dc/commit/e5a5bf2144d7f35c62368924c7e59f55eeb246ad))
|
|
||||||
|
|
||||||
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)
|
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -6,9 +6,9 @@ This file is the shared glossary and orientation for the repo. When your output
|
|||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
- `client/` - the Angular frontend (TypeScript). Uses [Handsontable](.agents/skills/handsontable/SKILL.md) for the editable grid and [HyperFormula](.agents/skills/hyperformula/SKILL.md) for Excel-formula support. Lint with `npm run lint:check` from `client/`.
|
- `client/` - the Angular frontend (TypeScript). Uses [Handsontable](.agent/skills/handsontable/SKILL.md) for the editable grid and [HyperFormula](.agent/skills/hyperformula/SKILL.md) for Excel-formula support. Lint with `npm run lint:check` from `client/`.
|
||||||
- `sas/` - the SAS backend. Services, macros, hooks and tests live under `sas/sasjs/**`; `sas/sasjsbuild/` is generated output (never hand-edit). Lint `.sas` files with `sasjs lint` from `sas/`.
|
- `sas/` - the SAS backend. Services, macros, hooks and tests live under `sas/sasjs/**`; `sas/sasjsbuild/` is generated output (never hand-edit). Lint `.sas` files with `sasjs lint` from `sas/`.
|
||||||
- `.agents/docs/` - technical deep-dives (see below). `.agents/skills/` - task skills (handsontable, hyperformula, dc-sas for Data Controller SAS development, plus npx-installed sasjs/ sas skills).
|
- `.agent/docs/` - technical deep-dives (see below). `.agent/skills/` - task skills (handsontable, hyperformula, sas).
|
||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
|
|
||||||
@@ -32,10 +32,10 @@ Admins are listed in `&mpeadmins`; admin membership bypasses Row Level Security.
|
|||||||
|
|
||||||
## Load types
|
## Load types
|
||||||
|
|
||||||
Set per table in `MPE_TABLES.LOADTYPE`. Determines the loader and how history is kept (see [.agents/docs/bitemporal-dataloader.md](.agents/docs/bitemporal-dataloader.md)):
|
Set per table in `MPE_TABLES.LOADTYPE`. Determines the loader and how history is kept (see [.agent/docs/bitemporal-dataloader.md](.agent/docs/bitemporal-dataloader.md)):
|
||||||
|
|
||||||
- **UPDATE** - no history; changed records deleted and re-appended (via `%bitemporal_dataloader`).
|
- **UPDATE** - no history; changed records deleted and re-appended (via `%bitemporal_dataloader`).
|
||||||
- **REPLACE** - no history; whole table wiped and reloaded. Implemented inline in `%mpe_targetloader`, deliberately *not* via `%bitemporal_dataloader` (see [.agents/docs/replace-load-type.md](.agents/docs/replace-load-type.md)).
|
- **REPLACE** - no history; whole table wiped and reloaded. Implemented inline in `%mpe_targetloader`, deliberately *not* via `%bitemporal_dataloader` (see [.agent/docs/replace-load-type.md](.agent/docs/replace-load-type.md)).
|
||||||
- **TXTEMPORAL** - SCD2-style history on technical (transaction) time.
|
- **TXTEMPORAL** - SCD2-style history on technical (transaction) time.
|
||||||
- **BITEMPORAL** - full two-dimensional history (business time + technical time).
|
- **BITEMPORAL** - full two-dimensional history (business time + technical time).
|
||||||
- **FORMAT_CAT** - format-catalog load (via `%mp_loadformat`).
|
- **FORMAT_CAT** - format-catalog load (via `%mp_loadformat`).
|
||||||
@@ -64,11 +64,11 @@ Selectbox seed values for these tables are defined in `sas/sasjs/macros/mpe_make
|
|||||||
- **HARDREGEX** - submission-blocking regex; failing cells are painted red and cannot be submitted.
|
- **HARDREGEX** - submission-blocking regex; failing cells are painted red and cannot be submitted.
|
||||||
- **SOFTREGEX** - display-only warning regex; failing cells are painted yellow but submission is allowed.
|
- **SOFTREGEX** - display-only warning regex; failing cells are painted yellow but submission is allowed.
|
||||||
|
|
||||||
Regex rule values are authored in SAS PRX form `/pattern/flags` and limited to 128 chars. See [.agents/docs/regex-validations.md](.agents/docs/regex-validations.md).
|
Regex rule values are authored in SAS PRX form `/pattern/flags` and limited to 128 chars. See [.agent/docs/regex-validations.md](.agent/docs/regex-validations.md).
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- **Row Level Security (RLS)** - server-side `WHERE`-clause generation via `%mpe_filtermaster` / `%mp_filtergenerate`; no data leaves SAS without passing the filter. Works on any engine because it's expressed as a standard SAS `WHERE`. On write, the *inverse* filter is applied to reject out-of-scope rows. See [.agents/docs/row-level-security.md](.agents/docs/row-level-security.md).
|
- **Row Level Security (RLS)** - server-side `WHERE`-clause generation via `%mpe_filtermaster` / `%mp_filtergenerate`; no data leaves SAS without passing the filter. Works on any engine because it's expressed as a standard SAS `WHERE`. On write, the *inverse* filter is applied to reject out-of-scope rows. See [.agent/docs/row-level-security.md](.agent/docs/row-level-security.md).
|
||||||
- **Column Level Security (CLS)** - restricts visibility/editability at column level.
|
- **Column Level Security (CLS)** - restricts visibility/editability at column level.
|
||||||
|
|
||||||
## Key macros / services
|
## Key macros / services
|
||||||
@@ -79,7 +79,7 @@ Regex rule values are authored in SAS PRX form `/pattern/flags` and limited to 1
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Backend tests run with the sasjs CLI from `sas/`. `npm run 4gl` compiles+deploys+seeds then `sasjs test -t 4gl` runs tests. Assertions use `%mp_assert()`. See [.agents/docs/testing.md](.agents/docs/testing.md).
|
Backend tests run with the sasjs CLI from `sas/`. `npm run 4gl` compiles+deploys+seeds then `sasjs test -t 4gl` runs tests. Assertions use `%mp_assert()`. See [.agent/docs/testing.md](.agent/docs/testing.md).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -88,6 +88,6 @@ Backend tests run with the sasjs CLI from `sas/`. `npm run 4gl` compiles+deploys
|
|||||||
- No external assets - everything must be bundled and served locally.
|
- No external assets - everything must be bundled and served locally.
|
||||||
- Comments and test names must be self-contained (no "original bug", "this PR", etc.); the only exception is a literal issue-tracker link.
|
- Comments and test names must be self-contained (no "original bug", "this PR", etc.); the only exception is a literal issue-tracker link.
|
||||||
- Do not auto-commit or push.
|
- Do not auto-commit or push.
|
||||||
- Never edit `CHANGELOG.md` or bump `package.json` version by hand - the release pipeline generates both from Conventional Commit messages. See [.agents/docs/releases-and-changelog.md](.agents/docs/releases-and-changelog.md).
|
- Never edit `CHANGELOG.md` or bump `package.json` version by hand - the release pipeline generates both from Conventional Commit messages. See [.agent/docs/releases-and-changelog.md](.agent/docs/releases-and-changelog.md).
|
||||||
|
|
||||||
See `AGENTS.md` for the full set of enforced rules (git, linting, no-wrap, no-external-assets, `.agents/` layout).
|
See `AGENTS.md` for the full set of enforced rules (git, linting, no-wrap, no-external-assets, `.agent/` layout).
|
||||||
|
|||||||
@@ -54,71 +54,6 @@ cd client
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Mocked backend (frontend development without SAS)
|
|
||||||
|
|
||||||
Data Controller ships a full set of JS mock services under `sas/mocks/sasjs/` that mimic the SAS backend using [SASjs Server](https://server.sasjs.io) in JS-only mode. This lets you run the entire frontend (editor grid, approvals, viewer, admin, Excel maps, etc.) without a SAS licence or SAS server. The Cypress E2E tests and Lighthouse CI both run against this setup.
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js (same version used for the frontend build)
|
|
||||||
- The frontend dependencies installed (`cd client && npm ci` after decrypting sheet-crypto as described above)
|
|
||||||
|
|
||||||
### 1. Start SASjs Server
|
|
||||||
|
|
||||||
Download the latest Linux (or macOS/Windows) binary from [SASjs Server releases](https://github.com/sasjs/server/releases), unzip it, and create a `.env` file alongside the executable:
|
|
||||||
|
|
||||||
```
|
|
||||||
RUN_TIMES=js
|
|
||||||
NODE_PATH=node
|
|
||||||
CORS=enable
|
|
||||||
WHITELIST=http://localhost:4200
|
|
||||||
```
|
|
||||||
|
|
||||||
`RUN_TIMES=js` means the server executes `.js` files as Stored Programs (no SAS binary needed). `CORS=enable` plus `WHITELIST` allows the Angular dev server on port 4200 to call the mock services on port 5000. Start the server:
|
|
||||||
|
|
||||||
```
|
|
||||||
./api-linux # or api-macos / api-win.exe depending on your platform
|
|
||||||
```
|
|
||||||
|
|
||||||
The server runs in desktop mode (no auth) on `http://localhost:5000` by default.
|
|
||||||
|
|
||||||
### 2. Deploy the mock services and streaming frontend
|
|
||||||
|
|
||||||
From `sas/mocks/sasjs/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
npm ci
|
|
||||||
sasjs cbd -t server-ci
|
|
||||||
```
|
|
||||||
|
|
||||||
This compiles, builds, and deploys the JS mock services plus the Angular frontend (as a streaming app) to the SASjs Server. The `server-ci` target in `sas/mocks/sasjs/sasjsconfig.json` points to `http://localhost:5000` with appLoc `/Public/app/dc` and stream service name `clickme`.
|
|
||||||
|
|
||||||
After deploy, the streamed app is available at: `http://localhost:5000/AppStream/clickme/`
|
|
||||||
|
|
||||||
### 3. Run the Angular dev server (optional, for hot reload)
|
|
||||||
|
|
||||||
For frontend development with live reload, point `client/src/index.html` at the local SASjs Server and start the dev server:
|
|
||||||
|
|
||||||
```
|
|
||||||
cd client
|
|
||||||
# Edit src/index.html: set serverUrl="http://localhost:5000", appLoc="/Public/app/dc", serverType="SASJS"
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
The app will be at `http://localhost:4200`. It calls the JS mock services on port 5000 via the SASjs adapter (CORS is enabled by the `.env` above).
|
|
||||||
|
|
||||||
### How the mocks work
|
|
||||||
|
|
||||||
Each `.js` file in `sas/mocks/sasjs/services/` corresponds to a real SAS service (e.g. `public/startupservice.js` mimics `services/public/startupservice.sas`). The JS runtime predeclares `fs` and `_webout`; the mock assigns a JSON string to `_webout` that matches the structure the real SAS service would return. Some mocks are stateful: they persist data to a `mock-storage/` folder on the SASjs Drive (e.g. `licence.json`, `users.json`, `filter.txt`) using the predeclared `fs` module.
|
|
||||||
|
|
||||||
The mock data includes sample tables (`DC996664.MPE_X_TEST`, `MPE_X_NEW`, `MPE_X_FORMULA_TEST`, etc.) with realistic columns, validation rules (HARDREGEX, SOFTREGEX, HARDSELECT, READONLY, HIDDEN, ROUND, NUMBER_FORMAT), and dropdown values. This is sufficient to exercise the full editor grid, approval workflow, viewer, and admin screens.
|
|
||||||
|
|
||||||
There is also a SAS 9 mock variant under `sas/mocks/sas9/` (target `sas9-mocks`) that mimics SAS 9 stored processes with the same JS approach but a different appLoc (`/User Folders/sasdemo/`).
|
|
||||||
|
|
||||||
### Re-deploying after changes
|
|
||||||
|
|
||||||
After changing mock services, re-run `sasjs cbd -t server-ci` from `sas/mocks/sasjs/`. After changing the frontend, rebuild (`cd client && npm run build`) then re-deploy so the streaming app picks up the new `client/dist/`. If you are using `ng serve` (step 3), only the mock services need re-deploying; the frontend hot-reloads automatically.
|
|
||||||
|
|
||||||
## GUI Elements
|
## GUI Elements
|
||||||
|
|
||||||
For documentation on the Clarity Design System, including a list of components and example usage, see [our website](https://vmware.github.io/clarity).
|
For documentation on the Clarity Design System, including a list of components and example usage, see [our website](https://vmware.github.io/clarity).
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export default defineConfig({
|
|||||||
html: true,
|
html: true,
|
||||||
json: false
|
json: false
|
||||||
},
|
},
|
||||||
|
|
||||||
viewportHeight: 900,
|
viewportHeight: 900,
|
||||||
viewportWidth: 1600,
|
viewportWidth: 1600,
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@ export default defineConfig({
|
|||||||
serverType: 'SASJS',
|
serverType: 'SASJS',
|
||||||
libraryToOpenIncludes_SASVIYA: 'viya',
|
libraryToOpenIncludes_SASVIYA: 'viya',
|
||||||
libraryToOpenIncludes_SAS9: 'dc',
|
libraryToOpenIncludes_SAS9: 'dc',
|
||||||
libraryToOpenIncludes_SASJS: 'dc_jslib',
|
libraryToOpenIncludes_SASJS: 'dc',
|
||||||
debug: false,
|
debug: false,
|
||||||
screenshotOnRunFailure: false,
|
screenshotOnRunFailure: false,
|
||||||
longerCommandTimeout: 50000,
|
longerCommandTimeout: 50000,
|
||||||
|
|||||||
+38
-680
@@ -197,7 +197,7 @@ context('editor tests: ', function () {
|
|||||||
// RULE_DEMO_COLS in the getdata.js mock) — MPE_X_TEST stays clean to
|
// RULE_DEMO_COLS in the getdata.js mock) — MPE_X_TEST stays clean to
|
||||||
// match the excel fixtures.
|
// match the excel fixtures.
|
||||||
it('7 | Blocks submission of a value that fails HARDREGEX', (done) => {
|
it('7 | Blocks submission of a value that fails HARDREGEX', (done) => {
|
||||||
openTableFromTree('testdata', 'mpe_x_new')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -236,7 +236,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('8 | Warns (but still submits) a value that fails SOFTREGEX', (done) => {
|
it('8 | Warns (but still submits) a value that fails SOFTREGEX', (done) => {
|
||||||
openTableFromTree('testdata', 'mpe_x_new')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -280,7 +280,7 @@ context('editor tests: ', function () {
|
|||||||
// A_COL=2, B_COL=10: HARDFORMULA (A_COL * B_COL) computes to 20,
|
// A_COL=2, B_COL=10: HARDFORMULA (A_COL * B_COL) computes to 20,
|
||||||
// SOFTFORMULA (A_COL + B_COL) computes to 12.
|
// SOFTFORMULA (A_COL + B_COL) computes to 12.
|
||||||
it('9 | HARDFORMULA shows the computed value and blocks direct edits', () => {
|
it('9 | HARDFORMULA shows the computed value and blocks direct edits', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -305,7 +305,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('10 | SOFTFORMULA shows the computed default but accepts an override', () => {
|
it('10 | SOFTFORMULA shows the computed default but accepts an override', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -328,7 +328,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('11 | Info dropdown shows the applied formula as √x=<formula>', () => {
|
it('11 | Info dropdown shows the applied formula as √x=<formula>', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -349,7 +349,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('12 | Info dropdown shows the applied HARDREGEX/SOFTREGEX pattern', () => {
|
it('12 | Info dropdown shows the applied HARDREGEX/SOFTREGEX pattern', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_new')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -376,7 +376,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('13 | Info dropdown shows only the applied HARDREGEX pattern when a column has both', () => {
|
it('13 | Info dropdown shows only the applied HARDREGEX pattern when a column has both', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_new')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -396,7 +396,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('14 | REGEX_BOTH_COL: a HARDREGEX failure blocks submission and sets its own tooltip, not yellow', (done) => {
|
it('14 | REGEX_BOTH_COL: a HARDREGEX failure blocks submission and sets its own tooltip, not yellow', (done) => {
|
||||||
openTableFromTree('testdata', 'mpe_x_new')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -435,7 +435,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('15 | REGEX_BOTH_COL: SOFTREGEX is ignored entirely when HARDREGEX is present', (done) => {
|
it('15 | REGEX_BOTH_COL: SOFTREGEX is ignored entirely when HARDREGEX is present', (done) => {
|
||||||
openTableFromTree('testdata', 'mpe_x_new')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -469,7 +469,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("16 | Insert Row above keeps existing rows' formulas aligned with their own shifted data", () => {
|
it("16 | Insert Row above keeps existing rows' formulas aligned with their own shifted data", () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -499,7 +499,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("17 | Insert Row below keeps existing rows' formulas aligned with their own shifted data", () => {
|
it("17 | Insert Row below keeps existing rows' formulas aligned with their own shifted data", () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -526,7 +526,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('18 | Insert Row seeds formulas that live-recalculate once A_COL/B_COL are filled in', () => {
|
it('18 | Insert Row seeds formulas that live-recalculate once A_COL/B_COL are filled in', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -562,7 +562,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('19 | Row header shows blank for unchanged, ± for a modified row', () => {
|
it('19 | Row header shows blank for unchanged, ± for a modified row', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -587,7 +587,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('20 | Row header shows - for a delete-marked row and + for a newly inserted row', () => {
|
it('20 | Row header shows - for a delete-marked row and + for a newly inserted row', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -614,7 +614,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('21 | Inserting a row does not falsely mark unrelated rows as modified or revert an existing override', () => {
|
it('21 | Inserting a row does not falsely mark unrelated rows as modified or revert an existing override', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -644,7 +644,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("22 | DC.ROW_STATUS resolves to a live cell reference, reacting to this row's own edit status", () => {
|
it("22 | DC.ROW_STATUS resolves to a live cell reference, reacting to this row's own edit status", () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -673,19 +673,19 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('23 | DC.USER_NAME resolves to the current logged-in user', () => {
|
it('23 | DC.USER_NAME resolves to the current logged-in user', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
timeout: longerCommandTimeout
|
timeout: longerCommandTimeout
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
getCellByHeaderAndRow(0, 'USER_NAME_COL').should('not.be.empty')
|
getCellByHeaderAndRow(0, 'USER_NAME_COL').should('have.text', 'sasdemo')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("24 | DC.ORIG_VALUE echoes this row's pre-edit value, and is blank for a newly-inserted row", () => {
|
it("24 | DC.ORIG_VALUE echoes this row's pre-edit value, and is blank for a newly-inserted row", () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -703,7 +703,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('25 | Row header symbols stay aligned with their own row after sorting by another column', () => {
|
it('25 | Row header symbols stay aligned with their own row after sorting by another column', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -752,7 +752,7 @@ context('editor tests: ', function () {
|
|||||||
// doesn't recompute the "changed from" text, it just flips which
|
// doesn't recompute the "changed from" text, it just flips which
|
||||||
// already-computed branch IF() reveals.
|
// already-computed branch IF() reveals.
|
||||||
it("26 | Combining DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE in one formula reveals the frozen 'changed from' text once the row is edited", () => {
|
it("26 | Combining DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE in one formula reveals the frozen 'changed from' text once the row is edited", () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -769,11 +769,10 @@ context('editor tests: ', function () {
|
|||||||
cy.focused().clear().type('999{enter}')
|
cy.focused().clear().type('999{enter}')
|
||||||
})
|
})
|
||||||
|
|
||||||
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL')
|
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
|
||||||
.invoke('text')
|
'have.text',
|
||||||
.should((text) => {
|
'sasdemo changed from orig-1'
|
||||||
expect(text).to.match(/^.+ changed from orig-1$/)
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -786,7 +785,7 @@ context('editor tests: ', function () {
|
|||||||
// and proves the live-recalculated value - not a stale one - is what
|
// and proves the live-recalculated value - not a stale one - is what
|
||||||
// gets sent.
|
// gets sent.
|
||||||
it('27 | Submits the computed formula value, not the raw formula string', () => {
|
it('27 | Submits the computed formula value, not the raw formula string', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -836,7 +835,7 @@ context('editor tests: ', function () {
|
|||||||
// other row's formula columns are seeded blank, so only this row is
|
// other row's formula columns are seeded blank, so only this row is
|
||||||
// affected.
|
// affected.
|
||||||
it('28 | A formula overwriting real pre-existing data marks the row modified, adds a comment, and can be reverted', () => {
|
it('28 | A formula overwriting real pre-existing data marks the row modified, adds a comment, and can be reverted', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
// Already true on the very first render, before Edit is ever clicked -
|
// Already true on the very first render, before Edit is ever clicked -
|
||||||
// dataSourceUnchanged is seeded with the formula overlay at load time
|
// dataSourceUnchanged is seeded with the formula overlay at load time
|
||||||
@@ -894,7 +893,7 @@ context('editor tests: ', function () {
|
|||||||
// "Revert") must keep showing the live computed value and the modified
|
// "Revert") must keep showing the live computed value and the modified
|
||||||
// marker, both in the cancelled edit session and back in read-only view.
|
// marker, both in the cancelled edit session and back in read-only view.
|
||||||
it('29 | Cancelling an edit session after a formula silently overwrote real data keeps the computed value and modified marker, not the stale raw one', () => {
|
it('29 | Cancelling an edit session after a formula silently overwrote real data keeps the computed value and modified marker, not the stale raw one', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -920,7 +919,7 @@ context('editor tests: ', function () {
|
|||||||
// getStableFormulaBaseCols, which excludes any DC.*-referencing formula
|
// getStableFormulaBaseCols, which excludes any DC.*-referencing formula
|
||||||
// rule from the "did a formula overwrite real data" comparison entirely.
|
// rule from the "did a formula overwrite real data" comparison entirely.
|
||||||
it('30 | DC.*-referencing formula columns never get a "changed value" comment, even when their own raw seed differs from the computed result', () => {
|
it('30 | DC.*-referencing formula columns never get a "changed value" comment, even when their own raw seed differs from the computed result', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -938,7 +937,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('31 | Reverting a formula value then cancelling keeps it plain, and it stays plain on the next edit session', () => {
|
it('31 | Reverting a formula value then cancelling keeps it plain, and it stays plain on the next edit session', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -987,7 +986,7 @@ context('editor tests: ', function () {
|
|||||||
// actually sent, not only formula columns - A_COL is a plain numeric
|
// actually sent, not only formula columns - A_COL is a plain numeric
|
||||||
// column with no HARDFORMULA/SOFTFORMULA rule of its own.
|
// column with no HARDFORMULA/SOFTFORMULA rule of its own.
|
||||||
it('32 | Editing a plain (non-formula) cell marks it overwritten, and Revert restores it', () => {
|
it('32 | Editing a plain (non-formula) cell marks it overwritten, and Revert restores it', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -1016,7 +1015,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('33 | Selecting a range containing one overwritten cell shows Revert and only reverts that cell', () => {
|
it('33 | Selecting a range containing one overwritten cell shows Revert and only reverts that cell', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -1054,7 +1053,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('34 | Selecting a range with no overwritten cells does not show Revert', () => {
|
it('34 | Selecting a range with no overwritten cells does not show Revert', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -1075,7 +1074,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('35 | A selection spanning a newly-inserted row and an existing overwritten cell only reverts the existing row', () => {
|
it('35 | A selection spanning a newly-inserted row and an existing overwritten cell only reverts the existing row', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -1118,7 +1117,7 @@ context('editor tests: ', function () {
|
|||||||
// already show their raw-vs-computed mismatch on load, with no manual
|
// already show their raw-vs-computed mismatch on load, with no manual
|
||||||
// edit needed.
|
// edit needed.
|
||||||
it('36 | Selecting a block spanning multiple pre-loaded overwritten cells across different rows and columns reverts only the actually-overwritten ones', () => {
|
it('36 | Selecting a block spanning multiple pre-loaded overwritten cells across different rows and columns reverts only the actually-overwritten ones', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -1176,7 +1175,7 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('37 | Selecting an entire column reverts every pre-loaded overwritten cell in it, leaving other columns and out-of-range rows untouched', () => {
|
it('37 | Selecting an entire column reverts every pre-loaded overwritten cell in it, leaving other columns and out-of-range rows untouched', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -1230,7 +1229,7 @@ context('editor tests: ', function () {
|
|||||||
// proves the general revert path still works via a plain live edit on a
|
// proves the general revert path still works via a plain live edit on a
|
||||||
// column that's a string, not a number.
|
// column that's a string, not a number.
|
||||||
it('38 | Editing a plain character column with no DQ rule at all marks it overwritten, and Revert restores it', () => {
|
it('38 | Editing a plain character column with no DQ rule at all marks it overwritten, and Revert restores it', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -1266,7 +1265,7 @@ context('editor tests: ', function () {
|
|||||||
// the dropdown were still closing, `.htDropdownMenu` would no longer
|
// the dropdown were still closing, `.htDropdownMenu` would no longer
|
||||||
// exist and the `.should()` below would time out.
|
// exist and the `.should()` below would time out.
|
||||||
it('39 | Left-click or right-click inside the info dropdown does not close it', () => {
|
it('39 | Left-click or right-click inside the info dropdown does not close it', () => {
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
|
||||||
|
|
||||||
clickOnEdit(() => {
|
clickOnEdit(() => {
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
||||||
@@ -1292,613 +1291,6 @@ context('editor tests: ', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Row 1 (0-indexed): A_COL=2, B_COL=10 - see the HARDFORMULA/SOFTFORMULA
|
|
||||||
// tests' own comment above. FORMULA_SOFT_COL is a character column (see
|
|
||||||
// character-column-formula-plan.md), so it's formula-capable for pasted
|
|
||||||
// input; unlike FORMULA_HARD_COL it also accepts a user override (test 10).
|
|
||||||
it("40 | Pasting a formula with column names translates them to this row's cell references and evaluates it, on a formula-designated column", () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').click()
|
|
||||||
|
|
||||||
pasteTextIntoFocusedCell('=A_COL * B_COL')
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '20')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// mpe_x_test has zero HARDFORMULA/SOFTFORMULA rules, but formulas are on
|
|
||||||
// for every table, not just ones that declare a formula rule - so
|
|
||||||
// paste-translation still works here too. Uses SOME_BESTNUM * 0 + 1936
|
|
||||||
// so the result is deterministic regardless of the column's actual value.
|
|
||||||
it('41 | Pasting a formula with column names on a table with zero formula rules still translates and evaluates it', () => {
|
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
// force: true - same overlay-clone-layer reason as sortByColumn's
|
|
||||||
// own comment below; SOME_CHAR's header sort icon covers the cell.
|
|
||||||
getCellByHeaderAndRow(1, 'SOME_CHAR').click({ force: true })
|
|
||||||
|
|
||||||
pasteTextIntoFocusedCell('=SOME_BESTNUM * 0 + 1936')
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(1, 'SOME_CHAR').should('have.text', '1936')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('42 | Pasting a formula referencing a column name inside a quoted string leaves the quoted text untouched', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').click()
|
|
||||||
|
|
||||||
// A_COL needs a leading/trailing blank to be recognised as a
|
|
||||||
// column-name token (see substituteColumnReferences' own "no
|
|
||||||
// surrounding blanks" test) - matches the space-after-paren
|
|
||||||
// convention already used by this fixture's own CHANGE_SUMMARY_COL
|
|
||||||
// rule value ('=IF( DC.ROW_STATUS ="U",...)').
|
|
||||||
pasteTextIntoFocusedCell(
|
|
||||||
'=IF( A_COL > 0, "A_COL is positive", "negative")'
|
|
||||||
)
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should(
|
|
||||||
'have.text',
|
|
||||||
'A_COL is positive'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('43 | Pasting a formula directly into an open cell editor (double-click, then paste) also translates and evaluates it', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL')
|
|
||||||
.dblclick({ force: true })
|
|
||||||
.then(() => {
|
|
||||||
pasteTextIntoFocusedCell('=A_COL * B_COL')
|
|
||||||
|
|
||||||
cy.focused().type('{enter}')
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should(
|
|
||||||
'have.text',
|
|
||||||
'20'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// PLAIN_TEXT_COL has no DQ rule at all - not HARDFORMULA/SOFTFORMULA-
|
|
||||||
// designated, and not referenced by any formula either. Only
|
|
||||||
// backend-sourced `=`-led values are ever auto-escaped - anything the
|
|
||||||
// user pastes or types is left exactly as entered, which genuinely
|
|
||||||
// evaluates as a live formula since formulas are on for every column.
|
|
||||||
it('44 | Pasting a formula-looking value into any character column evaluates it as a real formula - user input is never auto-escaped', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').click()
|
|
||||||
|
|
||||||
pasteTextIntoFocusedCell('=100 * 100')
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').should('have.text', '10000')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('44b | Typing a formula-looking value directly into a character column evaluates it as a real formula too', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
getCellByHeaderAndRow(2, 'PLAIN_TEXT_COL')
|
|
||||||
.dblclick({ force: true })
|
|
||||||
.then(() => {
|
|
||||||
cy.focused().clear().type('=100 * 100{enter}')
|
|
||||||
})
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(2, 'PLAIN_TEXT_COL').should('have.text', '10000')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Rows 5/6/7 (0-indexed 4/5/6) come straight from the mock backend, never
|
|
||||||
// touched by a user - see getdata.js's own comment. Rows 5-6 are a
|
|
||||||
// `=`-led value that must be auto-escaped to literal text on load (not
|
|
||||||
// evaluated - only a *user's* input evaluates live, per test 44 above);
|
|
||||||
// row 7 is already apostrophe-escaped straight from the backend, which
|
|
||||||
// the app must never mistake for its own marker.
|
|
||||||
it('46 | A formula-looking value seeded from the backend into a character column is escaped to literal text on load; a genuinely-literal backend value is left untouched', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
// Row 5 (PK=5): references a column name (B_COL) - never
|
|
||||||
// translated (that only happens for a pasted value), so this is
|
|
||||||
// purely proving the escape, not evaluation.
|
|
||||||
getCellByHeaderAndRow(4, 'PLAIN_TEXT_COL').should(
|
|
||||||
'have.text',
|
|
||||||
'=100 + B_COL'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Row 6 (PK=6): a bare arithmetic expression.
|
|
||||||
getCellByHeaderAndRow(5, 'PLAIN_TEXT_COL').should(
|
|
||||||
'have.text',
|
|
||||||
'=100 + 200'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Row 7 (PK=7): already apostrophe-escaped in the backend data
|
|
||||||
// itself - Handsontable's own formulas-plugin display logic strips
|
|
||||||
// exactly one leading `'` for display regardless of who put it
|
|
||||||
// there, so this also reads as literal text with no leading `'`
|
|
||||||
// visible - the difference from rows 5-6 only shows up in what
|
|
||||||
// gets submitted (test 45) and in "Apply as formula" (test 47).
|
|
||||||
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').should(
|
|
||||||
'have.text',
|
|
||||||
'=genuine literal'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Proves requirements 2/5/6 together: an untouched auto-escaped cell
|
|
||||||
// (row 5) submits with the marker stripped back off; a genuinely-literal
|
|
||||||
// backend value (row 7) - never marked, since it never started with `=`
|
|
||||||
// in the first place - submits completely unchanged, leading `'` and all.
|
|
||||||
it('45 | Submits an untouched auto-escaped cell unmarked, and a genuinely-literal backend value completely unchanged', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
|
|
||||||
const bodyText = JSON.stringify(req.body || '')
|
|
||||||
|
|
||||||
if (bodyText.includes('stagedata')) {
|
|
||||||
expect(bodyText).to.include('"PLAIN_TEXT_COL":"=100 + 200"')
|
|
||||||
expect(bodyText).not.to.include("'=100 + 200")
|
|
||||||
expect(bodyText).to.include('"PLAIN_TEXT_COL":"\'=genuine literal"')
|
|
||||||
}
|
|
||||||
|
|
||||||
req.continue()
|
|
||||||
}).as('stpExecute')
|
|
||||||
|
|
||||||
// PLAIN_TEXT_COL itself is never touched by hand on either row -
|
|
||||||
// editing each row's own A_COL instead marks both rows submittable
|
|
||||||
// (row 6 = PK 6, "=100 + 200"; row 7 = PK 7, the genuine literal)
|
|
||||||
// without disturbing PLAIN_TEXT_COL's auto-escaped/untouched state.
|
|
||||||
getCellByHeaderAndRow(5, 'A_COL')
|
|
||||||
.dblclick({ force: true })
|
|
||||||
.then(() => {
|
|
||||||
cy.focused().clear().type('60{enter}')
|
|
||||||
})
|
|
||||||
getCellByHeaderAndRow(6, 'A_COL')
|
|
||||||
.dblclick({ force: true })
|
|
||||||
.then(() => {
|
|
||||||
cy.focused().clear().type('70{enter}')
|
|
||||||
})
|
|
||||||
|
|
||||||
submitTable()
|
|
||||||
|
|
||||||
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.should('not.be.disabled')
|
|
||||||
|
|
||||||
cy.get('#formFields_8').type('round-trip submission test')
|
|
||||||
|
|
||||||
submitTableMessage()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Requirement 7: "Apply as formula" only ever appears for / acts on a
|
|
||||||
// cell the app itself auto-escaped.
|
|
||||||
it('47 | "Apply as formula" is hidden when the selection has no auto-escaped cell', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
// Row 7 (PK=7) is genuinely-literal, never auto-escaped.
|
|
||||||
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').click({ force: true })
|
|
||||||
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').rightclick({ force: true })
|
|
||||||
|
|
||||||
cy.get('.htContextMenu').should(($menu) => {
|
|
||||||
expect($menu.text()).not.to.include('Apply as formula')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('48 | "Apply as formula" appears when the selection has an auto-escaped cell, and only strips the auto-inserted marker from it', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
// Select rows 6-7 (PK 6-7) of PLAIN_TEXT_COL - row 6 auto-escaped
|
|
||||||
// (bare expression, no column-name translation needed), row 7
|
|
||||||
// genuinely literal from the backend.
|
|
||||||
getCellByHeaderAndRow(5, 'PLAIN_TEXT_COL').click({ force: true })
|
|
||||||
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').click({
|
|
||||||
force: true,
|
|
||||||
shiftKey: true
|
|
||||||
})
|
|
||||||
getCellByHeaderAndRow(5, 'PLAIN_TEXT_COL').rightclick({ force: true })
|
|
||||||
|
|
||||||
cy.get('.htContextMenu').contains('Apply as formula').click()
|
|
||||||
|
|
||||||
// Row 6: marker stripped, now a real live formula.
|
|
||||||
getCellByHeaderAndRow(5, 'PLAIN_TEXT_COL').should('have.text', '300')
|
|
||||||
// Row 7: never marked, left completely untouched.
|
|
||||||
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').should(
|
|
||||||
'have.text',
|
|
||||||
'=genuine literal'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// A user-typed formula in any character column (not just HARDFORMULA/
|
|
||||||
// SOFTFORMULA ones) is never auto-escaped and evaluates live (test 44b)
|
|
||||||
// - submission must send that computed value, not the raw formula text,
|
|
||||||
// the same way it already does for HARDFORMULA/SOFTFORMULA columns
|
|
||||||
// (test 27).
|
|
||||||
it('49 | Submits the computed value for a user-typed formula in a non-formula-rule column, not the raw formula text', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
|
|
||||||
const bodyText = JSON.stringify(req.body || '')
|
|
||||||
|
|
||||||
if (bodyText.includes('stagedata')) {
|
|
||||||
expect(bodyText).to.include('"PLAIN_TEXT_COL":2')
|
|
||||||
expect(bodyText).not.to.match(/"PLAIN_TEXT_COL":"?=1\+1/)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.continue()
|
|
||||||
}).as('stpExecute')
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL')
|
|
||||||
.dblclick({ force: true })
|
|
||||||
.then(() => {
|
|
||||||
cy.focused().clear().type('=1+1{enter}')
|
|
||||||
})
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').should('have.text', '2')
|
|
||||||
|
|
||||||
submitTable()
|
|
||||||
|
|
||||||
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.should('not.be.disabled')
|
|
||||||
|
|
||||||
cy.get('#formFields_8').type('computed formula value submission test')
|
|
||||||
|
|
||||||
submitTableMessage()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// B_COL is `type: numeric` (DDTYPE N) - coerceNumericRow (beforePaste)
|
|
||||||
// deliberately leaves a non-numeric, formula-looking pasted value alone
|
|
||||||
// rather than forcing it to NaN, so a live formula can end up in a
|
|
||||||
// numeric column too, not just a character one. Submission must resolve
|
|
||||||
// its computed value the same way (test 49) - the raw `=2+2` text would
|
|
||||||
// otherwise reach the backend, which a numeric SAS field can't parse.
|
|
||||||
it('50 | Submits the computed value for a live formula pasted into a numeric column, not the raw formula text', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
|
|
||||||
const bodyText = JSON.stringify(req.body || '')
|
|
||||||
|
|
||||||
if (bodyText.includes('stagedata')) {
|
|
||||||
expect(bodyText).to.include('"B_COL":4')
|
|
||||||
expect(bodyText).not.to.match(/"B_COL":"?=2\+2/)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.continue()
|
|
||||||
}).as('stpExecute')
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(1, 'B_COL').click()
|
|
||||||
|
|
||||||
pasteTextIntoFocusedCell('=2+2')
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(1, 'B_COL').should('have.text', '4')
|
|
||||||
|
|
||||||
submitTable()
|
|
||||||
|
|
||||||
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.should('not.be.disabled')
|
|
||||||
|
|
||||||
cy.get('#formFields_8').type(
|
|
||||||
'numeric column computed formula value submission test'
|
|
||||||
)
|
|
||||||
|
|
||||||
submitTableMessage()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// A primary key identifies its own row for the rest of the edit session
|
|
||||||
// (dataModified/classifyRow match rows by PK) - a live, still-
|
|
||||||
// recalculating formula left sitting in a PK cell would let that
|
|
||||||
// identity drift between when it's entered and when submission finally
|
|
||||||
// resolves it, silently dropping the row from what's submitted. So
|
|
||||||
// unlike B_COL above, a formula pasted into PRIMARY_KEY_FIELD resolves
|
|
||||||
// to its computed value immediately, the moment the paste completes,
|
|
||||||
// rather than staying live until submit. Row 4 (PK=5): pasting
|
|
||||||
// `=999+1` evaluates to 1000, a value clearly different from both the
|
|
||||||
// original and every other row's PK, so a passing assertion here can't
|
|
||||||
// be a coincidence (nor a PK collision with another seeded row).
|
|
||||||
it('51 | Resolves a formula pasted into the primary key column to its computed value immediately', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
|
|
||||||
const bodyText = JSON.stringify(req.body || '')
|
|
||||||
|
|
||||||
if (bodyText.includes('stagedata')) {
|
|
||||||
expect(bodyText).to.include('"PRIMARY_KEY_FIELD":1000')
|
|
||||||
}
|
|
||||||
|
|
||||||
req.continue()
|
|
||||||
}).as('stpExecute')
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').should('have.text', '5')
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').click()
|
|
||||||
|
|
||||||
pasteTextIntoFocusedCell('=999+1')
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').should(
|
|
||||||
'have.text',
|
|
||||||
'1000'
|
|
||||||
)
|
|
||||||
|
|
||||||
submitTable()
|
|
||||||
|
|
||||||
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.should('not.be.disabled')
|
|
||||||
|
|
||||||
cy.get('#formFields_8').type(
|
|
||||||
'primary key computed formula value submission test'
|
|
||||||
)
|
|
||||||
|
|
||||||
submitTableMessage()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Nothing gates a HARDFORMULA/SOFTFORMULA rule's BASE_COL by column type
|
|
||||||
// or PK-ness - MPE_X_FORMULA_PK_TEST's composite PK is itself made up of
|
|
||||||
// a HARDFORMULA numeric column and a SOFTFORMULA character column. Left
|
|
||||||
// unresolved, that reproduces the same PK-identity bug test 51 fixes -
|
|
||||||
// just triggered by the rule seeding instead of a user edit, and present
|
|
||||||
// on every row from the very first render rather than only an edited
|
|
||||||
// one. resolvePkFormulaSeedValues (editor.component.ts) freezes both PK
|
|
||||||
// cells to their computed values once, right after the initial table
|
|
||||||
// load, so they display correctly immediately and the row still submits
|
|
||||||
// - editing A_COL afterward (which the PK formulas reference) must NOT
|
|
||||||
// retroactively recompute either PK cell, proving they're frozen rather
|
|
||||||
// than still-live.
|
|
||||||
it('52 | A primary key made of HARDFORMULA/SOFTFORMULA columns freezes to its computed value at load and still submits correctly', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_pk_test')
|
|
||||||
|
|
||||||
// Row 0: A_COL=2, B_COL=3 -> PK_HARDFORMULA_COL=6, PK_SOFTFORMULA_COL="PK-2".
|
|
||||||
getCellByHeaderAndRow(0, 'PK_HARDFORMULA_COL').should('have.text', '6')
|
|
||||||
getCellByHeaderAndRow(0, 'PK_SOFTFORMULA_COL').should('have.text', 'PK-2')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
|
|
||||||
const bodyText = JSON.stringify(req.body || '')
|
|
||||||
|
|
||||||
if (bodyText.includes('stagedata')) {
|
|
||||||
// The row must not be silently dropped from what's submitted
|
|
||||||
// - both frozen PK values and the actually-edited column must
|
|
||||||
// be present.
|
|
||||||
expect(bodyText).to.include('"PK_HARDFORMULA_COL":6')
|
|
||||||
expect(bodyText).to.include('"PK_SOFTFORMULA_COL":"PK-2"')
|
|
||||||
expect(bodyText).to.include('"A_COL":9')
|
|
||||||
}
|
|
||||||
|
|
||||||
req.continue()
|
|
||||||
}).as('stpExecute')
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(0, 'A_COL')
|
|
||||||
.dblclick({ force: true })
|
|
||||||
.then(() => {
|
|
||||||
cy.focused().clear().type('9{enter}')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Frozen, not live - A_COL changing from 2 to 9 would recompute
|
|
||||||
// PK_HARDFORMULA_COL to 27 if it were still a live formula.
|
|
||||||
getCellByHeaderAndRow(0, 'PK_HARDFORMULA_COL').should('have.text', '6')
|
|
||||||
|
|
||||||
submitTable()
|
|
||||||
|
|
||||||
cy.get('#submitBtn', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.should('not.be.disabled')
|
|
||||||
|
|
||||||
cy.get('#formFields_8').type(
|
|
||||||
'formula-driven composite primary key submission test'
|
|
||||||
)
|
|
||||||
|
|
||||||
submitTableMessage()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// afterChange delivers Handsontable's own VISUAL row - the EDIT_STATUS/
|
|
||||||
// overwritten-comment sync hook must translate that to physical before
|
|
||||||
// touching dataSource (classifyRow, PK-matching), and the two methods it
|
|
||||||
// calls must translate back to visual for their own Handsontable/
|
|
||||||
// comments-plugin writes. ROW_STATUS_COL (=DC.ROW_STATUS) makes the
|
|
||||||
// EDIT_STATUS write directly observable, so sorting first (breaking
|
|
||||||
// visual/physical alignment) and editing the row that moved proves the
|
|
||||||
// fix end-to-end, not just that an unsorted grid still works.
|
|
||||||
it('53 | Editing a row after sorting updates DC.ROW_STATUS on that same row, not whatever row now visually sits in its old position', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
sortByColumn('PLAIN_TEXT_COL')
|
|
||||||
|
|
||||||
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
|
|
||||||
const pkColIndex = [...$ths].findIndex(
|
|
||||||
(th) => th.innerText.trim() === 'PRIMARY_KEY_FIELD'
|
|
||||||
)
|
|
||||||
const statusColIndex = [...$ths].findIndex(
|
|
||||||
(th) => th.innerText.trim() === 'ROW_STATUS_COL'
|
|
||||||
)
|
|
||||||
|
|
||||||
cy.get('.ht_master tbody tr').then((rows: any) => {
|
|
||||||
const visualRow = [...rows].findIndex(
|
|
||||||
(row: any) => row.childNodes[pkColIndex].innerText.trim() === '1'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rows 7-10 (their own pre-existing FORMULA_HARD_COL/
|
|
||||||
// FORMULA_SOFT_COL data, silently overwritten by the DQ rule -
|
|
||||||
// see test 28) already read 'M' from the very first render, so
|
|
||||||
// asserting a specific OTHER row is 'U' would be a coincidence
|
|
||||||
// of seed data, not a proof of anything. Snapshot every other
|
|
||||||
// row's own status instead, and assert none of them changed -
|
|
||||||
// that's what actually distinguishes "the edit landed on the
|
|
||||||
// right row" from "it landed on whatever moved into that
|
|
||||||
// visual slot".
|
|
||||||
const beforeStatuses = [...rows].map((row: any, i: number) =>
|
|
||||||
i === visualRow
|
|
||||||
? null
|
|
||||||
: row.childNodes[statusColIndex].innerText.trim()
|
|
||||||
)
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(visualRow, 'ROW_STATUS_COL').should(
|
|
||||||
'have.text',
|
|
||||||
'U'
|
|
||||||
)
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(visualRow, 'A_COL')
|
|
||||||
.dblclick({ force: true })
|
|
||||||
.then(() => {
|
|
||||||
cy.focused().clear().type('999{enter}')
|
|
||||||
})
|
|
||||||
|
|
||||||
// The edited row (PK=1) shows 'M' at its own visual position...
|
|
||||||
getCellByHeaderAndRow(visualRow, 'ROW_STATUS_COL').should(
|
|
||||||
'have.text',
|
|
||||||
'M'
|
|
||||||
)
|
|
||||||
|
|
||||||
// ...and every other row's status is exactly what it was
|
|
||||||
// before - none of them got the write meant for row PK=1.
|
|
||||||
cy.get('.ht_master tbody tr').should((rows2: any) => {
|
|
||||||
;[...rows2].forEach((row: any, i: number) => {
|
|
||||||
if (i === visualRow) return
|
|
||||||
|
|
||||||
const status = row.childNodes[statusColIndex].innerText.trim()
|
|
||||||
expect(status, `row ${i} status unchanged`).to.equal(
|
|
||||||
beforeStatuses[i]
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// multiColumnSorting's own sort() replaces its entire sort state on every
|
|
||||||
// call rather than accumulating (see its own JSDoc) - restoring a
|
|
||||||
// captured multi-column sort with a loop of single-column sort() calls
|
|
||||||
// therefore silently drops every column but the last one restored. This
|
|
||||||
// hits cancelEdit() specifically once a user has shift-clicked a second
|
|
||||||
// header to sort by two columns, then clicks Cancel: the underlying bug
|
|
||||||
// is already covered directly against the Handsontable plugin in
|
|
||||||
// sortedGridRowSync.integration.spec.ts, but only a real shift-click
|
|
||||||
// through the actual grid proves editor.component.ts's own cancelEdit()
|
|
||||||
// restores it correctly end-to-end.
|
|
||||||
it('54 | Cancelling an edit after sorting by two columns keeps both columns sorted, not just the last one clicked', () => {
|
|
||||||
openTableFromTree('testdata', 'mpe_x_formula_test')
|
|
||||||
|
|
||||||
clickOnEdit(() => {
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then(() => {
|
|
||||||
sortByColumn('A_COL')
|
|
||||||
sortByColumnAdditional('B_COL')
|
|
||||||
|
|
||||||
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
|
|
||||||
const pkColIndex = [...$ths].findIndex(
|
|
||||||
(th) => th.innerText.trim() === 'PRIMARY_KEY_FIELD'
|
|
||||||
)
|
|
||||||
|
|
||||||
cy.get('.ht_master tbody tr').then((rows: any) => {
|
|
||||||
const beforeCancelPkOrder = [...rows].map((row: any) =>
|
|
||||||
row.childNodes[pkColIndex].innerText.trim()
|
|
||||||
)
|
|
||||||
|
|
||||||
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).click()
|
|
||||||
|
|
||||||
// cancelEdit() re-renders read-only with the same dataSource,
|
|
||||||
// still ordered by the restored sort - the row order itself is
|
|
||||||
// the only observable signal that both columns' sort survived.
|
|
||||||
cy.get('.ht_master tbody tr').should((rows2: any) => {
|
|
||||||
const afterCancelPkOrder = [...rows2].map((row: any) =>
|
|
||||||
row.childNodes[pkColIndex].innerText.trim()
|
|
||||||
)
|
|
||||||
expect(afterCancelPkOrder).to.deep.equal(beforeCancelPkOrder)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
|
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
|
||||||
@@ -1928,19 +1320,6 @@ const sortByColumn = (headerText: string) => {
|
|||||||
.click({ force: true })
|
.click({ force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shift-clicks a column header's sort indicator to ADD it to whatever sort
|
|
||||||
// is already active, rather than replacing it - Handsontable's own
|
|
||||||
// multiColumnSorting UI convention for building a multi-column sort (a
|
|
||||||
// plain click, sortByColumn above, always starts a fresh single-column
|
|
||||||
// sort instead).
|
|
||||||
const sortByColumnAdditional = (headerText: string) => {
|
|
||||||
cy.get('.ht_clone_top .htCore thead tr th')
|
|
||||||
.filter((_, th) => Cypress.$(th).text().includes(headerText))
|
|
||||||
.last()
|
|
||||||
.find('.columnSorting')
|
|
||||||
.click({ force: true, shiftKey: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opens a column header's dropdown menu to reach its `info` item, which
|
// Opens a column header's dropdown menu to reach its `info` item, which
|
||||||
// has a custom renderer showing NAME/LABEL/TYPE/LENGTH/FORMAT and, when the
|
// has a custom renderer showing NAME/LABEL/TYPE/LENGTH/FORMAT and, when the
|
||||||
// column has a HARDREGEX/SOFTREGEX rule, the applied pattern. Clicking the
|
// column has a HARDREGEX/SOFTREGEX rule, the applied pattern. Clicking the
|
||||||
@@ -1973,27 +1352,6 @@ const openColumnDropdown = (headerText: string) => {
|
|||||||
// re-renders virtualized columns asynchronously after a scroll event, on
|
// re-renders virtualized columns asynchronously after a scroll event, on
|
||||||
// its own schedule outside Cypress's command queue, so scrollTo() settling
|
// its own schedule outside Cypress's command queue, so scrollTo() settling
|
||||||
// does not mean the target column has been rendered yet.
|
// does not mean the target column has been rendered yet.
|
||||||
// Simulates a real clipboard paste into whichever element currently has
|
|
||||||
// DOM focus (Handsontable moves focus to its own internal textarea once a
|
|
||||||
// cell is selected/clicked) - Cypress has no built-in clipboard simulation,
|
|
||||||
// and Handsontable's CopyPaste plugin only listens for a native 'paste'
|
|
||||||
// DOM event with clipboardData, so a synthetic ClipboardEvent is
|
|
||||||
// dispatched directly rather than trying to drive the OS clipboard.
|
|
||||||
const pasteTextIntoFocusedCell = (text: string) => {
|
|
||||||
cy.focused().then(($el) => {
|
|
||||||
const dataTransfer = new DataTransfer()
|
|
||||||
dataTransfer.setData('text/plain', text)
|
|
||||||
|
|
||||||
const pasteEvent = new ClipboardEvent('paste', {
|
|
||||||
clipboardData: dataTransfer,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true
|
|
||||||
})
|
|
||||||
|
|
||||||
$el[0].dispatchEvent(pasteEvent)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
|
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
|
||||||
return cy
|
return cy
|
||||||
.get('.ht_clone_top .htCore thead tr th')
|
.get('.ht_clone_top .htCore thead tr th')
|
||||||
|
|||||||
@@ -546,7 +546,7 @@ const checkResultOfFormulaUpload = (callback?: any) => {
|
|||||||
.find('tbody')
|
.find('tbody')
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const cell: any = data[0].children[0].children[5]
|
const cell: any = data[0].children[0].children[5]
|
||||||
expect(cell.innerText).to.equal('2')
|
expect(cell.innerText).to.equal('=1+1')
|
||||||
if (callback) callback()
|
if (callback) callback()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ context('filtering tests: ', function () {
|
|||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_CHAR', 'this is dummy data', 'value', () => {
|
setFilterWithValue('SOME_CHAR', 'this is dummy data', 'value', () => {
|
||||||
checkInfoBarIncludes(
|
checkInfoBarIncludes(
|
||||||
`(( SOME_CHAR = 'this is dummy data' ))`,
|
`AND,AND,0,SOME_CHAR,=,"'this is dummy data'"`,
|
||||||
(includes: boolean) => {
|
(includes: boolean) => {
|
||||||
if (includes) done()
|
if (includes) done()
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ context('filtering tests: ', function () {
|
|||||||
|
|
||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_NUM', '42', 'value', () => {
|
setFilterWithValue('SOME_NUM', '42', 'value', () => {
|
||||||
checkInfoBarIncludes(`(( SOME_NUM = 42 ))`, (includes: boolean) => {
|
checkInfoBarIncludes(`AND,AND,0,SOME_NUM,=,42`, (includes: boolean) => {
|
||||||
if (includes) done()
|
if (includes) done()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -51,9 +51,12 @@ context('filtering tests: ', function () {
|
|||||||
|
|
||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_TIME', '00:00:42', 'time', () => {
|
setFilterWithValue('SOME_TIME', '00:00:42', 'time', () => {
|
||||||
checkInfoBarIncludes(`(( SOME_TIME = 42 ))`, (includes: boolean) => {
|
checkInfoBarIncludes(
|
||||||
if (includes) done()
|
`AND,AND,0,SOME_TIME,=,42`,
|
||||||
})
|
(includes: boolean) => {
|
||||||
|
if (includes) done()
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -63,9 +66,12 @@ context('filtering tests: ', function () {
|
|||||||
|
|
||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_TIME', '42', 'value', () => {
|
setFilterWithValue('SOME_TIME', '42', 'value', () => {
|
||||||
checkInfoBarIncludes(`(( SOME_TIME = 42 ))`, (includes: boolean) => {
|
checkInfoBarIncludes(
|
||||||
if (includes) done()
|
`AND,AND,0,SOME_TIME,=,42`,
|
||||||
})
|
(includes: boolean) => {
|
||||||
|
if (includes) done()
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}, false)
|
}, false)
|
||||||
})
|
})
|
||||||
@@ -75,9 +81,12 @@ context('filtering tests: ', function () {
|
|||||||
|
|
||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_DATE', '12/02/1960', 'date', () => {
|
setFilterWithValue('SOME_DATE', '12/02/1960', 'date', () => {
|
||||||
checkInfoBarIncludes(`(( SOME_DATE = 42 ))`, (includes: boolean) => {
|
checkInfoBarIncludes(
|
||||||
if (includes) done()
|
`AND,AND,0,SOME_DATE,=,42`,
|
||||||
})
|
(includes: boolean) => {
|
||||||
|
if (includes) done()
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -87,9 +96,12 @@ context('filtering tests: ', function () {
|
|||||||
|
|
||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_DATE', '42', 'value', () => {
|
setFilterWithValue('SOME_DATE', '42', 'value', () => {
|
||||||
checkInfoBarIncludes(`(( SOME_DATE = 42 ))`, (includes: boolean) => {
|
checkInfoBarIncludes(
|
||||||
if (includes) done()
|
`AND,AND,0,SOME_DATE,=,42`,
|
||||||
})
|
(includes: boolean) => {
|
||||||
|
if (includes) done()
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}, false)
|
}, false)
|
||||||
})
|
})
|
||||||
@@ -104,7 +116,7 @@ context('filtering tests: ', function () {
|
|||||||
'datetime',
|
'datetime',
|
||||||
() => {
|
() => {
|
||||||
checkInfoBarIncludes(
|
checkInfoBarIncludes(
|
||||||
`(( SOME_DATETIME = 42 ))`,
|
`AND,AND,0,SOME_DATETIME,=,42`,
|
||||||
(includes: boolean) => {
|
(includes: boolean) => {
|
||||||
if (includes) done()
|
if (includes) done()
|
||||||
}
|
}
|
||||||
@@ -120,7 +132,7 @@ context('filtering tests: ', function () {
|
|||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_DATETIME', '42', 'value', () => {
|
setFilterWithValue('SOME_DATETIME', '42', 'value', () => {
|
||||||
checkInfoBarIncludes(
|
checkInfoBarIncludes(
|
||||||
`(( SOME_DATETIME = 42 ))`,
|
`AND,AND,0,SOME_DATETIME,=,42`,
|
||||||
(includes: boolean) => {
|
(includes: boolean) => {
|
||||||
if (includes) done()
|
if (includes) done()
|
||||||
}
|
}
|
||||||
@@ -134,9 +146,12 @@ context('filtering tests: ', function () {
|
|||||||
|
|
||||||
openFilterPopup(() => {
|
openFilterPopup(() => {
|
||||||
setFilterWithValue('SOME_DATE', '', 'in', () => {
|
setFilterWithValue('SOME_DATE', '', 'in', () => {
|
||||||
checkInfoBarIncludes(`(( SOME_DATE IN (42) ))`, (includes: boolean) => {
|
checkInfoBarIncludes(
|
||||||
if (includes) done()
|
`AND,AND,0,SOME_DATE,IN,(0)`,
|
||||||
})
|
(includes: boolean) => {
|
||||||
|
if (includes) done()
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -296,11 +311,7 @@ const setFilterWithValue = (
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// The IN clause is submitted by the modal footer button above; the
|
break
|
||||||
// trailing tab/click sequence below is for the plain value fields and
|
|
||||||
// must not run here (focus is gone once the modal closes and the
|
|
||||||
// editor reloads with the stored filter).
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
case 'between': {
|
case 'between': {
|
||||||
cy.focused().tab()
|
cy.focused().tab()
|
||||||
|
|||||||
@@ -319,17 +319,15 @@ context('licensing tests: ', function () {
|
|||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
visitPage('licensing/update')
|
visitPage('licensing/update')
|
||||||
cy.wait(2000)
|
// Chained via .then() rather than called as the next plain
|
||||||
|
// statement - generateCombinedKey is a raw async function that
|
||||||
|
// itself invokes cy commands (cy.log), and Cypress can't
|
||||||
|
// reconcile that promise with the preceding cy.visit()/cy.wait()
|
||||||
|
// unless it's explicitly handed off through a real command chain.
|
||||||
|
cy.wait(2000).then(() => proceed())
|
||||||
|
} else {
|
||||||
|
proceed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// proceed() generates the combined key asynchronously, then
|
|
||||||
// enqueues the actual page commands only once its promise
|
|
||||||
// resolves. Bridged through cy.then() so the command chain stays
|
|
||||||
// open across that async gap: a bare proceed() call returns a
|
|
||||||
// promise that nothing awaits, and once the surrounding queue
|
|
||||||
// has drained, commands enqueued from that promise's continuation
|
|
||||||
// are never run.
|
|
||||||
cy.then(() => proceed())
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -393,13 +391,14 @@ context('licensing tests: ', function () {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// See test 7's own comment for why proceed() is bridged through
|
// See test 7's own comment for why this is chained via .then()
|
||||||
// cy.then() rather than called as a plain statement.
|
// rather than called as the next plain statement.
|
||||||
if (!result) {
|
if (!result) {
|
||||||
visitPage('licensing/update')
|
visitPage('licensing/update')
|
||||||
cy.wait(2000)
|
cy.wait(2000).then(() => proceed())
|
||||||
|
} else {
|
||||||
|
proceed()
|
||||||
}
|
}
|
||||||
cy.then(() => proceed())
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -450,13 +449,14 @@ context('licensing tests: ', function () {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// See test 7's own comment for why proceed() is bridged through
|
// See test 7's own comment for why this is chained via .then()
|
||||||
// cy.then() rather than called as a plain statement.
|
// rather than called as the next plain statement.
|
||||||
if (!result) {
|
if (!result) {
|
||||||
visitPage('licensing/update')
|
visitPage('licensing/update')
|
||||||
cy.wait(2000)
|
cy.wait(2000).then(() => proceed())
|
||||||
|
} else {
|
||||||
|
proceed()
|
||||||
}
|
}
|
||||||
cy.then(() => proceed())
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+14
-120
@@ -1,81 +1,37 @@
|
|||||||
const username = Cypress.env('username')
|
|
||||||
const password = Cypress.env('password')
|
|
||||||
const hostUrl = Cypress.env('hosturl')
|
const hostUrl = Cypress.env('hosturl')
|
||||||
const appLocation = Cypress.env('appLocation')
|
const appLocation = Cypress.env('appLocation')
|
||||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||||
const serverType = Cypress.env('serverType')
|
|
||||||
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
|
||||||
const fixturePath = 'excels/'
|
|
||||||
|
|
||||||
context('stage tests: ', function () {
|
context('stage tests: ', function () {
|
||||||
this.beforeAll(() => {
|
this.beforeAll(() => {
|
||||||
cy.visit(`${hostUrl}/SASLogon/logout`)
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
||||||
cy.loginAndUpdateValidKey(true)
|
cy.loginAndUpdateValidKey()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.beforeEach(() => {
|
this.beforeEach(() => {
|
||||||
cy.visit(hostUrl + appLocation)
|
cy.visit(hostUrl + appLocation)
|
||||||
|
|
||||||
visitPage('home')
|
visitPage('stage/DC20221007T122326121_612316_7259')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// getstagetable's mock ignores the table_id param entirely, so any id in
|
||||||
|
// the URL resolves to the same fixture row - no need to submit a real
|
||||||
|
// table first just to reach this page.
|
||||||
it('1 | Formatted/Unformatted toggle switches between fmt_stagetable and stagetable, defaulting to formatted', () => {
|
it('1 | Formatted/Unformatted toggle switches between fmt_stagetable and stagetable, defaulting to formatted', () => {
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
||||||
|
'not.exist'
|
||||||
|
)
|
||||||
|
|
||||||
attachExcelFile('regular_excel.xlsx', () => {
|
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '12FEB1960')
|
||||||
submitExcel()
|
|
||||||
|
|
||||||
// The app navigates to /stage/{tableId}.
|
cy.get('.formatted-values-toggle').click()
|
||||||
cy.url({ timeout: longerCommandTimeout }).should('include', '/stage/')
|
|
||||||
|
|
||||||
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '42')
|
||||||
'not.exist'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Toggle to unformatted first to read the raw SOME_DATE value.
|
// Toggling back reverts to the formatted view.
|
||||||
cy.get('.formatted-values-toggle').click()
|
cy.get('.formatted-values-toggle').click()
|
||||||
|
|
||||||
getCellByHeaderAndRow(0, 'SOME_DATE')
|
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '12FEB1960')
|
||||||
.invoke('text')
|
|
||||||
.then((rawText) => {
|
|
||||||
const rawDate = parseInt(rawText, 10)
|
|
||||||
|
|
||||||
// Toggle back to formatted view.
|
|
||||||
cy.get('.formatted-values-toggle').click()
|
|
||||||
|
|
||||||
// Compute the expected SAS date9. format from the raw value.
|
|
||||||
const sasEpoch = new Date(Date.UTC(1960, 0, 1))
|
|
||||||
const formatted = new Date(sasEpoch.getTime() + rawDate * 86400000)
|
|
||||||
const months = [
|
|
||||||
'JAN',
|
|
||||||
'FEB',
|
|
||||||
'MAR',
|
|
||||||
'APR',
|
|
||||||
'MAY',
|
|
||||||
'JUN',
|
|
||||||
'JUL',
|
|
||||||
'AUG',
|
|
||||||
'SEP',
|
|
||||||
'OCT',
|
|
||||||
'NOV',
|
|
||||||
'DEC'
|
|
||||||
]
|
|
||||||
const expected =
|
|
||||||
String(formatted.getUTCDate()).padStart(2, '0') +
|
|
||||||
months[formatted.getUTCMonth()] +
|
|
||||||
formatted.getUTCFullYear()
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', expected)
|
|
||||||
|
|
||||||
// Toggle to unformatted again to verify round-trip.
|
|
||||||
cy.get('.formatted-values-toggle').click()
|
|
||||||
|
|
||||||
getCellByHeaderAndRow(0, 'SOME_DATE').should(
|
|
||||||
'have.text',
|
|
||||||
String(rawDate)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -83,68 +39,6 @@ const visitPage = (url: string) => {
|
|||||||
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
|
||||||
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
|
||||||
.should('not.exist')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
}).then((treeNodes: any) => {
|
|
||||||
let viyaLib
|
|
||||||
|
|
||||||
for (let node of treeNodes) {
|
|
||||||
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
|
|
||||||
viyaLib = node
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get(viyaLib).within(() => {
|
|
||||||
cy.get('.clr-tree-node-content-container > button').click()
|
|
||||||
|
|
||||||
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
|
||||||
for (let innerNode of innerNodes) {
|
|
||||||
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
|
||||||
innerNode.click()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachExcelFile = (excelFilename: string, callback?: any) => {
|
|
||||||
cy.get('.buttonBar button:last-child')
|
|
||||||
.should('exist')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('input[type="file"]#file-upload')
|
|
||||||
.attachFile(`/${fixturePath}/${excelFilename}`)
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.clr-abort-modal .modal-title').then((modalTitle) => {
|
|
||||||
if (!modalTitle[0].innerHTML.includes('Abort Message')) {
|
|
||||||
cy.get('.modal-footer .btn.btn-primary').then((modalBtn) => {
|
|
||||||
modalBtn.click()
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
if (callback) callback()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitExcel = (callback?: any) => {
|
|
||||||
cy.get('.buttonBar button.preview-submit', { timeout: longerCommandTimeout })
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
if (callback) callback()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locates a body cell by its column's header text rather than a hardcoded
|
// Locates a body cell by its column's header text rather than a hardcoded
|
||||||
// childNodes index - same helper as editor.cy.ts's own.
|
// childNodes index - same helper as editor.cy.ts's own.
|
||||||
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
|
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
// Marks this file as an ES module (rather than a global script) so its
|
|
||||||
// top-level consts don't collide, under the TS type-checker, with the same
|
|
||||||
// names declared in other spec files — see e.g. stage.cy.ts, which gets
|
|
||||||
// this for free via a real import.
|
|
||||||
export {}
|
|
||||||
|
|
||||||
const hostUrl = Cypress.env('hosturl')
|
|
||||||
const appLocation = Cypress.env('appLocation')
|
|
||||||
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
|
||||||
|
|
||||||
context('submitted-details tests: ', function () {
|
|
||||||
this.beforeAll(() => {
|
|
||||||
cy.loginAndUpdateValidKey(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('1 | clicking an open submit loads its diffs with a single postdata request', () => {
|
|
||||||
// Count every STP request the app makes during the test.
|
|
||||||
// The STP URL is `stp/execute/?_program=...` - glob segments don't match
|
|
||||||
// past `execute/` with a single `*`, so keep the glob within the path.
|
|
||||||
cy.intercept('POST', '**/SASjsApi/stp/**').as('stpExecute')
|
|
||||||
|
|
||||||
cy.visit(`${hostUrl}${appLocation}/#/review/submitted`)
|
|
||||||
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
|
||||||
'not.exist'
|
|
||||||
)
|
|
||||||
|
|
||||||
// the SUBMIT queue list must be rendered with at least one row
|
|
||||||
cy.get('app-submitter clr-datagrid clr-dg-row', {
|
|
||||||
timeout: longerCommandTimeout
|
|
||||||
})
|
|
||||||
.should('exist')
|
|
||||||
.and('have.length.greaterThan', 0)
|
|
||||||
|
|
||||||
// route-based flow: clicking a row navigates to /review/submitted/:tableId
|
|
||||||
// and triggers exactly one SHOW_DIFFS (auditors/postdata) request
|
|
||||||
cy.get('app-submitter clr-datagrid clr-dg-row')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.url().should('include', '/review/submitted/')
|
|
||||||
})
|
|
||||||
|
|
||||||
// wait for the diff table to render (the response was applied)
|
|
||||||
cy.get('app-approve-details .card', { timeout: longerCommandTimeout })
|
|
||||||
.should('exist')
|
|
||||||
.should('be.visible')
|
|
||||||
|
|
||||||
// give any (incorrect) duplicate request time to surface
|
|
||||||
cy.wait(3000)
|
|
||||||
|
|
||||||
cy.get('@stpExecute.all').then((allRequests: any) => {
|
|
||||||
// the adapter posts multipart form data, so match on the _program
|
|
||||||
// query param rather than the request body
|
|
||||||
const postdataRequests = allRequests.filter((req: any) =>
|
|
||||||
JSON.stringify(req.request.query || {}).includes('auditors/postdata')
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
postdataRequests.length,
|
|
||||||
'exactly one auditors/postdata SHOW_DIFFS request'
|
|
||||||
).to.equal(1)
|
|
||||||
|
|
||||||
// the submit queue was already fetched to render the list - opening a
|
|
||||||
// row must not fetch it again
|
|
||||||
const getsubmitsRequests = allRequests.filter((req: any) =>
|
|
||||||
JSON.stringify(req.request.query || {}).includes('editors/getsubmits')
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
getsubmitsRequests.length,
|
|
||||||
'no editors/getsubmits request after opening a submit'
|
|
||||||
).to.equal(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Binary file not shown.
Generated
+1431
-1772
File diff suppressed because it is too large
Load Diff
+4
-5
@@ -54,9 +54,9 @@
|
|||||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||||
"@clr/icons": "^13.0.2",
|
"@clr/icons": "^13.0.2",
|
||||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||||
"@handsontable/angular-wrapper": "18.0.0",
|
"@handsontable/angular-wrapper": "^18.0.0",
|
||||||
"@sasjs/adapter": "^4.18.0",
|
"@sasjs/adapter": "^4.17.3",
|
||||||
"@sasjs/utils": "^3.6.0",
|
"@sasjs/utils": "^3.5.9",
|
||||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||||
"@types/d3-graphviz": "^2.6.7",
|
"@types/d3-graphviz": "^2.6.7",
|
||||||
"@types/text-encoding": "0.0.35",
|
"@types/text-encoding": "0.0.35",
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
"d3-graphviz": "^5.0.2",
|
"d3-graphviz": "^5.0.2",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"fs-extra": "^7.0.1",
|
"fs-extra": "^7.0.1",
|
||||||
"handsontable": "18.0.0",
|
"handsontable": "^18.0.0",
|
||||||
"https-browserify": "1.0.0",
|
"https-browserify": "1.0.0",
|
||||||
"hyperformula": "^2.5.0",
|
"hyperformula": "^2.5.0",
|
||||||
"iconv-lite": "^0.5.0",
|
"iconv-lite": "^0.5.0",
|
||||||
@@ -148,7 +148,6 @@
|
|||||||
"ajv": "8.18.0",
|
"ajv": "8.18.0",
|
||||||
"uuid": "11.1.1",
|
"uuid": "11.1.1",
|
||||||
"lighthouse": "13.4.0",
|
"lighthouse": "13.4.0",
|
||||||
"fast-uri": "3.1.7",
|
|
||||||
"readdir-glob": {
|
"readdir-glob": {
|
||||||
"brace-expansion": "^5.0.9"
|
"brace-expansion": "^5.0.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -130,18 +130,25 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="logo d-flex clr-align-items-center">
|
<div class="logo d-flex clr-align-items-center">
|
||||||
<a href="#" [routerLink]="['/']" class="nav-link">
|
@if (!router.url.includes('deploy')) {
|
||||||
<img
|
<a href="#" [routerLink]="['/']" class="nav-link">
|
||||||
class="without-text d-block d-md-none"
|
<img
|
||||||
src="images/dc-logo.svg"
|
class="without-text d-block d-md-none"
|
||||||
alt="datacontroller logo without text"
|
src="images/dc-logo.svg"
|
||||||
/>
|
alt="datacontroller logo without text"
|
||||||
<img
|
/>
|
||||||
class="with-text d-none d-md-block"
|
<img
|
||||||
src="images/datacontroller.svg"
|
class="with-text d-none d-md-block"
|
||||||
alt="datacontroller logo"
|
src="images/datacontroller.svg"
|
||||||
/>
|
alt="datacontroller logo"
|
||||||
</a>
|
/>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (router.url.includes('deploy')) {
|
||||||
|
<a>
|
||||||
|
<span class="clr-icon header-logo ml-10"></span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (
|
@if (
|
||||||
!router.url.includes('deploy') && !router.url.includes('licensing')
|
!router.url.includes('deploy') && !router.url.includes('licensing')
|
||||||
@@ -311,45 +318,6 @@
|
|||||||
<div class="subline dec"></div>
|
<div class="subline dec"></div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (startupSteps.length > 0) {
|
|
||||||
<div class="startup-checks">
|
|
||||||
@for (step of startupSteps; track step.label) {
|
|
||||||
<div
|
|
||||||
class="startup-check"
|
|
||||||
[class.startup-check--error]="step.status === 'error'"
|
|
||||||
[class.startup-check--done]="step.status === 'done'"
|
|
||||||
>
|
|
||||||
@switch (step.status) {
|
|
||||||
@case ('pending') {
|
|
||||||
<clr-icon
|
|
||||||
shape="circle"
|
|
||||||
class="is-solid startup-check__icon--pending"
|
|
||||||
></clr-icon>
|
|
||||||
}
|
|
||||||
@case ('in_progress') {
|
|
||||||
<clr-spinner clrSmall></clr-spinner>
|
|
||||||
}
|
|
||||||
@case ('done') {
|
|
||||||
<clr-icon
|
|
||||||
shape="check-circle"
|
|
||||||
class="is-solid startup-check__icon--done"
|
|
||||||
></clr-icon>
|
|
||||||
}
|
|
||||||
@case ('error') {
|
|
||||||
<clr-icon
|
|
||||||
shape="exclamation-circle"
|
|
||||||
class="is-solid startup-check__icon--error"
|
|
||||||
></clr-icon>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<span class="startup-check__label">{{ step.label }}</span>
|
|
||||||
@if (step.detail) {
|
|
||||||
<span class="startup-check__detail">{{ step.detail }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- /App Loading Page -->
|
<!-- /App Loading Page -->
|
||||||
|
|||||||
@@ -19,10 +19,6 @@ import { InfoModal } from './models/InfoModal'
|
|||||||
import { DcAdapterSettings } from './models/DcAdapterSettings'
|
import { DcAdapterSettings } from './models/DcAdapterSettings'
|
||||||
import { AppStoreService } from './services/app-store.service'
|
import { AppStoreService } from './services/app-store.service'
|
||||||
import { LicenceService } from './services/licence.service'
|
import { LicenceService } from './services/licence.service'
|
||||||
import {
|
|
||||||
StartupCheckService,
|
|
||||||
StartupStep
|
|
||||||
} from './services/startup-check.service'
|
|
||||||
import '@cds/core/icon/register.js'
|
import '@cds/core/icon/register.js'
|
||||||
import {
|
import {
|
||||||
ClarityIcons,
|
ClarityIcons,
|
||||||
@@ -68,7 +64,6 @@ export class AppComponent {
|
|||||||
public requestsModal: boolean = false
|
public requestsModal: boolean = false
|
||||||
public showRegistration: boolean = true
|
public showRegistration: boolean = true
|
||||||
public startupDataLoaded: boolean = false
|
public startupDataLoaded: boolean = false
|
||||||
public startupSteps: StartupStep[] = []
|
|
||||||
public demoLimitNotice: { open: boolean; featureName: string } = {
|
public demoLimitNotice: { open: boolean; featureName: string } = {
|
||||||
open: false,
|
open: false,
|
||||||
featureName: ''
|
featureName: ''
|
||||||
@@ -86,16 +81,11 @@ export class AppComponent {
|
|||||||
private location: Location,
|
private location: Location,
|
||||||
private eventService: EventService,
|
private eventService: EventService,
|
||||||
private appStoreService: AppStoreService,
|
private appStoreService: AppStoreService,
|
||||||
private startupCheckService: StartupCheckService,
|
|
||||||
private cdr: ChangeDetectorRef,
|
private cdr: ChangeDetectorRef,
|
||||||
private elementRef: ElementRef
|
private elementRef: ElementRef
|
||||||
) {
|
) {
|
||||||
this.parseDcAdapterSettings()
|
this.parseDcAdapterSettings()
|
||||||
|
|
||||||
this.startupCheckService.steps$.subscribe((steps) => {
|
|
||||||
this.startupSteps = steps
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prints app info in the console such as:
|
* Prints app info in the console such as:
|
||||||
* - Adapter versions
|
* - Adapter versions
|
||||||
|
|||||||
@@ -77,173 +77,69 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<h4 class="text-center my-15">Viya Deploy</h4>
|
<h4 class="text-center my-15">Viya Deploy</h4>
|
||||||
<p class="deploy-intro">
|
|
||||||
Configure and deploy Data Controller to your Viya environment. Deployment
|
|
||||||
creates the backend SAS services and the control library used by the
|
|
||||||
application.
|
|
||||||
</p>
|
|
||||||
<hr />
|
<hr />
|
||||||
|
<label for="dcloc" class="mt-20 clr-control-label">App Loc</label>
|
||||||
<div class="deploy-field">
|
<div class="mb-10 clr-control-container">
|
||||||
<label class="clr-control-label">App Loc</label>
|
<div class="clr-input-wrapper">
|
||||||
<p class="deploy-field-description">
|
<p class="mt-0">{{ appLoc }}</p>
|
||||||
The SAS drive folder in which the Data Controller frontend and services are
|
|
||||||
deployed. Derived from your deployment configuration — it cannot be
|
|
||||||
changed here.
|
|
||||||
</p>
|
|
||||||
<div class="clr-control-container">
|
|
||||||
<div class="clr-input-wrapper">
|
|
||||||
<p class="m-0">{{ appLoc }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="deploy-field">
|
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
|
||||||
<label for="dcloc" class="clr-control-label">DC Loc</label>
|
<div class="mb-10 clr-control-container dc-loc-input-wrapper">
|
||||||
<p class="deploy-field-description">
|
<div class="clr-input-wrapper small-mt">
|
||||||
A physical directory on the compute server to which the deployment user can
|
<input clrInput name="dcloc" [(ngModel)]="dcPath" />
|
||||||
write. Data Controller stores its control tables, staging area and audit
|
|
||||||
history here.
|
|
||||||
</p>
|
|
||||||
<div class="clr-control-container dc-loc-input-wrapper">
|
|
||||||
<div class="clr-input-wrapper small-mt">
|
|
||||||
<input clrInput name="dcloc" [(ngModel)]="dcPath" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="deploy-field">
|
<label for="dcloc" class="mt-20 clr-control-label">SAS Admin group</label>
|
||||||
<label for="adminGroup" class="clr-control-label">SAS Admin group</label>
|
<div class="mb-10 clr-control-container">
|
||||||
<p class="deploy-field-description">
|
<div class="clr-input-wrapper small-mt">
|
||||||
The group you select becomes the Data Controller Admin Group —
|
@if (!adminGroupsLoading) {
|
||||||
everyone in it will have unrestricted access to Data Controller. By default
|
<select clrSelect name="options" [(ngModel)]="selectedAdminGroup">
|
||||||
the list is filtered to the groups your identity
|
@for (adminGroup of adminGroups; track adminGroup) {
|
||||||
@if (currentUserInfo?.id) {
|
<option [value]="adminGroup.id">
|
||||||
(<strong>{{ currentUserInfo?.id }}</strong
|
{{ adminGroup.name }}
|
||||||
>)
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
@if (adminGroupsLoading) {
|
||||||
|
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
|
||||||
}
|
}
|
||||||
is a member of. Toggle below to list every group on the Viya server.
|
|
||||||
</p>
|
|
||||||
<clr-toggle-container class="mt-0">
|
|
||||||
<clr-toggle-wrapper>
|
|
||||||
<input
|
|
||||||
id="show-all-groups-toggle"
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="showAllGroups"
|
|
||||||
(ngModelChange)="onShowAllGroupsChange()"
|
|
||||||
clrToggle
|
|
||||||
/>
|
|
||||||
<label for="show-all-groups-toggle">Show all groups</label>
|
|
||||||
</clr-toggle-wrapper>
|
|
||||||
</clr-toggle-container>
|
|
||||||
<div class="clr-control-container">
|
|
||||||
<div class="clr-input-wrapper small-mt">
|
|
||||||
@if (!adminGroupsLoading) {
|
|
||||||
<select clrSelect name="adminGroup" [(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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!showAllContexts) {
|
<label for="computeContext" class="mt-20 clr-control-label"
|
||||||
<div class="deploy-field">
|
>Compute Context</label
|
||||||
<label for="batchId" class="clr-control-label">Batch ID</label>
|
>
|
||||||
<p class="deploy-field-description">
|
<div class="mb-10 clr-control-container">
|
||||||
The batch identity (<code>runServerAs</code>) under which Data Controller
|
<div class="clr-input-wrapper small-mt">
|
||||||
jobs will execute. It is used to filter the Compute Context dropdown below
|
@if (!computeContextsLoading) {
|
||||||
to only the contexts running as this identity.
|
<select
|
||||||
</p>
|
clrSelect
|
||||||
<div class="clr-control-container">
|
name="options"
|
||||||
<div class="clr-input-wrapper small-mt">
|
(ngModelChange)="onComputeContextChange($event)"
|
||||||
@if (!computeContextsLoading) {
|
[(ngModel)]="selectedComputeContext"
|
||||||
<select
|
>
|
||||||
clrSelect
|
@for (computeContext of computeContexts; track computeContext) {
|
||||||
name="batchId"
|
<option [value]="computeContext.id">
|
||||||
[(ngModel)]="selectedBatchId"
|
{{ computeContext.name }}
|
||||||
(ngModelChange)="onBatchIdChange()"
|
</option>
|
||||||
>
|
|
||||||
@for (batchId of batchIds; track batchId) {
|
|
||||||
<option [value]="batchId">
|
|
||||||
{{ batchId }}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
}
|
}
|
||||||
@if (computeContextsLoading) {
|
</select>
|
||||||
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
|
}
|
||||||
}
|
@if (computeContextsLoading) {
|
||||||
</div>
|
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="deploy-field">
|
|
||||||
<label for="computeContext" class="clr-control-label">Compute Context</label>
|
|
||||||
<p class="deploy-field-description">
|
|
||||||
The compute context used to run the Data Controller services (including the
|
|
||||||
<code>makedata</code> deployment job). Only reusable contexts matching the
|
|
||||||
selected Batch ID are shown, unless the toggle below is enabled.
|
|
||||||
</p>
|
|
||||||
<clr-toggle-container class="mt-0">
|
|
||||||
<clr-toggle-wrapper>
|
|
||||||
<input
|
|
||||||
id="show-all-contexts-toggle"
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="showAllContexts"
|
|
||||||
(ngModelChange)="onShowAllContextsChange()"
|
|
||||||
clrToggle
|
|
||||||
/>
|
|
||||||
<label for="show-all-contexts-toggle">Show all compute contexts</label>
|
|
||||||
</clr-toggle-wrapper>
|
|
||||||
</clr-toggle-container>
|
|
||||||
<div class="clr-control-container">
|
|
||||||
<div class="clr-input-wrapper small-mt">
|
|
||||||
@if (!computeContextsLoading) {
|
|
||||||
<select
|
|
||||||
clrSelect
|
|
||||||
name="computeContext"
|
|
||||||
(ngModelChange)="onComputeContextChange($event)"
|
|
||||||
[(ngModel)]="selectedComputeContext"
|
|
||||||
>
|
|
||||||
@for (
|
|
||||||
computeContext of getFilteredComputeContexts();
|
|
||||||
track computeContext
|
|
||||||
) {
|
|
||||||
<option [value]="computeContext.id">
|
|
||||||
{{ computeContext.name }}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
}
|
|
||||||
@if (computeContextsLoading) {
|
|
||||||
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (runningAsUser) {
|
@if (runningAsUser) {
|
||||||
<div class="deploy-field">
|
<label for="dcloc" class="mt-20 clr-control-label">Running as user:</label>
|
||||||
<label class="clr-control-label">Running as user</label>
|
<div class="mb-10 clr-control-container">
|
||||||
<p class="deploy-field-description">
|
<div class="clr-input-wrapper">
|
||||||
The identity under which Data Controller jobs will run with the selected
|
<p class="mt-0">{{ runningAsUser }}</p>
|
||||||
compute context. This user must be able to write to the DC Loc above.
|
|
||||||
</p>
|
|
||||||
<div class="clr-control-container">
|
|
||||||
<div class="clr-input-wrapper">
|
|
||||||
<p class="m-0">
|
|
||||||
<strong>{{ runningAsUser }}</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ import {
|
|||||||
Item as ComputeContextItem
|
Item as ComputeContextItem
|
||||||
} from 'src/app/viya-api-explorer/models/viya-compute-contexts.model'
|
} from 'src/app/viya-api-explorer/models/viya-compute-contexts.model'
|
||||||
|
|
||||||
export interface ComputeContextInfo {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
runAs: string | null
|
|
||||||
reusable: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-automatic-deploy',
|
selector: 'app-automatic-deploy',
|
||||||
templateUrl: './automatic.component.html',
|
templateUrl: './automatic.component.html',
|
||||||
@@ -67,21 +60,6 @@ export class AutomaticComponent implements OnInit {
|
|||||||
public currentUserInfoLoading: boolean = false
|
public currentUserInfoLoading: boolean = false
|
||||||
public computeContextsLoading: boolean = false
|
public computeContextsLoading: boolean = false
|
||||||
public adminGroups: { id: string; name: string }[] = []
|
public adminGroups: { id: string; name: string }[] = []
|
||||||
/** Compute contexts enriched with their batch identity and reusability */
|
|
||||||
public computeContextInfos: ComputeContextInfo[] = []
|
|
||||||
/** Distinct batch identities (runServerAs) across reusable contexts */
|
|
||||||
public batchIds: string[] = []
|
|
||||||
public selectedBatchId: string = ''
|
|
||||||
/**
|
|
||||||
* When enabled, any compute context can be chosen (the batch id dropdown
|
|
||||||
* is hidden and no reusability / batch id filtering is applied)
|
|
||||||
*/
|
|
||||||
public showAllContexts: boolean = false
|
|
||||||
/** All groups on the Viya server */
|
|
||||||
public allAdminGroups: { id: string; name: string }[] = []
|
|
||||||
/** Groups the current user is a member of */
|
|
||||||
public memberAdminGroups: { id: string; name: string }[] = []
|
|
||||||
public showAllGroups: boolean = false
|
|
||||||
public runningAsUser: string | undefined
|
public runningAsUser: string | undefined
|
||||||
public currentUserInfo: ViyaApiCurrentUser | null = null
|
public currentUserInfo: ViyaApiCurrentUser | null = null
|
||||||
public computeContexts: ComputeContextItem[] = []
|
public computeContexts: ComputeContextItem[] = []
|
||||||
@@ -126,9 +104,9 @@ export class AutomaticComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async loadData() {
|
public async loadData() {
|
||||||
await this.getCurrentUser()
|
|
||||||
await this.getAdminGroups()
|
await this.getAdminGroups()
|
||||||
await this.getComputeContexts()
|
await this.getComputeContexts()
|
||||||
|
await this.getCurrentUser()
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.selectedComputeContext) {
|
if (this.selectedComputeContext) {
|
||||||
@@ -142,13 +120,20 @@ export class AutomaticComponent implements OnInit {
|
|||||||
this.computeContextsLoading = true
|
this.computeContextsLoading = true
|
||||||
|
|
||||||
this.sasViyaService.getComputeContexts().subscribe(
|
this.sasViyaService.getComputeContexts().subscribe(
|
||||||
async (res: ViyaComputeContexts) => {
|
(res: ViyaComputeContexts) => {
|
||||||
this.computeContexts = res.items
|
|
||||||
|
|
||||||
await this.getComputeContextDetails()
|
|
||||||
|
|
||||||
this.computeContextsLoading = false
|
this.computeContextsLoading = false
|
||||||
|
|
||||||
|
const defaultContext = res.items.find(
|
||||||
|
(item: ComputeContextItem) =>
|
||||||
|
item.name === 'SAS Job Execution compute context'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (defaultContext) {
|
||||||
|
this.selectedComputeContext = defaultContext.id
|
||||||
|
}
|
||||||
|
|
||||||
|
this.computeContexts = res.items
|
||||||
|
|
||||||
resolve()
|
resolve()
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
@@ -158,137 +143,6 @@ export class AutomaticComponent implements OnInit {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The `/compute/contexts` list view is minimal, so each context is fetched
|
|
||||||
* in full (in parallel) to pick up its batch identity (runServerAs) and
|
|
||||||
* whether it reuses server processes. Only reusable contexts with a batch
|
|
||||||
* identity feed the batch id dropdown.
|
|
||||||
*/
|
|
||||||
public async getComputeContextDetails() {
|
|
||||||
const infos = await Promise.all(
|
|
||||||
this.computeContexts.map(
|
|
||||||
(context: ComputeContextItem) =>
|
|
||||||
new Promise<ComputeContextInfo>((resolveInfo) => {
|
|
||||||
this.sasViyaService.getComputeContextById(context.id).subscribe(
|
|
||||||
(details: ComputeContextDetails) => {
|
|
||||||
resolveInfo({
|
|
||||||
id: context.id,
|
|
||||||
name: context.name,
|
|
||||||
runAs: this.extractRunAs(details),
|
|
||||||
reusable: details.attributes?.reuseServerProcesses === 'true'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
(err: any) => {
|
|
||||||
this.loggerService.error(
|
|
||||||
`Error while getting compute context ${context.name}`,
|
|
||||||
err
|
|
||||||
)
|
|
||||||
|
|
||||||
resolveInfo({
|
|
||||||
id: context.id,
|
|
||||||
name: context.name,
|
|
||||||
runAs: null,
|
|
||||||
reusable: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
this.computeContextInfos = infos
|
|
||||||
|
|
||||||
this.batchIds = [
|
|
||||||
...new Set(
|
|
||||||
infos
|
|
||||||
.filter((info) => info.reusable && info.runAs)
|
|
||||||
.map((info) => info.runAs as string)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
if (this.batchIds.length) {
|
|
||||||
this.selectedBatchId = this.batchIds[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredContexts = this.getFilteredComputeContexts()
|
|
||||||
|
|
||||||
const defaultContext =
|
|
||||||
filteredContexts.find(
|
|
||||||
(info: ComputeContextInfo) =>
|
|
||||||
info.name === 'SAS Job Execution compute context'
|
|
||||||
) || filteredContexts[0]
|
|
||||||
|
|
||||||
if (defaultContext) {
|
|
||||||
this.selectedComputeContext = defaultContext.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulls the runAs (batch) identity out of a compute context. The batch user
|
|
||||||
* is exposed as `attributes.runServerAs` in the Viya compute context API
|
|
||||||
* response. Not all contexts expose one.
|
|
||||||
*/
|
|
||||||
public extractRunAs(details: ComputeContextDetails): string | null {
|
|
||||||
return details.attributes?.runServerAs || null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The contexts offered in the dropdown. By default only reusable contexts
|
|
||||||
* (`reuseServerProcesses`) matching the selected batch id are shown. When
|
|
||||||
* the `showAllContexts` toggle is on, all contexts are shown.
|
|
||||||
*/
|
|
||||||
public getFilteredComputeContexts(): ComputeContextInfo[] {
|
|
||||||
if (this.showAllContexts) {
|
|
||||||
return this.computeContextInfos
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.computeContextInfos.filter(
|
|
||||||
(info: ComputeContextInfo) =>
|
|
||||||
info.reusable && info.runAs === this.selectedBatchId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Changing the batch id re-filters the compute context dropdown and makes
|
|
||||||
* sure the currently selected context exists in the displayed list.
|
|
||||||
*/
|
|
||||||
public onBatchIdChange() {
|
|
||||||
this.ensureSelectedComputeContext()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles the compute context dropdown between contexts matching the
|
|
||||||
* selected batch id and all contexts on the Viya server.
|
|
||||||
*/
|
|
||||||
public onShowAllContextsChange() {
|
|
||||||
this.ensureSelectedComputeContext()
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureSelectedComputeContext() {
|
|
||||||
const filteredContexts = this.getFilteredComputeContexts()
|
|
||||||
|
|
||||||
const selectedContextExists = filteredContexts.some(
|
|
||||||
(info: ComputeContextInfo) => info.id === this.selectedComputeContext
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!selectedContextExists) {
|
|
||||||
this.selectedComputeContext = filteredContexts.length
|
|
||||||
? filteredContexts[0].id
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateRunningAsUser()
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateRunningAsUser() {
|
|
||||||
const selectedContext = this.computeContextInfos.find(
|
|
||||||
(info: ComputeContextInfo) => info.id === this.selectedComputeContext
|
|
||||||
)
|
|
||||||
|
|
||||||
this.runningAsUser =
|
|
||||||
selectedContext?.runAs || this.currentUserInfo?.id || 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getCurrentUser() {
|
public async getCurrentUser() {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
this.currentUserInfoLoading = true
|
this.currentUserInfoLoading = true
|
||||||
@@ -316,21 +170,16 @@ export class AutomaticComponent implements OnInit {
|
|||||||
this.adminGroupsLoading = true
|
this.adminGroupsLoading = true
|
||||||
;(this.sasViyaService
|
;(this.sasViyaService
|
||||||
.getAdminGroups()
|
.getAdminGroups()
|
||||||
.subscribe(async (res: ViyaApiIdentities) => {
|
.subscribe((res: ViyaApiIdentities) => {
|
||||||
|
this.adminGroupsLoading = false
|
||||||
// Map admin groups with only needed fields
|
// Map admin groups with only needed fields
|
||||||
this.allAdminGroups = res.items.map((item: Item) => {
|
this.adminGroups = res.items.map((item: Item) => {
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name
|
name: item.name
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.getMemberAdminGroups()
|
|
||||||
|
|
||||||
this.applyAdminGroupFilter()
|
|
||||||
|
|
||||||
this.adminGroupsLoading = false
|
|
||||||
|
|
||||||
resolve()
|
resolve()
|
||||||
}),
|
}),
|
||||||
(err: any) => {
|
(err: any) => {
|
||||||
@@ -343,72 +192,16 @@ export class AutomaticComponent implements OnInit {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the groups the current user is a member of, from the Viya
|
|
||||||
* identities API. If the memberships cannot be fetched, the member list
|
|
||||||
* falls back to all groups.
|
|
||||||
*/
|
|
||||||
public async getMemberAdminGroups() {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
this.sasViyaService.getCurrentUserGroupMemberships().subscribe(
|
|
||||||
(res: ViyaApiIdentities) => {
|
|
||||||
const memberIds = res.items.map((item: Item) => item.id)
|
|
||||||
|
|
||||||
this.memberAdminGroups = this.allAdminGroups.filter(
|
|
||||||
(group: { id: string; name: string }) =>
|
|
||||||
memberIds.includes(group.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
resolve()
|
|
||||||
},
|
|
||||||
(err: any) => {
|
|
||||||
this.loggerService.error(
|
|
||||||
'Error while getting user group memberships',
|
|
||||||
err
|
|
||||||
)
|
|
||||||
this.memberAdminGroups = this.allAdminGroups
|
|
||||||
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles the SAS Admin group dropdown between the groups the current user
|
|
||||||
* is a member of and all groups on the Viya server.
|
|
||||||
*/
|
|
||||||
public onShowAllGroupsChange() {
|
|
||||||
this.applyAdminGroupFilter()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies the member/all groups filter to the dropdown and makes sure the
|
|
||||||
* currently selected group exists in the displayed list.
|
|
||||||
*/
|
|
||||||
private applyAdminGroupFilter() {
|
|
||||||
this.adminGroups =
|
|
||||||
this.showAllGroups || !this.memberAdminGroups.length
|
|
||||||
? this.allAdminGroups
|
|
||||||
: this.memberAdminGroups
|
|
||||||
|
|
||||||
const selectedGroupExists = this.adminGroups.some(
|
|
||||||
(group: { id: string; name: string }) =>
|
|
||||||
group.id === this.selectedAdminGroup
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!selectedGroupExists && this.adminGroups.length) {
|
|
||||||
this.selectedAdminGroup = this.adminGroups[0].id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async onComputeContextChange(computeContextId: string) {
|
public async onComputeContextChange(computeContextId: string) {
|
||||||
// The explicit (ngModelChange) handler runs before the [(ngModel)]
|
this.sasViyaService
|
||||||
// assignment updates the field, so synchronise the selected context id
|
.getComputeContextById(computeContextId)
|
||||||
// here before deriving the running user. Otherwise the displayed user
|
.subscribe((res: ComputeContextDetails) => {
|
||||||
// lags behind the dropdown by one change.
|
if (res.attributes && res.attributes.runServerAs) {
|
||||||
this.selectedComputeContext = computeContextId
|
this.runningAsUser = res.attributes.runServerAs
|
||||||
this.updateRunningAsUser()
|
} else {
|
||||||
|
this.runningAsUser = this.currentUserInfo?.id || 'unknown'
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public getComputeContextName(id: string): string | undefined {
|
public getComputeContextName(id: string): string | undefined {
|
||||||
@@ -548,8 +341,7 @@ export class AutomaticComponent implements OnInit {
|
|||||||
this.eventService.showAbortModal('makedata', abortMsg, {
|
this.eventService.showAbortModal('makedata', abortMsg, {
|
||||||
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
|
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
|
||||||
SYSERRORTEXT: abortRes.SYSERRORTEXT,
|
SYSERRORTEXT: abortRes.SYSERRORTEXT,
|
||||||
MAC: macMsg,
|
MAC: macMsg
|
||||||
_PROGRAM: abortRes._PROGRAM
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,9 +373,7 @@ export class AutomaticComponent implements OnInit {
|
|||||||
let contextname = `&_contextname=${params.contextName}`
|
let contextname = `&_contextname=${params.contextName}`
|
||||||
let admin = `&admin=${params.admin}`
|
let admin = `&admin=${params.admin}`
|
||||||
let dcPath = `&dcpath=${params.dcPath}`
|
let dcPath = `&dcpath=${params.dcPath}`
|
||||||
// Debug is ALWAYS enabled for makedata — it runs only once during
|
let debug = this.sasService.getDebugUrlParam()
|
||||||
// deployment and the full log is needed to diagnose any issues.
|
|
||||||
let debug = '&_debug=131'
|
|
||||||
|
|
||||||
let programUrl =
|
let programUrl =
|
||||||
serverUrl +
|
serverUrl +
|
||||||
@@ -597,53 +387,6 @@ export class AutomaticComponent implements OnInit {
|
|||||||
debug
|
debug
|
||||||
|
|
||||||
window.open(programUrl)
|
window.open(programUrl)
|
||||||
|
|
||||||
// Poll for makedata completion. The makedata service self-deletes
|
|
||||||
// when it finishes successfully, so we check if it's still present
|
|
||||||
// in the admin folder. When it's gone, reload the app fresh.
|
|
||||||
this.pollMakedataCompletion()
|
|
||||||
}
|
|
||||||
|
|
||||||
private makedataPollInterval: any = null
|
|
||||||
|
|
||||||
private pollMakedataCompletion() {
|
|
||||||
if (this.makedataPollInterval) {
|
|
||||||
clearInterval(this.makedataPollInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
let attempts = 0
|
|
||||||
const maxAttempts = 120 // 10 minutes at 5s intervals
|
|
||||||
|
|
||||||
this.makedataPollInterval = setInterval(async () => {
|
|
||||||
attempts++
|
|
||||||
console.log(
|
|
||||||
`pollMakedataCompletion: checking if makedata is gone (attempt ${attempts}/${maxAttempts})`
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const makedataGone = await this.sasService.viyaMakedataSuccessfull()
|
|
||||||
if (makedataGone) {
|
|
||||||
console.log(
|
|
||||||
'pollMakedataCompletion: makedata job is gone, reloading app'
|
|
||||||
)
|
|
||||||
clearInterval(this.makedataPollInterval)
|
|
||||||
this.makedataPollInterval = null
|
|
||||||
|
|
||||||
// Reload the app fresh from the SASJobExecution URL
|
|
||||||
window.location.href = window.location.href.split('#')[0]
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('pollMakedataCompletion: error checking', err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempts >= maxAttempts) {
|
|
||||||
console.warn(
|
|
||||||
'pollMakedataCompletion: timed out after 10 minutes, stopping poll'
|
|
||||||
)
|
|
||||||
clearInterval(this.makedataPollInterval)
|
|
||||||
this.makedataPollInterval = null
|
|
||||||
}
|
|
||||||
}, 5000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -685,12 +428,9 @@ export class AutomaticComponent implements OnInit {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Use a function replacement so $ in computeContextName is treated as a
|
|
||||||
literal character. In a string replacement, $ has special meaning
|
|
||||||
($&, $1, $` etc.); a function return value is used verbatim. */
|
|
||||||
const updatedContent = indexHtmlContent.replace(
|
const updatedContent = indexHtmlContent.replace(
|
||||||
/contextname="[^"]*"/g,
|
/contextname="[^"]*"/g,
|
||||||
() => `contextname="${computeContextName}"`
|
`contextname="${computeContextName}"`
|
||||||
)
|
)
|
||||||
|
|
||||||
await this.sasService
|
await this.sasService
|
||||||
|
|||||||
@@ -242,9 +242,6 @@ export class ManualComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
public createDatabase(newTab: boolean = true) {
|
public createDatabase(newTab: boolean = true) {
|
||||||
if (newTab) {
|
if (newTab) {
|
||||||
// Debug is ALWAYS enabled for makedata — it runs only once during
|
|
||||||
// deployment and the full log is needed to diagnose any issues.
|
|
||||||
const _debug = '&_debug=131'
|
|
||||||
let url =
|
let url =
|
||||||
this.sasService.getSasjsConfig().serverUrl +
|
this.sasService.getSasjsConfig().serverUrl +
|
||||||
'/SASJobExecution/?_program=' +
|
'/SASJobExecution/?_program=' +
|
||||||
@@ -255,7 +252,7 @@ export class ManualComponent implements OnInit {
|
|||||||
this.selectedAdminGroup +
|
this.selectedAdminGroup +
|
||||||
'&DCPATH=' +
|
'&DCPATH=' +
|
||||||
this.dcPath +
|
this.dcPath +
|
||||||
_debug
|
this.sasService.getDebugUrlParam()
|
||||||
|
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
|
|
||||||
|
|||||||
@@ -120,10 +120,8 @@ export class SasjsConfiguratorComponent implements OnInit {
|
|||||||
* Creating database
|
* Creating database
|
||||||
*/
|
*/
|
||||||
makeData() {
|
makeData() {
|
||||||
// Debug is ALWAYS enabled for this service. The makedata service runs only once
|
// const _debug = "&_debug=131"; //debug on
|
||||||
// during deployment configuration, so full debug output (131) is always wanted
|
const _debug = ' ' //debug off
|
||||||
// to help diagnose any issues. This is NOT temporary — do not remove or gate it.
|
|
||||||
const _debug = '&_debug=131'
|
|
||||||
|
|
||||||
let executor = this.sasService.getExecutionPath()
|
let executor = this.sasService.getExecutionPath()
|
||||||
const root = this.sasJsConfig.appLoc
|
const root = this.sasJsConfig.appLoc
|
||||||
|
|||||||
@@ -46,11 +46,9 @@ import { preventMenuItemAutoClose } from './utils/preventMenuItemAutoClose'
|
|||||||
import { findOverwrittenCells } from '../shared/dc-validator/utils/findOverwrittenCells'
|
import { findOverwrittenCells } from '../shared/dc-validator/utils/findOverwrittenCells'
|
||||||
import { getFormulaCellsToPreserveOnCancel } from '../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
|
import { getFormulaCellsToPreserveOnCancel } from '../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
|
||||||
import { getRevertableCols } from '../shared/dc-validator/utils/getRevertableCols'
|
import { getRevertableCols } from '../shared/dc-validator/utils/getRevertableCols'
|
||||||
import { resolveRevertedCellValue } from '../shared/dc-validator/utils/resolveRevertedCellValue'
|
|
||||||
import { getStableFormulaBaseCols } from '../shared/dc-validator/utils/getStableFormulaBaseCols'
|
import { getStableFormulaBaseCols } from '../shared/dc-validator/utils/getStableFormulaBaseCols'
|
||||||
import { syncOverwrittenCellComment } from '../shared/dc-validator/utils/syncOverwrittenCellComment'
|
import { syncOverwrittenCellComment } from '../shared/dc-validator/utils/syncOverwrittenCellComment'
|
||||||
import { parseFormulaRule } from '../shared/dc-validator/utils/parseFormulaRule'
|
import { parseFormulaRule } from '../shared/dc-validator/utils/parseFormulaRule'
|
||||||
import { substituteColumnReferences } from '../shared/dc-validator/utils/substituteColumnReferences'
|
|
||||||
import { DcValidator } from '../shared/dc-validator/dc-validator'
|
import { DcValidator } from '../shared/dc-validator/dc-validator'
|
||||||
import { Col } from '../shared/dc-validator/models/col.model'
|
import { Col } from '../shared/dc-validator/models/col.model'
|
||||||
import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
|
import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
|
||||||
@@ -59,17 +57,6 @@ import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
|
|||||||
import { excelRound } from '../shared/dc-validator/utils/excelRound'
|
import { excelRound } from '../shared/dc-validator/utils/excelRound'
|
||||||
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
|
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
|
||||||
import { hasFormulaRules } from '../shared/dc-validator/utils/hasFormulaRules'
|
import { hasFormulaRules } from '../shared/dc-validator/utils/hasFormulaRules'
|
||||||
import { getFormulaColumnNames } from '../shared/dc-validator/utils/getFormulaColumnNames'
|
|
||||||
import { isCharacterColumn } from '../shared/dc-validator/utils/isCharacterColumn'
|
|
||||||
import { escapeCharacterColumnValue } from '../shared/dc-validator/utils/escapeCharacterColumnValue'
|
|
||||||
import { unescapeFormula } from '../shared/dc-validator/utils/unescapeFormula'
|
|
||||||
import {
|
|
||||||
AutoEscapedCellMap,
|
|
||||||
getRowKey,
|
|
||||||
markAutoEscaped,
|
|
||||||
isAutoEscaped,
|
|
||||||
clearAutoEscaped
|
|
||||||
} from '../shared/dc-validator/utils/autoEscapedCellTracker'
|
|
||||||
import { HyperFormula } from 'hyperformula'
|
import { HyperFormula } from 'hyperformula'
|
||||||
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
|
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
|
||||||
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
|
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
|
||||||
@@ -86,7 +73,6 @@ import { parseTableColumns } from './utils/grid.utils'
|
|||||||
import { classifyRow } from './utils/classifyRow'
|
import { classifyRow } from './utils/classifyRow'
|
||||||
import { withoutEditStatus } from './utils/withoutEditStatus'
|
import { withoutEditStatus } from './utils/withoutEditStatus'
|
||||||
import { getEditStatusSymbol } from './utils/getEditStatusSymbol'
|
import { getEditStatusSymbol } from './utils/getEditStatusSymbol'
|
||||||
import { normalizeSortConfig } from './utils/normalizeSortConfig'
|
|
||||||
import { EDIT_STATUS_COLUMN_NAME } from '../shared/dc-validator/utils/editStatusColumnRule'
|
import { EDIT_STATUS_COLUMN_NAME } from '../shared/dc-validator/utils/editStatusColumnRule'
|
||||||
import {
|
import {
|
||||||
errorRenderer,
|
errorRenderer,
|
||||||
@@ -170,12 +156,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
licenseKey: this.hotTable.licenseKey,
|
licenseKey: this.hotTable.licenseKey,
|
||||||
readOnly: this.hotTable.readOnly,
|
readOnly: this.hotTable.readOnly,
|
||||||
copyPaste: this.hotTable.copyPaste,
|
copyPaste: this.hotTable.copyPaste,
|
||||||
// Must carry the current formulas config through - this rebuilds
|
|
||||||
// hotTableSettings (and re-applies it via the [settings] binding)
|
|
||||||
// whenever hot_license_key emits, which can happen after a table has
|
|
||||||
// already loaded and turned formulas on; omitting the key here would
|
|
||||||
// let that rebuild silently drop it again.
|
|
||||||
formulas: this.hotTable.formulas,
|
|
||||||
contextMenu: true,
|
contextMenu: true,
|
||||||
className: 'htDark',
|
className: 'htDark',
|
||||||
theme: 'ht-theme-classic'
|
theme: 'ht-theme-classic'
|
||||||
@@ -190,11 +170,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
height: 'calc(100vh - 160px)',
|
height: 'calc(100vh - 160px)',
|
||||||
licenseKey: undefined,
|
licenseKey: undefined,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
// Every table gets the formulas engine, regardless of whether it
|
|
||||||
// declares any HARDFORMULA/SOFTFORMULA rules - set here (not only
|
|
||||||
// inside initSetup) so it's already present in hotTableSettings for
|
|
||||||
// the very first [settings] binding, before any table has loaded.
|
|
||||||
formulas: { engine: HyperFormula, licenseKey: 'gpl-v3' },
|
|
||||||
copyPaste: {
|
copyPaste: {
|
||||||
copyColumnHeaders: true,
|
copyColumnHeaders: true,
|
||||||
copyColumnHeadersOnly: true
|
copyColumnHeadersOnly: true
|
||||||
@@ -287,71 +262,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
hot.setDataAtRowProp(
|
hot.setDataAtRowProp(
|
||||||
row,
|
row,
|
||||||
prop,
|
prop,
|
||||||
resolveRevertedCellValue(rawValueText, isNumericCol),
|
isNumericCol ? Number(rawValueText) : rawValueText
|
||||||
'revert'
|
|
||||||
)
|
)
|
||||||
commentsPlugin.removeCommentAtCell(row, col)
|
commentsPlugin.removeCommentAtCell(row, col)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Only ever shown when the selection contains at least one cell
|
|
||||||
// this app itself auto-escaped (see character-column-formula-
|
|
||||||
// plan.md) - a genuinely literal leading `'` the backend sent, or
|
|
||||||
// one the user typed themselves, is never in autoEscapedCells and
|
|
||||||
// so never makes this item appear or gets touched by it.
|
|
||||||
apply_as_formula: {
|
|
||||||
name: 'Apply as formula',
|
|
||||||
hidden: (): boolean => {
|
|
||||||
const hot = this.hotInstance
|
|
||||||
if (hot.getSettings().readOnly) return true
|
|
||||||
|
|
||||||
const ranges: CellRange[] | undefined = hot.getSelectedRange()
|
|
||||||
if (!ranges || ranges.length === 0) return true
|
|
||||||
|
|
||||||
const cells = expandCellRanges(
|
|
||||||
ranges,
|
|
||||||
hot.countRows(),
|
|
||||||
hot.countCols()
|
|
||||||
)
|
|
||||||
|
|
||||||
return !cells.some(({ row, col }) => {
|
|
||||||
const dataRow = this.dataSource[hot.toPhysicalRow(row)]
|
|
||||||
if (!dataRow) return false
|
|
||||||
|
|
||||||
const colName = hot.colToProp(col) as string
|
|
||||||
return isAutoEscaped(
|
|
||||||
this.autoEscapedCells,
|
|
||||||
getRowKey(dataRow, this.headerPks),
|
|
||||||
colName
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
callback: (key: string, selection: any[]) => {
|
|
||||||
const hot = this.hotInstance
|
|
||||||
const cells = expandCellRanges(
|
|
||||||
selection.map((sel) => ({ from: sel.start, to: sel.end })),
|
|
||||||
hot.countRows(),
|
|
||||||
hot.countCols()
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const { row, col } of cells) {
|
|
||||||
const dataRow = this.dataSource[hot.toPhysicalRow(row)]
|
|
||||||
if (!dataRow) continue
|
|
||||||
|
|
||||||
const colName = hot.colToProp(col) as string
|
|
||||||
const rowKey = getRowKey(dataRow, this.headerPks)
|
|
||||||
if (!isAutoEscaped(this.autoEscapedCells, rowKey, colName))
|
|
||||||
continue
|
|
||||||
|
|
||||||
hot.setDataAtRowProp(
|
|
||||||
row,
|
|
||||||
colName,
|
|
||||||
unescapeFormula(hot.getDataAtRowProp(row, colName))
|
|
||||||
)
|
|
||||||
clearAutoEscaped(this.autoEscapedCells, rowKey, colName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
row_above: {
|
row_above: {
|
||||||
name: 'Insert Row above',
|
name: 'Insert Row above',
|
||||||
hidden: () => this.hotTable.readOnly === true,
|
hidden: () => this.hotTable.readOnly === true,
|
||||||
@@ -576,12 +492,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
dataSourceRaw!: any[]
|
dataSourceRaw!: any[]
|
||||||
dataSourceBeforeSubmit!: any[]
|
dataSourceBeforeSubmit!: any[]
|
||||||
dataModified!: any[]
|
dataModified!: any[]
|
||||||
// Cells the app itself auto-escaped (see escapeCharacterColumnValue) -
|
|
||||||
// PK-keyed, not a property on the row objects, so it can never leak into
|
|
||||||
// the submit payload and survives insert/delete/sort. Reset on every
|
|
||||||
// fresh load, same lifecycle as dataSourceRaw/dataSourceUnchanged. See
|
|
||||||
// character-column-formula-plan.md.
|
|
||||||
private autoEscapedCells: AutoEscapedCellMap = new Map()
|
|
||||||
|
|
||||||
public filePasswordSubject: Subject<string | undefined> = new Subject()
|
public filePasswordSubject: Subject<string | undefined> = new Subject()
|
||||||
public fileUnlockError = false
|
public fileUnlockError = false
|
||||||
@@ -650,7 +560,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
private ariaObserver: MutationObserver | undefined
|
private ariaObserver: MutationObserver | undefined
|
||||||
private ariaCheckInterval: any | undefined
|
private ariaCheckInterval: any | undefined
|
||||||
private gridResizeObserver: ResizeObserver | undefined
|
private gridResizeObserver: ResizeObserver | undefined
|
||||||
private pasteListener: ((event: ClipboardEvent) => void) | undefined
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private licenceService: LicenceService,
|
private licenceService: LicenceService,
|
||||||
@@ -896,8 +805,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.eventService.showAbortModal('', abortMsg, {
|
this.eventService.showAbortModal('', abortMsg, {
|
||||||
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
|
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
|
||||||
SYSERRORTEXT: abortRes.SYSERRORTEXT,
|
SYSERRORTEXT: abortRes.SYSERRORTEXT,
|
||||||
MAC: macMsg,
|
MAC: macMsg
|
||||||
_PROGRAM: abortRes._PROGRAM
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -947,36 +855,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
previewDatasource.push(itemObject)
|
previewDatasource.push(itemObject)
|
||||||
})
|
})
|
||||||
|
|
||||||
// An uploaded file is bulk, external data - not something the user
|
|
||||||
// typed/pasted cell-by-cell - so a `=`-led value in a character column
|
|
||||||
// must be escaped to inert text, the same way a fresh backend load is
|
|
||||||
// (see initSetup's own escape loop). Formulas are enabled for every
|
|
||||||
// table, so without this a spreadsheet cell that merely looks like a
|
|
||||||
// formula (an inventory code, a serial number) would silently start
|
|
||||||
// evaluating as a live one.
|
|
||||||
const formulaBaseCols = getFormulaColumnNames(
|
|
||||||
this.dcValidator?.getDqDetails() ?? []
|
|
||||||
)
|
|
||||||
this.autoEscapedCells = new Map()
|
|
||||||
for (const row of previewDatasource) {
|
|
||||||
const rowKey = getRowKey(row, this.headerPks)
|
|
||||||
for (const colName of this.headerColumns) {
|
|
||||||
if (formulaBaseCols.includes(colName)) continue
|
|
||||||
if (!isCharacterColumn(this.cols, colName)) continue
|
|
||||||
|
|
||||||
const { value, wasEscaped } = escapeCharacterColumnValue(row[colName])
|
|
||||||
// A row whose backend source never had this column at all (a
|
|
||||||
// sparse row) must stay that way - writing back unconditionally
|
|
||||||
// would materialize a new own property set to undefined, which
|
|
||||||
// the SASjs adapter rejects at submit time even though nothing
|
|
||||||
// about this cell ever changed.
|
|
||||||
if (!wasEscaped) continue
|
|
||||||
|
|
||||||
row[colName] = value
|
|
||||||
markAutoEscaped(this.autoEscapedCells, rowKey, colName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
|
this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
|
||||||
|
|
||||||
this.dataSource = previewDatasource
|
this.dataSource = previewDatasource
|
||||||
@@ -986,7 +864,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
this.excelUploadState = 'Validating-HOT'
|
this.excelUploadState = 'Validating-HOT'
|
||||||
|
|
||||||
this.updateSettingsSortSafe(
|
hot.updateSettings(
|
||||||
{
|
{
|
||||||
data: this.dataSource,
|
data: this.dataSource,
|
||||||
maxRows: Infinity
|
maxRows: Infinity
|
||||||
@@ -1011,7 +889,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* @param discardData wheter to discard data parsed from the file or to keep it in the table after dropping a attached excel file
|
* @param discardData wheter to discard data parsed from the file or to keep it in the table after dropping a attached excel file
|
||||||
*/
|
*/
|
||||||
public discardPendingExcel(discardData?: boolean) {
|
public discardPendingExcel(discardData?: boolean) {
|
||||||
this.updateSettingsSortSafe({
|
this.hotInstance.updateSettings({
|
||||||
maxRows: this.licenceState.value.editor_rows_allowed
|
maxRows: this.licenceState.value.editor_rows_allowed
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1205,49 +1083,13 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (!hot) return []
|
if (!hot) return []
|
||||||
try {
|
try {
|
||||||
const plugin = hot.getPlugin('multiColumnSorting')
|
const plugin = hot.getPlugin('multiColumnSorting')
|
||||||
return normalizeSortConfig(plugin?.getSortConfig())
|
const cfg = plugin?.getSortConfig()
|
||||||
|
return Array.isArray(cfg) ? cfg : cfg ? [cfg] : []
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps hot.updateSettings() to work around a genuine Handsontable
|
|
||||||
* multiColumnSorting + formulas plugin bug: calling updateSettings() -
|
|
||||||
* with ANY settings, even a bare {} - while a sort is active corrupts
|
|
||||||
* formula cell references, rendering '#REF!' instead of their computed
|
|
||||||
* value (reproduced directly: a bare {} settings object alone is enough
|
|
||||||
* to trigger it, independent of what it actually changes). Clearing the
|
|
||||||
* sort first and restoring it immediately afterward avoids the
|
|
||||||
* corruption entirely. Not needed for the very first updateSettings()
|
|
||||||
* call at initial table load, since nothing can be sorted yet then.
|
|
||||||
*
|
|
||||||
* editTable()/cancelEdit() deliberately do NOT call this wrapper - they
|
|
||||||
* manage their own clear/restore inline, because their restore point is
|
|
||||||
* later in the method (after other logic like syncOverwrittenComments()),
|
|
||||||
* not immediately after their own updateSettings() call the way this
|
|
||||||
* wrapper restores it. Don't "simplify" those two to use this instead
|
|
||||||
* without preserving that later restore point.
|
|
||||||
*/
|
|
||||||
private updateSettingsSortSafe(
|
|
||||||
settings: Handsontable.GridSettings,
|
|
||||||
render?: boolean
|
|
||||||
): void {
|
|
||||||
const hot = this.hotInstance
|
|
||||||
const columnSorting = hot.getPlugin('multiColumnSorting')
|
|
||||||
const sortConfigs = this.getCurrentSortConfigs()
|
|
||||||
|
|
||||||
if (sortConfigs.length > 0) columnSorting.clearSort()
|
|
||||||
|
|
||||||
hot.updateSettings(settings, render)
|
|
||||||
|
|
||||||
// sort() replaces the entire sort state on every call rather than
|
|
||||||
// accumulating (see its own JSDoc) - restoring a multi-column sort
|
|
||||||
// needs one call with the whole array, not a loop of single-column
|
|
||||||
// calls, or every column but the last silently loses its sort.
|
|
||||||
if (sortConfigs.length > 0) columnSorting.sort(sortConfigs)
|
|
||||||
}
|
|
||||||
|
|
||||||
editTable(previewEdit?: boolean, newRow?: boolean) {
|
editTable(previewEdit?: boolean, newRow?: boolean) {
|
||||||
this.toggleHotPlugin('contextMenu', true)
|
this.toggleHotPlugin('contextMenu', true)
|
||||||
|
|
||||||
@@ -1277,13 +1119,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.hotTable.readOnly = false
|
this.hotTable.readOnly = false
|
||||||
this.hotTable.data = this.dataSource
|
this.hotTable.data = this.dataSource
|
||||||
|
|
||||||
// Handsontable's multiColumnSorting + formulas integration corrupts
|
|
||||||
// formula cell references (renders '#REF!') if updateSettings() runs
|
|
||||||
// while a sort is still active, even for an unrelated setting -
|
|
||||||
// clear it first and restore the captured sortConfigs afterward
|
|
||||||
// (below), rather than leaving the sort applied across the call.
|
|
||||||
if (sortConfigs.length > 0) columnSorting.clearSort()
|
|
||||||
|
|
||||||
hot.updateSettings(
|
hot.updateSettings(
|
||||||
{
|
{
|
||||||
readOnly: this.hotTable.readOnly
|
readOnly: this.hotTable.readOnly
|
||||||
@@ -1304,11 +1139,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
// same as cancelEdit() does for the read-only return path.
|
// same as cancelEdit() does for the read-only return path.
|
||||||
this.syncOverwrittenComments()
|
this.syncOverwrittenComments()
|
||||||
|
|
||||||
// sort() replaces the entire sort state on every call rather than
|
for (const sortConfig of sortConfigs) {
|
||||||
// accumulating (see its own JSDoc) - a multi-column sort needs one
|
columnSorting.sort(sortConfig)
|
||||||
// call with the whole array, not a loop of single-column calls, or
|
}
|
||||||
// every column but the last silently loses its sort.
|
|
||||||
if (sortConfigs.length > 0) columnSorting.sort(sortConfigs)
|
|
||||||
|
|
||||||
this.reSetCellValidationValues()
|
this.reSetCellValidationValues()
|
||||||
|
|
||||||
@@ -1351,16 +1184,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
const hot = this.hotInstance
|
const hot = this.hotInstance
|
||||||
const columnSorting = hot.getPlugin('multiColumnSorting')
|
const columnSorting = hot.getPlugin('multiColumnSorting')
|
||||||
const sortConfigs = this.getCurrentSortConfigs()
|
const columnSortConfig = columnSorting.getSortConfig()
|
||||||
|
const sortConfigs = Array.isArray(columnSortConfig)
|
||||||
// Cleared here, before anything below reads a row by index - both the
|
? columnSortConfig
|
||||||
// getFormulaCellsToPreserveOnCancel callbacks just below (physical
|
: [columnSortConfig]
|
||||||
// rowIndex passed straight to visual-row-expecting Handsontable APIs)
|
|
||||||
// and the later updateSettings() call (see editTable()'s own comment -
|
|
||||||
// corrupts formula cell references while a sort is active) need the
|
|
||||||
// grid unsorted. Restored below, after the re-render, from the
|
|
||||||
// sortConfigs captured here.
|
|
||||||
if (sortConfigs.length > 0) columnSorting.clearSort()
|
|
||||||
|
|
||||||
if (this.dataSourceUnchanged) {
|
if (this.dataSourceUnchanged) {
|
||||||
// dataSourceUnchanged deliberately holds the RAW pre-formula value for
|
// dataSourceUnchanged deliberately holds the RAW pre-formula value for
|
||||||
@@ -1396,10 +1223,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.hotTable.data = this.dataSource
|
this.hotTable.data = this.dataSource
|
||||||
this.hotTable.readOnly = true
|
this.hotTable.readOnly = true
|
||||||
|
|
||||||
// Sort was already cleared above (before the getFormulaCellsToPreserveOnCancel
|
|
||||||
// callbacks); still cleared here going into updateSettings() - see
|
|
||||||
// editTable()'s own comment: it corrupts formula cell references
|
|
||||||
// ('#REF!') while a sort is active. Restored below via sortConfigs.
|
|
||||||
hot.updateSettings(
|
hot.updateSettings(
|
||||||
{
|
{
|
||||||
readOnly: this.hotTable.readOnly,
|
readOnly: this.hotTable.readOnly,
|
||||||
@@ -1419,11 +1242,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.modifedRowsIndexes = []
|
this.modifedRowsIndexes = []
|
||||||
hot.validateCells()
|
hot.validateCells()
|
||||||
// this.editRecordListeners();
|
// this.editRecordListeners();
|
||||||
// sort() replaces the entire sort state on every call rather than
|
for (const sortConfig of sortConfigs) {
|
||||||
// accumulating (see its own JSDoc) - a multi-column sort needs one
|
columnSorting.sort(sortConfig)
|
||||||
// call with the whole array, not a loop of single-column calls, or
|
}
|
||||||
// every column but the last silently loses its sort.
|
|
||||||
if (sortConfigs.length > 0) columnSorting.sort(sortConfigs)
|
|
||||||
|
|
||||||
this.checkRowLimit()
|
this.checkRowLimit()
|
||||||
|
|
||||||
@@ -1628,14 +1449,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
// about the new row and HARDFORMULA/SOFTFORMULA columns silently
|
// about the new row and HARDFORMULA/SOFTFORMULA columns silently
|
||||||
// fall out of sync with the grid's own data.
|
// fall out of sync with the grid's own data.
|
||||||
hot.alter('insert_row_below', newIndex - 1, 1)
|
hot.alter('insert_row_below', newIndex - 1, 1)
|
||||||
|
this.dataSource[newIndex].noLinkOption = true
|
||||||
// alter() is expected to splice synchronously into dataSource, but
|
|
||||||
// this guards against relying on that if the data binding ever
|
|
||||||
// becomes async.
|
|
||||||
if (this.dataSource[newIndex]) {
|
|
||||||
this.dataSource[newIndex].noLinkOption = true
|
|
||||||
}
|
|
||||||
|
|
||||||
this.seedFormulaValuesForRow(newIndex)
|
this.seedFormulaValuesForRow(newIndex)
|
||||||
this.updateEditStatusForRow(newIndex)
|
this.updateEditStatusForRow(newIndex)
|
||||||
|
|
||||||
@@ -1786,13 +1600,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* call after a row insert/delete/sort has moved things around). No-op if
|
* call after a row insert/delete/sort has moved things around). No-op if
|
||||||
* the row has no PK match in dataSourceRaw (a newly-inserted row) - see
|
* the row has no PK match in dataSourceRaw (a newly-inserted row) - see
|
||||||
* findOverwrittenCells for why that's never revertable.
|
* findOverwrittenCells for why that's never revertable.
|
||||||
*
|
|
||||||
* rowIndex is physical (matches dataSource's own order, same contract as
|
|
||||||
* syncOverwrittenComments()'s own dataSource.forEach caller below) -
|
|
||||||
* getDataAtRowProp and the comments plugin's own cell API all document a
|
|
||||||
* VISUAL row instead, so it's translated via hot.toVisualRow() for every
|
|
||||||
* Handsontable/plugin call, while dataSource itself stays indexed by the
|
|
||||||
* physical rowIndex throughout.
|
|
||||||
*/
|
*/
|
||||||
private syncOverwrittenCommentForCell(rowIndex: number, prop: string): void {
|
private syncOverwrittenCommentForCell(rowIndex: number, prop: string): void {
|
||||||
const hot = this.hotInstance
|
const hot = this.hotInstance
|
||||||
@@ -1804,14 +1611,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
)
|
)
|
||||||
if (!rawRow) return
|
if (!rawRow) return
|
||||||
|
|
||||||
const visualRow = hot.toVisualRow(rowIndex)
|
|
||||||
if (visualRow === null) return
|
|
||||||
|
|
||||||
const colIndex = hot.propToCol(prop) as number
|
const colIndex = hot.propToCol(prop) as number
|
||||||
const commentsPlugin = hot.getPlugin('comments')
|
const commentsPlugin = hot.getPlugin('comments')
|
||||||
const currentValue = hot.getDataAtRowProp(visualRow, prop)
|
const currentValue = hot.getDataAtRowProp(rowIndex, prop)
|
||||||
const hasCommentAlready = !!commentsPlugin.getCommentAtCell(
|
const hasCommentAlready = !!commentsPlugin.getCommentAtCell(
|
||||||
visualRow,
|
rowIndex,
|
||||||
colIndex
|
colIndex
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1823,12 +1627,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
if (action === 'set') {
|
if (action === 'set') {
|
||||||
commentsPlugin.setCommentAtCell(
|
commentsPlugin.setCommentAtCell(
|
||||||
visualRow,
|
rowIndex,
|
||||||
colIndex,
|
colIndex,
|
||||||
`${EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX}${rawRow[prop]}`
|
`${EditorComponent.ORIGINAL_VALUE_COMMENT_PREFIX}${rawRow[prop]}`
|
||||||
)
|
)
|
||||||
} else if (action === 'remove') {
|
} else if (action === 'remove') {
|
||||||
commentsPlugin.removeCommentAtCell(visualRow, colIndex)
|
commentsPlugin.removeCommentAtCell(rowIndex, colIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1923,53 +1727,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A primary key identifies its own row for the rest of the edit session
|
|
||||||
* (getRowKey, dataModified, classifyRow all match rows by comparing PK
|
|
||||||
* values) - a HARDFORMULA/SOFTFORMULA rule on a PK column gets seeded by
|
|
||||||
* applyFormulaRules as a live `=...` formula string exactly like any
|
|
||||||
* other formula column, but a PK can never be allowed to keep
|
|
||||||
* recalculating for the rest of the session the way a normal formula
|
|
||||||
* column does: saveTable's own formulaRules loop only resolves a formula
|
|
||||||
* column's computed value once, at submit, so anything that changed a
|
|
||||||
* column the PK's formula depends on in between would silently shift the
|
|
||||||
* PK out from under dataModified/classifyRow's PK-matching - the same
|
|
||||||
* failure mode the afterChange hook below already prevents for a
|
|
||||||
* user-typed PK formula. So a PK-as-formula-column is frozen to its
|
|
||||||
* computed value once, right here at load. Both dataSource and
|
|
||||||
* dataSourceUnchanged get the same frozen value - overriding whatever
|
|
||||||
* overlayFormulaRawValuesOnUnchanged set for it, since a PK's identity
|
|
||||||
* role takes priority over the normal "did a formula overwrite real
|
|
||||||
* data" modified-detection every other formula column gets.
|
|
||||||
*/
|
|
||||||
private resolvePkFormulaSeedValues(): void {
|
|
||||||
const hot = this.hotInstance
|
|
||||||
const formulaBaseCols = getFormulaColumnNames(
|
|
||||||
this.dcValidator?.getDqDetails() ?? []
|
|
||||||
)
|
|
||||||
const pkFormulaCols = this.headerPks.filter((pk) =>
|
|
||||||
formulaBaseCols.includes(pk)
|
|
||||||
)
|
|
||||||
if (pkFormulaCols.length === 0) return
|
|
||||||
|
|
||||||
// Deferred for the same reason as this method's own caller
|
|
||||||
// (markOverwrittenCells) defers its setDataAtRowProp call above: right
|
|
||||||
// after the initial hot.updateSettings(), the Formulas plugin's
|
|
||||||
// hidden-column index mapping hasn't settled yet, and setDataAtRowProp
|
|
||||||
// throws ExpectedValueOfTypeError before it has.
|
|
||||||
setTimeout(() => {
|
|
||||||
this.dataSource.forEach((_row, rowIndex) => {
|
|
||||||
for (const pkCol of pkFormulaCols) {
|
|
||||||
const computed = hot.getDataAtRowProp(rowIndex, pkCol)
|
|
||||||
hot.setDataAtRowProp(rowIndex, pkCol, computed, 'resolvePkFormula')
|
|
||||||
|
|
||||||
const unchangedRow = this.dataSourceUnchanged?.[rowIndex]
|
|
||||||
if (unchangedRow) unchangedRow[pkCol] = computed
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recomputes this row's classification (M/A/D/U) and writes it into the
|
* Recomputes this row's classification (M/A/D/U) and writes it into the
|
||||||
* EDIT_STATUS cell - the same classification the row-header symbol shows,
|
* EDIT_STATUS cell - the same classification the row-header symbol shows,
|
||||||
@@ -1979,12 +1736,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* HyperFormula is notified and dependents recalculate - the source string
|
* HyperFormula is notified and dependents recalculate - the source string
|
||||||
* also lets the afterChange hook below recognise and ignore its own
|
* also lets the afterChange hook below recognise and ignore its own
|
||||||
* writes, avoiding infinite recursion.
|
* writes, avoiding infinite recursion.
|
||||||
*
|
|
||||||
* rowIndex is physical (matches dataSource's own order, same contract as
|
|
||||||
* addRow()/insertRowAtPosition()'s own callers) - setDataAtRowProp
|
|
||||||
* documents a VISUAL row instead, so it's translated via
|
|
||||||
* hot.toVisualRow() right before that one call, while dataSource itself
|
|
||||||
* stays indexed by the physical rowIndex.
|
|
||||||
*/
|
*/
|
||||||
private updateEditStatusForRow(rowIndex: number): void {
|
private updateEditStatusForRow(rowIndex: number): void {
|
||||||
const dataRow = this.dataSource[rowIndex]
|
const dataRow = this.dataSource[rowIndex]
|
||||||
@@ -1996,11 +1747,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.headerPks
|
this.headerPks
|
||||||
)
|
)
|
||||||
|
|
||||||
const visualRow = this.hotInstance.toVisualRow(rowIndex)
|
|
||||||
if (visualRow === null) return
|
|
||||||
|
|
||||||
this.hotInstance.setDataAtRowProp(
|
this.hotInstance.setDataAtRowProp(
|
||||||
visualRow,
|
rowIndex,
|
||||||
EDIT_STATUS_COLUMN_NAME,
|
EDIT_STATUS_COLUMN_NAME,
|
||||||
status,
|
status,
|
||||||
'editStatus'
|
'editStatus'
|
||||||
@@ -2013,7 +1761,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.hotTable.data = this.dataSource
|
this.hotTable.data = this.dataSource
|
||||||
|
|
||||||
const hot = this.hotInstance
|
const hot = this.hotInstance
|
||||||
this.updateSettingsSortSafe(
|
hot.updateSettings(
|
||||||
{
|
{
|
||||||
data: this.dataSource,
|
data: this.dataSource,
|
||||||
colHeaders: getDisplayColHeaders(
|
colHeaders: getDisplayColHeaders(
|
||||||
@@ -2479,7 +2227,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateSettingsSortSafe(
|
hot.updateSettings(
|
||||||
{
|
{
|
||||||
data: this.dataSource,
|
data: this.dataSource,
|
||||||
colHeaders: getDisplayColHeaders(
|
colHeaders: getDisplayColHeaders(
|
||||||
@@ -2562,54 +2310,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two things every other column (HARDFORMULA/SOFTFORMULA ones are
|
|
||||||
// already fully handled above) still needs before submission:
|
|
||||||
//
|
|
||||||
// 1. Strip the escape marker this app added (see
|
|
||||||
// escapeCharacterColumnValue/character-column-formula-plan.md) from
|
|
||||||
// every cell the app itself marked, so it never reaches the
|
|
||||||
// backend. A cell the user has since edited, or one whose leading
|
|
||||||
// `'` was already genuine backend data, is never in
|
|
||||||
// autoEscapedCells and so is never touched here - it submits
|
|
||||||
// exactly as it currently reads. autoEscapedCells is only ever
|
|
||||||
// populated for character columns, so this is naturally a no-op
|
|
||||||
// for any other column.
|
|
||||||
// 2. Resolve the computed value of a genuine, currently-live formula
|
|
||||||
// (typed, pasted, or "Apply as formula"'d - never auto-escaped, so
|
|
||||||
// its raw dataSource string still starts with a bare `=`) the same
|
|
||||||
// way the HARDFORMULA/SOFTFORMULA loop above does - otherwise the
|
|
||||||
// raw formula text itself would reach the backend instead of what
|
|
||||||
// the grid actually displays. Not scoped to character columns -
|
|
||||||
// HyperFormula evaluates a `=`-led value the same way regardless
|
|
||||||
// of the column's own declared type, and coerceNumericRow
|
|
||||||
// (beforePaste/beforeAutofill) deliberately leaves a non-numeric,
|
|
||||||
// formula-looking paste alone rather than forcing it to NaN, so a
|
|
||||||
// numeric column can end up holding a live formula too.
|
|
||||||
//
|
|
||||||
// Runs regardless of whether this table has any HARDFORMULA/
|
|
||||||
// SOFTFORMULA rules - formulas (and both of the above) are enabled
|
|
||||||
// everywhere.
|
|
||||||
const formulaBaseCols = getFormulaColumnNames(
|
|
||||||
this.dcValidator?.getDqDetails() ?? []
|
|
||||||
)
|
|
||||||
data.forEach((row: any, physicalRowIndex: number) => {
|
|
||||||
const visualRowIndex = hot.toVisualRow(physicalRowIndex)
|
|
||||||
const rowKey = getRowKey(row, this.headerPks)
|
|
||||||
|
|
||||||
for (const colName of this.headerColumns) {
|
|
||||||
if (formulaBaseCols.includes(colName)) continue
|
|
||||||
|
|
||||||
if (isAutoEscaped(this.autoEscapedCells, rowKey, colName)) {
|
|
||||||
row[colName] = unescapeFormula(row[colName])
|
|
||||||
} else if (
|
|
||||||
typeof row[colName] === 'string' &&
|
|
||||||
row[colName].startsWith('=')
|
|
||||||
) {
|
|
||||||
row[colName] = hot.getDataAtRowProp(visualRowIndex, colName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
data = data.filter((dataRow: any) => {
|
data = data.filter((dataRow: any) => {
|
||||||
const elModified = this.dataModified.find((row) => {
|
const elModified = this.dataModified.find((row) => {
|
||||||
for (const pkCol of this.headerPks) {
|
for (const pkCol of this.headerPks) {
|
||||||
@@ -2789,7 +2489,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (this.recordAction === 'ADD' && !confirmButtonClicked) {
|
if (this.recordAction === 'ADD' && !confirmButtonClicked) {
|
||||||
this.dataSource = this.helperService.deepClone(this.prevDataSource)
|
this.dataSource = this.helperService.deepClone(this.prevDataSource)
|
||||||
|
|
||||||
this.updateSettingsSortSafe(
|
const hot = this.hotInstance
|
||||||
|
|
||||||
|
hot.updateSettings(
|
||||||
{
|
{
|
||||||
data: this.dataSource
|
data: this.dataSource
|
||||||
},
|
},
|
||||||
@@ -2816,7 +2518,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.dataSource[closingRecordIndex] = this.currentEditRecord
|
this.dataSource[closingRecordIndex] = this.currentEditRecord
|
||||||
this.hotTable.data[closingRecordIndex] = this.currentEditRecord
|
this.hotTable.data[closingRecordIndex] = this.currentEditRecord
|
||||||
|
|
||||||
this.updateSettingsSortSafe(
|
const hot = this.hotInstance
|
||||||
|
|
||||||
|
hot.updateSettings(
|
||||||
{
|
{
|
||||||
data: this.dataSource
|
data: this.dataSource
|
||||||
},
|
},
|
||||||
@@ -2988,13 +2692,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
const cellData = hot.getDataAtCell(row, column)
|
const cellData = hot.getDataAtCell(row, column)
|
||||||
const clickedRow = this.helperService.deepClone(this.dataSource[row])
|
const clickedRow = this.helperService.deepClone(this.dataSource[row])
|
||||||
// hot.colToProp resolves the column's actual configured data key -
|
const clickedColumnKey = Object.keys(clickedRow)[column]
|
||||||
// Object.keys(clickedRow)[column] only worked by coincidence for a
|
|
||||||
// fully-populated row; a sparse row (one whose backend source never
|
|
||||||
// populated some earlier column) has fewer real keys than the grid has
|
|
||||||
// columns, so counting into its own key list drifts out of alignment
|
|
||||||
// (or runs out of bounds entirely) once column indexes past the gap.
|
|
||||||
const clickedColumnKey = hot.colToProp(column) as string
|
|
||||||
const skipRender = !!opts?.skipRender
|
const skipRender = !!opts?.skipRender
|
||||||
const myEpoch = this.validationEpoch
|
const myEpoch = this.validationEpoch
|
||||||
|
|
||||||
@@ -3589,7 +3287,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.useLabels
|
this.useLabels
|
||||||
)
|
)
|
||||||
|
|
||||||
this.updateSettingsSortSafe({ colHeaders }, false)
|
this.hotInstance.updateSettings({ colHeaders }, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
@@ -3613,16 +3311,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.gridResizeObserver = undefined
|
this.gridResizeObserver = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the native paste listener (see its own registration comment -
|
|
||||||
// attached once on rootElement rather than per edit session)
|
|
||||||
if (this.pasteListener && this.hotInstance?.rootElement) {
|
|
||||||
this.hotInstance.rootElement.removeEventListener(
|
|
||||||
'paste',
|
|
||||||
this.pasteListener
|
|
||||||
)
|
|
||||||
this.pasteListener = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel any pending debounced VA apply
|
// Cancel any pending debounced VA apply
|
||||||
if (this.vaDebounceTimer) {
|
if (this.vaDebounceTimer) {
|
||||||
clearTimeout(this.vaDebounceTimer)
|
clearTimeout(this.vaDebounceTimer)
|
||||||
@@ -3648,7 +3336,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const hot = this.hotInstance
|
const hot = this.hotInstance
|
||||||
if (!hot || hot.isDestroyed) return
|
if (!hot || hot.isDestroyed) return
|
||||||
this.updateSettingsSortSafe({ height: this.hotTable.height }, false)
|
hot.updateSettings({ height: this.hotTable.height }, false)
|
||||||
hot.render()
|
hot.render()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -3900,15 +3588,13 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
response.data.dqdata
|
response.data.dqdata
|
||||||
)
|
)
|
||||||
|
|
||||||
// this.hotTable.formulas is set once, on the field itself, and applies
|
// Only turn on Handsontable's formulas plugin (HyperFormula engine) for
|
||||||
// to every table regardless of whether it has any HARDFORMULA/
|
// tables that actually use HARDFORMULA/SOFTFORMULA - it's not free to
|
||||||
// SOFTFORMULA rules.
|
// run for every grid. gpl-v3: this app embeds HyperFormula under its
|
||||||
|
// GPLv3 free-tier terms, not a purchased commercial key.
|
||||||
// BASE_COL names of every HARDFORMULA/SOFTFORMULA rule - applyFormulaRules
|
this.hotTable.formulas = hasFormulaRules(response.data.dqrules)
|
||||||
// below legitimately writes a live `=...` formula string into these, so
|
? { engine: HyperFormula, licenseKey: 'gpl-v3' }
|
||||||
// the escape loop further down must skip them (they're never
|
: false
|
||||||
// backend-sourced text that merely looks like a formula).
|
|
||||||
const formulaBaseCols = getFormulaColumnNames(response.data.dqrules)
|
|
||||||
|
|
||||||
this.cellValidation = this.dcValidator.getRules()
|
this.cellValidation = this.dcValidator.getRules()
|
||||||
|
|
||||||
@@ -3945,48 +3631,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.userService.user?.username ?? ''
|
this.userService.user?.username ?? ''
|
||||||
)
|
)
|
||||||
|
|
||||||
// Prevent a `=`-led value from the backend in any character column from
|
|
||||||
// being treated as a live formula - see character-column-formula-plan.md.
|
|
||||||
// Must run after applyFormulaRules above (which legitimately writes a
|
|
||||||
// live formula string into HARDFORMULA/SOFTFORMULA columns) and before
|
|
||||||
// dataSourceUnchanged is cloned below, so the "unchanged baseline"
|
|
||||||
// already reflects the escaped form rather than flagging these cells as
|
|
||||||
// modified. Only backend-sourced values are ever escaped here - a value
|
|
||||||
// the user later types or pastes is never touched (see beforeChange).
|
|
||||||
//
|
|
||||||
// Also escapes the matching dataSourceRaw cell (still index-aligned
|
|
||||||
// with dataSource here, before any sort/insert reorders things) for the
|
|
||||||
// same non-formula columns, so it stays in sync with what the user
|
|
||||||
// actually sees. dataSourceRaw drives the Revert feature - if it kept
|
|
||||||
// the true unescaped backend string instead, reverting a cell after
|
|
||||||
// "Apply as formula" would restore the raw `=...` text, which
|
|
||||||
// (formulas being enabled everywhere) evaluates live again instead of
|
|
||||||
// going back to the escaped text the user started from. Formula base
|
|
||||||
// columns are still skipped, so dataSourceRaw keeps meaning "value
|
|
||||||
// before the formula overwrote it" for them, same as before.
|
|
||||||
this.autoEscapedCells = new Map()
|
|
||||||
this.dataSource.forEach((row, rowIndex) => {
|
|
||||||
const rowKey = getRowKey(row, this.headerPks)
|
|
||||||
for (const colName of this.headerColumns) {
|
|
||||||
if (formulaBaseCols.includes(colName)) continue
|
|
||||||
if (!isCharacterColumn(this.cols, colName)) continue
|
|
||||||
|
|
||||||
const { value, wasEscaped } = escapeCharacterColumnValue(row[colName])
|
|
||||||
// A row whose backend source never had this column at all (a
|
|
||||||
// sparse row) must stay that way - writing back unconditionally
|
|
||||||
// would materialize a new own property set to undefined, which
|
|
||||||
// the SASjs adapter rejects at submit time even though nothing
|
|
||||||
// about this cell ever changed.
|
|
||||||
if (!wasEscaped) continue
|
|
||||||
|
|
||||||
row[colName] = value
|
|
||||||
if (this.dataSourceRaw[rowIndex]) {
|
|
||||||
this.dataSourceRaw[rowIndex][colName] = value
|
|
||||||
}
|
|
||||||
markAutoEscaped(this.autoEscapedCells, rowKey, colName)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Seeded here too (not just editTable()) so a HARDFORMULA/SOFTFORMULA
|
// Seeded here too (not just editTable()) so a HARDFORMULA/SOFTFORMULA
|
||||||
// rule that silently overwrote real pre-existing data (see
|
// rule that silently overwrote real pre-existing data (see
|
||||||
// markOverwrittenCells) already shows the row as modified - both the
|
// markOverwrittenCells) already shows the row as modified - both the
|
||||||
@@ -4125,22 +3769,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
rowHeaderWidth: 20,
|
rowHeaderWidth: 20,
|
||||||
rowHeights: 24,
|
rowHeights: 24,
|
||||||
// Handsontable's own maxRows (a UI/licensing cap on how many NEW
|
maxRows: this.licenceState.value.editor_rows_allowed || Infinity,
|
||||||
// rows can be ADDED - see checkRowLimit/restrictAddRow) silently
|
|
||||||
// becomes HyperFormula's OWN sheet-size limit too (see
|
|
||||||
// getEngineSettingsOverrides in Handsontable's formulas plugin,
|
|
||||||
// which maps hotSettings.maxRows straight into the engine config).
|
|
||||||
// editor_rows_allowed can be smaller than a table's ALREADY-
|
|
||||||
// EXISTING row count, and formulas are on for every table - capping
|
|
||||||
// at editor_rows_allowed alone would make HyperFormula reject the
|
|
||||||
// sheet outright for any table bigger than that cap. Never let it
|
|
||||||
// go below what's actually loaded - the add-row restriction itself
|
|
||||||
// is still enforced separately, by checkRowLimit's own comparison
|
|
||||||
// against dataSource.length.
|
|
||||||
maxRows: Math.max(
|
|
||||||
this.dataSource.length,
|
|
||||||
this.licenceState.value.editor_rows_allowed || Infinity
|
|
||||||
),
|
|
||||||
invalidCellClassName: 'htInvalid',
|
invalidCellClassName: 'htInvalid',
|
||||||
// Prevent automatic row creation
|
// Prevent automatic row creation
|
||||||
autoWrapRow: false,
|
autoWrapRow: false,
|
||||||
@@ -4296,7 +3925,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
// actually evaluated them against the data/formulas settings just
|
// actually evaluated them against the data/formulas settings just
|
||||||
// applied.
|
// applied.
|
||||||
this.markOverwrittenCells()
|
this.markOverwrittenCells()
|
||||||
this.resolvePkFormulaSeedValues()
|
|
||||||
|
|
||||||
this.hotTable.hidden = false
|
this.hotTable.hidden = false
|
||||||
// Keep the context menu enabled in view mode too so Copy/Export remain
|
// Keep the context menu enabled in view mode too so Copy/Export remain
|
||||||
@@ -4374,56 +4002,24 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
// ROUND: round numeric values Excel-style before they are written.
|
// ROUND: round numeric values Excel-style before they are written.
|
||||||
// Mutating `changes` in place (rather than setDataAtRowProp) avoids
|
// Mutating `changes` in place (rather than setDataAtRowProp) avoids
|
||||||
// re-entrancy and uniformly covers edit, paste and autofill.
|
// re-entrancy and uniformly covers edit, paste and autofill.
|
||||||
hot.addHook('beforeChange', (changes: any[], source: any) => {
|
hot.addHook('beforeChange', (changes: any[]) => {
|
||||||
if (!changes) return
|
if (!changes) return
|
||||||
|
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
if (!change) continue
|
if (!change) continue
|
||||||
|
|
||||||
const [changeRow, prop, , newValue] = change
|
const [, prop, , newValue] = change
|
||||||
const colName =
|
const colName =
|
||||||
typeof prop === 'string'
|
typeof prop === 'string'
|
||||||
? prop
|
? prop
|
||||||
: (hot.colToProp(prop as number) as string)
|
: (hot.colToProp(prop as number) as string)
|
||||||
|
|
||||||
const digits = this.dcValidator?.getRoundDigits(colName)
|
const digits = this.dcValidator?.getRoundDigits(colName)
|
||||||
if (digits !== undefined) {
|
if (digits === undefined) continue
|
||||||
const num = Number(newValue)
|
|
||||||
if (newValue !== null && newValue !== '' && !isNaN(num)) {
|
|
||||||
change[3] = excelRound(num, digits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A `=`-led value the user types or pastes is never auto-escaped
|
const num = Number(newValue)
|
||||||
// (see character-column-formula-plan.md) - it's left exactly as
|
if (newValue !== null && newValue !== '' && !isNaN(num)) {
|
||||||
// entered, which genuinely evaluates as a live formula since
|
change[3] = excelRound(num, digits)
|
||||||
// formulas are enabled for every column. If this cell was
|
|
||||||
// previously auto-escaped from backend data, the user is now
|
|
||||||
// supplying their own content, so that marker no longer describes
|
|
||||||
// what's here - clear it so submit/"Apply as formula" leave this
|
|
||||||
// cell alone.
|
|
||||||
const row = this.dataSource[hot.toPhysicalRow(changeRow)]
|
|
||||||
if (!row) continue
|
|
||||||
|
|
||||||
const rowKey = getRowKey(row, this.headerPks)
|
|
||||||
|
|
||||||
// Revert (see the revert_cells context-menu item, which tags its
|
|
||||||
// own write with this source) restores dataSourceRaw's value
|
|
||||||
// verbatim - for a character column that's the same escaped form
|
|
||||||
// the initial-load pass would have produced, so it's correct to
|
|
||||||
// treat it the same way and re-mark it, letting "Apply as
|
|
||||||
// formula" and the submit-time strip work on it again. A value
|
|
||||||
// that merely happens to start with `'=` because the user typed
|
|
||||||
// it themselves never takes this path, since only revert_cells
|
|
||||||
// uses this source.
|
|
||||||
if (
|
|
||||||
source === 'revert' &&
|
|
||||||
typeof newValue === 'string' &&
|
|
||||||
newValue.startsWith("'=")
|
|
||||||
) {
|
|
||||||
markAutoEscaped(this.autoEscapedCells, rowKey, colName)
|
|
||||||
} else {
|
|
||||||
clearAutoEscaped(this.autoEscapedCells, rowKey, colName)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -4476,18 +4072,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
if (!change) continue
|
if (!change) continue
|
||||||
|
|
||||||
// changes gives the VISUAL row (Handsontable's own documented
|
const [row, prop] = change
|
||||||
// afterChange shape) - translate to physical before indexing
|
|
||||||
// dataSource, same as beforeChange's own hot.toPhysicalRow(changeRow)
|
|
||||||
// a few lines up. syncOverwrittenCommentForCell/updateEditStatusForRow
|
|
||||||
// both expect physical (they translate back to visual internally,
|
|
||||||
// only where a Handsontable API actually needs it).
|
|
||||||
const [visualRow, prop] = change
|
|
||||||
if (prop === EDIT_STATUS_COLUMN_NAME) continue
|
if (prop === EDIT_STATUS_COLUMN_NAME) continue
|
||||||
|
|
||||||
const row = hot.toPhysicalRow(visualRow)
|
|
||||||
if (row === null) continue
|
|
||||||
|
|
||||||
changedRows.add(row)
|
changedRows.add(row)
|
||||||
|
|
||||||
if (revertableCols.includes(prop)) {
|
if (revertableCols.includes(prop)) {
|
||||||
@@ -4543,7 +4130,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}, 50)
|
}, 50)
|
||||||
})
|
})
|
||||||
|
|
||||||
hot.addHook('afterCreateRow', (source: any) => {
|
hot.addHook('afterCreateRow', (source: any, change: any) => {
|
||||||
if (source > this.dataSource.length) {
|
if (source > this.dataSource.length) {
|
||||||
// don't scroll if row is not added to the end (bottom)
|
// don't scroll if row is not added to the end (bottom)
|
||||||
const wtHolder = document.querySelector('.wtHolder')
|
const wtHolder = document.querySelector('.wtHolder')
|
||||||
@@ -4610,117 +4197,13 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
return value
|
return value
|
||||||
})
|
})
|
||||||
|
|
||||||
// A primary key identifies its own row for the rest of the edit session
|
|
||||||
// (getRowKey, dataModified, classifyRow all match rows by comparing PK
|
|
||||||
// values) - if it's left holding a live formula, HyperFormula's
|
|
||||||
// recalculation can shift that identity out from under those matches
|
|
||||||
// between whenever the formula was entered and whenever saveTable()
|
|
||||||
// finally resolves it. Unlike any other column, resolve a PK's formula
|
|
||||||
// to its computed value immediately, right when it's entered (typed,
|
|
||||||
// pasted, autofilled, or "Apply as formula"'d), so the PK is never
|
|
||||||
// anything other than a stable, already-computed value going forward.
|
|
||||||
// Deferred via setTimeout, same as the NOTNULL default-population hook
|
|
||||||
// above, since writing back through setDataAtRowProp from inside
|
|
||||||
// afterChange itself would re-enter change processing immediately.
|
|
||||||
hot.addHook('afterChange', (changes: any[], source: any) => {
|
|
||||||
if (!changes || source === 'loadData' || source === 'resolvePkFormula')
|
|
||||||
return
|
|
||||||
|
|
||||||
for (const change of changes) {
|
|
||||||
if (!change) continue
|
|
||||||
|
|
||||||
const [row, prop, , newValue] = change
|
|
||||||
if (typeof newValue !== 'string' || !newValue.startsWith('=')) continue
|
|
||||||
|
|
||||||
const colName =
|
|
||||||
typeof prop === 'string'
|
|
||||||
? prop
|
|
||||||
: (hot.colToProp(prop as number) as string)
|
|
||||||
if (!this.headerPks.includes(colName)) continue
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const computed = hot.getDataAtRowProp(row, colName)
|
|
||||||
hot.setDataAtRowProp(row, colName, computed, 'resolvePkFormula')
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
hot.addHook('beforePaste', (data: any, cords: any) => {
|
hot.addHook('beforePaste', (data: any, cords: any) => {
|
||||||
const startCol = cords[0].startCol
|
const startCol = cords[0].startCol
|
||||||
const startRow = cords[0].startRow
|
|
||||||
|
|
||||||
for (let r = 0; r < data.length; r++) {
|
for (let r = 0; r < data.length; r++) {
|
||||||
data[r] = coerceNumericRow(data[r], startCol)
|
data[r] = coerceNumericRow(data[r], startCol)
|
||||||
|
|
||||||
// Translate column names in a pasted formula (e.g. "=PRICE * VOLUME")
|
|
||||||
// into this row's cell references ("=B4 * C4") - only meaningful on
|
|
||||||
// a character column, since that's what's formula-capable for user
|
|
||||||
// input (see character-column-formula-plan.md). Translating for a
|
|
||||||
// numeric column would be pointless and would leave a confusing
|
|
||||||
// half-translated string sitting there instead of the original,
|
|
||||||
// readable pasted text.
|
|
||||||
data[r] = data[r].map((value: any, index: number) => {
|
|
||||||
const colName = this.columnHeader[startCol + index]
|
|
||||||
if (!isCharacterColumn(this.cols, colName)) return value
|
|
||||||
|
|
||||||
return typeof value === 'string' && value.trim().startsWith('=')
|
|
||||||
? substituteColumnReferences(
|
|
||||||
value.trim(),
|
|
||||||
this.headerColumns,
|
|
||||||
startRow + r
|
|
||||||
)
|
|
||||||
: value
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Same column-name -> cell-reference translation as beforePaste above,
|
|
||||||
// but for pasting directly into an open cell editor (double-click,
|
|
||||||
// then Cmd+V/Ctrl+V or right-click > Paste) - Handsontable's CopyPaste
|
|
||||||
// plugin only intercepts a grid-level paste (cell selected but not
|
|
||||||
// being edited); pasting into the editor's own textarea is a native
|
|
||||||
// browser paste event the plugin never sees. Listens once on the root
|
|
||||||
// element (paste events bubble) rather than attaching a new listener
|
|
||||||
// per edit session, to avoid accumulating listeners across repeated
|
|
||||||
// edits.
|
|
||||||
//
|
|
||||||
// This whole registration only ever runs once per component instance
|
|
||||||
// in practice (initSetup's own retry-until-ready loop returns before
|
|
||||||
// reaching this point on every attempt except the one that succeeds,
|
|
||||||
// and table-switching navigates to a fresh component instance rather
|
|
||||||
// than reloading in place) - but removing any previous listener before
|
|
||||||
// replacing the reference costs nothing and keeps that guarantee from
|
|
||||||
// becoming a silent leak if this assumption ever changes.
|
|
||||||
if (this.pasteListener) {
|
|
||||||
hot.rootElement.removeEventListener('paste', this.pasteListener)
|
|
||||||
}
|
|
||||||
this.pasteListener = (event: ClipboardEvent) => {
|
|
||||||
const editor: any = hot.getActiveEditor()
|
|
||||||
if (!editor || editor.row === null || event.target !== editor.TEXTAREA)
|
|
||||||
return
|
|
||||||
|
|
||||||
// Only on a character column - see beforePaste's own comment above
|
|
||||||
// for why.
|
|
||||||
const colName = this.columnHeader[editor.col]
|
|
||||||
if (!isCharacterColumn(this.cols, colName)) return
|
|
||||||
|
|
||||||
const pastedText = event.clipboardData?.getData('text/plain') ?? ''
|
|
||||||
if (!pastedText.trim().startsWith('=')) return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
editor.TEXTAREA.value = substituteColumnReferences(
|
|
||||||
pastedText.trim(),
|
|
||||||
this.headerColumns,
|
|
||||||
editor.row
|
|
||||||
)
|
|
||||||
// A real paste's default action would fire this itself once the
|
|
||||||
// text landed - since preventDefault() above skips that, dispatch it
|
|
||||||
// manually so the editor's own input-tracking (autosize, its
|
|
||||||
// internal notion of the current value) picks up the change.
|
|
||||||
editor.TEXTAREA.dispatchEvent(new Event('input', { bubbles: true }))
|
|
||||||
}
|
|
||||||
hot.rootElement.addEventListener('paste', this.pasteListener)
|
|
||||||
|
|
||||||
hot.addHook(
|
hot.addHook(
|
||||||
'beforeAutofill',
|
'beforeAutofill',
|
||||||
(selectionData: any[][], sourceRange: any) => {
|
(selectionData: any[][], sourceRange: any) => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
|||||||
import { normalizeSortConfig } from './normalizeSortConfig'
|
|
||||||
|
|
||||||
describe('normalizeSortConfig', () => {
|
|
||||||
it('wraps a single config in an array', () => {
|
|
||||||
const cfg = { column: 0, sortOrder: 'asc' as const }
|
|
||||||
expect(normalizeSortConfig(cfg)).toEqual([cfg])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('passes an array of configs through unchanged', () => {
|
|
||||||
const cfgs = [
|
|
||||||
{ column: 0, sortOrder: 'asc' as const },
|
|
||||||
{ column: 1, sortOrder: 'desc' as const }
|
|
||||||
]
|
|
||||||
expect(normalizeSortConfig(cfgs)).toEqual(cfgs)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns an empty array for undefined (nothing currently sorted)', () => {
|
|
||||||
expect(normalizeSortConfig(undefined)).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* multiColumnSorting's getSortConfig() returns a single config, an array of
|
|
||||||
* them, or undefined depending on how many columns are currently sorted -
|
|
||||||
* normalizes all three shapes into one array so callers never need three
|
|
||||||
* branches to handle it. `SortConfig` isn't part of Handsontable's public
|
|
||||||
* API surface in this version (only re-exported internally), and its own
|
|
||||||
* `.d.ts` shape isn't structurally assignable to `Record<string, unknown>`
|
|
||||||
* either (a specific interface isn't assignable to an index-signature type
|
|
||||||
* without one of its own), so `any` is the pragmatic choice here rather
|
|
||||||
* than fighting the type system for a type this file can't even name.
|
|
||||||
*/
|
|
||||||
export const normalizeSortConfig = (cfg: any): any[] =>
|
|
||||||
Array.isArray(cfg) ? cfg : cfg ? [cfg] : []
|
|
||||||
@@ -1,540 +0,0 @@
|
|||||||
import Handsontable from 'handsontable'
|
|
||||||
import { HyperFormula } from 'hyperformula'
|
|
||||||
import { classifyRow } from './classifyRow'
|
|
||||||
import { normalizeSortConfig } from './normalizeSortConfig'
|
|
||||||
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
|
|
||||||
import { parseFormulaRule } from '../../shared/dc-validator/utils/parseFormulaRule'
|
|
||||||
import { getFormulaCellsToPreserveOnCancel } from '../../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* afterChange delivers changes as [visualRow, prop, oldValue, newValue] -
|
|
||||||
* Handsontable's own documented behaviour. Once multiColumnSorting reorders
|
|
||||||
* the grid, visual row order no longer matches dataSource's physical order.
|
|
||||||
* editor.component.ts's afterChange hook (the one syncing EDIT_STATUS and
|
|
||||||
* the "overwritten" comment) passes that raw visual row straight into
|
|
||||||
* updateEditStatusForRow/syncOverwrittenCommentForCell, both of which index
|
|
||||||
* dataSource directly - the same [visualRow] shape the beforeChange hook a
|
|
||||||
* few lines up in the same file already translates via
|
|
||||||
* hot.toPhysicalRow(changeRow) before doing the same kind of dataSource
|
|
||||||
* access.
|
|
||||||
*
|
|
||||||
* Mirrors the row extraction only (not the full hook), against a real
|
|
||||||
* Handsontable + multiColumnSorting instance, since proving visual/physical
|
|
||||||
* divergence requires an actual sort to have happened.
|
|
||||||
*/
|
|
||||||
describe('afterChange must translate the changed row to physical before indexing dataSource, on a sorted grid', () => {
|
|
||||||
const setup = (): { hot: Handsontable; dataSource: any[] } => {
|
|
||||||
// Physical row 0: PK=1. Physical row 1: PK=2.
|
|
||||||
const dataSource = [
|
|
||||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'b' },
|
|
||||||
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'a' }
|
|
||||||
]
|
|
||||||
const hot = new Handsontable(document.createElement('div'), {
|
|
||||||
data: dataSource,
|
|
||||||
columns: [{ data: 'PRIMARY_KEY_FIELD' }, { data: 'SOME_CHAR' }],
|
|
||||||
multiColumnSorting: true,
|
|
||||||
licenseKey: 'non-commercial-and-evaluation'
|
|
||||||
})
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
// Ascending by SOME_CHAR flips the order: physical row 1 (SOME_CHAR
|
|
||||||
// 'a') becomes visual row 0; physical row 0 (SOME_CHAR 'b') becomes
|
|
||||||
// visual row 1.
|
|
||||||
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
|
|
||||||
sortPlugin.sort({ column: 1, sortOrder: 'asc' })
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
return { hot, dataSource }
|
|
||||||
}
|
|
||||||
|
|
||||||
it('demonstrates the failure mode: indexing dataSource with the raw visual row reads the wrong row', () => {
|
|
||||||
const { hot, dataSource } = setup()
|
|
||||||
|
|
||||||
// The user edits what they see as the FIRST row on screen - visual
|
|
||||||
// row 0, which after sorting is physical row 1 (PK=2).
|
|
||||||
const visualRow = 0
|
|
||||||
|
|
||||||
// Mirrors the current (buggy) afterChange hook: uses the visual row
|
|
||||||
// straight from `changes` to index dataSource.
|
|
||||||
const wronglyReadRow = dataSource[visualRow]
|
|
||||||
expect(wronglyReadRow.PRIMARY_KEY_FIELD).not.toEqual(2)
|
|
||||||
expect(wronglyReadRow.PRIMARY_KEY_FIELD).toEqual(1) // the OTHER row
|
|
||||||
|
|
||||||
// Mirrors the fix: translate visual -> physical first, matching
|
|
||||||
// beforeChange's own hot.toPhysicalRow(changeRow) pattern.
|
|
||||||
const physicalRow = hot.toPhysicalRow(visualRow)
|
|
||||||
const correctlyReadRow = dataSource[physicalRow as number]
|
|
||||||
expect(correctlyReadRow.PRIMARY_KEY_FIELD).toEqual(2)
|
|
||||||
|
|
||||||
hot.destroy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* updateEditStatusForRow/syncOverwrittenCommentForCell's own established
|
|
||||||
* contract (per their other existing callers - addRow/insertRowAtPosition,
|
|
||||||
* and syncOverwrittenComments()'s own `dataSource.forEach((_row, rowIndex)
|
|
||||||
* => ...)`) is a PHYSICAL row index, used to index dataSource. But both
|
|
||||||
* also call Handsontable APIs - hot.setDataAtRowProp/getDataAtRowProp
|
|
||||||
* (updateEditStatusForRow) and the comments plugin's setCommentAtCell/
|
|
||||||
* getCommentAtCell/removeCommentAtCell (syncOverwrittenCommentForCell) -
|
|
||||||
* which Handsontable's own JSDoc documents as expecting the VISUAL row.
|
|
||||||
* Passing the physical row straight through to those (the current code)
|
|
||||||
* only happens to work when visual and physical coincide - i.e. an
|
|
||||||
* unsorted grid. On a sorted one, translating the afterChange hook's own
|
|
||||||
* row (see the describe block above) is necessary but not sufficient -
|
|
||||||
* these two methods must also convert their own physical rowIndex
|
|
||||||
* parameter to visual before calling any Handsontable API, or the fix
|
|
||||||
* above just moves the bug rather than closing it.
|
|
||||||
*/
|
|
||||||
describe('updateEditStatusForRow resolves the correct visual cell on a sorted grid', () => {
|
|
||||||
const headerPks = ['PRIMARY_KEY_FIELD']
|
|
||||||
|
|
||||||
const setup = (): { hot: Handsontable; dataSource: any[] } => {
|
|
||||||
const dataSource = [
|
|
||||||
{
|
|
||||||
PRIMARY_KEY_FIELD: 1,
|
|
||||||
SOME_CHAR: 'b',
|
|
||||||
[EDIT_STATUS_COLUMN_NAME]: 'U'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
PRIMARY_KEY_FIELD: 2,
|
|
||||||
SOME_CHAR: 'a',
|
|
||||||
[EDIT_STATUS_COLUMN_NAME]: 'U'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
const hot = new Handsontable(document.createElement('div'), {
|
|
||||||
data: dataSource,
|
|
||||||
columns: [
|
|
||||||
{ data: 'PRIMARY_KEY_FIELD' },
|
|
||||||
{ data: 'SOME_CHAR' },
|
|
||||||
{ data: EDIT_STATUS_COLUMN_NAME }
|
|
||||||
],
|
|
||||||
multiColumnSorting: true,
|
|
||||||
licenseKey: 'non-commercial-and-evaluation'
|
|
||||||
})
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
// Ascending by SOME_CHAR: physical row 1 (PK=2) -> visual row 0;
|
|
||||||
// physical row 0 (PK=1) -> visual row 1.
|
|
||||||
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
|
|
||||||
sortPlugin.sort({ column: 1, sortOrder: 'asc' })
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
return { hot, dataSource }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mirrors the CURRENT updateEditStatusForRow - uses the (correct,
|
|
||||||
// physical) rowIndex directly for the Handsontable write too.
|
|
||||||
const updateEditStatusForRowBuggy = (
|
|
||||||
hot: Handsontable,
|
|
||||||
dataSource: any[],
|
|
||||||
dataSourceUnchanged: any[],
|
|
||||||
rowIndex: number
|
|
||||||
): void => {
|
|
||||||
const dataRow = dataSource[rowIndex]
|
|
||||||
if (!dataRow) return
|
|
||||||
|
|
||||||
const status = classifyRow(dataRow, dataSourceUnchanged, headerPks)
|
|
||||||
hot.setDataAtRowProp(
|
|
||||||
rowIndex,
|
|
||||||
EDIT_STATUS_COLUMN_NAME,
|
|
||||||
status,
|
|
||||||
'editStatus'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mirrors the fix - dataSource access stays physical; the Handsontable
|
|
||||||
// write is translated to visual first.
|
|
||||||
const updateEditStatusForRowFixed = (
|
|
||||||
hot: Handsontable,
|
|
||||||
dataSource: any[],
|
|
||||||
dataSourceUnchanged: any[],
|
|
||||||
rowIndex: number
|
|
||||||
): void => {
|
|
||||||
const dataRow = dataSource[rowIndex]
|
|
||||||
if (!dataRow) return
|
|
||||||
|
|
||||||
const status = classifyRow(dataRow, dataSourceUnchanged, headerPks)
|
|
||||||
const visualRow = hot.toVisualRow(rowIndex)
|
|
||||||
if (visualRow === null) return
|
|
||||||
|
|
||||||
hot.setDataAtRowProp(
|
|
||||||
visualRow,
|
|
||||||
EDIT_STATUS_COLUMN_NAME,
|
|
||||||
status,
|
|
||||||
'editStatus'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
it('demonstrates the failure mode: the buggy version writes the status to the wrong cell', () => {
|
|
||||||
const { hot, dataSource } = setup()
|
|
||||||
// Physical row 1 (PK=2) is the one that actually changed vs its
|
|
||||||
// unchanged baseline (SOME_CHAR 'a' vs 'ORIGINAL') - classifyRow
|
|
||||||
// should mark it 'M'. It's currently displayed at visual row 0.
|
|
||||||
const dataSourceUnchanged = [
|
|
||||||
{
|
|
||||||
PRIMARY_KEY_FIELD: 1,
|
|
||||||
SOME_CHAR: 'b',
|
|
||||||
[EDIT_STATUS_COLUMN_NAME]: 'U'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
PRIMARY_KEY_FIELD: 2,
|
|
||||||
SOME_CHAR: 'ORIGINAL',
|
|
||||||
[EDIT_STATUS_COLUMN_NAME]: 'U'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
updateEditStatusForRowBuggy(hot, dataSource, dataSourceUnchanged, 1)
|
|
||||||
|
|
||||||
// Buggy: wrote 'M' to visual row 1 (PK=1's cell, unaffected), not
|
|
||||||
// visual row 0 (PK=2's actual current position).
|
|
||||||
expect(hot.getDataAtRowProp(1, EDIT_STATUS_COLUMN_NAME)).toEqual('M')
|
|
||||||
expect(hot.getDataAtRowProp(0, EDIT_STATUS_COLUMN_NAME)).toEqual('U')
|
|
||||||
|
|
||||||
hot.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('writes the status to the correct (visually current) cell once fixed', () => {
|
|
||||||
const { hot, dataSource } = setup()
|
|
||||||
const dataSourceUnchanged = [
|
|
||||||
{
|
|
||||||
PRIMARY_KEY_FIELD: 1,
|
|
||||||
SOME_CHAR: 'b',
|
|
||||||
[EDIT_STATUS_COLUMN_NAME]: 'U'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
PRIMARY_KEY_FIELD: 2,
|
|
||||||
SOME_CHAR: 'ORIGINAL',
|
|
||||||
[EDIT_STATUS_COLUMN_NAME]: 'U'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
updateEditStatusForRowFixed(hot, dataSource, dataSourceUnchanged, 1)
|
|
||||||
|
|
||||||
expect(hot.getDataAtRowProp(0, EDIT_STATUS_COLUMN_NAME)).toEqual('M')
|
|
||||||
expect(hot.getDataAtRowProp(1, EDIT_STATUS_COLUMN_NAME)).toEqual('U')
|
|
||||||
|
|
||||||
hot.destroy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Same physical/visual split as updateEditStatusForRow above, for the
|
|
||||||
* comments plugin's own row-indexed API (setCommentAtCell/
|
|
||||||
* getCommentAtCell/removeCommentAtCell all document a VISUAL row -
|
|
||||||
* propToCol, unlike the row side, already returns a visual column, so
|
|
||||||
* only the row needs translating).
|
|
||||||
*/
|
|
||||||
describe('syncOverwrittenCommentForCell resolves the correct visual cell on a sorted grid', () => {
|
|
||||||
const setup = (): { hot: Handsontable } => {
|
|
||||||
const dataSource = [
|
|
||||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'b' },
|
|
||||||
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'a' }
|
|
||||||
]
|
|
||||||
const hot = new Handsontable(document.createElement('div'), {
|
|
||||||
data: dataSource,
|
|
||||||
columns: [{ data: 'PRIMARY_KEY_FIELD' }, { data: 'SOME_CHAR' }],
|
|
||||||
multiColumnSorting: true,
|
|
||||||
comments: true,
|
|
||||||
licenseKey: 'non-commercial-and-evaluation'
|
|
||||||
})
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
|
|
||||||
sortPlugin.sort({ column: 1, sortOrder: 'asc' })
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
return { hot }
|
|
||||||
}
|
|
||||||
|
|
||||||
it('demonstrates the failure mode: setCommentAtCell(physicalRow, ...) lands on the wrong visual cell', () => {
|
|
||||||
const { hot } = setup()
|
|
||||||
const commentsPlugin: any = hot.getPlugin('comments')
|
|
||||||
|
|
||||||
// Mirrors the buggy call: the established-physical rowIndex (1) used
|
|
||||||
// directly against a Handsontable API that wants visual.
|
|
||||||
commentsPlugin.setCommentAtCell(1, 1, 'Original value: b')
|
|
||||||
|
|
||||||
// Landed on visual row 1 (PK=1's cell), not visual row 0 (PK=2's
|
|
||||||
// actual current position, physical row 1).
|
|
||||||
expect(commentsPlugin.getCommentAtCell(1, 1)).toEqual('Original value: b')
|
|
||||||
expect(commentsPlugin.getCommentAtCell(0, 1)).toBeUndefined()
|
|
||||||
|
|
||||||
hot.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lands on the correct visual cell once translated to visual first', () => {
|
|
||||||
const { hot } = setup()
|
|
||||||
const commentsPlugin: any = hot.getPlugin('comments')
|
|
||||||
|
|
||||||
const visualRow = hot.toVisualRow(1)
|
|
||||||
commentsPlugin.setCommentAtCell(visualRow, 1, 'Original value: a')
|
|
||||||
|
|
||||||
expect(commentsPlugin.getCommentAtCell(0, 1)).toEqual('Original value: a')
|
|
||||||
expect(commentsPlugin.getCommentAtCell(1, 1)).toBeUndefined()
|
|
||||||
|
|
||||||
hot.destroy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A genuine Handsontable multiColumnSorting + formulas plugin bug (not
|
|
||||||
* specific to any of DC's own hooks): calling hot.updateSettings() - with
|
|
||||||
* ANY settings, even a bare {} - while a sort is active corrupts formula
|
|
||||||
* cell references, rendering '#REF!' instead of their computed value.
|
|
||||||
* Reproduced directly: a no-op {} settings object alone is enough to
|
|
||||||
* trigger it, independent of what it actually changes. editTable(),
|
|
||||||
* cancelEdit(), and every other place editor.component.ts calls
|
|
||||||
* updateSettings() after the grid could already be sorted (e.g. the user
|
|
||||||
* sorted while still read-only, then clicked Edit) hits this. The fix
|
|
||||||
* (updateSettingsSortSafe) clears the sort before the call and restores it
|
|
||||||
* immediately afterward.
|
|
||||||
*/
|
|
||||||
describe('updateSettings must not run while a sort is active - it corrupts formula cell references', () => {
|
|
||||||
const buildSortedGrid = (): Handsontable => {
|
|
||||||
const columnNames = [
|
|
||||||
'PRIMARY_KEY_FIELD',
|
|
||||||
'A_COL',
|
|
||||||
'B_COL',
|
|
||||||
'FORMULA_HARD_COL',
|
|
||||||
'PLAIN_TEXT_COL'
|
|
||||||
]
|
|
||||||
const data = Array.from({ length: 10 }, (_, i) => ({
|
|
||||||
PRIMARY_KEY_FIELD: i + 1,
|
|
||||||
A_COL: i + 1,
|
|
||||||
B_COL: 10,
|
|
||||||
FORMULA_HARD_COL: '',
|
|
||||||
PLAIN_TEXT_COL: `note-${i + 1}`
|
|
||||||
}))
|
|
||||||
data.forEach((row: any, i) => {
|
|
||||||
row.FORMULA_HARD_COL = parseFormulaRule('=A_COL * B_COL', {
|
|
||||||
columnNames,
|
|
||||||
rowIndex: i,
|
|
||||||
userName: 'test',
|
|
||||||
origValue: undefined
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const hot = new Handsontable(document.createElement('div'), {
|
|
||||||
data,
|
|
||||||
columns: columnNames.map((c) => ({ data: c })),
|
|
||||||
formulas: { engine: HyperFormula, licenseKey: 'gpl-v3' },
|
|
||||||
multiColumnSorting: true,
|
|
||||||
licenseKey: 'non-commercial-and-evaluation'
|
|
||||||
})
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
|
|
||||||
sortPlugin.sort({
|
|
||||||
column: columnNames.indexOf('PLAIN_TEXT_COL'),
|
|
||||||
sortOrder: 'desc'
|
|
||||||
})
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
return hot
|
|
||||||
}
|
|
||||||
|
|
||||||
const formulaHardColValues = (hot: Handsontable): any[] =>
|
|
||||||
Array.from({ length: 10 }, (_, r) =>
|
|
||||||
hot.getDataAtRowProp(r, 'FORMULA_HARD_COL')
|
|
||||||
)
|
|
||||||
|
|
||||||
it('demonstrates the failure mode: updateSettings while sorted corrupts formula cells', () => {
|
|
||||||
const hot = buildSortedGrid()
|
|
||||||
|
|
||||||
hot.updateSettings({}, false)
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
expect(formulaHardColValues(hot)).toContain('#REF!')
|
|
||||||
|
|
||||||
hot.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clearing the sort before updateSettings and restoring it after avoids the corruption', () => {
|
|
||||||
const hot = buildSortedGrid()
|
|
||||||
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
|
|
||||||
const sortConfigs = normalizeSortConfig(sortPlugin.getSortConfig())
|
|
||||||
|
|
||||||
sortPlugin.clearSort()
|
|
||||||
hot.updateSettings({}, false)
|
|
||||||
hot.render()
|
|
||||||
if (sortConfigs.length > 0) sortPlugin.sort(sortConfigs)
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
expect(formulaHardColValues(hot)).not.toContain('#REF!')
|
|
||||||
|
|
||||||
hot.destroy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* multiColumnSorting's own JSDoc: "every call of `sort` function set an
|
|
||||||
* entirely new sort order. Previous sort configs aren't preserved." A loop
|
|
||||||
* calling sort() once per captured config - the shape editor.component.ts
|
|
||||||
* used before this fix, in updateSettingsSortSafe/editTable/cancelEdit's
|
|
||||||
* restore step - therefore doesn't accumulate a multi-column sort: each
|
|
||||||
* call replaces the last, so only the FINAL config in the array survives.
|
|
||||||
* Restoring a captured multi-column sort must pass the whole array to a
|
|
||||||
* single sort() call instead.
|
|
||||||
*/
|
|
||||||
describe('restoring a multi-column sort must apply as a single call, not a loop', () => {
|
|
||||||
const columnNames = ['PRIMARY_KEY_FIELD', 'GROUP_COL', 'VALUE_COL']
|
|
||||||
|
|
||||||
const buildGrid = (): Handsontable => {
|
|
||||||
const data = [
|
|
||||||
{ PRIMARY_KEY_FIELD: 1, GROUP_COL: 'B', VALUE_COL: 2 },
|
|
||||||
{ PRIMARY_KEY_FIELD: 2, GROUP_COL: 'A', VALUE_COL: 1 },
|
|
||||||
{ PRIMARY_KEY_FIELD: 3, GROUP_COL: 'A', VALUE_COL: 3 },
|
|
||||||
{ PRIMARY_KEY_FIELD: 4, GROUP_COL: 'B', VALUE_COL: 4 }
|
|
||||||
]
|
|
||||||
const hot = new Handsontable(document.createElement('div'), {
|
|
||||||
data,
|
|
||||||
columns: columnNames.map((c) => ({ data: c })),
|
|
||||||
multiColumnSorting: true,
|
|
||||||
licenseKey: 'non-commercial-and-evaluation'
|
|
||||||
})
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
return hot
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by GROUP_COL asc, then VALUE_COL desc within each group.
|
|
||||||
const sortConfigs = [
|
|
||||||
{ column: 1, sortOrder: 'asc' },
|
|
||||||
{ column: 2, sortOrder: 'desc' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const visiblePkOrder = (hot: Handsontable): number[] =>
|
|
||||||
Array.from(
|
|
||||||
{ length: 4 },
|
|
||||||
(_, r) => hot.getDataAtRowProp(r, 'PRIMARY_KEY_FIELD') as number
|
|
||||||
)
|
|
||||||
|
|
||||||
it('demonstrates the failure mode: looping sort() per column loses every column but the last', () => {
|
|
||||||
const hot = buildGrid()
|
|
||||||
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
|
|
||||||
|
|
||||||
for (const sc of sortConfigs) sortPlugin.sort(sc)
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
// GROUP_COL asc got wiped out by the second call - only VALUE_COL desc
|
|
||||||
// (the last config) survived, sorting the whole table by value alone.
|
|
||||||
expect(visiblePkOrder(hot)).toEqual([4, 3, 1, 2])
|
|
||||||
|
|
||||||
hot.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applying the full config array in one call restores the actual multi-column order', () => {
|
|
||||||
const hot = buildGrid()
|
|
||||||
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
|
|
||||||
|
|
||||||
sortPlugin.sort(sortConfigs)
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
// Group A first (value desc: 3, 1), then group B (value desc: 4, 2).
|
|
||||||
expect(visiblePkOrder(hot)).toEqual([3, 2, 4, 1])
|
|
||||||
|
|
||||||
hot.destroy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* getFormulaCellsToPreserveOnCancel's rowIndex parameter is established as
|
|
||||||
* PHYSICAL (it iterates 0..dataSource.length, matching dataSource's own
|
|
||||||
* order - see the file's own docblock). cancelEdit() passes that rowIndex
|
|
||||||
* straight into commentsPlugin.getCommentAtCell and hot.getDataAtRowProp,
|
|
||||||
* both of which - same as updateEditStatusForRow/syncOverwrittenCommentForCell
|
|
||||||
* above - document a VISUAL row. On a sorted grid this both checks and
|
|
||||||
* reads the wrong row's cell: a row that actually has a comment can go
|
|
||||||
* unflagged while a different row gets wrongly flagged, carrying that other
|
|
||||||
* row's live value onto its own prop - corrupting cancelEdit()'s restore
|
|
||||||
* rather than just skipping it. The fix is to clear the sort before this
|
|
||||||
* runs at all (cancelEdit() now does this - see its own comment), rather
|
|
||||||
* than translate physical->visual per call, since dataSourceUnchanged's
|
|
||||||
* restore right after also needs an unsorted physical dataSource to index
|
|
||||||
* into.
|
|
||||||
*/
|
|
||||||
describe('getFormulaCellsToPreserveOnCancel callbacks resolve the correct cell on a sorted grid', () => {
|
|
||||||
const formulaBaseCols = ['FORMULA_HARD_COL']
|
|
||||||
|
|
||||||
const setup = (): { hot: Handsontable } => {
|
|
||||||
const dataSource = [
|
|
||||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'b', FORMULA_HARD_COL: 10 },
|
|
||||||
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'a', FORMULA_HARD_COL: 20 }
|
|
||||||
]
|
|
||||||
const hot = new Handsontable(document.createElement('div'), {
|
|
||||||
data: dataSource,
|
|
||||||
columns: [
|
|
||||||
{ data: 'PRIMARY_KEY_FIELD' },
|
|
||||||
{ data: 'SOME_CHAR' },
|
|
||||||
{ data: 'FORMULA_HARD_COL' }
|
|
||||||
],
|
|
||||||
multiColumnSorting: true,
|
|
||||||
comments: true,
|
|
||||||
licenseKey: 'non-commercial-and-evaluation'
|
|
||||||
})
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
// Physical row 0 (PK=1) has the comment/live value that must be
|
|
||||||
// preserved. Ascending by SOME_CHAR flips it to visual row 1; physical
|
|
||||||
// row 1 (PK=2, no comment) becomes visual row 0.
|
|
||||||
const commentsPlugin: any = hot.getPlugin('comments')
|
|
||||||
commentsPlugin.setCommentAtCell(0, 2, 'Original value: 5')
|
|
||||||
|
|
||||||
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
|
|
||||||
sortPlugin.sort({ column: 1, sortOrder: 'asc' })
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
return { hot }
|
|
||||||
}
|
|
||||||
|
|
||||||
it('demonstrates the failure mode: physical rowIndex passed straight to visual-row APIs resolves the wrong cell', () => {
|
|
||||||
const { hot } = setup()
|
|
||||||
const commentsPlugin: any = hot.getPlugin('comments')
|
|
||||||
|
|
||||||
const toPreserve = getFormulaCellsToPreserveOnCancel(
|
|
||||||
2,
|
|
||||||
formulaBaseCols,
|
|
||||||
(rowIndex, baseCol) =>
|
|
||||||
!!commentsPlugin.getCommentAtCell(rowIndex, hot.propToCol(baseCol)),
|
|
||||||
(rowIndex, prop) => hot.getDataAtRowProp(rowIndex, prop)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Physical row 0's comment lives at visual row 1 post-sort, but the
|
|
||||||
// buggy call checked physical rowIndex 0 straight as a visual row -
|
|
||||||
// landing on physical row 1's cell (no comment, value 20) instead.
|
|
||||||
// rowIndex 1 then wrongly reads AS visual row 1, which is physical row
|
|
||||||
// 0's actual commented cell (value 10) - so the wrong row (1, not 0)
|
|
||||||
// gets flagged, carrying the wrong row's value.
|
|
||||||
expect(toPreserve).toEqual([
|
|
||||||
{ rowIndex: 1, prop: 'FORMULA_HARD_COL', value: 10 }
|
|
||||||
])
|
|
||||||
|
|
||||||
hot.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves the correct cell once the sort is cleared before reading', () => {
|
|
||||||
const { hot } = setup()
|
|
||||||
const commentsPlugin: any = hot.getPlugin('comments')
|
|
||||||
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
|
|
||||||
const sortConfigs = normalizeSortConfig(sortPlugin.getSortConfig())
|
|
||||||
|
|
||||||
sortPlugin.clearSort()
|
|
||||||
|
|
||||||
const toPreserve = getFormulaCellsToPreserveOnCancel(
|
|
||||||
2,
|
|
||||||
formulaBaseCols,
|
|
||||||
(rowIndex, baseCol) =>
|
|
||||||
!!commentsPlugin.getCommentAtCell(rowIndex, hot.propToCol(baseCol)),
|
|
||||||
(rowIndex, prop) => hot.getDataAtRowProp(rowIndex, prop)
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(toPreserve).toEqual([
|
|
||||||
{ rowIndex: 0, prop: 'FORMULA_HARD_COL', value: 10 }
|
|
||||||
])
|
|
||||||
|
|
||||||
for (const sc of sortConfigs) sortPlugin.sort(sc)
|
|
||||||
hot.destroy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -12,30 +12,6 @@ export const COMBINED_KEY_PREFIX = 'DCKEY1:'
|
|||||||
export const isCombinedLicenceKey = (text: string): boolean =>
|
export const isCombinedLicenceKey = (text: string): boolean =>
|
||||||
text.trim().startsWith(COMBINED_KEY_PREFIX)
|
text.trim().startsWith(COMBINED_KEY_PREFIX)
|
||||||
|
|
||||||
const streamToArrayBuffer = async (
|
|
||||||
readable: ReadableStream<Uint8Array>
|
|
||||||
): Promise<ArrayBuffer> => {
|
|
||||||
const reader = readable.getReader()
|
|
||||||
const chunks: Uint8Array[] = []
|
|
||||||
let totalLength = 0
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
chunks.push(value)
|
|
||||||
totalLength += value.length
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = new Uint8Array(totalLength)
|
|
||||||
let offset = 0
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
result.set(chunk, offset)
|
|
||||||
offset += chunk.length
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
const gzipDecompress = async (bytes: ArrayBuffer): Promise<ArrayBuffer> => {
|
const gzipDecompress = async (bytes: ArrayBuffer): Promise<ArrayBuffer> => {
|
||||||
const decompressionStream = new DecompressionStream('gzip')
|
const decompressionStream = new DecompressionStream('gzip')
|
||||||
const writer = decompressionStream.writable.getWriter()
|
const writer = decompressionStream.writable.getWriter()
|
||||||
@@ -45,7 +21,7 @@ const gzipDecompress = async (bytes: ArrayBuffer): Promise<ArrayBuffer> => {
|
|||||||
// unhandled rejection racing the readable side below.
|
// unhandled rejection racing the readable side below.
|
||||||
const result = await Promise.all([
|
const result = await Promise.all([
|
||||||
writer.write(new Uint8Array(bytes)).then(() => writer.close()),
|
writer.write(new Uint8Array(bytes)).then(() => writer.close()),
|
||||||
streamToArrayBuffer(decompressionStream.readable)
|
new Response(decompressionStream.readable).arrayBuffer()
|
||||||
])
|
])
|
||||||
|
|
||||||
return result[1]
|
return result[1]
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ export type LicenceKeyProtocolMismatch = 'requiresHttps' | 'requiresHttp' | null
|
|||||||
* alone tells us which format a pasted key is, without needing to attempt
|
* alone tells us which format a pasted key is, without needing to attempt
|
||||||
* decryption first.
|
* decryption first.
|
||||||
*
|
*
|
||||||
* This is a structural assumption about key-generation logic
|
|
||||||
* (a separate repo) - if that ever changes to generate distinct values for
|
|
||||||
* HTTP-format keys too, this detection would silently misclassify keys.
|
|
||||||
*
|
|
||||||
* isSecureContext must reflect whether this browsing context can actually
|
* isSecureContext must reflect whether this browsing context can actually
|
||||||
* decrypt a secure-connection key (mirrors the check licence.service.ts's
|
* decrypt a secure-connection key (mirrors the check licence.service.ts's
|
||||||
* own decryptLicenseKey() makes) - not simply `location.protocol ===
|
* own decryptLicenseKey() makes) - not simply `location.protocol ===
|
||||||
|
|||||||
@@ -10,6 +10,5 @@ export class AbortDetails {
|
|||||||
SYSWARNINGTEXT?: string
|
SYSWARNINGTEXT?: string
|
||||||
SYSERRORTEXT?: string
|
SYSERRORTEXT?: string
|
||||||
MAC?: string
|
MAC?: string
|
||||||
_PROGRAM?: string
|
|
||||||
LOG?: string
|
LOG?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,9 +217,7 @@ export class ApproveDetailsComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public calcDiff() {
|
public calcDiff() {
|
||||||
// params-only responses (e.g. already reviewed submissions) carry no diff
|
if (!this.response) return
|
||||||
// tables - nothing to calculate, the details header still renders
|
|
||||||
if (!this.response || !this.response.cols) return
|
|
||||||
|
|
||||||
let news = this.response.new
|
let news = this.response.new
|
||||||
let updates = this.response.updates
|
let updates = this.response.updates
|
||||||
@@ -357,6 +355,26 @@ export class ApproveDetailsComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.submitArr.push(item)
|
this.submitArr.push(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let diffs = {
|
||||||
|
ACTION: 'SHOW_DIFFS',
|
||||||
|
TABLE: this.tableId,
|
||||||
|
DIFFTIME: new Date().toUTCString()
|
||||||
|
}
|
||||||
|
// show diffs and changes info in a same call
|
||||||
|
this.sasStoreService
|
||||||
|
.showDiffs(diffs, 'SASControlTable', 'auditors/postdata')
|
||||||
|
.then((res: AuditorsPostdataSASResponse) => {
|
||||||
|
let param = res.params[0]
|
||||||
|
this.params = param
|
||||||
|
this.response = res
|
||||||
|
this.calcDiff()
|
||||||
|
this.callChangesInfo(this.tableId)
|
||||||
|
})
|
||||||
|
.catch((err: any) => err)
|
||||||
|
.finally(() => {
|
||||||
|
this.loadingTable = true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (typeof this.router.snapshot.params['tableId'] === 'undefined') {
|
if (typeof this.router.snapshot.params['tableId'] === 'undefined') {
|
||||||
|
|||||||
@@ -68,9 +68,6 @@ export class SubmitterComponent implements OnInit, AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public goToDetails(table_id: any) {
|
public goToDetails(table_id: any) {
|
||||||
// the details URL re-creates this component - carry the queue payload
|
|
||||||
// over so it is not fetched again on mount
|
|
||||||
this.sasStoreService.handoffSubmits(this.submitData)
|
|
||||||
this.router.navigateByUrl('/review/submitted/' + table_id)
|
this.router.navigateByUrl('/review/submitted/' + table_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,19 +83,14 @@ export class SubmitterComponent implements OnInit, AfterViewInit {
|
|||||||
|
|
||||||
this.itemsNum = 10
|
this.itemsNum = 10
|
||||||
try {
|
try {
|
||||||
// navigating from the submit list to a details URL re-creates this
|
let res = await this.sasStoreService.getSubmitts()
|
||||||
// component - take the queue payload carried over by that navigation
|
|
||||||
// instead of fetching it again
|
|
||||||
let fromsas: any = this.sasStoreService.takeSubmitsHandoff()
|
|
||||||
|
|
||||||
if (!fromsas) {
|
this.remained = res.fromsas.length
|
||||||
fromsas = (await this.sasStoreService.getSubmitts()).fromsas
|
|
||||||
}
|
|
||||||
|
|
||||||
this.remained = fromsas.length
|
|
||||||
if (this.remained > 0) {
|
if (this.remained > 0) {
|
||||||
this.submitter = fromsas[0].SUBMITTED_BY_NM
|
this.submitter = res.fromsas[0].SUBMITTED_BY_NM
|
||||||
let submitterList: SubmitterData[] = fromsas.map(function (item: any) {
|
let submitterList: SubmitterData[] = res.fromsas.map(function (
|
||||||
|
item: any
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
tableId: item.TABLE_ID,
|
tableId: item.TABLE_ID,
|
||||||
base: item.BASE_TABLE,
|
base: item.BASE_TABLE,
|
||||||
@@ -108,7 +100,7 @@ export class SubmitterComponent implements OnInit, AfterViewInit {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.submitterList = submitterList
|
this.submitterList = submitterList
|
||||||
this.submitData = fromsas
|
this.submitData = res.fromsas
|
||||||
|
|
||||||
// Details page
|
// Details page
|
||||||
if (typeof tableIdParam !== 'undefined') {
|
if (typeof tableIdParam !== 'undefined') {
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ const buildDeps = () => {
|
|||||||
const appStoreService: any = {
|
const appStoreService: any = {
|
||||||
getDcAdapterSettings: () => undefined
|
getDcAdapterSettings: () => undefined
|
||||||
}
|
}
|
||||||
const startupCheckService: any = {
|
|
||||||
updateStep: jasmine.createSpy('updateStep')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
licenceService,
|
licenceService,
|
||||||
@@ -49,8 +46,7 @@ const buildDeps = () => {
|
|||||||
loggerService,
|
loggerService,
|
||||||
appSettingsService,
|
appSettingsService,
|
||||||
router,
|
router,
|
||||||
appStoreService,
|
appStoreService
|
||||||
startupCheckService
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +58,7 @@ const buildAppService = (deps: ReturnType<typeof buildDeps>) =>
|
|||||||
deps.loggerService,
|
deps.loggerService,
|
||||||
deps.appSettingsService,
|
deps.appSettingsService,
|
||||||
deps.router,
|
deps.router,
|
||||||
deps.appStoreService,
|
deps.appStoreService
|
||||||
deps.startupCheckService
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Minimal valid startupservice payload — just enough to pass startUpData()'s
|
// Minimal valid startupservice payload — just enough to pass startUpData()'s
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { AppSettingsService } from './app-settings.service'
|
|||||||
import { AppThemes } from '../models/AppSettings'
|
import { AppThemes } from '../models/AppSettings'
|
||||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||||
import { AppStoreService } from './app-store.service'
|
import { AppStoreService } from './app-store.service'
|
||||||
import { StartupCheckService } from './startup-check.service'
|
|
||||||
import { retryOnce, RetryOnceOptions } from '../shared/utils/retry-once'
|
import { retryOnce, RetryOnceOptions } from '../shared/utils/retry-once'
|
||||||
import { getMalformedAdapterResponseMessage } from '../shared/utils/get-malformed-adapter-response-message'
|
import { getMalformedAdapterResponseMessage } from '../shared/utils/get-malformed-adapter-response-message'
|
||||||
|
|
||||||
@@ -30,8 +29,7 @@ export class AppService {
|
|||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
private appSettingsService: AppSettingsService,
|
private appSettingsService: AppSettingsService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private appStoreService: AppStoreService,
|
private appStoreService: AppStoreService
|
||||||
private startupCheckService: StartupCheckService
|
|
||||||
) {
|
) {
|
||||||
this.subscribe()
|
this.subscribe()
|
||||||
|
|
||||||
@@ -102,11 +100,6 @@ export class AppService {
|
|||||||
)
|
)
|
||||||
if (malformedMessage) {
|
if (malformedMessage) {
|
||||||
startupServiceError = true
|
startupServiceError = true
|
||||||
console.error(
|
|
||||||
'startUpData: startupservice returned malformed response:',
|
|
||||||
malformedMessage
|
|
||||||
)
|
|
||||||
this.startupCheckService.updateStep(2, 'error', 'Malformed response')
|
|
||||||
this.eventService.showInfoModal('Error', malformedMessage)
|
this.eventService.showInfoModal('Error', malformedMessage)
|
||||||
this.licenceService.isAppActivated.next(false)
|
this.licenceService.isAppActivated.next(false)
|
||||||
|
|
||||||
@@ -128,15 +121,6 @@ export class AppService {
|
|||||||
|
|
||||||
if (missingProps.length > 0) {
|
if (missingProps.length > 0) {
|
||||||
startupServiceError = true
|
startupServiceError = true
|
||||||
console.error(
|
|
||||||
'startUpData: startupservice missing properties:',
|
|
||||||
missingProps.join(', ')
|
|
||||||
)
|
|
||||||
this.startupCheckService.updateStep(
|
|
||||||
2,
|
|
||||||
'error',
|
|
||||||
`Missing: ${missingProps.join(', ')}`
|
|
||||||
)
|
|
||||||
this.eventService.showInfoModal(
|
this.eventService.showInfoModal(
|
||||||
'Error',
|
'Error',
|
||||||
`${missingProps.join(', ')} are not present in the startupservice`
|
`${missingProps.join(', ')} are not present in the startupservice`
|
||||||
@@ -160,9 +144,6 @@ export class AppService {
|
|||||||
SYSHOSTINFOLONG: res.adapterResponse.SYSHOSTINFOLONG,
|
SYSHOSTINFOLONG: res.adapterResponse.SYSHOSTINFOLONG,
|
||||||
SYSENCODING: res.adapterResponse.SYSENCODING,
|
SYSENCODING: res.adapterResponse.SYSENCODING,
|
||||||
AUTOEXEC: res.adapterResponse.AUTOEXEC,
|
AUTOEXEC: res.adapterResponse.AUTOEXEC,
|
||||||
TIMEZONE: res.adapterResponse.globvars[0].TIMEZONE,
|
|
||||||
SYSTIMEZONEIDENT: res.adapterResponse.globvars[0].SYSTIMEZONEIDENT,
|
|
||||||
SYSTIMEZONEOFFSET: res.adapterResponse.globvars[0].SYSTIMEZONEOFFSET,
|
|
||||||
ISADMIN: res.adapterResponse.globvars[0].ISADMIN,
|
ISADMIN: res.adapterResponse.globvars[0].ISADMIN,
|
||||||
DC_ADMIN_GROUP: res.adapterResponse.globvars[0].DC_ADMIN_GROUP,
|
DC_ADMIN_GROUP: res.adapterResponse.globvars[0].DC_ADMIN_GROUP,
|
||||||
APP_LOC: dcAdapterSettings?.appLoc
|
APP_LOC: dcAdapterSettings?.appLoc
|
||||||
@@ -217,8 +198,6 @@ export class AppService {
|
|||||||
})
|
})
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
startupServiceError = true
|
startupServiceError = true
|
||||||
console.error('startUpData: startupservice request failed:', err)
|
|
||||||
this.startupCheckService.updateStep(2, 'error', 'Request failed')
|
|
||||||
this.eventService.showInfoModal(
|
this.eventService.showInfoModal(
|
||||||
'Error',
|
'Error',
|
||||||
'There is an issue with startupservice response'
|
'There is an issue with startupservice response'
|
||||||
|
|||||||
@@ -118,13 +118,6 @@ export class LicenceService {
|
|||||||
|
|
||||||
let variables = globvars[0]
|
let variables = globvars[0]
|
||||||
|
|
||||||
// Trim whitespace — the SAS makedata service initialises licence keys
|
|
||||||
// as a single space, which is truthy in JS but not a valid key.
|
|
||||||
if (variables.LICENCE_KEY)
|
|
||||||
variables.LICENCE_KEY = variables.LICENCE_KEY.trim()
|
|
||||||
if (variables.ACTIVATION_KEY)
|
|
||||||
variables.ACTIVATION_KEY = variables.ACTIVATION_KEY.trim()
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
variables.LICENCE_KEY === undefined ||
|
variables.LICENCE_KEY === undefined ||
|
||||||
variables.ACTIVATION_KEY === undefined ||
|
variables.ACTIVATION_KEY === undefined ||
|
||||||
|
|||||||
@@ -35,13 +35,6 @@ export class SasStoreService {
|
|||||||
public setSubmit: Subject<any> = new Subject<any>()
|
public setSubmit: Subject<any> = new Subject<any>()
|
||||||
public setSubmitList: Subject<any> = new Subject<any>()
|
public setSubmitList: Subject<any> = new Subject<any>()
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit queue payload carried across the navigation from the submit
|
|
||||||
* list to a details URL - the re-created list component renders it
|
|
||||||
* without fetching the queue again.
|
|
||||||
*/
|
|
||||||
private submitsHandoff: Array<any> | null = null
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private sasService: SasService,
|
private sasService: SasService,
|
||||||
private helperService: HelperService,
|
private helperService: HelperService,
|
||||||
@@ -162,24 +155,6 @@ export class SasStoreService {
|
|||||||
.adapterResponse
|
.adapterResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Carries the fetched submit queue over to the details URL navigation,
|
|
||||||
* so the re-created submit list component does not fetch it again.
|
|
||||||
*/
|
|
||||||
public handoffSubmits(fromsas: Array<any> | undefined) {
|
|
||||||
this.submitsHandoff = fromsas || null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the submit queue carried by the current navigation, if any,
|
|
||||||
* and clears it - it is only valid for the navigation that set it.
|
|
||||||
*/
|
|
||||||
public takeSubmitsHandoff(): Array<any> | null {
|
|
||||||
const fromsas = this.submitsHandoff
|
|
||||||
this.submitsHandoff = null
|
|
||||||
return fromsas
|
|
||||||
}
|
|
||||||
|
|
||||||
private libsPromise: Promise<any> | null = null
|
private libsPromise: Promise<any> | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -150,12 +150,9 @@ export class SasViyaService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
getFolderMembers(
|
getFolderMembers(folderId: string): Observable<ViyaApiFolderMembers> {
|
||||||
folderId: string,
|
|
||||||
limit: number = 500
|
|
||||||
): Observable<ViyaApiFolderMembers> {
|
|
||||||
return this.get<ViyaApiFolderMembers>(
|
return this.get<ViyaApiFolderMembers>(
|
||||||
`${this.serverUrl}/folders/folders/${folderId}/members?limit=${limit}`,
|
`${this.serverUrl}/folders/folders/${folderId}/members`,
|
||||||
{
|
{
|
||||||
withCredentials: true
|
withCredentials: true
|
||||||
}
|
}
|
||||||
@@ -171,20 +168,6 @@ export class SasViyaService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns The groups the current user is a member of
|
|
||||||
*/
|
|
||||||
getCurrentUserGroupMemberships(
|
|
||||||
limit: number = 10000
|
|
||||||
): Observable<ViyaApiIdentities> {
|
|
||||||
return this.get<ViyaApiIdentities>(
|
|
||||||
`${this.serverUrl}/identities/users/@currentUser/memberships?limit=${limit}`,
|
|
||||||
{
|
|
||||||
withCredentials: true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentUser(): Observable<ViyaApiCurrentUser> {
|
getCurrentUser(): Observable<ViyaApiCurrentUser> {
|
||||||
return this.get<ViyaApiCurrentUser>(
|
return this.get<ViyaApiCurrentUser>(
|
||||||
`${this.serverUrl}/identities/users/@currentUser`,
|
`${this.serverUrl}/identities/users/@currentUser`,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Injectable, EventEmitter } from '@angular/core'
|
|||||||
import SASjs, { UploadFile } from '@sasjs/adapter'
|
import SASjs, { UploadFile } from '@sasjs/adapter'
|
||||||
import { BehaviorSubject } from 'rxjs'
|
import { BehaviorSubject } from 'rxjs'
|
||||||
import { UserService } from '../shared/user.service'
|
import { UserService } from '../shared/user.service'
|
||||||
import { StartupCheckService } from './startup-check.service'
|
|
||||||
|
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import { EventService } from './event.service'
|
import { EventService } from './event.service'
|
||||||
@@ -44,7 +43,6 @@ export class SasService {
|
|||||||
private sasjsService: SasjsService,
|
private sasjsService: SasjsService,
|
||||||
private sasViyaService: SasViyaService,
|
private sasViyaService: SasViyaService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
private startupCheckService: StartupCheckService,
|
|
||||||
private router: Router
|
private router: Router
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -197,8 +195,7 @@ export class SasService {
|
|||||||
{
|
{
|
||||||
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
|
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
|
||||||
SYSERRORTEXT: abortRes.SYSERRORTEXT,
|
SYSERRORTEXT: abortRes.SYSERRORTEXT,
|
||||||
MAC: macMsg,
|
MAC: macMsg
|
||||||
_PROGRAM: abortRes._PROGRAM
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -358,10 +355,7 @@ export class SasService {
|
|||||||
|
|
||||||
this.sasjsService.getFolderContentsFromDrive(configuratorFolder).subscribe(
|
this.sasjsService.getFolderContentsFromDrive(configuratorFolder).subscribe(
|
||||||
(contents: SASjsApiDriveFolderContents) => {
|
(contents: SASjsApiDriveFolderContents) => {
|
||||||
if (
|
if (contents.files.includes('makedata.sas')) {
|
||||||
contents.files.includes('makedata.sas') ||
|
|
||||||
contents.files.includes('makedata.js')
|
|
||||||
) {
|
|
||||||
this.eventService.startupDataLoaded()
|
this.eventService.startupDataLoaded()
|
||||||
this.router.navigateByUrl('/deploy')
|
this.router.navigateByUrl('/deploy')
|
||||||
} else {
|
} else {
|
||||||
@@ -423,10 +417,7 @@ export class SasService {
|
|||||||
.getFolderContentsFromDrive(configuratorFolder)
|
.getFolderContentsFromDrive(configuratorFolder)
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(contents: SASjsApiDriveFolderContents) => {
|
(contents: SASjsApiDriveFolderContents) => {
|
||||||
if (
|
if (!contents.files.includes('makedata.sas')) {
|
||||||
!contents.files.includes('makedata.sas') &&
|
|
||||||
!contents.files.includes('makedata.js')
|
|
||||||
) {
|
|
||||||
resolve(true)
|
resolve(true)
|
||||||
} else {
|
} else {
|
||||||
resolve(false)
|
resolve(false)
|
||||||
@@ -446,9 +437,6 @@ export class SasService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async checkViyaDeploy(path: string) {
|
public async checkViyaDeploy(path: string) {
|
||||||
console.log('checkViyaDeploy: checking appLoc', path)
|
|
||||||
this.startupCheckService.updateStep(0, 'in_progress')
|
|
||||||
|
|
||||||
const getFolderExistsInAdapter =
|
const getFolderExistsInAdapter =
|
||||||
typeof this.sasjsAdapter.getFolder !== 'undefined'
|
typeof this.sasjsAdapter.getFolder !== 'undefined'
|
||||||
|
|
||||||
@@ -464,58 +452,25 @@ export class SasService {
|
|||||||
appLocExists = await this.appLocCheckPreAxiosdAdapter(path)
|
appLocExists = await this.appLocCheckPreAxiosdAdapter(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
|
||||||
'checkViyaDeploy: appLoc exists?',
|
|
||||||
appLocExists,
|
|
||||||
'error?',
|
|
||||||
errorMessage
|
|
||||||
)
|
|
||||||
|
|
||||||
if (appLocExists) {
|
if (appLocExists) {
|
||||||
this.startupCheckService.updateStep(0, 'done', path)
|
|
||||||
|
|
||||||
// Check if there is appLoc/services/admin/makedata.sas present
|
// Check if there is appLoc/services/admin/makedata.sas present
|
||||||
// if yes, it needs to be run, so we redirect to /deploy
|
// if yes, it needs to be run, so we redirect to /deploy
|
||||||
// if not, we load the startup service
|
// if not, we load the startup service
|
||||||
|
|
||||||
this.startupCheckService.updateStep(1, 'in_progress')
|
|
||||||
this.viyaMakedataSuccessfull().then(
|
this.viyaMakedataSuccessfull().then(
|
||||||
(success: boolean) => {
|
(success: boolean) => {
|
||||||
console.log(
|
|
||||||
'checkViyaDeploy: makedata job already run?',
|
|
||||||
success,
|
|
||||||
success
|
|
||||||
? '-> loading startupservice'
|
|
||||||
: '-> redirecting to /deploy (setup screen)'
|
|
||||||
)
|
|
||||||
if (success) {
|
if (success) {
|
||||||
this.startupCheckService.updateStep(1, 'done', 'makedata complete')
|
|
||||||
this.startupCheckService.updateStep(2, 'in_progress')
|
|
||||||
this.loadStartupServiceEmitter.emit()
|
this.loadStartupServiceEmitter.emit()
|
||||||
} else {
|
} else {
|
||||||
this.startupCheckService.updateStep(
|
|
||||||
1,
|
|
||||||
'done',
|
|
||||||
'makedata job found - setup needed'
|
|
||||||
)
|
|
||||||
this.eventService.startupDataLoaded()
|
this.eventService.startupDataLoaded()
|
||||||
this.router.navigateByUrl('/deploy')
|
this.router.navigateByUrl('/deploy')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(error: any) => {
|
(error: any) => {
|
||||||
console.error(
|
console.error('Error while looking for the file: makedata.sas', error)
|
||||||
'checkViyaDeploy: error while looking for makedata job',
|
|
||||||
error
|
|
||||||
)
|
|
||||||
this.startupCheckService.updateStep(1, 'error', String(error))
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
this.startupCheckService.updateStep(
|
|
||||||
0,
|
|
||||||
'error',
|
|
||||||
errorMessage || 'not found'
|
|
||||||
)
|
|
||||||
const errorMessageToShow =
|
const errorMessageToShow =
|
||||||
(errorMessage ||
|
(errorMessage ||
|
||||||
'Viya services are not present on the current appLoc, or API not reachable. Check the ADAPTER configuration.') +
|
'Viya services are not present on the current appLoc, or API not reachable. Check the ADAPTER configuration.') +
|
||||||
@@ -525,71 +480,43 @@ export class SasService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async viyaMakedataSuccessfull(): Promise<boolean> {
|
private async viyaMakedataSuccessfull(): Promise<boolean> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const sasjsConfig = this.getSasjsConfig()
|
const sasjsConfig = this.getSasjsConfig()
|
||||||
const configuratorFolder = `${sasjsConfig.appLoc}/services/admin`
|
const configuratorFolder = `${sasjsConfig.appLoc}/services/admin`
|
||||||
|
|
||||||
console.log(
|
|
||||||
'viyaMakedataSuccessfull: checking folder',
|
|
||||||
configuratorFolder
|
|
||||||
)
|
|
||||||
|
|
||||||
this.sasViyaService.getFolderByPath(configuratorFolder).subscribe(
|
this.sasViyaService.getFolderByPath(configuratorFolder).subscribe(
|
||||||
(folderInfo: ViyaApiFolder) => {
|
(folderInfo: ViyaApiFolder) => {
|
||||||
const folderId = folderInfo.id
|
const folderId = folderInfo.id
|
||||||
|
|
||||||
if (!folderId) {
|
if (!folderId) {
|
||||||
console.error(
|
console.error(
|
||||||
`viyaMakedataSuccessfull: folder ID not present for ${configuratorFolder}`,
|
`Folder ID is not present. ${configuratorFolder}`,
|
||||||
sasjsConfig
|
sasjsConfig
|
||||||
)
|
)
|
||||||
resolve(false)
|
resolve(false)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sasViyaService.getFolderMembers(folderId).subscribe(
|
this.sasViyaService.getFolderMembers(folderId).subscribe(
|
||||||
(members: ViyaApiFolderMembers) => {
|
(members: ViyaApiFolderMembers) => {
|
||||||
const memberNames = members.items.map((item: any) => item.name)
|
if (
|
||||||
const hasMakedata = members.items.some(
|
!members.items.some((item: any) => item.name === 'makedata')
|
||||||
(item: any) => item.name === 'makedata'
|
) {
|
||||||
)
|
// Makedata.sas is not present, which means it was run
|
||||||
|
|
||||||
console.log(
|
|
||||||
'viyaMakedataSuccessfull: folder members:',
|
|
||||||
memberNames.join(', ') || '(none)'
|
|
||||||
)
|
|
||||||
console.log(
|
|
||||||
'viyaMakedataSuccessfull: makedata job present?',
|
|
||||||
hasMakedata,
|
|
||||||
hasMakedata
|
|
||||||
? '-> setup needed (redirect to /deploy)'
|
|
||||||
: '-> setup already done (load startupservice)'
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!hasMakedata) {
|
|
||||||
resolve(true)
|
resolve(true)
|
||||||
} else {
|
} else {
|
||||||
|
// Makedata.sas is present, which means it was not run
|
||||||
resolve(false)
|
resolve(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(err: any) => {
|
(err: any) => {
|
||||||
console.error(
|
console.error('Error getting folder contents', err)
|
||||||
'viyaMakedataSuccessfull: error getting folder members for',
|
reject()
|
||||||
configuratorFolder,
|
|
||||||
err
|
|
||||||
)
|
|
||||||
// On error, assume setup is needed rather than skipping it
|
|
||||||
resolve(false)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
(err: any) => {
|
(err: any) => {
|
||||||
console.warn(
|
console.warn('Error getting folder info', err)
|
||||||
'viyaMakedataSuccessfull: error getting folder info for',
|
|
||||||
configuratorFolder,
|
|
||||||
err
|
|
||||||
)
|
|
||||||
reject(err)
|
reject(err)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core'
|
|
||||||
import { BehaviorSubject } from 'rxjs'
|
|
||||||
|
|
||||||
export type StartupStepStatus = 'pending' | 'in_progress' | 'done' | 'error'
|
|
||||||
|
|
||||||
export interface StartupStep {
|
|
||||||
label: string
|
|
||||||
status: StartupStepStatus
|
|
||||||
detail?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class StartupCheckService {
|
|
||||||
private steps: StartupStep[] = [
|
|
||||||
{ label: 'Checking app location', status: 'pending' },
|
|
||||||
{ label: 'Checking Viya deploy', status: 'pending' },
|
|
||||||
{ label: 'Loading startup service', status: 'pending' }
|
|
||||||
]
|
|
||||||
|
|
||||||
public steps$ = new BehaviorSubject<StartupStep[]>([...this.steps])
|
|
||||||
|
|
||||||
public updateStep(index: number, status: StartupStepStatus, detail?: string) {
|
|
||||||
if (index >= 0 && index < this.steps.length) {
|
|
||||||
this.steps[index] = {
|
|
||||||
...this.steps[index],
|
|
||||||
status,
|
|
||||||
detail: detail ?? this.steps[index].detail
|
|
||||||
}
|
|
||||||
this.steps$.next([...this.steps])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public setSteps(steps: StartupStep[]) {
|
|
||||||
this.steps = steps
|
|
||||||
this.steps$.next([...this.steps])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
</p>
|
</p>
|
||||||
<p><strong>SYSERRORTEXT:</strong> {{ data.details.SYSERRORTEXT }}</p>
|
<p><strong>SYSERRORTEXT:</strong> {{ data.details.SYSERRORTEXT }}</p>
|
||||||
<p><strong>MAC:</strong> {{ data.details.MAC }}</p>
|
<p><strong>MAC:</strong> {{ data.details.MAC }}</p>
|
||||||
<p><strong>_PROGRAM:</strong> {{ data.details._PROGRAM }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import {
|
|
||||||
getRowKey,
|
|
||||||
markAutoEscaped,
|
|
||||||
isAutoEscaped,
|
|
||||||
clearAutoEscaped
|
|
||||||
} from './autoEscapedCellTracker'
|
|
||||||
|
|
||||||
describe('autoEscapedCellTracker', () => {
|
|
||||||
describe('getRowKey', () => {
|
|
||||||
it('derives the same key for two different row objects sharing the same PK values', () => {
|
|
||||||
const rowA = { PRIMARY_KEY_FIELD: 3, A_COL: 'x' }
|
|
||||||
const rowB = { PRIMARY_KEY_FIELD: 3, A_COL: 'y' }
|
|
||||||
|
|
||||||
expect(getRowKey(rowA, ['PRIMARY_KEY_FIELD'])).toEqual(
|
|
||||||
getRowKey(rowB, ['PRIMARY_KEY_FIELD'])
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('derives different keys for different PK values', () => {
|
|
||||||
const rowA = { PRIMARY_KEY_FIELD: 3 }
|
|
||||||
const rowB = { PRIMARY_KEY_FIELD: 4 }
|
|
||||||
|
|
||||||
expect(getRowKey(rowA, ['PRIMARY_KEY_FIELD'])).not.toEqual(
|
|
||||||
getRowKey(rowB, ['PRIMARY_KEY_FIELD'])
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('supports composite primary keys', () => {
|
|
||||||
const rowA = { LIBREF: 'DC1', DSN: 'A' }
|
|
||||||
const rowB = { LIBREF: 'DC1', DSN: 'B' }
|
|
||||||
|
|
||||||
expect(getRowKey(rowA, ['LIBREF', 'DSN'])).not.toEqual(
|
|
||||||
getRowKey(rowB, ['LIBREF', 'DSN'])
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('markAutoEscaped / isAutoEscaped / clearAutoEscaped', () => {
|
|
||||||
it('is not auto-escaped before being marked', () => {
|
|
||||||
const map = new Map<string, Set<string>>()
|
|
||||||
expect(isAutoEscaped(map, 'row-1', 'PLAIN_TEXT_COL')).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('is auto-escaped after being marked', () => {
|
|
||||||
const map = new Map<string, Set<string>>()
|
|
||||||
markAutoEscaped(map, 'row-1', 'PLAIN_TEXT_COL')
|
|
||||||
expect(isAutoEscaped(map, 'row-1', 'PLAIN_TEXT_COL')).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('is not auto-escaped after being cleared', () => {
|
|
||||||
const map = new Map<string, Set<string>>()
|
|
||||||
markAutoEscaped(map, 'row-1', 'PLAIN_TEXT_COL')
|
|
||||||
clearAutoEscaped(map, 'row-1', 'PLAIN_TEXT_COL')
|
|
||||||
expect(isAutoEscaped(map, 'row-1', 'PLAIN_TEXT_COL')).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clearing an unmarked cell is a no-op, not a throw', () => {
|
|
||||||
const map = new Map<string, Set<string>>()
|
|
||||||
expect(() =>
|
|
||||||
clearAutoEscaped(map, 'row-1', 'PLAIN_TEXT_COL')
|
|
||||||
).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('tracks two columns on the same row independently', () => {
|
|
||||||
const map = new Map<string, Set<string>>()
|
|
||||||
markAutoEscaped(map, 'row-1', 'PLAIN_TEXT_COL')
|
|
||||||
markAutoEscaped(map, 'row-1', 'OTHER_COL')
|
|
||||||
|
|
||||||
clearAutoEscaped(map, 'row-1', 'PLAIN_TEXT_COL')
|
|
||||||
|
|
||||||
expect(isAutoEscaped(map, 'row-1', 'PLAIN_TEXT_COL')).toEqual(false)
|
|
||||||
expect(isAutoEscaped(map, 'row-1', 'OTHER_COL')).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('tracks the same column on two different rows independently', () => {
|
|
||||||
const map = new Map<string, Set<string>>()
|
|
||||||
markAutoEscaped(map, 'row-1', 'PLAIN_TEXT_COL')
|
|
||||||
|
|
||||||
expect(isAutoEscaped(map, 'row-1', 'PLAIN_TEXT_COL')).toEqual(true)
|
|
||||||
expect(isAutoEscaped(map, 'row-2', 'PLAIN_TEXT_COL')).toEqual(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tracks which cells got a `'` the app itself inserted (see
|
|
||||||
* escapeCharacterColumnValue), so submission and the "Apply as formula"
|
|
||||||
* context-menu action only ever touch cells the app marked - never a
|
|
||||||
* genuine leading `'` that was already part of the data. Kept as a
|
|
||||||
* standalone Map (not a property on the row object) so it can never leak
|
|
||||||
* into the submit payload, and keyed by primary key rather than row index
|
|
||||||
* so it survives insert/delete/sort.
|
|
||||||
*/
|
|
||||||
export type AutoEscapedCellMap = Map<string, Set<string>>
|
|
||||||
|
|
||||||
export const getRowKey = (row: any, headerPks: string[]): string =>
|
|
||||||
headerPks.map((pk) => String(row[pk])).join(' ')
|
|
||||||
|
|
||||||
export const markAutoEscaped = (
|
|
||||||
map: AutoEscapedCellMap,
|
|
||||||
rowKey: string,
|
|
||||||
colName: string
|
|
||||||
): void => {
|
|
||||||
if (!map.has(rowKey)) map.set(rowKey, new Set())
|
|
||||||
map.get(rowKey)!.add(colName)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isAutoEscaped = (
|
|
||||||
map: AutoEscapedCellMap,
|
|
||||||
rowKey: string,
|
|
||||||
colName: string
|
|
||||||
): boolean => map.get(rowKey)?.has(colName) ?? false
|
|
||||||
|
|
||||||
export const clearAutoEscaped = (
|
|
||||||
map: AutoEscapedCellMap,
|
|
||||||
rowKey: string,
|
|
||||||
colName: string
|
|
||||||
): void => {
|
|
||||||
map.get(rowKey)?.delete(colName)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { escapeCharacterColumnValue } from './escapeCharacterColumnValue'
|
|
||||||
|
|
||||||
describe('escapeCharacterColumnValue', () => {
|
|
||||||
it('escapes a =-led value', () => {
|
|
||||||
expect(escapeCharacterColumnValue('=SUM(1)')).toEqual({
|
|
||||||
value: "'=SUM(1)",
|
|
||||||
wasEscaped: true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('escapes a =-led value referencing a column name', () => {
|
|
||||||
expect(escapeCharacterColumnValue('=100 + B_COL')).toEqual({
|
|
||||||
value: "'=100 + B_COL",
|
|
||||||
wasEscaped: true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves an already-escaped value unchanged (does not start with =)', () => {
|
|
||||||
expect(escapeCharacterColumnValue("'=already")).toEqual({
|
|
||||||
value: "'=already",
|
|
||||||
wasEscaped: false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves a plain string unchanged', () => {
|
|
||||||
expect(escapeCharacterColumnValue('note-1')).toEqual({
|
|
||||||
value: 'note-1',
|
|
||||||
wasEscaped: false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves a non-string value unchanged', () => {
|
|
||||||
expect(escapeCharacterColumnValue(123)).toEqual({
|
|
||||||
value: 123,
|
|
||||||
wasEscaped: false
|
|
||||||
})
|
|
||||||
expect(escapeCharacterColumnValue(null)).toEqual({
|
|
||||||
value: null,
|
|
||||||
wasEscaped: false
|
|
||||||
})
|
|
||||||
expect(escapeCharacterColumnValue(undefined)).toEqual({
|
|
||||||
value: undefined,
|
|
||||||
wasEscaped: false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* Prepends `'` to a `=`-led value so Handsontable/HyperFormula treats it as
|
|
||||||
* inert text rather than a live formula - Handsontable's own escape
|
|
||||||
* convention, which it strips automatically when displaying the value.
|
|
||||||
* Caller decides eligibility (character column? not a formula base column?)
|
|
||||||
* before calling this - it only ever looks at the value itself.
|
|
||||||
*/
|
|
||||||
export const escapeCharacterColumnValue = (
|
|
||||||
value: unknown
|
|
||||||
): { value: unknown; wasEscaped: boolean } => {
|
|
||||||
if (typeof value !== 'string' || !value.startsWith('=')) {
|
|
||||||
return { value, wasEscaped: false }
|
|
||||||
}
|
|
||||||
return { value: `'${value}`, wasEscaped: true }
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { getFormulaColumnNames } from './getFormulaColumnNames'
|
|
||||||
import { DQRule } from '../models/dq-rules.model'
|
|
||||||
|
|
||||||
const rule = (overrides: Partial<DQRule> = {}): DQRule => ({
|
|
||||||
BASE_COL: 'SOME_COL',
|
|
||||||
RULE_TYPE: 'NOTNULL',
|
|
||||||
RULE_VALUE: '',
|
|
||||||
X: 0,
|
|
||||||
...overrides
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getFormulaColumnNames', () => {
|
|
||||||
it('returns the BASE_COL of every HARDFORMULA rule', () => {
|
|
||||||
expect(
|
|
||||||
getFormulaColumnNames([
|
|
||||||
rule({ BASE_COL: 'FORMULA_HARD_COL', RULE_TYPE: 'HARDFORMULA' })
|
|
||||||
])
|
|
||||||
).toEqual(['FORMULA_HARD_COL'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns the BASE_COL of every SOFTFORMULA rule', () => {
|
|
||||||
expect(
|
|
||||||
getFormulaColumnNames([
|
|
||||||
rule({ BASE_COL: 'FORMULA_SOFT_COL', RULE_TYPE: 'SOFTFORMULA' })
|
|
||||||
])
|
|
||||||
).toEqual(['FORMULA_SOFT_COL'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('excludes non-formula rule types', () => {
|
|
||||||
expect(
|
|
||||||
getFormulaColumnNames([
|
|
||||||
rule({ BASE_COL: 'PRIMARY_KEY_FIELD', RULE_TYPE: 'NOTNULL' }),
|
|
||||||
rule({ BASE_COL: 'READONLY_COL', RULE_TYPE: 'READONLY' })
|
|
||||||
])
|
|
||||||
).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns [] for an empty rule set', () => {
|
|
||||||
expect(getFormulaColumnNames([])).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('mixes formula and non-formula rules correctly', () => {
|
|
||||||
expect(
|
|
||||||
getFormulaColumnNames([
|
|
||||||
rule({ BASE_COL: 'PRIMARY_KEY_FIELD', RULE_TYPE: 'NOTNULL' }),
|
|
||||||
rule({ BASE_COL: 'FORMULA_HARD_COL', RULE_TYPE: 'HARDFORMULA' }),
|
|
||||||
rule({ BASE_COL: 'FORMULA_SOFT_COL', RULE_TYPE: 'SOFTFORMULA' })
|
|
||||||
])
|
|
||||||
).toEqual(['FORMULA_HARD_COL', 'FORMULA_SOFT_COL'])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { DQRule } from '../models/dq-rules.model'
|
|
||||||
|
|
||||||
export const getFormulaColumnNames = (dqRules: DQRule[]): string[] =>
|
|
||||||
dqRules
|
|
||||||
.filter(
|
|
||||||
(rule) =>
|
|
||||||
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
|
|
||||||
)
|
|
||||||
.map((rule) => rule.BASE_COL)
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { isCharacterColumn } from './isCharacterColumn'
|
|
||||||
import { Col } from '../models/col.model'
|
|
||||||
|
|
||||||
const col = (NAME: string, DDTYPE: string): Col => ({ NAME, DDTYPE }) as Col
|
|
||||||
|
|
||||||
describe('isCharacterColumn', () => {
|
|
||||||
it('returns true for a column whose DDTYPE is C', () => {
|
|
||||||
const cols = [col('PLAIN_TEXT_COL', 'C')]
|
|
||||||
expect(isCharacterColumn(cols, 'PLAIN_TEXT_COL')).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns false for a column whose DDTYPE is N', () => {
|
|
||||||
const cols = [col('A_COL', 'N')]
|
|
||||||
expect(isCharacterColumn(cols, 'A_COL')).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns false for a column not present in cols at all', () => {
|
|
||||||
const cols = [col('A_COL', 'N')]
|
|
||||||
expect(isCharacterColumn(cols, 'EDIT_STATUS')).toEqual(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Col } from '../models/col.model'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DDTYPE is the backend's own char/numeric classification per column ('C'
|
|
||||||
* or 'N') - distinct from Col.TYPE, which mergeColsRules derives separately
|
|
||||||
* from $sasdata.vars (values 'char'/'num', not 'C'/'N').
|
|
||||||
*/
|
|
||||||
export const isCharacterColumn = (cols: Col[], colName: string): boolean =>
|
|
||||||
cols.find((col) => col.NAME === colName)?.DDTYPE === 'C'
|
|
||||||
@@ -56,21 +56,6 @@ describe('parseFormulaRule', () => {
|
|||||||
).toEqual('=\"sasinstaller\"')
|
).toEqual('=\"sasinstaller\"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('escapes embedded double quotes in DC.USER_NAME so the resulting literal stays valid', () => {
|
|
||||||
expect(
|
|
||||||
parseFormulaRule('=DC.USER_NAME', context({ userName: 'john"doe' }))
|
|
||||||
).toEqual('="john""doe"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('escapes embedded double quotes in DC.ORIG_VALUE so the resulting literal stays valid', () => {
|
|
||||||
expect(
|
|
||||||
parseFormulaRule(
|
|
||||||
'=DC.ORIG_VALUE',
|
|
||||||
context({ origValue: 'a "quoted" value' })
|
|
||||||
)
|
|
||||||
).toEqual('="a ""quoted"" value"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not substitute DC.USER_NAME/DC.ORIG_VALUE inside quoted strings either', () => {
|
it('does not substitute DC.USER_NAME/DC.ORIG_VALUE inside quoted strings either', () => {
|
||||||
expect(parseFormulaRule('="DC.USER_NAME"', context())).toEqual(
|
expect(parseFormulaRule('="DC.USER_NAME"', context())).toEqual(
|
||||||
'=\"DC.USER_NAME\"'
|
'=\"DC.USER_NAME\"'
|
||||||
@@ -116,33 +101,4 @@ describe('parseFormulaRule', () => {
|
|||||||
it('does not insert a leading = when the rule value does not have one, so HOT treats it as plain text rather than a formula', () => {
|
it('does not insert a leading = when the rule value does not have one, so HOT treats it as plain text rather than a formula', () => {
|
||||||
expect(parseFormulaRule('PRICE * VOLUME', context())).toEqual('B1 * C1')
|
expect(parseFormulaRule('PRICE * VOLUME', context())).toEqual('B1 * C1')
|
||||||
})
|
})
|
||||||
|
|
||||||
// String.prototype.replace interprets $&/$`/$'/$<digit> specially when
|
|
||||||
// the replacement is a string - DC.ORIG_VALUE/DC.USER_NAME substitute in
|
|
||||||
// arbitrary data (any character column's prior value, or a username),
|
|
||||||
// which can legitimately contain a literal '$'. These must round-trip
|
|
||||||
// untouched, not have HyperFormula's own pattern-matching corrupt them.
|
|
||||||
it('substitutes a DC.ORIG_VALUE containing a literal $& without corruption', () => {
|
|
||||||
expect(
|
|
||||||
parseFormulaRule('=DC.ORIG_VALUE', context({ origValue: 'a$&b' }))
|
|
||||||
).toEqual('="a$&b"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("substitutes a DC.ORIG_VALUE containing a literal $' without corruption", () => {
|
|
||||||
expect(
|
|
||||||
parseFormulaRule('=DC.ORIG_VALUE', context({ origValue: "a$'b" }))
|
|
||||||
).toEqual('="a$\'b"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('substitutes a DC.ORIG_VALUE containing a literal $` without corruption', () => {
|
|
||||||
expect(
|
|
||||||
parseFormulaRule('=DC.ORIG_VALUE', context({ origValue: 'a$`b' }))
|
|
||||||
).toEqual('="a$`b"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('substitutes a DC.USER_NAME containing a literal $1 without corruption', () => {
|
|
||||||
expect(
|
|
||||||
parseFormulaRule('=DC.USER_NAME', context({ userName: 'a$1b' }))
|
|
||||||
).toEqual('="a$1b"')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface FormulaVariableContext {
|
|||||||
origValue: string | number | undefined
|
origValue: string | number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export const escapeRegExpMetacharacters = (text: string): string =>
|
const escapeRegExpMetacharacters = (text: string): string =>
|
||||||
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +22,7 @@ export const escapeRegExpMetacharacters = (text: string): string =>
|
|||||||
* surrounding blanks) does not, without needing to know anything about
|
* surrounding blanks) does not, without needing to know anything about
|
||||||
* function names.
|
* function names.
|
||||||
*/
|
*/
|
||||||
export const substituteBoundedToken = (
|
const substituteBoundedToken = (
|
||||||
text: string,
|
text: string,
|
||||||
token: string,
|
token: string,
|
||||||
replacement: string
|
replacement: string
|
||||||
@@ -32,15 +32,11 @@ export const substituteBoundedToken = (
|
|||||||
'g'
|
'g'
|
||||||
)
|
)
|
||||||
|
|
||||||
// A function replacement's return value is used verbatim - a string
|
return text.replace(pattern, replacement)
|
||||||
// replacement would otherwise have String.replace interpret $&/$`/$'/
|
|
||||||
// $<digit> specially, and `replacement` here can be arbitrary data (a
|
|
||||||
// cell's prior value, a username) that may legitimately contain '$'.
|
|
||||||
return text.replace(pattern, () => replacement)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const quoteLiteral = (value: string | number | undefined): string =>
|
const quoteLiteral = (value: string | number | undefined): string =>
|
||||||
`"${String(value ?? '').replace(/"/g, '""')}"`
|
`"${value ?? ''}"`
|
||||||
|
|
||||||
export const parseFormulaRule = (
|
export const parseFormulaRule = (
|
||||||
ruleValue: string,
|
ruleValue: string,
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { resolveRevertedCellValue } from './resolveRevertedCellValue'
|
|
||||||
|
|
||||||
describe('resolveRevertedCellValue', () => {
|
|
||||||
it('returns the raw text unchanged for a non-numeric column', () => {
|
|
||||||
expect(resolveRevertedCellValue('sasdemo', false)).toEqual('sasdemo')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('converts a valid numeric string to a number for a numeric column', () => {
|
|
||||||
expect(resolveRevertedCellValue('42.5', true)).toEqual(42.5)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("falls back to the raw text instead of NaN when a numeric column's stored value is not a valid number", () => {
|
|
||||||
expect(resolveRevertedCellValue('.S', true)).toEqual('.S')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// A numeric column's original value is stored as plain text in a
|
|
||||||
// Handsontable comment - if it isn't a valid number (e.g. a SAS special
|
|
||||||
// missing like ".S"), Number() returns NaN, which would silently corrupt
|
|
||||||
// the revert instead of restoring the original value. Falling back to the
|
|
||||||
// raw text keeps the revert visibly correct (the original text) rather
|
|
||||||
// than writing NaN into the cell.
|
|
||||||
export const resolveRevertedCellValue = (
|
|
||||||
rawValueText: string,
|
|
||||||
isNumericCol: boolean
|
|
||||||
): string | number => {
|
|
||||||
if (!isNumericCol) return rawValueText
|
|
||||||
|
|
||||||
const numericValue = Number(rawValueText)
|
|
||||||
|
|
||||||
return Number.isNaN(numericValue) ? rawValueText : numericValue
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { substituteColumnReferences } from './substituteColumnReferences'
|
|
||||||
|
|
||||||
describe('substituteColumnReferences', () => {
|
|
||||||
const columnNames = ['ITEM', 'PRICE', 'VOLUME', 'REVENUE']
|
|
||||||
|
|
||||||
it("substitutes column-name variables with this row's cell references (the issue's own example)", () => {
|
|
||||||
expect(
|
|
||||||
substituteColumnReferences(
|
|
||||||
'=SOME_NUM * SOME_BESTNUM',
|
|
||||||
['ID', 'SOME_NUM', 'SOME_BESTNUM'],
|
|
||||||
3
|
|
||||||
)
|
|
||||||
).toEqual('=B4 * C4')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses the row-relative reference for a later row', () => {
|
|
||||||
expect(
|
|
||||||
substituteColumnReferences('=PRICE * VOLUME', columnNames, 1)
|
|
||||||
).toEqual('=B2 * C2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('requires a leading/trailing blank (or string edge) around a variable - no match without it', () => {
|
|
||||||
expect(substituteColumnReferences('=PRICE*VOLUME', columnNames, 0)).toEqual(
|
|
||||||
'=PRICE*VOLUME'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not substitute a variable name matched inside a function call with no surrounding blanks', () => {
|
|
||||||
expect(substituteColumnReferences('=MATCH(PRICE)', columnNames, 0)).toEqual(
|
|
||||||
'=MATCH(PRICE)'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves variable occurrences inside quoted strings untouched', () => {
|
|
||||||
expect(
|
|
||||||
substituteColumnReferences('=ITEM & " string ITEM "', columnNames, 0)
|
|
||||||
).toEqual('=A1 & " string ITEM "')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not insert a leading = when the input does not have one', () => {
|
|
||||||
expect(
|
|
||||||
substituteColumnReferences('PRICE * VOLUME', columnNames, 0)
|
|
||||||
).toEqual('B1 * C1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not perform any DC.* variable substitution - only column names', () => {
|
|
||||||
expect(substituteColumnReferences('=DC.USER_NAME', columnNames, 0)).toEqual(
|
|
||||||
'=DC.USER_NAME'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves a plain (non-formula) pasted value pass through unchanged aside from column tokens it happens to contain', () => {
|
|
||||||
expect(
|
|
||||||
substituteColumnReferences('just a note about PRICE', columnNames, 0)
|
|
||||||
).toEqual('just a note about B1')
|
|
||||||
})
|
|
||||||
|
|
||||||
// This function's own replacement values are always system-generated
|
|
||||||
// cell refs (e.g. "B1") that can never contain '$', so it can't trigger
|
|
||||||
// substituteBoundedToken's $-pattern corruption itself - but it shares
|
|
||||||
// that function with parseFormulaRule.ts (which can), so confirm a
|
|
||||||
// literal '$' elsewhere in the input still round-trips correctly
|
|
||||||
// through the shared (now function-replacement-based) substitution.
|
|
||||||
it('leaves a literal $ in the surrounding text untouched by an unrelated column substitution', () => {
|
|
||||||
expect(
|
|
||||||
substituteColumnReferences('PRICE + $5 fee', columnNames, 0)
|
|
||||||
).toEqual('B1 + $5 fee')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves a literal $ inside a quoted span untouched', () => {
|
|
||||||
expect(
|
|
||||||
substituteColumnReferences('PRICE & " costs $5 "', columnNames, 0)
|
|
||||||
).toEqual('B1 & " costs $5 "')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import Handsontable from 'handsontable'
|
|
||||||
import { substituteBoundedToken } from './parseFormulaRule'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Translates column names in a formula-like string into this row's cell
|
|
||||||
* references, e.g. "=PRICE * VOLUME" -> "=B4 * C4" for row index 3.
|
|
||||||
* Column-name substitution only - no DC.* variable support (that's
|
|
||||||
* specific to admin-defined rule values re-evaluated per row - see
|
|
||||||
* parseFormulaRule.ts, which this deliberately does not delegate to, to
|
|
||||||
* keep this scoped to exactly column names).
|
|
||||||
*/
|
|
||||||
export const substituteColumnReferences = (
|
|
||||||
formulaText: string,
|
|
||||||
columnNames: string[],
|
|
||||||
rowIndex: number
|
|
||||||
): string => {
|
|
||||||
const hasLeadingEquals = formulaText.startsWith('=')
|
|
||||||
const formulaBody = hasLeadingEquals ? formulaText.slice(1) : formulaText
|
|
||||||
|
|
||||||
const quotedSpanPattern = /"[^"]*"|'[^']*'/g
|
|
||||||
let result = ''
|
|
||||||
let lastIndex = 0
|
|
||||||
let match: RegExpExecArray | null
|
|
||||||
|
|
||||||
const substituteUnquotedSpan = (span: string): string => {
|
|
||||||
let substituted = span
|
|
||||||
columnNames.forEach((columnName, columnIndex) => {
|
|
||||||
const cellRef = `${Handsontable.helper.spreadsheetColumnLabel(columnIndex)}${rowIndex + 1}`
|
|
||||||
substituted = substituteBoundedToken(substituted, columnName, cellRef)
|
|
||||||
})
|
|
||||||
return substituted
|
|
||||||
}
|
|
||||||
|
|
||||||
while ((match = quotedSpanPattern.exec(formulaBody))) {
|
|
||||||
result += substituteUnquotedSpan(formulaBody.slice(lastIndex, match.index))
|
|
||||||
result += match[0]
|
|
||||||
lastIndex = match.index + match[0].length
|
|
||||||
}
|
|
||||||
result += substituteUnquotedSpan(formulaBody.slice(lastIndex))
|
|
||||||
|
|
||||||
return hasLeadingEquals ? `=${result}` : result
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { unescapeFormula } from './unescapeFormula'
|
|
||||||
|
|
||||||
describe('unescapeFormula', () => {
|
|
||||||
it('strips the leading escape marker from an escaped value', () => {
|
|
||||||
expect(unescapeFormula("'=100 * 100")).toEqual('=100 * 100')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves a value that merely starts with a leading quote (not followed by =) unchanged', () => {
|
|
||||||
expect(unescapeFormula("'hello")).toEqual("'hello")
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves a plain value with no leading quote unchanged', () => {
|
|
||||||
expect(unescapeFormula('note-1')).toEqual('note-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves a non-string value unchanged', () => {
|
|
||||||
expect(unescapeFormula(42)).toEqual(42)
|
|
||||||
expect(unescapeFormula(null)).toEqual(null)
|
|
||||||
expect(unescapeFormula(true)).toEqual(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Inverse of escapeCharacterColumnValue - strips the leading `'` this app
|
|
||||||
* itself added to keep a backend `=`-led value out of HyperFormula's
|
|
||||||
* evaluation. Only ever called for a cell autoEscapedCellTracker confirms
|
|
||||||
* the app itself marked - see character-column-formula-plan.md.
|
|
||||||
*/
|
|
||||||
export const unescapeFormula = (value: unknown): unknown => {
|
|
||||||
if (typeof value !== 'string') return value
|
|
||||||
if (value.charAt(0) !== "'" || value.charAt(1) !== '=') return value
|
|
||||||
|
|
||||||
return value.slice(1)
|
|
||||||
}
|
|
||||||
@@ -10,9 +10,6 @@ export interface EnvironmentInfo {
|
|||||||
SYSHOSTINFOLONG?: string
|
SYSHOSTINFOLONG?: string
|
||||||
SYSENCODING?: string
|
SYSENCODING?: string
|
||||||
AUTOEXEC?: string
|
AUTOEXEC?: string
|
||||||
TIMEZONE?: string
|
|
||||||
SYSTIMEZONEIDENT?: string
|
|
||||||
SYSTIMEZONEOFFSET?: string
|
|
||||||
ISADMIN?: number
|
ISADMIN?: number
|
||||||
DC_ADMIN_GROUP?: string
|
DC_ADMIN_GROUP?: string
|
||||||
APP_LOC?: string
|
APP_LOC?: string
|
||||||
|
|||||||
@@ -51,24 +51,6 @@
|
|||||||
<p cds-text="label" class="m-0">
|
<p cds-text="label" class="m-0">
|
||||||
AUTOEXEC: <span class="dark">{{ environmentInfo?.AUTOEXEC }}</span>
|
AUTOEXEC: <span class="dark">{{ environmentInfo?.AUTOEXEC }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p cds-text="label" class="m-0">
|
|
||||||
TIMEZONE:
|
|
||||||
<span class="dark">{{
|
|
||||||
environmentInfo?.TIMEZONE || '(not set)'
|
|
||||||
}}</span>
|
|
||||||
</p>
|
|
||||||
<p cds-text="label" class="m-0">
|
|
||||||
SYSTIMEZONEIDENT:
|
|
||||||
<span class="dark">{{
|
|
||||||
environmentInfo?.SYSTIMEZONEIDENT || '(not set)'
|
|
||||||
}}</span>
|
|
||||||
</p>
|
|
||||||
<p cds-text="label" class="m-0">
|
|
||||||
SYSTIMEZONEOFFSET:
|
|
||||||
<span class="dark">{{
|
|
||||||
environmentInfo?.SYSTIMEZONEOFFSET || '0'
|
|
||||||
}}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex clr-justify-content-lg-center">
|
<div class="d-flex clr-justify-content-lg-center">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export interface ComputeContextDetails {
|
export interface ComputeContextDetails {
|
||||||
attributes?: Attributes
|
attributes?: Attributes
|
||||||
environment?: Environment
|
|
||||||
createdBy: string
|
createdBy: string
|
||||||
creationTimeStamp: string
|
creationTimeStamp: string
|
||||||
description: string
|
description: string
|
||||||
@@ -28,14 +27,8 @@ export interface LaunchContext {
|
|||||||
contextId: string
|
contextId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Environment {
|
|
||||||
autoExecLines: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Attributes {
|
export interface Attributes {
|
||||||
allowXCMD?: string
|
reuseServerProcesses: string
|
||||||
reuseServerProcesses?: string
|
runServerAs: string
|
||||||
runServerAs?: string
|
serverMinAvailable: string
|
||||||
serverMinAvailable?: string
|
|
||||||
sessionInactiveTimeout?: number
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,8 +396,7 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
|||||||
this.eventService.showAbortModal('', abortMsg, {
|
this.eventService.showAbortModal('', abortMsg, {
|
||||||
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
|
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
|
||||||
SYSERRORTEXT: abortRes.SYSERRORTEXT,
|
SYSERRORTEXT: abortRes.SYSERRORTEXT,
|
||||||
MAC: macMsg,
|
MAC: macMsg
|
||||||
_PROGRAM: abortRes._PROGRAM
|
|
||||||
})
|
})
|
||||||
} else if (res.adapterResponse.sasparams) {
|
} else if (res.adapterResponse.sasparams) {
|
||||||
const params = res.adapterResponse.sasparams[0]
|
const params = res.adapterResponse.sasparams[0]
|
||||||
|
|||||||
+3
-70
@@ -2932,24 +2932,6 @@ body[cds-theme='light'] {
|
|||||||
|
|
||||||
// AUTOMATIC-DEPLOY.COMPONENT
|
// AUTOMATIC-DEPLOY.COMPONENT
|
||||||
app-automatic-deploy {
|
app-automatic-deploy {
|
||||||
.deploy-intro {
|
|
||||||
margin: 0 0 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deploy-field {
|
|
||||||
margin-top: 25px;
|
|
||||||
|
|
||||||
.clr-control-label {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deploy-field-description {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dc-loc-input-wrapper {
|
.dc-loc-input-wrapper {
|
||||||
input {
|
input {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
@@ -4190,10 +4172,10 @@ body[cds-theme='light'] {
|
|||||||
|
|
||||||
// Custom loading spinner
|
// Custom loading spinner
|
||||||
.slider {
|
.slider {
|
||||||
position: relative;
|
position: absolute;
|
||||||
width: 320px;
|
width: 320px;
|
||||||
margin-left: 0;
|
margin-left: 75px;
|
||||||
margin-top: 20px;
|
margin-top: 70px;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
@@ -4260,55 +4242,6 @@ body[cds-theme='light'] {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.startup-checks {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 24px;
|
|
||||||
max-width: 420px;
|
|
||||||
width: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.startup-check {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
|
|
||||||
&--done {
|
|
||||||
color: #3c8500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--error {
|
|
||||||
color: #c21d00;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon--pending {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon--done {
|
|
||||||
color: #3c8500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon--error {
|
|
||||||
color: #c21d00;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__detail {
|
|
||||||
color: #999;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-left: auto;
|
|
||||||
text-align: right;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-none {
|
.select-none {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+70
-70
@@ -1075,9 +1075,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1721,16 +1721,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz",
|
||||||
"integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==",
|
"integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.4",
|
"hasown": "^2.0.2",
|
||||||
"mime-types": "^2.1.35"
|
"mime-types": "^2.1.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1992,9 +1992,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2385,9 +2385,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm": {
|
"node_modules/npm": {
|
||||||
"version": "11.19.0",
|
"version": "11.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/npm/-/npm-11.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/npm/-/npm-11.13.0.tgz",
|
||||||
"integrity": "sha512-SDd/hHg3KqHE5Ht2NHWxNYNtqCQ2pXAPLl6OtQhPyED5PHsRfrOtO199MZTIG2cQoQ1ZRI9t28shrD+2cr3AAw==",
|
"integrity": "sha512-cRmhaghDWA1lFgl3Ug4/VxDJdPBK/U+tNtnrl9kXunFqhWw1x4xL5txkNn7qzPuVfvXOmXyjHpMwsuk2uisbkg==",
|
||||||
"bundleDependencies": [
|
"bundleDependencies": [
|
||||||
"@isaacs/string-locale-compare",
|
"@isaacs/string-locale-compare",
|
||||||
"@npmcli/arborist",
|
"@npmcli/arborist",
|
||||||
@@ -2466,8 +2466,8 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@isaacs/string-locale-compare": "^1.1.0",
|
"@isaacs/string-locale-compare": "^1.1.0",
|
||||||
"@npmcli/arborist": "^9.9.1",
|
"@npmcli/arborist": "^9.4.3",
|
||||||
"@npmcli/config": "^10.12.0",
|
"@npmcli/config": "^10.8.1",
|
||||||
"@npmcli/fs": "^5.0.0",
|
"@npmcli/fs": "^5.0.0",
|
||||||
"@npmcli/map-workspaces": "^5.0.3",
|
"@npmcli/map-workspaces": "^5.0.3",
|
||||||
"@npmcli/metavuln-calculator": "^9.0.3",
|
"@npmcli/metavuln-calculator": "^9.0.3",
|
||||||
@@ -2485,46 +2485,46 @@
|
|||||||
"fs-minipass": "^3.0.3",
|
"fs-minipass": "^3.0.3",
|
||||||
"glob": "^13.0.6",
|
"glob": "^13.0.6",
|
||||||
"graceful-fs": "^4.2.11",
|
"graceful-fs": "^4.2.11",
|
||||||
"hosted-git-info": "^9.0.3",
|
"hosted-git-info": "^9.0.2",
|
||||||
"ini": "^6.0.0",
|
"ini": "^6.0.0",
|
||||||
"init-package-json": "^8.2.5",
|
"init-package-json": "^8.2.5",
|
||||||
"is-cidr": "^6.0.4",
|
"is-cidr": "^6.0.4",
|
||||||
"json-parse-even-better-errors": "^5.0.0",
|
"json-parse-even-better-errors": "^5.0.0",
|
||||||
"libnpmaccess": "^10.0.3",
|
"libnpmaccess": "^10.0.3",
|
||||||
"libnpmdiff": "^8.1.12",
|
"libnpmdiff": "^8.1.6",
|
||||||
"libnpmexec": "^10.3.2",
|
"libnpmexec": "^10.2.6",
|
||||||
"libnpmfund": "^7.0.26",
|
"libnpmfund": "^7.0.20",
|
||||||
"libnpmorg": "^8.0.1",
|
"libnpmorg": "^8.0.1",
|
||||||
"libnpmpack": "^9.1.12",
|
"libnpmpack": "^9.1.6",
|
||||||
"libnpmpublish": "^11.2.0",
|
"libnpmpublish": "^11.1.3",
|
||||||
"libnpmsearch": "^9.0.1",
|
"libnpmsearch": "^9.0.1",
|
||||||
"libnpmteam": "^8.0.2",
|
"libnpmteam": "^8.0.2",
|
||||||
"libnpmversion": "^8.0.4",
|
"libnpmversion": "^8.0.3",
|
||||||
"make-fetch-happen": "^15.0.6",
|
"make-fetch-happen": "^15.0.5",
|
||||||
"minimatch": "^10.2.5",
|
"minimatch": "^10.2.5",
|
||||||
"minipass": "^7.1.3",
|
"minipass": "^7.1.3",
|
||||||
"minipass-pipeline": "^1.2.4",
|
"minipass-pipeline": "^1.2.4",
|
||||||
"ms": "^2.1.2",
|
"ms": "^2.1.2",
|
||||||
"node-gyp": "^12.4.0",
|
"node-gyp": "^12.3.0",
|
||||||
"nopt": "^9.0.0",
|
"nopt": "^9.0.0",
|
||||||
"npm-audit-report": "^7.0.0",
|
"npm-audit-report": "^7.0.0",
|
||||||
"npm-install-checks": "^8.0.0",
|
"npm-install-checks": "^8.0.0",
|
||||||
"npm-package-arg": "^13.0.2",
|
"npm-package-arg": "^13.0.2",
|
||||||
"npm-pick-manifest": "^11.0.3",
|
"npm-pick-manifest": "^11.0.3",
|
||||||
"npm-profile": "^12.0.2",
|
"npm-profile": "^12.0.1",
|
||||||
"npm-registry-fetch": "^19.1.1",
|
"npm-registry-fetch": "^19.1.1",
|
||||||
"npm-user-validate": "^4.0.0",
|
"npm-user-validate": "^4.0.0",
|
||||||
"p-map": "^7.0.4",
|
"p-map": "^7.0.4",
|
||||||
"pacote": "^21.5.1",
|
"pacote": "^21.5.0",
|
||||||
"parse-conflict-json": "^5.0.1",
|
"parse-conflict-json": "^5.0.1",
|
||||||
"proc-log": "^6.1.0",
|
"proc-log": "^6.1.0",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"read": "^5.0.1",
|
"read": "^5.0.1",
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.7.4",
|
||||||
"spdx-expression-parse": "^4.0.0",
|
"spdx-expression-parse": "^4.0.0",
|
||||||
"ssri": "^13.0.1",
|
"ssri": "^13.0.1",
|
||||||
"supports-color": "^10.2.2",
|
"supports-color": "^10.2.2",
|
||||||
"tar": "^7.5.19",
|
"tar": "^7.5.13",
|
||||||
"text-table": "~0.2.0",
|
"text-table": "~0.2.0",
|
||||||
"tiny-relative-date": "^2.0.2",
|
"tiny-relative-date": "^2.0.2",
|
||||||
"treeverse": "^3.0.0",
|
"treeverse": "^3.0.0",
|
||||||
@@ -2580,7 +2580,7 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/@npmcli/agent": {
|
"node_modules/npm/node_modules/@npmcli/agent": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -2596,7 +2596,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/@npmcli/arborist": {
|
"node_modules/npm/node_modules/@npmcli/arborist": {
|
||||||
"version": "9.9.1",
|
"version": "9.4.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -2644,7 +2644,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/@npmcli/config": {
|
"node_modules/npm/node_modules/@npmcli/config": {
|
||||||
"version": "10.12.0",
|
"version": "10.8.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -2838,7 +2838,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/@sigstore/core": {
|
"node_modules/npm/node_modules/@sigstore/core": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -2886,13 +2886,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/@sigstore/verify": {
|
"node_modules/npm/node_modules/@sigstore/verify": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sigstore/bundle": "^4.0.0",
|
"@sigstore/bundle": "^4.0.0",
|
||||||
"@sigstore/core": "^3.2.1",
|
"@sigstore/core": "^3.1.0",
|
||||||
"@sigstore/protobuf-specs": "^0.5.0"
|
"@sigstore/protobuf-specs": "^0.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2961,7 +2961,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/bin-links": {
|
"node_modules/npm/node_modules/bin-links": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -2989,7 +2989,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/brace-expansion": {
|
"node_modules/npm/node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3058,7 +3058,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/cidr-regex": {
|
"node_modules/npm/node_modules/cidr-regex": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
@@ -3182,7 +3182,7 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/hosted-git-info": {
|
"node_modules/npm/node_modules/hosted-git-info": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -3281,7 +3281,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/ip-address": {
|
"node_modules/npm/node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3363,12 +3363,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/libnpmdiff": {
|
"node_modules/npm/node_modules/libnpmdiff": {
|
||||||
"version": "8.1.12",
|
"version": "8.1.6",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@npmcli/arborist": "^9.9.1",
|
"@npmcli/arborist": "^9.4.3",
|
||||||
"@npmcli/installed-package-contents": "^4.0.0",
|
"@npmcli/installed-package-contents": "^4.0.0",
|
||||||
"binary-extensions": "^3.0.0",
|
"binary-extensions": "^3.0.0",
|
||||||
"diff": "^8.0.2",
|
"diff": "^8.0.2",
|
||||||
@@ -3382,13 +3382,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/libnpmexec": {
|
"node_modules/npm/node_modules/libnpmexec": {
|
||||||
"version": "10.3.2",
|
"version": "10.2.6",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gar/promise-retry": "^1.0.0",
|
"@gar/promise-retry": "^1.0.0",
|
||||||
"@npmcli/arborist": "^9.9.1",
|
"@npmcli/arborist": "^9.4.3",
|
||||||
"@npmcli/package-json": "^7.0.0",
|
"@npmcli/package-json": "^7.0.0",
|
||||||
"@npmcli/run-script": "^10.0.0",
|
"@npmcli/run-script": "^10.0.0",
|
||||||
"ci-info": "^4.0.0",
|
"ci-info": "^4.0.0",
|
||||||
@@ -3405,12 +3405,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/libnpmfund": {
|
"node_modules/npm/node_modules/libnpmfund": {
|
||||||
"version": "7.0.26",
|
"version": "7.0.20",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@npmcli/arborist": "^9.9.1"
|
"@npmcli/arborist": "^9.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
@@ -3430,12 +3430,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/libnpmpack": {
|
"node_modules/npm/node_modules/libnpmpack": {
|
||||||
"version": "9.1.12",
|
"version": "9.1.6",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@npmcli/arborist": "^9.9.1",
|
"@npmcli/arborist": "^9.4.3",
|
||||||
"@npmcli/run-script": "^10.0.0",
|
"@npmcli/run-script": "^10.0.0",
|
||||||
"npm-package-arg": "^13.0.0",
|
"npm-package-arg": "^13.0.0",
|
||||||
"pacote": "^21.0.2"
|
"pacote": "^21.0.2"
|
||||||
@@ -3445,7 +3445,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/libnpmpublish": {
|
"node_modules/npm/node_modules/libnpmpublish": {
|
||||||
"version": "11.2.0",
|
"version": "11.1.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -3489,7 +3489,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/libnpmversion": {
|
"node_modules/npm/node_modules/libnpmversion": {
|
||||||
"version": "8.0.4",
|
"version": "8.0.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -3505,7 +3505,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/lru-cache": {
|
"node_modules/npm/node_modules/lru-cache": {
|
||||||
"version": "11.5.1",
|
"version": "11.3.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
@@ -3514,7 +3514,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/make-fetch-happen": {
|
"node_modules/npm/node_modules/make-fetch-happen": {
|
||||||
"version": "15.0.6",
|
"version": "15.0.5",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -3680,7 +3680,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/node-gyp": {
|
"node_modules/npm/node_modules/node-gyp": {
|
||||||
"version": "12.4.0",
|
"version": "12.3.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3804,13 +3804,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/npm-profile": {
|
"node_modules/npm/node_modules/npm-profile": {
|
||||||
"version": "12.0.2",
|
"version": "12.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"npm-registry-fetch": "^19.0.0",
|
"npm-registry-fetch": "^19.0.0",
|
||||||
"proc-log": "^6.1.0"
|
"proc-log": "^6.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
@@ -3857,7 +3857,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/pacote": {
|
"node_modules/npm/node_modules/pacote": {
|
||||||
"version": "21.5.1",
|
"version": "21.5.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -3918,7 +3918,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/postcss-selector-parser": {
|
"node_modules/npm/node_modules/postcss-selector-parser": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -4015,7 +4015,7 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/semver": {
|
"node_modules/npm/node_modules/semver": {
|
||||||
"version": "7.8.5",
|
"version": "7.7.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -4039,17 +4039,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/sigstore": {
|
"node_modules/npm/node_modules/sigstore": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sigstore/bundle": "^4.0.0",
|
"@sigstore/bundle": "^4.0.0",
|
||||||
"@sigstore/core": "^3.2.1",
|
"@sigstore/core": "^3.1.0",
|
||||||
"@sigstore/protobuf-specs": "^0.5.0",
|
"@sigstore/protobuf-specs": "^0.5.0",
|
||||||
"@sigstore/sign": "^4.1.1",
|
"@sigstore/sign": "^4.1.0",
|
||||||
"@sigstore/tuf": "^4.0.2",
|
"@sigstore/tuf": "^4.0.1",
|
||||||
"@sigstore/verify": "^3.1.1"
|
"@sigstore/verify": "^3.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
@@ -4066,12 +4066,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/socks": {
|
"node_modules/npm/node_modules/socks": {
|
||||||
"version": "2.8.9",
|
"version": "2.8.7",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "^10.1.1",
|
"ip-address": "^10.0.1",
|
||||||
"smart-buffer": "^4.2.0"
|
"smart-buffer": "^4.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4140,7 +4140,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/tar": {
|
"node_modules/npm/node_modules/tar": {
|
||||||
"version": "7.5.19",
|
"version": "7.5.13",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
@@ -4168,7 +4168,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/tinyglobby": {
|
"node_modules/npm/node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.16",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -4236,7 +4236,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/npm/node_modules/undici": {
|
"node_modules/npm/node_modules/undici": {
|
||||||
"version": "6.27.0",
|
"version": "6.25.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -4951,9 +4951,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.28.0",
|
"version": "6.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
|
||||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dcfrontend",
|
"name": "dcfrontend",
|
||||||
"version": "7.14.0",
|
"version": "7.12.0",
|
||||||
"description": "Data Controller",
|
"description": "Data Controller",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@saithodev/semantic-release-gitea": "^2.1.0",
|
"@saithodev/semantic-release-gitea": "^2.1.0",
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"contextName": "Compute Reusable"
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"fromjs": [
|
|
||||||
{
|
|
||||||
"ADMIN": "viyagroup18",
|
|
||||||
"DCPATH": "/export/pvs/sasdata/sasbatch",
|
|
||||||
"_CONTEXTNAME": "Compute Reusable"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"SASControlTable": [
|
|
||||||
{
|
|
||||||
"ADMIN": "AllUsers"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -79,10 +79,7 @@ _webout=`{"SYSDATE" : "26SEP22"
|
|||||||
"DC_ADMIN_GROUP": "Data Management Business Approvers",
|
"DC_ADMIN_GROUP": "Data Management Business Approvers",
|
||||||
"LICENCE_KEY": "",
|
"LICENCE_KEY": "",
|
||||||
"ACTIVATION_KEY": "",
|
"ACTIVATION_KEY": "",
|
||||||
"DC_RESTRICT_EDITRECORD": "NO",
|
"DC_RESTRICT_EDITRECORD": "NO"
|
||||||
"TIMEZONE": "Europe/London",
|
|
||||||
"SYSTIMEZONEIDENT": "Europe/London",
|
|
||||||
"SYSTIMEZONEOFFSET": "+0100"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
,"xlmaps": [
|
,"xlmaps": [
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"httpsAgentOptions": {
|
"httpsAgentOptions": {
|
||||||
"allowInsecureRequests": true
|
"allowInsecureRequests": true
|
||||||
},
|
},
|
||||||
"appLoc": "/Public/app/dc",
|
"appLoc": "/Public/app/devtest",
|
||||||
"streamConfig": {
|
"streamConfig": {
|
||||||
"streamWeb": true,
|
"streamWeb": true,
|
||||||
"streamWebFolder": "web9",
|
"streamWebFolder": "web9",
|
||||||
|
|||||||
@@ -1,945 +0,0 @@
|
|||||||
const nodePath = require('path')
|
|
||||||
|
|
||||||
// Fixed libref for the mock environment
|
|
||||||
const dcLibref = 'DC_JSLIB'
|
|
||||||
|
|
||||||
// Read URL params (provided as module-scope consts by the JS runtime)
|
|
||||||
let adminGroup = 'AllUsers'
|
|
||||||
|
|
||||||
if (typeof admin !== 'undefined') adminGroup = admin
|
|
||||||
|
|
||||||
// Derive the SASjs Drive root from weboutPath
|
|
||||||
// weboutPath is <root>/sessions/<id>/webout.txt
|
|
||||||
// Drive is at <root>/drive
|
|
||||||
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
|
|
||||||
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
|
|
||||||
const appLoc = nodePath.join(..._program.split('services')[0].split('/'))
|
|
||||||
|
|
||||||
// DC data lives under <drive>/files/<appLoc>/data/<DCLIB>/ - one JSON file per table
|
|
||||||
// This is browsable in SASjs Studio and persists across service redeployes
|
|
||||||
const dataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', dcLibref)
|
|
||||||
|
|
||||||
// Ensure the data directory exists (create parent dirs as needed)
|
|
||||||
if (!fs.existsSync(dataDir)) {
|
|
||||||
fs.mkdirSync(dataDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the DC database - all MPE tables with seed data
|
|
||||||
// Column definitions follow mpe_makedatamodel.sas structure
|
|
||||||
const tables = {
|
|
||||||
MPE_ALERTS: [],
|
|
||||||
MPE_AUDIT: [],
|
|
||||||
MPE_COLUMN_LEVEL_SECURITY: [
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, CLS_SCOPE: 'EDIT', CLS_GROUP: 'AllUsers', CLS_LIBREF: dcLibref, CLS_TABLE: 'MPE_LOCKANYTABLE', CLS_VARIABLE_NM: 'LOCK_STATUS_CD', CLS_ACTIVE: 1, CLS_HIDE: 0 }
|
|
||||||
],
|
|
||||||
MPE_CONFIG: [
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC', var_name: 'DC_EMAIL_ALERTS', var_value: 'NO', var_active: 1, var_desc: 'YES or NO to enable email alerts.' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC', var_name: 'DC_VIEWLIB_CHECK', var_value: 'NO', var_active: 1, var_desc: 'Set to YES to enable library validity checking in viewLibs service.' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC', var_name: 'DC_MACROS', var_value: dataDir + '/dc_macros', var_active: 1, var_desc: 'Location of underlying macros - EUC feature.' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC', var_name: 'DC_MAXOBS_WEBEDIT', var_value: '100', var_active: 1, var_desc: 'Maximum observations for editing in the EDIT screen.' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC', var_name: 'DC_REQUEST_LOGS', var_value: 'YES', var_active: 1, var_desc: 'Setting to NO will prevent each request being logged to MPE_REQUESTS.' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC', var_name: 'DC_RESTRICT_VIEWER', var_value: 'NO', var_active: 1, var_desc: 'YES will restrict the viewer to tables in MPE_SECURITY.' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC', var_name: 'DC_RESTRICT_EDITRECORD', var_value: 'NO', var_active: 1, var_desc: 'YES will prevent the EDIT RECORD dialog.' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC_CATALOG', var_name: 'DC_IGNORELIBS', var_value: '|MAPSSAS|MAPS|', var_active: 1, var_desc: 'Pipe separated list of librefs to ignore in Data Catalog.' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC', var_name: 'DC_LOCALE', var_value: 'SYSTEM', var_active: 1, var_desc: 'Set to a locale to override the system value.' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC_REVIEW', var_name: 'HISTORY_ROWS', var_value: '100', var_active: 1, var_desc: 'Number of rows to return in the HISTORY page.' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC', var_name: 'DC_LICENCE_KEY', var_value: ' ', var_active: 1, var_desc: 'Licence Key' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC', var_name: 'DC_ACTIVATION_KEY', var_value: ' ', var_active: 1, var_desc: 'Activation Key' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC_EMAIL', var_name: 'SUBMITTED_TEMPLATE', var_value: 'Dear user, a change has been submitted.', var_active: 1, var_desc: 'Template email sent after submitting a change.' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC_EMAIL', var_name: 'APPROVED_TEMPLATE', var_value: 'Dear user, a change has been approved.', var_active: 1, var_desc: 'Template email sent after approving a change.' },
|
|
||||||
{ tx_from: 0, tx_to: 253717919999, var_scope: 'DC_EMAIL', var_name: 'REJECTED_TEMPLATE', var_value: 'Dear user, a change has been rejected.', var_active: 1, var_desc: 'Template email sent after rejecting a change.' }
|
|
||||||
],
|
|
||||||
MPE_DATACATALOG_CATS: [],
|
|
||||||
MPE_DATACATALOG_LIBS: [],
|
|
||||||
MPE_DATACATALOG_OBJS: [],
|
|
||||||
MPE_DATACATALOG_TABS: [],
|
|
||||||
MPE_DATACATALOG_VARS: [],
|
|
||||||
MPE_DATASTATUS_CATS: [],
|
|
||||||
MPE_DATASTATUS_LIBS: [],
|
|
||||||
MPE_DATASTATUS_OBJS: [],
|
|
||||||
MPE_DATASTATUS_TABS: [],
|
|
||||||
MPE_DATADICTIONARY: [
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, DD_TYPE: 'LIBRARY', DD_SOURCE: dcLibref, DD_SHORTDESC: 'Data Controller Control Tables', DD_LONGDESC: '# The Data Controller Library', DD_OWNER: 'sasdemo', DD_RESPONSIBLE: 'sasdemo', DD_SENSITIVITY: 'Low' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, DD_TYPE: 'TABLE', DD_SOURCE: dcLibref + '.MPE_TABLES', DD_SHORTDESC: 'Configuration of new tables for Data Controller', DD_LONGDESC: '# MPE_TABLES', DD_OWNER: 'sasdemo', DD_RESPONSIBLE: 'sasdemo', DD_SENSITIVITY: 'Low' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, DD_TYPE: 'COLUMN', DD_SOURCE: dcLibref + '.MPE_TABLES.DSN', DD_SHORTDESC: 'Dataset Name to be edited', DD_LONGDESC: '_DSN_ - must be UPCASE', DD_OWNER: 'sasdemo', DD_RESPONSIBLE: 'sasdemo', DD_SENSITIVITY: 'Low' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, DD_TYPE: 'DIRECTORY', DD_SOURCE: '/some/directory', DD_SHORTDESC: 'Directory for some purpose', DD_LONGDESC: 'This directory is great.', DD_OWNER: 'sasdemo', DD_RESPONSIBLE: 'sasdemo', DD_SENSITIVITY: 'Low' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, DD_TYPE: 'TABLE', DD_SOURCE: dcLibref, DD_SHORTDESC: 'Transaction table for capturing Data Controller users', DD_LONGDESC: 'After a user accepts the EULA they are registered here.', DD_OWNER: 'sasdemo', DD_RESPONSIBLE: 'sasdemo', DD_SENSITIVITY: 'Low' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, DD_TYPE: 'COLUMN', DD_SOURCE: dcLibref + '.MPE_CONFIG.VAR_ACTIVE', DD_SHORTDESC: 'Set to 1 to make an option active', DD_LONGDESC: 'Used as a filter when querying for option settings.', DD_OWNER: 'sasdemo', DD_RESPONSIBLE: 'sasdemo', DD_SENSITIVITY: 'Low' }
|
|
||||||
],
|
|
||||||
MPE_DATALOADS: [],
|
|
||||||
MPE_EMAILS: [],
|
|
||||||
MPE_EXCEL_CONFIG: [],
|
|
||||||
MPE_FILTERANYTABLE: [],
|
|
||||||
MPE_FILTERSOURCE: [],
|
|
||||||
MPE_GROUPS: [
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, group_name: 'dc-admin', group_desc: 'Custom Group for Data Controller Purposes', user_name: 'allbow' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, group_name: 'dc-admin', group_desc: 'Custom Group for Data Controller Purposes', user_name: 'dctestuser1' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, group_name: 'dc-admin', group_desc: 'Custom Group for Data Controller Purposes', user_name: 'mihmed' }
|
|
||||||
],
|
|
||||||
MPE_LINEAGE_COLS: [],
|
|
||||||
MPE_LINEAGE_TABS: [],
|
|
||||||
MPE_LOADS: [],
|
|
||||||
MPE_LOCKANYTABLE: [],
|
|
||||||
MPE_REVIEW: [],
|
|
||||||
MPE_REQUESTS: [],
|
|
||||||
MPE_ROW_LEVEL_SECURITY: [
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, RLS_RK: 1, RLS_SCOPE: 'ALL', RLS_GROUP: 'dc-admin', RLS_LIBREF: dcLibref, RLS_TABLE: 'MPE_GROUPS', RLS_GROUP_LOGIC: 'AND', RLS_SUBGROUP_LOGIC: 'OR', RLS_SUBGROUP_ID: 0, RLS_VARIABLE_NM: 'GROUP_NAME', RLS_OPERATOR_NM: 'NE', RLS_RAW_VALUE: "'-1'", RLS_ACTIVE: 1 },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, RLS_RK: 2, RLS_SCOPE: 'ALL', RLS_GROUP: 'dc-admin', RLS_LIBREF: dcLibref, RLS_TABLE: 'MPE_ROW_LEVEL_SECURITY', RLS_GROUP_LOGIC: 'AND', RLS_SUBGROUP_LOGIC: 'OR', RLS_SUBGROUP_ID: 0, RLS_VARIABLE_NM: 'RLS_RK', RLS_OPERATOR_NM: '>', RLS_RAW_VALUE: '0', RLS_ACTIVE: 1 },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, RLS_RK: 3, RLS_SCOPE: 'ALL', RLS_GROUP: 'DC Demo Group', RLS_LIBREF: dcLibref, RLS_TABLE: 'MPE_SECURITY', RLS_GROUP_LOGIC: 'AND', RLS_SUBGROUP_LOGIC: 'OR', RLS_SUBGROUP_ID: 0, RLS_VARIABLE_NM: 'ACCESS_LEVEL', RLS_OPERATOR_NM: 'NE', RLS_RAW_VALUE: "'N/A'", RLS_ACTIVE: 1 }
|
|
||||||
],
|
|
||||||
MPE_SECURITY: [
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: '*ALL*', dsn: '*ALL*', access_level: 'APPROVE', sas_group: 'dc-admin' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: '*ALL*', dsn: '*ALL*', access_level: 'EDIT', sas_group: 'dc-admin' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: '*ALL*', dsn: '*ALL*', access_level: 'VIEW', sas_group: 'AllUsers' }
|
|
||||||
],
|
|
||||||
MPE_SELECTBOX: [
|
|
||||||
{ selectbox_rk: 1, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_LOCKANYTABLE', base_column: 'LOCK_STATUS_CD', selectbox_value: 'LOCKED', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 2, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_LOCKANYTABLE', base_column: 'LOCK_STATUS_CD', selectbox_value: 'UNLOCKED', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 3, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_SECURITY', base_column: 'ACCESS_LEVEL', selectbox_value: 'EDIT', selectbox_order: 0, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 4, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_SECURITY', base_column: 'ACCESS_LEVEL', selectbox_value: 'APPROVE', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 5, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_SECURITY', base_column: 'ACCESS_LEVEL', selectbox_value: 'VIEW', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 6, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_SECURITY', base_column: 'ACCESS_LEVEL', selectbox_value: 'SIGNOFF', selectbox_order: 3, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 7, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_SECURITY', base_column: 'ACCESS_LEVEL', selectbox_value: 'AUDIT', selectbox_order: 4, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 8, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_TABLES', base_column: 'LOADTYPE', selectbox_value: 'UPDATE', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 9, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_TABLES', base_column: 'LOADTYPE', selectbox_value: 'REPLACE', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 10, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_TABLES', base_column: 'LOADTYPE', selectbox_value: 'TXTEMPORAL', selectbox_order: 3, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 11, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_TABLES', base_column: 'LOADTYPE', selectbox_value: 'BITEMPORAL', selectbox_order: 4, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 12, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_TABLES', base_column: 'LOADTYPE', selectbox_value: 'FORMAT_CAT', selectbox_order: 5, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 13, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ALERTS', base_column: 'ALERT_EVENT', selectbox_value: '*ALL*', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 14, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ALERTS', base_column: 'ALERT_EVENT', selectbox_value: 'SUBMITTED', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 15, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ALERTS', base_column: 'ALERT_EVENT', selectbox_value: 'APPROVED', selectbox_order: 3, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 16, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ALERTS', base_column: 'ALERT_EVENT', selectbox_value: 'REJECTED', selectbox_order: 4, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 17, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_X_TEST', base_column: 'SOME_DROPDOWN', selectbox_value: 'Option 1', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 18, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_X_TEST', base_column: 'SOME_DROPDOWN', selectbox_value: 'Option 2', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 19, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_X_TEST', base_column: 'SOME_DROPDOWN', selectbox_value: 'Option 3', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 20, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_X_TEST', base_column: 'SOME_DROPDOWN', selectbox_value: 'This is a long option. This option is very long. It is optional, though.', selectbox_order: 3, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 21, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'CASE', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 22, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'HARDSELECT', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 23, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'HARDSELECT_HOOK', selectbox_order: 3, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 24, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'HIDDEN', selectbox_order: 4, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 25, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'MAXVAL', selectbox_order: 5, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 26, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'MINVAL', selectbox_order: 6, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 27, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'NOTNULL', selectbox_order: 7, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 28, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'NUMBER_FORMAT', selectbox_order: 8, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 29, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'READONLY', selectbox_order: 9, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 30, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'ROUND', selectbox_order: 10, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 31, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'SOFTSELECT', selectbox_order: 11, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 32, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'SOFTSELECT_HOOK', selectbox_order: 12, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 33, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'HARDREGEX', selectbox_order: 13, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 34, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'SOFTREGEX', selectbox_order: 14, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 35, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'HARDFORMULA', selectbox_order: 15, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 36, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_TYPE', selectbox_value: 'SOFTFORMULA', selectbox_order: 16, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 37, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_ACTIVE', selectbox_value: '1', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 38, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_VALIDATIONS', base_column: 'RULE_ACTIVE', selectbox_value: '0', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 39, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_SECURITY', base_column: 'DSN', selectbox_value: '*ALL*', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 40, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_SECURITY', base_column: 'LIBREF', selectbox_value: '*ALL*', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 41, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_DATADICTIONARY', base_column: 'DD_TYPE', selectbox_value: 'COLUMN', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 42, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_DATADICTIONARY', base_column: 'DD_TYPE', selectbox_value: 'TABLE', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 43, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_DATADICTIONARY', base_column: 'DD_TYPE', selectbox_value: 'LIBRARY', selectbox_order: 3, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 44, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_DATADICTIONARY', base_column: 'DD_TYPE', selectbox_value: 'CATALOG', selectbox_order: 3, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 45, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_DATADICTIONARY', base_column: 'DD_TYPE', selectbox_value: 'FORMAT', selectbox_order: 3, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 46, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_SCOPE', selectbox_value: 'ALL', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 47, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_SCOPE', selectbox_value: 'EDIT', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 48, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_SCOPE', selectbox_value: 'VIEW', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 49, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_GROUP_LOGIC', selectbox_value: 'AND', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 50, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_GROUP_LOGIC', selectbox_value: 'OR', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 51, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_SUBGROUP_LOGIC', selectbox_value: 'AND', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 52, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_SUBGROUP_LOGIC', selectbox_value: 'OR', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 53, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_OPERATOR_NM', selectbox_value: '=', selectbox_order: 0, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 54, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_OPERATOR_NM', selectbox_value: '>', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 55, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_OPERATOR_NM', selectbox_value: '<', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 56, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_OPERATOR_NM', selectbox_value: '<=', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 57, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_OPERATOR_NM', selectbox_value: '>=', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 58, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_OPERATOR_NM', selectbox_value: 'BETWEEN', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 59, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_OPERATOR_NM', selectbox_value: 'IN', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 60, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_OPERATOR_NM', selectbox_value: 'NOT IN', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 61, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_OPERATOR_NM', selectbox_value: 'NE', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 62, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_OPERATOR_NM', selectbox_value: 'CONTAINS', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 63, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_ACTIVE', selectbox_value: '1', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 64, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_ROW_LEVEL_SECURITY', base_column: 'RLS_ACTIVE', selectbox_value: '0', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 65, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_column: 'CLS_ACTIVE', selectbox_value: '1', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 66, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_column: 'CLS_ACTIVE', selectbox_value: '0', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 67, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_column: 'CLS_SCOPE', selectbox_value: 'EDIT', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 68, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_column: 'CLS_SCOPE', selectbox_value: 'VIEW', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 69, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_column: 'CLS_SCOPE', selectbox_value: 'ALL', selectbox_order: 3, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 70, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_column: 'CLS_HIDE', selectbox_value: '0', selectbox_order: 1, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 71, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_column: 'CLS_HIDE', selectbox_value: '1', selectbox_order: 2, ver_to_dttm: 127490111999 },
|
|
||||||
{ selectbox_rk: 72, ver_from_dttm: 0, select_lib: dcLibref, select_ds: 'MPE_EXCEL_CONFIG', base_column: 'XL_RULE', selectbox_value: 'FORMULA', selectbox_order: 1, ver_to_dttm: 127490111999 }
|
|
||||||
],
|
|
||||||
MPE_SUBMIT: [],
|
|
||||||
MPE_TABLES: [
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_COLUMN_LEVEL_SECURITY', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', var_txfrom: 'TX_FROM', var_txto: 'TX_TO', buskey: 'CLS_SCOPE CLS_GROUP CLS_LIBREF CLS_TABLE CLS_VARIABLE_NM', notes: 'Column Level Security config', post_edit_hook: 'services/hooks/mpe_column_level_security_postedit' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_XLMAP_INFO', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', var_txfrom: 'TX_FROM', var_txto: 'TX_TO', buskey: 'XLMAP_ID', notes: 'Excel Map Info', post_edit_hook: 'services/hooks/mpe_xlmap_info_postedit' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_XLMAP_RULES', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', var_txfrom: 'TX_FROM', var_txto: 'TX_TO', buskey: 'XLMAP_ID XLMAP_RANGE_ID', notes: 'Excel Map Rules', post_edit_hook: 'services/hooks/mpe_xlmap_rules_postedit' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_XLMAP_DATA', num_of_approvals_required: 1, loadtype: 'UPDATE', buskey: 'LOAD_REF XLMAP_ID XLMAP_RANGE_ID ROW_NO COL_NO', notes: 'Excel Map Data' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_LOCKANYTABLE', num_of_approvals_required: 1, loadtype: 'UPDATE', buskey: 'LOCK_LIB LOCK_DS', notes: 'Table lock management' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_TABLES', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', buskey: 'LIBREF DSN', var_txfrom: 'TX_FROM', var_txto: 'TX_TO', notes: 'MPE Editor self-edit', post_edit_hook: 'services/hooks/mpe_tables_postedit' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_SECURITY', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', buskey: 'LIBREF DSN ACCESS_LEVEL SAS_GROUP', var_txfrom: 'TX_FROM', var_txto: 'TX_TO', notes: 'Group access control', post_edit_hook: 'services/hooks/mpe_security_postedit' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_SELECTBOX', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', buskey: 'SELECTBOX_RK', var_txfrom: 'VER_FROM_DTTM', var_txto: 'VER_TO_DTTM', notes: 'Dropdown configuration', rk_underlying: 'SELECT_LIB SELECT_DS BASE_COLUMN SELECTBOX_VALUE' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_X_TEST', num_of_approvals_required: 1, loadtype: 'UPDATE', buskey: 'PRIMARY_KEY_FIELD', notes: 'Test table for controller' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_EMAILS', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', buskey: 'USER_NAME', notes: 'Primary Emails Table', var_txfrom: 'TX_FROM', var_txto: 'TX_TO' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_CONFIG', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', buskey: 'VAR_SCOPE VAR_NAME', notes: 'Configuration variables', var_txfrom: 'TX_FROM', var_txto: 'TX_TO' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_ALERTS', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', buskey: 'ALERT_EVENT ALERT_LIB ALERT_DS ALERT_USER', notes: 'Alert email events', var_txfrom: 'TX_FROM', var_txto: 'TX_TO' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_GROUPS', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', buskey: 'GROUP_NAME USER_NAME', notes: 'Additional DC groups', var_txfrom: 'TX_FROM', var_txto: 'TX_TO' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_VALIDATIONS', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', buskey: 'BASE_LIB BASE_DS BASE_COL RULE_TYPE', notes: 'Data quality rules', var_txfrom: 'TX_FROM', var_txto: 'TX_TO', post_edit_hook: 'services/hooks/mpe_validations_postedit' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_DATADICTIONARY', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', buskey: 'DD_TYPE DD_SOURCE', notes: 'Data dictionary', var_txfrom: 'TX_FROM', var_txto: 'TX_TO' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_EXCEL_CONFIG', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', buskey: 'XL_LIBREF XL_TABLE XL_COLUMN', notes: 'Excel import rules', var_txfrom: 'TX_FROM', var_txto: 'TX_TO' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_ROW_LEVEL_SECURITY', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', buskey: 'RLS_RK', notes: 'Row Level Security', var_txfrom: 'TX_FROM', var_txto: 'TX_TO', rk_underlying: 'RLS_SCOPE RLS_GROUP RLS_LIBREF RLS_TABLE RLS_GROUP_LOGIC RLS_SUBGROUP_LOGIC RLS_SUBGROUP_ID RLS_VARIABLE_NM RLS_OPERATOR_NM RLS_RAW_VALUE', post_edit_hook: 'services/hooks/mpe_row_level_security_postedit' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: dcLibref, dsn: 'MPE_X_CATALOG-FC', num_of_approvals_required: 1, loadtype: 'FORMAT_CAT', buskey: 'TYPE FMTNAME FMTROW', notes: 'Sample Format Catalog' }
|
|
||||||
],
|
|
||||||
MPE_VALIDATIONS: [
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_col: 'CLS_SCOPE', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_col: 'CLS_LIBREF', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_col: 'CLS_LIBREF', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/libraries_all', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_col: 'CLS_TABLE', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_col: 'CLS_TABLE', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/tables_all', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_col: 'CLS_VARIABLE_NM', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_col: 'CLS_VARIABLE_NM', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/columns_in_libds', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_col: 'CLS_ACTIVE', rule_type: 'MAXVAL', rule_value: '1', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_col: 'CLS_HIDE', rule_type: 'MAXVAL', rule_value: '1', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_COLUMN_LEVEL_SECURITY', base_col: 'CLS_GROUP', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/sas_groups', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'LIBREF', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'DSN', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'LIBREF', rule_type: 'NOTNULL', rule_value: ' ', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'DSN', rule_type: 'NOTNULL', rule_value: ' ', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'NUM_OF_APPROVALS_REQUIRED', rule_type: 'MINVAL', rule_value: '1', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'BUSKEY', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'BUSKEY', rule_type: 'NOTNULL', rule_value: ' ', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'VAR_TXFROM', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'VAR_TXTO', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'VAR_BUSFROM', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'VAR_BUSTO', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'VAR_PROCESSED', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'LIBREF', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/libraries_all', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'DSN', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/mpe_tables.dsn', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'VAR_TXFROM', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/columns_in_libds', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'VAR_TXTO', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/columns_in_libds', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'VAR_BUSFROM', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/columns_in_libds', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'VAR_BUSTO', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/columns_in_libds', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_TABLES', base_col: 'VAR_PROCESSED', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/columns_in_libds', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_SECURITY', base_col: 'LIBREF', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_SECURITY', base_col: 'LIBREF', rule_type: 'HARDSELECT', rule_value: dcLibref + '.MPE_TABLES.LIBREF', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_SECURITY', base_col: 'DSN', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_SECURITY', base_col: 'DSN', rule_type: 'SOFTSELECT', rule_value: dcLibref + '.MPE_TABLES.DSN', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_SECURITY', base_col: 'SAS_GROUP', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/sas_groups', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_VALIDATIONS', base_col: 'BASE_LIB', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/libraries_editable', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_VALIDATIONS', base_col: 'BASE_DS', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/tables_editable', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_VALIDATIONS', base_col: 'BASE_COL', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/columns_in_libds', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_VALIDATIONS', base_col: 'RULE_ACTIVE', rule_type: 'MINVAL', rule_value: '0', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_VALIDATIONS', base_col: 'RULE_ACTIVE', rule_type: 'MAXVAL', rule_value: '1', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_EXCEL_CONFIG', base_col: 'XL_LIBREF', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/libraries_editable', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_EXCEL_CONFIG', base_col: 'XL_TABLE', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/tables_editable', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_EXCEL_CONFIG', base_col: 'XL_COLUMN', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/columns_in_libds', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_EXCEL_CONFIG', base_col: 'XL_ACTIVE', rule_type: 'MINVAL', rule_value: '0', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_EXCEL_CONFIG', base_col: 'XL_ACTIVE', rule_type: 'MAXVAL', rule_value: '1', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_XLMAP_INFO', base_col: 'XLMAP_ID', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_XLMAP_INFO', base_col: 'XLMAP_ID', rule_type: 'SOFTSELECT', rule_value: dcLibref + '.MPE_XLMAP_RULES.XLMAP_ID', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_XLMAP_RULES', base_col: 'XLMAP_ID', rule_type: 'CASE', rule_value: 'UPCASE', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_SELECTBOX', base_col: 'SELECT_LIB', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/libraries_editable', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_SELECTBOX', base_col: 'SELECT_DS', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/tables_editable', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_SELECTBOX', base_col: 'BASE_COLUMN', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/columns_in_libds', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_ROW_LEVEL_SECURITY', base_col: 'RLS_GROUP', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/sas_groups', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_ROW_LEVEL_SECURITY', base_col: 'RLS_LIBREF', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/libraries_all', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_ROW_LEVEL_SECURITY', base_col: 'RLS_TABLE', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/tables_all', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_ROW_LEVEL_SECURITY', base_col: 'RLS_SUBGROUP_ID', rule_type: 'MINVAL', rule_value: '0', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_ROW_LEVEL_SECURITY', base_col: 'RLS_SUBGROUP_ID', rule_type: 'NOTNULL', rule_value: '0', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_ROW_LEVEL_SECURITY', base_col: 'RLS_VARIABLE_NM', rule_type: 'SOFTSELECT_HOOK', rule_value: 'services/validations/columns_in_libds', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_ALERTS', base_col: 'ALERT_LIB', rule_type: 'HARDSELECT_HOOK', rule_value: 'services/validations/mpe_alerts.alert_lib', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_X_TEST', base_col: 'SOME_BESTNUM', rule_type: 'SOFTSELECT', rule_value: dcLibref + '.MPE_X_TEST.SOME_BESTNUM', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_X_TEST', base_col: 'SOME_CHAR', rule_type: 'HARDREGEX', rule_value: '/the|data/i', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_X_TEST', base_col: 'SOME_CHAR', rule_type: 'SOFTREGEX', rule_value: '/t/', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_X_TEST', base_col: 'PRIMARY_KEY_FIELD', rule_type: 'SOFTREGEX', rule_value: '/^\\d+$/', rule_active: 1, tx_to: 127490111999 },
|
|
||||||
{ tx_from: 0, base_lib: dcLibref, base_ds: 'MPE_X_TEST', base_col: 'SOME_NUM', rule_type: 'HARDSELECT_HOOK', rule_value: 'services/validations/mpe_x_test.some_num', rule_active: 1, tx_to: 127490111999 }
|
|
||||||
],
|
|
||||||
MPE_X_TEST: [
|
|
||||||
{ PRIMARY_KEY_FIELD: 0, SOME_CHAR: 'this is dummy data', SOME_DROPDOWN: 'Option 1', SOME_NUM: 42, SOME_DATE: 42, SOME_DATETIME: 42, SOME_TIME: 42, SOME_SHORTNUM: 8, SOME_BESTNUM: 44 },
|
|
||||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'more dummy data', SOME_DROPDOWN: 'Option 2', SOME_NUM: 42, SOME_DATE: 42, SOME_DATETIME: 42, SOME_TIME: 422, SOME_SHORTNUM: 8, SOME_BESTNUM: 44 },
|
|
||||||
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'even more dummy data', SOME_DROPDOWN: 'Option 3', SOME_NUM: 42, SOME_DATE: 42, SOME_DATETIME: 42, SOME_TIME: 142, SOME_SHORTNUM: 8, SOME_BESTNUM: 44 },
|
|
||||||
{ PRIMARY_KEY_FIELD: 3, SOME_CHAR: 'It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told:', SOME_DROPDOWN: 'Option 2', SOME_NUM: 1613.001, SOME_DATE: 423, SOME_DATETIME: 423, SOME_TIME: 44, SOME_SHORTNUM: 8, SOME_BESTNUM: 44 },
|
|
||||||
{ PRIMARY_KEY_FIELD: 4, SOME_CHAR: 'if you can fill the unforgiving minute', SOME_DROPDOWN: 'Option 1', SOME_NUM: 1613.001123456, SOME_DATE: 4231, SOME_DATETIME: 423123123, SOME_TIME: 412, SOME_SHORTNUM: 8, SOME_BESTNUM: 44 },
|
|
||||||
{ PRIMARY_KEY_FIELD: 5, SOME_CHAR: '= testing the formula situation', SOME_DROPDOWN: 'Option 1', SOME_NUM: 42, SOME_DATE: 42, SOME_DATETIME: 42, SOME_TIME: 42, SOME_SHORTNUM: 8, SOME_BESTNUM: 44 },
|
|
||||||
{ PRIMARY_KEY_FIELD: 6, SOME_CHAR: "'=the formula with leading apostrophe", SOME_DROPDOWN: 'Option 1', SOME_NUM: 42, SOME_DATE: 42, SOME_DATETIME: 42, SOME_TIME: 42, SOME_SHORTNUM: 8, SOME_BESTNUM: 44 }
|
|
||||||
],
|
|
||||||
MPE_XLMAP_DATA: [],
|
|
||||||
MPE_XLMAP_INFO: [
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, XLMAP_ID: 'BASEL-KM1', XLMAP_DESCRIPTION: 'Basel 3 Key Metrics report', XLMAP_TARGETLIBDS: dcLibref + '.MPE_XLMAP_DATA' }
|
|
||||||
],
|
|
||||||
MPE_XLMAP_RULES: [
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, xlmap_id: 'BASEL-KM1', xlmap_range_id: 'KM1:a', xlmap_sheet: 'KM1', xlmap_start: 'MATCH 4 R[2]C[0]:a' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, xlmap_id: 'BASEL-KM1', xlmap_range_id: 'KM1:b', xlmap_sheet: 'KM1', xlmap_start: 'MATCH 4 R[2]C[0]:b' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, xlmap_id: 'BASEL-KM1', xlmap_range_id: 'KM1:c', xlmap_sheet: 'KM1', xlmap_start: 'MATCH 4 R[2]C[0]:c' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, xlmap_id: 'BASEL-KM1', xlmap_range_id: 'KM1:d', xlmap_sheet: 'KM1', xlmap_start: 'MATCH 4 R[2]C[0]:d' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, xlmap_id: 'BASEL-KM1', xlmap_range_id: 'KM1:e', xlmap_sheet: 'KM1', xlmap_start: 'MATCH 4 R[2]C[0]:e' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, xlmap_id: 'BASEL-KM1', xlmap_range_id: 'KM1:f', xlmap_sheet: 'KM1', xlmap_start: 'MATCH 4 R[2]C[0]:f' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, xlmap_id: 'BASEL-CR2', xlmap_range_id: 'CR2-sec1', xlmap_sheet: 'CR2', xlmap_start: 'ABSOLUTE D8', xlmap_finish: 'BLANKROW' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, xlmap_id: 'BASEL-CR2', xlmap_range_id: 'CR2-sec2', xlmap_sheet: 'CR2', xlmap_start: 'ABSOLUTE D18', xlmap_finish: 'LASTDOWN' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, xlmap_id: 'SAMPLE', xlmap_range_id: 'header', xlmap_sheet: '/1', xlmap_start: 'ABSOLUTE B3', xlmap_finish: 'ABSOLUTE B8' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, xlmap_id: 'SAMPLE', xlmap_range_id: 'data', xlmap_sheet: '/1', xlmap_start: 'ABSOLUTE B13', xlmap_finish: 'ABSOLUTE E16' }
|
|
||||||
],
|
|
||||||
MPE_X_CATALOG: [],
|
|
||||||
MPE_USERS: []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Column metadata for each table, extracted from mpe_makedatamodel.sas
|
|
||||||
// Each entry: { name, type (N/C), length, format, label, notnull (bool) }
|
|
||||||
const schema = {
|
|
||||||
MPE_ALERTS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: false },
|
|
||||||
{ name: 'ALERT_EVENT', type: 'C', length: 20, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'ALERT_LIB', type: 'C', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'ALERT_DS', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'ALERT_USER', type: 'C', length: 100, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true }
|
|
||||||
],
|
|
||||||
MPE_AUDIT: [
|
|
||||||
{ name: 'LOAD_REF', type: 'C', length: 36, format: '', label: 'unique load reference', notnull: false },
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: 'Library Reference (8 chars)', notnull: false },
|
|
||||||
{ name: 'DSN', type: 'C', length: 32, format: '', label: 'Dataset Name (32 chars)', notnull: false },
|
|
||||||
{ name: 'KEY_HASH', type: 'C', length: 32, format: '', label: 'MD5 Hash of primary key values (pipe seperated)', notnull: false },
|
|
||||||
{ name: 'TGTVAR_NM', type: 'C', length: 32, format: '', label: 'Target variable name (32 chars)', notnull: false },
|
|
||||||
{ name: 'MOVE_TYPE', type: 'C', length: 1, format: '', label: 'Either (A)ppended, (D)eleted or (M)odified', notnull: false },
|
|
||||||
{ name: 'PROCESSED_DTTM', type: 'N', length: 8, format: 'E8601DT26.6', label: 'Processed at timestamp', notnull: false },
|
|
||||||
{ name: 'IS_PK', type: 'N', length: 8, format: '', label: 'Is Primary Key Field? (1/0)', notnull: false },
|
|
||||||
{ name: 'IS_DIFF', type: 'N', length: 8, format: '', label: 'Did value change? (1/0/-1)', notnull: false },
|
|
||||||
{ name: 'TGTVAR_TYPE', type: 'C', length: 1, format: '', label: 'Either (C)haracter or (N)umeric', notnull: false },
|
|
||||||
{ name: 'OLDVAL_NUM', type: 'N', length: 8, format: 'best32.', label: 'Old (numeric) value', notnull: false },
|
|
||||||
{ name: 'NEWVAL_NUM', type: 'N', length: 8, format: 'best32.', label: 'New (numeric) value', notnull: false },
|
|
||||||
{ name: 'OLDVAL_CHAR', type: 'C', length: 32765, format: '', label: 'Old (character) value', notnull: false },
|
|
||||||
{ name: 'NEWVAL_CHAR', type: 'C', length: 32765, format: '', label: 'New (character) value', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_COLUMN_LEVEL_SECURITY: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'CLS_SCOPE', type: 'C', length: 4, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'CLS_GROUP', type: 'C', length: 64, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'CLS_LIBREF', type: 'C', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'CLS_TABLE', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'CLS_VARIABLE_NM', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'CLS_ACTIVE', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'CLS_HIDE', type: 'N', length: 8, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_CONFIG: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'VAR_SCOPE', type: 'C', length: 10, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'VAR_NAME', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'VAR_VALUE', type: 'C', length: 5000, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'VAR_ACTIVE', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'VAR_DESC', type: 'C', length: 300, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_DATACATALOG_CATS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: false },
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: 'Library Name', notnull: false },
|
|
||||||
{ name: 'MEMNAME', type: 'C', length: 64, format: '', label: 'Member Name', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_DATACATALOG_LIBS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: 'Library Ref', notnull: false },
|
|
||||||
{ name: 'ENGINE', type: 'C', length: 32, format: '', label: 'Library Engine', notnull: false },
|
|
||||||
{ name: 'LIBNAME', type: 'C', length: 256, format: '$256.', label: 'Library Name', notnull: false },
|
|
||||||
{ name: 'PATHS', type: 'C', length: 8192, format: '', label: 'Library Paths', notnull: false },
|
|
||||||
{ name: 'PERMS', type: 'C', length: 500, format: '', label: 'Library Permissions (if BASE)', notnull: false },
|
|
||||||
{ name: 'OWNERS', type: 'C', length: 500, format: '', label: 'Library Owners (if BASE)', notnull: false },
|
|
||||||
{ name: 'SCHEMAS', type: 'C', length: 500, format: '', label: 'Library Schemas (if DB)', notnull: false },
|
|
||||||
{ name: 'LIBID', type: 'C', length: 17, format: '', label: 'LibraryId', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_DATACATALOG_OBJS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: false },
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: 'Library Name', notnull: true },
|
|
||||||
{ name: 'MEMNAME', type: 'C', length: 64, format: '', label: 'Member Name', notnull: true },
|
|
||||||
{ name: 'OBJNAME', type: 'C', length: 32, format: '', label: 'Object Name', notnull: true },
|
|
||||||
{ name: 'OBJTYPE', type: 'C', length: 8, format: '', label: 'Object Type', notnull: true },
|
|
||||||
{ name: 'OBJDESC', type: 'C', length: 256, format: '', label: 'Object Description', notnull: false },
|
|
||||||
{ name: 'ALIAS', type: 'C', length: 32, format: '', label: 'Object Alias', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_DATACATALOG_TABS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: 'Library Name', notnull: false },
|
|
||||||
{ name: 'DSN', type: 'C', length: 64, format: '', label: 'Member Name', notnull: false },
|
|
||||||
{ name: 'MEMTYPE', type: 'C', length: 8, format: '', label: 'Member Type', notnull: false },
|
|
||||||
{ name: 'DBMS_MEMTYPE', type: 'C', length: 32, format: '', label: 'DBMS Member Type', notnull: false },
|
|
||||||
{ name: 'MEMLABEL', type: 'C', length: 512, format: '', label: 'Data Set Label', notnull: false },
|
|
||||||
{ name: 'TYPEMEM', type: 'C', length: 8, format: '', label: 'Data Set Type', notnull: false },
|
|
||||||
{ name: 'NVAR', type: 'N', length: 8, format: '', label: 'Number of Variables', notnull: false },
|
|
||||||
{ name: 'COMPRESS', type: 'C', length: 8, format: '', label: 'Compression Routine', notnull: false },
|
|
||||||
{ name: 'PK_FIELDS', type: 'C', length: 512, format: '', label: 'Primary Key Fields', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_DATACATALOG_VARS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: 'Library Name', notnull: false },
|
|
||||||
{ name: 'DSN', type: 'C', length: 64, format: '', label: 'Table Name', notnull: false },
|
|
||||||
{ name: 'NAME', type: 'C', length: 64, format: '', label: 'Column Name', notnull: false },
|
|
||||||
{ name: 'MEMTYPE', type: 'C', length: 8, format: '', label: 'Member Type', notnull: false },
|
|
||||||
{ name: 'TYPE', type: 'C', length: 16, format: '', label: 'Column Type', notnull: false },
|
|
||||||
{ name: 'LENGTH', type: 'N', length: 8, format: '', label: 'Column Length', notnull: false },
|
|
||||||
{ name: 'VARNUM', type: 'N', length: 8, format: '', label: 'Column Number in Table', notnull: false },
|
|
||||||
{ name: 'LABEL', type: 'C', length: 512, format: '', label: 'Column Label', notnull: false },
|
|
||||||
{ name: 'FORMAT', type: 'C', length: 49, format: '', label: 'Column Format', notnull: false },
|
|
||||||
{ name: 'IDXUSAGE', type: 'C', length: 9, format: '', label: 'Column Index Type', notnull: false },
|
|
||||||
{ name: 'NOTNULL', type: 'C', length: 3, format: '', label: 'Not NULL?', notnull: false },
|
|
||||||
{ name: 'PK_IND', type: 'N', length: 8, format: '', label: 'Primary Key Indicator (1=Primary Key field)', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_DATASTATUS_CATS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: false },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: false },
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: 'Library Name', notnull: false },
|
|
||||||
{ name: 'MEMNAME', type: 'C', length: 64, format: '', label: 'Member Name', notnull: false },
|
|
||||||
{ name: 'NOBJS', type: 'N', length: 8, format: '', label: 'Number of objects', notnull: true },
|
|
||||||
{ name: 'CREATED', type: 'N', length: 8, format: 'DATETIME.', label: 'Date Created', notnull: true },
|
|
||||||
{ name: 'MODIFIED', type: 'N', length: 8, format: 'DATETIME.', label: 'Date Modified', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_DATASTATUS_LIBS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: 'Library Name', notnull: false },
|
|
||||||
{ name: 'LIBSIZE', type: 'N', length: 8, format: 'SIZEKMG.', label: 'Size of library', notnull: false },
|
|
||||||
{ name: 'TABLE_CNT', type: 'N', length: 8, format: '', label: 'Number of Tables', notnull: false },
|
|
||||||
{ name: 'CATALOG_CNT', type: 'N', length: 8, format: '', label: 'Number of Catalogs', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_DATASTATUS_OBJS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: false },
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: 'Library Name', notnull: false },
|
|
||||||
{ name: 'MEMNAME', type: 'C', length: 64, format: '', label: 'Member Name', notnull: false },
|
|
||||||
{ name: 'OBJNAME', type: 'C', length: 32, format: '', label: 'Object Name', notnull: false },
|
|
||||||
{ name: 'OBJTYPE', type: 'C', length: 8, format: '', label: 'Object Type', notnull: false },
|
|
||||||
{ name: 'CREATED', type: 'N', length: 8, format: 'DATETIME.', label: 'Date Created', notnull: true },
|
|
||||||
{ name: 'MODIFIED', type: 'N', length: 8, format: 'DATETIME.', label: 'Date Modified', notnull: false },
|
|
||||||
{ name: 'LEVEL', type: 'N', length: 8, format: '', label: 'Library Concatenation Level', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_DATASTATUS_TABS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: 'Library Name', notnull: false },
|
|
||||||
{ name: 'DSN', type: 'C', length: 64, format: '', label: 'Member Name', notnull: false },
|
|
||||||
{ name: 'FILESIZE', type: 'N', length: 8, format: 'SIZEKMG.', label: 'Size of file', notnull: false },
|
|
||||||
{ name: 'CRDATE', type: 'N', length: 8, format: 'DATETIME.', label: 'Date Created', notnull: false },
|
|
||||||
{ name: 'MODATE', type: 'N', length: 8, format: 'DATETIME.', label: 'Date Modified', notnull: false },
|
|
||||||
{ name: 'NOBS', type: 'N', length: 8, format: '', label: 'Number of Physical Observations', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_DATADICTIONARY: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'DD_TYPE', type: 'C', length: 16, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'DD_SOURCE', type: 'C', length: 1024, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'DD_SHORTDESC', type: 'C', length: 256, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'DD_LONGDESC', type: 'C', length: 32767, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'DD_OWNER', type: 'C', length: 128, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'DD_RESPONSIBLE', type: 'C', length: 128, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'DD_SENSITIVITY', type: 'C', length: 64, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_DATALOADS: [
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'DSN', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'ETLSOURCE', type: 'C', length: 100, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'LOADTYPE', type: 'C', length: 20, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'CHANGED_RECORDS', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'NEW_RECORDS', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'DELETED_RECORDS', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'DURATION', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'USER_NM', type: 'C', length: 50, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'PROCESSED_DTTM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: false },
|
|
||||||
{ name: 'MAC_VER', type: 'C', length: 5, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_EMAILS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'USER_NAME', type: 'C', length: 50, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'USER_DISPLAYNAME', type: 'C', length: 100, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'USER_EMAIL', type: 'C', length: 100, format: '', label: '', notnull: true }
|
|
||||||
],
|
|
||||||
MPE_EXCEL_CONFIG: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'XL_LIBREF', type: 'C', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'XL_TABLE', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'XL_COLUMN', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'XL_RULE', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'XL_ACTIVE', type: 'N', length: 8, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_FILTERANYTABLE: [
|
|
||||||
{ name: 'FILTER_RK', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'FILTER_HASH', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'FILTER_TABLE', type: 'C', length: 41, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'PROCESSED_DTTM', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: true }
|
|
||||||
],
|
|
||||||
MPE_FILTERSOURCE: [
|
|
||||||
{ name: 'FILTER_HASH', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'FILTER_LINE', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'GROUP_LOGIC', type: 'C', length: 3, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SUBGROUP_LOGIC', type: 'C', length: 3, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SUBGROUP_ID', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'VARIABLE_NM', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'OPERATOR_NM', type: 'C', length: 12, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'RAW_VALUE', type: 'C', length: 4000, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'PROCESSED_DTTM', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: true }
|
|
||||||
],
|
|
||||||
MPE_GROUPS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'GROUP_NAME', type: 'C', length: 100, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'USER_NAME', type: 'C', length: 50, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'GROUP_DESC', type: 'C', length: 256, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_LINEAGE_COLS: [
|
|
||||||
{ name: 'COL_ID', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'DIRECTION', type: 'C', length: 1, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SOURCECOLURI', type: 'C', length: 256, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'MAP_TYPE', type: 'C', length: 256, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'MAP_TRANSFORM', type: 'C', length: 256, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'JOBNAME', type: 'C', length: 256, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SOURCETABLENAME', type: 'C', length: 256, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SOURCECOLNAME', type: 'C', length: 256, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'TARGETTABLENAME', type: 'C', length: 256, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'TARGETCOLNAME', type: 'C', length: 256, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'TARGETCOLURI', type: 'C', length: 256, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'DERIVED_RULE', type: 'C', length: 500, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'LEVEL', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'MODIFIED_DTTM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: false },
|
|
||||||
{ name: 'MODIFIED_BY', type: 'C', length: 64, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_LINEAGE_TABS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'JOBID', type: 'C', length: 17, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SRCTABLEID', type: 'C', length: 17, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'TGTTABLEID', type: 'C', length: 17, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'JOBNAME', type: 'C', length: 128, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SRCTABLETYPE', type: 'C', length: 16, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SRCTABLENAME', type: 'C', length: 64, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SRCLIBREF', type: 'C', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'TGTTABLETYPE', type: 'C', length: 16, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'TGTTABLENAME', type: 'C', length: 64, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'TGTLIBREF', type: 'C', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true }
|
|
||||||
],
|
|
||||||
MPE_LOADS: [
|
|
||||||
{ name: 'CSV_DIR', type: 'C', length: 255, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'USER_NM', type: 'C', length: 50, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'STATUS', type: 'C', length: 15, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'DURATION', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'PROCESSED_DTTM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: false },
|
|
||||||
{ name: 'REASON_TXT', type: 'C', length: 2048, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'APPROVALS', type: 'C', length: 64, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_LOCKANYTABLE: [
|
|
||||||
{ name: 'LOCK_LIB', type: 'C', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'LOCK_DS', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'LOCK_STATUS_CD', type: 'C', length: 10, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'LOCK_USER_NM', type: 'C', length: 100, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'LOCK_REF', type: 'C', length: 200, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'LOCK_PID', type: 'C', length: 10, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'LOCK_START_DTTM', type: 'N', length: 8, format: 'E8601DT26.6', label: '', notnull: false },
|
|
||||||
{ name: 'LOCK_END_DTTM', type: 'N', length: 8, format: 'E8601DT26.6', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_MAXKEYVALUES: [
|
|
||||||
{ name: 'KEYTABLE', type: 'C', length: 41, format: '', label: 'Base table in libref.dataset format', notnull: false },
|
|
||||||
{ name: 'KEYCOLUMN', type: 'C', length: 32, format: '$32.', label: 'The Surrogate / Retained key field', notnull: false },
|
|
||||||
{ name: 'MAX_KEY', type: 'N', length: 8, format: '', label: 'Integer value representing current max RK or SK value', notnull: false },
|
|
||||||
{ name: 'PROCESSED_DTTM', type: 'N', length: 8, format: 'E8601DT26.6', label: 'Datetime this value was last updated', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_REQUESTS: [
|
|
||||||
{ name: 'REQUEST_DTTM', type: 'N', length: 8, format: 'datetime19.', label: '', notnull: true },
|
|
||||||
{ name: 'REQUEST_USER', type: 'C', length: 64, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'REQUEST_SERVICE', type: 'C', length: 64, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'REQUEST_PARAMS', type: 'C', length: 128, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_REVIEW: [
|
|
||||||
{ name: 'TABLE_ID', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'REVIEWED_BY_NM', type: 'C', length: 100, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'BASE_TABLE', type: 'C', length: 41, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'REVIEW_STATUS_ID', type: 'C', length: 10, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'REVIEWED_ON_DTTM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'REVIEW_REASON_TXT', type: 'C', length: 400, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_ROW_LEVEL_SECURITY: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'RLS_RK', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'RLS_SCOPE', type: 'C', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'RLS_GROUP', type: 'C', length: 128, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'RLS_LIBREF', type: 'C', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'RLS_TABLE', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'RLS_GROUP_LOGIC', type: 'C', length: 3, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'RLS_SUBGROUP_LOGIC', type: 'C', length: 3, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'RLS_SUBGROUP_ID', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'RLS_VARIABLE_NM', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'RLS_OPERATOR_NM', type: 'C', length: 12, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'RLS_RAW_VALUE', type: 'C', length: 4000, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'RLS_ACTIVE', type: 'N', length: 8, format: '', label: '', notnull: true }
|
|
||||||
],
|
|
||||||
MPE_SECURITY: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'DSN', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'ACCESS_LEVEL', type: 'C', length: 10, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SAS_GROUP', type: 'C', length: 100, format: '', label: '', notnull: true }
|
|
||||||
],
|
|
||||||
MPE_SELECTBOX: [
|
|
||||||
{ name: 'VER_FROM_DTTM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'VER_TO_DTTM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'SELECTBOX_RK', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SELECT_LIB', type: 'C', length: 17, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SELECT_DS', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'BASE_COLUMN', type: 'C', length: 36, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SELECTBOX_VALUE', type: 'C', length: 500, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SELECTBOX_ORDER', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SELECTBOX_TYPE', type: 'C', length: 32, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_SIGNOFFS: [
|
|
||||||
{ name: 'TECH_FROM_DTTM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TECH_TO_DTTM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'SIGNOFF_TABLE', type: 'C', length: 50, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SIGNOFF_SECTION_RK', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SIGNOFF_VERSION_RK', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SIGNOFF_NAME', type: 'C', length: 100, format: '', label: '', notnull: true }
|
|
||||||
],
|
|
||||||
MPE_SUBMIT: [
|
|
||||||
{ name: 'TABLE_ID', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SUBMIT_STATUS_CD', type: 'C', length: 10, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'BASE_LIB', type: 'C', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'BASE_DS', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SUBMITTED_BY_NM', type: 'C', length: 100, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SUBMITTED_ON_DTTM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'SUBMITTED_REASON_TXT', type: 'C', length: 400, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'INPUT_OBS', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'INPUT_VARS', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'NUM_OF_APPROVALS_REQUIRED', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'NUM_OF_APPROVALS_REMAINING', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'REVIEWED_BY_NM', type: 'C', length: 100, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'REVIEWED_ON_DTTM', type: 'N', length: 8, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_TABLES: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'LIBREF', type: 'C', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'DSN', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'NUM_OF_APPROVALS_REQUIRED', type: 'N', length: 4, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'LOADTYPE', type: 'C', length: 12, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'BUSKEY', type: 'C', length: 1000, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'VAR_TXFROM', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'VAR_TXTO', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'VAR_BUSFROM', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'VAR_BUSTO', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'VAR_PROCESSED', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'CLOSE_VARS', type: 'C', length: 500, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'PRE_EDIT_HOOK', type: 'C', length: 200, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'POST_EDIT_HOOK', type: 'C', length: 200, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'PRE_APPROVE_HOOK', type: 'C', length: 200, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'POST_APPROVE_HOOK', type: 'C', length: 200, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SIGNOFF_COLS', type: 'C', length: 500, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SIGNOFF_HOOK', type: 'C', length: 200, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'NOTES', type: 'C', length: 1000, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'RK_UNDERLYING', type: 'C', length: 1000, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'AUDIT_LIBDS', type: 'C', length: 41, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_USERS: [
|
|
||||||
{ name: 'USER_ID', type: 'C', length: 50, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'LAST_SEEN_DT', type: 'N', length: 8, format: 'date9.', label: '', notnull: true },
|
|
||||||
{ name: 'REGISTERED_DT', type: 'N', length: 8, format: 'date9.', label: '', notnull: true }
|
|
||||||
],
|
|
||||||
MPE_VALIDATIONS: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true },
|
|
||||||
{ name: 'BASE_LIB', type: 'C', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'BASE_DS', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'BASE_COL', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'RULE_TYPE', type: 'C', length: 32, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'RULE_VALUE', type: 'C', length: 128, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'RULE_ACTIVE', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: 'datetime19.3', label: '', notnull: true }
|
|
||||||
],
|
|
||||||
MPE_X_TEST: [
|
|
||||||
{ name: 'PRIMARY_KEY_FIELD', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SOME_CHAR', type: 'C', length: 32767, format: '', label: 'Some Character Column', notnull: false },
|
|
||||||
{ name: 'SOME_DROPDOWN', type: 'C', length: 128, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SOME_NUM', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SOME_DATE', type: 'N', length: 8, format: 'date9.', label: 'Some Date', notnull: false },
|
|
||||||
{ name: 'SOME_DATETIME', type: 'N', length: 8, format: 'datetime19.', label: 'Some Datetime', notnull: false },
|
|
||||||
{ name: 'SOME_TIME', type: 'N', length: 8, format: 'time8.', label: 'Some Time', notnull: false },
|
|
||||||
{ name: 'SOME_SHORTNUM', type: 'N', length: 4, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SOME_BESTNUM', type: 'N', length: 8, format: 'best.', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_XLMAP_DATA: [
|
|
||||||
{ name: 'LOAD_REF', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'XLMAP_ID', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'XLMAP_RANGE_ID', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'ROW_NO', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'COL_NO', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'VALUE_TXT', type: 'C', length: 4000, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_XLMAP_INFO: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'XLMAP_ID', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'XLMAP_DESCRIPTION', type: 'C', length: 1000, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'XLMAP_TARGETLIBDS', type: 'C', length: 41, format: '', label: '', notnull: true }
|
|
||||||
],
|
|
||||||
MPE_XLMAP_RULES: [
|
|
||||||
{ name: 'TX_FROM', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'TX_TO', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'XLMAP_ID', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'XLMAP_RANGE_ID', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'XLMAP_SHEET', type: 'C', length: 32, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'XLMAP_START', type: 'C', length: 1000, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'XLMAP_FINISH', type: 'C', length: 1000, format: '', label: '', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_X_CATALOG: [],
|
|
||||||
MPE_MAXKEYVALUES: []
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── TESTDATA library: demo user tables for the Cypress E2E suite ───────────
|
|
||||||
// These tables hold the DQ-rule demo columns (HARDSELECT, READONLY, HIDDEN,
|
|
||||||
// ROUND, NUMBER_FORMAT, HARDREGEX, SOFTREGEX) and the formula demo columns
|
|
||||||
// (HARDFORMULA, SOFTFORMULA, DC.* references) that the editor specs assert
|
|
||||||
// against. They live in a separate library so DC_JSLIB.MPE_X_TEST keeps
|
|
||||||
// matching the excel upload fixtures (which predate these columns), and so
|
|
||||||
// the editor/openTableFromTree flow exercises a real two-library nav tree
|
|
||||||
// (DC_JSLIB + TESTDATA), the same way a real SAS site is laid out.
|
|
||||||
const testDataLibref = 'TESTDATA'
|
|
||||||
const testDataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', testDataLibref)
|
|
||||||
if (!fs.existsSync(testDataDir)) {
|
|
||||||
fs.mkdirSync(testDataDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const testTables = {
|
|
||||||
MPE_X_NEW: [
|
|
||||||
{ PRIMARY_KEY_FIELD: 0, SOME_CHAR: 'this is dummy data', SOME_DROPDOWN: 'Option 1', SOME_HARDSELECT: 'Alpha', SOME_NUM: 0.00105564761956, SOME_DATE: 42, SOME_DATETIME: 42, SOME_TIME: 42, SOME_SHORTNUM: 8, SOME_BESTNUM: 44, READONLY_COL: 'Readonly default', HIDDEN_COL: 'Hidden default', ROUND_COL: 1.142857, NUMFMT_COL: 1000, REGEX_HARD_COL: 'user@example.com', REGEX_SOFT_COL: 'SW1A 1AA', REGEX_BOTH_COL: 'ABC-123' },
|
|
||||||
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'more dummy data', SOME_DROPDOWN: 'Option 2', SOME_HARDSELECT: 'Bravo', SOME_NUM: 0.00521895988156, SOME_DATE: 42, SOME_DATETIME: 42, SOME_TIME: 422, SOME_SHORTNUM: 8, SOME_BESTNUM: 44, READONLY_COL: 'Readonly default', HIDDEN_COL: 'Hidden default', ROUND_COL: 2.285714, NUMFMT_COL: 1012.5, REGEX_HARD_COL: 'user@example.com', REGEX_SOFT_COL: 'SW1A 1AA', REGEX_BOTH_COL: 'ABC-123' },
|
|
||||||
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'even more dummy data', SOME_DROPDOWN: 'Option 3', SOME_HARDSELECT: 'Charlie', SOME_NUM: 0.0058409725343, SOME_DATE: 42, SOME_DATETIME: 42, SOME_TIME: 142, SOME_SHORTNUM: 8, SOME_BESTNUM: 44, READONLY_COL: 'Readonly default', HIDDEN_COL: 'Hidden default', ROUND_COL: 3.428571, NUMFMT_COL: 1025, REGEX_HARD_COL: 'user@example.com', REGEX_SOFT_COL: 'SW1A 1AA', REGEX_BOTH_COL: 'ABC-123' },
|
|
||||||
{ PRIMARY_KEY_FIELD: 3, SOME_CHAR: 'It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told:', SOME_DROPDOWN: 'Option 2', SOME_HARDSELECT: 'Alpha', SOME_NUM: 0.00613050395908, SOME_DATE: 423, SOME_DATETIME: 423, SOME_TIME: 44, SOME_SHORTNUM: 8, SOME_BESTNUM: 44, READONLY_COL: 'Readonly default', HIDDEN_COL: 'Hidden default', ROUND_COL: 4.571428, NUMFMT_COL: 1037.5, REGEX_HARD_COL: 'user@example.com', REGEX_SOFT_COL: 'SW1A 1AA', REGEX_BOTH_COL: 'ABC-123' },
|
|
||||||
{ PRIMARY_KEY_FIELD: 4, SOME_CHAR: 'if you can fill the unforgiving minute', SOME_DROPDOWN: 'Option 1', SOME_HARDSELECT: 'Bravo', SOME_NUM: 0.01305025071513, SOME_DATE: 4231, SOME_DATETIME: 423123123, SOME_TIME: 412, SOME_SHORTNUM: 8, SOME_BESTNUM: 44, READONLY_COL: 'Readonly default', HIDDEN_COL: 'Hidden default', ROUND_COL: 5.714285, NUMFMT_COL: 1050, REGEX_HARD_COL: 'user@example.com', REGEX_SOFT_COL: 'SW1A 1AA', REGEX_BOTH_COL: 'ABC-123' },
|
|
||||||
{ PRIMARY_KEY_FIELD: 5, SOME_CHAR: '= testing the formula situation', SOME_DROPDOWN: 'Option 1', SOME_HARDSELECT: 'Charlie', SOME_NUM: 0.01442142483518, SOME_DATE: 42, SOME_DATETIME: 42, SOME_TIME: 42, SOME_SHORTNUM: 8, SOME_BESTNUM: 44, READONLY_COL: 'Readonly default', HIDDEN_COL: 'Hidden default', ROUND_COL: 6.857142, NUMFMT_COL: 1062.5, REGEX_HARD_COL: 'user@example.com', REGEX_SOFT_COL: 'SW1A 1AA', REGEX_BOTH_COL: 'ABC-123' },
|
|
||||||
{ PRIMARY_KEY_FIELD: 6, SOME_CHAR: "'=the formula with leading apostrophe", SOME_DROPDOWN: 'Option 1', SOME_HARDSELECT: 'Alpha', SOME_NUM: 0.02422838845487, SOME_DATE: 42, SOME_DATETIME: 42, SOME_TIME: 42, SOME_SHORTNUM: 8, SOME_BESTNUM: 44, READONLY_COL: 'Readonly default', HIDDEN_COL: 'Hidden default', ROUND_COL: 8, NUMFMT_COL: 1075, REGEX_HARD_COL: 'user@example.com', REGEX_SOFT_COL: 'SW1A 1AA', REGEX_BOTH_COL: 'ABC-123' }
|
|
||||||
],
|
|
||||||
MPE_X_FORMULA_TEST: Array.from({ length: 10 }, (_, i) => ({
|
|
||||||
_____DELETE__THIS__RECORD_____: 'No',
|
|
||||||
PRIMARY_KEY_FIELD: i + 1,
|
|
||||||
A_COL: i + 1,
|
|
||||||
B_COL: 10,
|
|
||||||
FORMULA_HARD_COL: i === 6 ? '7771' : i === 8 ? '9991' : i === 9 ? '1111' : '',
|
|
||||||
FORMULA_SOFT_COL: i === 7 ? '8882' : i === 8 ? '9992' : i === 9 ? '2222' : '',
|
|
||||||
ROW_STATUS_COL: '',
|
|
||||||
USER_NAME_COL: '',
|
|
||||||
ORIG_VALUE_COL: 'orig-' + (i + 1),
|
|
||||||
CHANGE_SUMMARY_COL: 'orig-' + (i + 1),
|
|
||||||
PLAIN_TEXT_COL: i === 4 ? '=100 + B_COL' : i === 5 ? '=100 + 200' : i === 6 ? "'=genuine literal" : 'note-' + (i + 1)
|
|
||||||
})),
|
|
||||||
MPE_X_FORMULA_PK_TEST: [
|
|
||||||
{ _____DELETE__THIS__RECORD_____: 'No', PK_HARDFORMULA_COL: '', PK_SOFTFORMULA_COL: '', A_COL: 2, B_COL: 3 },
|
|
||||||
{ _____DELETE__THIS__RECORD_____: 'No', PK_HARDFORMULA_COL: '', PK_SOFTFORMULA_COL: '', A_COL: 5, B_COL: 4 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schemas for the TESTDATA tables, matching the old inline getdata.js payload
|
|
||||||
// that the Cypress editor specs were calibrated against.
|
|
||||||
const testSchema = {
|
|
||||||
MPE_X_NEW: [
|
|
||||||
{ name: 'PRIMARY_KEY_FIELD', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'SOME_CHAR', type: 'C', length: 32767, format: '', label: 'Some Character Column', notnull: false },
|
|
||||||
{ name: 'SOME_DROPDOWN', type: 'C', length: 128, format: '', label: 'SOME_DROPDOWN', notnull: false },
|
|
||||||
{ name: 'SOME_HARDSELECT', type: 'C', length: 128, format: '', label: 'SOME_HARDSELECT', notnull: false },
|
|
||||||
{ name: 'SOME_NUM', type: 'N', length: 8, format: '', label: '', notnull: false },
|
|
||||||
{ name: 'SOME_DATE', type: 'N', length: 8, format: 'date9.', label: 'Some Date', notnull: false },
|
|
||||||
{ name: 'SOME_DATETIME', type: 'N', length: 8, format: 'datetime19.', label: 'SOME_DATETIME', notnull: false },
|
|
||||||
{ name: 'SOME_TIME', type: 'N', length: 8, format: 'time8.', label: 'SOME_TIME', notnull: false },
|
|
||||||
{ name: 'SOME_SHORTNUM', type: 'N', length: 4, format: '', label: 'SOME_SHORTNUM', notnull: false },
|
|
||||||
{ name: 'SOME_BESTNUM', type: 'N', length: 8, format: 'best.', label: 'SOME_BESTNUM', notnull: false },
|
|
||||||
{ name: 'READONLY_COL', type: 'C', length: 200, format: '', label: 'READONLY_COL', notnull: false },
|
|
||||||
{ name: 'HIDDEN_COL', type: 'C', length: 200, format: '', label: 'HIDDEN_COL', notnull: false },
|
|
||||||
{ name: 'ROUND_COL', type: 'N', length: 8, format: '', label: 'ROUND_COL', notnull: false },
|
|
||||||
{ name: 'NUMFMT_COL', type: 'N', length: 8, format: '', label: 'NUMFMT_COL', notnull: false },
|
|
||||||
{ name: 'REGEX_HARD_COL', type: 'C', length: 128, format: '', label: 'REGEX_HARD_COL', notnull: false },
|
|
||||||
{ name: 'REGEX_SOFT_COL', type: 'C', length: 128, format: '', label: 'REGEX_SOFT_COL', notnull: false },
|
|
||||||
{ name: 'REGEX_BOTH_COL', type: 'C', length: 128, format: '', label: 'REGEX_BOTH_COL', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_X_FORMULA_TEST: [
|
|
||||||
{ name: 'PRIMARY_KEY_FIELD', type: 'N', length: 8, format: '', label: '', notnull: true },
|
|
||||||
{ name: 'A_COL', type: 'N', length: 8, format: '', label: 'A_COL', notnull: false },
|
|
||||||
{ name: 'B_COL', type: 'N', length: 8, format: '', label: 'B_COL', notnull: false },
|
|
||||||
{ name: 'FORMULA_HARD_COL', type: 'C', length: 128, format: '', label: 'FORMULA_HARD_COL', notnull: false },
|
|
||||||
{ name: 'FORMULA_SOFT_COL', type: 'C', length: 128, format: '', label: 'FORMULA_SOFT_COL', notnull: false },
|
|
||||||
{ name: 'ROW_STATUS_COL', type: 'C', length: 128, format: '', label: 'ROW_STATUS_COL', notnull: false },
|
|
||||||
{ name: 'USER_NAME_COL', type: 'C', length: 128, format: '', label: 'USER_NAME_COL', notnull: false },
|
|
||||||
{ name: 'ORIG_VALUE_COL', type: 'C', length: 128, format: '', label: 'ORIG_VALUE_COL', notnull: false },
|
|
||||||
{ name: 'CHANGE_SUMMARY_COL', type: 'C', length: 128, format: '', label: 'CHANGE_SUMMARY_COL', notnull: false },
|
|
||||||
{ name: 'PLAIN_TEXT_COL', type: 'C', length: 128, format: '', label: 'PLAIN_TEXT_COL', notnull: false }
|
|
||||||
],
|
|
||||||
MPE_X_FORMULA_PK_TEST: [
|
|
||||||
{ name: 'PK_HARDFORMULA_COL', type: 'N', length: 8, format: '', label: 'PK_HARDFORMULA_COL', notnull: true },
|
|
||||||
{ name: 'PK_SOFTFORMULA_COL', type: 'C', length: 128, format: '', label: 'PK_SOFTFORMULA_COL', notnull: true },
|
|
||||||
{ name: 'A_COL', type: 'N', length: 8, format: '', label: 'A_COL', notnull: false },
|
|
||||||
{ name: 'B_COL', type: 'N', length: 8, format: '', label: 'B_COL', notnull: false }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the TESTDATA tables in MPE_TABLES and add their DQ rules to
|
|
||||||
// MPE_VALIDATIONS, the same way mpe_makedata.sas configures a real site.
|
|
||||||
// The tests open these tables from the nav tree and edit/submit them, so
|
|
||||||
// they need MPE_TABLES registrations (buskey, loadtype) to be editable.
|
|
||||||
tables.MPE_TABLES.push(
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: testDataLibref, dsn: 'MPE_X_NEW', num_of_approvals_required: 1, loadtype: 'UPDATE', buskey: 'PRIMARY_KEY_FIELD', notes: 'DQ rule demo table' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: testDataLibref, dsn: 'MPE_X_FORMULA_TEST', num_of_approvals_required: 1, loadtype: 'UPDATE', buskey: 'PRIMARY_KEY_FIELD', notes: 'Formula demo table' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: testDataLibref, dsn: 'MPE_X_FORMULA_PK_TEST', num_of_approvals_required: 1, loadtype: 'UPDATE', buskey: 'PK_HARDFORMULA_COL PK_SOFTFORMULA_COL', notes: 'Formula PK demo table' }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Grant EDIT/APPROVE access on TESTDATA tables to the admin group, mirroring
|
|
||||||
// the AllUsers VIEW + dc-admin EDIT/APPROVE rows that MPE_SECURITY carries
|
|
||||||
// for DC_JSLIB tables.
|
|
||||||
tables.MPE_SECURITY.push(
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: '*ALL*', dsn: '*ALL*', access_level: 'EDIT', sas_group: adminGroup },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: '*ALL*', dsn: '*ALL*', access_level: 'APPROVE', sas_group: adminGroup }
|
|
||||||
)
|
|
||||||
|
|
||||||
// DQ rules for the TESTDATA tables. These mirror the dqrules arrays the old
|
|
||||||
// inline getdata.js emitted, which the editor specs assert against.
|
|
||||||
const testValidations = [
|
|
||||||
// MPE_X_NEW
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_NEW', base_col: 'PRIMARY_KEY_FIELD', rule_type: 'NOTNULL', rule_value: '' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_NEW', base_col: 'SOME_NUM', rule_type: 'HARDSELECT_HOOK', rule_value: 'services/validations/mpe_x_test.some_num' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_NEW', base_col: 'SOME_HARDSELECT', rule_type: 'HARDSELECT', rule_value: '' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_NEW', base_col: 'READONLY_COL', rule_type: 'READONLY', rule_value: 'Readonly default' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_NEW', base_col: 'HIDDEN_COL', rule_type: 'HIDDEN', rule_value: 'Hidden default' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_NEW', base_col: 'ROUND_COL', rule_type: 'ROUND', rule_value: '2' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_NEW', base_col: 'NUMFMT_COL', rule_type: 'NUMBER_FORMAT', rule_value: '{"style":"currency","currency":"EUR"}' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_NEW', base_col: 'REGEX_HARD_COL', rule_type: 'HARDREGEX', rule_value: '/[\\w.]+@[\\w]+\\.[a-z]{2,}/' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_NEW', base_col: 'REGEX_SOFT_COL', rule_type: 'SOFTREGEX', rule_value: '/[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_NEW', base_col: 'REGEX_BOTH_COL', rule_type: 'HARDREGEX', rule_value: '/^[A-Z0-9_-]+$/' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_NEW', base_col: 'REGEX_BOTH_COL', rule_type: 'SOFTREGEX', rule_value: '/^.{5,10}$/' },
|
|
||||||
// MPE_X_FORMULA_TEST
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_FORMULA_TEST', base_col: 'PRIMARY_KEY_FIELD', rule_type: 'NOTNULL', rule_value: '' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_FORMULA_TEST', base_col: 'FORMULA_HARD_COL', rule_type: 'HARDFORMULA', rule_value: '=A_COL * B_COL' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_FORMULA_TEST', base_col: 'FORMULA_SOFT_COL', rule_type: 'SOFTFORMULA', rule_value: '=A_COL + B_COL' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_FORMULA_TEST', base_col: 'ROW_STATUS_COL', rule_type: 'SOFTFORMULA', rule_value: '=DC.ROW_STATUS' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_FORMULA_TEST', base_col: 'USER_NAME_COL', rule_type: 'SOFTFORMULA', rule_value: '=DC.USER_NAME' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_FORMULA_TEST', base_col: 'ORIG_VALUE_COL', rule_type: 'SOFTFORMULA', rule_value: '=DC.ORIG_VALUE' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_FORMULA_TEST', base_col: 'CHANGE_SUMMARY_COL', rule_type: 'SOFTFORMULA', rule_value: '=IF( DC.ROW_STATUS ="U","unedited", DC.USER_NAME &" changed from "& DC.ORIG_VALUE )' },
|
|
||||||
// MPE_X_FORMULA_PK_TEST
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_FORMULA_PK_TEST', base_col: 'PK_HARDFORMULA_COL', rule_type: 'HARDFORMULA', rule_value: '=A_COL * B_COL' },
|
|
||||||
{ base_lib: testDataLibref, base_ds: 'MPE_X_FORMULA_PK_TEST', base_col: 'PK_SOFTFORMULA_COL', rule_type: 'SOFTFORMULA', rule_value: '="PK-" & A_COL' }
|
|
||||||
]
|
|
||||||
for (const v of testValidations) {
|
|
||||||
tables.MPE_VALIDATIONS.push({ tx_from: 0, tx_to: 127490111999, ...v, rule_active: 1 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selectbox values for the TESTDATA demo dropdowns (HARDSELECT/SOME_HARDSELECT)
|
|
||||||
const testSelectboxBase = {
|
|
||||||
select_lib: testDataLibref,
|
|
||||||
select_ds: 'MPE_X_NEW',
|
|
||||||
ver_from_dttm: 0,
|
|
||||||
ver_to_dttm: 127490111999
|
|
||||||
}
|
|
||||||
tables.MPE_SELECTBOX.push(
|
|
||||||
{ ...testSelectboxBase, selectbox_rk: 1001, base_column: 'SOME_HARDSELECT', selectbox_value: 'Alpha', selectbox_order: 1 },
|
|
||||||
{ ...testSelectboxBase, selectbox_rk: 1002, base_column: 'SOME_HARDSELECT', selectbox_value: 'Bravo', selectbox_order: 2 },
|
|
||||||
{ ...testSelectboxBase, selectbox_rk: 1003, base_column: 'SOME_HARDSELECT', selectbox_value: 'Charlie', selectbox_order: 3 },
|
|
||||||
{ ...testSelectboxBase, selectbox_rk: 1004, base_column: 'SOME_DROPDOWN', selectbox_value: 'Option 1', selectbox_order: 1 },
|
|
||||||
{ ...testSelectboxBase, selectbox_rk: 1005, base_column: 'SOME_DROPDOWN', selectbox_value: 'Option 2', selectbox_order: 2 },
|
|
||||||
{ ...testSelectboxBase, selectbox_rk: 1006, base_column: 'SOME_DROPDOWN', selectbox_value: 'Option 3', selectbox_order: 3 }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Write one JSON file per test table with metadata + rows
|
|
||||||
for (const [tableName, rows] of Object.entries(testTables)) {
|
|
||||||
const tableFile = nodePath.resolve(testDataDir, tableName.toLowerCase() + '.json')
|
|
||||||
const tableData = {
|
|
||||||
name: tableName,
|
|
||||||
columns: testSchema[tableName] || [],
|
|
||||||
rows: rows
|
|
||||||
}
|
|
||||||
fs.writeFileSync(tableFile, JSON.stringify(tableData, null, 2))
|
|
||||||
}
|
|
||||||
console.log('TESTDATA data dir: ' + testDataDir)
|
|
||||||
|
|
||||||
// ─── DC996664 library: extra libref for the multi-load Cypress E2E fixtures ──
|
|
||||||
// The multi_load_test_1/2.xlsx fixture files have sheet names like
|
|
||||||
// DC996664.MPE_X_TEST, DC996664.MPE_TABLES, DC996664.MPE_VALIDATIONS.
|
|
||||||
// Those libref.table combos must exist in MPE_TABLES (so startupservice
|
|
||||||
// returns them in sasdatasets and the multi-load sheet matcher accepts
|
|
||||||
// them) and have their own data folder (so viewlibs lists the libref).
|
|
||||||
const multiLoadLibref = 'DC996664'
|
|
||||||
const multiLoadDataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', multiLoadLibref)
|
|
||||||
if (!fs.existsSync(multiLoadDataDir)) {
|
|
||||||
fs.mkdirSync(multiLoadDataDir, { recursive: true })
|
|
||||||
}
|
|
||||||
tables.MPE_TABLES.push(
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: multiLoadLibref, dsn: 'MPE_X_TEST', num_of_approvals_required: 1, loadtype: 'UPDATE', buskey: 'PRIMARY_KEY_FIELD', notes: 'Multi-load fixture table' },
|
|
||||||
{ tx_from: 0, tx_to: 127490111999, libref: multiLoadLibref, dsn: 'MPE_TABLES', num_of_approvals_required: 1, loadtype: 'TXTEMPORAL', buskey: 'LIBREF DSN', var_txfrom: 'TX_FROM', var_txto: 'TX_TO', notes: 'Multi-load fixture table' }
|
|
||||||
)
|
|
||||||
console.log(multiLoadLibref + ' data dir: ' + multiLoadDataDir)
|
|
||||||
|
|
||||||
// Write one JSON file per table with metadata + rows
|
|
||||||
for (const [tableName, rows] of Object.entries(tables)) {
|
|
||||||
const tableFile = nodePath.resolve(dataDir, tableName.toLowerCase() + '.json')
|
|
||||||
const tableData = {
|
|
||||||
name: tableName,
|
|
||||||
columns: schema[tableName] || [],
|
|
||||||
rows: rows
|
|
||||||
}
|
|
||||||
fs.writeFileSync(tableFile, JSON.stringify(tableData, null, 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy DC_JSLIB table files into DC996664 (same schema/rows) so the
|
|
||||||
// multi-load fixtures can fetch getdata for DC996664.MPE_X_TEST etc.
|
|
||||||
// Must run after the DC_JSLIB write loop above.
|
|
||||||
for (const tableName of ['MPE_X_TEST', 'MPE_TABLES']) {
|
|
||||||
const srcFile = nodePath.resolve(dataDir, tableName.toLowerCase() + '.json')
|
|
||||||
const dstFile = nodePath.resolve(multiLoadDataDir, tableName.toLowerCase() + '.json')
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(srcFile, {encoding:'utf8'}).toString())
|
|
||||||
fs.writeFileSync(dstFile, JSON.stringify(data, null, 2))
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Warning: could not copy ' + tableName + ' to ' + multiLoadLibref + ': ' + err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('DC data dir: ' + dataDir)
|
|
||||||
console.log('DCLIB: ' + dcLibref)
|
|
||||||
console.log('ADMIN: ' + adminGroup)
|
|
||||||
|
|
||||||
// Write settings (what the real settings.sas would contain) as a JS module at
|
|
||||||
// services/settings.js, mirroring how makedata.sas writes
|
|
||||||
// services/public/settings.sas on the real backend. Services eval() it to
|
|
||||||
// pick up dc_libref, dc_admin_group, dc_staging_area and dcpath.
|
|
||||||
const settings = {
|
|
||||||
dc_libref: dcLibref,
|
|
||||||
dc_admin_group: adminGroup,
|
|
||||||
dc_staging_area: dataDir + '/dc_staging',
|
|
||||||
dc_macros: dataDir + '/dc_macros',
|
|
||||||
mpelib: dcLibref,
|
|
||||||
dcpath: dataDir
|
|
||||||
}
|
|
||||||
const settingsFile = nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'settings.js')
|
|
||||||
// `var` (not const/let) so that eval'ing the file in a service's scope makes
|
|
||||||
// dcSettings visible to the reader, same as the dcMockUtils functions.
|
|
||||||
fs.writeFileSync(settingsFile, 'var dcSettings = ' + JSON.stringify(settings, null, 2) + '\n')
|
|
||||||
|
|
||||||
// Delete makedata from the Drive so the frontend polling detects completion
|
|
||||||
// The frontend checks if makedata.sas is still in appLoc/services/admin/
|
|
||||||
const makedataFilePath = nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'admin', 'makedata.sas')
|
|
||||||
const makedataJsPath = nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'admin', 'makedata.js')
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(makedataFilePath)) {
|
|
||||||
fs.unlinkSync(makedataFilePath)
|
|
||||||
console.log('makedata.sas deleted (self-destruct)')
|
|
||||||
}
|
|
||||||
if (fs.existsSync(makedataJsPath)) {
|
|
||||||
fs.unlinkSync(makedataJsPath)
|
|
||||||
console.log('makedata.js deleted (self-destruct)')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Warning: could not delete makedata file: ' + err.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return HTML response (makedata is called as a URL redirect, not via adapter)
|
|
||||||
_webout = `<h3>Data Controller Config</h3>
|
|
||||||
<p>The following items have been successfully configured:</p>
|
|
||||||
<ul><li>Library Location (${dataDir})</li>
|
|
||||||
<li>Table Creation (${dcLibref} library)</li>
|
|
||||||
<li>Data Controller Admin Group (${adminGroup})</li>
|
|
||||||
</ul>
|
|
||||||
<p>Next Steps:</p>
|
|
||||||
<ol><li><a href="/AppStream/clickme">Launch Data Controller</a></li></ol>`
|
|
||||||
@@ -1,73 +1,39 @@
|
|||||||
const nodePath = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
|
let appLoc = path.join(..._program.split('services')[0].split('/'))
|
||||||
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
|
let licenceKey = ''
|
||||||
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
|
let activationKey = ''
|
||||||
|
|
||||||
// Load shared DC mock utilities
|
let writeError = 0
|
||||||
eval(fs.readFileSync(nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'), 'utf8'))
|
|
||||||
|
|
||||||
// DC database location comes from services/public/settings.js (written by makedata)
|
if (_WEBIN_FILEREF1) {
|
||||||
const dcConf = loadDcSettings(driveRoot, appLoc)
|
const fileText = _WEBIN_FILEREF1.toString()
|
||||||
const dcLibref = dcConf.dcLibref
|
const split = fileText.split('\n')[1].split(',')
|
||||||
const dataDir = dcConf.dataDir
|
activationKey = split[0]
|
||||||
|
licenceKey = split[1]
|
||||||
const loadTableData = makeTableLoader(dataDir)
|
|
||||||
|
|
||||||
// ─── Parse the keyupload table (%webout(FETCH) equivalent) ────────────────────
|
|
||||||
|
|
||||||
const keyRow = fetchTable('keyupload')[0] || {}
|
|
||||||
let activationKey = (keyRow.ACTIVATION_KEY || '').toString()
|
|
||||||
let licenceKey = (keyRow.LICENCE_KEY || '').toString()
|
|
||||||
|
|
||||||
// ─── Validate key lengths (mirrors the mp_abort checks in registerkey.sas) ───
|
|
||||||
|
|
||||||
let msg = 'SUCCESS'
|
|
||||||
if (activationKey.length < 10) {
|
|
||||||
msg = 'Invalid activation_key'
|
|
||||||
} else if (licenceKey.length < 10) {
|
|
||||||
msg = 'Invalid licencekey'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Upsert keys into mpe_config (TXTEMPORAL, PK = VAR_SCOPE VAR_NAME) ───────
|
const sessionStoragePath = path.resolve(__dirname, '..', '..', 'drive', 'files', appLoc, 'mock-storage')
|
||||||
|
|
||||||
const TX_HIGH = 253717919999
|
if (!fs.existsSync(sessionStoragePath)){
|
||||||
|
fs.mkdirSync(sessionStoragePath);
|
||||||
if (msg === 'SUCCESS') {
|
|
||||||
let writeError = 0
|
|
||||||
try {
|
|
||||||
const configFile = nodePath.resolve(dataDir, 'mpe_config.json')
|
|
||||||
const tableData = loadTableData('MPE_CONFIG')
|
|
||||||
|
|
||||||
if (tableData && tableData.rows) {
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
|
||||||
|
|
||||||
// Close current rows: set tx_to to now for the two key variables
|
|
||||||
for (const row of tableData.rows) {
|
|
||||||
if (
|
|
||||||
row.var_scope === 'DC' &&
|
|
||||||
(row.var_name === 'DC_LICENCE_KEY' || row.var_name === 'DC_ACTIVATION_KEY') &&
|
|
||||||
row.tx_to === TX_HIGH
|
|
||||||
) {
|
|
||||||
row.tx_to = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append the new rows (as bitemporal_dataloader does with LOADTYPE=TXTEMPORAL)
|
|
||||||
tableData.rows.push({ tx_from: now, tx_to: TX_HIGH, var_scope: 'DC', var_name: 'DC_ACTIVATION_KEY', var_value: activationKey, var_active: 1, var_desc: 'Activation Key' })
|
|
||||||
tableData.rows.push({ tx_from: now, tx_to: TX_HIGH, var_scope: 'DC', var_name: 'DC_LICENCE_KEY', var_value: licenceKey, var_active: 1, var_desc: 'Licence Key' })
|
|
||||||
|
|
||||||
fs.writeFileSync(configFile, JSON.stringify(tableData, null, 2))
|
|
||||||
} else {
|
|
||||||
writeError = 1
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
writeError = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (writeError) msg = 'Error writing licence file'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
webOutOpen()
|
const licenceStore = path.resolve(sessionStoragePath, 'licence.json')
|
||||||
webOutObj([{ MSG: msg }], 'return')
|
|
||||||
webOutClose()
|
const json = {
|
||||||
|
licenceKey: licenceKey,
|
||||||
|
activationKey: activationKey
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(licenceStore, JSON.stringify(json))
|
||||||
|
} catch (err) {
|
||||||
|
writeError = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (writeError) {
|
||||||
|
_webout = `{ "return": [{ "MSG": "Error writing licence file" }] }`
|
||||||
|
} else {
|
||||||
|
_webout = `{ "return": [{ "MSG": "SUCCESS" }] }`
|
||||||
|
}
|
||||||
@@ -1,36 +1,29 @@
|
|||||||
const nodePath = require('path')
|
_webout = `{"SYSDATE" : "06OCT22"
|
||||||
|
,"SYSTIME" : "14:27"
|
||||||
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
|
, "fromsas":
|
||||||
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
|
[
|
||||||
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
|
{"TABLE_ID":"DC20221006T142649516_059582_7169" ,"REVIEW_STATUS_ID":"SUBMITTED" ,"SUBMITTED_BY_NM":"mihajlo" ,"BASE_TABLE":"DC988196.MPE_X_TEST" ,"SUBMITTED_ON_DTTM":"2022-10-06 14:26:49" ,"SUBMITTED_ON_DTTM2":1980685609.6 ,"SUBMITTED_REASON_TXT":"" ,"NUM_OF_APPROVALS_REQUIRED":1 ,"NUM_OF_APPROVALS_REMAINING":1 ,"LIBREF":"DC988196" ,"DSN":"MPE_X_TEST" }
|
||||||
|
]
|
||||||
eval(fs.readFileSync(nodePath.resolve(driveRoot, "files", appLoc, "services", "dcMockUtils.js"), "utf8"))
|
,"_DEBUG" : ""
|
||||||
const dcLibref = 'DC_JSLIB'
|
,"_PROGRAM" : "/30.SASApps/app/mihajlo/services/approvers/getapprovals"
|
||||||
const dataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', dcLibref)
|
,"AUTOEXEC" : "%2Fhome%2Fmihajlo%2Fsasjs_root%2Fsessions%2F20221006142704-37564-1665066424699%2Fautoexec.sas"
|
||||||
|
,"MF_GETUSER" : "mihajlo"
|
||||||
// Load MPE_SUBMIT - return all submitted items that haven't been approved yet
|
,"SYSCC" : "0"
|
||||||
let fromsas = []
|
,"SYSENCODING" : "utf-8"
|
||||||
try {
|
,"SYSERRORTEXT" : ""
|
||||||
const file = nodePath.resolve(dataDir, 'mpe_submit.json')
|
,"SYSHOSTINFOLONG" : ""
|
||||||
const raw = fs.readFileSync(file, {encoding:'utf8'}).toString()
|
,"SYSHOSTNAME" : "sas.4gl.io"
|
||||||
const submitData = JSON.parse(raw)
|
,"SYSPROCESSID" : "41DD83B74E36BAC30000000000000000"
|
||||||
fromsas = submitData.rows
|
,"SYSPROCESSMODE" : "Stored Program"
|
||||||
.filter(r => r.SUBMIT_STATUS_CD === 'SUBMITTED')
|
,"SYSPROCESSNAME" : ""
|
||||||
.map(r => ({
|
,"SYSJOBID" : "717200"
|
||||||
TABLE_ID: r.TABLE_ID,
|
,"SYSSCPL" : "LINUX"
|
||||||
REVIEW_STATUS_ID: r.SUBMIT_STATUS_CD,
|
,"syssite" : "123"
|
||||||
SUBMITTED_BY_NM: r.SUBMITTED_BY_NM,
|
,"SYSTCPIPHOSTNAME" : "https://sas.4gl.io:5002"
|
||||||
BASE_TABLE: r.BASE_LIB + '.' + r.BASE_DS,
|
,"SYSUSERID" : "mihajlo"
|
||||||
SUBMITTED_ON_DTTM: ' ' + new Date(r.SUBMITTED_ON_DTTM * 1000).toISOString().replace('T',' ').replace(/\.\d+Z$/, ''),
|
,"SYSVLONG" : "05.00.00.02.001146"
|
||||||
SUBMITTED_ON_DTTM2: r.SUBMITTED_ON_DTTM,
|
,"SYSWARNINGTEXT" : ""
|
||||||
SUBMITTED_REASON_TXT: r.SUBMITTED_REASON_TXT || '',
|
,"END_DTTM" : "2022-10-06T14:27:25.363516"
|
||||||
NUM_OF_APPROVALS_REQUIRED: r.NUM_OF_APPROVALS_REQUIRED,
|
,"MEMSIZE" : "0KB"
|
||||||
NUM_OF_APPROVALS_REMAINING: r.NUM_OF_APPROVALS_REMAINING,
|
}
|
||||||
LIBREF: r.BASE_LIB,
|
`
|
||||||
DSN: r.BASE_DS
|
|
||||||
}))
|
|
||||||
} catch(err) {}
|
|
||||||
|
|
||||||
webOutOpen()
|
|
||||||
webOutObj(fromsas, 'fromsas')
|
|
||||||
webOutClose()
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user